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]>
272 lines
17 KiB
Markdown
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.
|