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 } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.js"; import { canonicalizeVaryHeader, mergedVaryHeader, ModuleRouteConfigurationError, } from "../src/module-http-routes.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` to a generous value 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 it explicitly to prove the contract itself. */ function moduleRoute( fields: Omit & { maxBodyBytes?: number }, ): RegisteredModuleHttpRoute { return Object.freeze({ 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, moduleServices: services, moduleUi: createModuleUiPublicationRegistry([]), 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 moduleId, params, principal, services — never a Hono Context, authorization store, // or configuration reaches a module handler. expect(Object.keys(capturedContext ?? {}).sort()).toEqual([ "moduleId", "params", "principal", "services", ]); 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); }); 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); }); });