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:
parent
f608931779
commit
3b8ffb8e1f
2 changed files with 79 additions and 0 deletions
31
src/lib/intake-links/rate-limit.test.ts
Normal file
31
src/lib/intake-links/rate-limit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
48
src/lib/intake-links/rate-limit.ts
Normal file
48
src/lib/intake-links/rate-limit.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue