Files
Ekklesia/apps/api/tests/module-http-routes.test.ts
T
ekklesiaandClaude Opus 5 e36485bf99 feat: let a module route stream an image-sized upload, explicitly and bounded
ADR 0017 capped a module route at 1 MiB and said large media belonged to a
future storage design. That design arrived — ADR 0030 gave modules a bucket,
ADR 0031 took Ekklesia out of the read path — and it did not solve the write
path: signed write URLs were deliberately declined, so the only way bytes
reach a bucket is a module's own route, and a photograph is not 1 MiB.

Extend the route contract rather than adding a second HTTP system. A route
declares `bodyMode`: `buffered` (the default, and what every existing route
already meant) keeps the 1 MiB ceiling and materializes the body before the
handler runs; `streamed` raises the ceiling to 32 MiB and counts bytes as the
handler consumes them, holding nothing. The mode is explicit rather than
inferred from the size, because inferring it would mean an author who raised a
limit silently changed how their own handler receives its body and when a 413
can reach their own code.

32 MiB holds one 25 MiB image plus bounded overhead and is also the platform's
absolute ceiling: no route in any mode may declare more, and no unbounded
option exists in the contract. The unit is one file per request — twenty
images are twenty individually bounded requests, and nothing here can express
a 500 MiB aggregate whose failure would waste everything already sent.

Replace the buffering dependency with the platform's own limiter. A declared
Content-Length over the limit is refused synchronously, before authorization
and before a byte is read. Buffered mode now counts what actually arrives
rather than trusting the header, which closes a gap the previous
implementation had. Streamed mode wraps the body in a counting transform;
on overflow the stream errors, the source is cancelled, and the response is
replaced with the same sanitized 413 even if the handler swallowed the error —
a limit a handler can swallow is not a limit. An unread body is cancelled so
an abandoned upload releases its socket, and an abort stays an ordinary failed
request rather than becoming a 413.

Ordering is unchanged and now strictly better: in streamed mode no byte is
read until the handler reads it, and the handler runs only after authorization
succeeds, so an unauthorized caller cannot push an upload through at all.

The live suite uploads a real 25 MiB body over a real socket end to end.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 15:57:44 -05:00

2012 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 { 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(),
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);
});
});