22 * In-memory rate-limit plugin.
33 *
44 * Enforces per-IP and per-account caps per specs/api/conventions.md#rate-limiting:
5- * - Unauthenticated reads: 60 req / min / IP
6- * - Authenticated reads: 300 req / min / account
7- * - Writes: 30 req / min / account
8- * - Auth endpoints: 10 req / min / IP
5+ * - Unauthenticated reads: 1200 req / min / IP
6+ * - Authenticated reads: 300 req / min / account
7+ * - Writes: 30 req / min / account (120 / min / IP anonymous)
8+ * - Credential endpoints: 120 req / min / IP
9+ *
10+ * Only `/api/**` is counted. The SPA shell, its assets, and thumbnails come out
11+ * of the same process and a single page load fetches dozens of them; counting
12+ * those against the read cap is what produced a site-wide 429 storm at cutover.
13+ *
14+ * Per-IP caps are generous on purpose: production's load balancer does not yet
15+ * preserve client addresses (cfp-live-cluster #201), so every visitor shares
16+ * one "IP" until that lands.
917 *
1018 * Counters are reset on restart (intentional — single replica, civic scale).
1119 * Exceeded limit → RateLimitedError(retryAfterSeconds).
@@ -23,6 +31,14 @@ interface BucketEntry {
2331
2432const WINDOW_MS = 60_000 ; // 1 minute
2533
34+ export const RATE_LIMITS = {
35+ unauthenticatedReadsPerIp : 1200 ,
36+ authenticatedReadsPerAccount : 300 ,
37+ writesPerAccount : 30 ,
38+ anonymousWritesPerIp : 120 ,
39+ credentialPerIp : 120 ,
40+ } as const ;
41+
2642function getOrCreate ( map : Map < string , BucketEntry > , key : string ) : BucketEntry {
2743 let entry = map . get ( key ) ;
2844 if ( ! entry ) {
@@ -60,37 +76,60 @@ function clientIp(request: FastifyRequest): string {
6076}
6177
6278const WRITE_METHODS = new Set ( [ 'POST' , 'PUT' , 'PATCH' , 'DELETE' ] ) ;
63- const AUTH_PATH_PREFIX = '/api/auth' ;
79+ const API_PREFIX = '/api/' ;
80+
81+ /**
82+ * Routes that accept or mint a credential. Session reads (`/api/auth/me`,
83+ * `/api/auth/refresh`, `/api/auth/sessions`, `/api/auth/logout`) are ordinary
84+ * traffic — `/me` runs on every page load.
85+ */
86+ const CREDENTIAL_PATHS = [
87+ '/api/auth/login' ,
88+ '/api/auth/github/start' ,
89+ '/api/auth/github/callback' ,
90+ '/api/auth/link-github' ,
91+ '/api/auth/password-reset/' ,
92+ '/api/account-claim/by-password' ,
93+ ] ;
94+
95+ function pathOf ( url : string ) : string {
96+ const q = url . indexOf ( '?' ) ;
97+ return q === - 1 ? url : url . slice ( 0 , q ) ;
98+ }
99+
100+ export function isCredentialPath ( path : string ) : boolean {
101+ return CREDENTIAL_PATHS . some ( ( p ) => ( p . endsWith ( '/' ) ? path . startsWith ( p ) : path === p ) ) ;
102+ }
64103
65104async function rateLimitPlugin ( fastify : FastifyInstance ) : Promise < void > {
66105 const ipBuckets = new Map < string , BucketEntry > ( ) ;
67106 const accountBuckets = new Map < string , BucketEntry > ( ) ;
68107
69108 fastify . addHook ( 'onRequest' , ( request , _reply , done ) => {
70109 try {
110+ const path = pathOf ( request . url ) ;
111+ if ( ! path . startsWith ( API_PREFIX ) ) {
112+ done ( ) ;
113+ return ;
114+ }
115+
71116 const ip = clientIp ( request ) ;
72117 const isWrite = WRITE_METHODS . has ( request . method ) ;
73- const isAuthEndpoint = request . url . startsWith ( AUTH_PATH_PREFIX ) ;
74-
75118 const personId = request . session ?. person ?. id ;
76119
77- if ( isAuthEndpoint ) {
78- // Auth endpoints: 10 req / min / IP
79- check ( ipBuckets , `auth:${ ip } ` , 10 ) ;
120+ if ( isCredentialPath ( path ) ) {
121+ check ( ipBuckets , `credential:${ ip } ` , RATE_LIMITS . credentialPerIp ) ;
80122 } else if ( isWrite ) {
81123 if ( personId ) {
82- // Authenticated writes: 30 req / min / account
83- check ( accountBuckets , `write-account:${ personId } ` , 30 ) ;
124+ check ( accountBuckets , `write-account:${ personId } ` , RATE_LIMITS . writesPerAccount ) ;
84125 } else {
85- check ( ipBuckets , `write:${ ip } ` , 30 ) ;
126+ check ( ipBuckets , `write:${ ip } ` , RATE_LIMITS . anonymousWritesPerIp ) ;
86127 }
87128 } else {
88129 if ( personId ) {
89- // Authenticated reads: 300 req / min / account
90- check ( accountBuckets , `account:${ personId } ` , 300 ) ;
130+ check ( accountBuckets , `account:${ personId } ` , RATE_LIMITS . authenticatedReadsPerAccount ) ;
91131 } else {
92- // Unauthenticated reads: 60 req / min / IP
93- check ( ipBuckets , `read:${ ip } ` , 60 ) ;
132+ check ( ipBuckets , `read:${ ip } ` , RATE_LIMITS . unauthenticatedReadsPerIp ) ;
94133 }
95134 }
96135 } catch ( err ) {
0 commit comments