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
+39 -6
View File
@@ -70,7 +70,8 @@ Every module-facing contract in this platform is written so that **full Ekklesia
thing that can satisfy it**. The intended second implementation is a reusable single-module
standalone host — conceptually `@ekklesia/standalone-host` — which loads exactly one module and
supplies the same host contracts Ekklesia does: configuration, secrets, object storage, HTTP, module
lifecycle, and realtime, with display/kiosk hosting still later.
lifecycle, and realtime. Ekklesia now also has a dedicated display surface, but the reusable
single-module standalone host remains later.
**No such package exists yet, and this pass does not create one.** It is recorded here because it is
what several contract decisions are *for*, and because a change that quietly makes it unimplementable
@@ -370,9 +371,10 @@ and nothing more; there is no runtime health-probe contract. Modules remain trus
disabling one is an operational control, never a sandbox. No installer exists.
Phase 7A pass 2 adds the module configuration and secrets contract
([ADR 0029](decisions/0029-module-configuration-and-secrets.md)). A module declares what it needs
configured in its manifest — `manifestVersion: 5`, a hard cutover — using six types (`string`,
`integer`, `boolean`, `enum`, `url`, `secret`) with bounded constraints and no nesting. Static
([ADR 0029](decisions/0029-module-configuration-and-secrets.md)). That pass introduced static
configuration with the `manifestVersion: 5` hard cutover; the current strict manifest is version 6
after ADR 0033. A module uses six declaration types (`string`, `integer`, `boolean`, `enum`, `url`,
`secret`) with bounded constraints and no nesting. Static
declaration is what lets the platform validate configuration **before** deciding whether a module may
start, so an unconfigured module is reported `blocked · configuration_missing` rather than taking
startup down from inside its own `start` hook; discovery, `pnpm modules:validate`, and Doctor all read
@@ -469,5 +471,36 @@ for requests already in flight. Reverse-order stop then deactivates each module'
immediately before that module's own stop hook, so dependencies remain operational while dependents
clean up and captured publishers cannot outlive their module. Direct object-delivery URLs remain
opaque payload strings and are never re-signed per publication or subscriber. MQTT remains an
external-control concern for a future Companion boundary, not an internal module bus. Display/kiosk
hosting and its public/device principal are explicitly not part of this pass.
external-control concern for a future Companion boundary, not an internal module bus.
Phase 7A pass 6 adds a dedicated display role and Ekklesia host
([ADR 0033](decisions/0033-display-kiosk-host-contract.md)). Manifest version 6 gives a module one
optional `entrypoints.display` object containing a compiled artifact and one module-owned permission.
The permission must have an `authenticated` or `restricted` baseline: public/device display identity
is still intentionally unsolved. The display artifact must be distinct from every other configured
entrypoint. Primary UI, display UI, both, and neither are all valid module shapes.
The two browser roles share the existing `defineModuleUi({ id, version, mount })` export and exactly
the same `ModuleUiHostContext`; role selection comes from separate artifacts, never a `mode`,
`presentation.kind`, or host-identity branch. The canonical builder reads only
`src/ui/index.tsx` and `src/display/index.tsx`, bundles React into each declared role, rejects CSS
sidecars, and publishes no output until every requested bundle has compiled. Core validates both
paths inertly and builds both publication records only from the dependency-resolved eligible/running
set; it never imports browser code.
Ekklesia keeps its primary Dashboard host unchanged and adds a separate full-viewport application.
For each running display-capable module, the API registers the stable page `/display/<module-id>`, a
protected descriptor at `/api/v1/module-displays/<module-id>`, and the exact display artifact at
`/api/v1/module-ui/<module-id>/<version>/display`. The page and descriptor use normal cookie identity
and the manifest's display permission; the artifact remains anonymous trusted code because bytes
confer no authority. There is no display inventory in this pass. The descriptor supplies the same
module API root, realtime root, principal-derived permission hints, and exact artifact identity the
generic loader already consumes. The surface adds no Dashboard chrome and does not request the
browser Fullscreen API; failures remain bounded behind a fixed state and manual retry.
Production reads the fixed display HTML and script once before binding the listener and serves that
frozen snapshot, never a directory. Enablement, dependency blocking, and runtime startup determine
whether routes exist, and installed-manifest changes remain restart-based. Realtime and direct signed
object URLs are unchanged and flow through the same portable contracts. Named display variants,
targeting, display inventory/management, device/public identity, a standalone host, shared CSS,
Announcements migration, and Companion integration remain later work.
@@ -36,6 +36,11 @@ These rules map platform ownership to filesystem and package boundaries. The exe
Node's global declarations — into browser code. Core still owns the containing
application shell and all platform policy, and module UI reaches the host only through
`@ekklesia/module-ui-sdk`.
- Manifest v6 permits independently optional primary (`entrypoints.ui`) and full-viewport display
(`entrypoints.display`) artifacts. Modules own both feature experiences; surfaces own their
containers, routes, navigation/chrome, credentials, error states, and publication wiring. The
fixed authoring sources are `src/ui/index.tsx` and `src/display/index.tsx`; both use the same
role-neutral UI SDK ABI and neither may import a surface or host loader.
- Module UI **asset publication** runs Core → `apps/api`, never the other way
([ADR 0025](decisions/0025-trusted-module-ui-asset-publication.md)). Core owns the policy
(`createModuleUiPublicationRegistry`, built only from already-validated `DiscoveredModule`
@@ -69,6 +74,10 @@ These rules map platform ownership to filesystem and package boundaries. The exe
mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader,
and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate
concern from `@ekklesia/module-ui-build`, which compiles a *module's* declared artifact.
- The display browser application is a separate surface entrypoint under `surfaces/shell`; it also
statically imports no module and mounts only the exact display descriptor/artifact supplied by the
API. It contains no Dashboard chrome and does not move display rendering into the surface—the
module still owns that renderer.
- Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or
surface internals.
+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:
+146 -68
View File
@@ -9,11 +9,12 @@ platform context, the application shell, top-level routing, navigation, the glob
design-token contract, error boundaries, and trusted-origin/CORS policy are Core's and the host
application's. A module consumes those; it never redefines or bypasses them.
> **What exists today:** you can author a module UI in React, build it into the compiled artifact your
> manifest declares, have Core validate it, have Ekklesia publish it at a browser URL, and have
> Ekklesia's browser shell discover and mount it — the loop closes end to end. **What does not exist
> is the Dashboard**: no navigation, grouping, icons, ordering, theme, or design system, and no
> standalone host. See [Declaring a UI entrypoint](#declaring-a-ui-entrypoint),
> **What exists today:** you can author independently optional primary and full-viewport display UI
> roles in React, build their declared artifacts, have Core validate and publish them, and have
> Ekklesia mount each in its proper browser surface. The primary shell has flat configurable
> navigation; the display shell has no Dashboard chrome. A finished design system, display
> inventory/management, public/device identity, and the standalone host do not exist. See
> [Declaring UI entrypoints](#declaring-ui-entrypoints),
> [How Ekklesia publishes your artifact](#how-ekklesia-publishes-your-artifact), and
> [What actually mounts your UI](#what-actually-mounts-your-ui).
@@ -38,12 +39,16 @@ modules/example/
│ └── _journal.json
├── dist/schema/index.js # when the module owns tables
├── dist/server/index.js
├── dist/ui/index.js # when the module declares primary UI
├── dist/display/index.js # when the module declares a display
├── drizzle.config.ts # when generating migrations
├── ekklesia.module.json
├── package.json
├── src/schema/index.ts # when the module owns tables
├── src/schema/tables.ts # pure Drizzle declarations used by Drizzle Kit
└── src/server/index.ts
├── src/server/index.ts
├── src/ui/index.tsx # fixed primary-UI build source, when declared
└── src/display/index.tsx # fixed display build source, when declared
```
The schema and migration paths are optional. A tableless module omits `entrypoints.schema`; a
@@ -68,7 +73,7 @@ without executing module code:
```json
{
"manifestVersion": 4,
"manifestVersion": 6,
"id": "calendar",
"name": "Calendar",
"version": "0.1.0",
@@ -102,6 +107,11 @@ without executing module code:
"id": "calendar.events.read",
"baseline": "restricted",
"description": "Read calendar events."
},
{
"id": "calendar.display.view",
"baseline": "authenticated",
"description": "View the calendar display."
}
],
"storage": {
@@ -115,7 +125,12 @@ without executing module code:
"entrypoints": {
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js",
"migrations": "./drizzle"
"migrations": "./drizzle",
"ui": "./dist/ui/index.js",
"display": {
"artifact": "./dist/display/index.js",
"permission": "calendar.display.view"
}
}
}
```
@@ -125,12 +140,12 @@ has its own exact semantic version because its public contract can evolve indepe
module release. Capability dependencies use semantic-version ranges. Optional means the provider
may be absent; if it is installed, its capability version must be compatible.
`manifestVersion: 4` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 3 manifest, and any other version, fails validation outright rather than being silently
`manifestVersion: 6` (`packages/contracts/src/module-manifest.ts`) is a required, exact literal — a
version 5 manifest, and any other version, fails validation outright rather than being silently
reinterpreted; there is no compatibility adapter or automatic migration between manifest versions.
Version 4 adds one optional field, `entrypoints.ui`, so upgrading a backend-only version 3 manifest
is a one-character edit and changes nothing else
([ADR 0021](../architecture/decisions/0021-modules-as-hostable-products.md)).
Version 6 adds the optional `entrypoints.display` object. A version 5 module that declares no display
changes only the version literal; `ui` remains independently optional
([ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md)).
Each `permissions` entry is a complete declaration, not a bare identifier string: `id` (must begin
with `<module-id>.`, exactly like a capability id), `baseline` (`public`, `authenticated`, or
`restricted` — see [Authorization](../development/authorization.md#permission-registry) for what
@@ -176,14 +191,15 @@ relationship the platform has, and it is what orders discovery and startup. An a
your module into a different folder changes nothing about your artifact, your API root, your
permissions, your dependencies, or when you start.
Every entrypoint except `migrations` must be a compiled ESM `.js` or `.mjs` file — `server`,
`schema`, `jobs`, and `ui` alike. `migrations` is the one directory entrypoint. All entrypoints begin
Every artifact entrypoint must be a compiled ESM `.js` or `.mjs` file — `server`, `schema`, `jobs`,
`ui`, and `display.artifact` alike. `migrations` is the one directory entrypoint. All paths begin
with `./`, remain inside the package, and exist. Production never imports source TypeScript directly.
Modules with no tables omit `entrypoints.schema`; modules with no migration history omit
`entrypoints.migrations`; modules with no UI omit `entrypoints.ui`.
A display artifact must remain distinct from every other configured entrypoint before and after
symlink resolution. Modules with no tables omit `entrypoints.schema`; modules with no migration
history omit `entrypoints.migrations`; modules may omit `ui`, `display`, or both.
Unknown manifest properties are still rejected. `ui` is the only entrypoint version 4 adds — a
`dashboard`, `surface`, `client`, `web`, or `renderers` key fails validation — and the manifest
Unknown manifest properties are still rejected. A `dashboard`, `surface`, `client`, `web`, or
`renderers` key fails validation — and the manifest
deliberately has no group, folder, parent, menu, navigation, icon, or ordering field. Dashboard
organization is presentation configuration owned by the Dashboard, never module architecture.
@@ -191,10 +207,10 @@ Storage bucket IDs are logical names scoped to the module. Modules may request m
Core will later map them to deployment-specific physical S3 buckets and provide scoped access;
module code must not configure SeaweedFS or another S3 client directly.
## Declaring a UI entrypoint
## Declaring UI entrypoints
A module may declare one optional presentation entrypoint alongside its server, schema, and
migration entrypoints:
A module may declare a primary/management UI, a dedicated display UI, both, or neither. The roles
are separate artifacts because a full-viewport display is not a Dashboard screen with hidden chrome:
```json
{
@@ -202,20 +218,25 @@ migration entrypoints:
"server": "./dist/server/index.js",
"schema": "./dist/schema/index.js",
"migrations": "./drizzle",
"ui": "./dist/ui/index.js"
"ui": "./dist/ui/index.js",
"display": {
"artifact": "./dist/display/index.js",
"permission": "calendar.display.view"
}
}
}
```
`ui` obeys exactly the rules `server` and `schema` already obey. It must be package-relative, stay
inside the module package (checked before *and* after symlink resolution), resolve to a real file,
and be compiled ESM JavaScript ending in `.js` or `.mjs`. A `./src/ui/index.ts` path is rejected at
discovery — the platform never imports source TypeScript, and a UI artifact is a build output like
any other.
Both artifacts obey the same compiled-entrypoint rules as `server` and `schema`: package-relative,
inside the module before *and* after symlink resolution, a real `.js`/`.mjs` file, and never source
TypeScript. The display path must not collide with any other entrypoint. Its permission must be one
of this module's own declarations and must use `authenticated` or `restricted`, never `public`;
anonymous/device display identity is deferred. There is one display role per module for now. Named
variants and targeting are later contracts, not ad hoc manifest keys.
### What the artifact must export
The artifact default-exports one definition, built with `defineModuleUi` from
Each artifact default-exports one definition, built with `defineModuleUi` from
`@ekklesia/module-ui-sdk` (ADR 0022):
```tsx
@@ -244,7 +265,9 @@ return an unmount and release it there, the same discipline your `stop` hook fol
React is the supported way to write module UI. It is deliberately **not** part of the contract: the
platform hands you an element and a context, so your framework choice and the host's stay
independent.
independent. Primary and display artifacts use this exact same ABI and context. There is no mode
flag, `presentation.kind`, Dashboard boolean, or host identity to branch on; the separately declared
artifact is the role selection.
### What the host gives you
@@ -297,13 +320,12 @@ API process. Write module UI as trusted first-party code, because that is what i
### Building your UI
Put your UI source at `src/ui/index.tsx`, declare the artifact in your manifest, and build:
Put each declared role at its fixed source path and build once:
```text
src/ui/index.tsx # the compiled entrypoint's source
src/ui/ExampleApp.tsx # your components
↓ pnpm build
dist/ui/index.js # the artifact your manifest declares
src/ui/index.tsx → entrypoints.ui
src/display/index.tsx → entrypoints.display.artifact
pnpm build
```
You write no bundler configuration. `@ekklesia/module-ui-build` owns all of it
@@ -328,20 +350,31 @@ script runs both halves of the module:
}
```
The output path is **read from your manifest**, not from a build convention, so the artifact you
declare and the artifact you produce cannot drift. Declare it exactly as you would any other
entrypoint:
Output paths are **read from your manifest**, not inferred, so declarations and produced artifacts
cannot drift. Source paths are the exact conventions above; module authors do not configure them.
The builder resolves and preflights all roles in deterministic `ui`, then `display` order, compiles
both in memory, and writes only after every declared bundle succeeds. A missing or broken display
source therefore cannot leave a newly written primary artifact (or vice versa).
```json
{ "entrypoints": { "ui": "./dist/ui/index.js" } }
{
"entrypoints": {
"ui": "./dist/ui/index.js",
"display": {
"artifact": "./dist/display/index.js",
"permission": "calendar.display.view"
}
}
}
```
A module with no `entrypoints.ui` is not an error — the build reports that there is nothing to build
and exits successfully, so the same script works for a backend-only module.
A module may declare either role alone. Declaring neither is not an error — the build reports that
there is nothing to build and exits successfully, so the same script works for a backend-only module.
The build fails, loudly and with esbuild's own file-and-line diagnostics, when your TSX does not
compile, an import cannot be resolved, the declared path is unsafe or is not a `.js`/`.mjs` artifact,
or `src/ui/index.tsx` is missing.
or a declared role's fixed source is missing. It also refuses any display output that could
overwrite `server`, `schema`, `migrations`, `jobs`, or primary UI before invoking esbuild.
#### TypeScript projects
@@ -352,7 +385,12 @@ not match `.tsx`, and a UI project covers the browser half:
{
"extends": "@ekklesia/typescript-config/browser-react.json",
"compilerOptions": { "noEmit": true, "types": [] },
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"]
"include": [
"src/ui/**/*.tsx",
"src/ui/**/*.ts",
"src/display/**/*.tsx",
"src/display/**/*.ts"
]
}
```
@@ -384,10 +422,11 @@ Style with React's `style` prop:
```
**Stylesheet imports are not supported, and the build fails on one.** That is deliberate rather than
an oversight: esbuild would emit a `dist/ui/index.css` beside your artifact, but no manifest field
declares it and no host loads it — so it would work in your tests and silently do nothing in a real
host. A stylesheet contract needs a manifest field, a host loading mechanism, and a style-isolation
decision, none of which exist yet. Inline styles are the whole supported story for this phase.
an oversight: esbuild would emit a `.css` sidecar beside a primary or display artifact, but no
manifest field declares it and no host loads it — so it would work in your tests and silently do
nothing in a real host. A stylesheet contract needs a manifest field, a host loading mechanism, and
a style-isolation decision, none of which exist yet. Inline styles are the whole supported story for
this phase.
### What you may and may not use today
@@ -407,10 +446,11 @@ Layer rules are unchanged: module UI source may not import Core, an app, a surfa
another module. An unsupported framework aliased behind an allowed name — `"react": "npm:vue@3"` —
is resolved and rejected by its real target.
> **Your artifact is served, and Ekklesia's shell mounts it.** The platform publishes your compiled UI
> at `GET /api/v1/module-ui/<module-id>/<version>`
> **Your artifacts are served, and Ekklesia's surfaces mount them.** The platform publishes primary
> UI at `GET /api/v1/module-ui/<module-id>/<version>` and display UI at the sibling `/display`
> artifact URL
> ([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)) and its browser
> shell discovers and mounts it
> surfaces discover and mount them
> ([ADR 0026](../architecture/decisions/0026-minimal-ekklesia-module-ui-host.md)). That shell is a
> module list and a mount target, not a Dashboard. See
> [How a host loads your UI](#how-a-host-loads-your-ui) and
@@ -418,10 +458,10 @@ is resolved and rejected by its real target.
### What this does and does not do today
Discovery parses the manifest as inert JSON, resolves the `ui` path, validates it, and records it on
the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or renders a UI
entrypoint** — a UI artifact that throws on evaluation still discovers successfully, because it is
never evaluated. Hosts perform their own dynamic import and then validate the result with
Discovery parses the manifest as inert JSON, resolves each declared browser path, validates it, and
records it on the discovered module. That is all. **Nothing in Core imports, evaluates, mounts, or
renders either UI entrypoint** — an artifact that throws on evaluation still discovers successfully,
because it is never evaluated. Hosts perform their own dynamic import and then validate the result with
`parseModuleUiExport`; Core never loads browser code.
Core also never *builds* anything. Building is an authoring and packaging step you run; Core
@@ -435,10 +475,9 @@ You do not write this — a host does — but knowing the shape explains what yo
([ADR 0024](../architecture/decisions/0024-browser-safe-ui-sdk-and-host-loader.md)):
```text
compiled artifact (dist/ui/index.js)
compiled primary or display artifact
↓ asset publication — host-specific; Ekklesia serves it at
↓ GET /api/v1/module-ui/<module-id>/<version>, and its shell finds it
↓ through GET /api/v1/module-ui
↓ one exact server-produced artifact URL
browser-loadable module URL
↓ loadModuleUi({ source, container, context })
dynamic import → parseModuleUiExport → identity checked against the manifest
@@ -470,24 +509,26 @@ What that means for you as a module author:
### How Ekklesia publishes your artifact
Core resolves `entrypoints.ui` to a path on the *server's filesystem*, which a browser cannot import.
Ekklesia turns that into a URL
Core resolves each declared browser artifact to a path on the *server's filesystem*, which a browser
cannot import. Ekklesia turns each role into one exact URL
([ADR 0025](../architecture/decisions/0025-trusted-module-ui-asset-publication.md)):
```text
GET /api/v1/module-ui/<module-id>/<version>
GET /api/v1/module-ui/<module-id>/<version>/display
```
Four things about it matter to you as a module author:
- **Only the artifact your manifest declares is served.** The module id and version in that URL are
- **Only the artifact that role declares is served.** The module id and version in either URL are
lookup keys, never path components — there is no way to request a second file from your package.
Your source map, any sibling chunk, and every package-root file stay unreachable. If your UI needs
an asset, it must be inside the bundle; there is no sidecar contract yet, which is also why a
stylesheet import still fails the build.
- **The version must match exactly.** A request for any other version is refused rather than served
your installed artifact, so a stale host cannot mount something it did not ask for.
- **Your artifact is public.** The route requires no authentication — fetching it grants no
- **Your artifact bytes are public.** These two routes require no authentication — fetching code
grants no
authority, and your backend routes remain permission-enforced regardless. Never put a secret, a
private endpoint, or anything you would not publish in your UI bundle.
- **It is served byte-for-byte, never executed on the server.** No transformation, no re-bundling, no
@@ -526,6 +567,40 @@ Three things follow for you:
> `name`, never its id — and under "Other" until someone arranges it. There is no navigation editor,
> no nested folders, no icons, no theme, and no Dashboard Home yet.
### How Ekklesia hosts your display
The display is not part of the primary inventory or Dashboard navigation. For a module that is
eligible, running, and declares `entrypoints.display`, Ekklesia registers exactly these routes:
```text
/display/<module-id> stable full-viewport page
/api/v1/module-displays/<module-id> host descriptor
/api/v1/module-ui/<module-id>/<version>/display compiled artifact bytes
```
The page and descriptor both require the manifest's display permission through the ordinary Core
authorization evaluator and an authenticated cookie session. A `public` display permission is
rejected in manifest version 6; there is no anonymous exception or device token. Artifact bytes are
anonymous for the same reason as primary UI bytes: code alone carries no data authority. Unknown,
primary-only, disabled, and dependency-blocked modules have no display routes and return the normal
sanitized `404`. Enabling, disabling, installing, or changing the manifest takes effect on restart,
not through hot loading.
The stable page runs a separate `surfaces/shell` browser application with no Dashboard navigation,
theme chrome, or primary UI fallback. It asks for the protected descriptor, then passes the exact
artifact URL, module API base, realtime base, principal, and permission hints into the generic
loader. The host owns credentials and routes; your display still calls
`context.api.request("relative-path")` and `context.realtime.subscribe(...)`. Signed object-delivery
URLs remain opaque strings in HTTP or realtime state and should be refreshed according to their
expiry exactly as in any primary UI. Nothing in the display ABI identifies Ekklesia, so the same
artifact remains compatible with a future standalone host.
The display host fills the viewport but deliberately does **not** invoke the browser Fullscreen API.
It shows fixed loading/failure states and offers manual retry; your module owns only the rendered
feature experience and cleanup inside its container. There is one display per module. Display
inventory/management, named variants, targeting, public/device identity, a standalone host, shared
CSS, Announcements migration, and Companion integration are deferred.
### What a module UI will never own
A module UI consumes platform policy and never redefines it.
@@ -835,10 +910,11 @@ letting requests already in flight finish publishing. Reverse-order stop then di
publisher immediately before that module's own stop hook. Call `close()` during UI unmount as well;
it is synchronous and idempotent. Direct object-delivery URLs are opaque strings in a payload and are
forwarded unchanged.
Realtime does not make those bearer URLs live longer, refresh them, or authorize them. Public
display/device identity, durable replay, multi-node fanout, client-to-server commands, and
display/kiosk hosting remain future work. See
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md).
Realtime does not make those bearer URLs live longer, refresh them, or authorize them. Ekklesia's
authenticated display host consumes the same interface unchanged; public/device identity, durable
replay, multi-node fanout, and client-to-server commands remain future work. See
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md) and
[ADR 0033](../architecture/decisions/0033-display-kiosk-host-contract.md).
## Drizzle schema definition
@@ -1340,9 +1416,11 @@ disable or block a running module because a storage call timed out.
### Serving objects to a browser
Module UI gets no storage access and no credentials. Serve bytes through your own authorized route,
as in the example above: `browser → your module API → your scoped storage`. Signed URLs and public
object delivery do not exist yet.
Module UI gets no storage access and no credentials. Its authorized module route may either stream
through scoped storage or call `createReadUrl(key)` and return that short-lived bearer URL so the
browser fetches bytes directly from object storage. Primary and display UI consume that URL the same
way: keep it opaque, never persist or log it, and refresh it before expiry. A display host adds no
storage authority and realtime transport does not extend a URL's lifetime.
### Whether your module runs at all is your host's decision