feat: add display kiosk host
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s

This commit is contained in:
ekklesia
2026-08-23 20:50:56 -05:00
parent be5ed7853d
commit 2e0fe059c3
80 changed files with 4868 additions and 397 deletions
+88 -5
View File
@@ -81,6 +81,9 @@ No real values are shown here — configure per environment and never commit the
| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
| `GET /api/v1/module-ui/<module-id>/<version>` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). |
| `GET /api/v1/module-ui/<module-id>/<version>/display` | Ekklesia | Publishes the exact validated display artifact for that eligible/running module. Artifact bytes are anonymous trusted code; this route is not the authorization boundary. See [Module display host](#module-display-host). |
| `GET /display/<module-id>` | Ekklesia | Stable, full-viewport display page with no Dashboard chrome. Requires the module-owned permission declared beside `entrypoints.display`. See [Module display host](#module-display-host). |
| `GET /api/v1/module-displays/<module-id>` | Ekklesia | Protected descriptor for one display page: exact artifact, module API/realtime roots, and presentation-only permission hints. Requires that display's module-owned permission. See [Module display host](#module-display-host). |
| `GET /api/v1/module-lifecycle` | Ekklesia | Which installed modules are enabled, what this process actually did with each, and whether a restart is pending. Requires `core.modules.lifecycle.manage` (baseline `restricted`). See [Module lifecycle](#module-lifecycle). |
| `PUT /api/v1/module-lifecycle/<module-id>/enabled` | Ekklesia | Enable or disable one installed module. Takes effect at the next API start. Requires `core.modules.lifecycle.manage` and a trusted `Origin`. See [Module lifecycle](#module-lifecycle). |
| `GET /api/v1/module-configuration/<module-id>` | Ekklesia | One module's declared configuration schema, its stored values, and whether a restart is pending. Never returns a secret value. Requires `core.modules.configuration.manage` (baseline `restricted`). See [Module configuration](#module-configuration). |
@@ -990,7 +993,8 @@ root of the same resource the artifact route below serves: one prefix, two repre
prefix, which lets one change without touching host or module code.
- **The module set is the publication registry**, not a rescan: modules that survived discovery and
declare `entrypoints.ui`, captured after the module runtime started. A module with no UI is absent.
Order is the registry's own deterministic discovery order.
Order is the registry's own deterministic discovery order. Dedicated display artifacts are not
merged into this primary-UI inventory; this pass deliberately adds no display enumeration API.
- **Authenticated, unlike the artifact route.** Requires `core.modules.ui.read`, baseline
`authenticated` — any signed-in user, and nobody else. The artifact bytes are anonymous because
fetching them confers no authority, but that route is deliberately not an enumeration oracle, and
@@ -1027,7 +1031,9 @@ GET /api/v1/module-ui/<module-id>/<version>
joined, resolved, or concatenated into a filesystem path, and the route declares no wildcard
segment — so traversal, encoded separators, sibling bundle chunks, package-root files, and source
maps are unreachable by construction. **An installed module directory is never a static file
root**, and only the single artifact its manifest declares is ever served.
root**, and only the single primary artifact its manifest declares is ever served. A separately
declared display artifact uses the exact sibling route described below; neither role can select
the other's file.
- **A sibling of `/api/v1/modules/`, never a child.** A module may legitimately register the route
path `/ui/:version`, so nesting publication inside the module prefix would let a module and the
platform compete for one URL with routing order deciding the winner.
@@ -1058,9 +1064,86 @@ construction of this URL. It accepts identity only and percent-encodes both comp
root-relative, so a same-origin host uses it unchanged and a split-origin host resolves it against
the same API base URL it already needs for `ModuleUiApiClient`.
**Nothing mounts a module UI yet.** A host can now obtain a browser URL that
`@ekklesia/module-ui-host` accepts, which was previously impossible — but Ekklesia has no Dashboard
shell, no navigation, and no code that calls this route outside its own tests.
Ekklesia's Dashboard surface consumes the primary inventory and mounts these URLs through
`@ekklesia/module-ui-host`. The dedicated display surface reuses the same loader and ABI but obtains
its role-specific URL only from the protected descriptor below.
## Module display host
Manifest version 6 adds one independently optional display declaration
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)):
```json
{
"entrypoints": {
"display": {
"artifact": "./dist/display/index.js",
"permission": "announcements.display.view"
}
}
}
```
That permission must be declared by the same module and have an `authenticated` or `restricted`
baseline; `public` is rejected because public/device display identity is deferred. The artifact path
uses the normal compiled, package-contained entrypoint rules and may not collide with server,
schema, migrations, jobs, or primary UI. A module may declare primary UI only, display only, both,
or neither.
For every display-capable module in the immutable eligible/running publication registry, API
composition installs three exact routes:
```text
GET /display/<module-id>
GET /api/v1/module-displays/<module-id>
GET /api/v1/module-ui/<module-id>/<version>/display
```
The first is a stable bookmark independent of the installed version. It serves the same frozen,
module-agnostic full-viewport HTML shell for every module, with no Dashboard application or chrome.
The second returns the only descriptor that shell needs:
```json
{
"module": {
"id": "announcements",
"version": "0.1.0",
"display": {
"url": "/api/v1/module-ui/announcements/0.1.0/display"
},
"api": { "baseUrl": "/api/v1/modules/announcements" },
"realtime": { "baseUrl": "/api/v1/module-realtime/announcements" },
"permissions": ["announcements.display.view"]
}
}
```
- **Page and descriptor authorization.** Both run the ordinary cookie principal through Core's one
evaluator for exactly the display permission stored in the validated publication record. There
is no query token, public exception, device principal, or separate authorization system. The
descriptor's `permissions` remains presentation-only; module HTTP and realtime routes still
enforce their own declared permissions.
- **Artifact publication.** The versioned `/display` suffix publishes exactly the canonical display
artifact, byte-for-byte, with the same MIME, `nosniff`, strong ETag, `no-cache`, sanitized `404`/
`503`, and anonymous-code policy as primary UI. It never falls back to `entrypoints.ui`, and no
request component becomes a filesystem path. Fetching code grants no API, realtime, or data
authority.
- **Lifecycle and restart.** Unknown, primary-only, disabled, dependency-blocked, and failed-startup
modules have no display routes and collapse into the normal sanitized `404`. Production reads the
exact display-host HTML and script once before binding, freezes them, and serves fixed paths rather
than a directory. Installation, manifest, enablement, and build changes take effect on restart.
- **Browser behavior.** The shell fetches principal first, then the protected descriptor, and only
then dynamically imports the supplied artifact. It supplies the same role-neutral
`ModuleUiHostContext` used by primary UI—API, principal, permissions, and realtime—with no mode
flag. Failures become fixed states with manual retry. The host fills the viewport but does not
request the browser Fullscreen API.
- **Unchanged data paths.** Realtime stays on the permission-gated SSE route above. Signed object
delivery URLs remain opaque bearer strings generated by module backend code and fetched directly
from storage; the display host neither proxies nor re-signs them.
There is deliberately no display inventory or management API. Named variants, targeting,
public/device identity, a standalone host, a shared stylesheet contract, Announcements migration,
and Companion integration remain future work.
## Module database access