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
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Verify repository
run: pnpm ci
run: pnpm ci:verify
infrastructure-smoke:
needs: verify
+5 -2
View File
@@ -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.
+6 -4
View File
@@ -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
+8 -1
View File
@@ -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,
+37
View File
@@ -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"
}
}
+51
View File
@@ -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<string> {
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<void> {
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;
});
+1
View File
@@ -0,0 +1 @@
export * from "./modules/index.js";
+696
View File
@@ -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> = T extends (...arguments_: never[]) => unknown
? T
: T extends readonly (infer Item)[]
? readonly DeepReadonly<Item>[]
: T extends object
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
: T;
export type ReadonlyModuleManifest = DeepReadonly<ModuleManifest>;
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function freezeRecursively<T>(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<unknown> {
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<ResolvedModuleEntrypoints | undefined> {
const resolved: Partial<Record<keyof ModuleManifest["entrypoints"], string>> = {};
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<ModuleCandidate | undefined> {
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<string, Set<string>>,
indegrees: Map<string, number>,
): 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<string, Set<string>>): boolean {
const visited = new Set<string>();
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<string, ModuleCandidate>,
dependents: Map<string, Set<string>>,
indegrees: Map<string, number>,
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<readonly DiscoveredModule[]> {
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<string>();
const duplicatePackageNames = new Set<string>();
const firstById = new Map<string, ModuleCandidate>();
const firstByPackageName = new Map<string, ModuleCandidate>();
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<string, ModuleCandidate>();
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<string, Set<string>>();
const indegrees = new Map<string, number>();
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);
}),
);
}
+1
View File
@@ -0,0 +1 @@
export * from "./discovery.js";
+429
View File
@@ -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<string, unknown>;
packageJson?: Record<string, unknown>;
serverSource?: string;
}
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
async function createModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-modules-"));
temporaryDirectories.push(directory);
return directory;
}
function manifestFor(id: string): Record<string, unknown> {
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<void> {
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<ModuleDiscoveryError> {
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);
});
});
+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": "../packages/typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
@@ -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.
+1
View File
@@ -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
+9 -6
View File
@@ -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.
+7 -2
View File
@@ -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
+99
View File
@@ -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/<your-module-id>` and follow its README. The template is part of
the workspace and is validated by the same contracts and discovery implementation as installed
modules.
+3 -2
View File
@@ -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",
+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"]
}
+102
View File
@@ -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/[email protected])([email protected](@types/[email protected])([email protected]))
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/[email protected])([email protected](@types/[email protected])([email protected]))
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/[email protected])([email protected](@types/[email protected])([email protected]))
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/[email protected])([email protected](@types/[email protected])([email protected]))
packages:
'@biomejs/[email protected]':
@@ -255,6 +340,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/[email protected]':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@typescript/[email protected]':
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
[email protected]:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
@@ -685,6 +778,9 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
snapshots:
'@biomejs/[email protected]':
@@ -838,6 +934,8 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/[email protected]': {}
'@typescript/[email protected]':
optional: true
@@ -1052,6 +1150,8 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.1.5
'@rolldown/binding-win32-x64-msvc': 1.1.5
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -1153,3 +1253,5 @@ snapshots:
stackback: 0.0.2
[email protected]: {}
[email protected]: {}
+16
View File
@@ -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/<module-id>`.
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.
+24
View File
@@ -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"
}
}
+41
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
export interface ExampleModuleServer {
readonly id: "example";
}
export const exampleModuleServer: ExampleModuleServer = {
id: "example",
};
+14
View File
@@ -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"]);
});
});
+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": "@ekklesia/typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+10 -2
View File
@@ -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");
+1 -1
View File
@@ -15,7 +15,7 @@
"outputs": []
},
"typecheck": {
"dependsOn": ["^typecheck"],
"dependsOn": ["^build", "^typecheck"],
"outputs": []
},
"//#lint:root": {