Merge pull request 'feat: establish core identity foundation' (#7) from phase-4/core-identity-foundation into main
CI / verify (push) Successful in 23s
CI / infrastructure-smoke (push) Successful in 20s
CI / migration-smoke (push) Successful in 21s

Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/7
This commit was merged in pull request #7.
This commit is contained in:
The_River
2026-07-21 22:21:28 +00:00
43 changed files with 4259 additions and 64 deletions
+3
View File
@@ -13,3 +13,6 @@ generated/*
*.log
.DS_Store
core/.identity-schema-reference.ts
core/.identity-schema-check-*
+86
View File
@@ -0,0 +1,86 @@
# CLAUDE.md
Guidance for Claude Code (and other coding agents) working in this repository. This file is
intentionally short and durable — it points at authoritative sources instead of restating their
contents, because restated details drift.
## What this repository is
Ekklesia is a modular, self-hosted church platform built as a strictly layered pnpm/TypeScript
monorepo:
- **Core** (`core/`) owns platform infrastructure: identity, database connectivity, migration
execution, module discovery/lifecycle, and other feature-agnostic services. Core never imports a
feature module, a surface, or an app.
- **Modules** (`modules/*`, template at `templates/module`) are backend-only capability packages.
They own their own data and migrations, declare capabilities/permissions through
`@ekklesia/contracts` and `@ekklesia/module-sdk`, and must never contain UI or import Core,
another module, or a surface directly.
- **Surfaces** (future `surfaces/*`) own all UI and presentation. They consume public API/client
contracts only, never Core or module internals directly.
These boundaries are enforced by `pnpm boundaries` (`scripts/check-boundaries.ts`), not just
documented. Read `docs/architecture/repository-boundaries.md` and
`docs/architecture/platform-architecture.md` before proposing any change that crosses a layer.
## ADRs are authoritative
`docs/architecture/decisions/` records durable architectural decisions. **Accepted ADRs are
immutable except for status and cross-reference corrections** — do not edit their content to fit a
new change. A changed decision gets a new ADR that supersedes the old one. Read the full ADR index
(`docs/architecture/decisions/README.md`) and every ADR relevant to the area you are touching
before designing a change; do not assume this file's summary of any decision is complete or
current.
## Read before you design
At minimum, read the current versions of:
- `docs/architecture/decisions/README.md` and every ADR relevant to your change
- `docs/architecture/platform-architecture.md` and `docs/architecture/repository-boundaries.md`
- `README.md` (current platform status — what is and is not implemented)
- `docs/development/repository-workflow.md` and `docs/development/testing.md`
- `CONTRIBUTING.md`
These files change as the platform evolves. **Inspect the actual current repository state
(source, tests, package.json scripts, CI workflow) before relying on anything in this file or in
your own memory of a prior session** — treat stale assumptions as a bug to catch, not a shortcut.
## Working conventions
- Work on a clean branch off an up-to-date `main`; never commit or push directly to `main`; never
change branch-protection settings.
- Keep PRs focused on one bounded slice. Record major architectural changes in an ADR rather than
burying them in an implementation.
- Add or update tests when you change a contract or architectural rule. New behavior needs new
test coverage, not just a passing existing suite.
- Errors that reach a user, log, CLI, or `pnpm doctor` output must be sanitized: never expose
secrets, connection strings, raw SQL, raw driver/exception messages, stack traces, causes, or
machine-specific paths. Match the sanitization pattern already used by
`core/src/migrations/runner.ts` and `core/src/diagnostics/doctor.ts` rather than inventing a new
one.
- Never run a database migration automatically (on import, on startup, in a test that isn't
explicitly a migration test, or as a side effect of an unrelated command). Migrations are always
an explicit, operator-invoked action.
## Canonical verification commands
Run from the repository root; `package.json` is the source of truth if this list and the file
ever disagree:
- `pnpm verify` — the full code-only gate (format, lint, boundaries, typecheck, tests, module and
migration validation, identity schema drift check, build). Docker-free; never touches a live
database.
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL migration evidence. Requires
Docker. Run this for any change touching migration discovery, planning, or execution.
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
verify` or the integration suite.
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
against `DATABASE_URL`. Never run `migrations:apply` without the user's explicit intent.
## When you're unsure
If a task would require weakening an existing ADR's guarantee, introducing a second mechanism
where one already exists (a second migration runner, a second discovery path, a second config
system), or exposing something the boundary checker currently blocks, stop and surface the
conflict instead of working around it.
+38 -14
View File
@@ -6,19 +6,29 @@ surfaces.
## Current status
Phase 3 is underway. The repository now includes public module contracts, independently versioned
capabilities, static module manifests, deterministic automatic discovery, compiled server loading,
a staged capability registry, dependency-ordered lifecycle with rollback, module-owned Drizzle table
contracts, deterministic schema aggregation, and a cloneable backend-only module template. Migration
artifacts now have module-owned Drizzle contracts, deterministic offline discovery, explicit
destructive-change declarations, and exact checksums. Core now provides read-only applied-history
planning and a locked, whole-batch transactional PostgreSQL runner with exact external destructive
approval matching. A hermetic live PostgreSQL suite now proves history, rollback, statement
breakpoints, advisory-lock concurrency and release, exact destructive approvals, and sanitization
against the production adapter. A Core-owned `pnpm doctor` dashboard now combines the existing
static discovery and read-only migration-planning services into one concise readiness report.
Phase 4 identity with Better Auth is next; production approval-provider composition, HTTP routing,
jobs, physical storage provisioning, surfaces, and feature modules remain later vertical slices.
Phase 3 is complete and Phase 4A (Core identity foundation) has landed. The repository includes
public module contracts, independently versioned capabilities, static module manifests,
deterministic automatic discovery, compiled server loading, a staged capability registry,
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
schema aggregation, and a cloneable backend-only module template. Migration artifacts have
module-owned Drizzle contracts, deterministic offline discovery, explicit destructive-change
declarations, and exact checksums. Core provides read-only applied-history planning and a locked,
whole-batch transactional PostgreSQL runner with exact external destructive approval matching. A
hermetic live PostgreSQL suite proves history, rollback, statement breakpoints, advisory-lock
concurrency and release, exact destructive approvals, and sanitization against the production
adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only
migration-planning services into one concise readiness report.
Phase 4A adds Core-owned identity foundations: a Core-owned Better Auth database schema
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
`ekklesia_core_verification`), a first-class Core-owned migration source that coexists with
module-owned migrations under the exact same migration system, minimal opaque-identity contracts
in `@ekklesia/contracts`, and an explicit Better Auth construction boundary in Core. See
[Identity](docs/development/identity.md) and
[ADR 0010](docs/architecture/decisions/0010-core-identity-and-better-auth-adoption.md) for details.
**No authentication endpoint or sign-in UI exists yet** — that, along with production
approval-provider composition, HTTP routing, jobs, physical storage provisioning, surfaces, and
feature modules, remains later vertical slices.
## Quick start
@@ -30,6 +40,7 @@ cp .env.example .env
pnpm infrastructure:up
pnpm modules:validate
pnpm migrations:validate
pnpm identity:schema:validate
pnpm doctor
pnpm migrations:plan
pnpm test:migrations:integration
@@ -62,7 +73,8 @@ Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
0 migrations · checksums valid
1 migration · checksums valid
core:0000_identity_core_tables · f8f2086ef06f
PostgreSQL
○ Connection check skipped
@@ -70,6 +82,11 @@ PostgreSQL
○ Migration plan skipped
A validated migration inventory and configured PostgreSQL connection are required.
Identity
○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
Object storage
○ Check unavailable
Core storage integration has not been implemented yet.
@@ -90,6 +107,12 @@ The runner has both deterministic unit coverage through a narrow database port a
PostgreSQL integration suite. The live command creates and destroys disposable Docker
infrastructure; it remains separate from the Docker-free `pnpm verify` gate.
`pnpm identity:schema:validate` regenerates Better Auth's canonical schema with the pinned local
CLI into a temporary location and structurally compares it against the checked-in
`core/src/identity/schema.ts`, then removes the temporary output. It requires no `DATABASE_URL`
and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade
workflow.
Run `pnpm infrastructure:smoke` for an isolated end-to-end PostgreSQL and authenticated S3 check.
The development services bind only to loopback:
@@ -106,6 +129,7 @@ environment.
- [Repository boundaries](docs/architecture/repository-boundaries.md)
- [Repository workflow](docs/development/repository-workflow.md)
- [Testing](docs/development/testing.md)
- [Identity](docs/development/identity.md)
- [Module development](docs/module-development/README.md)
- [Architecture decisions](docs/architecture/decisions/README.md)
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./src/identity/schema.ts",
out: "./drizzle",
migrations: {
prefix: "index",
},
breakpoints: true,
});
@@ -0,0 +1,53 @@
CREATE TABLE "ekklesia_core_account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "ekklesia_core_session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "ekklesia_core_verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_account" ADD CONSTRAINT "ekklesia_core_account_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ekklesia_core_session" ADD CONSTRAINT "ekklesia_core_session_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ekklesia_core_account_user_id_idx" ON "ekklesia_core_account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "ekklesia_core_session_user_id_idx" ON "ekklesia_core_session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "ekklesia_core_verification_identifier_idx" ON "ekklesia_core_verification" USING btree ("identifier");
+362
View File
@@ -0,0 +1,362 @@
{
"id": "42cf39d2-7833-4bf3-9043-4e5303da8a75",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"version": 1,
"moduleId": "core",
"entries": [
{
"tag": "0000_identity_core_tables",
"destructive": {
"mode": "none"
}
}
]
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1784668552522,
"tag": "0000_identity_core_tables",
"breakpoints": true
}
]
}
+19
View File
@@ -0,0 +1,19 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
/**
* Drives the pinned Better Auth CLI's canonical schema generator for
* `pnpm identity:schema:generate` and `pnpm identity:schema:validate`.
*
* This file is a development-time aid only: it is never imported by application, factory, or
* runtime code, never opens a database connection, and intentionally omits our custom table
* names so the generator always produces Better Auth's official default (unrenamed) schema for
* comparison against core/src/identity/schema.ts. Do not add plugins or providers here.
*/
export const auth = betterAuth({
database: drizzleAdapter({} as never, {
provider: "pg",
}),
secret: "0".repeat(32),
baseURL: "https://identity-schema-generator.invalid",
});
+5
View File
@@ -26,12 +26,15 @@
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"identity:migration:generate": "drizzle-kit generate --config=drizzle.config.ts",
"identity:schema:generate": "auth generate --config=identity-schema-generator.config.ts --output=.identity-schema-reference.ts -y",
"lint": "biome lint .",
"test": "vitest run",
"typecheck": "tsc --noEmit --project tsconfig.json"
},
"dependencies": {
"@ekklesia/contracts": "workspace:*",
"better-auth": "1.6.23",
"drizzle-orm": "0.45.2",
"pg": "8.22.0"
},
@@ -39,6 +42,8 @@
"@biomejs/biome": "2.5.3",
"@types/node": "24.13.3",
"@types/pg": "8.20.0",
"auth": "1.6.23",
"drizzle-kit": "0.31.10",
"typescript": "7.0.2",
"vitest": "4.1.10"
},
+26 -1
View File
@@ -1,7 +1,12 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { diagnoseEkklesia, doctorExitCode, formatDoctorReport } from "../diagnostics/index.js";
import {
diagnoseEkklesia,
type DoctorOptions,
doctorExitCode,
formatDoctorReport,
} from "../diagnostics/index.js";
interface ModuleSdkPackageDocument {
readonly version?: unknown;
@@ -31,6 +36,25 @@ function requiredString(value: unknown): string {
return value;
}
function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] {
const secret = process.env.EKKLESIA_IDENTITY_SECRET;
const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL;
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
const trustedOrigins = trustedOriginsValue
? trustedOriginsValue
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0)
: undefined;
return {
...(baseUrl ? { baseUrl } : {}),
environment: process.env.NODE_ENV === "development" ? "development" : "production",
...(secret ? { secret } : {}),
...(trustedOrigins ? { trustedOrigins } : {}),
};
}
export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<number> {
const [rootValue, moduleSdkValue] = await Promise.all([
readJson(path.join(repositoryRoot, "package.json")),
@@ -41,6 +65,7 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
const report = await diagnoseEkklesia({
databaseUrl: process.env.DATABASE_URL,
identityConfiguration: readIdentityConfigurationFromEnvironment(),
moduleDiscovery: {
moduleSdkVersion: requiredString(moduleSdkPackage.version),
modulesDirectory: path.join(repositoryRoot, "modules"),
+9 -5
View File
@@ -1,6 +1,7 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { discoverModuleMigrations, type ModuleMigrationAggregate } from "../modules/index.js";
import { discoverAllMigrations } from "../migrations/index.js";
import type { ModuleMigrationAggregate } from "../modules/index.js";
interface PackageVersion {
readonly version?: unknown;
@@ -22,9 +23,12 @@ export async function discoverRepositoryMigrations(
readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")),
]);
return discoverModuleMigrations({
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion,
return discoverAllMigrations({
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
moduleDiscovery: {
moduleSdkVersion,
modulesDirectory: path.join(repositoryRoot, "modules"),
platformVersion,
},
});
}
+177
View File
@@ -0,0 +1,177 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { type AnyPgTable, getTableConfig } from "drizzle-orm/pg-core";
import { identityAdapterSchema } from "../identity/schema.js";
const execFileAsync = promisify(execFile);
interface ColumnShape {
readonly dataType: string;
readonly hasDefault: boolean;
readonly notNull: boolean;
readonly primary: boolean;
readonly unique: boolean;
}
interface TableShape {
readonly columns: Readonly<Record<string, ColumnShape>>;
readonly foreignKeyColumnSets: readonly string[];
readonly indexedColumnSets: readonly string[];
}
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function shapeOfTable(table: AnyPgTable): TableShape {
const config = getTableConfig(table);
const columns: Record<string, ColumnShape> = {};
for (const column of config.columns) {
columns[column.name] = {
dataType: column.dataType,
hasDefault: column.hasDefault,
notNull: column.notNull,
primary: column.primary,
unique: column.isUnique,
};
}
const foreignKeyColumnSets = config.foreignKeys
.map((foreignKey) =>
foreignKey
.reference()
.columns.map((column) => column.name)
.sort(compareText)
.join(","),
)
.sort(compareText);
const indexedColumnSets = config.indexes
.map((index) =>
index.config.columns
.map((column) => ("name" in column ? String(column.name) : "expression"))
.sort(compareText)
.join(","),
)
.sort(compareText);
return { columns: Object.freeze(columns), foreignKeyColumnSets, indexedColumnSets };
}
function diffTableShape(model: string, expected: TableShape, actual: TableShape): string[] {
const issues: string[] = [];
const expectedColumns = new Set(Object.keys(expected.columns));
const actualColumns = new Set(Object.keys(actual.columns));
for (const name of [...expectedColumns].sort(compareText)) {
if (!actualColumns.has(name)) {
issues.push(`${model}: column "${name}" is missing from the generated schema.`);
continue;
}
const expectedColumn = expected.columns[name];
const actualColumn = actual.columns[name];
if (JSON.stringify(expectedColumn) !== JSON.stringify(actualColumn)) {
issues.push(
`${model}: column "${name}" shape differs (checked-in ${JSON.stringify(expectedColumn)} vs generated ${JSON.stringify(actualColumn)}).`,
);
}
}
for (const name of [...actualColumns].sort(compareText)) {
if (!expectedColumns.has(name)) {
issues.push(`${model}: generated column "${name}" is missing from the checked-in schema.`);
}
}
if (
JSON.stringify(expected.foreignKeyColumnSets) !== JSON.stringify(actual.foreignKeyColumnSets)
) {
issues.push(`${model}: foreign key column sets differ from the generated schema.`);
}
if (JSON.stringify(expected.indexedColumnSets) !== JSON.stringify(actual.indexedColumnSets)) {
issues.push(`${model}: indexed column sets differ from the generated schema.`);
}
return issues;
}
async function resolveAuthCliEntry(): Promise<string> {
const corePackageDirectory = path.resolve(fileURLToPath(import.meta.url), "..", "..", "..");
const authPackageDirectory = path.join(corePackageDirectory, "node_modules", "auth");
const authPackageJson = JSON.parse(
await readFile(path.join(authPackageDirectory, "package.json"), "utf8"),
) as { readonly bin?: Readonly<Record<string, string>> };
const binEntry = authPackageJson.bin?.auth;
if (typeof binEntry !== "string" || binEntry.length === 0) {
throw new Error("The pinned Better Auth CLI package does not declare a usable binary.");
}
return path.join(authPackageDirectory, binEntry);
}
async function main(): Promise<void> {
const corePackageDirectory = path.resolve(fileURLToPath(import.meta.url), "..", "..", "..");
const authCliEntry = await resolveAuthCliEntry();
const generatorConfig = path.join(corePackageDirectory, "identity-schema-generator.config.ts");
// Generated inside the core package (not the OS temp directory) so the generated file's
// bare-specifier imports (e.g. "drizzle-orm") resolve against core/node_modules.
const tempDirectory = await mkdtemp(path.join(corePackageDirectory, ".identity-schema-check-"));
try {
const generatedSchemaPath = path.join(tempDirectory, "generated-schema.ts");
await execFileAsync(
process.execPath,
[
authCliEntry,
"generate",
`--config=${generatorConfig}`,
`--output=${generatedSchemaPath}`,
"-y",
],
{ cwd: corePackageDirectory },
);
const generated = (await import(generatedSchemaPath)) as Readonly<
Record<string, AnyPgTable | unknown>
>;
const issues: string[] = [];
for (const model of Object.keys(
identityAdapterSchema,
) as (keyof typeof identityAdapterSchema)[]) {
const generatedTable = generated[model];
if (!generatedTable || typeof generatedTable !== "object") {
issues.push(`${model}: the pinned generator did not produce this core model.`);
continue;
}
issues.push(
...diffTableShape(
model,
shapeOfTable(identityAdapterSchema[model]),
shapeOfTable(generatedTable as AnyPgTable),
),
);
}
if (issues.length > 0) {
console.error("Identity schema drift detected against the pinned Better Auth generator:");
for (const issue of issues) {
console.error(` - ${issue}`);
}
console.error(
"Review the difference, update core/src/identity/schema.ts deliberately, and add a new forward-only Core migration.",
);
process.exitCode = 1;
return;
}
console.log(
"Identity schema matches the pinned Better Auth generator for user, session, account, and verification.",
);
} finally {
await rm(tempDirectory, { recursive: true, force: true });
}
}
main().catch(() => {
console.error("Identity schema validation failed before a comparison could be completed.");
process.exitCode = 1;
});
+1
View File
@@ -14,6 +14,7 @@ const sections: readonly DoctorSection[] = Object.freeze([
section("Modules", ["modules.discovery", "modules.dependencies"]),
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]),
section("Identity", ["identity.configuration"]),
section("Object storage", ["object-storage.integration"]),
]);
+81 -2
View File
@@ -1,6 +1,15 @@
import path from "node:path";
import { isVersionCompatible, moduleIdSchema, semanticVersionSchema } from "@ekklesia/contracts";
import { getTableName } from "drizzle-orm";
import {
type CreateIdentityConfigOptions,
createIdentityConfig,
IdentityConfigError,
} from "../identity/config.js";
import { identityAdapterSchema } from "../identity/schema.js";
import {
createPostgresMigrationDatabase,
discoverAllMigrations,
type MigrationDatabase,
type MigrationPlanResult,
MigrationRunnerError,
@@ -10,7 +19,6 @@ import {
} from "../migrations/index.js";
import {
type DiscoveredModule,
discoverModuleMigrations,
discoverModuleSchemas,
discoverModules,
type ModuleDiscoveryOptions,
@@ -26,6 +34,7 @@ export type DoctorCheckId =
| "migrations.discovery"
| "postgresql.connection"
| "postgresql.plan"
| "identity.configuration"
| "object-storage.integration";
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
@@ -43,8 +52,16 @@ export interface DoctorReport {
readonly result: DoctorResult;
}
export interface DoctorIdentityConfigurationOptions {
readonly baseUrl?: string | undefined;
readonly environment?: CreateIdentityConfigOptions["environment"];
readonly secret?: string | undefined;
readonly trustedOrigins?: readonly string[] | undefined;
}
export interface DoctorOptions {
readonly databaseUrl?: string | undefined;
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
readonly moduleDiscovery: ModuleDiscoveryOptions;
readonly runtimeRange: string;
readonly runtimeVersion: string;
@@ -74,9 +91,18 @@ const checksumPattern = /^[a-f0-9]{64}$/u;
const migrationIdentityPattern =
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*:\d{4,}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/u;
async function discoverAllMigrationsForRuntime(
options: ModuleDiscoveryOptions,
): Promise<ModuleMigrationAggregate> {
return discoverAllMigrations({
coreMigrationsDirectory: path.join(path.dirname(options.modulesDirectory), "core", "drizzle"),
moduleDiscovery: options,
});
}
const productionDependencies: DoctorDependencies = Object.freeze({
createMigrationDatabase: createPostgresMigrationDatabase,
discoverMigrations: discoverModuleMigrations,
discoverMigrations: discoverAllMigrationsForRuntime,
discoverModules,
discoverSchemas: discoverModuleSchemas,
planMigrations: planModuleMigrations,
@@ -254,6 +280,56 @@ function migrationPlanCheck(plan: MigrationPlanResult): DoctorCheck {
);
}
function identitySchemaSummary(): string {
const tableNames = Object.values(identityAdapterSchema)
.map((table) => getTableName(table))
.sort();
return `Core identity schema defines ${tableNames.length} ${plural(tableNames.length, "table")} (${tableNames.join(", ")}).`;
}
/**
* Pure configuration validation only: never constructs Better Auth, never opens a database
* connection, and never prints the configured secret, base URL, or trusted origins. Missing
* configuration is a skip, not a failure — Doctor does not require identity to be configured yet.
*/
function identityConfigurationCheck(
input: DoctorIdentityConfigurationOptions | undefined,
): DoctorCheck {
const schemaDetail = identitySchemaSummary();
const hasAnyValue =
input?.secret !== undefined ||
input?.baseUrl !== undefined ||
(input?.trustedOrigins?.length ?? 0) > 0;
if (!hasAnyValue) {
return freezeCheck("identity.configuration", "skipped", "Identity configuration skipped", [
schemaDetail,
"Identity runtime configuration (signing key, base URL, trusted origins) is not set.",
]);
}
try {
createIdentityConfig(
{
baseUrl: input?.baseUrl,
secret: input?.secret,
trustedOrigins: input?.trustedOrigins,
},
{ environment: input?.environment ?? "production" },
);
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
schemaDetail,
]);
} catch (error) {
const code =
error instanceof IdentityConfigError ? error.code : "IDENTITY_CONFIGURATION_INVALID";
return freezeCheck("identity.configuration", "failure", "Identity configuration invalid", [
schemaDetail,
`[${code}] Configured identity values failed validation.`,
]);
}
}
function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] {
return [
freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]),
@@ -380,6 +456,8 @@ export async function diagnoseEkklesia(
}
}
const identityCheck = identityConfigurationCheck(options.identityConfiguration);
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
"Core storage integration has not been implemented yet.",
]);
@@ -392,6 +470,7 @@ export async function diagnoseEkklesia(
migrationCheck,
postgresConnectionCheck,
postgresPlanCheck,
identityCheck,
storageCheck,
]);
}
+184
View File
@@ -0,0 +1,184 @@
export type IdentityEnvironment = "development" | "production";
export type IdentityConfigErrorCode =
| "BASE_URL_INSECURE_IN_PRODUCTION"
| "BASE_URL_INVALID"
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
| "BASE_URL_MISSING"
| "SECRET_LOW_ENTROPY"
| "SECRET_MISSING"
| "SECRET_TOO_SHORT"
| "TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION"
| "TRUSTED_ORIGIN_INVALID"
| "TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION"
| "TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED"
| "TRUSTED_ORIGINS_EMPTY"
| "TRUSTED_ORIGINS_MISSING";
const MIN_SECRET_LENGTH = 32;
const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set([
"localhost",
"127.0.0.1",
"::1",
"0.0.0.0",
]);
const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.freeze({
BASE_URL_INSECURE_IN_PRODUCTION: "The identity base URL must use https in production.",
BASE_URL_INVALID: "The identity base URL must be an absolute http or https URL.",
BASE_URL_LOOPBACK_IN_PRODUCTION:
"The identity base URL must not resolve to a loopback host in production.",
BASE_URL_MISSING: "The identity base URL is required.",
SECRET_LOW_ENTROPY: "The identity secret does not have sufficient entropy.",
SECRET_MISSING: "The identity secret is required.",
SECRET_TOO_SHORT: `The identity secret must be at least ${MIN_SECRET_LENGTH} characters.`,
TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION: "Trusted origins must use https in production.",
TRUSTED_ORIGIN_INVALID:
"Every trusted origin must be an absolute http or https origin with no path, query, or fragment.",
TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION:
"Trusted origins must not resolve to a loopback host in production.",
TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED:
"Wildcard trusted origins are not supported in this release.",
TRUSTED_ORIGINS_EMPTY: "At least one trusted origin is required.",
TRUSTED_ORIGINS_MISSING: "Trusted origins are required.",
});
/**
* Never carries the offending secret, URL, origin, hostname, or any other caller-supplied value.
* Only a stable code and a fixed, generic message are ever exposed.
*/
export class IdentityConfigError extends Error {
readonly code: IdentityConfigErrorCode;
constructor(code: IdentityConfigErrorCode) {
super(errorMessages[code]);
this.name = "IdentityConfigError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface IdentityConfig {
readonly baseUrl: string;
readonly secret: string;
readonly trustedOrigins: readonly string[];
}
export interface IdentityConfigInput {
readonly baseUrl?: unknown;
readonly secret?: unknown;
readonly trustedOrigins?: unknown;
}
export interface CreateIdentityConfigOptions {
/** Defaults to "production" so an omitted environment never silently relaxes validation. */
readonly environment?: IdentityEnvironment;
}
function isLoopbackHostname(hostname: string): boolean {
return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase());
}
function parseAbsoluteHttpUrl(value: unknown): URL | undefined {
if (typeof value !== "string" || value.length === 0) {
return undefined;
}
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url : undefined;
} catch {
return undefined;
}
}
function validateSecret(secret: unknown): string {
if (typeof secret !== "string" || secret.length === 0) {
throw new IdentityConfigError("SECRET_MISSING");
}
if (secret.length < MIN_SECRET_LENGTH) {
throw new IdentityConfigError("SECRET_TOO_SHORT");
}
// A narrow, deterministic guard against trivially degenerate secrets (e.g. one repeated
// character). It cannot measure true entropy; operators remain responsible for generating a
// high-entropy value (for example `openssl rand -base64 32`).
if (/^(.)\1*$/u.test(secret)) {
throw new IdentityConfigError("SECRET_LOW_ENTROPY");
}
return secret;
}
function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): string {
if (typeof baseUrl !== "string" || baseUrl.length === 0) {
throw new IdentityConfigError("BASE_URL_MISSING");
}
const url = parseAbsoluteHttpUrl(baseUrl);
if (!url) {
throw new IdentityConfigError("BASE_URL_INVALID");
}
if (environment === "production") {
if (url.protocol !== "https:") {
throw new IdentityConfigError("BASE_URL_INSECURE_IN_PRODUCTION");
}
if (isLoopbackHostname(url.hostname)) {
throw new IdentityConfigError("BASE_URL_LOOPBACK_IN_PRODUCTION");
}
}
return baseUrl;
}
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
if (typeof origin !== "string" || origin.length === 0) {
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
}
// Better Auth itself supports glob-style wildcard trusted origins. Phase 4A deliberately does
// not: cross-subdomain cookies are not enabled yet (see ADR 0010), so a wildcard origin would
// widen the trusted-origin surface without a matching cookie-scope decision. Revisit only
// alongside a future ADR that enables cross-subdomain cookies.
if (origin.includes("*") || origin.includes("?")) {
throw new IdentityConfigError("TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED");
}
const url = parseAbsoluteHttpUrl(origin);
if (!url || url.origin !== origin) {
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
}
if (environment === "production") {
if (url.protocol !== "https:") {
throw new IdentityConfigError("TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION");
}
if (isLoopbackHostname(url.hostname)) {
throw new IdentityConfigError("TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION");
}
}
return origin;
}
function validateTrustedOrigins(
trustedOrigins: unknown,
environment: IdentityEnvironment,
): readonly string[] {
if (trustedOrigins === undefined || trustedOrigins === null) {
throw new IdentityConfigError("TRUSTED_ORIGINS_MISSING");
}
if (!Array.isArray(trustedOrigins) || trustedOrigins.length === 0) {
throw new IdentityConfigError("TRUSTED_ORIGINS_EMPTY");
}
return Object.freeze(trustedOrigins.map((origin) => validateTrustedOrigin(origin, environment)));
}
/**
* Validates explicit, typed identity configuration. Performs no environment reads, network
* calls, or database access; callers are responsible for sourcing `input` (for example from
* environment variables) before calling this function.
*/
export function createIdentityConfig(
input: IdentityConfigInput,
options: CreateIdentityConfigOptions = {},
): IdentityConfig {
const environment = options.environment ?? "production";
const secret = validateSecret(input.secret);
const baseUrl = validateBaseUrl(input.baseUrl, environment);
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
return Object.freeze({ baseUrl, secret, trustedOrigins });
}
+69
View File
@@ -0,0 +1,69 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import { Pool, type PoolConfig } from "pg";
import { identityAdapterSchema } from "./schema.js";
export type IdentityDrizzleDatabase = NodePgDatabase<typeof identityAdapterSchema>;
export type IdentityDatabaseErrorCode = "IDENTITY_DATABASE_CONFIGURATION_INVALID";
/** Never carries the offending connection string, host, or underlying driver error. */
export class IdentityDatabaseError extends Error {
readonly code: IdentityDatabaseErrorCode;
constructor(code: IdentityDatabaseErrorCode) {
super("Identity database configuration is invalid.");
this.name = "IdentityDatabaseError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface IdentityDatabaseOptions {
readonly connectionTimeoutMs?: number;
}
export interface IdentityDatabaseHandle {
readonly close: () => Promise<void>;
readonly db: IdentityDrizzleDatabase;
}
function validateConnectionString(connectionString: string): string {
if (connectionString.trim().length === 0) {
throw new IdentityDatabaseError("IDENTITY_DATABASE_CONFIGURATION_INVALID");
}
let protocol: string;
try {
protocol = new URL(connectionString).protocol;
} catch {
throw new IdentityDatabaseError("IDENTITY_DATABASE_CONFIGURATION_INVALID");
}
if (protocol !== "postgres:" && protocol !== "postgresql:") {
throw new IdentityDatabaseError("IDENTITY_DATABASE_CONFIGURATION_INVALID");
}
return connectionString;
}
/**
* Explicitly constructs the Core-owned Drizzle database instance used by the identity factory.
* Building the `pg.Pool` performs no network I/O until a query is issued, and this function is
* only ever invoked by explicit caller code — never during module import.
*/
export function createIdentityDrizzleDatabase(
connectionString: string,
options: IdentityDatabaseOptions = {},
): IdentityDatabaseHandle {
const validatedConnectionString = validateConnectionString(connectionString);
const poolConfig: PoolConfig = { connectionString: validatedConnectionString };
if (options.connectionTimeoutMs !== undefined) {
poolConfig.connectionTimeoutMillis = options.connectionTimeoutMs;
}
const pool = new Pool(poolConfig);
const db = drizzle(pool, { schema: identityAdapterSchema });
return Object.freeze({
close: () => pool.end(),
db,
});
}
+58
View File
@@ -0,0 +1,58 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import type { IdentityConfig } from "./config.js";
import type { IdentityDrizzleDatabase } from "./database.js";
import { identityAdapterSchema } from "./schema.js";
export type IdentityConstructionErrorCode = "IDENTITY_CONSTRUCTION_FAILED";
/** Never carries the underlying Better Auth error, cause, stack, secret, or URL. */
export class IdentityConstructionError extends Error {
readonly code: IdentityConstructionErrorCode;
constructor() {
super("Identity could not be constructed.");
this.name = "IdentityConstructionError";
this.code = "IDENTITY_CONSTRUCTION_FAILED";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface CreateIdentityAuthOptions {
readonly config: IdentityConfig;
readonly db: IdentityDrizzleDatabase;
}
/**
* Deliberately opaque: Phase 4A never exposes the Better Auth instance, its HTTP handler, the
* injected database, or the Drizzle adapter beyond this module. A later HTTP transport slice
* decides what (if anything) becomes reachable outside core/src/identity.
*/
export interface EkklesiaIdentityInstance {
readonly constructed: true;
}
/**
* Core's Better Auth composition boundary. Constructs Better Auth explicitly from injected,
* already-validated configuration and an injected Drizzle database — it never reads environment
* variables, never resolves a connection string, and enables no provider or plugin beyond the
* four core models required for the identity schema itself.
*/
export function createIdentityAuth(options: CreateIdentityAuthOptions): EkklesiaIdentityInstance {
try {
betterAuth({
baseURL: options.config.baseUrl,
database: drizzleAdapter(options.db, {
provider: "pg",
schema: identityAdapterSchema,
}),
secret: options.config.secret,
trustedOrigins: [...options.config.trustedOrigins],
});
} catch {
throw new IdentityConstructionError();
}
return Object.freeze({ constructed: true });
}
+111
View File
@@ -0,0 +1,111 @@
import { relations } from "drizzle-orm";
import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
/**
* Core-owned Better Auth schema for the four required core models: user, session, account,
* and verification. Table and index names are explicit and namespaced so ownership is
* unambiguous inside the shared public PostgreSQL schema. Columns, types, defaults, uniqueness,
* foreign keys, and delete behavior mirror the pinned [email protected] Drizzle/PostgreSQL
* generator output exactly (see docs/development/identity.md for the regeneration workflow).
*/
export const ekklesiaCoreUser = pgTable("ekklesia_core_user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
export const ekklesiaCoreSession = pgTable(
"ekklesia_core_session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
},
(table) => [index("ekklesia_core_session_user_id_idx").on(table.userId)],
);
export const ekklesiaCoreAccount = pgTable(
"ekklesia_core_account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("ekklesia_core_account_user_id_idx").on(table.userId)],
);
export const ekklesiaCoreVerification = pgTable(
"ekklesia_core_verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("ekklesia_core_verification_identifier_idx").on(table.identifier)],
);
export const ekklesiaCoreUserRelations = relations(ekklesiaCoreUser, ({ many }) => ({
accounts: many(ekklesiaCoreAccount),
sessions: many(ekklesiaCoreSession),
}));
export const ekklesiaCoreSessionRelations = relations(ekklesiaCoreSession, ({ one }) => ({
user: one(ekklesiaCoreUser, {
fields: [ekklesiaCoreSession.userId],
references: [ekklesiaCoreUser.id],
}),
}));
export const ekklesiaCoreAccountRelations = relations(ekklesiaCoreAccount, ({ one }) => ({
user: one(ekklesiaCoreUser, {
fields: [ekklesiaCoreAccount.userId],
references: [ekklesiaCoreUser.id],
}),
}));
/**
* Explicit mapping from Better Auth's logical core model names to Ekklesia's physical schema
* objects. Passed directly to the Drizzle adapter so table naming is never inferred by accident.
*/
export const identityAdapterSchema = {
account: ekklesiaCoreAccount,
session: ekklesiaCoreSession,
user: ekklesiaCoreUser,
verification: ekklesiaCoreVerification,
} as const;
+47
View File
@@ -0,0 +1,47 @@
import {
discoverModuleMigrations,
type ModuleDiscoveryOptions,
type ModuleMigrationAggregate,
ModuleMigrationDiscoveryError,
} from "../modules/index.js";
import { CORE_MIGRATION_SOURCE_ID, discoverCoreMigrations } from "./core-source.js";
export interface DiscoverAllMigrationsOptions {
readonly coreMigrationsDirectory: string;
readonly moduleDiscovery: ModuleDiscoveryOptions;
}
/**
* Discovers Core's own migrations and every installed module's migrations, then merges them into
* one frozen `ModuleMigrationAggregate` with Core's migrations ordered first. Core has no
* dependency relationship with feature modules, so it is not part of the module dependency graph;
* it simply precedes every module in the aggregate the planner and runner already consume
* unmodified. There remains exactly one migration aggregate and one executor.
*/
export async function discoverAllMigrations(
options: DiscoverAllMigrationsOptions,
): Promise<ModuleMigrationAggregate> {
const [coreMigrations, moduleAggregate] = await Promise.all([
discoverCoreMigrations({ coreMigrationsDirectory: options.coreMigrationsDirectory }),
discoverModuleMigrations(options.moduleDiscovery),
]);
if (moduleAggregate.modules.some((module) => module.moduleId === CORE_MIGRATION_SOURCE_ID)) {
throw new ModuleMigrationDiscoveryError([
{
code: "MODULE_MIGRATION_ID_DUPLICATE",
message: `The identifier "${CORE_MIGRATION_SOURCE_ID}" is reserved for Core-owned migrations and cannot be used by an installed module.`,
moduleId: CORE_MIGRATION_SOURCE_ID,
},
]);
}
return Object.freeze({
discoveredModules: moduleAggregate.discoveredModules,
migrations: Object.freeze([...coreMigrations, ...moduleAggregate.migrations]),
modules: Object.freeze([
Object.freeze({ migrations: coreMigrations, moduleId: CORE_MIGRATION_SOURCE_ID }),
...moduleAggregate.modules,
]),
});
}
+42
View File
@@ -0,0 +1,42 @@
import type { ModuleId } from "@ekklesia/contracts";
import {
type DiscoveredModuleMigration,
inspectMigrationsAtPath,
ModuleMigrationDiscoveryError,
type ModuleMigrationDiscoveryIssue,
} from "../modules/migration-discovery.js";
/**
* The reserved migration-source identifier for Core-owned platform schema (starting with
* identity). It is not a module: Core is never discovered through `discoverModules`, never has
* an `ekklesia.module.json` manifest, and never runs a server entrypoint. It reuses the exact
* same journal/checksum/breakpoint/destructive-policy validation as module migrations so there
* remains exactly one migration format and one executor.
*/
export const CORE_MIGRATION_SOURCE_ID: ModuleId = "core";
export interface CoreMigrationDiscoveryOptions {
readonly coreMigrationsDirectory: string;
}
/**
* Discovers Core's own migration inventory at `coreMigrationsDirectory` (the package-local
* `core/drizzle` directory). Performs no PostgreSQL connection or module discovery; it only
* validates packaged Drizzle migration artifacts, exactly like module migration discovery.
*/
export async function discoverCoreMigrations(
options: CoreMigrationDiscoveryOptions,
): Promise<readonly DiscoveredModuleMigration[]> {
const issues: ModuleMigrationDiscoveryIssue[] = [];
const migrations = await inspectMigrationsAtPath(
CORE_MIGRATION_SOURCE_ID,
options.coreMigrationsDirectory,
issues,
);
if (issues.length > 0) {
throw new ModuleMigrationDiscoveryError(issues);
}
return migrations;
}
+2
View File
@@ -1,2 +1,4 @@
export * from "./aggregate.js";
export * from "./core-source.js";
export * from "./postgres-database.js";
export * from "./runner.js";
+20 -8
View File
@@ -577,16 +577,17 @@ function validateInventory(
});
}
async function inspectModuleMigrations(
discovered: DiscoveredModule,
/**
* Validates and reads the Drizzle migration inventory at `migrationRoot`, attributing every
* migration and issue to `moduleId`. Shared by module-owned discovery (moduleId taken from the
* module manifest) and Core's own migration source (moduleId fixed to "core"), so both reuse the
* exact same journal, checksum, breakpoint, and destructive-policy validation.
*/
export async function inspectMigrationsAtPath(
moduleId: ModuleId,
migrationRoot: string,
issues: ModuleMigrationDiscoveryIssue[],
): Promise<readonly DiscoveredModuleMigration[]> {
const moduleId = discovered.manifest.id;
const migrationRoot = discovered.entrypoints.migrations;
if (!migrationRoot) {
return Object.freeze([]);
}
const issueCountBeforeModule = issues.length;
const inventory = await readInventory(migrationRoot, moduleId, issues);
const [journalBytes, policyBytes] = await Promise.all([
@@ -734,6 +735,17 @@ async function inspectModuleMigrations(
return Object.freeze(migrations);
}
async function inspectModuleMigrations(
discovered: DiscoveredModule,
issues: ModuleMigrationDiscoveryIssue[],
): Promise<readonly DiscoveredModuleMigration[]> {
const migrationRoot = discovered.entrypoints.migrations;
if (!migrationRoot) {
return Object.freeze([]);
}
return inspectMigrationsAtPath(discovered.manifest.id, migrationRoot, issues);
}
export async function discoverModuleMigrations(
options: ModuleDiscoveryOptions,
): Promise<ModuleMigrationAggregate> {
+243
View File
@@ -0,0 +1,243 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
CORE_MIGRATION_SOURCE_ID,
discoverAllMigrations,
discoverCoreMigrations,
} from "../src/migrations/index.js";
import { ModuleMigrationDiscoveryError } from "../src/modules/index.js";
const originSnapshotId = "00000000-0000-0000-0000-000000000000";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { force: true, recursive: true })),
);
});
async function createTemporaryDirectory(prefix: string): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), prefix));
temporaryDirectories.push(directory);
return directory;
}
/** Writes a single-migration Drizzle directory (journal + snapshot + policy + SQL). */
async function writeMigrationsDirectory(
migrationsDirectory: string,
moduleId: string,
tag: string,
sql: string,
): Promise<string> {
await mkdir(path.join(migrationsDirectory, "meta"), { recursive: true });
await Promise.all([
writeFile(
path.join(migrationsDirectory, "meta/_journal.json"),
JSON.stringify({
version: "7",
dialect: "postgresql",
entries: [{ idx: 0, version: "7", when: 1_700_000_000_000, tag, breakpoints: true }],
}),
),
writeFile(
path.join(migrationsDirectory, "meta/_ekklesia.json"),
JSON.stringify({
version: 1,
moduleId,
entries: [{ tag, destructive: { mode: "none" } }],
}),
),
writeFile(
path.join(migrationsDirectory, "meta/0000_snapshot.json"),
JSON.stringify({
version: "7",
dialect: "postgresql",
id: "10000000-0000-4000-8000-000000000001",
prevId: originSnapshotId,
}),
),
writeFile(path.join(migrationsDirectory, `${tag}.sql`), sql),
]);
return sql;
}
async function createModule(
modulesDirectory: string,
id: string,
options: { readonly dependsOn?: readonly string[] } = {},
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const migrationsDirectory = path.join(moduleDirectory, "drizzle");
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
const requiredDependencies = Object.fromEntries(
(options.dependsOn ?? []).map((dependencyId) => [dependencyId, "^0.1.0"]),
);
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
name: `@test/module-${id}`,
version: "0.1.0",
type: "module",
ekklesia: { layer: "module" },
}),
),
writeFile(
path.join(moduleDirectory, "ekklesia.module.json"),
JSON.stringify({
manifestVersion: 2,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
dependencies: {
modules: { required: requiredDependencies, optional: {} },
capabilities: { required: {}, optional: {} },
},
capabilities: [],
permissions: [],
entrypoints: { server: "./dist/server/index.js", migrations: "./drizzle" },
}),
),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
`export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
),
]);
await writeMigrationsDirectory(
migrationsDirectory,
id,
"0000_initial",
`CREATE TABLE "${id.replaceAll("-", "_")}_records" ("id" integer);\n`,
);
}
function sha256(contents: string): string {
return createHash("sha256").update(contents).digest("hex");
}
describe("Core-owned migration discovery", () => {
it("discovers Core's own migrations with the reserved core identity and an exact checksum", async () => {
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
const sql = await writeMigrationsDirectory(
coreMigrationsDirectory,
"core",
"0000_identity_core_tables",
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
);
const migrations = await discoverCoreMigrations({ coreMigrationsDirectory });
expect(CORE_MIGRATION_SOURCE_ID).toBe("core");
expect(migrations).toHaveLength(1);
expect(migrations[0]).toMatchObject({
approvalRequired: false,
checksum: sha256(sql),
identity: "core:0000_identity_core_tables",
index: 0,
moduleId: "core",
tag: "0000_identity_core_tables",
});
});
it("is deterministic across repeated discovery calls", async () => {
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
await writeMigrationsDirectory(
coreMigrationsDirectory,
"core",
"0000_identity_core_tables",
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
);
const first = await discoverCoreMigrations({ coreMigrationsDirectory });
const second = await discoverCoreMigrations({ coreMigrationsDirectory });
expect(first).toEqual(second);
});
it("orders Core migrations before every module migration while preserving dependency-first module order", async () => {
const modulesDirectory = await createTemporaryDirectory("ekklesia-modules-");
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
await writeMigrationsDirectory(
coreMigrationsDirectory,
"core",
"0000_identity_core_tables",
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
);
// "calendar" depends on "people"; created on disk in the opposite order so only the
// dependency graph (not directory order) can explain the resulting sequence.
await createModule(modulesDirectory, "calendar", { dependsOn: ["people"] });
await createModule(modulesDirectory, "people");
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0" },
});
expect(aggregate.modules.map((module) => module.moduleId)).toEqual([
"core",
"people",
"calendar",
]);
expect(aggregate.migrations.map((migration) => migration.identity)).toEqual([
"core:0000_identity_core_tables",
"people:0000_initial",
"calendar:0000_initial",
]);
});
it("rejects an installed module that reuses the reserved core migration identity", async () => {
const modulesDirectory = await createTemporaryDirectory("ekklesia-modules-");
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
await writeMigrationsDirectory(
coreMigrationsDirectory,
"core",
"0000_identity_core_tables",
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
);
await createModule(modulesDirectory, "core");
let captured: unknown;
try {
await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0" },
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(ModuleMigrationDiscoveryError);
expect((captured as ModuleMigrationDiscoveryError).issues).toEqual([
expect.objectContaining({ code: "MODULE_MIGRATION_ID_DUPLICATE", moduleId: "core" }),
]);
});
it("keeps a fresh, module-free repository honestly reporting only Core's migration", async () => {
const modulesDirectory = await createTemporaryDirectory("ekklesia-modules-");
const coreMigrationsDirectory = await createTemporaryDirectory("ekklesia-core-migrations-");
await writeMigrationsDirectory(
coreMigrationsDirectory,
"core",
"0000_identity_core_tables",
'CREATE TABLE "ekklesia_core_user" ("id" text PRIMARY KEY);\n',
);
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0" },
});
expect(aggregate.discoveredModules).toEqual([]);
expect(aggregate.modules).toHaveLength(1);
expect(aggregate.modules[0]?.moduleId).toBe("core");
expect(aggregate.migrations).toHaveLength(1);
});
});
+5
View File
@@ -88,6 +88,11 @@ PostgreSQL
○ Migration plan skipped
A validated migration inventory and configured PostgreSQL connection are required.
Identity
○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
Object storage
○ Check unavailable
Core storage integration has not been implemented yet.
+70
View File
@@ -200,6 +200,7 @@ describe("Ekklesia Doctor diagnostics", () => {
"migrations.discovery",
"postgresql.connection",
"postgresql.plan",
"identity.configuration",
"object-storage.integration",
]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
@@ -438,4 +439,73 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(services.discoverSchemas).toHaveBeenCalledOnce();
expect(services.discoverMigrations).toHaveBeenCalledOnce();
});
it("skips identity configuration (not a failure) when none of it is set", async () => {
const report = await diagnoseEkklesia(options(), dependencies());
const identity = check(report, "identity.configuration");
expect(identity.status).toBe("skipped");
expect(identity.details[0]).toContain("Core identity schema defines 4 tables");
expect(report.result).toBe("ready-with-warnings");
expect(doctorExitCode(report)).toBe(0);
});
it("passes identity configuration when a valid secret, base URL, and trusted origins are provided", async () => {
const report = await diagnoseEkklesia(
{
...options(),
identityConfiguration: {
baseUrl: "https://identity.example.test",
environment: "production",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
},
dependencies(),
);
const identity = check(report, "identity.configuration");
expect(identity.status).toBe("pass");
expect(report.result).toBe("ready-with-warnings"); // PostgreSQL is still skipped in this fixture
});
it("fails identity configuration for an invalid secret without leaking it", async () => {
const hostileSecret = "s3cr3t-do-not-log";
const report = await diagnoseEkklesia(
{
...options(),
identityConfiguration: {
baseUrl: "https://identity.example.test",
environment: "production",
secret: hostileSecret,
trustedOrigins: ["https://identity.example.test"],
},
},
dependencies(),
);
const identity = check(report, "identity.configuration");
expect(identity.status).toBe("failure");
expect(identity.details.some((detail) => detail.includes("SECRET_TOO_SHORT"))).toBe(true);
expect(report.result).toBe("not-ready");
expect(doctorExitCode(report)).toBe(1);
expect(JSON.stringify(report)).not.toContain(hostileSecret);
});
it("never constructs a migration database or Better Auth merely to check identity configuration", async () => {
const services = dependencies();
await diagnoseEkklesia(
{
...options(),
identityConfiguration: {
baseUrl: "https://identity.example.test",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
},
services,
);
expect(services.createMigrationDatabase).not.toHaveBeenCalled();
});
});
+189
View File
@@ -0,0 +1,189 @@
import { describe, expect, it } from "vitest";
import { createIdentityConfig, IdentityConfigError } from "../src/identity/config.js";
const validSecret = "correct-horse-battery-staple-0123456789";
const validBaseUrl = "https://identity.example.test";
const validTrustedOrigins = ["https://identity.example.test", "https://dashboard.example.test"];
function validInput() {
return { baseUrl: validBaseUrl, secret: validSecret, trustedOrigins: validTrustedOrigins };
}
describe("Core identity configuration", () => {
it("accepts a valid secret, base URL, and trusted origins", () => {
const config = createIdentityConfig(validInput(), { environment: "production" });
expect(config).toEqual({
baseUrl: validBaseUrl,
secret: validSecret,
trustedOrigins: validTrustedOrigins,
});
expect(Object.isFrozen(config)).toBe(true);
expect(Object.isFrozen(config.trustedOrigins)).toBe(true);
});
it("rejects a missing secret", () => {
expect(() =>
createIdentityConfig({ ...validInput(), secret: undefined }, { environment: "production" }),
).toThrow(IdentityConfigError);
try {
createIdentityConfig({ ...validInput(), secret: undefined });
} catch (error) {
expect(error).toMatchObject({ code: "SECRET_MISSING" });
}
});
it("rejects a secret shorter than 32 characters", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), secret: "x".repeat(31) });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "SECRET_TOO_SHORT" });
});
it("accepts a secret of exactly 32 characters", () => {
const secret = "aB1cD2eF3gH4iJ5kL6mN7oP8qR9sT0uV";
expect(secret).toHaveLength(32);
const config = createIdentityConfig({ ...validInput(), secret });
expect(config.secret).toBe(secret);
});
it("rejects a degenerate single-character-repeated secret", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), secret: "a".repeat(40) });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "SECRET_LOW_ENTROPY" });
});
it("rejects an invalid base URL", () => {
for (const baseUrl of [undefined, "", "not-a-url", "ftp://example.test", "example.test"]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), baseUrl });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConfigError);
}
});
it("rejects missing trusted origins", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), trustedOrigins: undefined });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "TRUSTED_ORIGINS_MISSING" });
});
it("rejects an empty trusted origins array", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), trustedOrigins: [] });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "TRUSTED_ORIGINS_EMPTY" });
});
it("rejects a trusted origin containing a path, query, or fragment", () => {
for (const origin of [
"https://example.test/",
"https://example.test/path",
"https://example.test#fragment",
]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), trustedOrigins: [origin] });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "TRUSTED_ORIGIN_INVALID" });
}
});
it("rejects wildcard trusted origins", () => {
for (const origin of ["https://*.example.test", "https://app?.example.test"]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), trustedOrigins: [origin] });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED" });
}
});
it("rejects insecure http origins and loopback hosts in production", () => {
let httpError: unknown;
try {
createIdentityConfig(
{ ...validInput(), trustedOrigins: ["http://identity.example.test"] },
{ environment: "production" },
);
} catch (error) {
httpError = error;
}
expect(httpError).toMatchObject({ code: "TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION" });
let loopbackError: unknown;
try {
createIdentityConfig(
{ ...validInput(), baseUrl: "https://localhost", trustedOrigins: ["https://localhost"] },
{ environment: "production" },
);
} catch (error) {
loopbackError = error;
}
expect(loopbackError).toMatchObject({ code: "BASE_URL_LOOPBACK_IN_PRODUCTION" });
});
it("defaults to production validation when environment is not specified", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), baseUrl: "http://identity.example.test" });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "BASE_URL_INSECURE_IN_PRODUCTION" });
});
it("explicitly allows local development origins only when environment is development", () => {
const config = createIdentityConfig(
{
baseUrl: "http://localhost:3000",
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
},
{ environment: "development" },
);
expect(config.baseUrl).toBe("http://localhost:3000");
});
it("never leaks the secret, base URL, or trusted origins through a thrown error", () => {
const hostileSecret = "s3cr3t-do-not-log-0123456789abcdef";
let captured: unknown;
try {
createIdentityConfig({ baseUrl: "not-a-url", secret: hostileSecret, trustedOrigins: [] });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConfigError);
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}\n${JSON.stringify(captured)}`;
expect(publicText).not.toContain(hostileSecret);
expect(publicText).not.toContain(validBaseUrl);
for (const origin of validTrustedOrigins) {
expect(publicText).not.toContain(origin);
}
});
});
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
import { createIdentityDrizzleDatabase, IdentityDatabaseError } from "../src/identity/database.js";
describe("Core identity Drizzle database composition", () => {
it("does not construct a pool merely because the module is imported", () => {
expect(poolConstructor).not.toHaveBeenCalled();
});
it.each([
"",
" ",
"not-a-database-url",
"https://admin:[email protected]/database",
])("rejects an invalid connection string without exposing it (%s)", (connectionString) => {
let captured: unknown;
try {
createIdentityDrizzleDatabase(connectionString);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityDatabaseError);
expect(captured).toMatchObject({ code: "IDENTITY_DATABASE_CONFIGURATION_INVALID" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
if (connectionString) {
expect(publicText).not.toContain(connectionString);
}
expect(poolConstructor).not.toHaveBeenCalled();
});
it("explicitly constructs the pool only when called, with the identity schema attached", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined };
});
const handle = createIdentityDrizzleDatabase(
"postgresql://operator:[email protected]/ekklesia",
);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(handle.db).toBeDefined();
expect(typeof handle.close).toBe("function");
expect(Object.isFrozen(handle)).toBe(true);
});
it("passes the connection timeout through to the pool configuration", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined };
});
poolConstructor.mockClear();
createIdentityDrizzleDatabase("postgresql://example.test/ekklesia", {
connectionTimeoutMs: 5_000,
});
expect(poolConstructor).toHaveBeenCalledWith(
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const betterAuthMock = vi.hoisted(() => vi.fn());
const drizzleAdapterMock = vi.hoisted(() =>
vi.fn((db: unknown, options: unknown) => ({ __adapterOptions: options, db })),
);
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
import type { IdentityConfig } from "../src/identity/config.js";
import { createIdentityAuth, IdentityConstructionError } from "../src/identity/factory.js";
import { identityAdapterSchema } from "../src/identity/schema.js";
function validConfig(): IdentityConfig {
return Object.freeze({
baseUrl: "https://identity.example.test",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: Object.freeze(["https://identity.example.test"]),
});
}
describe("Core identity Better Auth construction boundary", () => {
beforeEach(() => {
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
});
it("constructs explicitly with narrow injected dependencies and returns only a success marker", () => {
betterAuthMock.mockReturnValue({ api: {}, handler: () => undefined });
const fakeDb = { __fakeDb: true };
const result = createIdentityAuth({ config: validConfig(), db: fakeDb as never });
expect(result).toEqual({ constructed: true });
expect(Object.keys(result)).toEqual(["constructed"]);
expect(Object.isFrozen(result)).toBe(true);
});
it("does not expose the raw database, adapter, or Better Auth instance on the returned value", () => {
betterAuthMock.mockReturnValue({ api: {}, handler: () => undefined });
const fakeDb = { __fakeDb: true };
const result = createIdentityAuth({ config: validConfig(), db: fakeDb as never });
expect(JSON.stringify(result)).not.toContain("__fakeDb");
expect(JSON.stringify(result)).not.toContain("handler");
});
it("passes the explicit ekklesia_core_* schema mapping to the Drizzle adapter", () => {
betterAuthMock.mockReturnValue({});
const fakeDb = { __fakeDb: true };
createIdentityAuth({ config: validConfig(), db: fakeDb as never });
expect(drizzleAdapterMock).toHaveBeenCalledTimes(1);
const [db, options] = drizzleAdapterMock.mock.calls[0] as [unknown, unknown];
expect(db).toBe(fakeDb);
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
});
it("enables no provider or plugin beyond the four core models", () => {
betterAuthMock.mockReturnValue({});
createIdentityAuth({ config: validConfig(), db: {} as never });
expect(betterAuthMock).toHaveBeenCalledTimes(1);
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
expect(Object.keys(options).sort()).toEqual([
"baseURL",
"database",
"secret",
"trustedOrigins",
]);
expect(options.baseURL).toBe(validConfig().baseUrl);
expect(options.secret).toBe(validConfig().secret);
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
});
it("sanitizes a construction failure without leaking the underlying error, secret, or URL", () => {
const hostileSecret = validConfig().secret;
betterAuthMock.mockImplementation(() => {
throw new Error(
`Better Auth failed for secret ${hostileSecret} at https://internal.example.test`,
);
});
let captured: unknown;
try {
createIdentityAuth({ config: validConfig(), db: {} as never });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConstructionError);
expect(captured).toMatchObject({ code: "IDENTITY_CONSTRUCTION_FAILED" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain(hostileSecret);
expect(publicText).not.toContain("internal.example.test");
});
it("sanitizes an adapter construction failure the same way", () => {
drizzleAdapterMock.mockImplementation(() => {
throw new Error("adapter exploded with postgresql://admin:[email protected]/ekklesia");
});
let captured: unknown;
try {
createIdentityAuth({ config: validConfig(), db: {} as never });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConstructionError);
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain("db.internal");
expect(publicText).not.toContain("admin");
});
});
+91
View File
@@ -0,0 +1,91 @@
import { getTableName } from "drizzle-orm";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import {
ekklesiaCoreAccount,
ekklesiaCoreSession,
ekklesiaCoreUser,
ekklesiaCoreVerification,
identityAdapterSchema,
} from "../src/identity/schema.js";
describe("Core identity Drizzle schema", () => {
it("uses explicit, unambiguous ekklesia_core_* physical table names", () => {
expect(getTableName(ekklesiaCoreUser)).toBe("ekklesia_core_user");
expect(getTableName(ekklesiaCoreSession)).toBe("ekklesia_core_session");
expect(getTableName(ekklesiaCoreAccount)).toBe("ekklesia_core_account");
expect(getTableName(ekklesiaCoreVerification)).toBe("ekklesia_core_verification");
});
it("explicitly maps every Better Auth logical model to its physical table object", () => {
expect(identityAdapterSchema).toEqual({
account: ekklesiaCoreAccount,
session: ekklesiaCoreSession,
user: ekklesiaCoreUser,
verification: ekklesiaCoreVerification,
});
});
it("gives the user table a unique email and a default-false verification flag", () => {
const config = getTableConfig(ekklesiaCoreUser);
const columns = Object.fromEntries(config.columns.map((column) => [column.name, column]));
expect(columns.id?.primary).toBe(true);
expect(columns.email?.isUnique).toBe(true);
expect(columns.email?.notNull).toBe(true);
expect(columns.email_verified?.hasDefault).toBe(true);
expect(columns.email_verified?.notNull).toBe(true);
});
it("scopes session and account rows to a user with cascading delete", () => {
for (const table of [ekklesiaCoreSession, ekklesiaCoreAccount]) {
const config = getTableConfig(table);
expect(config.foreignKeys).toHaveLength(1);
const foreignKey = config.foreignKeys[0];
if (!foreignKey) {
throw new Error("Expected a foreign key.");
}
const reference = foreignKey.reference();
expect(reference.columns.map((column) => column.name)).toEqual(["user_id"]);
expect(reference.foreignColumns.map((column) => column.name)).toEqual(["id"]);
expect(getTableName(reference.foreignTable)).toBe("ekklesia_core_user");
expect(foreignKey.onDelete).toBe("cascade");
}
});
it("indexes session.user_id, account.user_id, and verification.identifier for lookup", () => {
expect(
getTableConfig(ekklesiaCoreSession).indexes.map((index) =>
index.config.columns.map((column) => ("name" in column ? column.name : undefined)),
),
).toEqual([["user_id"]]);
expect(
getTableConfig(ekklesiaCoreAccount).indexes.map((index) =>
index.config.columns.map((column) => ("name" in column ? column.name : undefined)),
),
).toEqual([["user_id"]]);
expect(
getTableConfig(ekklesiaCoreVerification).indexes.map((index) =>
index.config.columns.map((column) => ("name" in column ? column.name : undefined)),
),
).toEqual([["identifier"]]);
});
it("requires a unique session token", () => {
const config = getTableConfig(ekklesiaCoreSession);
const token = config.columns.find((column) => column.name === "token");
expect(token?.isUnique).toBe(true);
expect(token?.notNull).toBe(true);
});
it("keeps every table in the default public PostgreSQL schema", () => {
for (const table of [
ekklesiaCoreUser,
ekklesiaCoreSession,
ekklesiaCoreAccount,
ekklesiaCoreVerification,
]) {
expect(getTableConfig(table).schema).toBeUndefined();
}
});
});
@@ -1,10 +1,13 @@
import { createHash, randomBytes } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Client, type QueryResult, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest";
import { reportMigrationCommandError } from "../src/cli/migration-command-errors.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverCoreMigrations,
type MigrationApproval,
type MigrationApprovalProvider,
MigrationRunnerError,
@@ -16,6 +19,12 @@ import type {
ModuleMigrationAggregate,
} from "../src/modules/migration-discovery.js";
const coreMigrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const historyTable = "public.ekklesia_core_migrations";
const migrationLockNamespace = 1_162_562_380;
@@ -1236,3 +1245,215 @@ $body$;\n`;
});
});
});
const coreIdentityTables = Object.freeze([
"ekklesia_core_user",
"ekklesia_core_session",
"ekklesia_core_account",
"ekklesia_core_verification",
]);
async function loadRealCoreMigrations(): Promise<readonly DiscoveredModuleMigration[]> {
const migrations = await discoverCoreMigrations({ coreMigrationsDirectory });
if (migrations.length === 0) {
throw harnessError("The committed Core identity migration is missing.");
}
return migrations;
}
interface IndexRow extends QueryResultRow {
readonly indexname: string;
readonly tablename: string;
}
async function coreIdentityIndexNames(client: Client): Promise<readonly string[]> {
const result = await safeQuery<IndexRow>(
client,
`SELECT tablename, indexname FROM pg_catalog.pg_indexes
WHERE schemaname = 'public' AND tablename = ANY($1::text[])
ORDER BY tablename, indexname`,
[coreIdentityTables],
);
return Object.freeze(result.rows.map((row) => row.indexname));
}
describe.sequential("live PostgreSQL Core identity migration execution", () => {
it("plans the committed Core identity migration on a fresh database without mutation", async () => {
await withIsolatedDatabase("core_fresh_plan", async (database) => {
const coreMigrations = await loadRealCoreMigrations();
const inventory = aggregate(moduleFixture("core", coreMigrations));
const before = await databaseState(database.client);
const plan = await planModuleMigrations({
aggregate: inventory,
database: createPostgresMigrationDatabase(database.url),
});
expect(plan.entries.map((entry) => entry.status)).toEqual(
coreMigrations.map(() => "pending"),
);
expect(await databaseState(database.client)).toEqual(before);
expect(await relationExists(database.client, historyTable)).toBe(false);
for (const table of coreIdentityTables) {
expect(await relationExists(database.client, `public.${table}`)).toBe(false);
}
});
});
it("applies the committed Core identity migration and creates exactly the expected tables and indexes", async () => {
await withIsolatedDatabase("core_apply", async (database) => {
const coreMigrations = await loadRealCoreMigrations();
const inventory = aggregate(moduleFixture("core", coreMigrations));
const result = await applyModuleMigrations({
aggregate: inventory,
database: createPostgresMigrationDatabase(database.url),
});
expect(result.applied.map((entry) => entry.identity)).toEqual(
coreMigrations.map((entry) => entry.identity),
);
for (const table of coreIdentityTables) {
expect(await relationExists(database.client, `public.${table}`)).toBe(true);
}
expect(await coreIdentityIndexNames(database.client)).toEqual([
"ekklesia_core_account_pkey",
"ekklesia_core_account_user_id_idx",
"ekklesia_core_session_pkey",
"ekklesia_core_session_token_unique",
"ekklesia_core_session_user_id_idx",
"ekklesia_core_user_email_unique",
"ekklesia_core_user_pkey",
"ekklesia_core_verification_identifier_idx",
"ekklesia_core_verification_pkey",
]);
const history = await readHistory(database.client);
expect(history).toHaveLength(coreMigrations.length);
expect(history.map(({ applied_at: _appliedAt, ...row }) => row)).toEqual(
coreMigrations.map((entry, index) => ({
applied_order: index + 1,
checksum: entry.checksum,
identity: entry.identity,
migration_index: entry.index,
migration_tag: entry.tag,
module_id: "core",
})),
);
});
});
it("performs a genuine no-op on a second Core identity apply", async () => {
await withIsolatedDatabase("core_no_op", async (database) => {
const coreMigrations = await loadRealCoreMigrations();
const inventory = aggregate(moduleFixture("core", coreMigrations));
const migrationDatabase = createPostgresMigrationDatabase(database.url);
await applyModuleMigrations({ aggregate: inventory, database: migrationDatabase });
const before = await databaseState(database.client);
const result = await applyModuleMigrations({
aggregate: inventory,
database: migrationDatabase,
});
expect(result).toEqual({ applied: [], appliedCount: 0, noOp: true });
expect(await databaseState(database.client)).toEqual(before);
});
});
it("coexists with a fixture module migration, executing Core first as documented", async () => {
await withIsolatedDatabase("core_and_module", async (database) => {
const coreMigrations = await loadRealCoreMigrations();
const people = peopleInitial();
const inventory = aggregate(
moduleFixture("core", coreMigrations),
moduleFixture("people", [people]),
);
const result = await applyModuleMigrations({
aggregate: inventory,
database: createPostgresMigrationDatabase(database.url),
});
expect(result.applied.map((entry) => entry.identity)).toEqual([
...coreMigrations.map((entry) => entry.identity),
people.identity,
]);
for (const table of coreIdentityTables) {
expect(await relationExists(database.client, `public.${table}`)).toBe(true);
}
expect(await relationExists(database.client, "public.people_records")).toBe(true);
const history = await readHistory(database.client);
expect(history.map((row) => row.module_id)).toEqual([
...coreMigrations.map(() => "core"),
"people",
]);
});
});
it("does not corrupt previously valid module-only history when Core is introduced later", async () => {
await withIsolatedDatabase("core_added_later", async (database) => {
const people = peopleInitial();
const migrationDatabase = createPostgresMigrationDatabase(database.url);
await applyModuleMigrations({
aggregate: aggregate(moduleFixture("people", [people])),
database: migrationDatabase,
});
const historyBeforeCore = await readHistory(database.client);
const coreMigrations = await loadRealCoreMigrations();
const result = await applyModuleMigrations({
aggregate: aggregate(
moduleFixture("core", coreMigrations),
moduleFixture("people", [people]),
),
database: migrationDatabase,
});
expect(result.applied.map((entry) => entry.identity)).toEqual(
coreMigrations.map((entry) => entry.identity),
);
const historyAfterCore = await readHistory(database.client);
const peopleRowBefore = historyBeforeCore.find((row) => row.module_id === "people");
const peopleRowAfter = historyAfterCore.find((row) => row.module_id === "people");
expect(peopleRowAfter).toEqual(peopleRowBefore);
expect(historyAfterCore).toHaveLength(historyBeforeCore.length + coreMigrations.length);
});
});
it("does not damage the committed Core identity migration when a later module migration fails", async () => {
await withIsolatedDatabase("core_rollback_safety", async (database) => {
const coreMigrations = await loadRealCoreMigrations();
const migrationDatabase = createPostgresMigrationDatabase(database.url);
await applyModuleMigrations({
aggregate: aggregate(moduleFixture("core", coreMigrations)),
database: migrationDatabase,
});
const historyAfterCore = await readHistory(database.client);
const failing = migration("broken", 0, {
sql: `CREATE TABLE "broken_records" ("id" bigint PRIMARY KEY);\n--> statement-breakpoint\nSELECT 1 / 0;\n`,
tag: "0000_broken",
});
const error = await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate(
moduleFixture("core", coreMigrations),
moduleFixture("broken", [failing]),
),
database: migrationDatabase,
}),
"MIGRATION_EXECUTION_FAILED",
);
expect(error.migrationIdentity).toBe(failing.identity);
for (const table of coreIdentityTables) {
expect(await relationExists(database.client, `public.${table}`)).toBe(true);
}
expect(await relationExists(database.client, "public.broken_records")).toBe(false);
expect(await readHistory(database.client)).toEqual(historyAfterCore);
await expectMigrationLockAvailable(database.client);
});
});
});
+6 -1
View File
@@ -4,5 +4,10 @@
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
"include": [
"drizzle.config.ts",
"identity-schema-generator.config.ts",
"src/**/*.ts",
"tests/**/*.ts"
]
}
@@ -0,0 +1,279 @@
# 0010: Core identity architecture and Better Auth adoption
- Status: Accepted
- Date: 2026-07-21
## Context
Phase 3 delivered module discovery, module-owned Drizzle schemas and migrations, and
Core-controlled transactional migration execution (ADRs 0006-0009). Authentication and identity
were explicitly out of scope. `docs/architecture/platform-architecture.md` already names identity
as the next architectural phase, and ADR 0001 places identity under Core, not under a feature
module or a surface: "Core orchestrates generic platform infrastructure: startup, identity,
permissions, configuration, database connectivity, storage, events, jobs, module discovery, and
lifecycle."
Ekklesia adopts Better Auth as the underlying authentication library rather than writing
session/credential handling from scratch. Better Auth ships a PostgreSQL/Drizzle adapter, a
schema generator, and its own `migrate` command. None of that is a substitute for Ekklesia's
existing migration contracts: ADR 0008 and ADR 0009 already define the one migration format,
one checksum rule, one history table, one advisory lock, and one transactional executor that
every persistent schema change in this repository must go through. This decision defines how
identity is owned, how Better Auth is contained, and how its schema enters that existing pipeline
without creating a second migration system or weakening any Phase 3 guarantee.
This is a bounded Phase 4A slice: it establishes the identity foundation only. It does not mount
an HTTP handler, expose a sign-in endpoint, or make any provider or enrollment decision.
## Decision
### Core owns identity; Better Auth is an implementation detail behind it
Authentication and identity are a Core platform concern, exactly like database connectivity and
migration execution. Better Auth is contained entirely behind Ekklesia-owned contracts and
composition code under `core/src/identity/`:
- Modules must never import `better-auth`, `@better-auth/*`, or `auth` (the CLI package), and
must never receive a Better Auth instance, session token, or raw database client. This is
already structurally guaranteed today: `core → module` imports are impossible in either
direction under `scripts/check-boundaries.ts`'s `core-is-feature-agnostic` and
`modules-use-public-platform-contracts` rules, and modules may only import shared packages
whose role is `contracts`, `module-sdk`, `testing`, or `tooling` — never `core` and never a
Better Auth package. Phase 4A adds no exception to that matrix.
- Surfaces will eventually authenticate through Core-exposed HTTP/API and client contracts, never
by querying Better Auth's tables or importing Better Auth types directly. No such Core API
exists yet; it is explicitly out of scope for this slice (see "Not yet operational" below).
- Core's own public package barrel (`core/src/index.ts`) does not re-export anything from
`core/src/identity/` in this slice. The Better Auth factory, the Drizzle adapter wiring, and the
constructed instance stay internal to Core's identity module and are consumed only by Core's own
code and tests, never by a public `@ekklesia/core` subpath. A later HTTP transport slice decides
what (if anything) becomes part of Core's public surface.
### Better Auth's `user` row is an authentication identity, not a person profile
The four Better Auth core tables (`user`, `session`, `account`, `verification`) exist to answer
"who is this session," nothing more. They are not, and must never become, the future
church-person/profile/domain model. Church membership, ministry roles, contact history,
attendance, permissions, and any other module-specific field must never be added as columns on
these tables. A future person/profile module or Core extension, not this schema, is where that
data belongs, linked only through the opaque user identifier below. No custom user fields are
added in this slice, and none are anticipated by widening the Better Auth tables later — the
correct extension point is a separate, explicitly designed model.
### Ekklesia identity contracts (`@ekklesia/contracts`)
`packages/contracts/src/identity.ts` defines the only identity-shaped values a module may
eventually see:
- `EkklesiaUserId` — an opaque, validated, non-empty string (`ekklesiaUserIdSchema`). It carries
no meaning beyond "a stable identifier for one Ekklesia identity." Callers never construct one
by hand; they parse it via `parseEkklesiaUserId`/`safeParseEkklesiaUserId`.
- `EkklesiaPrincipal` — a frozen, discriminated union of exactly two shapes:
`{ kind: "anonymous" }` (the frozen `ANONYMOUS_PRINCIPAL` singleton) and
`{ kind: "authenticated", userId: EkklesiaUserId }` (built only through
`createAuthenticatedPrincipal`, which validates and freezes the result).
Deliberately absent from this contract, and out of scope for this PR: roles, permissions,
organization or campus identifiers, group memberships, church profile fields, the user's email or
any other personal data, Better Auth session tokens, cookie contents, and password information.
Email and other personal data are therefore not automatically exposed to modules through this
contract; a future slice that grants modules richer identity data must extend this type
deliberately and re-justify each field. Roles and permissions remain intentionally deferred — this
PR does not invent a generalized authorization framework. `packages/contracts` has no dependency on
`better-auth`, so no Better Auth type can leak through this or any other public contracts export.
### Better Auth schema ownership and table naming
Better Auth's four required core models are declared once, by hand, as an explicit Drizzle
schema at `core/src/identity/schema.ts`, using Core-owned physical table names that make ownership
unambiguous:
| Better Auth model | Physical table |
| --- | --- |
| `user` | `ekklesia_core_user` |
| `session` | `ekklesia_core_session` |
| `account` | `ekklesia_core_account` |
| `verification` | `ekklesia_core_verification` |
`core/src/identity/schema.ts` exports `identityAdapterSchema`, an explicit
`{ user, session, account, verification }` map passed directly to `drizzleAdapter(db, { provider:
"pg", schema: identityAdapterSchema })`. Table naming is therefore never inferred from Better
Auth's defaults; it is an explicit, reviewed mapping, matching the Drizzle adapter's documented
`schema` option. Columns, defaults, uniqueness, indexes, foreign keys (`ON DELETE CASCADE` from
`session`/`account` to `user`), and timestamp types mirror the pinned `[email protected]`
Drizzle/PostgreSQL generator output exactly; only the physical table names differ.
### Better Auth's own generator and migrate command are development aids only
`better-auth`/`auth`'s schema generator (`pnpm --filter @ekklesia/core identity:schema:generate`,
which shells out to the pinned local `auth generate` CLI) is a development-time aid used to derive
and later cross-check the canonical schema. It is never run against a production database, and it
never writes to `core/src/identity/schema.ts` directly — a developer reviews its output and
updates the checked-in schema deliberately. Better Auth's own `migrate` command is not used at
all: per its documentation it only supports the built-in Kysely adapter, and even if it supported
Drizzle it would still be a second, uncoordinated migration executor. Ekklesia has exactly one:
the Core migration runner defined by ADR 0009.
### Core-owned migrations: one migration system, one executor
Identity's persistent schema is packaged as a first-class **Core-owned** migration source, not a
disguised feature module. `core/drizzle/` holds the generated migration for the four identity
tables in the exact format ADR 0008 defines (native Drizzle Kit 0.31.10 journal, per-tag SHA-256
checksum of the exact SQL bytes, and an `_ekklesia.json` destructive-policy sidecar declaring
`{"mode":"none"}` for this create-only migration). Core has no `ekklesia.module.json` manifest, no
compiled server entrypoint, and is never passed through `discoverModules`; giving it one merely to
reuse discovery would be exactly the "disguise Core identity as an installed feature module" this
decision rejects, and would violate `core-is-feature-agnostic`.
Instead, `core/src/migrations/core-source.ts` reuses the exact same per-directory journal,
snapshot, checksum, and destructive-policy validation module discovery already used for module
migrations — the underlying inspection function was factored out of
`core/src/modules/migration-discovery.ts` without changing its behavior for modules — against
Core's own `core/drizzle` directory, attributed to the reserved source identifier `"core"`.
`core/src/migrations/aggregate.ts`'s `discoverAllMigrations` merges Core's migrations with the
existing module aggregate from `discoverModuleMigrations`, placing Core's migrations first, and
rejects discovery outright (a `ModuleMigrationDiscoveryError` with code
`MODULE_MIGRATION_ID_DUPLICATE`) if any installed module manifest ever declares `id: "core"`,
so Core and module migration identities can never collide. Core has no dependency relationship
with feature modules — per ADR 0001, Core underlies modules — so it is not part of the module
dependency graph; it simply precedes every module in the merged aggregate. Existing dependency-first
module ordering, identities, and checksums are untouched by this merge.
`core/src/migrations/runner.ts` and `core/src/migrations/postgres-database.ts` — the transactional
executor, the advisory lock, the breakpoint splitter, and the applied-history table — require **no
changes**. Both already operate purely on the generic `ModuleMigrationAggregate` shape (a flat
migration list plus a `{moduleId, migrations}` list) and treat `moduleId` as an opaque string key;
a Core-owned entry with `moduleId: "core"` is handled by every existing code path unmodified. The
fixed history table `public.ekklesia_core_migrations` and its `module_id` column, established by
ADR 0009, are not renamed or restructured for this decision: Core's identity migration honestly
populates that same free-text column with the literal value `"core"`, exactly like any module
populates it with its own module ID. ADR 0009's own contract already describes that column as
"Owning module ID" while its enforcement treats it as an opaque source key, so this is a
backward-compatible representation, not a special case. No existing valid history row's meaning
changes, and no column is added or removed. Migrations remain forward-only, matching ADR 0008 and
ADR 0009; there are no downgrade or uninstall migrations for identity either.
`core/src/cli/migration-command-support.ts`'s `discoverRepositoryMigrations` (the single chokepoint
feeding `migrations:plan`, `migrations:apply`, and `migrations:validate`) and Doctor's
`migrations.discovery`/`postgresql.plan` checks both now call `discoverAllMigrations`, so all three
CLI commands and `pnpm doctor` represent Core's migration honestly and deterministically alongside
module migrations, with Core listed first. A fresh database applies the Core identity migration
through the existing `pnpm migrations:apply`; a second application is a genuine no-op, proven
against real PostgreSQL. The identity schema is never created merely because a package is
imported, Better Auth is constructed, or Doctor runs — discovery, planning, and Doctor's identity
configuration check are read-only; only an explicit `pnpm migrations:apply` mutates the database.
### Better Auth construction boundary
`core/src/identity/factory.ts` exports `createIdentityAuth({ config, db })`, Core's explicit Better
Auth composition boundary:
- Better Auth is constructed only when this function is called. Importing any identity module
performs no PostgreSQL connection, no environment read, and no schema generation or migration —
proven by unit tests that mock `pg`, `better-auth`, and `better-auth/adapters/drizzle` and
assert those constructors are never invoked merely by import.
- The Core-owned Drizzle database instance (`core/src/identity/database.ts`'s
`createIdentityDrizzleDatabase`) and the validated `IdentityConfig`
(`core/src/identity/config.ts`) are both injected explicitly; the factory reads no environment
variables itself.
- The identity schema mapping (`identityAdapterSchema`) is passed to `drizzleAdapter` explicitly,
never left to Better Auth's naming defaults.
- No provider or plugin beyond the four core models is enabled: no `emailAndPassword`, no social
or OAuth provider, no organization/admin/2FA/passkey/username/magic-link plugin, no experimental
feature. `betterAuth()` is called with exactly `database`, `secret`, `baseURL`, and
`trustedOrigins` — nothing else — proven by a unit test asserting the exact option key set.
- The function returns only a frozen, opaque `{ constructed: true }` marker. The constructed
Better Auth instance, its HTTP handler, the injected database, and the Drizzle adapter are never
returned, so they cannot leak through any public contract; this is the concrete meaning of
"keep the Better Auth handler internal until the HTTP transport slice."
### Secrets, base URL, and trusted origins
`createIdentityConfig` requires an explicit secret of at least 32 characters (matching Better
Auth's own documented minimum) and rejects a missing secret, a secret under 32 characters, and a
trivially degenerate secret (every character identical) as a narrow, deterministic guard — it
cannot measure true entropy, and operators remain responsible for generating a high-entropy value
(for example `openssl rand -base64 32`). No built-in or default secret is ever provided, and no
secret is ever generated or persisted automatically. Secret rotation is deferred: Better Auth
supports a plural `secrets` list for non-destructive rotation, but Phase 4A accepts only a single
`secret` value. A future slice may adopt the plural form without a schema change.
Base URL and trusted origins are explicit, typed configuration, never inferred from an incoming
request. Every trusted origin must be an absolute `http`/`https` origin with no path, query, or
fragment. Wildcard trusted origins (Better Auth supports `*`/`?`/`**` glob patterns) are rejected
outright in this slice: cross-subdomain cookies are not enabled yet, so a wildcard origin would
widen the trusted surface without a matching cookie-scope decision. This is deliberately narrower
than upstream Better Auth, not an oversight; it may be revisited alongside a future ADR that
enables cross-subdomain cookies. `createIdentityConfig` defaults to `"production"` validation when
the environment is not specified, so an omitted environment never silently relaxes checks; in
production mode, `http://` origins and loopback hosts (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`)
are rejected for both the base URL and every trusted origin. Local development origins (for
example `http://localhost:3000`) are allowed only when the caller explicitly passes
`environment: "development"` — never silently.
### Cookies
Host-only secure cookies remain the default direction. This decision does not enable
`advanced.crossSubDomainCookies`; every cookie stays scoped to a single host, matching Better
Auth's own default posture (`httpOnly` always, `secure` in production, `SameSite=Lax`). Enabling
cross-subdomain cookies requires a future ADR that also addresses the matching trusted-origin
widening.
### Deferred: providers, enrollment, and everything past schema construction
This slice does not enable any authentication provider (email/password, social, passkey, magic
link, username, OAuth/OIDC) and does not implement any enrollment, invitation, verification, or
password-reset flow — those are explicit provider and enrollment decisions reserved for later,
separately reviewed slices. No `emailAndPassword` or other provider is enabled even internally;
the factory constructs only the database adapter and base configuration required to validate
schema wiring. Phase 4A does not mount an HTTP handler and exposes no authentication endpoint: it
is acceptable, and expected, that no user can sign in after this PR. Two-factor authentication,
Better Auth's admin and organization plugins, and any other plugin remain out of scope until a
slice that deliberately adopts them.
## Alternatives
- Discovering Core's migrations by giving it a synthetic `ekklesia.module.json` and passing it
through `discoverModules` was rejected: Core is not an installed, optional, versioned package
the way a feature module is, it has no server entrypoint to run, and forcing it through
module discovery would violate `core-is-feature-agnostic` and misrepresent Core as a module in
every diagnostic and history row.
- Writing a second, Core-specific migration runner or history table for identity was rejected:
ADR 0009 already defines one transactional executor and one history contract; a second one
would be exactly the duplicated migration system this decision must avoid, and would fork the
advisory-locking and rollback guarantees the runner already provides for free.
- Renaming or restructuring `ekklesia_core_migrations`'s `module_id` column for a "cleaner" Core
vocabulary was rejected: it is a breaking change to an already-shipped, ADR 0009-governed table
for a purely aesthetic gain, and the column already functions as an opaque source key today.
- Using Better Auth's own `migrate` command, or its programmatic `getMigrations` API, was
rejected: both are documented as Kysely-only and do not apply to the Drizzle adapter at all;
neither would coordinate with Ekklesia's advisory lock, checksum, or approval contract even if
they did.
- Relying on Better Auth's default (unrenamed) table names was rejected: `user`, `session`,
`account`, and `verification` do not signal Core ownership and are far more likely to collide
with a future module's own table names in the single shared `public` schema.
- Supporting Better Auth's wildcard trusted-origin syntax immediately was rejected for this slice
because it has no matching cookie-scope decision yet; narrowing to exact origins keeps the
trusted surface auditable until cross-subdomain cookies are deliberately designed.
- Putting church-person/profile fields directly on Better Auth's `user` table was rejected: it
would conflate an authentication identity with a domain model that modules, not Core, are
expected to own, and would make every future profile field a Core-owned, Better-Auth-coupled
migration.
## Consequences
Ekklesia has exactly one migration format, one checksum rule, one advisory lock, and one
transactional executor, and Core's identity schema is now the first non-module consumer of that
pipeline, applied and integration-tested against real PostgreSQL. `pnpm doctor`,
`pnpm migrations:plan`, and `pnpm migrations:apply` all represent Core's identity migration
honestly, ordered before any module migration. No module can import Better Auth or receive a
database client or session token; that boundary was already structurally enforced by
`scripts/check-boundaries.ts` and required no new code to hold for identity.
What remains explicitly not operational after this PR: no HTTP server or API route, no mounted
Better Auth handler, no sign-in or sign-up endpoint, no public registration or invitation flow, no
email delivery, no password reset or email verification, no social or OAuth provider, no passkeys
or magic links, no two-factor authentication, no roles or permissions, and no church
member/person/profile modeling. The next expected slice is identity HTTP transport and an explicit
enrollment policy (for example, how the first administrator account is created) — not a Dashboard
Surface, which depends on that transport slice existing first.
+1
View File
@@ -18,3 +18,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0007: Module-owned Drizzle schemas and table isolation](0007-module-owned-drizzle-schemas.md) — Accepted
- [0008: Module-owned migration contracts and offline validation](0008-module-owned-migration-contracts.md) — Accepted
- [0009: Transactional module migration execution](0009-transactional-module-migrations.md) — Accepted
- [0010: Core identity architecture and Better Auth adoption](0010-core-identity-and-better-auth-adoption.md) — Accepted
+15 -4
View File
@@ -78,7 +78,18 @@ definition entrypoints, which the module contract requires to be side-effect-fre
imports or starts module server entrypoints. It does not apply migrations or modify PostgreSQL, and
it honestly skips object storage because no production Core storage service exists yet.
The next architectural phase is Phase 4 identity with Better Auth. Other target architecture not
yet implemented includes production approval-provider composition, permission enforcement,
physical storage-bucket provisioning, Surface SDK, richer public resource and action contracts,
API server, dashboard, Builder, and reference feature modules.
Phase 4A adds the Core identity foundation. Core owns a Better Auth-backed identity schema
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
`ekklesia_core_verification`) reachable only through Ekklesia-owned contracts and an explicit
Core-owned Better Auth construction boundary; modules never import Better Auth or receive a raw
database client. That schema ships as a first-class Core-owned migration source that reuses the
exact migration format, checksum rule, planner, and transactional runner Phase 3 already
established, ordered before module migrations in the same aggregate. See
[ADR 0010](decisions/0010-core-identity-and-better-auth-adoption.md) and
[the identity development guide](../development/identity.md). Phase 4A does not mount an HTTP
handler or expose a sign-in endpoint; no user can authenticate yet.
The next architectural phase is identity HTTP transport and an explicit enrollment policy. Other
target architecture not yet implemented includes production approval-provider composition,
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
and action contracts, API server, dashboard, Builder, and reference feature modules.
+144
View File
@@ -0,0 +1,144 @@
# Identity
Phase 4A establishes Core's identity foundation: a Core-owned Better Auth database schema, a
first-class Core-owned migration source, minimal opaque-identity contracts, and an explicit Better
Auth construction boundary. It does not expose a sign-in endpoint, mount an HTTP handler, or make
any provider or enrollment decision. See
[ADR 0010](../architecture/decisions/0010-core-identity-and-better-auth-adoption.md) for the full
architectural decision; this page is the practical developer guide.
## Ownership
Authentication and identity are a Core platform concern (ADR 0001), not a feature module and not a
surface concern. Better Auth is an implementation detail contained entirely behind
`core/src/identity/`:
- **Modules never import `better-auth`, `@better-auth/*`, or `auth`, and never receive a Better
Auth instance, session token, or raw database client.** This holds structurally today: modules
cannot import `core` at all under `scripts/check-boundaries.ts`'s boundary matrix, regardless of
what Core exposes.
- **Surfaces will eventually authenticate through Core-exposed HTTP/API and client contracts**,
never by querying Better Auth's tables directly. No such Core API exists yet.
- Better Auth's `user` row is an authentication identity, not the future church-person/profile
model. Church membership, ministry roles, contact history, attendance, permissions, and other
module-specific fields must never be added to the identity tables; a future, separately designed
model owns that data, linked only through the opaque user identifier.
## The four Core identity tables
| Better Auth model | Physical table |
| --- | --- |
| `user` | `ekklesia_core_user` |
| `session` | `ekklesia_core_session` |
| `account` | `ekklesia_core_account` |
| `verification` | `ekklesia_core_verification` |
Declared once, by hand, at `core/src/identity/schema.ts`, and passed explicitly to Better Auth's
Drizzle adapter as `identityAdapterSchema`. Columns, defaults, uniqueness, foreign keys (cascading
delete from `session`/`account` to `user`), and indexes mirror the pinned
`[email protected]` generator output exactly; only the table names differ, so ownership is
unambiguous inside the shared `public` PostgreSQL schema. No custom fields are added in this
slice.
## Ekklesia identity contracts
`@ekklesia/contracts` exports the only identity-shaped values available today:
- `EkklesiaUserId` — an opaque, validated string. Parse untrusted input with
`parseEkklesiaUserId`/`safeParseEkklesiaUserId`.
- `EkklesiaPrincipal` — a frozen `{ kind: "anonymous" }` (the `ANONYMOUS_PRINCIPAL` singleton) or
`{ kind: "authenticated", userId }` (built with `createAuthenticatedPrincipal`).
No roles, permissions, organization/campus IDs, group memberships, profile fields, email, session
tokens, or password data are exposed through this contract. Extending it requires a deliberate,
separately reviewed change.
## Core-owned migrations coexist with module migrations
Identity's schema ships as a first-class Core-owned migration source at `core/drizzle/`, in the
exact Drizzle Kit / Ekklesia migration format ADR 0008 and ADR 0009 define — the same journal,
per-file SHA-256 checksum, and `_ekklesia.json` destructive-policy sidecar every module migration
uses. Core is not a module: it has no `ekklesia.module.json` manifest and is never passed through
module discovery. Instead, `core/src/migrations/core-source.ts` discovers `core/drizzle` directly
under the reserved source identifier `"core"`, and
`core/src/migrations/aggregate.ts`'s `discoverAllMigrations` merges it with the existing module
aggregate, **Core first**. An installed module manifest that ever declares `id: "core"` is rejected
at discovery time so Core and module migration identities can never collide.
The transactional runner, advisory lock, breakpoint splitter, and the fixed
`public.ekklesia_core_migrations` history table (ADR 0009) are unchanged: Core's migration honestly
populates the existing `module_id` column with the literal value `"core"`, exactly like a module
populates it with its own module ID. There is exactly one migration system and one executor.
## Validating and applying migrations
```bash
pnpm migrations:validate # offline; Core's migration plus every module's, Core first
pnpm migrations:plan # read-only comparison against PostgreSQL applied history
pnpm migrations:apply # locked, transactional application of the pending batch
pnpm doctor # includes Core's migration in its discovery/plan checks
```
No new commands are required to include identity: it flows through the existing migration
commands automatically. A fresh database applies the Core identity migration through
`pnpm migrations:apply` like any other pending migration; a second application is a genuine no-op.
Doctor, planning, and importing any identity module never create the identity tables — only an
explicit `pnpm migrations:apply` mutates the database.
## Upgrading the Better Auth schema
Better Auth schema changes must never be applied to production directly. Follow this workflow
whenever the pinned Better Auth version changes:
1. Deliberately update the pinned `better-auth`, `auth`, and `drizzle-kit` versions in
`core/package.json`.
2. Regenerate the canonical schema with the pinned local CLI:
`pnpm --filter @ekklesia/core identity:schema:generate` (writes a local, gitignored
`core/.identity-schema-reference.ts` — never committed).
3. Review the diff between that reference file and the checked-in
`core/src/identity/schema.ts`, deliberately updating the latter (table names, relations, and
any structural change) while preserving Core's explicit `ekklesia_core_*` naming.
4. Generate a new forward-only Core migration:
`pnpm --filter @ekklesia/core identity:migration:generate` (drizzle-kit, writes into
`core/drizzle/`). Add the matching entry to `core/drizzle/meta/_ekklesia.json`, declaring
`{"mode":"none"}` or `{"mode":"approval-required","reason":"..."}` for any destructive change.
5. Validate and test it through Ekklesia's existing migration system:
`pnpm migrations:validate`, `pnpm identity:schema:validate`, and
`pnpm test:migrations:integration`.
6. Never edit an already-applied migration. Editing changes its checksum, and Core's runner
refuses an applied migration whose packaged bytes no longer match its recorded history.
`pnpm identity:schema:validate` automates steps 2–3 as a read-only check: it regenerates Better
Auth's canonical schema into a temporary, package-local directory with the pinned local CLI,
structurally compares every core model's columns, types, nullability, defaults, foreign keys, and
indexes against the checked-in schema, and always removes the temporary output — no `DATABASE_URL`
required. It is part of `pnpm verify`.
## Required identity configuration
`createIdentityConfig` (`core/src/identity/config.ts`) validates three explicit values before
`core/src/identity/factory.ts` constructs Better Auth. This document intentionally contains no
real values — configure them per environment, never commit them:
| Value | Requirement |
| --- | --- |
| Secret | At least 32 characters, high entropy (for example `openssl rand -base64 32`). No built-in default is ever provided; a missing or too-short secret is rejected explicitly. |
| Base URL | An absolute `http`/`https` URL. In production, `https` is required and loopback hosts are rejected. |
| Trusted origins | One or more absolute `http`/`https` origins with no path, query, or fragment. Wildcards are rejected in this slice. In production, `https` is required and loopback hosts are rejected. |
Local development origins (for example `http://localhost:3000`) are only accepted when the caller
explicitly passes `environment: "development"` — never silently. Every configuration failure
raises a sanitized `IdentityConfigError` carrying only a stable code, never the offending value.
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
ADR 0010.
## What is not operational yet
No HTTP server or API route exists, Better Auth's handler is not mounted, and there is no sign-in
or sign-up endpoint, public registration, invitation flow, email delivery, password reset, email
verification, social or OAuth provider, passkey, magic link, two-factor authentication, or roles
and permissions model. **No user can authenticate through this repository yet.** The next expected
slice is identity HTTP transport and an explicit enrollment policy (for example, how the first
administrator account is created) — not a Dashboard Surface, which depends on that transport slice
existing first.
+27 -8
View File
@@ -19,7 +19,8 @@ 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 migrations:validate # offline module migration inventory and exact checksums
pnpm migrations:validate # offline Core + module migration inventory and exact checksums
pnpm identity:schema:validate # detects drift against the pinned Better Auth schema generator
pnpm migrations:plan # read-only comparison with PostgreSQL applied history
pnpm migrations:apply # locked, transactional application of the pending batch
pnpm doctor # build-first, read-only human readiness dashboard
@@ -41,9 +42,16 @@ schema entrypoints are loaded and validated for table ownership, namespacing, du
foreign-key isolation; this command does not connect to PostgreSQL or run migrations.
`pnpm migrations:validate` also builds first, completes static module discovery, and atomically
checks every declared stable Drizzle migration directory. It reports dependency-first module
inventories and exact SHA-256 checksums without importing module code, connecting to PostgreSQL, or
executing SQL. Modules with no migration history are valid.
checks Core's own `core/drizzle` migration directory together with every declared module Drizzle
migration directory. It reports Core's migration first, followed by dependency-first module
inventories, with exact SHA-256 checksums, without importing module code, connecting to
PostgreSQL, or executing SQL. Modules with no migration history are valid.
`pnpm identity:schema:validate` regenerates Better Auth's canonical schema with the pinned local
`auth` CLI into a temporary, package-local directory, structurally compares every core model
(columns, types, nullability, defaults, foreign keys, and indexes) against the checked-in
`core/src/identity/schema.ts`, and always removes the temporary output. It requires no
`DATABASE_URL`. See [Identity](identity.md) for the full upgrade workflow.
## Developer Doctor
@@ -66,8 +74,12 @@ Doctor reports a fixed, deterministic set of checks:
- Schema discovery, including validated module and table counts. This imports compiled schema
definitions, which the module contract requires to perform no configuration reads, I/O, or
database connection.
- Offline migration discovery, with safe identities and consistently truncated checksums.
- Offline migration discovery for Core's own identity migration and every module, with safe
identities and consistently truncated checksums, Core listed first.
- PostgreSQL connectivity and read-only applied-history planning when `DATABASE_URL` is configured.
- Identity runtime configuration: skipped (not a failure) when no secret, base URL, or trusted
origins are set; otherwise validated with `createIdentityConfig` and reported pass or failure.
This check never constructs Better Auth or opens a database connection.
- Object storage as skipped while Core has no production storage client or configuration contract.
Every check has a stable ID and one of four states: pass, warning, failure, or skipped. Independent
@@ -94,7 +106,8 @@ Data definitions
✓ Schemas validated
0 modules · 0 tables
✓ Migrations validated
0 migrations · checksums valid
1 migration · checksums valid
core:0000_identity_core_tables · f8f2086ef06f
PostgreSQL
○ Connection check skipped
@@ -102,6 +115,11 @@ PostgreSQL
○ Migration plan skipped
A validated migration inventory and configured PostgreSQL connection are required.
Identity
○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
Object storage
○ Check unavailable
Core storage integration has not been implemented yet.
@@ -112,8 +130,9 @@ Result: READY WITH WARNINGS
Doctor is a local, human-readable summary. `pnpm verify`, `pnpm ci:verify`, infrastructure smoke,
and migration smoke remain the authoritative automated evidence because they exercise formatting,
boundaries, types, tests, builds, containers, and live mutation/rollback behavior that Doctor does
not repeat. Phase 4 identity with Better Auth is the next architectural phase; Doctor does not
diagnose identity, authorization, HTTP routing, runtime module health, or production storage yet.
not repeat. Identity HTTP transport and an explicit enrollment policy are the next architectural
phase; Doctor does not diagnose authorization, HTTP routing, runtime module health, or production
storage yet, and it never constructs an operational Better Auth instance or claims sign-in works.
Migration planning and application require `DATABASE_URL`. Keep it in the uncommitted `.env`
created from `.env.example`:
+17 -4
View File
@@ -9,10 +9,15 @@ pnpm test
pnpm verify
```
These commands exercise package tests, repository boundaries, type checking, offline module and
migration discovery, formatting, linting, and builds. Migration-runner unit tests use a narrow
database/session port to prove deterministic planning, approval, transaction orchestration,
cleanup, and sanitized errors. They do not claim to prove PostgreSQL behavior.
These commands exercise package tests, repository boundaries, type checking, offline Core and
module migration discovery, identity schema drift detection, formatting, linting, and builds.
Migration-runner unit tests use a narrow database/session port to prove deterministic planning,
approval, transaction orchestration, cleanup, and sanitized errors. They do not claim to prove
PostgreSQL behavior. Identity unit tests cover principal/opaque-ID contracts, configuration
validation (secret, base URL, and trusted-origin rules), the Better Auth construction boundary
(explicit construction, exact schema mapping, no unexpected provider or plugin, sanitized
failures), and Core migration discovery (deterministic ordering, the reserved `core` identity, and
collision rejection) — all without a live database.
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
database.
@@ -39,6 +44,14 @@ whole-batch DDL rollback, Drizzle breakpoint execution, real advisory-lock concu
timeouts, lock release after failure, exact destructive approvals, and public-error sanitization.
It does not make migrations automatic during Core startup.
A dedicated set of scenarios exercises the committed Core identity migration against real
PostgreSQL: read-only planning on a fresh database, applying it and confirming exactly the four
expected `ekklesia_core_*` tables and their indexes exist, a genuine no-op on a second apply, Core
executing before a fixture module migration in one batch, previously applied module-only history
staying byte-for-byte unchanged once Core is introduced, and a later module migration's failure
rolling back cleanly without damaging the already-committed Core identity migration or leaking the
advisory lock.
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
the following pull-request status context to `main` branch protection:
+2 -1
View File
@@ -16,6 +16,7 @@
"doctor": "node scripts/run-doctor.ts",
"format": "biome format --write .",
"format:check": "biome format .",
"identity:schema:validate": "turbo run build && node core/dist/cli/validate-identity-schema.js",
"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",
@@ -33,7 +34,7 @@
"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 modules:validate && pnpm migrations:validate && pnpm build"
"verify": "pnpm format:check && pnpm lint && pnpm boundaries && pnpm typecheck && pnpm test && pnpm modules:validate && pnpm migrations:validate && pnpm identity:schema:validate && pnpm build"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
+47
View File
@@ -0,0 +1,47 @@
import { z } from "zod";
export const ekklesiaUserIdSchema = z.string().trim().min(1).max(128);
export type EkklesiaUserId = z.infer<typeof ekklesiaUserIdSchema>;
export function parseEkklesiaUserId(input: unknown): EkklesiaUserId {
return ekklesiaUserIdSchema.parse(input);
}
export function safeParseEkklesiaUserId(input: unknown): z.ZodSafeParseResult<EkklesiaUserId> {
return ekklesiaUserIdSchema.safeParse(input);
}
export interface EkklesiaAnonymousPrincipal {
readonly kind: "anonymous";
}
export interface EkklesiaAuthenticatedPrincipal {
readonly kind: "authenticated";
readonly userId: EkklesiaUserId;
}
export type EkklesiaPrincipal = EkklesiaAnonymousPrincipal | EkklesiaAuthenticatedPrincipal;
export const ANONYMOUS_PRINCIPAL: EkklesiaAnonymousPrincipal = Object.freeze({
kind: "anonymous",
});
export function createAuthenticatedPrincipal(userId: unknown): EkklesiaAuthenticatedPrincipal {
return Object.freeze({
kind: "authenticated",
userId: parseEkklesiaUserId(userId),
});
}
export function isAnonymousPrincipal(
principal: EkklesiaPrincipal,
): principal is EkklesiaAnonymousPrincipal {
return principal.kind === "anonymous";
}
export function isAuthenticatedPrincipal(
principal: EkklesiaPrincipal,
): principal is EkklesiaAuthenticatedPrincipal {
return principal.kind === "authenticated";
}
+1
View File
@@ -1,4 +1,5 @@
export * from "./identifiers.js";
export * from "./identity.js";
export * from "./module-lifecycle.js";
export * from "./module-manifest.js";
export * from "./module-migration.js";
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
type EkklesiaPrincipal,
isAnonymousPrincipal,
isAuthenticatedPrincipal,
parseEkklesiaUserId,
safeParseEkklesiaUserId,
} from "../src/index.js";
describe("Ekklesia identity contracts", () => {
it("parses a valid opaque user id", () => {
expect(parseEkklesiaUserId("user_123")).toBe("user_123");
});
it("rejects an empty or oversized user id", () => {
expect(() => parseEkklesiaUserId("")).toThrow();
expect(() => parseEkklesiaUserId(" ")).toThrow();
expect(() => parseEkklesiaUserId("x".repeat(129))).toThrow();
expect(() => parseEkklesiaUserId(42)).toThrow();
expect(() => parseEkklesiaUserId(null)).toThrow();
expect(() => parseEkklesiaUserId(undefined)).toThrow();
});
it("exposes a safe-parse variant that never throws", () => {
expect(safeParseEkklesiaUserId("user_123").success).toBe(true);
const failed = safeParseEkklesiaUserId("");
expect(failed.success).toBe(false);
});
it("returns a frozen, stable anonymous principal singleton", () => {
expect(ANONYMOUS_PRINCIPAL).toEqual({ kind: "anonymous" });
expect(Object.isFrozen(ANONYMOUS_PRINCIPAL)).toBe(true);
expect(isAnonymousPrincipal(ANONYMOUS_PRINCIPAL)).toBe(true);
expect(isAuthenticatedPrincipal(ANONYMOUS_PRINCIPAL)).toBe(false);
});
it("creates a frozen authenticated principal carrying only the opaque user id", () => {
const principal = createAuthenticatedPrincipal("user_123");
expect(principal.kind).toBe("authenticated");
expect(principal.userId).toBe("user_123");
expect(Object.isFrozen(principal)).toBe(true);
expect(isAuthenticatedPrincipal(principal)).toBe(true);
expect(isAnonymousPrincipal(principal)).toBe(false);
expect(Object.keys(principal).sort()).toEqual(["kind", "userId"]);
});
it("rejects an invalid user id when creating an authenticated principal", () => {
expect(() => createAuthenticatedPrincipal("")).toThrow();
expect(() => createAuthenticatedPrincipal(42)).toThrow();
});
it("discriminates principals on kind without any role, permission, or profile fields", () => {
const principals: EkklesiaPrincipal[] = [
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal("user_123"),
];
for (const principal of principals) {
expect(["anonymous", "authenticated"]).toContain(principal.kind);
expect(Object.keys(principal)).not.toContain("email");
expect(Object.keys(principal)).not.toContain("roles");
expect(Object.keys(principal)).not.toContain("permissions");
expect(Object.keys(principal)).not.toContain("organizationId");
expect(Object.keys(principal)).not.toContain("sessionToken");
}
});
});
+1212 -16
View File
File diff suppressed because it is too large Load Diff