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]>
The sanitized operational line promised for a header/request timeout was
never actually emitted. Driving a real slow-header and slow-body connection
through a real socket against the pinned Node 24 runtime shows the general
server 'timeout' event never fires for headersTimeout/requestTimeout — that
event belongs to server.setTimeout()'s idle-socket timer, which this server
never arms. Both surface as 'clientError' carrying
err.code === "ERR_HTTP_REQUEST_TIMEOUT".
bindServer now observes 'clientError'. Because registering any such listener
suppresses Node's own default response entirely, the handler reproduces that
default byte-for-byte — 408 for a timeout, 431 for a header overflow, 400
otherwise — writing it only when the socket is still writable and no response
has already begun, then destroying the socket exactly as Node would. Exactly
one fixed sanitized line is logged per event, never the caught error's own
message, which for a parse failure derives from attacker-controlled input.
Proof moves to the real transport boundary: raw-socket scenarios assert the
exact response bytes, exactly one status line, socket closure, handler
non-invocation, and exactly one sanitized log for a slow-header connection, a
slow-body connection, a malformed request line, and an oversized header block.
The prior unit test, which manually emitted 'timeout', is removed as
misleading.
Co-Authored-By: Claude Opus 5 <[email protected]>
Production configuration accepted publicSignupEnabled: true and started a
working Identity/API runtime, even though Ekklesia has no verified email,
invitation requirement, CAPTCHA, or durable shared/edge limiter — Doctor
merely reported "Public signup is explicitly ENABLED" as a pass.
validatePublicSignupEnabled now takes the same IdentityEnvironment the rest
of this config validates against and throws the stable
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION whenever the parsed value is true in
production. Parsing stays separate from the policy, so a malformed value
still reports PUBLIC_SIGNUP_ENABLED_INVALID everywhere. Because the
executable entry point validates configuration before startApi, such a
deployment fails before any pool, Better Auth instance, module runtime, or
listener exists, and Doctor reports NOT READY via its existing
IdentityConfigError path. Development and test composition still enable it
explicitly through the identical typed path.
Co-Authored-By: Claude Opus 5 <[email protected]>
Add a single platform-owned request-body policy (hono/body-limit) with
small, named limits for Better Auth and Core's grant-mutation endpoint, and
a new required maxBodyBytes field on the executable module route contract so
every module route makes a conscious no-body-or-bounded-body choice, capped
at a platform ceiling. Every oversized request gets one sanitized 413
contract and never reaches business logic, authorization, or a handler.
Also configure finite Node header/request/keep-alive timeouts, including the
non-obvious connectionsCheckingInterval needed for them to take effect
promptly, so a slow-header or slow-body connection cannot hold a socket
indefinitely.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Replace Better Auth's default trust of a caller-controlled X-Forwarded-For
with a platform-owned client-address resolution boundary: direct mode (the
safe default) trusts only the real socket peer, and an explicit trusted-proxy
mode requires a header name, a concrete trusted-proxy list, and only honors a
forwarded chain from an actual trusted peer. This closes a rate-limit bypass
where rotating a spoofed header let a caller choose a fresh bucket on every
request. Public sign-up is also disabled by default and requires an explicit
typed opt-in, using Better Auth's own disableSignUp gate so no email-existence
leak or second signup path is introduced.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Add a real installed-module fixture (core/tests/fixtures/module-database-integration/db-facade)
with its own manifest, Drizzle schema, drizzle-kit-generated migration, and permissioned HTTP
routes, then exercise it end to end in a new apps/api PostgreSQL integration suite: explicit
migration application before the module runtime starts (never auto-created), start/stop/HTTP
handlers sharing one ModuleDatabase facade by reference, authorized read/mutation/transaction
commit and rollback against real PostgreSQL, denied/rejected-origin/OPTIONS/HEAD requests never
reaching the module or its table, and both a module-caught and an uncaught real database failure
sanitizing exactly as pass 1 established. Registered alongside the existing suites in
vitest.migrations.integration.config.ts; no production code, schema, or migration changed.
Introduce ModuleDatabase (a typed, restricted Drizzle query surface) and the frozen
ModuleRuntimeServices boundary in @ekklesia/contracts, and thread the same services object
through Core's module start/stop lifecycle hooks and apps/api's authorized module HTTP handler
context. apps/api builds the facade via a new core/src/modules/database.ts view (createModuleDatabaseView)
over the one existing shared PostgreSQL pool, alongside the identity and authorization views, and
wires it into startApi's fixed startup order ahead of Identity/Authorization construction.
Registration stays database-free; a module reaches its database only through an authorized start,
stop, or HTTP handler invocation.
Found during a targeted post-audit review: sanitizeModuleResponse's Vary fix
(bf76f4e) canonicalized the module response's own Vary before Hono's own
cors() middleware (apps/api/src/app.ts) ran, but cors() unconditionally
appends its own "Vary: Origin" afterward, outside module-http-routes.ts's
reach. The wire-level response app.fetch actually returns could therefore
still read "*, Origin" for a module handler that sets Vary: *, or carry a
case-different duplicate Origin token — reproduced first via a new app-level
regression test hitting the final response, not sanitizeModuleResponse in
isolation.
Fixed by wrapping the shared corsMiddleware in withCanonicalVary
(apps/api/src/app.ts), which re-canonicalizes whatever Vary cors() leaves
behind only after it has fully finished, handling both the normal
next()-driven path (via c.header(), since reassigning c.res triggers Hono's
own header-preserving merge and would silently undo the fix) and the
OPTIONS-preflight path (cors() returns a Response directly without touching
c.res). Neither path reads or clones the response body.
Refactored module-http-routes.ts's Vary logic into one shared parsing/dedup
primitive with two policy-specific callers: mergedVaryHeader (module
responses; always forces canonical Origin/Cookie, unchanged behavior) and
the new canonicalizeVaryHeader (the app-level CORS boundary; only
deduplicates and collapses "*", never injects a token that was not already
present — an OPTIONS preflight has no Cookie/personalization concept to
force). This also incidentally fixes the same pre-existing Origin
duplication on /api/v1/principal (present since Phase 4B, flagged as a
remaining concern in the prior pass), since it wraps the one shared
middleware every /api/v1/* and /api/auth/* response already passes through.
ADR 0015 required no changes: its Vary/CORS ownership statements are
architectural, not tied to a specific function, and remain accurate.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Found during the Phase 5B pass-3 architectural audit: sanitizeModuleResponse's
Vary merge used a plain Set, so an existing "Vary: *" from a module handler
became "*, Origin, Cookie" (RFC 9110 §12.5.5 forbids combining "*" with any
other value), and a differently-cased existing token (e.g. "origin") produced
a redundant duplicate alongside the forced "Origin"/"Cookie". mergedVaryHeader
now de-duplicates case-insensitively, always emits canonical Origin/Cookie
casing, and collapses to a bare "*" whenever one is present, per focused unit
tests. The full app's own pre-existing cors() middleware (apps/api/src/app.ts,
unchanged here, present since Phase 4B on every /api/v1/* route including
/api/v1/principal) still unconditionally appends its own "Vary: Origin"
outside this module's control — noted in the new tests, not addressed by this
narrow fix.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Phase 5B, pass 2: production API mounting, authorization enforcement,
and module-runtime lifecycle integration.
apps/api now discovers the installed module inventory exactly once
(prepareStartupModuleInventory), composes the platform permission
registry from it, starts the module runtime from that same discovered
list (startDiscoveredModuleRuntime, never rediscovering), and mounts
StartedModuleRuntime's published routes under
/api/v1/modules/<module-id> via a new apps/api-owned Hono adapter
(module-http-routes.ts). Every module route is protected by the
existing requirePermission middleware against the composed registry,
state-changing methods additionally require a trusted Origin, OPTIONS
remains entirely platform-owned, and a handler that throws, rejects,
or returns a non-Response becomes a fixed, sanitized
500 MODULE_HANDLER_FAILED. Module-supplied CORS headers are stripped
and every module response is forced to Cache-Control: no-store with
Vary: Origin, Cookie. RunningApi shutdown now stops the module runtime
between draining the HTTP server and closing the shared pool, and
every startup failure path stops the runtime and/or closes the pool
before propagating, without masking the original failure.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Wires composePlatformPermissionRegistry into apps/api's production
startup: startApi() discovers every installed module, composes the one
immutable platform permission registry (Core's own declarations plus
every module's), and only then constructs the shared PostgreSQL pool,
Better Auth, and the HTTP server. A discovery or composition failure —
an invalid manifest, a reserved core.* namespace claim, or a duplicate
declaration — propagates before anything exists to close; there is no
Core-only registry fallback. apps/api/src/platform-permissions.ts
resolves the repository root from its own file location, never
process.cwd(), so package-scoped test runs discover the right modules/
directory.
createApiApp/createGrantsRoutes now require the composed registry as
an injected authorization option, replacing the previous hard-coded
CORE_PERMISSION_REGISTRY; requirePermission continues to work
identically for Core-owned and module-owned permissions, since it has
no concept of the distinction.
Extends pnpm doctor with a modules.permissions check (reusing the
already-discovered module list, distinguishing reserved-namespace from
generic-duplicate failures, never re-discovering modules) and pnpm
modules:validate to compose the same registry before a module is
considered installable.
Adds focused coverage: startup-ordering and failure-isolation tests in
apps/api/tests/lifecycle.test.ts, module-owned-declaration enforcement
tests in apps/api/tests/authorization-middleware.test.ts, Doctor
composition tests in core/tests/doctor.test.ts, and a live PostgreSQL
end-to-end scenario proving a real installed module's permission is
discoverable, grantable, and deniable once the module is absent again
after a restart, without ever executing its server entrypoint.
Two related fail-closed gaps let a well-formed but never-declared
permission id slip through:
- POST /api/v1/grants created explicit grants for any well-formed
PermissionId without checking the injected permission registry,
allowing dormant grants for invented identifiers that could become
effective the moment those identifiers were registered later.
- createAuthorizationEvaluator validated the permission id only inside
the restricted-baseline branch, so a malformed permission combined
with the public or authenticated baseline could still return allowed.
Both now fail closed: the grants route checks the target permission
against the trusted registry before calling store.createGrant (sanitized
403, no store or audit write), and the evaluator validates permission
shape before any baseline is allowed to return an allowed decision,
without adding a registry dependency to the evaluator itself.
The live integration lifecycle test switched from an unregistered
calendar.events.read to the registered core.authorization.grants.manage
permission, since module permission registration does not exist yet.
Wires Core's identity and authorization domains into apps/api over one
shared, validated PostgreSQL pool; adds an explicit, single-use,
concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible
immutable permission registry with one Core-owned restricted permission
(core.authorization.grants.manage); and adds reusable Hono authorization
enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing
explicit grants only, gated by an exact trusted Origin on every mutation.