From 6b6b77c105fd35441a93b191f1ea7e5939119ad1 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sun, 26 Jul 2026 17:03:52 -0500 Subject: [PATCH] fix: enforce production transport policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every PostgreSQL connection this repository opened was plaintext, in every environment: no connection site set `ssl` at all, so credentials, session tokens, and row data crossed the network in the clear and nothing in configuration, startup, or `pnpm doctor` said so. The public identity URL boundary was also looser than it appeared. `core/src/database/transport.ts` is now the one typed policy. It has exactly two modes: `verify-full` (TLS required, certificate chain verified, and server identity verified against the configured host — the production default) and `disable` (the development/test default, refused outright in production). There is deliberately no encryption-only `require` mode, no chain-only `verify-ca`, and no `rejectUnauthorized: false` escape hatch anywhere in the configuration surface; every loose or aliased spelling is rejected in both environments, so a typo never quietly becomes a weaker mode. Production has no plaintext exception at all, not even for a loopback database — a connection string cannot prove the database is process-local. Verification is certificate *and* hostname. The policy supplies an explicit `checkServerIdentity` pinned to the host parsed from DATABASE_URL, because the pinned driver sets TLS `servername` only for non-IP hosts and Node performs no identity check when it is unset — so `rejectUnauthorized: true` alone accepts a certificate from any host the trusted CA ever signed when connecting by IP. Both halves are proven against a real TLS-enabled PostgreSQL, including that the same connection without the pinned callback succeeds. Every CA, certificate, and key is generated at test runtime and deleted afterwards. A DATABASE_URL may no longer carry its own TLS parameters. The pinned driver merges parsed connection-string values over explicitly passed options, so a `?sslmode=disable` in the URL would silently replace the policy's verified `ssl` object; that now fails with a stable code, reported by both `pnpm doctor` and the migration CLIs rather than as "failed unexpectedly". One shared environment reader keeps `process.env` out of Core's reusable constructors, and every executable that opens a connection resolves the policy identically: the API (validated before any pool, Better Auth instance, module runtime, or listener exists), both migration commands, the bootstrap CLI, and Doctor's own connection. Doctor reports it as a blocking failure, never a warning, naming only the effective mode. Separately, the identity base URL and every trusted origin must now be exactly one canonical origin — the WHATWG origin serialization must equal the supplied string — so a path, trailing slash, credentials, explicit default port, or non-canonical case is rejected rather than normalized into something that no longer matches what a browser sends. Production's loopback rejection now classifies the address instead of comparing against three literals, covering `.localhost`, all of 127.0.0.0/8, 0.0.0.0, `::`, expanded and IPv4-mapped forms. This is a configuration-shape guarantee only: it does not resolve DNS or prove reachability, and TLS protects the connection, not the exposure. Co-Authored-By: Claude Opus 5 --- .env.example | 7 + README.md | 38 +- apps/api/src/config.ts | 28 +- apps/api/src/server.ts | 12 + apps/api/tests/api-http.integration.ts | 27 +- apps/api/tests/config.test.ts | 131 +++++++ apps/api/tests/lifecycle.test.ts | 53 +++ core/src/authorization/database.ts | 13 + core/src/cli/apply-migrations.ts | 10 +- core/src/cli/bootstrap-platform-owner.ts | 5 + .../src/cli/database-transport-environment.ts | 46 +++ core/src/cli/doctor.ts | 11 + core/src/cli/migration-command-errors.ts | 9 + core/src/cli/plan-migrations.ts | 10 +- core/src/database/index.ts | 1 + core/src/database/pool.ts | 14 + core/src/database/transport.ts | 252 +++++++++++++ core/src/diagnostics/doctor-report.ts | 2 +- core/src/diagnostics/doctor.ts | 61 ++++ core/src/identity/config.ts | 162 +++++++-- core/src/identity/database.ts | 13 + core/src/migrations/postgres-database.ts | 16 + core/tests/database-tls.integration.ts | 331 ++++++++++++++++++ core/tests/database-transport.test.ts | 303 ++++++++++++++++ core/tests/doctor-report.test.ts | 2 + core/tests/doctor.test.ts | 60 +++- core/tests/identity-config.test.ts | 120 +++++++ ...ssion-resolution-and-database-transport.md | 217 ++++++++++++ docs/architecture/decisions/README.md | 1 + docs/development/api.md | 63 +++- docs/development/authorization.md | 8 + docs/development/identity.md | 33 +- docs/development/testing.md | 27 +- vitest.migrations.integration.config.ts | 1 + 34 files changed, 2035 insertions(+), 52 deletions(-) create mode 100644 core/src/cli/database-transport-environment.ts create mode 100644 core/src/database/transport.ts create mode 100644 core/tests/database-tls.integration.ts create mode 100644 core/tests/database-transport.test.ts create mode 100644 docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md diff --git a/.env.example b/.env.example index 4880e03..d86af41 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,11 @@ # Local development only. Replace every credential outside a developer workstation. + +# Required for a local workstation, and only ever correct there. Anything else — including an +# unset value — is validated as production, which requires certificate-verified PostgreSQL TLS +# (EKKLESIA_DATABASE_TLS_MODE defaults to verify-full) and rejects loopback/http identity origins. +# The plaintext loopback DATABASE_URL below is therefore only usable with this set. +NODE_ENV=development + POSTGRES_DB=ekklesia POSTGRES_USER=ekklesia POSTGRES_PASSWORD=ekklesia-development-only diff --git a/README.md b/README.md index b3cb468..a5c0a88 100644 --- a/README.md +++ b/README.md @@ -117,13 +117,35 @@ guarantee, and it does not complete Phase 5D** — passes 2–5 (identity/databa authorization integrity, module/runtime/migration correctness, and deployment/supply-chain closure plus a final independent re-audit) remain outstanding before Phase 6 (Surface work) may begin. +Phase 5D pass 2 of 5 corrects identity session resolution and enforces a database transport policy. +A request carrying no cookies, or only unrelated cookies, now performs **no** identity database +work at all; only a request presenting the configured session cookie that Better Auth resolved to +`null` is disambiguated, by a real read of the session relation and the exact columns the adapter +depends on (under `where false`, so no row is ever read) rather than the previous `select 1`. A +missing table, dropped column, revoked privilege, unreachable database, or query timeout therefore +produces a sanitized `503`, never a silent anonymous downgrade. The identity base URL and every +trusted origin must now be exactly one canonical origin, and production rejects the full +loopback/unspecified address space rather than three literal spellings. **Production PostgreSQL +connections now require TLS with certificate *and* hostname verification** — from the API, both +migration commands, the bootstrap CLI, and `pnpm doctor` alike — with no encryption-only mode, no +`rejectUnauthorized: false` escape hatch, no plaintext exception (not even for a loopback +database), and an explicit refusal of a `DATABASE_URL` that tries to carry its own TLS parameters, +which the pinned driver would otherwise let silently override the policy. See +[Identity](docs/development/identity.md), [API](docs/development/api.md#database-transport-tls), and +[ADR 0018](docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md). +**This pass adds no secret store, no certificate rotation, no client-certificate authentication, and +no distributed rate limiting; TLS protects the connection, not the exposure, so the network boundary +remains the deployment's own responsibility. Phase 5D is still not complete** — passes 3–5 +(authorization integrity, module/runtime/migration correctness, and deployment/supply-chain closure +plus a final independent re-audit) remain outstanding. + ## Quick start Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. ```bash pnpm install --frozen-lockfile -cp .env.example .env +cp .env.example .env # keep its NODE_ENV=development: see below pnpm infrastructure:up pnpm modules:validate pnpm migrations:validate @@ -135,9 +157,15 @@ pnpm verify ``` `pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed -modules and dependency order, schema and migration inventories, optional read-only PostgreSQL -planning, and the current object-storage integration state. It never applies migrations, creates a -history table, starts module servers, or performs storage operations. Schema discovery does import +modules and dependency order, schema and migration inventories, the effective PostgreSQL transport +(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage integration +state. `.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset +value, is validated as production, and production requires certificate-verified PostgreSQL TLS and +rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own +development infrastructure provides is only usable with it set. + +Doctor never applies migrations, creates a history table, starts module servers, or performs +storage operations. Schema discovery does import compiled schema-definition entrypoints; those entrypoints are contractually side-effect-free. With no installed modules and no `DATABASE_URL`, the current repository produces a report shaped @@ -167,6 +195,8 @@ Data definitions core:0001_authorization_core_tables · 830e1fbc26d0 PostgreSQL + ✓ Database TLS verified + TLS required with certificate and hostname verification. ○ Connection check skipped DATABASE_URL is not configured. ○ Migration plan skipped diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index d48fe1c..48cca77 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -1,3 +1,9 @@ +import { + assertProductionDatabaseTransport, + type DatabaseTransportPolicy, + type DatabaseTransportPolicyInput, + createDatabaseTransportPolicy, +} from "@ekklesia/core/database"; import { type CreateIdentityConfigOptions, createIdentityConfig, @@ -44,6 +50,8 @@ const defaultPort = 3000; export interface ApiRuntimeConfig { readonly clientAddress: ClientAddressConfig; readonly databaseUrl: string; + /** The one validated PostgreSQL transport policy this runtime's single pool uses. */ + readonly databaseTransport: DatabaseTransportPolicy; readonly host: string; readonly identity: IdentityConfig; readonly port: number; @@ -54,6 +62,9 @@ export interface ApiRuntimeConfigInput { * trust unless explicitly configured. See `./client-address.js`. */ readonly clientAddress?: ClientAddressConfigInput; readonly databaseUrl?: unknown; + /** Optional. Defaults to certificate-verified TLS in production and plaintext in development; + * production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */ + readonly databaseTransport?: DatabaseTransportPolicyInput; readonly host?: unknown; readonly identity: IdentityConfigInput; readonly port?: unknown; @@ -122,6 +133,21 @@ export function createApiRuntimeConfig( options.environment === undefined ? {} : { environment: options.environment }, ); const clientAddress = createClientAddressConfig(input.clientAddress); + const environment = options.environment ?? "production"; + const databaseTransport = createDatabaseTransportPolicy(input.databaseTransport, { + environment, + }); + // Fails here — before `startApi` constructs a pool, Better Auth, the module runtime, or a + // listener — if a production deployment would connect without certificate-verified TLS, or if + // the URL carries TLS parameters that could override the policy. + assertProductionDatabaseTransport(databaseUrl, databaseTransport, environment); - return Object.freeze({ clientAddress, databaseUrl, host, identity, port }); + return Object.freeze({ + clientAddress, + databaseTransport, + databaseUrl, + host, + identity, + port, + }); } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 9c85247..ad19d58 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -260,6 +260,10 @@ export async function startApi( const databaseHandle = createApiDatabase(config.databaseUrl, { connectionTimeoutMs, operationTimeoutMs, + // The one validated transport policy reaches this runtime's single physical pool here; the + // identity, authorization, and module Drizzle views are all typed views over that same pool, + // so TLS never introduces a second connection. + transport: config.databaseTransport, }); let services: ModuleRuntimeServices; @@ -425,6 +429,14 @@ function readConfigFromEnvironment(): { environment: process.env.NODE_ENV === "development" ? "development" : "production", input: { ...(clientAddress === undefined ? {} : { clientAddress }), + databaseTransport: { + ...(process.env.EKKLESIA_DATABASE_CA === undefined + ? {} + : { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }), + ...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined + ? {} + : { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }), + }, databaseUrl: process.env.DATABASE_URL, host: process.env.EKKLESIA_API_HOST, identity: { diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index a862f0c..2161a6a 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -347,14 +347,20 @@ async function startConfiguredApi( options: StartConfiguredApiOptions = {}, ): Promise { const publicSignupEnabled = options.publicSignupEnabled ?? true; - // Production configuration refuses to enable public signup at all until durable abuse controls - // exist (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, `core/src/identity/config.ts`), so a scenario - // that creates accounts over HTTP must validate its configuration as development — the explicit, - // intended way to opt in. This changes only Ekklesia's own config-validation strictness: - // `environment` never reaches Better Auth, and this harness keeps the identical https, - // non-loopback base URL and trusted origins either way, so the production-equivalent origin/CSRF - // behavior ADR 0012 established is exercised exactly the same in both cases. - const environment = publicSignupEnabled ? "development" : "production"; + // This harness explicitly selects development configuration, for two independent reasons that + // both apply to every scenario in this suite: + // + // 1. It connects to the disposable *plaintext* loopback PostgreSQL. Production configuration + // requires certificate-verified TLS with no exception, so a plaintext test database can + // only be used by a deployment that has explicitly declared itself non-production. + // 2. Scenarios that create accounts over HTTP need public signup, which production refuses to + // enable at all until durable abuse controls exist. + // + // This changes only Ekklesia's own configuration-validation strictness — `environment` never + // reaches Better Auth — and the harness keeps the identical https, non-loopback base URL and + // trusted origins regardless, so the production-equivalent origin/CSRF behavior ADR 0012 + // established is exercised exactly the same either way. + const environment = "development" as const; const config = createApiRuntimeConfig( { ...(options.clientAddress === undefined ? {} : { clientAddress: options.clientAddress }), @@ -2255,7 +2261,10 @@ describe.sequential("live API — Node header/request timeouts and client errors }, port: 0, }, - { environment: "production" }, + // Development configuration, for the same reason `startConfiguredApi` documents: this suite + // connects to the disposable plaintext loopback PostgreSQL, which production configuration + // never permits. Nothing about the transport-level timeout behavior under test depends on it. + { environment: "development" }, ); // Deliberately tiny, test-only timeouts — production composition (server.ts's `main`) never // sets this override — so a real timeout is observed deterministically in well under a second diff --git a/apps/api/tests/config.test.ts b/apps/api/tests/config.test.ts index 8a3dfa4..49dc48b 100644 --- a/apps/api/tests/config.test.ts +++ b/apps/api/tests/config.test.ts @@ -22,6 +22,8 @@ describe("API runtime configuration", () => { expect(config).toEqual({ clientAddress: { mode: "direct" }, + // Production defaults to certificate-verified TLS with no configuration at all. + databaseTransport: { mode: "verify-full" }, databaseUrl: validInput().databaseUrl, host: "127.0.0.1", identity: { @@ -185,6 +187,135 @@ describe("API runtime configuration", () => { }); }); + describe("PostgreSQL transport policy", () => { + it("defaults to certificate-verified TLS in production and plaintext in development", () => { + expect(createApiRuntimeConfig(validInput()).databaseTransport).toEqual({ + mode: "verify-full", + }); + expect( + createApiRuntimeConfig( + { + ...validInput(), + identity: { + baseUrl: "http://localhost:3000", + secret: validSecret, + trustedOrigins: ["http://localhost:3000"], + }, + }, + { environment: "development" }, + ).databaseTransport, + ).toEqual({ mode: "disable" }); + }); + + it("refuses a production configuration that disables TLS", () => { + let captured: unknown; + try { + createApiRuntimeConfig( + { ...validInput(), databaseTransport: { mode: "disable" } }, + { environment: "production" }, + ); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION" }); + }); + + it("rejects a malformed or loosely spelled TLS mode in every environment, without coercion", () => { + for (const mode of [ + "VERIFY-FULL", + " verify-full ", + "require", + "verify-ca", + "prefer", + "no-verify", + "true", + true, + 1, + "", + ]) { + for (const environment of ["production", "development"] as const) { + let captured: unknown; + try { + createApiRuntimeConfig( + { + ...validInput(), + databaseTransport: { mode }, + ...(environment === "development" + ? { + identity: { + baseUrl: "http://localhost:3000", + secret: validSecret, + trustedOrigins: ["http://localhost:3000"], + }, + } + : {}), + }, + { environment }, + ); + } catch (error) { + captured = error; + } + expect(captured, `expected mode ${JSON.stringify(mode)} to be rejected`).toMatchObject({ + code: "DATABASE_TLS_MODE_INVALID", + }); + } + } + }); + + it("rejects a database URL carrying its own TLS parameters, which the pinned driver would let override the policy", () => { + for (const parameter of [ + "sslmode=disable", + "sslmode=require", + "sslmode=verify-full", + "ssl=true", + "sslrootcert=/tmp/ca.crt", + "sslcert=/tmp/c.crt", + "sslkey=/tmp/c.key", + "uselibpqcompat=true", + ]) { + let captured: unknown; + try { + createApiRuntimeConfig( + { + ...validInput(), + databaseUrl: `postgresql://ekklesia:ekklesia@db.example.test:5432/ekklesia?${parameter}`, + }, + { environment: "production" }, + ); + } catch (error) { + captured = error; + } + expect(captured, `expected ${parameter} to be rejected`).toMatchObject({ + code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT", + }); + } + }); + + it("never leaks the database URL, credentials, host, or CA material through a transport error", () => { + let captured: unknown; + try { + createApiRuntimeConfig( + { + ...validInput(), + databaseTransport: { certificateAuthority: "-----BEGIN CERTIFICATE-----SECRET" }, + databaseUrl: + "postgresql://admin:hunter2@db.internal.example:5432/ekklesia?sslmode=disable", + }, + { environment: "production" }, + ); + } catch (error) { + captured = error; + } + const publicText = `${String(captured)}\n${ + captured instanceof Error ? (captured.stack ?? "") : "" + }\n${JSON.stringify(captured)}`; + expect(publicText).not.toContain("hunter2"); + expect(publicText).not.toContain("db.internal.example"); + expect(publicText).not.toContain("SECRET"); + expect(publicText).not.toContain("sslmode"); + }); + }); + 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; diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index f4b14e2..19c0727 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -460,6 +460,59 @@ describe("Ekklesia API shutdown lifecycle", () => { expect(poolEnd).not.toHaveBeenCalled(); }); + it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => { + const poolEnd = vi.fn().mockResolvedValue(undefined); + poolConstructor.mockImplementation(function fakePool() { + return { end: poolEnd, on: vi.fn() }; + }); + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); + mockSuccessfulServe(); + + // Both an explicit production plaintext selection and a URL that would silently downgrade the + // policy are rejected in `createApiRuntimeConfig` — the same boundary the executable entry + // point calls before `startApi` — so no runtime resource is ever constructed to leak. + for (const [label, input] of [ + [ + "explicit production plaintext", + { + databaseTransport: { mode: "disable" }, + databaseUrl: "postgresql://ekklesia:ekklesia@db.example.test:5432/ekklesia", + }, + ], + [ + "connection-string TLS downgrade", + { + databaseUrl: + "postgresql://ekklesia:ekklesia@db.example.test:5432/ekklesia?sslmode=disable", + }, + ], + ] as const) { + let captured: unknown; + try { + createApiRuntimeConfig( + { + host: "127.0.0.1", + identity: { + baseUrl: "https://identity.example.test", + secret: "correct-horse-battery-staple-0123456789", + trustedOrigins: ["https://identity.example.test"], + }, + port: 0, + ...input, + }, + { environment: "production" }, + ); + } catch (error) { + captured = error; + } + expect(captured, `expected ${label} to be rejected`).toBeInstanceOf(Error); + expect(poolConstructor).not.toHaveBeenCalled(); + expect(betterAuthMock).not.toHaveBeenCalled(); + expect(serveMock).not.toHaveBeenCalled(); + expect(poolEnd).not.toHaveBeenCalled(); + } + }); + it("closes an already-created database pool when the server fails to bind its listen address", async () => { const poolEnd = vi.fn().mockResolvedValue(undefined); poolConstructor.mockImplementation(function fakePool() { diff --git a/core/src/authorization/database.ts b/core/src/authorization/database.ts index 8050614..a0dbe1d 100644 --- a/core/src/authorization/database.ts +++ b/core/src/authorization/database.ts @@ -1,5 +1,9 @@ import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import { Pool, type PoolConfig } from "pg"; +import { + type DatabaseTransportPolicy, + databaseTlsConnectionOptions, +} from "../database/transport.js"; import { authorizationSchema } from "./schema.js"; export type AuthorizationDrizzleDatabase = NodePgDatabase; @@ -27,6 +31,10 @@ export interface AuthorizationDatabaseOptions { * `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a * connection, not a hung query already running on one. */ readonly operationTimeoutMs?: number; + /** The validated transport policy (`core/src/database/transport.js`). Passed explicitly by + * every production executable; omitted by single-domain unit/integration factories that use + * the disposable loopback database, which keeps the pre-existing plaintext behavior. */ + readonly transport?: DatabaseTransportPolicy; } export interface AuthorizationDatabaseHandle { @@ -80,8 +88,13 @@ export function createAuthorizationDrizzleDatabase( const validatedConnectionString = validateConnectionString(connectionString); const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs); const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs); + const ssl = + options.transport === undefined + ? undefined + : databaseTlsConnectionOptions(validatedConnectionString, options.transport); const poolConfig: PoolConfig = { connectionString: validatedConnectionString, + ...(ssl === undefined ? {} : { ssl }), ...(validatedConnectionTimeoutMs === undefined ? {} : { connectionTimeoutMillis: validatedConnectionTimeoutMs }), diff --git a/core/src/cli/apply-migrations.ts b/core/src/cli/apply-migrations.ts index f1b91cf..50501f5 100644 --- a/core/src/cli/apply-migrations.ts +++ b/core/src/cli/apply-migrations.ts @@ -1,11 +1,19 @@ import { formatMigrationApplyReport } from "./migration-report.js"; import { discoverRepositoryMigrations } from "./migration-command-support.js"; import { reportMigrationCommandError } from "./migration-command-errors.js"; +import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js"; import { applyModuleMigrations, createPostgresMigrationDatabase } from "../migrations/index.js"; async function main(): Promise { const aggregate = await discoverRepositoryMigrations(); - const database = createPostgresMigrationDatabase(process.env.DATABASE_URL); + const databaseUrl = process.env.DATABASE_URL; + // Resolved and asserted before any client exists: an unsafe production transport fails + // here rather than after opening a connection. + const transport = + typeof databaseUrl === "string" ? resolveExecutableDatabaseTransport(databaseUrl) : undefined; + const database = createPostgresMigrationDatabase(databaseUrl, { + ...(transport === undefined ? {} : { transport }), + }); const result = await applyModuleMigrations({ aggregate, database }); for (const line of formatMigrationApplyReport(result)) { diff --git a/core/src/cli/bootstrap-platform-owner.ts b/core/src/cli/bootstrap-platform-owner.ts index 4be74c7..af35078 100644 --- a/core/src/cli/bootstrap-platform-owner.ts +++ b/core/src/cli/bootstrap-platform-owner.ts @@ -6,6 +6,7 @@ import { createAuthorizationDrizzleDatabase, PlatformOwnerBootstrapError, } from "../authorization/index.js"; +import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js"; class BootstrapCliError extends Error { constructor(message: string) { @@ -67,9 +68,13 @@ async function main(): Promise { throw new BootstrapCliError("DATABASE_URL is required."); } + // Asserted before the connection is constructed, so an unsafe production transport never opens + // a client at all. + const transport = resolveExecutableDatabaseTransport(databaseUrl); const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, { connectionTimeoutMs: 10_000, operationTimeoutMs: 10_000, + transport, }); try { await bootstrapPlatformOwner(db, { userId: parsedUserId.data }); diff --git a/core/src/cli/database-transport-environment.ts b/core/src/cli/database-transport-environment.ts new file mode 100644 index 0000000..0940f9e --- /dev/null +++ b/core/src/cli/database-transport-environment.ts @@ -0,0 +1,46 @@ +import { + assertProductionDatabaseTransport, + type DatabaseTransportEnvironment, + type DatabaseTransportPolicy, + createDatabaseTransportPolicy, +} from "../database/transport.js"; + +/** + * The one place every Core-owned executable reads its database transport policy from the process + * environment, so `pnpm doctor`, `pnpm migrations:plan`, `pnpm migrations:apply`, and + * `pnpm authorization:bootstrap-owner` cannot drift from each other or from `apps/api`. + * + * Environment reading lives here, in the executable layer, deliberately: the reusable Core + * database/migration constructors keep taking explicit typed inputs and never touch `process.env`. + * `NODE_ENV` is interpreted exactly as `apps/api` interprets it — only the literal string + * `"development"` relaxes anything, so an unset or misspelled value validates as production. + */ +export function readDatabaseTransportFromEnvironment(): { + readonly environment: DatabaseTransportEnvironment; + readonly transport: DatabaseTransportPolicy; +} { + const environment: DatabaseTransportEnvironment = + process.env.NODE_ENV === "development" ? "development" : "production"; + const transport = createDatabaseTransportPolicy( + { + ...(process.env.EKKLESIA_DATABASE_CA === undefined + ? {} + : { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }), + ...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined + ? {} + : { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }), + }, + { environment }, + ); + return { environment, transport }; +} + +/** + * Resolves the policy and asserts it is safe for the given database URL before any client, pool, + * or connection is constructed. Throws the sanitized `DatabaseTransportError` otherwise. + */ +export function resolveExecutableDatabaseTransport(databaseUrl: string): DatabaseTransportPolicy { + const { environment, transport } = readDatabaseTransportFromEnvironment(); + assertProductionDatabaseTransport(databaseUrl, transport, environment); + return transport; +} diff --git a/core/src/cli/doctor.ts b/core/src/cli/doctor.ts index f321caf..7dbf938 100644 --- a/core/src/cli/doctor.ts +++ b/core/src/cli/doctor.ts @@ -81,8 +81,19 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise< const rootPackage = rootPackageDocument(rootValue); const moduleSdkPackage = rootPackageDocument(moduleSdkValue); + const databaseEnvironment = + process.env.NODE_ENV === "development" ? ("development" as const) : ("production" as const); const report = await diagnoseEkklesia({ clientAddress: readClientAddressFromEnvironment(), + databaseEnvironment, + databaseTransport: { + ...(process.env.EKKLESIA_DATABASE_CA === undefined + ? {} + : { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }), + ...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined + ? {} + : { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }), + }, databaseUrl: process.env.DATABASE_URL, identityConfiguration: readIdentityConfigurationFromEnvironment(), moduleDiscovery: { diff --git a/core/src/cli/migration-command-errors.ts b/core/src/cli/migration-command-errors.ts index acb31a1..479457c 100644 --- a/core/src/cli/migration-command-errors.ts +++ b/core/src/cli/migration-command-errors.ts @@ -1,7 +1,16 @@ +import { DatabaseTransportError } from "../database/transport.js"; import { MigrationRunnerError, PostgresMigrationDatabaseError } from "../migrations/index.js"; import { ModuleDiscoveryError, ModuleMigrationDiscoveryError } from "../modules/index.js"; export function reportMigrationCommandError(error: unknown): void { + if (error instanceof DatabaseTransportError) { + // The message is a fixed string chosen by code; it never carries the connection string, host, + // port, credentials, CA material, or the offending mode value, so it is safe to print as-is — + // and an operator who misconfigured TLS needs to know that, not "failed unexpectedly". + console.error(`[${error.code}] ${error.message}`); + return; + } + if (error instanceof ModuleDiscoveryError) { console.error(error.message); for (const issue of error.issues) { diff --git a/core/src/cli/plan-migrations.ts b/core/src/cli/plan-migrations.ts index 2459f4f..a23f6d3 100644 --- a/core/src/cli/plan-migrations.ts +++ b/core/src/cli/plan-migrations.ts @@ -1,11 +1,19 @@ import { formatMigrationPlanReport } from "./migration-report.js"; import { discoverRepositoryMigrations } from "./migration-command-support.js"; import { reportMigrationCommandError } from "./migration-command-errors.js"; +import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js"; import { createPostgresMigrationDatabase, planModuleMigrations } from "../migrations/index.js"; async function main(): Promise { const aggregate = await discoverRepositoryMigrations(); - const database = createPostgresMigrationDatabase(process.env.DATABASE_URL); + const databaseUrl = process.env.DATABASE_URL; + // Resolved and asserted before any client exists: an unsafe production transport fails + // here rather than after opening a connection. + const transport = + typeof databaseUrl === "string" ? resolveExecutableDatabaseTransport(databaseUrl) : undefined; + const database = createPostgresMigrationDatabase(databaseUrl, { + ...(transport === undefined ? {} : { transport }), + }); const plan = await planModuleMigrations({ aggregate, database }); for (const line of formatMigrationPlanReport(plan)) { diff --git a/core/src/database/index.ts b/core/src/database/index.ts index 8636161..f98a1cd 100644 --- a/core/src/database/index.ts +++ b/core/src/database/index.ts @@ -1 +1,2 @@ export * from "./pool.js"; +export * from "./transport.js"; diff --git a/core/src/database/pool.ts b/core/src/database/pool.ts index 0273141..a09d3e4 100644 --- a/core/src/database/pool.ts +++ b/core/src/database/pool.ts @@ -1,4 +1,5 @@ import { Pool, type PoolConfig } from "pg"; +import { type DatabaseTransportPolicy, databaseTlsConnectionOptions } from "./transport.js"; export type PostgresPoolErrorCode = "POSTGRES_POOL_CONFIGURATION_INVALID"; @@ -22,6 +23,11 @@ export interface PostgresPoolOptions { /** Bounds both `query_timeout` and `statement_timeout`: `connectionTimeoutMs` alone only bounds * establishing a connection, not a hung query already running on one. */ readonly operationTimeoutMs?: number; + /** The validated transport policy (`./transport.js`). Omitted only by isolated unit-test and + * single-domain factories that connect to a disposable loopback database; every production + * executable passes one explicitly. When omitted, no `ssl` option is set and the connection is + * plaintext — exactly the pre-existing development behavior. */ + readonly transport?: DatabaseTransportPolicy; } export interface PostgresPoolHandle { @@ -84,8 +90,16 @@ export function createValidatedPostgresPool( const validatedConnectionString = validateConnectionString(connectionString); const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs); const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs); + // `databaseTlsConnectionOptions` also rejects a URL carrying its own TLS parameters, so the + // pinned driver's `Object.assign({}, config, parse(connectionString))` precedence can never + // silently replace the policy's `ssl` object with a weaker one. + const ssl = + options.transport === undefined + ? undefined + : databaseTlsConnectionOptions(validatedConnectionString, options.transport); const poolConfig: PoolConfig = { connectionString: validatedConnectionString, + ...(ssl === undefined ? {} : { ssl }), ...(validatedConnectionTimeoutMs === undefined ? {} : { connectionTimeoutMillis: validatedConnectionTimeoutMs }), diff --git a/core/src/database/transport.ts b/core/src/database/transport.ts new file mode 100644 index 0000000..f8a2918 --- /dev/null +++ b/core/src/database/transport.ts @@ -0,0 +1,252 @@ +import net from "node:net"; +import tls from "node:tls"; +import type { ConnectionOptions } from "node:tls"; + +export type DatabaseTransportEnvironment = "development" | "production"; + +/** + * The two transport modes this release supports, named after the libpq/PostgreSQL vocabulary an + * operator already knows: + * + * - `verify-full` — TLS is required, the server certificate must chain to a trusted CA, **and** + * the certificate identity must match the configured database host. This is the production + * default, and the only mode a production deployment may use. + * - `disable` — no TLS at all. Available only for the disposable loopback PostgreSQL used by local + * development and this repository's integration harness. + * + * There is deliberately no encryption-only mode (`require`/`verify-ca`) and no + * `rejectUnauthorized: false` escape hatch: both authenticate nothing, and an unauthenticated + * encrypted channel to an attacker-controlled endpoint is not a security property worth offering. + */ +export type DatabaseTlsMode = "disable" | "verify-full"; + +export type DatabaseTransportErrorCode = + | "DATABASE_TLS_CONNECTION_STRING_CONFLICT" + | "DATABASE_TLS_MODE_INVALID" + | "DATABASE_TLS_REQUIRED_IN_PRODUCTION" + | "DATABASE_URL_INVALID"; + +const errorMessages: Readonly> = Object.freeze({ + DATABASE_TLS_CONNECTION_STRING_CONFLICT: + "The database URL must not carry TLS parameters; configure the database TLS mode explicitly.", + DATABASE_TLS_MODE_INVALID: 'The database TLS mode must be exactly "verify-full" or "disable".', + DATABASE_TLS_REQUIRED_IN_PRODUCTION: + "A production database connection must use certificate-verified TLS.", + DATABASE_URL_INVALID: "The database URL must be an absolute postgres:// or postgresql:// URL.", +}); + +/** Never carries the connection string, host, port, database name, credentials, certificate, CA + * material, or the offending mode value. */ +export class DatabaseTransportError extends Error { + readonly code: DatabaseTransportErrorCode; + + constructor(code: DatabaseTransportErrorCode) { + super(errorMessages[code]); + this.name = "DatabaseTransportError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +export interface DatabaseTransportPolicy { + readonly mode: DatabaseTlsMode; + /** PEM trust material for a private PostgreSQL CA. Optional: when omitted, Node's own trusted CA + * set is used. Never logged, never echoed in an error. */ + readonly certificateAuthority?: string; +} + +export interface DatabaseTransportPolicyInput { + readonly mode?: unknown; + readonly certificateAuthority?: unknown; +} + +export interface CreateDatabaseTransportPolicyOptions { + /** Defaults to production, so an omitted environment never silently relaxes transport security. */ + readonly environment?: DatabaseTransportEnvironment; +} + +/** + * Every TLS-related parameter `pg-connection-string@2.14.0` recognizes in a PostgreSQL URL. These + * are rejected outright rather than merged, because the pinned driver resolves them in a way that + * silently defeats an explicit policy: `pg`'s own `ConnectionParameters` does + * `Object.assign({}, config, parse(config.connectionString))` + * (`pg/lib/connection-parameters.js`), so a URL parameter **overwrites** the `ssl` object a caller + * passed alongside it. Verified directly against a live TLS PostgreSQL instance: a + * `?sslmode=disable` URL combined with an explicit certificate-verifying `ssl` object connects in + * plaintext. + * + * The pinned release additionally aliases `prefer`/`require`/`verify-ca` to full verification while + * printing a deprecation warning that they will adopt weaker libpq semantics in `pg@9`, so even + * a URL that looks safe today is not a durable guarantee. One typed policy is the whole contract. + */ +const CONNECTION_STRING_TLS_PARAMETERS = Object.freeze([ + "ssl", + "sslcert", + "sslkey", + "sslmode", + "sslnegotiation", + "sslpassword", + "sslrootcert", + "uselibpqcompat", +]); + +function parseDatabaseUrl(databaseUrl: string): URL { + let url: URL; + try { + url = new URL(databaseUrl); + } catch { + throw new DatabaseTransportError("DATABASE_URL_INVALID"); + } + if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") { + throw new DatabaseTransportError("DATABASE_URL_INVALID"); + } + return url; +} + +/** The host a certificate's identity must match — the exact host the driver will connect to. */ +export function databaseHostFromUrl(databaseUrl: string): string { + const url = parseDatabaseUrl(databaseUrl); + // `URL.hostname` keeps IPv6 literals bracketed; TLS identity checking wants the bare address. + const hostname = url.hostname; + return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; +} + +/** Whether the database endpoint is an unambiguous loopback literal. Used only to decide whether + * the development plaintext mode is even permissible; it is never a production allowance. */ +function isLoopbackDatabaseHost(host: string): boolean { + const normalized = host.toLowerCase(); + if (normalized === "localhost" || normalized.endsWith(".localhost")) { + return true; + } + if (net.isIPv4(normalized)) { + return normalized.startsWith("127."); + } + if (net.isIPv6(normalized)) { + return normalized === "::1" || normalized === "::ffff:127.0.0.1"; + } + return false; +} + +/** + * Validates the typed transport policy. Parsing is deliberately separate from environment policy, + * matching the convention the public-signup gate already established: a malformed mode is reported + * as malformed in every environment, never relabeled as a production failure. + */ +export function createDatabaseTransportPolicy( + input: DatabaseTransportPolicyInput = {}, + options: CreateDatabaseTransportPolicyOptions = {}, +): DatabaseTransportPolicy { + const environment = options.environment ?? "production"; + + let mode: DatabaseTlsMode; + if (input.mode === undefined) { + // Production fails safe to verified TLS; development keeps the plaintext disposable-PostgreSQL + // workflow this repository's harness relies on. + mode = environment === "production" ? "verify-full" : "disable"; + } else if (input.mode === "verify-full" || input.mode === "disable") { + mode = input.mode; + } else { + // No coercion of booleans, `1`, `"TRUE"`, `" verify-full "`, or any other loose spelling. + throw new DatabaseTransportError("DATABASE_TLS_MODE_INVALID"); + } + + if (environment === "production" && mode !== "verify-full") { + throw new DatabaseTransportError("DATABASE_TLS_REQUIRED_IN_PRODUCTION"); + } + + if ( + input.certificateAuthority !== undefined && + (typeof input.certificateAuthority !== "string" || + input.certificateAuthority.trim().length === 0) + ) { + throw new DatabaseTransportError("DATABASE_TLS_MODE_INVALID"); + } + + return Object.freeze({ + ...(typeof input.certificateAuthority === "string" + ? { certificateAuthority: input.certificateAuthority } + : {}), + mode, + }); +} + +/** + * Rejects any database URL carrying its own TLS parameters. Called before a pool or client is ever + * constructed, so a URL can never silently downgrade or contradict the validated policy — there is + * exactly one place transport security is decided. + */ +export function assertNoConnectionStringTlsParameters(databaseUrl: string): void { + const url = parseDatabaseUrl(databaseUrl); + for (const [name] of url.searchParams) { + if (CONNECTION_STRING_TLS_PARAMETERS.includes(name.toLowerCase())) { + throw new DatabaseTransportError("DATABASE_TLS_CONNECTION_STRING_CONFLICT"); + } + } +} + +/** + * Translates the validated policy into the exact pinned-`pg` options that achieve it, and asserts + * the URL cannot override them. + * + * `verify-full` is **not** simply `ssl: true` or `{ rejectUnauthorized: true }`. Verified directly + * against a live TLS PostgreSQL instance: those verify the certificate *chain*, but `pg` only sets + * `options.servername` for non-IP hosts (`pg/lib/connection.js`), and Node performs no identity + * check at all when `servername` is unset — so a certificate issued for an entirely different name + * is accepted when connecting to an IP address. Supplying an explicit `checkServerIdentity` pinned + * to the configured host closes that gap for DNS and IP hosts alike (proven: a certificate valid + * only for `localhost` is rejected with `ERR_TLS_CERT_ALTNAME_INVALID` when reached via + * `127.0.0.1`). + */ +export function databaseTlsConnectionOptions( + databaseUrl: string, + policy: DatabaseTransportPolicy, +): false | ConnectionOptions { + assertNoConnectionStringTlsParameters(databaseUrl); + if (policy.mode === "disable") { + return false; + } + const host = databaseHostFromUrl(databaseUrl); + return { + ...(policy.certificateAuthority === undefined ? {} : { ca: policy.certificateAuthority }), + // Chain verification. There is no configuration that can set this to `false`. + rejectUnauthorized: true, + // Identity verification against the host actually being connected to — never the SNI value pg + // may or may not have set, and never skipped for an IP endpoint. + checkServerIdentity: (_servername: string, certificate: tls.PeerCertificate) => + tls.checkServerIdentity(host, certificate), + }; +} + +/** + * The one production guard every executable database path shares: a production deployment must use + * `verify-full`, and its URL must not carry TLS parameters. There is no production plaintext + * exception — not for loopback, not for an RFC1918 address, not for a Docker service name. A + * production process that genuinely runs beside its database still gets certificate-verified TLS, + * which costs nothing and removes an entire class of "it was only internal" misconfiguration. + */ +export function assertProductionDatabaseTransport( + databaseUrl: string, + policy: DatabaseTransportPolicy, + environment: DatabaseTransportEnvironment, +): void { + assertNoConnectionStringTlsParameters(databaseUrl); + if (environment !== "production") { + return; + } + if (policy.mode !== "verify-full") { + throw new DatabaseTransportError("DATABASE_TLS_REQUIRED_IN_PRODUCTION"); + } +} + +/** A fixed, generic description of the effective transport for diagnostics. Never includes the + * URL, host, port, database name, credentials, certificate, or CA path. */ +export function describeDatabaseTransport(policy: DatabaseTransportPolicy): string { + return policy.mode === "verify-full" + ? `TLS required with certificate and hostname verification${ + policy.certificateAuthority === undefined ? "" : " (explicit private CA supplied)" + }.` + : "TLS disabled (development/test plaintext)."; +} + +export { isLoopbackDatabaseHost }; diff --git a/core/src/diagnostics/doctor-report.ts b/core/src/diagnostics/doctor-report.ts index eb0847f..6b1e1aa 100644 --- a/core/src/diagnostics/doctor-report.ts +++ b/core/src/diagnostics/doctor-report.ts @@ -13,7 +13,7 @@ const sections: readonly DoctorSection[] = Object.freeze([ section("Platform", ["platform.runtime"]), section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]), section("Data definitions", ["schemas.discovery", "migrations.discovery"]), - section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]), + section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]), section("Identity", ["identity.configuration", "identity.client-address"]), section("Object storage", ["object-storage.integration"]), ]); diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index 31b9cf3..40f7981 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -7,6 +7,14 @@ import { } from "@ekklesia/contracts"; import { getTableName } from "drizzle-orm"; import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js"; +import { + assertProductionDatabaseTransport, + type DatabaseTransportEnvironment, + type DatabaseTransportPolicyInput, + DatabaseTransportError, + createDatabaseTransportPolicy, + describeDatabaseTransport, +} from "../database/transport.js"; import { type ClientAddressConfigInput, ClientAddressConfigError, @@ -41,6 +49,7 @@ import { } from "../modules/index.js"; export type DoctorCheckId = + | "database.transport" | "platform.runtime" | "modules.discovery" | "modules.dependencies" @@ -78,6 +87,10 @@ export interface DoctorIdentityConfigurationOptions { export interface DoctorOptions { readonly clientAddress?: ClientAddressConfigInput | undefined; + /** The environment the executable resolved, used only for transport policy evaluation; identity + * configuration keeps its own `identityConfiguration.environment` for the same purpose. */ + readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined; + readonly databaseTransport?: DatabaseTransportPolicyInput | undefined; readonly databaseUrl?: string | undefined; readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined; readonly moduleDiscovery: ModuleDiscoveryOptions; @@ -417,6 +430,41 @@ function identityConfigurationCheck( * combination is a `failure`, distinguishing the three states this pass's audit specifically calls * for: safe direct mode, explicitly configured proxy mode, and an invalid configuration. */ +/** + * Pure configuration validation only: never opens a connection, and never prints the database URL, + * host, port, database name, credentials, certificate, or CA material. An unsafe production + * transport is a blocking `failure`, not a warning — a production deployment that would reach + * PostgreSQL without certificate-verified TLS is not "ready with warnings". + */ +function databaseTransportCheck( + input: DatabaseTransportPolicyInput | undefined, + databaseUrl: string | undefined, + environment: DatabaseTransportEnvironment | undefined, +): DoctorCheck { + const effectiveEnvironment = environment ?? "production"; + try { + const policy = createDatabaseTransportPolicy(input, { environment: effectiveEnvironment }); + if (typeof databaseUrl === "string" && databaseUrl.trim() !== "") { + // Also rejects a URL carrying TLS parameters that could override the policy. + assertProductionDatabaseTransport(databaseUrl, policy, effectiveEnvironment); + } + return freezeCheck( + "database.transport", + "pass", + policy.mode === "verify-full" + ? "Database TLS verified" + : "Database TLS disabled (development)", + [describeDatabaseTransport(policy)], + ); + } catch (error) { + const code = + error instanceof DatabaseTransportError ? error.code : "DATABASE_TRANSPORT_INVALID"; + return freezeCheck("database.transport", "failure", "Database transport unsafe", [ + `[${code}] The configured database transport is not permitted.`, + ]); + } +} + function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck { try { const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER); @@ -580,9 +628,16 @@ export async function diagnoseEkklesia( ); } else { try { + // Doctor's own read-only connection uses the exact same validated transport policy every + // other executable does, so a Doctor "connection established" can never mean something + // weaker than what `pnpm api:start` or `pnpm migrations:apply` would use. + const doctorTransport = createDatabaseTransportPolicy(options.databaseTransport, { + environment: options.databaseEnvironment ?? "production", + }); const database = dependencies.createMigrationDatabase(databaseUrl, { connectionTimeoutMs: doctorPostgresTimeoutMs, operationTimeoutMs: doctorPostgresTimeoutMs, + transport: doctorTransport, }); const plan = await dependencies.planMigrations({ aggregate: migrations, database }); postgresConnectionCheck = freezeCheck( @@ -598,6 +653,11 @@ export async function diagnoseEkklesia( const identityCheck = identityConfigurationCheck(options.identityConfiguration); const clientAddressDoctorCheck = clientAddressCheck(options.clientAddress); + const transportCheck = databaseTransportCheck( + options.databaseTransport, + options.databaseUrl, + options.databaseEnvironment, + ); const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [ "Core storage integration has not been implemented yet.", @@ -610,6 +670,7 @@ export async function diagnoseEkklesia( permissionsCheck, schemaCheck, migrationCheck, + transportCheck, postgresConnectionCheck, postgresPlanCheck, identityCheck, diff --git a/core/src/identity/config.ts b/core/src/identity/config.ts index e5f8247..352dc51 100644 --- a/core/src/identity/config.ts +++ b/core/src/identity/config.ts @@ -18,12 +18,108 @@ export type IdentityConfigErrorCode = | "TRUSTED_ORIGINS_MISSING"; const MIN_SECRET_LENGTH = 32; -const LOOPBACK_HOSTNAMES: ReadonlySet = new Set([ - "localhost", - "127.0.0.1", - "::1", - "0.0.0.0", -]); + +/** + * Hostnames that are never a legitimate public production origin, matched exactly (after WHATWG + * URL canonicalization, which already folds `127.1`, `2130706433`, and `0x7f000001` into + * `127.0.0.1`, and lowercases every hostname). + * + * This is a purely *syntactic* classifier. It rejects literal and reserved loopback/unspecified + * spellings; it performs no DNS resolution, so it cannot and does not prove that some arbitrary + * public name will never resolve to a loopback address later. Network reachability remains the + * deployment's own firewall/DNS responsibility. + */ +const IPV4_LOOPBACK_BLOCK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u; +const UNSPECIFIED_HOSTNAMES: ReadonlySet = new Set(["0.0.0.0", "[::]"]); +const LITERAL_LOOPBACK_HOSTNAMES: ReadonlySet = new Set(["localhost"]); + +/** Expands a bracketed IPv6 `URL.hostname` into its 8 canonical 16-bit groups, or `undefined` when + * the value is not a bracketed IPv6 literal. Handles `::` zero-compression and the mixed + * `::ffff:a.b.c.d` notation the WHATWG parser preserves. */ +function expandIpv6Hostname(hostname: string): readonly number[] | undefined { + if (!hostname.startsWith("[") || !hostname.endsWith("]")) { + return undefined; + } + let body = hostname.slice(1, -1); + // Mixed notation: translate a trailing dotted-quad into two hex groups. + const dotted = /^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(body); + if (dotted) { + const octets = [dotted[2], dotted[3], dotted[4], dotted[5]].map((part) => Number(part)); + if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) { + return undefined; + } + const high = ((octets[0] as number) << 8) | (octets[1] as number); + const low = ((octets[2] as number) << 8) | (octets[3] as number); + body = `${dotted[1]}${high.toString(16)}:${low.toString(16)}`; + } + + const [head, tail, ...extra] = body.split("::"); + if (extra.length > 0) { + return undefined; + } + const parseGroups = (value: string | undefined): number[] | undefined => { + if (value === undefined || value === "") { + return []; + } + const groups: number[] = []; + for (const part of value.split(":")) { + if (!/^[0-9a-f]{1,4}$/u.test(part)) { + return undefined; + } + groups.push(Number.parseInt(part, 16)); + } + return groups; + }; + const leading = parseGroups(head); + const trailing = tail === undefined ? [] : parseGroups(tail); + if (!leading || !trailing) { + return undefined; + } + if (tail === undefined) { + return leading.length === 8 ? leading : undefined; + } + const missing = 8 - leading.length - trailing.length; + if (missing < 0) { + return undefined; + } + return [...leading, ...Array.from({ length: missing }, () => 0), ...trailing]; +} + +/** Whether a canonicalized `URL.hostname` is a loopback or unspecified listen address in any + * spelling this repository accepts — literal name, reserved `.localhost` namespace, the full IPv4 + * `127.0.0.0/8` block, IPv6 `::1` compressed or expanded, IPv6 `::` unspecified, and IPv4-mapped + * IPv6 forms of both in mixed (`::ffff:127.0.0.1`) and hexadecimal (`::ffff:7f00:1`) notation. */ +function isLoopbackOrUnspecifiedHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + if (LITERAL_LOOPBACK_HOSTNAMES.has(normalized) || UNSPECIFIED_HOSTNAMES.has(normalized)) { + return true; + } + // RFC 6761 reserves the entire `.localhost` namespace for loopback. + if (normalized.endsWith(".localhost")) { + return true; + } + if (IPV4_LOOPBACK_BLOCK.test(normalized)) { + return true; + } + const groups = expandIpv6Hostname(normalized); + if (!groups) { + return false; + } + const isAllZero = groups.every((group) => group === 0); + // `::` (unspecified) and `::1` (loopback). + if (isAllZero || (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1)) { + return true; + } + // IPv4-mapped IPv6 (`::ffff:a.b.c.d`): classify the embedded IPv4 address. + const isMapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff; + if (isMapped) { + const high = groups[6] as number; + const low = groups[7] as number; + const embedded = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`; + return IPV4_LOOPBACK_BLOCK.test(embedded) || embedded === "0.0.0.0"; + } + return false; +} const errorMessages: Readonly> = Object.freeze({ BASE_URL_INSECURE_IN_PRODUCTION: "The identity base URL must use https in production.", @@ -90,20 +186,38 @@ export interface CreateIdentityConfigOptions { readonly environment?: IdentityEnvironment; } -function isLoopbackHostname(hostname: string): boolean { - return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase()); -} - -function parseAbsoluteHttpUrl(value: unknown): URL | undefined { +/** + * Parses one value that must be exactly a canonical absolute `http:`/`https:` **origin** — the + * single representation both the identity base URL and every trusted origin use, so no component + * of the platform can reinterpret the same deployment differently. + * + * Canonical means `URL.origin` must equal the configured string byte-for-byte. That one comparison + * structurally rejects every non-origin component at once — credentials, any path (including a + * bare trailing `/`, since `URL.origin` is slashless), a query, a fragment, surrounding + * whitespace, an uppercase hostname, and a redundant default port — because the WHATWG parser + * normalizes or preserves each of those in a way that no longer matches `origin`. + * + * Structural parsing is deliberately separate from environment policy: a caller distinguishes + * "this is not an origin" from "this origin is not allowed in production" and reports the + * structural code first, so an already-malformed URL is never mislabeled as a production-loopback + * failure. + */ +function parseCanonicalHttpOrigin(value: unknown): URL | undefined { if (typeof value !== "string" || value.length === 0) { return undefined; } + let url: URL; try { - const url = new URL(value); - return url.protocol === "http:" || url.protocol === "https:" ? url : undefined; + url = new URL(value); } catch { return undefined; } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return undefined; + } + // `URL.origin` carries no credentials, path, query, or fragment, and is already lowercased with + // any default port elided — so requiring exact equality is the whole canonical-origin rule. + return url.origin === value ? url : undefined; } function validateSecret(secret: unknown): string { @@ -126,7 +240,11 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st if (typeof baseUrl !== "string" || baseUrl.length === 0) { throw new IdentityConfigError("BASE_URL_MISSING"); } - const url = parseAbsoluteHttpUrl(baseUrl); + // Structural validity first, in every environment: the base URL is the public origin of the + // already-fixed `/api/auth` transport, and Ekklesia does not support mounting Better Auth + // beneath a configurable URL path. A credentialed, pathed, queried, fragmented, whitespace- + // padded, or otherwise non-canonical spelling is structurally invalid, never a policy failure. + const url = parseCanonicalHttpOrigin(baseUrl); if (!url) { throw new IdentityConfigError("BASE_URL_INVALID"); } @@ -134,11 +252,13 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st if (url.protocol !== "https:") { throw new IdentityConfigError("BASE_URL_INSECURE_IN_PRODUCTION"); } - if (isLoopbackHostname(url.hostname)) { + if (isLoopbackOrUnspecifiedHostname(url.hostname)) { throw new IdentityConfigError("BASE_URL_LOOPBACK_IN_PRODUCTION"); } } - return baseUrl; + // Store the canonical origin — identical to what `validateTrustedOrigin` stores — so CORS, + // Better Auth's own origin checks, and this base URL can never disagree about the same origin. + return url.origin; } function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string { @@ -152,19 +272,21 @@ function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment if (origin.includes("*") || origin.includes("?")) { throw new IdentityConfigError("TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED"); } - const url = parseAbsoluteHttpUrl(origin); - if (!url || url.origin !== origin) { + const url = parseCanonicalHttpOrigin(origin); + if (!url) { throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID"); } if (environment === "production") { if (url.protocol !== "https:") { throw new IdentityConfigError("TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION"); } - if (isLoopbackHostname(url.hostname)) { + // The identical classifier the base URL uses, so the two can never disagree about whether the + // same host is a production-legal origin. + if (isLoopbackOrUnspecifiedHostname(url.hostname)) { throw new IdentityConfigError("TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION"); } } - return origin; + return url.origin; } /** diff --git a/core/src/identity/database.ts b/core/src/identity/database.ts index ff68722..cf1c28d 100644 --- a/core/src/identity/database.ts +++ b/core/src/identity/database.ts @@ -1,5 +1,9 @@ import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import { Pool, type PoolConfig } from "pg"; +import { + type DatabaseTransportPolicy, + databaseTlsConnectionOptions, +} from "../database/transport.js"; import { identityAdapterSchema } from "./schema.js"; export type IdentityDrizzleDatabase = NodePgDatabase; @@ -28,6 +32,10 @@ export interface IdentityDatabaseOptions { * indefinitely. `connectionTimeoutMs` alone only bounds establishing a connection — it does * nothing once a query is already running on one. */ readonly operationTimeoutMs?: number; + /** The validated transport policy (`core/src/database/transport.js`). Passed explicitly by + * every production executable; omitted by single-domain unit/integration factories that use + * the disposable loopback database, which keeps the pre-existing plaintext behavior. */ + readonly transport?: DatabaseTransportPolicy; } export interface IdentityDatabaseHandle { @@ -81,8 +89,13 @@ export function createIdentityDrizzleDatabase( const validatedConnectionString = validateConnectionString(connectionString); const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs); const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs); + const ssl = + options.transport === undefined + ? undefined + : databaseTlsConnectionOptions(validatedConnectionString, options.transport); const poolConfig: PoolConfig = { connectionString: validatedConnectionString, + ...(ssl === undefined ? {} : { ssl }), ...(validatedConnectionTimeoutMs === undefined ? {} : { connectionTimeoutMillis: validatedConnectionTimeoutMs }), diff --git a/core/src/migrations/postgres-database.ts b/core/src/migrations/postgres-database.ts index f730831..e25fd38 100644 --- a/core/src/migrations/postgres-database.ts +++ b/core/src/migrations/postgres-database.ts @@ -1,4 +1,8 @@ import { Client, type ClientConfig, type QueryResultRow } from "pg"; +import { + type DatabaseTransportPolicy, + databaseTlsConnectionOptions, +} from "../database/transport.js"; import type { AppliedMigrationRecord, MigrationDatabase, @@ -76,6 +80,10 @@ export class PostgresMigrationDatabaseError extends Error { export interface PostgresMigrationDatabaseOptions { readonly connectionTimeoutMs?: number; readonly operationTimeoutMs?: number; + /** The validated transport policy (`core/src/database/transport.js`). Every production executable + * that plans or applies migrations passes one; omitting it keeps the pre-existing plaintext + * behavior used by the disposable loopback PostgreSQL harness. */ + readonly transport?: DatabaseTransportPolicy; } interface HistoryExistenceRow extends QueryResultRow { @@ -178,8 +186,16 @@ function createClientConfig( "The PostgreSQL operation timeout is invalid.", ); + // Also rejects a URL carrying its own TLS parameters, so `pg`'s connection-string precedence + // can never silently replace this policy with a weaker one. + const ssl = + options?.transport === undefined + ? undefined + : databaseTlsConnectionOptions(connectionString, options.transport); + return { connectionString, + ...(ssl === undefined ? {} : { ssl }), ...(validatedConnectionTimeoutMs === undefined ? {} : { connectionTimeoutMillis: validatedConnectionTimeoutMs }), diff --git a/core/tests/database-tls.integration.ts b/core/tests/database-tls.integration.ts new file mode 100644 index 0000000..f88898b --- /dev/null +++ b/core/tests/database-tls.integration.ts @@ -0,0 +1,331 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { Client } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { databaseTlsConnectionOptions } from "../src/database/transport.js"; + +const run = promisify(execFile); + +/** + * Real TLS proof for the production transport policy, exercised through the pinned `pg` client + * against a disposable, TLS-enabled PostgreSQL container. + * + * Every certificate, key, and CA used here is generated at test runtime into an isolated temporary + * directory and deleted in `afterAll` — no private-key material is committed to this repository. + * The container, its name, and its published loopback port are likewise disposable and removed in + * `afterAll` even when an assertion fails. + */ +const containerName = `ekklesia-database-tls-${process.pid}`; +let certificateDirectory: string | undefined; +let containerStarted = false; +let boundPort: number | undefined; + +class TlsHarnessError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsHarnessError"; + this.stack = `${this.name}: ${this.message}`; + } +} + +async function openssl(args: readonly string[], cwd: string): Promise { + try { + await run("openssl", [...args], { cwd }); + } catch { + // Never surface openssl's own stderr: it can echo paths from the harness environment. + throw new TlsHarnessError("The disposable TLS material could not be generated."); + } +} + +/** Generates a private CA, a server certificate signed by it whose only SAN is `DNS:localhost`, + * and an unrelated second CA. The deliberately narrow SAN is what makes the hostname-mismatch + * assertion below meaningful: reaching the very same server through `127.0.0.1` must fail. */ +async function generateCertificates(directory: string): Promise { + await openssl( + [ + "req", + "-new", + "-x509", + "-days", + "1", + "-nodes", + "-out", + "ca.crt", + "-keyout", + "ca.key", + "-subj", + "/CN=Ekklesia Disposable Test CA", + ], + directory, + ); + await openssl( + [ + "req", + "-new", + "-nodes", + "-out", + "server.csr", + "-keyout", + "server.key", + "-subj", + "/CN=localhost", + ], + directory, + ); + await writeFile(path.join(directory, "san.cnf"), "subjectAltName=DNS:localhost\n"); + await openssl( + [ + "x509", + "-req", + "-in", + "server.csr", + "-CA", + "ca.crt", + "-CAkey", + "ca.key", + "-CAcreateserial", + "-out", + "server.crt", + "-days", + "1", + "-extfile", + "san.cnf", + ], + directory, + ); + await openssl( + [ + "req", + "-new", + "-x509", + "-days", + "1", + "-nodes", + "-out", + "other-ca.crt", + "-keyout", + "other-ca.key", + "-subj", + "/CN=Unrelated Test CA", + ], + directory, + ); +} + +async function docker(args: readonly string[]): Promise { + const { stdout } = await run("docker", [...args]); + return stdout.trim(); +} + +async function startTlsPostgres(directory: string): Promise { + // The certificate/key are copied *inside* the container and re-owned before PostgreSQL starts: + // PostgreSQL refuses a key file that is not owned by the database user or root, and a bind- + // mounted host file keeps the host's ownership. + await docker([ + "run", + "-d", + "--name", + containerName, + "-e", + "POSTGRES_DB=ekklesia_tls", + "-e", + "POSTGRES_USER=ekklesia_tls", + "-e", + "POSTGRES_PASSWORD=ekklesia_tls", + "-v", + `${directory}:/certs-src:ro`, + "-p", + "127.0.0.1:0:5432", + "--entrypoint", + "/bin/sh", + "postgres:18.4-alpine3.24", + "-c", + "mkdir -p /tls && cp /certs-src/server.crt /certs-src/server.key /tls/ && " + + "chown postgres:postgres /tls/server.* && chmod 600 /tls/server.key && " + + "exec docker-entrypoint.sh postgres -c ssl=on -c ssl_cert_file=/tls/server.crt " + + "-c ssl_key_file=/tls/server.key", + ]); + containerStarted = true; + + const published = await docker(["port", containerName, "5432"]); + const match = /^127\.0\.0\.1:(\d+)$/u.exec(published.split("\n")[0]?.trim() ?? ""); + if (!match) { + throw new TlsHarnessError( + "The disposable TLS PostgreSQL port is not bound safely to loopback.", + ); + } + return Number(match[1]); +} + +/** Bounded readiness polling — never an arbitrary sleep. Resolves once the server accepts a TLS + * connection, or fails after the deadline. */ +async function waitForTlsReadiness(port: number, ca: string): Promise { + const deadline = Date.now() + 60_000; + let lastError: unknown; + while (Date.now() < deadline) { + const client = new Client({ + connectionTimeoutMillis: 3_000, + database: "ekklesia_tls", + host: "localhost", + password: "ekklesia_tls", + port, + ssl: { ca, rejectUnauthorized: true }, + user: "ekklesia_tls", + }); + try { + await client.connect(); + await client.end(); + return; + } catch (error) { + lastError = error; + try { + await client.end(); + } catch { + // The failed client is already unusable; nothing to clean up beyond this. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw new TlsHarnessError( + `The disposable TLS PostgreSQL did not become ready: ${String(lastError)}`, + ); +} + +async function connectWith( + host: string, + port: number, + ssl: ReturnType, +): Promise<{ encrypted: boolean }> { + const client = new Client({ + connectionTimeoutMillis: 5_000, + database: "ekklesia_tls", + host, + password: "ekklesia_tls", + port, + ...(ssl === false ? {} : { ssl }), + user: "ekklesia_tls", + }); + try { + await client.connect(); + const result = await client.query<{ encrypted: boolean }>( + "SELECT ssl AS encrypted FROM pg_stat_ssl WHERE pid = pg_backend_pid()", + ); + return { encrypted: result.rows[0]?.encrypted === true }; + } finally { + try { + await client.end(); + } catch { + // Closing an already-failed client is not itself a test failure. + } + } +} + +afterAll(async () => { + if (containerStarted) { + await docker(["rm", "-f", containerName]).catch(() => undefined); + } + if (certificateDirectory) { + // Removes the generated CA, server certificate, and every private key. + await rm(certificateDirectory, { force: true, recursive: true }).catch(() => undefined); + } +}); + +describe.sequential("production PostgreSQL TLS policy against a real TLS endpoint", () => { + it("starts a disposable TLS-enabled PostgreSQL with runtime-generated certificates", async () => { + certificateDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-tls-")); + await generateCertificates(certificateDirectory); + boundPort = await startTlsPostgres(certificateDirectory); + const ca = await readFile(path.join(certificateDirectory, "ca.crt"), "utf8"); + await waitForTlsReadiness(boundPort, ca); + expect(boundPort).toBeGreaterThan(0); + }); + + it("connects successfully, and encrypted, when the certificate is trusted and its identity matches the host", async () => { + const ca = await readFile(path.join(certificateDirectory as string, "ca.crt"), "utf8"); + const ssl = databaseTlsConnectionOptions( + `postgresql://ekklesia_tls@localhost:${boundPort}/ekklesia_tls`, + { certificateAuthority: ca, mode: "verify-full" }, + ); + + const { encrypted } = await connectWith("localhost", boundPort as number, ssl); + expect(encrypted).toBe(true); + }); + + it("refuses a certificate that does not chain to the configured CA", async () => { + const otherCa = await readFile( + path.join(certificateDirectory as string, "other-ca.crt"), + "utf8", + ); + const ssl = databaseTlsConnectionOptions( + `postgresql://ekklesia_tls@localhost:${boundPort}/ekklesia_tls`, + { certificateAuthority: otherCa, mode: "verify-full" }, + ); + + await expect(connectWith("localhost", boundPort as number, ssl)).rejects.toMatchObject({ + code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + }); + }); + + it("refuses a trusted certificate whose identity does not match the host being connected to", async () => { + // The server certificate's only SAN is `DNS:localhost`, so reaching the identical server via + // its IP address is a genuine hostname mismatch. This is the case the pinned driver does *not* + // catch on its own: `pg` sets `servername` only for non-IP hosts, and Node performs no identity + // check when it is unset, so `rejectUnauthorized: true` alone would accept this connection. + const ca = await readFile(path.join(certificateDirectory as string, "ca.crt"), "utf8"); + const ssl = databaseTlsConnectionOptions( + `postgresql://ekklesia_tls@127.0.0.1:${boundPort}/ekklesia_tls`, + { certificateAuthority: ca, mode: "verify-full" }, + ); + + await expect(connectWith("127.0.0.1", boundPort as number, ssl)).rejects.toMatchObject({ + code: "ERR_TLS_CERT_ALTNAME_INVALID", + }); + }); + + it("proves the gap the policy closes: chain-only verification would accept that same mismatch", async () => { + const ca = await readFile(path.join(certificateDirectory as string, "ca.crt"), "utf8"); + // Exactly what `ssl: { ca, rejectUnauthorized: true }` alone does — no pinned + // `checkServerIdentity`. It connects, which is why the policy supplies one. + const { encrypted } = await connectWith("127.0.0.1", boundPort as number, { + ca, + rejectUnauthorized: true, + }); + expect(encrypted).toBe(true); + }); + + it("cannot satisfy verify-full against a plaintext-only PostgreSQL endpoint", async () => { + // The disposable plaintext PostgreSQL the rest of the integration suite uses. + const plaintextUrl = process.env.EKKLESIA_MIGRATION_TEST_DATABASE_URL; + if (typeof plaintextUrl !== "string" || plaintextUrl.trim() === "") { + throw new TlsHarnessError("The plaintext integration database is not configured."); + } + const parsed = new URL(plaintextUrl); + const ca = await readFile(path.join(certificateDirectory as string, "ca.crt"), "utf8"); + const ssl = databaseTlsConnectionOptions(plaintextUrl, { + certificateAuthority: ca, + mode: "verify-full", + }); + + const client = new Client({ + connectionTimeoutMillis: 5_000, + database: parsed.pathname.slice(1), + host: parsed.hostname, + password: decodeURIComponent(parsed.password), + port: Number(parsed.port), + ...(ssl === false ? {} : { ssl }), + user: decodeURIComponent(parsed.username), + }); + try { + await expect(client.connect()).rejects.toBeTruthy(); + } finally { + try { + await client.end(); + } catch { + // Already unusable. + } + } + }); +}); diff --git a/core/tests/database-transport.test.ts b/core/tests/database-transport.test.ts new file mode 100644 index 0000000..571e709 --- /dev/null +++ b/core/tests/database-transport.test.ts @@ -0,0 +1,303 @@ +import tls from "node:tls"; +import { describe, expect, it, vi } from "vitest"; +import { reportMigrationCommandError } from "../src/cli/migration-command-errors.js"; +import { + assertNoConnectionStringTlsParameters, + assertProductionDatabaseTransport, + createDatabaseTransportPolicy, + databaseHostFromUrl, + DatabaseTransportError, + databaseTlsConnectionOptions, + describeDatabaseTransport, +} from "../src/database/transport.js"; + +const productionUrl = "postgresql://ekklesia:secret@db.example.test:5432/ekklesia"; + +describe("createDatabaseTransportPolicy", () => { + it("defaults to certificate-verified TLS in production and plaintext in development", () => { + expect(createDatabaseTransportPolicy()).toEqual({ mode: "verify-full" }); + expect(createDatabaseTransportPolicy({}, { environment: "production" })).toEqual({ + mode: "verify-full", + }); + expect(createDatabaseTransportPolicy({}, { environment: "development" })).toEqual({ + mode: "disable", + }); + }); + + it("refuses plaintext in production", () => { + let captured: unknown; + try { + createDatabaseTransportPolicy({ mode: "disable" }, { environment: "production" }); + } catch (error) { + captured = error; + } + expect(captured).toBeInstanceOf(DatabaseTransportError); + expect(captured).toMatchObject({ code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION" }); + }); + + it("rejects every loose, aliased, or coercible mode spelling in both environments", () => { + for (const mode of [ + "VERIFY-FULL", + "Verify-Full", + " verify-full", + "verify-full ", + "require", // encryption-only: deliberately unsupported + "verify-ca", // no hostname verification: deliberately unsupported + "prefer", + "no-verify", + "allow", + "true", + true, + false, + 1, + 0, + "", + null, + ]) { + for (const environment of ["production", "development"] as const) { + let captured: unknown; + try { + createDatabaseTransportPolicy({ mode }, { environment }); + } catch (error) { + captured = error; + } + expect(captured, `expected ${JSON.stringify(mode)} to be rejected`).toMatchObject({ + code: "DATABASE_TLS_MODE_INVALID", + }); + } + } + }); + + it("accepts an optional private CA and rejects an empty one", () => { + expect( + createDatabaseTransportPolicy({ certificateAuthority: "-----BEGIN CERTIFICATE-----" }) + .certificateAuthority, + ).toBe("-----BEGIN CERTIFICATE-----"); + for (const certificateAuthority of ["", " ", 42]) { + let captured: unknown; + try { + createDatabaseTransportPolicy({ certificateAuthority }); + } catch (error) { + captured = error; + } + expect(captured).toBeInstanceOf(DatabaseTransportError); + } + }); + + it("never carries the offending mode or CA material in the thrown error", () => { + let captured: unknown; + try { + createDatabaseTransportPolicy({ certificateAuthority: "SUPER-SECRET-CA", mode: "sslmode=x" }); + } catch (error) { + captured = error; + } + const publicText = `${String(captured)}\n${ + captured instanceof Error ? (captured.stack ?? "") : "" + }\n${JSON.stringify(captured)}`; + expect(publicText).not.toContain("SUPER-SECRET-CA"); + expect(publicText).not.toContain("sslmode=x"); + }); +}); + +describe("assertNoConnectionStringTlsParameters", () => { + it("rejects every TLS parameter the pinned connection-string parser recognizes", () => { + for (const parameter of [ + "ssl=true", + "sslmode=disable", + "sslmode=require", + "sslmode=verify-full", + "sslcert=/tmp/c.crt", + "sslkey=/tmp/c.key", + "sslrootcert=/tmp/ca.crt", + "sslpassword=x", + "sslnegotiation=direct", + "uselibpqcompat=true", + "SSLMODE=disable", // case-insensitive + ]) { + let captured: unknown; + try { + assertNoConnectionStringTlsParameters(`${productionUrl}?${parameter}`); + } catch (error) { + captured = error; + } + expect(captured, `expected ${parameter} to be rejected`).toMatchObject({ + code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT", + }); + } + }); + + it("allows an unrelated parameter", () => { + expect(() => + assertNoConnectionStringTlsParameters(`${productionUrl}?application_name=ekklesia`), + ).not.toThrow(); + }); + + it("rejects a non-PostgreSQL or malformed URL", () => { + for (const url of ["", "not-a-url", "mysql://db.example.test/x", "http://db.example.test"]) { + let captured: unknown; + try { + assertNoConnectionStringTlsParameters(url); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "DATABASE_URL_INVALID" }); + } + }); +}); + +describe("databaseTlsConnectionOptions", () => { + it("produces no TLS options at all when the policy disables TLS", () => { + expect( + databaseTlsConnectionOptions("postgresql://127.0.0.1:5432/ekklesia", { mode: "disable" }), + ).toBe(false); + }); + + it("enables chain verification and pins hostname verification to the configured host", () => { + const options = databaseTlsConnectionOptions(productionUrl, { mode: "verify-full" }); + expect(options).not.toBe(false); + if (options === false) { + throw new Error("expected TLS options"); + } + // There is no configuration path that produces `rejectUnauthorized: false`. + expect(options.rejectUnauthorized).toBe(true); + expect(typeof options.checkServerIdentity).toBe("function"); + expect(options.ca).toBeUndefined(); + }); + + it("checks the certificate against the configured host, not against whatever SNI the driver set", () => { + // The pinned driver sets `servername` only for non-IP hosts, and Node skips identity checking + // entirely when it is unset — so the policy must pin the host itself. This asserts the + // supplied callback ignores the passed servername and uses the configured host. + const options = databaseTlsConnectionOptions(productionUrl, { mode: "verify-full" }); + if (options === false || !options.checkServerIdentity) { + throw new Error("expected a checkServerIdentity callback"); + } + const spy = vi.spyOn(tls, "checkServerIdentity").mockReturnValue(undefined); + try { + options.checkServerIdentity("attacker-supplied.example", {} as tls.PeerCertificate); + expect(spy).toHaveBeenCalledWith("db.example.test", expect.anything()); + } finally { + spy.mockRestore(); + } + }); + + it("uses the bare address for an IPv6 host so identity checking sees a usable value", () => { + expect(databaseHostFromUrl("postgresql://[2001:db8::1]:5432/ekklesia")).toBe("2001:db8::1"); + expect(databaseHostFromUrl("postgresql://db.example.test:5432/ekklesia")).toBe( + "db.example.test", + ); + }); + + it("passes an explicit private CA through unchanged", () => { + const certificateAuthority = + "-----BEGIN CERTIFICATE-----\nprivate-ca\n-----END CERTIFICATE-----"; + const options = databaseTlsConnectionOptions(productionUrl, { + certificateAuthority, + mode: "verify-full", + }); + if (options === false) { + throw new Error("expected TLS options"); + } + expect(options.ca).toBe(certificateAuthority); + }); + + it("refuses to build options for a URL carrying its own TLS parameters", () => { + let captured: unknown; + try { + databaseTlsConnectionOptions(`${productionUrl}?sslmode=disable`, { mode: "verify-full" }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT" }); + }); +}); + +describe("assertProductionDatabaseTransport", () => { + it("permits verified TLS in production and any validated policy in development", () => { + expect(() => + assertProductionDatabaseTransport(productionUrl, { mode: "verify-full" }, "production"), + ).not.toThrow(); + expect(() => + assertProductionDatabaseTransport( + "postgresql://127.0.0.1:5432/ekklesia", + { mode: "disable" }, + "development", + ), + ).not.toThrow(); + }); + + it("refuses production plaintext, including for a loopback database — there is no production exception", () => { + for (const url of [ + productionUrl, + "postgresql://127.0.0.1:5432/ekklesia", + "postgresql://localhost:5432/ekklesia", + "postgresql://[::1]:5432/ekklesia", + ]) { + let captured: unknown; + try { + assertProductionDatabaseTransport(url, { mode: "disable" }, "production"); + } catch (error) { + captured = error; + } + expect(captured, `expected production plaintext to ${url} to be refused`).toMatchObject({ + code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION", + }); + } + }); +}); + +describe("migration CLI transport reporting", () => { + it("reports the stable transport code instead of a generic 'failed unexpectedly', and leaks nothing", () => { + const captured: string[] = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + captured.push(args.map(String).join(" ")); + }); + try { + for (const url of [ + `${productionUrl}?sslmode=disable`, + "postgresql://ekklesia:hunter2@db.example.test:5432/ekklesia?ssl=true", + ]) { + try { + databaseTlsConnectionOptions(url, { mode: "verify-full" }); + } catch (error) { + reportMigrationCommandError(error); + } + } + try { + createDatabaseTransportPolicy({ mode: "disable" }, { environment: "production" }); + } catch (error) { + reportMigrationCommandError(error); + } + } finally { + errorSpy.mockRestore(); + } + + const output = captured.join("\n"); + expect(output).toContain("[DATABASE_TLS_CONNECTION_STRING_CONFLICT]"); + expect(output).toContain("[DATABASE_TLS_REQUIRED_IN_PRODUCTION]"); + expect(output).not.toContain("failed unexpectedly"); + // The connection string, its credentials, and its host never reach operator output. + for (const secret of ["hunter2", "db.example.test", "secret", "sslmode=disable"]) { + expect(output).not.toContain(secret); + } + }); +}); + +describe("describeDatabaseTransport", () => { + it("describes the effective mode without revealing any connection detail", () => { + expect(describeDatabaseTransport({ mode: "verify-full" })).toBe( + "TLS required with certificate and hostname verification.", + ); + expect( + describeDatabaseTransport({ certificateAuthority: "SECRET-CA", mode: "verify-full" }), + ).toBe( + "TLS required with certificate and hostname verification (explicit private CA supplied).", + ); + expect( + describeDatabaseTransport({ certificateAuthority: "SECRET-CA", mode: "verify-full" }), + ).not.toContain("SECRET-CA"); + expect(describeDatabaseTransport({ mode: "disable" })).toBe( + "TLS disabled (development/test plaintext).", + ); + }); +}); diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index 3520f3d..ffdda2a 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -87,6 +87,8 @@ Data definitions 0 migrations · checksums valid PostgreSQL + ✓ Database TLS verified + TLS required with certificate and hostname verification. ○ Connection check skipped DATABASE_URL is not configured. ○ Migration plan skipped diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index c4fdbb9..625de96 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -206,6 +206,7 @@ describe("Ekklesia Doctor diagnostics", () => { "modules.permissions", "schemas.discovery", "migrations.discovery", + "database.transport", "postgresql.connection", "postgresql.plan", "identity.configuration", @@ -255,9 +256,15 @@ describe("Ekklesia Doctor diagnostics", () => { dependencies({ createMigrationDatabase, planMigrations }), ); + // Doctor's own read-only connection carries the same validated transport policy every other + // executable uses — it defaults to certificate-verified TLS, exactly like production. expect(createMigrationDatabase).toHaveBeenCalledWith( "postgresql://not-rendered.invalid/ekklesia", - { connectionTimeoutMs: 10_000, operationTimeoutMs: 10_000 }, + { + connectionTimeoutMs: 10_000, + operationTimeoutMs: 10_000, + transport: { mode: "verify-full" }, + }, ); expect(planMigrations).toHaveBeenCalledOnce(); expect(check(report, "postgresql.connection")).toMatchObject({ @@ -702,6 +709,57 @@ describe("Ekklesia Doctor diagnostics", () => { expect(report.result).toBe("not-ready"); }); + it("passes database transport by default, reporting verified TLS without revealing any connection detail", async () => { + const report = await diagnoseEkklesia( + { ...options("postgresql://admin:hunter2@db.internal.example:5432/ekklesia") }, + dependencies(), + ); + + const transport = check(report, "database.transport"); + expect(transport.status).toBe("pass"); + expect(transport.summary).toBe("Database TLS verified"); + const rendered = JSON.stringify(transport); + expect(rendered).not.toContain("hunter2"); + expect(rendered).not.toContain("db.internal.example"); + expect(rendered).not.toContain("postgresql://"); + }); + + it("blocks readiness when a production deployment would reach PostgreSQL without verified TLS", async () => { + for (const [label, extra] of [ + ["explicit production plaintext", { databaseTransport: { mode: "disable" } }], + [ + "connection-string downgrade", + { databaseUrl: "postgresql://db.example.test/ekklesia?sslmode=disable" }, + ], + ["malformed mode", { databaseTransport: { mode: "require" } }], + ] as const) { + const report = await diagnoseEkklesia( + { ...options("postgresql://db.example.test/ekklesia"), ...extra }, + dependencies(), + ); + + const transport = check(report, "database.transport"); + expect(transport.status, `expected ${label} to block readiness`).toBe("failure"); + expect(transport.summary).toBe("Database transport unsafe"); + expect(report.result).toBe("not-ready"); + expect(doctorExitCode(report)).toBe(1); + } + }); + + it("passes development plaintext only when development is explicitly selected", async () => { + const report = await diagnoseEkklesia( + { + ...options("postgresql://127.0.0.1:5432/ekklesia"), + databaseEnvironment: "development", + }, + dependencies(), + ); + + const transport = check(report, "database.transport"); + expect(transport.status).toBe("pass"); + expect(transport.summary).toContain("disabled"); + }); + it("passes client-address configuration with direct mode when nothing is configured", async () => { const report = await diagnoseEkklesia(options(), dependencies()); diff --git a/core/tests/identity-config.test.ts b/core/tests/identity-config.test.ts index 90d629f..2c24a4e 100644 --- a/core/tests/identity-config.test.ts +++ b/core/tests/identity-config.test.ts @@ -179,6 +179,126 @@ describe("Core identity configuration", () => { expect(config.baseUrl).toBe("http://localhost:3000"); }); + it("requires the base URL to be exactly one canonical origin, rejecting every non-origin component", () => { + for (const baseUrl of [ + "https://id.example.test/", // a bare trailing slash is a path; `URL.origin` is slashless + "https://id.example.test/auth", // Better Auth is never mounted beneath a configurable path + "https://id.example.test?a=1", + "https://id.example.test#fragment", + "https://user:pw@id.example.test", + "https://:pw@id.example.test", + " https://id.example.test ", + "https://ID.EXAMPLE.TEST", // non-canonical hostname case + "https://id.example.test:443", // redundant default port + "http://id.example.test:80", + "ftp://id.example.test", + "id.example.test", + ]) { + let captured: unknown; + try { + createIdentityConfig({ ...validInput(), baseUrl }, { environment: "production" }); + } catch (error) { + captured = error; + } + expect(captured, `expected ${JSON.stringify(baseUrl)} to be rejected`).toMatchObject({ + code: "BASE_URL_INVALID", + }); + } + }); + + it("stores the base URL and trusted origins as one identical canonical origin representation", () => { + const config = createIdentityConfig( + { + ...validInput(), + baseUrl: "https://id.example.test", + trustedOrigins: ["https://id.example.test", "https://dashboard.example.test:8443"], + }, + { environment: "production" }, + ); + expect(config.baseUrl).toBe("https://id.example.test"); + expect(config.trustedOrigins).toEqual([ + "https://id.example.test", + "https://dashboard.example.test:8443", + ]); + }); + + it("rejects every loopback and unspecified-host spelling in production, for the base URL and trusted origins alike", () => { + // Canonical spellings the WHATWG parser preserves — these must be rejected as production + // policy failures, by the identical classifier on both fields. + const canonicalLoopback = [ + "https://127.0.0.1", + "https://127.0.0.2", // the full 127.0.0.0/8 block, not just .1 + "https://127.255.255.254", + "https://[::1]", // IPv6 loopback, compressed + "https://[::ffff:7f00:1]", // IPv4-mapped loopback, hexadecimal notation + "https://localhost", + "https://app.localhost", // RFC 6761 reserved namespace + "https://0.0.0.0", + "https://[::]", // IPv6 unspecified + "https://[::ffff:0:0]", // IPv4-mapped unspecified + ]; + for (const value of canonicalLoopback) { + let baseCaptured: unknown; + try { + createIdentityConfig({ ...validInput(), baseUrl: value }, { environment: "production" }); + } catch (error) { + baseCaptured = error; + } + expect(baseCaptured, `expected base URL ${value} to be rejected`).toMatchObject({ + code: "BASE_URL_LOOPBACK_IN_PRODUCTION", + }); + + let originCaptured: unknown; + try { + createIdentityConfig( + { ...validInput(), trustedOrigins: [value] }, + { environment: "production" }, + ); + } catch (error) { + originCaptured = error; + } + expect(originCaptured, `expected trusted origin ${value} to be rejected`).toMatchObject({ + code: "TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION", + }); + } + }); + + it("rejects non-canonical loopback spellings structurally, never as a misleading production-policy failure", () => { + // WHATWG canonicalization folds some of these into `127.0.0.1`; others simply are not the + // canonical origin text. Either way the honest answer is "this is not a canonical origin". + for (const baseUrl of [ + "https://127.1", + "https://2130706433", + "https://0x7f000001", + "https://[0:0:0:0:0:0:0:1]", // expanded IPv6 loopback + "https://[::ffff:127.0.0.1]", // mixed IPv4-mapped notation + "https://LOCALHOST", + ]) { + let captured: unknown; + try { + createIdentityConfig({ ...validInput(), baseUrl }, { environment: "production" }); + } catch (error) { + captured = error; + } + expect(captured, `expected ${baseUrl} to be structurally rejected`).toMatchObject({ + code: "BASE_URL_INVALID", + }); + } + }); + + it("still accepts canonical loopback development origins", () => { + const config = createIdentityConfig( + { + ...validInput(), + baseUrl: "http://localhost:3000", + trustedOrigins: ["http://localhost:3000", "http://127.0.0.1:5173"], + }, + { environment: "development" }, + ); + expect(config.baseUrl).toBe("http://localhost:3000"); + expect(config.trustedOrigins).toEqual(["http://localhost:3000", "http://127.0.0.1:5173"]); + }); + it("defaults publicSignupEnabled to false when entirely omitted", () => { const config = createIdentityConfig(validInput(), { environment: "production" }); expect(config.publicSignupEnabled).toBe(false); diff --git a/docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md b/docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md new file mode 100644 index 0000000..9b4203e --- /dev/null +++ b/docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md @@ -0,0 +1,217 @@ +# 0018: Identity session resolution, canonical public origins, and PostgreSQL transport policy + +- Status: Accepted +- Date: 2026-07-26 +- Amends: [0010](0010-core-identity-and-better-auth-adoption.md), [0011](0011-identity-http-transport-and-public-enrollment.md) +- Extends: [0017](0017-public-request-perimeter-hardening.md) + +## Context + +Phase 5D pass 1 ([ADR 0017](0017-public-request-perimeter-hardening.md), commits `d3c6bd73`, +`1161f5a076b66548767d67b58853bf83ab8f24b4`, `3d6c85b0`) closed the public request perimeter's +default-open exposures. Pass 2 addresses the layer immediately behind it: how a request's principal +is actually resolved, what a "public origin" is allowed to be, and how Core reaches PostgreSQL at +all. Five defects were reproduced against the pass-1 tree before anything was changed: + +1. **Any cookie triggered identity database work.** `resolvePrincipal`'s ambiguity check ran + whenever the request carried *a* `Cookie` header. `Cookie: x=y` on an otherwise anonymous + request caused a database round trip on every request — an unauthenticated caller could drive + identity database load with a header they chose themselves. +2. **The readiness probe proved almost nothing.** It issued `select 1`. A dropped session table, a + drifted or removed column, or a revoked `SELECT` privilege on the session relation all still + answered "reachable", so Better Auth's ambiguous `null` was reported as a genuinely anonymous + request. A deployment whose session store was broken in any of those ways served every request + as anonymous instead of failing. +3. **The base URL / trusted-origin boundary accepted non-origins.** Validation accepted any + absolute `http`/`https` URL, so a value carrying a path, a trailing slash, credentials, a + default port written explicitly, or non-canonical case became configuration that no longer + compared equal to the origin a browser would send. The loopback rejection was also a short + literal list (`localhost`, `127.0.0.1`, `::1`), missing `127.0.0.2`, the rest of `127.0.0.0/8`, + `0.0.0.0`, `::`, the expanded `0:0:0:0:0:0:0:1` spelling, IPv4-mapped forms such as + `::ffff:127.0.0.1`, and the RFC 6761 `.localhost` namespace. +4. **Every PostgreSQL connection was plaintext, in every environment.** No connection site set + `ssl` at all. Credentials, session tokens, and row data crossed the network in the clear, and + nothing in configuration, startup, or `pnpm doctor` said so. +5. **A connection string could silently downgrade an explicit TLS decision.** The pinned + `pg` 8.22.0 builds its parameters as + `Object.assign({}, config, parse(config.connectionString))`, so a `?sslmode=disable` in + `DATABASE_URL` *overrides* an explicit `ssl` object passed alongside it. A policy that only set + `ssl` would have been silently defeated by the URL it was given. + +## Decision + +### Only the configured session cookie makes `null` ambiguous + +`core/src/identity/runtime.ts` exports `hasConfiguredSessionCookie(headers, cookieName)`. It parses +the `Cookie` header the way RFC 6265's `cookie-string` grammar (and Better Auth's own parser) +requires — split on `;`, take each pair's name as the text before its first `=`, trim surrounding +whitespace — and compares the whole name with an exact string comparison. Never `includes`, never a +substring test, never an unanchored regular expression: `better-auth.session_token_x`, +`x_better-auth.session_token`, a differently-cased spelling, and a percent-encoded lookalike are all +correctly *not* the session cookie (cookie names are case-sensitive per RFC 6265 §4.1.1 and are +never percent-decoded). Ambiguity fails safe *towards* checking: a duplicated session-cookie name +still reports `true`, because reporting `false` is what would let a broken session store be served +as an anonymous request. Reporting `true` never grants access on its own — it only means the +readiness check runs, and Better Auth's own result still governs the principal. + +The cookie name is not a second, hard-coded copy. One `betterAuthOptions` object feeds both +`betterAuth()` and Better Auth's own supported `getCookies` helper (`better-auth/cookies`), so the +name this runtime detects — including the `__Secure-` prefix an `https` `baseURL` implies — is +derived from the exact configuration Better Auth itself uses. Drift is structurally impossible +rather than merely tested. + +The practical effect: a request with no cookies, or with only unrelated cookies, performs **zero** +identity database work. Better Auth never attempted to resolve an Ekklesia session for it, so its +`null` is unambiguous and the anonymous principal is returned directly. + +### The readiness check reads the real session relation + +`probeSessionStore` replaces `select 1` with a planned read of the exact session relation and the +exact columns the Better Auth adapter depends on (`id`, `token`, `userId`, `expiresAt`), under a +`where false` predicate. PostgreSQL still resolves the relation, every named column, and the +caller's `SELECT` privilege at plan time, while guaranteeing zero rows are matched, read, or +returned — so no session row, token, or user id can be enumerated, logged, or exposed by the check +itself. It uses the injected Identity Drizzle view (the API's one shared pool) and never constructs +a second pool, client, connection string, or Drizzle instance, and it stays bounded by the existing +3-second timeout whose timer is always cleared. + +A missing relation, a dropped column, a revoked privilege, an unreachable connection, and a query +timeout all surface at this seam identically — as a rejected query — and all produce the same +outcome: a sanitized `PRINCIPAL_RESOLUTION_FAILED`, which `apps/api` already maps to a +`503 {"code":"PRINCIPAL_RESOLUTION_FAILED"}` with no relation name, column name, SQL, driver text, +host, or stack in it. A genuinely missing, expired, revoked, or malformed session still resolves to +the anonymous principal exactly as ADR 0011 specifies. This is the distinction the pass-1 tree +could not make. + +### The public identity URL boundary is exactly one canonical origin + +`parseCanonicalHttpOrigin` (`core/src/identity/config.ts`) requires `url.origin === value` — the +WHATWG URL parser's own canonical serialization must equal the supplied string exactly. A path, +a trailing slash, a query, a fragment, embedded credentials, an explicitly written default port, or +non-canonical case therefore fails with the existing `BASE_URL_INVALID` / +`TRUSTED_ORIGIN_INVALID` codes rather than being normalized behind the operator's back, and both +`validateBaseUrl` and `validateTrustedOrigin` return that canonical origin, so what is stored is +what Better Auth compares against a browser's `Origin` header. The base URL is now an origin, not a +URL that merely happens to have one. + +`isLoopbackOrUnspecifiedHostname` replaces the literal list. It classifies `localhost` and the whole +reserved `.localhost` namespace (RFC 6761), all of `127.0.0.0/8`, `0.0.0.0`, `::1` in both its +compressed and fully expanded spellings, `::`, and IPv4-mapped forms such as `::ffff:127.0.0.1` and +`::ffff:7f00:1` — by expanding the address to its eight numeric groups rather than by comparing +strings. Production rejects every one of them for both the base URL and every trusted origin +(`BASE_URL_LOOPBACK_IN_PRODUCTION` / `TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION`), and development +composition must still opt in explicitly with `environment: "development"`. + +This is a configuration-shape guarantee and nothing more. **It does not resolve DNS and does not +prove reachability.** A public hostname that resolves to a loopback or private address still passes +this validation, and a firewall, network policy, or reverse proxy — not URL parsing — is what +actually determines who can reach the deployment. + +### Production PostgreSQL requires TLS with certificate *and* hostname verification + +`core/src/database/transport.ts` is the one typed transport policy. `DatabaseTlsMode` has exactly +two values: + +- `verify-full` — TLS required, certificate chain verified, and the server's identity verified + against the configured host. **The default in production.** +- `disable` — no TLS. **The default in development/test, and refused outright in production.** + +There is deliberately no encryption-only mode (`require`), no chain-only mode (`verify-ca`), and no +`rejectUnauthorized: false` escape hatch anywhere in the configuration surface: encryption without +identity verification is exactly what an interception attack defeats, so offering it as a +supported setting would offer a switch whose safe position is the only one worth having. Every +loose, aliased, or coercible spelling (`VERIFY-FULL`, `" verify-full"`, `require`, `prefer`, +`no-verify`, `true`, `1`, `""`, `null`) is rejected with `DATABASE_TLS_MODE_INVALID` in *both* +environments, so a typo never quietly becomes a weaker mode. + +**Production has no plaintext exception at all — not even for a loopback database.** +`assertProductionDatabaseTransport` refuses `mode: "disable"` in production whether the URL points +at `db.example.test`, `127.0.0.1`, `localhost`, or `[::1]`, with the stable +`DATABASE_TLS_REQUIRED_IN_PRODUCTION` code. A "same host, therefore safe" carve-out would be a +claim about the deployment's process isolation, container networking, and socket namespace that +this repository cannot verify from a URL string. + +`databaseTlsConnectionOptions` builds the actual `ssl` object: `rejectUnauthorized: true`, an +optional explicit private CA (`certificateAuthority`, supplied as PEM text — a path, a filesystem +read, and a secret store are all out of scope here), and — critically — an explicit +`checkServerIdentity` pinned to the host parsed from the connection string. That last part closes a +real gap in the pinned driver rather than restating a default: `pg` sets TLS `servername` only for +non-IP hosts (`connection.js`), and Node performs **no** identity check when `servername` is unset, +so `rejectUnauthorized: true` alone accepts a certificate from any host the configured CA ever +signed when connecting by IP address. Both halves of that claim are proven against a real +TLS-enabled PostgreSQL in `core/tests/database-tls.integration.ts`: the policy rejects the mismatch +with `ERR_TLS_CERT_ALTNAME_INVALID`, and the same connection *without* the pinned callback succeeds. + +### A connection string may not carry its own TLS decision + +`assertNoConnectionStringTlsParameters` rejects a `DATABASE_URL` carrying any TLS parameter the +pinned `pg-connection-string` 2.14.0 recognizes — `ssl`, `sslmode`, `sslcert`, `sslkey`, +`sslrootcert`, `sslpassword`, `sslnegotiation`, `uselibpqcompat`, case-insensitively — with +`DATABASE_TLS_CONNECTION_STRING_CONFLICT`. This is not stylistic. The pinned driver's +`Object.assign({}, config, parse(config.connectionString))` gives the *connection string* the last +word, so a `?sslmode=disable` in the URL would silently replace the policy's verified `ssl` object; +`sslmode=require` is additionally aliased to verify-full behavior with a deprecation warning in this +release, so even the "stricter-looking" spellings do not mean what an operator would assume. There +is exactly one place TLS is decided, and it is the policy — never the URL. `databaseTlsConnectionOptions` +performs this assertion itself, so no connection site can forget it. + +### One policy, every executable, and Doctor + +`core/src/cli/database-transport-environment.ts` is the single environment reader +(`EKKLESIA_DATABASE_TLS_MODE`, `EKKLESIA_DATABASE_CA`), keeping `process.env` out of Core's +reusable constructors: `createValidatedPostgresPool`, `createIdentityDrizzleDatabase`, +`createAuthorizationDrizzleDatabase`, and `createPostgresMigrationDatabase` all take an explicit +optional `transport` policy instead. Every executable that opens a PostgreSQL connection resolves +the policy the same way — `apps/api` (via `ApiRuntimeConfig.databaseTransport`, validated in +`createApiRuntimeConfig` *before* any pool, Better Auth instance, module runtime, or listener +exists), `pnpm migrations:plan`, `pnpm migrations:apply`, the platform-owner bootstrap CLI, and +`pnpm doctor`'s own connection. + +`pnpm doctor` gains a `database.transport` check reported in its PostgreSQL section. A verified +production policy passes; an unsafe or invalid one is a blocking `NOT READY` **failure**, never a +warning, and its output names the effective mode only — never the URL, host, port, credentials, or +CA material. + +## Alternatives + +- **Keeping `select 1` and adding a separate table-existence query** was rejected: it would be a + second, drifting description of what the adapter actually needs. Planning the real read against + the real relation and the real columns is the only check that fails when any of them changes. +- **Probing the session store on every request** was rejected: it makes an unauthenticated caller + able to drive database load, which is the defect being fixed, not a stricter version of it. +- **Normalizing a non-canonical base URL instead of rejecting it** was rejected: silently rewriting + an operator's origin is how a deployment ends up with a trusted-origin list that does not match + what browsers actually send, and the failure then appears as an unexplained CSRF rejection. +- **Resolving DNS during configuration validation to catch a public hostname pointing at a private + address** was rejected: it makes startup depend on resolver state, is trivially defeated by a + later DNS change, and would invite exactly the false confidence this ADR explicitly disclaims. +- **Supporting `sslmode=require` (encryption without identity verification)** was rejected: it is + the mode that looks secure and is not, and having it available makes it the one an operator picks + when verify-full is inconvenient. +- **A production plaintext exception for a loopback database** was rejected: the URL cannot prove + the database is actually process-local, and the exception would be the first thing a + containerized deployment mistakenly claimed. +- **Trusting `rejectUnauthorized: true` alone** was rejected once the reproduction showed the pinned + driver leaves `servername` unset for IP hosts and Node then performs no identity check at all. +- **Reading `EKKLESIA_DATABASE_*` inside Core's database constructors** was rejected: it would put + `process.env` inside reusable library code and give every test and future embedder an implicit, + invisible input. + +## Consequences + +A request without the configured session cookie performs no identity database work at all; a +request that presents it, and that Better Auth resolved to `null`, is disambiguated by a real +session-store read, so a broken store produces a sanitized `503` instead of a silent anonymous +downgrade. The identity base URL and every trusted origin are now exactly one canonical origin +each, with a complete loopback/unspecified classifier enforced in production — a shape guarantee +only, which does not resolve DNS, does not prove reachability, and does not replace a firewall or +reverse proxy. Production PostgreSQL connections require TLS with both certificate and hostname +verification, from every executable, with no plaintext exception and no way to express a weaker +mode; a connection string that tries to decide TLS for itself is refused rather than honored. + +None of this makes an internet-exposed database safe: TLS protects the connection, not the exposure, +and the network boundary remains the deployment's own responsibility. This pass adds no secret +store, no CA file loading, no certificate rotation, no client-certificate authentication, no +connection-level authorization change, and no distributed rate limiting. Phase 5D passes 3–5 +(authorization integrity, module/runtime/migration correctness, and deployment/supply-chain +closure) remain outstanding — Phase 5D is not complete. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index a4aea95..f4fe311 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -26,3 +26,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0015: Static module HTTP routing and production runtime lifecycle](0015-static-module-http-routing-and-runtime-lifecycle.md) — Accepted - [0016: Module database access](0016-module-database-access.md) — Accepted - [0017: Public request perimeter hardening — client address, signup gating, body limits, timeouts](0017-public-request-perimeter-hardening.md) — Accepted +- [0018: Identity session resolution, canonical public origins, and PostgreSQL transport policy](0018-identity-session-resolution-and-database-transport.md) — Accepted diff --git a/docs/development/api.md b/docs/development/api.md index 8bfbc26..6f4e328 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -49,12 +49,14 @@ No real values are shown here — configure per environment and never commit the | Variable | Requirement | | --- | --- | -| `DATABASE_URL` | An absolute `postgres://` or `postgresql://` URL to an already-migrated database. | +| `DATABASE_URL` | An absolute `postgres://` or `postgresql://` URL to an already-migrated database. It must **not** carry a TLS parameter (`sslmode`, `ssl`, `sslrootcert`, …) — see [Database transport (TLS)](#database-transport-tls). | +| `EKKLESIA_DATABASE_TLS_MODE` | Optional. Exactly `"verify-full"` or `"disable"`. Defaults to `verify-full` in production and `disable` in development/test. Production refuses `disable`. | +| `EKKLESIA_DATABASE_CA` | Optional. PEM text of a private certificate authority to trust in addition to the system store, for a database using an internal CA. | | `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. | +| `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. | +| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (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. | | `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). | | `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). | | `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). | @@ -499,6 +501,61 @@ authorization, and module domains all read from this one pool through independen views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly one pool, not two. +## Database transport (TLS) + +`core/src/database/transport.ts` is the one typed policy deciding how *every* Ekklesia executable +reaches PostgreSQL. It has exactly two modes: + +| Mode | Meaning | Default in | +| --- | --- | --- | +| `verify-full` | TLS required, certificate chain verified, **and** the server's identity verified against the configured host. | production | +| `disable` | No TLS. Refused outright in production. | development/test | + +There is deliberately no encryption-only mode (`require`), no chain-only mode (`verify-ca`), and no +`rejectUnauthorized: false` escape hatch: encryption without identity verification is exactly what +an interception attack defeats. Every loose or aliased spelling — `VERIFY-FULL`, `" verify-full"`, +`require`, `prefer`, `no-verify`, `true`, `1`, empty — is rejected with `DATABASE_TLS_MODE_INVALID` +in *both* environments, so a typo can never quietly become a weaker mode. + +**Production has no plaintext exception — not even for a loopback database.** `disable` in +production fails with `DATABASE_TLS_REQUIRED_IN_PRODUCTION` whether `DATABASE_URL` points at a +remote host, `127.0.0.1`, `localhost`, or `[::1]`. A connection string cannot prove the database is +actually process-local, so no "same host, therefore safe" carve-out exists. + +Verification is certificate *and* hostname, not encryption alone. The policy sets +`rejectUnauthorized: true` and an explicit `checkServerIdentity` pinned to the host parsed from +`DATABASE_URL`. That second part closes a real gap rather than restating a default: the pinned `pg` +driver sets TLS `servername` only for non-IP hosts, and Node performs **no** identity check when it +is unset — so `rejectUnauthorized: true` alone accepts a certificate from any host the trusted CA +ever signed when you connect by IP address. Both halves are proven against a real TLS-enabled +PostgreSQL in `core/tests/database-tls.integration.ts`. + +**`DATABASE_URL` may not carry its own TLS parameters.** `sslmode`, `ssl`, `sslcert`, `sslkey`, +`sslrootcert`, `sslpassword`, `sslnegotiation`, and `uselibpqcompat` are all rejected +(case-insensitively) with `DATABASE_TLS_CONNECTION_STRING_CONFLICT`. This is not stylistic: the +pinned driver merges parsed connection-string values *over* explicitly passed options, so a +`?sslmode=disable` in the URL would silently replace the policy's verified `ssl` object. TLS is +decided in exactly one place, and it is never the URL. + +For a database behind an internal CA, supply the CA's PEM text in `EKKLESIA_DATABASE_CA`; it is +added to the trusted set for verification and never appears in any error, log, or `pnpm doctor` +output. Loading it from a file path or a secret store is out of scope here, as are certificate +rotation and client-certificate authentication. + +The same policy is resolved identically by every executable that opens a connection — `apps/api`, +`pnpm migrations:plan`, `pnpm migrations:apply`, the platform-owner bootstrap CLI, and +`pnpm doctor`'s own connection — through one shared environment reader +(`core/src/cli/database-transport-environment.ts`), so `process.env` stays out of Core's reusable +constructors. `apps/api` validates it in `createApiRuntimeConfig`, *before* any pool, Better Auth +instance, module runtime, or listener exists. `pnpm doctor` reports it as `database.transport` in +its PostgreSQL section: a verified production policy passes, and an unsafe or invalid one is a +blocking `NOT READY` failure — never a warning — naming only the effective mode, never the URL, +host, port, credentials, or CA material. + +TLS protects the *connection*, not the exposure: it does not make an internet-reachable database +safe, and the network boundary remains the deployment's own responsibility. See +[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md). + ## Graceful shutdown `startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the diff --git a/docs/development/authorization.md b/docs/development/authorization.md index cfca816..a4cc63f 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -416,6 +416,14 @@ unaffected and remain available for standalone/testable use — the CLI bootstra `createAuthorizationDrizzleDatabase` directly for exactly that reason, since it only ever needs the authorization schema and has no reason to share a pool with anything else. +Both the shared pool and the per-domain factories accept the same optional `transport` policy — +Ekklesia's single PostgreSQL TLS decision, described in +[API § Database transport (TLS)](api.md#database-transport-tls). Every executable that opens a +connection (the API, both migration commands, the platform-owner bootstrap CLI, and `pnpm doctor`) +resolves it the same way, so production connections are TLS-verified regardless of which factory +built them. Omitting it is a development/test affordance only: no `ssl` option is set and the +connection is plaintext, which a production configuration refuses outright. + `apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one `new Pool(...)` call for a full `startApi()` lifecycle (covering identity, authorization, and module database usage), and exactly one `pool.end()` call regardless of how many concurrent diff --git a/docs/development/identity.md b/docs/development/identity.md index c14f965..88fbb3f 100644 --- a/docs/development/identity.md +++ b/docs/development/identity.md @@ -126,12 +126,28 @@ intentionally contains no real values — configure them per environment, never | Value | Requirement | | --- | --- | | Secret | At least 32 characters, high entropy (for example `openssl rand -base64 32`). No built-in default is ever provided; a missing or too-short secret is rejected explicitly. | -| Base URL | An absolute `http`/`https` URL. In production, `https` is required and loopback hosts are rejected. | -| Trusted origins | One or more absolute `http`/`https` origins with no path, query, or fragment. Wildcards are rejected in this slice. In production, `https` is required and loopback hosts are rejected. | +| Base URL | Exactly one canonical `http`/`https` **origin** — scheme, host, and non-default port only. In production, `https` is required and loopback/unspecified hosts are rejected. | +| Trusted origins | One or more canonical `http`/`https` origins. Wildcards are rejected in this slice. In production, `https` is required and loopback/unspecified hosts are rejected. | -Local development origins (for example `http://localhost:3000`) are only accepted when the caller -explicitly passes `environment: "development"` — never silently. Every configuration failure -raises a sanitized `IdentityConfigError` carrying only a stable code, never the offending value. +**"Canonical origin" is literal**: the value must equal the WHATWG URL parser's own origin +serialization of itself. A path (`https://id.example.test/auth`), a bare trailing slash +(`https://id.example.test/`), a query, a fragment, embedded credentials, an explicitly written +default port (`https://id.example.test:443`), or non-canonical case is rejected with +`BASE_URL_INVALID`/`TRUSTED_ORIGIN_INVALID` rather than normalized behind your back — so the stored +value always matches what a browser actually sends in an `Origin` header. + +Production rejects loopback and unspecified hosts by classifying the address, not by comparing it +against a short list: `localhost` and the whole reserved `.localhost` namespace (RFC 6761), all of +`127.0.0.0/8` (not just `127.0.0.1`), `0.0.0.0`, `::1` in both compressed and expanded spellings, +`::`, and IPv4-mapped forms such as `::ffff:127.0.0.1`. Local development origins (for example +`http://localhost:3000`) are only accepted when the caller explicitly passes +`environment: "development"` — never silently. Every configuration failure raises a sanitized +`IdentityConfigError` carrying only a stable code, never the offending value. + +This is a guarantee about the *shape* of the configured value and nothing more. It does not resolve +DNS and does not prove reachability: a public hostname that resolves to a loopback or private +address passes this validation, and a firewall, network policy, or reverse proxy — not URL +parsing — is what actually determines who can reach a deployment. Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see ADR 0010. @@ -165,7 +181,9 @@ pinned to `8`/`128` instead of left on Better Auth's fallback default. `operationTimeoutMs`, applied as both `pg`'s `query_timeout` and `statement_timeout` — bounding a hung or slow query, which `connectionTimeoutMs` alone cannot do since it only bounds establishing the connection. `apps/api` sets both timeouts explicitly (see [API](api.md)); an invalid value is -rejected the same sanitized way an invalid connection string already is. +rejected the same sanitized way an invalid connection string already is. It also accepts an optional +`transport` policy — the PostgreSQL TLS decision described in +[API § Database transport](api.md#database-transport-tls); every production executable passes one. ## How a principal is resolved @@ -194,7 +212,8 @@ internal failures and returns `null` for them exactly as it does for a genuinely A missing session table, a dropped or drifted column, a revoked `SELECT` privilege, an unreachable database, and a query timeout therefore all produce a sanitized `503 {"code":"PRINCIPAL_RESOLUTION_FAILED"}` — never a silent anonymous result. The response body, -logs, and error carry no relation name, column name, SQL, driver message, host, or stack. +logs, and error carry no relation name, column name, SQL, driver message, host, or stack. See +[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md). ## What is not operational yet diff --git a/docs/development/testing.md b/docs/development/testing.md index 15f18ac..4eabe3b 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -14,7 +14,9 @@ module migration discovery, identity schema drift detection, formatting, linting 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 branded opaque-ID contracts, principal shapes, -configuration validation (secret, base URL, and trusted-origin rules), bounded/validated +configuration validation (secret rules, canonical-origin-only base URL and trusted origins, and the +full loopback/unspecified classifier production rejects — `.localhost`, all of `127.0.0.0/8`, +`0.0.0.0`, `::1` compressed and expanded, `::`, and IPv4-mapped forms), bounded/validated connection and operation (`query_timeout`/`statement_timeout`) database timeouts, the Core identity runtime (explicit construction, exact schema mapping, only email/password and rate limiting enabled, an explicit `advanced.disableCSRFCheck`/`disableOriginCheck: false` pair, an explicit @@ -22,9 +24,12 @@ error-only sanitized logger that strips Better Auth's own message and arguments they contain, pinned 8–128 password-length bounds, sanitized failures, exact whole-name session-cookie detection that never fires on a prefixed, suffixed, or differently-cased lookalike, and that a broken session store during principal resolution is never silently reported as anonymous -while an unrelated cookie performs no identity database work at all), 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, module +while an unrelated cookie performs no identity database work at all), the PostgreSQL transport +policy (`core/tests/database-transport.test.ts`: mode validation including every loose or aliased +spelling, production's plaintext refusal with no loopback exception, connection-string +TLS-parameter rejection, and that no configuration path yields `rejectUnauthorized: false` or leaks +CA material into an error), 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, module route mounting/enforcement/CORS/response sanitization, CORS allow/deny/preflight, deterministic 404 and sanitized error responses), its runtime configuration, its idempotent shutdown lifecycle (concurrent callers share one server-close-then-module-runtime-stop-then-pool-close promise; a @@ -125,6 +130,20 @@ completes and is independently verifiable, through the harness's own administrat never the API's own pool — before that pool closes. See [ADR 0016](../architecture/decisions/0016-module-database-access.md). +A fourth dedicated suite (`core/tests/database-tls.integration.ts`, included in the same +`pnpm test:migrations:integration` run) proves the production PostgreSQL transport policy against a +real TLS-enabled PostgreSQL rather than against the shape of an options object: a trusted +certificate whose identity matches the host connects and is confirmed encrypted through +`pg_stat_ssl`; a certificate that does not chain to the configured CA is refused +(`UNABLE_TO_VERIFY_LEAF_SIGNATURE`); the same server reached through an address its certificate does +not cover is refused (`ERR_TLS_CERT_ALTNAME_INVALID`); the identical connection *without* the +policy's pinned `checkServerIdentity` succeeds, which is the concrete gap the policy closes; and +`verify-full` cannot be satisfied by a plaintext-only endpoint. Every CA, certificate, and private +key is generated at test runtime into a temporary directory and deleted afterwards — **no private +key material is ever committed to this repository** — and the disposable container is removed in +`afterAll` even when an assertion fails. See +[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md). + 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/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index 108e0c0..5e414f1 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ hookTimeout: 30_000, include: [ "core/tests/postgres-migration.integration.ts", + "core/tests/database-tls.integration.ts", "core/tests/authorization-store.integration.ts", "core/tests/authorization-bootstrap.integration.ts", "apps/api/tests/api-http.integration.ts",