Skip to content

fix(db): one SSL decision shared by every pool (DB-11) - #17

Merged
troysatchell merged 6 commits into
mainfrom
fix/db-11-pool-ssl
Jul 30, 2026
Merged

fix(db): one SSL decision shared by every pool (DB-11)#17
troysatchell merged 6 commits into
mainfrom
fix/db-11-pool-ssl

Conversation

@troysatchell

@troysatchell troysatchell commented Jul 29, 2026

Copy link
Copy Markdown
Owner

TRO-240 — [DB-11] One SSL decision, shared by every pool

Closes TRO-240. Post-baseline finding DB-11 (not one of the audit's 68).

What was broken

api/src/db/client.ts:17-26 built the pool the entire application uses with no ssl key at
all
, while migrate.ts:32 and seed.ts:44 each carried their own copy of the conditional.

An absent ssl key is not "let pg decide sensibly" — verified in pg 8.16.3 source rather than
assumed. connection-parameters.js:100 does
this.ssl = typeof config.ssl === 'undefined' ? readSSLConfigFromEnvironment() : config.ssl, and with
PGSSLMODE unset that lands on defaults.ssl, which is false (defaults.js:43). The app pool
connected in plaintext, unconditionally, in production.

A fourth pool had the same defect and was not in the ticket:
api/src/db/scripts/orphan-diagnostic.ts:34. Fixed — same one-line change through the same helper.
Leaving it would have left the trap.

Why the failure signature is confusing

Dockerfile:35 is node dist/db/migrate.js && node dist/index.js. migrate.ts does configure SSL,
so it connects, runs, exits 0, and the && proceeds. Then index.js starts and client.ts fails.
Logs show a successful migration immediately followed by a connection error — reading like a database
problem rather than a client-config one. connectionTimeoutMillis: 2000 makes it fail fast and
crash-loop instead of surfacing a clear TLS error.

What changed

api/src/db/ssl.tsresolveDatabaseSsl(nodeEnv = process.env.NODE_ENV). All four pools under
api/src/db/ call it. The value is unchanged from what the scripts already did.

The helper is not what prevents recurrence. The third test group is: it walks api/src/db/ for
new Pool(, pins the known site list, and asserts no file sets ssl to anything but
resolveDatabaseSsl(). A future file adding its own policy fails the suite. The helper alone would
have left the next author free to hand-roll a fifth.

rejectUnauthorized: false was carried over verbatim, not tightened. It encrypts but does not
verify the chain. A federal deployment likely wants rejectUnauthorized: true plus an explicit ca
that needs the CA bundle decided first, so it is a deliberate follow-up rather than a silent posture
change. Reasoning is in the ssl.ts header and in CHANGES.md.

Evidence

Check Result Ran under
Regression test api/src/db/__tests__/ssl.test.ts — 15 cases, 7 failed / 8 passed before vitest (the tier the gate executes), not e2e
Gate verdict: pass scripts/factory/gate.sh, run by the orchestrator independently
Api suite 484 passed / 0 failed, 31 files DATABASE_URL=…ship_wt_tro_240, docker :5433, NODE_ENV unset so vitest sets test
Web suite 13 failed / 186 passed — the same TEST-1/TRO-223 identities nothing in web/ touched
review-patterns clean no new !/as any/as unknown as/fixed sleeps

Observed. The headline red failure was an AssertionError on the claimed behaviour, not an import
error:

configures TLS in production (the DB-11 bug: it configured nothing)
AssertionError: expected undefined to deeply equal { rejectUnauthorized: false }

Beyond source, the compiled artifact was checked: api/dist/db/ssl.js is emitted and all four
compiled call sites import it with correct relative paths, so Dockerfile:35 resolves the module.
Running compiled dist/db/client.js with a dummy URL gave NODE_ENV=production → {"rejectUnauthorized":false}
and NODE_ENV=development → false. Everything up to the socket is observed.

Also confirmed nothing mutates NODE_ENV at runtime — ssm.ts sets DATABASE_URL/SESSION_SECRET
but only reads NODE_ENV (:39) — so reading it at module scope in client.ts is safe.

Two bugs in the test itself were caught by the red/green cycle and are worth recording: a
negative-lookahead regex (ssl:\s*(?!helper)) that matched correct code because \s* can consume
nothing, and Client.connectionParameters being absent from @types/pg — resolved with Reflect.get
(returns any, so no assertion operator) rather than as any/as unknown as, both of which are
gate G7b failures.

Not verified — and this is the important limitation

That TLS actually negotiates. Proving it needs a managed Postgres requiring TLS on a public
endpoint; the local docker Postgres speaks plaintext only, so a green local suite is silent on the
real failure mode.
"Render now starts" is untested — confirming it means deploying and reading the
startup logs.

Found, not fixed

  1. orphan-diagnostic.ts:180 queries d.program_id IS NULL — a column dropped by
    029_drop_program_id_column.sql (confirmed present). That script's "projects without program"
    query throws on any database that has run 029. Pre-existing, unrelated to SSL, needs its own ticket.
  2. Two more SSL policies deliberately left alone. api/scripts/migrate-shadow.ts:32,
    create-test-user.ts:35, check-db-user.ts:10,19 set ssl: { rejectUnauthorized: false }
    unconditionally. They are operator scripts outside api/tsconfig.json's include: ["src/**/*"],
    always aimed at a remote AWS endpoint, and routing them through a NODE_ENV-conditional helper
    would silently downgrade them to plaintext whenever NODE_ENV is unset — which is how they are
    normally invoked. Documented as out-of-scope in CHANGES.md.

Rollback

git revert the three commits. That restores the missing ssl key on the app pool and the duplicated
conditionals in migrate.ts/seed.ts. No schema change, no migration, no data touched.

Summary by CodeRabbit

  • Bug Fixes

    • Database connections now consistently use encrypted connections in production.
    • Application startup prevents insecure plaintext database connections caused by conflicting connection settings.
    • Database migrations, seeding, and diagnostics now follow the same secure connection behavior.
  • Tests

    • Added coverage for production, non-production, and connection-string SSL scenarios.
    • Added safeguards to ensure all database connection paths use the shared SSL configuration.

troysatchell and others added 3 commits July 29, 2026 16:43
migrate.ts and seed.ts each carried their own copy of
`NODE_ENV === 'production' ? { rejectUnauthorized: false } : false`.
client.ts -- the pool the whole application runs on -- carried no `ssl`
key at all, and neither did scripts/orphan-diagnostic.ts.

An absent `ssl` key does not mean "let pg decide sensibly". pg resolves
`this.ssl = typeof config.ssl === 'undefined' ? readSSLConfigFromEnvironment()
: config.ssl`, and with PGSSLMODE unset that lands on `defaults.ssl === false`.
So the application pool connected in plaintext, unconditionally, including
in production.

It never surfaced on AWS because Aurora is in-VPC and the connection is
internal. It bites against any managed Postgres requiring TLS on a public
endpoint. The signature misdirects, too: Dockerfile:35 is
`migrate.js && index.js`, so migrate configures SSL, connects, exits 0, and
then the app fails -- logs read "migration fine, database unreachable",
which looks like a database fault rather than a client-config one.

The drift is the defect, so this adds api/src/db/ssl.ts as the single
decision instead of a fourth copy of the ternary. Behaviour outside
production is unchanged: previously `false` by pg default, now `false` by
explicit decision.

`rejectUnauthorized: false` is carried over verbatim, not endorsed -- it
encrypts but does not verify the chain. Tightening it needs a CA bundle
decided first, so it is a deliberate follow-up rather than a silent
posture change; the reasoning is in the ssl.ts header.

api/scripts/*.ts are left alone on purpose: they set ssl unconditionally
and always target a remote endpoint, so routing them through a
NODE_ENV-conditional helper would downgrade them to plaintext.

Refs TRO-240 (DB-11)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three groups, because the defect had three faces.

1. The decision itself, per NODE_ENV -- including that "production" is
   matched exactly, so a deploy setting NODE_ENV=Production cannot silently
   drop to plaintext.

2. That the application pool actually applies it. client.ts builds its pool
   at module scope, so this stubs NODE_ENV, resets the module registry and
   re-imports. Constructing a pg Pool opens no socket, so no database is
   touched. Also asserts the option survives pg's config merge: pg does
   `Object.assign({}, config, parse(connectionString))`, so an explicit
   `ssl` only survives because parse() omits the key when the URL has no
   sslmode. If a pg upgrade changes that, the fix becomes a silent no-op
   and this test is the only thing that would notice.

3. That no file under api/src/db sets `ssl` to anything but
   resolveDatabaseSsl(). This is the one that prevents recurrence: a future
   `new Pool(...)` with its own policy fails the suite rather than quietly
   adding a fifth.

Verified red first against the unfixed call sites: 7 failed / 8 passed, every
failure an AssertionError on the claimed behaviour, the headline being
`expected undefined to deeply equal { rejectUnauthorized: false }` for the
application pool under NODE_ENV=production.

What this CANNOT prove: that TLS actually negotiates. That needs a managed
Postgres endpoint requiring TLS on a public address, which this repo's test
environment does not have. These assertions cover the decision and its
propagation to every call site -- everything up to the socket.

Refs TRO-240 (DB-11)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records what was broken, why AWS never showed it, why the Docker
`migrate && index` chain makes the logs read as a database fault, and the
two scoping calls: rejectUnauthorized:false carried over rather than
tightened, and api/scripts/*.ts left on their unconditional-TLS policy
because a NODE_ENV-conditional helper would downgrade them.

Marks the end-to-end claim unverified: nothing here proves TLS negotiates,
because that needs a managed endpoint requiring TLS and the local docker
Postgres speaks plaintext only.

Refs TRO-240 (DB-11)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 4 seconds

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 535a96ce-35e6-4c1e-a327-ccb08c266d2a

📥 Commits

Reviewing files that changed from the base of the PR and between f8ba788 and 6f1a4a1.

📒 Files selected for processing (2)
  • CHANGES.md
  • api/src/db/migrate.ts

Walkthrough

Database SSL configuration is centralized in resolveDatabaseSsl, applied to all PostgreSQL pools, and guarded against production plaintext connections caused by sslmode=disable. Tests verify resolver behavior, pg precedence, pool wiring, and future configuration drift.

Changes

Database SSL configuration

Layer / File(s) Summary
Shared SSL resolver
api/src/db/ssl.ts
Adds DatabaseSslConfig and resolveDatabaseSsl, including sslmode parsing and a production plaintext guard.
Database pool wiring
api/src/db/client.ts, api/src/db/migrate.ts, api/src/db/seed.ts, api/src/db/scripts/orphan-diagnostic.ts
Configures every database pool with the shared SSL resolver.
Regression coverage and release notes
api/src/db/__tests__/ssl.test.ts, CHANGES.md
Tests SSL decisions, pg precedence, pool propagation, and configuration drift; documents the change and rollback procedure.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PoolCaller
  participant resolveDatabaseSsl
  participant pgPool
  PoolCaller->>resolveDatabaseSsl: Resolve SSL options from environment and DATABASE_URL
  resolveDatabaseSsl-->>PoolCaller: Return TLS configuration or false
  PoolCaller->>pgPool: Create PostgreSQL Pool with ssl option
  pgPool-->>PoolCaller: Apply connection parameters and sslmode precedence
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: centralizing SSL resolution and applying it across every DB pool.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/db-11-pool-ssl

Comment @coderabbitai help to get the list of available commands.

troysatchell and others added 2 commits July 29, 2026 18:48
Brings in gate G7b, review-patterns.mjs and merge-changes.mjs.

CHANGES.md needed re-merging by hand: my merge-base still carried
`CHANGES.md merge=union` in .gitattributes, and the union driver spliced the
file exactly as merge-changes.mjs's header predicts -- it drops the shared
context lines, which here are `**How to run it.**` and the ``` fences, leaving
13 fences (odd) and my TRO-240 entry with no run block and no rollback block.
Redone with `merge-changes.mjs --ours --theirs`: 10 entries, 14 fences, all
entries byte-identical to their source. `--check` passes. The merge driver is
gone from .gitattributes as of this merge, so the next one conflicts loudly
instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ntext

CodeRabbit, MAJOR, on the ticket's own premise: nothing proved a connection
-string parameter could not override the resolved SSL object. It can, and
does.

Established by reading pg rather than inferring it from the earlier finding,
then confirmed empirically (pg 8.16.3 / pg-connection-string 2.9.1):

  pg-connection-string:76      sslmode present  -> config.ssl = {}
  pg-connection-string:133-135 case 'disable'   -> config.ssl = false
  connection-parameters.js:56  Object.assign({}, config, parse(connString))
                               -- parse() is the LAST source, so its ssl key
                               overwrites the caller's. The comment on :54
                               says so outright.
  connection-parameters.js:81  that value is then used as-is

Effective order, weakest to strongest: pg defaults -> PGSSLMODE -> the ssl
option this helper returns -> sslmode in the connection string.

Measured, passing an explicit { rejectUnauthorized: false } throughout:
absent -> {rejectUnauthorized:false}; disable -> false (PLAINTEXT); prefer,
require, verify-ca, verify-full -> {}; no-verify -> {rejectUnauthorized:false}.
So `disable` is the only value that discards our option, and it is exactly the
one a DATABASE_URL copied from a provider dashboard can carry.

The ssl option can never win that argument, so resolveDatabaseSsl now refuses
to start in production when the URL's sslmode resolves to plaintext, naming the
parameter and the remedy. It deliberately does NOT rewrite the URL: silently
editing an operator's explicit instruction is the same class of mistake as the
original bug -- code reporting one thing and doing another.

Outside production sslmode=disable is still allowed; local Postgres and the CI
container are plaintext-only and the guard would otherwise be unsatisfiable.

Two of the eight new tests were red first (`expected [Function] to throw an
error`). The other six assert behaviour that must NOT change, and two of those
characterise pg itself -- if a future pg makes the explicit option win, they
fail, which is the signal this guard can be relaxed.

DEPLOYMENT PRECONDITION: if the production DATABASE_URL in SSM already contains
sslmode=disable, this converts a working in-VPC deploy into a startup failure.
The value is in SSM and could not be inspected from here.

Refs TRO-240 (DB-11)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@troysatchell

Copy link
Copy Markdown
Owner Author

Review triage — the finding was a LIVE DEFECT, not a coverage gap

Gate: === TRO-240: pass ===, review-patterns clean, 19 test cases, 7 files.

Which layer wins, and how it was established

The connection string wins. ?sslmode=disable in DATABASE_URL discards the explicitly resolved ssl object entirely and puts the socket on plaintext. So the original fix could be defeated by the one thing most likely to arrive copied from a dashboard.

Established by reading pg source — independently of the earlier defaults.ssl finding — then confirmed empirically against pg 8.16.3 / pg-connection-string 2.9.1:

  • pg-connection-string/index.js:76 — any sslmode present → config.ssl = {}
  • pg-connection-string/index.js:133-135case 'disable': config.ssl = false
  • pg/lib/connection-parameters.js:56config = Object.assign({}, config, parse(config.connectionString)). parse() is the last source, so its ssl key overwrites the caller's. The comment at :54 says so outright: "this will override other default values with what is stored in connectionString."
  • pg/lib/connection-parameters.js:81 — that value is used as-is.

Effective precedence, weakest → strongest: pg defaults → PGSSLMODE → the ssl option this helper returns → sslmode in the connection string.

Measured, passing an explicit {rejectUnauthorized: false} throughout:

sslmode effective ssl wire
absent {rejectUnauthorized:false} encrypted — our option survives
disable false plaintext — our option discarded
prefer/require/verify-ca/verify-full {} encrypted
no-verify {rejectUnauthorized:false} encrypted

disable is the only value that defeats the fix, which let the guard be scoped precisely rather than defensively.

The fix is code, not a test

Because the ssl option can never win, resolveDatabaseSsl now refuses to start in production when the URL's sslmode resolves to plaintext, naming the parameter and the remedy. The guard lives inside the helper, so all four call sites inherit it and none can forget it.

It deliberately does not rewrite the URL. Silently editing an operator's explicit instruction is the same class of mistake as the original bug — code reporting one thing while doing another.

Outside production, sslmode=disable is still allowed: local Postgres and the CI container are plaintext-only, so a guard that failed there would be unsatisfiable and would get bypassed.

sslmode=require resolves to {}, leaving rejectUnauthorized at truestricter than the helper, and it will fail against a private CA. That is a loud connection error rather than a silent downgrade, so it is left alone and noted.

Tests — with the red/green split stated honestly

22 cases total. Of the 8 new ones, 2 were red against the unguarded helper (both AssertionError: expected [Function] to throw an error). The other 6 assert behaviour that must not change — dev still permits disable, the five encrypting modes still pass, a malformed URL stays pg's to report. Flagged rather than implying all 8 went red.

Two of those 6 characterise pg itself: they pin that disable discards the option and the others do not. If a future pg makes the explicit option win, those tests fail — which is the signal that the throw can be relaxed to a passed option. The guard's premise is held under test rather than assumed.

The compiled artifact was exercised too, since Dockerfile:35 runs dist/: production + clean URL → {"rejectUnauthorized":false}; production + ?sslmode=disable → importing dist/db/client.js throws the guard; development + ?sslmode=disablefalse.

⚠️ Deployment precondition — needs a human before rollout

If the production DATABASE_URL in SSM already contains sslmode=disable, this guard converts a currently-working in-VPC plaintext deploy into a startup failure.

The value lives in SSM and could not be inspected from here, so this is a stated risk, not a cleared check. If plaintext is genuinely intended for that deployment, that is an explicit human decision — which is exactly why a loud refusal was chosen over a silent rewrite. Check the SSM parameter before rolling out.

Still cannot verify

That TLS actually negotiates. That needs a managed Postgres requiring TLS on a public address; the local docker Postgres is plaintext-only. Everything up to the socket is now observed, including on the compiled artifact — but "Render starts" remains untested.

Merge note

The main merge damaged CHANGES.md again: this branch's merge-base still carried CHANGES.md merge=union, and the union driver spliced the file exactly as merge-changes.mjs's header predicts — dropped the shared **How to run it.** and fence lines, leaving 13 fences (odd) and the entry with no run or rollback block. Redone with merge-changes.mjs --ours --theirs: 10 entries, 14 fences, all byte-identical to source. The driver is gone from .gitattributes as of this merge, so the next one conflicts loudly instead.

# Conflicts:
#	CHANGES.md
#	api/src/db/migrate.ts
@troysatchell
troysatchell merged commit 239f28d into main Jul 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant