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
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@ekklesia/module-ui-host",
"version": "0.1.0",
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-ui-host",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
},
"dependencies": {
"@ekklesia/module-ui-sdk": "workspace:*"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@ekklesia/module-ui-build": "workspace:*",
"@types/node": "24.13.3",
"happy-dom": "20.0.11",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "module-ui-host"
}
}
+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?.();
},
};
}
@@ -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();
});
});
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": []
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["tests/**/*.ts"]
}
+2 -4
View File
@@ -16,13 +16,11 @@
"lint": "biome lint .",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-ui-sdk",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*"
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
+35 -5
View File
@@ -1,4 +1,33 @@
import type { EkklesiaPrincipal } from "@ekklesia/contracts";
/**
* Who is viewing, as a browser sees it.
*
* This is deliberately **not** Core's `EkklesiaPrincipal`, and the difference is the point. That
* type's `userId` is a Zod-branded string whose brand only means something to server-side parsing,
* and reaching for it made this package import `@ekklesia/contracts` — a backend barrel whose type
* graph pulls Drizzle, and with it Node's global declarations, back into every module UI program
* (measured: a UI file importing this package could typecheck `process.env`). A browser contract
* that drags a database ORM's types behind it is not a browser contract.
*
* So the shape is preserved and the brand is dropped: plain, serializable, and exactly what a UI
* needs to decide what to render. An Ekklesia host can pass its own `EkklesiaPrincipal` straight in
* — a branded string is assignable to `string`, so the two remain structurally compatible without
* this package knowing anything about the server's representation. A standalone host builds the
* same object literal from whatever identity system it has.
*
* Nothing server-internal belongs here. A field is added only when module UI provably needs it to
* render something.
*/
export interface ModuleUiAnonymousPrincipal {
readonly kind: "anonymous";
}
export interface ModuleUiAuthenticatedPrincipal {
readonly kind: "authenticated";
/** Opaque to module UI: an identifier to correlate with, never to parse or display verbatim. */
readonly userId: string;
}
export type ModuleUiPrincipal = ModuleUiAnonymousPrincipal | ModuleUiAuthenticatedPrincipal;
/**
* Everything a host hands a module's UI, and the complete list of what module UI may assume exists.
@@ -10,7 +39,8 @@ import type { EkklesiaPrincipal } from "@ekklesia/contracts";
* than to add. See ADR 0022.
*
* Nothing in this object is an Ekklesia implementation. Every member is satisfiable by a standalone
* host that never runs Core, which is what makes one module UI hostable by both.
* host that never runs Core, which is what makes one module UI hostable by both. This package has
* no dependencies at all, so importing it cannot pull a backend type graph into a browser program.
*/
export interface ModuleUiHostContext {
/** The module this UI belongs to, as the host knows it. Supplied so a module never has to hardcode
@@ -18,9 +48,9 @@ export interface ModuleUiHostContext {
readonly moduleId: string;
/** The only supported way for module UI to reach its own backend. See {@link ModuleUiApiClient}. */
readonly api: ModuleUiApiClient;
/** Who is viewing, for presentation decisions only. Reuses Core's own principal type so a host and
* a module UI cannot disagree about its shape. */
readonly principal: EkklesiaPrincipal;
/** Who is viewing, for presentation decisions only. See {@link ModuleUiPrincipal} for why this is
* a browser-owned shape rather than Core's server principal type. */
readonly principal: ModuleUiPrincipal;
/** Presentation-only permission hints. See {@link ModuleUiPermissions}. */
readonly permissions: ModuleUiPermissions;
}
@@ -0,0 +1,48 @@
import { execFile } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
const run = promisify(execFile);
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const fixtureDirectory = path.join(packageDirectory, "tests", "browser-boundary");
const typescript = path.join(packageDirectory, "node_modules", ".bin", "tsc");
async function typecheck(project: string): Promise<{ code: number; output: string }> {
try {
await run(typescript, ["--noEmit", "--project", path.join(fixtureDirectory, project)]);
return { code: 0, output: "" };
} catch (error) {
const failure = error as { code?: number; stdout?: string; stderr?: string };
return { code: failure.code ?? 1, output: `${failure.stdout ?? ""}${failure.stderr ?? ""}` };
}
}
/**
* The browser boundary, tested the way a module author experiences it rather than by inspecting the
* dependency graph.
*
* Both fixtures compile under the *exact* project a module uses for UI source —
* `browser-react.json` with `"types": []`. Before this package dropped its `@ekklesia/contracts`
* dependency, the negative fixture below compiled successfully: that import reached a backend
* barrel whose Drizzle types re-introduced Node's global declarations, so `process.env` typechecked
* inside browser-only code (ADR 0024).
*/
describe("module UI SDK browser boundary", () => {
it("compiles supported UI source that imports the public SDK", async () => {
const result = await typecheck("tsconfig.supported.json");
expect(result.output).toBe("");
expect(result.code).toBe(0);
}, 60_000);
it("refuses Node globals in UI source that imports the public SDK", async () => {
const result = await typecheck("tsconfig.node-global.json");
expect(result.code).not.toBe(0);
// Specifically because `process` does not exist — not because of an unrelated error.
expect(result.output).toContain("Cannot find name 'process'");
expect(result.output).toContain("node-global.tsx");
}, 60_000);
});
@@ -0,0 +1,6 @@
// The negative case. Importing the public SDK must NOT make Node globals visible. If this file
// ever typechecks, the browser boundary has regressed and `browser-boundary.test.ts` fails.
import type { ModuleUiHostContext } from "../../src/index.js";
export type Context = ModuleUiHostContext;
export const leaked: string | undefined = process.env.NODE_ENV;
@@ -0,0 +1,24 @@
// A module author's supported UI source: it imports the public SDK and must typecheck cleanly
// under the exact browser project a module uses (`browser-react.json`, `"types": []`).
import {
defineModuleUi,
type ModuleUiHostContext,
type ModuleUiPrincipal,
} from "../../src/index.js";
function describePrincipal(principal: ModuleUiPrincipal): string {
return principal.kind === "authenticated" ? principal.userId : "anonymous";
}
export default defineModuleUi({
id: "example",
version: "0.1.0",
mount(container: Element, context: ModuleUiHostContext) {
container.textContent = describePrincipal(context.principal);
void context.api.request("records");
void context.permissions.has("example.records.read");
return () => {
container.textContent = "";
};
},
});
@@ -0,0 +1,5 @@
{
"extends": "../../../typescript-config/browser-react.json",
"compilerOptions": { "noEmit": true, "types": [] },
"include": ["node-global.tsx"]
}
@@ -0,0 +1,5 @@
{
"extends": "../../../typescript-config/browser-react.json",
"compilerOptions": { "noEmit": true, "types": [] },
"include": ["supported.tsx"]
}
@@ -18,29 +18,30 @@ async function sourceFiles(): Promise<string[]> {
* way; `import type` is erased at build time, a plain `import` is not.
*/
describe("module UI SDK stays browser-safe", () => {
it("imports nothing from another workspace package at runtime", async () => {
it("imports nothing from another package at all, at type level or runtime", async () => {
// Stronger than "type-only imports are fine". A type-only import is erased at runtime but still
// drags the imported package's *type graph* into every consuming program — which is exactly how
// Node globals reached browser-only module UI before ADR 0024. This package now imports nothing
// external, so there is no graph to drag.
for (const file of await sourceFiles()) {
const source = await readFile(path.join(sourceDirectory, file), "utf8");
const workspaceImports = [
...source.matchAll(/^import\s+(.*?)\s+from\s+"(@ekklesia\/[^"]+)"/gmu),
];
const externalImports = [...source.matchAll(/^(?:import|export)\s[^;]*?from\s+"([^"]+)"/gmu)]
.map(([, specifier]) => specifier)
.filter((specifier) => specifier !== undefined && !specifier.startsWith("."));
for (const [, clause, specifier] of workspaceImports) {
expect(
clause?.startsWith("type "),
`${file} imports ${specifier} at runtime; it must be a type-only import`,
).toBe(true);
}
expect(externalImports, `${file} imports outside this package`).toEqual([]);
}
});
it("declares no runtime dependency other than the type-only contracts package", async () => {
it("declares no dependencies whatsoever", async () => {
const manifestPath = path.join(sourceDirectory, "..", "package.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
expect(Object.keys(manifest.dependencies ?? {})).toEqual(["@ekklesia/contracts"]);
expect(Object.keys(manifest.dependencies ?? {})).toEqual([]);
expect(Object.keys(manifest.peerDependencies ?? {})).toEqual([]);
});
it("does not depend on a UI framework, so the contract stays framework-neutral", async () => {
@@ -61,5 +62,9 @@ describe("module UI SDK stays browser-safe", () => {
for (const framework of ["react", "react-dom", "vue", "svelte", "preact"]) {
expect(declared).not.toContain(framework);
}
// Backend graphs must not reappear through a development dependency either.
for (const backend of ["drizzle-orm", "zod", "semver", "@ekklesia/contracts"]) {
expect(declared).not.toContain(backend);
}
});
});
+1 -2
View File
@@ -6,8 +6,7 @@
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": []
"sourceMap": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
+2 -2
View File
@@ -2,7 +2,7 @@
"extends": "../typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": ["vitest/globals"]
"types": []
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/browser.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["tests/**/*.ts"]
}