feat: add display kiosk host
This commit is contained in:
@@ -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