Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/middleware/securityHeaders.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use strict';

/**
* Security headers middleware (#516).
*
* Adds standard security headers to all API responses to prevent XSS,
* clickjacking, MIME-type sniffing, and other common web vulnerabilities.
*/

const SECURITY_HEADERS = {
// Prevent MIME-type sniffing
'X-Content-Type-Options': 'nosniff',
// Prevent clickjacking
'X-Frame-Options': 'DENY',
// XSS protection (legacy browsers)
'X-XSS-Protection': '1; mode=block',
// Referrer policy — don't leak full URLs to third parties
'Referrer-Policy': 'strict-origin-when-cross-origin',
// Permissions policy — disable powerful browser features
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=()',
// HSTS — force HTTPS (only when served over HTTPS)
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
// Content Security Policy — restrict resource loading
'Content-Security-Policy': "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
};

/**
* Apply security headers to the response.
* @param {Object} _req - Express request
* @param {Object} res - Express response
* @param {Function} next - Express next
*/
function securityHeadersMiddleware(_req, res, next) {
for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
// Don't overwrite if already set by another middleware
if (!res.getHeader(name)) {
res.setHeader(name, value);
}
}
next();
}

module.exports = {
securityHeadersMiddleware,
SECURITY_HEADERS,
};
81 changes: 81 additions & 0 deletions src/services/bulkOperations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use strict';

/**
* Bulk operations service (#522).
*
* Provides bulk operation endpoints for common workflows to reduce
* request count and improve performance for bulk work.
*/

const logger = require('../logger');

/**
* Process a batch of operations with per-item error handling.
* Returns results for each item — successes and failures independently.
*
* @param {Array} items - Array of items to process
* @param {Function} handler - async (item, index) => result
* @param {Object} [options]
* @param {number} [options.concurrency=5] - Max parallel operations
* @returns {Promise<{ succeeded: Array, failed: Array }>}
*/
async function processBatch(items, handler, options = {}) {
const concurrency = options.concurrency ?? 5;
const succeeded = [];
const failed = [];

for (let i = 0; i < items.length; i += concurrency) {
const batch = items.slice(i, i + concurrency);
const results = await Promise.allSettled(
batch.map((item, j) => handler(item, i + j)),
);
for (let j = 0; j < results.length; j++) {
const result = results[j];
if (result.status === 'fulfilled') {
succeeded.push({ index: i + j, item: items[i + j], result: result.value });
} else {
failed.push({ index: i + j, item: items[i + j], error: result.reason?.message ?? String(result.reason) });
}
}
}

logger.info(`Bulk operation complete: ${succeeded.length} succeeded, ${failed.length} failed`);
return { succeeded, failed };
}

/**
* Express handler factory for bulk endpoints.
*
* @param {Function} operationHandler - async (item) => result
* @param {string} itemName - Name of the item type for error messages
* @returns {Function} Express route handler
*/
function bulkHandler(operationHandler, itemName = 'item') {
return async (req, res, next) => {
try {
const items = req.body?.items;
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: `Request body must contain an 'items' array with at least one ${itemName}` });
}
if (items.length > 100) {
return res.status(400).json({ error: `Batch size limited to 100 ${itemName}s per request` });
}

const results = await processBatch(items, operationHandler);
res.json({
total: items.length,
succeeded: results.succeeded.length,
failed: results.failed.length,
results: results.succeeded,
errors: results.failed,
});
} catch (err) {
next(err);
}
};
}

module.exports = {
processBatch,
bulkHandler,
};
52 changes: 52 additions & 0 deletions src/services/emailQueue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use strict';

/**
* Email queue service (#515).
*
* Queues email sending through the job queue to prevent request blocking.
* Emails are sent asynchronously in the background with retry on failure.
*/

const jobQueue = require('./jobQueue');
const logger = require('../logger');

const EMAIL_JOB_TYPE = 'email-send';

/**
* Register the email-send handler with the job queue.
* Replace the placeholder transport with your actual email provider (Resend, SES, etc.).
*/
function registerEmailHandler(transport) {
jobQueue.register(EMAIL_JOB_TYPE, async (job) => {
const { to, subject, html, text, metadata } = job.data;
logger.info('Sending email', { to: to?.substring(0, 3) + '***', subject });
await transport.send({ to, subject, html, text, ...(metadata && { metadata }) });
logger.info('Email sent', { to: to?.substring(0, 3) + '***', subject });
});
}

/**
* Queue an email for async delivery (#515).
* Returns immediately — the email is sent in the background.
*
* @param {Object} options
* @param {string} options.to - Recipient email
* @param {string} options.subject - Email subject
* @param {string} [options.html] - HTML body
* @param {string} [options.text] - Plain text body
* @param {Object} [options.metadata] - Extra metadata for the transport
* @returns {Promise<string>} Job ID
*/
async function queueEmail(options) {
const { to, subject, html, text, metadata } = options;
if (!to || !subject) {
throw new Error('queueEmail requires to and subject');
}
return jobQueue.enqueue(EMAIL_JOB_TYPE, { to, subject, html, text, metadata });
}

module.exports = {
queueEmail,
registerEmailHandler,
EMAIL_JOB_TYPE,
};
121 changes: 121 additions & 0 deletions src/services/featureFlags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
'use strict';

/**
* Feature flag service (#517).
*
* Simple feature flag system supporting boolean flags, percentage rollouts,
* and user allowlists. Flags are stored in memory and can be updated at runtime
* via the admin API or environment variables.
*
* Usage:
* const featureFlags = require('./featureFlags');
* if (featureFlags.isEnabled('new-checkout', { userId })) {
* // new checkout flow
* }
*/

const logger = require('../logger');

// In-memory flag store. In production, back this with Redis or a DB.
const flags = new Map();

/**
* Flag structure:
* {
* enabled: boolean, — master toggle
* percentage: number, — 0-100, percentage of users who see the flag
* allowlist: string[], — always-enabled user IDs
* denylist: string[], — always-disabled user IDs
* }
*/

/**
* Initialize flags from environment variables.
* Format: FEATURE_FLAG_<NAME>=true|false|percentage:50
*/
function initFromEnv() {
const prefix = 'FEATURE_FLAG_';
for (const [key, value] of Object.entries(process.env)) {
if (!key.startsWith(prefix)) continue;
const name = key.slice(prefix.length).toLowerCase().replace(/_/g, '-');
if (value === 'true' || value === 'false') {
setFlag(name, { enabled: value === 'true' });
} else if (value.startsWith('percentage:')) {
setFlag(name, { enabled: true, percentage: parseInt(value.slice(11), 10) });
}
logger.info(`Feature flag initialized from env: ${name} = ${value}`);
}
}

/**
* Set or update a feature flag.
* @param {string} name - Flag name
* @param {Object} config - Flag configuration
*/
function setFlag(name, config) {
flags.set(name, {
enabled: config.enabled ?? true,
percentage: config.percentage ?? 100,
allowlist: config.allowlist ?? [],
denylist: config.denylist ?? [],
updatedAt: Date.now(),
});
}

/**
* Check if a feature is enabled for a given context.
* @param {string} name - Flag name
* @param {Object} [context] - { userId: string }
* @returns {boolean}
*/
function isEnabled(name, context = {}) {
const flag = flags.get(name);
if (!flag) return false;
if (!flag.enabled) return false;

const { userId } = context;

// Denylist takes precedence
if (userId && flag.denylist.includes(userId)) return false;

// Allowlist always enabled
if (userId && flag.allowlist.includes(userId)) return true;

// Percentage rollout: deterministic hash so the same user always gets
// the same result within a flag's lifetime.
if (flag.percentage < 100) {
if (!userId) return false;
const hash = hashCode(`${name}:${userId}`);
const bucket = (Math.abs(hash) % 100) + 1;
return bucket <= flag.percentage;
}

return true;
}

/**
* Get all flags (for admin display).
*/
function getAllFlags() {
return Object.fromEntries(flags);
}

/**
* Simple string hash for consistent percentage bucketing.
*/
function hashCode(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0;
}
return hash;
}

module.exports = {
initFromEnv,
setFlag,
isEnabled,
getAllFlags,
};
Loading