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
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@ekklesia/contracts",
"version": "0.1.0",
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/contracts",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"semver": "7.8.5",
"zod": "4.4.3"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"@types/semver": "7.7.1",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "contracts"
}
}
+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>;
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import {
isVersionCompatible,
MODULE_LIFECYCLE_STAGES,
packageRelativePathSchema,
parseModuleManifest,
safeParseModuleManifest,
} from "../src/index.js";
function validManifest(): Record<string, unknown> {
return {
manifestVersion: 1,
id: "calendar",
name: "Calendar",
version: "0.1.0",
compatibility: {
platform: "^0.1.0",
moduleSdk: "^0.1.0",
},
capabilities: ["calendar.events"],
permissions: ["calendar.events.read"],
storage: {
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
},
entrypoints: {
server: "./src/server/index.ts",
},
};
}
describe("module manifest contract", () => {
it("normalizes a valid manifest with safe defaults", () => {
const manifest = parseModuleManifest(validManifest());
expect(manifest.id).toBe("calendar");
expect(manifest.dependencies).toEqual({
capabilities: { optional: [], required: [] },
modules: { optional: {}, required: {} },
});
expect(manifest.storage.buckets).toEqual([
{ id: "assets", description: "Calendar-owned event assets." },
]);
});
it("creates isolated default collections for every parsed manifest", () => {
const input = validManifest();
delete input.storage;
const first = parseModuleManifest(input);
const second = parseModuleManifest(input);
first.dependencies.modules.required.people = "^0.1.0";
first.dependencies.capabilities.required.push("people.records");
first.storage.buckets.push({ id: "exports" });
expect(second.dependencies.modules.required).toEqual({});
expect(second.dependencies.capabilities.required).toEqual([]);
expect(second.storage.buckets).toEqual([]);
});
it("rejects unknown and presentation-oriented manifest properties", () => {
const manifest = { ...validManifest(), renderers: ["calendar-month"] };
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
});
it("requires module-owned capability and permission namespaces", () => {
const manifest = {
...validManifest(),
capabilities: ["media.items"],
permissions: ["media.items.read"],
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
expect.arrayContaining(["capabilities.0", "permissions.0"]),
);
}
});
it("rejects duplicate declarations and dependency overlap", () => {
const manifest = {
...validManifest(),
dependencies: {
modules: {
required: { people: "^1.0.0" },
optional: { people: "^1.0.0" },
},
capabilities: {
required: ["people.records"],
optional: ["people.records"],
},
},
capabilities: ["calendar.events", "calendar.events"],
storage: { buckets: [{ id: "assets" }, { id: "assets" }] },
};
const result = safeParseModuleManifest(manifest);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toHaveLength(4);
}
});
it("rejects module and capability self-dependencies", () => {
const manifest = {
...validManifest(),
dependencies: {
modules: { required: { calendar: "^0.1.0" } },
capabilities: { required: ["calendar.events"] },
},
};
expect(safeParseModuleManifest(manifest).success).toBe(false);
});
it.each([
"../outside.ts",
"/absolute.ts",
"file:///server.ts",
"./src/../server.ts",
"./src\\server.ts",
"./src/server.ts?raw",
])("rejects unsafe package entrypoint %s", (entrypoint) => {
expect(packageRelativePathSchema.safeParse(entrypoint).success).toBe(false);
});
it("allows package directories whose names merely begin with two dots", () => {
expect(packageRelativePathSchema.safeParse("./..data/server.ts").success).toBe(true);
});
it("uses full semantic-version compatibility ranges", () => {
expect(isVersionCompatible("0.1.5", "^0.1.0")).toBe(true);
expect(isVersionCompatible("0.2.0", "^0.1.0")).toBe(false);
expect(isVersionCompatible("not-a-version", "*")).toBe(false);
});
it("publishes the complete lifecycle vocabulary", () => {
expect(MODULE_LIFECYCLE_STAGES).toContain("discovered");
expect(MODULE_LIFECYCLE_STAGES).toContain("failed");
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@ekklesia/module-sdk",
"version": "0.1.0",
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "biome lint .",
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-sdk",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
"ekklesia": {
"layer": "module-sdk"
}
}
+9
View File
@@ -0,0 +1,9 @@
import {
type ModuleManifest,
type ModuleManifestInput,
parseModuleManifest,
} from "@ekklesia/contracts";
export function defineModule(manifest: ModuleManifestInput): ModuleManifest {
return parseModuleManifest(manifest);
}
+2
View File
@@ -0,0 +1,2 @@
export * from "@ekklesia/contracts";
export * from "./define-module.js";
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { defineModule } from "../src/index.js";
describe("defineModule", () => {
it("returns a validated and normalized public manifest", () => {
const module = defineModule({
manifestVersion: 1,
id: "example",
name: "Example",
version: "0.1.0",
compatibility: {
platform: "^0.1.0",
moduleSdk: "^0.1.0",
},
entrypoints: {
server: "./src/server/index.ts",
},
});
expect(module.capabilities).toEqual([]);
expect(module.permissions).toEqual([]);
expect(module.storage.buckets).toEqual([]);
});
it("rejects invalid module definitions before runtime registration", () => {
expect(() =>
defineModule({
manifestVersion: 1,
id: "Not Valid",
name: "Invalid",
version: "0.1.0",
compatibility: {
platform: "^0.1.0",
moduleSdk: "^0.1.0",
},
entrypoints: {
server: "./src/server/index.ts",
},
}),
).toThrow();
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "tests"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}