chore: establish Ekklesia platform foundation
CI / verify (push) Has been cancelled

This commit is contained in:
ekklesia
2026-07-12 20:28:19 -05:00
commit 192a6514cf
47 changed files with 2505 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
+14
View File
@@ -0,0 +1,14 @@
# Local development only. Replace every credential outside a developer workstation.
POSTGRES_DB=ekklesia
POSTGRES_USER=ekklesia
POSTGRES_PASSWORD=ekklesia-development-only
POSTGRES_PORT=5432
DATABASE_URL=postgresql://ekklesia:[email protected]:5432/ekklesia
S3_ACCESS_KEY=ekklesia-development
S3_SECRET_KEY=ekklesia-development-secret
S3_BUCKET=ekklesia-development
S3_ENDPOINT=http://127.0.0.1:8333
S3_PORT=8333
S3_REGION=us-east-1
S3_FORCE_PATH_STYLE=true
+8
View File
@@ -0,0 +1,8 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.woff2 binary
+38
View File
@@ -0,0 +1,38 @@
name: CI
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: .node-version
- name: Activate pinned pnpm
run: |
corepack enable
corepack prepare [email protected] --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify repository
run: pnpm ci
+15
View File
@@ -0,0 +1,15 @@
node_modules/
.turbo/
coverage/
dist/
.next/
.env
.env.*
!.env.example
generated/*
!generated/.gitkeep
*.log
.DS_Store
+1
View File
@@ -0,0 +1 @@
24.18.0
+5
View File
@@ -0,0 +1,5 @@
engine-strict=true
save-exact=true
save-workspace-protocol=rolling
shared-workspace-lockfile=true
strict-peer-dependencies=true
+1
View File
@@ -0,0 +1 @@
24.18.0
+32
View File
@@ -0,0 +1,32 @@
# Contributing
Ekklesia is being built in verified, reviewable phases. Changes should preserve the dependency
boundaries described in [repository-boundaries.md](docs/architecture/repository-boundaries.md).
Before opening a change, run:
```bash
pnpm verify
```
If Docker is available, also validate the development infrastructure:
```bash
pnpm infrastructure:config
```
## Package rules
- Use strict TypeScript for production packages.
- Expose supported entry points through package `exports`; do not import another package's
private source paths.
- Use `workspace:` ranges for internal package dependencies.
- Keep modules backend-only. Module packages cannot contain React, mobile, stylesheet, or other
UI source files.
- Put rendering, layout, navigation, accessibility, and all other presentation concerns in a
surface.
- 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.
+41
View File
@@ -0,0 +1,41 @@
# Ekklesia
Ekklesia is an open, modular church technology platform for composing backend capabilities
into independently developed dashboards, websites, mobile apps, kiosks, displays, and other
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.
## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
```bash
pnpm install --frozen-lockfile
cp .env.example .env
pnpm infrastructure:up
pnpm verify
```
The development services bind only to loopback:
- PostgreSQL: `127.0.0.1:5432`
- S3-compatible object storage: `127.0.0.1:8333`
The values in `.env.example` are development-only and must never be reused in a deployed
environment.
## Architecture
- [Platform architecture](docs/architecture/platform-architecture.md)
- [Repository boundaries](docs/architecture/repository-boundaries.md)
- [Repository workflow](docs/development/repository-workflow.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
owner makes that decision.
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
"files": {
"includes": ["**", "!tests/fixtures", "!pnpm-lock.yaml"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended"
}
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all"
}
}
}
@@ -0,0 +1,34 @@
# 0001: Four-layer platform boundary
- Status: Accepted
- Date: 2026-07-12
## Context
Ekklesia must remain understandable and independently extensible after many backend capabilities
and user experiences have been added. A conventional feature monolith or UI-bearing plugin model
would couple business behavior to a particular presentation framework.
## Decision
Adopt four explicit ownership layers: Core orchestration, backend Modules, presentation Surfaces,
and declarative Surface Builder configuration. Begin as a modular monolith. Modules expose data
and behavior only; every renderer and UI concern belongs to a surface.
## Alternatives
- A conventional feature monolith was rejected because boundaries degrade as features grow.
- UI-bearing plugins were rejected because they bind capabilities to frameworks and visual design.
- Initial microservices were rejected because distribution adds failure modes before contracts
have been proven.
## Consequences
Stable contracts, SDK ports, generated composition, and automated boundary checks are required.
Some integrations take more deliberate service or event design, but modules and surfaces remain
independently replaceable.
## Migration implications
Future boundary violations must move behind public contracts before the affected package can be
treated as independently installable or upgradeable.
@@ -0,0 +1,36 @@
# 0002: pnpm workspace and strict TypeScript
- Status: Accepted
- Date: 2026-07-12
## Context
The platform needs atomic cross-package changes, reproducible installs, independent package
builds, and guardrails that do not force every surface onto one framework.
## Decision
Use a pnpm workspace with one committed lockfile and `workspace:` ranges for internal packages.
Use Turborepo for task ordering and local caching. Standardize production packages on strict
TypeScript and native ESM, with separate Node, browser, and React Native base configurations. Use
Biome and Vitest for the initial repository quality gates.
Node 24 LTS and pnpm 11.12 are pinned for Phase 1. Package exports are the supported cross-package
API; aliases may not expose another package's private `src` tree.
## Alternatives
Polyrepos would slow coordinated contract changes. npm and Yarn workspaces are viable but do not
match the selected pnpm workflow. A universal browser-oriented TypeScript configuration was
rejected because server and native packages have different resolution and library requirements.
## Consequences
Repository-wide changes are straightforward, while package manifests and automated checks must
prevent monorepo convenience from becoming hidden coupling. Remote cache and independent-release
automation are deferred until they solve an observed need.
## Migration implications
Packages can move to separate repositories later because consumers use package names and public
exports instead of filesystem aliases.
@@ -0,0 +1,36 @@
# 0003: Development infrastructure
- Status: Accepted
- Date: 2026-07-12
## Context
Developers need reproducible PostgreSQL and object-storage services without requiring host-level
database installation. Application source does not yet exist, so containerizing the whole
toolchain would add friction without value.
## Decision
Run PostgreSQL 18.4 and an S3-compatible object store in Docker Compose. Pin reviewed image
versions, enable health checks, use persistent named volumes, bind host ports to loopback, and use
explicitly development-only credentials. Run Node applications on the host during normal
development.
This Compose file is a development baseline, not the production deployment topology.
## Alternatives
Locally installed services make onboarding inconsistent. Containerizing editors and the complete
Node toolchain is unnecessary at this phase. Kubernetes is premature for a modular monolith
foundation.
## Consequences
Docker is required for infrastructure testing but not for code-only validation. Production
backups, high availability, secret injection, image digests, and deployment topology require a
later decision.
## Migration implications
The future Core storage service targets S3 behavior rather than a provider-specific client, so
development and production providers can change after contract testing.
@@ -0,0 +1,40 @@
# 0004: Replace unmaintained MinIO baseline
- Status: Accepted
- Date: 2026-07-12
## Context
The original Ekklesia brief selected MinIO as the default self-hosted S3-compatible service. The
upstream `minio/minio` repository was archived on April 25, 2026 and states that historical
community binary releases are no longer maintained. New church deployments may store highly
sensitive content, so beginning on an unmaintained security-sensitive service is unacceptable.
## Decision
Keep Ekklesia's future storage contract provider-neutral and use SeaweedFS 4.28 in the Phase 1
development Compose environment. SeaweedFS is actively maintained and offers an S3-compatible
endpoint and a small single-node development mode.
Before treating any provider as production-ready, add storage contract tests covering put, get,
head, delete, ranges, multipart uploads, metadata/content type, signed URLs, bucket and prefix
isolation, and denied access. A production provider decision remains separate.
## Alternatives
- Retaining the final MinIO community image was rejected because it no longer receives upstream
maintenance.
- Provider-specific Core APIs were rejected because they would couple every module to this choice.
- Deferring all object storage was rejected because a reproducible S3 development endpoint is part
of the repository foundation.
## Consequences
The implementation intentionally uses `S3_*` configuration rather than `MINIO_*` names. Some
SeaweedFS behavior may differ from AWS S3 or historical MinIO and must be captured by contract
tests before the storage abstraction is implemented.
## Migration implications
No product data exists in Phase 1. Later provider changes must follow an object-copy and integrity
verification plan while preserving stable Core storage contracts.
+15
View File
@@ -0,0 +1,15 @@
# Architecture decision records
ADRs capture durable choices that affect more than one package or phase.
- Filenames use a four-digit sequence followed by a short kebab-case title.
- Status is `Proposed`, `Accepted`, `Superseded`, or `Rejected`.
- Accepted ADRs are immutable except for status and cross-reference corrections.
- A changed decision receives a new ADR that links to and supersedes the old one.
## Index
- [0001: Four-layer platform boundary](0001-four-layer-platform-boundary.md) — Accepted
- [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
@@ -0,0 +1,66 @@
# Platform architecture
Ekklesia is a long-lived platform from which churches can compose many applications and user
experiences. It begins as a modular monolith so contracts can stabilize before distribution is
introduced.
## Dependency model
The arrows below mean "depends on." Presentation and composition depend on stable lower-level
contracts; infrastructure never learns individual feature names.
```text
Surface Builder configuration -> Surfaces -> public capability contracts
^
|
HTTP/API and client contracts
|
Feature Modules -> Module SDK / platform contracts <- Core implementations
```
The four ownership layers are:
1. **Core** orchestrates generic platform infrastructure: startup, identity, permissions,
configuration, database connectivity, storage, events, jobs, module discovery, and lifecycle.
2. **Modules** own backend domain behavior, data schemas, resources, actions, routes, events,
permissions, and jobs.
3. **Surfaces** own every presentation concern: components, renderers, layout, navigation,
accessibility, interaction, branding, and client state.
4. **Surface Builder** stores validated declarative composition. It selects capabilities and
surface-owned renderers but contains neither executable code nor module business logic.
Modules do not own UI. For example, Calendar data, actions, and permissions will live in
`modules/calendar`; Calendar renderers for a dashboard or public website will live in those
surfaces.
## Stable seams
- Modules use public SDK ports and injected Core services, never undocumented Core internals.
- Modules do not import another module's schema or private code. Integrations use public service
contracts, capabilities, events, or APIs.
- Surfaces consume API/client and capability contracts, never feature-module packages.
- Core discovers feature modules generically. It contains no feature-specific imports or
conditional branches.
- Deployable apps are thin composition roots and do not become domain-logic owners.
- Generated bootstrap artifacts may connect discovered entry points at the application edge;
they do not become manually maintained Core registries.
## Security invariants
- Authorization is enforced server-side.
- Secrets are isolated from public configuration and logs.
- Authentication tokens, private form content, prayer requests, and pastoral information are not
logged.
- Sensitive actions are designed for auditability without copying sensitive content into audit
records.
- Private objects use signed access and namespaced storage paths.
- Every network service starts from least-privilege and local-only development defaults.
## Phase 1 status
Implemented now: monorepo/tooling, strict TypeScript policy, repository-boundary validation,
tests, base CI, PostgreSQL, and a development S3-compatible object store.
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.
@@ -0,0 +1,40 @@
# Repository boundaries
These rules map platform ownership to filesystem and package boundaries. The executable check is
`pnpm boundaries`; CI must reject violations.
| Area | Owns | May depend on |
| --- | --- | --- |
| `apps/*` | Thin deployable composition roots | Public Core APIs, generated bootstrap, or a surface as required by the app |
| `core` | Feature-agnostic platform implementations | Public contracts and external infrastructure libraries |
| `modules/*` | Backend domain capability and data | Module SDK, public contracts, and external backend libraries |
| `surfaces/*` | UI, renderers, navigation, and interaction | Surface SDK, API/auth clients, public contracts, and design packages |
| `builders/*` | Declarative surface composition tooling | Builder/surface SDKs and public contracts |
| `packages/*` | Explicitly scoped SDKs, contracts, clients, design, testing, or tooling | Only dependencies appropriate to the package's declared level |
| `templates/module` | A small example backend module | The same dependencies allowed for any module |
## Mandatory rules
- Core never imports a feature module, surface, Builder implementation, or app.
- A module never imports Core implementations, another module, a surface, Builder code, or an app.
- A module cannot contain `.tsx`, `.jsx`, `.css`, `.scss`, `.less`, `.vue`, or `.svelte` source
and cannot depend on a UI framework. Modules expose data and behavior only.
- A surface never imports Core or a feature module. It uses public API/client contracts.
- Separate surfaces do not import each other's internals.
- Builder code composes contracts and surface renderer identifiers; it does not import module or
surface internals.
- Contract and SDK packages cannot depend upward on implementations.
- Workspace package consumers use declared `exports`, not private subpaths.
- Circular source and workspace-package dependencies are rejected.
- Cross-module data access uses services, events, capabilities, or opaque identifiers rather than
direct table imports and foreign keys.
The boundary test suite includes intentionally invalid fixture graphs. This proves that the guard
rejects forbidden dependency directions even before production packages fill every directory.
## Generated code
Generated artifacts are reproducible outputs with a named generator and validation step. A
generated module bootstrap may import discovered module entry points at an application
composition edge, but Core itself remains feature-agnostic. Generated files are never an excuse
for a hand-maintained central module registry.
+53
View File
@@ -0,0 +1,53 @@
# Repository workflow
## Prerequisites
- Node.js 24 LTS (`.node-version` and `.nvmrc` pin the reviewed patch)
- pnpm 11.12.0
- Docker with the Compose plugin for local infrastructure
Install dependencies from the committed lockfile:
```bash
pnpm install --frozen-lockfile
```
## Quality gates
```bash
pnpm format:check # formatting without mutation
pnpm lint # Biome recommended rules
pnpm boundaries # architectural directions, UI ownership, and cycles
pnpm typecheck # strict TypeScript
pnpm test # Vitest
pnpm build # Turborepo package build graph
pnpm verify # every code gate above
```
CI runs `pnpm ci`, which adds Compose configuration validation to `pnpm verify`.
## Development infrastructure
Create an untracked local environment file before using Compose:
```bash
cp .env.example .env
pnpm infrastructure:up
```
Stop services or follow their logs with:
```bash
pnpm infrastructure:down
pnpm infrastructure:logs
```
PostgreSQL and the S3 endpoint bind to `127.0.0.1` only. Compose uses persistent named volumes;
`infrastructure:down` preserves data. The S3 service creates the configured development bucket
idempotently at startup.
The checked-in credentials are safe only for local development. Deployed secrets must come from
an environment-appropriate secret provider and must not be committed.
Docker is not installed in every contributor environment. `pnpm infrastructure:config` and live
service health therefore run separately from the code-only `pnpm verify` gate.
+46
View File
@@ -0,0 +1,46 @@
name: ekklesia-development
services:
postgres:
image: postgres:18.4-alpine3.24
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required}
POSTGRES_INITDB_ARGS: --data-checksums
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required}
TZ: UTC
ports:
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
object-storage:
image: chrislusf/seaweedfs:4.28
restart: unless-stopped
command: ["mini", "-dir=/data"]
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY:?S3_ACCESS_KEY is required}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY:?S3_SECRET_KEY is required}
S3_BUCKET: ${S3_BUCKET:?S3_BUCKET is required}
TZ: UTC
ports:
- "127.0.0.1:${S3_PORT:-8333}:8333"
volumes:
- object-storage-data:/data
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9333/cluster/status"]
interval: 5s
timeout: 5s
retries: 18
start_period: 15s
volumes:
object-storage-data:
postgres-data:
+36
View File
@@ -0,0 +1,36 @@
{
"name": "ekklesia",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Open, modular church technology platform",
"engines": {
"node": ">=24.11.0 <25",
"pnpm": ">=11.12.0 <12"
},
"packageManager": "[email protected]",
"scripts": {
"boundaries": "node scripts/check-boundaries.ts",
"build": "turbo run build",
"ci": "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",
"infrastructure:down": "docker compose --env-file .env -f infrastructure/compose.yaml down",
"infrastructure:logs": "docker compose --env-file .env -f infrastructure/compose.yaml logs --follow",
"infrastructure:up": "docker compose --env-file .env -f infrastructure/compose.yaml up --detach --wait",
"lint": "biome lint .",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit --project tsconfig.json",
"verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm build"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"turbo": "2.10.4",
"typescript": "7.0.2",
"vitest": "4.1.10",
"yaml": "2.9.0"
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"strict": true,
"useUnknownInCatchVariables": true
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["DOM", "DOM.Iterable", "ES2024"],
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2024"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2024",
"types": ["node"]
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@ekklesia/typescript-config",
"version": "0.1.0",
"private": true,
"files": [
"base.json",
"browser.json",
"node.json",
"react-native.json"
],
"exports": {
"./base.json": "./base.json",
"./browser.json": "./browser.json",
"./node.json": "./node.json",
"./react-native.json": "./react-native.json"
},
"ekklesia": {
"layer": "tooling"
}
}
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2024"],
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022"
}
}
+1155
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
packages:
- "apps/*"
- "core"
- "modules/*"
- "packages/*"
- "surfaces/*"
- "templates/*"
onlyBuiltDependencies:
- esbuild
+498
View File
@@ -0,0 +1,498 @@
import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
type Layer = "app" | "builder" | "core" | "module" | "neutral" | "shared" | "surface";
interface Location {
layer: Layer;
owner: string;
relativePath: string;
role?: string;
}
interface PackageManifest {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
ekklesia?: { layer?: string };
exports?: unknown;
name?: string;
optionalDependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
}
interface WorkspacePackage {
directory: string;
location: Location;
manifest: PackageManifest;
name: string;
}
export interface BoundaryViolation {
from: string;
message: string;
rule: string;
to?: string;
}
export interface BoundaryResult {
filesChecked: number;
packagesChecked: number;
violations: BoundaryViolation[];
}
const CODE_EXTENSIONS = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]);
const MODULE_UI_EXTENSIONS = new Set([".css", ".jsx", ".less", ".scss", ".svelte", ".tsx", ".vue"]);
const MODULE_UI_PACKAGES = new Set([
"@angular/core",
"expo",
"next",
"react",
"react-dom",
"react-native",
"solid-js",
"svelte",
"tailwindcss",
"vue",
]);
const IGNORED_DIRECTORIES = new Set([
".git",
".next",
".turbo",
"coverage",
"dist",
"node_modules",
]);
const DEPENDENCY_FIELDS = [
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
] as const;
function normalizePath(value: string): string {
return value.split(path.sep).join("/").replace(/^\.\//, "");
}
export function describeRepositoryPath(relativePath: string): Location {
const normalized = normalizePath(relativePath);
const [area, owner = area] = normalized.split("/");
if (area === "core") {
return { layer: "core", owner: "core", relativePath: normalized };
}
if (area === "modules" && owner) {
return { layer: "module", owner, relativePath: normalized };
}
if (area === "surfaces" && owner) {
return { layer: "surface", owner, relativePath: normalized };
}
if ((area === "builders" || area === "surface-builder") && owner) {
return { layer: "builder", owner, relativePath: normalized };
}
if (area === "apps" && owner) {
return { layer: "app", owner, relativePath: normalized };
}
if (area === "packages" && owner) {
return { layer: "shared", owner, relativePath: normalized, role: owner };
}
if (area === "templates" && owner === "module") {
return { layer: "module", owner: "template-module", relativePath: normalized };
}
return { layer: "neutral", owner: area ?? "root", relativePath: normalized };
}
function boundaryRule(from: Location, to: Location): string | undefined {
if (from.layer === "neutral" || to.layer === "neutral") {
return undefined;
}
if (from.layer === to.layer && from.owner === to.owner) {
return undefined;
}
if (from.layer === "core" && ["app", "builder", "module", "surface"].includes(to.layer)) {
return "core-is-feature-agnostic";
}
if (from.layer === "module") {
if (["app", "builder", "core", "surface"].includes(to.layer)) {
return "modules-use-public-platform-contracts";
}
if (to.layer === "module" && from.owner !== to.owner) {
return "modules-do-not-import-other-modules";
}
}
if (from.layer === "surface") {
if (["app", "builder", "core", "module"].includes(to.layer)) {
return "surfaces-use-public-contracts";
}
if (to.layer === "surface" && from.owner !== to.owner) {
return "surfaces-are-independent";
}
}
if (from.layer === "builder" && ["app", "core", "module", "surface"].includes(to.layer)) {
return "builder-composes-contracts";
}
if (
from.layer === "shared" &&
from.role === "contracts" &&
["app", "builder", "core", "module", "surface"].includes(to.layer)
) {
return "contracts-do-not-depend-upward";
}
if (
from.layer === "shared" &&
["module-sdk", "surface-sdk"].includes(from.role ?? "") &&
["app", "builder", "core", "module", "surface"].includes(to.layer)
) {
return "sdks-do-not-depend-on-implementations";
}
return undefined;
}
function packageNameFromSpecifier(specifier: string): string {
if (specifier.startsWith("@")) {
return specifier.split("/").slice(0, 2).join("/");
}
return specifier.split("/")[0] ?? specifier;
}
function isModuleUiPackage(packageName: string): boolean {
return MODULE_UI_PACKAGES.has(packageName) || packageName.startsWith("@angular/");
}
async function isFile(filePath: string): Promise<boolean> {
try {
return (await stat(filePath)).isFile();
} catch {
return false;
}
}
async function walkFiles(root: string, current = root): Promise<string[]> {
const entries = await readdir(current, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) {
continue;
}
const absolutePath = path.join(current, entry.name);
const relativePath = normalizePath(path.relative(root, absolutePath));
if (entry.isDirectory()) {
if (relativePath === "tests/fixtures" || relativePath.startsWith("tests/fixtures/")) {
continue;
}
files.push(...(await walkFiles(root, absolutePath)));
continue;
}
if (entry.isFile()) {
files.push(absolutePath);
}
}
return files;
}
function collectImportSpecifiers(sourceText: string): string[] {
const specifiers = new Set<string>();
const staticImport =
/\b(?:import|export)\s+(?:type\s+)?(?:[^"'\n;]*?\s+from\s+)?["']([^"'\n]+)["']/gu;
const dynamicImport = /\b(?:import|require)\s*\(\s*["']([^"'\n]+)["']\s*\)/gu;
for (const pattern of [staticImport, dynamicImport]) {
for (const match of sourceText.matchAll(pattern)) {
const specifier = match[1];
if (specifier) {
specifiers.add(specifier);
}
}
}
return [...specifiers];
}
async function resolveSourceImport(
fromFile: string,
specifier: string,
): Promise<string | undefined> {
if (!specifier.startsWith(".")) {
return undefined;
}
const candidate = path.resolve(path.dirname(fromFile), specifier);
const extension = path.extname(candidate);
const withoutExtension = extension ? candidate.slice(0, -extension.length) : candidate;
const candidates = [candidate];
if ([".js", ".jsx", ".mjs", ".cjs"].includes(extension)) {
candidates.push(
`${withoutExtension}.ts`,
`${withoutExtension}.tsx`,
`${withoutExtension}.mts`,
`${withoutExtension}.cts`,
);
} else if (!extension) {
for (const codeExtension of CODE_EXTENSIONS) {
candidates.push(
`${candidate}${codeExtension}`,
path.join(candidate, `index${codeExtension}`),
);
}
}
for (const possibleFile of candidates) {
if (await isFile(possibleFile)) {
return possibleFile;
}
}
return candidate;
}
function findCycles(graph: Map<string, Set<string>>): string[][] {
const visited = new Set<string>();
const active = new Set<string>();
const stack: string[] = [];
const cycles: string[][] = [];
const seenCycles = new Set<string>();
function visit(node: string): void {
if (active.has(node)) {
const start = stack.indexOf(node);
const cycle = [...stack.slice(start), node];
const key = [...new Set(cycle)].sort().join("|");
if (!seenCycles.has(key)) {
seenCycles.add(key);
cycles.push(cycle);
}
return;
}
if (visited.has(node)) {
return;
}
visited.add(node);
active.add(node);
stack.push(node);
for (const target of graph.get(node) ?? []) {
if (graph.has(target)) {
visit(target);
}
}
stack.pop();
active.delete(node);
}
for (const node of graph.keys()) {
visit(node);
}
return cycles;
}
function exportedSubpath(
manifest: PackageManifest,
specifier: string,
packageName: string,
): boolean {
const suffix = specifier.slice(packageName.length);
if (!suffix) {
return true;
}
if (!manifest.exports || typeof manifest.exports !== "object") {
return false;
}
const requested = `.${suffix}`;
return Object.hasOwn(manifest.exports, requested);
}
export async function checkBoundaries(rootDirectory: string): Promise<BoundaryResult> {
const root = path.resolve(rootDirectory);
const allFiles = await walkFiles(root);
const manifestFiles = allFiles.filter((file) => path.basename(file) === "package.json");
const codeFiles = allFiles.filter((file) => CODE_EXTENSIONS.has(path.extname(file)));
const workspacePackages = new Map<string, WorkspacePackage>();
const packageByDirectory = new Map<string, WorkspacePackage>();
const violations: BoundaryViolation[] = [];
for (const manifestFile of manifestFiles) {
const manifest = JSON.parse(await readFile(manifestFile, "utf8")) as PackageManifest;
if (!manifest.name || manifestFile === path.join(root, "package.json")) {
continue;
}
const directory = path.dirname(manifestFile);
const relativeDirectory = normalizePath(path.relative(root, directory));
const location = describeRepositoryPath(relativeDirectory);
if (manifest.ekklesia?.layer) {
location.role = manifest.ekklesia.layer;
}
const workspacePackage = { directory, location, manifest, name: manifest.name };
workspacePackages.set(manifest.name, workspacePackage);
packageByDirectory.set(directory, workspacePackage);
}
const packageGraph = new Map<string, Set<string>>();
for (const workspacePackage of workspacePackages.values()) {
packageGraph.set(workspacePackage.name, new Set());
for (const dependencyField of DEPENDENCY_FIELDS) {
for (const dependencyName of Object.keys(workspacePackage.manifest[dependencyField] ?? {})) {
if (workspacePackage.location.layer === "module" && isModuleUiPackage(dependencyName)) {
violations.push({
from: workspacePackage.location.relativePath,
message: `Module package depends on UI framework ${dependencyName}.`,
rule: "modules-do-not-own-ui",
});
}
const targetPackage = workspacePackages.get(dependencyName);
if (!targetPackage) {
continue;
}
packageGraph.get(workspacePackage.name)?.add(targetPackage.name);
const rule = boundaryRule(workspacePackage.location, targetPackage.location);
if (rule) {
violations.push({
from: workspacePackage.location.relativePath,
message: `${workspacePackage.name} may not depend on ${targetPackage.name}.`,
rule,
to: targetPackage.location.relativePath,
});
}
}
}
}
for (const cycle of findCycles(packageGraph)) {
violations.push({
from: cycle[0] ?? "unknown-package",
message: `Workspace package cycle: ${cycle.join(" -> ")}`,
rule: "no-circular-package-dependencies",
});
}
for (const repositoryFile of allFiles) {
const relativeFile = normalizePath(path.relative(root, repositoryFile));
const location = describeRepositoryPath(relativeFile);
if (location.layer === "module" && MODULE_UI_EXTENSIONS.has(path.extname(repositoryFile))) {
violations.push({
from: relativeFile,
message: "UI source files belong in a surface, not a module.",
rule: "modules-do-not-own-ui",
});
}
}
const sourceGraph = new Map<string, Set<string>>();
for (const codeFile of codeFiles) {
const relativeFile = normalizePath(path.relative(root, codeFile));
const from = describeRepositoryPath(relativeFile);
sourceGraph.set(relativeFile, new Set());
const sourceText = await readFile(codeFile, "utf8");
for (const specifier of collectImportSpecifiers(sourceText)) {
const dependencyName = packageNameFromSpecifier(specifier);
if (from.layer === "module" && isModuleUiPackage(dependencyName)) {
violations.push({
from: relativeFile,
message: `Module source imports UI framework ${dependencyName}.`,
rule: "modules-do-not-own-ui",
to: specifier,
});
}
let targetFile: string | undefined;
let to: Location | undefined;
if (specifier.startsWith(".")) {
targetFile = await resolveSourceImport(codeFile, specifier);
if (targetFile) {
to = describeRepositoryPath(normalizePath(path.relative(root, targetFile)));
if (await isFile(targetFile)) {
sourceGraph.get(relativeFile)?.add(normalizePath(path.relative(root, targetFile)));
}
}
} else {
const targetPackage = workspacePackages.get(dependencyName);
if (targetPackage) {
to = targetPackage.location;
if (!exportedSubpath(targetPackage.manifest, specifier, dependencyName)) {
violations.push({
from: relativeFile,
message: `${specifier} is not a public package export.`,
rule: "workspace-imports-use-public-exports",
to: targetPackage.location.relativePath,
});
}
}
}
if (to) {
const rule = boundaryRule(from, to);
if (rule) {
violations.push({
from: relativeFile,
message: `${from.layer} code may not import ${to.layer} code directly.`,
rule,
to: to.relativePath,
});
}
}
}
}
for (const cycle of findCycles(sourceGraph)) {
violations.push({
from: cycle[0] ?? "unknown-source",
message: `Source dependency cycle: ${cycle.join(" -> ")}`,
rule: "no-circular-source-dependencies",
});
}
return {
filesChecked: codeFiles.length,
packagesChecked: packageByDirectory.size,
violations,
};
}
async function main(): Promise<void> {
const root = process.argv[2] ? path.resolve(process.argv[2]) : process.cwd();
const result = await checkBoundaries(root);
if (result.violations.length > 0) {
console.error(`Boundary validation failed with ${result.violations.length} violation(s):`);
for (const violation of result.violations) {
const target = violation.to ? ` -> ${violation.to}` : "";
console.error(`- [${violation.rule}] ${violation.from}${target}: ${violation.message}`);
}
process.exitCode = 1;
return;
}
console.log(
`Boundary validation passed (${result.packagesChecked} packages, ${result.filesChecked} source files).`,
);
}
const entryPoint = process.argv[1];
if (entryPoint && import.meta.url === pathToFileURL(path.resolve(entryPoint)).href) {
await main();
}
+32
View File
@@ -0,0 +1,32 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { checkBoundaries } from "../scripts/check-boundaries.js";
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
const fixtures = path.join(testDirectory, "fixtures", "boundaries");
describe("repository dependency boundaries", () => {
it("accepts public contracts below Core", async () => {
const result = await checkBoundaries(path.join(fixtures, "legal"));
expect(result.violations).toEqual([]);
});
it.each([
["core-imports-module", "core-is-feature-agnostic"],
["module-imports-surface", "modules-use-public-platform-contracts"],
["cross-module-import", "modules-do-not-import-other-modules"],
["module-has-ui", "modules-do-not-own-ui"],
["cycle", "no-circular-source-dependencies"],
])("rejects %s", async (fixture, expectedRule) => {
const result = await checkBoundaries(path.join(fixtures, fixture));
expect(result.violations.map((violation) => violation.rule)).toContain(expectedRule);
});
it("rejects both component and stylesheet files inside a module", async () => {
const result = await checkBoundaries(path.join(fixtures, "module-has-ui"));
expect(
result.violations.filter((violation) => violation.rule === "modules-do-not-own-ui"),
).toHaveLength(2);
});
});
@@ -0,0 +1,3 @@
import { calendar } from "../../modules/calendar/src/index.js";
export const invalidCoreFeature = calendar;
@@ -0,0 +1 @@
export const calendar = "calendar";
@@ -0,0 +1,3 @@
import { media } from "../../../modules/media/src/index.js";
export const invalidModuleDependency = media;
@@ -0,0 +1 @@
export const media = "media";
+3
View File
@@ -0,0 +1,3 @@
import { b } from "./b.js";
export const a = b;
+3
View File
@@ -0,0 +1,3 @@
import { a } from "./a.js";
export const b = a;
+3
View File
@@ -0,0 +1,3 @@
import type { HealthContract } from "@ekklesia/contracts";
export const health: HealthContract = { status: "ok" };
@@ -0,0 +1,11 @@
{
"name": "@ekklesia/contracts",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"ekklesia": {
"layer": "contracts"
}
}
@@ -0,0 +1,3 @@
export interface HealthContract {
readonly status: "ok";
}
@@ -0,0 +1,3 @@
.calendar {
display: grid;
}
@@ -0,0 +1,3 @@
export function Calendar(): string {
return "calendar";
}
@@ -0,0 +1,3 @@
import { CalendarView } from "../../../surfaces/dashboard/src/calendar-view.js";
export const invalidModuleUi = CalendarView;
@@ -0,0 +1 @@
export const CalendarView = "calendar-view";
+61
View File
@@ -0,0 +1,61 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
interface ComposeService {
healthcheck?: unknown;
image?: string;
ports?: string[];
volumes?: string[];
}
interface ComposeDocument {
services?: Record<string, ComposeService>;
volumes?: Record<string, unknown>;
}
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
describe("development infrastructure", () => {
it("has valid, local-only, version-pinned Compose services", async () => {
const source = await readFile(
path.join(repositoryRoot, "infrastructure", "compose.yaml"),
"utf8",
);
const compose = parse(source) as ComposeDocument;
const postgres = compose.services?.postgres;
const objectStorage = compose.services?.["object-storage"];
expect(postgres?.image).toBe("postgres:18.4-alpine3.24");
expect(objectStorage?.image).toBe("chrislusf/seaweedfs:4.28");
expect(postgres?.healthcheck).toBeDefined();
expect(objectStorage?.healthcheck).toBeDefined();
for (const service of [postgres, objectStorage]) {
expect(service).toBeDefined();
expect(service?.image).not.toMatch(/:latest$/u);
expect(service?.ports?.every((port) => port.startsWith("127.0.0.1:"))).toBe(true);
expect(service?.volumes?.length).toBeGreaterThan(0);
}
expect(compose.volumes).toHaveProperty("postgres-data");
expect(compose.volumes).toHaveProperty("object-storage-data");
});
it("has a parseable CI workflow with a single repository gate", async () => {
const source = await readFile(
path.join(repositoryRoot, ".github", "workflows", "ci.yml"),
"utf8",
);
const workflow = parse(source) as {
jobs?: { verify?: { steps?: Array<{ run?: string }> } };
};
const commands = workflow.jobs?.verify?.steps?.flatMap((step) => step.run ?? []) ?? [];
expect(workflow.jobs?.verify).toBeDefined();
expect(commands).toContain("pnpm install --frozen-lockfile");
expect(commands).toContain("pnpm ci");
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./packages/typescript-config/node.json",
"compilerOptions": {
"noEmit": true
},
"include": ["scripts/**/*.ts", "tests/**/*.ts", "vitest.config.ts"],
"exclude": ["node_modules", "tests/fixtures"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "stream",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"lint": {
"dependsOn": ["^lint"],
"outputs": []
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"typecheck": {
"dependsOn": ["^typecheck"],
"outputs": []
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
},
include: ["tests/**/*.test.ts"],
passWithNoTests: false,
},
});