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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user