Files
Ekklesia/docs/architecture/decisions/0025-trusted-module-ui-asset-publication.md
ekklesiaandClaude Opus 5 7729c99e24
CI / verify (pull_request) Successful in 35s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 46s
feat: add the minimal operational module lifecycle
Ekklesia had no notion of a module being on or off: the accepted module set
was exactly "whatever discovery found on disk", and `ModuleLifecycleStage`
named nine states nothing produced or consumed. This replaces that vocabulary
with two axes that real code produces — persistent, administrator-owned
configuration (`enabled`/`disabled`) and per-boot runtime observation
(`running`/`disabled`/`blocked`) — and wires them through startup.

- One Core table (`ekklesia_core_module_state`, migration 0004) holding only
  `module_id` and `enabled`. A module with no record is enabled by default, so
  deploying a module is still sufficient to run it and discovery never needs an
  initialization write. A record for an undeployed module is retained and
  ignored, so redeploying restores the decision.
- `resolveModuleEligibility` is pure: a disabled module transitively blocks
  every module that *requires* it (module and capability edges alike) with a
  structured reason naming the provider; optional dependencies never block; and
  a missing, incompatible, or cyclic dependency stays discovery's concern.
- `startApi` starts only the eligible set and builds the UI publication
  registry from that same list, so routes, artifacts, browser inventory, and
  Dashboard navigation all follow one authoritative set. A disabled module is
  never imported, so there is no per-request enablement check anywhere.
- Startup stays all-or-nothing, deliberately; there is consequently no `failed`
  runtime state. `pnpm modules:enable`/`modules:disable` is the out-of-band
  recovery path for a module that breaks startup.
- Changes take effect at the next start, and the read model reports
  configuration, runtime, and `restartRequired` separately rather than claiming
  a module stopped when it has not.
- Migration participation and permission composition still span the deployed
  and discovered inventories respectively, unchanged.
- New restricted platform permission `core.modules.lifecycle.manage` gates both
  reading and changing enablement, at a sibling prefix (`/api/v1/module-lifecycle`)
  that cannot collide with the module route namespace.
- `pnpm doctor` gains a `modules.lifecycle` check so "why is this module
  unavailable?" is answerable without reading logs.

See ADR 0028.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 14:45:34 -05:00

272 lines
17 KiB
Markdown

# 0025: Trusted module UI asset publication
- Status: Accepted
- Date: 2026-08-22
- Extends: [0021](0021-modules-as-hostable-products.md),
[0022](0022-module-ui-authoring-and-host-contract.md),
[0023](0023-module-ui-build-pipeline.md),
[0024](0024-browser-safe-ui-sdk-and-host-loader.md)
## Context
ADR 0024 closed the loader and named the one thing still missing, precisely:
> Core's discovery resolves `entrypoints.ui` to an **absolute path on the server's disk**
> (`ResolvedModuleEntrypoints.ui`). A browser cannot import that. […] **Ekklesia has no such route
> today.** The loader is complete; publication is deferred and is the first thing the next pass must
> build.
So the pipeline ran `module UI source → build → compiled browser ESM → entrypoints.ui → discovery
validates a filesystem artifact → ??? → browser URL → loadModuleUi`. This ADR fills in the `???`.
Two constraints shape it, and both come from the repository rather than from general principle.
`docs/development/security.md` already wrote down the obligation this pass inherits: *"the existence
of an artifact on disk must not be what makes it servable. Only installed, reviewed modules may be
published or mounted under this trust model."*
And the platform's module state is genuinely thin. `ModuleLifecycleStage` (`@ekklesia/contracts`)
names nine stages, but nothing implements a state machine over them: there is no installer, no
enable/disable, no trust store, and no per-module status record. What exists is one accepted set —
the modules `discoverModules` validated at startup and `startDiscoveredModuleRuntime` then started.
> **Cross-reference — see [ADR 0028](0028-module-operational-lifecycle.md).**
> The paragraph above described the platform accurately at the time. There is now a persistent
> enable/disable state and a resolved eligibility model, and `ModuleLifecycleStage` has been replaced
> by the two axes ADR 0028 records. "Publishable" consequently derives from the **eligible** module
> set rather than the discovered one, so a disabled or dependency-blocked module publishes no
> artifact. Everything else in this ADR — lookup by validated identity, the sibling prefix, exact
> version matching, the anonymous artifact route, and the caching contract — is unchanged and still
> accepted. There is still no installer.
## Decision
### Publication is derived from validated module identity, never from a request path
The route takes a module id and a version, hands both to a registry as **lookup keys**, and — only
on an exact match — reads the canonical `artifactPath` Core's discovery already resolved.
```text
moduleId + version
↓ exact lookup, no path arithmetic
validated DiscoveredModule.entrypoints.ui
↓ read exactly that path
response bytes
```
No request value is ever joined, resolved, normalized, or concatenated into a filesystem path, and
the route declares no wildcard segment for one to hide in. Path traversal, an encoded separator, a
symlink, a sibling chunk, a source map, and a package-root file are therefore unreachable *by
construction* rather than by filtering — there is no code path that could reach them to filter.
This is the rule the pass exists to establish: **an installed module directory is never a static
file root.** A publisher that accepted `moduleId + relativePath` would be one careless normalization
away from serving `.env`, and it would also silently create an undeclared asset contract that no
manifest field, build step, or host loader knows about.
### Core owns the policy; `apps/api` owns the HTTP
`createModuleUiPublicationRegistry(modules)` (`core/src/modules/ui-publication.ts`) answers "may this
be published, and which exact file is it?". It is pure, synchronous, and consumes only
`DiscoveredModule` records — so a module reaches it having already passed manifest validation,
compiled-artifact validation (ADR 0006, extended to every non-directory entrypoint), and package
containment before *and* after symlink resolution.
"Publishable" therefore means exactly two things: the module is in the discovered set the platform
accepted at startup, **and** its manifest declares `entrypoints.ui`. No new state, no new lifecycle,
no installer. `apps/api/src/server.ts` builds the registry only after
`startDiscoveredModuleRuntime` has succeeded, so the published set is also the running set — module
startup is all-or-nothing, and a module that failed to load, register, or start takes API startup
down rather than lingering as publishable-but-not-running.
Keeping the policy in Core is not filing: it means the decision is expressed once, over already
validated data, where the rest of module discovery and lifecycle lives, rather than being re-derived
inside an HTTP handler that also has to think about headers and status codes.
### The URL is a sibling of the module API prefix, not a child of it
```text
GET /api/v1/module-ui/<module-id>/<version>
```
Nesting it under `/api/v1/modules/<module-id>/ui/...` was the obvious first choice and is wrong.
Core's own route grammar (`moduleRoutePathSchema`) accepts `ui` as an ordinary lowercase static
segment and `:version` as an ordinary parameter, so a module may legitimately register
`GET /ui/:version` — mounted at exactly `/api/v1/modules/<module-id>/ui/<version>`. Publishing there
would put the platform and installed modules in one URL namespace with Hono's registration order
silently deciding which one answers a request. A sibling prefix cannot collide: every module route
begins `/api/v1/modules/`, and nothing published here does.
It stays inside `/api/v1/*` so it inherits the application's single CORS policy — exact reflected
trusted origins, never a wildcard — which a cross-origin browser `import()` of this URL requires. A
new top-level prefix would have meant a second, separately-configured public URL space.
`moduleUiArtifactUrl({ moduleId, version })` (`apps/api/src/module-ui-routes.ts`) is the one
construction of that URL. It accepts **identity only** — no filename, no relative path, no artifact
selector — and percent-encodes both components, so even an unvalidated value can only ever become
one segment's worth of text, never a new segment, a traversal, a query, or a fragment. The result is
root-relative: a same-origin host passes it to `loadModuleUi` unchanged, and a split-origin host
resolves it against the same API base URL it already needs for `ModuleUiApiClient`. Baking in an
absolute origin would have broken reverse proxies and split-origin deployments alike.
### Version is part of the path, and a mismatch fails
A request naming a version other than the installed one resolves to nothing. It is never satisfied
with "the version that happens to be installed", which would let a stale host mount an artifact it
did not ask for — the exact failure `parseModuleUiExport`'s identity check exists to catch, arriving
one layer earlier and more cheaply.
Version in the path also means an upgrade changes the URL, so a cached artifact from the previous
version can never be served for the new one.
### Cache: revalidate always, with a strong `ETag`
`Cache-Control: no-cache` plus a SHA-256 `ETag` over the bytes actually being served, and RFC 9110
`If-None-Match` handling (wildcard, weak comparison, `304`).
`immutable` was tempting and is unearned. The URL carries *identity*, not *immutability*: Ekklesia
has no installer, no content addressing, and no integrity verification, so nothing stops an operator
from rebuilding version `1.2.3` with different bytes and restarting — which is the ordinary inner
loop of developing a module. Under `immutable`, a browser holding a superseded artifact would have
no way to recover.
`no-cache` is not `no-store`: the response is cached and merely revalidated, so an unchanged artifact
costs one conditional request and a `304`. Hashing the bytes rather than trusting an mtime also means
the validator can never describe a different revision than the body does. When an installer can
guarantee that an installed version's bytes never change, one constant moves and the policy becomes
immutable caching.
### No authentication, decided rather than defaulted
Retrieving the artifact confers no authority. The bundle is compiled first-party UI code an operator
reviewed and installed; it carries no credential, and the host — not the artifact — supplies the
principal and permissions at mount time. Every module API stays behind `requirePermission` against
the composed platform registry, and fetching this file changes no authorization decision anywhere.
Requiring a session would also break the thing it purports to protect. A browser's dynamic `import()`
uses `credentials: "same-origin"`, so a cross-origin import — the split-origin deployments
`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` exists to support — sends no cookie and would fail. That failure
tests green on a server and only appears in a real browser, which is precisely the class of error ADR
0024 was written to prevent. Working around it means fetching bytes and importing a `blob:` URL,
which costs a host its `script-src` CSP.
**The cost, stated plainly: an anonymous caller who knows a module id *and* its exact installed
version can download that module's UI bundle, and thereby learn that the module is installed.** That
is information disclosure, not authority. Two things bound it: the caller must already know both
halves of the identity, and unknown-module, no-UI, and wrong-version all return one byte-identical
`404 MODULE_UI_NOT_PUBLISHED`, so the route is not an enumeration oracle. An operator who needs the
bundle itself confidential must not expose the API to untrusted networks — which
`docs/development/security.md` already assumes for this deployment model. If a future deployment
model needs more, an `authenticated` baseline belongs on this route and nowhere else.
### Only the declared artifact, and never the source map
`@ekklesia/module-ui-build` emits `sourcemap: true`, so a real `.map` file sits next to every built
artifact. It is not published, for the same reason `.css` sidecars are rejected at build time (ADR
0023): the manifest declares the UI entrypoint and nothing else, so nothing else has a publication
contract. A browser's devtools will request `<artifact>.map` and receive a `404`; that is the correct
answer, not a gap. Source maps embed `sourcesContent`, and publishing an installation's module
sources unauthenticated as an accident of "the file was next to the one we meant" is exactly what a
narrow route prevents.
### The response is bytes, and the server never runs them
The route reads the file and writes it back: no import, no evaluation, no transformation, no
minification, no re-bundling, no rewriting. A module UI whose top level throws is served exactly like
any other. ADR 0021's rule that discovery never executes `entrypoints.ui` is unchanged, and
publication does not become a second, quieter way to execute browser code on the server.
`Content-Type: text/javascript; charset=utf-8` — the canonical JavaScript type, which a browser
requires before it will evaluate an ES module — with `X-Content-Type-Options: nosniff`, so the
declared type is the only type.
### Discovery-time validation plus request-time exact-path read is the trust model
Discovery validated the artifact at startup; the file may change or vanish before any HTTP retrieval.
The route does not pretend otherwise. At request time it opens exactly the previously resolved path,
once. A read failure produces a sanitized `503 MODULE_UI_ARTIFACT_UNAVAILABLE` carrying no path, no
`errno`, and no caught error — there is deliberately no fallback path, no re-resolution, and no retry
against a reconstructed location, so a vanished artifact fails rather than quietly resolving to a
neighbour.
No hashing, signing, or integrity pinning is introduced. Under the current model an installed module
package is trusted server code an operator placed on disk (`docs/development/security.md`), and a
platform that cannot verify provenance at install time gains nothing from verifying it at request
time. Symlink safety is likewise not re-implemented: discovery already proved containment before and
after resolution, and this route consumes that canonical result rather than re-deriving it from
request input.
## Alternatives
- **`GET /api/v1/modules/:moduleId/ui`, under the module API prefix.** Rejected on a concrete
collision, not on taste: `ui` is a legal module route segment, so a module could register the same
path and routing order would decide who answers.
- **`GET /modules/:moduleId/assets/*path`.** Rejected outright — it is the generic static root this
ADR exists to forbid, and it would invent an undeclared asset contract with no manifest field, no
build step, and no loader behind it.
- **A new top-level prefix (`/_ekklesia/...`).** Rejected: the repository has exactly one public API
namespace, and a second one would need its own CORS, cache, and error conventions to be
re-decided and kept in sync.
- **Unversioned URLs (`/api/v1/module-ui/:moduleId`).** Rejected: it forces conservative caching for
every deployment to survive an upgrade, and it discards identity the host already has and
`parseModuleUiExport` will check anyway.
- **`Cache-Control: public, max-age=31536000, immutable`.** Rejected: version reuse is possible and
unrecoverable under it. Revisit when an installer makes an installed version's bytes immutable.
- **`Cache-Control: no-store`,** matching every other route in the application. Rejected as
needlessly strict: those routes carry personalized or credentialed data, and this one is the same
bytes for every caller. `no-cache` with a validator is both correct and cheap.
- **Requiring an authenticated principal.** Rejected on the concrete grounds above — no authority is
conferred, and cookie-less cross-origin `import()` would break. It is the change to make first if
the deployment model ever requires confidentiality.
- **Requiring a permission.** Rejected more firmly: it would mean inventing a Core permission whose
only effect is on a file that grants nothing, and it puts an authorization decision in front of an
asset while implying the asset is itself protected.
- **Publishing source maps alongside the artifact.** Rejected: undeclared, and `sourcesContent`
would expose module sources to any caller.
- **Streaming the file instead of buffering it.** Rejected as unjustified complexity for one small
artifact — and the `ETag` has to hash the bytes anyway, so buffering is what makes the validator
describe exactly what was sent.
- **A shared `@ekklesia/module-ui-publication` package for the URL contract.** Rejected: one
producer, one consumer, and a future Dashboard will receive the URL from server metadata rather
than importing a package to rebuild it.
- **Teaching `@ekklesia/module-ui-host` how Ekklesia publishes artifacts.** Rejected, again: it is
exactly the coupling ADR 0024 refused, and it would make the standalone host's problem Ekklesia's.
- **Request-time integrity verification (hash or signature).** Rejected as infrastructure without a
root of trust: nothing produces a trusted digest at install time, so the check would only compare
the file to itself.
## Consequences
A validated installed module's UI artifact can now be fetched by a browser from a platform-controlled
URL, and the bytes that URL returns are the bytes `@ekklesia/module-ui-host` mounts — proven by
feeding the route's own HTTP response into the real loader.
**This is not Dashboard integration.** Nothing in Ekklesia mounts a module UI: there is no shell, no
navigation, no place a module's screen appears, and no code calls `moduleUiArtifactUrl` outside its
own tests. What changed is that a host now *could* obtain a URL, which was previously impossible.
`createApiApp` gained a required `moduleUi` option. Required rather than defaulted, matching
`moduleRoutes`: a caller that publishes no module UI says so with an empty registry instead of
falling through to an implicit one.
Node cannot `import()` an `http:` URL, so the end-to-end chain is proven in two honest halves rather
than one misleading whole: the route's publication behaviour is proven over real HTTP (both through
`app.request` and through a real socket against a started API), and loader compatibility is proven by
taking the bytes **that route returned** and importing them as a `data:` URL — a genuine module URL,
the same technique ADR 0024's own tests use. No test imports the artifact from the filesystem and
calls that a proof of HTTP publication.
## Deferred
Not implemented or promised here: **the Dashboard host** — the shell, routing, navigation, grouping,
and the code that actually calls `loadModuleUi` with a published URL — which is the next pass; a
module metadata endpoint that hands a host the list of installed modules and their UI URLs (the URL
helper exists for it, but no route returns one); a design system, theme tokens, or component library;
stylesheet, image, font, and general module asset publication, along with the manifest contract each
would need; source-map publication; React runtime sharing; a standalone application host; module
installation, upload, marketplace, hot loading, signing, or provenance; enable/disable or trust state
for installed modules; and every real feature module.
The permission-id typing gap carried over from ADR 0022 is likewise unchanged: `permissions.has`
takes a plain string, so a typo silently hides a control.