feat: add display kiosk host
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user