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

17 KiB

0025: Trusted module UI asset publication

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. 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.

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

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.