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]>
200 lines
12 KiB
Markdown
200 lines
12 KiB
Markdown
# 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.
|