Files
Ekklesia/apps/api/tests/module-http-routes.test.ts
T
ekklesiaandClaude Sonnet 5 dfaf324a65 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]>
2026-07-24 19:16:20 -05:00

937 lines
32 KiB
TypeScript

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);
});
});