From 4671622b9d0138f53feb46995acbf0d2e4a8710e Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 15 Oct 2026 08:21:50 -0500 Subject: [PATCH 1/3] 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: / is the landing view, // 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 --- README.md | 15 +- apps/api/src/dashboard-routes.ts | 36 +- apps/api/src/module-ui-inventory.ts | 11 +- apps/api/tests/dashboard-host-routes.test.ts | 23 +- apps/api/tests/dashboard-routes.test.ts | 76 ++- apps/api/tests/module-ui-host-e2e.test.ts | 1 + apps/api/tests/module-ui-routes.test.ts | 1 + core/src/modules/ui-publication.ts | 27 +- core/tests/dashboard-flat-modules.test.ts | 9 +- core/tests/module-discovery.test.ts | 108 +++- ...2-module-ui-authoring-and-host-contract.md | 1 + ...7-dashboard-navigation-and-flat-modules.md | 1 + .../decisions/0050-module-database-abi-v1.md | 1 + ...board-shell-navigation-and-lucide-icons.md | 1 + .../0059-shared-interaction-primitives.md | 1 + ...gs-hierarchy-and-module-settings-routes.md | 1 + ...-module-navigation-contract-manifest-v9.md | 284 +++++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 12 +- docs/development/api.md | 14 + docs/development/security.md | 23 + docs/development/testing.md | 27 + docs/module-development/README.md | 142 ++++- packages/contracts/src/index.ts | 1 + packages/contracts/src/module-manifest.ts | 481 ++++++++------ packages/contracts/src/module-navigation.ts | 242 +++++++ .../released-v8-signage-0.1.5.module.json | 161 +++++ .../contracts/tests/module-manifest.test.ts | 17 +- .../contracts/tests/module-navigation.test.ts | 241 +++++++ packages/module-sdk/src/index.ts | 5 + .../tests/load-module-ui.test.ts | 1 + .../tests/real-artifact.test.ts | 2 + packages/module-ui-sdk/src/host-context.ts | 17 +- packages/module-ui-sdk/src/index.ts | 1 + packages/module-ui-sdk/src/navigation.ts | 113 ++++ .../tests/module-ui-contract.test.ts | 2 + .../module-ui-sdk/tests/navigation.test.ts | 40 ++ scripts/lib/deploy-smoke-browser.mjs | 179 ++++++ surfaces/shell/package.json | 4 + surfaces/shell/src/browser/DashboardLink.tsx | 10 +- surfaces/shell/src/browser/HostApp.tsx | 145 ++++- surfaces/shell/src/browser/ModuleUiFrame.tsx | 11 +- surfaces/shell/src/browser/SettingsPages.tsx | 43 +- .../shell/src/browser/ShellNavigation.tsx | 81 ++- surfaces/shell/src/browser/host-history.ts | 18 +- surfaces/shell/src/browser/module-icon.tsx | 228 +++++++ .../shell/src/browser/module-ui-navigation.ts | 153 +++++ surfaces/shell/src/dashboard-routes.ts | 102 ++- surfaces/shell/src/host-protocol.ts | 54 +- surfaces/shell/src/module-context.ts | 7 + surfaces/shell/src/module-icons.ts | 115 ++++ surfaces/shell/src/module-navigation.ts | 99 +++ surfaces/shell/tests/dashboard-routes.test.ts | 130 +++- surfaces/shell/tests/display-app.test.tsx | 4 + surfaces/shell/tests/host-app.test.tsx | 1 + surfaces/shell/tests/host-history.test.ts | 35 +- surfaces/shell/tests/host-protocol.test.ts | 26 +- surfaces/shell/tests/host-routing.test.tsx | 1 + surfaces/shell/tests/modularity.test.ts | 10 +- .../shell/tests/module-navigation.test.tsx | 590 ++++++++++++++++++ surfaces/shell/tests/return-target.test.ts | 38 +- .../shell/tests/settings-navigation.test.tsx | 1 + .../shell/tests/shell-navigation.test.tsx | 1 + surfaces/shell/tsconfig.build.json | 6 +- templates/module/README.md | 10 +- templates/module/ekklesia.module.json | 17 +- templates/module/src/ui/ExampleApp.tsx | 189 +++++- templates/module/src/ui/styles.css | 27 + templates/module/tests/module-ui.test.ts | 118 +++- 69 files changed, 4221 insertions(+), 372 deletions(-) create mode 100644 docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md create mode 100644 packages/contracts/src/module-navigation.ts create mode 100644 packages/contracts/tests/fixtures/released-v8-signage-0.1.5.module.json create mode 100644 packages/contracts/tests/module-navigation.test.ts create mode 100644 packages/module-ui-sdk/src/navigation.ts create mode 100644 packages/module-ui-sdk/tests/navigation.test.ts create mode 100644 surfaces/shell/src/browser/module-icon.tsx create mode 100644 surfaces/shell/src/browser/module-ui-navigation.ts create mode 100644 surfaces/shell/src/module-icons.ts create mode 100644 surfaces/shell/src/module-navigation.ts create mode 100644 surfaces/shell/tests/module-navigation.test.tsx diff --git a/README.md b/README.md index ced7e96..a2ccfad 100644 --- a/README.md +++ b/README.md @@ -532,8 +532,10 @@ reported `blocked · configuration_missing · bucket` — never imported, never from inside its own `start` hook — and its required dependents block behind it through ADR 0028's existing mechanism. Doctor and `pnpm modules:validate` report the same thing while still importing no module code. That change introduced `manifestVersion: 5` as a hard cutover; the current strict -manifest is version 8 after ADR 0050 added the optional database ABI declaration. Version 8 is a -hard cutover; a version 7 source module or package must be updated and rebuilt. +manifest was version 8 after ADR 0050 added the optional database ABI declaration, a hard cutover +that requires a version 7 source module or package to be updated and rebuilt. Version 9 (ADR 0063) +adds optional module navigation, and is the first version that keeps its predecessor accepted: +released version 8 modules run unchanged. **Ekklesia does not store secrets, and does not pretend to.** There is no encryption at rest here and no key management, so a `secret` key's value is supplied by the deployment @@ -951,6 +953,15 @@ and updated (`/settings/discover/browse`, `/installed`, `/updates`); configuring Settings page. A person sees only what the server says they may administer, and no Settings link at all when that is nothing. The pages are placeholders until each section is built. +**Modules are first-class parts of the Dashboard** +([ADR 0063](docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). A module +declares its Lucide icon and its primary pages in manifest version 9, and the sidebar shows those +pages beneath it while it is open. Every page has a real address: `/signage/timelines`, and routes +beneath a page such as `/media/sermons/123`. A refresh, a bookmark, and Back and Forward all stay put. +A module moves between its own pages, and opens its Settings page, through a narrow host navigation +capability. Settings' Back returns to exactly the module page that opened it. Released manifest +version 8 modules keep working unchanged, with the generic icon and a single page. + **Your account** is at the bottom of the sidebar, showing your name, email, and initials ([ADR 0062](docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)). `/account` shows who you are signed in as. On `/account/appearance` you choose **System, Light, or diff --git a/apps/api/src/dashboard-routes.ts b/apps/api/src/dashboard-routes.ts index 07f811c..f537262 100644 --- a/apps/api/src/dashboard-routes.ts +++ b/apps/api/src/dashboard-routes.ts @@ -21,7 +21,11 @@ import { requireTrustedOrigin, } from "./authorization-middleware.js"; import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; -import { describeMountableModules, type HostModuleUiDescriptor } from "./module-ui-inventory.js"; +import { + describeMountableModules, + type HostModuleUiDescriptor, + type MountableModuleDescription, +} from "./module-ui-inventory.js"; export const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation"; const DASHBOARD_GROUPS_PATH = `${DASHBOARD_NAVIGATION_PATH}/groups`; @@ -41,6 +45,20 @@ const DASHBOARD_PLACEMENTS_PATH = `${DASHBOARD_NAVIGATION_PATH}/placements`; export interface DashboardNavigationModuleView { readonly name: string; readonly module: HostModuleUiDescriptor; + /** + * What the module declares about its own pages (manifest v9, ADR 0063): the Lucide icon the + * Dashboard draws it with (`null` for the generic one) and its primary pages, in declared order — + * empty for a single-page module, which every manifest v8 module is. The module's identity, never + * its placement: where it sits in the sidebar stays the administrator's (ADR 0027). + */ + readonly navigation: { + readonly icon: string | null; + readonly pages: readonly { + readonly id: string; + readonly label: string; + readonly path: string; + }[]; + }; } export interface DashboardNavigationGroupView { @@ -203,10 +221,18 @@ export function createDashboardRoutes(options: CreateDashboardRoutesOptions): Ho configuration, descriptors.map((entry) => ({ id: entry.descriptor.id, ...entry })), ); - const present = (entry: { - readonly descriptor: HostModuleUiDescriptor; - readonly name: string; - }): DashboardNavigationModuleView => ({ module: entry.descriptor, name: entry.name }); + const present = (entry: MountableModuleDescription): DashboardNavigationModuleView => ({ + module: entry.descriptor, + name: entry.name, + navigation: { + icon: entry.navigation.icon ?? null, + pages: entry.navigation.pages.map((page) => ({ + id: page.id, + label: page.label, + path: page.path, + })), + }, + }); return c.json( { groups: view.groups.map((group) => ({ diff --git a/apps/api/src/module-ui-inventory.ts b/apps/api/src/module-ui-inventory.ts index 43e83fa..5df72e4 100644 --- a/apps/api/src/module-ui-inventory.ts +++ b/apps/api/src/module-ui-inventory.ts @@ -1,4 +1,8 @@ -import type { EkklesiaPrincipal, PermissionDeclaration } from "@ekklesia/contracts"; +import type { + EkklesiaPrincipal, + ModuleNavigationDeclaration, + PermissionDeclaration, +} from "@ekklesia/contracts"; import { CORE_MODULES_UI_READ } from "@ekklesia/core/authorization"; import type { ModuleUiPublicationRegistry, @@ -196,6 +200,10 @@ export interface MountableModuleDescription { * Dashboard owns (ADR 0027). Deliberately absent from `HostModuleUiDescriptor`, so the * host-neutral contract a standalone host consumes stays free of presentation concerns. */ readonly name: string; + /** The module's own declared icon and primary pages (manifest v9, ADR 0063); empty for one that + * declares none. Presentation identity like `name`, and kept beside the descriptor for the same + * reason. */ + readonly navigation: ModuleNavigationDeclaration; } /** @@ -216,6 +224,7 @@ export async function describeMountableModules( registry.list().map(async (published) => ({ descriptor: await describeModule(evaluator, principal, published, registry), name: published.name, + navigation: published.navigation, })), ); } diff --git a/apps/api/tests/dashboard-host-routes.test.ts b/apps/api/tests/dashboard-host-routes.test.ts index dc83805..2251f99 100644 --- a/apps/api/tests/dashboard-host-routes.test.ts +++ b/apps/api/tests/dashboard-host-routes.test.ts @@ -43,7 +43,13 @@ import { DashboardHostConfigurationError, createDashboardHostRoutes, } from "../src/dashboard-host-routes.js"; -import { DASHBOARD_PLATFORM_PAGES, SETTINGS_SECTIONS } from "@ekklesia/shell/dashboard-routes"; +import { MODULE_NAVIGATION_ICONS } from "@ekklesia/contracts"; +import { + DASHBOARD_PLATFORM_PAGES, + HOST_NAMESPACES, + SETTINGS_SECTIONS, +} from "@ekklesia/shell/dashboard-routes"; +import { DASHBOARD_MODULE_ICON_NAMES } from "@ekklesia/shell/module-icons"; import { MODULE_DISPLAY_DESCRIPTOR_PREFIX, MODULE_DISPLAY_PAGE_PREFIX, @@ -417,6 +423,14 @@ describe("the platform's reserved top-level names (ADR 0057)", () => { } }); + it("are exactly the Dashboard's platform pages plus the host namespaces it never treats as pages (ADR 0063)", () => { + // With module routes nested beneath `//`, the shell must know every other top-level + // name: `/api/v1/principal` must never read as the route `v1/principal` of a module called `api`. + expect([...DASHBOARD_PLATFORM_PAGES, ...HOST_NAMESPACES].sort()).toEqual([ + ...RESERVED_TOP_LEVEL_NAMES, + ]); + }); + it("include exactly the Settings sections the shell interprets, so /settings/ never collides (ADR 0061)", () => { expect([...SETTINGS_SECTIONS].sort()).toEqual([...RESERVED_SETTINGS_NAMES]); for (const section of SETTINGS_SECTIONS) { @@ -630,3 +644,10 @@ describe("Dashboard host boundaries", () => { } }); }); + +describe("module navigation icons (ADR 0063)", () => { + it("are the same set in the manifest contract and in the Dashboard that draws them", () => { + // The platform cannot accept an icon the Dashboard cannot draw, nor draw one it would refuse. + expect([...DASHBOARD_MODULE_ICON_NAMES]).toEqual([...MODULE_NAVIGATION_ICONS]); + }); +}); diff --git a/apps/api/tests/dashboard-routes.test.ts b/apps/api/tests/dashboard-routes.test.ts index d19bafd..78c09d8 100644 --- a/apps/api/tests/dashboard-routes.test.ts +++ b/apps/api/tests/dashboard-routes.test.ts @@ -55,6 +55,8 @@ interface ModuleFixture { readonly id: string; readonly name: string; readonly withUi?: boolean; + /** Present, the fixture is a manifest v9 module declaring this navigation (ADR 0063). */ + readonly navigation?: unknown; } async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { @@ -78,8 +80,9 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P ...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }), }, id: fixture.id, - manifestVersion: 8, + manifestVersion: fixture.navigation === undefined ? 8 : 9, name: fixture.name, + ...(fixture.navigation === undefined ? {} : { navigation: fixture.navigation }), version: "1.0.0", }), ); @@ -170,6 +173,14 @@ function signedIn(): EkklesiaPrincipal { interface NavigationEntry { readonly name: string; + readonly navigation: { + readonly icon: string | null; + readonly pages: readonly { + readonly id: string; + readonly label: string; + readonly path: string; + }[]; + }; readonly module: { readonly id: string; readonly version: string; @@ -284,7 +295,68 @@ describe("Dashboard navigation — reading", () => { ui: { url: "/api/v1/module-ui/livestream/1.0.0" }, version: "1.0.0", }); - expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name"]); + expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name", "navigation"]); + // A module that declares no navigation — this manifest v8 fixture — is drawn with the generic + // icon and has its root as its only page (ADR 0063). Never a group or an order. + expect(livestream?.navigation).toEqual({ icon: null, pages: [] }); + }); + + it("carries a manifest v9 module's own icon and pages beside its descriptor, in declared order", async () => { + const { app } = await install({ + fixtures: [ + { + id: "signage", + name: "Signage", + navigation: { + icon: "monitor-play", + pages: [ + { id: "playback", label: "Playback", path: "playback" }, + { id: "timelines", label: "Timelines", path: "timelines" }, + { id: "media", label: "Media", path: "media" }, + ], + }, + }, + { id: "calendar", name: "Calendar" }, + ], + principal: signedIn(), + }); + + const navigation = await navigationOf(app); + const signage = navigation.ungrouped.find((entry) => entry.module.id === "signage"); + const calendar = navigation.ungrouped.find((entry) => entry.module.id === "calendar"); + + expect(signage?.navigation).toEqual({ + icon: "monitor-play", + pages: [ + { id: "playback", label: "Playback", path: "playback" }, + { id: "timelines", label: "Timelines", path: "timelines" }, + { id: "media", label: "Media", path: "media" }, + ], + }); + // The host-neutral descriptor is untouched by navigation: it lives beside it, never inside. + expect(Object.keys(signage?.module ?? {}).sort()).toEqual( + Object.keys(calendar?.module ?? {}).sort(), + ); + // A manifest v8 module beside it keeps the generic icon and its single root page. + expect(calendar?.navigation).toEqual({ icon: null, pages: [] }); + }); + + it("refuses at discovery a v9 module whose navigation is unsafe, so it never reaches the sidebar", async () => { + const { app } = await install({ + fixtures: [ + { id: "calendar", name: "Calendar" }, + { + id: "escape", + name: "Escape", + navigation: { pages: [{ id: "out", label: "Out", path: "../settings" }] }, + }, + ], + principal: signedIn(), + }).catch((error: unknown) => ({ app: undefined, error })); + + // Discovery is atomic (ADR 0005): an invalid manifest fails the whole inventory rather than + // quietly dropping one module, exactly as for any other manifest error. + expect(app).toBeUndefined(); }); it("agrees exactly with the generic inventory about what is mountable", async () => { diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index f73af62..5e9a0ef 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -479,6 +479,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () "contributions", "display", "moduleId", + "navigation", "permissions", "principal", "realtime", diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index bee6f08..2cd768f 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -953,6 +953,7 @@ describe("module UI artifact publication — the generic loader consumes what is contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId: "announcements", + navigation: undefined, permissions: { has: () => false }, principal: { kind: "anonymous" as const }, realtime: { subscribe: () => ({ close: () => undefined }) }, diff --git a/core/src/modules/ui-publication.ts b/core/src/modules/ui-publication.ts index 43a1ceb..136cd0b 100644 --- a/core/src/modules/ui-publication.ts +++ b/core/src/modules/ui-publication.ts @@ -1,5 +1,7 @@ import { isVersionCompatible, + type ModuleNavigationDeclaration, + moduleManifestNavigation, type PermissionDeclaration, type PermissionId, } from "@ekklesia/contracts"; @@ -24,10 +26,18 @@ export interface PublishableModuleUi { * * Carried here so a host can render something a person recognizes instead of a module id. It is * presentation *identity*, not presentation *placement*: a module says what it is called, while - * where it appears in a Dashboard is configuration an administrator owns. Nothing about grouping, - * ordering, or navigation belongs on this record. + * where it appears in a Dashboard is configuration an administrator owns. Nothing about grouping + * or ordering belongs on this record. */ readonly name: string; + /** + * What the module declares about its own pages (manifest v9, ADR 0063): the Lucide icon it is + * drawn with and its primary pages, in its declared order. Like `name`, this is the module's own + * identity — what it is and which pages it has — never where it sits in the Dashboard, which stays + * the administrator's (ADR 0027). Empty for a manifest v8 module and for one that declares none: + * the generic icon and a single root page. + */ + readonly navigation: ModuleNavigationDeclaration; readonly artifactPath: string; /** * The module's own manifest-declared permissions, carried here so a publisher can compute the @@ -44,6 +54,18 @@ export interface PublishableModuleUi { readonly permissions: readonly PermissionDeclaration[]; } +/** A fresh, deeply frozen copy, so a caller holding a record never reaches the manifest graph. */ +function frozenNavigation(navigation: ModuleNavigationDeclaration): ModuleNavigationDeclaration { + return Object.freeze({ + ...(navigation.icon === undefined ? {} : { icon: navigation.icon }), + pages: Object.freeze( + navigation.pages.map((page) => + Object.freeze({ id: page.id, label: page.label, path: page.path }), + ), + ), + }); +} + /** * One installed module whose dedicated full-viewport display artifact may be published. * @@ -233,6 +255,7 @@ export function createModuleUiPublicationRegistry( artifactPath, moduleId: module.manifest.id, name: module.manifest.name, + navigation: frozenNavigation(moduleManifestNavigation(module.manifest)), // Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding // this record can never reach the manifest object graph itself. permissions: Object.freeze([...module.manifest.permissions]), diff --git a/core/tests/dashboard-flat-modules.test.ts b/core/tests/dashboard-flat-modules.test.ts index 72996d9..56bd81a 100644 --- a/core/tests/dashboard-flat-modules.test.ts +++ b/core/tests/dashboard-flat-modules.test.ts @@ -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 () => { diff --git a/core/tests/module-discovery.test.ts b/core/tests/module-discovery.test.ts index 967a593..8c73398 100644 --- a/core/tests/module-discovery.test.ts +++ b/core/tests/module-discovery.test.ts @@ -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"]); + }); +}); diff --git a/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md b/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md index 6c86e56..41f2f6c 100644 --- a/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md +++ b/docs/architecture/decisions/0022-module-ui-authoring-and-host-contract.md @@ -2,6 +2,7 @@ - Status: Accepted - Date: 2026-08-21 +- Navigation member decided by: [0063](0063-module-navigation-contract-manifest-v9.md) - Extends: [0021](0021-modules-as-hostable-products.md) ## Context diff --git a/docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md b/docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md index 1188825..ee6e1e5 100644 --- a/docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md +++ b/docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md @@ -2,6 +2,7 @@ - Status: Accepted - Date: 2026-08-22 +- Module icon and primary pages superseded by: [0063](0063-module-navigation-contract-manifest-v9.md) — a module now declares its own icon and pages; groups and ordering stay the administrator's - Extends: [0021](0021-modules-as-hostable-products.md), [0026](0026-minimal-ekklesia-module-ui-host.md) diff --git a/docs/architecture/decisions/0050-module-database-abi-v1.md b/docs/architecture/decisions/0050-module-database-abi-v1.md index 484c176..f17002f 100644 --- a/docs/architecture/decisions/0050-module-database-abi-v1.md +++ b/docs/architecture/decisions/0050-module-database-abi-v1.md @@ -2,6 +2,7 @@ - Status: Accepted - Date: 2026-10-07 +- Manifest version acceptance extended by: [0063](0063-module-navigation-contract-manifest-v9.md) — version 8 stays accepted beside version 9 - Supersedes (partial): [0016](0016-module-database-access.md) — for modules that declare ABI v1, only its definition of the module database service as `Pick`, a facade over Core's Drizzle instance, and its rule that every module receives the same `services` object. diff --git a/docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md b/docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md index 2319cb1..3a1da63 100644 --- a/docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md +++ b/docs/architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md @@ -2,6 +2,7 @@ - Status: Accepted - Date: 2026-10-14 +- Module-declared icons and pages decided by: [0063](0063-module-navigation-contract-manifest-v9.md) - Extends: [0026](0026-minimal-ekklesia-module-ui-host.md), [0027](0027-dashboard-navigation-and-flat-modules.md), [0039](0039-shared-design-tokens-and-elements.md), diff --git a/docs/architecture/decisions/0059-shared-interaction-primitives.md b/docs/architecture/decisions/0059-shared-interaction-primitives.md index 29f85db..fc8692f 100644 --- a/docs/architecture/decisions/0059-shared-interaction-primitives.md +++ b/docs/architecture/decisions/0059-shared-interaction-primitives.md @@ -2,6 +2,7 @@ - Status: Accepted - Date: 2026-10-15 +- Return-target carrier decided by: [0063](0063-module-navigation-contract-manifest-v9.md) - Extends: [0034](0034-host-owned-browser-authentication.md), [0039](0039-shared-design-tokens-and-elements.md), [0040](0040-module-stylesheets-and-artifact-isolation.md), diff --git a/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md b/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md index 1bccd2e..8a9af65 100644 --- a/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md +++ b/docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md @@ -2,6 +2,7 @@ - Status: Accepted - Date: 2026-10-15 +- Module → Settings return link decided by: [0063](0063-module-navigation-contract-manifest-v9.md) - Extends: [0027](0027-dashboard-navigation-and-flat-modules.md), [0028](0028-module-operational-lifecycle.md), [0029](0029-module-configuration-and-secrets.md), diff --git a/docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md b/docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md new file mode 100644 index 0000000..3574ec2 --- /dev/null +++ b/docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md @@ -0,0 +1,284 @@ +# 0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin + +- Status: Accepted +- Date: 2026-10-15 +- Extends: [0022](0022-module-ui-authoring-and-host-contract.md), + [0026](0026-minimal-ekklesia-module-ui-host.md), + [0050](0050-module-database-abi-v1.md), + [0057](0057-dashboard-urls-are-host-owned-routes.md), + [0058](0058-dashboard-shell-navigation-and-lucide-icons.md), + [0059](0059-shared-interaction-primitives.md), + [0061](0061-settings-hierarchy-and-module-settings-routes.md) +- Supersedes, in part: [0027](0027-dashboard-navigation-and-flat-modules.md) (a module now declares + its own icon and primary pages; grouping and ordering stay the administrator's) + +## Context + +DESIGN.md treats a module as a first-class application area: + +- its primary pages belong in the global sidebar beneath it, with no second tab strip inside the + module (§4.4, locked decision 11); +- every meaningful page has a real route, so a refresh, a bookmark, Back and Forward work (§2.7, + locked decision 12); +- a module can deep-link to its centralized Settings page and come back to exactly the workflow + that sent the person there (§2.7, §9.3, locked decision 13). + +The platform had none of this: + +- **Icons.** Every module was drawn with one generic icon. ADR 0058 left a module's own icon to + "the module navigation contract (manifest v9)". +- **Routes.** `/` was a module's only route, and everything beneath it was not-found + (ADR 0057). +- **Navigation.** The module UI context had no navigation member. ADR 0022 deferred it because "it + needs a design that does not exist", and modules may not bring a router (ADR 0026). +- **Return target.** ADR 0059 built and validated `parseReturnTarget`, and ADR 0061 gave every + module's settings `/settings/`, but both deferred *how* a link carries a return + target. + +Released modules (Signage 0.1.5 among them) are packaged, signed, and installed as manifest version +8, and every earlier manifest version was rejected outright by its successor. + +## Decision + +### Manifest v9: one optional `navigation` field + +Manifest version 8's top-level object is strict, so a new field is a new version. Version 9 is +version 8 plus one optional field: + +```json +"navigation": { + "icon": "monitor-play", + "pages": [ + { "id": "playback", "label": "Playback", "path": "playback" }, + { "id": "timelines", "label": "Timelines", "path": "timelines" }, + { "id": "media", "label": "Media", "path": "media" } + ] +} +``` + +- **`icon`** (optional) is a Lucide name from a curated set (below). Omitted, the module is drawn + with the generic module icon. +- **`pages`** (optional, at most 12) are the module's primary pages, in the order the sidebar shows + them. Each page has: + - **`id`**: stable and never displayed. Lowercase kebab-case, at most 64 characters, unique within + the manifest. The navigation API reports it as the current page. + - **`label`**: plain text, trimmed, 1–60 characters, with no control or line-separator characters. + - **`path`**: relative to the module's root. One to four lowercase kebab-case segments joined by + `/`, unique within the manifest. It may not lie beneath another page's path. +- **What a path can never be:** a leading or trailing slash, an empty segment, `.` or `..`, + percent-encoding, a query, a fragment, or a scheme. A declared path cannot escape `//` + or name anything outside the module. +- **Requires a UI.** `navigation` is only valid with `entrypoints.ui`. +- **Validated before activation.** Validation lives in `@ekklesia/contracts`, so discovery, + `pnpm modules:validate`, the packager, and package verification all refuse a bad declaration + before the module can start. +- **No page icons.** The collapsed sidebar shows the module item alone, so a page icon would never + be seen. +- **Nothing else is added.** No groups, order, Overview widgets, or settings declaration: a module + already has settings exactly when it declares `configuration` (ADR 0029, ADR 0061). + +`moduleManifestNavigation(manifest)` reads the declaration the same way for every version. A version +8 manifest, and a version 9 manifest that omits the field, both have no icon and no pages. + +### Version 8 stays accepted beside version 9 + +Until now every manifest version rejected its predecessor ("no dual acceptance": ADR 0006, ADR 0014, +ADR 0021, ADR 0050). This decision is the first exception, deliberately: + +- **Why.** Version 9 adds one optional field and changes the meaning of nothing a version 8 document + says. Rejecting version 8 would stop every installed module at its next upgrade for no safety gain. +- **One document, one version.** `manifestVersion` selects one strict schema (a discriminated union + of 8 and 9). A document is only ever read as the version it declares. A version 8 document that + names `navigation` is refused, never quietly read as version 9. +- **Nothing is rewritten.** The packager still copies manifest bytes verbatim, and verification + checks them against the package digest. Core never writes a manifest back. +- **The template moves to version 9.** `MODULE_MANIFEST_VERSION` is now 9, the version a new module + declares. `SUPPORTED_MODULE_MANIFEST_VERSIONS` is `[8, 9]`. Version 7 and earlier are still + refused. + +How long version 8 stays accepted is a later decision. It needs its own ADR. + +### Icons: a curated Lucide set, named in the manifest, drawn by the host + +A manifest names an icon. It never supplies one: no SVG, URL, component, or code. + +- **The set.** `MODULE_NAVIGATION_ICONS` (`@ekklesia/contracts`) is about ninety Lucide names chosen + for generic subjects (`calendar`, `monitor-play`, `users`, `book-open`, …). There is no entry for + any particular module, and no module-to-icon table anywhere. +- **Validated against the set.** A manifest's `icon` must be in it, so a typo is caught when a + module is packaged or discovered, not drawn silently as the generic icon. +- **Imported by name.** The Dashboard imports exactly these icons from `lucide-react` + (`browser/module-icon.tsx`) and never bundles the library's thousands of others. +- **One list, held together by tests.** A surface may not import `@ekklesia/contracts` (ADR 0024), + so the shell keeps the names in a framework-free file, `@ekklesia/shell/module-icons`. + - An `apps/api` test holds the shell's list equal to `MODULE_NAVIGATION_ICONS`, so the platform + cannot accept an icon the Dashboard cannot draw. + - A shell test proves every entry is the real Lucide component of that name. +- **Growing the set is additive.** It needs a platform release, and a module that uses a new name + declares the platform range that has it. +- **Fallback.** A host that cannot draw a name draws the generic icon instead of failing. The + Dashboard can draw all of them; another host might not. +- **Decorative only.** Icons never carry meaning (ADR 0058). The module's name is always beside the + icon or in its control's accessible name. + +### Module routes + +```text +/ the module's landing view (its root) +// a declared primary page +///… a route beneath a declared page: a detail view the module owns +``` + +**The root stays the canonical landing view and is never itself a declared page.** A page may not +use the root's path, so no page has two addresses, and `/` never silently redirects. + +- **Landing on a page.** A module that wants to land on, say, Playback renders Playback at its root + and simply doesn't declare a Playback page. +- **Beneath a declared page.** Any route under a page (`/signage/media/sermons/123`) mounts the + module with that full path. Detail views therefore need no manifest or sidebar entry. Primary + navigation and every valid internal route are deliberately different things. +- **What a route can contain.** Segments are unreserved URL characters only (letters, digits, `-`, + `.`, `_`, `~`), at most 8 segments and 512 characters. Nothing else is a route. +- **Unknown paths.** A path that is neither the root nor at or beneath a declared page is the + host's *Page not found*, and the module is not mounted. Nothing silently opens some other page. +- **Version 8 modules.** A module that declares no pages, which includes every version 8 module, has + its root and nothing beneath it, exactly as before. +- **Disabled modules.** A module that is installed but not running gets *This page isn't available* + at its root and at every path beneath it, and nothing mounts. A module that isn't installed is not + served at all (ADR 0057). +- **No remount.** The mounted module is keyed by identity, not path, so moving between its own pages + never remounts it. Focus moves to the page on each move, as for any page change. + +The API already serves the Dashboard page throughout `//*` (ADR 0057), so none of this +needs a server or proxy change. + +**The host's own namespaces are never pages.** `api`, `assets`, and `display` (`HOST_NAMESPACES`) +are never Dashboard pages at any depth, so `/api/v1/principal` can never read as the route +`v1/principal` of a module called `api`. Together with the Dashboard's platform pages they are +exactly Core's `RESERVED_TOP_LEVEL_NAMES`, and an `apps/api` test holds the lists together. + +### The sidebar + +- **A version 9 module** is drawn with its own icon. While it is open (its root, a page, or beneath + one), its pages appear beneath it, the same in-place expansion Settings uses (ADR 0061). +- **The current item.** Exactly one item carries `aria-current="page"`: the open page, or the + module itself on its landing view. A detail route marks its page. +- **Collapsed sidebar.** It shows icons only, so the pages stay hidden. The module item is then the + current one, named by its tooltip and accessible name. +- **Phone drawer.** It has the identical hierarchy and closes once a page is chosen. +- **A version 8 module** is one item with the generic icon, as before. +- **A disabled module** is absent, as before. + +The server sends each module's declared icon (`null` for none) and pages beside its name in +`GET /api/v1/dashboard/navigation`, never inside the host-neutral descriptor (ADR 0026, ADR 0027). +The shell parses both strictly. + +### The module's navigation capability + +`ModuleUiHostContext.navigation` (`@ekklesia/module-ui-sdk`) is a narrow host capability, not a +router. It is `undefined` where a host has no addressable pages: the display host, and a standalone +host without routes. + +```ts +interface ModuleUiNavigation { + location(): { path: string; page: string | undefined }; // "media/sermons/123", "media" + subscribe(listener: () => void): () => void; + navigate(path: string, options?: { replace?: boolean }): void; + link(path: string): { href: string; onClick(event): void }; + readonly settings: { open(): void; link(): ModuleUiLink } | undefined; +} +``` + +- **Relative paths only.** Every path is relative to the module's own root, and a leading `/` means + the same as none. There is therefore no way to name another module or a platform page. +- **Refused paths.** A path the host would not open throws `ModuleUiNavigationError`: malformed or + escaping gives `MODULE_NAVIGATION_PATH_INVALID`, and not at or beneath a declared page gives + `MODULE_NAVIGATION_PATH_UNKNOWN`. The error message never echoes the path. +- **Real links.** `link()` returns a real `href`, so a link can be opened in a new tab, copied, or + bookmarked. Its `onClick` opens the route in place on an ordinary click and leaves modified or + middle clicks to the browser. +- **Location updates.** `location()` returns a stable snapshot between moves. Every subscriber hears + every move: the module's own navigation, the sidebar, Back and Forward. +- **Settings.** `settings` is present only when the module has a Settings page this viewer may open + (it declares configuration, it is running, and Settings lists it for them). It's a presentation + hint: Settings enforces its own authority. +- **What a module never gets.** No history object, no `window` access, and no way to drive the shell + beyond its own routes. Modules still may not bring a router (ADR 0026). +- **Version 8 modules too.** They receive navigation with only their root, so a version 8 module + rebuilt against the new SDK can already link to its Settings page. + +Adding a context member changes nothing about how an artifact is mounted, so the mount ABI stays at +version 2. A module that uses `navigation` checks for it, exactly as it checks `display`. + +### Return-to-origin: one carrier, the `return` query parameter + +The place to come back to travels as **one query parameter on the page that was opened**: + +```text +/settings/signage?return=%2Fsignage%2Ftimelines +``` + +- **It is a query, not history state,** so it survives a refresh and works in a link opened in a new + tab. A canonical respelling of the address keeps it. +- **The module never builds or checks it.** `navigation.settings` builds it from the module's + current location. +- **It is only ever read through `parseReturnTarget`** (ADR 0059): a canonical, same-origin + Dashboard address with no query, fragment, traversal, or encoded separator. The host namespaces + are never accepted. +- **A module's address is also checked against what is running.** The module must be running here, + and the route must be its root or at or beneath a declared page. Anything else is ignored, and + Back goes to the page's own parent, Settings. +- **Back follows it.** A Settings page's Back goes to exactly that address ("Back to Signage"), as + a real link, never `history.back()`. + +`HostHistory` gains `search()` so the shell can read the query. The shell reads exactly this one +parameter from it. + +## Alternatives + +- **Keep version 8 and add `navigation` to it.** Rejected: version 8's object is strict. Older + platforms would refuse such a manifest with a misleading "invalid manifest". A version number + says what a document is. +- **Reject version 8 as earlier versions were.** Rejected: it would break every installed module + for an optional, additive field. +- **Any Lucide name, validated only by shape.** Rejected: a typo would silently draw the generic + icon, and drawing any name means bundling or code-splitting the whole library. The API serves a + closed set of files (ADR 0044), so it cannot serve a thousand chunks. +- **Icons as SVG or URLs in the manifest.** Rejected: executable or remote presentation in an inert + document, with an asset-trust story nobody needs. +- **The root as a declared page, or a redirect from the root to the first page.** Rejected: either + gives one page two addresses or surprises a bookmark. +- **Every route in the manifest.** Rejected: record URLs (`/media/sermons/123`) cannot be listed. + Primary navigation and valid routes are different things. +- **A router in the module, or `window.history`.** Rejected: routing belongs to the host + (ADR 0026), and two owners of one address bar disagree. +- **Return target in history state, `sessionStorage`, or a path segment.** Rejected: history state + and storage don't survive a new tab, and a path segment would collide with module routes. A query + parameter is visible, refreshable, and validated on every read. +- **Page icons.** Not added: the collapsed sidebar never shows pages. + +## Consequences + +- **Packages.** `@ekklesia/contracts` gains `module-navigation.ts`, the v8/v9 manifest union, and + `moduleManifestNavigation`. `@ekklesia/module-ui-sdk` gains `navigation.ts` and + `ModuleUiHostContext.navigation`. `@ekklesia/module-sdk` re-exports the navigation helpers and + types. All changes are additive for module code. A host that builds a context itself must now say + `navigation: undefined`. +- **Core** publishes each module's navigation beside its name. The navigation response gains a + `navigation` member per module. +- **The shell** reads module routes at any depth, draws icons and pages, and supplies the + capability. Its route type's `module` destination gains `path`. +- **The template module** is version 9, with two pages, a detail route, and a Settings link. +- **Signage** is unaffected until it chooses to move to version 9. Its released 0.1.5 manifest is a + test fixture and runs in the deploy smoke. + +## Deferred + +- Migrating Signage to version 9. +- How long version 8 stays accepted. +- Overview widgets. +- A module reporting "not found" for its own unknown detail route back to the host. +- Return targets for platform pages other than Settings. +- Navigation groups and their editor (ADR 0027). +- Growing the icon set. +- Module-owned breadcrumbs. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index a48aed4..d76ab87 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -71,3 +71,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted - [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted - [0062: The account area, and appearance preferences that belong to the account](0062-account-area-and-synced-appearance-preferences.md) — Accepted +- [0063: Module navigation — manifest v9 icons and pages, real module routes, and return-to-origin](0063-module-navigation-contract-manifest-v9.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 3b1bec1..2c07f8a 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -614,7 +614,17 @@ Core one typed column per preference and served only to their owner (`GET /api/v `PATCH /api/v1/account/preferences`, `core.account.self.manage`), so they follow the person to every device. A small same-origin script in the page's `` applies this browser's cached copy before first paint, and the account's answer replaces it on every load -([ADR 0062](decisions/0062-account-area-and-synced-appearance-preferences.md)). `pnpm boundaries` opens +([ADR 0062](decisions/0062-account-area-and-synced-appearance-preferences.md)). A module is a +first-class application area. Manifest version 9 declares its Lucide icon, from a curated set the +Dashboard imports by name, and its primary pages. The sidebar nests those pages beneath it while it is +open. Routes go to the module's root, to a declared page, or to anything beneath a page; anything +else under a module is not-found and mounts nothing. The host's `api`, `assets`, and `display` +namespaces are never pages. The mounted module receives `context.navigation`, a narrow capability to +read its location, move among its own routes, make real links, and open its Settings page. The +Settings URL carries `?return=` with where the module was; it is validated against the running +modules and their declared routes, and Back returns there. Manifest version 8 stays accepted beside +version 9, read strictly as itself +([ADR 0063](decisions/0063-module-navigation-contract-manifest-v9.md)). `pnpm boundaries` opens exactly two shared roles to modules **by name**, and pins both design packages beneath everything else. The Dashboard and display install the defaults at their document root; custom properties inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at diff --git a/docs/development/api.md b/docs/development/api.md index 7e37003..1a7af76 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -1165,6 +1165,13 @@ How this installation presents its mountable modules "api": { "baseUrl": "/api/v1/modules/livestream" }, "realtime": { "baseUrl": "/api/v1/module-realtime/livestream" }, "permissions": ["livestream.streams.read"] + }, + "navigation": { + "icon": "video", + "pages": [ + { "id": "streams", "label": "Streams", "path": "streams" }, + { "id": "archive", "label": "Archive", "path": "archive" } + ] } } ] @@ -1174,6 +1181,13 @@ How this installation presents its mountable modules } ``` +`navigation` is what the module itself declares in manifest version 9 +([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)): the Lucide +icon the Dashboard draws it with, and its primary pages in declared order. It is +`{ "icon": null, "pages": [] }` for a module that declares none, which every manifest version 8 +module is: the generic icon and a single root page. It sits beside the host-neutral descriptor, +never inside it, and says nothing about where the module is placed. + ### Navigation versus inventory They are deliberately separate, and this route is layered **on top of** the inventory rather than diff --git a/docs/development/security.md b/docs/development/security.md index 922ce27..0ac35e8 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -416,6 +416,29 @@ holding the channel permission may receive. If it intentionally includes a signe URL remains a bearer capability and is forwarded byte-for-byte; fixed transport errors never reflect payloads, URLs, permissions, runtime exceptions, paths, or stacks. +### A module's navigation reaches only its own pages, and return links only real Dashboard pages + +Module navigation ([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)) +is inert data plus a narrow host capability: + +- **The manifest supplies no executable or remote presentation.** A module's `navigation` is a + Lucide name from a curated set (never an SVG, a URL, or code) and page paths that are lowercase + kebab-case segments beneath its own root. Traversal, schemes, queries, and percent-encoding are + refused before the module can start. +- **The module gets no history object, router, or `window`.** It asks to move to a path relative to + its own root. The host refuses anything that is not its root, or at or beneath a page it declares, + so no module can navigate the Dashboard into another module or a platform page. +- **The host's own namespaces are never pages.** `/api/…`, `/assets/…`, and `/display/…` are never + read as a module route, a page, or a return target. +- **`?return=` is untrusted input.** It is only ever read through `parseReturnTarget`, which accepts + a canonical same-origin Dashboard address and nothing else. A module address must also name a + running module and one of its routes. Anything else is ignored, and Back is a real link to that + validated address, never `history.back()`. + +None of this is a security boundary between modules: module UI is trusted code in the host's page +(see above). It keeps honest mistakes and hostile links from turning Dashboard navigation into an +open redirect or an API link. + ### Client-side permission checks are not a security boundary A host may tell a module UI what permissions it believes the viewer holds diff --git a/docs/development/testing.md b/docs/development/testing.md index 59ad72c..85c3cb9 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -412,6 +412,33 @@ pre-paint appearance cache one write of each fixed key, one removal of the appearance key, and no storage API in any file that holds the reset token. +Module navigation +([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)) is proven at +each layer: + +- `packages/contracts/tests/module-navigation.test.ts` covers manifest v9: icons, pages, order, every + unsafe path, duplicates, nesting, and bounds. It also accepts the byte-exact released Signage 0.1.5 + manifest (`tests/fixtures/`) as version 8 with no navigation, and refuses `navigation` in a + version 8 document. +- `core/tests/module-discovery.test.ts` discovers that released v8 module and a v9 module side by + side, and refuses an escaping page path. +- `apps/api/tests/dashboard-routes.test.ts` serves a v9 module's icon and pages beside its + descriptor, and `{ icon: null, pages: [] }` for v8. +- `apps/api/tests/dashboard-host-routes.test.ts` holds the shell's icon names equal to the contract's + set, and the host namespaces plus platform pages equal to Core's reserved top-level names. +- `surfaces/shell/tests/module-navigation.test.tsx` mounts real module artifacts through the loader's + Shadow DOM boundary. It covers: + - roots, declared pages, detail routes, unknown pages, and disabled modules; + - Back and Forward and refresh with no remount; + - the sidebar's icons and nested pages, collapsed, and in the drawer; + - the navigation capability, including refused paths; + - module → Settings → Back to the exact page, and every unsafe return target falling back. +- `templates/module/tests/module-ui.test.ts` renders the v9 template's pages from the host's + location. +- The deploy smoke's real Chromium drives the v9 template through its pages, a deep link, refresh, + Back and Forward, the drawer, and module → Settings → Back. With + `EKKLESIA_SMOKE_MODULE_RELEASE_DIR` it runs the released v8 Signage package as well. + The account area ([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)) is proven at each layer: diff --git a/docs/module-development/README.md b/docs/module-development/README.md index ac1052e..8c707c3 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -463,7 +463,7 @@ without executing module code: ```json { - "manifestVersion": 8, + "manifestVersion": 9, "id": "calendar", "name": "Calendar", "version": "0.1.0", @@ -522,6 +522,13 @@ without executing module code: "description": "Renders an upcoming-events slide for a signage timeline." } ], + "navigation": { + "icon": "calendar", + "pages": [ + { "id": "events", "label": "Events", "path": "events" }, + { "id": "rooms", "label": "Rooms", "path": "rooms" } + ] + }, "entrypoints": { "server": "./dist/server/index.js", "schema": "./dist/schema/index.js", @@ -548,18 +555,24 @@ visible to whoever holds `core.modules.configuration.manage` ([ADR 0061](../architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)). Module lifecycle — installing, turning on and off, updating — is the platform's Settings → Discover, not yours to build. Do not build a second settings system inside your module; link to your Settings page -instead once the module navigation contract can carry a return link. A provided capability +instead, through `context.navigation.settings` (see [Module navigation](#module-navigation)), which +brings the person back to exactly the page they left. A provided capability has its own exact semantic version because its public contract can evolve independently from the module release. Capability dependencies use semantic-version ranges. Optional means the provider may be absent; if it is installed, its capability version must be compatible. -`manifestVersion: 8` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a -version 7 manifest, and any other version, fails validation outright rather than being silently -reinterpreted; there is no compatibility adapter or automatic migration between manifest versions. -Already-built version 7 packages must be rebuilt. Version 7 added the optional `contributions` array -([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)). A version 6 module that -contributes nothing changes only the version literal; every browser artifact remains independently -optional ([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). +`manifestVersion` (`packages/contracts/src/module-manifest.ts`) is required and selects exactly one +strict schema: **9**, the current version and what a new module declares, or **8**, which stays +accepted so released modules keep working unchanged +([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Version 9 is +version 8 plus the optional `navigation` field; a version 8 manifest that names `navigation` is +refused, never read as version 9. To adopt navigation, change `8` to `9` and add the field — nothing +else changes. Version 7 and every other version fail validation outright; there is no compatibility +adapter or automatic migration, and already-built version 7 packages must be rebuilt. Version 7 +added the optional `contributions` array +([ADR 0041](../architecture/decisions/0041-module-browser-contributions.md)), and every browser +artifact remains independently optional +([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin with `.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or `restricted` — see [Authorization](../development/authorization.md#permission-registry) for what @@ -591,9 +604,9 @@ Lucide is Ekklesia's icon vocabulary UI needs icons, depend on `lucide-react` and import each icon by name, rather than drawing your own SVGs (module `.svg` presentation assets are rejected by `pnpm boundaries` anyway). Treat icons as decorative: give every control a text name, visible or visually hidden, and keep the icon -`aria-hidden`. The Dashboard's navigation currently shows every module with one generic icon; a -module will declare its own navigation icon through the module navigation contract (manifest v9), not -yet. +`aria-hidden`. The icon the **Dashboard's sidebar** draws for your module is different: you name it +in your manifest (`navigation.icon`), and the Dashboard draws it — see +[Module navigation](#module-navigation). ### Your module's display name @@ -606,8 +619,9 @@ your package name or module id. Production" — are presentation configuration an administrator owns, stored by the platform and changed at any time without touching your module ([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). There is no -manifest field for a folder, a group, an order, an icon, or a category, and adding one would make a -rendering preference into module structure. +manifest field for a folder, a group, an order, or a category, and adding one would make a rendering +preference into module structure. What you *do* declare is what your module is: its icon and its +primary pages ([Module navigation](#module-navigation)). That separation also means grouping never affects you. Two modules sharing a folder have no relationship: no dependency, no ordering, no shared anything. If your module genuinely needs another @@ -624,9 +638,9 @@ symlink resolution. Modules with no tables omit `entrypoints.schema`; modules wi history omit `entrypoints.migrations`; modules may omit `ui`, `display`, or both. Unknown manifest properties are still rejected. A `dashboard`, `surface`, `client`, `web`, or -`renderers` key fails validation — and the manifest -deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard -organization is presentation configuration owned by the Dashboard, never module architecture. +`renderers` key fails validation — and the manifest deliberately has no group, folder, parent, +menu, or ordering field. Dashboard organization is presentation configuration owned by the +Dashboard, never module architecture. `navigation` declares only your module's own icon and pages. Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets. Core will later map them to deployment-specific physical S3 buckets and provide scoped access; @@ -706,7 +720,7 @@ artifact is the role selection. ### What the host gives you -`mount` receives exactly six members, and nothing else is available: +`mount` receives exactly eight members, and nothing else is available: | Member | What it is | | --- | --- | @@ -716,10 +730,12 @@ artifact is the role selection. | `permissions` | `has(permissionId)` — a presentation hint, never a security check | | `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) | | `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) | +| `display` | Your display page's URL, or `undefined` when you publish none | +| `navigation` | Where your module is, and how it moves between its own pages and to its Settings page — see [Module navigation](#module-navigation). `undefined` on the display host | -Navigation, module configuration, organization context, and diagnostics reporting are **not** in the -contract. They need designs that do not exist, and a speculative field frozen into a public contract -is harder to remove than to add. +Module configuration, organization context, and diagnostics reporting are **not** in the contract. +They need designs that do not exist, and a speculative field frozen into a public contract is harder +to remove than to add. The host **theme** is deliberately not a context member either, and that is not a gap: design tokens reach your artifact as inherited CSS custom properties rather than as data (ADR 0039). You import @@ -727,6 +743,90 @@ reach your artifact as inherited CSS custom properties rather than as data (ADR root. Nothing is negotiated at run time, so there is no token map to keep in sync and no host that can forget to pass one. +### Module navigation + +Your module is a first-class part of the Dashboard +([ADR 0063](../architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Declare its +icon and primary pages in the manifest (version 9): + +```json +"navigation": { + "icon": "monitor-play", + "pages": [ + { "id": "playback", "label": "Playback", "path": "playback" }, + { "id": "timelines", "label": "Timelines", "path": "timelines" }, + { "id": "media", "label": "Media", "path": "media" } + ] +} +``` + +**The icon.** + +- `icon` is a Lucide name from `MODULE_NAVIGATION_ICONS` (`@ekklesia/contracts`, re-exported by + `@ekklesia/module-sdk`), a curated set of generic subjects. A name outside it fails validation, so + you'll know at packaging time. +- Omit it and the Dashboard draws its generic module icon. +- Never ship an SVG or a URL for it. + +**The pages.** + +- `pages` are your primary pages, at most 12, in the order the sidebar shows them beneath your + module, while your module is open. +- `id` is stable and never shown. +- `label` is what a person reads (1–60 characters). +- `path` is relative to your module's root: one to four lowercase kebab-case segments, such as + `timelines` or `media/library`. +- Paths must be unique, and one page may not sit beneath another's path. +- Don't add a tab bar of your own for these. The sidebar is your primary navigation (DESIGN.md + §4.4). Tabs inside one resource are still fine. + +**Your routes.** + +| Address | What it is | +| --- | --- | +| `/` | Your landing view. Never a declared page, so no page has two addresses. To open on, say, Playback, render it at your root and don't declare it. | +| `//` | A declared page. | +| `///…` | Anything beneath a declared page: your detail views (`media/sermons/123`), with no manifest entry. | +| anything else under your id | The Dashboard's *Page not found*. Your UI isn't mounted for it. | + +A refresh, a bookmark, and Back and Forward all land on the same route. + +**Moving around: `context.navigation`.** Never touch `window.location` or `window.history`, and +never build a Dashboard URL yourself: + +```tsx +const { navigation } = context; // undefined on the display host +navigation?.location(); // { path: "media/sermons/123", page: "media" } +navigation?.subscribe(() => rerender()); // every move: yours, the sidebar's, Back and Forward +navigation?.navigate("timelines"); // or { replace: true } to respell the current entry +const link = navigation?.link("media/sermons/123"); +Sermon // a real link; new tabs still work +``` + +- **Paths are relative to your root**, and a leading `/` means the same thing. +- **Refused paths throw.** A path that names anything outside your root, or a page you don't + declare, throws `ModuleUiNavigationError` (`MODULE_NAVIGATION_PATH_INVALID` or + `MODULE_NAVIGATION_PATH_UNKNOWN`). There is no way to navigate into another module. +- **You stay mounted.** Your UI is not remounted as it moves between its own pages. Subscribe and + re-render from `location()`, which returns the same object until something moves. + +**Your Settings page.** If your module declares `configuration`, its settings live at +`/settings/` (ADR 0061). `navigation.settings` opens that page, or `undefined` means this +viewer can't: + +```tsx +navigation?.settings?.open(); // or navigation.settings.link() for a real +``` + +The host carries where your module is as the place to come back to +(`/settings/?return=%2F%2Ftimelines`). Settings' Back then returns to exactly +that page, after refresh too. You never build or validate the return address: the host does, +accepting only real pages of running modules on this Dashboard. + +**Version 8 modules.** A module still on version 8 keeps working unchanged: the generic icon, its root +as its only page, and settings detected from `configuration` as before. Rebuilt against the new SDK, +it also receives `navigation`, with only its root, and can link to its Settings page. + ### Never build your own API URL Ask the host for a path relative to your own module's API root: diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f86a201..154e9ce 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -9,6 +9,7 @@ export * from "./module-http.js"; export * from "./module-lifecycle.js"; export * from "./module-manifest.js"; export * from "./module-migration.js"; +export * from "./module-navigation.js"; export * from "./module-realtime.js"; export * from "./module-runtime.js"; export * from "./module-schema-metadata.js"; diff --git a/packages/contracts/src/module-manifest.ts b/packages/contracts/src/module-manifest.ts index 24e04e5..ec401bd 100644 --- a/packages/contracts/src/module-manifest.ts +++ b/packages/contracts/src/module-manifest.ts @@ -15,6 +15,11 @@ import { type ModuleContributionDeclaration, moduleContributionDeclarationSchema, } from "./module-contribution.js"; +import { + EMPTY_MODULE_NAVIGATION, + type ModuleNavigationDeclaration, + moduleNavigationDeclarationSchema, +} from "./module-navigation.js"; import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js"; const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema); @@ -130,9 +135,21 @@ function reportDuplicates( } /** - * The one manifest shape Core accepts. Version 7's strict top-level object rejects `database`, so - * the static ABI declaration is an incompatible shape change and the canonical manifest moves to - * `manifestVersion: 8` without silently reinterpreting version 7. + * The manifest shapes Core accepts: **version 8 and version 9**, each read strictly as itself. + * + * Version 7's strict top-level object rejected `database`, so that ABI declaration moved the + * manifest to version 8 (ADR 0050). Version 8's strict object likewise rejects `navigation`, so + * module-declared Dashboard navigation — an icon and primary pages (ADR 0063) — is version 9. + * + * Unlike every earlier step, **version 8 stays accepted beside version 9** (ADR 0063). Released + * modules were packaged, verified, and installed as version 8, and the navigation version adds one + * optional field and changes the meaning of nothing a version 8 document says. Requiring every + * installation's modules to be rebuilt just because Core gained navigation would break working + * churches for no safety gain. The rule that made single-version acceptance matter still holds + * exactly: `manifestVersion` selects one strict schema, so a document is only ever read as the one + * version it declares — a version 8 document that names `navigation` is refused, never quietly + * reinterpreted as version 9 — and nothing ever rewrites a released manifest, its package bytes, or + * its digest. * * Configuration is declared **statically, in this inert document**, rather than registered by * executable module code, for one decisive reason: the platform validates a module's configuration @@ -141,219 +158,267 @@ function reportDuplicates( * inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without * importing a single line of module code, exactly as they always have. * - * Version 7, and every other version, fails validation: there is no compatibility adapter, no - * automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2 - * (ADR 0006), version 2 by version 3 (ADR 0014), version 3 by version 4 (ADR 0021), and version 7 - * by version 8 (ADR 0050). One manifest document can therefore never be read as two different - * versions. A version 7 package must be rebuilt; omitting `database` selects the legacy facade. + * Every other version fails validation: there is no conversion and no guessing. Version 7 and + * earlier must be rebuilt; omitting `database` selects the legacy facade. */ -export const MODULE_MANIFEST_VERSION = 8; +export const SUPPORTED_MODULE_MANIFEST_VERSIONS = Object.freeze([8, 9] as const); -export const moduleManifestSchema = z +/** The newest manifest version: what a new module declares, and what the template uses. */ +export const MODULE_MANIFEST_VERSION = 9; + +const manifestFields = { + id: moduleIdSchema, + name: z.string().trim().min(1).max(120), + description: z.string().trim().min(1).max(500).optional(), + version: semanticVersionSchema, + compatibility: z + .object({ + platform: semanticVersionRangeSchema, + moduleSdk: semanticVersionRangeSchema, + }) + .strict(), + dependencies: dependenciesSchema, + capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []), + permissions: z.array(permissionDeclarationSchema).default(() => []), + /** What this module needs an operator to configure, and how to validate it (ADR 0029). Inert + * data: declaring configuration never causes Core to read a database, contact a host, or run + * module code during discovery. A module that needs nothing configured omits the field. */ + configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []), + storage: moduleStorageSchema, + database: moduleDatabaseDeclarationSchema.optional(), + /** Compiled browser renderers this module offers to capabilities **other** modules provide + * (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and + * never learns what a contribution renders. A module that contributes nothing omits the field. */ + contributions: z.array(moduleContributionDeclarationSchema).default(() => []), + entrypoints: entrypointsSchema, +}; + +const manifestFieldsObject = z.object(manifestFields); + +/** The fields every supported version shares, after parsing. */ +type SharedManifestFields = z.output; + +/** The cross-field rules every supported version obeys, identically. */ +function refineManifest(manifest: SharedManifestFields, context: z.RefinementCtx): void { + reportDuplicates( + manifest.capabilities.map((capability) => capability.id), + ["capabilities"], + context, + "id", + ); + reportDuplicates( + manifest.permissions.map((permission) => permission.id), + ["permissions"], + context, + "id", + ); + reportDuplicates( + manifest.storage.buckets.map((bucket) => bucket.id), + ["storage", "buckets"], + context, + "id", + ); + // One namespace for every configuration key a module owns, so an ordinary key and a secret key + // can never collide: a module cannot declare `apiToken` as both a defaultable string and a + // deployment-supplied secret and leave the platform to guess which one a stored row means. + reportDuplicates( + manifest.configuration.map((declaration) => declaration.key), + ["configuration"], + context, + "key", + ); + + manifest.capabilities.forEach((capability, index) => { + if (!capability.id.startsWith(`${manifest.id}.`)) { + context.addIssue({ + code: "custom", + message: `Capability identifiers owned by this module must begin with ${manifest.id}.`, + path: ["capabilities", index, "id"], + }); + } + }); + + reportDuplicates( + manifest.contributions.map((contribution) => contribution.id), + ["contributions"], + context, + "id", + ); + + const ownedCapabilityIds = new Set(manifest.capabilities.map((capability) => capability.id)); + const contributionArtifacts = new Set(); + manifest.contributions.forEach((contribution, index) => { + // Namespacing is what makes a contribution id globally unique without a registry: module ids + // are already unique, so two modules can never produce the same contribution id. + if (!contribution.id.startsWith(`${manifest.id}.`)) { + context.addIssue({ + code: "custom", + message: `Contribution identifiers owned by this module must begin with ${manifest.id}.`, + path: ["contributions", index, "id"], + }); + } + // A contribution extends *another* module's capability. Targeting one this module already + // provides would be a module contributing to itself, which needs no platform mechanism at all + // and would make the compatibility check meaningless. + if (ownedCapabilityIds.has(contribution.capability)) { + context.addIssue({ + code: "custom", + message: "A browser contribution must target a capability another module provides.", + path: ["contributions", index, "capability"], + }); + } + if ( + Object.entries(manifest.entrypoints).some(([entrypointName, configured]) => + entrypointName === "display" + ? (configured as { artifact?: string } | undefined)?.artifact === contribution.artifact + : configured === contribution.artifact, + ) + ) { + context.addIssue({ + code: "custom", + message: "A contribution artifact must use a different path from every entrypoint.", + path: ["contributions", index, "artifact"], + }); + } + if (contributionArtifacts.has(contribution.artifact)) { + context.addIssue({ + code: "custom", + message: "Two contributions must not declare the same artifact path.", + path: ["contributions", index, "artifact"], + }); + } + contributionArtifacts.add(contribution.artifact); + }); + + manifest.permissions.forEach((permission, index) => { + if (!permission.id.startsWith(`${manifest.id}.`)) { + context.addIssue({ + code: "custom", + message: `Permission identifiers owned by this module must begin with ${manifest.id}.`, + path: ["permissions", index, "id"], + }); + } + }); + + const display = manifest.entrypoints.display; + if (display !== undefined) { + const displayPermission = manifest.permissions.find( + (permission) => permission.id === display.permission, + ); + if (displayPermission === undefined) { + context.addIssue({ + code: "custom", + message: "A display entrypoint must reference a permission declared by this module.", + path: ["entrypoints", "display", "permission"], + }); + } else if (displayPermission.baseline === "public") { + context.addIssue({ + code: "custom", + message: "A display entrypoint permission must require an authenticated principal.", + path: ["entrypoints", "display", "permission"], + }); + } + if ( + Object.entries(manifest.entrypoints).some( + ([entrypointName, configured]) => + entrypointName !== "display" && configured === display.artifact, + ) + ) { + context.addIssue({ + code: "custom", + message: "The display UI must use a different path from every other configured entrypoint.", + path: ["entrypoints", "display", "artifact"], + }); + } + } + + for (const dependencyKind of ["required", "optional"] as const) { + if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) { + context.addIssue({ + code: "custom", + message: "A module cannot depend on itself.", + path: ["dependencies", "modules", dependencyKind, manifest.id], + }); + } + } + + for (const dependencyId of Object.keys(manifest.dependencies.modules.required)) { + if (Object.hasOwn(manifest.dependencies.modules.optional, dependencyId)) { + context.addIssue({ + code: "custom", + message: `${dependencyId} cannot be both a required and optional module dependency.`, + path: ["dependencies", "modules", "optional", dependencyId], + }); + } + } + + for (const dependencyKind of ["required", "optional"] as const) { + for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) { + if (ownedCapabilityIds.has(capability)) { + context.addIssue({ + code: "custom", + message: "A module does not need to depend on a capability it owns.", + path: ["dependencies", "capabilities", dependencyKind, capability], + }); + } + } + } + for (const capability of Object.keys(manifest.dependencies.capabilities.required)) { + if (Object.hasOwn(manifest.dependencies.capabilities.optional, capability)) { + context.addIssue({ + code: "custom", + message: `${capability} cannot be both a required and optional capability dependency.`, + path: ["dependencies", "capabilities", "optional", capability], + }); + } + } +} + +const moduleManifestV8Schema = z + .object({ manifestVersion: z.literal(8), ...manifestFields }) + .strict() + .superRefine(refineManifest); + +const moduleManifestV9Schema = z .object({ - manifestVersion: z.literal(MODULE_MANIFEST_VERSION), - id: moduleIdSchema, - name: z.string().trim().min(1).max(120), - description: z.string().trim().min(1).max(500).optional(), - version: semanticVersionSchema, - compatibility: z - .object({ - platform: semanticVersionRangeSchema, - moduleSdk: semanticVersionRangeSchema, - }) - .strict(), - dependencies: dependenciesSchema, - capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []), - permissions: z.array(permissionDeclarationSchema).default(() => []), - /** What this module needs an operator to configure, and how to validate it (ADR 0029). Inert - * data: declaring configuration never causes Core to read a database, contact a host, or run - * module code during discovery. A module that needs nothing configured omits the field. */ - configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []), - storage: moduleStorageSchema, - database: moduleDatabaseDeclarationSchema.optional(), - /** Compiled browser renderers this module offers to capabilities **other** modules provide - * (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and - * never learns what a contribution renders. A module that contributes nothing omits the field. */ - contributions: z.array(moduleContributionDeclarationSchema).default(() => []), - entrypoints: entrypointsSchema, + manifestVersion: z.literal(9), + ...manifestFields, + /** The module's Dashboard navigation (ADR 0063): its Lucide icon and primary pages. Omitted, + * the module is drawn with the generic icon and has one page, its root — exactly as a version 8 + * module. Only meaningful for a module with a primary UI. */ + navigation: moduleNavigationDeclarationSchema.optional(), }) .strict() .superRefine((manifest, context) => { - reportDuplicates( - manifest.capabilities.map((capability) => capability.id), - ["capabilities"], - context, - "id", - ); - reportDuplicates( - manifest.permissions.map((permission) => permission.id), - ["permissions"], - context, - "id", - ); - reportDuplicates( - manifest.storage.buckets.map((bucket) => bucket.id), - ["storage", "buckets"], - context, - "id", - ); - // One namespace for every configuration key a module owns, so an ordinary key and a secret key - // can never collide: a module cannot declare `apiToken` as both a defaultable string and a - // deployment-supplied secret and leave the platform to guess which one a stored row means. - reportDuplicates( - manifest.configuration.map((declaration) => declaration.key), - ["configuration"], - context, - "key", - ); - - manifest.capabilities.forEach((capability, index) => { - if (!capability.id.startsWith(`${manifest.id}.`)) { - context.addIssue({ - code: "custom", - message: `Capability identifiers owned by this module must begin with ${manifest.id}.`, - path: ["capabilities", index, "id"], - }); - } - }); - - reportDuplicates( - manifest.contributions.map((contribution) => contribution.id), - ["contributions"], - context, - "id", - ); - - const ownedCapabilityIds = new Set(manifest.capabilities.map((capability) => capability.id)); - const contributionArtifacts = new Set(); - manifest.contributions.forEach((contribution, index) => { - // Namespacing is what makes a contribution id globally unique without a registry: module ids - // are already unique, so two modules can never produce the same contribution id. - if (!contribution.id.startsWith(`${manifest.id}.`)) { - context.addIssue({ - code: "custom", - message: `Contribution identifiers owned by this module must begin with ${manifest.id}.`, - path: ["contributions", index, "id"], - }); - } - // A contribution extends *another* module's capability. Targeting one this module already - // provides would be a module contributing to itself, which needs no platform mechanism at all - // and would make the compatibility check meaningless. - if (ownedCapabilityIds.has(contribution.capability)) { - context.addIssue({ - code: "custom", - message: "A browser contribution must target a capability another module provides.", - path: ["contributions", index, "capability"], - }); - } - if ( - Object.entries(manifest.entrypoints).some(([entrypointName, configured]) => - entrypointName === "display" - ? (configured as { artifact?: string } | undefined)?.artifact === contribution.artifact - : configured === contribution.artifact, - ) - ) { - context.addIssue({ - code: "custom", - message: "A contribution artifact must use a different path from every entrypoint.", - path: ["contributions", index, "artifact"], - }); - } - if (contributionArtifacts.has(contribution.artifact)) { - context.addIssue({ - code: "custom", - message: "Two contributions must not declare the same artifact path.", - path: ["contributions", index, "artifact"], - }); - } - contributionArtifacts.add(contribution.artifact); - }); - - manifest.permissions.forEach((permission, index) => { - if (!permission.id.startsWith(`${manifest.id}.`)) { - context.addIssue({ - code: "custom", - message: `Permission identifiers owned by this module must begin with ${manifest.id}.`, - path: ["permissions", index, "id"], - }); - } - }); - - const display = manifest.entrypoints.display; - if (display !== undefined) { - const displayPermission = manifest.permissions.find( - (permission) => permission.id === display.permission, - ); - if (displayPermission === undefined) { - context.addIssue({ - code: "custom", - message: "A display entrypoint must reference a permission declared by this module.", - path: ["entrypoints", "display", "permission"], - }); - } else if (displayPermission.baseline === "public") { - context.addIssue({ - code: "custom", - message: "A display entrypoint permission must require an authenticated principal.", - path: ["entrypoints", "display", "permission"], - }); - } - if ( - Object.entries(manifest.entrypoints).some( - ([entrypointName, configured]) => - entrypointName !== "display" && configured === display.artifact, - ) - ) { - context.addIssue({ - code: "custom", - message: - "The display UI must use a different path from every other configured entrypoint.", - path: ["entrypoints", "display", "artifact"], - }); - } - } - - for (const dependencyKind of ["required", "optional"] as const) { - if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) { - context.addIssue({ - code: "custom", - message: "A module cannot depend on itself.", - path: ["dependencies", "modules", dependencyKind, manifest.id], - }); - } - } - - for (const dependencyId of Object.keys(manifest.dependencies.modules.required)) { - if (Object.hasOwn(manifest.dependencies.modules.optional, dependencyId)) { - context.addIssue({ - code: "custom", - message: `${dependencyId} cannot be both a required and optional module dependency.`, - path: ["dependencies", "modules", "optional", dependencyId], - }); - } - } - - for (const dependencyKind of ["required", "optional"] as const) { - for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) { - if (ownedCapabilityIds.has(capability)) { - context.addIssue({ - code: "custom", - message: "A module does not need to depend on a capability it owns.", - path: ["dependencies", "capabilities", dependencyKind, capability], - }); - } - } - } - for (const capability of Object.keys(manifest.dependencies.capabilities.required)) { - if (Object.hasOwn(manifest.dependencies.capabilities.optional, capability)) { - context.addIssue({ - code: "custom", - message: `${capability} cannot be both a required and optional capability dependency.`, - path: ["dependencies", "capabilities", "optional", capability], - }); - } + refineManifest(manifest, context); + if (manifest.navigation !== undefined && manifest.entrypoints.ui === undefined) { + context.addIssue({ + code: "custom", + message: "Navigation can only be declared by a module with a primary UI (entrypoints.ui).", + path: ["navigation"], + }); } }); +export const moduleManifestSchema = z.discriminatedUnion("manifestVersion", [ + moduleManifestV8Schema, + moduleManifestV9Schema, +]); + +/** + * The navigation a manifest declares, normalized: version 8 declares none, so it gets no icon and + * no pages, exactly like a version 9 manifest that omits the field. Read through this rather than + * from the manifest, so no consumer branches on the version. + */ +export function moduleManifestNavigation(manifest: { + readonly manifestVersion: number; + readonly navigation?: ModuleNavigationDeclaration | undefined; +}): ModuleNavigationDeclaration { + return manifest.manifestVersion === 9 && manifest.navigation !== undefined + ? manifest.navigation + : EMPTY_MODULE_NAVIGATION; +} + export function parseModuleManifest(input: unknown): ModuleManifest { return moduleManifestSchema.parse(input); } @@ -364,6 +429,8 @@ export function safeParseModuleManifest(input: unknown) { export type ModuleManifestInput = z.input; export type ModuleManifest = z.output; +export type ModuleManifestV8 = z.output; +export type ModuleManifestV9 = z.output; export type ModuleBucketDeclaration = z.output; /** A module manifest's `contributions` array entry — the same `ModuleContributionDeclaration` shape * Core's publication registry consumes (`packages/contracts/src/module-contribution.ts`). */ diff --git a/packages/contracts/src/module-navigation.ts b/packages/contracts/src/module-navigation.ts new file mode 100644 index 0000000..87e36a8 --- /dev/null +++ b/packages/contracts/src/module-navigation.ts @@ -0,0 +1,242 @@ +import { z } from "zod"; + +/** + * A module's Dashboard navigation (manifest v9, ADR 0063): the icon it is drawn with, and the + * meaningful primary pages it contributes to the global sidebar beneath its own entry. + * + * Inert data, like every other manifest field. The host resolves the icon and draws the pages; a + * manifest never supplies executable icon code, a URL, or a route outside its own namespace. + */ + +/** + * The Lucide icons a module may declare (DESIGN.md locked decision 94, ADR 0058), by their stable + * Lucide names. + * + * A curated set rather than "any Lucide name", for two reasons. The host imports exactly these by + * name, so the Dashboard never bundles the whole library to honour one manifest. And a manifest is + * validated against it, so a typo is caught when a module is packaged or discovered instead of + * silently drawing the generic icon. Growing the set is an additive platform change: the shell's + * tests hold its icon map equal to this list, so a name cannot be added here without the Dashboard + * being able to draw it. + * + * Generic subjects only. There is no "signage" or "calendar" entry tied to a particular module: a + * module chooses what represents it. + */ +export const MODULE_NAVIGATION_ICONS = Object.freeze([ + "activity", + "archive", + "baby", + "bell", + "book-open", + "book-open-text", + "bookmark", + "briefcase", + "building", + "bus", + "calendar", + "calendar-check", + "calendar-days", + "camera", + "car", + "chart-column", + "chart-pie", + "church", + "clipboard-check", + "clipboard-list", + "clock", + "coffee", + "contact", + "credit-card", + "database", + "door-open", + "file-text", + "files", + "film", + "flag", + "folder", + "gift", + "globe", + "graduation-cap", + "hand-coins", + "hand-heart", + "handshake", + "headphones", + "heart", + "heart-handshake", + "house", + "image", + "images", + "inbox", + "key-round", + "landmark", + "layout-grid", + "library", + "list-checks", + "list-todo", + "mail", + "map", + "map-pin", + "megaphone", + "message-circle", + "message-square", + "mic", + "monitor", + "monitor-play", + "music", + "newspaper", + "notebook", + "package", + "palette", + "party-popper", + "phone", + "presentation", + "printer", + "projector", + "radio", + "receipt", + "school", + "send", + "shield", + "shopping-bag", + "sparkles", + "star", + "sun", + "tablet-smartphone", + "tag", + "tent", + "ticket", + "trophy", + "truck", + "tv", + "user-check", + "user-round", + "users", + "users-round", + "utensils", + "video", + "wallet", + "wrench", +] as const); + +export type ModuleNavigationIcon = (typeof MODULE_NAVIGATION_ICONS)[number]; + +const navigationIcons: ReadonlySet = new Set(MODULE_NAVIGATION_ICONS); + +export function isModuleNavigationIcon(value: unknown): value is ModuleNavigationIcon { + return typeof value === "string" && navigationIcons.has(value); +} + +/** The most primary pages one module may contribute. A sidebar entry with more than this is a + * module that needs its own information architecture, not a longer list. */ +export const MODULE_NAVIGATION_PAGES_MAX = 12; +export const MODULE_NAVIGATION_PAGE_LABEL_MAX_LENGTH = 60; +export const MODULE_NAVIGATION_PAGE_PATH_MAX_SEGMENTS = 4; + +const PAGE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +const PATH_SEGMENT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused. +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u; + +/** + * A page path, relative to the module's own root: one to four lowercase kebab-case segments joined + * by `/` — `timelines`, `media/library`. There is no leading slash, no empty segment, no `.` or + * `..`, no percent-encoding, no query, and no scheme, so a declared path can never escape + * `//` or name anything but a page of this module. The module root itself is not a page + * path: it is the module's landing view (ADR 0063). + */ +export function isModuleNavigationPagePath(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0 || value.length > 120) { + return false; + } + const segments = value.split("/"); + return ( + segments.length <= MODULE_NAVIGATION_PAGE_PATH_MAX_SEGMENTS && + segments.every((segment) => PATH_SEGMENT_PATTERN.test(segment)) + ); +} + +export const moduleNavigationPageSchema = z + .object({ + /** Stable, never displayed: what the host and the module call this page. Renaming a label never + * changes it, and the module's navigation API reports it as the current page. */ + id: z.string().max(64).regex(PAGE_ID_PATTERN), + /** What a person reads in the sidebar. Plain text, trimmed and bounded. */ + label: z + .string() + .trim() + .min(1) + .max(MODULE_NAVIGATION_PAGE_LABEL_MAX_LENGTH) + .refine((label) => !CONTROL_CHARACTER_PATTERN.test(label), { + message: "A page label must not contain control characters.", + }), + path: z.string().refine(isModuleNavigationPagePath, { + message: + "A page path must be one to four lowercase kebab-case segments relative to the module root.", + }), + }) + .strict(); + +export const moduleNavigationDeclarationSchema = z + .object({ + /** The Lucide icon this module is drawn with. Omitted, the host draws its generic module icon. */ + icon: z + .custom((value) => isModuleNavigationIcon(value), { + message: "The navigation icon must be one of the Lucide names in MODULE_NAVIGATION_ICONS.", + }) + .optional(), + /** Primary pages, in the order the sidebar shows them. */ + pages: z + .array(moduleNavigationPageSchema) + .max(MODULE_NAVIGATION_PAGES_MAX) + .default(() => []), + }) + .strict() + .superRefine((navigation, context) => { + const ids = new Set(); + const paths = new Set(); + navigation.pages.forEach((page, index) => { + if (ids.has(page.id)) { + context.addIssue({ + code: "custom", + message: `${page.id} is declared more than once.`, + path: ["pages", index, "id"], + }); + } + ids.add(page.id); + if (paths.has(page.path)) { + context.addIssue({ + code: "custom", + message: "Two pages must not declare the same path.", + path: ["pages", index, "path"], + }); + } + paths.add(page.path); + }); + // A page beneath another would make "which page is open" ambiguous for every path under both, + // and the sidebar's list is flat. Deeper routes belong to a page, not to a second page. + navigation.pages.forEach((page, index) => { + if ( + navigation.pages.some((other) => other !== page && page.path.startsWith(`${other.path}/`)) + ) { + context.addIssue({ + code: "custom", + message: "A page path must not lie beneath another declared page's path.", + path: ["pages", index, "path"], + }); + } + }); + }); + +export type ModuleNavigationPage = Readonly>; + +/** A module's navigation as every consumer reads it, parsed or deeply frozen alike. */ +export interface ModuleNavigationDeclaration { + readonly icon?: ModuleNavigationIcon | undefined; + readonly pages: readonly ModuleNavigationPage[]; +} + +/** What a module declared, as every consumer reads it: no icon and no pages for a module that + * declares no navigation — every manifest-v8 module, and a v9 module that omits the field. */ +export const EMPTY_MODULE_NAVIGATION: ModuleNavigationDeclaration = Object.freeze({ + pages: Object.freeze([]), +}); diff --git a/packages/contracts/tests/fixtures/released-v8-signage-0.1.5.module.json b/packages/contracts/tests/fixtures/released-v8-signage-0.1.5.module.json new file mode 100644 index 0000000..7a32849 --- /dev/null +++ b/packages/contracts/tests/fixtures/released-v8-signage-0.1.5.module.json @@ -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" + } + } +} diff --git a/packages/contracts/tests/module-manifest.test.ts b/packages/contracts/tests/module-manifest.test.ts index 23b911f..ec9c73e 100644 --- a/packages/contracts/tests/module-manifest.test.ts +++ b/packages/contracts/tests/module-manifest.test.ts @@ -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", () => { diff --git a/packages/contracts/tests/module-navigation.test.ts b/packages/contracts/tests/module-navigation.test.ts new file mode 100644 index 0000000..194bc1f --- /dev/null +++ b/packages/contracts/tests/module-navigation.test.ts @@ -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 { + 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: "" }], + ["an icon as a URL", { icon: "https://cdn.example.test/icon.svg" }], + ["an icon as an object", { icon: { svg: "" } }], + ])("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; + 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); + }); +}); diff --git a/packages/module-sdk/src/index.ts b/packages/module-sdk/src/index.ts index 59c5a78..e02fd1d 100644 --- a/packages/module-sdk/src/index.ts +++ b/packages/module-sdk/src/index.ts @@ -60,6 +60,11 @@ export { type ModuleConfigurationValue, type ModuleHttpMethod, type ModuleManifest, + moduleManifestNavigation, + MODULE_NAVIGATION_ICONS, + type ModuleNavigationDeclaration, + type ModuleNavigationIcon, + type ModuleNavigationPage, type ModuleRealtimeChannel, type ModuleRealtimeChannelDefinitionInput, type ModuleRealtimeChannelRegistrar, diff --git a/packages/module-ui-host/tests/load-module-ui.test.ts b/packages/module-ui-host/tests/load-module-ui.test.ts index ef9535f..f9a8375 100644 --- a/packages/module-ui-host/tests/load-module-ui.test.ts +++ b/packages/module-ui-host/tests/load-module-ui.test.ts @@ -54,6 +54,7 @@ function hostContext(requested: string[], moduleId = "announcements"): ModuleUiH contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId, + navigation: undefined, api: { async request(path) { requested.push(path); diff --git a/packages/module-ui-host/tests/real-artifact.test.ts b/packages/module-ui-host/tests/real-artifact.test.ts index 0b1e391..9adc8f7 100644 --- a/packages/module-ui-host/tests/real-artifact.test.ts +++ b/packages/module-ui-host/tests/real-artifact.test.ts @@ -35,6 +35,7 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext { contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId: "example", + navigation: undefined, api: { async request(pathname) { requested.push(`/api/v1/modules/example/${pathname.replace(/^\//u, "")}`); @@ -53,6 +54,7 @@ function standaloneContext(requested: string[]): ModuleUiHostContext { contributions: NO_MODULE_UI_CONTRIBUTIONS, display: { url: "https://example.test/display" }, moduleId: "example", + navigation: undefined, api: { async request(pathname) { requested.push(`https://example.test/api/${pathname.replace(/^\//u, "")}`); diff --git a/packages/module-ui-sdk/src/host-context.ts b/packages/module-ui-sdk/src/host-context.ts index bb9aa74..3762462 100644 --- a/packages/module-ui-sdk/src/host-context.ts +++ b/packages/module-ui-sdk/src/host-context.ts @@ -1,4 +1,5 @@ import type { ModuleUiContributions } from "./contribution.js"; +import type { ModuleUiNavigation } from "./navigation.js"; import type { ModuleUiRealtimeClient } from "./realtime.js"; /** @@ -55,9 +56,10 @@ export type ModuleUiPrincipal = * Everything a host hands a module's UI, and the complete list of what module UI may assume exists. * * This is deliberately the smallest set that proves the architecture rather than everything a real - * screen will eventually want. Navigation, module configuration, organization/platform context, and - * diagnostics reporting are **not** here: each needs a design that does not exist, and a speculative - * field frozen into a public contract is harder to remove than to add. See ADR 0022. + * screen will eventually want. Module configuration, organization/platform context, and diagnostics + * reporting are **not** here: each needs a design that does not exist, and a speculative field + * frozen into a public contract is harder to remove than to add. See ADR 0022. Navigation arrived + * once its design did (ADR 0063). * * The host **theme** is deliberately absent for a different reason. Design tokens reach a module as * inherited CSS custom properties, not as data (ADR 0039): the host installs the values at its own @@ -102,6 +104,15 @@ export interface ModuleUiHostContext { * resolves `api.request`. */ readonly contributions: ModuleUiContributions; + /** + * Where this module is and how it moves between its own pages, and to its Settings page + * (ADR 0063). See {@link ModuleUiNavigation}. + * + * `undefined` where the host has no addressable pages for it — the full-viewport display host, + * which shows one screen, and any standalone host without routes. A module that offers + * navigation checks for it, exactly as it checks `display`. + */ + readonly navigation: ModuleUiNavigation | undefined; } export interface ModuleUiDisplayLocation { diff --git a/packages/module-ui-sdk/src/index.ts b/packages/module-ui-sdk/src/index.ts index f758388..0f64f92 100644 --- a/packages/module-ui-sdk/src/index.ts +++ b/packages/module-ui-sdk/src/index.ts @@ -3,4 +3,5 @@ export * from "./define-module-ui.js"; export * from "./host-context.js"; export * from "./json.js"; export * from "./module-ui.js"; +export * from "./navigation.js"; export * from "./realtime.js"; diff --git a/packages/module-ui-sdk/src/navigation.ts b/packages/module-ui-sdk/src/navigation.ts new file mode 100644 index 0000000..bbe9bfd --- /dev/null +++ b/packages/module-ui-sdk/src/navigation.ts @@ -0,0 +1,113 @@ +/** + * Where a mounted module UI is in the Dashboard, and how it moves (ADR 0063). + * + * A **host capability**, not a router. The host owns the address bar, the sidebar, and every + * platform route; a module owns the pages beneath its own root. So a module never touches + * `window.location` or `window.history`, never spells a platform route, and never builds another + * module's address: it names paths **relative to its own root**, and the host maps them to real, + * refreshable, bookmarkable Dashboard addresses — or refuses them. + * + * Paths: + * + * - `""` is the module's root, its landing view. + * - `"timelines"` is a declared page; `"media/sermons/123"` is a route **beneath** the declared page + * `media`, which a module may use for detail views without listing every record in its manifest. + * - A leading `/` means the same thing as none: `"/timelines"` is still this module's `timelines`. + * There is no way to name anything outside this module. + * - Segments are unreserved URL characters only (letters, digits, `-`, `.`, `_`, `~`), never `.` or + * `..`, never empty; no query, fragment, scheme, or percent-encoding. + * + * A path the host would not open — malformed, or outside the module's root and its declared pages — + * is refused with a {@link ModuleUiNavigationError} rather than silently opening something else. + */ +export interface ModuleUiNavigation { + /** Where this module is now. */ + location(): ModuleUiLocation; + /** Notifies after the location changes — a link, Back or Forward, or a refresh of the sidebar. + * Returns an unsubscribe. The module stays mounted while it moves between its own pages. */ + subscribe(listener: () => void): () => void; + /** + * Opens one of this module's own routes. `replace` corrects the current history entry instead of + * adding one — for a route the module just respelled, not for ordinary navigation. + * + * @throws {ModuleUiNavigationError} for a path this module may not open. + */ + navigate(path: string, options?: ModuleUiNavigateOptions): void; + /** + * A link to one of this module's own routes, for a real ``: `href` is the host's address for it, + * so the link can be opened in a new tab, copied, or bookmarked, and `onClick` opens it in place on + * an ordinary click while leaving a modified or middle click to the browser. + * + * @throws {ModuleUiNavigationError} for a path this module may not open. + */ + link(path: string): ModuleUiLink; + /** + * This module's page in the Dashboard's centralized Settings, or `undefined` when there is none for + * this viewer — the module declares no configuration, or the viewer may not change it. A + * presentation hint like {@link ModuleUiPermissions}: Settings enforces its own authority. + * + * Opening it carries the module's current location as the place to come back to, so Settings' + * Back returns exactly here. The host validates that destination; the module neither builds nor + * checks it. + */ + readonly settings: ModuleUiSettingsLocation | undefined; +} + +export interface ModuleUiLocation { + /** This module's route, relative to its root: `""`, `"timelines"`, `"media/sermons/123"`. Never a + * leading or trailing `/`. */ + readonly path: string; + /** The declared page that route is, or lies beneath; `undefined` at the module's root. */ + readonly page: string | undefined; +} + +export interface ModuleUiNavigateOptions { + readonly replace?: boolean; +} + +/** What a link's click handler reads: satisfied by a DOM `MouseEvent` and by React's synthetic one. */ +export interface ModuleUiLinkClickEvent { + readonly button: number; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly defaultPrevented: boolean; + preventDefault(): void; +} + +export interface ModuleUiLink { + readonly href: string; + onClick(event: ModuleUiLinkClickEvent): void; +} + +export interface ModuleUiSettingsLocation { + /** Opens this module's Settings page, remembering where the module was. */ + open(): void; + /** The same, as a link: its `href` includes the place to return to, so it also works in a new tab. */ + link(): ModuleUiLink; +} + +export type ModuleUiNavigationErrorCode = + /** Not a path this contract can express: absolute, traversing, malformed, or out of bounds. */ + | "MODULE_NAVIGATION_PATH_INVALID" + /** A well-formed path that is neither the module's root nor a declared page or beneath one. */ + | "MODULE_NAVIGATION_PATH_UNKNOWN"; + +const navigationErrorMessages: Readonly> = + Object.freeze({ + MODULE_NAVIGATION_PATH_INVALID: "A module asked to open a path outside its own routes.", + MODULE_NAVIGATION_PATH_UNKNOWN: "A module asked to open a page it does not declare.", + }); + +/** Carries a stable code and a fixed message — never the path the module asked for. */ +export class ModuleUiNavigationError extends Error { + readonly code: ModuleUiNavigationErrorCode; + + constructor(code: ModuleUiNavigationErrorCode) { + super(navigationErrorMessages[code]); + this.name = "ModuleUiNavigationError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + } +} diff --git a/packages/module-ui-sdk/tests/module-ui-contract.test.ts b/packages/module-ui-sdk/tests/module-ui-contract.test.ts index 525a646..1451d4d 100644 --- a/packages/module-ui-sdk/tests/module-ui-contract.test.ts +++ b/packages/module-ui-sdk/tests/module-ui-contract.test.ts @@ -188,6 +188,7 @@ describe("the same module UI artifact hosted by two different hosts", () => { contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId, + navigation: undefined, api, principal: { kind: "anonymous" }, permissions: { has: () => canPublish }, @@ -214,6 +215,7 @@ describe("the same module UI artifact hosted by two different hosts", () => { contributions: NO_MODULE_UI_CONTRIBUTIONS, display: { url: "https://announcements.example.org/display" }, moduleId, + navigation: undefined, api, principal: { kind: "anonymous" }, permissions: { has: () => canPublish }, diff --git a/packages/module-ui-sdk/tests/navigation.test.ts b/packages/module-ui-sdk/tests/navigation.test.ts new file mode 100644 index 0000000..6ee4fce --- /dev/null +++ b/packages/module-ui-sdk/tests/navigation.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + type ModuleUiHostContext, + type ModuleUiNavigation, + ModuleUiNavigationError, +} from "../src/index.js"; + +/** The navigation capability's public surface (ADR 0063). Its behaviour is the host's: the + * Dashboard's implementation is proven in `surfaces/shell/tests/module-navigation.test.tsx`. */ +describe("module UI navigation contract", () => { + it("reports a refused path with a stable code and a fixed message that never echoes the path", () => { + for (const code of [ + "MODULE_NAVIGATION_PATH_INVALID", + "MODULE_NAVIGATION_PATH_UNKNOWN", + ] as const) { + const error = new ModuleUiNavigationError(code); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe("ModuleUiNavigationError"); + expect(error.code).toBe(code); + expect(error.stack).toBe(`ModuleUiNavigationError: ${error.message}`); + expect(error.message).not.toMatch(/[/:?#]/u); + } + }); + + it("makes navigation a context member a host without pages leaves undefined", () => { + // A type-level contract: a context may say it has no navigation, as the display host does, and + // a module reads it through an optional check. + const describe = (context: Pick): string => + context.navigation?.location().path ?? "no navigation"; + const navigation: ModuleUiNavigation = { + link: () => ({ href: "/m/x", onClick: () => undefined }), + location: () => ({ page: "x", path: "x" }), + navigate: () => undefined, + settings: undefined, + subscribe: () => () => undefined, + }; + expect(describe({ navigation: undefined })).toBe("no navigation"); + expect(describe({ navigation })).toBe("x"); + }); +}); diff --git a/scripts/lib/deploy-smoke-browser.mjs b/scripts/lib/deploy-smoke-browser.mjs index 46c0cb3..3d74375 100644 --- a/scripts/lib/deploy-smoke-browser.mjs +++ b/scripts/lib/deploy-smoke-browser.mjs @@ -395,6 +395,185 @@ check( "widening the window brings the sidebar back on the same page", ); +// --- Module navigation (ADR 0063): pages in the sidebar, real routes, module → Settings → back --- +if (openedModuleId) { + const moduleId = openedModuleId; + const declared = + await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/dashboard/navigation`)}, { + credentials: "include", + }).then((response) => response.json()).then((navigation) => { + const entries = [...navigation.ungrouped, ...navigation.groups.flatMap((group) => group.modules)]; + return entries.find((entry) => entry.module.id === ${JSON.stringify(moduleId)})?.navigation ?? null; + })`); + const inModule = (inner) => + `document.querySelector("[data-ekklesia-module-ui]")?.shadowRoot?.querySelector(${JSON.stringify(inner)})`; + const moduleMounted = `${selector("module-container")}?.childElementCount > 0 && !${selector("module-error")}`; + const isCurrent = (testId) => `${selector(testId)}?.getAttribute("aria-current") === "page"`; + if (declared === null) { + check(false, "the navigation view describes the opened module"); + } else if (declared.pages.length === 0) { + // A manifest v8 module (or a v9 one with no pages): exactly as before. + await navigate(`${dashboardOrigin}/${moduleId}`); + check( + await waitFor( + `${moduleMounted} && ${isCurrent(`select-${moduleId}`)} && !${selector(`module-subnavigation-${moduleId}`)} && ${selector(`select-${moduleId}`)}?.querySelector("svg")?.dataset.icon === ${JSON.stringify(declared.icon === null ? "module" : `module:${declared.icon}`)}`, + "the single-page module with its icon and no sub-navigation", + ), + `${moduleId} declares no pages: one sidebar item with its icon, mounted at its root`, + ); + await navigate(`${dashboardOrigin}/${moduleId}/definitely-not-a-page`); + check( + await waitFor( + `${selector("host-not-found")} && !(${selector("module-container")}?.childElementCount > 0)`, + "not-found beneath a single-page module", + ), + `an address beneath ${moduleId}'s root is not found, and mounts nothing`, + ); + } else { + const [first, second] = declared.pages; + const pagePath = (page) => `/${moduleId}/${page.path}`; + await navigate(`${dashboardOrigin}/${moduleId}`); + check( + await waitFor( + `${moduleMounted} && ${isCurrent(`select-${moduleId}`)} && ${selector(`module-subnavigation-${moduleId}`)}?.querySelectorAll("a").length === ${declared.pages.length} && ${selector(`select-${moduleId}`)}?.querySelector("svg")?.dataset.icon === ${JSON.stringify(declared.icon === null ? "module" : `module:${declared.icon}`)}`, + "the module's pages beneath it in the sidebar", + ), + `${moduleId}'s declared icon and ${declared.pages.length} pages appear beneath it while it is open`, + ); + await evaluate( + `window.__ekklesiaModuleRoot = document.querySelector("[data-ekklesia-module-ui]")`, + ); + await evaluate(`${selector(`select-${moduleId}-${first.id}`)}.click()`); + check( + await waitFor( + `location.pathname === ${JSON.stringify(pagePath(first))} && ${isCurrent(`select-${moduleId}-${first.id}`)} && document.querySelector("[data-ekklesia-module-ui]") === window.__ekklesiaModuleRoot`, + "the first page, without remounting the module", + ), + `choosing ${first.label} opens ${pagePath(first)} in place, marks it, and keeps the module mounted`, + ); + if (second) { + await navigate(`${dashboardOrigin}${pagePath(second)}`); + check( + await waitFor( + `location.pathname === ${JSON.stringify(pagePath(second))} && ${moduleMounted} && ${isCurrent(`select-${moduleId}-${second.id}`)}`, + "a declared page from its deep link", + ), + `a deep link to ${pagePath(second)} opens that page (what a refresh or bookmark does)`, + ); + await evaluate("history.back()"); + check( + await waitFor( + `location.pathname === ${JSON.stringify(pagePath(first))} && ${isCurrent(`select-${moduleId}-${first.id}`)} && ${moduleMounted}`, + "Back to the previous page", + ), + "Back returns to the previously open page", + ); + await evaluate("history.forward()"); + check( + await waitFor( + `location.pathname === ${JSON.stringify(pagePath(second))} && ${isCurrent(`select-${moduleId}-${second.id}`)}`, + "Forward to the next page", + ), + "Forward returns to it again", + ); + } + await navigate(`${dashboardOrigin}/${moduleId}/definitely-not-a-page`); + check( + await waitFor( + `${selector("host-not-found")} && !(${selector("module-container")}?.childElementCount > 0)`, + "not-found for an undeclared page", + ), + `an undeclared page under ${moduleId} is not found, and mounts nothing`, + ); + if (moduleId === "example") { + // The template's own pages: a detail route beneath Records, and its Settings link. + await navigate(`${dashboardOrigin}/example/records`); + await waitFor( + inModule('[data-testid^="example-record-link-"]'), + "a record link inside the module", + ); + await evaluate(`${inModule('[data-testid^="example-record-link-"]')}.click()`); + check( + await waitFor( + `location.pathname.startsWith("/example/records/") && ${isCurrent("select-example-records")} && ${inModule('[data-testid="example-record-value"]')}`, + "the record's detail route", + ), + "a detail route beneath a declared page opens inside the module, its page marked", + ); + await navigate(`${dashboardOrigin}/example`); + await waitFor( + inModule('[data-testid="example-open-settings"]'), + "the module's Settings button", + ); + await evaluate(`${inModule('[data-testid="example-open-settings"]')}.click()`); + check( + await waitFor( + `location.pathname === "/settings/example" && location.search === "?return=%2Fexample" && ${selector("module-settings")}`, + "the module's Settings page, carrying where it came from", + ), + "the module opens its centralized Settings page through its navigation capability", + ); + } + // Settings → Back to exactly the module page that sent the person there, across a refresh. + await navigate( + `${dashboardOrigin}/settings/${moduleId}?return=${encodeURIComponent(pagePath(first))}`, + ); + check( + await waitFor( + `${selector("module-settings")}?.querySelector(".ek-page-header__back")?.getAttribute("href") === ${JSON.stringify(pagePath(first))}`, + "Back to the originating module page", + ), + "after a refresh, Settings' Back still names the exact module page it was opened from", + ); + await evaluate(`${selector("module-settings")}.querySelector(".ek-page-header__back").click()`); + check( + await waitFor( + `location.pathname === ${JSON.stringify(pagePath(first))} && ${moduleMounted} && ${isCurrent(`select-${moduleId}-${first.id}`)}`, + "the originating module page", + ), + `Back from Settings returns to exactly ${pagePath(first)}`, + ); + await navigate( + `${dashboardOrigin}/settings/${moduleId}?return=${encodeURIComponent("https://evil.example.test/")}`, + ); + check( + await waitFor( + `${selector("module-settings")}?.querySelector(".ek-page-header__back")?.getAttribute("href") === "/settings"`, + "an unsafe return target ignored", + ), + "an off-site return target is ignored: Back goes to Settings", + ); + // The phone drawer: the identical hierarchy, closing once a page is chosen. + await send("Emulation.setDeviceMetricsOverride", { + deviceScaleFactor: 2, + height: 844, + mobile: true, + width: 390, + }); + await navigate(`${dashboardOrigin}/${moduleId}`); + await waitFor(selector("mobile-navigation-trigger"), "the phone navigation trigger"); + await evaluate(`${selector("mobile-navigation-trigger")}.click()`); + await waitFor( + `${selector("navigation-drawer")}?.querySelector('[data-testid="module-subnavigation-${moduleId}"]')`, + "the module's pages in the drawer", + ); + await evaluate(`${selector(`select-${moduleId}-${first.id}`)}.click()`); + check( + await waitFor( + `location.pathname === ${JSON.stringify(pagePath(first))} && !${selector("navigation-drawer")} && ${moduleMounted}`, + "a page chosen from the drawer", + ), + "the phone drawer shows the module's pages, and closes once one is chosen", + ); + await send("Emulation.setDeviceMetricsOverride", { + deviceScaleFactor: 1, + height: 1080, + mobile: false, + width: 1920, + }); + } +} + // --- Settings (ADR 0061): a deep link, a refresh, Discover, a module's settings, the drawer ------- await navigate(`${dashboardOrigin}/settings`); check( diff --git a/surfaces/shell/package.json b/surfaces/shell/package.json index 38d3bcd..69e6587 100644 --- a/surfaces/shell/package.json +++ b/surfaces/shell/package.json @@ -23,6 +23,10 @@ "./display-assets": { "types": "./dist/display-assets.d.ts", "import": "./dist/display-assets.js" + }, + "./module-icons": { + "types": "./dist/module-icons.d.ts", + "import": "./dist/module-icons.js" } }, "scripts": { diff --git a/surfaces/shell/src/browser/DashboardLink.tsx b/surfaces/shell/src/browser/DashboardLink.tsx index 7a14be8..14a2133 100644 --- a/surfaces/shell/src/browser/DashboardLink.tsx +++ b/surfaces/shell/src/browser/DashboardLink.tsx @@ -3,7 +3,6 @@ import type { CSSProperties, FocusEventHandler, KeyboardEventHandler, - MouseEvent, MouseEventHandler, ReactElement, ReactNode, @@ -57,7 +56,14 @@ const currentStyle: CSSProperties = { }; /** A click the browser should handle itself: a new tab or window, a download, or another button. */ -function isBrowserHandledClick(event: MouseEvent): boolean { +export function isBrowserHandledClick(event: { + readonly defaultPrevented: boolean; + readonly button: number; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; +}): boolean { return ( event.defaultPrevented || event.button !== 0 || diff --git a/surfaces/shell/src/browser/HostApp.tsx b/surfaces/shell/src/browser/HostApp.tsx index b7705fd..88803ed 100644 --- a/surfaces/shell/src/browser/HostApp.tsx +++ b/surfaces/shell/src/browser/HostApp.tsx @@ -20,7 +20,14 @@ import { signInWithEmail, signOutHost, } from "../auth-protocol.js"; -import { type DashboardRoute, dashboardPath, parseDashboardPath } from "../dashboard-routes.js"; +import { + type DashboardRoute, + dashboardPath, + parseDashboardPath, + returnTargetFromSearch, +} from "../dashboard-routes.js"; +import { resolveModuleRoute } from "../module-navigation.js"; +import { parseReturnTarget } from "../return-target.js"; import { bootstrapHost, type DashboardNavigationEntry, @@ -42,6 +49,7 @@ import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js import { ModuleUnavailablePage, NotFoundPage, OverviewPage } from "./HostPages.js"; import { LoginForm } from "./LoginForm.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js"; +import { createModuleUiNavigation } from "./module-ui-navigation.js"; import { ResetPasswordForm } from "./ResetPasswordForm.js"; import { ShellLayout } from "./ShellLayout.js"; import { resolveSettingsPage, type SettingsPage, settingsPageTitle } from "./settings-catalog.js"; @@ -51,6 +59,7 @@ import { SettingsHomePage, SettingsLoadFailedPage, SettingsSectionPage, + type SettingsReturn, SettingsUnavailablePage, } from "./SettingsPages.js"; import { createMemorySidebarPreference, type SidebarPreference } from "./sidebar-preference.js"; @@ -194,19 +203,20 @@ function SettingsPageView(props: { readonly page: SettingsPage; readonly history: HostHistory; readonly onRetry: () => void; + readonly returnTo: SettingsReturn; }): ReactElement { - const { page, history } = props; + const { page, history, returnTo } = props; switch (page.kind) { case "home": return ; case "section": - return ; + return ; case "discover": - return ; + return ; case "module": - return ; + return ; case "unavailable": - return ; + return ; case "load-failed": return ; case "not-found": @@ -220,6 +230,74 @@ function routeKey(route: DashboardRoute): string { return route.kind === "not-found" ? "not-found" : dashboardPath(route); } +/** + * A `?return=` value the Settings pages may use (ADR 0063), or `undefined`. `parseReturnTarget` + * accepts only a canonical same-origin Dashboard address; a module's address is additionally checked + * against the modules actually running here and the routes each declares, so Back never points at a + * module that is not running or at a page it does not have. + */ +function usableReturnTarget( + raw: string | undefined, + entries: readonly DashboardNavigationEntry[], +): string | undefined { + const destination = parseReturnTarget(raw); + if (destination === undefined) { + return undefined; + } + if (destination.kind !== "module") { + return raw; + } + const entry = entries.find((candidate) => candidate.descriptor.id === destination.moduleId); + return entry !== undefined && resolveModuleRoute(entry.pages, destination.path) !== undefined + ? raw + : undefined; +} + +/** + * One mounted module UI and the navigation capability it is handed (ADR 0063). The capability is + * created once per mount and reads the address bar live, so it is the same object for the module's + * whole life however often it moves between its pages, and the frame never remounts because of it. + */ +function MountedModule(props: { + readonly bootstrap: HostBootstrap; + readonly config: EkklesiaHostConfig; + readonly entry: DashboardNavigationEntry; + readonly history: HostHistory; + readonly importModule?: ModuleUiImporter; + readonly onAuthorizationResponse: (status: 401 | 403) => void; +}): ReactElement { + const { bootstrap, entry, history } = props; + const settingsRef = useRef(bootstrap.settings); + settingsRef.current = bootstrap.settings; + const moduleId = entry.descriptor.id; + const navigation = useMemo( + () => + createModuleUiNavigation({ + hasSettings: () => { + const settings = settingsRef.current; + return ( + settings !== "unavailable" && + settings.modules.some((module) => module.id === moduleId && module.available) + ); + }, + history, + moduleId, + pages: entry.pages, + }), + [entry.pages, history, moduleId], + ); + return ( + + ); +} + function HostMessage(props: { readonly children: string; readonly testId: string; @@ -299,8 +377,11 @@ export function HostApp(props: HostAppProps): ReactElement { const sidebarPreference = props.sidebarPreference ?? fallbackPreference; const appearance = props.appearance ?? fallbackAppearance; const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]); - const readPathname = useCallback(() => history.pathname(), [history]); - const pathname = useSyncExternalStore(subscribe, readPathname, readPathname); + // The whole address, so a change of query alone (a new place to return to) re-renders too. + const readAddress = useCallback(() => `${history.pathname()}${history.search()}`, [history]); + const address = useSyncExternalStore(subscribe, readAddress, readAddress); + const pathname = address.split("?")[0] ?? "/"; + const search = address.slice(pathname.length); const route = useMemo(() => parseDashboardPath(pathname), [pathname]); // Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign // in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter @@ -364,9 +445,11 @@ export function HostApp(props: HostAppProps): ReactElement { // history entry, so what is bookmarked or shared next is the one canonical address. useEffect(() => { if (route.kind !== "not-found" && dashboardPath(route) !== pathname) { - history.replace(dashboardPath(route)); + // The query is kept: it is where to return to (ADR 0063), and respelling the path must not + // lose it. + history.replace(`${dashboardPath(route)}${search}`); } - }, [history, pathname, route]); + }, [history, pathname, route, search]); useEffect(() => { mountedRef.current = true; @@ -522,6 +605,12 @@ export function HostApp(props: HostAppProps): ReactElement { state.status === "ready" && route.kind === "module" ? allEntries(state.bootstrap).find((entry) => entry.descriptor.id === route.moduleId) : undefined; + // Which of the open module's own routes this is (ADR 0063): its root, a declared page, or a route + // beneath one. Anything else is the host's not-found, and the module is not mounted for it. + const openModuleRoute = + openEntry !== undefined && route.kind === "module" + ? resolveModuleRoute(openEntry.pages, route.path) + : undefined; const settingsPage = state.status === "ready" && (route.kind === "settings" || @@ -542,7 +631,13 @@ export function HostApp(props: HostAppProps): ReactElement { : route.kind === "overview" ? "Overview" : route.kind === "module" - ? (openEntry?.name ?? "This page isn’t available") + ? openEntry === undefined + ? "This page isn’t available" + : openModuleRoute === undefined + ? "Page not found" + : openModuleRoute.page === undefined + ? openEntry.name + : `${openModuleRoute.page.label} · ${openEntry.name}` : route.kind === "account" ? "Account" : route.kind === "account-section" @@ -675,7 +770,16 @@ export function HostApp(props: HostAppProps): ReactElement { viewport={viewport} > {settingsPage !== undefined ? ( - + + allEntries(bootstrap).find((entry) => entry.descriptor.id === moduleId)?.name, + target: usableReturnTarget(returnTargetFromSearch(search), allEntries(bootstrap)), + }} + /> ) : route.kind === "account" || route.kind === "account-section" ? ( bootstrap.account === "unavailable" ? ( @@ -698,17 +802,22 @@ export function HostApp(props: HostAppProps): ReactElement { ) : route.kind === "overview" ? ( ) : route.kind === "module" ? ( - openEntry ? ( - + ) : openModuleRoute === undefined ? ( + + ) : ( + - ) : ( - ) ) : ( diff --git a/surfaces/shell/src/browser/ModuleUiFrame.tsx b/surfaces/shell/src/browser/ModuleUiFrame.tsx index 80b175c..87c42fe 100644 --- a/surfaces/shell/src/browser/ModuleUiFrame.tsx +++ b/surfaces/shell/src/browser/ModuleUiFrame.tsx @@ -1,6 +1,6 @@ import { designToken } from "@ekklesia/design-tokens"; import { loadModuleUi, type ModuleUiImporter } from "@ekklesia/module-ui-host"; -import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk"; +import type { ModuleUiNavigation, ModuleUiPrincipal } from "@ekklesia/module-ui-sdk"; import { type ReactElement, useEffect, useRef, useState } from "react"; import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../host-protocol.js"; import { createModuleUiContext, moduleUiSourceUrl } from "../module-context.js"; @@ -11,6 +11,9 @@ export interface ModuleUiFrameProps { readonly config: EkklesiaHostConfig; readonly descriptor: HostModuleUiDescriptor; readonly principal: ModuleUiPrincipal; + /** The module's navigation capability (ADR 0063). Must be stable for the module's life: a new one + * remounts the module. */ + readonly navigation?: ModuleUiNavigation; /** Host-internal session signal. It never enters the module-facing context. */ readonly onAuthorizationResponse?: (status: 401 | 403) => void; /** Test-only seam, forwarded to the generic loader so a test can hold an import pending and prove @@ -52,7 +55,8 @@ export interface ModuleUiFrameProps { export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement { const containerRef = useRef(null); const [status, setStatus] = useState("loading"); - const { config, descriptor, importModule, onAuthorizationResponse, principal } = props; + const { config, descriptor, importModule, navigation, onAuthorizationResponse, principal } = + props; useEffect(() => { const container = containerRef.current; @@ -81,6 +85,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement { descriptor, principal, ...(importModule ? { importModule } : {}), + ...(navigation ? { navigation } : {}), ...(onAuthorizationResponse ? { onAuthorizationResponse: reportAuthorizationResponse } : {}), @@ -114,7 +119,7 @@ export function ModuleUiFrame(props: ModuleUiFrameProps): ReactElement { // already in progress, and a module that fails to clean up must not take the shell with it. void mounted?.unmount().catch(() => undefined); }; - }, [config, descriptor, importModule, onAuthorizationResponse, principal]); + }, [config, descriptor, importModule, navigation, onAuthorizationResponse, principal]); return (
string | undefined; +} + +/** Back plus title for a page inside Settings (DESIGN.md §4.8). Back goes to Settings — or, when a + * module opened this page, to exactly the page it was opened from, if that is a real Dashboard page. + * Never the browser's own `history.back()`. */ +function settingsBack(history: HostHistory, returnTo?: SettingsReturn) { return dashboardBack(history, { fallback: BACK_TO_SETTINGS, - label: (destination) => (destination.kind === "settings" ? "Back to Settings" : "Back"), - returnTarget, + label: (destination) => { + if (destination.kind === "settings") { + return "Back to Settings"; + } + const name = + destination.kind === "module" ? returnTo?.moduleName(destination.moduleId) : undefined; + return name === undefined ? "Back" : `Back to ${name}`; + }, + returnTarget: returnTo?.target, }); } @@ -147,6 +165,7 @@ const SECTION_NOT_YET: Readonly> = Object.fr export function DiscoverPage(props: { readonly history: HostHistory; readonly view: DiscoverView; + readonly returnTo?: SettingsReturn; }): ReactElement { const copy = DISCOVER_VIEW_COPY[props.view]; return ( @@ -155,7 +174,7 @@ export function DiscoverPage(props: { style={{ display: "grid", gap: designToken("spacing.xl") }} > @@ -186,15 +206,17 @@ export function SettingsSectionPage(props: { ); } -/** `/settings/`: a running module's settings, not built yet. */ +/** `/settings/`: a running module's settings, not built yet. A module opens it through its + * navigation capability, which carries where it was so Back returns exactly there (ADR 0063). */ export function ModuleSettingsPage(props: { readonly history: HostHistory; readonly module: DashboardSettingsModule; + readonly returnTo?: SettingsReturn; }): ReactElement { return (
- + {props.icon === undefined ? undefined : typeof props.icon === "string" ? ( + + ) : ( + props.icon + )}
a personal settings page: appearance - * /[/…] a module's page, and later its own sub-pages + * / a module's landing view + * // one of its declared pages, or a route beneath one (ADR 0063) * ``` * * The server serves the page at `/` only for a module that is actually installed with a @@ -31,6 +32,15 @@ */ export const DASHBOARD_PLATFORM_PAGES = Object.freeze(["settings", "account"] as const); +/** + * The host's top-level namespaces that are **not** Dashboard pages: the API, served assets, and the + * display host. No address beneath them is ever a Dashboard page or module route, so nothing — a + * typed URL, a link, or a return target (ADR 0063) — can make the Dashboard treat `/api/…` as a page + * to open or link to. Together with {@link DASHBOARD_PLATFORM_PAGES} these are exactly Core's + * reserved top-level names (`RESERVED_TOP_LEVEL_NAMES`); `apps/api` tests hold the lists together. + */ +export const HOST_NAMESPACES = Object.freeze(["api", "assets", "display"] as const); + /** * The platform's own Settings sections, in their navigation order (ADR 0061). Every other name under * `/settings/` is a module's settings, at `/settings/`. Module lifecycle — finding, @@ -72,7 +82,10 @@ export type DiscoverView = (typeof DISCOVER_VIEWS)[number]; /** What a Dashboard path means. A path the shell does not understand is `not-found`, never a guess. */ export type DashboardRoute = | { readonly kind: "overview" } - | { readonly kind: "module"; readonly moduleId: string } + /** `path` is relative to the module's root: `""` for the landing view, `"timelines"`, + * `"media/sermons/123"`. Whether the module has such a page is the module's declaration's to say, + * so the shell resolves it against the navigation, not here (ADR 0063). */ + | { readonly kind: "module"; readonly moduleId: string; readonly path: string } | { readonly kind: "settings" } | { readonly kind: "settings-section"; readonly section: SettingsPageSection } | { readonly kind: "discover"; readonly view: DiscoverView } @@ -85,6 +98,7 @@ export type DashboardRoute = export type DashboardDestination = Exclude; const platformPages: ReadonlySet = new Set(DASHBOARD_PLATFORM_PAGES); +const hostNamespaces: ReadonlySet = new Set(HOST_NAMESPACES); const settingsSections: ReadonlySet = new Set(SETTINGS_SECTIONS); const discoverViews: ReadonlySet = new Set(DISCOVER_VIEWS); @@ -102,6 +116,40 @@ function isAccountSection(value: string): value is AccountSection { return accountSections.has(value); } +/** The most segments, and characters, a module route may have beneath its root. Generous for detail + * routes (`media/sermons/123`), bounded so a URL cannot carry anything else. */ +export const MODULE_ROUTE_MAX_SEGMENTS = 8; +export const MODULE_ROUTE_MAX_LENGTH = 512; +const MODULE_ROUTE_SEGMENT_PATTERN = /^[A-Za-z0-9._~-]+$/u; + +/** + * Reads a module-relative route (ADR 0063): `""` for the root, otherwise segments of unreserved URL + * characters joined by `/`, none of them `.` or `..`. One leading `/` is the root's own spelling and + * is dropped; nothing else is normalized. Anything else — a scheme, a query, a fragment, an empty + * segment, percent-encoding, a backslash, traversal — is `undefined`, so a route can only ever name a + * place inside its own module. + */ +export function parseModuleRoute(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const path = value.startsWith("/") ? value.slice(1) : value; + if (path === "") { + return ""; + } + if (path.length > MODULE_ROUTE_MAX_LENGTH) { + return undefined; + } + const segments = path.split("/"); + return segments.length <= MODULE_ROUTE_MAX_SEGMENTS && + segments.every( + (segment) => + segment !== "." && segment !== ".." && MODULE_ROUTE_SEGMENT_PATTERN.test(segment), + ) + ? path + : undefined; +} + function decodeSegment(segment: string): string | undefined { try { const decoded = decodeURIComponent(segment); @@ -118,9 +166,10 @@ function decodeSegment(segment: string): string | undefined { * decoded here, never validated against a pattern: the shell compares it with the navigation the * server resolved, and an id that matches no entry is an unavailable module, not a malformed URL. * - * Everything below `//` belongs to that module's own pages (`/signage/timelines`), which - * need a contract that does not exist yet, so it is `not-found` for now rather than silently showing - * the module's first page. Settings and Account pages are likewise `not-found` until they exist. One + * Everything below `//` belongs to that module's own pages (`/signage/timelines`, ADR + * 0063): read here as a module-relative route, and resolved by the shell against the pages the module + * declares, so a route no page owns is `not-found` there rather than silently showing the module's + * first page. Settings and Account pages beyond those that exist are `not-found` too. One * trailing slash is tolerated, as a person typing a URL expects; the shell then replaces it with the * canonical spelling. */ @@ -140,13 +189,17 @@ export function parseDashboardPath(pathname: string): DashboardRoute { if (first === "account") { return parseAccountPath(meaningful.slice(1)); } - if (first === undefined || platformPages.has(first)) { + if (first === undefined || platformPages.has(first) || hostNamespaces.has(first)) { return { kind: "not-found" }; } - if (meaningful.length === 1) { - return { kind: "module", moduleId: first }; + const rest = meaningful.slice(1); + // `/signage//` is not `/signage/`: only one trailing slash is tolerated, and an empty segment + // anywhere beneath a module is not a route. + if (rest.some((segment) => segment === "")) { + return { kind: "not-found" }; } - return { kind: "not-found" }; + const path = parseModuleRoute(rest.join("/")); + return path === undefined ? { kind: "not-found" } : { kind: "module", moduleId: first, path }; } /** @@ -200,7 +253,9 @@ export function dashboardPath(route: DashboardDestination): string { case "overview": return "/"; case "module": - return `/${encodeURIComponent(route.moduleId)}`; + return route.path === "" + ? `/${encodeURIComponent(route.moduleId)}` + : `/${encodeURIComponent(route.moduleId)}/${route.path}`; case "settings": return "/settings"; case "settings-section": @@ -215,3 +270,30 @@ export function dashboardPath(route: DashboardDestination): string { return `/account/${route.section}`; } } + +/** + * The one carrier for "where to come back to" (ADR 0063): a `return` query parameter on the page + * that was opened, holding the Dashboard address of the page that opened it — + * `/settings/signage?return=%2Fsignage%2Ftimelines`. A query rather than history state, so it + * survives a refresh and a link opened in a new tab; it is only ever read through + * `parseReturnTarget`, so nothing but a real same-origin Dashboard page is ever used from it. + */ +export const RETURN_TARGET_PARAMETER = "return"; + +/** `destination`'s address, carrying `returnTo` as the place to come back to. */ +export function dashboardPathReturningTo( + destination: DashboardDestination, + returnTo: DashboardDestination, +): string { + const query = new URLSearchParams({ [RETURN_TARGET_PARAMETER]: dashboardPath(returnTo) }); + return `${dashboardPath(destination)}?${query.toString()}`; +} + +/** The raw, unvalidated return target in a query string, for `parseReturnTarget` to judge. */ +export function returnTargetFromSearch(search: string): string | undefined { + try { + return new URLSearchParams(search).get(RETURN_TARGET_PARAMETER) ?? undefined; + } catch { + return undefined; + } +} diff --git a/surfaces/shell/src/host-protocol.ts b/surfaces/shell/src/host-protocol.ts index 829c1e5..aef4961 100644 --- a/surfaces/shell/src/host-protocol.ts +++ b/surfaces/shell/src/host-protocol.ts @@ -6,6 +6,11 @@ import { } from "@ekklesia/design-tokens"; import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk"; import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js"; +import { + type DashboardModulePage, + isDashboardModulePage, + isModuleIconName, +} from "./module-navigation.js"; /** * How this shell reaches the Ekklesia API. @@ -640,6 +645,11 @@ export function moduleDisplayDescriptorPath(moduleId: string): string { export interface DashboardNavigationEntry { readonly name: string; readonly descriptor: HostModuleUiDescriptor; + /** The Lucide name the module declared (manifest v9, ADR 0063), or `undefined` for the generic + * module icon. */ + readonly icon: string | undefined; + /** Its primary pages, in its declared order; empty for a single-page module. */ + readonly pages: readonly DashboardModulePage[]; } export interface DashboardNavigationGroup { @@ -662,12 +672,48 @@ export interface DashboardNavigation { readonly ungrouped: readonly DashboardNavigationEntry[]; } -function parseNavigationEntry(entry: unknown): DashboardNavigationEntry { - const name = nestedString(entry, "name"); - if (name === undefined || !isRecord(entry)) { +/** Strict: the module's declared icon (`null` for none) and pages, each in the manifest's grammar, + * with no repeated page id or path. */ +function parseModuleNavigation(value: unknown): { + readonly icon: string | undefined; + readonly pages: readonly DashboardModulePage[]; +} { + if ( + !hasExactKeys(value, ["icon", "pages"]) || + (value.icon !== null && !isModuleIconName(value.icon)) || + !Array.isArray(value.pages) || + value.pages.length > 12 || + !value.pages.every(isDashboardModulePage) + ) { throw new HostProtocolError("HOST_RESPONSE_INVALID"); } - return Object.freeze({ descriptor: parseDescriptor(entry.module), name }); + const pages = value.pages as readonly DashboardModulePage[]; + if ( + new Set(pages.map((page) => page.id)).size !== pages.length || + new Set(pages.map((page) => page.path)).size !== pages.length + ) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return Object.freeze({ + icon: value.icon === null ? undefined : (value.icon as string), + pages: Object.freeze( + pages.map((page) => Object.freeze({ id: page.id, label: page.label, path: page.path })), + ), + }); +} + +function parseNavigationEntry(entry: unknown): DashboardNavigationEntry { + const name = nestedString(entry, "name"); + if (name === undefined || !hasExactKeys(entry, ["module", "name", "navigation"])) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + const navigation = parseModuleNavigation(entry.navigation); + return Object.freeze({ + descriptor: parseDescriptor(entry.module), + icon: navigation.icon, + name, + pages: navigation.pages, + }); } /** Strict, total validation, exactly like the inventory parser: a malformed group or entry fails diff --git a/surfaces/shell/src/module-context.ts b/surfaces/shell/src/module-context.ts index 26c17fa..a904867 100644 --- a/surfaces/shell/src/module-context.ts +++ b/surfaces/shell/src/module-context.ts @@ -5,6 +5,7 @@ import { type ModuleUiApiClient, type ModuleUiContributions, type ModuleUiHostContext, + type ModuleUiNavigation, type ModuleUiPrincipal, type ModuleUiRealtimeClient, type ModuleUiRealtimeError, @@ -272,6 +273,9 @@ export interface CreateModuleUiContextOptions { /** Test-only seam, forwarded to the generic contribution loader so a test can drive a contributed * renderer without a real network. Production composition leaves it unset. */ readonly importModule?: (url: string) => Promise; + /** The module's navigation capability (ADR 0063), from a host whose pages are addressable — the + * Dashboard. Omitted, the context carries `navigation: undefined`. */ + readonly navigation?: ModuleUiNavigation; } /** @@ -356,6 +360,9 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo : Object.freeze({ url: resolveApiUrl(options.config, options.descriptor.displayPageUrl) }), moduleId: options.descriptor.id, permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }), + // Only a host with addressable module pages supplies navigation (ADR 0063); the display host + // shows one screen and supplies none. + navigation: options.navigation, principal: options.principal, realtime: createModuleRealtimeClient(options.config, options.descriptor), }); diff --git a/surfaces/shell/src/module-icons.ts b/surfaces/shell/src/module-icons.ts new file mode 100644 index 0000000..58f5637 --- /dev/null +++ b/surfaces/shell/src/module-icons.ts @@ -0,0 +1,115 @@ +/** + * The Lucide icons the Dashboard can draw for a module (manifest v9, ADR 0063), by Lucide name. + * + * The same curated set `@ekklesia/contracts` validates manifests against (`MODULE_NAVIGATION_ICONS`). + * A surface may not import that package — its type graph is the server's (ADR 0024) — so the list is + * repeated here, and `apps/api` tests hold the two equal: the platform cannot accept an icon the + * Dashboard cannot draw. The browser's map from these names to Lucide components is + * `browser/module-icon.tsx`, which imports exactly these icons and no others. + * + * Framework-free and exported on its own subpath, so the server's parity test reads the list without + * importing React. + */ +export const DASHBOARD_MODULE_ICON_NAMES = Object.freeze([ + "activity", + "archive", + "baby", + "bell", + "book-open", + "book-open-text", + "bookmark", + "briefcase", + "building", + "bus", + "calendar", + "calendar-check", + "calendar-days", + "camera", + "car", + "chart-column", + "chart-pie", + "church", + "clipboard-check", + "clipboard-list", + "clock", + "coffee", + "contact", + "credit-card", + "database", + "door-open", + "file-text", + "files", + "film", + "flag", + "folder", + "gift", + "globe", + "graduation-cap", + "hand-coins", + "hand-heart", + "handshake", + "headphones", + "heart", + "heart-handshake", + "house", + "image", + "images", + "inbox", + "key-round", + "landmark", + "layout-grid", + "library", + "list-checks", + "list-todo", + "mail", + "map", + "map-pin", + "megaphone", + "message-circle", + "message-square", + "mic", + "monitor", + "monitor-play", + "music", + "newspaper", + "notebook", + "package", + "palette", + "party-popper", + "phone", + "presentation", + "printer", + "projector", + "radio", + "receipt", + "school", + "send", + "shield", + "shopping-bag", + "sparkles", + "star", + "sun", + "tablet-smartphone", + "tag", + "tent", + "ticket", + "trophy", + "truck", + "tv", + "user-check", + "user-round", + "users", + "users-round", + "utensils", + "video", + "wallet", + "wrench", +] as const); + +export type DashboardModuleIconName = (typeof DASHBOARD_MODULE_ICON_NAMES)[number]; + +const iconNames: ReadonlySet = new Set(DASHBOARD_MODULE_ICON_NAMES); + +export function isDashboardModuleIconName(value: unknown): value is DashboardModuleIconName { + return typeof value === "string" && iconNames.has(value); +} diff --git a/surfaces/shell/src/module-navigation.ts b/surfaces/shell/src/module-navigation.ts new file mode 100644 index 0000000..bf6a206 --- /dev/null +++ b/surfaces/shell/src/module-navigation.ts @@ -0,0 +1,99 @@ +import { type DashboardDestination, parseModuleRoute } from "./dashboard-routes.js"; + +/** + * Resolving a module's own routes (ADR 0063), free of React and browser globals. + * + * A module declares its primary pages; the shell opens: + * + * - its **root**, `/`, which is its landing view and never itself a declared page, so no + * page has two addresses; + * - each **declared page**, `//`; + * - any route **beneath** a declared page, `///…`, which the module owns — + * detail views (`media/sermons/123`) need no manifest entry and no sidebar entry. + * + * Everything else under a module is the host's `not-found`, and the module is not mounted for it: + * an undeclared path never silently opens some other page. A module that declares no pages — every + * manifest v8 module — has its root and nothing else, exactly as before. + */ + +export interface DashboardModulePage { + readonly id: string; + readonly label: string; + /** Relative to the module root: `timelines`, `media/library`. */ + readonly path: string; +} + +/** Where a route lands inside its module: the declared page it is or lies beneath, `undefined` at the + * root. `undefined` as a whole means the module has no such route. */ +export interface ModuleRouteResolution { + readonly page: DashboardModulePage | undefined; +} + +export function resolveModuleRoute( + pages: readonly DashboardModulePage[], + path: string, +): ModuleRouteResolution | undefined { + if (path === "") { + return { page: undefined }; + } + const page = pages.find( + (candidate) => path === candidate.path || path.startsWith(`${candidate.path}/`), + ); + return page === undefined ? undefined : { page }; +} + +/** `path` as one of this module's routes the shell would open, or `undefined`. Accepts the same + * spellings `parseModuleRoute` does: an optional leading `/`, unreserved segments, no traversal. */ +export function moduleRouteFor( + pages: readonly DashboardModulePage[], + path: unknown, +): { readonly path: string; readonly resolution: ModuleRouteResolution } | "invalid" | "unknown" { + const route = parseModuleRoute(path); + if (route === undefined) { + return "invalid"; + } + const resolution = resolveModuleRoute(pages, route); + return resolution === undefined ? "unknown" : { path: route, resolution }; +} + +export function moduleDestination(moduleId: string, path = ""): DashboardDestination { + return { kind: "module", moduleId, path }; +} + +const PAGE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +const PAGE_PATH_SEGMENT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const ICON_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are what is refused. +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u; + +/** The manifest's page grammar (`@ekklesia/contracts`), re-checked on what the server sends. */ +export function isDashboardModulePage(value: unknown): value is DashboardModulePage { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const record = value as Record; + const keys = Object.keys(record).sort(); + if (keys.length !== 3 || keys[0] !== "id" || keys[1] !== "label" || keys[2] !== "path") { + return false; + } + const { id, label, path } = record; + return ( + typeof id === "string" && + id.length <= 64 && + PAGE_ID_PATTERN.test(id) && + typeof label === "string" && + label.trim().length > 0 && + label.length <= 60 && + !CONTROL_CHARACTER_PATTERN.test(label) && + typeof path === "string" && + path.length <= 120 && + path.split("/").length <= 4 && + path.split("/").every((segment) => PAGE_PATH_SEGMENT_PATTERN.test(segment)) + ); +} + +/** A syntactically valid Lucide name. Whether the Dashboard can draw it is the icon map's question: + * a name it does not know is drawn as the generic module icon, never refused. */ +export function isModuleIconName(value: unknown): value is string { + return typeof value === "string" && value.length <= 64 && ICON_NAME_PATTERN.test(value); +} diff --git a/surfaces/shell/tests/dashboard-routes.test.ts b/surfaces/shell/tests/dashboard-routes.test.ts index 2b74999..9435c7b 100644 --- a/surfaces/shell/tests/dashboard-routes.test.ts +++ b/surfaces/shell/tests/dashboard-routes.test.ts @@ -6,6 +6,10 @@ import { describe, expect, it } from "vitest"; import { ACCOUNT_SECTIONS, DASHBOARD_PLATFORM_PAGES, + dashboardPathReturningTo, + HOST_NAMESPACES, + parseModuleRoute, + returnTargetFromSearch, dashboardPath, parseDashboardPath, DISCOVER_VIEWS, @@ -27,13 +31,25 @@ describe("parseDashboardPath", () => { }); it("names a module page by its id at the top level, tolerating one trailing slash", () => { - expect(parseDashboardPath("/signage")).toEqual({ kind: "module", moduleId: "signage" }); - expect(parseDashboardPath("/signage/")).toEqual({ kind: "module", moduleId: "signage" }); - expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people" }); + expect(parseDashboardPath("/signage")).toEqual({ + kind: "module", + moduleId: "signage", + path: "", + }); + expect(parseDashboardPath("/signage/")).toEqual({ + kind: "module", + moduleId: "signage", + path: "", + }); + expect(parseDashboardPath("/people")).toEqual({ kind: "module", moduleId: "people", path: "" }); }); it("decodes a percent-encoded segment, so an id is compared as the server spells it", () => { - expect(parseDashboardPath("/sign%61ge")).toEqual({ kind: "module", moduleId: "signage" }); + expect(parseDashboardPath("/sign%61ge")).toEqual({ + kind: "module", + moduleId: "signage", + path: "", + }); }); it("never reads a platform page as a module page", () => { @@ -65,7 +81,9 @@ describe("parseDashboardPath", () => { "/accounts", ]) { expect(parseDashboardPath(pathname), pathname).toEqual( - pathname === "/accounts" ? { kind: "module", moduleId: "accounts" } : { kind: "not-found" }, + pathname === "/accounts" + ? { kind: "module", moduleId: "accounts", path: "" } + : { kind: "not-found" }, ); } }); @@ -76,10 +94,13 @@ describe("parseDashboardPath", () => { "//", "//signage", "/signage//", - // A module's own sub-pages need a contract that does not exist yet (ADR 0057). - "/signage/timelines", - "/signage/timelines/abc", + "/signage/a//b", "/%E0%A4%A", + // The host's non-Dashboard namespaces are never pages, however deep (ADR 0063). + "/api", + "/api/v1/principal", + "/assets/ekklesia-dashboard/app.js", + "/display/signage", // Deeper Settings pages are reserved for later (ADR 0061). "/settings/displays/add", "/settings/signage/timelines", @@ -166,27 +187,112 @@ describe("Settings routes (ADR 0061)", () => { describe("dashboardPath", () => { it("spells each route canonically, with no implementation prefix", () => { expect(dashboardPath({ kind: "overview" })).toBe("/"); - expect(dashboardPath({ kind: "module", moduleId: "signage" })).toBe("/signage"); + expect(dashboardPath({ kind: "module", moduleId: "signage", path: "" })).toBe("/signage"); }); it("encodes a module id so it can never escape its own segment", () => { - expect(dashboardPath({ kind: "module", moduleId: "a/../b" })).toBe("/a%2F..%2Fb"); - expect(parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b" }))).toEqual({ + expect(dashboardPath({ kind: "module", moduleId: "a/../b", path: "" })).toBe("/a%2F..%2Fb"); + expect( + parseDashboardPath(dashboardPath({ kind: "module", moduleId: "a/../b", path: "" })), + ).toEqual({ kind: "module", moduleId: "a/../b", + path: "", }); }); it("round-trips through the parser", () => { for (const route of [ { kind: "overview" } as const, - { kind: "module", moduleId: "example" } as const, + { kind: "module", moduleId: "example", path: "" } as const, + { kind: "module", moduleId: "example", path: "records" } as const, + { kind: "module", moduleId: "media", path: "sermons/2026-10-11_service~1.mp4" } as const, ]) { expect(parseDashboardPath(dashboardPath(route))).toEqual(route); } }); }); +describe("module routes (ADR 0063)", () => { + it("reads everything beneath a module's root as that module's own route", () => { + expect(parseDashboardPath("/signage/timelines")).toEqual({ + kind: "module", + moduleId: "signage", + path: "timelines", + }); + expect(parseDashboardPath("/signage/timelines/")).toEqual({ + kind: "module", + moduleId: "signage", + path: "timelines", + }); + expect(parseDashboardPath("/media/sermons/123")).toEqual({ + kind: "module", + moduleId: "media", + path: "sermons/123", + }); + }); + + it("spells a module route canonically, its path beneath the encoded module id", () => { + expect(dashboardPath({ kind: "module", moduleId: "signage", path: "timelines" })).toBe( + "/signage/timelines", + ); + expect(dashboardPath({ kind: "module", moduleId: "signage", path: "" })).toBe("/signage"); + }); + + it.each([ + ["", ""], + ["/", ""], + ["timelines", "timelines"], + ["/timelines", "timelines"], + ["media/sermons/123", "media/sermons/123"], + ["a.b_c~d-e", "a.b_c~d-e"], + ])("accepts the module route %j as %j", (input, expected) => { + expect(parseModuleRoute(input)).toBe(expected); + }); + + it.each([ + "../calendar", + "media/../../settings", + "./timelines", + "media/.", + "//evil.example.test", + "https://evil.example.test", + "javascript:alert(1)", + "timelines?x=1", + "timelines#top", + "media%2F..%2Fapi", + "media\\library", + "media//library", + "timelines/", + "time lines", + "a/b/c/d/e/f/g/h/i", + `${"a".repeat(513)}`, + ])("refuses the module route %j", (input) => { + expect(parseModuleRoute(input)).toBeUndefined(); + }); + + it("refuses anything that is not a string", () => { + for (const value of [undefined, null, 1, ["timelines"], { path: "timelines" }]) { + expect(parseModuleRoute(value)).toBeUndefined(); + } + }); + + it("names exactly the host's non-Dashboard namespaces", () => { + expect(HOST_NAMESPACES).toEqual(["api", "assets", "display"]); + }); + + it("carries a return target on its own query parameter, readable only as a raw value", () => { + const href = dashboardPathReturningTo( + { kind: "module-settings", moduleId: "signage" }, + { kind: "module", moduleId: "signage", path: "timelines" }, + ); + expect(href).toBe("/settings/signage?return=%2Fsignage%2Ftimelines"); + expect(returnTargetFromSearch("?return=%2Fsignage%2Ftimelines")).toBe("/signage/timelines"); + expect(returnTargetFromSearch("")).toBeUndefined(); + expect(returnTargetFromSearch("?other=1")).toBeUndefined(); + }); +}); + describe("the route locator is published for the API that serves the page", () => { it("is a narrow package subpath, not the shell root barrel", async () => { const manifest = JSON.parse( diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index 71f51b2..24453cf 100644 --- a/surfaces/shell/tests/display-app.test.tsx +++ b/surfaces/shell/tests/display-app.test.tsx @@ -251,10 +251,13 @@ describe("display host — full-viewport boundary", () => { "contributions", "display", "moduleId", + "navigation", "permissions", "principal", "realtime", ]); + // A display is one screen with no pages: the display host offers no navigation (ADR 0063). + expect(seenContext?.navigation).toBeUndefined(); expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" }); expect(isolatedText(query("display-module-container"))).toBe("permitted"); expect( @@ -352,6 +355,7 @@ describe("display host — full-viewport boundary", () => { version: "1.2.0", }, name: "Announcements", + navigation: { icon: null, pages: [] }, }, ], }); diff --git a/surfaces/shell/tests/host-app.test.tsx b/surfaces/shell/tests/host-app.test.tsx index bbdead9..aba99d2 100644 --- a/surfaces/shell/tests/host-app.test.tsx +++ b/surfaces/shell/tests/host-app.test.tsx @@ -52,6 +52,7 @@ function entry(id: string, name: string, version = "0.1.0", permissions: readonl version, }, name, + navigation: { icon: null, pages: [] }, }; } diff --git a/surfaces/shell/tests/host-history.test.ts b/surfaces/shell/tests/host-history.test.ts index 26f5c6c..7fc6942 100644 --- a/surfaces/shell/tests/host-history.test.ts +++ b/surfaces/shell/tests/host-history.test.ts @@ -65,7 +65,7 @@ describe("the browser host history", () => { const view = { addEventListener: vi.fn(), history: { pushState: vi.fn(), replaceState: vi.fn() }, - location: { pathname: "/" }, + location: { pathname: "/", search: "" }, removeEventListener: vi.fn(), }; const history = createBrowserHostHistory(view); @@ -87,7 +87,7 @@ describe("the browser host history", () => { const view = { addEventListener: vi.fn(), history: { pushState: vi.fn(), replaceState: vi.fn() }, - location: { pathname: "/" }, + location: { pathname: "/", search: "" }, removeEventListener: vi.fn(), }; const history = createBrowserHostHistory(view); @@ -100,6 +100,37 @@ describe("the browser host history", () => { }); }); +describe("the query, which carries only where to return to (ADR 0063)", () => { + it("reads the browser's query, and pushes an address that carries one", () => { + const view = { + addEventListener: vi.fn(), + history: { pushState: vi.fn(), replaceState: vi.fn() }, + location: { pathname: "/settings/signage", search: "?return=%2Fsignage" }, + removeEventListener: vi.fn(), + }; + const history = createBrowserHostHistory(view); + + expect(history.search()).toBe("?return=%2Fsignage"); + // The address already showing, query included, adds no entry; the same path with another query + // is another address. + history.push("/settings/signage?return=%2Fsignage"); + expect(view.history.pushState).not.toHaveBeenCalled(); + history.push("/settings/signage"); + expect(view.history.pushState).toHaveBeenCalledWith(null, "", "/settings/signage"); + }); + + it("keeps the query beside the path in memory, as the browser does", () => { + const history = createMemoryHostHistory("/settings/signage?return=%2Fsignage%2Fmedia"); + + expect(history.pathname()).toBe("/settings/signage"); + expect(history.search()).toBe("?return=%2Fsignage%2Fmedia"); + history.push("/signage/media"); + expect(history.search()).toBe(""); + history.go(-1); + expect(history.search()).toBe("?return=%2Fsignage%2Fmedia"); + }); +}); + describe("the in-memory host history", () => { it("behaves like the browser's: push truncates forward entries, go steps within bounds", () => { const history = createMemoryHostHistory("/"); diff --git a/surfaces/shell/tests/host-protocol.test.ts b/surfaces/shell/tests/host-protocol.test.ts index be0f96f..a8271a5 100644 --- a/surfaces/shell/tests/host-protocol.test.ts +++ b/surfaces/shell/tests/host-protocol.test.ts @@ -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" }); }); diff --git a/surfaces/shell/tests/host-routing.test.tsx b/surfaces/shell/tests/host-routing.test.tsx index 76bfd99..1c8398a 100644 --- a/surfaces/shell/tests/host-routing.test.tsx +++ b/surfaces/shell/tests/host-routing.test.tsx @@ -53,6 +53,7 @@ function entry(id: string, name: string, version = "0.1.0") { version, }, name, + navigation: { icon: null, pages: [] }, }; } diff --git a/surfaces/shell/tests/modularity.test.ts b/surfaces/shell/tests/modularity.test.ts index 864fcc4..bd5c339 100644 --- a/surfaces/shell/tests/modularity.test.ts +++ b/surfaces/shell/tests/modularity.test.ts @@ -179,7 +179,15 @@ describe("the shell builds no module knowledge into its bundle", () => { // 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-"]) { + // "calendar" is no longer one of them: it is a generic Lucide icon a module may declare + // (ADR 0063), so the bundle names it on purpose. + for (const term of [ + "planning", + "sunday-production", + "livestream", + "youth-ministry", + "ungrouped-", + ]) { expect(bundle.toLowerCase(), term).not.toContain(term); } }); diff --git a/surfaces/shell/tests/module-navigation.test.tsx b/surfaces/shell/tests/module-navigation.test.tsx new file mode 100644 index 0000000..18185d8 --- /dev/null +++ b/surfaces/shell/tests/module-navigation.test.tsx @@ -0,0 +1,590 @@ +import { + MODULE_UI_ABI_VERSION, + type ModuleUiHostContext, + type ModuleUiNavigation, + ModuleUiNavigationError, +} from "@ekklesia/module-ui-sdk"; +import * as lucide from "lucide-react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HostApp } from "../src/browser/HostApp.js"; +import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js"; +import { createMemoryHostViewport } from "../src/browser/host-viewport.js"; +import { MODULE_ICON_COMPONENTS } from "../src/browser/module-icon.js"; +import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js"; +import type { DashboardSettings } from "../src/host-protocol.js"; +import { DASHBOARD_MODULE_ICON_NAMES } from "../src/module-icons.js"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +/** + * Module navigation (manifest v9, ADR 0063): a module's own icon and primary pages in the global + * sidebar, its real nested routes, the navigation capability a mounted module is handed through the + * real mounting boundary, and module → Settings → back to exactly where it was — beside a + * manifest v8 module that keeps behaving as it always has. + */ + +const apiBaseUrl = "https://ekklesia.example.test"; + +let container: HTMLDivElement; +let root: Root; +let consoleError: ReturnType; + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + consoleError.mockRestore(); +}); + +function entry( + id: string, + name: string, + navigation: { icon: string | null; pages: { id: string; label: string; path: string }[] } = { + icon: null, + pages: [], + }, +) { + return { + module: { + api: { baseUrl: `/api/v1/modules/${id}` }, + contributions: [], + display: null, + id, + permissions: [], + realtime: { baseUrl: `/api/v1/module-realtime/${id}` }, + ui: { url: `/api/v1/module-ui/${id}/0.1.0` }, + version: "0.1.0", + }, + name, + navigation, + }; +} + +const SIGNAGE_PAGES = [ + { id: "playback", label: "Playback", path: "playback" }, + { id: "timelines", label: "Timelines", path: "timelines" }, + { id: "media", label: "Media", path: "media" }, +]; + +/** A manifest v9 module with an icon and three pages, and a manifest v8 module with neither. */ +const NAVIGATION = { + groups: [], + ungrouped: [ + entry("signage", "Signage", { icon: "monitor-play", pages: SIGNAGE_PAGES }), + entry("calendar", "Calendar"), + ], +}; + +/** Signage declares configuration and the viewer may change it; Calendar has no settings; Archive + * is installed but turned off, so it is in Settings but not in the navigation. */ +const SETTINGS: DashboardSettings = { + modules: [ + { available: false, id: "archive", name: "Archive" }, + { available: true, id: "signage", name: "Signage" }, + ], + sections: [], +}; + +interface Mounts { + imported: string[]; + mounted: string[]; + unmounted: string[]; + contexts: Map; +} + +async function render( + path: string, + options: { readonly history?: MemoryHostHistory; readonly compact?: boolean } = {}, +) { + const history = options.history ?? createMemoryHostHistory(path); + const titles: string[] = []; + const mounts: Mounts = { contexts: new Map(), imported: [], mounted: [], unmounted: [] }; + const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/v1/principal")) { + return Response.json({ kind: "authenticated", userId: "user-1" }); + } + if (url.endsWith("/api/v1/dashboard/navigation")) { + return Response.json(NAVIGATION); + } + if (url.endsWith("/api/v1/dashboard/settings")) { + return Response.json(SETTINGS); + } + return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); + }) as unknown as typeof fetch; + await act(async () => { + root.render( + { + const moduleId = /module-ui\/([a-z-]+)\//u.exec(url)?.[1] ?? "unknown"; + mounts.imported.push(moduleId); + return { + default: { + abi: MODULE_UI_ABI_VERSION, + id: moduleId, + mount(element: Element, context: ModuleUiHostContext) { + mounts.contexts.set(moduleId, context); + const show = (): void => { + element.textContent = `${moduleId}:${context.navigation?.location().path ?? "-"}`; + }; + show(); + const unsubscribe = context.navigation?.subscribe(show); + mounts.mounted.push(moduleId); + return () => { + unsubscribe?.(); + mounts.unmounted.push(moduleId); + }; + }, + version: "0.1.0", + }, + }; + }} + setDocumentTitle={(title) => titles.push(title)} + sidebarPreference={createMemorySidebarPreference()} + viewport={createMemoryHostViewport(options.compact ?? false)} + />, + ); + }); + return { history, mounts, titles }; +} + +function query(testId: string): HTMLElement | null { + return container.querySelector(`[data-testid="${testId}"]`); +} + +function required(testId: string): HTMLElement { + const element = query(testId); + if (!element) { + throw new Error(`missing ${testId}`); + } + return element; +} + +async function click(element: Element, init: MouseEventInit = {}): Promise { + await act(async () => { + element.dispatchEvent( + new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init }), + ); + }); +} + +/** What the mounted module rendered, read through its isolated (Shadow DOM) root. */ +function moduleText(): string { + const host = container.querySelector("[data-ekklesia-module-ui]"); + return host?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""; +} + +const current = () => + [...container.querySelectorAll('nav [aria-current="page"]')].map( + (element) => (element as HTMLElement).dataset.testid, + ); + +function navigationOf(mounts: Mounts, moduleId = "signage"): ModuleUiNavigation { + const navigation = mounts.contexts.get(moduleId)?.navigation; + if (!navigation) { + throw new Error(`no navigation for ${moduleId}`); + } + return navigation; +} + +describe("a manifest v9 module's routes", () => { + it("opens the module root as its landing view, with its pages beneath it in the sidebar", async () => { + const { mounts, titles } = await render("/signage"); + + expect(mounts.mounted).toEqual(["signage"]); + expect(moduleText()).toBe("signage:"); + expect(titles.at(-1)).toBe("Signage"); + expect(current()).toEqual(["select-signage"]); + const pages = [...required("module-subnavigation-signage").querySelectorAll("a")]; + expect(pages.map((link) => link.textContent)).toEqual(["Playback", "Timelines", "Media"]); + expect(pages.map((link) => link.getAttribute("href"))).toEqual([ + "/signage/playback", + "/signage/timelines", + "/signage/media", + ]); + expect(required("module-subnavigation-signage").getAttribute("aria-label")).toBe("Signage"); + }); + + it("opens a declared page directly from its deep link, as a refresh or a bookmark does", async () => { + const { mounts, titles } = await render("/signage/timelines"); + + expect(mounts.mounted).toEqual(["signage"]); + expect(navigationOf(mounts).location()).toEqual({ page: "timelines", path: "timelines" }); + expect(moduleText()).toBe("signage:timelines"); + expect(current()).toEqual(["select-signage-timelines"]); + expect(titles.at(-1)).toBe("Timelines · Signage"); + }); + + it("opens a route beneath a declared page — a detail view — marking its page", async () => { + const { mounts } = await render("/signage/media/sermons/2026-10-11"); + + expect(navigationOf(mounts).location()).toEqual({ + page: "media", + path: "media/sermons/2026-10-11", + }); + expect(current()).toEqual(["select-signage-media"]); + }); + + it("is not found at a path no declared page owns, and never mounts the module for it", async () => { + for (const path of ["/signage/nope", "/signage/timeline", "/signage/Timelines"]) { + await act(async () => { + root.unmount(); + }); + root = createRoot(container); + const { mounts, titles } = await render(path); + + expect(query("host-not-found"), path).not.toBeNull(); + expect(mounts.imported, path).toEqual([]); + expect(titles.at(-1), path).toBe("Page not found"); + expect(current(), path).toEqual([]); + } + }); + + it("moves between its own pages without remounting, and Back and Forward walk them", async () => { + const { history, mounts } = await render("/signage"); + + await click(required("select-signage-timelines")); + expect(history.entries).toEqual(["/signage", "/signage/timelines"]); + expect(moduleText()).toBe("signage:timelines"); + await click(required("select-signage-media")); + + await act(async () => history.go(-1)); + expect(moduleText()).toBe("signage:timelines"); + expect(current()).toEqual(["select-signage-timelines"]); + await act(async () => history.go(-1)); + expect(moduleText()).toBe("signage:"); + await act(async () => history.go(2)); + expect(moduleText()).toBe("signage:media"); + + // One import and one mount for the whole walk: the module was never torn down. + expect(mounts.imported).toEqual(["signage"]); + expect(mounts.mounted).toEqual(["signage"]); + expect(mounts.unmounted).toEqual([]); + }); + + it("moves focus to the page when the module moves to another of its pages", async () => { + await render("/signage"); + const main = required("host-main-region"); + (document.activeElement as HTMLElement | null)?.blur(); + + await click(required("select-signage-playback")); + + expect(document.activeElement).toBe(main); + }); + + it("keeps the exact page across a refresh", async () => { + const history = createMemoryHostHistory("/signage"); + await render("/signage", { history }); + await click(required("select-signage-media")); + + await act(async () => { + root.unmount(); + }); + root = createRoot(container); + const { mounts } = await render("/signage/media"); + + expect(navigationOf(mounts).location().path).toBe("media"); + expect(current()).toEqual(["select-signage-media"]); + }); +}); + +describe("a released manifest v8 module", () => { + it("is one page, its root, with the generic icon and no sub-navigation", async () => { + const { mounts } = await render("/calendar"); + + expect(mounts.mounted).toEqual(["calendar"]); + expect(moduleText()).toBe("calendar:"); + expect(current()).toEqual(["select-calendar"]); + expect(query("module-subnavigation-calendar")).toBeNull(); + expect(required("select-calendar").querySelector("svg")?.dataset.icon).toBe("module"); + }); + + it("is not found beneath its root, exactly as before", async () => { + const { mounts } = await render("/calendar/events/456"); + + expect(query("host-not-found")).not.toBeNull(); + expect(mounts.imported).toEqual([]); + }); + + it("still receives navigation — its root, and no Settings link when it has no settings", async () => { + const { mounts } = await render("/calendar"); + const navigation = navigationOf(mounts, "calendar"); + + expect(navigation.location()).toEqual({ page: undefined, path: "" }); + expect(navigation.settings).toBeUndefined(); + expect(() => navigation.navigate("events")).toThrowError( + expect.objectContaining({ code: "MODULE_NAVIGATION_PATH_UNKNOWN" }), + ); + }); +}); + +describe("modules that are not running", () => { + it("says a turned-off module is unavailable at its root and beneath it, mounting nothing", async () => { + for (const path of ["/archive", "/archive/timelines", "/archive/a/b"]) { + await act(async () => { + root.unmount(); + }); + root = createRoot(container); + const { mounts } = await render(path); + + expect(query("host-module-unavailable"), path).not.toBeNull(); + expect(mounts.imported, path).toEqual([]); + // A module that is not running is absent from the navigation. + expect(query("select-archive"), path).toBeNull(); + } + }); +}); + +describe("the sidebar", () => { + it("draws a module's declared Lucide icon, and the generic icon for one that declares none", async () => { + await render("/"); + + expect(required("select-signage").querySelector("svg")?.dataset.icon).toBe( + "module:monitor-play", + ); + expect(required("select-signage").querySelector("svg")?.getAttribute("aria-hidden")).toBe( + "true", + ); + expect(required("select-calendar").querySelector("svg")?.dataset.icon).toBe("module"); + }); + + it("shows a module's pages only while that module is open", async () => { + await render("/"); + expect(query("module-subnavigation-signage")).toBeNull(); + + await click(required("select-signage")); + expect(query("module-subnavigation-signage")).not.toBeNull(); + + await click(required("select-calendar")); + expect(query("module-subnavigation-signage")).toBeNull(); + }); + + it("collapsed, hides the pages and marks the module itself, named for assistive technology", async () => { + await render("/signage/timelines"); + await click(container.querySelector('[aria-label="Collapse navigation"]') as Element); + + expect(query("module-subnavigation-signage")).toBeNull(); + expect(current()).toEqual(["select-signage"]); + const label = required("select-signage").querySelector("[data-nav-label]"); + expect(label?.textContent).toBe("Signage"); + expect(label?.className).toBe("ek-shell-visually-hidden"); + }); + + it("uses the identical hierarchy in the phone drawer, which closes once a page is chosen", async () => { + const { history } = await render("/signage", { compact: true }); + + await click(required("mobile-navigation-trigger")); + const drawer = required("navigation-drawer"); + const pages = [ + ...drawer.querySelectorAll('[data-testid="module-subnavigation-signage"] a'), + ] as HTMLElement[]; + expect(pages.map((link) => link.textContent)).toEqual(["Playback", "Timelines", "Media"]); + for (const link of pages) { + expect(link.style.minHeight).toBe("var(--ekklesia-control-height-lg)"); + } + + await click(pages[1] as HTMLElement); + expect(history.pathname()).toBe("/signage/timelines"); + expect(query("navigation-drawer")).toBeNull(); + }); +}); + +describe("the module navigation capability", () => { + it("navigates to the module's own routes, adding or replacing a history entry", async () => { + const { history, mounts } = await render("/signage"); + const navigation = navigationOf(mounts); + + await act(async () => navigation.navigate("timelines")); + expect(history.entries).toEqual(["/signage", "/signage/timelines"]); + await act(async () => navigation.navigate("/media/sermons/9")); + expect(history.pathname()).toBe("/signage/media/sermons/9"); + expect(navigation.location()).toEqual({ page: "media", path: "media/sermons/9" }); + await act(async () => navigation.navigate("media", { replace: true })); + expect(history.entries).toEqual(["/signage", "/signage/timelines", "/signage/media"]); + await act(async () => navigation.navigate("")); + expect(navigation.location()).toEqual({ page: undefined, path: "" }); + expect(mounts.mounted).toEqual(["signage"]); + }); + + it.each([ + ["../calendar", "MODULE_NAVIGATION_PATH_INVALID"], + ["timelines/../../calendar", "MODULE_NAVIGATION_PATH_INVALID"], + ["//evil.example.test", "MODULE_NAVIGATION_PATH_INVALID"], + ["https://evil.example.test/", "MODULE_NAVIGATION_PATH_INVALID"], + ["javascript:alert(1)", "MODULE_NAVIGATION_PATH_INVALID"], + ["timelines?x=1", "MODULE_NAVIGATION_PATH_INVALID"], + ["timelines#top", "MODULE_NAVIGATION_PATH_INVALID"], + ["nope", "MODULE_NAVIGATION_PATH_UNKNOWN"], + ["timeline", "MODULE_NAVIGATION_PATH_UNKNOWN"], + ])("refuses %j — no other module, platform page, or undeclared page", async (path, code) => { + const { history, mounts } = await render("/signage"); + const navigation = navigationOf(mounts); + + expect(() => navigation.navigate(path)).toThrowError(expect.objectContaining({ code })); + expect(() => navigation.link(path)).toThrowError(ModuleUiNavigationError); + expect(history.entries).toEqual(["/signage"]); + }); + + it("names nothing it refused in its error", async () => { + const { mounts } = await render("/signage"); + const failure = (() => { + try { + navigationOf(mounts).navigate("../calendar?token=secret"); + } catch (error) { + return error as Error; + } + return undefined; + })(); + expect(failure).toBeInstanceOf(ModuleUiNavigationError); + expect(`${failure?.message}${failure?.stack}`).not.toMatch(/calendar|secret/u); + }); + + it("makes real links: in place on an ordinary click, to the browser with a modifier", async () => { + const { history, mounts } = await render("/signage"); + const link = navigationOf(mounts).link("timelines"); + expect(link.href).toBe("/signage/timelines"); + + const ordinary = { + altKey: false, + button: 0, + ctrlKey: false, + defaultPrevented: false, + metaKey: false, + preventDefault: vi.fn(), + shiftKey: false, + }; + await act(async () => link.onClick(ordinary)); + expect(ordinary.preventDefault).toHaveBeenCalledOnce(); + expect(history.pathname()).toBe("/signage/timelines"); + + const newTab = { ...ordinary, metaKey: true, preventDefault: vi.fn() }; + await act(async () => navigationOf(mounts).link("media").onClick(newTab)); + expect(newTab.preventDefault).not.toHaveBeenCalled(); + expect(history.pathname()).toBe("/signage/timelines"); + }); + + it("reports moves made by the host — the sidebar, Back — to the mounted module", async () => { + const { history, mounts } = await render("/signage"); + const listener = vi.fn(); + navigationOf(mounts).subscribe(listener); + + await click(required("select-signage-playback")); + await act(async () => history.go(-1)); + + expect(listener).toHaveBeenCalledTimes(2); + }); +}); + +describe("module → Settings → back to exactly where it was", () => { + it("opens the module's Settings page carrying its current page as the place to return to", async () => { + const { history, mounts } = await render("/signage/timelines"); + const settings = navigationOf(mounts).settings; + + expect(settings?.link().href).toBe("/settings/signage?return=%2Fsignage%2Ftimelines"); + await act(async () => settings?.open()); + + expect(history.entries).toEqual([ + "/signage/timelines", + "/settings/signage?return=%2Fsignage%2Ftimelines", + ]); + expect(query("module-settings")).not.toBeNull(); + const back = + required("module-settings").querySelector(".ek-page-header__back"); + expect(back?.getAttribute("href")).toBe("/signage/timelines"); + expect(back?.textContent).toContain("Back to Signage"); + + await click(back as HTMLAnchorElement); + expect(history.pathname()).toBe("/signage/timelines"); + expect(current()).toEqual(["select-signage-timelines"]); + }); + + it("returns to where the module is now, even if it never read its location since moving", async () => { + const { history, mounts } = await render("/signage"); + const settings = navigationOf(mounts).settings; + mounts.contexts.get("signage")?.navigation?.subscribe(() => undefined)(); + + await act(async () => history.push("/signage/media")); + + expect(settings?.link().href).toBe("/settings/signage?return=%2Fsignage%2Fmedia"); + }); + + it("keeps the place to return to across a refresh of the Settings page", async () => { + await render("/settings/signage?return=%2Fsignage%2Fmedia%2Fsermons%2F9"); + + const back = required("module-settings").querySelector(".ek-page-header__back"); + expect(back?.getAttribute("href")).toBe("/signage/media/sermons/9"); + expect(back?.textContent).toContain("Back to Signage"); + }); + + it("keeps it when the Settings address is respelled", async () => { + const { history } = await render("/settings/signage/?return=%2Fsignage%2Fmedia"); + + expect(history.entries).toEqual(["/settings/signage?return=%2Fsignage%2Fmedia"]); + }); + + it.each([ + ["another origin", "https://evil.example.test/signage"], + ["a protocol-relative URL", "//evil.example.test"], + ["a script", "javascript:alert(1)"], + ["the API", "/api/v1/principal"], + ["a page the module does not declare", "/signage/nope"], + ["a module that is not running", "/archive"], + ["a module that does not exist", "/nowhere"], + ["traversal", "/signage/../api"], + ["a nested query", "/signage?return=https://evil.example.test"], + ])("ignores a return target naming %s, going back to Settings instead", async (_label, target) => { + await render(`/settings/signage?return=${encodeURIComponent(target)}`); + + const back = required("module-settings").querySelector(".ek-page-header__back"); + expect(back?.getAttribute("href")).toBe("/settings"); + expect(back?.textContent).toContain("Back to Settings"); + }); + + it("offers no Settings link to a module whose settings this viewer cannot open", async () => { + const { mounts } = await render("/calendar"); + + expect(navigationOf(mounts, "calendar").settings).toBeUndefined(); + }); +}); + +describe("module icons", () => { + it("maps exactly the names the platform accepts, each to a real Lucide component", () => { + expect(Object.keys(MODULE_ICON_COMPONENTS).sort()).toEqual( + [...DASHBOARD_MODULE_ICON_NAMES].sort(), + ); + const exports = lucide as unknown as Record; + for (const name of DASHBOARD_MODULE_ICON_NAMES) { + const pascal = name + .split("-") + .map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`) + .join(""); + expect(MODULE_ICON_COMPONENTS[name], name).toBe(exports[pascal]); + } + }); + + it("draws the generic icon for a name this Dashboard cannot draw", async () => { + NAVIGATION.ungrouped.push(entry("press", "Press", { icon: "not-yet-an-icon", pages: [] })); + try { + await render("/"); + expect(required("select-press").querySelector("svg")?.dataset.icon).toBe("module"); + } finally { + NAVIGATION.ungrouped.pop(); + } + }); +}); diff --git a/surfaces/shell/tests/return-target.test.ts b/surfaces/shell/tests/return-target.test.ts index 7d14565..a507c1f 100644 --- a/surfaces/shell/tests/return-target.test.ts +++ b/surfaces/shell/tests/return-target.test.ts @@ -7,8 +7,16 @@ import { parseReturnTarget } from "../src/return-target.js"; describe("parseReturnTarget", () => { it("accepts a Dashboard page on this origin, as its canonical destination", () => { expect(parseReturnTarget("/")).toEqual({ kind: "overview" }); - expect(parseReturnTarget("/signage")).toEqual({ kind: "module", moduleId: "signage" }); - expect(parseReturnTarget("/signage/")).toEqual({ kind: "module", moduleId: "signage" }); + expect(parseReturnTarget("/signage")).toEqual({ + kind: "module", + moduleId: "signage", + path: "", + }); + expect(parseReturnTarget("/signage/")).toEqual({ + kind: "module", + moduleId: "signage", + path: "", + }); // Settings pages are Dashboard pages too (ADR 0061). expect(parseReturnTarget("/settings")).toEqual({ kind: "settings" }); expect(parseReturnTarget("/settings/signage")).toEqual({ @@ -59,13 +67,29 @@ describe("parseReturnTarget", () => { "/signage?x=1", "/signage#top", "/api/v1/principal", + "/api/auth/sign-out", + "/assets/ekklesia-dashboard/app.js", "/display/signage", "/settings/displays/add", - "/signage/timelines", + "/signage//", + "/signage/a//b", ]) { expect(parseReturnTarget(value), value).toBeUndefined(); } }); + + it("accepts a module's own deeper route, whose page the host then checks (ADR 0063)", () => { + expect(parseReturnTarget("/signage/timelines")).toEqual({ + kind: "module", + moduleId: "signage", + path: "timelines", + }); + expect(parseReturnTarget("/media/sermons/123")).toEqual({ + kind: "module", + moduleId: "media", + path: "sermons/123", + }); + }); }); describe("dashboardBack", () => { @@ -84,7 +108,13 @@ describe("dashboardBack", () => { }); it("falls back to the page's own parent when the target is missing or unsafe", () => { - for (const returnTarget of [undefined, "https://evil.example/", "//evil.example", "/nope/x"]) { + for (const returnTarget of [ + undefined, + "https://evil.example/", + "//evil.example", + "/api/v1/principal", + "/signage//", + ]) { const history = createMemoryHostHistory("/calendar"); const back = dashboardBack(history, { fallback: { kind: "overview" }, diff --git a/surfaces/shell/tests/settings-navigation.test.tsx b/surfaces/shell/tests/settings-navigation.test.tsx index 6df41b5..ed0f999 100644 --- a/surfaces/shell/tests/settings-navigation.test.tsx +++ b/surfaces/shell/tests/settings-navigation.test.tsx @@ -53,6 +53,7 @@ function entry(id: string, name: string) { version: "0.1.0", }, name, + navigation: { icon: null, pages: [] }, }; } diff --git a/surfaces/shell/tests/shell-navigation.test.tsx b/surfaces/shell/tests/shell-navigation.test.tsx index 37599e0..b066523 100644 --- a/surfaces/shell/tests/shell-navigation.test.tsx +++ b/surfaces/shell/tests/shell-navigation.test.tsx @@ -60,6 +60,7 @@ function entry(id: string, name: string, version = "0.1.0") { version, }, name, + navigation: { icon: null, pages: [] }, }; } diff --git a/surfaces/shell/tsconfig.build.json b/surfaces/shell/tsconfig.build.json index 85149be..e6eb6a3 100644 --- a/surfaces/shell/tsconfig.build.json +++ b/surfaces/shell/tsconfig.build.json @@ -9,11 +9,13 @@ "types": [] }, "include": [ - "src/index.ts", "src/dashboard-assets.ts", "src/dashboard-routes.ts", "src/display-assets.ts", "src/host-protocol.ts", - "src/module-context.ts" + "src/index.ts", + "src/module-context.ts", + "src/module-icons.ts", + "src/module-navigation.ts" ] } diff --git a/templates/module/README.md b/templates/module/README.md index 1b03be4..b0a3825 100644 --- a/templates/module/README.md +++ b/templates/module/README.md @@ -69,9 +69,13 @@ pnpm modules:pack --module templates/module --target any --out \ --source-revision "$(git rev-parse HEAD)" ``` -The module owns its feature screens and display renderer. Do not add platform navigation, the -containing shell, global theme, authentication screens, or top-level routing; those belong to -surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never +The module owns its feature screens and display renderer. Its manifest (version 9) declares its +sidebar icon and two primary pages, Records and About. Its UI renders whichever page +`context.navigation.location()` names, links with `navigation.link()`, shows a record's detail at +`records/` beneath the Records page, and opens its Settings page with `navigation.settings` +([ADR 0063](../../docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md)). Do +not add a tab bar for those pages, the containing shell, global theme, authentication screens, or +top-level routing, and never touch `window.history`; those belong to surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never hardcodes Ekklesia routes. The display permission is enforced by the host before it serves the page or descriptor; `context.permissions` remains a presentation-only hint. Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`, diff --git a/templates/module/ekklesia.module.json b/templates/module/ekklesia.module.json index b979771..23bcc3d 100644 --- a/templates/module/ekklesia.module.json +++ b/templates/module/ekklesia.module.json @@ -1,5 +1,5 @@ { - "manifestVersion": 8, + "manifestVersion": 9, "id": "example", "name": "Example Module", "description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.", @@ -48,6 +48,21 @@ "description": "Renders this module's record summary inside a host module's own screen." } ], + "navigation": { + "icon": "clipboard-list", + "pages": [ + { + "id": "records", + "label": "Records", + "path": "records" + }, + { + "id": "about", + "label": "About", + "path": "about" + } + ] + }, "entrypoints": { "server": "./dist/server/index.js", "schema": "./dist/schema/index.js", diff --git a/templates/module/src/ui/ExampleApp.tsx b/templates/module/src/ui/ExampleApp.tsx index c7260c4..85ee53a 100644 --- a/templates/module/src/ui/ExampleApp.tsx +++ b/templates/module/src/ui/ExampleApp.tsx @@ -1,24 +1,64 @@ -import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; -import { EmptyState, LoadingState, Panel } from "@ekklesia/ui"; -import { type ReactElement, useEffect, useState } from "react"; +import type { + ModuleUiHostContext, + ModuleUiLocation, + ModuleUiNavigation, +} from "@ekklesia/module-ui-sdk"; +import { Button, EmptyState, LoadingState, PageHeader, Panel } from "@ekklesia/ui"; +import { type ReactElement, useCallback, useEffect, useState, useSyncExternalStore } from "react"; /** - * The template's example screen. It is deliberately tiny, and deliberately host-neutral: it reads - * everything it needs from the host context and hardcodes no URL, no origin, and no global. + * The template's example screens. Deliberately small, and deliberately host-neutral: everything + * comes from the host context, and no URL, origin, route, or global is hardcoded. + * + * The module declares two primary pages in its manifest (`navigation.pages`, manifest v9, + * ADR 0063): **Records** and **About**. The Dashboard draws them in its sidebar beneath this + * module, so the module renders no tab bar of its own — it renders whichever page the host says is + * open (`context.navigation.location()`), and moves with `navigation.link()` and + * `navigation.navigate()` instead of touching `window.history`. A record's own page, + * `records/`, lies beneath the Records page: a detail route, which needs no manifest entry. * * Presentation comes from the platform's shared elements and this module's own stylesheet * (`./styles.css`, declared through `defineModuleUi({ styles })`), so it matches every other * Ekklesia screen without copying a single colour value (ADR 0039, ADR 0040). */ -export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement { - const [recordCount, setRecordCount] = useState(undefined); - // Presentation-only gating. The server decides what actually happens; this only decides what the - // viewer is offered. See docs/development/security.md. - const canRead = context.permissions.has("example.records.read"); +interface ExampleRecord { + readonly id: string; + readonly value: string; +} +const NO_LOCATION: ModuleUiLocation = Object.freeze({ page: undefined, path: "" }); + +/** Where the host says this module is. A host without addressable pages — a standalone host — + * supplies no navigation, and the module shows its landing view. */ +function useModuleLocation(navigation: ModuleUiNavigation | undefined): ModuleUiLocation { + const subscribe = useCallback( + (listener: () => void) => navigation?.subscribe(listener) ?? (() => undefined), + [navigation], + ); + const read = useCallback(() => navigation?.location() ?? NO_LOCATION, [navigation]); + return useSyncExternalStore(subscribe, read, read); +} + +/** A link to one of this module's own pages: a real ``, opened in place by the host. */ +function ModuleLink(props: { + readonly navigation: ModuleUiNavigation; + readonly to: string; + readonly children: string; + readonly testId: string; +}): ReactElement { + const link = props.navigation.link(props.to); + return ( + + {props.children} + + ); +} + +function useRecords(context: ModuleUiHostContext, enabled: boolean) { + const [records, setRecords] = useState(undefined); useEffect(() => { - if (!canRead) { + if (!enabled) { return; } let cancelled = false; @@ -28,20 +68,40 @@ export function ExampleApp({ context }: { readonly context: ModuleUiHostContext void context.api .request("records") .then((response) => (response.ok ? response.json() : [])) - .then((records: unknown) => { + .then((body: unknown) => { if (!cancelled) { - setRecordCount(Array.isArray(records) ? records.length : 0); + setRecords( + Array.isArray(body) + ? body.filter( + (record): record is ExampleRecord => + typeof record === "object" && + record !== null && + typeof (record as ExampleRecord).id === "string" && + typeof (record as ExampleRecord).value === "string", + ) + : [], + ); } }) .catch(() => { if (!cancelled) { - setRecordCount(0); + setRecords([]); } }); return () => { cancelled = true; }; - }, [canRead, context.api]); + }, [context.api, enabled]); + return records; +} + +export function ExampleApp({ context }: { readonly context: ModuleUiHostContext }): ReactElement { + // Presentation-only gating. The server decides what actually happens; this only decides what the + // viewer is offered. See docs/development/security.md. + const canRead = context.permissions.has("example.records.read"); + const { navigation } = context; + const location = useModuleLocation(navigation); + const records = useRecords(context, canRead); if (!canRead) { return ( @@ -52,15 +112,106 @@ export function ExampleApp({ context }: { readonly context: ModuleUiHostContext ); } + if (navigation !== undefined && location.page === "about") { + return ( +
+ + +

+ This module declares two pages, Records and About, in its manifest. The Dashboard shows + them in its own navigation, so the module never draws a tab bar of its own. +

+
+
+ ); + } + + if (navigation !== undefined && location.page === "records") { + const recordId = location.path.startsWith("records/") + ? location.path.slice("records/".length) + : undefined; + if (recordId !== undefined) { + const record = records?.find((candidate) => candidate.id === recordId); + return ( +
+ navigation.navigate("records"), + }} + title="Record" + /> + {records === undefined ? ( + + ) : record === undefined ? ( + + ) : ( + +

+ {record.value} +

+
+ )} +
+ ); + } + return ( +
+ + {records === undefined ? ( + + ) : records.length === 0 ? ( + + ) : ( + +
    + {records.map((record) => ( +
  • + + {record.value} + +
  • + ))} +
+
+ )} +
+ ); + } + + const settings = navigation?.settings; return ( - - {recordCount === undefined ? ( + settings.open()}> + Settings + + ) + } + description="Everything this module knows about." + title="Example module UI" + > + {records === undefined ? ( ) : ( -
+

- {`${recordCount} record(s)`} + {`${records.length} record(s)`}

+ {navigation === undefined ? undefined : ( + + View records + + )}
)} diff --git a/templates/module/src/ui/styles.css b/templates/module/src/ui/styles.css index 8358e48..0150304 100644 --- a/templates/module/src/ui/styles.css +++ b/templates/module/src/ui/styles.css @@ -29,3 +29,30 @@ font-variant-numeric: tabular-nums; margin: 0; } + +.example-page { + display: grid; + gap: var(--ekklesia-spacing-lg); +} + +.example-text { + color: var(--ekklesia-text-primary); + margin: 0; +} + +.example-list { + display: grid; + gap: var(--ekklesia-spacing-xs); + list-style: none; + margin: 0; + padding: 0; +} + +.example-link { + color: var(--ekklesia-accent-strong); +} + +.example-link:focus-visible { + outline: var(--ekklesia-state-focus-visible-width) solid var(--ekklesia-accent-focus); + outline-offset: var(--ekklesia-state-focus-visible-offset); +} diff --git a/templates/module/tests/module-ui.test.ts b/templates/module/tests/module-ui.test.ts index af954f5..1618e18 100644 --- a/templates/module/tests/module-ui.test.ts +++ b/templates/module/tests/module-ui.test.ts @@ -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 { // 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( + "[data-testid='example-open-records']", + ); + expect(toRecords?.getAttribute("href")).toBe("/example/records"); + container.querySelector("[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( + "[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)(); + 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); From ae360ef3187bc5c6f5ae2c452402a08da8a7572c Mon Sep 17 00:00:00 2001 From: ekklesia Date: Thu, 15 Oct 2026 08:23:53 -0500 Subject: [PATCH 2/3] Keep module pages indented beneath their module in the sidebar Co-Authored-By: Claude Opus 5.5 --- surfaces/shell/src/browser/ShellNavigation.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/surfaces/shell/src/browser/ShellNavigation.tsx b/surfaces/shell/src/browser/ShellNavigation.tsx index 560ea1a..84e8e12 100644 --- a/surfaces/shell/src/browser/ShellNavigation.tsx +++ b/surfaces/shell/src/browser/ShellNavigation.tsx @@ -153,7 +153,9 @@ function ModuleItems(props: { aria-label={entry.name} className="ek-shell-nav__group ek-shell-nav__nested" data-testid={`module-subnavigation-${moduleId}`} - style={{ listStyle: "none", margin: 0, padding: 0 }} + // The shared nested indentation (as Settings uses) comes from the class; only the + // list marker is reset here. + style={{ listStyle: "none" }} > {entry.pages.map((page) => ( Date: Thu, 15 Oct 2026 09:13:03 -0500 Subject: [PATCH 3/3] Make the module navigation capability an optional context member ModuleUiHostContext.navigation is now `navigation?: ModuleUiNavigation | undefined`, so the public context change is source-compatible: a context built before navigation existed (without the member) still compiles unchanged, as 0.x patch releases require. The Dashboard supplies navigation; hosts without addressable pages, including the display host, omit the member; modules feature-detect it. Existing hosts' tests are restored to their pre-PR shape, and a module-ui-sdk test proves a pre-navigation context still satisfies ModuleUiHostContext (and fails if the member is ever made required). Runtime behaviour, manifest v8/v9 compatibility, and security rules are unchanged. ADR 0063 and the module development guide say so. Co-Authored-By: Claude Opus 5.5 --- apps/api/tests/module-ui-host-e2e.test.ts | 1 - apps/api/tests/module-ui-routes.test.ts | 1 - ...-module-navigation-contract-manifest-v9.md | 16 +++++--- docs/module-development/README.md | 7 ++-- .../tests/load-module-ui.test.ts | 1 - .../tests/real-artifact.test.ts | 2 - packages/module-ui-sdk/src/host-context.ts | 10 +++-- .../tests/module-ui-contract.test.ts | 2 - .../module-ui-sdk/tests/navigation.test.ts | 37 +++++++++++++++---- surfaces/shell/src/module-context.ts | 6 +-- surfaces/shell/tests/display-app.test.tsx | 5 +-- surfaces/shell/tests/host-protocol.test.ts | 8 ++-- templates/module/tests/module-ui.test.ts | 1 - 13 files changed, 60 insertions(+), 37 deletions(-) diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index 5e9a0ef..f73af62 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -479,7 +479,6 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () "contributions", "display", "moduleId", - "navigation", "permissions", "principal", "realtime", diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index 2cd768f..bee6f08 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -953,7 +953,6 @@ describe("module UI artifact publication — the generic loader consumes what is contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId: "announcements", - navigation: undefined, permissions: { has: () => false }, principal: { kind: "anonymous" as const }, realtime: { subscribe: () => ({ close: () => undefined }) }, diff --git a/docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md b/docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md index 3574ec2..55c1f0e 100644 --- a/docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md +++ b/docs/architecture/decisions/0063-module-navigation-contract-manifest-v9.md @@ -176,8 +176,14 @@ The shell parses both strictly. ### The module's navigation capability `ModuleUiHostContext.navigation` (`@ekklesia/module-ui-sdk`) is a narrow host capability, not a -router. It is `undefined` where a host has no addressable pages: the display host, and a standalone -host without routes. +router, and an **optional** member (`navigation?: ModuleUiNavigation`). A host with addressable +module pages (Ekklesia's Dashboard) supplies it. A host without them (the display host, and a +standalone host without routes) omits it, and may also set it to `undefined`. + +The member is optional so that adding it breaks no one. Ekklesia's 0.x versioning makes patch +releases compatible, and a context built before navigation existed must stay a complete, valid +context that compiles unchanged. A `@ekklesia/module-ui-sdk` test holds exactly that, and fails if +the member is ever made required. ```ts interface ModuleUiNavigation { @@ -208,7 +214,7 @@ interface ModuleUiNavigation { rebuilt against the new SDK can already link to its Settings page. Adding a context member changes nothing about how an artifact is mounted, so the mount ABI stays at -version 2. A module that uses `navigation` checks for it, exactly as it checks `display`. +version 2. A module that uses `navigation` feature-detects it (`context.navigation?.navigate(…)`). ### Return-to-origin: one carrier, the `return` query parameter @@ -262,8 +268,8 @@ parameter from it. - **Packages.** `@ekklesia/contracts` gains `module-navigation.ts`, the v8/v9 manifest union, and `moduleManifestNavigation`. `@ekklesia/module-ui-sdk` gains `navigation.ts` and `ModuleUiHostContext.navigation`. `@ekklesia/module-sdk` re-exports the navigation helpers and - types. All changes are additive for module code. A host that builds a context itself must now say - `navigation: undefined`. + types. Every change is additive at the source level as well as at run time: existing module code, + and an existing host that builds a context itself, compile and run unchanged. - **Core** publishes each module's navigation beside its name. The navigation response gains a `navigation` member per module. - **The shell** reads module routes at any depth, draws icons and pages, and supplies the diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 8c707c3..ba41552 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -720,7 +720,8 @@ artifact is the role selection. ### What the host gives you -`mount` receives exactly eight members, and nothing else is available: +`mount` receives these members, and nothing else is available. `navigation` is optional: a host that +has no addressable pages leaves it out, so feature-detect it. | Member | What it is | | --- | --- | @@ -731,7 +732,7 @@ artifact is the role selection. | `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) | | `contributions` | Renderers other modules contributed to capabilities **you** provide — see [Browser contributions](#browser-contributions) | | `display` | Your display page's URL, or `undefined` when you publish none | -| `navigation` | Where your module is, and how it moves between its own pages and to its Settings page — see [Module navigation](#module-navigation). `undefined` on the display host | +| `navigation` | Where your module is, and how it moves between its own pages and to its Settings page — see [Module navigation](#module-navigation). Optional: absent on the display host | Module configuration, organization context, and diagnostics reporting are **not** in the contract. They need designs that do not exist, and a speculative field frozen into a public contract is harder @@ -795,7 +796,7 @@ A refresh, a bookmark, and Back and Forward all land on the same route. never build a Dashboard URL yourself: ```tsx -const { navigation } = context; // undefined on the display host +const { navigation } = context; // optional: absent on the display host navigation?.location(); // { path: "media/sermons/123", page: "media" } navigation?.subscribe(() => rerender()); // every move: yours, the sidebar's, Back and Forward navigation?.navigate("timelines"); // or { replace: true } to respell the current entry diff --git a/packages/module-ui-host/tests/load-module-ui.test.ts b/packages/module-ui-host/tests/load-module-ui.test.ts index f9a8375..ef9535f 100644 --- a/packages/module-ui-host/tests/load-module-ui.test.ts +++ b/packages/module-ui-host/tests/load-module-ui.test.ts @@ -54,7 +54,6 @@ function hostContext(requested: string[], moduleId = "announcements"): ModuleUiH contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId, - navigation: undefined, api: { async request(path) { requested.push(path); diff --git a/packages/module-ui-host/tests/real-artifact.test.ts b/packages/module-ui-host/tests/real-artifact.test.ts index 9adc8f7..0b1e391 100644 --- a/packages/module-ui-host/tests/real-artifact.test.ts +++ b/packages/module-ui-host/tests/real-artifact.test.ts @@ -35,7 +35,6 @@ function ekklesiaContext(requested: string[]): ModuleUiHostContext { contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId: "example", - navigation: undefined, api: { async request(pathname) { requested.push(`/api/v1/modules/example/${pathname.replace(/^\//u, "")}`); @@ -54,7 +53,6 @@ function standaloneContext(requested: string[]): ModuleUiHostContext { contributions: NO_MODULE_UI_CONTRIBUTIONS, display: { url: "https://example.test/display" }, moduleId: "example", - navigation: undefined, api: { async request(pathname) { requested.push(`https://example.test/api/${pathname.replace(/^\//u, "")}`); diff --git a/packages/module-ui-sdk/src/host-context.ts b/packages/module-ui-sdk/src/host-context.ts index 3762462..27f6c61 100644 --- a/packages/module-ui-sdk/src/host-context.ts +++ b/packages/module-ui-sdk/src/host-context.ts @@ -108,11 +108,13 @@ export interface ModuleUiHostContext { * Where this module is and how it moves between its own pages, and to its Settings page * (ADR 0063). See {@link ModuleUiNavigation}. * - * `undefined` where the host has no addressable pages for it — the full-viewport display host, - * which shows one screen, and any standalone host without routes. A module that offers - * navigation checks for it, exactly as it checks `display`. + * **Optional**, so adding it broke no existing host: a context built before navigation existed + * is still a complete context. A host with addressable module pages — Ekklesia's Dashboard — + * supplies it; a host without them — the full-viewport display host, which shows one screen, or a + * standalone host without routes — omits it (or sets it to `undefined`). A module feature-detects + * it: `context.navigation?.navigate("timelines")`. */ - readonly navigation: ModuleUiNavigation | undefined; + readonly navigation?: ModuleUiNavigation | undefined; } export interface ModuleUiDisplayLocation { diff --git a/packages/module-ui-sdk/tests/module-ui-contract.test.ts b/packages/module-ui-sdk/tests/module-ui-contract.test.ts index 1451d4d..525a646 100644 --- a/packages/module-ui-sdk/tests/module-ui-contract.test.ts +++ b/packages/module-ui-sdk/tests/module-ui-contract.test.ts @@ -188,7 +188,6 @@ describe("the same module UI artifact hosted by two different hosts", () => { contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId, - navigation: undefined, api, principal: { kind: "anonymous" }, permissions: { has: () => canPublish }, @@ -215,7 +214,6 @@ describe("the same module UI artifact hosted by two different hosts", () => { contributions: NO_MODULE_UI_CONTRIBUTIONS, display: { url: "https://announcements.example.org/display" }, moduleId, - navigation: undefined, api, principal: { kind: "anonymous" }, permissions: { has: () => canPublish }, diff --git a/packages/module-ui-sdk/tests/navigation.test.ts b/packages/module-ui-sdk/tests/navigation.test.ts index 6ee4fce..6904837 100644 --- a/packages/module-ui-sdk/tests/navigation.test.ts +++ b/packages/module-ui-sdk/tests/navigation.test.ts @@ -3,6 +3,7 @@ import { type ModuleUiHostContext, type ModuleUiNavigation, ModuleUiNavigationError, + NO_MODULE_UI_CONTRIBUTIONS, } from "../src/index.js"; /** The navigation capability's public surface (ADR 0063). Its behaviour is the host's: the @@ -22,11 +23,26 @@ describe("module UI navigation contract", () => { } }); - it("makes navigation a context member a host without pages leaves undefined", () => { - // A type-level contract: a context may say it has no navigation, as the display host does, and - // a module reads it through an optional check. - const describe = (context: Pick): string => - context.navigation?.location().path ?? "no navigation"; + it("keeps a context built before navigation existed a complete, valid context", () => { + // Exactly the seven members every host supplied before ADR 0063, with no `navigation` member at + // all. This must keep compiling unchanged: `tsc` over this file is the compatibility proof, and + // `satisfies` makes the check exact rather than structural-by-accident. + const preNavigationContext = { + api: { request: async () => new Response(null, { status: 204 }) }, + contributions: NO_MODULE_UI_CONTRIBUTIONS, + display: undefined, + moduleId: "announcements", + permissions: { has: () => false }, + principal: { kind: "anonymous" }, + realtime: { subscribe: () => ({ close: () => undefined }) }, + } satisfies ModuleUiHostContext; + const asContext: ModuleUiHostContext = preNavigationContext; + + expect("navigation" in asContext).toBe(false); + expect(asContext.navigation?.location().path).toBeUndefined(); + }); + + it("lets a host supply navigation, omit it, or set it to undefined, and a module feature-detect it", () => { const navigation: ModuleUiNavigation = { link: () => ({ href: "/m/x", onClick: () => undefined }), location: () => ({ page: "x", path: "x" }), @@ -34,7 +50,14 @@ describe("module UI navigation contract", () => { settings: undefined, subscribe: () => () => undefined, }; - expect(describe({ navigation: undefined })).toBe("no navigation"); - expect(describe({ navigation })).toBe("x"); + const where = (context: Pick): string => + context.navigation?.location().path ?? "no navigation"; + + expect(where({ navigation })).toBe("x"); + expect(where({})).toBe("no navigation"); + expect(where({ navigation: undefined })).toBe("no navigation"); + // Present, it must be the real capability — not any object. + // @ts-expect-error a navigation member must satisfy ModuleUiNavigation + expect(where({ navigation: { location: () => "x" } })).toBeDefined(); }); }); diff --git a/surfaces/shell/src/module-context.ts b/surfaces/shell/src/module-context.ts index a904867..0431ae1 100644 --- a/surfaces/shell/src/module-context.ts +++ b/surfaces/shell/src/module-context.ts @@ -274,7 +274,7 @@ export interface CreateModuleUiContextOptions { * renderer without a real network. Production composition leaves it unset. */ readonly importModule?: (url: string) => Promise; /** The module's navigation capability (ADR 0063), from a host whose pages are addressable — the - * Dashboard. Omitted, the context carries `navigation: undefined`. */ + * Dashboard. Omitted, the context omits the member, as it did before navigation existed. */ readonly navigation?: ModuleUiNavigation; } @@ -361,8 +361,8 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo moduleId: options.descriptor.id, permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }), // Only a host with addressable module pages supplies navigation (ADR 0063); the display host - // shows one screen and supplies none. - navigation: options.navigation, + // shows one screen and omits the member entirely, exactly as before navigation existed. + ...(options.navigation === undefined ? {} : { navigation: options.navigation }), principal: options.principal, realtime: createModuleRealtimeClient(options.config, options.descriptor), }); diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index 24453cf..a289339 100644 --- a/surfaces/shell/tests/display-app.test.tsx +++ b/surfaces/shell/tests/display-app.test.tsx @@ -251,13 +251,12 @@ describe("display host — full-viewport boundary", () => { "contributions", "display", "moduleId", - "navigation", "permissions", "principal", "realtime", ]); - // A display is one screen with no pages: the display host offers no navigation (ADR 0063). - expect(seenContext?.navigation).toBeUndefined(); + // A display is one screen with no pages: the display host omits navigation (ADR 0063). + expect(seenContext && "navigation" in seenContext).toBe(false); expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" }); expect(isolatedText(query("display-module-container"))).toBe("permitted"); expect( diff --git a/surfaces/shell/tests/host-protocol.test.ts b/surfaces/shell/tests/host-protocol.test.ts index a8271a5..65414bc 100644 --- a/surfaces/shell/tests/host-protocol.test.ts +++ b/surfaces/shell/tests/host-protocol.test.ts @@ -569,7 +569,7 @@ describe("module UI host context", () => { expect(context.display?.url).toBe("https://api.example.test/display/example"); }); - it("exposes exactly the eight contract members and the browser principal", () => { + it("exposes exactly the seven contract members and the browser principal", () => { const { implementation } = recordingFetch(); const context = contextFor(implementation); @@ -579,14 +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(); + // Navigation comes only from a host with addressable pages (ADR 0063). Without it the member is + // omitted, so the context is exactly the shape it was before navigation existed. + expect("navigation" in context).toBe(false); expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" }); }); diff --git a/templates/module/tests/module-ui.test.ts b/templates/module/tests/module-ui.test.ts index 1618e18..6dcc375 100644 --- a/templates/module/tests/module-ui.test.ts +++ b/templates/module/tests/module-ui.test.ts @@ -58,7 +58,6 @@ function recordingHost(options: { contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId: "example", - navigation: undefined, api, principal: { kind: "anonymous" }, permissions: { has: () => options.canRead },