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
+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