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
+85 -5
View File
@@ -8,11 +8,16 @@ const betterAuthMock = vi.hoisted(() => vi.fn());
const drizzleAdapterMock = vi.hoisted(() => vi.fn());
const serveMock = vi.hoisted(() => vi.fn());
const processOnSpy = vi.hoisted(() => vi.fn());
const loadDisplayHostAssetsMock = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
vi.mock("../src/display-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/display-host-assets.js")>();
return { ...actual, loadDisplayHostAssets: loadDisplayHostAssetsMock };
});
// Wraps the real `createApiApp` in a spy rather than replacing it, so every existing test still
// gets the real Hono application; only the one test that deliberately forces an app-construction
// failure overrides this with `mockImplementationOnce`.
@@ -30,6 +35,7 @@ import {
import { PermissionRegistryError } from "@ekklesia/core/authorization";
import { createApiApp } from "../src/app.js";
import { createApiRuntimeConfig } from "../src/config.js";
import { DisplayHostAssetError } from "../src/display-host-assets.js";
import { installShutdownSignalHandlers, startApi } from "../src/server.js";
function validConfig() {
@@ -82,7 +88,7 @@ async function createFixtureModule(
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "0.1.0",
@@ -149,6 +155,8 @@ interface WorkingFixtureModuleOptions {
* can be exercised deterministically without any sleep. */
readonly stopNeverSettles?: boolean;
readonly dependencies?: Record<string, unknown>;
/** Declares one protected display artifact without importing it. */
readonly withDisplay?: boolean;
}
/** Writes a real, working module fixture — unlike `createFixtureModule`, this entrypoint does not
@@ -162,12 +170,43 @@ async function createWorkingFixtureModule(
options: WorkingFixtureModuleOptions = {},
): Promise<void> {
const permissionId = `${id}.ping.read`;
const displayPermission = `${id}.display.view`;
const permissions = [
...(options.withRoute
? [{ baseline: "public" as const, description: "Ping.", id: permissionId }]
: []),
...(options.withDisplay
? [
{
baseline: "restricted" as const,
description: "View the display.",
id: displayPermission,
},
]
: []),
];
await createFixtureModule(modulesDirectory, id, {
permissions: options.withRoute
? [{ baseline: "public", description: "Ping.", id: permissionId }]
: [],
entrypoints: {
server: "./dist/server/index.js",
...(options.withDisplay
? {
display: {
artifact: "./dist/display/index.js",
permission: displayPermission,
},
}
: {}),
},
permissions,
...(options.dependencies ? { dependencies: options.dependencies } : {}),
});
if (options.withDisplay) {
await mkdir(path.join(modulesDirectory, id, "dist/display"), { recursive: true });
await writeFile(
path.join(modulesDirectory, id, "dist/display/index.js"),
'throw new Error("display artifact must never execute during server startup");\n',
);
}
const routeSource = options.withRoute
? `http.route({
method: "GET",
@@ -215,7 +254,7 @@ function fakeDiscoveredModule(
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
entrypoints: { server: "./dist/does-not-exist/index.js" },
id,
manifestVersion: 5,
manifestVersion: 6,
name: `${id} module`,
permissions,
version: "0.1.0",
@@ -942,6 +981,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
loadDisplayHostAssetsMock.mockReset();
vi.mocked(createApiApp).mockClear();
});
@@ -954,6 +994,46 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
return { poolEnd };
}
it("fails on missing display-host assets before importing module code and still closes startup resources", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "announcements", recorder, {
withDisplay: true,
});
const { poolEnd } = mockPoolAndAuth();
loadDisplayHostAssetsMock.mockRejectedValueOnce(new DisplayHostAssetError());
const failure = await startApi(validConfig(), {
modulesDirectoryOverride: modulesDirectory,
}).catch((error: unknown) => error);
expect(failure).toMatchObject({
code: "DISPLAY_HOST_ASSET_UNAVAILABLE",
message: "The display host browser assets are unavailable.",
name: "DisplayHostAssetError",
});
expect((failure as Error).stack).toBe(
"DisplayHostAssetError: The display host browser assets are unavailable.",
);
expect(loadDisplayHostAssetsMock).toHaveBeenCalledTimes(1);
expect(recorder.events).toEqual([]);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("does not read display-host assets when the eligible set declares no display", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "calendar", recorder);
mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
expect(loadDisplayHostAssetsMock).not.toHaveBeenCalled();
await running.shutdown();
});
it("starts the module runtime from the exact discovered inventory permission composition used, mounting routes in discovery order", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();