feat: add display kiosk host
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
isVersionCompatible,
|
||||
type ModuleCapabilityDeclaration,
|
||||
type ModuleManifest,
|
||||
type PermissionId,
|
||||
safeParseModuleManifest,
|
||||
semanticVersionSchema,
|
||||
} from "@ekklesia/contracts";
|
||||
@@ -61,10 +62,16 @@ export interface ResolvedModuleEntrypoints {
|
||||
readonly schema?: string;
|
||||
readonly migrations?: string;
|
||||
readonly jobs?: string;
|
||||
/** A module's declared presentation artifact (ADR 0021), resolved and validated exactly like the
|
||||
* other entrypoints. Discovery only proves the file exists, is compiled ESM, and stays inside the
|
||||
* module package — nothing in this repository imports, evaluates, mounts, or renders it yet. */
|
||||
/** A module's primary presentation artifact (ADR 0021), resolved and validated exactly like the
|
||||
* other executable entrypoints. Discovery never imports or evaluates browser artifacts. */
|
||||
readonly ui?: string;
|
||||
/** The independently mountable full-viewport artifact and its statically declared access policy.
|
||||
* `artifactPath` is canonical and package-contained; `permission` already matched one of this
|
||||
* module's own manifest declarations during manifest parsing. */
|
||||
readonly display?: {
|
||||
readonly artifactPath: string;
|
||||
readonly permission: PermissionId;
|
||||
};
|
||||
}
|
||||
|
||||
// Branded primitives (`PermissionId`, `ModuleId`, ...) are `string & {brand}` intersection types,
|
||||
@@ -258,11 +265,49 @@ async function resolveEntrypoints(
|
||||
): Promise<ResolvedModuleEntrypoints | undefined> {
|
||||
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
|
||||
const canonicalModuleDirectory = await realpath(moduleDirectory);
|
||||
const { display, ...ordinaryEntrypoints } = manifest.entrypoints;
|
||||
const ordinaryConfiguredEntrypoints = Object.entries(ordinaryEntrypoints)
|
||||
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
||||
.map(([entrypointName, relativeEntrypoint]) => ({
|
||||
entrypointName,
|
||||
issuePath: `entrypoints.${entrypointName}`,
|
||||
relativeEntrypoint,
|
||||
}));
|
||||
const configuredEntrypoints = [
|
||||
...ordinaryConfiguredEntrypoints,
|
||||
...(display === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
entrypointName: "display",
|
||||
issuePath: "entrypoints.display.artifact",
|
||||
relativeEntrypoint: display.artifact,
|
||||
},
|
||||
]),
|
||||
];
|
||||
let entrypointCollision = false;
|
||||
|
||||
for (const [entrypointName, relativeEntrypoint] of Object.entries(manifest.entrypoints)) {
|
||||
if (relativeEntrypoint === undefined) {
|
||||
continue;
|
||||
if (display !== undefined) {
|
||||
const configuredDisplayPath = path.resolve(moduleDirectory, display.artifact);
|
||||
for (const ordinary of ordinaryConfiguredEntrypoints) {
|
||||
if (path.resolve(moduleDirectory, ordinary.relativeEntrypoint) === configuredDisplayPath) {
|
||||
entrypointCollision = true;
|
||||
issues.push({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
message: `display must not resolve to the same configured file as ${ordinary.entrypointName}.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: "entrypoints.display.artifact",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const {
|
||||
entrypointName,
|
||||
issuePath: entrypointIssuePath,
|
||||
relativeEntrypoint,
|
||||
} of configuredEntrypoints) {
|
||||
if (
|
||||
isCompiledEntrypoint(entrypointName) &&
|
||||
![".js", ".mjs"].includes(path.extname(relativeEntrypoint).toLowerCase())
|
||||
@@ -272,7 +317,7 @@ async function resolveEntrypoints(
|
||||
message: `${entrypointName} must reference compiled ESM JavaScript with a .js or .mjs extension.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: `entrypoints.${entrypointName}`,
|
||||
path: entrypointIssuePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -283,7 +328,7 @@ async function resolveEntrypoints(
|
||||
message: `${entrypointName} resolves outside its module directory.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: `entrypoints.${entrypointName}`,
|
||||
path: entrypointIssuePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -296,7 +341,7 @@ async function resolveEntrypoints(
|
||||
message: `${entrypointName} resolves through a link outside its module directory.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: `entrypoints.${entrypointName}`,
|
||||
path: entrypointIssuePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -312,7 +357,7 @@ async function resolveEntrypoints(
|
||||
: `${entrypointName} must resolve to a file.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: `entrypoints.${entrypointName}`,
|
||||
path: entrypointIssuePath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -329,12 +374,27 @@ async function resolveEntrypoints(
|
||||
}.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: `entrypoints.${entrypointName}`,
|
||||
path: entrypointIssuePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolved.server) {
|
||||
if (resolved.display !== undefined) {
|
||||
for (const [entrypointName, canonicalEntrypoint] of Object.entries(resolved)) {
|
||||
if (entrypointName !== "display" && canonicalEntrypoint === resolved.display) {
|
||||
entrypointCollision = true;
|
||||
issues.push({
|
||||
code: "MODULE_ENTRYPOINT_INVALID",
|
||||
message: `display must not resolve through a link to the same file as ${entrypointName}.`,
|
||||
moduleDirectory: directoryName,
|
||||
moduleId: manifest.id,
|
||||
path: "entrypoints.display.artifact",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolved.server || entrypointCollision) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -344,6 +404,14 @@ async function resolveEntrypoints(
|
||||
...(resolved.migrations ? { migrations: resolved.migrations } : {}),
|
||||
...(resolved.jobs ? { jobs: resolved.jobs } : {}),
|
||||
...(resolved.ui ? { ui: resolved.ui } : {}),
|
||||
...(resolved.display && display
|
||||
? {
|
||||
display: {
|
||||
artifactPath: resolved.display,
|
||||
permission: display.permission,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PermissionDeclaration } from "@ekklesia/contracts";
|
||||
import type { PermissionDeclaration, PermissionId } from "@ekklesia/contracts";
|
||||
import type { DiscoveredModule } from "./discovery.js";
|
||||
|
||||
/**
|
||||
@@ -40,6 +40,26 @@ export interface PublishableModuleUi {
|
||||
readonly permissions: readonly PermissionDeclaration[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One installed module whose dedicated full-viewport display artifact may be published.
|
||||
*
|
||||
* This is deliberately a separate record from {@link PublishableModuleUi}. A primary UI and a
|
||||
* display UI are two independently declared artifacts with different host roles, and a module may
|
||||
* publish either one without publishing the other. `displayPermission` is the module-owned
|
||||
* permission the manifest places beside `entrypoints.display`; it travels with the exact artifact
|
||||
* identity so a display descriptor cannot accidentally authorize one artifact with another
|
||||
* module's policy. The complete declaration list supports the same browser-safe permission hints
|
||||
* as the primary UI while conferring no authority by itself.
|
||||
*/
|
||||
export interface PublishableModuleDisplay {
|
||||
readonly moduleId: string;
|
||||
readonly version: string;
|
||||
readonly name: string;
|
||||
readonly artifactPath: string;
|
||||
readonly displayPermission: PermissionId;
|
||||
readonly permissions: readonly PermissionDeclaration[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's answer to "may this module's UI artifact be served, and if so, which exact file?"
|
||||
*
|
||||
@@ -60,6 +80,15 @@ export interface ModuleUiPublicationRegistry {
|
||||
* version are all indistinguishable here — each returns `undefined`.
|
||||
*/
|
||||
resolve(moduleId: unknown, version: unknown): PublishableModuleUi | undefined;
|
||||
/** Every dedicated display artifact, in discovery order. Stable and frozen. */
|
||||
listDisplays(): readonly PublishableModuleDisplay[];
|
||||
/**
|
||||
* The dedicated display record for an exact module id and installed version, or `undefined`.
|
||||
*
|
||||
* This lookup never falls back to `entrypoints.ui`: a primary/dashboard artifact cannot become
|
||||
* a display merely because a caller appends `/display` to its publication URL.
|
||||
*/
|
||||
resolveDisplay(moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,8 +99,9 @@ export interface ModuleUiPublicationRegistry {
|
||||
* 1. the module is in the **eligible** set the platform started — it survived discovery *and*
|
||||
* the operational lifecycle permits it to run (`resolveModuleEligibility`, `./lifecycle.js`) —
|
||||
* and
|
||||
* 2. its manifest declares `entrypoints.ui`, which discovery resolved to a real compiled file
|
||||
* inside the module package.
|
||||
* 2. for each role independently, its manifest declares `entrypoints.ui` or
|
||||
* `entrypoints.display`, which discovery resolved to a real compiled file inside the module
|
||||
* package.
|
||||
*
|
||||
* A module directory sitting on disk satisfies neither on its own — it has to have survived
|
||||
* discovery — which is the property `docs/development/security.md` requires: *the existence of an
|
||||
@@ -93,30 +123,47 @@ export function createModuleUiPublicationRegistry(
|
||||
modules: readonly DiscoveredModule[],
|
||||
): ModuleUiPublicationRegistry {
|
||||
const byModuleId = new Map<string, PublishableModuleUi>();
|
||||
const displaysByModuleId = new Map<string, PublishableModuleDisplay>();
|
||||
|
||||
for (const module of modules) {
|
||||
const artifactPath = module.entrypoints.ui;
|
||||
if (artifactPath === undefined) {
|
||||
continue;
|
||||
if (artifactPath !== undefined) {
|
||||
byModuleId.set(
|
||||
module.manifest.id,
|
||||
Object.freeze({
|
||||
artifactPath,
|
||||
moduleId: module.manifest.id,
|
||||
name: module.manifest.name,
|
||||
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
|
||||
// this record can never reach the manifest object graph itself.
|
||||
permissions: Object.freeze([...module.manifest.permissions]),
|
||||
version: module.manifest.version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const display = module.entrypoints.display;
|
||||
if (display !== undefined) {
|
||||
displaysByModuleId.set(
|
||||
module.manifest.id,
|
||||
Object.freeze({
|
||||
artifactPath: display.artifactPath,
|
||||
displayPermission: display.permission,
|
||||
moduleId: module.manifest.id,
|
||||
name: module.manifest.name,
|
||||
permissions: Object.freeze([...module.manifest.permissions]),
|
||||
version: module.manifest.version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
byModuleId.set(
|
||||
module.manifest.id,
|
||||
Object.freeze({
|
||||
artifactPath,
|
||||
moduleId: module.manifest.id,
|
||||
name: module.manifest.name,
|
||||
// Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding
|
||||
// this record can never reach the manifest object graph itself.
|
||||
permissions: Object.freeze([...module.manifest.permissions]),
|
||||
version: module.manifest.version,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const published = Object.freeze([...byModuleId.values()]);
|
||||
const publishedDisplays = Object.freeze([...displaysByModuleId.values()]);
|
||||
|
||||
return Object.freeze({
|
||||
list: (): readonly PublishableModuleUi[] => published,
|
||||
listDisplays: (): readonly PublishableModuleDisplay[] => publishedDisplays,
|
||||
resolve: (moduleId: unknown, version: unknown): PublishableModuleUi | undefined => {
|
||||
if (typeof moduleId !== "string" || typeof version !== "string") {
|
||||
return undefined;
|
||||
@@ -127,5 +174,12 @@ export function createModuleUiPublicationRegistry(
|
||||
// than to a different artifact that happens to exist.
|
||||
return candidate?.version === version ? candidate : undefined;
|
||||
},
|
||||
resolveDisplay: (moduleId: unknown, version: unknown): PublishableModuleDisplay | undefined => {
|
||||
if (typeof moduleId !== "string" || typeof version !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = displaysByModuleId.get(moduleId);
|
||||
return candidate?.version === version ? candidate : undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -57,7 +57,7 @@ function manifestFor(
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 6,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "0.1.0",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18,7 +18,7 @@ function fakeModule(
|
||||
} = {},
|
||||
): DiscoveredModule {
|
||||
const manifest = parseModuleManifest({
|
||||
manifestVersion: 5,
|
||||
manifestVersion: 6,
|
||||
id,
|
||||
name: `${id} module`,
|
||||
version: "1.0.0",
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user