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:
co-authored by
Claude Opus 5.5
parent
7447381c8f
commit
4671622b9d
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user