feat: add display kiosk host
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user