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. */