Add the module navigation contract: manifest v9, module routes, return-to-origin

Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard.

- Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated
  MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe
  label, kebab-case path relative to the module root; no traversal, schemes,
  queries, duplicates, or nesting). Validated before activation.
- Version 8 stays accepted beside version 9, each read strictly as itself
  (discriminated union); no released manifest is rewritten. The template
  moves to v9; the released Signage 0.1.5 manifest is a test fixture.
- Real module routes: /<id> is the landing view, /<id>/<page> a declared
  page, anything beneath a page a module-owned detail route; anything else
  is not-found and mounts nothing. api/assets/display are never pages.
- Sidebar: declared icons, pages nested under an open module, one
  aria-current, collapsed and drawer behaviour; v8 modules unchanged.
- module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe,
  navigate, link, settings), refusing anything outside the module's routes;
  undefined on the display host. Mount ABI unchanged.
- Return-to-origin: one `?return=` carrier on Settings URLs, validated by
  parseReturnTarget and against running modules' declared routes; Settings
  Back returns to exactly the originating module page, across refreshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-15 08:21:50 -05:00
co-authored by Claude Opus 5.5
parent 7447381c8f
commit 4671622b9d
69 changed files with 4221 additions and 372 deletions
+117 -1
View File
@@ -2,7 +2,7 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseModuleManifest } from "@ekklesia/module-sdk";
import { moduleManifestNavigation, parseModuleManifest } from "@ekklesia/module-sdk";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import {
type ModuleUiApiClient,
@@ -58,6 +58,7 @@ function recordingHost(options: {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId: "example",
navigation: undefined,
api,
principal: { kind: "anonymous" },
permissions: { has: () => options.canRead },
@@ -65,6 +66,56 @@ function recordingHost(options: {
};
}
/** A host navigation capability the test drives: the pages this module declares, a location the
* host can move, and a record of every place the module asked to go. */
function fakeNavigation(initial: string) {
const pages = ["records", "about"];
let path = initial;
const listeners = new Set<() => void>();
const pageOf = (route: string) =>
pages.find((page) => route === page || route.startsWith(`${page}/`));
let snapshot = { page: pageOf(path), path };
const visited: string[] = [];
const goTo = (next: string): void => {
path = next;
snapshot = { page: pageOf(path), path };
for (const listener of [...listeners]) {
listener();
}
};
const navigation = {
goTo,
link: (to: string) => ({
href: `/example/${to}`,
onClick: (event: { preventDefault(): void }) => {
event.preventDefault();
visited.push(to);
goTo(to);
},
}),
location: () => snapshot,
navigate: (to: string) => {
visited.push(to);
goTo(to);
},
settings: {
link: () => ({ href: "/settings/example", onClick: () => undefined }),
open: () => {
navigation.settingsOpened += 1;
},
},
settingsOpened: 0,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
visited,
};
return navigation;
}
async function flush(): Promise<void> {
// Let React commit and the awaited fetch microtasks settle.
for (let index = 0; index < 5; index += 1) {
@@ -187,6 +238,71 @@ describe("module UI build pipeline", () => {
container.remove();
});
it("declares two primary pages and an icon in its manifest v9 navigation (ADR 0063)", async () => {
const manifest = parseModuleManifest(
JSON.parse(await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8")),
);
expect(manifest.manifestVersion).toBe(9);
expect(moduleManifestNavigation(manifest)).toEqual({
icon: "clipboard-list",
pages: [
{ id: "records", label: "Records", path: "records" },
{ id: "about", label: "About", path: "about" },
],
});
});
it("renders the page its host says is open, and moves only through the host's navigation", async () => {
const namespace = (await import(artifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity);
const container = document.createElement("div");
document.body.append(container);
const navigation = fakeNavigation("");
const host = {
...recordingHost({
canRead: true,
records: [{ id: "a1", value: "Hello, one" }],
requested: [],
resolve: () => undefined,
}),
navigation,
};
const unmount = await definition.mount(container, host);
await flush();
// The landing view links to a page by its host-made address, and offers Settings.
const toRecords = container.querySelector<HTMLAnchorElement>(
"[data-testid='example-open-records']",
);
expect(toRecords?.getAttribute("href")).toBe("/example/records");
container.querySelector<HTMLButtonElement>("[data-testid='example-open-settings']")?.click();
expect(navigation.settingsOpened).toBe(1);
toRecords?.click();
await flush();
expect(navigation.visited).toEqual(["records"]);
expect(container.querySelector("[data-testid='example-records']")).not.toBeNull();
const toRecord = container.querySelector<HTMLAnchorElement>(
"[data-testid='example-record-link-a1']",
);
expect(toRecord?.getAttribute("href")).toBe("/example/records/a1");
toRecord?.click();
await flush();
expect(container.querySelector("[data-testid='example-record-value']")?.textContent).toBe(
"Hello, one",
);
navigation.goTo("about");
await flush();
expect(container.querySelector("[data-testid='example-about']")).not.toBeNull();
// The module never touched the browser's own history.
expect(window.location.pathname).toBe("/");
await (unmount as () => Promise<void>)();
container.remove();
});
it("mounts the display artifact through the same ABI, then unmounts it", async () => {
const namespace = (await import(displayArtifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity);