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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-15 08:21:50 -05:00
co-authored by Claude Opus 5.5
parent 7447381c8f
commit 4671622b9d
69 changed files with 4221 additions and 372 deletions
+121 -21
View File
@@ -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 `<module-id>.`, 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 |
| --- | --- |
| `/<module-id>` | 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. |
| `/<module-id>/<page-path>` | A declared page. |
| `/<module-id>/<page-path>/…` | 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");
<a href={link.href} onClick={link.onClick}>Sermon</a> // 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/<module-id>` (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 <a>
```
The host carries where your module is as the place to come back to
(`/settings/<module-id>?return=%2F<module-id>%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: