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. */
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user