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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7a76ff6f26
commit
616c1d4834
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user