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
+1
View File
@@ -0,0 +1 @@
export * from "./load-module-ui.js";
@@ -0,0 +1,156 @@
import {
type ModuleUiDefinition,
type ModuleUiHostContext,
type ModuleUiUnmount,
parseModuleUiExport,
} from "@ekklesia/module-ui-sdk";
/**
* How a host obtains a module's UI artifact.
*
* `url` is a **browser-loadable module URL**, not a filesystem path. That distinction is the whole
* reason this type exists. Core's module discovery resolves `entrypoints.ui` to an absolute path on
* the server's disk (`ResolvedModuleEntrypoints.ui`); a browser cannot import that, and a host that
* passed one here would fail at run time in a way no test on a server would catch. Turning an
* installed artifact into a URL a browser may fetch is **asset publication**, a separate concern
* this package deliberately does not own and Ekklesia does not implement yet (ADR 0024).
*
* `moduleId` and `version` are the identity the host already read from the module's validated
* manifest. They are checked against the artifact before anything is mounted.
*/
export interface ModuleUiSource {
readonly moduleId: string;
readonly version: string;
readonly url: string;
}
/**
* How the artifact is actually imported.
*
* Injectable because bundlers disagree about dynamic `import()`. The default is a plain
* `import(url)` with a non-analyzable specifier, which esbuild and Rollup leave as a run-time import
* rather than trying to resolve at build time — the behaviour a host needs, since installed modules
* are not known when the host is built. A host whose bundler needs a specific escape hatch (Vite's
* `/* @vite-ignore *\/`, webpack's `webpackIgnore`) supplies its own one-line importer instead of
* this package guessing.
*
* Deliberately not implemented with `new Function` or `eval`: those defeat static analysis reliably
* but require `unsafe-eval` in a Content-Security-Policy, and a host should not have to weaken its
* CSP to load a module.
*/
export type ModuleUiImporter = (url: string) => Promise<unknown>;
const defaultImporter: ModuleUiImporter = (url) => import(url);
export interface LoadModuleUiOptions {
readonly source: ModuleUiSource;
/** The element the module renders into. The host owns it; the module owns its contents. */
readonly container: Element;
/** Supplied whole by the host. This package forwards it and never adds to it. */
readonly context: ModuleUiHostContext;
readonly importModule?: ModuleUiImporter;
}
/** One mounted instance. Each `loadModuleUi` call returns its own; nothing is shared or cached
* here, so a module may be mounted into several containers at once if its own implementation
* supports it. */
export interface MountedModuleUi {
readonly moduleId: string;
readonly version: string;
/** Whether cleanup is still pending. Becomes `false` after the first {@link unmount}. */
readonly mounted: boolean;
/**
* Runs the module's cleanup, if it returned one. Idempotent: the first call cleans up, later
* calls do nothing. Safe when the module returned no cleanup function.
*/
unmount(): Promise<void>;
}
export type ModuleUiHostErrorCode = "MODULE_UI_IMPORT_FAILED" | "MODULE_UI_MOUNT_FAILED";
const errorMessages: Readonly<Record<ModuleUiHostErrorCode, string>> = Object.freeze({
MODULE_UI_IMPORT_FAILED: "A module UI artifact could not be imported from its published URL.",
MODULE_UI_MOUNT_FAILED: "A module UI artifact failed while mounting.",
});
/**
* Carries a stable code and a fixed message, never the artifact URL or a caught cause, matching
* every other Ekklesia error boundary. The originating error is attached as `cause` for a host's
* own diagnostics, and is never part of the message.
*/
export class ModuleUiHostError extends Error {
readonly code: ModuleUiHostErrorCode;
constructor(code: ModuleUiHostErrorCode, options?: { readonly cause?: unknown }) {
super(errorMessages[code], options);
this.name = "ModuleUiHostError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
}
}
/**
* Imports a published module UI artifact, verifies it is the module the host expected, and mounts
* it into the host's container.
*
* **This executes trusted first-party code in the host's own page.** A module UI artifact is
* ordinary same-realm JavaScript once imported: it can reach the DOM, globals, and anything else the
* page can. Nothing here is a sandbox, and publishing an artifact must never be read as evidence
* that it is safe — only installed, reviewed modules should ever be published or mounted. Server
* APIs remain the sole authority for authorization; a module UI that is mounted has gained no
* privilege it did not already have (ADR 0022, ADR 0024).
*
* Nothing is mounted until the artifact's declared identity matches the manifest identity the host
* passed in, so a stale or mismatched bundle fails before it can render or run `mount`.
*/
export async function loadModuleUi(options: LoadModuleUiOptions): Promise<MountedModuleUi> {
const { container, context, source } = options;
const importModule = options.importModule ?? defaultImporter;
let namespace: unknown;
try {
namespace = await importModule(source.url);
} catch (error) {
throw new ModuleUiHostError("MODULE_UI_IMPORT_FAILED", { cause: error });
}
// The one contract parser, reused rather than reimplemented: it validates the default export's
// shape and that its id/version match what the host expected, and throws `ModuleUiContractError`
// with a specific code when either fails. That error propagates unwrapped — a malformed artifact
// is a contract violation, not a host failure.
const definition: ModuleUiDefinition = parseModuleUiExport(namespace, {
id: source.moduleId,
version: source.version,
});
let cleanup: ModuleUiUnmount | undefined;
try {
const result = await definition.mount(container, context);
if (typeof result === "function") {
cleanup = result;
}
} catch (error) {
// No cleanup is registered for a mount that never completed, so the returned handle can never
// claim a failed module is mounted. The container is deliberately left untouched: the host owns
// it and decides what a failed mount should look like.
throw new ModuleUiHostError("MODULE_UI_MOUNT_FAILED", { cause: error });
}
let mounted = true;
return {
moduleId: definition.id,
version: definition.version,
get mounted() {
return mounted;
},
async unmount() {
if (!mounted) {
return;
}
// Marked unmounted before running cleanup, so a throwing cleanup surfaces to the host once
// and a second call never re-runs it.
mounted = false;
await cleanup?.();
},
};
}