feat: close the browser boundary and add the generic UI host loader
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:
co-authored by
Claude Opus 5
parent
5d75d910fd
commit
44e676f0df
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user