feat: add Dashboard navigation as presentation configuration
CI / verify (pull_request) Successful in 34s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 44s

Phase 6D pass 1. The shell's navigation was a flat list of module ids, which
is not a usable experience — and the obvious fix carries an obvious trap.
Letting a module declare its folder, or modelling "Media" as something
Livestream and Video belong to, turns a rendering preference into
architecture: a folder becomes an owner, membership starts to look like a
dependency, and the flat module graph acquires a hierarchy nobody decided on.

So the rule comes first: a Dashboard group is a label on a rendering bucket.
No manifest, no version, no artifact, no API, no lifecycle; nothing depends on
it. Livestream depends on Video because it calls Video's functionality, and
that orders startup. Livestream is shown under Media, and that orders nothing.
No parent module, child module, owning module, or group-derived dependency
appears anywhere in the contracts.

No manifest change and no version 5. `manifest.name` has been required since
version 1 and already is the module's product identity, so the shell renders
"Livestream" instead of `livestream` with nothing added. `suggestedGroup` is
deliberately deferred rather than shipped: it would force a hard
manifest-version cutover for a hint, and its only correct semantics needs
initialization logic that risks a suggestion overwriting an administrator's
decision. Defaulting and ownership are different things.

Two Core tables (migration 0003) hold groups and placements — server-side,
because a church configures its Dashboard rather than one browser;
platform-global, because no organization model exists to scope them to.
`group_id` is `on delete set null`, so deleting a group ungroups its modules
and a dangling reference is impossible. `module_id` carries no foreign key,
because modules are packages rather than rows, so a placement for an
uninstalled module is retained and restores its position on reinstall.

`resolveDashboardNavigation` is a pure function of configuration plus the
mountable set: an unplaced module is ungrouped and visible with no
initialization write, an absent module renders nothing, empty groups are kept,
and ordering is total with the module id as final tie-break.

`GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of
the host-neutral module UI inventory rather than merged into it — each generic
descriptor is embedded untouched, so a standalone host never inherits Dashboard
semantics. Reading needs the permission the inventory already needs; organizing
needs the new platform permission `core.dashboard.navigation.manage` through a
narrow, idempotent, closed-validation mutation contract. No editor UI.

The flatness rule is executable: a test discovers real fixtures with a genuine
dependency, arranges them four ways including both in one folder and the
dependency inverted in presentation, and asserts discovery order, dependency
declarations, and the module set stay byte-identical. The end-to-end test
organizes a real installation through the real API, mounts a module from inside
a group, moves it, and asserts the navigation entry is byte-identical across the
move and still mounts the same artifact.

The shell's client for the generic inventory became dead code and was removed
rather than kept speculatively; the endpoint itself is unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-22 12:44:40 -05:00
co-authored by Claude Opus 5
parent ac5b2b7f6c
commit bd34c9c714
49 changed files with 4866 additions and 213 deletions
+111 -50
View File
@@ -2,6 +2,7 @@ import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useEffect, useState } from "react";
import {
bootstrapHost,
type DashboardNavigationEntry,
type EkklesiaHostConfig,
type HostBootstrap,
HostProtocolError,
@@ -24,7 +25,14 @@ export interface HostAppProps {
}
const shellStyles = {
aside: { borderRight: "1px solid #e7e5e4", minWidth: "12rem", padding: "1rem" },
aside: { borderRight: "1px solid #e7e5e4", minWidth: "13rem", padding: "1rem" },
groupLabel: {
color: "#78716c",
fontSize: "0.75rem",
letterSpacing: "0.05em",
margin: "0 0 0.25rem",
textTransform: "uppercase",
},
header: {
borderBottom: "1px solid #e7e5e4",
fontWeight: 600,
@@ -42,24 +50,66 @@ const shellStyles = {
message: { color: "#57534e", padding: "1rem" },
} as const;
function moduleKey(id: string, version: string): string {
return `${id}@${version}`;
/** Identity, not placement: a module keeps the same selection key wherever it is arranged, so moving
* it between groups never resets what is open or remounts what is showing. */
function moduleKey(entry: DashboardNavigationEntry): string {
return `${entry.descriptor.id}@${entry.descriptor.version}`;
}
function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry[] {
return [
...bootstrap.navigation.groups.flatMap((group) => group.modules),
...bootstrap.navigation.ungrouped,
];
}
function ModuleButton(props: {
readonly entry: DashboardNavigationEntry;
readonly selected: boolean;
readonly onSelect: () => void;
}): ReactElement {
return (
<button
aria-current={props.selected ? "page" : undefined}
data-testid={`select-${props.entry.descriptor.id}`}
onClick={props.onSelect}
style={{
background: props.selected ? "#e7e5e4" : "transparent",
border: "none",
borderRadius: "0.25rem",
cursor: "pointer",
font: "inherit",
padding: "0.375rem 0.5rem",
textAlign: "left",
width: "100%",
}}
type="button"
>
{props.entry.name}
</button>
);
}
/**
* The minimal Ekklesia browser shell: sign-in awareness, a module list, and one mount target.
* The Ekklesia browser shell: sign-in awareness, the Dashboard's configured navigation, 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.
* ## The shell renders the arrangement; it never computes one
*
* 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.
* Groups, their order, their labels, which module sits where, and the order within each group all
* arrive already resolved from the server (ADR 0027). This component reads no manifest, performs no
* sorting, and knows nothing about how a placement is decided — so changing the Dashboard's ordering
* rules, or eventually where configuration is scoped, needs no change here at all. The module id is
* never displayed: the module's own manifest name is.
*
* ## Still not the finished Dashboard
*
* There is one level of grouping and no nesting, no icons, no drag and drop, no editor, no home
* screen or widgets, no theme, and no client routing — selection is state. What this proves is the
* layer beneath a Dashboard: persistent, platform-owned presentation configuration driving a real
* host that mounts flat, independent modules.
*
* Nothing about any specific module appears in this file, or anywhere in this package.
*/
export function HostApp(props: HostAppProps): ReactElement {
const [state, setState] = useState<BootstrapState>({ status: "loading" });
@@ -79,11 +129,8 @@ export function HostApp(props: HostAppProps): ReactElement {
? { status: "signed-out" }
: { bootstrap, status: "ready" },
);
setSelectedKey(
bootstrap.modules[0]
? moduleKey(bootstrap.modules[0].id, bootstrap.modules[0].version)
: undefined,
);
const first = allEntries(bootstrap)[0];
setSelectedKey(first ? moduleKey(first) : undefined);
})
.catch((error: unknown) => {
if (abort.signal.aborted) {
@@ -118,54 +165,68 @@ export function HostApp(props: HostAppProps): ReactElement {
}
const { bootstrap } = state;
const selected = bootstrap.modules.find(
(module) => moduleKey(module.id, module.version) === selectedKey,
);
const entries = allEntries(bootstrap);
const selected = entries.find((entry) => moduleKey(entry) === selectedKey);
const { groups, ungrouped } = bootstrap.navigation;
return (
<div style={shellStyles.layout}>
<header style={shellStyles.header}>Ekklesia</header>
<aside style={shellStyles.aside}>
{bootstrap.modules.length === 0 ? (
{entries.length === 0 && groups.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 aria-label="Modules" style={{ display: "grid", gap: "1rem" }}>
{groups.map((group) => (
// An empty group still renders: an administrator created it, and hiding it would look
// like it had been deleted.
<section data-testid={`group-${group.id}`} key={group.id}>
<h2 style={shellStyles.groupLabel}>{group.label}</h2>
<div style={{ display: "grid", gap: "0.125rem" }}>
{group.modules.map((entry) => (
<ModuleButton
entry={entry}
key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))}
selected={moduleKey(entry) === selectedKey}
/>
))}
</div>
</section>
))}
{ungrouped.length > 0 ? (
// Where a module lives until someone decides otherwise — including one installed five
// minutes ago. The heading is omitted when nothing is grouped, so an unconfigured
// installation reads as a plain module list rather than a mysterious "Other" bucket.
<section data-testid="group-ungrouped">
{groups.length > 0 ? <h2 style={shellStyles.groupLabel}>Other</h2> : undefined}
<div style={{ display: "grid", gap: "0.125rem" }}>
{ungrouped.map((entry) => (
<ModuleButton
entry={entry}
key={moduleKey(entry)}
onSelect={() => setSelectedKey(moduleKey(entry))}
selected={moduleKey(entry) === selectedKey}
/>
))}
</div>
</section>
) : undefined}
</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.
// Keyed by module identity — not by group — so switching selection discards this frame and
// its container entirely, while re-grouping a module never remounts it. See
// `ModuleUiFrame` for why that is what makes a slow, stale load harmless.
<ModuleUiFrame
config={config}
descriptor={selected}
descriptor={selected.descriptor}
{...(importModule ? { importModule } : {})}
key={moduleKey(selected.id, selected.version)}
key={moduleKey(selected)}
principal={bootstrap.principal}
/>
) : undefined}
+113 -40
View File
@@ -17,10 +17,15 @@ export interface EkklesiaHostConfig {
}
/** 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. */
* URL — the server produces both and hands them over in the navigation view — so these two are the
* only URLs it knows, and they describe the platform itself rather than any module.
*
* `/api/v1/module-ui` is deliberately absent: that host-neutral inventory (ADR 0026) remains the
* endpoint a standalone host would consume, but Ekklesia's own shell needs the arrangement too, so
* it reads the navigation view instead. Keeping an unused client for it here would be speculative
* code for a consumer that does not exist. */
const PRINCIPAL_PATH = "/api/v1/principal";
const MODULE_UI_INVENTORY_PATH = "/api/v1/module-ui";
const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
export type HostProtocolErrorCode =
/** The caller is not signed in, or lacks `core.modules.ui.read`. */
@@ -112,37 +117,99 @@ function rootRelativePath(value: unknown, key: string): string | undefined {
* 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)) {
function parseDescriptor(entry: unknown): 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(
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,
});
}),
);
return Object.freeze({
apiBaseUrl,
id,
permissions: Object.freeze([...(permissions as string[])]),
uiUrl,
version,
});
}
/**
* One module as the Dashboard presents it.
*
* The mount descriptor is nested exactly as the server sends it, not flattened into this object.
* That keeps the two concerns visibly separate in the browser as well as on the wire: `descriptor`
* is the host-neutral "what and how to mount", and `name` is Ekklesia's presentation of it. Nothing
* about a group is stored on the module — a module does not know, and must not know, where it has
* been placed.
*/
export interface DashboardNavigationEntry {
readonly name: string;
readonly descriptor: HostModuleUiDescriptor;
}
export interface DashboardNavigationGroup {
/** Opaque and stable; the shell uses it as a React key and never displays it. */
readonly id: string;
readonly label: string;
readonly modules: readonly DashboardNavigationEntry[];
}
/**
* The whole navigation the shell renders, already resolved and ordered by the server.
*
* The browser performs no grouping, no sorting, and no joining of its own: it does not read
* manifests, does not know which module belongs where, and could not compute this view if it wanted
* to. Ordering arrives as array order, so a change to the server's ordering rules needs no shell
* change at all.
*/
export interface DashboardNavigation {
readonly groups: readonly DashboardNavigationGroup[];
readonly ungrouped: readonly DashboardNavigationEntry[];
}
function parseNavigationEntry(entry: unknown): DashboardNavigationEntry {
const name = nestedString(entry, "name");
if (name === undefined || !isRecord(entry)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({ descriptor: parseDescriptor(entry.module), name });
}
/** Strict, total validation, exactly like the inventory parser: a malformed group or entry fails
* the whole document rather than rendering a half-built navigation. */
export function parseDashboardNavigation(payload: unknown): DashboardNavigation {
const groups = isRecord(payload) ? payload.groups : undefined;
const ungrouped = isRecord(payload) ? payload.ungrouped : undefined;
if (!Array.isArray(groups) || !Array.isArray(ungrouped)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
groups: Object.freeze(
groups.map((group): DashboardNavigationGroup => {
const id = nestedString(group, "id");
const label = nestedString(group, "label");
const modules = isRecord(group) ? group.modules : undefined;
if (id === undefined || label === undefined || !Array.isArray(modules)) {
throw new HostProtocolError("HOST_RESPONSE_INVALID");
}
return Object.freeze({
id,
label,
modules: Object.freeze(modules.map((entry) => parseNavigationEntry(entry))),
});
}),
),
ungrouped: Object.freeze(ungrouped.map((entry) => parseNavigationEntry(entry))),
});
}
/** `/api/v1/principal`'s two documented shapes, narrowed to the browser-owned `ModuleUiPrincipal`
@@ -214,21 +281,27 @@ export async function loadHostPrincipal(
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(
/** The resolved Dashboard navigation: the same mountable modules, arranged as this installation has
* configured them (ADR 0027). */
export async function loadDashboardNavigation(
config: EkklesiaHostConfig,
signal?: AbortSignal,
): Promise<readonly HostModuleUiDescriptor[]> {
return parseHostModuleUiInventory(
await requestPlatformJson(config, MODULE_UI_INVENTORY_PATH, signal),
): Promise<DashboardNavigation> {
return parseDashboardNavigation(
await requestPlatformJson(config, DASHBOARD_NAVIGATION_PATH, signal),
);
}
export interface HostBootstrap {
readonly principal: ModuleUiPrincipal;
readonly modules: readonly HostModuleUiDescriptor[];
readonly navigation: DashboardNavigation;
}
const EMPTY_NAVIGATION: DashboardNavigation = Object.freeze({
groups: Object.freeze([]),
ungrouped: Object.freeze([]),
});
/**
* 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
@@ -241,7 +314,7 @@ export async function bootstrapHost(
): Promise<HostBootstrap> {
const principal = await loadHostPrincipal(config, signal);
if (principal.kind === "anonymous") {
return { modules: Object.freeze([]), principal };
return { navigation: EMPTY_NAVIGATION, principal };
}
return { modules: await loadModuleUiInventory(config, signal), principal };
return { navigation: await loadDashboardNavigation(config, signal), principal };
}
+5 -2
View File
@@ -11,14 +11,17 @@
*/
export {
bootstrapHost,
type DashboardNavigation,
type DashboardNavigationEntry,
type DashboardNavigationGroup,
type EkklesiaHostConfig,
type HostBootstrap,
type HostModuleUiDescriptor,
HostProtocolError,
type HostProtocolErrorCode,
loadDashboardNavigation,
loadHostPrincipal,
loadModuleUiInventory,
parseHostModuleUiInventory,
parseDashboardNavigation,
parseHostPrincipal,
resolveApiUrl,
} from "./host-protocol.js";
+194 -25
View File
@@ -34,25 +34,36 @@ afterEach(async () => {
consoleError.mockRestore();
});
function descriptor(id: string, version = "0.1.0", permissions: readonly string[] = []) {
/** One navigation entry, in the wire shape the Dashboard navigation endpoint produces: the module's
* display name beside the untouched mount descriptor. */
function entry(id: string, name: 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,
module: {
api: { baseUrl: `/api/v1/modules/${id}` },
id,
permissions,
ui: { url: `/api/v1/module-ui/${id}/${version}` },
version,
},
name,
};
}
function group(id: string, label: string, modules: ReturnType<typeof entry>[], order = 10) {
return { id, label, modules, order };
}
interface PlatformStub {
readonly modules?: ReadonlyArray<ReturnType<typeof descriptor>>;
readonly groups?: ReadonlyArray<ReturnType<typeof group>>;
readonly ungrouped?: ReadonlyArray<ReturnType<typeof entry>>;
readonly principal?: unknown;
readonly inventoryStatus?: number;
readonly inventoryThrows?: boolean;
readonly navigationStatus?: number;
readonly navigationThrows?: boolean;
}
function platformFetch(stub: PlatformStub = {}): typeof fetch {
const modules = stub.modules ?? [descriptor("example")];
const groups = stub.groups ?? [];
const ungrouped = stub.ungrouped ?? (stub.groups ? [] : [entry("example", "Example Module")]);
return vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
@@ -61,13 +72,13 @@ function platformFetch(stub: PlatformStub = {}): typeof fetch {
{ headers: { "content-type": "application/json" }, status: 200 },
);
}
if (url.endsWith("/api/v1/module-ui")) {
if (stub.inventoryThrows) {
if (url.endsWith("/api/v1/dashboard/navigation")) {
if (stub.navigationThrows) {
throw new TypeError("network down");
}
return new Response(JSON.stringify({ modules }), {
return new Response(JSON.stringify({ groups, ungrouped }), {
headers: { "content-type": "application/json" },
status: stub.inventoryStatus ?? 200,
status: stub.navigationStatus ?? 200,
});
}
return new Response("{}", { status: 404 });
@@ -123,10 +134,12 @@ function query(testId: string): Element | null {
}
describe("Ekklesia shell — bootstrap", () => {
it("renders the module list the inventory returned", async () => {
it("renders the module list the navigation returned", async () => {
await render({
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
fetch: platformFetch({
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
}),
});
expect(query("select-example")).not.toBeNull();
@@ -140,26 +153,174 @@ describe("Ekklesia shell — bootstrap", () => {
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 }) });
it("treats refused navigation as signed out rather than as a crash", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ navigationStatus: 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 }) });
await render({ apiBaseUrl, fetch: platformFetch({ navigationThrows: 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: [] }) });
await render({ apiBaseUrl, fetch: platformFetch({ groups: [], ungrouped: [] }) });
expect(query("host-empty")).not.toBeNull();
});
});
describe("Ekklesia shell — Dashboard navigation", () => {
const mediaGroup = () =>
group("media", "Media", [entry("video", "Video"), entry("livestream", "Livestream", "2.0.0")]);
const planningGroup = () => group("planning", "Planning", [entry("calendar", "Calendar")], 20);
it("renders groups with their labels and the modules' display names", async () => {
await render({
apiBaseUrl,
fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }),
});
expect(query("group-media")?.textContent).toBe("MediaVideoLivestream");
expect(query("group-planning")?.textContent).toBe("PlanningCalendar");
// The module id is never shown; the manifest name is.
expect(text()).not.toContain("livestream");
});
it("renders groups and modules in exactly the server's order", async () => {
// The shell sorts nothing: whatever order arrives is the order shown, so the server stays the
// single authority on arrangement.
await render({
apiBaseUrl,
fetch: platformFetch({
groups: [
group("planning", "Planning", [entry("calendar", "Calendar")]),
group("media", "Media", [
entry("livestream", "Livestream", "2.0.0"),
entry("video", "Video"),
]),
],
}),
});
const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent);
expect(labels).toEqual(["Planning", "Media"]);
const buttons = [...container.querySelectorAll("button")].map((button) => button.textContent);
expect(buttons).toEqual(["Calendar", "Livestream", "Video"]);
});
it("renders ungrouped modules alongside groups, under a heading", async () => {
await render({
apiBaseUrl,
fetch: platformFetch({
groups: [mediaGroup()],
ungrouped: [entry("roster", "Roster")],
}),
});
expect(query("group-ungrouped")?.textContent).toBe("OtherRoster");
});
it("omits the heading when nothing is grouped, so an unconfigured install reads as a list", async () => {
await render({
apiBaseUrl,
fetch: platformFetch({ ungrouped: [entry("roster", "Roster"), entry("video", "Video")] }),
});
expect(query("group-ungrouped")?.textContent).toBe("RosterVideo");
expect(container.querySelectorAll("h2")).toHaveLength(0);
});
it("keeps an empty group visible", async () => {
await render({
apiBaseUrl,
fetch: platformFetch({
groups: [group("media", "Media", [])],
ungrouped: [entry("video", "Video")],
}),
});
// The administrator created it; hiding it would look like it had been deleted.
expect(query("group-media")?.textContent).toBe("Media");
});
it("selects the first module in the arranged order and marks it active", async () => {
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async (url) => artifact(url.includes("/video/") ? "video" : "other", "0.1.0"),
);
expect(query("select-video")?.getAttribute("aria-current")).toBe("page");
expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull();
});
it("mounts a module selected from inside a group, using the descriptor the group carried", async () => {
const requested: string[] = [];
await render(
{ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) },
async (url) => {
requested.push(url);
return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0");
},
);
await act(async () => {
(query("select-calendar") as HTMLButtonElement).click();
});
// The nested descriptor is used unchanged: grouping altered nothing about the artifact URL.
expect(requested).toEqual([
`${apiBaseUrl}/api/v1/module-ui/video/0.1.0`,
`${apiBaseUrl}/api/v1/module-ui/calendar/0.1.0`,
]);
expect(query("module-container")?.textContent).toBe("calendar mounted");
});
it("mounts the identical artifact whichever group a module is presented in", async () => {
const requestedWhenGrouped: string[] = [];
await render(
{
apiBaseUrl,
fetch: platformFetch({
groups: [group("media", "Media", [entry("livestream", "Livestream")])],
}),
},
async (url) => {
requestedWhenGrouped.push(url);
return artifact("livestream", "0.1.0");
},
);
await act(async () => {
root.unmount();
});
// The same module, presented somewhere else entirely.
root = createRoot(container);
const requestedWhenMoved: string[] = [];
await render(
{
apiBaseUrl,
fetch: platformFetch({
groups: [
group("sunday-production", "Sunday Production", [entry("livestream", "Livestream")], 5),
],
}),
},
async (url) => {
requestedWhenMoved.push(url);
return artifact("livestream", "0.1.0");
},
);
// Presentation changed; the module did not.
expect(requestedWhenMoved).toEqual(requestedWhenGrouped);
expect(query("group-sunday-production")?.textContent).toBe("Sunday ProductionLivestream");
});
});
describe("Ekklesia shell — mount lifecycle", () => {
it("mounts the first module into the host's own container", async () => {
const mountedInto: Element[] = [];
@@ -175,7 +336,7 @@ describe("Ekklesia shell — mount lifecycle", () => {
expect(mountedInto[0]).not.toBe(document.body);
});
it("imports exactly the URL the inventory published, resolved against the API origin", async () => {
it("imports exactly the URL the navigation published, resolved against the API origin", async () => {
const requested: string[] = [];
await render({ apiBaseUrl, fetch: platformFetch() }, async (url) => {
requested.push(url);
@@ -229,7 +390,9 @@ describe("Ekklesia shell — mount lifecycle", () => {
await render(
{
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
fetch: platformFetch({
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
}),
},
importModule,
);
@@ -275,7 +438,9 @@ describe("Ekklesia shell — mount lifecycle", () => {
await render(
{
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
fetch: platformFetch({
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
}),
},
importModule,
);
@@ -352,7 +517,9 @@ describe("Ekklesia shell — failure is host-owned", () => {
await render(
{
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
fetch: platformFetch({
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
}),
},
importModule,
);
@@ -389,7 +556,9 @@ describe("Ekklesia shell — failure is host-owned", () => {
await render(
{
apiBaseUrl,
fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }),
fetch: platformFetch({
ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")],
}),
},
importModule,
);
+125 -54
View File
@@ -1,11 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import {
bootstrapHost,
type EkklesiaHostConfig,
type HostModuleUiDescriptor,
loadDashboardNavigation,
loadHostPrincipal,
loadModuleUiInventory,
parseHostModuleUiInventory,
parseDashboardNavigation,
parseHostPrincipal,
} from "../src/host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
@@ -47,6 +46,37 @@ const inventoryPayload = {
],
};
const calendarDescriptor: HostModuleUiDescriptor = {
apiBaseUrl: "/api/v1/modules/calendar",
id: "calendar",
permissions: [],
uiUrl: "/api/v1/module-ui/calendar/2.0.0",
version: "2.0.0",
};
const navigationPayload = {
groups: [
{
id: "media",
label: "Media",
modules: [{ module: inventoryPayload.modules[0], name: "Example Module" }],
order: 10,
},
],
ungrouped: [
{
module: {
api: { baseUrl: "/api/v1/modules/calendar" },
id: "calendar",
permissions: [],
ui: { url: "/api/v1/module-ui/calendar/2.0.0" },
version: "2.0.0",
},
name: "Calendar",
},
],
};
const exampleDescriptor: HostModuleUiDescriptor = {
apiBaseUrl: "/api/v1/modules/example",
id: "example",
@@ -55,69 +85,57 @@ const exampleDescriptor: HostModuleUiDescriptor = {
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]);
function navigationWithModule(module: unknown): unknown {
return { groups: [], ungrouped: [{ module, name: "Example Module" }] };
}
describe("host protocol — descriptor validation", () => {
it("parses a well-formed descriptor out of the navigation view", () => {
const navigation = parseDashboardNavigation(navigationWithModule(inventoryPayload.modules[0]));
expect(navigation.ungrouped[0]?.descriptor).toEqual(exampleDescriptor);
});
it("rejects a malformed document rather than yielding a half-built descriptor", () => {
it("rejects a malformed descriptor rather than yielding a half-built one", () => {
const base = inventoryPayload.modules[0] as Record<string, unknown>;
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] }] },
{ id: "example", version: "0.1.0" },
{ ...base, ui: {} },
{ ...base, api: { baseUrl: "" } },
{ ...base, permissions: "example.records.read" },
{ ...base, 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" } },
],
},
{ ...base, ui: { url: "https://evil.example.test/x.js" } },
{ ...base, ui: { url: "//evil.example.test/x.js" } },
{ ...base, ui: { url: "relative/x.js" } },
{ ...base, api: { baseUrl: "https://evil.example.test/api" } },
];
for (const payload of malformed) {
expect(() => parseHostModuleUiInventory(payload)).toThrowError(
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
);
for (const module of malformed) {
expect(
() => parseDashboardNavigation(navigationWithModule(module)),
JSON.stringify(module),
).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),
"/api/v1/dashboard/navigation": () =>
jsonResponse({ code: "AUTHORIZATION_DENIED" }, status),
});
await expect(
loadModuleUiInventory({ apiBaseUrl, fetch: implementation }),
loadDashboardNavigation({ apiBaseUrl, fetch: implementation }),
).rejects.toMatchObject({ code: "HOST_UNAUTHENTICATED" });
}
const { implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500),
"/api/v1/dashboard/navigation": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500),
});
await expect(
loadModuleUiInventory({ apiBaseUrl, fetch: implementation }),
loadDashboardNavigation({ apiBaseUrl, fetch: implementation }),
).rejects.toMatchObject({ code: "HOST_REQUEST_FAILED" });
});
@@ -126,7 +144,7 @@ describe("host protocol — inventory", () => {
throw new Error("connect ECONNREFUSED /run/postgres/.s.PGSQL.5432");
}) as unknown as typeof fetch;
const error = await loadModuleUiInventory({ apiBaseUrl, fetch: failing }).catch(
const error = await loadDashboardNavigation({ apiBaseUrl, fetch: failing }).catch(
(caught: unknown) => caught,
);
@@ -167,10 +185,63 @@ describe("host protocol — principal", () => {
});
});
describe("host protocol — bootstrap", () => {
it("resolves the principal and then the inventory", async () => {
describe("host protocol — Dashboard navigation", () => {
it("parses the server's resolved arrangement, keeping the mount descriptor nested", () => {
const navigation = parseDashboardNavigation(navigationPayload);
expect(navigation.groups.map((group) => [group.id, group.label])).toEqual([["media", "Media"]]);
expect(navigation.groups[0]?.modules).toEqual([
{ descriptor: exampleDescriptor, name: "Example Module" },
]);
expect(navigation.ungrouped).toEqual([{ descriptor: calendarDescriptor, name: "Calendar" }]);
});
it("rejects a malformed navigation document rather than rendering half of it", () => {
const malformed: unknown[] = [
{},
{ groups: [] },
{ groups: {}, ungrouped: [] },
{ groups: [{ id: "media", label: "Media" }], ungrouped: [] },
{ groups: [{ id: "media", modules: [] }], ungrouped: [] },
{ groups: [], ungrouped: [{ name: "Example" }] },
{ groups: [], ungrouped: [{ module: inventoryPayload.modules[0] }] },
// An absolute artifact URL must be refused here exactly as in the inventory.
{
groups: [],
ungrouped: [
{
module: {
...inventoryPayload.modules[0],
ui: { url: "https://evil.example.test/x.js" },
},
name: "Example",
},
],
},
];
for (const payload of malformed) {
expect(() => parseDashboardNavigation(payload), JSON.stringify(payload)).toThrowError(
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
);
}
});
it("fetches navigation from the platform path with credentials", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
});
await loadDashboardNavigation({ apiBaseUrl, fetch: implementation });
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/dashboard/navigation`);
expect(calls[0]?.init?.credentials).toBe("include");
});
});
describe("host protocol — bootstrap", () => {
it("resolves the principal and then the Dashboard navigation", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
});
@@ -178,22 +249,22 @@ describe("host protocol — bootstrap", () => {
expect(calls.map((call) => call.url)).toEqual([
`${apiBaseUrl}/api/v1/principal`,
`${apiBaseUrl}/api/v1/module-ui`,
`${apiBaseUrl}/api/v1/dashboard/navigation`,
]);
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(bootstrap.modules).toEqual([exampleDescriptor]);
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
});
it("never requests the inventory for an anonymous viewer", async () => {
it("never requests navigation for an anonymous viewer", async () => {
const { calls, implementation } = stubFetch({
"/api/v1/module-ui": () => jsonResponse(inventoryPayload),
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
});
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
expect(bootstrap.principal).toEqual({ kind: "anonymous" });
expect(bootstrap.modules).toEqual([]);
expect(bootstrap.navigation).toEqual({ groups: [], ungrouped: [] });
expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
});
+17 -2
View File
@@ -144,9 +144,24 @@ describe("the shell builds no module knowledge into its bundle", () => {
expect(bundle).not.toContain("entrypoints");
});
it("spells only the two platform paths it genuinely owns", () => {
it("spells only the platform paths it genuinely owns", () => {
// The platform's own endpoints — never a module's. Everything module-specific arrives at run
// time from the navigation the server resolved.
expect(bundle).toContain('"/api/v1/principal"');
expect(bundle).toContain('"/api/v1/module-ui"');
expect(bundle).toContain('"/api/v1/dashboard/navigation"');
// The shell reads the navigation view, which already contains the inventory; it never calls
// the host-neutral inventory endpoint itself.
expect(bundle).not.toContain('"/api/v1/module-ui"');
});
it("bakes in no group, label, or placement", () => {
// Grouping is server-resolved configuration. A group id or label appearing in the bundle would
// mean the shell had an opinion about an arrangement it must only render.
// Deliberately distinctive terms: a generic word like "media" appears inside React's own
// bundled source and would make this assertion meaningless rather than strict.
for (const term of ["planning", "sunday-production", "livestream", "calendar", "ungrouped-"]) {
expect(bundle.toLowerCase(), term).not.toContain(term);
}
});
it("uses neither eval nor a blob URL to load module code", () => {