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
+87 -21
View File
@@ -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/<module-id>/*` | 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/<module-id>`, 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