feat: establish core identity foundation #7
@@ -13,3 +13,6 @@ generated/*
|
||||
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
core/.identity-schema-reference.ts
|
||||
core/.identity-schema-check-*
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"moduleId": "core",
|
||||
"entries": [
|
||||
{
|
||||
"tag": "0000_identity_core_tables",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1784668552522,
|
||||
"tag": "0000_identity_core_tables",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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({
|
||||
return discoverAllMigrations({
|
||||
coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"),
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion,
|
||||
modulesDirectory: path.join(repositoryRoot, "modules"),
|
||||
platformVersion,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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"]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
]),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from "./aggregate.js";
|
||||
export * from "./core-source.js";
|
||||
export * from "./postgres-database.js";
|
||||
export * from "./runner.js";
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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`:
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
Generated
+1212
-16
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user