feat(intake-links): per-(token,ip) rate limiter

Phase 8a Task 4. 60 requests per 5-minute window per opaque key.
In-memory, lossy on restart, no IP persistence. Backed by the
daily cap on intake_links for coarse-grained abuse control.
This commit is contained in:
Dave 2026-09-21 01:58:38 +02:00
parent f608931779
commit 3b8ffb8e1f
2 changed files with 79 additions and 0 deletions

View file

@ -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);
});
});

View file

@ -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<string, Bucket>();
/** 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;
}