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
+24 -13
View File
@@ -1,6 +1,6 @@
# Ekklesia module template
This is the smallest supported backend-only Ekklesia module. It proves the static manifest,
This is the smallest supported Ekklesia module product. It proves the static manifest,
compiled runtime and schema entrypoints, capability registration, a module-owned Drizzle table, a
generated module-owned migration, logical storage-bucket declarations, and one permissioned HTTP
route (`GET /records`, declared in `register`) that reads that table through
@@ -8,18 +8,25 @@ route (`GET /records`, declared in `register`) that reads that table through
module-constructed Drizzle instance or a raw `pg` client. Migration execution and storage
provisioning are intentionally not included.
It also ships an **optional** React UI (`src/ui/`), compiled to the `entrypoints.ui` artifact its
manifest declares by `pnpm build`
([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). UI is a choice, not
a requirement: to keep a module backend-only, delete `src/ui/`, `tsconfig.ui.json`,
`tsconfig.ui-test.json`, `tests/module-ui.test.ts`, the `ui` entrypoint from the manifest, and the
React and module-UI dependencies. Everything else keeps working, and `ekklesia-build-module-ui`
simply reports that there is nothing to build.
It also ships two independently optional React roles: a primary/management UI at `src/ui/index.tsx`
and a full-viewport display at `src/display/index.tsx`. One invocation of `pnpm build` compiles each
declared role to the exact artifact in `entrypoints.ui` and `entrypoints.display.artifact`
([ADR 0023](../../docs/architecture/decisions/0023-module-ui-build-pipeline.md)). Both default-export
the same role-neutral `defineModuleUi({ id, version, mount })` ABI and receive the same
`ModuleUiHostContext`; there is deliberately no mode flag. The manifest entrypoint and the host route
choose the role.
No host mounts a module UI yet — the Dashboard shell does not exist. `tests/module-ui.test.ts` builds
the artifact from source, validates it with `parseModuleUiExport`, mounts it into a real DOM, and
unmounts it, which is what proves the pipeline today. See
[Declaring a UI entrypoint](../../docs/module-development/README.md#declaring-a-ui-entrypoint).
Either role can be omitted independently. To remove only the display example, delete `src/display/`
and `entrypoints.display` (and remove `example.display.view` if nothing else uses it). To keep the
module backend-only, also delete `src/ui/`, `tsconfig.ui.json`, `tsconfig.ui-test.json`,
`tests/module-ui.test.ts`, the `ui` entrypoint, and the React/module-UI dependencies. The builder
succeeds with an empty artifact list when neither role is declared.
`tests/module-ui.test.ts` builds both artifacts from source, validates them with
`parseModuleUiExport`, mounts each into a real DOM, and unmounts it. Ekklesia mounts the primary UI
inside its Dashboard shell and serves the display without Dashboard chrome at the stable
`/display/example` page. See
[Declaring UI entrypoints](../../docs/module-development/README.md#declaring-ui-entrypoints).
To create a module inside this repository:
@@ -46,7 +53,11 @@ To create a module inside this repository:
root. The declared production entrypoint is the compiled `dist/server/index.js`, not source
TypeScript; the schema entrypoint is likewise compiled to `dist/schema/index.js`.
Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces.
The module owns its feature screens and display renderer. Do not add platform navigation, the
containing shell, global theme, authentication screens, or top-level routing; those belong to
surfaces and Core. A module UI asks its host for relative API paths and realtime state, and never
hardcodes Ekklesia routes. The display permission is enforced by the host before it serves the page
or descriptor; `context.permissions` remains a presentation-only hint.
Keep imports side-effect-free, register capabilities in `register`, acquire resources in `start`,
and release them in `stop`. Never construct a Drizzle instance, read `DATABASE_URL`, or reach for a
raw `pg` client — `context.services.database` is the only supported way to query this module's own
+12 -3
View File
@@ -1,8 +1,8 @@
{
"manifestVersion": 5,
"manifestVersion": 6,
"id": "example",
"name": "Example Module",
"description": "A minimal Ekklesia module template with backend behavior and optional UI.",
"description": "A minimal Ekklesia module template with backend behavior and optional primary and display UI.",
"version": "0.1.0",
"compatibility": {
"platform": "^0.1.0",
@@ -19,6 +19,11 @@
"id": "example.records.read",
"baseline": "restricted",
"description": "Read example records."
},
{
"id": "example.display.view",
"baseline": "restricted",
"description": "View the example full-screen display."
}
],
"configuration": [
@@ -34,6 +39,10 @@
"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": "example.display.view"
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import { defineModuleUi, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { createRoot } from "react-dom/client";
function ExampleDisplay(props: { readonly context: ModuleUiHostContext }) {
const mayView = props.context.permissions.has("example.display.view");
return (
<main
data-testid="example-display"
style={{
alignItems: "center",
background: "#111827",
color: "#f9fafb",
display: "flex",
fontFamily: "system-ui, sans-serif",
height: "100%",
justifyContent: "center",
minHeight: "100vh",
padding: "2rem",
textAlign: "center",
width: "100%",
}}
>
<h1 style={{ margin: 0 }}>
{mayView
? `Example full-screen display (${props.context.moduleId})`
: "Display unavailable"}
</h1>
</main>
);
}
/**
* A display uses the same role-neutral ABI as the primary UI. Its separate manifest entrypoint —
* not a mode flag in `ModuleUiHostContext` — tells a host which artifact it is mounting.
*/
export default defineModuleUi({
id: "example",
version: "0.1.0",
mount(container, context) {
const root = createRoot(container);
root.render(<ExampleDisplay context={context} />);
return () => root.unmount();
},
});
+11 -1
View File
@@ -59,7 +59,7 @@ async function registerAndCaptureRoutes(): Promise<{
}
describe("module template", () => {
it("ships a valid backend-only manifest", async () => {
it("ships a valid product manifest with independently declared primary and display UI", async () => {
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as unknown;
const manifest = parseModuleManifest(manifestInput);
@@ -72,6 +72,11 @@ describe("module template", () => {
baseline: "restricted",
description: "Read example records.",
},
{
id: "example.display.view",
baseline: "restricted",
description: "View the example full-screen display.",
},
]);
// The template declares **no** logical storage bucket, deliberately. Since ADR 0030 a declared
// bucket is a real requirement: a module that declares one is blocked on an installation with no
@@ -80,6 +85,11 @@ describe("module template", () => {
expect(manifest.storage.buckets).toEqual([]);
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
expect(manifest.entrypoints.migrations).toBe("./drizzle");
expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js");
expect(manifest.entrypoints.display).toEqual({
artifact: "./dist/display/index.js",
permission: "example.display.view",
});
});
it("packages a generated stable Drizzle migration and matching policy", async () => {
+57 -15
View File
@@ -2,8 +2,8 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import { parseModuleManifest } from "@ekklesia/module-sdk";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import {
type ModuleUiApiClient,
type ModuleUiHostContext,
@@ -14,7 +14,7 @@ import { beforeAll, describe, expect, it } from "vitest";
/**
* The end-to-end proof for the module UI build pipeline (ADR 0023):
*
* React/TSX source -> `pnpm build` -> compiled artifact at the manifest's `entrypoints.ui`
* React/TSX source -> `pnpm build` -> compiled artifacts at the manifest's UI entrypoints
* -> `parseModuleUiExport` -> `mount(container, context)` -> `unmount()`
*
* The artifact is rebuilt from source here rather than read from a checked-in file, so the test can
@@ -26,7 +26,10 @@ const moduleDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)
let artifactHref: string;
let artifactPath: string;
let displayArtifactHref: string;
let displayArtifactPath: string;
let manifestUi: string;
let manifestDisplay: string;
let manifestIdentity: { id: string; version: string };
/** Records what the host was asked for, so the test can prove the UI never builds a URL itself. */
@@ -72,30 +75,49 @@ describe("module UI build pipeline", () => {
if (!manifest.entrypoints.ui) {
throw new Error("The template manifest must declare entrypoints.ui for this test.");
}
if (!manifest.entrypoints.display) {
throw new Error("The template manifest must declare entrypoints.display for this test.");
}
manifestUi = manifest.entrypoints.ui;
manifestDisplay = manifest.entrypoints.display.artifact;
manifestIdentity = { id: manifest.id, version: manifest.version };
const result = await buildModuleUi({ moduleDirectory });
artifactPath = result.outputFile;
artifactHref = pathToFileURL(result.outputFile).href;
const uiArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
const displayArtifact = result.artifacts.find((artifact) => artifact.kind === "display");
if (!uiArtifact || !displayArtifact) {
throw new Error("The template build must produce both declared UI roles.");
}
expect(result.artifacts.map((artifact) => artifact.kind)).toEqual(["ui", "display"]);
artifactPath = uiArtifact.outputFile;
artifactHref = pathToFileURL(uiArtifact.outputFile).href;
displayArtifactPath = displayArtifact.outputFile;
displayArtifactHref = pathToFileURL(displayArtifact.outputFile).href;
}, 60_000);
it("writes the artifact exactly where the manifest declares it", () => {
it("writes both artifacts exactly where the manifest declares them", () => {
expect(manifestUi).toBe("./dist/ui/index.js");
expect(artifactPath.endsWith(path.join("dist", "ui", "index.js"))).toBe(true);
expect(manifestDisplay).toBe("./dist/display/index.js");
expect(displayArtifactPath.endsWith(path.join("dist", "display", "index.js"))).toBe(true);
});
it("produces a browser-safe ESM artifact that needs no host-provided React", async () => {
const artifact = await readFile(artifactPath, "utf8");
it("produces browser-safe ESM artifacts that need no host-provided React", async () => {
const artifacts = await Promise.all([
readFile(artifactPath, "utf8"),
readFile(displayArtifactPath, "utf8"),
]);
// React is bundled, so the artifact resolves nothing at run time (ADR 0023). A bare top-level
// import would mean the host had to supply that module.
expect(artifact).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
expect(artifact).toContain("export {");
// No Ekklesia deployment detail may be baked into a portable artifact.
expect(artifact).not.toContain("/api/v1/modules");
// `process.env` must be substituted at build time; a browser has no `process`.
expect(artifact).not.toContain("process.env.NODE_ENV");
for (const artifact of artifacts) {
// React is bundled, so each artifact resolves nothing at run time (ADR 0023). A bare
// top-level import would mean the host had to supply that module.
expect(artifact).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
expect(artifact).toContain("export {");
// No Ekklesia deployment detail may be baked into a portable artifact.
expect(artifact).not.toContain("/api/v1/modules");
// `process.env` must be substituted at build time; a browser has no `process`.
expect(artifact).not.toContain("process.env.NODE_ENV");
}
});
it("satisfies parseModuleUiExport and matches the manifest identity", async () => {
@@ -139,6 +161,26 @@ describe("module UI build pipeline", () => {
container.remove();
});
it("mounts the display artifact through the same ABI, then unmounts it", async () => {
const namespace = (await import(displayArtifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity);
const container = document.createElement("div");
const requested: string[] = [];
const unmount = await definition.mount(
container,
recordingHost({ canRead: true, records: [], requested, resolve: () => undefined }),
);
await flush();
expect(container.querySelector("[data-testid='example-display']")?.textContent).toContain(
"Example full-screen display (example)",
);
expect(requested).toEqual([]);
await (unmount as () => Promise<void>)();
expect(container.textContent).toBe("");
});
it("asks its host for a relative path, never Ekklesia's module API prefix", async () => {
const namespace = (await import(artifactHref)) as unknown;
const definition = parseModuleUiExport(namespace, manifestIdentity);
+1 -1
View File
@@ -4,5 +4,5 @@
"noEmit": true,
"types": []
},
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts"]
"include": ["src/ui/**/*.tsx", "src/ui/**/*.ts", "src/display/**/*.tsx", "src/display/**/*.ts"]
}