diff --git a/.gitignore b/.gitignore index a7bb05f..f3d95c1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ generated/* *.log .DS_Store + +core/.identity-schema-reference.ts +core/.identity-schema-check-* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3ffa340 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index fca2a3a..b8419df 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/core/drizzle.config.ts b/core/drizzle.config.ts new file mode 100644 index 0000000..c7436db --- /dev/null +++ b/core/drizzle.config.ts @@ -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, +}); diff --git a/core/drizzle/0000_identity_core_tables.sql b/core/drizzle/0000_identity_core_tables.sql new file mode 100644 index 0000000..3cbdbc6 --- /dev/null +++ b/core/drizzle/0000_identity_core_tables.sql @@ -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"); \ No newline at end of file diff --git a/core/drizzle/meta/0000_snapshot.json b/core/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..3d54aa0 --- /dev/null +++ b/core/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} diff --git a/core/drizzle/meta/_ekklesia.json b/core/drizzle/meta/_ekklesia.json new file mode 100644 index 0000000..07be324 --- /dev/null +++ b/core/drizzle/meta/_ekklesia.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "moduleId": "core", + "entries": [ + { + "tag": "0000_identity_core_tables", + "destructive": { + "mode": "none" + } + } + ] +} diff --git a/core/drizzle/meta/_journal.json b/core/drizzle/meta/_journal.json new file mode 100644 index 0000000..c6f7b2b --- /dev/null +++ b/core/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1784668552522, + "tag": "0000_identity_core_tables", + "breakpoints": true + } + ] +} diff --git a/core/identity-schema-generator.config.ts b/core/identity-schema-generator.config.ts new file mode 100644 index 0000000..312e2fe --- /dev/null +++ b/core/identity-schema-generator.config.ts @@ -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", +}); diff --git a/core/package.json b/core/package.json index 9d53074..5b830b8 100644 --- a/core/package.json +++ b/core/package.json @@ -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" }, diff --git a/core/src/cli/doctor.ts b/core/src/cli/doctor.ts index ac01541..570703a 100644 --- a/core/src/cli/doctor.ts +++ b/core/src/cli/doctor.ts @@ -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 { 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"), diff --git a/core/src/cli/migration-command-support.ts b/core/src/cli/migration-command-support.ts index 2bf7ffa..25bea43 100644 --- a/core/src/cli/migration-command-support.ts +++ b/core/src/cli/migration-command-support.ts @@ -1,6 +1,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; -import { discoverModuleMigrations, type ModuleMigrationAggregate } from "../modules/index.js"; +import { discoverAllMigrations } from "../migrations/index.js"; +import type { ModuleMigrationAggregate } from "../modules/index.js"; interface PackageVersion { readonly version?: unknown; @@ -22,9 +23,12 @@ export async function discoverRepositoryMigrations( readPackageVersion(path.join(repositoryRoot, "packages/module-sdk/package.json")), ]); - return discoverModuleMigrations({ - moduleSdkVersion, - modulesDirectory: path.join(repositoryRoot, "modules"), - platformVersion, + return discoverAllMigrations({ + coreMigrationsDirectory: path.join(repositoryRoot, "core", "drizzle"), + moduleDiscovery: { + moduleSdkVersion, + modulesDirectory: path.join(repositoryRoot, "modules"), + platformVersion, + }, }); } diff --git a/core/src/cli/validate-identity-schema.ts b/core/src/cli/validate-identity-schema.ts new file mode 100644 index 0000000..9f5ba22 --- /dev/null +++ b/core/src/cli/validate-identity-schema.ts @@ -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>; + 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 = {}; + 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 { + 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> }; + 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 { + 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 + >; + + 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; +}); diff --git a/core/src/diagnostics/doctor-report.ts b/core/src/diagnostics/doctor-report.ts index 921bf05..e9a5267 100644 --- a/core/src/diagnostics/doctor-report.ts +++ b/core/src/diagnostics/doctor-report.ts @@ -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"]), ]); diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index c8969f0..53498ac 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -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 { + 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, ]); } diff --git a/core/src/identity/config.ts b/core/src/identity/config.ts new file mode 100644 index 0000000..48485ef --- /dev/null +++ b/core/src/identity/config.ts @@ -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 = new Set([ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", +]); + +const errorMessages: Readonly> = 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 }); +} diff --git a/core/src/identity/database.ts b/core/src/identity/database.ts new file mode 100644 index 0000000..ff5312f --- /dev/null +++ b/core/src/identity/database.ts @@ -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; + +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; + 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, + }); +} diff --git a/core/src/identity/factory.ts b/core/src/identity/factory.ts new file mode 100644 index 0000000..528961f --- /dev/null +++ b/core/src/identity/factory.ts @@ -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 }); +} diff --git a/core/src/identity/schema.ts b/core/src/identity/schema.ts new file mode 100644 index 0000000..14083a6 --- /dev/null +++ b/core/src/identity/schema.ts @@ -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 better-auth@1.6.23 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; diff --git a/core/src/migrations/aggregate.ts b/core/src/migrations/aggregate.ts new file mode 100644 index 0000000..14f4cb1 --- /dev/null +++ b/core/src/migrations/aggregate.ts @@ -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 { + 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, + ]), + }); +} diff --git a/core/src/migrations/core-source.ts b/core/src/migrations/core-source.ts new file mode 100644 index 0000000..7afd218 --- /dev/null +++ b/core/src/migrations/core-source.ts @@ -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 { + const issues: ModuleMigrationDiscoveryIssue[] = []; + const migrations = await inspectMigrationsAtPath( + CORE_MIGRATION_SOURCE_ID, + options.coreMigrationsDirectory, + issues, + ); + + if (issues.length > 0) { + throw new ModuleMigrationDiscoveryError(issues); + } + + return migrations; +} diff --git a/core/src/migrations/index.ts b/core/src/migrations/index.ts index 5d48b03..8d5ab20 100644 --- a/core/src/migrations/index.ts +++ b/core/src/migrations/index.ts @@ -1,2 +1,4 @@ +export * from "./aggregate.js"; +export * from "./core-source.js"; export * from "./postgres-database.js"; export * from "./runner.js"; diff --git a/core/src/modules/migration-discovery.ts b/core/src/modules/migration-discovery.ts index c732585..1c7f9dd 100644 --- a/core/src/modules/migration-discovery.ts +++ b/core/src/modules/migration-discovery.ts @@ -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 { - 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 { + const migrationRoot = discovered.entrypoints.migrations; + if (!migrationRoot) { + return Object.freeze([]); + } + return inspectMigrationsAtPath(discovered.manifest.id, migrationRoot, issues); +} + export async function discoverModuleMigrations( options: ModuleDiscoveryOptions, ): Promise { diff --git a/core/tests/core-migration-discovery.test.ts b/core/tests/core-migration-discovery.test.ts new file mode 100644 index 0000000..6633404 --- /dev/null +++ b/core/tests/core-migration-discovery.test.ts @@ -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 { + 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 { + 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 { + 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); + }); +}); diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index eeb6a92..ac7349d 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -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. diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 748c15d..9854d2c 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -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(); + }); }); diff --git a/core/tests/identity-config.test.ts b/core/tests/identity-config.test.ts new file mode 100644 index 0000000..a975d76 --- /dev/null +++ b/core/tests/identity-config.test.ts @@ -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); + } + }); +}); diff --git a/core/tests/identity-database.test.ts b/core/tests/identity-database.test.ts new file mode 100644 index 0000000..1aeb807 --- /dev/null +++ b/core/tests/identity-database.test.ts @@ -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:do-not-log@db.example.test/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:do-not-log@example.test/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 }), + ); + }); +}); diff --git a/core/tests/identity-factory.test.ts b/core/tests/identity-factory.test.ts new file mode 100644 index 0000000..9cc4696 --- /dev/null +++ b/core/tests/identity-factory.test.ts @@ -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]; + 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:secret@db.internal/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"); + }); +}); diff --git a/core/tests/identity-schema.test.ts b/core/tests/identity-schema.test.ts new file mode 100644 index 0000000..bb11804 --- /dev/null +++ b/core/tests/identity-schema.test.ts @@ -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(); + } + }); +}); diff --git a/core/tests/postgres-migration.integration.ts b/core/tests/postgres-migration.integration.ts index 2b47f66..bc8a61d 100644 --- a/core/tests/postgres-migration.integration.ts +++ b/core/tests/postgres-migration.integration.ts @@ -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 { + 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 { + const result = await safeQuery( + 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); + }); + }); +}); diff --git a/core/tsconfig.json b/core/tsconfig.json index 92cc584..6d11db5 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -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" + ] } diff --git a/docs/architecture/decisions/0010-core-identity-and-better-auth-adoption.md b/docs/architecture/decisions/0010-core-identity-and-better-auth-adoption.md new file mode 100644 index 0000000..b256d0d --- /dev/null +++ b/docs/architecture/decisions/0010-core-identity-and-better-auth-adoption.md @@ -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 `better-auth@1.6.23` +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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index ae236a0..14e3e84 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index cefd0db..0203ca5 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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. diff --git a/docs/development/identity.md b/docs/development/identity.md new file mode 100644 index 0000000..00a9ee9 --- /dev/null +++ b/docs/development/identity.md @@ -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 +`better-auth@1.6.23` 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. diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index 4e59eac..c93540b 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -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`: diff --git a/docs/development/testing.md b/docs/development/testing.md index 32737a2..c90f69f 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -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: diff --git a/package.json b/package.json index 696ba97..79b13c0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/contracts/src/identity.ts b/packages/contracts/src/identity.ts new file mode 100644 index 0000000..d9cce00 --- /dev/null +++ b/packages/contracts/src/identity.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +export const ekklesiaUserIdSchema = z.string().trim().min(1).max(128); + +export type EkklesiaUserId = z.infer; + +export function parseEkklesiaUserId(input: unknown): EkklesiaUserId { + return ekklesiaUserIdSchema.parse(input); +} + +export function safeParseEkklesiaUserId(input: unknown): z.ZodSafeParseResult { + 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"; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 91ff094..5cf678a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -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"; diff --git a/packages/contracts/tests/identity.test.ts b/packages/contracts/tests/identity.test.ts new file mode 100644 index 0000000..fbea4db --- /dev/null +++ b/packages/contracts/tests/identity.test.ts @@ -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"); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79bb613..ed9cd3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) yaml: specifier: 2.9.0 version: 2.9.0 @@ -32,9 +32,12 @@ importers: '@ekklesia/contracts': specifier: workspace:* version: link:../packages/contracts + better-auth: + specifier: 1.6.23 + version: 1.6.23(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(pg@8.22.0)(vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))) drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(pg@8.22.0) + version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) pg: specifier: 8.22.0 version: 8.22.0 @@ -48,18 +51,24 @@ importers: '@types/pg': specifier: 8.20.0 version: 8.20.0 + auth: + specifier: 1.6.23 + version: 1.6.23(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1)(pg@8.22.0)(vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))) + drizzle-kit: + specifier: 0.31.10 + version: 0.31.10 typescript: specifier: 7.0.2 version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages/contracts: dependencies: drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(pg@8.22.0) + version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) semver: specifier: 7.8.5 version: 7.8.5 @@ -81,7 +90,7 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages/module-sdk: dependencies: @@ -97,13 +106,13 @@ importers: version: 24.13.3 drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(pg@8.22.0) + version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) typescript: specifier: 7.0.2 version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages/typescript-config: {} @@ -114,7 +123,7 @@ importers: version: link:../../packages/module-sdk drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@types/pg@8.20.0)(pg@8.22.0) + version: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) devDependencies: '@biomejs/biome': specifier: 2.5.3 @@ -133,10 +142,248 @@ importers: version: 7.0.2 vitest: specifier: 4.1.10 - version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + version: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages: + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.29.7': + resolution: {integrity: sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-development@7.29.7': + resolution: {integrity: sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.29.7': + resolution: {integrity: sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-pure-annotations@7.29.7': + resolution: {integrity: sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-react@7.29.7': + resolution: {integrity: sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.29.7': + resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@better-auth/core@1.6.23': + resolution: {integrity: sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==} + peerDependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.3.7 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.6.23': + resolution: {integrity: sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==} + peerDependencies: + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.6.23': + resolution: {integrity: sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==} + peerDependencies: + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.6.23': + resolution: {integrity: sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==} + peerDependencies: + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.6.23': + resolution: {integrity: sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==} + peerDependencies: + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.6.23': + resolution: {integrity: sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==} + peerDependencies: + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.6.23': + resolution: {integrity: sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==} + peerDependencies: + '@better-auth/core': ^1.6.23 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + + '@better-fetch/fetch@1.3.1': + resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@biomejs/biome@2.5.3': resolution: {integrity: sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A==} engines: {node: '>=14.21.3'} @@ -194,6 +441,24 @@ packages: cpu: [x64] os: [win32] + '@chevrotain/cst-dts-gen@10.5.0': + resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} + + '@chevrotain/gast@10.5.0': + resolution: {integrity: sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==} + + '@chevrotain/types@10.5.0': + resolution: {integrity: sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==} + + '@chevrotain/utils@10.5.0': + resolution: {integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==} + + '@clack/core@0.5.0': + resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} + + '@clack/prompts@0.11.0': + resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -658,15 +923,44 @@ packages: cpu: [x64] os: [win32] + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mrleebo/prisma-ast@0.13.1': + resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} + engines: {node: '>=16'} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@noble/ciphers@2.2.0': + resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} @@ -975,20 +1269,168 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + auth@1.6.23: + resolution: {integrity: sha512-HJHK9nCD5/7ZCfy7k+0lPY3miNDAt9erDOkWPn6y2U+FRkdjVS74sZGkZEfhmK7jaBVJ2d9B8AvY1C5/thHLSA==} + hasBin: true + + baseline-browser-mapping@2.10.44: + resolution: {integrity: sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-auth@1.6.23: + resolution: {integrity: sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: ^0.45.2 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.3.7: + resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + browserslist@4.28.6: + resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chevrotain@10.5.0: + resolution: {integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -1085,6 +1527,9 @@ packages: sqlite3: optional: true + electron-to-chromium@1.5.393: + resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} + es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} @@ -1103,6 +1548,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1110,6 +1559,9 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + exsolve@1.1.0: + resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1124,9 +1576,59 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + giget@3.3.0: + resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kysely@0.29.4: + resolution: {integrity: sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==} + engines: {node: '>=22.0.0'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1201,21 +1703,52 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.4.1: + resolution: {integrity: sha512-PGd3uPojJB9Z07d5NX3Db/SOSBbyy3wLMUGq0GpnEEJfVzY9mq7daPMAZ3jObV5D3Jn+YKND636eI5ULg7F80Q==} + engines: {node: ^20.0.0 || >=22.0.0} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -1257,6 +1790,9 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + postcss@8.5.17: resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} engines: {node: ^10 || ^12 || >=14} @@ -1277,6 +1813,25 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + prettier@3.9.5: + resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==} + engines: {node: '>=14'} + hasBin: true + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + regexp-to-ast@0.5.0: + resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1285,14 +1840,31 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1349,6 +1921,12 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + vite@8.1.4: resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1438,20 +2016,315 @@ packages: engines: {node: '>=8'} hasBin: true + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yocto-spinner@0.2.3: + resolution: {integrity: sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==} + engines: {node: '>=18.19'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} snapshots: + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.6 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-development@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-pure-annotations@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/preset-react@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-pure-annotations': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.7(zod@4.4.3) + jose: 6.2.3 + kysely: 0.29.4 + nanostores: 1.4.1 + zod: 4.4.3 + + '@better-auth/drizzle-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))': + dependencies: + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/utils': 0.4.2 + optionalDependencies: + drizzle-orm: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) + + '@better-auth/kysely-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + dependencies: + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.4 + + '@better-auth/memory-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/utils': 0.4.2 + + '@better-auth/prisma-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/utils': 0.4.2 + + '@better-auth/telemetry@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + dependencies: + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.2.0 + + '@better-fetch/fetch@1.3.1': {} + '@biomejs/biome@2.5.3': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.5.3 @@ -1487,6 +2360,32 @@ snapshots: '@biomejs/cli-win32-x64@2.5.3': optional: true + '@chevrotain/cst-dts-gen@10.5.0': + dependencies: + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/gast@10.5.0': + dependencies: + '@chevrotain/types': 10.5.0 + lodash: 4.17.21 + + '@chevrotain/types@10.5.0': {} + + '@chevrotain/utils@10.5.0': {} + + '@clack/core@0.5.0': + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + + '@clack/prompts@0.11.0': + dependencies: + '@clack/core': 0.5.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.11.1': @@ -1737,8 +2636,30 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mrleebo/prisma-ast@0.13.1': + dependencies: + chevrotain: 10.5.0 + lilconfig: 2.1.0 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -1746,6 +2667,12 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@noble/ciphers@2.2.0': {} + + '@noble/hashes@2.2.0': {} + + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxc-project/types@0.139.0': {} '@rolldown/binding-android-arm64@1.1.5': @@ -1914,13 +2841,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -1948,14 +2875,172 @@ snapshots: assertion-error@2.0.1: {} + auth@1.6.23(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1)(pg@8.22.0)(vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))): + dependencies: + '@babel/core': 7.29.7 + '@babel/preset-react': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/telemetry': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@clack/prompts': 0.11.0 + '@mrleebo/prisma-ast': 0.13.1 + better-auth: 1.6.23(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(pg@8.22.0)(vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))) + c12: 3.3.4 + chalk: 5.6.2 + commander: 12.1.0 + dotenv: 17.4.2 + get-tsconfig: 4.14.0 + open: 10.2.0 + prettier: 3.9.5 + prompts: 2.4.2 + semver: 7.8.5 + yocto-spinner: 0.2.3 + zod: 4.4.3 + transitivePeerDependencies: + - '@better-fetch/fetch' + - '@cloudflare/workers-types' + - '@lynx-js/react' + - '@opentelemetry/api' + - '@prisma/client' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@tanstack/solid-start' + - better-call + - better-sqlite3 + - drizzle-kit + - drizzle-orm + - jose + - kysely + - magicast + - mongodb + - mysql2 + - nanostores + - next + - pg + - prisma + - react + - react-dom + - solid-js + - supports-color + - svelte + - vitest + - vue + + baseline-browser-mapping@2.10.44: {} + + better-auth@1.6.23(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0))(pg@8.22.0)(vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0))): + dependencies: + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0)) + '@better-auth/kysely-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.7(zod@4.4.3) + defu: 6.1.7 + jose: 6.2.3 + kysely: 0.29.4 + nanostores: 1.4.1 + zod: 4.4.3 + optionalDependencies: + drizzle-kit: 0.31.10 + drizzle-orm: 0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0) + pg: 8.22.0 + vitest: 4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-call@1.3.7(zod@4.4.3): + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + rou3: 0.7.12 + set-cookie-parser: 3.1.2 + optionalDependencies: + zod: 4.4.3 + + browserslist@4.28.6: + dependencies: + baseline-browser-mapping: 2.10.44 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.393 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) + buffer-from@1.1.2: {} + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + c12@3.3.4: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.1.0 + giget: 3.3.0 + jiti: 2.7.0 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + + caniuse-lite@1.0.30001806: {} + chai@6.2.2: {} + chalk@5.6.2: {} + + chevrotain@10.5.0: + dependencies: + '@chevrotain/cst-dts-gen': 10.5.0 + '@chevrotain/gast': 10.5.0 + '@chevrotain/types': 10.5.0 + '@chevrotain/utils': 10.5.0 + lodash: 4.17.21 + regexp-to-ast: 0.5.0 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + commander@12.1.0: {} + + confbox@0.2.4: {} + convert-source-map@2.0.0: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + + destr@2.0.5: {} + detect-libc@2.1.2: {} + dotenv@17.4.2: {} + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -1963,11 +3048,14 @@ snapshots: esbuild: 0.25.12 tsx: 4.23.1 - drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.22.0): + drizzle-orm@0.45.2(@types/pg@8.20.0)(kysely@0.29.4)(pg@8.22.0): optionalDependencies: '@types/pg': 8.20.0 + kysely: 0.29.4 pg: 8.22.0 + electron-to-chromium@1.5.393: {} + es-module-lexer@2.3.0: {} esbuild@0.18.20: @@ -2053,12 +3141,16 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 expect-type@1.4.0: {} + exsolve@1.1.0: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -2066,10 +3158,38 @@ snapshots: fsevents@2.3.3: optional: true + gensync@1.0.0-beta.2: {} + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 + giget@3.3.0: {} + + is-docker@3.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + jiti@2.7.0: {} + + jose@6.2.3: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + kleur@3.0.3: {} + + kysely@0.29.4: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -2119,16 +3239,41 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@2.1.0: {} + + lodash@4.17.21: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + ms@2.1.3: {} + nanoid@3.3.15: {} + nanostores@1.4.1: {} + + node-releases@2.0.51: {} + obug@2.1.3: {} + ohash@2.0.11: {} + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + pathe@2.0.3: {} + perfect-debounce@2.1.0: {} + pg-cloudflare@1.4.0: optional: true @@ -2168,6 +3313,12 @@ snapshots: picomatch@4.0.5: {} + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.0 + pathe: 2.0.3 + postcss@8.5.17: dependencies: nanoid: 3.3.15 @@ -2184,6 +3335,22 @@ snapshots: dependencies: xtend: 4.0.2 + prettier@3.9.5: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + readdirp@5.0.0: {} + + regexp-to-ast@0.5.0: {} + resolve-pkg-maps@1.0.0: {} rolldown@1.1.5: @@ -2207,10 +3374,20 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.5 '@rolldown/binding-win32-x64-msvc': 1.1.5 + rou3@0.7.12: {} + + run-applescript@7.1.0: {} + + semver@6.3.1: {} + semver@7.8.5: {} + set-cookie-parser@3.1.2: {} + siginfo@2.0.0: {} + sisteransi@1.0.5: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -2280,7 +3457,13 @@ snapshots: undici-types@7.18.2: {} - vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0): + update-browserslist-db@1.2.3(browserslist@4.28.6): + dependencies: + browserslist: 4.28.6 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -2291,13 +3474,14 @@ snapshots: '@types/node': 24.13.3 esbuild: 0.28.1 fsevents: 2.3.3 + jiti: 2.7.0 tsx: 4.23.1 yaml: 2.9.0 - vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@types/node@24.13.3)(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -2314,7 +3498,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.9.0) + vite: 8.1.4(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.3 @@ -2326,8 +3510,20 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + xtend@4.0.2: {} + yallist@3.1.1: {} + yaml@2.9.0: {} + yocto-spinner@0.2.3: + dependencies: + yoctocolors: 2.1.2 + + yoctocolors@2.1.2: {} + zod@4.4.3: {}