diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7aa3426..f42d653 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Verify repository - run: pnpm ci + run: pnpm ci:verify infrastructure-smoke: needs: verify diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e4f905..81c075d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,9 @@ Before opening a change, run: pnpm verify ``` +When adding or changing a module manifest, also run `pnpm modules:validate` directly for focused, +path-aware discovery errors. It is included in `pnpm verify`. + If Docker is available, also validate the development infrastructure: ```bash @@ -28,5 +31,5 @@ pnpm infrastructure:config - Add or update tests when changing a contract or architectural rule. - Record major architectural changes in an ADR rather than burying them in an implementation. -Generated registries and schemas will be produced by later phases. Do not add a manually -maintained feature-module registry. +Runtime registries and aggregate schemas will be produced by later phases. Do not add a manually +maintained feature-module registry; discovery starts from validated `ekklesia.module.json` files. diff --git a/README.md b/README.md index a48992a..237064f 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ surfaces. ## Current status -Phase 1 establishes the repository foundation only. It includes reproducible workspace -tooling, strict TypeScript defaults, executable architectural boundary checks, tests, CI, and -local PostgreSQL/S3-compatible infrastructure. Core runtime, module discovery, identity, -Module SDK, surfaces, and feature modules intentionally begin in later phases. +Phase 2 is underway. The repository now includes public module contracts, the Module SDK, +side-effect-free module manifests, deterministic automatic discovery, compatibility and dependency +validation, and a cloneable backend-only module template. Runtime registration, Drizzle, identity, +surfaces, and feature modules remain later vertical slices. ## Quick start @@ -19,6 +19,7 @@ Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. pnpm install --frozen-lockfile cp .env.example .env pnpm infrastructure:up +pnpm modules:validate pnpm verify ``` @@ -37,6 +38,7 @@ environment. - [Platform architecture](docs/architecture/platform-architecture.md) - [Repository boundaries](docs/architecture/repository-boundaries.md) - [Repository workflow](docs/development/repository-workflow.md) +- [Module development](docs/module-development/README.md) - [Architecture decisions](docs/architecture/decisions/README.md) No open-source license has been selected yet. A license should be added only after the project diff --git a/biome.json b/biome.json index a37e905..5796ace 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,14 @@ { "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json", "files": { - "includes": ["**", "!tests/fixtures", "!pnpm-lock.yaml"] + "includes": [ + "**", + "!**/.turbo", + "!**/dist", + "!**/node_modules", + "!tests/fixtures", + "!pnpm-lock.yaml" + ] }, "formatter": { "enabled": true, diff --git a/core/package.json b/core/package.json new file mode 100644 index 0000000..1411592 --- /dev/null +++ b/core/package.json @@ -0,0 +1,37 @@ +{ + "name": "@ekklesia/core", + "version": "0.1.0", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./modules": { + "types": "./dist/modules/index.d.ts", + "import": "./dist/modules/index.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "lint": "biome lint .", + "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": "core" + } +} diff --git a/core/src/cli/validate-modules.ts b/core/src/cli/validate-modules.ts new file mode 100644 index 0000000..4a444d8 --- /dev/null +++ b/core/src/cli/validate-modules.ts @@ -0,0 +1,51 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { discoverModules, ModuleDiscoveryError } from "../modules/index.js"; + +interface PackageVersion { + version?: unknown; +} + +async function readPackageVersion(packagePath: string): Promise { + const packageData = JSON.parse(await readFile(packagePath, "utf8")) as PackageVersion; + if (typeof packageData.version !== "string") { + throw new Error(`${packagePath} does not declare a version.`); + } + return packageData.version; +} + +async function main(): Promise { + const repositoryRoot = process.cwd(); + const [platformVersion, moduleSdkVersion] = await Promise.all([ + readPackageVersion(path.join(repositoryRoot, "package.json")), + readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")), + ]); + const modules = await discoverModules({ + moduleSdkVersion, + modulesDirectory: path.join(repositoryRoot, "modules"), + platformVersion, + }); + + if (modules.length === 0) { + console.log("Module validation passed (0 modules installed)."); + return; + } + + console.log(`Module validation passed (${modules.length} modules):`); + for (const discoveredModule of modules) { + console.log(`- ${discoveredModule.manifest.id}@${discoveredModule.manifest.version}`); + } +} + +main().catch((error: unknown) => { + if (error instanceof ModuleDiscoveryError) { + console.error(error.message); + for (const issue of error.issues) { + const issuePath = issue.path ? ` (${issue.path})` : ""; + console.error(`[${issue.code}] ${issue.moduleDirectory}${issuePath}: ${issue.message}`); + } + } else { + console.error(error instanceof Error ? error.message : String(error)); + } + process.exitCode = 1; +}); diff --git a/core/src/index.ts b/core/src/index.ts new file mode 100644 index 0000000..7b60d89 --- /dev/null +++ b/core/src/index.ts @@ -0,0 +1 @@ +export * from "./modules/index.js"; diff --git a/core/src/modules/discovery.ts b/core/src/modules/discovery.ts new file mode 100644 index 0000000..28185bd --- /dev/null +++ b/core/src/modules/discovery.ts @@ -0,0 +1,696 @@ +import type { Dirent } from "node:fs"; +import { readdir, readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { + isVersionCompatible, + type ModuleManifest, + safeParseModuleManifest, + semanticVersionSchema, +} from "@ekklesia/contracts"; + +export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json"; + +export type ModuleDiscoveryIssueCode = + | "DISCOVERY_CONFIGURATION_INVALID" + | "MODULE_CAPABILITY_MISSING" + | "MODULE_DEPENDENCY_CYCLE" + | "MODULE_DEPENDENCY_INCOMPATIBLE" + | "MODULE_DEPENDENCY_MISSING" + | "MODULE_DIRECTORY_INVALID" + | "MODULE_ENTRYPOINT_INVALID" + | "MODULE_ENTRYPOINT_MISSING" + | "MODULE_ID_DUPLICATE" + | "MODULE_MANIFEST_INVALID" + | "MODULE_MANIFEST_MISSING" + | "MODULE_PACKAGE_INVALID" + | "MODULE_PACKAGE_MISSING" + | "MODULE_PACKAGE_NAME_DUPLICATE" + | "MODULE_PLATFORM_INCOMPATIBLE" + | "MODULE_SDK_INCOMPATIBLE" + | "MODULE_VERSION_MISMATCH" + | "MODULES_DIRECTORY_UNREADABLE"; + +export interface ModuleDiscoveryIssue { + readonly code: ModuleDiscoveryIssueCode; + readonly message: string; + readonly moduleDirectory: string; + readonly moduleId?: string; + readonly path?: string; +} + +export interface ResolvedModuleEntrypoints { + readonly server: string; + readonly schema?: string; + readonly migrations?: string; + readonly jobs?: string; +} + +type DeepReadonly = T extends (...arguments_: never[]) => unknown + ? T + : T extends readonly (infer Item)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; + +export type ReadonlyModuleManifest = DeepReadonly; + +export interface DiscoveredModule { + readonly directory: string; + readonly entrypoints: ResolvedModuleEntrypoints; + readonly manifest: ReadonlyModuleManifest; + readonly manifestPath: string; + readonly packageName: string; +} + +export interface ModuleDiscoveryOptions { + readonly moduleSdkVersion: string; + readonly modulesDirectory: string; + readonly platformVersion: string; +} + +interface ModulePackageData { + name: string; + version: string; +} + +interface ModuleCandidate { + directory: string; + directoryName: string; + entrypoints: ResolvedModuleEntrypoints; + manifest: ModuleManifest; + manifestPath: string; + packageName: string; +} + +export class ModuleDiscoveryError extends Error { + readonly issues: readonly ModuleDiscoveryIssue[]; + + constructor(issues: readonly ModuleDiscoveryIssue[]) { + const sortedIssues = [...issues].sort(compareIssues); + super(`Module discovery failed with ${sortedIssues.length} issue(s).`); + this.name = "ModuleDiscoveryError"; + this.issues = freezeRecursively(sortedIssues); + } +} + +function compareText(left: string, right: string): number { + if (left < right) { + return -1; + } + if (left > right) { + return 1; + } + return 0; +} + +function compareIssues(left: ModuleDiscoveryIssue, right: ModuleDiscoveryIssue): number { + return ( + compareText(left.moduleDirectory, right.moduleDirectory) || + compareText(left.code, right.code) || + compareText(left.path ?? "", right.path ?? "") || + compareText(left.message, right.message) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function freezeRecursively(value: T): T { + if (typeof value !== "object" || value === null || Object.isFrozen(value)) { + return value; + } + for (const nestedValue of Object.values(value)) { + freezeRecursively(nestedValue); + } + Object.freeze(value); + return value; +} + +function errorCode(error: unknown): string | undefined { + if (isRecord(error) && typeof error.code === "string") { + return error.code; + } + return undefined; +} + +function issuePath(pathParts: readonly PropertyKey[]): string { + return pathParts.map(String).join("."); +} + +function pathIsInside(parent: string, candidate: string): boolean { + const relativePath = path.relative(parent, candidate); + return ( + relativePath === "" || + (relativePath !== ".." && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath)) + ); +} + +async function readJson(pathname: string): Promise { + return JSON.parse(await readFile(pathname, "utf8")) as unknown; +} + +function validatePackageData( + input: unknown, + directoryName: string, + issues: ModuleDiscoveryIssue[], +): ModulePackageData | undefined { + if (!isRecord(input)) { + issues.push({ + code: "MODULE_PACKAGE_INVALID", + message: "package.json must contain a JSON object.", + moduleDirectory: directoryName, + }); + return undefined; + } + + const name = input.name; + const version = input.version; + const packageType = input.type; + const layer = isRecord(input.ekklesia) ? input.ekklesia.layer : undefined; + let validPackage = true; + + if (typeof name !== "string" || name.length === 0) { + validPackage = false; + issues.push({ + code: "MODULE_PACKAGE_INVALID", + message: "package.json must declare a non-empty package name.", + moduleDirectory: directoryName, + path: "name", + }); + } + if (semanticVersionSchema.safeParse(version).success === false) { + validPackage = false; + issues.push({ + code: "MODULE_PACKAGE_INVALID", + message: "package.json must declare a strict semantic version.", + moduleDirectory: directoryName, + path: "version", + }); + } + if (packageType !== "module") { + validPackage = false; + issues.push({ + code: "MODULE_PACKAGE_INVALID", + message: 'package.json must declare "type": "module".', + moduleDirectory: directoryName, + path: "type", + }); + } + if (layer !== "module") { + validPackage = false; + issues.push({ + code: "MODULE_PACKAGE_INVALID", + message: 'package.json must declare "ekklesia.layer": "module".', + moduleDirectory: directoryName, + path: "ekklesia.layer", + }); + } + + if (!validPackage || typeof name !== "string" || typeof version !== "string") { + return undefined; + } + return { name, version }; +} + +async function resolveEntrypoints( + moduleDirectory: string, + directoryName: string, + manifest: ModuleManifest, + issues: ModuleDiscoveryIssue[], +): Promise { + const resolved: Partial> = {}; + const canonicalModuleDirectory = await realpath(moduleDirectory); + + for (const [entrypointName, relativeEntrypoint] of Object.entries(manifest.entrypoints)) { + if (relativeEntrypoint === undefined) { + continue; + } + const configuredPath = path.resolve(moduleDirectory, relativeEntrypoint); + if (!pathIsInside(moduleDirectory, configuredPath)) { + issues.push({ + code: "MODULE_ENTRYPOINT_INVALID", + message: `${entrypointName} resolves outside its module directory.`, + moduleDirectory: directoryName, + moduleId: manifest.id, + path: `entrypoints.${entrypointName}`, + }); + continue; + } + + try { + const canonicalEntrypoint = await realpath(configuredPath); + if (!pathIsInside(canonicalModuleDirectory, canonicalEntrypoint)) { + issues.push({ + code: "MODULE_ENTRYPOINT_INVALID", + message: `${entrypointName} resolves through a link outside its module directory.`, + moduleDirectory: directoryName, + moduleId: manifest.id, + path: `entrypoints.${entrypointName}`, + }); + continue; + } + const entrypointStats = await stat(canonicalEntrypoint); + const hasExpectedKind = + entrypointName === "migrations" ? entrypointStats.isDirectory() : entrypointStats.isFile(); + if (!hasExpectedKind) { + issues.push({ + code: "MODULE_ENTRYPOINT_INVALID", + message: + entrypointName === "migrations" + ? "migrations must resolve to a directory." + : `${entrypointName} must resolve to a file.`, + moduleDirectory: directoryName, + moduleId: manifest.id, + path: `entrypoints.${entrypointName}`, + }); + continue; + } + resolved[entrypointName as keyof ModuleManifest["entrypoints"]] = canonicalEntrypoint; + } catch (error) { + const code = errorCode(error); + issues.push({ + code: code === "ENOENT" ? "MODULE_ENTRYPOINT_MISSING" : "MODULE_ENTRYPOINT_INVALID", + message: + code === "ENOENT" + ? `${entrypointName} does not exist at ${relativeEntrypoint}.` + : `${entrypointName} could not be resolved at ${relativeEntrypoint}${ + code ? ` (${code})` : "" + }.`, + moduleDirectory: directoryName, + moduleId: manifest.id, + path: `entrypoints.${entrypointName}`, + }); + } + } + + if (!resolved.server) { + return undefined; + } + + return { + server: resolved.server, + ...(resolved.schema ? { schema: resolved.schema } : {}), + ...(resolved.migrations ? { migrations: resolved.migrations } : {}), + ...(resolved.jobs ? { jobs: resolved.jobs } : {}), + }; +} + +async function scanModuleDirectory( + modulesDirectory: string, + directoryName: string, + issues: ModuleDiscoveryIssue[], +): Promise { + const moduleDirectory = path.join(modulesDirectory, directoryName); + const packagePath = path.join(moduleDirectory, "package.json"); + const manifestPath = path.join(moduleDirectory, MODULE_MANIFEST_FILENAME); + const canonicalModuleDirectory = await realpath(moduleDirectory); + let canonicalManifestPath: string | undefined; + let packageInput: unknown; + let manifestInput: unknown; + let packageLoaded = false; + let manifestLoaded = false; + + try { + const canonicalPackagePath = await realpath(packagePath); + if (!pathIsInside(canonicalModuleDirectory, canonicalPackagePath)) { + issues.push({ + code: "MODULE_PACKAGE_INVALID", + message: "package.json must not resolve outside its module directory.", + moduleDirectory: directoryName, + }); + } else { + packageInput = await readJson(canonicalPackagePath); + packageLoaded = true; + } + } catch (error) { + const code = errorCode(error); + issues.push({ + code: code === "ENOENT" ? "MODULE_PACKAGE_MISSING" : "MODULE_PACKAGE_INVALID", + message: + code === "ENOENT" + ? "package.json is missing." + : `package.json could not be read or parsed${code ? ` (${code})` : ""}.`, + moduleDirectory: directoryName, + }); + } + + try { + canonicalManifestPath = await realpath(manifestPath); + if (!pathIsInside(canonicalModuleDirectory, canonicalManifestPath)) { + issues.push({ + code: "MODULE_MANIFEST_INVALID", + message: `${MODULE_MANIFEST_FILENAME} must not resolve outside its module directory.`, + moduleDirectory: directoryName, + }); + } else { + manifestInput = await readJson(canonicalManifestPath); + manifestLoaded = true; + } + } catch (error) { + const code = errorCode(error); + issues.push({ + code: code === "ENOENT" ? "MODULE_MANIFEST_MISSING" : "MODULE_MANIFEST_INVALID", + message: + code === "ENOENT" + ? `${MODULE_MANIFEST_FILENAME} is missing.` + : `${MODULE_MANIFEST_FILENAME} could not be read or parsed${code ? ` (${code})` : ""}.`, + moduleDirectory: directoryName, + }); + } + + if (!packageLoaded || !manifestLoaded || !canonicalManifestPath) { + return undefined; + } + + const packageData = validatePackageData(packageInput, directoryName, issues); + const parsedManifest = safeParseModuleManifest(manifestInput); + if (!parsedManifest.success) { + for (const validationIssue of parsedManifest.error.issues) { + issues.push({ + code: "MODULE_MANIFEST_INVALID", + message: validationIssue.message, + moduleDirectory: directoryName, + path: issuePath(validationIssue.path), + }); + } + } + + if (!packageData || !parsedManifest.success) { + return undefined; + } + + const manifest = parsedManifest.data; + if (packageData.version !== manifest.version) { + issues.push({ + code: "MODULE_VERSION_MISMATCH", + message: `package.json version ${packageData.version} does not match manifest version ${manifest.version}.`, + moduleDirectory: directoryName, + moduleId: manifest.id, + path: "version", + }); + } + + const entrypoints = await resolveEntrypoints(moduleDirectory, directoryName, manifest, issues); + if (!entrypoints) { + return undefined; + } + + return { + directory: canonicalModuleDirectory, + directoryName, + entrypoints, + manifest, + manifestPath: canonicalManifestPath, + packageName: packageData.name, + }; +} + +function addGraphEdge( + dependencyId: string, + dependentId: string, + dependents: Map>, + indegrees: Map, +): void { + const targets = dependents.get(dependencyId); + if (!targets || targets.has(dependentId)) { + return; + } + targets.add(dependentId); + indegrees.set(dependentId, (indegrees.get(dependentId) ?? 0) + 1); +} + +function nodeParticipatesInCycle(node: string, graph: ReadonlyMap>): boolean { + const visited = new Set(); + const pending = [...(graph.get(node) ?? [])]; + while (pending.length > 0) { + const candidate = pending.pop(); + if (!candidate) { + continue; + } + if (candidate === node) { + return true; + } + if (visited.has(candidate)) { + continue; + } + visited.add(candidate); + pending.push(...(graph.get(candidate) ?? [])); + } + return false; +} + +function topologicalModuleIds( + modulesById: ReadonlyMap, + dependents: Map>, + indegrees: Map, + issues: ModuleDiscoveryIssue[], +): string[] { + const ready = [...modulesById.keys()] + .filter((moduleId) => indegrees.get(moduleId) === 0) + .sort(compareText); + const ordered: string[] = []; + + while (ready.length > 0) { + const moduleId = ready.shift(); + if (!moduleId) { + break; + } + ordered.push(moduleId); + for (const dependentId of [...(dependents.get(moduleId) ?? [])].sort(compareText)) { + const nextIndegree = (indegrees.get(dependentId) ?? 0) - 1; + indegrees.set(dependentId, nextIndegree); + if (nextIndegree === 0) { + ready.push(dependentId); + ready.sort(compareText); + } + } + } + + if (ordered.length !== modulesById.size) { + const cycleMembers = [...modulesById.keys()] + .filter((moduleId) => nodeParticipatesInCycle(moduleId, dependents)) + .sort(compareText); + issues.push({ + code: "MODULE_DEPENDENCY_CYCLE", + message: `Module dependency cycle contains: ${cycleMembers.join(", ")}.`, + moduleDirectory: cycleMembers.join(","), + }); + } + + return ordered; +} + +export async function discoverModules( + options: ModuleDiscoveryOptions, +): Promise { + const issues: ModuleDiscoveryIssue[] = []; + for (const [versionName, version] of [ + ["platformVersion", options.platformVersion], + ["moduleSdkVersion", options.moduleSdkVersion], + ] as const) { + if (!semanticVersionSchema.safeParse(version).success) { + issues.push({ + code: "DISCOVERY_CONFIGURATION_INVALID", + message: `${versionName} must be a strict semantic version, received ${version}.`, + moduleDirectory: ".", + path: versionName, + }); + } + } + if (issues.length > 0) { + throw new ModuleDiscoveryError(issues); + } + + let directoryEntries: Dirent[]; + try { + directoryEntries = await readdir(options.modulesDirectory, { withFileTypes: true }); + } catch (error) { + if (errorCode(error) === "ENOENT") { + return Object.freeze([]); + } + throw new ModuleDiscoveryError([ + { + code: "MODULES_DIRECTORY_UNREADABLE", + message: `The configured modules directory could not be read${ + errorCode(error) ? ` (${errorCode(error)})` : "" + }.`, + moduleDirectory: ".", + }, + ]); + } + + const visibleEntries = directoryEntries + .filter((entry) => !entry.name.startsWith(".")) + .sort((left, right) => compareText(left.name, right.name)); + const directoryNames: string[] = []; + for (const entry of visibleEntries) { + if (entry.isSymbolicLink()) { + issues.push({ + code: "MODULE_DIRECTORY_INVALID", + message: "Module directories must not be symbolic links.", + moduleDirectory: entry.name, + }); + } else if (entry.isDirectory()) { + directoryNames.push(entry.name); + } + } + const candidates: ModuleCandidate[] = []; + for (const directoryName of directoryNames) { + const candidate = await scanModuleDirectory(options.modulesDirectory, directoryName, issues); + if (candidate) { + candidates.push(candidate); + } + } + + const duplicateIds = new Set(); + const duplicatePackageNames = new Set(); + const firstById = new Map(); + const firstByPackageName = new Map(); + for (const candidate of candidates) { + const existingId = firstById.get(candidate.manifest.id); + if (existingId) { + duplicateIds.add(candidate.manifest.id); + issues.push({ + code: "MODULE_ID_DUPLICATE", + message: `Module ID ${candidate.manifest.id} is also declared by ${existingId.directoryName}.`, + moduleDirectory: candidate.directoryName, + moduleId: candidate.manifest.id, + path: "id", + }); + } else { + firstById.set(candidate.manifest.id, candidate); + } + + const existingPackage = firstByPackageName.get(candidate.packageName); + if (existingPackage) { + duplicatePackageNames.add(candidate.packageName); + issues.push({ + code: "MODULE_PACKAGE_NAME_DUPLICATE", + message: `Package name ${candidate.packageName} is also used by ${existingPackage.directoryName}.`, + moduleDirectory: candidate.directoryName, + moduleId: candidate.manifest.id, + path: "name", + }); + } else { + firstByPackageName.set(candidate.packageName, candidate); + } + } + + const modulesById = new Map( + candidates + .filter( + (candidate) => + !duplicateIds.has(candidate.manifest.id) && + !duplicatePackageNames.has(candidate.packageName), + ) + .map((candidate) => [candidate.manifest.id, candidate] as const), + ); + + const capabilityProviders = new Map(); + for (const candidate of modulesById.values()) { + if (!isVersionCompatible(options.platformVersion, candidate.manifest.compatibility.platform)) { + issues.push({ + code: "MODULE_PLATFORM_INCOMPATIBLE", + message: `Platform ${options.platformVersion} does not satisfy ${candidate.manifest.compatibility.platform}.`, + moduleDirectory: candidate.directoryName, + moduleId: candidate.manifest.id, + path: "compatibility.platform", + }); + } + if ( + !isVersionCompatible(options.moduleSdkVersion, candidate.manifest.compatibility.moduleSdk) + ) { + issues.push({ + code: "MODULE_SDK_INCOMPATIBLE", + message: `Module SDK ${options.moduleSdkVersion} does not satisfy ${candidate.manifest.compatibility.moduleSdk}.`, + moduleDirectory: candidate.directoryName, + moduleId: candidate.manifest.id, + path: "compatibility.moduleSdk", + }); + } + + for (const capability of candidate.manifest.capabilities) { + capabilityProviders.set(capability, candidate); + } + } + + const dependents = new Map>(); + const indegrees = new Map(); + for (const moduleId of modulesById.keys()) { + dependents.set(moduleId, new Set()); + indegrees.set(moduleId, 0); + } + + for (const candidate of modulesById.values()) { + for (const dependencyKind of ["required", "optional"] as const) { + for (const [dependencyId, versionRange] of Object.entries( + candidate.manifest.dependencies.modules[dependencyKind], + )) { + const dependency = modulesById.get(dependencyId); + if (!dependency) { + if (dependencyKind === "required") { + issues.push({ + code: "MODULE_DEPENDENCY_MISSING", + message: `Required module ${dependencyId} is not installed.`, + moduleDirectory: candidate.directoryName, + moduleId: candidate.manifest.id, + path: `dependencies.modules.${dependencyKind}.${dependencyId}`, + }); + } + continue; + } + if (!isVersionCompatible(dependency.manifest.version, versionRange)) { + issues.push({ + code: "MODULE_DEPENDENCY_INCOMPATIBLE", + message: `${dependencyId} ${dependency.manifest.version} does not satisfy ${versionRange}.`, + moduleDirectory: candidate.directoryName, + moduleId: candidate.manifest.id, + path: `dependencies.modules.${dependencyKind}.${dependencyId}`, + }); + } + addGraphEdge(dependencyId, candidate.manifest.id, dependents, indegrees); + } + } + + for (const dependencyKind of ["required", "optional"] as const) { + for (const [index, capability] of candidate.manifest.dependencies.capabilities[ + dependencyKind + ].entries()) { + const provider = capabilityProviders.get(capability); + if (!provider) { + if (dependencyKind === "required") { + issues.push({ + code: "MODULE_CAPABILITY_MISSING", + message: `Required capability ${capability} is not installed.`, + moduleDirectory: candidate.directoryName, + moduleId: candidate.manifest.id, + path: `dependencies.capabilities.${dependencyKind}.${index}`, + }); + } + continue; + } + addGraphEdge(provider.manifest.id, candidate.manifest.id, dependents, indegrees); + } + } + } + + const orderedIds = topologicalModuleIds(modulesById, dependents, indegrees, issues); + if (issues.length > 0) { + throw new ModuleDiscoveryError(issues); + } + + return Object.freeze( + orderedIds.map((moduleId) => { + const candidate = modulesById.get(moduleId); + if (!candidate) { + throw new Error(`Internal discovery error: ${moduleId} was not indexed.`); + } + const { directoryName: _directoryName, ...discoveredModule } = candidate; + return freezeRecursively(discoveredModule); + }), + ); +} diff --git a/core/src/modules/index.ts b/core/src/modules/index.ts new file mode 100644 index 0000000..540ec03 --- /dev/null +++ b/core/src/modules/index.ts @@ -0,0 +1 @@ +export * from "./discovery.js"; diff --git a/core/tests/module-discovery.test.ts b/core/tests/module-discovery.test.ts new file mode 100644 index 0000000..61b2fdf --- /dev/null +++ b/core/tests/module-discovery.test.ts @@ -0,0 +1,429 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { + discoverModules, + ModuleDiscoveryError, + type ModuleDiscoveryIssueCode, +} from "../src/modules/index.js"; + +interface ModuleFixtureOptions { + directory?: string; + manifest?: Record; + packageJson?: Record; + serverSource?: string; +} + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +async function createModulesDirectory(): Promise { + const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-modules-")); + temporaryDirectories.push(directory); + return directory; +} + +function manifestFor(id: string): Record { + return { + manifestVersion: 1, + id, + name: `${id} module`, + version: "0.1.0", + compatibility: { + platform: "^0.1.0", + moduleSdk: "^0.1.0", + }, + capabilities: [`${id}.records`], + permissions: [`${id}.records.read`], + entrypoints: { + server: "./src/server/index.ts", + }, + }; +} + +async function createModule( + modulesDirectory: string, + id: string, + options: ModuleFixtureOptions = {}, +): Promise { + const directoryName = options.directory ?? id; + const moduleDirectory = path.join(modulesDirectory, directoryName); + const manifest = options.manifest ?? manifestFor(id); + const packageJson = options.packageJson ?? { + name: `@test/module-${id}`, + version: "0.1.0", + type: "module", + ekklesia: { layer: "module" }, + }; + + await mkdir(path.join(moduleDirectory, "src/server"), { recursive: true }); + await Promise.all([ + writeFile(path.join(moduleDirectory, "package.json"), JSON.stringify(packageJson)), + writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)), + writeFile( + path.join(moduleDirectory, "src/server/index.ts"), + options.serverSource ?? `export const moduleId = ${JSON.stringify(id)};`, + ), + ]); +} + +async function discoveryError( + modulesDirectory: string, + versions: { moduleSdkVersion?: string; platformVersion?: string } = {}, +): Promise { + try { + await discoverModules({ + moduleSdkVersion: versions.moduleSdkVersion ?? "0.1.0", + modulesDirectory, + platformVersion: versions.platformVersion ?? "0.1.0", + }); + } catch (error) { + if (error instanceof ModuleDiscoveryError) { + return error; + } + throw error; + } + throw new Error("Expected module discovery to fail."); +} + +function issueCodes(error: ModuleDiscoveryError): ModuleDiscoveryIssueCode[] { + return error.issues.map((issue) => issue.code); +} + +describe("module discovery", () => { + it("treats a missing or empty modules directory as zero installed modules", async () => { + const parent = await createModulesDirectory(); + const missing = path.join(parent, "missing"); + + await expect( + discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory: missing, + platformVersion: "0.1.0", + }), + ).resolves.toEqual([]); + await expect( + discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory: parent, + platformVersion: "0.1.0", + }), + ).resolves.toEqual([]); + }); + + it("discovers static manifests and orders dependencies deterministically", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "calendar", { + manifest: { + ...manifestFor("calendar"), + dependencies: { + modules: { required: { people: "^0.1.0" } }, + capabilities: { required: ["people.records"] }, + }, + storage: { buckets: [{ id: "assets" }, { id: "exports" }] }, + }, + serverSource: 'throw new Error("discovery must never execute server code");', + }); + await createModule(modulesDirectory, "people"); + + const modules = await discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); + + expect(modules.map((module) => module.manifest.id)).toEqual(["people", "calendar"]); + expect(modules[1]?.manifest.storage.buckets.map((bucket) => bucket.id)).toEqual([ + "assets", + "exports", + ]); + expect(Object.isFrozen(modules[1]?.manifest)).toBe(true); + const mutableBuckets = modules[1]?.manifest.storage.buckets as unknown as Array<{ id: string }>; + expect(() => mutableBuckets.push({ id: "tampered" })).toThrow(TypeError); + }); + + it("accepts the repository's cloneable module template", async () => { + const testDirectory = path.dirname(fileURLToPath(import.meta.url)); + const templatesDirectory = path.resolve(testDirectory, "../../templates"); + + const modules = await discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory: templatesDirectory, + platformVersion: "0.1.0", + }); + + expect(modules.map((module) => module.manifest.id)).toEqual(["example"]); + }); + + it("aggregates malformed packages and manifests without partial discovery", async () => { + const modulesDirectory = await createModulesDirectory(); + await mkdir(path.join(modulesDirectory, "missing")); + await createModule(modulesDirectory, "invalid", { + manifest: { ...manifestFor("invalid"), renderers: ["not-allowed"] }, + packageJson: { + name: "@test/module-invalid", + version: "not-semver", + type: "commonjs", + }, + }); + + const error = await discoveryError(modulesDirectory); + + expect(issueCodes(error)).toEqual( + expect.arrayContaining([ + "MODULE_MANIFEST_INVALID", + "MODULE_MANIFEST_MISSING", + "MODULE_PACKAGE_INVALID", + "MODULE_PACKAGE_MISSING", + ]), + ); + }); + + it("aggregates malformed JSON without exposing machine-specific paths", async () => { + const modulesDirectory = await createModulesDirectory(); + const moduleDirectory = path.join(modulesDirectory, "broken-json"); + await mkdir(moduleDirectory); + await Promise.all([ + writeFile(path.join(moduleDirectory, "package.json"), "{"), + writeFile(path.join(moduleDirectory, "ekklesia.module.json"), "["), + ]); + + const error = await discoveryError(modulesDirectory); + expect(issueCodes(error)).toEqual( + expect.arrayContaining(["MODULE_MANIFEST_INVALID", "MODULE_PACKAGE_INVALID"]), + ); + expect(error.issues.every((issue) => !issue.message.includes(modulesDirectory))).toBe(true); + }); + + it("requires package and manifest versions to match", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "mismatch", { + packageJson: { + name: "@test/module-mismatch", + version: "0.2.0", + type: "module", + ekklesia: { layer: "module" }, + }, + }); + + const error = await discoveryError(modulesDirectory); + expect(issueCodes(error)).toContain("MODULE_VERSION_MISMATCH"); + }); + + it("rejects duplicate module IDs and package names", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "one", { + manifest: { + ...manifestFor("one"), + id: "shared", + capabilities: ["shared.records"], + permissions: ["shared.records.read"], + }, + packageJson: { + name: "@test/duplicate", + version: "0.1.0", + type: "module", + ekklesia: { layer: "module" }, + }, + }); + await createModule(modulesDirectory, "two", { + manifest: { + ...manifestFor("two"), + id: "shared", + capabilities: ["shared.records"], + permissions: ["shared.records.read"], + }, + packageJson: { + name: "@test/duplicate", + version: "0.1.0", + type: "module", + ekklesia: { layer: "module" }, + }, + }); + + const error = await discoveryError(modulesDirectory); + + expect(issueCodes(error)).toEqual( + expect.arrayContaining(["MODULE_ID_DUPLICATE", "MODULE_PACKAGE_NAME_DUPLICATE"]), + ); + }); + + it("enforces platform and Module SDK compatibility", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "future", { + manifest: { + ...manifestFor("future"), + compatibility: { platform: ">=1.0.0", moduleSdk: ">=2.0.0" }, + }, + }); + + const error = await discoveryError(modulesDirectory); + + expect(issueCodes(error)).toEqual( + expect.arrayContaining(["MODULE_PLATFORM_INCOMPATIBLE", "MODULE_SDK_INCOMPATIBLE"]), + ); + }); + + it("allows missing optional dependencies but rejects missing required dependencies", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "optional-consumer", { + manifest: { + ...manifestFor("optional-consumer"), + dependencies: { modules: { optional: { absent: "^1.0.0" } } }, + }, + }); + + await expect( + discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }), + ).resolves.toHaveLength(1); + + await createModule(modulesDirectory, "required-consumer", { + manifest: { + ...manifestFor("required-consumer"), + dependencies: { modules: { required: { absent: "^1.0.0" } } }, + }, + }); + const error = await discoveryError(modulesDirectory); + expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_MISSING"); + }); + + it("rejects installed dependencies with incompatible versions", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "people"); + await createModule(modulesDirectory, "calendar", { + manifest: { + ...manifestFor("calendar"), + dependencies: { modules: { optional: { people: "^2.0.0" } } }, + }, + }); + + const error = await discoveryError(modulesDirectory); + expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_INCOMPATIBLE"); + }); + + it("rejects missing required capabilities", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "consumer", { + manifest: { + ...manifestFor("consumer"), + dependencies: { capabilities: { required: ["people.records"] } }, + }, + }); + const error = await discoveryError(modulesDirectory); + expect(issueCodes(error)).toContain("MODULE_CAPABILITY_MISSING"); + }); + + it("detects dependency cycles", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "one", { + manifest: { + ...manifestFor("one"), + dependencies: { modules: { required: { two: "^0.1.0" } } }, + }, + }); + await createModule(modulesDirectory, "two", { + manifest: { + ...manifestFor("two"), + dependencies: { modules: { required: { one: "^0.1.0" } } }, + }, + }); + await createModule(modulesDirectory, "three", { + manifest: { + ...manifestFor("three"), + dependencies: { modules: { required: { two: "^0.1.0" } } }, + }, + }); + + const error = await discoveryError(modulesDirectory); + expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_CYCLE"); + expect(error.issues.find((issue) => issue.code === "MODULE_DEPENDENCY_CYCLE")?.message).toBe( + "Module dependency cycle contains: one, two.", + ); + }); + + it("rejects missing entrypoint targets and invalid caller versions", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "missing-server", { + manifest: { + ...manifestFor("missing-server"), + entrypoints: { server: "./src/server/missing.ts" }, + }, + }); + + let error = await discoveryError(modulesDirectory); + expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING"); + + error = await discoveryError(modulesDirectory, { platformVersion: "invalid" }); + expect(issueCodes(error)).toContain("DISCOVERY_CONFIGURATION_INVALID"); + }); + + it("requires executable entrypoints to be files and migrations to be a directory", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "wrong-entrypoint-kind", { + manifest: { + ...manifestFor("wrong-entrypoint-kind"), + entrypoints: { + server: "./src/server", + migrations: "./src/server/index.ts", + }, + }, + }); + + const error = await discoveryError(modulesDirectory); + expect(error.issues.filter((issue) => issue.code === "MODULE_ENTRYPOINT_INVALID")).toHaveLength( + 2, + ); + }); + + it("rejects package metadata links that escape the module directory", async () => { + const modulesDirectory = await createModulesDirectory(); + await createModule(modulesDirectory, "linked"); + const externalManifest = path.join(modulesDirectory, "external-manifest.json"); + const linkedManifest = path.join(modulesDirectory, "linked", "ekklesia.module.json"); + await writeFile(externalManifest, JSON.stringify(manifestFor("linked"))); + await rm(linkedManifest); + await symlink("../external-manifest.json", linkedManifest); + + const error = await discoveryError(modulesDirectory); + expect(issueCodes(error)).toContain("MODULE_MANIFEST_INVALID"); + }); + + it("rejects symbolic links masquerading as installed module directories", async () => { + const modulesDirectory = await createModulesDirectory(); + const externalModules = await createModulesDirectory(); + await createModule(externalModules, "outside"); + await symlink( + path.join(externalModules, "outside"), + path.join(modulesDirectory, "linked-module"), + "dir", + ); + + const error = await discoveryError(modulesDirectory); + expect(issueCodes(error)).toContain("MODULE_DIRECTORY_INVALID"); + }); + + it("produces stable diagnostics independent of the absolute modules path", async () => { + const firstModulesDirectory = await createModulesDirectory(); + const secondModulesDirectory = await createModulesDirectory(); + await mkdir(path.join(firstModulesDirectory, "broken")); + await mkdir(path.join(secondModulesDirectory, "broken")); + + const [firstError, secondError] = await Promise.all([ + discoveryError(firstModulesDirectory), + discoveryError(secondModulesDirectory), + ]); + + expect(firstError.issues).toEqual(secondError.issues); + }); +}); diff --git a/core/tsconfig.build.json b/core/tsconfig.build.json new file mode 100644 index 0000000..4f5dbdb --- /dev/null +++ b/core/tsconfig.build.json @@ -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"] +} diff --git a/core/tsconfig.json b/core/tsconfig.json new file mode 100644 index 0000000..92cc584 --- /dev/null +++ b/core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../packages/typescript-config/node.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/docs/architecture/decisions/0005-static-module-manifests.md b/docs/architecture/decisions/0005-static-module-manifests.md new file mode 100644 index 0000000..fdcb0b0 --- /dev/null +++ b/docs/architecture/decisions/0005-static-module-manifests.md @@ -0,0 +1,55 @@ +# 0005: Static module manifests and atomic discovery + +- Status: Accepted +- Date: 2026-07-12 + +## Context + +Ekklesia modules must be installable by placing a package in `modules/` without editing Core or a +central registry. Core must validate identity, compatibility, dependencies, capabilities, storage +requirements, and entrypoints before it runs module behavior. Importing an executable TypeScript +manifest would allow top-level module code to run before validation completed. + +## Decision + +Every module uses one inert, versioned `ekklesia.module.json` manifest. `@ekklesia/contracts` owns +the authoritative Zod schema and serializable types. `@ekklesia/module-sdk` re-exports those public +contracts and provides `defineModule` for typed programmatic validation without becoming a Core +service locator. + +Core scans immediate non-hidden directories under the configured modules directory. Discovery +validates all manifests and package metadata, resolves entrypoints without loading them, checks +platform and Module SDK compatibility, resolves module and capability dependencies, and produces a +deterministic dependency-first order. Any error rejects the entire discovery result with sorted, +structured issues; no module server code is partially loaded. + +Storage requirements are declarative. A module may request one or more logical buckets in its +manifest, but it cannot create provider clients or select physical bucket names. The later Core +storage service will provision deployment-prefixed physical buckets and give the owning module +scoped handles. Buckets remain private unless a future explicit storage contract says otherwise. + +## Alternatives + +- Executable `module.ts` manifests were rejected because discovery would cross the code-execution + boundary before validation. +- Embedding the full manifest in `package.json` was rejected because it couples the platform + contract to package-manager metadata and makes the manifest harder to version independently. +- A manually maintained import registry was rejected because every module installation would + require a central source edit. +- Letting modules configure SeaweedFS or S3 clients directly was rejected because Core owns + credentials, provider selection, isolation, and lifecycle. + +## Consequences + +JSON manifests do not provide TypeScript inference by themselves, so validation commands and clear +path-aware errors are part of the authoring workflow. A JSON Schema can be generated from the +authoritative contract later for editor completion. + +Discovery currently proves entrypoint existence but does not import server behavior, execute +lifecycle hooks, provision requested buckets, or register routes, jobs, schemas, and migrations. +Those operations begin only after a complete valid discovery result in later runtime slices. + +## Migration implications + +The manifest includes `manifestVersion: 1`. Future incompatible shapes require an explicit schema +version and migration path rather than silently changing existing module interpretation. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 57332bd..9b6b536 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -13,3 +13,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0002: pnpm workspace and strict TypeScript](0002-pnpm-workspace-and-typescript.md) — Accepted - [0003: Development infrastructure](0003-development-infrastructure.md) — Accepted - [0004: Replace unmaintained MinIO baseline](0004-replace-unmaintained-minio.md) — Accepted +- [0005: Static module manifests and atomic discovery](0005-static-module-manifests.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 6c2d1d6..61f7c8c 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -56,11 +56,14 @@ surfaces. - Private objects use signed access and namespaced storage paths. - Every network service starts from least-privilege and local-only development defaults. -## Phase 1 status +## Current implementation status -Implemented now: monorepo/tooling, strict TypeScript policy, repository-boundary validation, -tests, base CI, PostgreSQL, and a development S3-compatible object store. +Implemented now: the Phase 1 repository and infrastructure foundation plus public module contracts, +the Module SDK manifest API, static versioned module manifests, automatic filesystem discovery, +compatibility/dependency ordering, structured discovery failures, and a backend-only module +template. Core can validate zero or more modules without importing their server behavior. -Target architecture, not yet implemented: Core runtime, module discovery and lifecycle, Drizzle -schema aggregation/migrations, Better Auth, permissions, storage service, Module SDK, Surface -SDK, capability/resource/action registries, API server, dashboard, Builder, and reference modules. +Target architecture, not yet implemented: lifecycle execution and runtime registration, Drizzle +schema aggregation/migrations, Better Auth, permissions, physical storage-bucket provisioning, +Surface SDK, capability/resource/action registries, API server, dashboard, Builder, and reference +feature modules. diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index 1d5c820..f89570b 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -18,6 +18,7 @@ pnpm install --frozen-lockfile pnpm format:check # formatting without mutation pnpm lint # Biome recommended rules pnpm boundaries # architectural directions, UI ownership, and cycles +pnpm modules:validate # installed module manifests, compatibility, and dependency graph pnpm typecheck # strict TypeScript pnpm test # Vitest pnpm build # Turborepo package build graph @@ -28,8 +29,12 @@ The lint, typecheck, and test commands run both repository-root checks and every package-level Turborepo task. New packages add their own `lint`, `typecheck`, and `test` scripts; the root verification command discovers them automatically. -CI runs `pnpm ci`, which adds Compose configuration validation to `pnpm verify`. A separate CI job -runs the live infrastructure smoke test so code-only local verification does not require Docker. +`pnpm modules:validate` scans `modules/*` without a central registry. A missing or empty modules +directory is valid and reports zero installed modules. + +CI runs `pnpm ci:verify`, which adds Compose configuration validation to `pnpm verify`. A separate +CI job runs the live infrastructure smoke test so code-only local verification does not require +Docker. The explicit suffix avoids pnpm's built-in `ci` install command. ## Development infrastructure diff --git a/docs/module-development/README.md b/docs/module-development/README.md new file mode 100644 index 0000000..7307f6a --- /dev/null +++ b/docs/module-development/README.md @@ -0,0 +1,99 @@ +# Module development + +An Ekklesia module is a backend capability package. It owns business behavior and data declarations; +it never owns components, styles, layouts, renderers, navigation, or other UI. + +## Required package shape + +Each immediate child of `modules/` must contain: + +```text +modules/example/ +├── ekklesia.module.json +├── package.json +└── src/server/index.ts +``` + +`package.json` must use ESM, a strict semantic version, a unique package name, and: + +```json +{ + "ekklesia": { + "layer": "module" + } +} +``` + +Its version must match the module manifest version. Internal package dependencies use `workspace:` +ranges and supported package entrypoints. + +## Manifest + +The canonical manifest is inert JSON so discovery can validate a module without executing it: + +```json +{ + "manifestVersion": 1, + "id": "calendar", + "name": "Calendar", + "version": "0.1.0", + "compatibility": { + "platform": "^0.1.0", + "moduleSdk": "^0.1.0" + }, + "dependencies": { + "modules": { + "required": { + "people": "^0.1.0" + } + }, + "capabilities": { + "optional": ["media.items"] + } + }, + "capabilities": ["calendar.events"], + "permissions": ["calendar.events.read"], + "storage": { + "buckets": [ + { + "id": "assets", + "description": "Private event assets owned by Calendar." + } + ] + }, + "entrypoints": { + "server": "./src/server/index.ts" + } +} +``` + +Module, capability, and permission identifiers are lowercase and namespaced. Entrypoints must begin +with `./`, stay inside the package, and exist. Unknown manifest properties are rejected, including +presentation fields such as components or renderers. + +Storage bucket IDs are logical names scoped to the module. Modules may request multiple buckets. +Core will later map them to deployment-specific physical S3 buckets and provide scoped access; +module code must not configure SeaweedFS or other S3 clients directly. + +## Discovery behavior + +Run: + +```bash +pnpm modules:validate +``` + +Discovery checks every installed module atomically. It rejects malformed manifests, duplicate IDs +or package names, version mismatches, incompatible platform or SDK ranges, missing required modules +or capabilities, incompatible installed dependencies, cycles, and missing or escaping entrypoints. +Missing optional dependencies are allowed. Valid modules are ordered dependency-first with stable +identifier sorting. + +Discovery does not load server code. Lifecycle execution, route/job registration, Drizzle schema +aggregation and migrations, and physical bucket provisioning are deliberately deferred. + +## Start from the template + +Copy `templates/module` to `modules/` and follow its README. The template is part of +the workspace and is validated by the same contracts and discovery implementation as installed +modules. diff --git a/package.json b/package.json index ee85e6c..a3d3bbb 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "scripts": { "boundaries": "node scripts/check-boundaries.ts", "build": "turbo run build", - "ci": "pnpm verify && pnpm infrastructure:config", + "ci:verify": "pnpm verify && pnpm infrastructure:config", "format": "biome format --write .", "format:check": "biome format .", "infrastructure:config": "docker compose --env-file .env.example -f infrastructure/compose.yaml config --quiet", @@ -22,12 +22,13 @@ "infrastructure:up": "docker compose --env-file .env -f infrastructure/compose.yaml up --detach --wait", "lint": "turbo run lint lint:root", "lint:root": "biome lint .", + "modules:validate": "turbo run build --filter=@ekklesia/core && node core/dist/cli/validate-modules.js", "test": "turbo run test test:root", "test:root": "vitest run --config vitest.config.ts", "test:watch": "vitest", "typecheck": "turbo run typecheck typecheck:root", "typecheck:root": "tsc --noEmit --project tsconfig.json", - "verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm build" + "verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm modules:validate && pnpm build" }, "devDependencies": { "@biomejs/biome": "2.5.3", diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 0000000..400cab3 --- /dev/null +++ b/packages/contracts/package.json @@ -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" + } +} diff --git a/packages/contracts/src/identifiers.ts b/packages/contracts/src/identifiers.ts new file mode 100644 index 0000000..322faee --- /dev/null +++ b/packages/contracts/src/identifiers.ts @@ -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; +export type CapabilityId = z.infer; +export type PermissionId = z.infer; +export type LogicalBucketId = z.infer; +export type PackageRelativePath = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 0000000..81dda42 --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,4 @@ +export * from "./identifiers.js"; +export * from "./module-lifecycle.js"; +export * from "./module-manifest.js"; +export * from "./versions.js"; diff --git a/packages/contracts/src/module-lifecycle.ts b/packages/contracts/src/module-lifecycle.ts new file mode 100644 index 0000000..dc6ad76 --- /dev/null +++ b/packages/contracts/src/module-lifecycle.ts @@ -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; diff --git a/packages/contracts/src/module-manifest.ts b/packages/contracts/src/module-manifest.ts new file mode 100644 index 0000000..9c439bf --- /dev/null +++ b/packages/contracts/src/module-manifest.ts @@ -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(); + 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; +export type ModuleManifest = z.output; +export type ModuleBucketDeclaration = z.output; diff --git a/packages/contracts/src/versions.ts b/packages/contracts/src/versions.ts new file mode 100644 index 0000000..e7c1e6c --- /dev/null +++ b/packages/contracts/src/versions.ts @@ -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; +export type SemanticVersionRange = z.infer; diff --git a/packages/contracts/tests/module-manifest.test.ts b/packages/contracts/tests/module-manifest.test.ts new file mode 100644 index 0000000..97362e2 --- /dev/null +++ b/packages/contracts/tests/module-manifest.test.ts @@ -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 { + 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"); + }); +}); diff --git a/packages/contracts/tsconfig.build.json b/packages/contracts/tsconfig.build.json new file mode 100644 index 0000000..4f5dbdb --- /dev/null +++ b/packages/contracts/tsconfig.build.json @@ -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"] +} diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..0d93162 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../typescript-config/node.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/module-sdk/package.json b/packages/module-sdk/package.json new file mode 100644 index 0000000..22d3273 --- /dev/null +++ b/packages/module-sdk/package.json @@ -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" + } +} diff --git a/packages/module-sdk/src/define-module.ts b/packages/module-sdk/src/define-module.ts new file mode 100644 index 0000000..254424d --- /dev/null +++ b/packages/module-sdk/src/define-module.ts @@ -0,0 +1,9 @@ +import { + type ModuleManifest, + type ModuleManifestInput, + parseModuleManifest, +} from "@ekklesia/contracts"; + +export function defineModule(manifest: ModuleManifestInput): ModuleManifest { + return parseModuleManifest(manifest); +} diff --git a/packages/module-sdk/src/index.ts b/packages/module-sdk/src/index.ts new file mode 100644 index 0000000..8091634 --- /dev/null +++ b/packages/module-sdk/src/index.ts @@ -0,0 +1,2 @@ +export * from "@ekklesia/contracts"; +export * from "./define-module.js"; diff --git a/packages/module-sdk/tests/define-module.test.ts b/packages/module-sdk/tests/define-module.test.ts new file mode 100644 index 0000000..f511183 --- /dev/null +++ b/packages/module-sdk/tests/define-module.test.ts @@ -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(); + }); +}); diff --git a/packages/module-sdk/tsconfig.build.json b/packages/module-sdk/tsconfig.build.json new file mode 100644 index 0000000..4f5dbdb --- /dev/null +++ b/packages/module-sdk/tsconfig.build.json @@ -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"] +} diff --git a/packages/module-sdk/tsconfig.json b/packages/module-sdk/tsconfig.json new file mode 100644 index 0000000..0d93162 --- /dev/null +++ b/packages/module-sdk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../typescript-config/node.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd56876..11ddb86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,93 @@ importers: specifier: 2.9.0 version: 2.9.0 + core: + dependencies: + '@ekklesia/contracts': + specifier: workspace:* + version: link:../packages/contracts + devDependencies: + '@biomejs/biome': + specifier: 2.5.3 + version: 2.5.3 + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)) + + packages/contracts: + dependencies: + semver: + specifier: 7.8.5 + version: 7.8.5 + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@biomejs/biome': + specifier: 2.5.3 + version: 2.5.3 + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + '@types/semver': + specifier: 7.7.1 + version: 7.7.1 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)) + + packages/module-sdk: + dependencies: + '@ekklesia/contracts': + specifier: workspace:* + version: link:../contracts + devDependencies: + '@biomejs/biome': + specifier: 2.5.3 + version: 2.5.3 + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)) + packages/typescript-config: {} + templates/module: + dependencies: + '@ekklesia/module-sdk': + specifier: workspace:* + version: link:../../packages/module-sdk + devDependencies: + '@biomejs/biome': + specifier: 2.5.3 + version: 2.5.3 + '@ekklesia/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(yaml@2.9.0)) + packages: '@biomejs/biome@2.5.3': @@ -255,6 +340,9 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -548,6 +636,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -685,6 +778,9 @@ packages: engines: {node: '>= 14.6'} hasBin: true + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@biomejs/biome@2.5.3': @@ -838,6 +934,8 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/semver@7.7.1': {} + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1052,6 +1150,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.5 '@rolldown/binding-win32-x64-msvc': 1.1.5 + semver@7.8.5: {} + siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -1153,3 +1253,5 @@ snapshots: stackback: 0.0.2 yaml@2.9.0: {} + + zod@4.4.3: {} diff --git a/templates/module/README.md b/templates/module/README.md new file mode 100644 index 0000000..9943313 --- /dev/null +++ b/templates/module/README.md @@ -0,0 +1,16 @@ +# Ekklesia module template + +This is the smallest supported backend-only Ekklesia module. It proves the manifest, package, +Module SDK, discovery, capability, permission, and logical storage-bucket contracts without +pretending that the later runtime, API, or Drizzle registration layers already exist. + +To create a module inside this repository: + +1. Copy this directory to `modules/`. +2. Rename the package in `package.json` and remove `private` when it is ready for publication. +3. Update the ID, name, version, namespaces, compatibility ranges, and bucket requests in + `ekklesia.module.json`. +4. Replace the example server entrypoint with backend behavior only. +5. Run `pnpm install`, `pnpm modules:validate`, and `pnpm verify` from the repository root. + +Do not add UI components, styles, renderers, navigation, or layouts. Those belong to surfaces. diff --git a/templates/module/ekklesia.module.json b/templates/module/ekklesia.module.json new file mode 100644 index 0000000..5a7705d --- /dev/null +++ b/templates/module/ekklesia.module.json @@ -0,0 +1,24 @@ +{ + "manifestVersion": 1, + "id": "example", + "name": "Example Module", + "description": "A minimal backend-only Ekklesia module template.", + "version": "0.1.0", + "compatibility": { + "platform": "^0.1.0", + "moduleSdk": "^0.1.0" + }, + "capabilities": ["example.records"], + "permissions": ["example.records.read"], + "storage": { + "buckets": [ + { + "id": "records", + "description": "Private objects owned by this example module." + } + ] + }, + "entrypoints": { + "server": "./src/server/index.ts" + } +} diff --git a/templates/module/package.json b/templates/module/package.json new file mode 100644 index 0000000..c02d0aa --- /dev/null +++ b/templates/module/package.json @@ -0,0 +1,41 @@ +{ + "name": "@ekklesia/module-template", + "version": "0.1.0", + "private": true, + "type": "module", + "files": [ + "dist", + "src", + "ekklesia.module.json" + ], + "exports": { + ".": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "lint": "biome lint .", + "prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-template", + "test": "vitest run", + "typecheck": "tsc --noEmit --project tsconfig.json" + }, + "dependencies": { + "@ekklesia/module-sdk": "workspace:*" + }, + "devDependencies": { + "@biomejs/biome": "2.5.3", + "@types/node": "24.13.3", + "@ekklesia/typescript-config": "workspace:*", + "typescript": "7.0.2", + "vitest": "4.1.10" + }, + "ekklesia": { + "layer": "module" + } +} diff --git a/templates/module/src/server/index.ts b/templates/module/src/server/index.ts new file mode 100644 index 0000000..cd5fb83 --- /dev/null +++ b/templates/module/src/server/index.ts @@ -0,0 +1,7 @@ +export interface ExampleModuleServer { + readonly id: "example"; +} + +export const exampleModuleServer: ExampleModuleServer = { + id: "example", +}; diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts new file mode 100644 index 0000000..525d50f --- /dev/null +++ b/templates/module/tests/manifest.test.ts @@ -0,0 +1,14 @@ +import { readFile } from "node:fs/promises"; +import { defineModule, type ModuleManifestInput } from "@ekklesia/module-sdk"; +import { describe, expect, it } from "vitest"; + +describe("module template", () => { + it("ships a valid backend-only manifest", async () => { + const manifestUrl = new URL("../ekklesia.module.json", import.meta.url); + const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as ModuleManifestInput; + const manifest = defineModule(manifestInput); + + expect(manifest.id).toBe("example"); + expect(manifest.storage.buckets.map((bucket) => bucket.id)).toEqual(["records"]); + }); +}); diff --git a/templates/module/tsconfig.build.json b/templates/module/tsconfig.build.json new file mode 100644 index 0000000..4f5dbdb --- /dev/null +++ b/templates/module/tsconfig.build.json @@ -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"] +} diff --git a/templates/module/tsconfig.json b/templates/module/tsconfig.json new file mode 100644 index 0000000..837feb5 --- /dev/null +++ b/templates/module/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@ekklesia/typescript-config/node.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/tests/infrastructure.test.ts b/tests/infrastructure.test.ts index c333735..8403365 100644 --- a/tests/infrastructure.test.ts +++ b/tests/infrastructure.test.ts @@ -75,7 +75,7 @@ describe("development infrastructure", () => { expect(workflow.jobs?.verify).toBeDefined(); expect(commands).toContain("pnpm install --frozen-lockfile"); - expect(commands).toContain("pnpm ci"); + expect(commands).toContain("pnpm ci:verify"); expect(infrastructureCommands).toContain("bash scripts/smoke-infrastructure.sh"); }); @@ -84,7 +84,14 @@ describe("development infrastructure", () => { const workspace = parse(source) as WorkspaceDocument; expect(workspace.packages).toEqual( - expect.arrayContaining(["apps/*", "builders/*", "core", "modules/*", "surfaces/*"]), + expect.arrayContaining([ + "apps/*", + "builders/*", + "core", + "modules/*", + "surfaces/*", + "templates/*", + ]), ); }); @@ -97,6 +104,7 @@ describe("development infrastructure", () => { expect(packageDocument.scripts?.lint).toBe("turbo run lint lint:root"); expect(packageDocument.scripts?.test).toBe("turbo run test test:root"); expect(packageDocument.scripts?.typecheck).toBe("turbo run typecheck typecheck:root"); + expect(packageDocument.scripts?.verify).toContain("pnpm modules:validate"); expect(turboDocument.tasks).toHaveProperty("//#lint:root"); expect(turboDocument.tasks).toHaveProperty("//#test:root"); expect(turboDocument.tasks).toHaveProperty("//#typecheck:root"); diff --git a/turbo.json b/turbo.json index f19b605..31b68ad 100644 --- a/turbo.json +++ b/turbo.json @@ -15,7 +15,7 @@ "outputs": [] }, "typecheck": { - "dependsOn": ["^typecheck"], + "dependsOn": ["^build", "^typecheck"], "outputs": [] }, "//#lint:root": {