feat: mount discovered module UI in a minimal Ekklesia browser host
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 43s

Phase 6C pass 2. Every link in the module UI chain existed except the one
that closes it: a module could be authored, built, validated, published at a
browser URL, and loaded by a generic loader — and nothing in Ekklesia did any
of it. There was no browser application at all.

Two pieces were missing. A browser could not discover what to mount, and it
must not work that out for itself.

GET /api/v1/module-ui is the inventory: per publishable module, its id and
version, the server-produced artifact URL, the server-produced root of the
module's own API, and the subset of that module's declared permissions the
caller holds. Five fields, each because building a ModuleUiHostContext needs
it — no filesystem paths, and no name/icon/folder/order, because navigation
metadata is a design that does not exist yet. Its module set is the ADR 0025
publication registry rather than a rescan, so the inventory and the artifact
route cannot disagree. It requires a new Core permission, core.modules.ui.read
(baseline authenticated), because it is precisely the enumeration the anonymous
artifact route deliberately withholds.

Both URLs are server-produced. moduleUiArtifactUrl already existed;
moduleApiBaseUrl is now exported and moduleRoutePath builds on it, so each URL
shape has exactly one author. Without it the shell would hardcode
/api/v1/modules/<id> — the prefix ADR 0022 forbids a module UI from spelling,
reappearing in the host instead.

surfaces/shell (@ekklesia/shell) is the repository's first surface: a small
React application built to static files by its own esbuild script. It resolves
the principal, fetches the inventory, builds the host context entirely from
server-derived data, and hands the artifact URL to @ekklesia/module-ui-host.
One frame per selection — keyed by module identity — defeats the stale-load
race; the host owns the container, the loading state, and every failure, so a
broken module costs its own panel rather than the shell.

The shell statically imports no module, and both levels are checked: no source
import may name a module, and the emitted bundle must contain exactly one
dynamic import (the loader's) with no module id, no /api/v1/modules, no eval,
and no blob URL. Permission hints come from Core's own evaluator and stay
presentation-only — the end-to-end test has a UI holding no grant call its API
anyway and still receive 403.

Surfaces may now depend only on browser-safe shared roles; @ekklesia/contracts
is deliberately excluded, since its barrel pulls Drizzle's types and Node's
globals into the browser — the hole ADR 0024 closed one layer down.

Not the Dashboard: no navigation, grouping, icons, ordering, design system, or
routing. The selector is a list of module ids.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-22 08:55:44 -05:00
co-authored by Claude Opus 5
parent 7a76ff6f26
commit 616c1d4834
46 changed files with 3629 additions and 36 deletions
+415
View File
@@ -0,0 +1,415 @@
import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
// The shell deliberately reports failures to the browser console; silence them so a passing run
// is quiet, and so a test can assert *that* it reported without the noise.
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
});
function descriptor(id: string, version = "0.1.0", permissions: readonly string[] = []) {
return {
api: { baseUrl: `/api/v1/modules/${id}` },
id,
permissions,
ui: { url: `/api/v1/module-ui/${id}/${version}` },
version,
};
}
interface PlatformStub {
readonly modules?: ReadonlyArray<ReturnType<typeof descriptor>>;
readonly principal?: unknown;
readonly inventoryStatus?: number;
readonly inventoryThrows?: boolean;
}
function platformFetch(stub: PlatformStub = {}): typeof fetch {
const modules = stub.modules ?? [descriptor("example")];
return vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return new Response(
JSON.stringify(stub.principal ?? { kind: "authenticated", userId: "user-1" }),
{ headers: { "content-type": "application/json" }, status: 200 },
);
}
if (url.endsWith("/api/v1/module-ui")) {
if (stub.inventoryThrows) {
throw new TypeError("network down");
}
return new Response(JSON.stringify({ modules }), {
headers: { "content-type": "application/json" },
status: stub.inventoryStatus ?? 200,
});
}
return new Response("{}", { status: 404 });
}) as unknown as typeof fetch;
}
interface ArtifactOptions {
readonly mountThrows?: boolean;
readonly unmountThrows?: boolean;
/** Records the container each mount received, so a test can prove a stale mount never touched the
* newer selection's element. */
readonly mountedInto?: Element[];
}
/** A module UI artifact namespace, exactly the shape the generic loader parses. */
function artifact(id: string, version: string, options: ArtifactOptions = {}) {
return {
default: {
id,
mount(element: Element, context: ModuleUiHostContext) {
options.mountedInto?.push(element);
if (options.mountThrows) {
throw new Error("module mount failed");
}
element.textContent = `${context.moduleId} mounted`;
return () => {
if (options.unmountThrows) {
throw new Error("module cleanup failed");
}
element.textContent = "";
};
},
version,
},
};
}
async function render(
config: EkklesiaHostConfig,
importModule?: (url: string) => Promise<unknown>,
): Promise<void> {
await act(async () => {
root.render(<HostApp config={config} {...(importModule ? { importModule } : {})} />);
});
}
function text(): string {
return container.textContent ?? "";
}
function query(testId: string): Element | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
describe("Ekklesia shell — bootstrap", () => {
it("renders the module list the inventory returned", async () => {
await render({
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
});
expect(query("select-example")).not.toBeNull();
expect(query("select-calendar")).not.toBeNull();
});
it("asks an anonymous viewer to sign in, and mounts nothing", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ principal: { kind: "anonymous" } }) });
expect(query("host-signed-out")).not.toBeNull();
expect(query("module-container")).toBeNull();
});
it("treats a refused inventory as signed out rather than as a crash", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ inventoryStatus: 403 }) });
expect(query("host-signed-out")).not.toBeNull();
});
it("renders a host-owned error when the platform cannot be reached", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ inventoryThrows: true }) });
expect(query("host-error")).not.toBeNull();
expect(text()).not.toContain("network down");
});
it("says so plainly when no installed module provides a UI", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ modules: [] }) });
expect(query("host-empty")).not.toBeNull();
});
});
describe("Ekklesia shell — mount lifecycle", () => {
it("mounts the first module into the host's own container", async () => {
const mountedInto: Element[] = [];
await render({ apiBaseUrl, fetch: platformFetch() }, async () =>
artifact("example", "0.1.0", { mountedInto }),
);
expect(query("module-container")?.textContent).toBe("example mounted");
expect(query("module-loading")).toBeNull();
// The module was handed the shell's container, never the page root or the shell's own layout.
expect(mountedInto[0]).toBe(query("module-container"));
expect(mountedInto[0]).not.toBe(container);
expect(mountedInto[0]).not.toBe(document.body);
});
it("imports exactly the URL the inventory published, resolved against the API origin", async () => {
const requested: string[] = [];
await render({ apiBaseUrl, fetch: platformFetch() }, async (url) => {
requested.push(url);
return artifact("example", "0.1.0");
});
expect(requested).toEqual([`${apiBaseUrl}/api/v1/module-ui/example/0.1.0`]);
});
it("shows a host-owned loading state until the module mounts", async () => {
let release: (() => void) | undefined;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
await pending;
return artifact("example", "0.1.0");
});
expect(query("module-loading")).not.toBeNull();
expect(query("module-container")?.textContent).toBe("");
await act(async () => {
release?.();
await pending;
});
expect(query("module-loading")).toBeNull();
expect(query("module-container")?.textContent).toBe("example mounted");
});
it("unmounts the previous module when the selection changes", async () => {
const unmountedContainers: Element[] = [];
const importModule = async (url: string) => {
const id = url.includes("/calendar/") ? "calendar" : "example";
return {
default: {
id,
mount(element: Element) {
element.textContent = `${id} mounted`;
return () => {
unmountedContainers.push(element);
element.textContent = "";
};
},
version: id === "calendar" ? "2.0.0" : "0.1.0",
},
};
};
await render(
{
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
},
importModule,
);
expect(query("module-container")?.textContent).toBe("example mounted");
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
expect(unmountedContainers).toHaveLength(1);
expect(query("module-container")?.textContent).toBe("calendar mounted");
});
it("keeps a stale load from ever reaching the newer selection's container", async () => {
const mountedInto: Element[] = [];
const unmountedContainers: Element[] = [];
let releaseExample: (() => void) | undefined;
const examplePending = new Promise<void>((resolve) => {
releaseExample = resolve;
});
const importModule = async (url: string) => {
const id = url.includes("/calendar/") ? "calendar" : "example";
if (id === "example") {
await examplePending;
}
return {
default: {
id,
mount(element: Element) {
mountedInto.push(element);
element.textContent = `${id} mounted`;
return () => {
unmountedContainers.push(element);
element.textContent = "";
};
},
version: id === "calendar" ? "2.0.0" : "0.1.0",
},
};
};
await render(
{
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
},
importModule,
);
// `example` is still importing; nothing has mounted yet.
expect(mountedInto).toHaveLength(0);
// Switch to `calendar`, which resolves immediately and mounts.
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
const liveContainer = query("module-container");
expect(liveContainer?.textContent).toBe("calendar mounted");
// Only now does the stale `example` import resolve.
await act(async () => {
releaseExample?.();
await examplePending;
});
// It mounted into its own, already-discarded container — never the live one — and was torn
// down immediately, so the newer selection is untouched.
expect(liveContainer?.textContent).toBe("calendar mounted");
const staleContainer = mountedInto.find((element) => element !== liveContainer);
expect(staleContainer).toBeDefined();
expect(staleContainer?.isConnected).toBe(false);
expect(unmountedContainers).toContain(staleContainer);
});
});
describe("Ekklesia shell — failure is host-owned", () => {
it("renders its own error when the artifact cannot be imported", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => {
throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing");
});
expect(query("module-error")).not.toBeNull();
// Not a path, not a stack, not the caught message.
expect(text()).not.toContain("/srv/ekklesia");
expect(text()).not.toContain("index.js");
// The shell itself is still rendered around the failure.
expect(query("select-example")).not.toBeNull();
// The real error still reaches the browser console for a developer.
expect(consoleError).toHaveBeenCalled();
});
it("renders its own error when the artifact is not a valid module UI", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => ({ notADefault: true }));
expect(query("module-error")).not.toBeNull();
});
it("renders its own error when the artifact claims a different identity", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "9.9.9"));
expect(query("module-error")).not.toBeNull();
expect(query("module-container")?.textContent).toBe("");
});
it("renders its own error when the module's mount throws", async () => {
await render({ apiBaseUrl, fetch: platformFetch() }, async () =>
artifact("example", "0.1.0", { mountThrows: true }),
);
expect(query("module-error")).not.toBeNull();
});
it("stays usable after a failure: another module still mounts", async () => {
const importModule = async (url: string) => {
if (url.includes("/example/")) {
throw new Error("boom");
}
return artifact("calendar", "2.0.0");
};
await render(
{
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
},
importModule,
);
expect(query("module-error")).not.toBeNull();
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
expect(query("module-error")).toBeNull();
expect(query("module-container")?.textContent).toBe("calendar mounted");
});
it("survives a module whose cleanup throws, and never re-runs it", async () => {
let cleanupCalls = 0;
const importModule = async (url: string) => {
if (url.includes("/calendar/")) {
return artifact("calendar", "2.0.0");
}
return {
default: {
id: "example",
mount(element: Element) {
element.textContent = "example mounted";
return () => {
cleanupCalls += 1;
throw new Error("module cleanup failed");
};
},
version: "0.1.0",
},
};
};
await render(
{
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
},
importModule,
);
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
expect(cleanupCalls).toBe(1);
expect(query("module-container")?.textContent).toBe("calendar mounted");
// Switch back and away again: the throwing cleanup ran once per mount, never re-invoked for an
// instance already torn down.
await act(async () => {
(query("select-example") as HTMLButtonElement).click();
});
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
expect(cleanupCalls).toBe(2);
expect(query("module-container")?.textContent).toBe("calendar mounted");
});
});
+343
View File
@@ -0,0 +1,343 @@
import { describe, expect, it, vi } from "vitest";
import {
bootstrapHost,
type EkklesiaHostConfig,
type HostModuleUiDescriptor,
loadHostPrincipal,
loadModuleUiInventory,
parseHostModuleUiInventory,
parseHostPrincipal,
} from "../src/host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
const apiBaseUrl = "https://ekklesia.example.test";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
/** A fetch double that answers the two platform paths and records every call, so the tests can
* assert both the URL the shell built and the credentials it used. */
function stubFetch(routes: Readonly<Record<string, () => Response>>) {
const calls: Array<{ init: RequestInit | undefined; url: string }> = [];
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ init, url });
const route = Object.entries(routes).find(([path]) => url === `${apiBaseUrl}${path}`);
if (!route) {
return new Response(JSON.stringify({ code: "NOT_FOUND" }), { status: 404 });
}
return route[1]();
});
return { calls, implementation: implementation as unknown as typeof fetch };
}
const inventoryPayload = {
modules: [
{
api: { baseUrl: "/api/v1/modules/example" },
id: "example",
permissions: ["example.records.read"],
ui: { url: "/api/v1/module-ui/example/0.1.0" },
version: "0.1.0",
},
],
};
const exampleDescriptor: HostModuleUiDescriptor = {
apiBaseUrl: "/api/v1/modules/example",
id: "example",
permissions: ["example.records.read"],
uiUrl: "/api/v1/module-ui/example/0.1.0",
version: "0.1.0",
};
describe("host protocol — inventory", () => {
it("parses the server's descriptor into exactly what a host context needs", () => {
expect(parseHostModuleUiInventory(inventoryPayload)).toEqual([exampleDescriptor]);
});
it("rejects a malformed document rather than yielding a half-built descriptor", () => {
const malformed: unknown[] = [
{},
{ modules: {} },
{ modules: [{ id: "example", version: "0.1.0" }] },
{ modules: [{ ...inventoryPayload.modules[0], ui: {} }] },
{ modules: [{ ...inventoryPayload.modules[0], api: { baseUrl: "" } }] },
{ modules: [{ ...inventoryPayload.modules[0], permissions: "example.records.read" }] },
{ modules: [{ ...inventoryPayload.modules[0], permissions: [42] }] },
// Both URLs must be root-relative, so a value that would resolve against another origin is
// refused rather than silently used to fetch browser code.
{
modules: [
{ ...inventoryPayload.modules[0], ui: { url: "https://evil.example.test/x.js" } },
],
},
{ modules: [{ ...inventoryPayload.modules[0], ui: { url: "//evil.example.test/x.js" } }] },
{ modules: [{ ...inventoryPayload.modules[0], ui: { url: "relative/x.js" } }] },
{
modules: [
{ ...inventoryPayload.modules[0], api: { baseUrl: "https://evil.example.test/api" } },
],
},
];
for (const payload of malformed) {
expect(() => parseHostModuleUiInventory(payload)).toThrowError(
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
);
}
});
it("fetches the inventory from the platform path with credentials", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
});
const config: EkklesiaHostConfig = { apiBaseUrl, fetch: implementation };
await loadModuleUiInventory(config);
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/module-ui`);
expect(calls[0]?.init?.credentials).toBe("include");
});
it("reports an unauthenticated caller distinctly from any other failure", async () => {
for (const status of [401, 403]) {
const { implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse({ code: "AUTHORIZATION_DENIED" }, status),
});
await expect(
loadModuleUiInventory({ apiBaseUrl, fetch: implementation }),
).rejects.toMatchObject({ code: "HOST_UNAUTHENTICATED" });
}
const { implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500),
});
await expect(
loadModuleUiInventory({ apiBaseUrl, fetch: implementation }),
).rejects.toMatchObject({ code: "HOST_REQUEST_FAILED" });
});
it("never renders a caught cause, a URL, or a server path in its message", async () => {
const failing = vi.fn(async () => {
throw new Error("connect ECONNREFUSED /run/postgres/.s.PGSQL.5432");
}) as unknown as typeof fetch;
const error = await loadModuleUiInventory({ apiBaseUrl, fetch: failing }).catch(
(caught: unknown) => caught,
);
expect(error).toMatchObject({ code: "HOST_REQUEST_FAILED" });
expect((error as Error).message).toBe("Ekklesia could not be reached.");
expect((error as Error).message).not.toContain("PGSQL");
});
});
describe("host protocol — principal", () => {
it("maps the platform principal onto the browser-safe SDK shape", () => {
expect(parseHostPrincipal({ kind: "anonymous" })).toEqual({ kind: "anonymous" });
expect(parseHostPrincipal({ kind: "authenticated", userId: "user-1" })).toEqual({
kind: "authenticated",
userId: "user-1",
});
});
it("carries nothing beyond kind and userId, even when the server sends more", () => {
const principal = parseHostPrincipal({
email: "[email protected]",
kind: "authenticated",
roles: ["owner"],
sessionToken: "secret",
userId: "user-1",
});
expect(Object.keys(principal).sort()).toEqual(["kind", "userId"]);
expect(JSON.stringify(principal)).not.toContain("secret");
});
it("rejects a principal it does not recognize", () => {
for (const payload of [null, {}, { kind: "authenticated" }, { kind: "owner" }]) {
expect(() => parseHostPrincipal(payload)).toThrowError(
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
);
}
});
});
describe("host protocol — bootstrap", () => {
it("resolves the principal and then the inventory", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
});
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
expect(calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/principal`,
`${apiBaseUrl}/api/v1/module-ui`,
]);
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(bootstrap.modules).toEqual([exampleDescriptor]);
});
it("never requests the inventory for an anonymous viewer", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
});
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
expect(bootstrap.modules).toEqual([]);
expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
});
it("sends the platform principal request with credentials too", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
});
await loadHostPrincipal({ apiBaseUrl, fetch: implementation });
expect(calls[0]?.init?.credentials).toBe("include");
});
});
describe("module UI host context", () => {
function contextFor(fetchImplementation: typeof fetch, descriptor = exampleDescriptor) {
return createModuleUiContext({
config: { apiBaseUrl, fetch: fetchImplementation },
descriptor,
principal: { kind: "authenticated", userId: "user-1" },
});
}
function recordingFetch() {
const calls: Array<{ init: RequestInit | undefined; url: string }> = [];
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ init, url: String(input) });
return new Response("[]", { status: 200 });
});
return { calls, implementation: implementation as unknown as typeof fetch };
}
it("resolves a module-relative path onto the server-produced module API root", async () => {
const { calls, implementation } = recordingFetch();
await contextFor(implementation).api.request("records");
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/modules/example/records`);
});
it("normalizes a leading slash and preserves query strings and nested paths", async () => {
const { calls, implementation } = recordingFetch();
const api = contextFor(implementation).api;
await api.request("/records");
await api.request("records?since=2026-01-01&limit=10");
await api.request("records/abc/comments");
await api.request("");
expect(calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/modules/example/records`,
`${apiBaseUrl}/api/v1/modules/example/records?since=2026-01-01&limit=10`,
`${apiBaseUrl}/api/v1/modules/example/records/abc/comments`,
`${apiBaseUrl}/api/v1/modules/example`,
]);
});
it("forwards the module's own RequestInit and imposes credentials", async () => {
const { calls, implementation } = recordingFetch();
await contextFor(implementation).api.request("records", {
body: JSON.stringify({ value: "one" }),
headers: { "content-type": "application/json" },
method: "POST",
});
expect(calls[0]?.init).toMatchObject({
credentials: "include",
headers: { "content-type": "application/json" },
method: "POST",
});
});
it("refuses to address anything outside the module's own API", async () => {
const { calls, implementation } = recordingFetch();
const api = contextFor(implementation).api;
const escapes = [
"../calendar/records",
"records/../../calendar/records",
"/../../v1/grants",
"https://evil.example.test/steal",
"//evil.example.test/steal",
"HTTPS://evil.example.test/steal",
"javascript:alert(1)",
];
for (const path of escapes) {
await expect(api.request(path), path).rejects.toMatchObject({
code: "MODULE_API_PATH_INVALID",
});
}
// Not one of them reached the network.
expect(calls).toEqual([]);
});
it("gives each module only its own API root", async () => {
const { calls, implementation } = recordingFetch();
const calendar: HostModuleUiDescriptor = {
apiBaseUrl: "/api/v1/modules/calendar",
id: "calendar",
permissions: [],
uiUrl: "/api/v1/module-ui/calendar/2.0.0",
version: "2.0.0",
};
await contextFor(implementation).api.request("records");
await contextFor(implementation, calendar).api.request("events");
expect(calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/modules/example/records`,
`${apiBaseUrl}/api/v1/modules/calendar/events`,
]);
});
it("builds permission hints from the server's granted set, never a default of true", () => {
const { implementation } = recordingFetch();
const granted = contextFor(implementation).permissions;
const none = contextFor(implementation, { ...exampleDescriptor, permissions: [] }).permissions;
expect(granted.has("example.records.read")).toBe(true);
expect(granted.has("example.records.write")).toBe(false);
// A module the viewer holds nothing for gets a hint set that answers false to everything.
expect(none.has("example.records.read")).toBe(false);
expect(none.has("core.authorization.grants.manage")).toBe(false);
});
it("exposes exactly the four contract members and the browser principal", () => {
const { implementation } = recordingFetch();
const context = contextFor(implementation);
expect(Object.keys(context).sort()).toEqual(["api", "moduleId", "permissions", "principal"]);
expect(context.moduleId).toBe("example");
expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" });
});
it("resolves the artifact URL from the descriptor the server produced", () => {
expect(moduleUiSourceUrl({ apiBaseUrl }, exampleDescriptor)).toBe(
`${apiBaseUrl}/api/v1/module-ui/example/0.1.0`,
);
// Same descriptor, a different deployment origin: the shell composes, it never authors.
expect(moduleUiSourceUrl({ apiBaseUrl: "http://127.0.0.1:3000" }, exampleDescriptor)).toBe(
"http://127.0.0.1:3000/api/v1/module-ui/example/0.1.0",
);
});
});
+157
View File
@@ -0,0 +1,157 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
import { beforeAll, describe, expect, it } from "vitest";
/**
* The proof that this shell is a *generic* host rather than an application that happens to render
* some known modules.
*
* Two levels, because they fail differently. At source level, a static import of a module would be
* an architectural mistake a reviewer should catch. At bundle level, a dynamic import that a bundler
* managed to resolve statically would be an invisible one: the code would look correct, the tests
* would pass, and installing a new module would silently do nothing until the shell was rebuilt.
*
* `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so
* everything here works in plain filesystem paths.
*/
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const sourceDirectory = path.join(packageDirectory, "src");
/** The complete set of packages this shell may reach for. `@ekklesia/module-ui-host` is the generic
* loader and `@ekklesia/module-ui-sdk` is the browser-side contract; no other `@ekklesia/*` package
* — and no module — may appear. */
const ALLOWED_EKKLESIA_IMPORTS = new Set(["@ekklesia/module-ui-host", "@ekklesia/module-ui-sdk"]);
async function sourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...(await sourceFiles(entryPath)));
} else if (/\.tsx?$/u.test(entry.name)) {
files.push(entryPath);
}
}
return files.sort();
}
function importSpecifiers(source: string): string[] {
const specifiers = new Set<string>();
const patterns = [
/\b(?:import|export)\s+(?:type\s+)?[^;]*?\bfrom\s*["']([^"'\n]+)["']/gu,
/\bimport\s*["']([^"'\n]+)["']/gu,
/\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/gu,
];
for (const pattern of patterns) {
for (const match of source.matchAll(pattern)) {
if (match[1]) {
specifiers.add(match[1]);
}
}
}
return [...specifiers];
}
let files: string[];
let sources: Map<string, string>;
beforeAll(async () => {
files = await sourceFiles(sourceDirectory);
sources = new Map(
await Promise.all(files.map(async (file) => [file, await readFile(file, "utf8")] as const)),
);
});
describe("the shell is coupled to no module", () => {
it("has source files to check", () => {
expect(files.length).toBeGreaterThan(3);
});
it("imports no module, template, or workspace package beyond the two browser contracts", () => {
for (const [file, source] of sources) {
for (const specifier of importSpecifiers(source)) {
const label = `${path.relative(packageDirectory, file)} -> ${specifier}`;
expect(specifier.includes("modules/"), label).toBe(false);
expect(specifier.includes("templates/"), label).toBe(false);
// Nothing may climb out of this package at all.
expect(specifier.startsWith("../../"), label).toBe(false);
if (specifier.startsWith("@ekklesia/")) {
expect(ALLOWED_EKKLESIA_IMPORTS.has(specifier), label).toBe(true);
}
}
}
});
it("performs no dynamic import of its own: all module loading goes through the generic loader", () => {
for (const [file, source] of sources) {
// Comment lines are dropped so a doc comment may still *describe* the loader's own
// `(url) => import(url)`. The authoritative check is the bundle-level one below, which counts
// dynamic imports in the emitted output where no comment survives.
const code = source
.split("\n")
.filter((line) => !/^\s*(\*|\/\/)/u.test(line))
.join("\n");
const dynamicImports = [...code.matchAll(/\bimport\s*\(/gu)];
expect(dynamicImports, path.relative(packageDirectory, file)).toEqual([]);
}
});
});
describe("the shell builds no module knowledge into its bundle", () => {
let bundle: string;
beforeAll(async () => {
// The real production bundle configuration, produced in memory so this test never depends on a
// previous `pnpm build` having run.
const result = await build({
absWorkingDir: packageDirectory,
bundle: true,
define: {
__EKKLESIA_API_BASE_URL__: '""',
"process.env.NODE_ENV": '"production"',
},
entryPoints: [path.join(sourceDirectory, "browser", "main.tsx")],
format: "esm",
jsx: "automatic",
logLevel: "silent",
minify: false,
platform: "browser",
target: "es2022",
write: false,
});
bundle = result.outputFiles.map((file) => file.text).join("\n");
}, 60_000);
it("leaves exactly one dynamic import — the loader's — unresolved for run time", () => {
const dynamicImports = [...bundle.matchAll(/\bimport\s*\(/gu)];
// Exactly one: `@ekklesia/module-ui-host`'s `(url) => import(url)`. More would mean the shell
// acquired its own loader; fewer would mean the bundler resolved it away and installed modules
// could never be fetched at run time.
expect(dynamicImports).toHaveLength(1);
expect(bundle).toContain("(url) => import(url)");
});
it("contains no module identity and no module API prefix", () => {
// The shell learns every module id, artifact URL, and API root from the inventory at run time.
// Any of these appearing in the bundle would mean it had been baked in instead.
expect(bundle).not.toContain("/api/v1/modules");
expect(bundle).not.toContain("/api/v1/module-ui/");
expect(bundle).not.toContain("ekklesia.module.json");
expect(bundle).not.toContain("entrypoints");
});
it("spells only the two platform paths it genuinely owns", () => {
expect(bundle).toContain('"/api/v1/principal"');
expect(bundle).toContain('"/api/v1/module-ui"');
});
it("uses neither eval nor a blob URL to load module code", () => {
expect(bundle).not.toContain("new Function(");
expect(bundle).not.toMatch(/\beval\s*\(/u);
expect(bundle).not.toContain("createObjectURL");
});
});