feat: add module contracts and discovery
CI / verify (push) Has been cancelled
CI / infrastructure-smoke (push) Has been cancelled

This commit is contained in:
ekklesia
2026-07-12 22:14:01 -05:00
parent d9489384c1
commit 57552a21a4
43 changed files with 2277 additions and 21 deletions
+60
View File
@@ -0,0 +1,60 @@
import { z } from "zod";
const localIdentifierPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const namespacedIdentifierPattern =
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/u;
export const moduleIdSchema = z
.string()
.min(1)
.max(64)
.regex(localIdentifierPattern, "Must be a lowercase kebab-case module identifier.");
export const capabilityIdSchema = z
.string()
.min(3)
.max(160)
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced capability identifier.");
export const permissionIdSchema = z
.string()
.min(3)
.max(160)
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier.");
export const logicalBucketIdSchema = z
.string()
.min(1)
.max(48)
.regex(localIdentifierPattern, "Must be a lowercase kebab-case logical bucket identifier.");
export const packageRelativePathSchema = z.string().superRefine((value, context) => {
if (!value.startsWith("./")) {
context.addIssue({
code: "custom",
message: "Must begin with ./ and remain inside the module package.",
});
return;
}
if (value.includes("\\") || value.includes("\0") || value.includes("?") || value.includes("#")) {
context.addIssue({
code: "custom",
message: "Must be a portable package-relative path without URLs, queries, or fragments.",
});
}
const segments = value.slice(2).split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
context.addIssue({
code: "custom",
message: "Must not contain empty, current-directory, or parent-directory segments.",
});
}
});
export type ModuleId = z.infer<typeof moduleIdSchema>;
export type CapabilityId = z.infer<typeof capabilityIdSchema>;
export type PermissionId = z.infer<typeof permissionIdSchema>;
export type LogicalBucketId = z.infer<typeof logicalBucketIdSchema>;
export type PackageRelativePath = z.infer<typeof packageRelativePathSchema>;
+4
View File
@@ -0,0 +1,4 @@
export * from "./identifiers.js";
export * from "./module-lifecycle.js";
export * from "./module-manifest.js";
export * from "./versions.js";
@@ -0,0 +1,17 @@
import { z } from "zod";
export const MODULE_LIFECYCLE_STAGES = [
"discovered",
"validated",
"configured",
"registered",
"migrated",
"initialized",
"started",
"stopped",
"failed",
] as const;
export const moduleLifecycleStageSchema = z.enum(MODULE_LIFECYCLE_STAGES);
export type ModuleLifecycleStage = z.infer<typeof moduleLifecycleStageSchema>;
+199
View File
@@ -0,0 +1,199 @@
import { z } from "zod";
import {
capabilityIdSchema,
logicalBucketIdSchema,
moduleIdSchema,
packageRelativePathSchema,
permissionIdSchema,
} from "./identifiers.js";
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
const moduleDependenciesSchema = z
.object({
required: moduleVersionMapSchema.default(() => ({})),
optional: moduleVersionMapSchema.default(() => ({})),
})
.strict()
.default(() => ({ required: {}, optional: {} }));
const capabilityDependenciesSchema = z
.object({
required: z.array(capabilityIdSchema).default(() => []),
optional: z.array(capabilityIdSchema).default(() => []),
})
.strict()
.default(() => ({ required: [], optional: [] }));
const dependenciesSchema = z
.object({
modules: moduleDependenciesSchema,
capabilities: capabilityDependenciesSchema,
})
.strict()
.default(() => ({
modules: { required: {}, optional: {} },
capabilities: { required: [], optional: [] },
}));
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: [] }));
const entrypointsSchema = z
.object({
server: packageRelativePathSchema,
schema: packageRelativePathSchema.optional(),
migrations: packageRelativePathSchema.optional(),
jobs: packageRelativePathSchema.optional(),
})
.strict();
function reportDuplicates(
values: readonly string[],
path: readonly (string | number)[],
context: z.RefinementCtx,
): 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],
});
}
seen.add(value);
});
}
export const moduleManifestSchema = z
.object({
manifestVersion: z.literal(1),
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(capabilityIdSchema).default(() => []),
permissions: z.array(permissionIdSchema).default(() => []),
storage: moduleStorageSchema,
entrypoints: entrypointsSchema,
})
.strict()
.superRefine((manifest, context) => {
reportDuplicates(manifest.capabilities, ["capabilities"], context);
reportDuplicates(manifest.permissions, ["permissions"], context);
reportDuplicates(
manifest.dependencies.capabilities.required,
["dependencies", "capabilities", "required"],
context,
);
reportDuplicates(
manifest.dependencies.capabilities.optional,
["dependencies", "capabilities", "optional"],
context,
);
reportDuplicates(
manifest.storage.buckets.map((bucket) => bucket.id),
["storage", "buckets"],
context,
);
manifest.capabilities.forEach((capability, index) => {
if (!capability.startsWith(`${manifest.id}.`)) {
context.addIssue({
code: "custom",
message: `Capability identifiers owned by this module must begin with ${manifest.id}.`,
path: ["capabilities", index],
});
}
});
manifest.permissions.forEach((permission, index) => {
if (!permission.startsWith(`${manifest.id}.`)) {
context.addIssue({
code: "custom",
message: `Permission identifiers owned by this module must begin with ${manifest.id}.`,
path: ["permissions", index],
});
}
});
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],
});
}
}
const ownedCapabilities = new Set(manifest.capabilities);
const requiredCapabilities = new Set(manifest.dependencies.capabilities.required);
manifest.dependencies.capabilities.required.forEach((capability, index) => {
if (ownedCapabilities.has(capability)) {
context.addIssue({
code: "custom",
message: "A module does not need to depend on a capability it owns.",
path: ["dependencies", "capabilities", "required", index],
});
}
});
manifest.dependencies.capabilities.optional.forEach((capability, index) => {
if (ownedCapabilities.has(capability)) {
context.addIssue({
code: "custom",
message: "A module does not need to depend on a capability it owns.",
path: ["dependencies", "capabilities", "optional", index],
});
}
if (requiredCapabilities.has(capability)) {
context.addIssue({
code: "custom",
message: `${capability} cannot be both a required and optional capability dependency.`,
path: ["dependencies", "capabilities", "optional", index],
});
}
});
});
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>;
+20
View File
@@ -0,0 +1,20 @@
import { satisfies, valid, validRange } from "semver";
import { z } from "zod";
export const semanticVersionSchema = z.string().refine((value) => valid(value) === value, {
message: "Must be a strict semantic version.",
});
export const semanticVersionRangeSchema = z
.string()
.min(1)
.refine((value) => validRange(value) !== null, {
message: "Must be a valid semantic version range.",
});
export function isVersionCompatible(version: string, range: string): boolean {
return valid(version) === version && validRange(range) !== null && satisfies(version, range);
}
export type SemanticVersion = z.infer<typeof semanticVersionSchema>;
export type SemanticVersionRange = z.infer<typeof semanticVersionRangeSchema>;