/** * IP-keyed rate-limit middleware. * * Sliding-window counter per remote IP, kept in-memory. Use for routes * that take expensive upstream calls and aren't behind requireAuth — * the public demo (/api/analyze/*) is the canonical caller. Auth'd * routes are gated by tier, which already throttles abuse. * * Why in-memory not Redis: this middleware is the FIRST line of defense * — it must not depend on Redis being warm. If Redis is down the API * still serves and this still throttles. Memory cost is bounded by * MAX_TRACKED_IPS (the LRU-style trim on overflow). * * Why not the factory in src/utils/rateLimiter.js: that gives one bucket * per call site. We need one bucket PER IP. */ const DEFAULT_WINDOW_MS = 60_000; const DEFAULT_MAX = 10; const MAX_TRACKED_IPS = 10_000; function clientIp(req) { // Express's req.ip respects trust proxy; fall back to socket if not. return req.ip || req.socket?.remoteAddress || 'unknown'; } function createRateLimit({ windowMs = DEFAULT_WINDOW_MS, max = DEFAULT_MAX, key = clientIp } = {}) { // Map preserves insertion order; oldest-IP eviction is O(1) via shift. const hits = new Map(); function evictIfFull() { if (hits.size <= MAX_TRACKED_IPS) return; // Drop the oldest entry — Map.keys() yields in insertion order. const first = hits.keys().next().value; if (first !== undefined) hits.delete(first); } function pruneOlderThan(timestamps, cutoff) { // In-place filter (mutating the array end-to-start), faster than // building a new array on every request. Returns the surviving count. let writeIdx = 0; for (let i = 0; i < timestamps.length; i += 1) { if (timestamps[i] > cutoff) { timestamps[writeIdx] = timestamps[i]; writeIdx += 1; } } timestamps.length = writeIdx; return writeIdx; } return function rateLimit(req, res, next) { const id = key(req); const now = Date.now(); const cutoff = now - windowMs; let timestamps = hits.get(id); if (!timestamps) { timestamps = []; hits.set(id, timestamps); evictIfFull(); } const remaining = pruneOlderThan(timestamps, cutoff); if (remaining >= max) { const retryAfterSec = Math.max(1, Math.ceil((timestamps[0] + windowMs - now) / 1000)); res.set('Retry-After', String(retryAfterSec)); return res.status(429).json({ error: 'Too many requests' }); } timestamps.push(now); return next(); }; } module.exports = { createRateLimit, __internals: { clientIp, MAX_TRACKED_IPS } };