diff --git a/hub/package-lock.json b/hub/package-lock.json index 5457b5d..377d137 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -1,12 +1,12 @@ { "name": "@paradigm/hub", - "version": "0.0.27", + "version": "0.0.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@paradigm/hub", - "version": "0.0.27", + "version": "0.0.28", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@fastify/cookie": "^11.0.2", diff --git a/hub/package.json b/hub/package.json index 0d39bed..71f44a7 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.27", + "version": "0.0.28", "private": true, "type": "module", "engines": { diff --git a/hub/src/deployment/siloRateLimit.ts b/hub/src/deployment/siloRateLimit.ts index 9eee173..c17b056 100644 --- a/hub/src/deployment/siloRateLimit.ts +++ b/hub/src/deployment/siloRateLimit.ts @@ -1,3 +1,27 @@ +/** + * 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; diff --git a/hub/src/hub.ts b/hub/src/hub.ts index a165496..18509ca 100644 --- a/hub/src/hub.ts +++ b/hub/src/hub.ts @@ -14,7 +14,7 @@ import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuAppl import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js"; import { readServerBinding } from "./settings/server.js"; import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js"; -import { SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js"; +import { isSiloHttpRateLimitExempt, SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js"; function requireEnv(name: string): string { const value = process.env[name]; @@ -42,7 +42,9 @@ export async function startHub(): Promise { const app = Fastify({ logger: true, bodyLimit: httpBodyLimit }); const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000); app.addHook("onRequest", async (request, reply) => { - if (request.url === "/api/healthz") return; + // Static SPA assets / admin HTML shell / healthz do not count toward the + // silo requestRate budget (a full admin load can pull dozens of chunks). + if (isSiloHttpRateLimitExempt(request.url)) return; const decision = requestLimiter.consume(); if (!decision.allowed) { await reply diff --git a/hub/test/unit/silo-rate-limit.test.ts b/hub/test/unit/silo-rate-limit.test.ts index 98dfb69..37d4edd 100644 --- a/hub/test/unit/silo-rate-limit.test.ts +++ b/hub/test/unit/silo-rate-limit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { SiloFixedWindowRateLimiter } from "../../src/deployment/siloRateLimit.js"; +import { + isSiloHttpRateLimitExempt, + SiloFixedWindowRateLimiter, +} from "../../src/deployment/siloRateLimit.js"; describe("Silo fixed-window rate limiter", () => { it("returns an explicit retry interval and resets at the next window", () => { @@ -10,3 +13,24 @@ describe("Silo fixed-window rate limiter", () => { expect(limiter.consume(61_000)).toEqual({ allowed: true }); }); }); + +describe("isSiloHttpRateLimitExempt", () => { + it("exempts healthz, SPA static assets, and the admin HTML shell", () => { + expect(isSiloHttpRateLimitExempt("/api/healthz")).toBe(true); + expect(isSiloHttpRateLimitExempt("/_app/version.json")).toBe(true); + expect(isSiloHttpRateLimitExempt("/_app/immutable/chunks/foo.js?v=1")).toBe(true); + expect(isSiloHttpRateLimitExempt("/favicon.ico")).toBe(true); + expect(isSiloHttpRateLimitExempt("/favicon.svg")).toBe(true); + expect(isSiloHttpRateLimitExempt("/robots.txt")).toBe(true); + expect(isSiloHttpRateLimitExempt("/admin")).toBe(true); + expect(isSiloHttpRateLimitExempt("/admin/org/para-26071100/members")).toBe(true); + }); + + it("still rate-limits APIs and auth", () => { + expect(isSiloHttpRateLimitExempt("/api/me")).toBe(false); + expect(isSiloHttpRateLimitExempt("/api/org/x/members")).toBe(false); + expect(isSiloHttpRateLimitExempt("/auth/feishu/x")).toBe(false); + expect(isSiloHttpRateLimitExempt("/auth/feishu/callback?code=1")).toBe(false); + expect(isSiloHttpRateLimitExempt("/")).toBe(false); + }); +});