import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; // Per-class limiter configuration. Distinct Redis prefix per class is // REQUIRED — without it, a search-class hit would consume the auth-class // budget for the same identifier. `slidingWindow` chosen across all // classes to match Brief 4's existing algorithm; switching to // `tokenBucket` per-class would be its own convoy. const LIMITER_CONFIG = { auth: { limit: 5, window: '15 m', prefix: 'deckhearth:auth' }, search: { limit: 60, window: '1 m', prefix: 'deckhearth:search' }, upload: { limit: 10, window: '1 h', prefix: 'deckhearth:upload' }, generate: { limit: 5, window: '1 h', prefix: 'deckhearth:generate' }, import: { limit: 5, window: '1 h', prefix: 'deckhearth:import' }, scan: { limit: 5, window: '1 m', prefix: 'deckhearth:scan' }, }; // Lazy singleton. Module-load init would throw in environments without // Upstash env vars (local dev pre-onboarding, tests that transitively // import the auth handlers, Vercel build-time bundling). Defer // construction until the first request actually arrives. let cached = null; function init() { // Env-var names match Vercel's Upstash Marketplace integration, which // auto-provisions KV_REST_API_URL and KV_REST_API_TOKEN. See // https://upstash.com/docs/redis/howto/vercelintegration. Single-source- // of-truth — do NOT alias to UPSTASH_REDIS_REST_*. const url = process.env.KV_REST_API_URL; const token = process.env.KV_REST_API_TOKEN; if (url && token) { const redis = new Redis({ url, token }); const instances = new Map(); for (const [name, cfg] of Object.entries(LIMITER_CONFIG)) { instances.set( name, new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(cfg.limit, cfg.window), prefix: cfg.prefix, }) ); } return { mode: 'live', instances }; } if (process.env.NODE_ENV === 'production') { // Fail-closed in production. A single failed login is a better outcome // than silently disabling brute-force protection on the live site. throw new Error( '[rate-limit] Upstash not configured. Set KV_REST_API_URL and KV_REST_API_TOKEN in the deployment environment (auto-provisioned by the Vercel Upstash Marketplace integration) before serving auth traffic.' ); } console.warn( '[rate-limit] KV_REST_API_URL / KV_REST_API_TOKEN not set — rate limiting disabled (dev/test only)' ); return { mode: 'noop' }; } function extractIpIdentifier(req) { const xff = req.headers?.['x-forwarded-for']; const firstHop = Array.isArray(xff) ? xff[0] : xff?.split(',')[0]?.trim(); return firstHop || req.socket?.remoteAddress || 'anonymous'; } // THROWS on missing userId. Per-user limiters MUST sit AFTER the auth // check in the handler body — silently falling back to IP here would // convert a per-user limit into a per-IP limit, locking out other // household members for one user's behavior. The throw surfaces the // misordering immediately during development rather than at first // production incident. function extractUserIdentifier(userId) { if ( userId === null || userId === undefined || userId === '' || (typeof userId === 'number' && Number.isNaN(userId)) ) { throw new Error( '[rate-limit] extractUserIdentifier called without an authenticated userId. Place the rate-limit gate AFTER the auth check, never before.' ); } return `user:${userId}`; } async function check(className, identifier) { if (!cached) { cached = init(); } if (cached.mode === 'noop') { return { allowed: true, remaining: Infinity, reset: 0 }; } const limiter = cached.instances.get(className); if (!limiter) { throw new Error(`[rate-limit] Unknown limiter class: ${className}`); } try { const { success, remaining, reset } = await limiter.limit(identifier); return { allowed: success, remaining, reset }; } catch (err) { // Fail-open on Upstash outage. A hard outage at the rate-limit backend // should not lock the entire user base out. Brute-force protection // lives behind defense-in-depth (Vercel firewall, etc.). console.error('[rate-limit]', err); return { allowed: true, remaining: Infinity, reset: 0 }; } } export async function checkAuthRateLimit(req) { return check('auth', extractIpIdentifier(req)); } export async function checkSearchRateLimit(req) { return check('search', extractIpIdentifier(req)); } export async function checkUploadRateLimit(req, userId) { return check('upload', extractUserIdentifier(userId)); } export async function checkGenerateRateLimit(req, userId) { return check('generate', extractUserIdentifier(userId)); } export async function checkImportRateLimit(req, userId) { return check('import', extractUserIdentifier(userId)); } export async function checkScanRateLimit(req, userId) { return check('scan', extractUserIdentifier(userId)); }