# 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. ## 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// ``` Nesting it under `/api/v1/modules//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//ui/`. 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 `.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.