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
+21 -5
View File
@@ -78,7 +78,13 @@ const navigationPayload = {
{
id: "media",
label: "Media",
modules: [{ module: inventoryPayload.modules[0], name: "Example Module" }],
modules: [
{
module: inventoryPayload.modules[0],
name: "Example Module",
navigation: { icon: null, pages: [] },
},
],
order: 10,
},
],
@@ -95,6 +101,7 @@ const navigationPayload = {
version: "2.0.0",
},
name: "Calendar",
navigation: { icon: null, pages: [] },
},
],
};
@@ -111,7 +118,10 @@ const exampleDescriptor: HostModuleUiDescriptor = {
};
function navigationWithModule(module: unknown): unknown {
return { groups: [], ungrouped: [{ module, name: "Example Module" }] };
return {
groups: [],
ungrouped: [{ module, name: "Example Module", navigation: { icon: null, pages: [] } }],
};
}
describe("host protocol — API origin (ADR 0045)", () => {
@@ -257,9 +267,11 @@ describe("host protocol — Dashboard navigation", () => {
expect(navigation.groups.map((group) => [group.id, group.label])).toEqual([["media", "Media"]]);
expect(navigation.groups[0]?.modules).toEqual([
{ descriptor: exampleDescriptor, name: "Example Module" },
{ descriptor: exampleDescriptor, icon: undefined, name: "Example Module", pages: [] },
]);
expect(navigation.ungrouped).toEqual([
{ descriptor: calendarDescriptor, icon: undefined, name: "Calendar", pages: [] },
]);
expect(navigation.ungrouped).toEqual([{ descriptor: calendarDescriptor, name: "Calendar" }]);
});
it("rejects a malformed navigation document rather than rendering half of it", () => {
@@ -281,6 +293,7 @@ describe("host protocol — Dashboard navigation", () => {
ui: { url: "https://evil.example.test/x.js" },
},
name: "Example",
navigation: { icon: null, pages: [] },
},
],
},
@@ -556,7 +569,7 @@ describe("module UI host context", () => {
expect(context.display?.url).toBe("https://api.example.test/display/example");
});
it("exposes exactly the seven contract members and the browser principal", () => {
it("exposes exactly the eight contract members and the browser principal", () => {
const { implementation } = recordingFetch();
const context = contextFor(implementation);
@@ -566,11 +579,14 @@ describe("module UI host context", () => {
"contributions",
"display",
"moduleId",
"navigation",
"permissions",
"principal",
"realtime",
]);
expect(context.moduleId).toBe("example");
// Navigation comes only from a host with addressable pages (ADR 0063); this one supplies none.
expect(context.navigation).toBeUndefined();
expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" });
});