import 'server-only'; import { sql } from '@/lib/db'; import { db } from '@/lib/db'; /** * Fixed-window counter stored in Postgres so limits hold across PM2 instances. * Returns whether the call is allowed and the seconds until the window resets. */ export async function rateLimit(key: string, limit: number, windowSeconds: number): Promise<{ ok: boolean; remaining: number; retryAfterSeconds: number }> { const now = new Date(); const resetAt = new Date(now.getTime() + windowSeconds * 1000); const nowIso = now.toISOString(); const resetIso = resetAt.toISOString(); const rows = (await db().execute(sql` insert into rate_limits (key, count, reset_at) values (${key}, 1, ${resetIso}::timestamptz) on conflict (key) do update set count = case when rate_limits.reset_at <= ${nowIso}::timestamptz then 1 else rate_limits.count + 1 end, reset_at = case when rate_limits.reset_at <= ${nowIso}::timestamptz then ${resetIso}::timestamptz else rate_limits.reset_at end returning count, reset_at `)) as unknown as Array<{ count: number; reset_at: Date | string }>; const row = rows[0]!; const count = Number(row.count); const reset = new Date(row.reset_at); return { ok: count <= limit, remaining: Math.max(0, limit - count), retryAfterSeconds: Math.max(1, Math.ceil((reset.getTime() - now.getTime()) / 1000)) }; } export class RateLimited extends Error { constructor(public retryAfterSeconds: number) { super(`Too many attempts. Try again in ${retryAfterSeconds}s.`); } } export async function enforce(key: string, limit: number, windowSeconds: number): Promise { const r = await rateLimit(key, limit, windowSeconds); if (!r.ok) throw new RateLimited(r.retryAfterSeconds); }