feat: publish validated module UI artifacts at a platform-owned URL
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 41s

Phase 6C pass 1. ADR 0024 named the one thing missing between the module UI
build pipeline and the generic host loader: Core resolves entrypoints.ui to an
absolute server filesystem path, and a browser needs a URL. This adds that
publication layer.

Core owns the policy. createModuleUiPublicationRegistry builds, from the
discovered and started module inventory, the answer to "may this be published,
and which exact file is it?" — publishable means the module survived discovery
and declares entrypoints.ui, with no new lifecycle state and no installer.

apps/api owns the transport: GET /api/v1/module-ui/<module-id>/<version>. Both
segments are lookup keys, not path components. An exact match yields the
canonical artifact path discovery already validated; no request value is ever
composed into a filesystem path, and the route declares no wildcard segment, so
traversal, encoded separators, sibling chunks, package-root files, and source
maps are unreachable by construction. An installed module directory never
becomes a static file root.

The prefix is a sibling of /api/v1/modules/, not a child: `ui` is a legal module
route segment, so a module registering GET /ui/:version would otherwise compete
with the platform for one URL with routing order deciding the winner.

The route requires no authentication, deliberately. Fetching the bundle confers
no authority, and a cookie-gated route would break cross-origin import(), which
sends none. The cost is stated rather than hidden: a caller who knows a module
id and its exact version can download that bundle. The three failure modes
return one identical 404 so the route is not an enumeration oracle.

Caching is no-cache with a strong SHA-256 ETag and RFC 9110 If-None-Match
handling, not immutable: without an installer nothing stops a version being
rebuilt with different bytes. The server reads bytes and writes bytes — never
imports, evaluates, or transforms the artifact.

Nothing mounts a module UI. A host could now obtain a URL, which was previously
impossible; there is still no Dashboard shell, navigation, or metadata endpoint.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-22 08:11:53 -05:00
co-authored by Claude Opus 5
parent 82cbeef437
commit a02deb9433
20 changed files with 1898 additions and 30 deletions
+27 -4
View File
@@ -216,10 +216,33 @@ host will use: dynamic import from a browser URL, identity validated against the
mounting, a host-owned container the loader never mutates, and an idempotent unmount. Modules may not
depend on it.
The distinction that shapes the next pass: Core resolves `entrypoints.ui` to a **server filesystem
path**, and a browser needs a **URL**. Turning an installed artifact into a browser URL is asset
publication — host-specific, deliberately outside the loader, and not implemented. Until it exists,
no host can mount an installed module UI.
**Phase 6C pass 1 publishes a validated module UI artifact at a browser URL.**
[ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md) closes the gap the previous pass
named: Core resolves `entrypoints.ui` to a **server filesystem path**, and a browser needs a **URL**.
Core owns the policy and `apps/api` owns the transport. `createModuleUiPublicationRegistry`
(`core/src/modules/ui-publication.ts`) answers "may this be published, and which exact file is it?"
from the discovered, started module inventory — publishable means the module survived discovery
*and* declares `entrypoints.ui`, no new lifecycle state and no installer. `apps/api` serves
`GET /api/v1/module-ui/<module-id>/<version>`, where both segments are **lookup keys, not path
components**: an exact match yields the canonical artifact path discovery already validated, and no
request value is ever composed into a filesystem path. There is no wildcard segment, so traversal,
sibling chunks, package-root files, and source maps are unreachable by construction — an installed
module directory never becomes a static file root. The prefix is a sibling of `/api/v1/modules/`
rather than a child because `ui` is a legal module route segment and a module could otherwise collide
with the platform.
The route requires no authentication: the bundle confers no authority, every module API stays behind
`requirePermission`, and a cookie-gated route would break cross-origin `import()`, which sends none.
The disclosure that buys — a caller who knows a module id and its exact version can download that
bundle — is stated rather than papered over, and the three failure modes are made indistinguishable
so the route is not an enumeration oracle. Caching is `no-cache` with a strong `ETag`, not
`immutable`, because without an installer a version can be rebuilt with different bytes. The server
reads and writes bytes; it never imports, evaluates, or transforms the artifact.
**A host could now obtain a URL, and nothing uses one.** There is no Dashboard shell, no navigation,
no module metadata endpoint, and no code that calls the URL helper outside its own tests. A design
system, stylesheet and general asset publication, and the standalone host remain future work.
Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model
are all unchanged.