Phase 6C pass 1. ADR 0024 named the one thing missing between the module UI build pipeline and the generic host loader: Core resolves entrypoints.ui to an absolute server filesystem path, and a browser needs a URL. This adds that publication layer. Core owns the policy. createModuleUiPublicationRegistry builds, from the discovered and started module inventory, the answer to "may this be published, and which exact file is it?" — publishable means the module survived discovery and declares entrypoints.ui, with no new lifecycle state and no installer. apps/api owns the transport: GET /api/v1/module-ui/<module-id>/<version>. Both segments are lookup keys, not path components. An exact match yields the canonical artifact path discovery already validated; no request value is ever composed into a filesystem path, and the route declares no wildcard segment, so traversal, encoded separators, sibling chunks, package-root files, and source maps are unreachable by construction. An installed module directory never becomes a static file root. The prefix is a sibling of /api/v1/modules/, not a child: `ui` is a legal module route segment, so a module registering GET /ui/:version would otherwise compete with the platform for one URL with routing order deciding the winner. The route requires no authentication, deliberately. Fetching the bundle confers no authority, and a cookie-gated route would break cross-origin import(), which sends none. The cost is stated rather than hidden: a caller who knows a module id and its exact version can download that bundle. The three failure modes return one identical 404 so the route is not an enumeration oracle. Caching is no-cache with a strong SHA-256 ETag and RFC 9110 If-None-Match handling, not immutable: without an installer nothing stops a version being rebuilt with different bytes. The server reads bytes and writes bytes — never imports, evaluates, or transforms the artifact. Nothing mounts a module UI. A host could now obtain a URL, which was previously impossible; there is still no Dashboard shell, navigation, or metadata endpoint. Co-Authored-By: Claude Opus 5 <[email protected]>
1610 lines
58 KiB
TypeScript
1610 lines
58 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 } from "@ekklesia/core/modules";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { createApiApp } from "../src/app.js";
|
|
import {
|
|
canonicalizeVaryHeader,
|
|
mergedVaryHeader,
|
|
ModuleRouteConfigurationError,
|
|
} from "../src/module-http-routes.js";
|
|
|
|
const trustedOrigin = "https://dashboard.example.test";
|
|
const untrustedOrigin = "https://evil.example.test";
|
|
const readPermission = parsePermissionId("calendar.events.read");
|
|
const writePermission = parsePermissionId("calendar.events.write");
|
|
|
|
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
|
|
return {
|
|
handleAuthRequest: vi.fn(),
|
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function fakeStore(): AuthorizationStore {
|
|
return {
|
|
createGrant: vi.fn(),
|
|
grantPlatformOwner: vi.fn(),
|
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
|
revokeGrant: vi.fn(),
|
|
revokePlatformOwner: vi.fn(),
|
|
};
|
|
}
|
|
|
|
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the
|
|
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
|
|
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
|
|
* suite further down overrides it explicitly to prove the contract itself. */
|
|
function moduleRoute(
|
|
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number },
|
|
): RegisteredModuleHttpRoute {
|
|
return Object.freeze({ maxBodyBytes: 1024, ...fields });
|
|
}
|
|
|
|
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
|
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
|
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
|
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
|
function fakeModuleServices(): ModuleRuntimeServices {
|
|
const database = Object.freeze({
|
|
delete: vi.fn(),
|
|
execute: vi.fn(),
|
|
insert: vi.fn(),
|
|
select: vi.fn(),
|
|
selectDistinct: vi.fn(),
|
|
selectDistinctOn: vi.fn(),
|
|
transaction: vi.fn(),
|
|
update: vi.fn(),
|
|
}) as unknown as ModuleDatabase;
|
|
return Object.freeze({ database });
|
|
}
|
|
|
|
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
|
|
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
|
}
|
|
|
|
function buildModuleApp(options: {
|
|
readonly evaluator?: AuthorizationEvaluator;
|
|
readonly registry: PermissionRegistry;
|
|
readonly routes: readonly RegisteredModuleHttpRoute[];
|
|
readonly runtime?: EkklesiaIdentityRuntime;
|
|
readonly services?: ModuleRuntimeServices;
|
|
readonly trustedOrigins?: readonly string[];
|
|
}) {
|
|
const evaluator = options.evaluator ?? {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
};
|
|
const runtime = options.runtime ?? fakeRuntime();
|
|
const services = options.services ?? fakeModuleServices();
|
|
const app = createApiApp({
|
|
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
|
moduleRoutes: options.routes,
|
|
moduleServices: services,
|
|
moduleUi: createModuleUiPublicationRegistry([]),
|
|
runtime,
|
|
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
|
});
|
|
return { app, evaluator, runtime, services };
|
|
}
|
|
|
|
function permissionRegistry(
|
|
declarations: ReadonlyArray<{
|
|
readonly id: PermissionId;
|
|
readonly baseline: "authenticated" | "public" | "restricted";
|
|
}>,
|
|
): PermissionRegistry {
|
|
return createPermissionRegistry(
|
|
declarations.map((declaration) => ({ ...declaration, description: "Test permission." })),
|
|
);
|
|
}
|
|
|
|
describe("module HTTP route mounting", () => {
|
|
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
|
|
const handler = vi.fn<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 moduleId, params, principal, services — never a Hono Context, authorization store,
|
|
// or configuration reaches a module handler.
|
|
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
|
|
"moduleId",
|
|
"params",
|
|
"principal",
|
|
"services",
|
|
]);
|
|
expect(capturedContext).toMatchObject({
|
|
moduleId: "calendar",
|
|
params: { eventId: "evt-1" },
|
|
principal: { kind: "authenticated", userId: "user_1" },
|
|
});
|
|
// Reference-identical to the exact services object the caller passed in — never a fresh one
|
|
// built per request.
|
|
expect(capturedContext?.services).toBe(services);
|
|
expect(Object.isFrozen(capturedContext)).toBe(true);
|
|
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
|
|
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
|
|
});
|
|
|
|
it("gives static and root routes a frozen, empty params object", async () => {
|
|
let rootContext: ModuleRouteHandlerContext | undefined;
|
|
let staticContext: ModuleRouteHandlerContext | undefined;
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: async (_request, context) => {
|
|
rootContext = context;
|
|
return Response.json({ ok: true });
|
|
},
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/",
|
|
permission: readPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: async (_request, context) => {
|
|
staticContext = context;
|
|
return Response.json({ ok: true });
|
|
},
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
await app.request("/api/v1/modules/calendar");
|
|
await app.request("/api/v1/modules/calendar/events");
|
|
|
|
expect(rootContext?.params).toEqual({});
|
|
expect(Object.isFrozen(rootContext?.params)).toBe(true);
|
|
expect(staticContext?.params).toEqual({});
|
|
expect(Object.isFrozen(staticContext?.params)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("module route authorization", () => {
|
|
it("uses the registry's exact baseline for public, authenticated, and restricted module permissions", async () => {
|
|
const publicPermission = parsePermissionId("calendar.events.list");
|
|
const authenticatedPermission = parsePermissionId("calendar.events.subscribe");
|
|
const restrictedPermission = parsePermissionId("calendar.events.read");
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
};
|
|
const { app } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([
|
|
{ id: publicPermission, baseline: "public" },
|
|
{ id: authenticatedPermission, baseline: "authenticated" },
|
|
{ id: restrictedPermission, baseline: "restricted" },
|
|
]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: async () => Response.json({ ok: true }),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/public",
|
|
permission: publicPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: async () => Response.json({ ok: true }),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/authenticated",
|
|
permission: authenticatedPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: async () => Response.json({ ok: true }),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/restricted",
|
|
permission: restrictedPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
await app.request("/api/v1/modules/calendar/public");
|
|
await app.request("/api/v1/modules/calendar/authenticated");
|
|
await app.request("/api/v1/modules/calendar/restricted");
|
|
|
|
expect(evaluator.authorize).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({ baseline: "public", permission: publicPermission }),
|
|
);
|
|
expect(evaluator.authorize).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({ baseline: "authenticated", permission: authenticatedPermission }),
|
|
);
|
|
expect(evaluator.authorize).toHaveBeenNthCalledWith(
|
|
3,
|
|
expect.objectContaining({ baseline: "restricted", permission: restrictedPermission }),
|
|
);
|
|
});
|
|
|
|
it("does not invoke the handler or touch the module database when authorization denies", async () => {
|
|
const handler = vi.fn<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);
|
|
});
|
|
});
|