/** * Per-tier daily scan limit middleware. * * Reads `req.user.tier` (set by requireAuth) and counts scans in a * rolling 24h window. Anonymous callers fall through to a 'free' * bucket keyed by IP so the open demo can't be DOS'd via shell. * * The middleware is separate from the IP rate limiter (SEC-1, Session * 7d) on /api/analyze: that one caps RAW request rate (10/min) per IP * regardless of who's authenticated. This one caps PAID-OPERATION * counts per user per day per tier. * * Counts live in-memory (Map>) — same * trade-off as rateLimit.js. Survives a Coolify redeploy by resetting * to zero, which is the user-friendly fallback. */ const { getScanLimit } = require('../config/tiers'); const WINDOW_MS = 24 * 60 * 60 * 1000; const MAX_TRACKED = 50_000; const hits = new Map(); function clientKey(req) { if (req.user?.id) return `u:${req.user.id}`; return `ip:${req.ip || req.socket?.remoteAddress || 'unknown'}`; } function evictIfFull() { if (hits.size <= MAX_TRACKED) return; const oldest = hits.keys().next().value; if (oldest !== undefined) hits.delete(oldest); } function pruneOlderThan(arr, cutoff) { let w = 0; for (let i = 0; i < arr.length; i += 1) { if (arr[i] > cutoff) { arr[w] = arr[i]; w += 1; } } arr.length = w; return w; } function scanLimit() { return function scanLimitMiddleware(req, res, next) { const tier = req.user?.tier || 'free'; const limit = getScanLimit(tier); // Infinity → never block; skip the bookkeeping entirely so the // hot Desk path doesn't allocate Map entries it'll never read. if (limit === Infinity) return next(); const key = clientKey(req); const now = Date.now(); const cutoff = now - WINDOW_MS; let ts = hits.get(key); if (!ts) { ts = []; hits.set(key, ts); evictIfFull(); } const used = pruneOlderThan(ts, cutoff); if (used >= limit) { const oldest = ts[0]; const retryAfterSec = Math.max(1, Math.ceil((oldest + WINDOW_MS - now) / 1000)); res.set('Retry-After', String(retryAfterSec)); res.set('X-Scans-Used', String(used)); res.set('X-Scans-Limit', String(limit)); return res.status(429).json({ error: 'Daily scan limit reached. Upgrade for more scans.', scans_used: used, scans_limit: limit, retry_after_seconds: retryAfterSec, tier, }); } ts.push(now); res.set('X-Scans-Used', String(used + 1)); res.set('X-Scans-Limit', String(limit)); return next(); }; } // Test helper — drop all tracked counts. Not exported in module.exports // to keep prod surface clean; reachable via the __internals bag. function resetForTests() { hits.clear(); } /** * Session 60 (night2/F, 5.4) — read-only usage for the Settings meter. * Reports the SAME rolling-24h window the limiter enforces, from the same * store — the meter can never disagree with the gate. Infinity limit → * { unlimited: true }. */ function scanUsage(req) { const tier = req.user?.tier || 'free'; const limit = getScanLimit(tier); if (limit === Infinity) return { tier, unlimited: true, used: null, limit: null }; const ts = hits.get(clientKey(req)); const used = ts ? pruneOlderThan(ts, Date.now() - WINDOW_MS) : 0; return { tier, unlimited: false, used, limit, remaining: Math.max(0, limit - used) }; } module.exports = { scanLimit, scanUsage, __internals: { hits, clientKey, resetForTests, WINDOW_MS, MAX_TRACKED }, };