Two security records did not survive user deletion, because both hung off a
cascading foreign key to the live Better Auth user row.
Deleting a user with authorization history deleted that history. A subject with
a create and a revoke audit row had zero rows afterwards: the record of what
they had been granted, by whom, and when it was revoked disappeared at exactly
the moment it mattered most. `ekklesia_core_authorization_audit.subject_user_id`
is now a retained, non-null historical snapshot with no foreign key — a plain
identifier that may name a user row which no longer exists. No name, email, or
other profile data is copied into authorization tables. The Drizzle relation to
the user table is dropped too, because modelling a retained snapshot as a live
relation misdescribes the data. Live rows still cascade, as intended: deleting a
user removes their grants and platform-owner row, so nothing continues
authorizing a principal that no longer exists.
Platform-owner bootstrap's single use was equally fragile. "Already consumed"
meant "a row exists in ekklesia_core_platform_owner", and that table cascades
too — so deleting the bootstrapped owner's user row removed the owner row, its
audit evidence, and with them the only thing preventing the operation that
establishes a platform's first administrator from being run again. A dedicated
`ekklesia_core_platform_owner_bootstrap` singleton now carries that authority.
Its id is pinned by a check constraint to the literal 'singleton', so the
primary key makes a second row structurally impossible rather than merely
unwritten by convention; it has no user foreign key, and Core never deletes from
it. Bootstrap claims that record inside the same transaction as the owner row
and its audit entry, so consumption is tied to success: a missing target user or
any other failure rolls the sentinel back and leaves the one legitimate first
use available. The existing transaction-scoped advisory lock still serializes
concurrent first attempts to exactly one success.
The operational consequence is deliberate and permanent: deleting the last
platform owner does not reactivate bootstrap, and neither does revoking one.
Core migration 0002_authorization_integrity creates the singleton table, drops
the cascading audit-subject constraint, and backfills consumed state for any
installation that already holds a platform-owner row — active or revoked — so an
existing deployment cannot gain a fresh first use by upgrading. It records no
subject, because a backfill cannot know which of several historical owner rows
the original use targeted and guessing would fabricate history. A never-owned
installation is untouched and keeps its one first use.
One upgrade limitation is irrecoverable and is documented rather than papered
over: if an operator manually deleted the bootstrapped owner's user row before
this migration, the old cascade already erased the owner row and its audit
evidence, so nothing remains to detect. At this checkpoint no supported Better
Auth or API user-deletion path can produce that state; it requires direct
database access.
Co-Authored-By: Claude Opus 5 <[email protected]>
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]>
Principal resolution could not tell a broken session store from a genuinely
anonymous request, and did unnecessary database work for callers that never
presented a session at all. Both are corrected here.
Any cookie previously triggered the ambiguity check, so `Cookie: x=y` on an
otherwise anonymous request caused an identity database round trip an
unauthenticated caller could drive with a header they chose themselves.
`hasConfiguredSessionCookie` now parses the Cookie header per RFC 6265 (split
on `;`, name is the text before the first `=`, trimmed) and compares the whole
name exactly — never `includes`, a substring test, or an unanchored regular
expression — so `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. A duplicated name still reports true: ambiguity fails
safe towards checking, and reporting true never grants access on its own.
The cookie name is not a second hard-coded copy. One `betterAuthOptions` object
now feeds both `betterAuth()` and Better Auth's own supported `getCookies`
helper, so the detected name — including the `__Secure-` prefix an https baseURL
implies — is derived from the exact configuration Better Auth itself uses, and
drift is structurally impossible rather than merely tested.
The readiness check itself proved almost nothing: `select 1` still succeeded
against a dropped session table, a drifted or removed column, or a revoked
SELECT privilege, so Better Auth's ambiguous null was reported as a genuinely
anonymous request. `probeSessionStore` now plans a real read of the session
relation and the exact columns the adapter depends on, under `where false`:
PostgreSQL resolves the relation, every column, and the caller's privilege at
plan time while matching zero rows, so no session row, token, or user id can be
read, logged, or enumerated by the check. It uses the injected pool, never a
second connection, and stays bounded by the existing timeout.
A missing relation, dropped column, revoked privilege, unreachable connection,
and query timeout now all produce the same sanitized 503 with no relation name,
column name, SQL, driver text, host, or stack — proven against real PostgreSQL,
since that is the only place a broken store looks exactly like an anonymous
request.
Co-Authored-By: Claude Opus 5 <[email protected]>
Add ADR 0016 recording the accepted module database access design (ModuleDatabase, the frozen
ModuleRuntimeServices boundary, one pool/one facade/one services-object identity, explicit
migrations, and the trust model), with narrow cross-reference corrections to ADR 0006 and ADR 0015
where their original text is now inaccurate. Sweep docs/development/api.md,
docs/development/authorization.md, docs/module-development/README.md, docs/development/testing.md,
and docs/development/identity.md for the new database boundary and remaining Phase 4C staleness;
correct the stale "Phase 4C is next" roadmap paragraph in platform-architecture.md and the matching
gap in README's current-status narrative. Extend the module template with a permissioned
GET /records route reading through context.services.database, using the canonical
createModuleTable helper, with matching template tests.
Phase 5B, pass 3: adds ADR 0015 recording the durable module HTTP
routing and production runtime lifecycle architecture pass 1 and pass
2 implemented, extending ADR 0006 and ADR 0014 without superseding
either. Adds narrow cross-reference notes to both, and to the ADR
index, pointing at ADR 0015 for the work they deferred, while
preserving their historical text and accepted status.
Corrects stale forward-looking "pass 2"/future-tense language left
over from pass 1 in core/src/modules/runtime.ts and
packages/contracts/src/module-http.ts (comment-only; production
behavior unchanged), and a shutdown-lifecycle description in
docs/development/testing.md that predated the module-runtime-stop
step pass 2 added between draining the HTTP server and closing the
pool.
Full audit against origin/main...HEAD found the architecture,
contract, startup ordering, routing, authorization/origin
enforcement, and PostgreSQL integration coverage from passes 1-2
sound; the one concrete defect found (Vary-header merge producing an
invalid "*, Origin, Cookie" combination and case-sensitive duplicate
tokens) was already fixed with focused regression tests in a separate
commit (bf76f4e), not amending either checkpoint commit.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Adds Core-owned identity contracts, a Core-owned Better Auth database schema
(ekklesia_core_user/session/account/verification), a first-class Core-owned
migration source that coexists with module-owned migrations under the exact
existing migration system, and an explicit Better Auth construction boundary.
No authentication endpoint or sign-in UI is exposed yet (ADR 0010).
Co-Authored-By: Claude Sonnet 5 <[email protected]>