diff --git a/src/lib/intake-links/rate-limit.test.ts b/src/lib/intake-links/rate-limit.test.ts new file mode 100644 index 0000000..32cb6a1 --- /dev/null +++ b/src/lib/intake-links/rate-limit.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +import { allow, resetRateLimiter } from "@/lib/intake-links/rate-limit"; + +beforeEach(() => { + resetRateLimiter(); +}); + +describe("allow", () => { + it("permits requests under the limit", () => { + for (let i = 0; i < 60; i++) { + expect(allow("k", 1_000_000 + i * 1000)).toBe(true); + } + }); + + it("refuses once the limit is hit", () => { + for (let i = 0; i < 60; i++) allow("k", 1_000_000 + i * 1000); + expect(allow("k", 1_060_000)).toBe(false); + }); + + it("resets after the window passes", () => { + for (let i = 0; i < 60; i++) allow("k", 1_000_000 + i * 1000); + expect(allow("k", 1_000_000 + 5 * 60 * 1000 + 1)).toBe(true); + }); + + it("keeps buckets independent per key", () => { + for (let i = 0; i < 60; i++) allow("k1", 1_000_000 + i * 1000); + expect(allow("k1", 1_000_000 + 60 * 1000)).toBe(false); + expect(allow("k2", 1_000_000 + 60 * 1000)).toBe(true); + }); +}); diff --git a/src/lib/intake-links/rate-limit.ts b/src/lib/intake-links/rate-limit.ts new file mode 100644 index 0000000..5011d7a --- /dev/null +++ b/src/lib/intake-links/rate-limit.ts @@ -0,0 +1,48 @@ +/** + * A per-(token, ip) sliding-window limiter for the public intake route. + * + * Window length and request budget are tuned for the worst-case interview + * (seven turns, each one round trip, plus the brief call, plus a handful of + * retries), with room to spare so a real client never trips it. A hostile + * script that wants to spend API money through a leaked token does. + * + * Deliberately in-memory, deliberately single-process, deliberately lossy on + * restart. The spec is explicit: losing the limiter on restart costs nothing + * because the daily cap (separate, persisted on intake_links) is the + * coarse-grained backstop. Persistence would invite questions about IP + * retention this phase does not need to answer. + */ + +const WINDOW_MS = 5 * 60 * 1000; +const LIMIT = 60; + +interface Bucket { + count: number; + resetAt: number; +} + +const buckets = new Map(); + +/** Forget every bucket. Only used by tests. */ +export function resetRateLimiter(): void { + buckets.clear(); +} + +/** + * Charge one call against `(key, bucket)` and return true if it is allowed. + * + * `key` is opaque — typically `${token}:${ip}`. The limiter knows nothing + * about tokens or addresses; the caller composes them. + */ +export function allow(key: string, now: number = Date.now()): boolean { + const bucket = buckets.get(key); + if (!bucket || now >= bucket.resetAt) { + buckets.set(key, { count: 1, resetAt: now + WINDOW_MS }); + return true; + } + + if (bucket.count >= LIMIT) return false; + + bucket.count += 1; + return true; +}