diff --git a/src/middleware/securityHeaders.js b/src/middleware/securityHeaders.js new file mode 100644 index 0000000..95c7429 --- /dev/null +++ b/src/middleware/securityHeaders.js @@ -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, +}; diff --git a/src/services/bulkOperations.js b/src/services/bulkOperations.js new file mode 100644 index 0000000..dc4f09d --- /dev/null +++ b/src/services/bulkOperations.js @@ -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, +}; diff --git a/src/services/emailQueue.js b/src/services/emailQueue.js new file mode 100644 index 0000000..58834c3 --- /dev/null +++ b/src/services/emailQueue.js @@ -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} 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, +}; diff --git a/src/services/featureFlags.js b/src/services/featureFlags.js new file mode 100644 index 0000000..5e6945b --- /dev/null +++ b/src/services/featureFlags.js @@ -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_=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, +};