feat: add display kiosk host
This commit is contained in:
@@ -322,7 +322,7 @@ async function createGatedRouteModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 6,
|
||||
name: `${id} module`,
|
||||
permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }],
|
||||
version: "0.1.0",
|
||||
@@ -366,7 +366,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",
|
||||
@@ -471,7 +471,7 @@ async function createUiModule(
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 6,
|
||||
name: `${id} module`,
|
||||
version,
|
||||
}),
|
||||
|
||||
@@ -75,7 +75,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 6,
|
||||
name: fixture.name,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:fs/promises", () => ({ readFile: readFileMock }));
|
||||
vi.mock("@ekklesia/shell/display-assets", () => ({
|
||||
displayHostAssetUrls: Object.freeze({
|
||||
html: "file:///surface/browser/display/index.html",
|
||||
script: "file:///surface/browser/display/app.js",
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
createDisplayHostAssets,
|
||||
DisplayHostAssetError,
|
||||
loadDisplayHostAssets,
|
||||
} from "../src/display-host-assets.js";
|
||||
|
||||
describe("display host asset loading", () => {
|
||||
beforeEach(() => readFileMock.mockReset());
|
||||
|
||||
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
const html = '<!doctype html><div id="ekklesia-display"></div>';
|
||||
const script = "export const displayHost = true;\n";
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||
|
||||
const assets = await loadDisplayHostAssets();
|
||||
|
||||
expect(readFileMock.mock.calls).toEqual([
|
||||
[new URL("file:///surface/browser/display/index.html"), "utf8"],
|
||||
[new URL("file:///surface/browser/display/app.js"), "utf8"],
|
||||
]);
|
||||
expect(assets).toEqual({
|
||||
html,
|
||||
script,
|
||||
scriptEtag: `"${createHash("sha256").update(script).digest("base64url")}"`,
|
||||
});
|
||||
expect(Object.isFrozen(assets)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails deterministically without attempting a neighboring file or exposing the read error", async () => {
|
||||
readFileMock.mockRejectedValueOnce(
|
||||
new Error("ENOENT /home/operator/private/surface-build secret-token"),
|
||||
);
|
||||
|
||||
const failure = await loadDisplayHostAssets().catch((error: unknown) => error);
|
||||
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(failure).toBeInstanceOf(DisplayHostAssetError);
|
||||
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.",
|
||||
);
|
||||
const publicText = `${String(failure)}\n${JSON.stringify(failure)}\n${(failure as Error).stack}`;
|
||||
for (const secret of ["/home/", "operator", "ENOENT", "secret-token"]) {
|
||||
expect(publicText).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an empty page or script as an unavailable host build", () => {
|
||||
for (const [html, script] of [
|
||||
["", "export {};"],
|
||||
["<html></html>", " \n"],
|
||||
] as const) {
|
||||
expect(() => createDisplayHostAssets(html, script)).toThrow(DisplayHostAssetError);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -106,7 +106,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
configuration: fixture.configuration ?? [],
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 6,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.dependencies ? { dependencies: fixture.dependencies } : {}),
|
||||
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
|
||||
id: fixture.id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 6,
|
||||
name: `${fixture.id} module`,
|
||||
version: "1.0.0",
|
||||
}),
|
||||
|
||||
@@ -188,7 +188,7 @@ async function createFixtureModule(control: FixtureControl): Promise<{
|
||||
compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" },
|
||||
entrypoints: { server: "./dist/server/index.js" },
|
||||
id: "announcements",
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 6,
|
||||
name: "Announcements fixture",
|
||||
permissions: [
|
||||
{
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -83,7 +83,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
|
||||
...(fixture.withUi ? { ui: "./dist/ui/index.js" } : {}),
|
||||
},
|
||||
id: fixture.id,
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 6,
|
||||
name: `${fixture.id} module`,
|
||||
permissions: (fixture.permissions ?? []).map((permission) => ({
|
||||
...permission,
|
||||
|
||||
@@ -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