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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-22 08:55:44 -05:00
co-authored by Claude Opus 5
parent 7a76ff6f26
commit 616c1d4834
46 changed files with 3629 additions and 36 deletions
+175
View File
@@ -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>
);
}