Files
Ekklesia/surfaces/shell/tests/modularity.test.ts
T
ekklesiaandClaude Opus 5 616c1d4834
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 43s
feat: mount discovered module UI in a minimal Ekklesia browser host
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]>
2026-08-22 08:55:44 -05:00

158 lines
6.2 KiB
TypeScript

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");
});
});