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