diff --git a/apps/api/src/plugins/rate-limit.ts b/apps/api/src/plugins/rate-limit.ts index 77dab08..70ce9ca 100644 --- a/apps/api/src/plugins/rate-limit.ts +++ b/apps/api/src/plugins/rate-limit.ts @@ -2,18 +2,17 @@ * In-memory rate-limit plugin. * * Enforces per-IP and per-account caps per specs/api/conventions.md#rate-limiting: - * - Unauthenticated reads: 1200 req / min / IP + * - Unauthenticated reads: 300 req / min / IP * - Authenticated reads: 300 req / min / account - * - Writes: 30 req / min / account (120 / min / IP anonymous) - * - Credential endpoints: 120 req / min / IP + * - Writes: 30 req / min / account (30 / min / IP anonymous) + * - Credential endpoints: 20 req / min / IP * * Only `/api/**` is counted. The SPA shell, its assets, and thumbnails come out * of the same process and a single page load fetches dozens of them; counting * those against the read cap is what produced a site-wide 429 storm at cutover. * - * Per-IP caps are generous on purpose: production's load balancer does not yet - * preserve client addresses (cfp-live-cluster #201), so every visitor shares - * one "IP" until that lands. + * Per-IP caps assume the first X-Forwarded-For hop is the real client — the + * NodeBalancer speaks PROXY protocol to Envoy (cfp-live-cluster #203). * * Counters are reset on restart (intentional — single replica, civic scale). * Exceeded limit → RateLimitedError(retryAfterSeconds). @@ -32,11 +31,11 @@ interface BucketEntry { const WINDOW_MS = 60_000; // 1 minute export const RATE_LIMITS = { - unauthenticatedReadsPerIp: 1200, + unauthenticatedReadsPerIp: 300, authenticatedReadsPerAccount: 300, writesPerAccount: 30, - anonymousWritesPerIp: 120, - credentialPerIp: 120, + anonymousWritesPerIp: 30, + credentialPerIp: 20, } as const; function getOrCreate(map: Map, key: string): BucketEntry { diff --git a/plans/rate-limit-scope.md b/plans/rate-limit-scope.md index fac12f2..6b13568 100644 --- a/plans/rate-limit-scope.md +++ b/plans/rate-limit-scope.md @@ -53,4 +53,6 @@ cap back down once real client IPs arrive. ## Follow-ups -- Tracked as: cfp-live-cluster #201 (PROXY protocol so per-IP is per visitor). +- Tracked as: cfp-live-cluster #203 landed PROXY protocol the same night; the + generous caps were then tightened to real per-visitor numbers (reads 300, + credential 20, anonymous writes 30 per minute per IP). diff --git a/specs/api/conventions.md b/specs/api/conventions.md index b71d80c..2015aa0 100644 --- a/specs/api/conventions.md +++ b/specs/api/conventions.md @@ -161,12 +161,12 @@ Single-replica means rate-limit state is in-memory. Counters reset on restart; a Only `/api/**` requests are counted. The SPA shell, its assets, and image thumbnails are served by the same process but are never rate limited — a single page load fetches dozens of them. -- Unauthenticated reads: 1200 requests / minute / IP +- Unauthenticated reads: 300 requests / minute / IP - Authenticated reads: 300 requests / minute / account -- Writes: 30 requests / minute / account (120 / minute / IP when anonymous) -- Credential endpoints: 120 requests / minute / IP. These are the routes that accept or mint a credential: `POST /api/auth/login`, `/api/auth/github/start` and `/callback`, `/api/auth/link-github`, `/api/auth/password-reset/*`, and `POST /api/account-claim/by-password`. Session reads such as `GET /api/auth/me` (called on every page load) and `/api/auth/refresh` are ordinary reads. +- Writes: 30 requests / minute / account (30 / minute / IP when anonymous) +- Credential endpoints: 20 requests / minute / IP. These are the routes that accept or mint a credential: `POST /api/auth/login`, `/api/auth/github/start` and `/callback`, `/api/auth/link-github`, `/api/auth/password-reset/*`, and `POST /api/account-claim/by-password`. Session reads such as `GET /api/auth/me` (called on every page load) and `/api/auth/refresh` are ordinary reads. -The per-IP caps are deliberately generous: in production the gateway sits behind a load balancer that does not yet preserve client addresses, so every visitor currently shares one "IP" (cfp-live-cluster #201). Tighten the credential cap once that lands. +Per-IP caps assume the gateway delivers the real client address in the first `X-Forwarded-For` hop (the NodeBalancer speaks PROXY protocol to Envoy — cfp-live-cluster #203). Exceeded → `429 rate_limited`, `Retry-After` header in seconds.