Files
Ekklesia/apps/api/tests/module-http-routes.test.ts
T
ekklesiaandClaude Opus 5.5 a12cbe9600
CI / verify (pull_request) Successful in 46s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m44s
feat(api): serve the built Dashboard from the API origin
Serve surfaces/shell's built Dashboard from apps/api on the same origin
as /api (ADR 0044), exactly as the display host is served: a closed set
of files read once at startup, never a static directory, never a path
built from request input.

- The shell exports @ekklesia/shell/dashboard-assets (the exact built
  index.html and script URLs), emits the script at
  assets/ekklesia-dashboard/app.js, and index.html names that absolute
  path so dev and production serve the same URLs.
- GET / and GET /assets/ekklesia-dashboard/app.js answer from a frozen
  snapshot: no-cache with a strong ETag and 304 on match. Source maps
  are never served; every other path is the ordinary sanitized 404.
- The page sets nosniff, X-Frame-Options: DENY, Content-Security-Policy:
  frame-ancestors 'none' (that directive only), and Referrer-Policy:
  no-referrer, on 200 and 304 alike.
- A missing or empty shell build fails startup with a fixed
  DashboardHostAssetError before any module code is imported.
- The If-None-Match comparison moves to conditional-request.ts, shared
  with the display host routes.
- The emailed-link token stripping moves from browser/main.tsx into
  consumeAuthLanding (location and history injected) so it is tested;
  behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 17:54:55 -05:00

2016 lines
72 KiB
TypeScript

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 { testDashboardHostAssets } from "./support/dashboard-host-assets.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> = {}): 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<RegisteredModuleHttpRoute, "bodyMode" | "maxBodyBytes"> & {
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(),
dashboardHostAssets: testDashboardHostAssets(),
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<ModuleRouteHandler>(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<ModuleRouteHandler>(async () => Response.json({ kind: "static" }));
const nestedHandler = vi.fn<ModuleRouteHandler>(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<ModuleRouteHandler>(async () => Response.json({ kind: "static" }));
const paramHandler = vi.fn<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(() => 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<ModuleRouteHandler>(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<ModuleRouteHandler>(
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<ModuleRouteHandler>(
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<ModuleRouteHandler>(
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<ModuleRouteHandler>(
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<ModuleRouteHandler>(async () => {
const stream = new ReadableStream<Uint8Array>({
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<ModuleRouteHandler>(
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<ModuleRouteHandler>(
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<ModuleRouteHandler>(
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<ModuleRouteHandler>(
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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<Uint8Array>({
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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<Uint8Array>({
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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<Uint8Array>({
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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<Readonly<Record<string, string>>> = [];
const handler = vi.fn<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(async () => Response.json({ handler: "byId" }));
const create = vi.fn<ModuleRouteHandler>(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<Uint8Array> {
let sent = 0;
return new ReadableStream<Uint8Array>({
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<ModuleRouteHandler> {
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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<void>((resolve) => {
released = resolve;
});
let firstChunkSeen = false;
let secondChunkQueued = false;
const body = new ReadableStream<Uint8Array>({
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<ModuleRouteHandler>(async (request) => {
const reader = (request.body as ReadableStream<Uint8Array>).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<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(16).fill(97));
controller.error(new Error("client went away"));
},
});
const handler = vi.fn<ModuleRouteHandler>(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<Uint8Array>({
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<ModuleRouteHandler>(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<ModuleRouteHandler>(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<ModuleRouteHandler>(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);
});
});