Files
Ekklesia/docs/architecture/decisions/0033-display-kiosk-host-contract.md
T
ekklesia 2e0fe059c3
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s
feat: add display kiosk host
2026-08-23 20:50:56 -05:00

12 KiB

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:

{
  "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:

/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:

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.