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
+7 -2
View File
@@ -219,20 +219,25 @@ describe("Dashboard grouping never becomes module structure", () => {
}
});
it("keeps the publication record free of any grouping, ordering, or navigation field", async () => {
it("keeps the publication record free of any grouping or ordering field", async () => {
const registry = createModuleUiPublicationRegistry(await discoverFixtures());
const published = registry.resolve("livestream", "1.0.0");
// The record a host mounts from carries identity, the validated artifact, declared permissions,
// and the module's own name — never a group, an order, a parent, or a folder.
// the module's own name, and its own declared navigation — never a group, an order, a parent,
// or a folder. Navigation is what the module *is* (its icon and pages, ADR 0063); where it sits
// stays the administrator's (ADR 0027).
expect(Object.keys(published ?? {}).sort()).toEqual([
"artifactPath",
"moduleId",
"name",
"navigation",
"permissions",
"version",
]);
expect(Object.keys(published?.navigation ?? {}).sort()).toEqual(["pages"]);
expect(published?.navigation.pages).toEqual([]);
});
it("takes the display name from the module's own manifest, not from any folder", async () => {
+107 -1
View File
@@ -1,6 +1,8 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { moduleManifestNavigation } from "@ekklesia/contracts";
import { afterEach, describe, expect, it } from "vitest";
import {
discoverModules,
@@ -1220,3 +1222,107 @@ describe("browser contribution artifact resolution", () => {
});
});
});
describe("manifest v8 and v9 side by side (ADR 0063)", () => {
const releasedSignageManifest = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"packages",
"contracts",
"tests",
"fixtures",
"released-v8-signage-0.1.5.module.json",
);
it("discovers the released Signage 0.1.5 module exactly as published, with no navigation", async () => {
const modulesDirectory = await createModulesDirectory();
const manifest = JSON.parse(await readFile(releasedSignageManifest, "utf8")) as Record<
string,
unknown
>;
await createModule(modulesDirectory, "signage", {
extraFiles: {
"dist/display/index.js": "export default {};\n",
"dist/schema/index.js": "export {};\n",
"dist/ui/index.js": "export default {};\n",
"drizzle/meta/_journal.json": JSON.stringify({
dialect: "postgresql",
entries: [],
version: "7",
}),
},
manifest,
packageJson: {
ekklesia: { layer: "module" },
name: "@zayin/signage",
type: "module",
version: manifest.version,
},
serverSource: 'export default { id: "signage", version: "0.1.5" };',
});
const [signage] = await discoverModules({
moduleSdkVersion: "0.2.5",
modulesDirectory,
platformVersion: "0.2.5",
});
expect(signage?.manifest.manifestVersion).toBe(8);
expect(signage?.entrypoints.ui).toBeDefined();
expect(moduleManifestNavigation(signage?.manifest ?? { manifestVersion: 8 })).toEqual({
pages: [],
});
// Its settings, which Settings → Modules lists (ADR 0061), are read exactly as before.
expect(signage?.manifest.configuration.map((declaration) => declaration.key)).toEqual(
(manifest.configuration as { key: string }[]).map((declaration) => declaration.key),
);
});
it("discovers a manifest v9 module with its declared icon and pages", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "media", {
extraFiles: { "dist/ui/index.js": "export default {};\n" },
manifest: {
...manifestFor("media"),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
manifestVersion: 9,
navigation: {
icon: "images",
pages: [
{ id: "sermons", label: "Sermons", path: "sermons" },
{ id: "library", label: "Library", path: "library" },
],
},
},
});
const [media] = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(media?.manifest.manifestVersion).toBe(9);
const navigation = moduleManifestNavigation(media?.manifest ?? { manifestVersion: 9 });
expect(navigation.icon).toBe("images");
expect(navigation.pages.map((page) => page.path)).toEqual(["sermons", "library"]);
// Deeply frozen like the rest of a discovered manifest.
expect(Object.isFrozen(navigation.pages)).toBe(true);
});
it("refuses a manifest v9 module whose navigation escapes its own namespace", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "media", {
extraFiles: { "dist/ui/index.js": "export default {};\n" },
manifest: {
...manifestFor("media"),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
manifestVersion: 9,
navigation: { pages: [{ id: "out", label: "Out", path: "../settings" }] },
},
});
expect(issueCodes(await discoveryError(modulesDirectory))).toEqual(["MODULE_MANIFEST_INVALID"]);
});
});