/** * Tiny in-process fixed-window rate limiter for the Smart Gallery AI routes. * * --------------------------------------------------------------------------- * SCOPE AND LIMITATIONS — READ BEFORE RELYING ON THIS * --------------------------------------------------------------------------- * State lives in a plain `Map` in THIS process's memory. That means: * * - PER-INSTANCE, NOT GLOBAL. With N app instances behind a load balancer a * caller gets up to N x the configured budget. On serverless platforms each * cold start begins with an empty map, so the effective limit is weaker * still. * - NOT DURABLE. A restart or redeploy clears every counter. * - FIXED WINDOW, NOT SLIDING. A caller can burst `max` at the very end of one * window and `max` again at the start of the next — up to 2x `max` across a * window boundary. Acceptable here; the goal is to bound runaway cost, not * to meter precisely. * * It exists because the routes it guards spend real money on GPU inference and * shipping them with NO limit at all is worse than shipping an imperfect one. * * REPLACE WITH REDIS (or the platform's rate limiter) BEFORE RUNNING MORE THAN * ONE INSTANCE. The `limit()` signature is deliberately narrow so a Redis-backed * implementation can drop straight in — the only change needed is making it * async at the call sites. * * This is a throttle, not an authorization check. See src/lib/server/session.ts. */ interface Window { /** Requests counted so far in the current window. */ count: number; /** Epoch ms at which the current window ends. */ resetAt: number; } const windows = new Map(); /** Drop expired entries so the map cannot grow without bound. */ const SWEEP_INTERVAL_MS = 60_000; let lastSweep = 0; function sweep(now: number): void { if (now - lastSweep < SWEEP_INTERVAL_MS) return; lastSweep = now; for (const [key, w] of windows) { if (w.resetAt <= now) windows.delete(key); } } export interface LimitResult { ok: boolean; /** Seconds until the window resets. Send as `Retry-After` when `ok` is false. */ retryAfter: number; } /** * Count one request against `key` and report whether it is allowed. * * @param key Caller identity — use `rateLimitKey()` from ./session. * @param max Requests allowed per window. * @param windowMs Window length in ms. */ export function limit(key: string, max: number, windowMs: number): LimitResult { const now = Date.now(); sweep(now); const existing = windows.get(key); if (!existing || existing.resetAt <= now) { windows.set(key, { count: 1, resetAt: now + windowMs }); return { ok: true, retryAfter: 0 }; } if (existing.count >= max) { return { ok: false, retryAfter: Math.max(1, Math.ceil((existing.resetAt - now) / 1000)) }; } existing.count += 1; return { ok: true, retryAfter: 0 }; } /** Test/maintenance helper — clears all counters. */ export function resetAllLimits(): void { windows.clear(); lastSweep = 0; }