feat: close the browser boundary and add the generic UI host loader
CI / verify (pull_request) Successful in 36s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 41s

Two gaps stood between the module UI contract and a host that could use
it. ADR 0023 recorded the first honestly but never diagnosed it: a module
UI file compiled with `"types": []` could still typecheck `process.env`.
The second was simply missing — nothing imported a compiled artifact,
checked its identity, mounted it, or tore it down.

The leak was one type-only import, bisected against the real type graph
rather than guessed. `@ekklesia/module-ui-sdk` imported
`type { EkklesiaPrincipal } from "@ekklesia/contracts"`. Importing
contracts/identity alone is clean and importing Zod alone is clean, but
contracts exposes a single barrel entry, so that one import reached
module-database, whose Drizzle types re-introduce Node's globals. The
runtime was never affected — the import was erased and host-context.js is
`export {}` — but a type-graph leak is still a real boundary failure,
because it silently permits code that cannot run in its target
environment.

The fix is not a narrower import path. The SDK now has no dependencies at
all, so there is no graph to leak through and no future contracts change
can reopen this. It owns `ModuleUiPrincipal`: the same shape ADR 0022
chose, with the Zod brand dropped. That brand encodes a server-side
parsing guarantee a browser cannot produce, and it is the same reason
`permissions.has` already took a plain string. An Ekklesia host still
passes its own `EkklesiaPrincipal` straight through — a branded string is
assignable to `string` — while a standalone host builds the literal.
Proven the way an author experiences it: two fixtures compiled under the
exact project a module uses, one supported and one deliberately reaching
for `process`, with the second required to fail on that specific name.

`@ekklesia/module-ui-host` is the generic loader, in its own package
because the SDK is bundled into every module artifact and host-only
loader code has no business shipping there. The boundary checker enforces
both directions: modules may not depend on the loader, and the loader may
depend only on the SDK plus dev-time tooling.

The distinction that shaped the design: Core resolves `entrypoints.ui` to
an absolute path on the server's disk, and a browser needs a URL. A
loader that accepted the path would test green on a server and fail in
every real host, and would quietly own asset publication. So the loader
takes a browser-loadable URL and knows nothing about how one is obtained.
Ekklesia has no route that publishes installed artifacts, so this is
precisely: loader complete, publication deferred. Nothing can mount an
installed module UI yet, and the docs say so rather than implying
otherwise.

Dynamic import is `(url) => import(url)` with a non-analyzable specifier,
kept as a runtime import so a host does not statically bundle every
installed module or need a hardcoded registry. It is injectable for
bundlers needing their own escape hatch, and deliberately not `eval` or
`new Function`, which would cost a host `unsafe-eval` in its CSP.

Identity is validated through the SDK's existing parser before `mount`
runs, so a stale or mismatched bundle never renders. Contract violations
propagate unwrapped; the loader adds only import and mount failure codes,
sanitized, with the cause attached. Unmount is idempotent, safe when a
module returned no cleanup, and marks an instance unmounted before
running cleanup so a throwing cleanup surfaces once and never re-runs. A
failed mount registers no cleanup. Each instance owns its own state, so
two mounts coexist. The loader never mutates the host's container — not
on success, failure, or unmount.

Tests prove all of it against real module URLs (`data:`, which both
browsers and Node import natively) rather than filesystem paths,
including one artifact built by the canonical Pass 2 pipeline and run
under both an Ekklesia-shaped and a standalone-shaped context. Core,
apps/api, and the contracts package are untouched; discovery remains
inert.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-21 21:44:42 -05:00
co-authored by Claude Opus 5
parent 5d75d910fd
commit 44e676f0df
40 changed files with 1286 additions and 44 deletions
@@ -87,6 +87,14 @@ components, no layout, no design system, no router.
### The host context is deliberately four members
> **Partial correction — see [ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md).**
> `principal` is no longer typed as Core's `EkklesiaPrincipal`. That import made this package depend
> on `@ekklesia/contracts`, whose barrel pulled Drizzle's types — and Node's globals — into every
> browser program that imported the SDK. The context now uses a browser-owned `ModuleUiPrincipal`
> with the same shape and an unbranded `userId`, and the SDK has no dependencies at all. The four
> members and everything else in this section are unchanged.
```ts
interface ModuleUiHostContext {
readonly moduleId: string;
@@ -170,6 +170,13 @@ through two independent paths.
## Consequences
> **Cross-reference — see [ADR 0024](0024-browser-safe-ui-sdk-and-host-loader.md).**
> The Node-globals leak this ADR recorded under "Browser TypeScript stays scoped to UI source" is
> now diagnosed and closed: it was a single type-only import of `@ekklesia/contracts` in the UI SDK,
> whose barrel reached Drizzle's types. The SDK now has no dependencies, and a compile test proves a
> module UI file importing it cannot use `process`. Everything else in this ADR is unchanged.
A module author can now write React and TypeScript, run their normal build, and get exactly the
artifact their manifest declares — with no bundler configuration of their own. The end-to-end path
from ADR 0021's static field through ADR 0022's contract to a mounted, unmountable UI is closed and
@@ -0,0 +1,199 @@
# 0024: Browser-safe module UI SDK and the generic host loader
- Status: Accepted
- Date: 2026-08-21
- Extends: [0022](0022-module-ui-authoring-and-host-contract.md),
[0023](0023-module-ui-build-pipeline.md)
- Supersedes (partial): [0022](0022-module-ui-authoring-and-host-contract.md) — only its decision to
type `ModuleUiHostContext.principal` as Core's `EkklesiaPrincipal`. Every other part of that ADR —
the mount-function ABI, the four-member context, module-relative API access, and the trust model —
remains accepted and unchanged.
## Context
Two gaps stand between the module UI contract and a host that could actually use it.
**The browser boundary had a hole.** ADR 0023 recorded it honestly: a module UI file compiled with
`"types": []` should not see Node globals, yet `process.env` typechecked. The cause was never
diagnosed beyond "transitively, through dependency type graphs."
**Nothing loads a module UI.** ADR 0022 defined `parseModuleUiExport` for a host to call, and ADR
0023 made the artifact buildable, but no code imports an artifact, checks its identity, mounts it, or
tears it down. Every future host — the Ekklesia Dashboard and a standalone host alike — needs that
lifecycle, and it must not be invented twice.
## Decision
### The leak was one type-only import, and it is removed by deleting the dependency
Bisected against the real type graph rather than inferred: `@ekklesia/module-ui-sdk` imported
`type { EkklesiaPrincipal } from "@ekklesia/contracts"`. Importing `contracts/identity` alone is
clean, and importing Zod alone is clean — but `@ekklesia/contracts` exposes a single barrel entry,
so that one import pulled in `module-database`, whose Drizzle types re-introduce Node's global
declarations. A browser file importing the SDK therefore inherited `process`.
The runtime was never affected: the import was type-only and the emitted `host-context.js` is
`export {};`. This was purely a type-graph leak — and a type-graph leak is still a real boundary
failure, because it silently permits code that cannot run in the target environment.
The fix is not a narrower import path. `@ekklesia/module-ui-sdk` now has **no dependencies at all**,
so there is no graph to leak through and no future contracts change can reopen this. That is the
property worth having: the boundary holds because the package is empty of external references, not
because today's import happens to resolve to something clean.
### A browser principal is not a server principal
`ModuleUiPrincipal` is now defined by the SDK: `{ kind: "anonymous" }` or
`{ kind: "authenticated", userId: string }`. The shape ADR 0022 chose is preserved; only the brand is
dropped.
That is a deliberate contract change, not a convenience. Core's `EkklesiaUserId` is a Zod-branded
string, and the brand encodes a *server-side parsing* guarantee that a browser cannot produce without
running Zod — which ADR 0022 already identified as the reason `permissions.has` takes a plain string.
A browser-facing identity should be plain and serializable; a server type that drags an ORM's types
behind it is not a browser contract merely because it is convenient to reuse.
The two remain structurally compatible: a branded string is assignable to `string`, so an Ekklesia
host passes its own `EkklesiaPrincipal` straight into the context with no conversion, while a
standalone host builds the same object literal from whatever identity system it has. No field was
added. Nothing server-internal is exposed to gain type reuse.
### The generic loader lives in its own package
`@ekklesia/module-ui-host` (new shared role `module-ui-host`) owns the load-and-mount lifecycle. It
is not part of `@ekklesia/module-ui-sdk`, because the two have opposite audiences: the SDK is bundled
into **every module artifact**, so putting host-only loader code there would ship it inside every
module that never runs it and blur the author/host split. `pnpm boundaries` enforces the separation
in both directions — the `module-ui-host` role is deliberately absent from the roles a module may
depend on, and the loader itself may depend only on the SDK (plus dev-time `tooling`), never on a
module, an app, a surface, or the contracts barrel.
Dependency direction is therefore: module UI → SDK; host → loader → SDK. Never module → loader, and
never SDK → anything.
### A browser module URL is not a filesystem path, and this is the pass's central distinction
Core's discovery resolves `entrypoints.ui` to an **absolute path on the server's disk**
(`ResolvedModuleEntrypoints.ui`). A browser cannot import that. A loader that accepted one would look
correct in a server-side test and fail in every real host.
So `loadModuleUi` takes a `url` — a browser-loadable module URL — and this package deliberately does
not know how one is obtained. Turning an installed artifact into a URL a browser may fetch is **asset
publication**: a separate, host-specific concern. Ekklesia will need a trusted route that serves an
installed module's compiled artifact; a standalone host may publish the same file from a CDN, a
static directory, or its own bundle. Making the loader own publication would weld it to one host and
recreate exactly the coupling ADR 0022 exists to prevent.
**Ekklesia has no such route today.** The loader is complete; publication is deferred and is the
first thing the next pass must build. Nothing here should be read as "a host can mount an installed
module now" — it cannot, because it has no way to obtain a URL.
### Dynamic import is injectable, and never `eval`
The default importer is `(url) => import(url)` with a non-analyzable specifier, which esbuild and
Rollup leave as a run-time import rather than resolving at build time. That is required, not
incidental: installed modules are unknown when a host is built, and a statically-resolved import
would either fail or force a hardcoded registry of module imports — defeating independently
installable modules entirely.
`importModule` is injectable so a host whose bundler needs a specific escape hatch (Vite's
`@vite-ignore`, webpack's `webpackIgnore`) supplies a one-line importer instead of this package
guessing. It is deliberately **not** implemented with `new Function` or `eval`: those defeat static
analysis reliably but require `unsafe-eval` in a Content-Security-Policy, and loading a module should
never cost a host its CSP.
### Identity is verified before anything mounts
The loader calls the SDK's existing `parseModuleUiExport(namespace, { id, version })` — it does not
reimplement shape or identity checking. A missing default export, a malformed definition, a wrong
module id, or a wrong version throws `ModuleUiContractError` with its specific code, **before**
`mount` is called, so a stale or mismatched bundle can never render or execute module logic.
Contract violations propagate unwrapped; the loader adds only two codes of its own,
`MODULE_UI_IMPORT_FAILED` and `MODULE_UI_MOUNT_FAILED`, whose messages carry no URL or caught text.
The originating error is attached as `cause` for host diagnostics.
### Lifecycle: idempotent unmount, no hidden state, host owns the container
`loadModuleUi` returns a `MountedModuleUi` that owns its own cleanup. There is no cache, registry, or
singleton, so a module may be mounted into several containers at once and unmounting one leaves the
others untouched.
`unmount()` is idempotent: the first call runs the module's cleanup, later calls do nothing, and it
is safe when the module returned no cleanup at all. An instance is marked unmounted *before* cleanup
runs, so a throwing cleanup surfaces to the host exactly once and never re-runs.
A failed `mount` registers no cleanup, so no handle can claim a failed module is mounted. The loader
never mutates the container — not on success, not on failure, not on unmount. The host owns the
container; the module owns its contents; deciding what a half-rendered failure should look like is
the host's call, not the loader's.
### Mounting executes trusted code in the host's page
Importing a module UI artifact runs ordinary same-realm JavaScript: it can reach the DOM, globals,
and anything else the page can. **The loader is not a sandbox and does not become one by validating
identity.** Identity validation prevents mounting the *wrong* module; it says nothing about whether a
module is safe.
Publishing an artifact must never be read as evidence that it is safe. Only installed, reviewed
modules may be published or mounted under the current trust model, and asset publication must be
restricted accordingly when it is built. Server APIs remain the sole authority for authorization: a
mounted module UI has gained no privilege it did not already have.
## Alternatives
- **Narrowing the import to a `@ekklesia/contracts` subpath.** Rejected. It would work today —
`identity` alone is clean — but it makes the browser boundary depend on which types a backend
package happens to co-locate, and any future edit could reopen the hole silently.
- **A new `@ekklesia/client-contracts` package.** Rejected: only one package originates these types,
and the loader receives them transitively through the SDK. A package for two interfaces would be
structure without a second consumer to justify it.
- **Keeping `EkklesiaPrincipal` and adding Node types to the UI tsconfig.** Rejected outright: that
hides the error instead of fixing the graph, and hands browser code a `process` that does not
exist at run time.
- **Putting the loader in `@ekklesia/module-ui-sdk`.** Rejected: it would ship host-only code inside
every module artifact and erase the author/host distinction the roles exist to express.
- **A loader that accepts Core's resolved filesystem path.** Rejected as the most tempting wrong
answer: it would test green on a server and fail in every browser, and it would quietly make the
loader responsible for asset publication.
- **Implementing publication here to make the loader "complete".** Rejected: it is host-specific, it
needs an authorization decision about who may fetch an artifact, and bundling it in would couple
the generic loader to Ekklesia. Deferred explicitly instead of half-built.
- **`new Function("return import(u)")` to defeat bundler analysis.** Rejected: requires `unsafe-eval`
in a CSP. An injectable importer achieves the same result without that cost.
- **Throwing on a second `unmount()`.** Rejected: teardown races are ordinary in UI hosts, and an
idempotent unmount lets a host clean up without tracking whether it already did.
- **Clearing the container on mount failure.** Rejected: the loader promises never to mutate the
host's container, and a single unconditional rule is easier to reason about than one with an
exception.
## Consequences
A module author importing the supported SDK now provably cannot reach Node globals — proven by
compiling both a supported fixture and a deliberately-failing one under the exact project a module
uses. And a host implementer has one narrow, tested lifecycle to call instead of writing dynamic
import, identity checking, and teardown themselves.
The cost is that this is still infrastructure without a consumer. **Ekklesia cannot mount an
installed module UI**, because nothing serves an installed artifact at a browser URL. The loader is
proven against `data:` URLs built from real artifacts, which is a genuine module URL rather than a
filesystem path — but a real host needs a real route.
`ModuleUiPrincipal` is also a public type change. Nothing outside this repository consumes it yet, so
the migration cost is zero today, and an Ekklesia host's `EkklesiaPrincipal` remains assignable to it
without conversion.
## Deferred
Not implemented or promised here: **module UI asset publication** — the trusted route that turns an
installed module's compiled artifact into a browser URL, including who may fetch it — which is the
prerequisite for any real host; the Dashboard shell, navigation, or grouping; a design system, theme
tokens, or component library; stylesheet and asset loading; React runtime sharing; a standalone
application host; module installation, marketplace, or hot loading; and every real feature module.
`entrypoints.jobs` still accepts source TypeScript where other executable entrypoints require
compiled output. This pass did not centralize compiled-entrypoint validation, so that inconsistency
is unchanged and still open. The permission-id typing gap (`permissions.has` takes a plain string, so
a typo silently hides a control) is likewise unchanged: the browser-safe split did not produce a
clean answer for it, and inventing one would have meant shipping generated constants this phase does
not need.
+1
View File
@@ -32,3 +32,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0021: Modules as independently hostable products](0021-modules-as-hostable-products.md) — Accepted
- [0022: Module UI authoring model and host contract](0022-module-ui-authoring-and-host-contract.md) — Accepted
- [0023: Module UI build pipeline](0023-module-ui-build-pipeline.md) — Accepted
- [0024: Browser-safe module UI SDK and the generic host loader](0024-browser-safe-ui-sdk-and-host-loader.md) — Accepted