feat: mount module HTTP routes

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

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-24 19:16:20 -05:00
co-authored by Claude Sonnet 5
parent e296e083ec
commit dfaf324a65
12 changed files with 1930 additions and 123 deletions
+27 -8
View File
@@ -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/<module-id>`. 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);
+153
View File
@@ -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<string> = 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<string>;
}
/** 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/<module-id>/` (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<Response> => {
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);
}
}
}
+49 -12
View File
@@ -54,7 +54,7 @@ async function readPackageVersion(packagePath: string): Promise<string> {
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<PermissionRegistry> {
export async function prepareStartupModuleInventory(
options: PrepareStartupModuleInventoryOptions = {},
): Promise<StartupModuleInventory> {
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<PermissionRegistry> {
const inventory = await prepareStartupModuleInventory(options);
return inventory.permissionRegistry;
}
+90 -25
View File
@@ -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<void> {
}
}
/** 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<void> {
try {
await moduleRuntime.stop();
} catch {
// See safeCloseDatabase: never mask the startup failure already propagating.
}
}
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> {
return new Promise((resolve, reject) => {
let settled = false;
@@ -63,25 +79,36 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
}
/**
* 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<RunningApi> {
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<void> | undefined;
const shutdown = (): Promise<void> => {
if (!shutdownPromise) {
@@ -141,7 +197,16 @@ export async function startApi(
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
let moduleStopError: unknown;
try {
await moduleRuntime.stop();
} catch (error) {
moduleStopError = error;
}
await safeCloseDatabase(databaseHandle);
if (moduleStopError) {
throw moduleStopError;
}
})();
}
return shutdownPromise;