/** * Silo-wide HTTP request rate limit (ADR-0022 `requestRate`). * * Counts dynamic traffic only: APIs, auth, and other application handlers. * Static SPA assets and the org-admin HTML shell are exempt so a single page * load (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget. */ /** Paths that must not consume the silo HTTP request-rate budget. */ export function isSiloHttpRateLimitExempt(url: string): boolean { const path = (url.split("?", 1)[0] ?? url) || "/"; if (path === "/api/healthz") return true; // SvelteKit build output and top-level static files (see admin/static.ts). if (path === "/_app" || path.startsWith("/_app/")) return true; if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true; // SPA index shell for client-side routes (not an API). if (path === "/admin" || path.startsWith("/admin/")) return true; return false; } export class SiloFixedWindowRateLimiter { private windowStartedAt: number; private used = 0; constructor(readonly limit: number, readonly windowMs: number, now = Date.now()) { if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("rate limit must be a positive integer"); if (!Number.isSafeInteger(windowMs) || windowMs <= 0) throw new Error("rate window must be a positive integer"); this.windowStartedAt = now; } consume(now = Date.now()): { readonly allowed: true } | { readonly allowed: false; readonly retryAfterSeconds: number } { if (now >= this.windowStartedAt + this.windowMs) { this.windowStartedAt = now; this.used = 0; } if (this.used >= this.limit) { return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((this.windowStartedAt + this.windowMs - now) / 1000)), }; } this.used += 1; return { allowed: true }; } }