/** * Per-tier rate limiter for /api/scan and other write endpoints. * * Bucket model: * - free: 5 scans/minute, daily cap of 5 (the daily cap is enforced * elsewhere in the route handler) * - analyst: 30 scans/minute, unlimited daily * - desk: 60 scans/minute, unlimited daily * * Storage: * - In-memory ring buffer per key. * - Process-local — good for single-instance Vercel deployments. For * multi-instance later, swap to upstash/redis without changing the * external interface. */ import { NextResponse, type NextRequest } from 'next/server'; type Tier = 'free' | 'analyst' | 'desk'; const LIMITS: Record = { free: 5, analyst: 30, desk: 60, }; const WINDOW_MS = 60_000; interface Bucket { hits: number[]; // ms timestamps } const buckets = new Map(); export function rateLimitCheck(key: string, tier: Tier): { ok: true } | { ok: false; retryAfter: number } { const limit = LIMITS[tier] ?? LIMITS.free; const now = Date.now(); const bucket = buckets.get(key) ?? { hits: [] }; // Drop hits older than the window while (bucket.hits.length && now - bucket.hits[0] > WINDOW_MS) { bucket.hits.shift(); } if (bucket.hits.length >= limit) { const oldest = bucket.hits[0]; const retryAfter = Math.max(1, Math.ceil((WINDOW_MS - (now - oldest)) / 1000)); buckets.set(key, bucket); return { ok: false, retryAfter }; } bucket.hits.push(now); buckets.set(key, bucket); // Opportunistic GC — keep the map small in long-running processes if (buckets.size > 5000 && Math.random() < 0.01) { pruneStale(); } return { ok: true }; } export function rateLimitResponse(retryAfter: number) { return NextResponse.json( { error: "Slow down — you're reading faster than the model can think. Try again in a minute.", retryAfter, }, { status: 429, headers: { 'Retry-After': String(retryAfter) } }, ); } /** * Build a stable key from the incoming request. Prefers the auth bearer * (per-user) and falls back to the forwarded IP for anonymous traffic. */ export function rateLimitKey(req: NextRequest): string { const auth = req.headers.get('authorization'); if (auth) return `user:${auth.slice(-32)}`; const fwd = req.headers.get('x-forwarded-for') || ''; const ip = fwd.split(',')[0].trim() || 'anon'; return `ip:${ip}`; } function pruneStale() { const cutoff = Date.now() - WINDOW_MS * 2; for (const [key, bucket] of buckets.entries()) { if (!bucket.hits.length || bucket.hits[bucket.hits.length - 1] < cutoff) { buckets.delete(key); } } }