fix: enforce production transport policy

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 <[email protected]>
This commit is contained in:
ekklesia
2026-07-26 17:03:52 -05:00
co-authored by Claude Opus 5
parent 7a0f624fc3
commit 6b6b77c105
34 changed files with 2035 additions and 52 deletions
+7
View File
@@ -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
+34 -4
View File
@@ -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
+27 -1
View File
@@ -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,
});
}
+12
View File
@@ -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: {
+18 -9
View File
@@ -347,14 +347,20 @@ async function startConfiguredApi(
options: StartConfiguredApiOptions = {},
): Promise<RunningApi> {
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
+131
View File
@@ -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:[email protected]: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:[email protected]: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:[email protected]:5432/ekklesia";
let captured: unknown;
+53
View File
@@ -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:[email protected]:5432/ekklesia",
},
],
[
"connection-string TLS downgrade",
{
databaseUrl:
"postgresql://ekklesia:[email protected]: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() {
+13
View File
@@ -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<typeof authorizationSchema>;
@@ -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 }),
+9 -1
View File
@@ -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<void> {
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)) {
+5
View File
@@ -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<void> {
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 });
@@ -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;
}
+11
View File
@@ -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: {
+9
View File
@@ -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) {
+9 -1
View File
@@ -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<void> {
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)) {
+1
View File
@@ -1 +1,2 @@
export * from "./pool.js";
export * from "./transport.js";
+14
View File
@@ -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 }),
+252
View File
@@ -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<Record<DatabaseTransportErrorCode, string>> = 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 `[email protected]` 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 };
+1 -1
View File
@@ -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"]),
]);
+61
View File
@@ -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,
+142 -20
View File
@@ -18,12 +18,108 @@ export type IdentityConfigErrorCode =
| "TRUSTED_ORIGINS_MISSING";
const MIN_SECRET_LENGTH = 32;
const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set([
"localhost",
"127.0.0.1",
"::1",
"0.0.0.0",
]);
/**
* 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<string> = new Set(["0.0.0.0", "[::]"]);
const LITERAL_LOOPBACK_HOSTNAMES: ReadonlySet<string> = 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<Record<IdentityConfigErrorCode, string>> = 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;
}
/**
+13
View File
@@ -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<typeof identityAdapterSchema>;
@@ -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 }),
+16
View File
@@ -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 }),
+331
View File
@@ -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<void> {
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<void> {
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<string> {
const { stdout } = await run("docker", [...args]);
return stdout.trim();
}
async function startTlsPostgres(directory: string): Promise<number> {
// 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<void> {
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<typeof databaseTlsConnectionOptions>,
): 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://[email protected]:${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.
}
}
});
});
+303
View File
@@ -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:[email protected]: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:[email protected]: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).",
);
});
});
+2
View File
@@ -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
+59 -1
View File
@@ -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:[email protected]: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());
+120
View File
@@ -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:[email protected]",
"https://:[email protected]",
" 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);
@@ -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.
+1
View File
@@ -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
+60 -3
View File
@@ -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
+8
View File
@@ -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
+26 -7
View File
@@ -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
+23 -4
View File
@@ -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:
+1
View File
@@ -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",