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
+1 -1
View File
@@ -91,7 +91,7 @@ async function createModule(
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -59,7 +59,7 @@ async function createModule(modulesDirectory: string, fixture: ModuleFixture): P
: {}),
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
id: fixture.id,
manifestVersion: 5,
manifestVersion: 6,
name: fixture.name,
version: "1.0.0",
}),
+1 -1
View File
@@ -42,7 +42,7 @@ function discoveredModule(id: string, version = "0.1.0"): DiscoveredModule {
},
entrypoints: { server: "./dist/server.js" },
id,
manifestVersion: 5,
manifestVersion: 6,
name: `${id} module`,
permissions: [],
storage: { buckets: [] },
@@ -1,5 +1,5 @@
{
"manifestVersion": 5,
"manifestVersion": 6,
"id": "db-facade",
"name": "Database Facade Integration Test Module",
"description": "Test-only installed module fixture proving ModuleDatabase against real PostgreSQL. Never discovered by production startup, Doctor, or normal migration aggregation — only apps/api/tests/module-database.integration.ts points discovery at it explicitly.",
@@ -17,7 +17,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "1.0.0",
@@ -192,7 +192,7 @@ afterAll(async () => {
function manifestWith(configuration: readonly Record<string, unknown>[], id = "announcements") {
return parseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "1.0.0",
+8 -8
View File
@@ -21,7 +21,7 @@ function manifestWith(
id = "announcements",
) {
return parseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "1.0.0",
@@ -92,7 +92,7 @@ describe("module configuration declarations", () => {
it("rejects two declarations of the same key, so an ordinary key and a secret can never collide", () => {
const result = safeParseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -114,7 +114,7 @@ describe("module configuration declarations", () => {
it("rejects a default on a secret, because a checked-in default credential is a leaked one", () => {
const result = safeParseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -130,7 +130,7 @@ describe("module configuration declarations", () => {
it("rejects a key that is both required and defaulted, which states an intent nothing can honour", () => {
const result = safeParseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -169,7 +169,7 @@ describe("module configuration declarations", () => {
],
])("rejects %s", (_label, declaration) => {
const result = safeParseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -189,7 +189,7 @@ describe("module configuration declarations", () => {
"valueOf",
])("rejects the reserved object property name %s as a configuration key", (key) => {
const result = safeParseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -210,7 +210,7 @@ describe("module configuration declarations", () => {
"",
])("rejects the malformed configuration key %s", (key) => {
const result = safeParseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id: "announcements",
name: "announcements module",
version: "1.0.0",
@@ -591,7 +591,7 @@ describe("configuration over a real discovered inventory", () => {
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "1.0.0",
+136 -1
View File
@@ -34,7 +34,7 @@ async function createModulesDirectory(): Promise<string> {
function manifestFor(id: string): Record<string, unknown> {
return {
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "0.1.0",
@@ -748,7 +748,9 @@ describe("module discovery", () => {
});
expect(discovered?.entrypoints.ui).toBeUndefined();
expect(discovered?.entrypoints.display).toBeUndefined();
expect(Object.hasOwn(discovered?.entrypoints ?? {}, "ui")).toBe(false);
expect(Object.hasOwn(discovered?.entrypoints ?? {}, "display")).toBe(false);
});
it("rejects a source TypeScript UI entrypoint where compiled output is required", async () => {
@@ -821,4 +823,137 @@ describe("module discovery", () => {
expect(discovered[0]?.entrypoints.ui).toContain("dist/ui/index.js");
});
});
describe("module-owned display UI entrypoint", () => {
function manifestWithDisplay(
id: string,
artifact: string,
ui?: string,
): Record<string, unknown> {
const manifest = manifestFor(id);
manifest.entrypoints = {
server: "./dist/server/index.js",
...(ui === undefined ? {} : { ui }),
display: { artifact, permission: `${id}.records.read` },
};
return manifest;
}
it("resolves a display-only artifact and preserves its module-owned permission", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "announcements", {
manifest: manifestWithDisplay("announcements", "./dist/display/index.mjs"),
extraFiles: {
"dist/display/index.mjs": "throw new Error('discovery must stay inert');",
},
});
const [discovered] = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(discovered?.entrypoints.ui).toBeUndefined();
expect(discovered?.entrypoints.display).toEqual({
artifactPath: path.join(discovered?.directory ?? "", "dist/display/index.mjs"),
permission: "announcements.records.read",
});
expect(Object.isFrozen(discovered?.entrypoints.display)).toBe(true);
});
it("resolves independent primary and display artifacts without executing either", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "signage", {
manifest: manifestWithDisplay("signage", "./dist/display/index.js", "./dist/ui/index.js"),
extraFiles: {
"dist/display/index.js": "throw new Error('display must stay inert');",
"dist/ui/index.js": "throw new Error('primary UI must stay inert');",
},
});
const [discovered] = await discoverModules({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
});
expect(discovered?.entrypoints.ui).toContain("dist/ui/index.js");
expect(discovered?.entrypoints.display?.artifactPath).toContain("dist/display/index.js");
});
it("rejects source TypeScript and reports the nested display artifact path", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "signage", {
manifest: manifestWithDisplay("signage", "./src/display/index.tsx"),
extraFiles: { "src/display/index.tsx": "export default {};" },
});
const error = await discoveryError(modulesDirectory);
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
expect(error.issues).toContainEqual(
expect.objectContaining({ path: "entrypoints.display.artifact" }),
);
});
it("rejects a declared display whose compiled artifact is missing", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "signage", {
manifest: manifestWithDisplay("signage", "./dist/display/index.js"),
});
const error = await discoveryError(modulesDirectory);
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING");
expect(error.issues).toContainEqual(
expect.objectContaining({ path: "entrypoints.display.artifact" }),
);
});
it("rejects distinct configured UI paths that resolve through links to one artifact", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "signage", {
manifest: manifestWithDisplay("signage", "./dist/display/index.js", "./dist/ui/index.js"),
extraFiles: { "dist/shared.js": "export default {};" },
});
const moduleDirectory = path.join(modulesDirectory, "signage");
await Promise.all([
mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }),
mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true }),
]);
await Promise.all([
symlink("../shared.js", path.join(moduleDirectory, "dist/ui/index.js")),
symlink("../shared.js", path.join(moduleDirectory, "dist/display/index.js")),
]);
const error = await discoveryError(modulesDirectory);
expect(error.issues).toContainEqual(
expect.objectContaining({
code: "MODULE_ENTRYPOINT_INVALID",
path: "entrypoints.display.artifact",
}),
);
});
it("rejects a display artifact that resolves through a link to the server artifact", async () => {
const modulesDirectory = await createModulesDirectory();
await createModule(modulesDirectory, "signage", {
manifest: manifestWithDisplay("signage", "./dist/display/index.js"),
});
const moduleDirectory = path.join(modulesDirectory, "signage");
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
await symlink("../server/index.js", path.join(moduleDirectory, "dist/display/index.js"));
const error = await discoveryError(modulesDirectory);
expect(error.issues).toContainEqual(
expect.objectContaining({
code: "MODULE_ENTRYPOINT_INVALID",
path: "entrypoints.display.artifact",
}),
);
});
});
});
@@ -198,7 +198,7 @@ afterAll(async () => {
* by eligibility resolution. */
function fakeModule(id: string, dependencies?: Record<string, unknown>): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "1.0.0",
+2 -2
View File
@@ -22,7 +22,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "0.1.0",
@@ -343,7 +343,7 @@ describe("resolveModuleEligibility — over a real discovered inventory", () =>
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "0.1.0",
@@ -65,7 +65,7 @@ function manifestFor(
} = {},
): Record<string, unknown> {
return {
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -27,7 +27,7 @@ interface PermissionInput {
* is read by `composePlatformPermissionRegistry`; the rest exist only to satisfy the type. */
function fakeModule(id: string, permissions: readonly PermissionInput[] = []): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -31,7 +31,7 @@ function manifest(
permissions: readonly Record<string, string>[] = [],
): Record<string, unknown> {
return {
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -57,7 +57,7 @@ function manifestFor(
} = {},
): Record<string, unknown> {
return {
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -41,7 +41,7 @@ function manifestFor(
options: { dependencies?: Record<string, unknown>; schema?: string | false } = {},
): Record<string, unknown> {
return {
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "0.1.0",
+1 -1
View File
@@ -18,7 +18,7 @@ function fakeModule(
} = {},
): DiscoveredModule {
const manifest = parseModuleManifest({
manifestVersion: 5,
manifestVersion: 6,
id,
name: `${id} module`,
version: "1.0.0",
+111 -1
View File
@@ -27,10 +27,13 @@ interface ModuleFixture {
readonly version?: string;
/** Omitted entirely for a backend-only module, so the manifest declares no `entrypoints.ui`. */
readonly ui?: string;
/** A dedicated full-viewport artifact, independent of the primary UI. */
readonly display?: string;
}
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(
@@ -49,10 +52,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,
}),
);
@@ -64,6 +85,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);
}
}
/** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that
@@ -158,7 +183,9 @@ describe("module UI publication registry", () => {
const registry = createModuleUiPublicationRegistry([]);
expect(registry.list()).toEqual([]);
expect(registry.listDisplays()).toEqual([]);
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
expect(registry.resolveDisplay("announcements", "0.1.0")).toBeUndefined();
});
it("publishes an immutable snapshot that no caller can extend", async () => {
@@ -184,3 +211,86 @@ describe("module UI publication registry", () => {
expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBeDefined();
});
});
describe("module display UI publication registry", () => {
it("keeps primary UI and display publication independent for display-only, both, and neither", async () => {
const modules = await discover([
{ display: "export default { role: 'display' };", id: "announcements" },
{
display: "export default { role: 'display' };",
id: "signage",
ui: "export default { role: 'primary' };",
},
{ id: "calendar" },
]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.list().map((entry) => entry.moduleId)).toEqual(["signage"]);
expect(registry.listDisplays().map((entry) => entry.moduleId)).toEqual([
"announcements",
"signage",
]);
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
expect(registry.resolveDisplay("calendar", "0.1.0")).toBeUndefined();
const signage = registry.resolveDisplay("signage", "0.1.0");
expect(signage).toMatchObject({
displayPermission: "signage.display.view",
moduleId: "signage",
name: "signage module",
version: "0.1.0",
});
expect(signage?.artifactPath).not.toBe(registry.resolve("signage", "0.1.0")?.artifactPath);
});
it("carries only discovery's canonical display artifact and declared permission metadata", async () => {
const modules = await discover([
{ display: 'throw new Error("display must remain inert");', id: "announcements" },
]);
const discovered = modules[0] as DiscoveredModule;
const registry = createModuleUiPublicationRegistry(modules);
const published = registry.resolveDisplay("announcements", "0.1.0");
expect(published?.artifactPath).toBe(discovered.entrypoints.display?.artifactPath);
expect(path.isAbsolute(published?.artifactPath as string)).toBe(true);
expect(published?.displayPermission).toBe(discovered.entrypoints.display?.permission);
expect(published?.permissions.map((permission) => permission.id)).toEqual([
"announcements.display.view",
]);
});
it("requires exact identity and fails closed on malformed lookup values", async () => {
const modules = await discover([
{ display: "export default {};", id: "announcements", version: "1.4.2" },
]);
const registry = createModuleUiPublicationRegistry(modules);
expect(registry.resolveDisplay("announcements", "1.4.2")).toBeDefined();
for (const version of ["1.4.1", "latest", "^1.4.2", "1.4.2 ", ""]) {
expect(registry.resolveDisplay("announcements", version)).toBeUndefined();
}
for (const value of [undefined, null, 0, {}, [], Symbol("announcements")]) {
expect(registry.resolveDisplay(value, "1.4.2")).toBeUndefined();
expect(registry.resolveDisplay("announcements", value)).toBeUndefined();
}
});
it("publishes a frozen snapshot and cannot expose a module omitted from its eligible input", async () => {
const modules = await discover([
{ display: "export default {};", id: "announcements" },
{ display: "export default {};", id: "signage" },
]);
// Production passes Core's eligible/started set. Omitting a blocked module at this boundary is
// sufficient to make both its metadata and artifact structurally unresolvable.
const registry = createModuleUiPublicationRegistry(
modules.filter((module) => module.manifest.id === "signage"),
);
expect(registry.resolveDisplay("announcements", "0.1.0")).toBeUndefined();
expect(registry.listDisplays().map((entry) => entry.moduleId)).toEqual(["signage"]);
expect(Object.isFrozen(registry.listDisplays())).toBe(true);
expect(Object.isFrozen(registry.listDisplays()[0])).toBe(true);
expect(Object.isFrozen(registry.listDisplays()[0]?.permissions)).toBe(true);
expect(registry.listDisplays()).toBe(registry.listDisplays());
});
});