feat: mount module HTTP routes

Phase 5B, pass 2: production API mounting, authorization enforcement,
and module-runtime lifecycle integration.

apps/api now discovers the installed module inventory exactly once
(prepareStartupModuleInventory), composes the platform permission
registry from it, starts the module runtime from that same discovered
list (startDiscoveredModuleRuntime, never rediscovering), and mounts
StartedModuleRuntime's published routes under
/api/v1/modules/<module-id> via a new apps/api-owned Hono adapter
(module-http-routes.ts). Every module route is protected by the
existing requirePermission middleware against the composed registry,
state-changing methods additionally require a trusted Origin, OPTIONS
remains entirely platform-owned, and a handler that throws, rejects,
or returns a non-Response becomes a fixed, sanitized
500 MODULE_HANDLER_FAILED. Module-supplied CORS headers are stripped
and every module response is forced to Cache-Control: no-store with
Vary: Origin, Cookie. RunningApi shutdown now stops the module runtime
between draining the HTTP server and closing the shared pool, and
every startup failure path stops the runtime and/or closes the pool
before propagating, without masking the original failure.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-24 19:16:20 -05:00
co-authored by Claude Sonnet 5
parent e296e083ec
commit dfaf324a65
12 changed files with 1930 additions and 123 deletions
+133 -8
View File
@@ -200,6 +200,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
}
const trackedModulesDirectories: string[] = [];
const eventSymbols: symbol[] = [];
async function createFixtureModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-installed-modules-"));
@@ -207,13 +208,31 @@ async function createFixtureModulesDirectory(): Promise<string> {
return directory;
}
/** Writes a minimal, structurally valid module fixture straight to disk — real discovery and
* composition run against it, nothing hand-built. The server entrypoint throws immediately if
* ever imported/executed, so a scenario that completes without that throw firing is direct
* evidence permission composition and Doctor never ran module lifecycle code. */
/** A dynamically imported module fixture cannot close over this test file's local variables, so
* lifecycle markers cross that boundary through a `globalThis` symbol instead (same technique as
* `core/tests/module-runtime.test.ts`). */
function createEventRecorder(): { events: string[]; expression: string } {
const key = `ekklesia.api.integration.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
const symbol = Symbol.for(key);
const events: string[] = [];
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
eventSymbols.push(symbol);
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
}
/** Writes a minimal, structurally valid module fixture straight to disk — real discovery,
* composition, and (where a scenario actually starts the API) real module runtime execution all
* run against it, nothing hand-built. Register/start/stop push markers onto `recorder.events`
* instead of throwing, so both "Doctor/composition never executed this" (an empty recorder) and
* "startApi actually imported, registered, and started this" (a populated recorder) can be proven
* against the exact same fixture. When declared with the `calendar.events.read` permission, the
* module also registers a restricted `GET /events/:eventId` route and a restricted, state-changing
* `POST /events` route against that same permission, so the full HTTP mounting/enforcement chain
* can be exercised live. */
async function createFixtureModule(
modulesDirectory: string,
id: string,
recorder: { expression: string },
manifestOverrides: Record<string, unknown> = {},
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
@@ -227,6 +246,9 @@ async function createFixtureModule(
entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides,
};
const declaresCalendarRead = (manifest.permissions as ReadonlyArray<{ id: string }>).some(
(declaration) => declaration.id === "calendar.events.read",
);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
@@ -241,7 +263,39 @@ async function createFixtureModule(
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
'throw new Error("permission composition and Doctor must never execute a module server entrypoint");',
`export default {
id: ${JSON.stringify(id)},
version: "0.1.0",
register({ http }) {
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
${
declaresCalendarRead
? `http.route({
method: "GET",
path: "/events/:eventId",
permission: "calendar.events.read",
async handler(request, context) {
return Response.json({ eventId: context.params.eventId, principal: context.principal });
},
});
http.route({
method: "POST",
path: "/events",
permission: "calendar.events.read",
async handler(request, context) {
return Response.json({ created: true, principal: context.principal }, { status: 201 });
},
});`
: ""
}
},
start() {
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
},
stop() {
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
},
};`,
),
]);
}
@@ -253,6 +307,10 @@ afterAll(async () => {
for (const directory of trackedModulesDirectories.splice(0)) {
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
}
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
for (const symbol of eventSymbols.splice(0)) {
delete globalValues[symbol];
}
});
const identityBaseUrl = "https://identity.example.test";
@@ -1222,10 +1280,11 @@ describe.sequential("live API — installed module permission end-to-end", () =>
const password = "correct horse battery staple";
const modulePermission = "calendar.events.read";
const absentPermission = "scheduler.jobs.read";
const recorder = createEventRecorder();
it("recognizes a real installed module and passes permission composition in Doctor, without executing its server entrypoint", async () => {
modulesDirectory = await createFixtureModulesDirectory();
await createFixtureModule(modulesDirectory, "calendar", {
await createFixtureModule(modulesDirectory, "calendar", recorder, {
permissions: [
{
baseline: "restricted",
@@ -1257,14 +1316,21 @@ describe.sequential("live API — installed module permission end-to-end", () =>
details: ["2 permissions registered"],
status: "pass",
});
// Doctor and permission composition read only the manifest — the fixture's register/start
// markers must remain entirely unset.
expect(recorder.events).toEqual([]);
});
it("starts a freshly migrated API that discovers the fixture module and signs up its eventual owner", async () => {
it("starts a freshly migrated API that discovers the fixture module, imports and starts its runtime exactly once, and mounts its route", async () => {
database = await createDisposableDatabase("authz_module");
await applyCoreMigrations(database.url);
running = await startMigratedApi(database.url, modulesDirectory);
origin = apiOrigin(running);
// Unlike Doctor above, a successful production startup does deliberately import, register,
// and start the module's runtime — exactly once each.
expect(recorder.events).toEqual(["calendar:register", "calendar:start"]);
const ownerSignUp = await signUp(origin, ownerEmail, password);
ownerUserId = ownerSignUp.body.user.id;
const ownerSignIn = await signIn(origin, ownerEmail, password);
@@ -1316,6 +1382,56 @@ describe.sequential("live API — installed module permission end-to-end", () =>
}
});
it("denies the module's own mounted route to an anonymous caller, then serves it to the newly granted owner with the decoded path parameter and authenticated principal", async () => {
const anonymousResponse = await fetch(`${origin}/api/v1/modules/calendar/events/evt-42`);
expect(anonymousResponse.status).toBe(403);
expect(await anonymousResponse.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
const grantedResponse = await fetch(`${origin}/api/v1/modules/calendar/events/evt-42`, {
headers: { cookie: ownerCookie },
});
expect(grantedResponse.status).toBe(200);
const body = (await grantedResponse.json()) as {
eventId: string;
principal: { kind: string; userId: string };
};
expect(body.eventId).toBe("evt-42");
expect(body.principal).toEqual({ kind: "authenticated", userId: ownerUserId });
});
it("requires a trusted Origin for the module's own state-changing route, exactly like the platform's own mutation routes", async () => {
const noOriginResponse = await fetch(`${origin}/api/v1/modules/calendar/events`, {
body: JSON.stringify({}),
headers: { "content-type": "application/json", cookie: ownerCookie },
method: "POST",
});
expect(noOriginResponse.status).toBe(403);
expect(await noOriginResponse.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
const untrustedResponse = await fetch(`${origin}/api/v1/modules/calendar/events`, {
body: JSON.stringify({}),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: untrustedOrigin,
},
method: "POST",
});
expect(untrustedResponse.status).toBe(403);
const trustedResponse = await fetch(`${origin}/api/v1/modules/calendar/events`, {
body: JSON.stringify({}),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(trustedResponse.status).toBe(201);
expect(await trustedResponse.json()).toMatchObject({ created: true });
});
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
const admin = await openAdministrativeClient(database.url);
try {
@@ -1355,15 +1471,24 @@ describe.sequential("live API — installed module permission end-to-end", () =>
}
});
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission while the historical grant remains listable", async () => {
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission, returns NOT_FOUND for its former route, while the historical grant remains listable", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0);
// Exactly one stop, matching the exactly-one register/start already asserted above.
expect(recorder.events.filter((event) => event === "calendar:stop")).toHaveLength(1);
const emptyModulesDirectory = await createFixtureModulesDirectory();
running = await startMigratedApi(database.url, emptyModulesDirectory);
origin = apiOrigin(running);
// The module's own route is unmounted along with the module itself.
const removedRouteResponse = await fetch(`${origin}/api/v1/modules/calendar/events/evt-42`, {
headers: { cookie: ownerCookie },
});
expect(removedRouteResponse.status).toBe(404);
expect(await removedRouteResponse.json()).toEqual({ code: "NOT_FOUND" });
// The owner's existing session survives the restart (sessions live in the database, not the
// process), so the same cookie proves ordinary identity behavior is unaffected here too.
const listResponse = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
+15
View File
@@ -52,6 +52,7 @@ describe("Ekklesia API Hono application", () => {
it("mounts Better Auth exactly under /api/auth/*", async () => {
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
@@ -67,6 +68,7 @@ describe("Ekklesia API Hono application", () => {
it("does not route requests outside /api/auth/* to Better Auth", async () => {
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
@@ -83,6 +85,7 @@ describe("Ekklesia API Hono application", () => {
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
});
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
@@ -101,6 +104,7 @@ describe("Ekklesia API Hono application", () => {
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")),
});
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
@@ -121,6 +125,7 @@ describe("Ekklesia API Hono application", () => {
.mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")),
});
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
@@ -138,6 +143,7 @@ describe("Ekklesia API Hono application", () => {
handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")),
});
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
@@ -154,6 +160,7 @@ describe("Ekklesia API Hono application", () => {
it("has deterministic 404 behavior for an unknown route", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
@@ -167,6 +174,7 @@ describe("Ekklesia API Hono application", () => {
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
@@ -182,6 +190,7 @@ describe("Ekklesia API Hono application", () => {
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
@@ -196,6 +205,7 @@ describe("Ekklesia API Hono application", () => {
it("supports requests with no Origin header at all (native/server clients)", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
@@ -208,6 +218,7 @@ describe("Ekklesia API Hono application", () => {
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
@@ -227,6 +238,7 @@ describe("Ekklesia API Hono application", () => {
it("never wildcards Access-Control-Allow-Origin", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
@@ -249,6 +261,7 @@ describe("Ekklesia API Hono application", () => {
it("mounts the v1 grants routes under /api/v1/grants", async () => {
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
@@ -274,6 +287,7 @@ describe("Ekklesia API Hono application", () => {
has: () => false,
};
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({
evaluator: { authorize: evaluatorAuthorize },
registry: emptyRegistry,
@@ -290,6 +304,7 @@ describe("Ekklesia API Hono application", () => {
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
const app = createApiApp({
moduleRoutes: Object.freeze([]),
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
+327
View File
@@ -13,6 +13,13 @@ vi.mock("pg", () => ({ Pool: poolConstructor }));
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
// gets the real Hono application; only the one test that deliberately forces an app-construction
// failure overrides this with `mockImplementationOnce`.
vi.mock("../src/app.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/app.js")>();
return { ...actual, createApiApp: vi.fn(actual.createApiApp) };
});
import { parseModuleManifest } from "@ekklesia/contracts";
import {
@@ -21,6 +28,7 @@ import {
ModulePermissionCompositionError,
} from "@ekklesia/core/modules";
import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiApp } from "../src/app.js";
import { createApiRuntimeConfig } from "../src/config.js";
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
@@ -87,6 +95,76 @@ async function createFixtureModule(
]);
}
const eventSymbols: symbol[] = [];
/** Same technique as `core/tests/module-runtime.test.ts`: a dynamically imported module fixture
* cannot close over this test file's local variables, so lifecycle events cross that boundary
* through a `globalThis` symbol instead. Returns the same `events` array the fixture's generated
* source pushes into, so test code can both read it directly and push its own markers (pool
* close, server close) onto the identical array to observe cross-cutting shutdown order. */
function createEventRecorder(): { events: string[]; expression: string } {
const key = `ekklesia.api.lifecycle.test.${eventSymbols.length}.${Date.now()}.${Math.random()}`;
const symbol = Symbol.for(key);
const events: string[] = [];
(globalThis as unknown as Record<PropertyKey, unknown>)[symbol] = events;
eventSymbols.push(symbol);
return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` };
}
interface WorkingFixtureModuleOptions {
/** Declares one `GET /ping` route against `<id>.ping.read` when true. */
readonly withRoute?: boolean;
readonly registerThrows?: boolean;
readonly startThrows?: boolean;
readonly stopThrows?: boolean;
readonly dependencies?: Record<string, unknown>;
}
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
* throw on import; it pushes lifecycle markers onto `recorder.events` and can be told to fail at a
* specific hook, so tests can prove exactly what a successful (or a specifically-failing) module
* runtime startup actually executes and in what order, relative to pool/server construction. */
async function createWorkingFixtureModule(
modulesDirectory: string,
id: string,
recorder: { expression: string },
options: WorkingFixtureModuleOptions = {},
): Promise<void> {
const permissionId = `${id}.ping.read`;
await createFixtureModule(modulesDirectory, id, {
permissions: options.withRoute
? [{ baseline: "public", description: "Ping.", id: permissionId }]
: [],
...(options.dependencies ? { dependencies: options.dependencies } : {}),
});
const routeSource = options.withRoute
? `http.route({
method: "GET",
path: "/ping",
permission: ${JSON.stringify(permissionId)},
handler: async () => new Response("pong", { status: 200 }),
});`
: "";
const serverSource = `export default {
id: ${JSON.stringify(id)},
version: "0.1.0",
register({ http }) {
${recorder.expression}.push(${JSON.stringify(`${id}:register`)});
${routeSource}
${options.registerThrows ? 'throw new Error("register failed");' : ""}
},
start() {
${recorder.expression}.push(${JSON.stringify(`${id}:start`)});
${options.startThrows ? 'throw new Error("start failed");' : ""}
},
stop() {
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
${options.stopThrows ? 'throw new Error("stop failed");' : ""}
},
};`;
await writeFile(path.join(modulesDirectory, id, "dist/server/index.js"), serverSource);
}
/** A minimal, in-memory `DiscoveredModule` double built from a real, validated manifest — used
* only to reach `composePlatformPermissionRegistry`'s own duplicate-detection failure, which is
* unreachable through real filesystem discovery (module ids are unique and every permission id is
@@ -518,3 +596,252 @@ describe("Ekklesia API module permission composition at startup", () => {
expect(poolConstructor).not.toHaveBeenCalled();
});
});
describe("Ekklesia API module runtime startup and shutdown", () => {
afterEach(async () => {
const globalValues = globalThis as unknown as Record<PropertyKey, unknown>;
for (const symbol of eventSymbols.splice(0)) {
delete globalValues[symbol];
}
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
beforeEach(() => {
poolConstructor.mockReset();
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
vi.mocked(createApiApp).mockClear();
});
function mockPoolAndAuth(): { poolEnd: ReturnType<typeof vi.fn> } {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
return { poolEnd };
}
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "alpha", recorder, { withRoute: true });
await createWorkingFixtureModule(modulesDirectory, "beta", recorder, { withRoute: true });
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
expect(recorder.events).toEqual([
"alpha:register",
"beta:register",
"alpha:start",
"beta:start",
]);
// Hono records one internal route entry per handler in a route's middleware chain, not one
// per logical route, so entries for the same method/path are deduplicated (preserving first-
// seen order) before asserting mounting order.
const moduleRoutePaths = [
...new Set(
running.app.routes
.filter((route) => route.path.startsWith("/api/v1/modules/"))
.map((route) => `${route.method} ${route.path}`),
),
];
expect(moduleRoutePaths).toEqual([
"GET /api/v1/modules/alpha/ping",
"GET /api/v1/modules/beta/ping",
]);
await running.shutdown();
});
it("closes the pool exactly once and binds no server when a module's entrypoint fails to load", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
await createFixtureModule(modulesDirectory, "broken-export");
await writeFile(
path.join(modulesDirectory, "broken-export", "dist/server/index.js"),
"export default { thisIsNotAValidRuntimeDefinition: true };",
);
const { poolEnd } = mockPoolAndAuth();
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_RUNTIME_EXPORT_INVALID" })],
});
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("closes the pool exactly once and binds no server when a module's register hook fails", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "register-fails", recorder, {
registerThrows: true,
});
const { poolEnd } = mockPoolAndAuth();
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_REGISTER_FAILED" })],
});
expect(recorder.events).toEqual(["register-fails:register"]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("closes the pool exactly once and binds no server when a module's start hook fails, preserving Core's rollback of already-started modules", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
dependencies: { modules: { required: { one: "^0.1.0" } } },
startThrows: true,
});
const { poolEnd } = mockPoolAndAuth();
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_START_FAILED" })],
});
expect(recorder.events).toEqual([
"one:register",
"two:register",
"one:start",
"two:start",
"one:stop",
]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("stops the module runtime and closes the pool, binding no server, when app construction fails", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "mounted", recorder);
const { poolEnd } = mockPoolAndAuth();
vi.mocked(createApiApp).mockImplementationOnce(() => {
throw new Error("forced app construction failure");
});
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toThrow("forced app construction failure");
expect(recorder.events).toEqual(["mounted:register", "mounted:start", "mounted:stop"]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("stops the module runtime and closes the pool when the server fails to bind", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "mounted", recorder);
const { poolEnd } = mockPoolAndAuth();
mockFailingServe(new Error("EADDRINUSE"));
await expect(
startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }),
).rejects.toThrow();
expect(recorder.events).toEqual(["mounted:register", "mounted:start", "mounted:stop"]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("drains the HTTP server, then stops the module runtime, then closes the pool, in that exact order", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "ordered", recorder);
poolConstructor.mockImplementation(function fakePool() {
return {
end: vi.fn(async () => {
recorder.events.push("pool:end");
}),
on: vi.fn(),
};
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { server } = mockSuccessfulServe();
const originalClose = server.close;
server.close = vi.fn((callback?: () => void) => {
recorder.events.push("server:close");
return originalClose(callback);
});
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
recorder.events.length = 0;
await running.shutdown();
expect(recorder.events).toEqual(["server:close", "ordered:stop", "pool:end"]);
});
it("still runs pool cleanup and lets the shutdown rejection surface when the module runtime fails to stop", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "stop-fails", recorder, {
stopThrows: true,
});
const { poolEnd } = mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
await expect(running.shutdown()).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })],
});
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("keeps a direct discoveredModulesOverride driving both permission composition and module runtime startup", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder, { withRoute: true });
const discovered = await (await import("@ekklesia/core/modules")).discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { discoveredModulesOverride: discovered });
expect(recorder.events).toEqual(["calendar:register", "calendar:start"]);
// Hono records one internal route entry per handler in a route's middleware chain, not one
// per logical route, so entries for the same method/path are deduplicated (preserving first-
// seen order) before asserting mounting order.
const moduleRoutePaths = [
...new Set(
running.app.routes
.filter((route) => route.path.startsWith("/api/v1/modules/"))
.map((route) => `${route.method} ${route.path}`),
),
];
expect(moduleRoutePaths).toEqual(["GET /api/v1/modules/calendar/ping"]);
await running.shutdown();
});
it("performs no filesystem I/O, module import, pool construction, Better Auth construction, or server binding merely by importing the module-route adapter", async () => {
const module = await import("../src/module-http-routes.js");
expect(typeof module.mountModuleHttpRoutes).toBe("function");
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
});
});
+936
View File
@@ -0,0 +1,936 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type ModuleRouteHandler,
type ModuleRouteHandlerContext,
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 { describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { 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([]),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
};
}
function moduleRoute(fields: RegisteredModuleHttpRoute): RegisteredModuleHttpRoute {
return Object.freeze({ ...fields });
}
function buildModuleApp(options: {
readonly evaluator?: AuthorizationEvaluator;
readonly registry: PermissionRegistry;
readonly routes: readonly RegisteredModuleHttpRoute[];
readonly runtime?: EkklesiaIdentityRuntime;
readonly trustedOrigins?: readonly string[];
}) {
const evaluator = options.evaluator ?? {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
};
const runtime = options.runtime ?? fakeRuntime();
const app = createApiApp({
authorization: { evaluator, registry: options.registry, store: fakeStore() },
moduleRoutes: options.routes,
runtime,
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
});
return { app, evaluator, runtime };
}
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 { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
method: "POST",
moduleId: "calendar",
path: "/events/:eventId/rsvp",
permission: readPermission,
}),
],
runtime,
});
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");
expect(capturedContext).toEqual({
moduleId: "calendar",
params: { eventId: "evt-1" },
principal: { kind: "authenticated", userId: "user_1" },
});
expect(Object.isFrozen(capturedContext)).toBe(true);
expect(Object.isFrozen(capturedContext?.params)).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 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 } = 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();
});
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 } = 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();
});
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("never invokes authorization or the handler 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 } = 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();
});
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");
});
});
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 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);
});
});