diff --git a/README.md b/README.md index fab23bb..2256c2b 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,25 @@ enforceable and grantable through the exact same `requirePermission` middleware claimable by a module. `pnpm doctor` and `pnpm modules:validate` both validate permission composition. Installing, removing, or editing a module currently requires an API restart; removing one makes its permission unavailable to new checks and new grants without deleting or revoking any -historical grant or audit row. Module HTTP route registration and relationship-derived grant -creation remain later work. See +historical grant or audit row. See [Authorization](docs/development/authorization.md#module-permission-composition), [Module development](docs/module-development/README.md), and [ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md). +Phase 5B mounts a module's own HTTP routes in production. A module registers framework-neutral +Fetch (`Request`/`Response`) route handlers during its existing `register` hook; Core collects them +into an immutable snapshot alongside its capabilities, and `apps/api` discovers modules exactly +once, starts the module runtime from that same discovered inventory, and mounts its published +routes under `/api/v1/modules/` — after every module has registered and started +successfully, so no request can reach a module before startup completes. Every route is enforced +through the identical `requirePermission` middleware and composed permission registry Core's own +routes use (a module can never supply its own baseline), state-changing methods require a trusted +`Origin`, and a handler failure or non-`Response` return becomes a fixed, sanitized `500` — never a +raw error, stack, path, or permission id. Relationship-derived grant creation remains later work. +See [API](docs/development/api.md#module-http-routes), +[Module development](docs/module-development/README.md#http-routes), and +[ADR 0014](docs/architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred). + ## Quick start Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index fce51be..3207bd0 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,4 +1,4 @@ -import { isAuthenticatedPrincipal } from "@ekklesia/contracts"; +import { isAuthenticatedPrincipal, type RegisteredModuleHttpRoute } from "@ekklesia/contracts"; import type { AuthorizationEvaluator, AuthorizationStore, @@ -9,23 +9,30 @@ import { Hono } from "hono"; import type { Context } from "hono"; import { cors } from "hono/cors"; import { createGrantsRoutes } from "./grants-routes.js"; +import { mountModuleHttpRoutes } from "./module-http-routes.js"; export interface CreateApiAppAuthorizationOptions { readonly evaluator: AuthorizationEvaluator; /** The complete platform permission registry (Core's own declarations plus every installed * module's) — always the caller-composed registry `startApi` produces via - * `composeStartupPermissionRegistry`, never a Core-only fallback constructed here. */ + * `prepareStartupModuleInventory`, never a Core-only fallback constructed here. */ readonly registry: PermissionRegistry; readonly store: AuthorizationStore; } export interface CreateApiAppOptions { readonly authorization: CreateApiAppAuthorizationOptions; + /** The started module runtime's published, immutable route snapshot — always the caller-started + * runtime's exact `routes` (`startDiscoveredModuleRuntime`, `apps/api/src/server.ts`), mounted in + * publication order under `/api/v1/modules/`. Required rather than defaulted so a + * caller not exercising modules must say so explicitly (an empty frozen array), never rely on an + * implicit fallback. */ + readonly moduleRoutes: readonly RegisteredModuleHttpRoute[]; readonly runtime: EkklesiaIdentityRuntime; /** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an * exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set - * `requireTrustedOrigin` checks for grant-mutation routes — there is no second, competing origin - * configuration. */ + * `requireTrustedOrigin` checks for grant- and module-route-mutation routes — there is no second, + * competing origin configuration. */ readonly trustedOrigins: readonly string[]; } @@ -36,9 +43,14 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) { } /** - * Pure Hono application factory: no I/O, no environment reads, no process side effects. Suitable - * for unit and integration tests via `app.fetch` directly, with a fully injected identity runtime - * and authorization evaluator/store standing in for real Better Auth/PostgreSQL where appropriate. + * Pure Hono application factory: no I/O, no environment reads, no process side effects, and it + * never invokes a module route handler while composing routes. Suitable for unit and integration + * tests via `app.fetch` directly, with a fully injected identity runtime and authorization + * evaluator/store standing in for real Better Auth/PostgreSQL where appropriate. Synchronously + * throws `ModuleRouteConfigurationError` (`./module-http-routes.js`) if `moduleRoutes` and + * `authorization.registry` are inconsistent — a route naming a permission the registry does not + * register, which should never happen since both derive from the same discovered inventory, but is + * checked explicitly rather than silently mounting an unenforceable route. */ export function createApiApp(options: CreateApiAppOptions): Hono { const { runtime } = options; @@ -48,7 +60,7 @@ export function createApiApp(options: CreateApiAppOptions): Hono { const corsMiddleware = cors({ allowHeaders: ["Content-Type"], - allowMethods: ["GET", "POST", "OPTIONS"], + allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], credentials: true, maxAge: 600, origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined), @@ -91,6 +103,13 @@ export function createApiApp(options: CreateApiAppOptions): Hono { }), ); + mountModuleHttpRoutes(app, options.moduleRoutes, { + evaluator: options.authorization.evaluator, + registry: options.authorization.registry, + runtime, + trustedOrigins: allowedOrigins, + }); + app.notFound((c) => { c.header("Cache-Control", "no-store"); return c.json({ code: "NOT_FOUND" }, 404); diff --git a/apps/api/src/module-http-routes.ts b/apps/api/src/module-http-routes.ts new file mode 100644 index 0000000..a73a165 --- /dev/null +++ b/apps/api/src/module-http-routes.ts @@ -0,0 +1,153 @@ +import type { + ModuleId, + ModuleRouteHandlerContext, + RegisteredModuleHttpRoute, +} from "@ekklesia/contracts"; +import type { Context, Hono } from "hono"; +import { + type AuthorizationMiddlewareOptions, + getAuthorizedPrincipal, + requirePermission, + requireTrustedOrigin, +} from "./authorization-middleware.js"; + +const MODULE_ROUTE_PREFIX = "/api/v1/modules"; +const STATE_CHANGING_METHODS: ReadonlySet = new Set(["DELETE", "PATCH", "POST", "PUT"]); +const STRIPPED_CORS_RESPONSE_HEADERS = [ + "access-control-allow-credentials", + "access-control-allow-headers", + "access-control-allow-methods", + "access-control-allow-origin", + "access-control-expose-headers", + "access-control-max-age", +]; + +export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions { + readonly trustedOrigins: ReadonlySet; +} + +/** Never carries the offending permission id, route path, module id, or any other configuration + * detail — a forged or inconsistent route snapshot is a startup-time composition bug, not + * something to describe in detail to whatever eventually logs it. */ +export class ModuleRouteConfigurationError extends Error { + constructor() { + super("A published module HTTP route references a permission that is not registered."); + this.name = "ModuleRouteConfigurationError"; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +/** "/" is the module's own root and maps to the bare module prefix, never a required trailing + * slash; every other local path appends directly. Root and non-root therefore never collide, and + * `/api/v1/modules//` (the root path with a trailing slash) is never produced here. */ +function moduleRoutePath(moduleId: ModuleId, routePath: string): string { + return routePath === "/" + ? `${MODULE_ROUTE_PREFIX}/${moduleId}` + : `${MODULE_ROUTE_PREFIX}/${moduleId}${routePath}`; +} + +function sanitizedModuleHandlerFailure(c: Context): Response { + c.header("Cache-Control", "no-store"); + return c.json({ code: "MODULE_HANDLER_FAILED" }, 500); +} + +/** + * The platform owns CORS and caching for every module route: strips any CORS-control header a + * handler set (defense in depth — the outer `cors()` middleware already computes the authoritative + * values for the actual response) and forces `Cache-Control: no-store` plus a `Vary` that always + * includes `Origin` and `Cookie`, since even a public-baseline handler receives an authorized + * principal and may personalize its response. A separate cache-policy contract is future work; + * this is the conservative default until then. A module's own legitimate `Vary` values (if any) + * are preserved alongside the forced ones. + */ +function sanitizeModuleResponse(response: Response): Response { + const headers = new Headers(response.headers); + for (const headerName of STRIPPED_CORS_RESPONSE_HEADERS) { + headers.delete(headerName); + } + headers.set("Cache-Control", "no-store"); + const varyValues = new Set( + (headers.get("Vary") ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ); + varyValues.add("Origin"); + varyValues.add("Cookie"); + headers.set("Vary", [...varyValues].join(", ")); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + +/** + * Mounts `StartedModuleRuntime`'s published, immutable route snapshot into `app`, in publication + * order — never sorted or mutated, and never invoking a handler while mounting. Every route is + * protected by the exact same `requirePermission` middleware Core's own routes use, with + * `route.permission` as the only permission id ever consulted and its baseline coming exclusively + * from `options.registry`; a state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`) additionally + * requires `requireTrustedOrigin` before authorization ever runs. `GET` inherits Hono's own + * implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the + * response body afterward — there is no separate `HEAD` registration or permission path. + * `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a + * frozen context — never a Hono `Context`, database, store, or configuration. + * + * Because the routes and registry both derive from the same discovered inventory + * (`prepareStartupModuleInventory`), every registered route permission should already be + * registered; this loop still checks that invariant explicitly and throws + * `ModuleRouteConfigurationError` before mounting anything further if it ever does not hold, so a + * forged or hand-built route snapshot fails app construction instead of silently mounting an + * unenforceable route. `requirePermission`'s own per-request registry lookup remains the live, + * defense-in-depth enforcement regardless. + */ +export function mountModuleHttpRoutes( + app: Hono, + routes: readonly RegisteredModuleHttpRoute[], + options: ModuleHttpRoutesOptions, +): void { + const middlewareOptions: AuthorizationMiddlewareOptions = { + evaluator: options.evaluator, + registry: options.registry, + runtime: options.runtime, + }; + const requireOrigin = requireTrustedOrigin(options.trustedOrigins); + + for (const route of routes) { + if (!options.registry.has(route.permission)) { + throw new ModuleRouteConfigurationError(); + } + + const requireRoutePermission = requirePermission(middlewareOptions, route.permission); + const path = moduleRoutePath(route.moduleId, route.path); + const finalHandler = async (c: Context): Promise => { + const context: ModuleRouteHandlerContext = Object.freeze({ + moduleId: route.moduleId, + params: Object.freeze({ ...c.req.param() }), + principal: getAuthorizedPrincipal(c), + }); + + let response: Response; + try { + response = await route.handler(c.req.raw, context); + } catch { + return sanitizedModuleHandlerFailure(c); + } + if (!(response instanceof Response)) { + return sanitizedModuleHandlerFailure(c); + } + return sanitizeModuleResponse(response); + }; + + // Hono's `on()` overloads resolve by literal call arity, not by the runtime length of a spread + // array — each branch below is therefore a distinct, fixed-arity call rather than one call + // spreading a variable-length handlers array. + if (STATE_CHANGING_METHODS.has(route.method)) { + app.on(route.method, path, requireOrigin, requireRoutePermission, finalHandler); + } else { + app.on(route.method, path, requireRoutePermission, finalHandler); + } + } +} diff --git a/apps/api/src/platform-permissions.ts b/apps/api/src/platform-permissions.ts index 707a38e..dd57ca8 100644 --- a/apps/api/src/platform-permissions.ts +++ b/apps/api/src/platform-permissions.ts @@ -54,7 +54,7 @@ async function readPackageVersion(packagePath: string): Promise { return packageData.version; } -export interface ComposeStartupPermissionRegistryOptions { +export interface PrepareStartupModuleInventoryOptions { /** Test-only escape hatch: overrides the directory module discovery scans for installed * modules. Never set by the executable entry point — production always discovers the real * repository's `modules/` directory. Platform and Module SDK versions are always read from the @@ -62,7 +62,8 @@ export interface ComposeStartupPermissionRegistryOptions { readonly modulesDirectoryOverride?: string; /** Test-only escape hatch: supplies an already-discovered module list directly, skipping the * filesystem discovery step entirely, while still running the real `composePlatformPermissionRegistry` - * on it. Exists because a genuine exact permission-id collision between two modules is + * on it — and, if startup progresses that far, driving module runtime startup with this exact + * list too. Exists because a genuine exact permission-id collision between two modules is * unreachable through real discovery: module ids are unique (discovery itself rejects a * duplicate) and every permission id is namespaced by its own module's id, so no two * really-discovered modules can ever contribute the same permission id. Proving @@ -76,18 +77,35 @@ export interface ComposeStartupPermissionRegistryOptions { readonly discoveredModulesOverride?: readonly DiscoveredModule[]; } +export type ComposeStartupPermissionRegistryOptions = PrepareStartupModuleInventoryOptions; + +export interface StartupModuleInventory { + /** The exact discovered-module list — in discovery order — permission composition ran against. + * Production startup (`apps/api/src/server.ts`) passes this same list to + * `startDiscoveredModuleRuntime` rather than letting the module runtime rediscover modules a + * second time. */ + readonly modules: readonly DiscoveredModule[]; + readonly permissionRegistry: PermissionRegistry; +} + /** - * Discovers every installed module's manifest and composes the one immutable platform permission - * registry (Core's own declarations plus every module's) — the production source of truth - * `apps/api` enforces every request against. Only manifests are read; no module server entrypoint - * is imported or executed, and no module lifecycle hook runs. Read the platform and Module SDK - * versions only when this function actually executes, never at import time. + * Discovers every installed module's manifest exactly once and composes the one immutable platform + * permission registry (Core's own declarations plus every module's) from that exact discovered + * list — the production source of truth `apps/api` enforces every request against, and the exact + * module inventory `startDiscoveredModuleRuntime` starts from. Only manifests are read here; no + * module server entrypoint is imported or executed, and no module lifecycle hook runs. Read the + * platform and Module SDK versions only when this function actually executes, never at import + * time. */ -export async function composeStartupPermissionRegistry( - options: ComposeStartupPermissionRegistryOptions = {}, -): Promise { +export async function prepareStartupModuleInventory( + options: PrepareStartupModuleInventoryOptions = {}, +): Promise { if (options.discoveredModulesOverride) { - return composePlatformPermissionRegistry(options.discoveredModulesOverride); + const modules = options.discoveredModulesOverride; + return Object.freeze({ + modules, + permissionRegistry: composePlatformPermissionRegistry(modules), + }); } const repositoryRoot = resolveRepositoryRoot(); @@ -102,5 +120,24 @@ export async function composeStartupPermissionRegistry( platformVersion, }); - return composePlatformPermissionRegistry(modules); + return Object.freeze({ + modules, + permissionRegistry: composePlatformPermissionRegistry(modules), + }); +} + +/** + * Compatible wrapper for callers — currently only existing tests — that need this package's + * composed registry without the discovered module list itself. (Doctor and `modules:validate` are + * Core-owned and never call into `apps/api` at all; they call Core's own + * `composePlatformPermissionRegistry` directly over their own already-discovered module list.) + * Production `startApi` does not call this wrapper: it calls `prepareStartupModuleInventory` + * directly so it can hand the exact same discovered list to `startDiscoveredModuleRuntime`, rather + * than discarding it here and rediscovering modules a second time for runtime startup. + */ +export async function composeStartupPermissionRegistry( + options: ComposeStartupPermissionRegistryOptions = {}, +): Promise { + const inventory = await prepareStartupModuleInventory(options); + return inventory.permissionRegistry; } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 754df1b..59ef64e 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -5,13 +5,17 @@ import { createAuthorizationStore, } from "@ekklesia/core/authorization"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; -import type { DiscoveredModule } from "@ekklesia/core/modules"; +import { + type DiscoveredModule, + type StartedModuleRuntime, + startDiscoveredModuleRuntime, +} from "@ekklesia/core/modules"; import { serve, type ServerType } from "@hono/node-server"; import type { Hono } from "hono"; import { createApiApp } from "./app.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; -import { composeStartupPermissionRegistry } from "./platform-permissions.js"; +import { prepareStartupModuleInventory } from "./platform-permissions.js"; const connectionTimeoutMs = 10_000; const operationTimeoutMs = 10_000; @@ -26,12 +30,13 @@ export interface StartApiOptions { /** Test-only escape hatch: never set by the executable entry point. See * `IdentityRateLimitOverride` for why production must leave this unset. */ readonly rateLimitOverride?: IdentityRateLimitOverride; - /** Test-only escape hatch: overrides the directory permission-composition module discovery - * scans. Never set by the executable entry point. See `composeStartupPermissionRegistry`. */ + /** Test-only escape hatch: overrides the directory module discovery scans. Never set by the + * executable entry point. See `prepareStartupModuleInventory`. */ readonly modulesDirectoryOverride?: string; - /** Test-only escape hatch: supplies an already-discovered module list directly to permission - * composition, skipping filesystem discovery entirely. Never set by the executable entry - * point. See `composeStartupPermissionRegistry`. */ + /** Test-only escape hatch: supplies an already-discovered module list directly, skipping + * filesystem discovery entirely — this exact list drives both permission composition and module + * runtime startup. Never set by the executable entry point. See + * `prepareStartupModuleInventory`. */ readonly discoveredModulesOverride?: readonly DiscoveredModule[]; } @@ -44,6 +49,17 @@ async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise { } } +/** Same swallow-on-cleanup-path rule as `safeCloseDatabase`, used only while unwinding a startup + * failure that already occurred after the module runtime started — never during normal shutdown, + * where a module stop failure remains an observable result instead (see `startApi`'s `shutdown`). */ +async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promise { + try { + await moduleRuntime.stop(); + } catch { + // See safeCloseDatabase: never mask the startup failure already propagating. + } +} + function bindServer(app: Hono, host: string, port: number): Promise { return new Promise((resolve, reject) => { let settled = false; @@ -63,25 +79,36 @@ function bindServer(app: Hono, host: string, port: number): Promise } /** - * Explicit runtime composition, in a fixed order: discover every installed module's manifest and - * compose the one immutable platform permission registry from Core's own declarations plus every - * module's (`./platform-permissions.js`); only then construct the one shared PostgreSQL pool - * (identity and authorization each get a typed Drizzle view over it — see `./database.js`), the - * Core identity runtime, the Core authorization store and evaluator, and the Hono application; - * finally start the Node HTTP server. Permission composition happens before any pool, Better - * Auth instance, or HTTP server exists, so a discovery or composition failure — an invalid + * Explicit runtime composition, in a fixed order: + * + * 1. Discover every installed module's manifest exactly once and compose the one immutable + * platform permission registry from Core's own declarations plus every module's + * (`prepareStartupModuleInventory`, `./platform-permissions.js`). + * 2. Construct the one shared PostgreSQL pool (identity and authorization each get a typed + * Drizzle view over it — see `./database.js`). + * 3. Construct the Core identity runtime, authorization store, and evaluator. + * 4. Start the module runtime from that exact same discovered inventory + * (`startDiscoveredModuleRuntime`) — never rediscovering modules a second time. + * 5. Create the Hono application, mounting the started module runtime's published routes. + * 6. Bind the Node HTTP server. + * + * Discovery and permission composition happen before any pool, Better Auth instance, module + * entrypoint import, or HTTP server exists, so a discovery or composition failure — an invalid * manifest, a dependency-graph problem, a module claiming the reserved `core.*` permission * namespace, or a duplicate permission declaration — leaves nothing to close: it propagates - * directly, and startup never falls back to a Core-only registry. Returns a handle with an - * idempotent `shutdown()` so both the executable entry point and tests can stop the server and - * close the shared pool deterministically. Never applies migrations or generates schema — the - * database must already be migrated through `pnpm migrations:apply`. + * directly, and startup never falls back to a Core-only registry. Core infrastructure (the pool, + * identity, authorization) exists before any executable module lifecycle code runs, and every + * module route is published only after the entire module runtime has registered and started + * successfully, so no request can ever reach a module before every module has started. Returns a + * handle with an idempotent `shutdown()` so both the executable entry point and tests can stop the + * server, the module runtime, and the shared pool deterministically. Never applies migrations or + * generates schema — the database must already be migrated through `pnpm migrations:apply`. */ export async function startApi( config: ApiRuntimeConfig, options: StartApiOptions = {}, ): Promise { - const permissionRegistry = await composeStartupPermissionRegistry({ + const inventory = await prepareStartupModuleInventory({ ...(options.modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride: options.modulesDirectoryOverride }), @@ -114,16 +141,40 @@ export async function startApi( platformOwners: authorizationStore, }); - const app = createApiApp({ - authorization: { evaluator, registry: permissionRegistry, store: authorizationStore }, - runtime, - trustedOrigins: config.identity.trustedOrigins, - }); + // Module load/register/start failures use Core's own existing rollback (already-started + // modules are stopped, in reverse order, before this rejects) — there is no separate runtime + // handle to stop here, only the pool this failure leaves open. + let moduleRuntime: StartedModuleRuntime; + try { + moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules); + } catch (error) { + await safeCloseDatabase(databaseHandle); + throw error; + } + + let app: Hono; + try { + app = createApiApp({ + authorization: { + evaluator, + registry: inventory.permissionRegistry, + store: authorizationStore, + }, + moduleRoutes: moduleRuntime.routes, + runtime, + trustedOrigins: config.identity.trustedOrigins, + }); + } catch (error) { + await safeStopModuleRuntime(moduleRuntime); + await safeCloseDatabase(databaseHandle); + throw error; + } let server: ServerType; try { server = await bindServer(app, config.host, config.port); } catch (error) { + await safeStopModuleRuntime(moduleRuntime); await safeCloseDatabase(databaseHandle); throw error; } @@ -133,7 +184,12 @@ export async function startApi( // would previously get an already-resolved `undefined` immediately, before the listener had // actually drained or the pool had actually closed. Storing the promise itself means every // caller — concurrent or sequential — awaits the exact same drain-then-close operation and - // observes it complete (or fail) exactly once. + // observes it complete (or fail) exactly once. Order matters: the HTTP listener is drained and + // closed first so no new or in-flight request can reach a module handler while the module + // runtime is stopping, then the module runtime stops, then the shared pool closes. Unlike the + // startup-cleanup paths above, a module-stop failure here is not swallowed — it is a genuine + // shutdown result a caller should observe — but database cleanup still always runs first, so a + // stop failure never leaves the pool open. let shutdownPromise: Promise | undefined; const shutdown = (): Promise => { if (!shutdownPromise) { @@ -141,7 +197,16 @@ export async function startApi( await new Promise((resolve) => { server.close(() => resolve()); }); + let moduleStopError: unknown; + try { + await moduleRuntime.stop(); + } catch (error) { + moduleStopError = error; + } await safeCloseDatabase(databaseHandle); + if (moduleStopError) { + throw moduleStopError; + } })(); } return shutdownPromise; diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 902ae1b..c25af5a 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -200,6 +200,7 @@ async function applyCoreMigrations(url: string): Promise { } const trackedModulesDirectories: string[] = []; +const eventSymbols: symbol[] = []; async function createFixtureModulesDirectory(): Promise { const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-installed-modules-")); @@ -207,13 +208,31 @@ async function createFixtureModulesDirectory(): Promise { 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)[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 = {}, ): Promise { 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; + 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}`, { diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index a7d9e27..a2bbe26 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -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], diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index c58cdae..b404456 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -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(); + 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)[symbol] = events; + eventSymbols.push(symbol); + return { events, expression: `globalThis[Symbol.for(${JSON.stringify(key)})]` }; +} + +interface WorkingFixtureModuleOptions { + /** Declares one `GET /ping` route against `.ping.read` when true. */ + readonly withRoute?: boolean; + readonly registerThrows?: boolean; + readonly startThrows?: boolean; + readonly stopThrows?: boolean; + readonly dependencies?: Record; +} + +/** 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 { + 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; + 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 } { + 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(); + }); +}); diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts new file mode 100644 index 0000000..ca38c82 --- /dev/null +++ b/apps/api/tests/module-http-routes.test.ts @@ -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 { + 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(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(async () => Response.json({ kind: "static" })); + const nestedHandler = vi.fn(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(async () => Response.json({ kind: "static" })); + const paramHandler = vi.fn(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(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(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(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(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(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(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(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(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(() => 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(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( + 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( + 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( + 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( + 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( + 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(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(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); + }); +}); diff --git a/docs/development/api.md b/docs/development/api.md index 36b4237..9010016 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -57,6 +57,7 @@ No real values are shown here — configure per environment and never commit the | `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). | | `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. | | `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). | +| `/api/v1/modules//*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). | Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a deterministic `404 {"code":"NOT_FOUND"}`. @@ -206,17 +207,74 @@ trusted-proxy IP policy is configured, Better Auth may fall back to a single sha rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade abuse-control design. -## Module permission discovery at startup +## Module discovery, permission composition, and module runtime startup -Before any database pool, Better Auth instance, or HTTP server exists, `startApi()` discovers -every installed module's manifest and composes the one immutable platform permission registry -(Core's own declarations plus every module's) that the rest of startup — and every subsequent -request — enforces against. A discovery or composition failure (an invalid manifest, a module -claiming the reserved `core.*` permission namespace, a duplicate permission declaration, ...) fails -startup outright, before there is anything to close; startup never falls back to a Core-only -registry. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement) -for the full flow, and [Module development](module-development/README.md) for the manifest shape -that produces these declarations. +`startApi()` composes production in one fixed order: + +1. Discover every installed module's manifest exactly once and compose the one immutable platform + permission registry (Core's own declarations plus every module's) — `prepareStartupModuleInventory`, + `apps/api/src/platform-permissions.ts`. +2. Construct the one shared PostgreSQL pool. +3. Construct the Core identity runtime, authorization store, and evaluator. +4. Start the module runtime from that exact same discovered inventory + (`startDiscoveredModuleRuntime`) — modules are never rediscovered for this step. +5. Create the Hono application, mounting the started module runtime's published routes. +6. Bind the HTTP server. + +Discovery and permission composition happen before any pool, Better Auth instance, module +entrypoint import, or HTTP server exists, so a discovery or composition failure (an invalid +manifest, a module claiming the reserved `core.*` permission namespace, a duplicate permission +declaration, ...) fails startup outright, before there is anything to close; startup never falls +back to a Core-only registry. Core infrastructure exists before any executable module lifecycle +code runs, and every module route is published only after the *entire* module runtime has +registered and started successfully, so no request can ever reach a module before every module has +started. See [Authorization](authorization.md#api-startup-discovery-composition-and-enforcement) +for the permission-composition detail, and [Module development](module-development/README.md) for +the manifest shape that produces these declarations. + +Unlike discovery/composition/Doctor/`modules:validate` (which only ever read manifests), a +successful production startup does import each module's compiled server entrypoint and run its +`register`/`start` hooks — this is the one place in the platform that deliberately executes module +code, and only after every other startup step already listed has succeeded. + +## Module HTTP routes + +An installed module's routes (declared via `http.route(...)` in its `register` hook — see +[Module development](module-development/README.md#http-routes) for the module-authoring contract) +are mounted under `/api/v1/modules/`, in the module runtime's published order, by +`apps/api/src/module-http-routes.ts`. Mounting never invokes a handler. + +- **Enforcement.** Every route runs through the identical `requirePermission` middleware Core's own + routes use, with the route's own declared permission id as the only permission ever consulted — + its trusted baseline always comes from the composed platform registry, never from the route or + the request. A `public`, `authenticated`, and `restricted` module permission are enforced through + the exact same path; a public-baseline route still resolves and receives an authorized principal. +- **Trusted origin.** `POST`, `PUT`, `PATCH`, and `DELETE` module routes additionally require an + exact trusted `Origin` (see [Origin, CORS, and cookies](#origin-cors-and-cookies)) before + authorization ever runs, exactly like the existing grants-mutation routes. `GET` does not require + one. `OPTIONS` remains entirely platform-owned: a module cannot register it, and a CORS preflight + never reaches authorization or a handler. `GET` inherits Hono's own implicit `HEAD` handling + (same permission check, response body stripped); a module cannot separately author `HEAD`. +- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and + body all behave normally, and the full query string remains available) plus a frozen context of + exactly `moduleId`, the authorized `EkklesiaPrincipal`, and frozen, Hono-decoded path parameters + (an empty frozen object for a route with none) — never a Hono `Context`, a database connection, + the authorization store, storage, jobs, configuration, or secrets. +- **Handler failure.** A handler that throws, rejects, or returns anything other than a `Response` + becomes a fixed `500 {"code":"MODULE_HANDLER_FAILED"}` with `Cache-Control: no-store` — never the + caught value, a stack, the route's path or permission id, or the module id. A `Response` a + handler deliberately returns — including a 4xx/5xx one — passes through unchanged. +- **CORS and caching.** The platform remains the sole owner of CORS for module routes exactly like + every other `/api/v1/*` route (see [Origin, CORS, and cookies](#origin-cors-and-cookies), now + additionally advertising `PUT`/`PATCH`/`DELETE`); any CORS-control header a handler sets on its + own response is stripped before the response leaves the process. Every module response is forced + to `Cache-Control: no-store` and a `Vary` that always includes `Origin` and `Cookie` (a handler's + own legitimate `Vary` values are preserved alongside those) — deliberately conservative until a + dedicated cache-policy contract exists, since even a public-baseline handler receives an + authorized principal and may personalize its response. +- **Static snapshot.** Routes are part of the same static startup snapshot as the permission + registry: installing, removing, or editing a module's routes takes effect only on the next API + restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`. ## Database connection limits @@ -239,17 +297,25 @@ not two. `startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` memoizes one shared promise the first time it is called: every caller, concurrent or sequential, awaits the -exact same listener-drain-then-pool-close operation, and that operation runs exactly once no matter -how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and -lets in-flight requests finish, then closes the one shared PostgreSQL pool (identity and -authorization both read from it — see -[Authorization](authorization.md#shared-database-composition)). The executable entry point's -`SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the -same function production composition uses, but independently testable with a fake `running`/`exit` -so a test can trigger "a signal arrived" without sending a real process signal or invoking the real -`process.exit`. A startup failure after the database pool was already created (identity -construction failure, or the server failing to bind its listen address) always closes that pool -before the failure propagates. +exact same operation, and that operation runs exactly once no matter how many times `shutdown()` +is called. In order: it stops the HTTP listener from accepting new connections and lets in-flight +requests finish; then stops the module runtime (so no module resource stops while a route handler +might still be in flight); then closes the one shared PostgreSQL pool (identity and authorization +both read from it — see [Authorization](authorization.md#shared-database-composition)). Unlike a +startup-failure cleanup path (below), a module-stop failure here is not swallowed — it is a +genuine result a caller should observe — but pool cleanup always still runs first, so a stop +failure never leaves the pool open. The executable entry point's `SIGINT`/`SIGTERM` wiring is +extracted into `installShutdownSignalHandlers(running, exit)` — the same function production +composition uses, but independently testable with a fake `running`/`exit` so a test can trigger "a +signal arrived" without sending a real process signal or invoking the real `process.exit`. + +A startup failure after the database pool was already created always closes that pool before the +failure propagates: identity/authorization construction failing (no module runtime was started +yet); the server failing to bind its listen address; or an app-construction failure, both of which +also stop the module runtime first, since it may have already started successfully. A module +load/register/start failure uses Core's own existing rollback (already-started modules stop, in +reverse order, before the failure propagates) and then closes the pool — there is no separate +runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one. ## A real signup → sign-in → principal → sign-out transcript diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 7bc9243..7f1954c 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -16,14 +16,15 @@ for the durable architectural decisions this page describes in more implementati An operator can bootstrap exactly one platform owner for a deployment; a platform owner (or anyone they delegate to) can create, list, and revoke *explicit* authorization grants over HTTP for any -registered permission, Core-owned or module-owned; and every enforced route fails closed on -infrastructure trouble instead of silently allowing or denying. It is **not** possible to manage -roles, groups, teams, or tenants; there is no owner-management HTTP endpoint (revoking or -re-granting platform-owner status is not exposed at all); there are no service accounts or API -tokens, so only cookie-authenticated browser callers can reach the mutation routes; a module cannot -yet mount its own HTTP routes; and relationship-derived grants (for example, automatic access from -a roster assignment) cannot yet be created by anything in this repository — the generic grants API -structurally cannot create or revoke them either. +registered permission, Core-owned or module-owned; a module can register its own HTTP routes and +have them mounted, enforced, and reachable in production (see +[API](api.md#module-http-routes)); and every enforced route fails closed on infrastructure trouble +instead of silently allowing or denying. It is **not** possible to manage roles, groups, teams, or +tenants; there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status +is not exposed at all); there are no service accounts or API tokens, so only cookie-authenticated +browser callers can reach mutation routes, Core-owned or module-owned; and relationship-derived +grants (for example, automatic access from a roster assignment) cannot yet be created by anything +in this repository — the generic grants API structurally cannot create or revoke them either. ## Core authorization schema and store @@ -105,27 +106,37 @@ integration layer depends on authorization). ## API startup: discovery, composition, and enforcement -`apps/api` builds its production permission registry from the complete installed-module inventory -every time it starts, in a fixed order, before anything else: +`apps/api` builds its production permission registry, and starts the module runtime, from the +exact same installed-module inventory every time it starts, in a fixed order, before anything +else: 1. Resolve module-discovery configuration (the repository's `modules/` directory, and the platform/Module SDK versions from their own `package.json` files). -2. Discover installed modules (`discoverModules`). -3. Compose the platform permission registry (`composePlatformPermissionRegistry`). +2. Discover installed modules exactly once (`discoverModules`). +3. Compose the platform permission registry from that exact discovered list + (`composePlatformPermissionRegistry`). 4. Construct the shared PostgreSQL pool. -5. Construct Identity and Authorization. -6. Create the Hono application, injecting the composed registry. -7. Bind the HTTP server. +5. Construct Identity, the authorization store, and the evaluator. +6. Start the module runtime from that same discovered list (`startDiscoveredModuleRuntime`) — + modules are never rediscovered for this step. +7. Create the Hono application, injecting the composed registry and mounting the started module + runtime's published routes (see [API](api.md#module-http-routes)). +8. Bind the HTTP server. -`apps/api/src/platform-permissions.ts`'s `composeStartupPermissionRegistry` performs steps 1–3. -Discovery and composition happen strictly before any pool, Better Auth instance, or HTTP server -exists, so a failure there — an invalid manifest, a broken dependency graph, a module claiming the -reserved `core.*` namespace, or a duplicate permission declaration — leaves nothing to close: it -propagates directly out of `startApi()`, and startup never falls back to `CORE_PERMISSION_REGISTRY` -or any Core-only registry. The repository root is resolved from this compiled/source file's own -location (two directories up from `apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at -identical depth), never `process.cwd()` — a package-scoped test run has `apps/api` as its working -directory, which would otherwise point discovery at the wrong `modules/` directory. +`apps/api/src/platform-permissions.ts`'s `prepareStartupModuleInventory` performs steps 1–3, +returning both the discovered module list and the composed registry so step 6 can start the module +runtime from that identical list rather than rediscovering modules a second time. +(`composeStartupPermissionRegistry` remains available as a registry-only convenience wrapper +around it, for callers — Doctor, `modules:validate`, existing tests — that only need the +registry.) Discovery and composition happen strictly before any pool, Better Auth instance, module +entrypoint import, or HTTP server exists, so a failure there — an invalid manifest, a broken +dependency graph, a module claiming the reserved `core.*` namespace, or a duplicate permission +declaration — leaves nothing to close: it propagates directly out of `startApi()`, and startup +never falls back to `CORE_PERMISSION_REGISTRY` or any Core-only registry. The repository root is +resolved from this compiled/source file's own location (two directories up from +`apps/api/dist/*.js` or `apps/api/src/*.ts`, which sit at identical depth), never `process.cwd()` +— a package-scoped test run has `apps/api` as its working directory, which would otherwise point +discovery at the wrong `modules/` directory. The resulting registry is a **static, immutable snapshot** taken once at startup. There is no hot installation, removal, or dynamic mutation of installed modules or their permissions while the @@ -148,10 +159,11 @@ consults the registry for a grant that already exists — revocation is keyed to and source kind, not to whether its permission is currently registered — so a manager can still revoke a historical grant for a now-unregistered permission. -Module HTTP route registration (a module owning its own mounted endpoints) and relationship-derived -grant creation both remain deferred, unrelated slices — this work only makes a module's *declared* -permission usable through Core's existing, generic explicit-grant HTTP API and enforcement -middleware. +A module's own mounted HTTP routes (`/api/v1/modules//*`) are enforced through this +identical registry and `requirePermission` middleware — see [API](api.md#module-http-routes) for +the route-mounting contract. Relationship-derived grant creation remains a deferred, unrelated +slice — this work only makes a module's *declared* permission usable through Core's existing, +generic explicit-grant HTTP API, enforcement middleware, and now its own mounted routes. ## Doctor and modules:validate @@ -446,12 +458,34 @@ there are or whether the failure happened during startup. `403` and `store.createGrant` never called, exact server-derived-argument assertions, sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and store-error mapping. +- `apps/api/tests/module-http-routes.test.ts` — the production route-mounting contract: exact + prefix/path joining (including the non-trailing-slash root case), every supported method, + independent same-path routes across two modules, deterministic static-versus-parameterized + resolution by published order, request/handler adaptation (raw `Request`, frozen context, decoded + and frozen path parameters, empty frozen params for static/root routes), the registry's exact + baseline for `public`/`authenticated`/`restricted` module permissions, denial/infrastructure- + failure paths never invoking the handler, `ModuleRouteConfigurationError` for an inconsistent + route/registry snapshot, trusted-origin enforcement for state-changing methods, CORS (advertised + methods, no reflection for an untrusted origin, no authorization or handler invocation on + preflight), fixed sanitized `500` for a throwing/rejecting/non-`Response`-returning handler with + no raw leakage, a valid module-owned 4xx/5xx response passing through unchanged, forced + `Cache-Control`/`Vary` with a handler's own `Vary` preserved, stripped module-supplied CORS + headers, `HEAD` traversing the same permission middleware as `GET` with no body, and unmatched + paths/zero-route/no-handler-during-construction behavior. - `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above, plus module-permission composition at startup: discovery/composition run before any pool, Better Auth instance, or HTTP server is constructed; a discovery failure, a reserved-namespace failure, and a generic duplicate-declaration failure each create zero pools/Better Auth instances/servers and remain distinguishable from one another; zero installed modules still - succeeds; and import safety for `platform-permissions.ts`. + succeeds; and import safety for `platform-permissions.ts`. A dedicated block covers module + *runtime* startup and shutdown against real, working (non-throwing) fixture modules: permission + composition and module runtime startup share the exact same discovered inventory, route mounting + order matches discovery order, a load/register/start failure each close the pool exactly once and + bind no server (preserving Core's own rollback for a start failure), an app-construction failure + and a bind failure both stop the module runtime before closing the pool, normal shutdown stops the + server/module-runtime/pool in that exact order, a module stop failure still runs pool cleanup + before the shutdown rejection surfaces, and a direct `discoveredModulesOverride` drives both + composition and runtime startup identically. - `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never consulting the grant or platform-owner ports for `public`/`authenticated`. @@ -466,16 +500,23 @@ there are or whether the failure happened during startup. invisible to LIST and unreachable by REVOKE, and an infrastructure-failure scenario (database dropped out from under a live session) asserting a `503` rather than a false `403` or `200`. - `apps/api/tests/api-http.integration.ts`, "installed module permission end-to-end" (live - PostgreSQL) — a real temporary module fixture (structurally valid manifest, a server entrypoint - that throws if ever imported) proves the complete chain against a real API process and real - Doctor run: Doctor recognizes the installed module and passes permission composition without - executing its entrypoint; API startup discovers the fixture and makes its declared permission - enforceable; a bootstrapped platform owner creates a real explicit grant for the module's - permission with an atomic audit row recorded under its exact declared id; a well-formed - permission absent from the composed registry is rejected with no grant or audit write; and, - after a clean shutdown and restart with the module absent, the same permission can no longer be - newly granted while the earlier, still-unrevoked grant remains listed — with ordinary identity - HTTP behavior (sign-up, sign-in, principal resolution) unaffected throughout. + PostgreSQL) — a real temporary module fixture (structurally valid manifest; a real, working + server entrypoint recording register/start/stop markers instead of throwing, and declaring a + restricted `GET /events/:eventId` route plus a restricted, state-changing `POST /events` route + against its own manifest permission) proves the complete chain against a real API process and + real Doctor run: Doctor recognizes the installed module and passes permission composition + without executing its entrypoint (an empty marker set); a successful `startApi()` discovers the + same fixture, imports/registers/starts its runtime exactly once, and makes its declared + permission enforceable; a bootstrapped platform owner creates a real explicit grant for the + module's permission with an atomic audit row recorded under its exact declared id; the module's + own mounted route denies an anonymous caller and then serves the newly granted owner with the + decoded path parameter and authenticated principal; the module's state-changing route requires a + trusted `Origin` exactly like the platform's own mutation routes; a well-formed permission absent + from the composed registry is rejected with no grant or audit write; and, after a clean shutdown + (module stop invoked exactly once) and restart with the module absent, its former route returns + the ordinary `404`, the same permission can no longer be newly granted, and the earlier, + still-unrevoked grant remains listed — with ordinary identity HTTP behavior (sign-up, sign-in, + principal resolution) unaffected throughout. Run the focused suites: @@ -487,10 +528,11 @@ pnpm test:migrations:integration # requires Docker ## Next phase -Deliberately deferred, unrelated slices — see +Module HTTP route registration and production mounting (see [ADR 0014](../architecture/decisions/0014-module-permission-declarations-and-platform-registry.md#deferred) -for the complete list: module HTTP route-registration contracts (a module mounting its own -endpoints), a module runtime-context authorization service, relationship-derived grant creation +for the deferral this closes) is now implemented — see [API](api.md#module-http-routes) and +[Module development](module-development/README.md#http-routes). Deliberately deferred, unrelated +slices remain: a module runtime-context authorization service, relationship-derived grant creation itself (a roster or similar module operation, not the generic grants API), reinstall/reactivation policy for a permission whose owning module was previously removed, roles/groups/teams/tenants, an owner-management HTTP endpoint, and service accounts/API tokens for non-browser automation. diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 429158c..d465eeb 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -240,11 +240,20 @@ connection, authorization store, grant/platform-owner state, storage, jobs, conf a framework-specific request context, or a raw Better Auth session. The full query string remains available through `Request.url`; there is no separate query or body abstraction. -This pass only defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route -snapshot — Core never invokes a handler here. Mounting routes into the production `apps/api` Hono -application, enforcing each route's permission through `requirePermission`, applying -`requireTrustedOrigin`, mapping handler errors to sanitized responses, and wiring this into -`startApi`'s production lifecycle are later work. +Core defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route +snapshot — it never invokes a handler itself. `apps/api` mounts that exact snapshot into production +at startup, under the `/api/v1/modules/` prefix described above: every route is enforced +through the identical `requirePermission` middleware Core's own routes use, a state-changing method +(`POST`/`PUT`/`PATCH`/`DELETE`) additionally requires a trusted `Origin` before authorization ever +runs, and a handler that throws, rejects, or returns something other than a `Response` becomes a +fixed, sanitized `500 {"code":"MODULE_HANDLER_FAILED"}` — never the caught value, a stack, the +route's path, its permission id, or the module id. The platform remains the sole owner of CORS and +caching for every module route: a handler-supplied CORS-control header is stripped, and every +module response is forced to `Cache-Control: no-store` with `Vary: Origin, Cookie` (in addition to +any of the handler's own legitimate `Vary` values) until a dedicated cache-policy contract exists — +deliberately conservative, since even a public-baseline handler still receives an authorized +principal and may personalize its response. See [API](../development/api.md#module-http-routes) for +the full production mounting, enforcement, and error-response contract. ## Drizzle schema definition