import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type ModuleDatabase, type ModuleRouteHandler, type ModuleRouteHandlerContext, type ModuleRuntimeServices, moduleRoutesOverlap, parsePermissionId, type PermissionId, type RegisteredModuleHttpRoute, } from "@ekklesia/contracts"; import { type AuthorizationEvaluator, type AuthorizationStore, createPermissionRegistry, type PermissionRegistry, } from "@ekklesia/core/authorization"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry, EMPTY_MODULE_REALTIME_REGISTRY, } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { readyHealth } from "./support/ready-health.js"; import { createApiApp } from "../src/app.js"; import { canonicalizeVaryHeader, mergedVaryHeader, ModuleRouteConfigurationError, } from "../src/module-http-routes.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; const trustedOrigin = "https://dashboard.example.test"; const untrustedOrigin = "https://evil.example.test"; const readPermission = parsePermissionId("calendar.events.read"); const writePermission = parsePermissionId("calendar.events.write"); function fakeRuntime(overrides: Partial = {}): EkklesiaIdentityRuntime { return { handleAuthRequest: vi.fn(), resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), ...overrides, }; } function fakeStore(): AuthorizationStore { return { createGrant: vi.fn(), grantPlatformOwner: vi.fn(), hasActiveGrant: vi.fn().mockResolvedValue(false), isPlatformOwner: vi.fn().mockResolvedValue(false), listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), revokeGrant: vi.fn(), revokePlatformOwner: vi.fn(), }; } /** Defaults `maxBodyBytes` and `bodyMode` for tests that are not themselves exercising the * body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not * each need to restate an unrelated body-size choice; the dedicated "module route body limits" * suite further down overrides them explicitly to prove the contract itself. */ function moduleRoute( fields: Omit & { bodyMode?: RegisteredModuleHttpRoute["bodyMode"]; maxBodyBytes?: number; }, ): RegisteredModuleHttpRoute { return Object.freeze({ bodyMode: "buffered", maxBodyBytes: 1024, ...fields }); } /** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting, * authorization, and response sanitization, never real database behavior, so no PostgreSQL is * required. Each `database` method stays a distinct spy so a test can assert none of them were * ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */ function fakeModuleServices(): ModuleRuntimeServices { const database = Object.freeze({ delete: vi.fn(), execute: vi.fn(), insert: vi.fn(), select: vi.fn(), selectDistinct: vi.fn(), selectDistinctOn: vi.fn(), transaction: vi.fn(), update: vi.fn(), }) as unknown as ModuleDatabase; return Object.freeze({ database }); } function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void { expect(vi.mocked(services.database.select)).not.toHaveBeenCalled(); expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled(); expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled(); expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled(); expect(vi.mocked(services.database.update)).not.toHaveBeenCalled(); expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled(); expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled(); expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled(); } function buildModuleApp(options: { readonly evaluator?: AuthorizationEvaluator; readonly registry: PermissionRegistry; readonly routes: readonly RegisteredModuleHttpRoute[]; readonly runtime?: EkklesiaIdentityRuntime; readonly services?: ModuleRuntimeServices; readonly trustedOrigins?: readonly string[]; }) { const evaluator = options.evaluator ?? { authorize: vi.fn().mockResolvedValue({ allowed: true }), }; const runtime = options.runtime ?? fakeRuntime(); const services = options.services ?? fakeModuleServices(); const app = createApiApp({ authorization: { evaluator, registry: options.registry, store: fakeStore() }, moduleRoutes: options.routes, moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, moduleServices: services, moduleUi: createModuleUiPublicationRegistry([]), moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), dashboard: createInMemoryDashboardStore(), health: readyHealth(), runtime, trustedOrigins: options.trustedOrigins ?? [trustedOrigin], }); return { app, evaluator, runtime, services }; } function permissionRegistry( declarations: ReadonlyArray<{ readonly id: PermissionId; readonly baseline: "authenticated" | "public" | "restricted"; }>, ): PermissionRegistry { return createPermissionRegistry( declarations.map((declaration) => ({ ...declaration, description: "Test permission." })), ); } describe("module HTTP route mounting", () => { it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => { const handler = vi.fn(async () => new Response("root", { status: 200 })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/", permission: readPermission, }), ], }); const rootResponse = await app.request("/api/v1/modules/calendar"); expect(rootResponse.status).toBe(200); expect(await rootResponse.text()).toBe("root"); expect(handler).toHaveBeenCalledTimes(1); const trailingSlashResponse = await app.request("/api/v1/modules/calendar/"); expect(trailingSlashResponse.status).toBe(404); }); it("mounts static and nested paths directly under the module prefix", async () => { const staticHandler = vi.fn(async () => Response.json({ kind: "static" })); const nestedHandler = vi.fn(async () => Response.json({ kind: "nested" })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler: staticHandler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), moduleRoute({ handler: nestedHandler, method: "GET", moduleId: "calendar", path: "/events/upcoming/soon", permission: readPermission, }), ], }); expect(await (await app.request("/api/v1/modules/calendar/events")).json()).toEqual({ kind: "static", }); expect( await (await app.request("/api/v1/modules/calendar/events/upcoming/soon")).json(), ).toEqual({ kind: "nested" }); }); it("resolves a same-depth static path and its parameterized sibling by published route order, without silently reordering them", async () => { // Hono's router matches overlapping patterns in registration order, not by specificity — // mounting must therefore preserve `StartedModuleRuntime.routes`' exact publication order // rather than sorting it, or a module author's more-specific-first declaration would silently // stop taking precedence. The static route is declared first here, matching how a module // author would order these two routes to get the static path resolved on its own. const staticHandler = vi.fn(async () => Response.json({ kind: "static" })); const paramHandler = vi.fn(async (_request, context) => Response.json({ id: context.params.eventId, kind: "param" }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler: staticHandler, method: "GET", moduleId: "calendar", path: "/events/upcoming", permission: readPermission, }), moduleRoute({ handler: paramHandler, method: "GET", moduleId: "calendar", path: "/events/:eventId", permission: readPermission, }), ], }); expect(await (await app.request("/api/v1/modules/calendar/events/upcoming")).json()).toEqual({ kind: "static", }); expect(staticHandler).toHaveBeenCalledTimes(1); expect(paramHandler).not.toHaveBeenCalled(); expect(await (await app.request("/api/v1/modules/calendar/events/other-id")).json()).toEqual({ id: "other-id", kind: "param", }); expect(paramHandler).toHaveBeenCalledTimes(1); }); it("mounts every supported method", async () => { const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; const permissions = new Map( methods.map((method) => [method, parsePermissionId(`multi.${method.toLowerCase()}.action`)]), ); const { app } = buildModuleApp({ registry: permissionRegistry( methods.map((method) => ({ baseline: "public" as const, id: permissions.get(method) as PermissionId, })), ), routes: methods.map((method) => moduleRoute({ handler: async () => Response.json({ method }), method, moduleId: "multi", path: "/action", permission: permissions.get(method) as PermissionId, }), ), }); for (const method of methods) { const response = await app.request("/api/v1/modules/multi/action", { headers: method === "GET" ? {} : { origin: trustedOrigin }, method, }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ method }); } }); it("keeps two modules' identical local method/path routes independent", async () => { const alphaPermission = parsePermissionId("alpha.items.list"); const betaPermission = parsePermissionId("beta.items.list"); const { app } = buildModuleApp({ registry: permissionRegistry([ { id: alphaPermission, baseline: "public" }, { id: betaPermission, baseline: "public" }, ]), routes: [ moduleRoute({ handler: async () => Response.json({ moduleId: "alpha" }), method: "GET", moduleId: "alpha", path: "/items", permission: alphaPermission, }), moduleRoute({ handler: async () => Response.json({ moduleId: "beta" }), method: "GET", moduleId: "beta", path: "/items", permission: betaPermission, }), ], }); expect(await (await app.request("/api/v1/modules/alpha/items")).json()).toEqual({ moduleId: "alpha", }); expect(await (await app.request("/api/v1/modules/beta/items")).json()).toEqual({ moduleId: "beta", }); }); }); describe("module route handler adaptation", () => { it("adapts the request into a raw Fetch Request and a frozen context with decoded parameters", async () => { let capturedRequest: Request | undefined; let capturedContext: ModuleRouteHandlerContext | undefined; const handler: ModuleRouteHandler = async (request, context) => { capturedRequest = request; capturedContext = context; const body = await request.text(); return Response.json({ body, url: request.url }); }; const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")), }); const services = fakeModuleServices(); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "POST", moduleId: "calendar", path: "/events/:eventId/rsvp", permission: readPermission, }), ], runtime, services, }); const response = await app.request("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true", { body: "hello", headers: { origin: trustedOrigin, "x-custom": "abc" }, method: "POST", }); const body = (await response.json()) as { body: string; url: string }; expect(response.status).toBe(200); expect(body.body).toBe("hello"); expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true"); expect(capturedRequest).toBeInstanceOf(Request); expect(capturedRequest?.headers.get("x-custom")).toBe("abc"); // Exactly config, moduleId, params, principal, services, storage — and nothing else. `config` // is this module's own frozen configuration snapshot (ADR 0029) and `storage` its own scoped // object storage (ADR 0030); a Hono Context, the authorization store or evaluator, raw // PostgreSQL, the storage provider, an S3 endpoint or credential, platform configuration, and // another module's configuration or storage all remain unreachable, which is what this // exact-shape assertion exists to keep true. expect(Object.keys(capturedContext ?? {}).sort()).toEqual([ "config", "moduleId", "params", "principal", "realtime", "services", "storage", ]); expect(capturedContext).toMatchObject({ moduleId: "calendar", params: { eventId: "evt-1" }, principal: { kind: "authenticated", userId: "user_1" }, }); // Reference-identical to the exact services object the caller passed in — never a fresh one // built per request. expect(capturedContext?.services).toBe(services); expect(Object.isFrozen(capturedContext)).toBe(true); expect(Object.isFrozen(capturedContext?.params)).toBe(true); expect(Object.isFrozen(capturedContext?.services)).toBe(true); expect(Object.isFrozen(capturedContext?.config)).toBe(true); // This module declared no configuration, so its snapshot is empty rather than absent — module // code never has to branch on whether the platform supplied one. expect(capturedContext?.config.keys()).toEqual([]); // Likewise storage: this module declared no buckets, so it gets a frozen facade that refuses // every one rather than `undefined`. expect(Object.isFrozen(capturedContext?.storage)).toBe(true); expect(capturedContext?.storage.buckets()).toEqual([]); }); it("gives static and root routes a frozen, empty params object", async () => { let rootContext: ModuleRouteHandlerContext | undefined; let staticContext: ModuleRouteHandlerContext | undefined; const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler: async (_request, context) => { rootContext = context; return Response.json({ ok: true }); }, method: "GET", moduleId: "calendar", path: "/", permission: readPermission, }), moduleRoute({ handler: async (_request, context) => { staticContext = context; return Response.json({ ok: true }); }, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); await app.request("/api/v1/modules/calendar"); await app.request("/api/v1/modules/calendar/events"); expect(rootContext?.params).toEqual({}); expect(Object.isFrozen(rootContext?.params)).toBe(true); expect(staticContext?.params).toEqual({}); expect(Object.isFrozen(staticContext?.params)).toBe(true); }); }); describe("module route authorization", () => { it("uses the registry's exact baseline for public, authenticated, and restricted module permissions", async () => { const publicPermission = parsePermissionId("calendar.events.list"); const authenticatedPermission = parsePermissionId("calendar.events.subscribe"); const restrictedPermission = parsePermissionId("calendar.events.read"); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: true }), }; const { app } = buildModuleApp({ evaluator, registry: permissionRegistry([ { id: publicPermission, baseline: "public" }, { id: authenticatedPermission, baseline: "authenticated" }, { id: restrictedPermission, baseline: "restricted" }, ]), routes: [ moduleRoute({ handler: async () => Response.json({ ok: true }), method: "GET", moduleId: "calendar", path: "/public", permission: publicPermission, }), moduleRoute({ handler: async () => Response.json({ ok: true }), method: "GET", moduleId: "calendar", path: "/authenticated", permission: authenticatedPermission, }), moduleRoute({ handler: async () => Response.json({ ok: true }), method: "GET", moduleId: "calendar", path: "/restricted", permission: restrictedPermission, }), ], }); await app.request("/api/v1/modules/calendar/public"); await app.request("/api/v1/modules/calendar/authenticated"); await app.request("/api/v1/modules/calendar/restricted"); expect(evaluator.authorize).toHaveBeenNthCalledWith( 1, expect.objectContaining({ baseline: "public", permission: publicPermission }), ); expect(evaluator.authorize).toHaveBeenNthCalledWith( 2, expect.objectContaining({ baseline: "authenticated", permission: authenticatedPermission }), ); expect(evaluator.authorize).toHaveBeenNthCalledWith( 3, expect.objectContaining({ baseline: "restricted", permission: restrictedPermission }), ); }); it("does not invoke the handler or touch the module database when authorization denies", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: false }), }; const { app, services } = buildModuleApp({ evaluator, registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); expect(handler).not.toHaveBeenCalled(); expectDatabaseFakeUntouched(services); }); it("does not invoke the handler when principal resolution fails", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const runtime = fakeRuntime({ resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")), }); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], runtime, }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(503); expect(handler).not.toHaveBeenCalled(); }); it("does not invoke the handler when the evaluator itself fails", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockRejectedValue(new Error("connection terminated")), }; const { app } = buildModuleApp({ evaluator, registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(503); expect(handler).not.toHaveBeenCalled(); }); it("fails app construction safely when a route names a permission the registry does not register", () => { expect(() => buildModuleApp({ registry: permissionRegistry([]), routes: [ moduleRoute({ handler: vi.fn(), method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }), ).toThrow(ModuleRouteConfigurationError); }); it("never leaks the offending permission, route path, or module id in the configuration error", () => { let captured: unknown; try { buildModuleApp({ registry: permissionRegistry([]), routes: [ moduleRoute({ handler: vi.fn(), method: "GET", moduleId: "secret-module", path: "/secret-path", permission: parsePermissionId("secret-module.secret.permission"), }), ], }); } catch (error) { captured = error; } expect(captured).toBeInstanceOf(ModuleRouteConfigurationError); const text = `${(captured as Error).message}\n${(captured as Error).stack}`; expect(text).not.toContain("secret-module"); expect(text).not.toContain("secret-path"); expect(text).not.toContain("secret.permission"); }); }); describe("module route trusted-origin enforcement", () => { it("rejects a state-changing module route with no or untrusted Origin before authorization runs", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: true }), }; const methods = ["POST", "PUT", "PATCH", "DELETE"] as const; const { app, services } = buildModuleApp({ evaluator, registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: methods.map((method) => moduleRoute({ handler, method, moduleId: "calendar", path: "/events", permission: writePermission, }), ), }); for (const method of methods) { const noOriginResponse = await app.request("/api/v1/modules/calendar/events", { method }); expect(noOriginResponse.status).toBe(403); expect(await noOriginResponse.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); const untrustedResponse = await app.request("/api/v1/modules/calendar/events", { headers: { origin: untrustedOrigin }, method, }); expect(untrustedResponse.status).toBe(403); } expect(handler).not.toHaveBeenCalled(); expect(evaluator.authorize).not.toHaveBeenCalled(); expectDatabaseFakeUntouched(services); }); it("allows a trusted-origin state-changing request through to the handler", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "POST", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { origin: trustedOrigin }, method: "POST", }); expect(response.status).toBe(200); expect(handler).toHaveBeenCalledTimes(1); }); it("does not require an Origin header for a GET module route", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(200); expect(handler).toHaveBeenCalledTimes(1); }); }); describe("module route CORS", () => { it("advertises GET, POST, PUT, PATCH, and DELETE on preflight", async () => { const { app } = buildModuleApp({ registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler: vi.fn(), method: "PUT", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { "access-control-request-method": "PUT", origin: trustedOrigin }, method: "OPTIONS", }); expect(response.status).toBe(204); const allowedMethods = response.headers.get("access-control-allow-methods") ?? ""; for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) { expect(allowedMethods).toContain(method); } }); it("keeps the OPTIONS preflight's own Vary canonical and never force-injects Cookie, exercising the returned-Response path of the CORS-Vary fix-up", async () => { const { app } = buildModuleApp({ registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler: vi.fn(), method: "PUT", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { "access-control-request-method": "PUT", origin: trustedOrigin }, method: "OPTIONS", }); const vary = response.headers.get("vary") ?? ""; const tokens = vary.split(",").map((token) => token.trim()); // Hono's own cors() unconditionally appends "Access-Control-Request-Headers" here since // allowHeaders is non-empty; that is legitimate and must survive canonicalization. Cookie must // never be force-injected onto a preflight response, which has no cookie/personalization // concept at all — unlike a module response, which always forces it (see mergedVaryHeader). expect(tokens).toEqual(["Origin", "Access-Control-Request-Headers"]); expect(tokens.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1); }); it("never invokes authorization, the handler, or the module database for an OPTIONS preflight", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: true }), }; const { app, services } = buildModuleApp({ evaluator, registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "POST", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); await app.request("/api/v1/modules/calendar/events", { headers: { "access-control-request-method": "POST", origin: trustedOrigin }, method: "OPTIONS", }); expect(handler).not.toHaveBeenCalled(); expect(evaluator.authorize).not.toHaveBeenCalled(); expectDatabaseFakeUntouched(services); }); it("never reflects an untrusted origin for a module route", async () => { const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler: async () => Response.json({ ok: true }), method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { origin: untrustedOrigin }, }); expect(response.headers.get("access-control-allow-origin")).toBeNull(); }); }); describe("module route handler failure sanitization", () => { it("returns a fixed sanitized 500 when the handler throws, never leaking the caught message", async () => { const handler = vi.fn(async () => { throw new Error("password=hunter2 host=db.internal.example"); }); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); const text = await response.text(); expect(response.status).toBe(500); expect(JSON.parse(text)).toEqual({ code: "MODULE_HANDLER_FAILED" }); expect(text).not.toContain("hunter2"); expect(text).not.toContain("db.internal.example"); expect(response.headers.get("cache-control")).toBe("no-store"); }); it("returns the same fixed sanitized 500 when the handler rejects", async () => { const handler = vi.fn(() => Promise.reject(new Error("rejected"))); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(500); expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" }); }); it("returns the same fixed sanitized 500 when the handler returns a non-Response value", async () => { const handler = vi.fn(async () => ({ ok: true }) as unknown as Response); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(500); expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" }); }); it("passes through a valid module-owned 4xx/5xx Response unchanged", async () => { const handler = vi.fn(async () => Response.json({ code: "MODULE_NOT_FOUND" }, { status: 404 }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(404); expect(await response.json()).toEqual({ code: "MODULE_NOT_FOUND" }); }); }); describe("module route response header policy", () => { it("strips module-supplied CORS-control headers so the platform policy remains authoritative", async () => { const handler = vi.fn( async () => new Response("body", { headers: { "access-control-allow-credentials": "true", "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE", "access-control-allow-origin": "*", "access-control-expose-headers": "X-Secret", }, status: 200, }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { origin: untrustedOrigin }, }); expect(response.headers.get("access-control-allow-origin")).not.toBe("*"); expect(response.headers.get("access-control-expose-headers")).toBeNull(); }); it("forces Cache-Control: no-store and Vary: Origin, Cookie on a successful module response", async () => { const handler = vi.fn( async () => new Response("body", { headers: { "content-type": "text/plain" }, status: 200 }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.headers.get("cache-control")).toBe("no-store"); const vary = response.headers.get("vary") ?? ""; expect(vary).toContain("Origin"); expect(vary).toContain("Cookie"); }); it("preserves an additional legitimate Vary value from the handler alongside the forced ones", async () => { const handler = vi.fn( async () => new Response("body", { headers: { Vary: "Accept-Language" }, status: 200 }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); const vary = response.headers.get("vary") ?? ""; expect(vary).toContain("Accept-Language"); expect(vary).toContain("Origin"); expect(vary).toContain("Cookie"); }); // The following exercise `mergedVaryHeader` directly rather than through the full app: the // outer, pre-existing `cors()` middleware (`apps/api/src/app.ts`, unchanged by module routes) // unconditionally appends its own "Vary: Origin" to every response with a dynamic-function // origin, on top of whatever this module already set — a real Hono behavior, present on every // route including `/api/v1/principal` since Phase 4B, not something module routing introduces // or can suppress. Testing the merge function directly proves this module's own logic is // correct in isolation, without coupling the assertion to that separate middleware's behavior. describe("mergedVaryHeader", () => { it("collapses an existing * to a bare * instead of the invalid *, Origin, Cookie", () => { // RFC 9110 §12.5.5: a Vary field-value of "*" must never be combined with any other value. expect(mergedVaryHeader("*")).toBe("*"); expect(mergedVaryHeader("*, Accept-Language")).toBe("*"); expect(mergedVaryHeader(" * ")).toBe("*"); }); it("merges case-insensitively so a differently-cased Origin/Cookie never appears as a redundant duplicate", () => { expect(mergedVaryHeader("origin, Accept-Language, COOKIE, origin")).toBe( "Origin, Accept-Language, Cookie", ); }); it("forces canonical Origin/Cookie casing even when absent, and tolerates irregular whitespace", () => { expect(mergedVaryHeader(null)).toBe("Origin, Cookie"); expect(mergedVaryHeader("")).toBe("Origin, Cookie"); expect(mergedVaryHeader(" Accept-Language , ORIGIN ")).toBe( "Accept-Language, Origin, Cookie", ); }); it("de-duplicates a literal repeated token with identical casing", () => { expect(mergedVaryHeader("Accept-Language, Accept-Language")).toBe( "Accept-Language, Origin, Cookie", ); }); }); // The generic app-level CORS-Vary fix-up (`apps/api/src/app.ts`) uses this instead of // `mergedVaryHeader`: it must never inject a token (Origin, Cookie, or anything else) that was // not already present, since it wraps every response through the shared CORS middleware — // including an OPTIONS preflight, which has no Cookie/personalization concept at all. describe("canonicalizeVaryHeader", () => { it("collapses an existing * to a bare * instead of an invalid combination", () => { expect(canonicalizeVaryHeader("*")).toBe("*"); expect(canonicalizeVaryHeader("Origin, *")).toBe("*"); }); it("de-duplicates case-insensitively without injecting Origin or Cookie", () => { expect(canonicalizeVaryHeader("origin, Accept-Encoding, ORIGIN")).toBe( "origin, Accept-Encoding", ); }); it("never injects a token that was not already present", () => { expect(canonicalizeVaryHeader("Accept-Encoding")).toBe("Accept-Encoding"); expect(canonicalizeVaryHeader("")).toBe(""); }); }); it("still forces Origin and Cookie into Vary end-to-end even when a handler supplies its own casing", async () => { const handler = vi.fn( async () => new Response("body", { headers: { Vary: "origin" }, status: 200 }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); const vary = response.headers.get("vary") ?? ""; expect(vary).toContain("Origin"); expect(vary).toContain("Cookie"); }); it("does not read or buffer a streaming response body while sanitizing headers", async () => { const chunks = ["first-chunk", "second-chunk"]; const handler = vi.fn(async () => { const stream = new ReadableStream({ start(controller) { for (const chunk of chunks) { controller.enqueue(new TextEncoder().encode(chunk)); } controller.close(); }, }); return new Response(stream, { status: 200 }); }); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { origin: trustedOrigin }, }); expect(await response.text()).toBe(chunks.join("")); }); // These two exercise the *final* response `app.fetch` actually returns — after Hono's own, // separate `cors()` middleware (`apps/api/src/app.ts`) has also run and unconditionally appended // its own "Vary: Origin" following every module response, on top of whatever this module's own // sanitization already set. `sanitizeModuleResponse`'s merge alone cannot prove the wire-level // result is well-formed, because cors's append happens strictly afterward, outside this file's // code. it("keeps the final response's Vary exactly \"*\" end-to-end, even after Hono's own CORS append", async () => { const handler = vi.fn( async () => new Response("body", { headers: { Vary: "*" }, status: 200 }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { origin: trustedOrigin }, }); expect(response.headers.get("vary")).toBe("*"); }); it("collapses a differently-cased existing token with Hono's own appended Origin into one case-insensitive final token", async () => { const handler = vi.fn( async () => new Response("body", { headers: { Vary: "origin, Accept-Encoding" }, status: 200 }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { origin: trustedOrigin }, }); const vary = response.headers.get("vary") ?? ""; const tokens = vary .toLowerCase() .split(",") .map((token) => token.trim()); const originOccurrences = tokens.filter((token) => token === "origin").length; expect(originOccurrences).toBe(1); expect(tokens).toContain("accept-encoding"); expect(tokens).toContain("cookie"); }); }); describe("module route HEAD semantics", () => { it("traverses the GET route's permission middleware and returns no body when authorized", async () => { const handler = vi.fn( async () => new Response("body-text", { status: 200 }), ); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { method: "HEAD" }); expect(response.status).toBe(200); expect(await response.text()).toBe(""); expect(handler).toHaveBeenCalledTimes(1); }); it("denies a HEAD request exactly like the GET route would, without invoking the handler", async () => { const handler = vi.fn( async () => new Response("body-text", { status: 200 }), ); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: false }), }; const { app } = buildModuleApp({ evaluator, registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { method: "HEAD" }); expect(response.status).toBe(403); expect(await response.text()).toBe(""); expect(handler).not.toHaveBeenCalled(); }); }); describe("module route body limits", () => { it("rejects a non-empty declared body on a route declaring maxBodyBytes: 0", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler, maxBodyBytes: 0, method: "POST", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { body: "a", headers: { "content-length": "1", origin: trustedOrigin }, method: "POST", }); expect(response.status).toBe(413); expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); expect(handler).not.toHaveBeenCalled(); }); it("rejects a non-empty streamed body on a route declaring maxBodyBytes: 0", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler, maxBodyBytes: 0, method: "POST", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("a")); controller.close(); }, }); const response = await app.request("/api/v1/modules/calendar/events", { body: stream, duplex: "half", headers: { origin: trustedOrigin }, method: "POST", } as RequestInit); expect(response.status).toBe(413); expect(handler).not.toHaveBeenCalled(); }); it("accepts a genuinely empty body on a route declaring maxBodyBytes: 0", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler, maxBodyBytes: 0, method: "POST", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { headers: { origin: trustedOrigin }, method: "POST", }); expect(response.status).toBe(200); expect(handler).toHaveBeenCalledTimes(1); }); it("accepts a body exactly at an explicit maxBodyBytes limit and rejects one byte over", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const limit = 32; const { app } = buildModuleApp({ registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler, maxBodyBytes: limit, method: "POST", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const atLimit = "a".repeat(limit); const atLimitResponse = await app.request("/api/v1/modules/calendar/events", { body: atLimit, headers: { "content-length": String(limit), origin: trustedOrigin }, method: "POST", }); expect(atLimitResponse.status).toBe(200); const overLimit = "a".repeat(limit + 1); const overLimitResponse = await app.request("/api/v1/modules/calendar/events", { body: overLimit, headers: { "content-length": String(limit + 1), origin: trustedOrigin }, method: "POST", }); expect(overLimitResponse.status).toBe(413); expect(await overLimitResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); expect(handler).toHaveBeenCalledTimes(1); }); it("rejects an oversized streamed body on a route with an explicit limit, with no declared Content-Length", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const limit = 64; const { app } = buildModuleApp({ registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler, maxBodyBytes: limit, method: "POST", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const chunk = new Uint8Array(16).fill(97); let enqueued = 0; const stream = new ReadableStream({ pull(controller) { if (enqueued > limit) { controller.close(); return; } enqueued += chunk.length; controller.enqueue(chunk); }, }); const response = await app.request("/api/v1/modules/calendar/events", { body: stream, duplex: "half", headers: { origin: trustedOrigin }, method: "POST", } as RequestInit); expect(response.status).toBe(413); expect(handler).not.toHaveBeenCalled(); }); it("enforces the body limit before authorization runs, so an oversized denied request never reaches the evaluator", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: false }), }; const { app } = buildModuleApp({ evaluator, registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]), routes: [ moduleRoute({ handler, maxBodyBytes: 8, method: "POST", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events", { body: "a".repeat(9), headers: { "content-length": "9", origin: trustedOrigin }, method: "POST", }); expect(response.status).toBe(413); expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); expect(evaluator.authorize).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled(); }); it("never reads an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ handler, maxBodyBytes: 8, method: "POST", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const chunk = new Uint8Array(16).fill(97); let pullCount = 0; const stream = new ReadableStream({ pull(controller) { pullCount += 1; if (pullCount > 3) { controller.close(); return; } controller.enqueue(chunk); }, }); const response = await app.request("/api/v1/modules/calendar/events", { body: stream, duplex: "half", headers: { origin: untrustedOrigin }, method: "POST", } as RequestInit); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); expect(handler).not.toHaveBeenCalled(); }); it("applies the route's own declared limit to a GET route too", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, maxBodyBytes: 0, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/events"); expect(response.status).toBe(200); expect(handler).toHaveBeenCalledTimes(1); }); }); describe("module routes and the rest of the API", () => { it("does not invoke a handler for an unmatched module path", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/does-not-exist"); expect(response.status).toBe(404); expect(await response.json()).toEqual({ code: "NOT_FOUND" }); expect(handler).not.toHaveBeenCalled(); }); it("never invokes a handler merely by constructing the app", () => { const handler = vi.fn(async () => Response.json({ ok: true })); buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/events", permission: readPermission, }), ], }); expect(handler).not.toHaveBeenCalled(); }); it("preserves existing API behavior with zero module routes", async () => { const { app } = buildModuleApp({ registry: permissionRegistry([]), routes: [] }); const response = await app.request("/api/v1/principal"); expect(response.status).toBe(200); }); }); /** * Pass 4: the decoded-parameter boundary and the routing ambiguity that made it dangerous. * * These drive the pinned Hono matcher with real requests rather than asserting what a mock returns, * because the whole point is what the real router actually hands a module handler. */ describe("module route parameters are decoded, untrusted input", () => { function paramsApp() { const seen: Array>> = []; const handler = vi.fn(async (_request, context) => { seen.push(context.params); return Response.json({ id: context.params.id }); }); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/items/:id", permission: readPermission, }), ], }); return { app, handler, seen }; } it.each([ // A single encoded segment can carry characters the declaration grammar forbids outright. ["an encoded path separator and dot segments", "%2F..%2Fadmin", "/../admin"], ["an encoded backslash", "a%5Cb", "a\\b"], ["an embedded NUL", "a%00b", "a\u0000b"], ["encoded CR/LF", "a%0D%0Ab", "a\r\nb"], ["a percent sign", "100%25", "100%"], ["non-ASCII text", "%C3%A9v%C3%A9nement", "événement"], ["a statement separator", "1%3B%20drop", "1; drop"], ])("delivers %s decoded, exactly as the router produced it", async (_label, encoded, decoded) => { const { app, seen } = paramsApp(); const response = await app.request(`/api/v1/modules/calendar/items/${encoded}`); expect(response.status).toBe(200); expect(await response.json()).toEqual({ id: decoded }); // The declared path grammar (`/items/:id`) permits none of these characters — validating the // *declaration* says nothing about what a request may send, which is exactly why a handler // must validate the decoded value against its own domain grammar before using it. expect(seen[0]?.id).toBe(decoded); expect(Object.isFrozen(seen[0])).toBe(true); }); it("keeps parameter delivery behind authorization", async () => { const handler = vi.fn(async () => new Response("never")); const { app, evaluator } = buildModuleApp({ evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: false }) }, registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/items/:id", permission: readPermission, }), ], }); const response = await app.request("/api/v1/modules/calendar/items/%2F..%2Fadmin"); expect(response.status).toBe(403); expect(evaluator.authorize).toHaveBeenCalled(); // An unauthorized caller can never observe how the handler would treat a parameter, because // the handler never runs. expect(handler).not.toHaveBeenCalled(); }); it("lets a handler reject a decoded value against its own domain grammar", async () => { // The platform supplies no generic parameter validator; this is the module's own check, which // is exactly the documented contract. const canonicalId = /^[0-9a-f]{8}$/u; const handler = vi.fn(async (_request, context) => { const id = context.params.id; if (id === undefined || !canonicalId.test(id)) { return Response.json({ code: "INVALID_REQUEST" }, { status: 400 }); } return Response.json({ id }); }); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: readPermission, baseline: "public" }]), routes: [ moduleRoute({ handler, method: "GET", moduleId: "calendar", path: "/items/:id", permission: readPermission, }), ], }); expect((await app.request("/api/v1/modules/calendar/items/deadbeef")).status).toBe(200); const rejected = await app.request("/api/v1/modules/calendar/items/%2F..%2Fadmin"); expect(rejected.status).toBe(400); expect(await rejected.json()).toEqual({ code: "INVALID_REQUEST" }); }); }); describe("module route overlap can never reach mounting", () => { it("would otherwise let registration order choose the governing permission", async () => { // Both orders are mounted directly here — bypassing the runtime rejection — to show precisely // what `MODULE_ROUTE_OVERLAP` prevents: the same request reaching a different handler *and* a // different permission depending only on the order the routes were registered in. const createPermission = parsePermissionId("calendar.events.create"); const byId = vi.fn(async () => Response.json({ handler: "byId" })); const create = vi.fn(async () => Response.json({ handler: "new" })); const registry = permissionRegistry([ { id: readPermission, baseline: "public" }, { id: createPermission, baseline: "public" }, ]); const paramRoute = moduleRoute({ handler: byId, method: "GET", moduleId: "calendar", path: "/events/:id", permission: readPermission, }); const staticRoute = moduleRoute({ handler: create, method: "GET", moduleId: "calendar", path: "/events/new", permission: createPermission, }); const parameterFirst = buildModuleApp({ registry, routes: [paramRoute, staticRoute] }); const staticFirst = buildModuleApp({ registry, routes: [staticRoute, paramRoute] }); expect( await (await parameterFirst.app.request("/api/v1/modules/calendar/events/new")).json(), ).toEqual({ handler: "byId" }); expect( await (await staticFirst.app.request("/api/v1/modules/calendar/events/new")).json(), ).toEqual({ handler: "new" }); // Core rejects this pair during staged registration, so no such snapshot can ever be published // to `mountModuleHttpRoutes` — see `core/tests/module-runtime.test.ts`. expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true); }); }); /** * Explicit, bounded streaming uploads (ADR 0042). * * The distinction being proven throughout is *when* the platform knows a body is too large, and what * it holds while finding out. In `buffered` mode the answer is "before the handler runs, holding at * most the route's own declared allowance". In `streamed` mode it is "as the handler consumes it, * holding nothing" — and the `413` still wins, even when the handler tried to answer anyway. */ describe("module route streamed body limits", () => { const oneMebibyte = 1024 * 1024; /** A body of `size` bytes delivered in realistic chunks, with no declared `Content-Length` — the * chunked-transfer shape a browser `fetch` of a `ReadableStream` actually produces. */ function chunkedBody(size: number, chunkSize = 64 * 1024): ReadableStream { let sent = 0; return new ReadableStream({ pull(controller) { if (sent >= size) { controller.close(); return; } const next = Math.min(chunkSize, size - sent); sent += next; controller.enqueue(new Uint8Array(next).fill(97)); }, }); } function streamingApp(options: { readonly handler: ModuleRouteHandler; readonly maxBodyBytes: number; readonly evaluator?: AuthorizationEvaluator; }) { return buildModuleApp({ ...(options.evaluator ? { evaluator: options.evaluator } : {}), registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ bodyMode: "streamed", handler: options.handler, maxBodyBytes: options.maxBodyBytes, method: "POST", moduleId: "calendar", path: "/uploads", permission: writePermission, }), ], }); } /** Counts what the handler actually received, which is the only honest way to tell "streamed" from * "buffered then handed over". */ async function countingHandler(received: { bytes: number }): Promise { return async (request) => { const body = request.body; if (!body) { return Response.json({ bytes: 0 }); } const reader = body.getReader(); for (;;) { const { done, value } = await reader.read(); if (done) { break; } received.bytes += value.byteLength; } return Response.json({ bytes: received.bytes }); }; } it("accepts a body of exactly the declared limit", async () => { const received = { bytes: 0 }; const { app } = streamingApp({ handler: await countingHandler(received), maxBodyBytes: oneMebibyte, }); const response = await app.request("/api/v1/modules/calendar/uploads", { body: chunkedBody(oneMebibyte), duplex: "half", headers: { origin: trustedOrigin }, method: "POST", } as RequestInit); expect(response.status).toBe(200); expect(await response.json()).toEqual({ bytes: oneMebibyte }); }); it("rejects a declared Content-Length over the limit before the handler runs", async () => { const received = { bytes: 0 }; const counting = await countingHandler(received); const handler = vi.fn(counting); const { app } = streamingApp({ handler, maxBodyBytes: 1024 }); const response = await app.request("/api/v1/modules/calendar/uploads", { body: chunkedBody(2048, 16), duplex: "half", headers: { "content-length": String(1025), origin: trustedOrigin }, method: "POST", } as RequestInit); expect(response.status).toBe(413); expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); // The cheap rejection: the declared size alone decided it, so no handler work happened and no // byte of the upload reached module code. expect(handler).not.toHaveBeenCalled(); expect(received.bytes).toBe(0); }); it("terminates a body whose actual size exceeds a smaller declared Content-Length", async () => { // A misleading `Content-Length` buys nothing: the running byte count is what bounds the request, // so a header claiming 8 bytes cannot smuggle a megabyte past the limit. const received = { bytes: 0 }; const { app } = streamingApp({ handler: await countingHandler(received), maxBodyBytes: 64, }); const response = await app.request("/api/v1/modules/calendar/uploads", { body: chunkedBody(4096, 32), duplex: "half", headers: { "content-length": "8", origin: trustedOrigin }, method: "POST", } as RequestInit); expect(response.status).toBe(413); expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); // It stopped promptly rather than draining the whole upload first. expect(received.bytes).toBeLessThanOrEqual(96); }); it("answers 413 even when the handler swallowed the stream error and tried to respond", async () => { // A limit a handler can swallow is not a limit. const handler = vi.fn(async (request) => { try { await request.arrayBuffer(); } catch { // Deliberately ignored, exactly as a careless module might. } return Response.json({ ok: "handler won" }); }); const { app } = streamingApp({ handler, maxBodyBytes: 64 }); const response = await app.request("/api/v1/modules/calendar/uploads", { body: chunkedBody(4096, 32), duplex: "half", headers: { origin: trustedOrigin }, method: "POST", } as RequestInit); expect(handler).toHaveBeenCalledTimes(1); expect(response.status).toBe(413); expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" }); }); it("never buffers the body: the handler sees the first bytes before the last are sent", async () => { let released: (() => void) | undefined; const finished = new Promise((resolve) => { released = resolve; }); let firstChunkSeen = false; let secondChunkQueued = false; const body = new ReadableStream({ async pull(controller) { if (!secondChunkQueued) { secondChunkQueued = true; controller.enqueue(new Uint8Array(16).fill(97)); return; } // Only produce the rest once the handler has demonstrably consumed the first chunk. A // buffering platform would deadlock here rather than reach the assertion below. await finished; controller.close(); }, }); const handler = vi.fn(async (request) => { const reader = (request.body as ReadableStream).getReader(); const first = await reader.read(); firstChunkSeen = !first.done; released?.(); for (;;) { const next = await reader.read(); if (next.done) { break; } } return Response.json({ ok: true }); }); const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte }); const response = await app.request("/api/v1/modules/calendar/uploads", { body, duplex: "half", headers: { origin: trustedOrigin }, method: "POST", } as RequestInit); expect(firstChunkSeen).toBe(true); expect(response.status).toBe(200); }); it("releases an aborted upload without taking the platform down", async () => { const aborted = new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array(16).fill(97)); controller.error(new Error("client went away")); }, }); const handler = vi.fn(async (request) => { await request.arrayBuffer(); return Response.json({ ok: true }); }); const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte }); const response = await app.request("/api/v1/modules/calendar/uploads", { body: aborted, duplex: "half", headers: { origin: trustedOrigin }, method: "POST", } as RequestInit); // An abort is a failed request, never an unhandled rejection or a 413: nothing exceeded a limit. expect(response.status).toBe(500); expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" }); }); it("releases a body the handler never read", async () => { let cancelled = false; const body = new ReadableStream({ cancel() { cancelled = true; }, pull(controller) { controller.enqueue(new Uint8Array(16).fill(97)); }, }); const { app } = streamingApp({ handler: async () => Response.json({ ok: true }), maxBodyBytes: oneMebibyte, }); const response = await app.request("/api/v1/modules/calendar/uploads", { body, duplex: "half", headers: { origin: trustedOrigin }, method: "POST", } as RequestInit); expect(response.status).toBe(200); expect(cancelled).toBe(true); }); it("refuses a declared oversize before authorization, and never streams for a denied caller", async () => { const evaluator: AuthorizationEvaluator = { authorize: vi.fn().mockResolvedValue({ allowed: false }), }; const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = buildModuleApp({ evaluator, registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]), routes: [ moduleRoute({ bodyMode: "streamed", handler, maxBodyBytes: 64, method: "POST", moduleId: "calendar", path: "/uploads", permission: writePermission, }), ], }); const declaredOversize = await app.request("/api/v1/modules/calendar/uploads", { body: "a".repeat(65), headers: { "content-length": "65", origin: trustedOrigin }, method: "POST", }); expect(declaredOversize.status).toBe(413); expect(evaluator.authorize).not.toHaveBeenCalled(); // A within-limit body from a denied caller is refused by authorization, and the handler — the // only thing that ever reads a byte in streamed mode — never runs. const response = await app.request("/api/v1/modules/calendar/uploads", { body: chunkedBody(32, 8), duplex: "half", headers: { origin: trustedOrigin }, method: "POST", } as RequestInit); expect(response.status).toBe(403); expect(handler).not.toHaveBeenCalled(); }); it("still rejects an untrusted Origin before any body handling", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); const { app } = streamingApp({ handler, maxBodyBytes: oneMebibyte }); const response = await app.request("/api/v1/modules/calendar/uploads", { body: chunkedBody(4096), duplex: "half", headers: { origin: untrustedOrigin }, method: "POST", } as RequestInit); expect(response.status).toBe(403); expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); expect(handler).not.toHaveBeenCalled(); }); it("keeps every rejection sanitized, naming no limit, size, or body content", async () => { const { app } = streamingApp({ handler: async () => Response.json({ ok: true }), maxBodyBytes: 64, }); const response = await app.request("/api/v1/modules/calendar/uploads", { body: "secret-payload-".repeat(64), headers: { "content-length": String(15 * 64), origin: trustedOrigin }, method: "POST", }); const body = await response.text(); expect(response.status).toBe(413); expect(body).toBe('{"code":"REQUEST_BODY_TOO_LARGE"}'); expect(body).not.toContain("secret-payload"); expect(body).not.toContain("64"); expect(response.headers.get("cache-control")).toBe("no-store"); }); it("still materializes a buffered body before the handler runs", async () => { // The pre-existing promise, unchanged: a buffered handler may call `request.json()` and know the // body is already there and already within its declared allowance. const handler = vi.fn(async (request) => { const text = await request.text(); return Response.json({ length: text.length }); }); const { app } = buildModuleApp({ registry: permissionRegistry([{ id: writePermission, baseline: "public" }]), routes: [ moduleRoute({ bodyMode: "buffered", handler, maxBodyBytes: 128, method: "POST", moduleId: "calendar", path: "/events", permission: writePermission, }), ], }); const withinLimit = await app.request("/api/v1/modules/calendar/events", { body: chunkedBody(128, 16), duplex: "half", headers: { origin: trustedOrigin }, method: "POST", } as RequestInit); expect(withinLimit.status).toBe(200); expect(await withinLimit.json()).toEqual({ length: 128 }); const overLimit = await app.request("/api/v1/modules/calendar/events", { body: chunkedBody(129, 16), duplex: "half", headers: { origin: trustedOrigin }, method: "POST", } as RequestInit); expect(overLimit.status).toBe(413); // A buffered route's handler never sees an over-limit request at all. expect(handler).toHaveBeenCalledTimes(1); }); });