CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
381 lines
15 KiB
TypeScript
381 lines
15 KiB
TypeScript
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: [] }));
|
|
|
|
/** The database contract Core delivers to this module. Omitted means the temporary ADR 0016
|
|
* legacy facade; ABI v1 is the stable SQL boundary from ADR 0050. */
|
|
export const moduleDatabaseDeclarationSchema = z
|
|
.object({
|
|
abi: z.literal(1),
|
|
})
|
|
.strict();
|
|
|
|
/**
|
|
* 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<string>();
|
|
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 7's strict top-level object rejects `database`, so
|
|
* the static ABI declaration is an incompatible shape change and the canonical manifest moves to
|
|
* `manifestVersion: 8` without silently reinterpreting version 7.
|
|
*
|
|
* 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 7, 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 7
|
|
* by version 8 (ADR 0050). One manifest document can therefore never be read as two different
|
|
* versions. A version 7 package must be rebuilt; omitting `database` selects the legacy facade.
|
|
*/
|
|
export const MODULE_MANIFEST_VERSION = 8;
|
|
|
|
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,
|
|
database: moduleDatabaseDeclarationSchema.optional(),
|
|
/** 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<string>();
|
|
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<typeof moduleManifestSchema>;
|
|
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
|
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
|
/** 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<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. */
|
|
export type ModuleManifestConfigurationDeclaration = ModuleConfigurationDeclaration;
|
|
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
|
|
/** 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;
|