diff --git a/CLAUDE.md b/CLAUDE.md index 3ffa340..00b8e16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,12 +71,15 @@ 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 test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery, + planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal + resolution, sign-out). Requires Docker. Run this for any change touching either area. - `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. +- `pnpm api:start` — starts the identity HTTP transport against an already-migrated `DATABASE_URL`. + It never applies migrations itself. Never run it without the user's explicit intent. ## When you're unsure diff --git a/README.md b/README.md index b8419df..eeb42cf 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ surfaces. ## Current status -Phase 3 is complete and Phase 4A (Core identity foundation) has landed. The repository includes +Phase 3 is complete, and Phase 4A (Core identity foundation) and Phase 4B (identity HTTP +transport) have 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 @@ -19,16 +20,25 @@ concurrency and release, exact destructive approvals, and sanitization against t 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 +Phase 4A added 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. +[ADR 0010](docs/architecture/decisions/0010-core-identity-and-better-auth-adoption.md). + +Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root +(`pnpm api:start`) mounts Better Auth at `/api/auth/*` and exposes `GET /api/v1/principal` behind +Core's own Fetch-compatible identity runtime — Core still does not depend on Hono, and modules +still cannot reach any of this. Public email/password registration is intentional; every account +receives identical lowest-privilege baseline status and signup order never grants elevation. See +[API](docs/development/api.md) and +[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md). +**No email verification, password recovery, Dashboard, or role/permission model exists yet** — +that, along with production approval-provider composition, jobs, physical storage provisioning, +surfaces, and feature modules, remains later vertical slices. Public registration is not yet ready +for a real public launch. ## Quick start @@ -113,6 +123,11 @@ CLI into a temporary location and structurally compares it against the checked-i and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade workflow. +`pnpm api:start` builds the workspace, then starts the identity HTTP transport +(`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated +`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for +required configuration, routes, and a full request/response transcript. + Run `pnpm infrastructure:smoke` for an isolated end-to-end PostgreSQL and authenticated S3 check. The development services bind only to loopback: @@ -130,6 +145,7 @@ environment. - [Repository workflow](docs/development/repository-workflow.md) - [Testing](docs/development/testing.md) - [Identity](docs/development/identity.md) +- [API](docs/development/api.md) - [Module development](docs/module-development/README.md) - [Architecture decisions](docs/architecture/decisions/README.md) diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..6a7a28d --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,39 @@ +{ + "name": "@ekklesia/api", + "version": "0.1.0", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/app.d.ts", + "import": "./dist/app.js" + } + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "lint": "biome lint .", + "test": "vitest run", + "typecheck": "tsc --noEmit --project tsconfig.json" + }, + "dependencies": { + "@ekklesia/contracts": "workspace:*", + "@ekklesia/core": "workspace:*", + "@hono/node-server": "2.0.10", + "hono": "4.12.31" + }, + "devDependencies": { + "@biomejs/biome": "2.5.3", + "@ekklesia/typescript-config": "workspace:*", + "@types/node": "24.13.3", + "@types/pg": "8.20.0", + "pg": "8.22.0", + "typescript": "7.0.2", + "vitest": "4.1.10" + }, + "ekklesia": { + "layer": "app" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..166b01f --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,72 @@ +import { isAuthenticatedPrincipal } from "@ekklesia/contracts"; +import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; +import { Hono } from "hono"; +import type { Context } from "hono"; +import { cors } from "hono/cors"; + +export interface CreateApiAppOptions { + readonly runtime: EkklesiaIdentityRuntime; + /** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an + * exact, reflected match is ever returned in `Access-Control-Allow-Origin`. */ + readonly trustedOrigins: readonly string[]; +} + +function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) { + const code = error instanceof IdentityRuntimeError ? error.code : "INTERNAL_ERROR"; + c.header("Cache-Control", "no-store"); + return c.json({ code }, status); +} + +/** + * Pure Hono application factory: no I/O, no environment reads, no process side effects. Suitable + * for unit and integration tests via `app.fetch` directly, with a fully injected identity runtime + * standing in for real Better Auth/PostgreSQL where appropriate. + */ +export function createApiApp(options: CreateApiAppOptions): Hono { + const { runtime } = options; + const allowedOrigins = new Set(options.trustedOrigins); + + const app = new Hono(); + + const corsMiddleware = cors({ + allowHeaders: ["Content-Type"], + allowMethods: ["GET", "POST", "OPTIONS"], + credentials: true, + maxAge: 600, + origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined), + }); + app.use("/api/auth/*", corsMiddleware); + app.use("/api/v1/*", corsMiddleware); + + app.on(["POST", "GET"], "/api/auth/*", async (c) => { + try { + return await runtime.handleAuthRequest(c.req.raw); + } catch (error) { + return sanitizedErrorResponse(c, error, 500); + } + }); + + app.get("/api/v1/principal", async (c) => { + c.header("Cache-Control", "no-store"); + c.header("Vary", "Origin, Cookie"); + try { + const principal = await runtime.resolvePrincipal(c.req.raw.headers); + return c.json( + isAuthenticatedPrincipal(principal) + ? { kind: "authenticated" as const, userId: principal.userId } + : { kind: "anonymous" as const }, + 200, + ); + } catch (error) { + return sanitizedErrorResponse(c, error, 503); + } + }); + + app.notFound((c) => { + c.header("Cache-Control", "no-store"); + return c.json({ code: "NOT_FOUND" }, 404); + }); + app.onError((_error, c) => sanitizedErrorResponse(c, _error, 500)); + + return app; +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..1843153 --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,117 @@ +import { + type CreateIdentityConfigOptions, + createIdentityConfig, + type IdentityConfig, + type IdentityConfigInput, +} from "@ekklesia/core/identity"; + +export type ApiConfigErrorCode = + | "DATABASE_URL_INVALID" + | "DATABASE_URL_MISSING" + | "HOST_INVALID" + | "PORT_INVALID"; + +/** Never carries the offending database URL, host, port, or any other caller-supplied value. */ +export class ApiConfigError extends Error { + readonly code: ApiConfigErrorCode; + + constructor(code: ApiConfigErrorCode) { + super(apiConfigErrorMessages[code]); + this.name = "ApiConfigError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +const apiConfigErrorMessages: Readonly> = Object.freeze({ + DATABASE_URL_INVALID: + "The API database URL must be an absolute postgres:// or postgresql:// URL.", + DATABASE_URL_MISSING: "The API database URL is required.", + HOST_INVALID: "The API listen address must be a non-empty host.", + PORT_INVALID: + "The API listen port must be an integer between 0 and 65535 (0 requests an OS-assigned port).", +}); + +const defaultHost = "127.0.0.1"; +const defaultPort = 3000; + +export interface ApiRuntimeConfig { + readonly databaseUrl: string; + readonly host: string; + readonly identity: IdentityConfig; + readonly port: number; +} + +export interface ApiRuntimeConfigInput { + readonly databaseUrl?: unknown; + readonly host?: unknown; + readonly identity: IdentityConfigInput; + readonly port?: unknown; +} + +export interface CreateApiRuntimeConfigOptions { + readonly environment?: CreateIdentityConfigOptions["environment"]; +} + +function validateDatabaseUrl(databaseUrl: unknown): string { + if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) { + throw new ApiConfigError("DATABASE_URL_MISSING"); + } + let protocol: string; + try { + protocol = new URL(databaseUrl).protocol; + } catch { + throw new ApiConfigError("DATABASE_URL_INVALID"); + } + if (protocol !== "postgres:" && protocol !== "postgresql:") { + throw new ApiConfigError("DATABASE_URL_INVALID"); + } + return databaseUrl; +} + +function validateHost(host: unknown): string { + if (host === undefined) { + return defaultHost; + } + if (typeof host !== "string" || host.trim().length === 0) { + throw new ApiConfigError("HOST_INVALID"); + } + return host; +} + +function validatePort(port: unknown): number { + if (port === undefined) { + return defaultPort; + } + const numericPort = typeof port === "string" ? Number(port) : port; + if ( + typeof numericPort !== "number" || + !Number.isInteger(numericPort) || + numericPort < 0 || + numericPort > 65_535 + ) { + throw new ApiConfigError("PORT_INVALID"); + } + return numericPort; +} + +/** + * Validates explicit, typed API runtime configuration, delegating identity fields to Core's + * existing `createIdentityConfig` rather than defining a competing identity configuration + * contract. Performs no environment reads, network calls, or database access. + */ +export function createApiRuntimeConfig( + input: ApiRuntimeConfigInput, + options: CreateApiRuntimeConfigOptions = {}, +): ApiRuntimeConfig { + const databaseUrl = validateDatabaseUrl(input.databaseUrl); + const host = validateHost(input.host); + const port = validatePort(input.port); + const identity = createIdentityConfig( + input.identity, + options.environment === undefined ? {} : { environment: options.environment }, + ); + + return Object.freeze({ databaseUrl, host, identity, port }); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..dada6eb --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,168 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { + createIdentityDrizzleDatabase, + createIdentityRuntime, + type IdentityDatabaseHandle, + type IdentityRateLimitOverride, +} from "@ekklesia/core/identity"; +import { serve, type ServerType } from "@hono/node-server"; +import type { Hono } from "hono"; +import { createApiApp } from "./app.js"; +import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; + +const connectionTimeoutMs = 10_000; + +export interface RunningApi { + readonly app: Hono; + readonly server: ServerType; + shutdown(): Promise; +} + +export interface StartApiOptions { + /** Test-only escape hatch: never set by the executable entry point. See + * `IdentityRateLimitOverride` for why production must leave this unset. */ + readonly rateLimitOverride?: IdentityRateLimitOverride; +} + +async function safeCloseDatabase(handle: IdentityDatabaseHandle): Promise { + try { + await handle.close(); + } catch { + // Cleanup failures are swallowed here deliberately: they must never mask a startup failure + // that is already propagating, and there is no raw driver detail safe to surface. + } +} + +function bindServer(app: Hono, host: string, port: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const server = serve({ fetch: app.fetch, hostname: host, port }, () => { + if (!settled) { + settled = true; + resolve(server); + } + }); + server.once("error", () => { + if (!settled) { + settled = true; + reject(new Error("The API server failed to start listening.")); + } + }); + }); +} + +/** + * Explicit runtime composition: constructs the PostgreSQL pool and Drizzle database, the Core + * identity runtime, and the Hono application, then starts the Node HTTP server. Returns a handle + * with an idempotent `shutdown()` so both the executable entry point and tests can stop the + * server and close the database pool deterministically. Never applies migrations or generates + * schema — the database must already be migrated through `pnpm migrations:apply`. + */ +export async function startApi( + config: ApiRuntimeConfig, + options: StartApiOptions = {}, +): Promise { + const databaseHandle = createIdentityDrizzleDatabase(config.databaseUrl, { + connectionTimeoutMs, + }); + + let runtime: ReturnType; + try { + runtime = createIdentityRuntime({ + config: config.identity, + db: databaseHandle.db, + ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}), + }); + } catch (error) { + await safeCloseDatabase(databaseHandle); + throw error; + } + + const app = createApiApp({ runtime, trustedOrigins: config.identity.trustedOrigins }); + + let server: ServerType; + try { + server = await bindServer(app, config.host, config.port); + } catch (error) { + await safeCloseDatabase(databaseHandle); + throw error; + } + + let shuttingDown = false; + const shutdown = async (): Promise => { + if (shuttingDown) { + return; + } + shuttingDown = true; + await new Promise((resolve) => { + server.close(() => resolve()); + }); + await safeCloseDatabase(databaseHandle); + }; + + return Object.freeze({ app, server, shutdown }); +} + +function readConfigFromEnvironment(): { + readonly input: Parameters[0]; + readonly environment: "development" | "production"; +} { + const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS; + const trustedOrigins = trustedOriginsValue + ?.split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); + + return { + environment: process.env.NODE_ENV === "development" ? "development" : "production", + input: { + databaseUrl: process.env.DATABASE_URL, + host: process.env.EKKLESIA_API_HOST, + identity: { + baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL, + secret: process.env.EKKLESIA_IDENTITY_SECRET, + trustedOrigins, + }, + port: process.env.EKKLESIA_API_PORT, + }, + }; +} + +export async function main(): Promise { + const { environment, input } = readConfigFromEnvironment(); + const config = createApiRuntimeConfig(input, { environment }); + const running = await startApi(config); + + const address = running.server.address(); + const boundPort = address && typeof address === "object" ? address.port : config.port; + console.log(`Ekklesia API listening on ${config.host}:${boundPort}`); + + let signalReceived = false; + const handleSignal = (): void => { + if (signalReceived) { + return; + } + signalReceived = true; + running + .shutdown() + .then(() => process.exit(0)) + .catch(() => process.exit(1)); + }; + process.on("SIGINT", handleSignal); + process.on("SIGTERM", handleSignal); +} + +function isDirectExecution(): boolean { + const entrypoint = process.argv[1]; + return ( + entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href + ); +} + +if (isDirectExecution()) { + main().catch(() => { + console.error("Ekklesia API failed to start."); + process.exitCode = 1; + }); +} diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts new file mode 100644 index 0000000..c53a031 --- /dev/null +++ b/apps/api/tests/api-http.integration.ts @@ -0,0 +1,631 @@ +import { randomBytes } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { + applyModuleMigrations, + createPostgresMigrationDatabase, + discoverAllMigrations, +} from "@ekklesia/core/migrations"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { Client, type QueryResultRow } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { createApiRuntimeConfig } from "../src/config.js"; +import { startApi, type RunningApi } from "../src/server.js"; + +const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; +const coreMigrationsDirectory = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "..", + "core", + "drizzle", +); +const trackedDatabases = new Set(); +let databaseCounter = 0; + +class IntegrationHarnessError extends Error { + constructor(message: string) { + super(message); + this.name = "IntegrationHarnessError"; + this.stack = `${this.name}: ${this.message}`; + } +} + +function harnessError(message: string): IntegrationHarnessError { + return new IntegrationHarnessError(message); +} + +function baseDatabaseUrl(): string { + const value = process.env[integrationDatabaseUrlEnvironment]; + if (typeof value !== "string" || value.trim() === "") { + throw harnessError("The API integration database is not configured."); + } + try { + const parsed = new URL(value); + if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") { + throw new TypeError("Unsupported protocol."); + } + } catch { + throw harnessError("The API integration database configuration is invalid."); + } + return value; +} + +function quoteDatabaseName(databaseName: string): string { + if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) { + throw harnessError("The generated integration database name is invalid."); + } + return `"${databaseName}"`; +} + +function makeDatabaseName(label: string): string { + const safeLabel = label + .toLowerCase() + .replaceAll(/[^a-z0-9]+/gu, "_") + .replaceAll(/^_+|_+$/gu, "") + .slice(0, 14); + databaseCounter += 1; + return `ekk_api_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`; +} + +function databaseUrl(databaseName: string): string { + const parsed = new URL(baseDatabaseUrl()); + parsed.pathname = `/${databaseName}`; + parsed.searchParams.delete("application_name"); + return parsed.toString(); +} + +async function openAdministrativeClient(connectionString: string): Promise { + const client = new Client({ connectionString }); + client.on("error", () => undefined); + try { + await client.connect(); + return client; + } catch { + try { + await client.end(); + } catch { + // The public harness failure remains static when connection cleanup also fails. + } + throw harnessError("The API integration database connection failed."); + } +} + +async function safeQuery( + client: Client, + text: string, + values: unknown[] = [], +): Promise { + try { + const result = await client.query(text, values); + return result.rows; + } catch { + throw harnessError("An integration database administration operation failed."); + } +} + +async function safeEnd(client: Client): Promise { + try { + await client.end(); + } catch { + throw harnessError("An integration database connection could not be closed."); + } +} + +async function createDatabase(databaseName: string): Promise { + const admin = await openAdministrativeClient(baseDatabaseUrl()); + try { + await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`); + } finally { + await safeEnd(admin); + } +} + +async function dropDatabase(databaseName: string): Promise { + const admin = await openAdministrativeClient(baseDatabaseUrl()); + try { + await safeQuery( + admin, + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()", + [databaseName], + ); + await safeQuery( + admin, + `DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`, + ); + trackedDatabases.delete(databaseName); + } finally { + await safeEnd(admin); + } +} + +async function activeConnectionCount(databaseName: string): Promise { + const admin = await openAdministrativeClient(baseDatabaseUrl()); + try { + const rows = await safeQuery<{ count: string }>( + admin, + "SELECT count(*)::text AS count FROM pg_stat_activity WHERE datname = $1", + [databaseName], + ); + return Number(rows[0]?.count ?? "0"); + } finally { + await safeEnd(admin); + } +} + +async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> { + const name = makeDatabaseName(label); + await createDatabase(name); + trackedDatabases.add(name); + return { name, url: databaseUrl(name) }; +} + +async function applyCoreMigrations(url: string): Promise { + const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-no-modules-")); + try { + const aggregate = await discoverAllMigrations({ + coreMigrationsDirectory, + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory: emptyModulesDirectory, + platformVersion: "0.1.0", + }, + }); + // Phase 4A's committed migration must not have drifted while building this transport slice. + expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ + "core:0000_identity_core_tables", + ]); + expect(aggregate.migrations[0]?.checksum).toBe( + "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", + ); + await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); + } finally { + await rm(emptyModulesDirectory, { recursive: true, force: true }); + } +} + +afterAll(async () => { + for (const databaseName of [...trackedDatabases]) { + await dropDatabase(databaseName).catch(() => undefined); + } +}); + +const identityBaseUrl = "https://identity.example.test"; +const dashboardOrigin = "https://dashboard.example.test"; +const untrustedOrigin = "https://evil.example.test"; + +function apiOrigin(running: RunningApi): string { + const address = running.server.address(); + if (!address || typeof address !== "object") { + throw harnessError("The API integration server has no bound address."); + } + return `http://127.0.0.1:${address.port}`; +} + +async function startMigratedApi(url: string): Promise { + const config = createApiRuntimeConfig( + { + databaseUrl: url, + host: "127.0.0.1", + identity: { + baseUrl: identityBaseUrl, + secret: "integration-test-secret-0123456789abcdefgh", + trustedOrigins: [identityBaseUrl, dashboardOrigin], + }, + port: 0, + }, + { environment: "production" }, + ); + // A narrow, explicit test-only override: production composition (server.ts's `main`) never + // sets this. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would + // otherwise reject this suite's many sequential requests. + return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } }); +} + +function setCookieValue(response: Response): string | undefined { + return response.headers.get("set-cookie") ?? undefined; +} + +function firstCookiePair(setCookieHeader: string): string { + return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? ""; +} + +interface SignUpResult { + readonly response: Response; + readonly body: { token: string | null; user: { id: string; email: string } }; +} + +async function signUp( + origin: string, + email: string, + password: string, + headers: Record = {}, +): Promise { + const response = await fetch(`${origin}/api/auth/sign-up/email`, { + body: JSON.stringify({ email, name: "Integration Test User", password }), + headers: { "content-type": "application/json", ...headers }, + method: "POST", + }); + return { body: (await response.clone().json()) as SignUpResult["body"], response }; +} + +interface SignInResult { + readonly cookiePair: string | undefined; + readonly response: Response; +} + +async function signIn( + origin: string, + email: string, + password: string, + headers: Record = {}, +): Promise { + const response = await fetch(`${origin}/api/auth/sign-in/email`, { + body: JSON.stringify({ email, password }), + headers: { + "content-type": "application/json", + origin: dashboardOrigin, + ...headers, + }, + method: "POST", + }); + const setCookie = setCookieValue(response); + return { cookiePair: setCookie ? firstCookiePair(setCookie) : undefined, response }; +} + +async function fetchPrincipal( + origin: string, + headers: Record = {}, +): Promise<{ body: unknown; response: Response }> { + const response = await fetch(`${origin}/api/v1/principal`, { headers }); + return { body: await response.clone().json(), response }; +} + +describe.sequential("live API — signup, sign-in, principal, sign-out", () => { + let database: { name: string; url: string }; + let running: RunningApi; + let origin: string; + const emailOne = "alice@example.test"; + const emailTwo = "bob@example.test"; + const password = "correct horse battery staple"; + + it("applies migrations and starts the API against a freshly migrated database", async () => { + database = await createDisposableDatabase("signup_flow"); + await applyCoreMigrations(database.url); + running = await startMigratedApi(database.url); + origin = apiOrigin(running); + + expect(running.server.listening).toBe(true); + }); + + it("returns the anonymous principal shape before any sign-in", async () => { + const { body, response } = await fetchPrincipal(origin); + expect(response.status).toBe(200); + expect(body).toEqual({ kind: "anonymous" }); + }); + + it("publicly creates the first ordinary identity via sign-up, with no automatic session (autoSignIn: false)", async () => { + const { body, response } = await signUp(origin, emailOne, password); + + expect(response.status).toBe(200); + expect(body.token).toBeNull(); + expect(body.user.email).toBe(emailOne); + expect(setCookieValue(response)).toBeUndefined(); + }); + + it("does not sign in automatically after sign-up", async () => { + const { body } = await fetchPrincipal(origin); + expect(body).toEqual({ kind: "anonymous" }); + }); + + it("rejects wrong credentials without leaking raw internal information", async () => { + const { response } = await signIn(origin, emailOne, "wrong password entirely"); + const text = await response.text(); + + expect(response.status).toBe(401); + expect(text).not.toContain(database.url); + expect(text).not.toContain("postgres"); + expect(text.toLowerCase()).not.toContain("stack"); + }); + + let sessionCookie: string; + + it("signs in explicitly and returns a valid, host-only, HttpOnly, SameSite=Lax, secure session cookie", async () => { + const { cookiePair, response } = await signIn(origin, emailOne, password); + const setCookie = setCookieValue(response); + + expect(response.status).toBe(200); + expect(setCookie).toBeDefined(); + expect(cookiePair).toBeDefined(); + sessionCookie = cookiePair as string; + + const cookieText = setCookie as string; + expect(cookieText).toContain("HttpOnly"); + expect(cookieText).toContain("SameSite=Lax"); + expect(cookieText).toContain("Secure"); + expect(cookieText).not.toMatch(/domain=/iu); + }); + + it("resolves the authenticated principal containing only kind and the opaque user id", async () => { + const { body, response } = await fetchPrincipal(origin, { cookie: sessionCookie }); + + expect(response.status).toBe(200); + expect(body).toMatchObject({ kind: "authenticated" }); + const record = body as Record; + expect(Object.keys(record).sort()).toEqual(["kind", "userId"]); + expect(typeof record.userId).toBe("string"); + }); + + it("resolves a malformed cookie as anonymous rather than failing", async () => { + const { body, response } = await fetchPrincipal(origin, { + cookie: "better-auth.session_token=not-a-real-token", + }); + + expect(response.status).toBe(200); + expect(body).toEqual({ kind: "anonymous" }); + }); + + it("resolves a nonexistent cookie name as anonymous", async () => { + const { body, response } = await fetchPrincipal(origin, { cookie: "unrelated=value" }); + expect(response.status).toBe(200); + expect(body).toEqual({ kind: "anonymous" }); + }); + + it("does not create a second identity on duplicate sign-up, and does not leak unstable internal details", async () => { + const { body, response } = await signUp(origin, emailOne, "a totally different password"); + + expect(response.status).toBe(200); + expect(body.token).toBeNull(); + expect(typeof body.user.id).toBe("string"); + }); + + it("keeps exactly one user row for the duplicated email after the duplicate sign-up attempt", async () => { + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ count: string }>( + admin, + 'select count(*)::text as count from "ekklesia_core_user" where email = $1', + [emailOne], + ); + expect(rows[0]?.count).toBe("1"); + } finally { + await safeEnd(admin); + } + }); + + it("signs out and invalidates the session", async () => { + const response = await fetch(`${origin}/api/auth/sign-out`, { + body: "{}", + headers: { + "content-type": "application/json", + cookie: sessionCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + + expect(response.status).toBe(200); + expect(setCookieValue(response)).toBeDefined(); + }); + + it("resolves the old cookie as anonymous after sign-out", async () => { + const { body, response } = await fetchPrincipal(origin, { cookie: sessionCookie }); + expect(response.status).toBe(200); + expect(body).toEqual({ kind: "anonymous" }); + }); + + let secondUserId: string; + + it("registers and signs in a second, distinct public account", async () => { + const signUpResult = await signUp(origin, emailTwo, password); + expect(signUpResult.response.status).toBe(200); + + const signInResult = await signIn(origin, emailTwo, password); + expect(signInResult.response.status).toBe(200); + expect(signInResult.cookiePair).toBeDefined(); + + const { body } = await fetchPrincipal(origin, { + cookie: signInResult.cookiePair as string, + }); + const record = body as { kind: string; userId: string }; + expect(record.kind).toBe("authenticated"); + secondUserId = record.userId; + expect(secondUserId).toBeTruthy(); + }); + + it("gives the first and second account identical principal shapes with no elevation", async () => { + const first = await signIn(origin, emailOne, password); + const { body: firstBody } = await fetchPrincipal(origin, { + cookie: first.cookiePair as string, + }); + const second = await signIn(origin, emailTwo, password); + const { body: secondBody } = await fetchPrincipal(origin, { + cookie: second.cookiePair as string, + }); + + expect(Object.keys(firstBody as object).sort()).toEqual( + Object.keys(secondBody as object).sort(), + ); + expect((firstBody as { kind: string }).kind).toBe((secondBody as { kind: string }).kind); + }); + + it("gives neither account owner, role, permission, membership, or volunteer data at the database level", async () => { + const admin = await openAdministrativeClient(database.url); + try { + const columns = await safeQuery<{ column_name: string }>( + admin, + "select column_name from information_schema.columns where table_schema = 'public' and table_name = 'ekklesia_core_user'", + ); + const columnNames = columns.map((row) => row.column_name); + for (const forbidden of [ + "role", + "is_admin", + "is_owner", + "is_staff", + "is_volunteer", + "is_member", + "permissions", + "grants", + ]) { + expect(columnNames).not.toContain(forbidden); + } + } finally { + await safeEnd(admin); + } + }); + + it("never stores a plaintext password", async () => { + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ password: string | null }>( + admin, + `select a.password from "ekklesia_core_account" a + inner join "ekklesia_core_user" u on u.id = a.user_id + where u.email = $1`, + [emailOne], + ); + const stored = rows[0]?.password; + expect(stored).toBeTruthy(); + expect(stored).not.toBe(password); + expect(stored?.length ?? 0).toBeGreaterThan(password.length); + } finally { + await safeEnd(admin); + } + }); + + it("supports a server-to-server sign-up request that carries no Origin header at all", async () => { + const { response } = await signUp(origin, "carol@example.test", password); + expect(response.status).toBe(200); + }); + + it("allows a trusted browser origin for the principal route with credentials", async () => { + const response = await fetch(`${origin}/api/v1/principal`, { + headers: { cookie: sessionCookie, origin: dashboardOrigin }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin); + expect(response.headers.get("access-control-allow-credentials")).toBe("true"); + }); + + it("never returns Access-Control-Allow-Origin for an untrusted browser origin", async () => { + const principalResponse = await fetch(`${origin}/api/v1/principal`, { + headers: { origin: untrustedOrigin }, + }); + expect(principalResponse.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("leaves Better Auth's handler unavailable outside /api/auth/*", async () => { + const response = await fetch(`${origin}/sign-in/email`, { method: "POST" }); + expect(response.status).toBe(404); + }); + + it("shuts down cleanly, closing the HTTP listener and the PostgreSQL pool", async () => { + await running.shutdown(); + + await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy(); + const remainingConnections = await activeConnectionCount(database.name); + expect(remainingConnections).toBe(0); + + await dropDatabase(database.name); + }); +}); + +// Better Auth's own server-side rejection of a cookie-bearing state-changing request from an +// untrusted origin (403 INVALID_ORIGIN) was verified directly and repeatedly against this exact +// pinned release and this exact compiled `apps/api` server — signup, sign-in, then a sign-out +// attempt with the valid session cookie and an untrusted Origin header reliably returns 403, and +// the presented session remains valid afterward — but only when driven by a plain Node script +// against the running server. The identical scenario could not be made to reproduce reliably when +// driven through Vitest specifically (the rejection silently does not fire), even in a single +// isolated test with its own fresh database and API instance, and even with `NODE_ENV` forced to +// `"production"` inside the test process. The cause was not pinned down to a documented, +// reproducible contract inside the time available for this slice, so no automated assertion is +// made for this specific request shape here rather than commit a flaky or misleading test. What +// *is* covered by an automated test above is the layer this repository's own code owns: CORS +// never returns `Access-Control-Allow-Origin` for an untrusted origin, which is the actual +// browser-enforced boundary preventing a malicious page from reading a response. See ADR 0011 for +// the full account, including the exact manual reproduction used. + +describe.sequential("live API — startup and migration guarantees", () => { + it("does not create any tables or migration history when started against an unmigrated database", async () => { + const database = await createDisposableDatabase("unmigrated"); + try { + const config = createApiRuntimeConfig( + { + databaseUrl: database.url, + host: "127.0.0.1", + identity: { + baseUrl: identityBaseUrl, + secret: "integration-test-secret-0123456789abcdefgh", + trustedOrigins: [identityBaseUrl], + }, + port: 0, + }, + { environment: "production" }, + ); + const running = await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } }); + const origin = apiOrigin(running); + + try { + // A request that touches the (nonexistent) identity tables fails, but must not repair + // or create anything. The exact failure body is not asserted here (Better Auth's + // internal-error shape for this case is not a stable public contract); only that it is + // not a successful sign-up. + const response = await fetch(`${origin}/api/auth/sign-up/email`, { + body: JSON.stringify({ + email: "dana@example.test", + name: "Integration Test User", + password: "correct horse battery staple", + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + expect(response.status).not.toBe(200); + } finally { + await running.shutdown(); + } + + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ count: string }>( + admin, + "select count(*)::text as count from information_schema.tables where table_schema = 'public'", + ); + expect(rows[0]?.count).toBe("0"); + } finally { + await safeEnd(admin); + } + } finally { + await dropDatabase(database.name); + } + }); +}); + +describe.sequential("live API — principal infrastructure failure is never downgraded to anonymous", () => { + it("returns a sanitized failure, not a false anonymous 200, when the database becomes unreachable mid-session", async () => { + const database = await createDisposableDatabase("infra_failure"); + await applyCoreMigrations(database.url); + const running = await startMigratedApi(database.url); + const origin = apiOrigin(running); + + await signUp(origin, "erin@example.test", "correct horse battery staple"); + const { cookiePair } = await signIn( + origin, + "erin@example.test", + "correct horse battery staple", + ); + expect(cookiePair).toBeDefined(); + + // Force the database itself unreachable out from under the running API pool. + await dropDatabase(database.name); + + const { body, response } = await fetchPrincipal(origin, { cookie: cookiePair as string }); + expect(response.status).toBe(503); + expect(body).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" }); + + await running.shutdown().catch(() => undefined); + }); +}); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts new file mode 100644 index 0000000..a9e9821 --- /dev/null +++ b/apps/api/tests/app.test.ts @@ -0,0 +1,167 @@ +import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { IdentityRuntimeError } from "@ekklesia/core/identity"; +import { describe, expect, it, vi } from "vitest"; +import { createApiApp } from "../src/app.js"; + +const trustedOrigin = "https://dashboard.example.test"; +const untrustedOrigin = "https://evil.example.test"; + +function fakeRuntime(overrides: Partial = {}): EkklesiaIdentityRuntime { + return { + handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })), + resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), + ...overrides, + }; +} + +describe("Ekklesia API Hono application", () => { + it("mounts Better Auth exactly under /api/auth/*", async () => { + const runtime = fakeRuntime(); + const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); + + const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("auth-ok"); + expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1); + }); + + it("does not route requests outside /api/auth/* to Better Auth", async () => { + const runtime = fakeRuntime(); + const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); + + const response = await app.request("/sign-in/email", { method: "POST" }); + + expect(response.status).toBe(404); + expect(runtime.handleAuthRequest).not.toHaveBeenCalled(); + }); + + it("returns the anonymous principal shape with no-store caching", async () => { + const runtime = fakeRuntime({ + resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL), + }); + const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); + + const response = await app.request("/api/v1/principal"); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ kind: "anonymous" }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("vary")).toContain("Cookie"); + }); + + it("returns the authenticated principal shape containing only kind and userId", async () => { + const runtime = fakeRuntime({ + resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")), + }); + const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); + + const response = await app.request("/api/v1/principal"); + const body = (await response.json()) as Record; + + expect(response.status).toBe(200); + expect(body).toEqual({ kind: "authenticated", userId: "user_123" }); + expect(Object.keys(body).sort()).toEqual(["kind", "userId"]); + }); + + it("returns a stable sanitized 503 when principal resolution fails, never a false anonymous 200", async () => { + const runtime = fakeRuntime({ + resolvePrincipal: vi + .fn() + .mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")), + }); + const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); + + const response = await app.request("/api/v1/principal"); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" }); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("returns a stable sanitized 500 for an unexpected auth-handler failure, never a raw message", async () => { + const runtime = fakeRuntime({ + handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")), + }); + const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] }); + + const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ code: "INTERNAL_ERROR" }); + expect(JSON.stringify(body)).not.toContain("secret"); + expect(JSON.stringify(body)).not.toContain("postgresql://"); + }); + + it("has deterministic 404 behavior for an unknown route", async () => { + const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); + + const response = await app.request("/does-not-exist"); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ code: "NOT_FOUND" }); + }); + + it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => { + const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); + + const response = await app.request("/api/v1/principal", { + headers: { origin: trustedOrigin }, + }); + + expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin); + expect(response.headers.get("access-control-allow-credentials")).toBe("true"); + }); + + it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => { + const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); + + const response = await app.request("/api/v1/principal", { + headers: { origin: untrustedOrigin }, + }); + + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); + + it("supports requests with no Origin header at all (native/server clients)", async () => { + const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); + + const response = await app.request("/api/v1/principal"); + + expect(response.status).toBe(200); + }); + + it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => { + const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); + + const response = await app.request("/api/v1/principal", { + headers: { + "access-control-request-method": "GET", + origin: trustedOrigin, + }, + method: "OPTIONS", + }); + + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin); + }); + + it("never wildcards Access-Control-Allow-Origin", async () => { + const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] }); + + const response = await app.request("/api/v1/principal", { + headers: { origin: trustedOrigin }, + }); + + expect(response.headers.get("access-control-allow-origin")).not.toBe("*"); + }); + + // "No generalized session/user object stored in Hono context" is verified by construction + // rather than by a black-box context probe: unlike Better Auth's own documented Hono example + // (which hydrates `c.set("user", session.user)` on every request), createApiApp never calls + // `c.set` at all — the only per-request identity data that ever exists is the narrow + // EkklesiaPrincipal returned directly from the /api/v1/principal handler above, whose exact + // shape (kind + userId only) is already asserted by the principal-shape tests in this file. +}); diff --git a/apps/api/tests/config.test.ts b/apps/api/tests/config.test.ts new file mode 100644 index 0000000..91f8a08 --- /dev/null +++ b/apps/api/tests/config.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js"; + +const validSecret = "correct-horse-battery-staple-0123456789"; + +function validInput() { + return { + databaseUrl: "postgresql://ekklesia:ekklesia@127.0.0.1:5432/ekklesia", + host: "127.0.0.1", + identity: { + baseUrl: "https://identity.example.test", + secret: validSecret, + trustedOrigins: ["https://identity.example.test"], + }, + port: 3000, + }; +} + +describe("API runtime configuration", () => { + it("accepts valid API and identity configuration", () => { + const config = createApiRuntimeConfig(validInput(), { environment: "production" }); + + expect(config).toEqual({ + databaseUrl: validInput().databaseUrl, + host: "127.0.0.1", + identity: { + baseUrl: "https://identity.example.test", + secret: validSecret, + trustedOrigins: ["https://identity.example.test"], + }, + port: 3000, + }); + expect(Object.isFrozen(config)).toBe(true); + }); + + it("defaults host to loopback and port to 3000 when omitted", () => { + const { host: _host, port: _port, ...rest } = validInput(); + const config = createApiRuntimeConfig(rest, { environment: "production" }); + + expect(config.host).toBe("127.0.0.1"); + expect(config.port).toBe(3000); + }); + + it("permits an explicit non-loopback bind address", () => { + const config = createApiRuntimeConfig( + { ...validInput(), host: "0.0.0.0" }, + { environment: "production" }, + ); + + expect(config.host).toBe("0.0.0.0"); + }); + + it("rejects a missing database URL", () => { + let captured: unknown; + try { + createApiRuntimeConfig({ ...validInput(), databaseUrl: undefined }); + } catch (error) { + captured = error; + } + expect(captured).toBeInstanceOf(ApiConfigError); + expect(captured).toMatchObject({ code: "DATABASE_URL_MISSING" }); + }); + + it("rejects an invalid database URL", () => { + for (const databaseUrl of ["", "not-a-url", "mysql://example.test/db", "http://example.test"]) { + let captured: unknown; + try { + createApiRuntimeConfig({ ...validInput(), databaseUrl }); + } catch (error) { + captured = error; + } + expect(captured).toBeInstanceOf(ApiConfigError); + } + }); + + it("rejects an empty listen host", () => { + let captured: unknown; + try { + createApiRuntimeConfig({ ...validInput(), host: "" }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "HOST_INVALID" }); + }); + + it("rejects an invalid listen port", () => { + for (const port of [-1, 1.5, 70_000, Number.NaN, "not-a-port"]) { + let captured: unknown; + try { + createApiRuntimeConfig({ ...validInput(), port }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "PORT_INVALID" }); + } + }); + + it("accepts port 0 as an explicit request for an OS-assigned ephemeral port", () => { + const config = createApiRuntimeConfig({ ...validInput(), port: 0 }); + expect(config.port).toBe(0); + }); + + it("delegates identity validation to Core's createIdentityConfig, rejecting a short secret", () => { + let captured: unknown; + try { + createApiRuntimeConfig({ + ...validInput(), + identity: { ...validInput().identity, secret: "too-short" }, + }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "SECRET_TOO_SHORT" }); + }); + + it("explicitly allows local development origins only when environment is development", () => { + const config = createApiRuntimeConfig( + { + databaseUrl: validInput().databaseUrl, + host: "127.0.0.1", + identity: { + baseUrl: "http://localhost:3000", + secret: validSecret, + trustedOrigins: ["http://localhost:3000"], + }, + port: 3000, + }, + { environment: "development" }, + ); + + expect(config.identity.baseUrl).toBe("http://localhost:3000"); + }); + + it("never leaks the database URL, secret, or trusted origins through a thrown error", () => { + const hostileUrl = "postgresql://admin:s3cr3t@db.internal.example:5432/ekklesia"; + let captured: unknown; + try { + createApiRuntimeConfig({ ...validInput(), databaseUrl: hostileUrl, port: -1 }); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(ApiConfigError); + const publicText = `${String(captured)}\n${ + captured instanceof Error ? (captured.stack ?? "") : "" + }\n${JSON.stringify(captured)}`; + expect(publicText).not.toContain(hostileUrl); + expect(publicText).not.toContain("s3cr3t"); + expect(publicText).not.toContain("db.internal.example"); + }); +}); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts new file mode 100644 index 0000000..8a51c40 --- /dev/null +++ b/apps/api/tests/lifecycle.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const poolConstructor = vi.hoisted(() => vi.fn()); +const betterAuthMock = vi.hoisted(() => vi.fn()); +const drizzleAdapterMock = vi.hoisted(() => vi.fn()); +const serveMock = vi.hoisted(() => vi.fn()); +const processOnSpy = vi.hoisted(() => vi.fn()); + +vi.mock("pg", () => ({ Pool: poolConstructor })); +vi.mock("better-auth", () => ({ betterAuth: betterAuthMock })); +vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); +vi.mock("@hono/node-server", () => ({ serve: serveMock })); + +describe("Ekklesia API import safety", () => { + const originalEnv = { ...process.env }; + const originalProcessOn = process.on.bind(process); + + beforeEach(() => { + poolConstructor.mockClear(); + betterAuthMock.mockClear(); + drizzleAdapterMock.mockClear(); + serveMock.mockClear(); + processOnSpy.mockClear(); + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) { + delete process.env[key]; + } + } + process.env = { ...originalEnv }; + process.on = originalProcessOn; + }); + + it("does not construct a database pool, Better Auth, or an HTTP server merely by importing the app factory", async () => { + await import("../src/app.js"); + + expect(poolConstructor).not.toHaveBeenCalled(); + expect(betterAuthMock).not.toHaveBeenCalled(); + expect(serveMock).not.toHaveBeenCalled(); + }); + + it("does not construct a database pool, Better Auth, or an HTTP server merely by importing configuration", async () => { + await import("../src/config.js"); + + expect(poolConstructor).not.toHaveBeenCalled(); + expect(betterAuthMock).not.toHaveBeenCalled(); + expect(serveMock).not.toHaveBeenCalled(); + }); + + it("does not read environment variables, connect to PostgreSQL, construct Better Auth, start a server, or install signal handlers merely by importing the executable server module", async () => { + // Deliberately leave every runtime-required environment variable unset. If importing this + // module performed any runtime composition, missing configuration would throw here. + delete process.env.DATABASE_URL; + delete process.env.EKKLESIA_IDENTITY_SECRET; + delete process.env.EKKLESIA_IDENTITY_BASE_URL; + delete process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS; + const onSpy = vi.spyOn(process, "on").mockImplementation(processOnSpy); + + await expect(import("../src/server.js")).resolves.toBeDefined(); + + expect(poolConstructor).not.toHaveBeenCalled(); + expect(betterAuthMock).not.toHaveBeenCalled(); + expect(drizzleAdapterMock).not.toHaveBeenCalled(); + expect(serveMock).not.toHaveBeenCalled(); + expect(processOnSpy).not.toHaveBeenCalled(); + onSpy.mockRestore(); + }); + + it("exposes startApi and main as explicit functions without invoking them at import time", async () => { + const module = await import("../src/server.js"); + + expect(typeof module.startApi).toBe("function"); + expect(typeof module.main).toBe("function"); + expect(serveMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..4f5dbdb --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "tests"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..837feb5 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@ekklesia/typescript-config/node.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/core/package.json b/core/package.json index 5b830b8..a25d3b7 100644 --- a/core/package.json +++ b/core/package.json @@ -22,6 +22,10 @@ "./diagnostics": { "types": "./dist/diagnostics/index.d.ts", "import": "./dist/diagnostics/index.js" + }, + "./identity": { + "types": "./dist/identity/index.d.ts", + "import": "./dist/identity/index.js" } }, "scripts": { diff --git a/core/src/identity/database.ts b/core/src/identity/database.ts index ff5312f..f3c2e4f 100644 --- a/core/src/identity/database.ts +++ b/core/src/identity/database.ts @@ -60,6 +60,12 @@ export function createIdentityDrizzleDatabase( } const pool = new Pool(poolConfig); + // pg's own documentation requires an error listener on the pool: an idle client can be + // terminated by the server (for example, its backend is killed, or the network drops) after + // being returned to the pool, and the pool surfaces that as an 'error' event on itself rather + // than on any in-flight query. Without a listener, that event is an unhandled Node.js error and + // can crash the process even though no caller is doing anything wrong at the time. + pool.on("error", () => undefined); const db = drizzle(pool, { schema: identityAdapterSchema }); return Object.freeze({ diff --git a/core/src/identity/factory.ts b/core/src/identity/factory.ts deleted file mode 100644 index 528961f..0000000 --- a/core/src/identity/factory.ts +++ /dev/null @@ -1,58 +0,0 @@ -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/index.ts b/core/src/identity/index.ts new file mode 100644 index 0000000..843a30d --- /dev/null +++ b/core/src/identity/index.ts @@ -0,0 +1,3 @@ +export * from "./config.js"; +export * from "./database.js"; +export * from "./runtime.js"; diff --git a/core/src/identity/runtime.ts b/core/src/identity/runtime.ts new file mode 100644 index 0000000..919758a --- /dev/null +++ b/core/src/identity/runtime.ts @@ -0,0 +1,187 @@ +import { + ANONYMOUS_PRINCIPAL, + createAuthenticatedPrincipal, + type EkklesiaPrincipal, +} from "@ekklesia/contracts"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { sql } from "drizzle-orm"; +import type { IdentityConfig } from "./config.js"; +import type { IdentityDrizzleDatabase } from "./database.js"; +import { identityAdapterSchema } from "./schema.js"; + +export type IdentityRuntimeErrorCode = + | "AUTH_REQUEST_FAILED" + | "IDENTITY_CONSTRUCTION_FAILED" + | "PRINCIPAL_RESOLUTION_FAILED"; + +const runtimeErrorMessages: Readonly> = Object.freeze({ + AUTH_REQUEST_FAILED: "The identity authentication request could not be handled.", + IDENTITY_CONSTRUCTION_FAILED: "The identity runtime could not be constructed.", + PRINCIPAL_RESOLUTION_FAILED: "The request principal could not be resolved.", +}); + +const identityDatabaseProbeTimeoutMs = 3_000; + +/** Never carries the underlying Better Auth error, database error, cause, stack, secret, or URL. */ +export class IdentityRuntimeError extends Error { + readonly code: IdentityRuntimeErrorCode; + + constructor(code: IdentityRuntimeErrorCode) { + super(runtimeErrorMessages[code]); + this.name = "IdentityRuntimeError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +export interface IdentityRateLimitOverride { + readonly max?: number; + readonly window?: number; +} + +export interface CreateIdentityRuntimeOptions { + readonly config: IdentityConfig; + readonly db: IdentityDrizzleDatabase; + /** + * A narrow, explicit override for the sign-in/sign-up rate-limit window used only by test + * composition that issues many such requests quickly. Rate limiting itself is always enabled; + * production composition must leave this unset so Better Auth's own strict built-in default + * (3 requests / 10 seconds on sign-in and sign-up) applies unmodified. + */ + readonly rateLimitOverride?: IdentityRateLimitOverride; +} + +/** + * Deliberately narrow: exposes exactly two Fetch-standard capabilities and nothing else. It never + * returns the Better Auth instance, `auth.api`, inferred user/session types, the Drizzle adapter, + * the database, a session token, cookie contents, a password hash, or Better Auth configuration. + */ +export interface EkklesiaIdentityRuntime { + /** Delegates directly to Better Auth's Fetch handler. Normal Better Auth HTTP responses + * (including its own 4xx error responses) pass through unmodified; only a genuinely unexpected + * failure is converted to a sanitized `IdentityRuntimeError`. */ + handleAuthRequest(request: Request): Promise; + /** Resolves the caller's Ekklesia principal from request headers. Returns the anonymous + * principal for a missing, expired, revoked, or malformed session — never for an infrastructure + * failure, which instead throws a sanitized `IdentityRuntimeError`. */ + resolvePrincipal(headers: Headers): Promise; +} + +async function withTimeout(operation: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("timed out")), timeoutMs); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +async function probeIdentityDatabase(db: IdentityDrizzleDatabase): Promise { + try { + await withTimeout(db.execute(sql`select 1`), identityDatabaseProbeTimeoutMs); + return true; + } catch { + return false; + } +} + +function constructBetterAuth(build: () => T): T { + try { + return build(); + } catch { + throw new IdentityRuntimeError("IDENTITY_CONSTRUCTION_FAILED"); + } +} + +/** + * Core's Better Auth composition boundary and Fetch-compatible identity transport. Constructs + * Better Auth explicitly from injected, already-validated configuration and an injected Drizzle + * database — it never reads environment variables and enables no provider or plugin beyond the + * four core models and email/password sign-up/sign-in required for this slice. + */ +export function createIdentityRuntime( + options: CreateIdentityRuntimeOptions, +): EkklesiaIdentityRuntime { + const rateLimitOverride = options.rateLimitOverride; + + const auth = constructBetterAuth(() => + betterAuth({ + baseURL: options.config.baseUrl, + database: drizzleAdapter(options.db, { + provider: "pg", + schema: identityAdapterSchema, + }), + emailAndPassword: { + autoSignIn: false, + disableSignUp: false, + enabled: true, + }, + rateLimit: { + enabled: true, + max: 100, + window: 10, + ...(rateLimitOverride + ? { + customRules: { + "/sign-in/email": { + max: rateLimitOverride.max ?? 100, + window: rateLimitOverride.window ?? 10, + }, + "/sign-up/email": { + max: rateLimitOverride.max ?? 100, + window: rateLimitOverride.window ?? 10, + }, + }, + } + : {}), + }, + secret: options.config.secret, + trustedOrigins: [...options.config.trustedOrigins], + }), + ); + + return Object.freeze({ + handleAuthRequest: async (request: Request): Promise => { + try { + return await auth.handler(request); + } catch { + throw new IdentityRuntimeError("AUTH_REQUEST_FAILED"); + } + }, + resolvePrincipal: async (headers: Headers): Promise => { + let session: Awaited>; + try { + session = await auth.api.getSession({ headers }); + } catch { + // Defense in depth: the pinned Better Auth release already catches internal failures and + // resolves to null instead of throwing here, but a future release is not guaranteed to. + throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED"); + } + + const userId = session?.user?.id; + if (typeof userId === "string") { + return createAuthenticatedPrincipal(userId); + } + + // Better Auth's getSession swallows infrastructure failures internally and returns null + // for them exactly like a genuinely missing, expired, or malformed session — it never + // throws. A request with no Cookie header at all is unambiguous: Better Auth never queries + // the database for that case, so it is always genuinely anonymous. Only when a cookie was + // actually presented is the null result ambiguous enough to require disambiguation. + if (headers.has("cookie")) { + const reachable = await probeIdentityDatabase(options.db); + if (!reachable) { + throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED"); + } + } + return ANONYMOUS_PRINCIPAL; + }, + }); +} diff --git a/core/src/index.ts b/core/src/index.ts index b3e3cb9..8cc005f 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -1,3 +1,4 @@ export * from "./diagnostics/index.js"; +export * from "./identity/index.js"; export * from "./modules/index.js"; export * from "./migrations/index.js"; diff --git a/core/tests/identity-database.test.ts b/core/tests/identity-database.test.ts index 1aeb807..4b50432 100644 --- a/core/tests/identity-database.test.ts +++ b/core/tests/identity-database.test.ts @@ -37,7 +37,7 @@ describe("Core identity Drizzle database composition", () => { it("explicitly constructs the pool only when called, with the identity schema attached", () => { poolConstructor.mockImplementation(function fakePool() { - return { end: async () => undefined }; + return { end: async () => undefined, on: vi.fn() }; }); const handle = createIdentityDrizzleDatabase( @@ -52,7 +52,7 @@ describe("Core identity Drizzle database composition", () => { it("passes the connection timeout through to the pool configuration", () => { poolConstructor.mockImplementation(function fakePool() { - return { end: async () => undefined }; + return { end: async () => undefined, on: vi.fn() }; }); poolConstructor.mockClear(); @@ -64,4 +64,21 @@ describe("Core identity Drizzle database composition", () => { expect.objectContaining({ connectionTimeoutMillis: 5_000 }), ); }); + + it("registers a pool-level error listener so an idle client failure never becomes an uncaught exception", () => { + const on = vi.fn(); + poolConstructor.mockImplementation(function fakePool() { + return { end: async () => undefined, on }; + }); + + createIdentityDrizzleDatabase("postgresql://example.test/ekklesia"); + + expect(on).toHaveBeenCalledWith("error", expect.any(Function)); + const handler = on.mock.calls.find((call) => call[0] === "error")?.[1] as + | ((error: unknown) => void) + | undefined; + expect(() => + handler?.(new Error("terminating connection due to administrator command")), + ).not.toThrow(); + }); }); diff --git a/core/tests/identity-factory.test.ts b/core/tests/identity-factory.test.ts deleted file mode 100644 index 9cc4696..0000000 --- a/core/tests/identity-factory.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -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-runtime.test.ts b/core/tests/identity-runtime.test.ts new file mode 100644 index 0000000..8f2caed --- /dev/null +++ b/core/tests/identity-runtime.test.ts @@ -0,0 +1,288 @@ +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 { createIdentityRuntime, IdentityRuntimeError } from "../src/identity/runtime.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"]), + }); +} + +function fakeDb(overrides: Partial<{ execute: ReturnType }> = {}) { + return { __fakeDb: true, execute: vi.fn().mockResolvedValue(undefined), ...overrides } as never; +} + +describe("Core identity runtime construction boundary", () => { + beforeEach(() => { + betterAuthMock.mockReset(); + drizzleAdapterMock.mockClear(); + }); + + it("does not construct Better Auth or the Drizzle adapter merely because the module is imported", () => { + expect(betterAuthMock).not.toHaveBeenCalled(); + expect(drizzleAdapterMock).not.toHaveBeenCalled(); + }); + + it("constructs explicitly and returns only the two narrow transport capabilities", () => { + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); + + const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() }); + + expect(Object.keys(runtime).sort()).toEqual(["handleAuthRequest", "resolvePrincipal"]); + expect(typeof runtime.handleAuthRequest).toBe("function"); + expect(typeof runtime.resolvePrincipal).toBe("function"); + expect(Object.isFrozen(runtime)).toBe(true); + }); + + it("does not expose the raw database, adapter, or Better Auth instance on the returned value", () => { + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); + const db = fakeDb(); + + const runtime = createIdentityRuntime({ config: validConfig(), db }); + + expect(JSON.stringify(runtime)).toBe("{}"); + }); + + it("passes the explicit ekklesia_core_* schema mapping to the Drizzle adapter", () => { + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); + const db = fakeDb(); + + createIdentityRuntime({ config: validConfig(), db }); + + expect(drizzleAdapterMock).toHaveBeenCalledTimes(1); + const [passedDb, options] = drizzleAdapterMock.mock.calls[0] as [unknown, unknown]; + expect(passedDb).toBe(db); + expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema }); + }); + + it("enables exactly email/password (autoSignIn disabled) and rate limiting, nothing else", () => { + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); + + createIdentityRuntime({ config: validConfig(), db: fakeDb() }); + + expect(betterAuthMock).toHaveBeenCalledTimes(1); + const [options] = betterAuthMock.mock.calls[0] as [Record]; + expect(Object.keys(options).sort()).toEqual([ + "baseURL", + "database", + "emailAndPassword", + "rateLimit", + "secret", + "trustedOrigins", + ]); + expect(options.emailAndPassword).toEqual({ + autoSignIn: false, + disableSignUp: false, + enabled: true, + }); + expect(options.rateLimit).toEqual({ enabled: true, max: 100, window: 10 }); + expect(options.baseURL).toBe(validConfig().baseUrl); + expect(options.secret).toBe(validConfig().secret); + expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]); + }); + + it("only widens the sign-in/sign-up rate-limit window when a narrow override is explicitly injected", () => { + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); + + createIdentityRuntime({ + config: validConfig(), + db: fakeDb(), + rateLimitOverride: { max: 1000, window: 1 }, + }); + + const [options] = betterAuthMock.mock.calls[0] as [Record]; + expect(options.rateLimit).toEqual({ + customRules: { + "/sign-in/email": { max: 1000, window: 1 }, + "/sign-up/email": { max: 1000, window: 1 }, + }, + enabled: true, + max: 100, + window: 10, + }); + }); + + 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 { + createIdentityRuntime({ config: validConfig(), db: fakeDb() }); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(IdentityRuntimeError); + 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"); + }); +}); + +describe("Core identity runtime — handleAuthRequest", () => { + beforeEach(() => { + betterAuthMock.mockReset(); + drizzleAdapterMock.mockClear(); + }); + + it("delegates directly to Better Auth's Fetch handler and passes the response through unmodified", async () => { + const expectedResponse = new Response("ok", { status: 200 }); + const handler = vi.fn().mockResolvedValue(expectedResponse); + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler }); + + const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() }); + const request = new Request("https://identity.example.test/api/auth/sign-in/email"); + const response = await runtime.handleAuthRequest(request); + + expect(handler).toHaveBeenCalledWith(request); + expect(response).toBe(expectedResponse); + }); + + it("passes through Better Auth's own error responses (e.g. 401, 403, 422) unmodified", async () => { + const errorResponse = new Response(JSON.stringify({ code: "INVALID_EMAIL_OR_PASSWORD" }), { + status: 401, + }); + const handler = vi.fn().mockResolvedValue(errorResponse); + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler }); + + const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() }); + const response = await runtime.handleAuthRequest( + new Request("https://identity.example.test/x"), + ); + + expect(response.status).toBe(401); + }); + + it("sanitizes a genuinely unexpected handler failure", async () => { + const handler = vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db.internal/x")); + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler }); + + const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() }); + + let captured: unknown; + try { + await runtime.handleAuthRequest(new Request("https://identity.example.test/x")); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(IdentityRuntimeError); + expect(captured).toMatchObject({ code: "AUTH_REQUEST_FAILED" }); + const publicText = `${String(captured)}\n${ + captured instanceof Error ? (captured.stack ?? "") : "" + }`; + expect(publicText).not.toContain("db.internal"); + expect(publicText).not.toContain("admin"); + }); +}); + +describe("Core identity runtime — resolvePrincipal", () => { + beforeEach(() => { + betterAuthMock.mockReset(); + drizzleAdapterMock.mockClear(); + }); + + it("maps a valid session to an authenticated principal containing only the opaque user id", async () => { + const getSession = vi.fn().mockResolvedValue({ + session: { id: "sess_1", token: "tok" }, + user: { email: "person@example.test", id: "user_123", name: "Person" }, + }); + betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); + + const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() }); + const principal = await runtime.resolvePrincipal( + new Headers({ cookie: "better-auth.session_token=tok" }), + ); + + expect(principal).toEqual({ kind: "authenticated", userId: "user_123" }); + expect(Object.keys(principal)).toEqual(["kind", "userId"]); + expect(Object.isFrozen(principal)).toBe(true); + }); + + it("maps a missing session with no cookie header to anonymous without probing the database", async () => { + const getSession = vi.fn().mockResolvedValue(null); + const execute = vi.fn().mockResolvedValue(undefined); + betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); + + const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) }); + const principal = await runtime.resolvePrincipal(new Headers()); + + expect(principal).toEqual({ kind: "anonymous" }); + expect(execute).not.toHaveBeenCalled(); + }); + + it("maps an expired/revoked/malformed session (cookie present, null session) to anonymous when the database is reachable", async () => { + const getSession = vi.fn().mockResolvedValue(null); + const execute = vi.fn().mockResolvedValue(undefined); + betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); + + const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) }); + const principal = await runtime.resolvePrincipal( + new Headers({ cookie: "better-auth.session_token=stale-or-garbage" }), + ); + + expect(principal).toEqual({ kind: "anonymous" }); + expect(execute).toHaveBeenCalledTimes(1); + }); + + it("never downgrades an infrastructure failure to anonymous: throws when the database is unreachable", async () => { + const getSession = vi.fn().mockResolvedValue(null); + const execute = vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")); + betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); + + const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) }); + + let captured: unknown; + try { + await runtime.resolvePrincipal(new Headers({ cookie: "better-auth.session_token=x" })); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(IdentityRuntimeError); + expect(captured).toMatchObject({ code: "PRINCIPAL_RESOLUTION_FAILED" }); + }); + + it("treats an unexpected throw from getSession itself as a sanitized infrastructure failure", async () => { + const hostile = "password=hunter2 host=db.internal.example port=5432"; + const getSession = vi.fn().mockRejectedValue(new Error(hostile)); + betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); + + const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() }); + + let captured: unknown; + try { + await runtime.resolvePrincipal(new Headers({ cookie: "better-auth.session_token=x" })); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(IdentityRuntimeError); + expect(captured).toMatchObject({ code: "PRINCIPAL_RESOLUTION_FAILED" }); + const publicText = `${String(captured)}\n${ + captured instanceof Error ? (captured.stack ?? "") : "" + }`; + expect(publicText).not.toContain("hunter2"); + expect(publicText).not.toContain("db.internal.example"); + }); +}); diff --git a/docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md b/docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md new file mode 100644 index 0000000..fb83cf5 --- /dev/null +++ b/docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md @@ -0,0 +1,323 @@ +# 0011: Identity HTTP transport and public enrollment policy + +- Status: Accepted +- Date: 2026-07-21 + +## Context + +ADR 0010 established Core's identity schema, Core-owned identity migrations, and a Better Auth +construction boundary, but deliberately stopped before exposing anything over HTTP: "no +authentication endpoint or UI exists yet." Ekklesia now needs a real, publicly reachable way for a +person to create an account and sign in, and a way for any Core/Surface/API code to resolve who +(if anyone) is making a given request. + +This is also the first PR to make a product policy decision about *who* may hold an Ekklesia +identity and what that identity is worth. The project owner has been explicit: public registration +is intentional, a registered account is not a church-membership record, and the platform must not +let signup order or account creation itself grant any authority. Those decisions govern this ADR +as much as the technical transport choices do. + +## Decision + +### Public registration is an intentional product policy, not a placeholder + +Anyone may create an Ekklesia account through email/password sign-up. Registration creates only an +**authentication identity** — a row proving "this email and password combination can prove control +of a session" — not a church-person record, a membership record, or evidence of attending, +volunteering, or belonging to the church in any way. A future church-person/profile model, not +Better Auth's `user` table, is where that domain data will eventually live, linked only through the +opaque `EkklesiaUserId` (ADR 0010). + +### Every signup receives identical, lowest-privilege baseline status + +Every account created through public registration receives the same thing: an authenticated +`EkklesiaPrincipal` carrying only its own opaque user ID. That status exists because being +authenticated is useful on its own — it is what will eventually let a signed-in person have +ownership-scoped personal features such as watch history, continue-watching state, saved content, +or preferences, all scoped to *their own* data. None of those features are implemented in this PR; +only the identity that will eventually carry them is. Baseline authenticated access is **not** an +administrative role, is not stored as a role/grant row (there is no role or grant system yet), and +is not distinguishable in storage from any other account beyond Better Auth's own required +`user`/`account` rows. It never grants Dashboard access, module-management access, staff or +volunteer-management access, or any media/event/form/roster/people-management permission — none of +that exists yet, and nothing in this PR creates a path toward an authenticated account acquiring it +implicitly. + +**Signup order is never an authorization input.** No code path in this PR inspects how many users +already exist, branches on being the first account, or treats an empty database as special. The +first registered account and every account after it go through the exact same code path and +receive the exact same principal shape — proven directly by the test suite (see Testing). Platform +owner authority is not part of this schema at all: there is no `isAdmin`, `isOwner`, `isStaff`, +`isVolunteer`, `isMember`, role column, or admin plugin anywhere in this slice, because Core has no +authorization schema yet to grant such authority meaningfully. Platform-owner bootstrap is +deferred to Phase 4C, which must introduce an explicit, auditable, operator-invoked command that +grants an *existing* identity platform-owner authority — never an implicit consequence of being +first, and never a side effect of a public HTTP endpoint. + +### Future authorization is deferred, and its shape is already distinguished + +This PR does not implement authorization. It is written expecting Phase 4C (or later) to +distinguish, at minimum: public (unauthenticated) access; authenticated, self-scoped baseline +access (this PR); relationship-derived access (for example, a person added to a volunteer roster +automatically gaining narrowly scoped, reversible read access to that roster, which disappears the +moment the qualifying relationship ends — no unrelated or permanent elevation); explicit grants or +roles; and platform-owner authority. None of the last three exist yet. Relationship-derived access +in particular belongs to a future roster slice, not this one — it is a fundamentally different +mechanism (derived from a live domain relationship, not stored as a standing grant) and must not be +approximated here with a role field. + +### Email/password is the only enabled provider, and why + +`emailAndPassword: { enabled: true, disableSignUp: false, autoSignIn: false }` is the entire +authentication surface enabled in this slice. No social, OAuth/OIDC, passkey, magic-link, username, +OTP, or anonymous provider is enabled; no admin, organization, or two-factor plugin is enabled; no +custom user field, role field, or owner field is added to the schema. `autoSignIn: false` is +deliberate, not merely permitted: the pinned `better-auth@1.6.23` release documents that setting +`autoSignIn: false` (like `requireEmailVerification: true`) activates its built-in **email +enumeration protection** — a duplicate sign-up returns the same `200` shape and a synthetic user +(`token: null`) instead of revealing that the email is already registered, and no second identity +row is created. This was verified directly against the pinned release, both by reading +`packages/better-auth/src/api/routes/sign-up.ts` and by exercising it against a live disposable +PostgreSQL database: a duplicate `/sign-up/email` call returns `200` with a fresh random `id` and +no second `ekklesia_core_user` row, while a first-time `/sign-up/email` call returns `200` with +`token: null` and **no session cookie is set**. A caller must call `/sign-in/email` explicitly +afterward to obtain a session. This is intentionally the *only* email-enumeration mitigation in +this slice; it is a Better Auth built-in, not new code. + +Email verification and password recovery remain deferred **because no email delivery service +exists in this repository yet** — `sendResetPassword` and any verification-email sender are left +unconfigured. Consequently, **public registration is not ready for a real public launch** after +this PR: an account with a forgotten password has no recovery path, and an unverified email address +is trusted as entered. Both are explicitly out of scope here and are called out as follow-up work, +not silently accepted risk. + +### Password policy + +Better Auth's default password-length policy (`minPasswordLength: 8`, `maxPasswordLength: 128`) is +kept unmodified; this ADR does not deliberately justify a different bound. Passwords are hashed +with Better Auth's own `scrypt`-based hashing (its documented default) — Core never substitutes +custom cryptography. + +### The Better Auth transport boundary hides Better Auth completely + +`core/src/identity/runtime.ts` replaces Phase 4A's construction-proof-only factory (which returned +a discarded `{constructed: true}` marker) with `createIdentityRuntime`, now that a real HTTP +consumer exists. It returns a frozen `EkklesiaIdentityRuntime` exposing exactly two Fetch-standard +capabilities and nothing else: + +- `handleAuthRequest(request: Request): Promise` — delegates directly to + `auth.handler(request)`. Better Auth's own HTTP responses (including its 401s, 403s, and 422s) + pass through completely unmodified; this is not rewritten into a second authentication protocol. + Only a genuinely unexpected throw (not a normal Better Auth error response, which Better Auth + itself already returns as a `Response`) is converted into a sanitized `IdentityRuntimeError`. +- `resolvePrincipal(headers: Headers): Promise` — resolves the caller's + `EkklesiaPrincipal`, described below. + +Neither method, nor anything else reachable from `@ekklesia/core/identity`, exposes `auth.api`, +Better Auth's inferred user/session types, Better Auth's database models, the Drizzle adapter, the +raw database, a session token, cookie contents, a password hash, or Better Auth configuration. +Core's public barrel (`core/src/index.ts` → `core/src/identity/index.ts`) exports only +`createIdentityConfig`/`IdentityConfig` (ADR 0010), `createIdentityDrizzleDatabase` (ADR 0010, for +composing the database in the executable entry point), and `createIdentityRuntime`/ +`EkklesiaIdentityRuntime`/`IdentityRuntimeError`. The Drizzle table objects +(`core/src/identity/schema.ts`) remain internal to `core/src/identity/`, reachable only by Core's +own code and tests — nothing outside Core needs them, since `createIdentityDrizzleDatabase` already +bakes the schema in. + +**A resolved infrastructure failure must never look like "no session."** This is a genuine tension +with the pinned Better Auth release: `auth.api.getSession({headers})` is documented (and +source-verified) to catch every internal failure — including a downed database — and resolve to +`null`, exactly like a missing or expired session; it never throws. Left unmitigated, that would +mean a database outage during principal resolution would silently look identical to "nobody is +signed in," which is precisely the ambiguity this ADR is required to avoid. `resolvePrincipal` +therefore does not trust a `null` result unconditionally: when the incoming request carries **no** +`Cookie` header at all, Better Auth's own source never queries the database for that case (verified +in `packages/better-auth/src/api/routes/session.ts`), so `null` is unambiguous and is returned as +the anonymous principal immediately, at zero extra cost — this is the common case (most requests +carry no session cookie). Only when a `Cookie` header **is** present and `getSession` still returns +`null` — a genuinely ambiguous case, since it could mean an expired/malformed session *or* a +database that went unreachable partway through resolution — does `resolvePrincipal` run one small, +bounded (3-second) reachability probe (`select 1`) against the same injected database connection +before trusting the `null`. If the probe also fails, `resolvePrincipal` throws +`IdentityRuntimeError` with code `PRINCIPAL_RESOLUTION_FAILED` instead of returning the anonymous +principal, and the `/api/v1/principal` route converts that into a sanitized `503`, never a `200` +anonymous response. This is a deliberate, documented, imperfect mitigation — a small race window +remains between the probe and Better Auth's own lookup — not a claim of perfect detection; a future +slice could tighten it (for example, by tracking the underlying connection pool's own error state) +without changing this boundary's public shape. + +### Ekklesia user ID and principal (extends ADR 0010) + +`EkklesiaUserId` was, in Phase 4A, a plain Zod-validated `string` with no nominal typing — +confirmed by inspection before this PR began. It is now branded via Zod's `.brand<"EkklesiaUserId">()`: +a bare `string` is no longer assignable to `EkklesiaUserId` without passing through +`parseEkklesiaUserId`/`safeParseEkklesiaUserId`. The brand is type-system-only — it adds no runtime +wrapper, tag, or field, so a value still serializes as a plain opaque string (`JSON.stringify`, +string concatenation, and equality all behave exactly as before). Validation still asserts only the +constraints Ekklesia itself owns (non-empty after trimming, a generous 128-character upper bound) +and still does **not** assume any particular format. In particular it does not assume UUIDs: Phase +4A's schema stores `id: text("id").primaryKey()` with Better Auth's default *application-generated* +id mode, an opaque base62-style string, not a database-generated UUID — confirmed by inspecting +`core/src/identity/schema.ts` and the pinned Better Auth ID-generation documentation. Asserting a +UUID shape here would have been asserting something Ekklesia does not actually guarantee. + +`EkklesiaPrincipal` (ADR 0010) is unchanged in shape: `{kind: "anonymous"}` or +`{kind: "authenticated", userId}`. This PR does not add email, name, image, email-verification +status, session ID, session expiry, session token, account/provider details, roles, permissions, +membership status, or volunteer relationships to it. The general principal stays exactly as narrow +as ADR 0010 defined it. + +### HTTP surface + +Better Auth is mounted at exactly `/api/auth/*` (`app.on(["POST", "GET"], "/api/auth/*", ...)`), +matching Better Auth's own documented Hono integration pattern. All Ekklesia-owned APIs live under +a versioned `/api/v1/*` namespace; this slice adds exactly one: `GET /api/v1/principal`, returning +the existing `EkklesiaPrincipal` contract as JSON (`{"kind":"anonymous"}` or +`{"kind":"authenticated","userId":"..."}`) with `Cache-Control: no-store` and a +`Vary: Origin, Cookie` response header. No generalized authorization middleware is added; there is +nothing yet for it to authorize. + +### `apps/api` is a composition root and transport host, not a Surface + +`apps/api` (workspace glob `apps/*` already existed per ADR 0002/repository-boundaries.md) owns +HTTP composition and process lifecycle. It depends on `@ekklesia/core`, `@ekklesia/contracts`, +`hono`, and `@hono/node-server` — Hono lives in the API application, never in Core, keeping Core +importable by any future transport without forcing an HTTP framework dependency on it. +`scripts/check-boundaries.ts`'s existing `apps-are-composition-roots` rule already forbids an app +from importing another app, a builder, or a module; it does not restrict `app → core`, so no +boundary-checker change was needed to let `apps/api` depend on `@ekklesia/core` directly. `app.ts` +exports a pure `createApiApp({runtime, trustedOrigins})` factory (no I/O, testable via `app.fetch` +directly with an injected fake runtime); `server.ts` separately exports a testable `startApi(config)` +composition function (constructs the database, the identity runtime, the app, and starts the real +HTTP listener, returning `{app, server, shutdown}`) and a process-level `main()` that only reads +environment variables, calls `startApi`, and wires `SIGINT`/`SIGTERM` to the returned `shutdown()` +— gated behind the same `isDirectExecution()` guard already used by `core/src/cli/doctor.ts`, so +importing `server.ts` (as tests do, to reach `startApi`) never starts a server or reads the +environment. + +### The server never applies migrations + +`apps/api` requires an already-migrated PostgreSQL database. It has no migration-discovery, +planning, or execution code at all — it only ever opens a Drizzle connection via Core's existing +`createIdentityDrizzleDatabase` (ADR 0010) and queries through it. There is no code path by which +starting the API creates the migration history table, creates the identity tables, or executes any +DDL. Operators must run `pnpm migrations:apply` first, exactly as ADR 0009 already requires for +every other Core or module migration. + +### Origin, CORS, cookies, and CSRF + +The API reuses Phase 4A's typed `IdentityConfig.trustedOrigins` as the single source of truth for +both Better Auth's own trusted-origin/CSRF checks and the API's CORS policy — there is no second, +competing origin configuration. `createApiApp`'s CORS middleware (`hono/cors`) uses a **function** +`origin`, not a static string or `"*"`: it reflects a request's `Origin` header back only when that +exact origin is present in the configured allowlist, and returns nothing (no CORS header at all) +otherwise. This is the safe, standard way to combine per-request dynamic origins with +`credentials: true` — the Fetch spec forbids `Access-Control-Allow-Origin: *` together with +`Access-Control-Allow-Credentials: true`, and Hono's own middleware does not enforce that +constraint for you, so an exact allowlist match (never a wildcard) is required here regardless. +CORS is applied to both `/api/auth/*` and `/api/v1/*`; only `GET`, `POST`, and `OPTIONS` and only +the `Content-Type` header are permitted, matching what these two routes actually need. A request +that legitimately carries no `Origin` header (native/server-to-server clients) is never blocked by +this CORS layer — CORS is a browser-enforced, response-header-only mechanism; omitting the header +for an unrecognized origin does not prevent the server from processing the request, and Better +Auth's own origin/CSRF checks (below) independently protect state-changing calls regardless of +CORS. + +Better Auth's own CSRF/origin protections are untouched: `advanced.disableCSRFCheck` and +`advanced.disableOriginCheck` are never set. Verified directly and repeatedly against the pinned +release, through the real, compiled `apps/api` HTTP server (not just a direct `auth.handler()` +call) driven by a plain Node script: any non-`GET`/`HEAD`/`OPTIONS` request that carries a +`Cookie` header must also carry a trusted `Origin` (or `Referer`) header, or Better Auth itself +rejects it with `403 MISSING_OR_NULL_ORIGIN` (unrecognized origin: `403 INVALID_ORIGIN`) — a +`sign-out` call with a valid session cookie but an untrusted `Origin` is reliably rejected by +Better Auth itself, before this ADR's CORS layer is even involved, and the presented session +remains valid afterward since the rejected call never reaches sign-out logic. This is Ekklesia's +baseline, always-on CSRF guarantee for every cookie-bearing state-changing call, and it is real, +reproduced, production behavior of the pinned release running this exact code. + +That guarantee is **not**, however, part of the automated `pnpm test:migrations:integration` suite. +While building this ADR, the identical scenario — same compiled server, same request sequence — +could not be made to reproduce reliably when driven through Vitest specifically: the rejection +silently does not fire, even in a single isolated test with its own fresh database and API +instance, and even with `NODE_ENV` forced to `"production"` inside the test process. The same is +true of Better Auth's documented stricter "first-login CSRF" check for email sign-in/sign-up +(validating `Origin`/`Referer` even before any cookie exists), which was also observed to stop +firing after several earlier sign-in/sign-up calls in the same process. Both were reproduced +correctly and repeatedly outside Vitest and were not pinned down to a documented, reproducible +cause inside the time available for this slice, so `apps/api/tests/api-http.integration.ts` +deliberately does not assert either behavior rather than commit a flaky or misleading automated +test — it documents the manual reproduction inline instead. What the automated suite does assert +is the layer this repository's own code owns end-to-end: CORS never returns +`Access-Control-Allow-Origin` for an untrusted origin, which is the actual browser-enforced +boundary. A future slice touching authentication endpoints should re-verify Better Auth's +server-side origin rejection directly (as this ADR did) before relying on it changing. + +Cookies keep Better Auth's own defaults: `httpOnly: true` always, `sameSite: "lax"` always, and +`secure` driven by the configured `baseUrl`'s protocol (Better Auth treats an `https://` `baseURL` +as secure regardless of `NODE_ENV`). `advanced.crossSubDomainCookies` is not enabled and no cookie +`Domain` attribute is set, so every cookie remains host-only, matching ADR 0010's direction. +Trusted proxy headers (`advanced.ipAddress.trustedProxies`, `ipAddressHeaders`, or any +`X-Forwarded-*` trust) are not configured in this slice; a deployment behind a reverse proxy is +future work, and accepting spoofable forwarded headers without a proxy policy in place would be +worse than not trusting them at all. + +### Rate limiting + +Better Auth's built-in rate limiting is always enabled (`rateLimit.enabled: true`), including in +test/local composition — it is never disabled to make testing convenient. Its endpoint-specific +built-in rules (by default, a strict 3 requests per 10 seconds on every `/sign-in*` and `/sign-up*` +path) are preserved unmodified in production composition. Test composition alone may inject a +narrow, explicit `rateLimitOverride` (verified empirically to work via Better Auth's own +`rateLimit.customRules` targeting `/sign-in/email` and `/sign-up/email` specifically) so an +integration suite that legitimately issues many sign-in/sign-up requests in a short window is not +itself rate-limited; production composition (`apps/api/src/server.ts`'s `main()`) never sets this +override. Storage remains the default in-memory backend (`rateLimit.storage` unset). This has two +honest, documented limitations carried forward from ADR 0010's rate-limiting-adjacent guidance and +confirmed against the pinned release: it is **per-process**, so it provides no protection across +multiple API instances or restarts; and because no trusted-proxy IP policy is configured, Better +Auth may fall back to a single shared per-path bucket rather than a genuine per-client-IP bucket +when it cannot resolve a real client address. Neither limitation is solved by trusting spoofable +`X-Forwarded-*` headers, which this PR deliberately does not do. Public signup is therefore +rate-limited, but this is not yet the final, distributed, production-grade abuse-control design. + +## Alternatives + +- Letting the first registered account become an administrator automatically was rejected outright + per explicit product direction: signup order must never be an authorization input, and Core has + no owner capability to grant safely in this slice regardless. +- Adding an `isAdmin`/`isOwner`/role column now, "to be ready for" Phase 4C, was rejected: an unused + authorization field invites exactly the kind of accidental elevation this ADR forbids, and Phase + 4C's bootstrap command is explicitly required to be an auditable operator action, not a schema + default. +- Leaving `autoSignIn` at its default (`true`) was rejected: it would immediately grant a session + (and disable Better Auth's built-in enumeration protection) on every signup, including duplicate + attempts against an existing email, which is a weaker default than the one Better Auth itself + documents for exactly this public-registration scenario. +- Trusting `getSession`'s `null` result unconditionally as "anonymous" was rejected: the pinned + release's own source shows it swallows infrastructure failures, and the task's authorization + ambiguity concern is real — a later authorization system consuming a falsely-anonymous principal + during a database outage would be a silent security-relevant bug, not a graceful degradation. +- Enabling `advanced.ipAddress.trustedProxies`/`ipAddressHeaders` now, to give rate limiting a real + per-client bucket, was rejected: there is no deployment proxy policy yet, and accepting forwarded + IP headers without one would let any direct caller spoof its rate-limit bucket. +- A wildcard or reflect-any-origin CORS policy was rejected as incompatible with + `credentials: true` per the Fetch specification, and as a direct violation of "allow only exact + configured trusted origins." +- Reimplementing Better Auth's session validation directly against the identity tables (to sidestep + the `getSession`-swallows-errors problem entirely) was rejected: it would create a second, + divergent session-validation implementation alongside Better Auth's own, exactly the kind of + duplicated authentication logic Core is meant to avoid. + +## Consequences + +A person can now publicly register, sign in, be resolved to an `EkklesiaPrincipal` by any code +holding the narrow Core identity runtime, and sign out — all while Better Auth stays fully +contained behind Core's contracts and Core stays fully unaware of Hono. Every account, first or +five-hundredth, is provably identical in privilege. What remains explicitly unavailable after this +PR: any role, permission, grant, or platform-owner capability; any relationship-derived access; +Dashboard, Website, or Mobile UI; email delivery, email verification, or password recovery; any +provider beyond email/password; and any deployment-proxy or TLS decision. The next expected slice +is Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants / +platform-owner), an explicit, auditable platform-owner bootstrap command, and the beginning of +reversible relationship-derived access — not a Dashboard Surface, which still depends on an +authorization model that does not exist yet. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 14e3e84..8703757 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -19,3 +19,4 @@ ADRs capture durable choices that affect more than one package or phase. - [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 +- [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 0203ca5..ff85b4a 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -78,18 +78,29 @@ 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. -Phase 4A adds the Core identity foundation. Core owns a Better Auth-backed identity schema +Phase 4A added 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. +[ADR 0010](decisions/0010-core-identity-and-better-auth-adoption.md). -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. +Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root mounts +Better Auth at `/api/auth/*` and exposes one Ekklesia-owned route, `GET /api/v1/principal`, behind +Core's own Fetch-compatible identity runtime (`@ekklesia/core/identity`) — Core still does not +depend on Hono. Public email/password registration is intentional; every account receives +identical lowest-privilege baseline status, and signup order never grants elevation. See +[ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and +[the API guide](../development/api.md), and +[the identity development guide](../development/identity.md) for the underlying schema and +contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out; it +is not yet possible to verify an email, recover a password, reach a Dashboard, or hold any role or +permission. + +The next architectural phase is Phase 4C: Core authorization (public / authenticated-baseline / +relationship-derived / grants / platform-owner) and an explicit, auditable platform-owner bootstrap +command. Other target architecture not yet implemented includes production approval-provider +composition, physical storage-bucket provisioning, Surface SDK, richer public resource and action +contracts, dashboard, Builder, and reference feature modules. diff --git a/docs/development/api.md b/docs/development/api.md new file mode 100644 index 0000000..f71fc0c --- /dev/null +++ b/docs/development/api.md @@ -0,0 +1,201 @@ +# API + +Phase 4B adds `apps/api`: a minimal Hono-based HTTP transport that mounts Better Auth and exposes +one Ekklesia-owned route to resolve the current principal. It does not add a Dashboard, Website, or +Mobile Surface, and it does not add roles, permissions, or a platform-owner concept — see +[ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for the +full architectural decision and [Identity](identity.md) for the identity foundation this builds on. + +## What this is, and is not + +After this slice it is possible to start the API, publicly create an ordinary account, sign in, +resolve a request to an Ekklesia principal, and sign out. It is **not** possible to verify an email +address, recover a forgotten password, use any provider besides email/password, reach a Dashboard, +or hold any role or permission — none of that exists yet. **Public registration is not ready for a +real public launch after this PR**: there is no email delivery service, so an unverified email is +trusted as entered and a forgotten password has no recovery path. + +## Starting the API + +```bash +pnpm migrations:apply # the database must already be migrated — the API never does this itself +pnpm api:start +``` + +`pnpm api:start` follows the repository's build-first convention (`turbo run build`) so it works +from a clean checkout, then runs the compiled `apps/api/dist/server.js`. It reads configuration +from the environment (optionally via an uncommitted `.env`, loaded with +`--env-file-if-exists=.env`) and fails with a concise, sanitized error and a nonzero exit code if +required configuration is missing or invalid — it never applies migrations, creates the identity +tables, or repairs schema, and it fails safely (without mutating the database) if the required +schema is absent. + +### Required configuration + +No real values are shown here — configure per environment and never commit them: + +| Variable | Requirement | +| --- | --- | +| `DATABASE_URL` | An absolute `postgres://` or `postgresql://` URL to an already-migrated database. | +| `EKKLESIA_API_HOST` | Optional. Listen address. Defaults to loopback (`127.0.0.1`). An explicit non-loopback address is permitted for future deployment. | +| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. | +| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. | +| `EKKLESIA_IDENTITY_BASE_URL` | An absolute `http`/`https` URL. `https` is required in production; loopback hosts are rejected in production. | +| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of absolute origins (no path/query/fragment, no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. | +| `NODE_ENV` | `development` explicitly opts into relaxed (loopback/`http`) origin validation for local development. Anything else, including unset, validates as production. | + +## Routes + +| Route | Owner | Notes | +| --- | --- | --- | +| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). | +| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. | + +Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a +deterministic `404 {"code":"NOT_FOUND"}`. + +### `GET /api/v1/principal` response shapes + +Anonymous (no session, or an expired/revoked/malformed session): + +```json +{ "kind": "anonymous" } +``` + +Authenticated: + +```json +{ "kind": "authenticated", "userId": "2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1" } +``` + +Infrastructure failure (the identity database could not be reached while resolving the principal — +never silently reported as anonymous): + +```json +{ "code": "PRINCIPAL_RESOLUTION_FAILED" } +``` +returned with HTTP `503`. + +This is deliberately not the same thing as Better Auth's own `GET /api/auth/get-session` response, +which returns Better Auth's full `{session, user}` shape (session id, expiry, IP address, user +agent, email, name, image, email-verification status, and more). `/api/v1/principal` is the +Ekklesia-owned contract every other Core/Surface consumer should use; it never contains email, +name, image, session id or expiry, session token, account/provider details, or any role, +permission, membership, or volunteer data. + +## Public registration policy + +Anyone may create an account (`emailAndPassword.enabled: true`, `disableSignUp: false`). A +registered account is an **authentication identity** — proof that someone can present this email +and password — not a church-membership record; it does not imply attendance or involvement, and +nothing about it is recorded beyond Better Auth's own required `user`/`account` rows. Every +account, first or five-hundredth, receives identical baseline authenticated status: no Dashboard +access, no module-management access, no staff/volunteer-management access, and no +media/event/form/roster/people-management permission — Core has no authorization schema in this +slice to grant any of that, and no code path in this repository inspects how many accounts already +exist or treats an empty database as special. Baseline authenticated status is what will eventually +let a signed-in person use ownership-scoped personal features (watch history, continue watching, +saved content, preferences) — none of those features are implemented in this PR. + +Explicit **platform-owner bootstrap is deferred to Phase 4C**, which must introduce an explicit, +auditable operator command that grants an *existing* identity platform-owner authority — signup +order is never part of that decision. Future **relationship-derived access** (for example, a +person added to a volunteer roster automatically gaining narrow, reversible read access to that +roster) belongs to a later roster slice, not this one. + +`emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not** +automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field +is `null`. The caller must call `/api/auth/sign-in/email` explicitly afterward. This is deliberate: +per the pinned Better Auth release, `autoSignIn: false` activates its built-in email-enumeration +protection, so a duplicate sign-up returns the same `200` shape (with a fresh, unrelated random id) +whether or not the email was already registered, rather than revealing which. No custom +cryptography replaces Better Auth's own password hashing, and its default password-length policy +(8–128 characters) is kept unmodified. + +## Origin, CORS, and cookies + +The API reuses the same `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` list for both CORS and Better Auth's +own origin/CSRF checks — there is no second, competing configuration. CORS reflects a request's +`Origin` header back only when it exactly matches a configured trusted origin (never a wildcard, +since `Access-Control-Allow-Origin: *` cannot be combined with `Access-Control-Allow-Credentials: +true`); a disallowed origin never receives the header at all, though the request itself may still +be processed — CORS is a browser-enforced, response-header mechanism, not a server-side gate. A +request with no `Origin` header at all (native or server-to-server clients) is unaffected by CORS. + +Better Auth's own CSRF/origin protections are never disabled +(`advanced.disableCSRFCheck`/`advanced.disableOriginCheck` are never set). Verified directly +against the running compiled server: any non-`GET`/`HEAD`/`OPTIONS` request carrying a session +cookie must also carry a trusted `Origin` (or `Referer`), or Better Auth itself rejects it with +`403`. See ADR 0011 for the exact verification method and an important caveat about reproducing +part of this guarantee inside the automated test suite. + +Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `Secure` when the +configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain` +attribute is set, so every cookie is host-only. Trusted proxy headers are not configured in this +slice — a deployment behind a reverse proxy is future work. + +## Rate limiting + +Better Auth's built-in rate limiting is always enabled, including by the executable entry point — +it is never disabled. Its built-in per-path rules (a strict default of 3 requests per 10 seconds on +sign-in/sign-up-prefixed paths) are preserved unmodified in production composition; only the +integration test suite injects a narrow, explicit override so it is not itself rate-limited by its +own many requests. Storage remains the default in-memory backend, which has two honest limitations: +it is **per-process** (no protection across multiple API instances or restarts), and because no +trusted-proxy IP policy is configured, Better Auth may fall back to a single shared bucket per path +rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade +abuse-control design. + +## Graceful shutdown + +`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the +executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` is idempotent: it +stops the HTTP listener from accepting new connections and lets in-flight requests finish, then +closes the PostgreSQL pool. The executable entry point wires `SIGINT`/`SIGTERM` to call it and +exits afterward; a startup failure after the database pool was already created (identity +construction failure, or the server failing to bind its listen address) always closes that pool +before the failure propagates. + +## A real signup → sign-in → principal → sign-out transcript + +Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL +database. Session tokens, cookie values, and generated ids below are sanitized/truncated examples, +not real credentials. + +```text +POST /api/auth/sign-up/email +{"email":"alice@example.test","name":"Alice","password":""} +→ 200 +{"token":null,"user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1","email":"alice@example.test", ...}} + +POST /api/auth/sign-in/email +{"email":"alice@example.test","password":""} +→ 200 +Set-Cookie: better-auth.session_token=; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax; Secure +{"redirect":false,"token":"","user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1", ...}} + +GET /api/v1/principal +Cookie: better-auth.session_token= +→ 200 +Cache-Control: no-store +{"kind":"authenticated","userId":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1"} + +POST /api/auth/sign-out +Cookie: better-auth.session_token= +Origin: https://dashboard.example.test +→ 200 +Set-Cookie: better-auth.session_token=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax +{"success":true} + +GET /api/v1/principal +Cookie: better-auth.session_token= +→ 200 +{"kind":"anonymous"} +``` + +## Next phase + +Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants / +platform-owner), an explicit, auditable platform-owner bootstrap command, and reversible +relationship-derived access. Not a Dashboard Surface, which still depends on an authorization model +that does not exist yet. diff --git a/docs/development/identity.md b/docs/development/identity.md index 00a9ee9..35b34e4 100644 --- a/docs/development/identity.md +++ b/docs/development/identity.md @@ -1,11 +1,13 @@ # Identity -Phase 4A establishes Core's identity foundation: a Core-owned Better Auth database schema, a +Phase 4A established 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. +Auth construction boundary. Phase 4B built the HTTP transport on top of it — see +[API](api.md) and +[ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for +public registration, sign-in, and principal resolution over HTTP. This page covers the identity +foundation itself; see [ADR 0010](../architecture/decisions/0010-core-identity-and-better-auth-adoption.md) +for that architectural decision. ## Ownership @@ -18,7 +20,8 @@ surface concern. Better Auth is an implementation detail contained entirely behi 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. + never by querying Better Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that + transport for server-side/API consumers; no Dashboard, Website, or Mobile Surface 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 @@ -117,8 +120,8 @@ 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: +`core/src/identity/runtime.ts`'s `createIdentityRuntime` constructs Better Auth. This document +intentionally contains no real values — configure them per environment, never commit them: | Value | Requirement | | --- | --- | @@ -133,12 +136,25 @@ raises a sanitized `IdentityConfigError` carrying only a stable code, never the Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see ADR 0010. +## The identity runtime (Core's Better Auth transport boundary) + +`@ekklesia/core/identity` publicly exports `createIdentityRuntime({config, db})` +(`core/src/identity/runtime.ts`), which constructs Better Auth explicitly and returns a frozen +`EkklesiaIdentityRuntime` exposing exactly two Fetch-standard capabilities: +`handleAuthRequest(request): Promise` (delegates directly to Better Auth's handler; its +normal responses, including its own error responses, pass through unmodified) and +`resolvePrincipal(headers): Promise` (never silently downgrades a database +outage to an anonymous result — see ADR 0011 for exactly how). Neither method, nor anything else +exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's session/user types, the +Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the +only current consumer of this boundary; see [API](api.md). + ## 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. +No email verification, password recovery, invitation flow, email delivery, social or OAuth +provider, passkey, magic link, two-factor authentication, or roles/permissions/platform-owner model +exists. Public registration, sign-in, principal resolution, and sign-out over HTTP **are** +operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why +public registration is intentional and grants no elevation. The next expected slice is Phase 4C: +Core authorization and an explicit, auditable platform-owner bootstrap command — not a Dashboard +Surface, which still depends on an authorization model that does not exist yet. diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index c93540b..6438197 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -130,9 +130,11 @@ 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. 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. +not repeat. Core authorization and an explicit platform-owner bootstrap command are the next +architectural phase; Doctor does not diagnose authorization, the `apps/api` HTTP transport, runtime +module health, or production storage yet, and it never constructs an operational Better Auth +instance, starts the API, or claims sign-in works. See [API](api.md) for `pnpm api:start` and the +identity HTTP transport this repository now has. 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 c90f69f..712ace8 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -13,11 +13,16 @@ These commands exercise package tests, repository boundaries, type checking, off 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. +PostgreSQL behavior. Identity unit tests cover branded opaque-ID contracts, principal shapes, +configuration validation (secret, base URL, and trusted-origin rules), the Core identity runtime +(explicit construction, exact schema mapping, only email/password and rate limiting enabled, +sanitized failures, and that a database outage during principal resolution is never silently +reported as anonymous), and Core migration discovery (deterministic ordering, the reserved `core` +identity, and collision rejection) — all without a live database. `apps/api`'s own unit tests cover +its pure Hono app factory (route shapes, CORS allow/deny/preflight, deterministic 404 and sanitized +error responses), its runtime configuration, and that importing any of its modules — including the +executable server module without calling its exported `main` — never reads the environment, +connects to PostgreSQL, or starts a server. `pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live database. @@ -52,6 +57,22 @@ staying byte-for-byte unchanged once Core is introduced, and a later module migr rolling back cleanly without damaging the already-committed Core identity migration or leaking the advisory lock. +A second dedicated suite (`apps/api/tests/api-http.integration.ts`, included in the same +`pnpm test:migrations:integration` run) starts the real `apps/api` HTTP server against a freshly +migrated disposable database and exercises it with real HTTP requests: public sign-up (with no +automatic session, per `autoSignIn: false`), explicit sign-in producing a host-only +`HttpOnly`/`SameSite=Lax`/secure session cookie, `/api/v1/principal` for both the authenticated and +anonymous shapes, sign-out actually invalidating the session, a malformed or missing cookie +resolving as anonymous, duplicate sign-up creating no second identity row, two distinct accounts +producing identical principal shapes with no elevation and no owner/role/permission data at the +database level, no plaintext password ever stored, CORS never returning an allow-origin header for +an untrusted origin, Better Auth's handler being unreachable outside `/api/auth/*`, a database +outage during principal resolution producing a sanitized `503` rather than a false anonymous `200`, +starting the API against an unmigrated database creating no tables at all, and a clean listener/pool +shutdown. See [ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) +for one CSRF/origin guarantee that was verified manually against the real server but could not be +reproduced reliably inside this test runner, and is documented rather than silently asserted. + 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 79b13c0..5a90f9a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "packageManager": "pnpm@11.12.0", "scripts": { + "api:start": "turbo run build && node --env-file-if-exists=.env apps/api/dist/server.js", "boundaries": "node scripts/check-boundaries.ts", "build": "turbo run build", "ci:verify": "pnpm verify && pnpm infrastructure:config", diff --git a/packages/contracts/src/identity.ts b/packages/contracts/src/identity.ts index d9cce00..c70cddd 100644 --- a/packages/contracts/src/identity.ts +++ b/packages/contracts/src/identity.ts @@ -1,6 +1,16 @@ import { z } from "zod"; -export const ekklesiaUserIdSchema = z.string().trim().min(1).max(128); +/** + * Opaque at the type level via Zod's nominal `.brand()`: a plain `string` is not assignable to + * `EkklesiaUserId` without passing through `parseEkklesiaUserId`/`safeParseEkklesiaUserId`. The + * brand exists only in the type system — it adds no runtime tag, wrapper, or field, so a value + * still serializes (JSON.stringify, string concatenation, equality) as a plain opaque string. + * Ekklesia does not assume any particular format (in particular, not UUID): Phase 4A's identity + * schema stores Better Auth's default application-generated id, an opaque base62-ish string, not + * a database-generated UUID. Only the constraints Ekklesia itself owns are validated: non-empty + * after trimming, and a generous upper bound against unreasonably large values. + */ +export const ekklesiaUserIdSchema = z.string().trim().min(1).max(128).brand<"EkklesiaUserId">(); export type EkklesiaUserId = z.infer; diff --git a/packages/contracts/tests/identity.test.ts b/packages/contracts/tests/identity.test.ts index fbea4db..9818a60 100644 --- a/packages/contracts/tests/identity.test.ts +++ b/packages/contracts/tests/identity.test.ts @@ -3,6 +3,7 @@ import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal, type EkklesiaPrincipal, + type EkklesiaUserId, isAnonymousPrincipal, isAuthenticatedPrincipal, parseEkklesiaUserId, @@ -14,6 +15,25 @@ describe("Ekklesia identity contracts", () => { expect(parseEkklesiaUserId("user_123")).toBe("user_123"); }); + it("is opaque at the type level: a plain string is not assignable without parsing", () => { + // @ts-expect-error a bare string must not satisfy the branded EkklesiaUserId type. + const notAllowed: EkklesiaUserId = "raw-string"; + expect(notAllowed).toBe("raw-string"); // the brand adds no runtime wrapper or tag + }); + + it("serializes as a plain opaque string despite the type-level brand", () => { + const userId = parseEkklesiaUserId("user_123"); + + expect(JSON.stringify({ userId })).toBe('{"userId":"user_123"}'); + expect(`${userId}`).toBe("user_123"); + expect(typeof userId).toBe("string"); + }); + + it("does not assume any particular id format such as UUID", () => { + expect(() => parseEkklesiaUserId("not-a-uuid-at-all")).not.toThrow(); + expect(() => parseEkklesiaUserId("2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1")).not.toThrow(); + }); + it("rejects an empty or oversized user id", () => { expect(() => parseEkklesiaUserId("")).toThrow(); expect(() => parseEkklesiaUserId(" ")).toThrow(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed9cd3a..51a14a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,43 @@ importers: specifier: 2.9.0 version: 2.9.0 + apps/api: + dependencies: + '@ekklesia/contracts': + specifier: workspace:* + version: link:../../packages/contracts + '@ekklesia/core': + specifier: workspace:* + version: link:../../core + '@hono/node-server': + specifier: 2.0.10 + version: 2.0.10(hono@4.12.31) + hono: + specifier: 4.12.31 + version: 4.12.31 + devDependencies: + '@biomejs/biome': + specifier: 2.5.3 + version: 2.5.3 + '@ekklesia/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + '@types/pg': + specifier: 8.20.0 + version: 8.20.0 + pg: + specifier: 8.22.0 + version: 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)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + core: dependencies: '@ekklesia/contracts': @@ -923,6 +960,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@2.0.10': + resolution: {integrity: sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1587,6 +1630,10 @@ packages: resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==} hasBin: true + hono@4.12.31: + resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} + engines: {node: '>=16.9.0'} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2636,6 +2683,10 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@hono/node-server@2.0.10(hono@4.12.31)': + dependencies: + hono: 4.12.31 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3166,6 +3217,8 @@ snapshots: giget@3.3.0: {} + hono@4.12.31: {} + is-docker@3.0.0: {} is-inside-container@1.0.0: diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index 9271684..b97bc8d 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -4,7 +4,10 @@ export default defineConfig({ test: { fileParallelism: false, hookTimeout: 30_000, - include: ["core/tests/postgres-migration.integration.ts"], + include: [ + "core/tests/postgres-migration.integration.ts", + "apps/api/tests/api-http.integration.ts", + ], passWithNoTests: false, testTimeout: 30_000, },