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
+84 -42
View File
@@ -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/<module-id>/*`) 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.