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