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
+25 -1
View File
@@ -9,7 +9,9 @@ Related: [ADR 0006](../architecture/decisions/0006-versioned-capabilities-and-co
(module database access), [ADR 0020](../architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md)
(module routing/lifecycle and migration execution),
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md)
(native module realtime), and
(native module realtime),
[ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)
(authenticated display hosting), and
[Module development](../module-development/README.md).
## Installed modules are trusted compiled backend code
@@ -47,6 +49,7 @@ cooperative. None of them constrains a module that is not:
| Permission declarations and the composed registry | Which permissions *this* module's own routes may name, checked when Core mounts them |
| Route validation, overlap rejection, body limits | Deterministic, unambiguous HTTP routing and request bounds |
| Scoped realtime channels, publishers, and subscriptions | Correct module-local state wiring, permission enforcement, and finite fanout — not process isolation |
| Display declaration, permission, and exact publication record | Correct role selection and normal authorization for an eligible/running module — not browser isolation |
| `Object.freeze` on contexts and services | Prevents accidental mutation; a module can still reach the underlying objects |
| The `ModuleDatabase` facade | A typed, ergonomic surface — not a privilege reduction, since `execute` is arbitrary SQL |
| Capability registry | Explicit inter-module wiring, not access control |
@@ -155,6 +158,27 @@ shell owns the container, the loading state, and every error, so a failing modul
rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm
JavaScript, exactly as described above.
### A display page is authorized; its artifact bytes are not authority
Manifest version 6 binds a display artifact to one permission declared by the same module
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). The declaration rejects
a `public` baseline: the current display host requires an ordinary authenticated cookie principal,
and public/device identity is not approximated with an anonymous exception. Both the stable
`/display/<module-id>` page and `/api/v1/module-displays/<module-id>` descriptor evaluate that exact
permission through the normal Core evaluator. Client permission hints remain presentation only.
The versioned display artifact is anonymous code just like primary UI code. It is served only from
the frozen eligible/running publication registry, by exact module identity and version, and never
falls back to primary UI or opens the module directory. Fetching it grants no module HTTP, realtime,
storage, or data authority. Disabled and dependency-blocked modules receive no page, descriptor, or
artifact route; this is operational exposure control, not sandboxing.
The full-viewport host is a separate same-realm surface, not an iframe or containment boundary. It
uses normal cookie authorization, the same scoped API/realtime context, and opaque signed object URLs
without logging, proxying, extending, or re-signing them. It does not invoke the browser Fullscreen
API. Display inventory, device credentials, public principals, variants, and targeting are deferred
rather than silently granted.
### Dashboard arrangement is not an access control
Dashboard groups and placements are presentation configuration
+18
View File
@@ -275,6 +275,24 @@ enablement. During realtime development, run the focused live Node suite repeate
`pnpm verify` and `pnpm test:migrations:integration`; consecutive runs are specifically intended to
catch retained timers, listeners, subscribers, or loopback servers.
Phase 7A pass 6 adds display evidence across the same boundaries
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)). Contract tests prove the
manifest v6 hard cutover, optional role combinations, module-owned non-public display permission,
and collision rejection. Discovery tests prove inert compiled-artifact validation, containment,
symlink collision detection, deterministic diagnostics, and that modules without a display remain
valid. Builder tests prove the exact `src/ui/index.tsx` / `src/display/index.tsx` sources,
deterministic role order, preflight/compile atomicity, no runtime-entrypoint overwrite, and identical
browser/CSS boundaries.
Core/API tests prove independently frozen primary and display publication records, exact anonymous
artifact routing, permission-gated stable page and descriptor routes, eligible/running lifecycle
filtering, sanitized failures, and startup-time frozen host assets. Surface tests prove principal-
first descriptor bootstrap, the role-neutral host context, full-viewport mounting without Dashboard
chrome or static module imports, late-import cleanup, manual retry, realtime reuse, and unmount.
The canonical template builds and mounts both artifacts through `parseModuleUiExport`; contract
fixtures also retain no-display and backend-only cases. These are code-only tests: display hosting
adds no PostgreSQL, object-store, or Docker requirement beyond the existing suites.
### Which kind of timing proof lives where
Pass 4 introduced several deadlines, and their tests deliberately differ in kind: