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
@@ -0,0 +1,161 @@
{
"manifestVersion": 8,
"id": "signage",
"name": "Signage",
"description": "Visual programming, timelines, synchronized channels, and display delivery for church screens.",
"version": "0.1.5",
"compatibility": {
"platform": "^0.2.0",
"moduleSdk": "^0.2.0"
},
"database": {
"abi": 1
},
"capabilities": [
{
"id": "signage.content-providers",
"version": "1.0.0"
},
{
"id": "signage.slide-renderer",
"version": "1.0.0"
},
{
"id": "signage.playback",
"version": "1.0.0"
},
{
"id": "signage.displays",
"version": "1.0.0"
}
],
"permissions": [
{
"id": "signage.view",
"baseline": "restricted",
"description": "View Signage timelines, images, channels, provider health, and display health."
},
{
"id": "signage.assets.manage",
"baseline": "restricted",
"description": "Upload, rename, expire, and delete Signage's own native images."
},
{
"id": "signage.timelines.manage",
"baseline": "restricted",
"description": "Create, edit, reorder, duplicate, schedule, and delete saved timelines."
},
{
"id": "signage.playback.control",
"baseline": "restricted",
"description": "Activate timelines and operate live playback transport for a channel group."
},
{
"id": "signage.displays.manage",
"baseline": "restricted",
"description": "Create channel groups and audience channels, and assign or rename viewers."
},
{
"id": "signage.display.view",
"baseline": "restricted",
"description": "Render the Signage full-viewport display and report its own health."
}
],
"configuration": [
{
"key": "timezone",
"type": "string",
"default": "America/Chicago",
"minLength": 1,
"maxLength": 64,
"description": "IANA time zone this installation's Signage schedules are written and displayed in."
},
{
"key": "nativeImageDurationSeconds",
"type": "integer",
"default": 7,
"minimum": 1,
"maximum": 3600,
"description": "How long a native Signage image is shown when no slot override applies."
},
{
"key": "crossfadeMilliseconds",
"type": "integer",
"default": 1000,
"minimum": 0,
"maximum": 10000,
"description": "How long a display fades an incoming slide over the outgoing one."
},
{
"key": "defaultBackgroundMode",
"type": "enum",
"values": ["blurred", "solid", "black"],
"default": "blurred",
"description": "How a native image that is not 16:9 is backed when a slot says nothing."
},
{
"key": "defaultBackgroundColor",
"type": "string",
"default": "#000000",
"minLength": 7,
"maxLength": 7,
"description": "The solid background colour used when the background mode is solid."
},
{
"key": "defaultBackgroundDarkened",
"type": "boolean",
"default": false,
"description": "Whether a native image's background is darkened behind the foreground by default."
},
{
"key": "defaultBlurPixels",
"type": "integer",
"default": 20,
"minimum": 0,
"maximum": 120,
"description": "Blur radius applied to a blurred native-image background."
},
{
"key": "providerTimeoutMilliseconds",
"type": "integer",
"default": 2000,
"minimum": 100,
"maximum": 15000,
"description": "How long Signage waits for one content-provider call before treating it as failed."
},
{
"key": "playbackHistoryLimit",
"type": "integer",
"default": 50,
"minimum": 2,
"maximum": 500,
"description": "How many resolved selections per channel group are retained as playback history."
},
{
"key": "viewerHeartbeatWriteSeconds",
"type": "integer",
"default": 30,
"minimum": 5,
"maximum": 600,
"description": "Shortest interval between two persisted heartbeat writes for one steady viewer."
}
],
"storage": {
"buckets": [
{
"id": "native-images",
"description": "Archived original uploads and generated renditions for Signage's native images."
}
]
},
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js",
"migrations": "./drizzle",
"ui": "./dist/ui/index.js",
"display": {
"artifact": "./dist/display/index.js",
"permission": "signage.display.view"
}
}
}
@@ -7,6 +7,7 @@ import {
MODULE_DEPENDENCY_KINDS,
MODULE_MANIFEST_VERSION,
MODULE_RUNTIME_STATES,
SUPPORTED_MODULE_MANIFEST_VERSIONS,
MODULE_STORAGE_BLOCKED_REASON,
packageRelativePathSchema,
parseModuleManifest,
@@ -256,15 +257,17 @@ describe("module manifest contract", () => {
expect(safeParseModuleManifest(version6Manifest).success).toBe(false);
});
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(8);
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 7, 9, "8", 8.5, null]) {
it("accepts exactly versions 8 and 9, each as itself, so no document is read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(9);
expect(SUPPORTED_MODULE_MANIFEST_VERSIONS).toEqual([8, 9]);
for (const manifestVersion of [1, 2, 3, 4, 5, 6, 7, 10, "8", "9", 8.5, null]) {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
}
expect(
safeParseModuleManifest({ ...validManifest(), manifestVersion: MODULE_MANIFEST_VERSION })
.success,
).toBe(true);
for (const manifestVersion of SUPPORTED_MODULE_MANIFEST_VERSIONS) {
const parsed = safeParseModuleManifest({ ...validManifest(), manifestVersion });
expect(parsed.success).toBe(true);
expect(parsed.success && parsed.data.manifestVersion).toBe(manifestVersion);
}
});
it("defaults contributions to an empty list for a module that contributes nothing", () => {
@@ -0,0 +1,241 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
EMPTY_MODULE_NAVIGATION,
isModuleNavigationIcon,
isModuleNavigationPagePath,
MODULE_NAVIGATION_ICONS,
MODULE_NAVIGATION_PAGES_MAX,
moduleManifestNavigation,
parseModuleManifest,
safeParseModuleManifest,
} from "../src/index.js";
/**
* Manifest v9's Dashboard navigation (ADR 0063), and the guarantee that released manifest v8
* modules keep working unchanged.
*/
const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
function v9Manifest(navigation?: unknown): Record<string, unknown> {
return {
manifestVersion: 9,
id: "signage",
name: "Signage",
version: "0.2.0",
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
...(navigation === undefined ? {} : { navigation }),
};
}
const pages = [
{ id: "playback", label: "Playback", path: "playback" },
{ id: "timelines", label: "Timelines", path: "timelines" },
{ id: "media", label: "Media", path: "media" },
];
function issues(input: unknown): string[] {
const parsed = safeParseModuleManifest(input);
return parsed.success
? []
: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
}
describe("manifest v9 navigation", () => {
it("accepts a single-page v9 module with no navigation at all: the generic icon, one root page", () => {
const manifest = parseModuleManifest(v9Manifest());
expect(manifest.manifestVersion).toBe(9);
expect(moduleManifestNavigation(manifest)).toEqual({ pages: [] });
expect(moduleManifestNavigation(manifest).icon).toBeUndefined();
});
it("accepts a multi-page module with a Lucide icon, keeping pages in declared order", () => {
const manifest = parseModuleManifest(v9Manifest({ icon: "monitor-play", pages }));
const navigation = moduleManifestNavigation(manifest);
expect(navigation.icon).toBe("monitor-play");
expect(navigation.pages.map((page) => page.id)).toEqual(["playback", "timelines", "media"]);
// Reversed in the manifest, reversed in the result: order is the author's, never re-sorted.
const reversed = moduleManifestNavigation(
parseModuleManifest(v9Manifest({ pages: [...pages].reverse() })),
);
expect(reversed.pages.map((page) => page.id)).toEqual(["media", "timelines", "playback"]);
});
it("accepts an icon on its own, and pages on their own", () => {
expect(moduleManifestNavigation(parseModuleManifest(v9Manifest({ icon: "calendar" })))).toEqual(
{
icon: "calendar",
pages: [],
},
);
expect(
moduleManifestNavigation(parseModuleManifest(v9Manifest({ pages }))).icon,
).toBeUndefined();
});
it("accepts multi-segment page paths", () => {
expect(
issues(v9Manifest({ pages: [{ id: "library", label: "Library", path: "media/library" }] })),
).toEqual([]);
});
it.each([
["an unknown icon", { icon: "not-an-icon" }],
["a PascalCase component name", { icon: "MonitorPlay" }],
["an icon as markup", { icon: "<svg onload=alert(1)>" }],
["an icon as a URL", { icon: "https://cdn.example.test/icon.svg" }],
["an icon as an object", { icon: { svg: "<path/>" } }],
])("refuses %s", (_label, navigation) => {
expect(issues(v9Manifest(navigation))).not.toEqual([]);
});
it.each([
["an empty path", ""],
["the root as a page", "/"],
["a leading slash", "/timelines"],
["a trailing slash", "timelines/"],
["an empty segment", "media//library"],
["traversal", "../calendar"],
["an inner traversal", "media/../../calendar"],
["a dot segment", "./timelines"],
["an absolute URL", "https://evil.example.test/x"],
["a protocol-relative URL", "//evil.example.test"],
["a scheme", "javascript:alert(1)"],
["a query", "timelines?x=1"],
["a fragment", "timelines#top"],
["percent-encoding", "media%2Flibrary"],
["a backslash", "media\\library"],
["uppercase", "Timelines"],
["whitespace", "time lines"],
["too many segments", "a/b/c/d/e"],
])("refuses a page path with %s", (_label, pagePath) => {
expect(isModuleNavigationPagePath(pagePath)).toBe(false);
expect(
issues(v9Manifest({ pages: [{ id: "page", label: "Page", path: pagePath }] })),
).not.toEqual([]);
});
it.each([
["an uppercase id", { id: "Playback", label: "Playback", path: "playback" }],
["an id with a slash", { id: "media/library", label: "Library", path: "library" }],
["an empty label", { id: "playback", label: " ", path: "playback" }],
["an overlong label", { id: "playback", label: "x".repeat(61), path: "playback" }],
[
"a control character in a label",
{ id: "playback", label: "Play\u0007back", path: "playback" },
],
["a line separator in a label", { id: "playback", label: "Play\u2028back", path: "playback" }],
["an unknown page key", { id: "playback", label: "Playback", path: "playback", icon: "tv" }],
["an executable field", { id: "playback", label: "Playback", path: "playback", onClick: "x" }],
])("refuses a page with %s", (_label, page) => {
expect(issues(v9Manifest({ pages: [page] }))).not.toEqual([]);
});
it("refuses duplicate page ids and duplicate page paths, naming the duplicate", () => {
expect(
issues(
v9Manifest({
pages: [
{ id: "media", label: "Media", path: "media" },
{ id: "media", label: "More media", path: "more-media" },
],
}),
),
).toEqual(["navigation.pages.1.id: media is declared more than once."]);
expect(
issues(
v9Manifest({
pages: [
{ id: "media", label: "Media", path: "media" },
{ id: "library", label: "Library", path: "media" },
],
}),
),
).toEqual(["navigation.pages.1.path: Two pages must not declare the same path."]);
});
it("refuses a page beneath another page, which would make the open page ambiguous", () => {
expect(
issues(
v9Manifest({
pages: [
{ id: "media", label: "Media", path: "media" },
{ id: "library", label: "Library", path: "media/library" },
],
}),
),
).toEqual([
"navigation.pages.1.path: A page path must not lie beneath another declared page's path.",
]);
// A shared prefix that is not a parent segment is unrelated.
expect(
issues(
v9Manifest({
pages: [
{ id: "media", label: "Media", path: "media" },
{ id: "media-library", label: "Library", path: "media-library" },
],
}),
),
).toEqual([]);
});
it("bounds the number of pages", () => {
const many = Array.from({ length: MODULE_NAVIGATION_PAGES_MAX + 1 }, (_, index) => ({
id: `page-${index}`,
label: `Page ${index}`,
path: `page-${index}`,
}));
expect(issues(v9Manifest({ pages: many }))).not.toEqual([]);
expect(issues(v9Manifest({ pages: many.slice(0, MODULE_NAVIGATION_PAGES_MAX) }))).toEqual([]);
});
it("refuses navigation for a module with no primary UI, and unknown navigation keys", () => {
const noUi = {
...v9Manifest({ icon: "calendar" }),
entrypoints: { server: "./dist/server/index.js" },
};
expect(issues(noUi)).toEqual([
"navigation: Navigation can only be declared by a module with a primary UI (entrypoints.ui).",
]);
expect(issues(v9Manifest({ groups: ["media"] }))).not.toEqual([]);
expect(issues(v9Manifest({ widgets: [] }))).not.toEqual([]);
});
it("offers a curated, sorted, duplicate-free set of Lucide names, all in Lucide's kebab-case form", () => {
expect([...MODULE_NAVIGATION_ICONS]).toEqual([...MODULE_NAVIGATION_ICONS].sort());
expect(new Set(MODULE_NAVIGATION_ICONS).size).toBe(MODULE_NAVIGATION_ICONS.length);
for (const icon of MODULE_NAVIGATION_ICONS) {
expect(icon).toMatch(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u);
expect(isModuleNavigationIcon(icon)).toBe(true);
}
expect(isModuleNavigationIcon("signage")).toBe(false);
expect(Object.isFrozen(MODULE_NAVIGATION_ICONS)).toBe(true);
});
});
describe("released manifest v8 modules", () => {
it("still accepts the released Signage 0.1.5 manifest exactly as published", async () => {
const published = JSON.parse(
await readFile(path.join(fixtures, "released-v8-signage-0.1.5.module.json"), "utf8"),
) as Record<string, unknown>;
const manifest = parseModuleManifest(published);
expect(manifest.manifestVersion).toBe(8);
expect(manifest.id).toBe("signage");
// The settings Signage declares are what Settings reads (ADR 0061), unchanged by v9.
expect(manifest.configuration.length).toBeGreaterThan(0);
// A v8 module declares no navigation: the generic icon and one root page.
expect(moduleManifestNavigation(manifest)).toBe(EMPTY_MODULE_NAVIGATION);
expect("navigation" in manifest).toBe(false);
});
it("never reads navigation into a version 8 document: one that names it is refused, not upgraded", () => {
const v8WithNavigation = { ...v9Manifest({ icon: "tv" }), manifestVersion: 8 };
expect(safeParseModuleManifest(v8WithNavigation).success).toBe(false);
});
});