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
@@ -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
@@ -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)
@@ -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<NodePgDatabase, …>`, a facade over
Core's Drizzle instance, and its rule that every module receives the same `services` object.
@@ -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),
@@ -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),
@@ -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),
@@ -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.** `/<module-id>` 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/<module-id>`, 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 `/<module-id>/`
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
/<module-id> the module's landing view (its root)
/<module-id>/<page-path> a declared primary page
/<module-id>/<page-path>/… 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 `/<module-id>` 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 `/<module-id>/*` (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.
+1
View File
@@ -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/<module-id>`](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
+11 -1
View File
@@ -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 `<head>` 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
+14
View File
@@ -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
+23
View File
@@ -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
+27
View File
@@ -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:
+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: