From 65eb61281c5d25315445999550c31be5cc2a0a90 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 13 May 2026 23:22:06 -0700 Subject: [PATCH] fix(rate-limit): interpolate WINDOW_SECONDS as a Postgres interval value, not inside a quoted literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (#315) introduced a per-IP rate limit that was a silent no-op in production. Symptom: 12 streaming requests in a row all returned 200; rate_limit_events table had 0 rows. Root cause: the SQL used `interval '${WINDOW_SECONDS} seconds'` inside a tagged-template literal. The @neondatabase/serverless driver substitutes `${...}` placeholders as $N parameters, but parameters cannot appear inside a Postgres string literal. The driver emitted `interval '$2 seconds'` and the planner rejected it with `invalid input syntax for type interval`. The proxy's fail-open catch then allowed the request through. Fix: build `WINDOW_INTERVAL = '60 seconds'` at module load and splice it as a parameterized value cast to ::interval: `ts < now() - ${WINDOW_INTERVAL}::interval` That emits `ts < now() - $2::interval`, which Postgres evaluates correctly. Also added `AND ts > now() - ${WINDOW_INTERVAL}::interval` to the SELECT — the DELETE+SELECT now use the same window boundary so the count can't accidentally include rows that the DELETE didn't yet prune. Smoke-tested against the live Neon DB: Request 1: count=1, allowed=true ... Request 10: count=10, allowed=true Request 11: count=11, allowed=false ← rate limited Request 12: count=12, allowed=false Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/rate-limit.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/rate-limit.ts b/scripts/rate-limit.ts index af982f549..dafba77bb 100644 --- a/scripts/rate-limit.ts +++ b/scripts/rate-limit.ts @@ -44,12 +44,18 @@ if (!databaseUrl) { const ALLOW_PASSTHROUGH: RateLimitResult = { allowed: true, retryAfterSec: 0, count: 0 }; +// Pre-built `interval` literal. Splicing WINDOW_SECONDS via the tagged-template +// parameter machinery would emit `interval '$1 seconds'`, which Postgres rejects +// (parameters can't substitute inside string literals). The constant is hardcoded +// at module load instead. +const WINDOW_INTERVAL = `${WINDOW_SECONDS} seconds`; + export async function checkRateLimit(ip: string): Promise { if (!sql) return ALLOW_PASSTHROUGH; try { - await sql`DELETE FROM rate_limit_events WHERE ip = ${ip} AND ts < now() - interval '${WINDOW_SECONDS} seconds'`; + await sql`DELETE FROM rate_limit_events WHERE ip = ${ip} AND ts < now() - ${WINDOW_INTERVAL}::interval`; await sql`INSERT INTO rate_limit_events (ip, ts) VALUES (${ip}, now())`; - const rows = await sql`SELECT count(*)::int AS c FROM rate_limit_events WHERE ip = ${ip}`; + const rows = await sql`SELECT count(*)::int AS c FROM rate_limit_events WHERE ip = ${ip} AND ts > now() - ${WINDOW_INTERVAL}::interval`; const count = (rows[0] as { c?: number } | undefined)?.c ?? 0; return { allowed: count <= limit,