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
+66 -21
View File
@@ -1,15 +1,16 @@
import { z } from "zod";
import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js";
import {
type ModuleConfigurationDeclaration,
moduleConfigurationDeclarationSchema,
} from "./module-configuration.js";
import {
capabilityIdSchema,
logicalBucketIdSchema,
moduleIdSchema,
packageRelativePathSchema,
permissionIdSchema,
} from "./identifiers.js";
import {
type ModuleConfigurationDeclaration,
moduleConfigurationDeclarationSchema,
} from "./module-configuration.js";
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
@@ -64,17 +65,27 @@ const moduleStorageSchema = z
.default(() => ({ buckets: [] }));
/**
* The compiled artifacts a module publishes for the platform to resolve. Every entry is a
* package-relative path to a build output, never source: Core resolves and validates these
* statically during discovery, and the manifest stays inert JSON (ADR 0006, ADR 0021).
* A module's optional full-viewport display UI. The artifact is kept beside its access policy so a
* display can never be declared without saying which one of the module's own permissions protects
* it. Core validates the reference against `permissions` below without executing module code.
*/
export const moduleDisplayEntrypointSchema = z
.object({
artifact: packageRelativePathSchema,
permission: permissionIdSchema,
})
.strict();
/**
* The compiled artifacts a module publishes for the platform to resolve. File entries are
* package-relative paths to build output, never source: Core resolves and validates them statically
* during discovery, and the manifest stays inert JSON (ADR 0006, ADR 0021).
*
* `ui` is a module's optional, host-neutral presentation entrypoint (ADR 0021). It is declared and
* statically validated here, exactly like `jobs` already is, but **no runtime consumer exists yet**
* — nothing in this repository imports, evaluates, mounts, or renders it. Its name is deliberately
* not `dashboard`: a module's UI must be hostable by Ekklesia's own application shell *and* by a
* future standalone host, so the contract cannot be named after one of them. It is deliberately not
* `surface` either — `surfaces/*` is a separate ownership layer (ADR 0001), and reusing that word
* for a module-owned artifact would conflate two different things.
* statically validated here and mounted by a compatible host. Its name is deliberately not
* `dashboard`: a module's primary UI must be hostable by Ekklesia's application shell *and* by a
* future standalone host. `display` is separate because full-viewport output and primary management
* UI are distinct product experiences; both still use the same browser mount ABI.
*/
const entrypointsSchema = z
.object({
@@ -83,6 +94,7 @@ const entrypointsSchema = z
migrations: packageRelativePathSchema.optional(),
jobs: packageRelativePathSchema.optional(),
ui: packageRelativePathSchema.optional(),
display: moduleDisplayEntrypointSchema.optional(),
})
.strict();
@@ -106,10 +118,9 @@ function reportDuplicates(
}
/**
* The one manifest shape Core accepts. Adding a top-level `configuration` array is an incompatible
* shape change — version 4's manifest object is `.strict()` and rejects `configuration` outright —
* so the canonical manifest moves to `manifestVersion: 5` rather than reinterpreting version 4
* (ADR 0029).
* The one manifest shape Core accepts. Version 5's strict `entrypoints` object rejects `display`, so
* the static display declaration is an incompatible shape change and the canonical manifest moves
* to `manifestVersion: 6` rather than silently reinterpreting version 5.
*
* Configuration is declared **statically, in this inert document**, rather than registered by
* executable module code, for one decisive reason: the platform validates a module's configuration
@@ -118,13 +129,13 @@ function reportDuplicates(
* inside its own `start` hook. Discovery, `pnpm modules:validate`, and Doctor all read this without
* importing a single line of module code, exactly as they always have.
*
* Version 4, and every other version, fails validation: there is no compatibility adapter, no
* Version 5, and every other version, fails validation: there is no compatibility adapter, no
* automatic conversion, and no dual acceptance, exactly as version 1 was rejected by version 2
* (ADR 0006), version 2 by version 3 (ADR 0014), and version 3 by version 4 (ADR 0021). One
* manifest document can therefore never be read as two different versions. Upgrading a version 4
* manifest is a one-character edit: no field added by version 5 is required.
* manifest document can therefore never be read as two different versions. Upgrading a version 5
* manifest is a one-character edit for a module that declares no display: the new field is optional.
*/
export const MODULE_MANIFEST_VERSION = 5;
export const MODULE_MANIFEST_VERSION = 6;
export const moduleManifestSchema = z
.object({
@@ -199,6 +210,39 @@ export const moduleManifestSchema = z
}
});
const display = manifest.entrypoints.display;
if (display !== undefined) {
const displayPermission = manifest.permissions.find(
(permission) => permission.id === display.permission,
);
if (displayPermission === undefined) {
context.addIssue({
code: "custom",
message: "A display entrypoint must reference a permission declared by this module.",
path: ["entrypoints", "display", "permission"],
});
} else if (displayPermission.baseline === "public") {
context.addIssue({
code: "custom",
message: "A display entrypoint permission must require an authenticated principal.",
path: ["entrypoints", "display", "permission"],
});
}
if (
Object.entries(manifest.entrypoints).some(
([entrypointName, configured]) =>
entrypointName !== "display" && configured === display.artifact,
)
) {
context.addIssue({
code: "custom",
message:
"The display UI must use a different path from every other configured entrypoint.",
path: ["entrypoints", "display", "artifact"],
});
}
}
for (const dependencyKind of ["required", "optional"] as const) {
if (Object.hasOwn(manifest.dependencies.modules[dependencyKind], manifest.id)) {
context.addIssue({
@@ -253,6 +297,7 @@ export function safeParseModuleManifest(input: unknown) {
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
export type ModuleDisplayEntrypoint = z.output<typeof moduleDisplayEntrypointSchema>;
/** A module manifest's `configuration` array entry — the same `ModuleConfigurationDeclaration` shape
* Core's configuration resolver consumes (`packages/contracts/src/module-configuration.ts`), aliased
* here for discoverability alongside the manifest's other per-field declaration types. */
@@ -1,13 +1,13 @@
import { describe, expect, it } from "vitest";
import {
isVersionCompatible,
isModuleConfigurationBlockedReason,
isVersionCompatible,
MODULE_BLOCKED_REASONS,
MODULE_CONFIGURATION_BLOCKED_REASONS,
MODULE_DEPENDENCY_KINDS,
MODULE_STORAGE_BLOCKED_REASON,
MODULE_MANIFEST_VERSION,
MODULE_RUNTIME_STATES,
MODULE_STORAGE_BLOCKED_REASON,
packageRelativePathSchema,
parseModuleManifest,
parsePermissionId,
@@ -16,7 +16,7 @@ import {
function validManifest(): Record<string, unknown> {
return {
manifestVersion: 5,
manifestVersion: 6,
id: "calendar",
name: "Calendar",
version: "0.1.0",
@@ -226,9 +226,15 @@ describe("module manifest contract", () => {
expect(safeParseModuleManifest(version4Manifest).success).toBe(false);
});
it("rejects a version 5 manifest rather than silently interpreting it as version 6", () => {
const version5Manifest = { ...validManifest(), manifestVersion: 5 };
expect(safeParseModuleManifest(version5Manifest).success).toBe(false);
});
it("accepts exactly one manifest version, so no document can be read as two versions", () => {
expect(MODULE_MANIFEST_VERSION).toBe(5);
for (const manifestVersion of [1, 2, 3, 4, 6, "5", 5.5, null]) {
expect(MODULE_MANIFEST_VERSION).toBe(6);
for (const manifestVersion of [1, 2, 3, 4, 5, 7, "6", 6.5, null]) {
expect(safeParseModuleManifest({ ...validManifest(), manifestVersion }).success).toBe(false);
}
expect(
@@ -391,6 +397,7 @@ describe("module manifest contract", () => {
const manifest = parseModuleManifest(validManifest());
expect(manifest.entrypoints.ui).toBeUndefined();
expect(manifest.entrypoints.display).toBeUndefined();
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
});
@@ -405,6 +412,153 @@ describe("module manifest contract", () => {
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
});
it("accepts an independently declared display UI protected by a module-owned permission", () => {
const manifest = parseModuleManifest({
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
display: {
artifact: "./dist/display/index.js",
permission: "calendar.events.read",
},
},
});
expect(manifest.entrypoints.ui).toBeUndefined();
expect(manifest.entrypoints.display).toEqual({
artifact: "./dist/display/index.js",
permission: "calendar.events.read",
});
});
it("rejects a display permission that this module did not declare", () => {
const result = safeParseModuleManifest({
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
display: {
artifact: "./dist/display/index.js",
permission: "calendar.display.view",
},
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"entrypoints.display.permission",
);
}
});
it("rejects a display permission whose baseline would admit an anonymous principal", () => {
const result = safeParseModuleManifest({
...validManifest(),
permissions: [
{
id: "calendar.display.view",
baseline: "public",
description: "View the calendar display.",
},
],
entrypoints: {
server: "./dist/server/index.js",
display: {
artifact: "./dist/display/index.js",
permission: "calendar.display.view",
},
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"entrypoints.display.permission",
);
}
});
for (const entrypointName of ["server", "schema", "migrations", "jobs", "ui"] as const) {
it(`rejects a display artifact that is also configured as the ${entrypointName} entrypoint`, () => {
const sharedPath = "./dist/shared.js";
const result = safeParseModuleManifest({
...validManifest(),
entrypoints: {
server: entrypointName === "server" ? sharedPath : "./dist/server/index.js",
...(entrypointName === "server" ? {} : { [entrypointName]: sharedPath }),
display: {
artifact: sharedPath,
permission: "calendar.events.read",
},
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"entrypoints.display.artifact",
);
}
});
}
it("rejects primary and display UI declarations that name the same artifact", () => {
const result = safeParseModuleManifest({
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
ui: "./dist/ui/index.js",
display: {
artifact: "./dist/ui/index.js",
permission: "calendar.events.read",
},
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"entrypoints.display.artifact",
);
}
});
it.each([
"../outside/display.js",
"/absolute/display.js",
"./dist/../../display.js",
"file:///dist/display.js",
".\\dist\\display.js",
"./dist/display.js?raw",
"",
])("rejects the unsafe display artifact path %s", (artifact) => {
expect(
safeParseModuleManifest({
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
display: { artifact, permission: "calendar.events.read" },
},
}).success,
).toBe(false);
});
it("rejects unknown properties in the static display declaration", () => {
expect(
safeParseModuleManifest({
...validManifest(),
entrypoints: {
server: "./dist/server/index.js",
display: {
artifact: "./dist/display/index.js",
permission: "calendar.events.read",
public: true,
},
},
}).success,
).toBe(false);
});
it.each([
["../outside/ui.js", "escapes the package boundary"],
["/absolute/ui.js", "is an absolute path"],