feat: add display kiosk host
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
type EkklesiaPrincipal,
|
||||
type ModuleDatabase,
|
||||
type ModuleRuntimeServices,
|
||||
parseEkklesiaUserId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
createAuthorizationEvaluator,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import {
|
||||
composePlatformPermissionRegistry,
|
||||
createModuleUiPublicationRegistry,
|
||||
type DiscoveredModule,
|
||||
discoverModules,
|
||||
EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createDisplayHostAssets, type DisplayHostAssets } from "../src/display-host-assets.js";
|
||||
import {
|
||||
DISPLAY_HOST_SCRIPT_PATH,
|
||||
ModuleDisplayConfigurationError,
|
||||
moduleDisplayDescriptorUrl,
|
||||
moduleDisplayPageUrl,
|
||||
} from "../src/module-display-routes.js";
|
||||
import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { moduleConfigurationOptionFor } from "./support/in-memory-module-configuration.js";
|
||||
import {
|
||||
createInMemoryModuleLifecycleStore,
|
||||
moduleLifecycleSnapshotFor,
|
||||
} from "./support/in-memory-module-lifecycle.js";
|
||||
import { moduleStorageOptionFor } from "./support/in-memory-module-storage.js";
|
||||
|
||||
const viewerId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111");
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const displayPermission = "announcements.display.view";
|
||||
|
||||
const displayHostAssets: DisplayHostAssets = createDisplayHostAssets(
|
||||
`<!doctype html><html><body><div id="ekklesia-display"></div><script type="module" src="${DISPLAY_HOST_SCRIPT_PATH}"></script></body></html>`,
|
||||
"globalThis.__ekklesiaDisplayHostLoaded = true;\n",
|
||||
);
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
interface ModuleFixture {
|
||||
readonly display?: boolean;
|
||||
readonly id?: string;
|
||||
readonly ui?: boolean;
|
||||
readonly version?: string;
|
||||
}
|
||||
|
||||
async function createModule(modulesDirectory: string, fixture: ModuleFixture = {}): Promise<void> {
|
||||
const id = fixture.id ?? "announcements";
|
||||
const version = fixture.version ?? "1.4.2";
|
||||
const moduleDirectory = path.join(modulesDirectory, id);
|
||||
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "package.json"),
|
||||
JSON.stringify({
|
||||
ekklesia: { layer: "module" },
|
||||
name: `@test/module-${id}`,
|
||||
type: "module",
|
||||
version,
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "ekklesia.module.json"),
|
||||
JSON.stringify({
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: {
|
||||
server: "./dist/server/index.js",
|
||||
...(fixture.ui ? { ui: "./dist/ui/index.js" } : {}),
|
||||
...(fixture.display
|
||||
? {
|
||||
display: {
|
||||
artifact: "./dist/display/index.js",
|
||||
permission: `${id}.display.view`,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
id,
|
||||
manifestVersion: 6,
|
||||
name: `${id} module`,
|
||||
permissions: fixture.display
|
||||
? [
|
||||
{
|
||||
baseline: "restricted",
|
||||
description: `View the ${id} display.`,
|
||||
id: `${id}.display.view`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
version,
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/server/index.js"),
|
||||
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)} };\n`,
|
||||
);
|
||||
if (fixture.ui) {
|
||||
await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/ui/index.js"),
|
||||
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount() {} };\n`,
|
||||
);
|
||||
}
|
||||
if (fixture.display) {
|
||||
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(moduleDirectory, "dist/display/index.js"),
|
||||
`export default { id: ${JSON.stringify(id)}, version: ${JSON.stringify(version)}, mount(container, context) { container.textContent = context.moduleId + ":slideshow"; } };\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface InstallOptions extends ModuleFixture {
|
||||
readonly assets?: DisplayHostAssets;
|
||||
readonly evaluator?: AuthorizationEvaluator;
|
||||
readonly granted?: readonly string[];
|
||||
readonly principal?: EkklesiaPrincipal;
|
||||
/** The API builds publication from the eligible/running set. `false` models a module that was
|
||||
* discovered but disabled or dependency-blocked before runtime startup. */
|
||||
readonly published?: boolean;
|
||||
readonly withAssets?: boolean;
|
||||
}
|
||||
|
||||
interface Installation {
|
||||
readonly app: Hono;
|
||||
readonly modules: readonly DiscoveredModule[];
|
||||
}
|
||||
|
||||
function fakeServices(): ModuleRuntimeServices {
|
||||
return Object.freeze({ database: Object.freeze({}) as unknown as ModuleDatabase });
|
||||
}
|
||||
|
||||
function fakeAuthorizationStore(): AuthorizationStore {
|
||||
return {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
} as unknown as AuthorizationStore;
|
||||
}
|
||||
|
||||
async function install(options: InstallOptions): Promise<Installation> {
|
||||
const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-display-"));
|
||||
temporaryDirectories.push(modulesDirectory);
|
||||
await createModule(modulesDirectory, options);
|
||||
const modules = await discoverModules({
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
});
|
||||
const granted = new Set(options.granted ?? []);
|
||||
const evaluator =
|
||||
options.evaluator ??
|
||||
createAuthorizationEvaluator({
|
||||
grants: { hasActiveGrant: async (query) => granted.has(query.permission) },
|
||||
platformOwners: { isPlatformOwner: async () => false },
|
||||
});
|
||||
const runtime: EkklesiaIdentityRuntime = {
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL),
|
||||
};
|
||||
const publishedModules = options.published === false ? [] : modules;
|
||||
const moduleUi = createModuleUiPublicationRegistry(publishedModules);
|
||||
const app = createApiApp({
|
||||
authorization: {
|
||||
evaluator,
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: fakeAuthorizationStore(),
|
||||
},
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
...(moduleUi.listDisplays().length > 0 && options.withAssets !== false
|
||||
? { displayHostAssets: options.assets ?? displayHostAssets }
|
||||
: {}),
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleLifecycle: {
|
||||
snapshot: moduleLifecycleSnapshotFor(modules),
|
||||
store: createInMemoryModuleLifecycleStore(),
|
||||
},
|
||||
moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY,
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeServices(),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
moduleUi,
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
return { app, modules };
|
||||
}
|
||||
|
||||
function signedIn(): EkklesiaPrincipal {
|
||||
return createAuthenticatedPrincipal(viewerId);
|
||||
}
|
||||
|
||||
describe("module display routes — authorization and descriptor", () => {
|
||||
it("does not let an anonymous viewer open either the page or descriptor", async () => {
|
||||
const { app } = await install({ display: true });
|
||||
|
||||
const [page, descriptor] = await Promise.all([
|
||||
app.request(moduleDisplayPageUrl("announcements")),
|
||||
app.request(moduleDisplayDescriptorUrl("announcements")),
|
||||
]);
|
||||
|
||||
expect(page.status).toBe(403);
|
||||
expect(descriptor.status).toBe(403);
|
||||
expect(page.headers.get("cache-control")).toBe("no-store");
|
||||
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await page.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(await descriptor.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
});
|
||||
|
||||
it("requires the declared display permission, not merely an authenticated principal", async () => {
|
||||
const { app } = await install({ display: true, principal: signedIn() });
|
||||
|
||||
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(403);
|
||||
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(403);
|
||||
});
|
||||
|
||||
it("serves the stable shell and current versioned descriptor to a permitted principal", async () => {
|
||||
const { app } = await install({
|
||||
display: true,
|
||||
granted: [displayPermission],
|
||||
principal: signedIn(),
|
||||
version: "1.4.2",
|
||||
});
|
||||
|
||||
const page = await app.request(moduleDisplayPageUrl("announcements"));
|
||||
const descriptor = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
||||
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
expect(page.headers.get("cache-control")).toBe("no-store");
|
||||
expect(page.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(await page.text()).toBe(displayHostAssets.html);
|
||||
expect(await descriptor.json()).toEqual({
|
||||
module: {
|
||||
api: { baseUrl: moduleApiBaseUrl("announcements") },
|
||||
display: {
|
||||
url: moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||
},
|
||||
id: "announcements",
|
||||
permissions: [displayPermission],
|
||||
realtime: { baseUrl: moduleRealtimeBaseUrl("announcements") },
|
||||
version: "1.4.2",
|
||||
},
|
||||
});
|
||||
expect(descriptor.headers.get("cache-control")).toBe("no-store");
|
||||
expect(descriptor.headers.get("content-type")).toContain("application/json");
|
||||
expect(descriptor.headers.get("vary")).toContain("Origin");
|
||||
expect(descriptor.headers.get("vary")).toContain("Cookie");
|
||||
});
|
||||
|
||||
it("sanitizes a permission-hint evaluation failure after route authorization succeeds", async () => {
|
||||
const authorize = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ allowed: true })
|
||||
.mockRejectedValueOnce(new Error("postgresql://[email protected] /srv/modules/announcements"));
|
||||
const evaluator: AuthorizationEvaluator = { authorize };
|
||||
const { app } = await install({ display: true, evaluator, principal: signedIn() });
|
||||
|
||||
const response = await app.request(moduleDisplayDescriptorUrl("announcements"));
|
||||
const body = await response.text();
|
||||
|
||||
expect(authorize).toHaveBeenCalledTimes(2);
|
||||
expect(response.status).toBe(503);
|
||||
expect(JSON.parse(body)).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("vary")).toContain("Origin");
|
||||
expect(response.headers.get("vary")).toContain("Cookie");
|
||||
for (const secret of ["postgresql", "127.0.0.1", "/srv/", "announcements"]) {
|
||||
expect(body).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the outer page stable when the internal artifact version changes", () => {
|
||||
expect(moduleDisplayPageUrl("announcements")).toBe("/display/announcements");
|
||||
expect(moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.0.0" })).not.toBe(
|
||||
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "2.0.0" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module display routes — role and lifecycle separation", () => {
|
||||
it("keeps a display-only module out of the primary UI inventory", async () => {
|
||||
const { app } = await install({
|
||||
display: true,
|
||||
granted: [displayPermission],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||
expect(inventory.status).toBe(200);
|
||||
expect(await inventory.json()).toEqual({ modules: [] });
|
||||
});
|
||||
|
||||
it("does not give a dashboard-only module a display route", async () => {
|
||||
const { app } = await install({ principal: signedIn(), ui: true });
|
||||
|
||||
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
||||
const inventory = await app.request(MODULE_UI_INVENTORY_PATH);
|
||||
expect(inventory.status).toBe(200);
|
||||
expect((await inventory.json()) as { modules: unknown[] }).toMatchObject({
|
||||
modules: [{ id: "announcements" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes both roles independently when a module declares both", async () => {
|
||||
const { app } = await install({
|
||||
display: true,
|
||||
granted: [displayPermission],
|
||||
principal: signedIn(),
|
||||
ui: true,
|
||||
});
|
||||
|
||||
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(200);
|
||||
expect((await app.request(MODULE_UI_INVENTORY_PATH)).status).toBe(200);
|
||||
expect(
|
||||
((await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { modules: unknown[] })
|
||||
.modules,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("has no display page, descriptor, or artifact when the module is outside the eligible set", async () => {
|
||||
const { app } = await install({
|
||||
display: true,
|
||||
granted: [displayPermission],
|
||||
principal: signedIn(),
|
||||
published: false,
|
||||
});
|
||||
|
||||
expect((await app.request(moduleDisplayPageUrl("announcements"))).status).toBe(404);
|
||||
expect((await app.request(moduleDisplayDescriptorUrl("announcements"))).status).toBe(404);
|
||||
expect(
|
||||
(
|
||||
await app.request(
|
||||
moduleDisplayUiArtifactUrl({ moduleId: "announcements", version: "1.4.2" }),
|
||||
)
|
||||
).status,
|
||||
).toBe(404);
|
||||
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("registers only the exact stable page and descriptor paths", async () => {
|
||||
const { app } = await install({
|
||||
display: true,
|
||||
granted: [displayPermission],
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
for (const route of [
|
||||
"/display",
|
||||
"/display/announcements/",
|
||||
"/display/announcements/settings",
|
||||
"/api/v1/module-displays",
|
||||
"/api/v1/module-displays/announcements/",
|
||||
"/api/v1/module-displays/announcements/settings",
|
||||
]) {
|
||||
expect((await app.request(route)).status, route).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it("fails composition when a display exists but its host assets were not supplied", async () => {
|
||||
await expect(install({ display: true, withAssets: false })).rejects.toBeInstanceOf(
|
||||
ModuleDisplayConfigurationError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module display host asset publication", () => {
|
||||
it("serves only the fixed script with JavaScript and revalidation headers", async () => {
|
||||
const { app } = await install({ display: true });
|
||||
|
||||
const response = await app.request(DISPLAY_HOST_SCRIPT_PATH);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(displayHostAssets.script);
|
||||
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
|
||||
const notModified = await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||
headers: { "if-none-match": displayHostAssets.scriptEtag },
|
||||
});
|
||||
expect(notModified.status).toBe(304);
|
||||
expect(await notModified.text()).toBe("");
|
||||
expect(notModified.headers.get("etag")).toBe(displayHostAssets.scriptEtag);
|
||||
expect(notModified.headers.get("cache-control")).toBe("no-cache");
|
||||
|
||||
for (const header of [
|
||||
`W/${displayHostAssets.scriptEtag}`,
|
||||
"*",
|
||||
`"stale", ${displayHostAssets.scriptEtag}`,
|
||||
]) {
|
||||
expect(
|
||||
(
|
||||
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||
headers: { "if-none-match": header },
|
||||
})
|
||||
).status,
|
||||
header,
|
||||
).toBe(304);
|
||||
}
|
||||
expect(
|
||||
(
|
||||
await app.request(DISPLAY_HOST_SCRIPT_PATH, {
|
||||
headers: { "if-none-match": '"stale"' },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
it("exposes no source map, neighboring file, arbitrary role, or request-selected path", async () => {
|
||||
const { app } = await install({ display: true });
|
||||
|
||||
for (const candidate of [
|
||||
`${DISPLAY_HOST_SCRIPT_PATH}.map`,
|
||||
"/assets/ekklesia-display/index.html",
|
||||
"/assets/ekklesia-display/secret.txt",
|
||||
"/assets/ekklesia-display/../secret.txt",
|
||||
"/api/v1/module-ui/announcements/1.4.2/management",
|
||||
]) {
|
||||
expect((await app.request(candidate)).status, candidate).toBe(404);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses identical non-interpolated HTML for every eligible module", async () => {
|
||||
const first = await install({
|
||||
display: true,
|
||||
granted: [displayPermission],
|
||||
principal: signedIn(),
|
||||
});
|
||||
const second = await install({
|
||||
display: true,
|
||||
granted: ["signage.display.view"],
|
||||
id: "signage",
|
||||
principal: signedIn(),
|
||||
});
|
||||
|
||||
const firstHtml = await (await first.app.request(moduleDisplayPageUrl("announcements"))).text();
|
||||
const secondHtml = await (await second.app.request(moduleDisplayPageUrl("signage"))).text();
|
||||
expect(firstHtml).toBe(secondHtml);
|
||||
expect(firstHtml).not.toContain("announcements");
|
||||
expect(secondHtml).not.toContain("signage");
|
||||
});
|
||||
|
||||
it("snapshots injected assets and rejects a validator that does not describe its script", async () => {
|
||||
const source = createDisplayHostAssets("<html>display host</html>", "export const host = 1;\n");
|
||||
const mutable = { ...source };
|
||||
const { app } = await install({ assets: mutable, display: true });
|
||||
mutable.html = "<html>mutated secret</html>";
|
||||
mutable.script = "throw new Error('mutated secret');";
|
||||
|
||||
expect(await (await app.request(DISPLAY_HOST_SCRIPT_PATH)).text()).toBe(source.script);
|
||||
|
||||
await expect(
|
||||
install({
|
||||
assets: { ...source, scriptEtag: '"stale-validator"' },
|
||||
display: true,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ModuleDisplayConfigurationError);
|
||||
});
|
||||
|
||||
it("does not publish the generic host script when no eligible display uses it", async () => {
|
||||
const { app } = await install({ ui: true, withAssets: false });
|
||||
|
||||
expect((await app.request(DISPLAY_HOST_SCRIPT_PATH)).status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user