docs: record module HTTP architecture
Phase 5B, pass 3: adds ADR 0015 recording the durable module HTTP
routing and production runtime lifecycle architecture pass 1 and pass
2 implemented, extending ADR 0006 and ADR 0014 without superseding
either. Adds narrow cross-reference notes to both, and to the ADR
index, pointing at ADR 0015 for the work they deferred, while
preserving their historical text and accepted status.
Corrects stale forward-looking "pass 2"/future-tense language left
over from pass 1 in core/src/modules/runtime.ts and
packages/contracts/src/module-http.ts (comment-only; production
behavior unchanged), and a shutdown-lifecycle description in
docs/development/testing.md that predated the module-runtime-stop
step pass 2 added between draining the HTTP server and closing the
pool.
Full audit against origin/main...HEAD found the architecture,
contract, startup ordering, routing, authorization/origin
enforcement, and PostgreSQL integration coverage from passes 1-2
sound; the one concrete defect found (Vary-header merge producing an
invalid "*, Origin, Cookie" combination and case-sensitive duplicate
tokens) was already fixed with focused regression tests in a separate
commit (bf76f4e), not amending either checkpoint commit.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bf76f4e006
commit
1285c90618
@@ -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
|
||||||
|
|||||||
+4
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user