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,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<title>Ekklesia</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- The shell's root. A module UI never receives this element: it is given a container the
|
||||
shell creates inside its own layout. -->
|
||||
<div id="ekklesia-host"></div>
|
||||
<!-- A real module script, from the same origin, with no inline script anywhere on the page:
|
||||
a deployment can therefore apply a strict script-src without exceptions, and the runtime
|
||||
`import()` of a published module artifact stays same-origin under it. -->
|
||||
<script src="./app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@ekklesia/shell",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json && node scripts/build-browser.mjs",
|
||||
"dev": "node scripts/build-browser.mjs --serve",
|
||||
"lint": "biome lint .",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.test.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ekklesia/module-ui-host": "workspace:*",
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@ekklesia/typescript-config": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
"esbuild": "0.28.1",
|
||||
"happy-dom": "20.0.11",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "surface"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { copyFile, mkdir, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import esbuild from "esbuild";
|
||||
|
||||
/**
|
||||
* Builds the Ekklesia shell into a static browser application.
|
||||
*
|
||||
* This is **not** the module UI build pipeline, and the two must not be conflated. ADR 0023's
|
||||
* `@ekklesia/module-ui-build` compiles a *module's* `src/ui/index.tsx` into the artifact its
|
||||
* manifest declares, is owned by the platform so module authors write no bundler configuration, and
|
||||
* runs when a module is packaged. This script builds *this application*, is owned by this package
|
||||
* alone, and knows nothing about manifests. They happen to share a bundler; they share no
|
||||
* configuration, no contract, and no output.
|
||||
*
|
||||
* Installed module UIs are deliberately **not** bundled in. The shell has no import of any module,
|
||||
* so there is nothing for esbuild to reach: every module artifact is fetched at run time from the
|
||||
* URL the inventory returned. That is the property that makes modules independently installable, and
|
||||
* bundling one would silently destroy it.
|
||||
*/
|
||||
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
const outputDirectory = path.join(packageDirectory, "dist", "browser");
|
||||
const serve = process.argv.includes("--serve");
|
||||
|
||||
/**
|
||||
* Empty means "the API is on this page's own origin" — the expected production shape. Set it to an
|
||||
* absolute origin for split-origin development (`EKKLESIA_API_BASE_URL=http://127.0.0.1:3000`), and
|
||||
* add this page's own origin to `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` so the API reflects it for CORS
|
||||
* and the session cookie travels.
|
||||
*/
|
||||
const apiBaseUrl = process.env.EKKLESIA_API_BASE_URL ?? "";
|
||||
|
||||
/** @type {import("esbuild").BuildOptions} */
|
||||
const buildOptions = {
|
||||
absWorkingDir: packageDirectory,
|
||||
// Everything the shell needs is bundled; a browser resolves nothing at run time except the module
|
||||
// artifacts it is told about, which are real URLs rather than bare specifiers.
|
||||
bundle: true,
|
||||
define: {
|
||||
__EKKLESIA_API_BASE_URL__: JSON.stringify(apiBaseUrl),
|
||||
// React reads this at module scope and a browser has no `process`.
|
||||
"process.env.NODE_ENV": JSON.stringify(serve ? "development" : "production"),
|
||||
},
|
||||
entryPoints: [path.join(packageDirectory, "src", "browser", "main.tsx")],
|
||||
format: "esm",
|
||||
jsx: "automatic",
|
||||
logLevel: "info",
|
||||
// Unminified, matching the module UI build: an operator is expected to be able to read what their
|
||||
// installation actually serves, and nothing else in this repository minifies.
|
||||
minify: false,
|
||||
outfile: path.join(outputDirectory, "app.js"),
|
||||
platform: "browser",
|
||||
sourcemap: true,
|
||||
// Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree.
|
||||
target: "es2022",
|
||||
};
|
||||
|
||||
await rm(outputDirectory, { force: true, recursive: true });
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
await copyFile(path.join(packageDirectory, "index.html"), path.join(outputDirectory, "index.html"));
|
||||
|
||||
if (serve) {
|
||||
const context = await esbuild.context(buildOptions);
|
||||
await context.watch();
|
||||
const { host, port } = await context.serve({ servedir: outputDirectory });
|
||||
console.log(`Ekklesia shell (development) on http://${host}:${port}`);
|
||||
console.log(
|
||||
apiBaseUrl === ""
|
||||
? "API origin: this page's own origin. Set EKKLESIA_API_BASE_URL for a split-origin API."
|
||||
: `API origin: ${apiBaseUrl}`,
|
||||
);
|
||||
} else {
|
||||
await esbuild.build(buildOptions);
|
||||
console.log(`Built the Ekklesia shell into ${path.relative(packageDirectory, outputDirectory)}`);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import {
|
||||
bootstrapHost,
|
||||
type EkklesiaHostConfig,
|
||||
type HostBootstrap,
|
||||
HostProtocolError,
|
||||
} from "../host-protocol.js";
|
||||
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
||||
|
||||
type BootstrapState =
|
||||
| { readonly status: "loading" }
|
||||
| { readonly status: "ready"; readonly bootstrap: HostBootstrap }
|
||||
| { readonly status: "signed-out" }
|
||||
| { readonly status: "failed" };
|
||||
|
||||
export interface HostAppProps {
|
||||
/** Must be a stable reference: bootstrap re-runs whenever it changes, so a fresh object literal
|
||||
* built during render would re-fetch forever. The browser entry point builds it once at module
|
||||
* scope. */
|
||||
readonly config: EkklesiaHostConfig;
|
||||
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
}
|
||||
|
||||
const shellStyles = {
|
||||
aside: { borderRight: "1px solid #e7e5e4", minWidth: "12rem", padding: "1rem" },
|
||||
header: {
|
||||
borderBottom: "1px solid #e7e5e4",
|
||||
fontWeight: 600,
|
||||
gridColumn: "1 / -1",
|
||||
padding: "0.75rem 1rem",
|
||||
},
|
||||
layout: {
|
||||
color: "#1c1917",
|
||||
display: "grid",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
gridTemplateColumns: "auto 1fr",
|
||||
minHeight: "100vh",
|
||||
},
|
||||
main: { padding: "1rem" },
|
||||
message: { color: "#57534e", padding: "1rem" },
|
||||
} as const;
|
||||
|
||||
function moduleKey(id: string, version: string): string {
|
||||
return `${id}@${version}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimal Ekklesia browser shell: sign-in awareness, a module list, and one mount target.
|
||||
*
|
||||
* **This is not the Dashboard.** There is no navigation model, no grouping or folders, no icons, no
|
||||
* ordering, no home screen, no design system, and no routing — a selected-module state is the whole
|
||||
* of it, and the selector renders module ids because the inventory deliberately carries no display
|
||||
* metadata yet (ADR 0021 keeps that an administrator's presentation configuration, not a module
|
||||
* declaration). What this proves is the architecture underneath: that a module discovered on the
|
||||
* server, published as an artifact, and described by the inventory can be mounted here without the
|
||||
* shell knowing anything about it in advance.
|
||||
*
|
||||
* Nothing about any specific module appears in this file, or anywhere in this package. There is no
|
||||
* import of a module, no module id, no registry, no allowlist, and no fallback bundle. Every module
|
||||
* the shell can mount arrives at run time from the inventory the server returned.
|
||||
*/
|
||||
export function HostApp(props: HostAppProps): ReactElement {
|
||||
const [state, setState] = useState<BootstrapState>({ status: "loading" });
|
||||
const [selectedKey, setSelectedKey] = useState<string | undefined>(undefined);
|
||||
const { config, importModule } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
setState({ status: "loading" });
|
||||
void bootstrapHost(config, abort.signal)
|
||||
.then((bootstrap) => {
|
||||
if (abort.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setState(
|
||||
bootstrap.principal.kind === "anonymous"
|
||||
? { status: "signed-out" }
|
||||
: { bootstrap, status: "ready" },
|
||||
);
|
||||
setSelectedKey(
|
||||
bootstrap.modules[0]
|
||||
? moduleKey(bootstrap.modules[0].id, bootstrap.modules[0].version)
|
||||
: undefined,
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (abort.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.error("[ekklesia] host bootstrap failed", error);
|
||||
setState(
|
||||
error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED"
|
||||
? { status: "signed-out" }
|
||||
: { status: "failed" },
|
||||
);
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [config]);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <p style={shellStyles.message}>Loading Ekklesia…</p>;
|
||||
}
|
||||
if (state.status === "signed-out") {
|
||||
return (
|
||||
<p data-testid="host-signed-out" style={shellStyles.message}>
|
||||
Sign in to Ekklesia to use the module host.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.status === "failed") {
|
||||
return (
|
||||
<p data-testid="host-error" style={shellStyles.message}>
|
||||
Ekklesia could not be reached. Refresh to try again.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { bootstrap } = state;
|
||||
const selected = bootstrap.modules.find(
|
||||
(module) => moduleKey(module.id, module.version) === selectedKey,
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={shellStyles.layout}>
|
||||
<header style={shellStyles.header}>Ekklesia</header>
|
||||
<aside style={shellStyles.aside}>
|
||||
{bootstrap.modules.length === 0 ? (
|
||||
<p data-testid="host-empty" style={{ color: "#57534e", margin: 0 }}>
|
||||
No installed module provides a user interface.
|
||||
</p>
|
||||
) : (
|
||||
<nav style={{ display: "grid", gap: "0.25rem" }}>
|
||||
{bootstrap.modules.map((module) => {
|
||||
const key = moduleKey(module.id, module.version);
|
||||
return (
|
||||
<button
|
||||
data-testid={`select-${module.id}`}
|
||||
key={key}
|
||||
onClick={() => setSelectedKey(key)}
|
||||
style={{
|
||||
background: key === selectedKey ? "#e7e5e4" : "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
padding: "0.375rem 0.5rem",
|
||||
textAlign: "left",
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{module.id}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</aside>
|
||||
<main style={shellStyles.main}>
|
||||
{selected ? (
|
||||
// Keyed by module identity, so switching selection discards this frame — and its container
|
||||
// element — entirely rather than reusing one across two modules. See `ModuleUiFrame` for
|
||||
// why that is what makes a slow, stale load harmless.
|
||||
<ModuleUiFrame
|
||||
config={config}
|
||||
descriptor={selected}
|
||||
{...(importModule ? { importModule } : {})}
|
||||
key={moduleKey(selected.id, selected.version)}
|
||||
principal={bootstrap.principal}
|
||||
/>
|
||||
) : undefined}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
import { type ReactElement, useEffect, useRef, useState } from "react";
|
||||
import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js";
|
||||
import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js";
|
||||
|
||||
type FrameStatus = "loading" | "ready" | "failed";
|
||||
|
||||
export interface ModuleUiFrameProps {
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly descriptor: HostModuleUiDescriptor;
|
||||
readonly principal: ModuleUiPrincipal;
|
||||
/** Test-only seam, forwarded to the generic loader so a test can hold an import pending and prove
|
||||
* the stale-load rule. Production composition leaves it unset and the loader's own
|
||||
* `(url) => import(url)` runs. */
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts exactly one module UI into a container this component owns, and tears it down again.
|
||||
*
|
||||
* ## Host-owned container, module-owned contents
|
||||
*
|
||||
* The `<div>` below belongs to the shell. The module fills it and must return an unmount that
|
||||
* empties it; it never receives the shell's header, the module list, the page root, or the document.
|
||||
* The loading and error states are rendered as siblings, so a module can never own the shell's
|
||||
* pre-mount or failure state — which is the point of the host owning them.
|
||||
*
|
||||
* ## One frame per selection, which is what defeats the stale-load race
|
||||
*
|
||||
* The parent renders this with a `key` of the module's identity, so selecting a different module
|
||||
* unmounts this component entirely and creates a fresh one with a **fresh container element**. A
|
||||
* slow import that resolves after the selection changed therefore mounts into a node that is already
|
||||
* detached from the document and is immediately unmounted by this component's own cleanup — it can
|
||||
* never render over, or into, the newer selection's container. The `cancelled` flag makes that
|
||||
* explicit rather than incidental: a load that finishes after cleanup ran is torn down at once and
|
||||
* never reaches `setStatus`, so it also cannot resurrect a stale status on a component React has
|
||||
* already discarded.
|
||||
*
|
||||
* ## Failure is the host's to render
|
||||
*
|
||||
* Every way this can fail — the artifact will not fetch, the bundle is not a module, its declared
|
||||
* identity does not match the manifest, or `mount` throws — is caught here and turned into a fixed,
|
||||
* host-owned message. Nothing from the caught error reaches the DOM: not a URL, not a path, not a
|
||||
* stack. The error object goes to the browser console, which is where a developer's diagnostics
|
||||
* belong and where ADR 0024 intends the loader's `cause` to be read. The rest of the shell keeps
|
||||
* rendering, so a broken module costs its own panel and nothing else.
|
||||
*/
|
||||
export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [status, setStatus] = useState<FrameStatus>("loading");
|
||||
const { config, descriptor, importModule, principal } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let mounted: Awaited<ReturnType<typeof loadModuleUi>> | undefined;
|
||||
setStatus("loading");
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const instance = await loadModuleUi({
|
||||
container,
|
||||
context: createModuleUiContext({ config, descriptor, principal }),
|
||||
...(importModule ? { importModule } : {}),
|
||||
source: {
|
||||
moduleId: descriptor.id,
|
||||
url: moduleUiSourceUrl(config, descriptor),
|
||||
version: descriptor.version,
|
||||
},
|
||||
});
|
||||
if (cancelled) {
|
||||
// Selection already moved on. Release it immediately rather than leaving a module running
|
||||
// in a detached container with no handle to stop it.
|
||||
await instance.unmount().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
mounted = instance;
|
||||
setStatus("ready");
|
||||
} catch (error) {
|
||||
console.error("[ekklesia] a module UI failed to load", error);
|
||||
if (!cancelled) {
|
||||
setStatus("failed");
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Idempotent by contract (ADR 0024), and a throwing cleanup is swallowed here: teardown is
|
||||
// already in progress, and a module that fails to clean up must not take the shell with it.
|
||||
void mounted?.unmount().catch(() => undefined);
|
||||
};
|
||||
}, [config, descriptor, importModule, principal]);
|
||||
|
||||
return (
|
||||
<section aria-live="polite" style={{ display: "grid", gap: "0.75rem" }}>
|
||||
{status === "loading" ? (
|
||||
<p data-testid="module-loading" style={{ color: "#57534e", margin: 0 }}>
|
||||
Loading module…
|
||||
</p>
|
||||
) : undefined}
|
||||
{status === "failed" ? (
|
||||
<p data-testid="module-error" style={{ color: "#9f1239", margin: 0 }}>
|
||||
This module’s interface could not be loaded. Try another module, or ask an administrator
|
||||
to check the installation.
|
||||
</p>
|
||||
) : undefined}
|
||||
<div data-testid="module-container" ref={containerRef} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { EkklesiaHostConfig } from "../host-protocol.js";
|
||||
import { HostApp } from "./HostApp.js";
|
||||
|
||||
/**
|
||||
* Replaced at build time by `scripts/build-browser.mjs` with a JSON string literal: the API origin
|
||||
* when `EKKLESIA_API_BASE_URL` was set for the build, and `""` otherwise. Declared rather than read
|
||||
* from `process.env`, because a browser has no `process` and the substitution must be visible here
|
||||
* instead of hidden in bundler configuration.
|
||||
*/
|
||||
declare const __EKKLESIA_API_BASE_URL__: string;
|
||||
|
||||
/**
|
||||
* The one place the shell touches a browser global.
|
||||
*
|
||||
* An empty build-time value means the API is served from the same origin as this page, which is the
|
||||
* expected production shape; a non-empty one is the split-origin development shape the platform's
|
||||
* trusted-origin configuration already supports (`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` must list this
|
||||
* page's origin for the session cookie to travel). Everything downstream receives the resolved
|
||||
* origin as a plain argument, so no other file in this package reads `window`.
|
||||
*/
|
||||
const config: EkklesiaHostConfig = {
|
||||
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
|
||||
};
|
||||
|
||||
const container = document.getElementById("ekklesia-host");
|
||||
if (!container) {
|
||||
throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
|
||||
}
|
||||
|
||||
createRoot(container).render(<HostApp config={config} />);
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
|
||||
/**
|
||||
* How this shell reaches the Ekklesia API.
|
||||
*
|
||||
* `apiBaseUrl` is an absolute **origin** (`https://ekklesia.example.test`, `http://127.0.0.1:3000`)
|
||||
* rather than something read from a global. The browser entry point supplies it — from a build-time
|
||||
* value when the shell and the API are served from different origins in development, and from
|
||||
* `window.location.origin` otherwise — so every function below is a pure function of its arguments
|
||||
* and can be exercised without a DOM, a `window`, or a page. Nothing here reads a global.
|
||||
*/
|
||||
export interface EkklesiaHostConfig {
|
||||
readonly apiBaseUrl: string;
|
||||
/** Injected so tests can drive the real protocol against a real in-process app without a socket.
|
||||
* Production composition leaves this unset and the platform `fetch` is used. */
|
||||
readonly fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
/** Root-relative platform paths. The shell never builds a module UI artifact URL or a module API
|
||||
* URL — the server produces both and hands them over in the inventory — so these two are the only
|
||||
* URLs it knows, and they are the two that describe the platform itself rather than any module. */
|
||||
const PRINCIPAL_PATH = "/api/v1/principal";
|
||||
const MODULE_UI_INVENTORY_PATH = "/api/v1/module-ui";
|
||||
|
||||
export type HostProtocolErrorCode =
|
||||
/** The caller is not signed in, or lacks `core.modules.ui.read`. */
|
||||
| "HOST_UNAUTHENTICATED"
|
||||
/** The API answered, but not with something this shell understands. */
|
||||
| "HOST_RESPONSE_INVALID"
|
||||
/** The API could not be reached, or failed. */
|
||||
| "HOST_REQUEST_FAILED"
|
||||
/** A module UI asked for something outside its own API root. */
|
||||
| "MODULE_API_PATH_INVALID";
|
||||
|
||||
const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.freeze({
|
||||
HOST_REQUEST_FAILED: "Ekklesia could not be reached.",
|
||||
HOST_RESPONSE_INVALID: "Ekklesia returned a response this host does not understand.",
|
||||
HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.",
|
||||
MODULE_API_PATH_INVALID: "A module asked for a path outside its own API.",
|
||||
});
|
||||
|
||||
/**
|
||||
* Carries a stable code and a fixed, user-safe message — never a URL, a response body, a server
|
||||
* path, or a caught cause — matching every other Ekklesia error boundary. The originating error is
|
||||
* attached as `cause` for browser-console diagnostics and is never rendered.
|
||||
*/
|
||||
export class HostProtocolError extends Error {
|
||||
readonly code: HostProtocolErrorCode;
|
||||
|
||||
constructor(code: HostProtocolErrorCode, options?: { readonly cause?: unknown }) {
|
||||
super(errorMessages[code], options);
|
||||
this.name = "HostProtocolError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One module UI this host may mount, exactly as the server described it.
|
||||
*
|
||||
* This mirrors the server's `HostModuleUiDescriptor` (`apps/api/src/module-ui-inventory.ts`) rather
|
||||
* than importing it: a surface may not depend on an app, and the type on either side of an HTTP
|
||||
* boundary should be validated on arrival rather than assumed. `parseHostModuleUiInventory` below is
|
||||
* that validation, and the end-to-end test feeds a real server response through it so the two
|
||||
* declarations are proven to agree instead of merely looking similar.
|
||||
*/
|
||||
export interface HostModuleUiDescriptor {
|
||||
readonly id: string;
|
||||
readonly version: string;
|
||||
/** Server-produced. The shell resolves it against `apiBaseUrl` and hands it to the generic loader
|
||||
* unchanged; it never constructs, parses, or pattern-matches this URL. */
|
||||
readonly uiUrl: string;
|
||||
/** Server-produced root of this module's own HTTP API. */
|
||||
readonly apiBaseUrl: string;
|
||||
/** Granted ids only, already evaluated server-side. Presentation hints, never authority. */
|
||||
readonly permissions: readonly string[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function nestedString(value: unknown, key: string): string | undefined {
|
||||
return isRecord(value) ? nonEmptyString(value[key]) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Both URLs in a descriptor must be **root-relative** paths, exactly as the server produces them.
|
||||
*
|
||||
* Enforced rather than assumed. The shell resolves each against the configured API origin, so an
|
||||
* absolute or protocol-relative value arriving here would silently redirect a module's script import
|
||||
* or its API calls to another origin. This does not defend against a compromised server — one could
|
||||
* serve anything at the real URL — but it does pin the wire contract, so a proxy rewrite, a
|
||||
* misconfiguration, or a future server-side bug fails loudly here instead of quietly changing where
|
||||
* browser code is fetched from.
|
||||
*/
|
||||
function rootRelativePath(value: unknown, key: string): string | undefined {
|
||||
const candidate = nestedString(value, key);
|
||||
if (candidate === undefined || !candidate.startsWith("/") || candidate.startsWith("//")) {
|
||||
return undefined;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict, total validation of the inventory document. Anything unexpected — a missing field, a
|
||||
* non-array `modules`, a permission entry that is not a string — fails the whole parse rather than
|
||||
* yielding a half-built descriptor, because a descriptor with a missing URL would surface much later
|
||||
* as an inexplicable mount failure.
|
||||
*/
|
||||
export function parseHostModuleUiInventory(payload: unknown): readonly HostModuleUiDescriptor[] {
|
||||
const modules = isRecord(payload) ? payload.modules : undefined;
|
||||
if (!Array.isArray(modules)) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze(
|
||||
modules.map((entry): HostModuleUiDescriptor => {
|
||||
const id = nestedString(entry, "id");
|
||||
const version = nestedString(entry, "version");
|
||||
const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined;
|
||||
const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined;
|
||||
const permissions = isRecord(entry) ? entry.permissions : undefined;
|
||||
if (
|
||||
id === undefined ||
|
||||
version === undefined ||
|
||||
uiUrl === undefined ||
|
||||
apiBaseUrl === undefined ||
|
||||
!Array.isArray(permissions) ||
|
||||
permissions.some((permission) => typeof permission !== "string")
|
||||
) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze({
|
||||
apiBaseUrl,
|
||||
id,
|
||||
permissions: Object.freeze([...(permissions as string[])]),
|
||||
uiUrl,
|
||||
version,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** `/api/v1/principal`'s two documented shapes, narrowed to the browser-owned `ModuleUiPrincipal`
|
||||
* the UI SDK defines. Nothing else the endpoint might ever return is carried into module UI. */
|
||||
export function parseHostPrincipal(payload: unknown): ModuleUiPrincipal {
|
||||
if (!isRecord(payload)) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
if (payload.kind === "anonymous") {
|
||||
return { kind: "anonymous" };
|
||||
}
|
||||
const userId = nonEmptyString(payload.userId);
|
||||
if (payload.kind === "authenticated" && userId !== undefined) {
|
||||
return { kind: "authenticated", userId };
|
||||
}
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
|
||||
/** Resolves a root-relative platform path against the configured API origin. The one place the
|
||||
* shell turns a server-produced path into an absolute URL. */
|
||||
export function resolveApiUrl(config: EkklesiaHostConfig, rootRelativePath: string): string {
|
||||
return new URL(rootRelativePath, config.apiBaseUrl).href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every request this shell makes to Ekklesia carries credentials.
|
||||
*
|
||||
* Same-origin that is simply the session cookie. Split-origin — the development shape the platform's
|
||||
* trusted-origin configuration already supports — it is what makes the cookie travel at all, and it
|
||||
* works only because the API reflects an exact trusted origin with
|
||||
* `Access-Control-Allow-Credentials: true` and never a wildcard. This does not weaken that policy;
|
||||
* it is the client half of it.
|
||||
*/
|
||||
async function requestPlatformJson(
|
||||
config: EkklesiaHostConfig,
|
||||
rootRelativePath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const performRequest = config.fetch ?? fetch;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await performRequest(resolveApiUrl(config, rootRelativePath), {
|
||||
credentials: "include",
|
||||
headers: { accept: "application/json" },
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new HostProtocolError("HOST_REQUEST_FAILED", { cause: error });
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new HostProtocolError("HOST_UNAUTHENTICATED");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new HostProtocolError("HOST_REQUEST_FAILED");
|
||||
}
|
||||
try {
|
||||
return (await response.json()) as unknown;
|
||||
} catch (error) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID", { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
/** Who is viewing. Anonymous is a normal answer, not a failure: the shell renders a sign-in prompt
|
||||
* rather than an error, and never asks for the inventory it would be refused. */
|
||||
export async function loadHostPrincipal(
|
||||
config: EkklesiaHostConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ModuleUiPrincipal> {
|
||||
return parseHostPrincipal(await requestPlatformJson(config, PRINCIPAL_PATH, signal));
|
||||
}
|
||||
|
||||
/** Which module UIs this viewer may mount, where to fetch each, and what they may do inside it. */
|
||||
export async function loadModuleUiInventory(
|
||||
config: EkklesiaHostConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<readonly HostModuleUiDescriptor[]> {
|
||||
return parseHostModuleUiInventory(
|
||||
await requestPlatformJson(config, MODULE_UI_INVENTORY_PATH, signal),
|
||||
);
|
||||
}
|
||||
|
||||
export interface HostBootstrap {
|
||||
readonly principal: ModuleUiPrincipal;
|
||||
readonly modules: readonly HostModuleUiDescriptor[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell's whole startup sequence, in one explicit function: resolve who is viewing, then ask
|
||||
* what they may mount. Sequential rather than parallel on purpose — an anonymous viewer is told to
|
||||
* sign in and the inventory is never requested, so the shell does not depend on a `403` to discover
|
||||
* something `/api/v1/principal` already said plainly.
|
||||
*/
|
||||
export async function bootstrapHost(
|
||||
config: EkklesiaHostConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HostBootstrap> {
|
||||
const principal = await loadHostPrincipal(config, signal);
|
||||
if (principal.kind === "anonymous") {
|
||||
return { modules: Object.freeze([]), principal };
|
||||
}
|
||||
return { modules: await loadModuleUiInventory(config, signal), principal };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The framework-free half of the Ekklesia shell: how it talks to the platform and how it builds a
|
||||
* module UI host context. Deliberately the only public surface of this package.
|
||||
*
|
||||
* The React shell itself (`src/browser/`) is not exported. It is an application, bundled straight to
|
||||
* a browser artifact by this package's own build; exporting components would invite another package
|
||||
* to depend on Ekklesia's shell internals, which is exactly the coupling the surface boundary
|
||||
* exists to prevent. What *is* exported is the protocol — validated wire parsing, context
|
||||
* construction, module API resolution — so the end-to-end test can drive the real host code against
|
||||
* a real server instead of a reimplementation of it.
|
||||
*/
|
||||
export {
|
||||
bootstrapHost,
|
||||
type EkklesiaHostConfig,
|
||||
type HostBootstrap,
|
||||
type HostModuleUiDescriptor,
|
||||
HostProtocolError,
|
||||
type HostProtocolErrorCode,
|
||||
loadHostPrincipal,
|
||||
loadModuleUiInventory,
|
||||
parseHostModuleUiInventory,
|
||||
parseHostPrincipal,
|
||||
resolveApiUrl,
|
||||
} from "./host-protocol.js";
|
||||
export {
|
||||
type CreateModuleUiContextOptions,
|
||||
createModuleUiContext,
|
||||
moduleUiSourceUrl,
|
||||
} from "./module-context.js";
|
||||
@@ -0,0 +1,104 @@
|
||||
import type {
|
||||
ModuleUiApiClient,
|
||||
ModuleUiHostContext,
|
||||
ModuleUiPrincipal,
|
||||
} from "@ekklesia/module-ui-sdk";
|
||||
import {
|
||||
type EkklesiaHostConfig,
|
||||
type HostModuleUiDescriptor,
|
||||
HostProtocolError,
|
||||
resolveApiUrl,
|
||||
} from "./host-protocol.js";
|
||||
|
||||
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/u;
|
||||
|
||||
/**
|
||||
* Turns the relative path a module UI asks for into the real Ekklesia URL, and refuses anything
|
||||
* that is not the module's own API.
|
||||
*
|
||||
* `context.api.request("records")` becomes `<origin>/api/v1/modules/<module-id>/records`. The
|
||||
* `/api/v1/modules/<module-id>` half is **not** written here: it arrives from the server in the
|
||||
* inventory descriptor (`apiBaseUrl`), so Ekklesia's module mount prefix is spelled in exactly one
|
||||
* place in the whole repository (`moduleApiBaseUrl`, `apps/api/src/module-http-routes.ts`). The
|
||||
* browser is a consumer of that URL, never a second author of it.
|
||||
*
|
||||
* Containment is enforced with the URL parser rather than string inspection, because the parser is
|
||||
* what a real `fetch` would use: `"../calendar/records"` normalizes to another module's API, and a
|
||||
* check that only looked at the input string would miss it. Anything that resolves outside this
|
||||
* module's own root — a traversal, a protocol-relative `//host`, an absolute URL, an encoded
|
||||
* separator — is refused with `MODULE_API_PATH_INVALID`.
|
||||
*
|
||||
* This is a portability boundary, not a security one. Module UI is trusted same-realm code (ADR
|
||||
* 0022) and could call `fetch` directly; refusing here is what keeps a module honest about
|
||||
* addressing only its own backend, so the same artifact still runs under a standalone host. Server
|
||||
* authorization is unaffected either way: every module route stays behind `requirePermission`.
|
||||
*/
|
||||
function createModuleApiClient(
|
||||
config: EkklesiaHostConfig,
|
||||
descriptor: HostModuleUiDescriptor,
|
||||
): ModuleUiApiClient {
|
||||
const performRequest = config.fetch ?? fetch;
|
||||
const moduleRoot = new URL(resolveApiUrl(config, descriptor.apiBaseUrl));
|
||||
|
||||
return Object.freeze({
|
||||
async request(path: string, init?: RequestInit): Promise<Response> {
|
||||
if (ABSOLUTE_URL_PATTERN.test(path) || path.startsWith("//")) {
|
||||
throw new HostProtocolError("MODULE_API_PATH_INVALID");
|
||||
}
|
||||
const relative = path.replace(/^\/+/u, "");
|
||||
const target = new URL(
|
||||
relative === "" ? moduleRoot.pathname : `${moduleRoot.pathname}/${relative}`,
|
||||
moduleRoot,
|
||||
);
|
||||
const contained =
|
||||
target.origin === moduleRoot.origin &&
|
||||
(target.pathname === moduleRoot.pathname ||
|
||||
target.pathname.startsWith(`${moduleRoot.pathname}/`));
|
||||
if (!contained) {
|
||||
throw new HostProtocolError("MODULE_API_PATH_INVALID");
|
||||
}
|
||||
// The module's own `init` is forwarded whole — method, headers, body, signal — and only
|
||||
// `credentials` is imposed, because session handling is the host's job and a module UI never
|
||||
// sees or supplies a token (ADR 0022). A module cannot opt out of it.
|
||||
return performRequest(target.href, { ...init, credentials: "include" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface CreateModuleUiContextOptions {
|
||||
readonly config: EkklesiaHostConfig;
|
||||
readonly descriptor: HostModuleUiDescriptor;
|
||||
readonly principal: ModuleUiPrincipal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `ModuleUiHostContext` (ADR 0022) Ekklesia hands a module UI at mount time.
|
||||
*
|
||||
* All four members come from server-derived data: the module id and API root from the inventory
|
||||
* descriptor, the principal from `/api/v1/principal`, and the permission hints from the server's own
|
||||
* evaluation of that module's declared permissions against that principal. Nothing is guessed,
|
||||
* defaulted to `true`, or inferred from a client-side role name.
|
||||
*
|
||||
* The permission set is the module's own and only the module's own. A module UI cannot use this to
|
||||
* discover what the viewer may do elsewhere in the platform, and `has` returning `true` is a
|
||||
* statement about what the server said a moment ago — a hint for hiding a control, never a
|
||||
* substitute for the server check that actually decides.
|
||||
*/
|
||||
export function createModuleUiContext(options: CreateModuleUiContextOptions): ModuleUiHostContext {
|
||||
const granted = new Set(options.descriptor.permissions);
|
||||
return Object.freeze({
|
||||
api: createModuleApiClient(options.config, options.descriptor),
|
||||
moduleId: options.descriptor.id,
|
||||
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
|
||||
principal: options.principal,
|
||||
});
|
||||
}
|
||||
|
||||
/** The absolute URL the generic loader should import this module's artifact from — the descriptor's
|
||||
* server-produced path resolved against the configured API origin, and nothing more. */
|
||||
export function moduleUiSourceUrl(
|
||||
config: EkklesiaHostConfig,
|
||||
descriptor: HostModuleUiDescriptor,
|
||||
): string {
|
||||
return resolveApiUrl(config, descriptor.uiUrl);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/index.ts", "src/host-protocol.ts", "src/module-context.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@ekklesia/typescript-config/browser-react.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["tests/**/*.ts", "tests/**/*.tsx", "scripts/**/*.mjs"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "happy-dom",
|
||||
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"],
|
||||
passWithNoTests: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user