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
+131 -4
View File
@@ -22,6 +22,7 @@ import {
} from "@ekklesia/core/modules";
import { loadModuleUi } from "@ekklesia/module-ui-host";
import {
bootstrapDisplayHost,
bootstrapHost,
createModuleUiContext,
type EkklesiaHostConfig,
@@ -30,6 +31,7 @@ import {
import { afterEach, describe, expect, it, vi } from "vitest";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { createApiApp } from "../src/app.js";
import { createDisplayHostAssets } from "../src/display-host-assets.js";
import {
createInMemoryModuleLifecycleStore,
moduleLifecycleSnapshotFor,
@@ -84,10 +86,14 @@ async function createFixtureModule(
modulesDirectory: string,
id = "example",
name = "Example module",
withDisplay = false,
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
if (withDisplay) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
}
await writeFile(
path.join(moduleDirectory, "package.json"),
@@ -102,9 +108,20 @@ async function createFixtureModule(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
entrypoints: {
server: "./dist/server/index.js",
ui: "./dist/ui/index.js",
...(withDisplay
? {
display: {
artifact: "./dist/display/index.js",
permission: `${id}.display.view`,
},
}
: {}),
},
id,
manifestVersion: 5,
manifestVersion: 6,
name,
permissions: [
{
@@ -112,6 +129,15 @@ async function createFixtureModule(
description: "Read example records.",
id: `${id}.records.read`,
},
...(withDisplay
? [
{
baseline: "restricted",
description: "View the example display.",
id: `${id}.display.view`,
},
]
: []),
],
version: "0.1.0",
}),
@@ -154,9 +180,26 @@ async function createFixtureModule(
},
};\n`,
);
if (withDisplay) {
await writeFile(
path.join(moduleDirectory, "dist/display/index.js"),
`export default {
id: ${JSON.stringify(id)},
version: "0.1.0",
async mount(container, context) {
const response = await context.api.request("records");
const records = response.ok ? await response.json() : [];
container.textContent =
context.moduleId + ":display:" + response.status + ":" + records.length;
return () => { container.textContent = ""; };
},
};\n`,
);
}
}
interface HarnessOptions {
readonly display?: boolean;
readonly principal?: EkklesiaPrincipal;
readonly grants?: readonly string[];
/** Extra fixture modules, so grouping can be exercised with more than one — a folder containing a
@@ -178,7 +221,7 @@ interface Harness {
async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-"));
temporaryDirectories.push(modulesDirectory);
await createFixtureModule(modulesDirectory);
await createFixtureModule(modulesDirectory, "example", "Example module", options.display);
for (const extra of options.extraModules ?? []) {
await createFixtureModule(modulesDirectory, extra.id, extra.name);
}
@@ -214,12 +257,21 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
revokePlatformOwner: vi.fn(),
} as unknown as AuthorizationStore;
const moduleUi = createModuleUiPublicationRegistry(modules);
const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
moduleRoutes: moduleRuntime.routes,
moduleRealtime: moduleRuntime.realtime,
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleUi,
...(moduleUi.listDisplays().length === 0
? {}
: {
displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="ekklesia-display"></div><script type="module" src="/assets/ekklesia-display/app.js"></script>',
"export const displayHost = true;\n",
),
}),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
@@ -326,6 +378,81 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
}
});
it("opens the stable display route, resolves its real descriptor, and mounts the published display bytes", async () => {
const harness = await startHarness({
display: true,
grants: ["example.records.read", "example.display.view"],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
// 1. A device keeps this version-independent host URL. It is a real permission-gated API route,
// not a test-only HTML wrapper around the browser functions below.
const page = await (harness.config.fetch as typeof fetch)(
"https://ekklesia.example.test/display/example",
);
expect(page.status).toBe(200);
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
expect(await page.text()).toContain("/assets/ekklesia-display/app.js");
// 2. The display surface's own bootstrap resolves principal first, then the singular protected
// descriptor. The browser constructs no artifact, API, or realtime URL itself.
const bootstrap = await bootstrapDisplayHost(harness.config, "example");
expect(bootstrap.status).toBe("ready");
if (bootstrap.status !== "ready") {
throw new Error("the authenticated display did not resolve");
}
expect(bootstrap.descriptor).toEqual({
apiBaseUrl: "/api/v1/modules/example",
id: "example",
permissions: ["example.records.read", "example.display.view"],
realtimeBaseUrl: "/api/v1/module-realtime/example",
uiUrl: "/api/v1/module-ui/example/0.1.0/display",
version: "0.1.0",
});
// 3. Fetch the exact role-specific bytes from the real publication route, then genuinely import
// those returned bytes. Reading the fixture file directly would not prove publication.
const sourceUrl = moduleUiSourceUrl(harness.config, bootstrap.descriptor);
expect(sourceUrl).toBe(
"https://ekklesia.example.test/api/v1/module-ui/example/0.1.0/display",
);
const artifactUrl = await publishedArtifactUrl(harness, sourceUrl);
// 4. The generic loader receives the unchanged five-member browser context and mounts the
// display artifact independently of the Dashboard artifact.
const context = createModuleUiContext({
config: harness.config,
descriptor: bootstrap.descriptor,
principal: bootstrap.principal,
});
expect(Object.keys(context).sort()).toEqual([
"api",
"moduleId",
"permissions",
"principal",
"realtime",
]);
const container = containerSink();
const mounted = await loadModuleUi({
container: container as unknown as LoaderContainer,
context,
source: {
moduleId: bootstrap.descriptor.id,
url: artifactUrl,
version: bootstrap.descriptor.version,
},
});
expect(container.textContent).toBe("example:display:200:2");
expect(harness.moduleApiCalls).toEqual(["GET /api/v1/modules/example/records"]);
await mounted.unmount();
expect(container.textContent).toBe("");
expect(mounted.mounted).toBe(false);
} finally {
await harness.stop();
}
});
it("keeps server authorization authoritative when the UI ignores its own hint", async () => {
// Signed in, but holding no grant for the module's restricted permission.
const harness = await startHarness({