feat: add module HTTP transport #12

Merged
Zayin_Admin merged 5 commits from phase-5/module-http-transport into main 2026-07-25 15:44:40 +00:00
7 changed files with 321 additions and 14 deletions
Showing only changes of commit 1285c90618 - Show all commits
+3 -3
View File
@@ -518,9 +518,9 @@ async function startModules(
* Starts the module runtime from a module list the caller already discovered — performs no * Starts the module runtime from a module list the caller already discovered — performs no
* module-directory scan or manifest read of its own, and preserves the given list's order exactly. * module-directory scan or manifest read of its own, and preserves the given list's order exactly.
* `startModuleRuntime` below is the convenient discovery-plus-start wrapper most callers want; * `startModuleRuntime` below is the convenient discovery-plus-start wrapper most callers want;
* `apps/api`'s production startup (pass 2) will call this form directly so permission composition * `apps/api`'s production startup calls this form directly (`prepareStartupModuleInventory`,
* and module HTTP/runtime startup share Phase 5A's exact same one-time discovery rather than * `apps/api/src/platform-permissions.ts`) so permission composition and module HTTP/runtime
* discovering modules twice. * startup share the exact same one-time discovery rather than discovering modules twice.
*/ */
export async function startDiscoveredModuleRuntime( export async function startDiscoveredModuleRuntime(
discoveredModules: readonly DiscoveredModule[], discoveredModules: readonly DiscoveredModule[],
@@ -28,6 +28,15 @@ registry, which conflicts with install-by-cloning discovery.
> ordering — remains accepted and unchanged. This section is left as originally accepted, as the > ordering — remains accepted and unchanged. This section is left as originally accepted, as the
> historical record of the manifest's prior shape. > historical record of the manifest's prior shape.
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md).**
> "HTTP routing... remain[s] outside this slice" (Consequences, below) and "The injected contexts
> contain only the module ID and a capability registrar or resolver" (this section, below) described
> this ADR's own scope accurately at the time. ADR 0015 implements module HTTP routing: it extends
> `ModuleRegistrationContext` (the `register`-only context) with an `http` route registrar, while
> `ModuleRuntimeContext` (the `start`/`stop` context this sentence also describes) remains exactly as
> limited to module ID and capability access as originally decided here. Every other statement in
> this ADR remains accepted and unchanged.
`ekklesia.module.json` remains the one canonical static manifest and moves to `ekklesia.module.json` remains the one canonical static manifest and moves to
`manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact `manifestVersion: 2`. Provided capabilities are declarations containing an identifier and an exact
semantic version. Required and optional capability dependencies map identifiers to semantic-version semantic version. Required and optional capability dependencies map identifiers to semantic-version
@@ -223,6 +223,10 @@ current deployment model but not the final word on it.
## Deferred ## Deferred
> **Cross-reference — see [ADR 0015](0015-static-module-http-routing-and-runtime-lifecycle.md).**
> Module HTTP route-registration contracts and their production mounting, listed immediately below
> as deferred, are now implemented. Every other item in this list remains deferred and unimplemented.
Module HTTP route-registration contracts (a module mounting its own endpoints); a Module HTTP route-registration contracts (a module mounting its own endpoints); a
`ModuleRuntimeContext` authorization service; relationship-derived grant creation (a roster or `ModuleRuntimeContext` authorization service; relationship-derived grant creation (a roster or
similar module operation using `sourceKind: "relationship"`, not the generic explicit-grant API); similar module operation using `sourceKind: "relationship"`, not the generic explicit-grant API);
@@ -0,0 +1,286 @@
# 0015: Static module HTTP routing and production runtime lifecycle
- Status: Accepted
- Date: 2026-07-24
- Extends: [0006](0006-versioned-capabilities-and-compiled-module-runtime.md) and
[0014](0014-module-permission-declarations-and-platform-registry.md)
## Context
ADR 0006 established the compiled module runtime — deterministic discovery, a staged capability
registry, dependency-ordered registration and lifecycle, and the injected context's deliberate
limit to module ID and capability access. ADR 0014 wired module-declared permissions into Core's
authorization system and explicitly deferred two things: a module mounting its own HTTP routes,
and `apps/api`'s permission-composition startup retaining the discovered module list for anything
beyond composition itself. This ADR records the architecture that closes both gaps: a
framework-neutral route contract a module declares during its existing `register` hook, Core's
atomic collection of those routes into the same immutable startup snapshot as capabilities, and
`apps/api`'s production mounting of that snapshot — enforced, origin-checked, and
response-sanitized — under one platform-owned path prefix.
## Decision
### One contracts-layer, Fetch-based route definition
`packages/contracts/src/module-http.ts` is the sole route contract, consumed identically by Core's
module runtime and `apps/api`'s mounting adapter — there is no second, parallel definition in
either layer. It depends only on standard Fetch primitives (`Request`, `Response`), `EkklesiaPrincipal`,
`ModuleId`, and `PermissionId`; it never imports Hono, Node HTTP types, Better Auth, a database
client, or any other framework or Core implementation type. A route definition contains exactly
four fields — `method`, `path`, `permission`, `handler` — validated as an exact-shape object with
no unknown properties; a module cannot smuggle a baseline, a mount prefix, an absolute path, CORS
or origin policy, authentication behavior, grant/platform-owner state, middleware, audit metadata,
or another module's namespace through this contract, because the type has no field for any of
them. `permission` is accepted as a plain string at the authoring boundary (so a module writes a
literal identifier with no parse call) and validated and branded to `PermissionId` by the
contract's own parser, mirroring how `PermissionDeclarationInput`/`PermissionDeclaration` already
split authoring input from validated output (ADR 0014).
Exactly `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` are authorable, always uppercase; there is no
`OPTIONS` (the platform owns CORS preflight) and no wildcard or all-methods registration. A path is
always local to the module and canonical: it begins with `/`, a non-root path never ends with `/`,
and every segment is either a lowercase kebab-case static segment or a single `:parameterName`
segment — no empty or repeated slashes, no query string or fragment, no backslash or
percent-encoding, no dot segment, and no wildcard/catch-all segment, because none of those
characters can ever satisfy either segment grammar. Malformed input is rejected outright, never
normalized, so a module can never register a path Core silently reinterpreted.
### Permission ownership is structural, not a runtime policy choice
Every route — including one meant to be publicly reachable — names exactly one permission the
owning module's own manifest declares (ADR 0014's `manifestVersion: 3` declarations); there is no
way to construct an unprotected, no-permission route through this contract. A route can never
supply a baseline: the trusted `public`/`authenticated`/`restricted` baseline comes exclusively
from the composed platform permission registry at enforcement time, exactly like every other
permission check in the platform. Public access is expressed by referencing a permission whose own
manifest declaration has `baseline: "public"`, never by omitting a permission. Core's module
runtime checks route ownership against only the registering module's own manifest-declared
permissions — never the composed platform registry, never another module's manifest — so a module
can never gate a route with Core's or another module's permission; this is a structural
consequence of what the ownership check is allowed to consult, not a policy a caller could
misconfigure.
### Atomic validation and publication alongside capabilities
Core's staged registration (ADR 0006) now stages a module's HTTP routes exactly as it already
stages capability provision: a route is validated and, on failure, rejected immediately at the
`http.route(...)` call site during `register`, using structured `ModuleRuntimeIssue` codes
distinguishing invalid shape/handler, invalid method, invalid path, invalid permission identifier,
an undeclared (unowned) permission, and a duplicate route — never a caught exception's message,
handler source, or a machine path. A module's routes are merged into the shared, eventually
published list only after that module's `register` hook returns without throwing *and* every
capability it declared was actually provided — the same all-or-nothing gate capabilities already
enforce. `StartedModuleRuntime.routes` is populated, and becomes non-empty, only after every
discovered module has registered and started successfully; a failed registration or a failed
startup (with Core's existing reverse-order rollback) publishes no route snapshot at all, exactly
as no capability registry is published in that case either.
Duplicate detection uses route *shape* — method plus path with every parameter segment collapsed
to one placeholder — not the parameter's chosen name, so `/events/:eventId` and `/events/:id`
collide under the same method exactly like the identical route they are. Detection is scoped to
one module's own routes; two different modules may register the identical local method and path
without conflict, because the production mount prefix (below) namespaces them apart regardless.
### An immutable, ordered startup snapshot
`StartedModuleRuntime.routes` is a single frozen array of frozen route records, published once at
startup and never mutated afterward — there is no dynamic route addition or removal while the
process runs. Its order is deterministic: discovered-module order, then registration order within
each module. Because a caller-owned route-definition object could otherwise be mutated after
`http.route(...)` returns, Core never retains the caller's own object — it copies each validated
field into a freshly frozen record, so mutating the original definition afterward cannot alter the
published snapshot. Core never invokes a handler during registration or startup; a handler is only
ever a stored function reference until a route is actually dispatched (a concern entirely owned by
`apps/api`, below).
### One discovered inventory drives both composition and runtime startup
`prepareStartupModuleInventory` (`apps/api/src/platform-permissions.ts`) discovers the installed
module inventory exactly once and returns both the discovered module list and the platform
permission registry composed from it, together. `apps/api`'s production startup
(`startApi`, `apps/api/src/server.ts`) passes that same discovered list directly to
`startDiscoveredModuleRuntime` — a Core entry point that performs no discovery of its own and
preserves the given list's order exactly — so permission composition and executable module
startup are provably looking at the same installed-module set, never two independently discovered
ones. `startModuleRuntime` (discover-then-start) remains available as a convenience wrapper for
callers that do not already have a discovered list; production never calls it. The test-only
`discoveredModulesOverride` drives both composition and runtime startup identically, for the same
reason: it stands in for the one real discovered list, never a second, competing one.
Production startup composes in one fixed order: discover the module inventory and compose the
platform permission registry from it; construct the shared PostgreSQL pool; construct Identity,
the authorization store, and the evaluator; start the module runtime from that same discovered
inventory; create the Hono application and mount the started runtime's published routes; bind the
HTTP server. A discovery or composition failure therefore occurs before any pool, Better Auth
instance, or executable module code exists; a database/identity construction failure occurs before
any module runtime starts; and a module runtime or app-construction failure binds no server. Every
cleanup path preserves the original failure if cleanup itself also fails, matching the sanitized,
non-masking failure discipline the rest of the platform already follows.
### The platform-owned `/api/v1/modules/<module-id>` namespace, enforced identically to Core's own routes
`apps/api/src/module-http-routes.ts` — the one place in the repository below `apps/api` importing
Hono for this purpose — mounts a started runtime's published route snapshot under
`/api/v1/modules/<module-id>`, in publication order, never sorted or otherwise rewritten. A local
`"/"` maps to the bare module prefix; every other local path appends directly; the platform alone
decides this mapping, and a module has no way to choose or influence its own mount prefix. This
ordering is load-bearing, not cosmetic: Hono's router resolves two routes whose patterns overlap at
the same depth (a static segment and a same-position `:parameter`) by registration order, not by
static-path specificity, so preserving the runtime's exact publication order is what lets a module
author's more-specific-first declaration continue to take precedence after mounting. Mounting uses
one fixed-arity `app.on(method, path, ...middleware, handler)` call per route rather than spreading
a variable-length handler array — Hono's overload resolution is arity-sensitive, and a spread call
silently resolves to an unrelated, incompatible overload.
Every mounted route is protected by the identical `requirePermission` middleware Core's own routes
use (ADR 0013), consulting `route.permission` against the exact same composed platform registry —
there is no module-specific enforcement path, no wildcard, prefix, namespace, or implication
matching, and a route's baseline always comes from the registry, never the route. A public
permission still resolves a principal and traverses the same authorization check as any other
baseline; only the evaluator's own decision differs. Because the mounted routes and the injected
registry both derive from the same discovered inventory, every mounted route's permission should
already be registered; `apps/api` still checks this invariant explicitly at app-construction time
and fails construction with a fixed, sanitized configuration error if it ever does not hold, rather
than silently mounting an unenforceable route — `requirePermission`'s own per-request registry
lookup remains the live, defense-in-depth enforcement regardless of that check.
### Trusted origin, and platform-owned `OPTIONS`/`HEAD`
A state-changing method (`POST`, `PUT`, `PATCH`, `DELETE`) additionally requires an exact trusted
`Origin`, checked before authorization or the handler ever run — the identical `requireTrustedOrigin`
mechanism the explicit-grant mutation routes already use (ADR 0013), for the identical reason: CORS
response headers alone do not stop a browser from *sending* a state-changing cross-origin request,
only from reading its response. `GET` does not require an `Origin`. `OPTIONS` is never authorable by
a module and remains entirely platform-owned: the shared CORS middleware intercepts every preflight
request before any route-specific middleware or handler runs, so a preflight never reaches
authorization or module code. `HEAD` is not separately authorable either; Hono's own router
transparently re-dispatches a `HEAD` request through the identical registered `GET` chain and
strips the response body afterward, so a `HEAD` request is authorized exactly like the `GET` it
mirrors and cannot bypass anything a direct `GET` would not also bypass. The platform's CORS policy
advertises `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`, and reflects only an exact
configured trusted origin — never a wildcard, and never anything module-influenced.
### A frozen, minimal handler context and sanitized failure boundary
A handler receives the unmodified Fetch `Request` (`c.req.raw`, so `Request.url`, headers, method,
body, and a streaming request all behave normally, and the full query string remains available
without a competing query abstraction) and a frozen context of exactly `moduleId`, the
already-authorized `EkklesiaPrincipal`, and an immutable, Hono-decoded parameter record — an empty
frozen object for a root or fully static route. A handler never receives a Hono `Context`, a
database connection, the authorization store or evaluator, PostgreSQL, configuration, secrets, or
any other middleware-internal state. Module code is executable, trusted backend plugin code, not a
sandboxed boundary — the same trust model ADR 0006 already established for capability registration
and lifecycle hooks — but its HTTP-facing boundary is still sanitized: 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`, and nothing about the caught
value, a stack, the module ID, the route's path, or its permission ID is ever included. A
`Response` a handler deliberately returns — including a 4xx or 5xx one — is never remapped, and a
streaming body is passed through unread rather than consumed or buffered while headers are
sanitized.
### Platform-controlled caching and CORS on every module response
The platform remains the sole owner of caching and CORS for a module response: any
`Access-Control-*` header a handler set is stripped (case-insensitively, since header names are
themselves case-insensitive) before the response leaves the process, and every module response is
forced to `Cache-Control: no-store` with a `Vary` that always includes `Origin` and `Cookie` —
merged case-insensitively so a differently-cased duplicate never appears, and collapsed to a bare
`*` rather than an invalid combination whenever an existing `Vary` already contains one (RFC 9110
§12.5.5 forbids combining `*` with any other value) — while any of a handler's own legitimate
`Vary` values are preserved alongside the forced ones. This is deliberately conservative: even a
public-baseline handler receives an authorized principal and may personalize its response, so
until a dedicated module-declared cache-policy contract exists, no module response is ever treated
as cacheable or origin-independent by default.
### Discovery, Doctor, and `modules:validate` remain manifest-only; production runtime startup does not
Module discovery, Doctor's checks, `modules:validate`, and permission composition continue to read
only manifests — none of them imports or executes a module's server entrypoint, register hook, or
any other lifecycle code, exactly as ADR 0006 and ADR 0014 established. A *successful* production
runtime startup is the one deliberate exception: it imports each installed module's compiled server
entrypoint and runs its `register` and `start` hooks, because that is the only way a module's
declared routes and capabilities become real, dispatchable behavior. Entrypoint modules remain
trusted backend plugins, not a sandbox boundary (ADR 0006); their top-level ESM evaluation must
remain side-effect-free, because ESM evaluation cannot be rolled back or unloaded once it has run —
this was already a hard requirement for capability registration and remains exactly as hard a
requirement now that routes are declared the same way, during the same hook.
### Restart-based static installation, and ordered runtime shutdown
Installing, removing, or editing a module's routes takes effect only on the next API restart,
exactly like permission composition (ADR 0014) — there is no hot installation, removal, or dynamic
route mutation while the process runs. Removing a module makes its former routes return the
platform's ordinary `404 {"code":"NOT_FOUND"}`, the same fallback every unmatched path already
receives. `RunningApi`'s shutdown stops accepting new HTTP work and drains in-flight requests
first, then stops the module runtime, then closes the shared PostgreSQL pool — in that order, so no
module resource stops while a route handler might still be executing. One memoized shutdown promise
is shared by every concurrent or later caller; each of the three steps runs at most once regardless
of how many times `shutdown()` is called. Unlike a startup-failure cleanup path (which always
swallows a secondary cleanup failure so it never masks the original error), a module-stop failure
during *normal* shutdown is not swallowed — it is a genuine result a caller should observe — but
database cleanup still always completes first, so a stop failure never leaves the pool open.
## Alternatives
- **Hono or another framework's own request/response/routing types in the contracts layer** was
rejected: it would leak an implementation-layer framework choice into the one public contract
every module and Core depend on, and would make swapping the HTTP transport a breaking contract
change instead of an internal one.
- **Declaring routes in the static module manifest** (alongside capabilities and permissions) was
rejected: a route's `handler` is executable behavior, not inert data a manifest can express
without executing module code, and duplicating the same route between a manifest and the runtime
definition would create two sources of truth that could silently drift.
- **Permissionless routes, or a route/handler that selects its own baseline** were rejected outright
by the contract's shape: there is no field for either, so no caller can construct a route that
omits a permission or asserts its own trust level.
- **An arbitrary or module-chosen mount prefix** was rejected in favor of a single platform-owned
`/api/v1/modules/<module-id>` namespace: a module choosing its own path risks colliding with
Core's own routes or another module's, and removes the platform's ability to reason about the
complete route surface from one place.
- **A second module discovery pass, or a second route/runtime registry, for HTTP routes
specifically** was rejected in favor of reusing the exact discovery Phase 5A already performs and
staging routes inside the same `StartedModuleRuntime` capabilities already publish through —
avoiding two independently discovered inventories that could disagree, and two registry
implementations that could drift.
- **Module-owned authorization, CORS, or origin-checking middleware** was rejected: every module
route is enforced through the identical, already-audited `requirePermission`/`requireTrustedOrigin`
mechanism Core's own routes use, so there is exactly one enforcement mechanism to reason about and
audit, not one per module.
- **`OPTIONS` or `HEAD` as module-authorable methods** were rejected: `OPTIONS` is inseparable from
the platform's own CORS policy, and `HEAD` is fully and correctly served by Hono's own transparent
`GET`-chain re-dispatch; making either authorable would only create a second, competing code path
for behavior the platform already provides correctly.
- **Dynamic or hot route mutation** (adding, removing, or replacing a route while the process runs)
was rejected for this phase: it would reopen the same concurrent-mutation and partial-update
hazards ADR 0014 already rejected for permission composition, for no benefit the current
deployment model needs.
- **A module-declared cache policy** (letting a route specify its own `Cache-Control`/`Vary`
behavior) was rejected for this phase: the conservative default (`no-store`, vary on `Origin` and
`Cookie`) is always correct, while a wrong module-declared policy could leak one user's
personalized response to another; a real cache-policy contract is deferred, deliberately, rather
than guessed at now.
## Consequences
A module gains a real, dispatchable HTTP surface the moment its `register` hook declares routes
against permissions its own manifest already declares — no second registration step, no
module-specific transport code to write. The integration boundary is safe by construction: the
contracts-layer shape structurally prevents a permissionless route or a module-asserted baseline,
Core's ownership check structurally prevents borrowing another owner's permission, and `apps/api`'s
mounting reuses exactly the enforcement, origin-checking, and response-sanitization machinery
already audited for Core's own routes. The operational cost mirrors ADR 0014's: every module
inventory or route change requires an API restart to take effect, and — because production startup
now deliberately imports and executes trusted module entrypoints, not merely their manifests — a
module's compiled server code must be as trustworthy as any other backend plugin code Core already
runs, since nothing about this design sandboxes it.
## Deferred
Module database or Drizzle access; module storage access; jobs and workflow/event automation;
module-owned configuration and secrets services; a `ModuleRuntimeContext` authorization service;
relationship-derived grant operations; reinstall/reactivation semantics for a permission or route
whose owning module was previously removed; roles, groups, teams, and tenants; an owner-management
HTTP endpoint; service accounts and API tokens for non-browser automation; a native-client
trusted-origin exemption policy; a module-declared cache-policy contract; hot module
installation, removal, or reloading; Surfaces and UI; and real product-module endpoints. None of
these are implemented by, or required for, the architecture this ADR records.
+1
View File
@@ -23,3 +23,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted - [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted
- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted - [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted
- [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted - [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted
- [0015: Static module HTTP routing and production runtime lifecycle](0015-static-module-http-routing-and-runtime-lifecycle.md) — Accepted
+12 -7
View File
@@ -22,13 +22,18 @@ error-only sanitized logger that strips Better Auth's own message and arguments
they contain, pinned 8–128 password-length bounds, sanitized failures, and that a database outage they contain, pinned 8–128 password-length bounds, sanitized failures, and that a database outage
during principal resolution is never silently reported as anonymous), and Core migration discovery during principal resolution is never silently reported as anonymous), and Core migration discovery
(deterministic ordering, the reserved `core` identity, and collision rejection) — all without a (deterministic ordering, the reserved `core` identity, and collision rejection) — all without a
live database. `apps/api`'s own unit tests cover its pure Hono app factory (route shapes, CORS live database. `apps/api`'s own unit tests cover its pure Hono app factory (route shapes, module
allow/deny/preflight, deterministic 404 and sanitized error responses), its runtime configuration, route mounting/enforcement/CORS/response sanitization, CORS allow/deny/preflight, deterministic 404
its idempotent shutdown lifecycle (concurrent callers share one server-close-then-pool-close and sanitized error responses), its runtime configuration, its idempotent shutdown lifecycle
promise; a startup failure after the pool was already created still closes it; `SIGINT`/`SIGTERM` (concurrent callers share one server-close-then-module-runtime-stop-then-pool-close promise; a
wiring is exercised through an injectable exit function rather than the real `process.exit`), and module-stop failure never prevents pool cleanup; a startup failure after the pool was already
that importing any of its modules — including the executable server module without calling its created still closes it, stopping the module runtime first if it had already started;
exported `main` — never reads the environment, connects to PostgreSQL, or starts a server. `SIGINT`/`SIGTERM` wiring is exercised through an injectable exit function rather than the real
`process.exit`), and that importing any of its modules — including the executable server module
without calling its exported `main` — never reads the environment, connects to PostgreSQL, imports
a module entrypoint, or starts a server. Real, working (non-throwing) module fixtures prove
production module-runtime startup/shutdown ordering and cleanup against a mocked pool/Better
Auth/server — see [Authorization](authorization.md#tests).
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live `pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
database. database.
+6 -4
View File
@@ -26,7 +26,8 @@ const parameterSegmentPattern = /^:[a-z][a-zA-Z0-9]*$/u;
/** /**
* A module-local route path — never the production-mounted `/api/v1/modules/<module-id>` path a * A module-local route path — never the production-mounted `/api/v1/modules/<module-id>` path a
* caller actually sends (pass 2). Not branded (unlike `PermissionId`): like `PackageRelativePath`, * caller actually sends (`apps/api/src/module-http-routes.ts` owns that mapping). Not branded
* (unlike `PermissionId`): like `PackageRelativePath`,
* this is a validated location, not an opaque cross-system identifier. `"/"` is the module root; * this is a validated location, not an opaque cross-system identifier. `"/"` is the module root;
* any other canonical path begins with `/`, never ends with `/`, and is composed only of lowercase * any other canonical path begins with `/`, never ends with `/`, and is composed only of lowercase
* kebab-case static segments or single `:parameterName` segments — no empty or repeated slashes, no * kebab-case static segments or single `:parameterName` segments — no empty or repeated slashes, no
@@ -126,7 +127,8 @@ export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRouteP
* What a module's route handler receives about the caller and the match — never a database * What a module's route handler receives about the caller and the match — never a database
* connection, authorization-store, grant-management, storage, jobs, configuration, secrets, the * connection, authorization-store, grant-management, storage, jobs, configuration, secrets, the
* Hono `Context`, or a raw Better Auth session. `principal` is already authorized by the time a * Hono `Context`, or a raw Better Auth session. `principal` is already authorized by the time a
* handler runs (pass 2 mounts and enforces); this pass never invokes a handler at all. * handler runs: `apps/api/src/module-http-routes.ts` mounts and enforces every route before ever
* invoking its handler; this contracts-layer module never invokes a handler itself.
*/ */
export interface ModuleRouteHandlerContext { export interface ModuleRouteHandlerContext {
readonly moduleId: ModuleId; readonly moduleId: ModuleId;
@@ -174,8 +176,8 @@ export interface ModuleHttpRouteRegistrar {
/** One published route in `StartedModuleRuntime`'s immutable route snapshot — the same validated /** One published route in `StartedModuleRuntime`'s immutable route snapshot — the same validated
* fields as `ModuleRouteDefinition` plus the owning module's ID, which the production mount prefix * fields as `ModuleRouteDefinition` plus the owning module's ID, which the production mount prefix
* (pass 2) uses to keep two modules' routes from ever colliding regardless of their own local * (`/api/v1/modules/<module-id>`, `apps/api/src/module-http-routes.ts`) uses to keep two modules'
* paths. */ * routes from ever colliding regardless of their own local paths. */
export interface RegisteredModuleHttpRoute { export interface RegisteredModuleHttpRoute {
readonly moduleId: ModuleId; readonly moduleId: ModuleId;
readonly method: ModuleHttpMethod; readonly method: ModuleHttpMethod;