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