11import { NextResponse } from 'next/server' ;
22import { getTrustedProxyConfig } from '@/config/environment' ;
33
4+ type DbQueryResult = { rows : Array < Record < string , unknown > > } ;
5+ type DbQueryFn = ( text : string , params ?: unknown [ ] ) => Promise < DbQueryResult > ;
6+
7+ let dbQueryPromise : Promise < DbQueryFn | null > | null = null ;
8+
9+ /**
10+ * Lazily resolves the pg-backed `query` helper.
11+ *
12+ * `pg` can only run in a Node.js runtime and importing it from a module shared
13+ * with Edge routes would break their bundling, so the database module is
14+ * loaded dynamically and only outside the Edge runtime. When the DB is
15+ * unavailable the in-memory cache remains authoritative.
16+ */
17+ async function loadDbQuery ( ) : Promise < DbQueryFn | null > {
18+ if ( process . env . NEXT_RUNTIME === 'edge' ) return null ;
19+ if ( ! dbQueryPromise ) {
20+ dbQueryPromise = import ( /* webpackIgnore: true */ '@/lib/db/pool' )
21+ . then ( ( mod ) => mod . query as DbQueryFn )
22+ . catch ( ( ) => null ) ;
23+ }
24+ return dbQueryPromise ;
25+ }
26+
427/**
5- * In-memory sliding window rate limiter for API routes.
28+ * Database-backed sliding window rate limiter for API routes.
629 * Provides IP-based rate limiting with configurable limits and windows.
730 *
31+ * Counters are persisted to PostgreSQL so they survive process restarts.
32+ * An in-memory Map serves as a fast synchronous cache; every write is also
33+ * persisted to the database asynchronously (fire-and-forget) so that
34+ * subsequent processes can pick up the state after a deploy.
35+ *
836 * Security: getClientIP() only trusts x-forwarded-for / x-real-ip headers when
937 * the request arrives from a proxy listed in TRUSTED_PROXY_IPS (see
1038 * src/config/environment.ts). When no proxies are configured the headers are
@@ -32,8 +60,85 @@ interface RateLimitEntry {
3260 resetAt : number ;
3361}
3462
63+ /**
64+ * Fast in-memory cache used as the synchronous hot path.
65+ * Writes are also persisted to the database asynchronously.
66+ */
3567const stores = new Map < string , RateLimitEntry > ( ) ;
3668
69+ /**
70+ * Persists a single rate-limit entry to the database.
71+ * Errors are silently swallowed so that a DB outage never blocks request
72+ * processing — the in-memory cache still provides best-effort limiting.
73+ */
74+ async function persistToDb ( identifier : string , entry : RateLimitEntry ) : Promise < void > {
75+ const query = await loadDbQuery ( ) ;
76+ if ( ! query ) return ;
77+ try {
78+ await query (
79+ `INSERT INTO rate_limits (identifier, count, reset_at, updated_at)
80+ VALUES ($1, $2, $3, NOW())
81+ ON CONFLICT (identifier) DO UPDATE
82+ SET count = EXCLUDED.count,
83+ reset_at = EXCLUDED.reset_at,
84+ updated_at = NOW()` ,
85+ [ identifier , entry . count , entry . resetAt ] ,
86+ ) ;
87+ } catch {
88+ // Silently ignore — the in-memory store is still authoritative for
89+ // the current process, and DB unavailability should not break requests.
90+ }
91+ }
92+
93+ /**
94+ * Removes an expired entry from the database.
95+ */
96+ async function removeFromDb ( identifier : string ) : Promise < void > {
97+ const query = await loadDbQuery ( ) ;
98+ if ( ! query ) return ;
99+ try {
100+ await query ( 'DELETE FROM rate_limits WHERE identifier = $1' , [ identifier ] ) ;
101+ } catch {
102+ // Silently ignore.
103+ }
104+ }
105+
106+ /**
107+ * Loads all non-expired rate-limit entries from the database into the
108+ * in-memory cache on process startup. Called once at module load time.
109+ */
110+ async function loadFromDb ( ) : Promise < void > {
111+ const query = await loadDbQuery ( ) ;
112+ if ( ! query ) return ;
113+ try {
114+ const now = Date . now ( ) ;
115+ const result = await query (
116+ 'SELECT identifier, count, reset_at FROM rate_limits WHERE reset_at > $1' ,
117+ [ now ] ,
118+ ) ;
119+ for ( const row of result . rows ) {
120+ stores . set ( row . identifier as string , {
121+ count : row . count as number ,
122+ resetAt : row . reset_at as number ,
123+ } ) ;
124+ }
125+ } catch {
126+ // DB may not be available yet (e.g. during build or early startup).
127+ // The in-memory store will be used as a fallback.
128+ }
129+ }
130+
131+ // Kick off the load-on-startup — non-blocking.
132+ void loadFromDb ( ) ;
133+
134+ /**
135+ * Synchronous sliding-window rate limiter backed by an in-memory cache
136+ * with asynchronous database persistence.
137+ *
138+ * The function signature is intentionally synchronous so that the 30+
139+ * existing call-sites (withRateLimit, certificate routes, etc.) do not
140+ * need to become async.
141+ */
37142export function slidingWindowRateLimit (
38143 identifier : string ,
39144 config : RateLimitConfig ,
@@ -44,9 +149,12 @@ export function slidingWindowRateLimit(
44149 if ( ! entry || entry . resetAt <= now ) {
45150 if ( entry ) {
46151 stores . delete ( identifier ) ;
152+ void removeFromDb ( identifier ) ;
47153 }
48154 const resetAt = now + config . windowMs ;
49- stores . set ( identifier , { count : 1 , resetAt } ) ;
155+ const newEntry : RateLimitEntry = { count : 1 , resetAt } ;
156+ stores . set ( identifier , newEntry ) ;
157+ void persistToDb ( identifier , newEntry ) ;
50158 return {
51159 success : true ,
52160 remaining : config . limit - 1 ,
@@ -68,6 +176,7 @@ export function slidingWindowRateLimit(
68176
69177 entry . count += 1 ;
70178 stores . set ( identifier , entry ) ;
179+ void persistToDb ( identifier , entry ) ;
71180
72181 return {
73182 success : true ,
@@ -232,4 +341,4 @@ export function withRateLimit<T extends Request>(
232341 addHeaders,
233342 rateLimitResponse : createRateLimitResponse ( result ) ,
234343 } ;
235- }
344+ }
0 commit comments