Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/cli-tls-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'stash': minor
---

The CLI now handles database TLS properly, so the discoverable fix for a certificate failure is never `NODE_TLS_REJECT_UNAUTHORIZED=0`.

- Every CLI database connection honours `sslmode` and `sslrootcert` from the connection string — and `PGSSLMODE` / `PGSSLROOTCERT` from the environment when the URL carries no TLS parameters (URL wins; unlike raw node-postgres, `PGSSLROOTCERT` is actually consumed): `verify-full` (and `require`/`verify-ca`/`prefer`, kept as full verification — node-postgres's current behaviour) verifies the server certificate; `no-verify` is honoured with a one-line stderr warning; `disable` turns TLS off. Client-certificate setups (`sslcert`/`sslkey`) pass through untouched.
- CA resolution: `sslrootcert=<path>` (libpq semantics — sole trust anchor; `sslrootcert=system` selects the system store) → `PGSSLROOTCERT` → for `*.supabase.co`/`*.supabase.com` hosts a **bundled Supabase root CA** (appended to the system roots) → the system store. `sslmode=verify-full` against Supabase — direct hosts and the pgBouncer pooler — now verifies out of the box.
- Certificate-verification failures — shaped centrally in the connection factory, so every command surfaces them — name the host and the supported remedies in order (`sslrootcert=…`, then `sslmode=no-verify` as a last resort with the consequence spelled out), and explicitly warn against `NODE_TLS_REJECT_UNAUTHORIZED=0`, which is process-wide and would also disable verification for the connections carrying CipherStash credentials.
- The node-postgres "SSL modes … are treated as aliases for verify-full" SECURITY WARNING no longer appears on every invocation against `sslmode=require` URLs: the CLI decides the TLS config itself and hands pg a URL with the TLS params stripped (fixes the upstream-advisory passthrough).
4 changes: 2 additions & 2 deletions packages/cli/src/commands/db/install.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { installMigrationsSchema } from '@cipherstash/migrate'
import * as p from '@clack/prompts'
import pg from 'pg'
import { resolveDatabaseUrl } from '@/config/database-url.js'
import { findConfigFile, loadStashConfig } from '@/config/index.js'
import { createPgClient } from '@/db/client.js'
import { EQLInstaller } from '@/installer/index.js'
import { messages } from '@/messages.js'
import { detectPackageManager, runnerCommand } from '../init/utils.js'
Expand Down Expand Up @@ -186,7 +186,7 @@ export async function installCommand(
if (supabase) reportSupabaseGrantsOutcome(installResult)

s.start('Installing cs_migrations tracking schema...')
const migrationsDb = new pg.Client({ connectionString: databaseUrl })
const migrationsDb = createPgClient(databaseUrl)
try {
await migrationsDb.connect()
await installMigrationsSchema(migrationsDb)
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/db/status.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as p from '@clack/prompts'
import pg from 'pg'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { loadStashConfig } from '@/config/index.js'
import { createPgClient } from '@/db/client.js'
import { EQLInstaller } from '@/installer/index.js'

export async function statusCommand(options: { databaseUrl?: string } = {}) {
Expand Down Expand Up @@ -110,7 +110,7 @@ export async function statusCommand(options: { databaseUrl?: string } = {}) {

s.start('Checking encrypt configuration...')

const client = new pg.Client({ connectionString: config.databaseUrl })
const client = createPgClient(config.databaseUrl)

try {
await client.connect()
Expand Down
11 changes: 8 additions & 3 deletions packages/cli/src/commands/db/test-connection.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as p from '@clack/prompts'
import pg from 'pg'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { detectDotenvFile } from '@/config/database-url.js'
import { loadStashConfig } from '@/config/index.js'
import { createPgClient, TlsVerificationError } from '@/db/client.js'
import { messages } from '@/messages.js'

export async function testConnectionCommand(
Expand All @@ -16,7 +16,7 @@ export async function testConnectionCommand(
const config = await loadStashConfig({ databaseUrlFlag: options.databaseUrl })
s.stop('Configuration loaded.')

const client = new pg.Client({ connectionString: config.databaseUrl })
const client = createPgClient(config.databaseUrl)

try {
s.start('Connecting to database...')
Expand Down Expand Up @@ -48,7 +48,12 @@ export async function testConnectionCommand(
const message =
error instanceof Error ? error.message : 'An unknown error occurred'

p.log.error(`Failed to connect to database: ${message}`)
if (error instanceof TlsVerificationError) {
// Shaped centrally by createPgClient — self-contained, print verbatim.
p.log.error(error.message)
} else {
p.log.error(`Failed to connect to database: ${message}`)
}
console.log()
p.log.info(messages.db.urlConnectionFailedHint(detectDotenvFile()))
process.exit(1)
Expand Down
18 changes: 16 additions & 2 deletions packages/cli/src/commands/encrypt/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as p from '@clack/prompts'
import pg from 'pg'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { loadStashConfig } from '@/config/index.js'
import { buildPgClientConfig, explainTlsError } from '@/db/client.js'
import { loadEncryptionContext, requireTable } from './context.js'

/**
Expand Down Expand Up @@ -115,8 +116,11 @@ export async function backfillCommand(options: BackfillCommandOptions) {
const ctx = await loadEncryptionContext()
const tableSchema = requireTable(ctx, options.table)

// Through the TLS-aware config builder like every other connection —
// pg.PoolConfig extends pg.ClientConfig, so the pool inherits the same
// sslmode/sslrootcert handling and the bundled Supabase CA.
const pool = new pg.Pool({
connectionString: stashConfig.databaseUrl,
...buildPgClientConfig(stashConfig.databaseUrl),
max: 2,
})

Expand All @@ -131,7 +135,17 @@ export async function backfillCommand(options: BackfillCommandOptions) {
try {
process.on('SIGINT', onSignal)
process.on('SIGTERM', onSignal)
db = await pool.connect()
try {
db = await pool.connect()
} catch (error) {
// A certificate-verification failure is an author-shaped, row-data-free
// diagnostic — route it through BackfillConfigError so it prints
// verbatim (the generic handler below deliberately suppresses message
// text, which would bury the remedy).
const tlsExplanation = explainTlsError(error, stashConfig.databaseUrl)
if (tlsExplanation) throw new BackfillConfigError(tlsExplanation)
throw error
}

// `stash eql install` normally creates `cipherstash.cs_migrations`, but
// not every integration runs it — Prisma Next installs EQL through its
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/encrypt/drop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import {
setManifestTargetPhase,
} from '@cipherstash/migrate'
import * as p from '@clack/prompts'
import pg from 'pg'
import { detectDrizzle } from '@/commands/db/detect.js'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { loadStashConfig } from '@/config/index.js'
import { createPgClient } from '@/db/client.js'
import { scaffoldDrizzleMigration } from './drizzle-helper.js'
import { explainUnresolved, resolveColumnLifecycle } from './lib/resolve-eql.js'

Expand Down Expand Up @@ -58,7 +58,7 @@ export async function dropCommand(options: DropCommandOptions) {
p.intro(runnerCommand(detectPackageManager(), 'stash encrypt drop'))

const config = await loadStashConfig()
const client = new pg.Client({ connectionString: config.databaseUrl })
const client = createPgClient(config.databaseUrl)
let exitCode = 0

try {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/encrypt/plan.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { latestByColumn, readManifest } from '@cipherstash/migrate'
import * as p from '@clack/prompts'
import pg from 'pg'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { loadStashConfig } from '@/config/index.js'
import { createPgClient } from '@/db/client.js'

/**
* CLI handler for `stash encrypt plan`. Reads the repo manifest and the
Expand All @@ -24,7 +24,7 @@ export async function planCommand() {
return
}

const client = new pg.Client({ connectionString: config.databaseUrl })
const client = createPgClient(config.databaseUrl)
let exitCode = 0
try {
await client.connect()
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/encrypt/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {
readManifest,
} from '@cipherstash/migrate'
import * as p from '@clack/prompts'
import pg from 'pg'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { loadStashConfig } from '@/config/index.js'
import { createPgClient } from '@/db/client.js'
import {
type EqlColumnInfo,
fetchActiveEqlConfig,
Expand Down Expand Up @@ -41,7 +41,7 @@ export async function statusCommand() {

const config = await loadStashConfig()
const manifest = await readManifest(process.cwd())
const client = new pg.Client({ connectionString: config.databaseUrl })
const client = createPgClient(config.databaseUrl)
let exitCode = 0

try {
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/commands/eql/applied.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* `migrations.table` in drizzle.config.ts); a project that overrides them must
* say so, because the probe cannot discover it — see {@link LEDGER_ABSENT}.
*/
import { buildPgClientConfig } from '@/db/config.js'

export const DEFAULT_MIGRATIONS_RELATION = 'drizzle.__drizzle_migrations'

/**
Expand Down Expand Up @@ -93,7 +95,7 @@ export async function latestAppliedMillis(
relation: string = DEFAULT_MIGRATIONS_RELATION,
): Promise<number | typeof NOTHING_APPLIED | typeof LEDGER_ABSENT> {
const { default: pg } = await import('pg')
const client = new pg.Client({ connectionString: databaseUrl })
const client = new pg.Client(buildPgClientConfig(databaseUrl))
try {
await client.connect()
const result = await client.query<{ max_created_at: string | null }>(
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/eql/validate.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { AnyV3Table } from '@cipherstash/stack/eql/v3'
import type { ColumnSchema, EncryptConfig } from '@cipherstash/stack/schema'
import * as p from '@clack/prompts'
import pg from 'pg'
import { fetchPhysicalColumns } from '@/commands/encrypt/lib/db-readers.js'
import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js'
import { loadEncryptSchemas, loadStashConfig } from '@/config/index.js'
import { createPgClient } from '@/db/client.js'

// ---------------------------------------------------------------------------
// The vocabulary
Expand Down Expand Up @@ -875,7 +875,7 @@ async function tryReadObservedState(
}

const tables = [...new Set(columns.map((column) => column.table))]
const client = new pg.Client({ connectionString: databaseUrl })
const client = createPgClient(databaseUrl)

try {
await client.connect()
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/init/lib/introspect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as p from '@clack/prompts'
import pg from 'pg'
import { createPgClient } from '@/db/client.js'
import type { ColumnDef, DataType, SchemaDef, V3Domain } from '../types.js'

export interface DbColumn {
Expand Down Expand Up @@ -81,8 +81,7 @@ export async function introspectDatabase(
// this, an unreachable / firewalled database silently hangs the spinner
// until the user kills the process. 10 s is generous for healthy hosts
// and short enough to surface a real failure quickly.
const client = new pg.Client({
connectionString: databaseUrl,
const client = createPgClient(databaseUrl, {
connectionTimeoutMillis: 10_000,
})
try {
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/init/lib/rollout-state.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { MigrationPhase } from '@cipherstash/migrate'
import pg from 'pg'
import { createPgClient } from '@/db/client.js'
import { latestByColumnSafe } from '../../encrypt/lib/db-readers.js'

/** Conservative connect timeout for rollout-state lookups: the CLI
Expand Down Expand Up @@ -65,8 +65,7 @@ export async function detectColumnStates(
): Promise<ColumnState[] | null> {
if (columns.length === 0) return []

const client = new pg.Client({
connectionString: databaseUrl,
const client = createPgClient(databaseUrl, {
connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
})
try {
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/commands/status/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
readManifest,
} from '@cipherstash/migrate'
import * as p from '@clack/prompts'
import pg from 'pg'
import { createPgClient } from '@/db/client.js'
import {
fetchActiveEqlConfig,
fetchPhysicalColumns,
Expand Down Expand Up @@ -114,8 +114,7 @@ export async function gatherObservations(
}
}

const client = new pg.Client({
connectionString: databaseUrl,
const client = createPgClient(databaseUrl, {
connectionTimeoutMillis: CONNECT_TIMEOUT_MS,
})
const tables = Array.from(new Set(targetColumns.map((c) => c.table)))
Expand Down
59 changes: 59 additions & 0 deletions packages/cli/src/db/__tests__/client-wrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const mockConnect = vi.fn()
const mockEnd = vi.fn()

vi.mock('pg', () => ({
default: {
Client: vi.fn(() => {
const client: Record<string, unknown> = {
connect: (...args: unknown[]) => mockConnect(...args),
end: mockEnd,
}
return client
}),
},
}))

describe('createPgClient connect wrapping', () => {
beforeEach(() => vi.clearAllMocks())
afterEach(() => vi.restoreAllMocks())

it('re-throws certificate failures as TlsVerificationError with the remedy', async () => {
mockConnect.mockRejectedValue(
Object.assign(new Error('self-signed certificate in certificate chain'), {
code: 'SELF_SIGNED_CERT_IN_CHAIN',
}),
)
const { createPgClient, TlsVerificationError } = await import(
'../client.js'
)
const client = createPgClient(
'postgres://u@aws-0-us-east-1.pooler.supabase.com/postgres?sslmode=require',
)
const failure = await client.connect().catch((error: unknown) => error)
expect(failure).toBeInstanceOf(TlsVerificationError)
expect((failure as Error).message).toContain(
'aws-0-us-east-1.pooler.supabase.com',
)
expect((failure as Error).message).toContain('sslrootcert=')
expect((failure as Error).message).toContain(
'Never set NODE_TLS_REJECT_UNAUTHORIZED=0',
)
})

it('passes non-TLS connect failures through untouched', async () => {
const original = new Error('password authentication failed for user "u"')
mockConnect.mockRejectedValue(original)
const { createPgClient } = await import('../client.js')
const client = createPgClient('postgres://u@h/app?sslmode=require')
await expect(client.connect()).rejects.toBe(original)
})

it('resolves normally when connect succeeds', async () => {
mockConnect.mockResolvedValue(undefined)
const { createPgClient } = await import('../client.js')
const client = createPgClient('postgres://u@h/app')
await expect(client.connect()).resolves.toBeUndefined()
})
})
Loading
Loading