import { z } from "zod"; import { type PermissionDeclaration, permissionDeclarationSchema } from "./authorization.js"; import { capabilityIdSchema, logicalBucketIdSchema, moduleIdSchema, packageRelativePathSchema, permissionIdSchema, } from "./identifiers.js"; import { type ModuleConfigurationDeclaration, moduleConfigurationDeclarationSchema, } from "./module-configuration.js"; import { type ModuleContributionDeclaration, moduleContributionDeclarationSchema, } from "./module-contribution.js"; import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js"; const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema); const capabilityVersionMapSchema = z.record(capabilityIdSchema, semanticVersionRangeSchema); const moduleDependenciesSchema = z .object({ required: moduleVersionMapSchema.default(() => ({})), optional: moduleVersionMapSchema.default(() => ({})), }) .strict() .default(() => ({ required: {}, optional: {} })); const capabilityDependenciesSchema = z .object({ required: capabilityVersionMapSchema.default(() => ({})), optional: capabilityVersionMapSchema.default(() => ({})), }) .strict() .default(() => ({ required: {}, optional: {} })); const dependenciesSchema = z .object({ modules: moduleDependenciesSchema, capabilities: capabilityDependenciesSchema, }) .strict() .default(() => ({ modules: { required: {}, optional: {} }, capabilities: { required: {}, optional: {} }, })); export const moduleCapabilityDeclarationSchema = z .object({ id: capabilityIdSchema, version: semanticVersionSchema, }) .strict(); export const moduleBucketDeclarationSchema = z .object({ id: logicalBucketIdSchema, description: z.string().trim().min(1).max(240).optional(), }) .strict(); const moduleStorageSchema = z .object({ buckets: z.array(moduleBucketDeclarationSchema).default(() => []), }) .strict() .default(() => ({ buckets: [] })); /** * 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 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({ server: packageRelativePathSchema, schema: packageRelativePathSchema.optional(), migrations: packageRelativePathSchema.optional(), jobs: packageRelativePathSchema.optional(), ui: packageRelativePathSchema.optional(), display: moduleDisplayEntrypointSchema.optional(), }) .strict(); function reportDuplicates( values: readonly string[], path: readonly (string | number)[], context: z.RefinementCtx, property?: string, ): void { const seen = new Set(); values.forEach((value, index) => { if (seen.has(value)) { context.addIssue({ code: "custom", message: `${value} is declared more than once.`, path: [...path, index, ...(property ? [property] : [])], }); } seen.add(value); }); } /** * The one manifest shape Core accepts. Version 6's strict top-level object rejects `contributions`, * so the static browser-contribution declaration is an incompatible shape change and the canonical * manifest moves to `manifestVersion: 7` rather than silently reinterpreting version 6. * * 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 * *before* deciding whether it may start, so an enabled module whose required configuration is * missing is reported as `blocked` with a structured reason instead of taking API startup down from * 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 6, 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), version 3 by version 4 (ADR 0021), and version 5 * by version 6 (ADR 0033). One manifest document can therefore never be read as two different * versions. Upgrading a version 6 manifest is a one-character edit for a module that contributes * nothing: the new field defaults to empty. */ export const MODULE_MANIFEST_VERSION = 7; export const moduleManifestSchema = z .object({ manifestVersion: z.literal(MODULE_MANIFEST_VERSION), id: moduleIdSchema, name: z.string().trim().min(1).max(120), description: z.string().trim().min(1).max(500).optional(), version: semanticVersionSchema, compatibility: z .object({ platform: semanticVersionRangeSchema, moduleSdk: semanticVersionRangeSchema, }) .strict(), dependencies: dependenciesSchema, capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []), permissions: z.array(permissionDeclarationSchema).default(() => []), /** What this module needs an operator to configure, and how to validate it (ADR 0029). Inert * data: declaring configuration never causes Core to read a database, contact a host, or run * module code during discovery. A module that needs nothing configured omits the field. */ configuration: z.array(moduleConfigurationDeclarationSchema).default(() => []), storage: moduleStorageSchema, /** Compiled browser renderers this module offers to capabilities **other** modules provide * (ADR 0041). Inert data: Core validates identity, compatibility, and the artifact path, and * never learns what a contribution renders. A module that contributes nothing omits the field. */ contributions: z.array(moduleContributionDeclarationSchema).default(() => []), entrypoints: entrypointsSchema, }) .strict() .superRefine((manifest, context) => { reportDuplicates( manifest.capabilities.map((capability) => capability.id), ["capabilities"], context, "id", ); reportDuplicates( manifest.permissions.map((permission) => permission.id), ["permissions"], context, "id", ); reportDuplicates( manifest.storage.buckets.map((bucket) => bucket.id), ["storage", "buckets"], context, "id", ); // One namespace for every configuration key a module owns, so an ordinary key and a secret key // can never collide: a module cannot declare `apiToken` as both a defaultable string and a // deployment-supplied secret and leave the platform to guess which one a stored row means. reportDuplicates( manifest.configuration.map((declaration) => declaration.key), ["configuration"], context, "key", ); manifest.capabilities.forEach((capability, index) => { if (!capability.id.startsWith(`${manifest.id}.`)) { context.addIssue({ code: "custom", message: `Capability identifiers owned by this module must begin with ${manifest.id}.`, path: ["capabilities", index, "id"], }); } }); reportDuplicates( manifest.contributions.map((contribution) => contribution.id), ["contributions"], context, "id", ); const ownedCapabilityIds = new Set(manifest.capabilities.map((capability) => capability.id)); const contributionArtifacts = new Set(); manifest.contributions.forEach((contribution, index) => { // Namespacing is what makes a contribution id globally unique without a registry: module ids // are already unique, so two modules can never produce the same contribution id. if (!contribution.id.startsWith(`${manifest.id}.`)) { context.addIssue({ code: "custom", message: `Contribution identifiers owned by this module must begin with ${manifest.id}.`, path: ["contributions", index, "id"], }); } // A contribution extends *another* module's capability. Targeting one this module already // provides would be a module contributing to itself, which needs no platform mechanism at all // and would make the compatibility check meaningless. if (ownedCapabilityIds.has(contribution.capability)) { context.addIssue({ code: "custom", message: "A browser contribution must target a capability another module provides.", path: ["contributions", index, "capability"], }); } if ( Object.entries(manifest.entrypoints).some(([entrypointName, configured]) => entrypointName === "display" ? (configured as { artifact?: string } | undefined)?.artifact === contribution.artifact : configured === contribution.artifact, ) ) { context.addIssue({ code: "custom", message: "A contribution artifact must use a different path from every entrypoint.", path: ["contributions", index, "artifact"], }); } if (contributionArtifacts.has(contribution.artifact)) { context.addIssue({ code: "custom", message: "Two contributions must not declare the same artifact path.", path: ["contributions", index, "artifact"], }); } contributionArtifacts.add(contribution.artifact); }); manifest.permissions.forEach((permission, index) => { if (!permission.id.startsWith(`${manifest.id}.`)) { context.addIssue({ code: "custom", message: `Permission identifiers owned by this module must begin with ${manifest.id}.`, path: ["permissions", index, "id"], }); } }); 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({ code: "custom", message: "A module cannot depend on itself.", path: ["dependencies", "modules", dependencyKind, manifest.id], }); } } for (const dependencyId of Object.keys(manifest.dependencies.modules.required)) { if (Object.hasOwn(manifest.dependencies.modules.optional, dependencyId)) { context.addIssue({ code: "custom", message: `${dependencyId} cannot be both a required and optional module dependency.`, path: ["dependencies", "modules", "optional", dependencyId], }); } } for (const dependencyKind of ["required", "optional"] as const) { for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) { if (ownedCapabilityIds.has(capability)) { context.addIssue({ code: "custom", message: "A module does not need to depend on a capability it owns.", path: ["dependencies", "capabilities", dependencyKind, capability], }); } } } for (const capability of Object.keys(manifest.dependencies.capabilities.required)) { if (Object.hasOwn(manifest.dependencies.capabilities.optional, capability)) { context.addIssue({ code: "custom", message: `${capability} cannot be both a required and optional capability dependency.`, path: ["dependencies", "capabilities", "optional", capability], }); } } }); export function parseModuleManifest(input: unknown): ModuleManifest { return moduleManifestSchema.parse(input); } export function safeParseModuleManifest(input: unknown) { return moduleManifestSchema.safeParse(input); } export type ModuleManifestInput = z.input; export type ModuleManifest = z.output; export type ModuleBucketDeclaration = z.output; /** A module manifest's `contributions` array entry — the same `ModuleContributionDeclaration` shape * Core's publication registry consumes (`packages/contracts/src/module-contribution.ts`). */ export type ModuleManifestContributionDeclaration = ModuleContributionDeclaration; export type ModuleDisplayEntrypoint = z.output; /** 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. */ export type ModuleManifestConfigurationDeclaration = ModuleConfigurationDeclaration; export type ModuleCapabilityDeclaration = z.output; /** A module manifest's `permissions` array entry — the same `PermissionDeclaration` shape Core's * authorization registry consumes (`packages/contracts/src/authorization.ts`), aliased here for * discoverability alongside the manifest's other per-field declaration types. */ export type ModulePermissionDeclaration = PermissionDeclaration;