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
@@ -0,0 +1,224 @@
# 0033: Display UI is a separate, permission-gated module entrypoint
- Status: Accepted
- Date: 2026-08-23
## Context
ADR 0021 through ADR 0027 established one optional module-owned browser artifact,
`entrypoints.ui`, and an Ekklesia Dashboard shell that discovers, publishes, and mounts it. That
contract is appropriate for a module's ordinary product interface: Announcements management,
calendar editing, settings, or operational controls.
A long-lived screen is a different presentation context. An announcement TV, lobby sign,
confidence monitor, or kiosk needs a clean viewport, no Dashboard navigation, and a stable bookmark.
Forcing that experience through the Dashboard would either expose management chrome on the screen or
make one large artifact branch on an Ekklesia-specific mode. Neither shape transfers cleanly to the
future one-module standalone host.
The real Announcements client pressure-tests the boundary. Its slideshow wants an authoritative
snapshot, subsequent state updates, direct image URLs, a black mode-off state, and local fade
transitions. It does not need Dashboard navigation or a second transport. Its current fixed LAN
addresses, anonymous Socket.IO connection, global socket, and incomplete teardown are migration
concerns, not contracts Core should copy.
## Decision
### A module may declare one separate display artifact
Manifest version 6 adds this optional declaration while retaining `entrypoints.ui` unchanged:
```json
{
"manifestVersion": 6,
"permissions": [
{
"id": "announcements.display.view",
"baseline": "restricted",
"description": "View the announcement display."
}
],
"entrypoints": {
"server": "./dist/server/index.js",
"ui": "./dist/ui/index.js",
"display": {
"artifact": "./dist/display/index.js",
"permission": "announcements.display.view"
}
}
}
```
`ui` remains the module's primary/management artifact and is the only role eligible for Dashboard
navigation. `display` is a full-viewport artifact and never implies Dashboard placement. Either
field may be absent, so a module may provide primary UI only, display only, both, or neither.
The display declaration carries its permission beside its artifact. The permission must be declared
and owned by that module, and its baseline must be `authenticated` or `restricted`, never `public`.
This makes it impossible to declare a display without an explicit access policy, silently reuse a
management permission chosen by Core, or introduce anonymous display access before a deliberate
device/public principal exists. The display artifact must differ from every other configured
entrypoint before and after canonical path resolution.
One display artifact per module is deliberate. Named variants such as lobby, stage, or confidence
display are deferred until a real module requires them; a generic display registry now would create
identity, routing, permission, and management contracts without a consumer.
Version 6 is a hard cutover. Version 5's strict `entrypoints` object did not admit `display`, so
silently accepting the new field under the old version would allow one version number to mean two
schemas. A module with no display upgrades by changing only the version literal.
### Discovery and build remain static
Discovery reads the manifest without importing browser code. The declared display artifact obeys
the same package containment, symlink, real-file, and compiled `.js`/`.mjs` rules as primary UI.
Complete discovery still succeeds or fails atomically before any runtime entrypoint executes.
The canonical module UI builder reads both declarations and builds exactly what is declared in one
invocation:
- `entrypoints.ui`, when present, from `src/ui/index.tsx`;
- `entrypoints.display.artifact`, when present, from `src/display/index.tsx`.
It validates every source and output before starting either build. The artifacts use the same
browser boundary and bundling policy from ADR 0023. No CSS sidecar, router, or alternate React
runtime contract is introduced.
### Both artifacts use the existing module UI ABI and context
A display artifact default-exports the same `defineModuleUi({ id, version, mount })` definition and
returns the same mount cleanup. The generic loader still validates identity and creates one
independent mounted instance per call. No display-specific ABI exists.
`ModuleUiHostContext` also remains unchanged: `moduleId`, the module-relative API client, principal,
permission hints, and realtime are sufficient. A `presentation.kind` flag is intentionally omitted.
The distinct static artifact already tells module code which product experience it implements; a
mode flag would encourage the single branching artifact this decision rejects. A module may share
ordinary internal components between its two sources and parameterize those components itself.
Nothing exposes a database, storage provider, S3 credential, bucket name, session token, server
path, or Ekklesia route. A standalone host can provide the exact same ABI and context at a different
outer URL.
### Publication distinguishes the role exactly
The existing eligible/running publication registry gains a display view. Primary UI keeps its
existing URL unchanged. The display artifact has one exact role-specific URL:
```text
/api/v1/module-ui/<module-id>/<version>/display
```
There is no request-controlled role, filename, or package path. The publisher reads only the exact
canonical path discovery accepted. A source map, sibling chunk, management artifact, and every
other package file remain unreachable. Artifact bytes keep ADR 0025's anonymous-code policy:
downloading trusted browser code grants no authority, while failures remain indistinguishable to
avoid enumeration. Authorization occurs before the host discloses a descriptor or mounts that code.
### Ekklesia owns a stable, permission-gated outer route
Ekklesia registers these exact routes only for eligible, running display publications:
```text
GET /display/<module-id>
GET /api/v1/module-displays/<module-id>
```
The first is the version-independent bookmark a device keeps across module upgrades. The second is
the singular bootstrap descriptor containing the current versioned display artifact URL, the
module's API and realtime bases, and that principal's module-scoped permission hints. There is no
display inventory or management UI in this pass.
Both routes require the manifest's module-owned display permission through the existing identity,
permission registry, and authorization evaluator. There is no anonymous exception. A future
device/kiosk/public-display principal is a separate security decision; until then, a display uses an
ordinary authenticated session and explicit permission.
Routes are composed from the eligible set after successful module startup. A dashboard-only,
disabled, dependency-blocked, failed, or unknown module has no display route or descriptor. A
running module remains available until process restart under ADR 0028; this decision adds no hot
disable behavior.
### The display browser root is separate from Dashboard chrome
`surfaces/shell` builds a second top-level browser application for the display route. It does not
render `HostApp`, Dashboard navigation, a header, sidebar, breadcrumbs, or operational controls, and
Dashboard code is not statically imported into its bundle. Keeping both applications in the one
surface package reuses the security-sensitive principal/API/realtime adaptation already there;
creating `surfaces/display` would either duplicate it or require a broader shared-package extraction,
while repository boundaries correctly forbid one surface from importing another.
The page gives its mount root the whole viewport, removes default margin and padding, hides document
overflow, and otherwise does not theme module output. Full-screen means **full viewport**. The host
does not invoke the browser Fullscreen API; OS/browser kiosk launch policy belongs to deployment.
The host owns neutral loading and sanitized failure output, clears partial DOM after a failed mount,
and provides a deliberate retry without a tight automatic loop. It aborts stale bootstrap work,
immediately unmounts a load that resolves after teardown, and swallows cleanup failure only while
teardown is already in progress. Multiple tabs and physical screens create independent loader,
context, and realtime instances; no browser-global module state is introduced by the host.
Some frameworks report a render failure only after their `mount` call has returned. Because the
display route is a dedicated page containing exactly one module artifact, an uncaught error targeted
at `window` or an unhandled promise rejection conservatively fails and unmounts that display attempt.
Element-targeted media/resource errors do not: those remain module state. There is no portable way to
attribute a global exception to a framework tree, and errors a module catches internally remain that
module's responsibility.
The surface's exact HTML and JavaScript files are read once during API startup and served from
frozen in-memory text at fixed routes. The API never exposes the surface output directory, derives a
file from request input, serves its source map, or interpolates a module id into HTML. A missing host
build fails startup before the listener binds.
### Existing delivery and realtime contracts are reused
Display UI calls `context.realtime.subscribe("state", ...)`. The existing SSE host supplies the
immediate authoritative snapshot, native reconnect, fresh post-reconnect snapshot, bounded delivery,
and cleanup defined in ADR 0032. No Socket.IO, WebSocket, MQTT, display-specific channel, or retry
transport is added.
If state contains opaque signed object URLs from ADR 0031, display UI assigns those URLs directly to
browser media elements. The display host does not proxy, inspect, rewrite, renew, or regenerate
them. The module remains responsible for publishing refreshed state before URLs expire. A temporary
storage failure is therefore module state/error policy, not a host storage abstraction.
## Consequences
- Management and display products evolve independently without a Dashboard mode branch.
- A TV bookmark survives module version upgrades while the internal artifact URL remains exact and
versioned.
- Display-only modules remain absent from Dashboard navigation.
- Existing trusted-code and same-realm risks remain: the display artifact is reviewed installed
code, not a sandbox.
- Authentication session expiry and the SSE connection-age bound eventually re-run server
authorization. This pass does not invent unattended-device credentials.
- A module may be mounted by many browser instances; per-device targeting and identity are deferred.
- The future standalone host may expose `/display` instead of Ekklesia's outer route without any
module change.
## Rejected alternatives
- **Reuse one artifact with an Ekklesia display-mode flag.** It couples portable module UI to this
host and conflates management with output.
- **Hide Dashboard chrome with CSS.** The navigation and management application would still be the
containing product and failure boundary.
- **Make every authenticated primary-UI viewer a display viewer.** Display access deserves its own
explicit module permission.
- **Make displays anonymous to match the legacy Announcements client.** That is an authorization
bypass, not hosting. A device/public principal needs a later threat model and lifecycle.
- **Expose a module or surface directory as static files.** It turns request paths into filesystem
selectors and defeats discovery's exact-artifact trust decision.
- **Add a second realtime transport or Companion controls.** Display consumes state; Companion's
future actions, variables, and feedbacks are a separate capability contract.
- **Invoke the Fullscreen API.** It requires a user gesture and confuses page layout with deployment
kiosk policy.
## Deferred
- device registration, pairing, credentials, and public-display policy;
- a display inventory and management Dashboard;
- named display variants and per-device targeting;
- the reusable one-module standalone host;
- generic CSS asset publication or isolation;
- an Announcements module migration;
- Companion action/variable/feedback contracts.
+1
View File
@@ -41,3 +41,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0030: Modules declare logical buckets; hosts provide object storage](0030-host-provided-module-object-storage.md) — Accepted
- [0031: Browsers fetch objects directly, through opaque temporary read URLs](0031-direct-object-delivery.md) — Accepted
- [0032: Native module realtime is scoped snapshot state over SSE](0032-native-module-realtime-transport.md) — Accepted
- [0033: Display UI is a separate, permission-gated module entrypoint](0033-display-kiosk-host-contract.md) — Accepted