/** * Minimal in-memory sliding-window rate limiter for auth-sensitive routes. * * Works per Node.js process. If the app ever scales to multiple instances, * swap the backing store for Redis — the call sites won't need to change. */ type WindowEntry = { timestamps: number[] }; const buckets = new Map(); // Purge stale buckets periodically so the Map can't grow without bound. const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; let lastCleanup = Date.now(); function cleanup(windowMs: number) { const now = Date.now(); if (now - lastCleanup < CLEANUP_INTERVAL_MS) return; lastCleanup = now; for (const [key, entry] of buckets) { entry.timestamps = entry.timestamps.filter(t => now - t < windowMs); if (entry.timestamps.length === 0) buckets.delete(key); } } export interface RateLimitResult { ok: boolean; /** Seconds until the client may retry (only set when blocked). */ retryAfterSeconds?: number; } /** * Record a hit for `key` and report whether it exceeds `limit` hits * within the trailing `windowMs` window. */ export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult { const now = Date.now(); cleanup(windowMs); const entry = buckets.get(key) ?? { timestamps: [] }; entry.timestamps = entry.timestamps.filter(t => now - t < windowMs); if (entry.timestamps.length >= limit) { const oldest = entry.timestamps[0]; return { ok: false, retryAfterSeconds: Math.max(1, Math.ceil((oldest + windowMs - now) / 1000)), }; } entry.timestamps.push(now); buckets.set(key, entry); return { ok: true }; } /** Best-effort client IP extraction behind a reverse proxy. */ export function clientIp(request: Request): string { const forwarded = request.headers.get('x-forwarded-for'); if (forwarded) return forwarded.split(',')[0].trim(); return request.headers.get('x-real-ip') ?? 'unknown'; } /** Standard 429 payload so all auth routes respond consistently. */ export function tooManyRequests(retryAfterSeconds = 60) { return new Response( JSON.stringify({ error: 'Too many requests. Please try again later.' }), { status: 429, headers: { 'Content-Type': 'application/json', 'Retry-After': String(retryAfterSeconds), }, } ); }