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
@@ -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: