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
@@ -0,0 +1,38 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
/**
* The host loader runs in a browser too, so it inherits the same rule as the authoring SDK: it must
* not reach a backend type graph. Its only dependency is the module-author contract (ADR 0024).
*/
describe("module UI host loader stays browser-safe", () => {
it("declares exactly one runtime dependency: the module UI SDK", async () => {
const manifest = JSON.parse(
await readFile(path.join(packageDirectory, "package.json"), "utf8"),
) as { dependencies?: Record<string, string>; peerDependencies?: Record<string, string> };
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/module-ui-sdk"]);
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual([]);
});
it("imports nothing beyond the module UI SDK in its source", async () => {
const source = await readFile(path.join(packageDirectory, "src", "load-module-ui.ts"), "utf8");
const external = [...source.matchAll(/from\s+"([^"]+)"/gmu)]
.map(([, specifier]) => specifier)
.filter((specifier) => specifier !== undefined && !specifier.startsWith("."));
expect([...new Set(external)]).toEqual(["@ekklesia/module-ui-sdk"]);
});
it("never names a Node built-in or an Ekklesia URL prefix", async () => {
const source = await readFile(path.join(packageDirectory, "src", "load-module-ui.ts"), "utf8");
expect(source).not.toContain('"node:');
// The loader forwards a host-supplied URL; it must never construct one.
expect(source).not.toMatch(/["'`]\/api\/v1\//u);
});
});
@@ -0,0 +1,14 @@
{
"manifestVersion": 4,
"id": "example",
"name": "Example",
"version": "0.1.0",
"compatibility": {
"platform": "^0.1.0",
"moduleSdk": "^0.1.0"
},
"entrypoints": {
"server": "./dist/server/index.js",
"ui": "./dist/ui/index.js"
}
}
@@ -0,0 +1,21 @@
import { defineModuleUi } from "@ekklesia/module-ui-sdk";
/**
* A real module UI compiled by the canonical pipeline. It imports the public SDK so the built
* artifact proves the whole loop closes: SDK source -> esbuild bundle -> browser URL -> host loader.
*
* React is deliberately absent — the module template already proves the React path end to end, and
* omitting it keeps this artifact small enough to serve as a `data:` URL.
*/
export default defineModuleUi({
id: "example",
version: "0.1.0",
async mount(container, context) {
const response = await context.api.request("records");
const label = context.principal.kind === "authenticated" ? context.principal.userId : "guest";
container.textContent = `${context.moduleId}:${response.status}:${label}`;
return () => {
container.textContent = "";
};
},
});
@@ -0,0 +1,309 @@
// @vitest-environment happy-dom
import { ModuleUiContractError, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { describe, expect, it } from "vitest";
import { loadModuleUi, ModuleUiHostError, type ModuleUiImporter } from "../src/index.js";
/**
* A browser-loadable URL for a module built from source text.
*
* `data:` URLs are genuine module URLs that both browsers and Node import natively, so these tests
* exercise the loader's **default** `import(url)` against a real URL rather than a filesystem path.
* That matters: Core resolves `entrypoints.ui` to a server filesystem path, and a loader proven only
* against such a path would not prove anything a browser host could rely on (ADR 0024).
*/
function moduleUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
}
const validArtifact = (options: { id?: string; version?: string; cleanup?: boolean } = {}) => `
export default {
id: ${JSON.stringify(options.id ?? "announcements")},
version: ${JSON.stringify(options.version ?? "1.2.0")},
mount(container, context) {
globalThis.__mounts ??= [];
globalThis.__mounts.push({ moduleId: context.moduleId, container });
container.textContent = "mounted:" + context.moduleId;
void context.api.request("posts");
${
options.cleanup === false
? ""
: `return () => { globalThis.__unmounts ??= []; globalThis.__unmounts.push(context.moduleId); container.textContent = ""; };`
}
},
};
`;
function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext {
return {
moduleId,
api: {
async request(path) {
requested.push(path);
return new Response(null, { status: 200 });
},
},
principal: { kind: "anonymous" },
permissions: { has: () => true },
};
}
function container(): Element {
const element = document.createElement("div");
document.body.append(element);
return element;
}
const source = { moduleId: "announcements", version: "1.2.0" };
describe("generic module UI host loader", () => {
it("imports a browser URL with the default importer, mounts, and reports identity", async () => {
const element = container();
const requested: string[] = [];
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext(requested),
});
expect(mounted.moduleId).toBe("announcements");
expect(mounted.version).toBe("1.2.0");
expect(mounted.mounted).toBe(true);
expect(element.textContent).toBe("mounted:announcements");
});
it("passes the host's own container and context straight through", async () => {
const element = container();
const requested: string[] = [];
const context = hostContext(requested);
await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context,
});
const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] })
.__mounts;
expect(mounts?.at(-1)?.container).toBe(element);
expect(mounts?.at(-1)?.moduleId).toBe("announcements");
});
it("keeps the module's API calls relative — the loader adds no URL knowledge", async () => {
const element = container();
const requested: string[] = [];
await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext(requested),
});
expect(requested).toEqual(["posts"]);
expect(requested.join(" ")).not.toContain("/api/v1/modules");
});
it("runs the module's cleanup on unmount without touching the host container", async () => {
const element = container();
const parent = element.parentElement;
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext([]),
});
await mounted.unmount();
expect(mounted.mounted).toBe(false);
expect(element.textContent).toBe("");
// The host owns the container; unmounting a module never removes it.
expect(element.parentElement).toBe(parent);
expect(document.body.contains(element)).toBe(true);
});
it("is idempotent: a second unmount does nothing", async () => {
const element = container();
const before = ((globalThis as { __unmounts?: string[] }).__unmounts ?? []).length;
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact()) },
container: element,
context: hostContext([]),
});
await mounted.unmount();
await mounted.unmount();
await mounted.unmount();
const after = ((globalThis as { __unmounts?: string[] }).__unmounts ?? []).length;
expect(after - before).toBe(1);
expect(mounted.mounted).toBe(false);
});
it("unmounts safely when the module returned no cleanup function", async () => {
const element = container();
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(validArtifact({ cleanup: false })) },
container: element,
context: hostContext([]),
});
await expect(mounted.unmount()).resolves.toBeUndefined();
expect(mounted.mounted).toBe(false);
});
it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => {
const element = container();
const artifact = `
export default {
id: "announcements", version: "1.2.0",
mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; },
};`;
const mounted = await loadModuleUi({
source: { ...source, url: moduleUrl(artifact) },
container: element,
context: hostContext([]),
});
await expect(mounted.unmount()).rejects.toThrow("cleanup exploded");
expect(mounted.mounted).toBe(false);
await expect(mounted.unmount()).resolves.toBeUndefined();
expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1);
});
it("supports two independent instances in two containers", async () => {
const first = container();
const second = container();
const url = moduleUrl(validArtifact());
const a = await loadModuleUi({
source: { ...source, url },
container: first,
context: hostContext([]),
});
const b = await loadModuleUi({
source: { ...source, url },
container: second,
context: hostContext([]),
});
expect(first.textContent).toBe("mounted:announcements");
expect(second.textContent).toBe("mounted:announcements");
await a.unmount();
// Each handle owns its own cleanup state; unmounting one leaves the other alone.
expect(a.mounted).toBe(false);
expect(b.mounted).toBe(true);
expect(first.textContent).toBe("");
expect(second.textContent).toBe("mounted:announcements");
});
describe("rejects an artifact before mounting it", () => {
it("when the module id does not match the manifest", async () => {
const element = container();
const url = moduleUrl(validArtifact({ id: "calendar" }));
await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
expect(element.textContent).toBe("");
});
it("when the version does not match the manifest", async () => {
const element = container();
const url = moduleUrl(validArtifact({ version: "1.1.0" }));
await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
expect(element.textContent).toBe("");
});
it("when there is no default export", async () => {
const element = container();
const url = moduleUrl("export const ui = {};");
await expect(
loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }),
).rejects.toMatchObject({ code: "MODULE_UI_EXPORT_MISSING" });
});
it("when the default export is not a module UI definition", async () => {
const element = container();
const url = moduleUrl('export default { id: "announcements", version: "1.2.0" };');
const failure = loadModuleUi({
source: { ...source, url },
container: element,
context: hostContext([]),
});
await expect(failure).rejects.toBeInstanceOf(ModuleUiContractError);
await expect(failure).rejects.toMatchObject({ code: "MODULE_UI_DEFINITION_INVALID" });
});
});
it("reports an unreachable artifact as an import failure, with the cause attached", async () => {
const element = container();
try {
await loadModuleUi({
source: { ...source, url: "data:text/javascript,this is not(valid javascript" },
container: element,
context: hostContext([]),
});
expect.unreachable("expected the import to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiHostError);
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_IMPORT_FAILED");
expect((error as ModuleUiHostError).message).not.toContain("data:");
expect((error as ModuleUiHostError).cause).toBeDefined();
}
});
it("reports a throwing mount as a mount failure and registers no cleanup", async () => {
const element = container();
const url = moduleUrl(`
export default {
id: "announcements", version: "1.2.0",
mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); },
};`);
try {
await loadModuleUi({
source: { ...source, url },
container: element,
context: hostContext([]),
});
expect.unreachable("expected mount to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiHostError);
expect((error as ModuleUiHostError).code).toBe("MODULE_UI_MOUNT_FAILED");
expect((error as ModuleUiHostError).message).not.toContain("exploded");
expect((error as { cause?: Error }).cause?.message).toBe("mount exploded");
}
// No handle exists, so nothing can claim the module is mounted. The container is left exactly
// as the module left it: the host owns it and decides what a failed mount looks like.
expect(element.textContent).toBe("partial");
});
it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => {
const element = container();
const seen: string[] = [];
const importModule: ModuleUiImporter = async (url) => {
seen.push(url);
return { default: { id: "announcements", version: "1.2.0", mount: () => undefined } };
};
const mounted = await loadModuleUi({
source: { ...source, url: "https://assets.example.test/modules/announcements/ui.js" },
container: element,
context: hostContext([]),
importModule,
});
expect(seen).toEqual(["https://assets.example.test/modules/announcements/ui.js"]);
expect(mounted.mounted).toBe(true);
});
});
@@ -0,0 +1,121 @@
// @vitest-environment happy-dom
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { beforeAll, describe, expect, it } from "vitest";
import { loadModuleUi } from "../src/index.js";
const fixtureDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"fixtures",
"artifact-module",
);
let artifactUrl: string;
let artifactSource: string;
/** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
return {
moduleId: "example",
api: {
async request(pathname) {
requested.push(`/api/v1/modules/example/${pathname.replace(/^\//u, "")}`);
return new Response(null, { status: 200 });
},
},
principal: { kind: "authenticated", userId: "user-123" },
permissions: { has: () => true },
};
}
/** A standalone host: one module, a different origin, a completely different URL shape, no Core. */
function standaloneContext(requested: string[]): ModuleUiHostContext {
return {
moduleId: "example",
api: {
async request(pathname) {
requested.push(`https://example.test/api/${pathname.replace(/^\//u, "")}`);
return new Response(null, { status: 200 });
},
},
principal: { kind: "anonymous" },
permissions: { has: () => false },
};
}
/**
* Proves the loader consumes exactly the browser ESM artifact `@ekklesia/module-ui-build` produces
* (ADR 0023) — no second output format, no checked-in build output. The artifact is rebuilt from
* source each run and served through a `data:` URL, a real module URL rather than a filesystem path.
*/
describe("loading a real compiled artifact", () => {
beforeAll(async () => {
const result = await buildModuleUi({ moduleDirectory: fixtureDirectory });
artifactSource = await readFile(result.outputFile, "utf8");
artifactUrl = `data:text/javascript;base64,${Buffer.from(artifactSource, "utf8").toString("base64")}`;
}, 60_000);
it("produces a self-contained artifact with no bare imports for a host to resolve", () => {
expect(artifactSource).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
expect(artifactSource).toContain("export {");
expect(artifactSource).not.toContain("/api/v1/modules");
});
it("mounts the real artifact through the generic loader and unmounts it", async () => {
const container = document.createElement("div");
const requested: string[] = [];
const mounted = await loadModuleUi({
source: { moduleId: "example", version: "0.1.0", url: artifactUrl },
container,
context: ekklesiaContext(requested),
});
expect(container.textContent).toBe("example:200:user-123");
await mounted.unmount();
expect(container.textContent).toBe("");
expect(mounted.mounted).toBe(false);
});
it("still rejects the real artifact when the host expects a different version", async () => {
await expect(
loadModuleUi({
source: { moduleId: "example", version: "0.2.0", url: artifactUrl },
container: document.createElement("div"),
context: ekklesiaContext([]),
}),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
});
it("runs the same artifact under an Ekklesia host and a standalone host", async () => {
const ekklesiaRequests: string[] = [];
const standaloneRequests: string[] = [];
const ekklesiaContainer = document.createElement("div");
const standaloneContainer = document.createElement("div");
const source = { moduleId: "example", version: "0.1.0", url: artifactUrl };
const inEkklesia = await loadModuleUi({
source,
container: ekklesiaContainer,
context: ekklesiaContext(ekklesiaRequests),
});
const inStandalone = await loadModuleUi({
source,
container: standaloneContainer,
context: standaloneContext(standaloneRequests),
});
// One artifact, one loader, two hosts. Every difference lives in the context the host supplied:
// the module asked for "records" both times and resolved nothing itself.
expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]);
expect(standaloneRequests).toEqual(["https://example.test/api/records"]);
expect(ekklesiaContainer.textContent).toBe("example:200:user-123");
expect(standaloneContainer.textContent).toBe("example:200:guest");
await inEkklesia.unmount();
await inStandalone.unmount();
});
});