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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 17 additions & 1 deletion src/db/client.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<typeof drizzle<typeof schema>> {
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 });
}
Expand Down
51 changes: 47 additions & 4 deletions tests/unit/db/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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';
Expand Down Expand Up @@ -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();
Expand Down
18 changes: 17 additions & 1 deletion tools/migrate-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '';
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading