diff --git a/CLAUDE.md b/CLAUDE.md index 196b678e..038202ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,7 +120,8 @@ Required: Optional (infrastructure): - `PORT` - Server port (default: 3000) - `LOG_LEVEL` - Logging level (default: info) -- `DATABASE_SSL` - Set to `false` to disable SSL for local PostgreSQL (default: enabled) +- `DATABASE_SSL` - Set to `false` to disable SSL for local PostgreSQL (default: enabled with certificate validation) +- `DATABASE_CA_CERT` - Path to a PEM-encoded CA certificate file for managed databases that use a private CA (e.g., AWS RDS, Azure Database, GCP Cloud SQL). When set, the certificate is read and passed as the `ca` option to `pg.Pool`, enabling TLS certificate validation against the specified CA. Example: `DATABASE_CA_CERT=/etc/ssl/certs/rds-ca.pem` - `CLAUDE_CODE_OAUTH_TOKEN` - For Claude Code engine (subscription auth) - `CREDENTIAL_MASTER_KEY` - 64-char hex string (32-byte AES-256 key) for encrypting credentials at rest. Generate with `npm run credentials:generate-key`. When set, all new/updated credentials are encrypted automatically; existing plaintext credentials continue to work. - `WEBHOOK_CALLBACK_BASE_URL` - Base URL for webhook callbacks (e.g., `https://cascade.example.com`). Used by `tools/setup-webhooks.ts` and the `cascade webhooks create` CLI command to construct the full webhook URL. diff --git a/src/db/client.ts b/src/db/client.ts index 52dc1336..b4aea43a 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -1,3 +1,4 @@ +import fs, { existsSync } from 'node:fs'; import { drizzle } from 'drizzle-orm/node-postgres'; import pg from 'pg'; import * as schema from './schema/index.js'; @@ -28,13 +29,28 @@ function getDatabaseUrl(): string { throw new Error('DATABASE_URL or CASCADE_POSTGRES_HOST must be set'); } +function getSslConfig(): false | { rejectUnauthorized: boolean; ca?: string } { + if (process.env.DATABASE_SSL === 'false') { + return false; + } + const sslConfig: { rejectUnauthorized: boolean; ca?: string } = { rejectUnauthorized: true }; + if (process.env.DATABASE_CA_CERT) { + const certPath = process.env.DATABASE_CA_CERT; + if (!existsSync(certPath)) { + throw new Error(`DATABASE_CA_CERT file not found: ${certPath}`); + } + sslConfig.ca = fs.readFileSync(certPath, 'utf8'); + } + return sslConfig; +} + export function getDb(): ReturnType> { if (_testDbOverride) return _testDbOverride; if (!db) { pool = new pg.Pool({ connectionString: getDatabaseUrl(), max: 5, - ssl: process.env.DATABASE_SSL === 'false' ? false : { rejectUnauthorized: false }, + ssl: getSslConfig(), }); db = drizzle(pool, { schema }); } diff --git a/tests/unit/db/client.test.ts b/tests/unit/db/client.test.ts index f95d26cb..f0196f56 100644 --- a/tests/unit/db/client.test.ts +++ b/tests/unit/db/client.test.ts @@ -4,10 +4,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // vi.mock factories are hoisted to the top of the file, so any variables they // reference must also be hoisted via vi.hoisted(). -const { mockPoolEnd, mockPoolConstructor } = vi.hoisted(() => { +const { mockPoolEnd, mockPoolConstructor, mockReadFileSync, mockExistsSync } = vi.hoisted(() => { const mockPoolEnd = vi.fn().mockResolvedValue(undefined); const mockPoolConstructor = vi.fn().mockImplementation(() => ({ end: mockPoolEnd })); - return { mockPoolEnd, mockPoolConstructor }; + const mockReadFileSync = vi.fn().mockReturnValue('mock-ca-cert-content'); + const mockExistsSync = vi.fn().mockReturnValue(true); + return { mockPoolEnd, mockPoolConstructor, mockReadFileSync, mockExistsSync }; }); vi.mock('pg', () => ({ @@ -20,6 +22,13 @@ vi.mock('drizzle-orm/node-postgres', () => ({ drizzle: vi.fn().mockReturnValue({ __isMockDrizzle: true }), })); +vi.mock('node:fs', () => ({ + default: { + readFileSync: mockReadFileSync, + }, + existsSync: mockExistsSync, +})); + // ── Imports (after mocks) ───────────────────────────────────────────────────── import { _setTestDb, closeDb, getDb } from '../../../src/db/client.js'; @@ -150,22 +159,56 @@ describe('getDb', () => { it('creates pool with SSL disabled when DATABASE_SSL=false', () => { vi.stubEnv('DATABASE_SSL', 'false'); + vi.stubEnv('DATABASE_CA_CERT', ''); getDb(); expect(mockPoolConstructor).toHaveBeenCalledWith(expect.objectContaining({ ssl: false })); }); - it('creates pool with rejectUnauthorized:false by default (DATABASE_SSL not set)', () => { + it('creates pool with rejectUnauthorized:true by default (DATABASE_SSL not set)', () => { vi.stubEnv('DATABASE_SSL', ''); + vi.stubEnv('DATABASE_CA_CERT', ''); getDb(); expect(mockPoolConstructor).toHaveBeenCalledWith( - expect.objectContaining({ ssl: { rejectUnauthorized: false } }), + expect.objectContaining({ ssl: { rejectUnauthorized: true } }), ); }); + it('creates pool with custom CA cert when DATABASE_CA_CERT is set', () => { + vi.stubEnv('DATABASE_SSL', ''); + vi.stubEnv('DATABASE_CA_CERT', '/path/to/ca.pem'); + + getDb(); + + expect(mockReadFileSync).toHaveBeenCalledWith('/path/to/ca.pem', 'utf8'); + expect(mockPoolConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + ssl: { rejectUnauthorized: true, ca: 'mock-ca-cert-content' }, + }), + ); + }); + + it('throws a descriptive error when DATABASE_CA_CERT path does not exist', () => { + vi.stubEnv('DATABASE_SSL', ''); + vi.stubEnv('DATABASE_CA_CERT', '/nonexistent/ca.pem'); + mockExistsSync.mockReturnValueOnce(false); + + expect(() => getDb()).toThrow('DATABASE_CA_CERT file not found: /nonexistent/ca.pem'); + }); + + it('DATABASE_CA_CERT is ignored when DATABASE_SSL=false', () => { + vi.stubEnv('DATABASE_SSL', 'false'); + vi.stubEnv('DATABASE_CA_CERT', '/path/to/ca.pem'); + + getDb(); + + expect(mockReadFileSync).not.toHaveBeenCalled(); + expect(mockPoolConstructor).toHaveBeenCalledWith(expect.objectContaining({ ssl: false })); + }); + it('returns singleton — second call returns same instance', () => { const first = getDb(); const second = getDb(); diff --git a/tools/migrate-hooks.ts b/tools/migrate-hooks.ts index 8e754c94..6d35549c 100644 --- a/tools/migrate-hooks.ts +++ b/tools/migrate-hooks.ts @@ -8,6 +8,7 @@ * npx tsx tools/migrate-hooks.ts --apply # Apply changes */ +import { existsSync, readFileSync } from 'node:fs'; import pg from 'pg'; const DATABASE_URL = process.env.DATABASE_URL ?? ''; @@ -16,6 +17,21 @@ if (!DATABASE_URL) { process.exit(1); } +function getSslConfig(): false | { rejectUnauthorized: boolean; ca?: string } { + if (process.env.DATABASE_SSL === 'false') { + return false; + } + const sslConfig: { rejectUnauthorized: boolean; ca?: string } = { rejectUnauthorized: true }; + if (process.env.DATABASE_CA_CERT) { + const certPath = process.env.DATABASE_CA_CERT; + if (!existsSync(certPath)) { + throw new Error(`DATABASE_CA_CERT file not found: ${certPath}`); + } + sslConfig.ca = readFileSync(certPath, 'utf8'); + } + return sslConfig; +} + const dryRun = !process.argv.includes('--apply'); interface LegacyBackend { @@ -124,7 +140,7 @@ async function main() { const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 2, - ssl: process.env.DATABASE_SSL === 'false' ? false : { rejectUnauthorized: false }, + ssl: getSslConfig(), }); try {