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
+197 -9
View File
@@ -6,13 +6,10 @@ import {
type ModuleDatabase,
type ModuleRuntimeServices,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
CORE_PERMISSION_REGISTRY,
} from "@ekklesia/core/authorization";
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import {
composePlatformPermissionRegistry,
createModuleUiPublicationRegistry,
type DiscoveredModule,
discoverModules,
@@ -23,10 +20,12 @@ import type { Hono } from "hono";
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 {
MODULE_UI_ASSET_PREFIX,
MODULE_UI_CACHE_CONTROL,
MODULE_UI_CONTENT_TYPE,
moduleDisplayUiArtifactUrl,
moduleUiArtifactUrl,
} from "../src/module-ui-routes.js";
import {
@@ -78,6 +77,8 @@ interface ModuleFixture {
/** Written to `dist/ui/index.js` and declared as `entrypoints.ui`. Omit for a backend-only
* module, whose manifest then declares no UI entrypoint at all. */
readonly ui?: string;
/** Written to a distinct artifact and declared as the protected display entrypoint. */
readonly display?: string;
/** Extra package-relative files written alongside the artifact — a source map, a sibling bundle
* chunk, an operator secret — so "only the declared artifact is reachable" is asserted against
* files that genuinely exist on disk rather than against hypothetical ones. */
@@ -86,6 +87,7 @@ interface ModuleFixture {
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
const version = fixture.version ?? "0.1.0";
const displayPermission = `${fixture.id}.display.view`;
const moduleDirectory = path.join(modulesDirectory, fixture.id);
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await writeFile(
@@ -104,10 +106,28 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
entrypoints: {
server: "./dist/server/index.js",
...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }),
...(fixture.display === undefined
? {}
: {
display: {
artifact: "./dist/display/index.js",
permission: displayPermission,
},
}),
},
id: fixture.id,
manifestVersion: 5,
manifestVersion: 6,
name: `${fixture.id} module`,
permissions:
fixture.display === undefined
? []
: [
{
baseline: "restricted",
description: `View ${fixture.id}'s display`,
id: displayPermission,
},
],
version,
}),
);
@@ -119,6 +139,10 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui);
}
if (fixture.display !== undefined) {
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
}
for (const [relativePath, contents] of Object.entries(fixture.extraFiles ?? {})) {
const target = path.join(moduleDirectory, relativePath);
await mkdir(path.dirname(target), { recursive: true });
@@ -139,7 +163,10 @@ interface Installation {
* artifact path is hand-built, so these tests exercise the actual security boundary rather than a
* reconstruction of it.
*/
async function install(fixtures: readonly ModuleFixture[]): Promise<Installation> {
async function install(
fixtures: readonly ModuleFixture[],
publicationModuleIds?: readonly string[],
): Promise<Installation> {
const modulesDirectory = await createModulesDirectory();
for (const fixture of fixtures) {
await createModule(modulesDirectory, fixture);
@@ -149,6 +176,10 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
modulesDirectory,
platformVersion: "0.1.0",
});
const publicationModules =
publicationModuleIds === undefined
? modules
: modules.filter((module) => publicationModuleIds.includes(module.manifest.id));
const database = Object.freeze({}) as unknown as ModuleDatabase;
const services: ModuleRuntimeServices = Object.freeze({ database });
@@ -172,11 +203,19 @@ async function install(fixtures: readonly ModuleFixture[]): Promise<Installation
} as unknown as AuthorizationStore;
const app = createApiApp({
authorization: { evaluator, registry: CORE_PERMISSION_REGISTRY, store },
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
...(publicationModules.some((module) => module.entrypoints.display !== undefined)
? {
displayHostAssets: createDisplayHostAssets(
'<!doctype html><div id="display-root"></div>',
"export {};\n",
),
}
: {}),
moduleRoutes: Object.freeze([]),
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
moduleServices: services,
moduleUi: createModuleUiPublicationRegistry(modules),
moduleUi: createModuleUiPublicationRegistry(publicationModules),
moduleLifecycle: {
snapshot: moduleLifecycleSnapshotFor(modules),
store: createInMemoryModuleLifecycleStore(),
@@ -345,6 +384,155 @@ describe("module UI artifact publication — happy path", () => {
});
});
describe("module display UI artifact publication", () => {
it("serves the declared display bytes anonymously at the exact role-specific URL", async () => {
const source = `${artifactSource("announcements", "1.4.2")}// display\n`;
const { app } = await install([{ display: source, id: "announcements", version: "1.4.2" }]);
const url = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" });
expect(url).toBe(`${MODULE_UI_ASSET_PREFIX}/announcements/1.4.2/display`);
const response = await app.request(url);
expect(response.status).toBe(200);
expect(await response.text()).toBe(source);
expect(response.headers.get("content-type")).toBe(MODULE_UI_CONTENT_TYPE);
expect(response.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
const etag = response.headers.get("etag") as string;
expect(etag).toMatch(/^"[A-Za-z0-9_-]+"$/u);
const revalidated = await app.request(url, { headers: { "if-none-match": etag } });
expect(revalidated.status).toBe(304);
expect(await revalidated.text()).toBe("");
expect(revalidated.headers.get("cache-control")).toBe(MODULE_UI_CACHE_CONTROL);
expect(revalidated.headers.get("etag")).toBe(etag);
});
it("keeps display-only, primary-only, both, and neither roles strictly separate", async () => {
const primary = `${artifactSource("signage", "0.1.0")}// primary\n`;
const display = `${artifactSource("signage", "0.1.0")}// display\n`;
const { app } = await install([
{ display: `${display}// announcements\n`, id: "announcements" },
{ id: "calendar", ui: `${primary}// calendar\n` },
{ display, id: "signage", ui: primary },
{ id: "backend" },
]);
const primaryUrl = (moduleId: string) => moduleUiArtifactUrl({ moduleId, version: "0.1.0" });
const displayUrl = (moduleId: string) =>
moduleDisplayUiArtifactUrl({ moduleId, version: "0.1.0" });
expect((await app.request(primaryUrl("announcements"))).status).toBe(404);
expect((await app.request(displayUrl("announcements"))).status).toBe(200);
expect((await app.request(primaryUrl("calendar"))).status).toBe(200);
expect((await app.request(displayUrl("calendar"))).status).toBe(404);
expect(await (await app.request(primaryUrl("signage"))).text()).toBe(primary);
expect(await (await app.request(displayUrl("signage"))).text()).toBe(display);
expect((await app.request(primaryUrl("backend"))).status).toBe(404);
expect((await app.request(displayUrl("backend"))).status).toBe(404);
});
it("refuses wrong versions with the same sanitized response as an undeclared display", async () => {
const { app } = await install([
{
display: `${artifactSource("announcements", "1.4.2")}// display\n`,
id: "announcements",
version: "1.4.2",
},
{ id: "calendar", ui: artifactSource("calendar", "0.1.0") },
]);
const responses = await Promise.all(
[
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "latest" }),
moduleDisplayUiArtifactUrl({ moduleId: "calendar", version: "0.1.0" }),
moduleDisplayUiArtifactUrl({ moduleId: "missing", version: "1.4.2" }),
].map(async (url) => {
const response = await app.request(url);
return { body: await response.text(), status: response.status };
}),
);
expect(responses).toEqual([
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
{ body: '{"code":"MODULE_UI_NOT_PUBLISHED"}', status: 404 },
]);
});
it("never turns the display URL into a package directory or generic role selector", async () => {
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
const { app } = await install([
{
display,
extraFiles: {
".env": "DISPLAY_SECRET=do-not-serve\n",
"dist/display/index.js.map": "display source map secret",
"dist/display/internal-chunk.js": "export const internal = 'secret';\n",
},
id: "announcements",
},
]);
const exact = moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" });
for (const url of [
`${exact}.map`,
`${exact}/index.js.map`,
`${exact}/internal-chunk.js`,
`${exact}/../../.env`,
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/dashboard`,
`${MODULE_UI_ASSET_PREFIX}/announcements/0.1.0/arbitrary-role`,
]) {
const response = await app.request(url);
expect(response.status, url).toBe(404);
const body = await response.text();
expect(body, url).not.toContain("secret");
expect(body, url).not.toContain("mount");
}
});
it("cannot publish a blocked module omitted from Core's eligible registry input", async () => {
const display = `${artifactSource("announcements", "0.1.0")}// display\n`;
const { app } = await install(
[
{ display, id: "announcements" },
{ display: `${display}// signage\n`, id: "signage" },
],
["signage"],
);
expect(
(
await app.request(
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
)
).status,
).toBe(404);
expect(
(await app.request(moduleDisplayUiArtifactUrl({ moduleId: "signage", version: "0.1.0" })))
.status,
).toBe(200);
});
it("reports the same fixed 503 when the validated display artifact disappears", async () => {
const { app, modulesDirectory } = await install([
{
display: `${artifactSource("announcements", "0.1.0")}// display\n`,
id: "announcements",
},
]);
await rm(path.join(modulesDirectory, "announcements/dist/display/index.js"));
const response = await app.request(
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "0.1.0" }),
);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "MODULE_UI_ARTIFACT_UNAVAILABLE" });
expect(response.headers.get("cache-control")).toBe("no-store");
});
});
describe("module UI artifact publication — module identity", () => {
async function expectNotPublished(app: Hono, url: string): Promise<void> {
const response = await app.request(url);