/** * PostgreSQL-based sliding window rate limiter. * Uses atomic upsert via raw SQL for correctness across multiple instances. */ import { prisma } from "@/lib/db"; export interface RateLimitResult { allowed: boolean; remaining: number; resetAt: number; } /** * Check rate limit for a given key. * Uses a single atomic SQL upsert — safe for concurrent requests. * @param key Unique identifier (e.g., "auth:login:1.2.3.4") * @param limit Max requests per window * @param windowMs Window duration in milliseconds */ export async function checkRateLimit( key: string, limit: number, windowMs: number, ): Promise { const windowInterval = `${windowMs} milliseconds`; const rows = await prisma.$queryRaw<{ count: number; reset_at: Date }[]>` INSERT INTO rate_limits (key, count, reset_at) VALUES (${key}, 1, NOW() + ${windowInterval}::interval) ON CONFLICT (key) DO UPDATE SET count = CASE WHEN rate_limits.reset_at < NOW() THEN 1 ELSE rate_limits.count + 1 END, reset_at = CASE WHEN rate_limits.reset_at < NOW() THEN NOW() + ${windowInterval}::interval ELSE rate_limits.reset_at END RETURNING count, reset_at `; const row = rows[0]; const resetAt = row.reset_at.getTime(); const count = Number(row.count); return { allowed: count <= limit, remaining: Math.max(0, limit - count), resetAt, }; } /** * Rate limit response headers. */ export function rateLimitHeaders( result: RateLimitResult, ): Record { return { "X-RateLimit-Remaining": String(result.remaining), "X-RateLimit-Reset": new Date(result.resetAt).toISOString(), }; }