Skip to content

Commit 2bde7ad

Browse files
Marfuenclaude
andauthored
feat: verified-TLS to RDS from every runtime (#2761)
* chore(db): commit AWS RDS global CA bundle for verified TLS * feat(db): strict TLS gating in shared prisma client Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(db): extract resolveSslConfig and use bun:test for consistency Move SSL-resolution logic into a pure ssl-config.ts module so it can be tested with bun:test (matching strip-ssl-mode.test.ts's pattern) without importing the module-level Prisma client. Drop vitest devDependency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(app): strict TLS gating in app prisma client Extracts SSL config logic into apps/app/prisma/ssl-config.ts and updates the Prisma client to throw at boot when connecting to a non-local database without a verified CA bundle or explicit PRISMA_ALLOW_INSECURE_TLS=1 opt-in. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(db): expose resolveSslConfig via subpath export; dedupe in apps/app Add `./ssl-config` subpath export to @trycompai/db so apps/app (and upcoming portal/framework-editor) can import the single source of truth instead of maintaining their own copy. Widen the `env` parameter type from `NodeJS.ProcessEnv` to `Partial<NodeJS.ProcessEnv>` (strictly more permissive) to satisfy apps/app's strict TS config. Delete the duplicate apps/app/prisma/ssl-config.ts and its redundant test file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(portal): strict TLS gating in prisma client Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(framework-editor): strict TLS gating in prisma client Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(trigger): add caBundleExtension for verified-TLS Postgres Ships the RDS CA bundle (packages/db/certs/rds-global-bundle.pem) into Trigger.dev task images at /app/certs/rds-global-bundle.pem and sets NODE_EXTRA_CA_CERTS via the deploy.env layer so Node TLS initialization picks it up before any Prisma connection attempt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(prisma): inline TLS gating in app clients to avoid published-package dependency Drop `import { resolveSslConfig } from '@trycompai/db/ssl-config'` from apps/app, apps/portal, and apps/framework-editor and inline the full localhost/CA-bundle/PRISMA_ALLOW_INSECURE_TLS logic directly. Trigger.dev pins @trycompai/db@^2.0.0 from npm which lacks the ./ssl-config subpath, causing indexer crashes at deploy time. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(prisma): skip hostname check when CA bundle is set (NLB compatibility) AWS NLB → RDS Proxy connections fail TLS hostname verification because the NLB hostname (*.elb.amazonaws.com) isn't in the RDS Proxy cert's SAN list. Cert chain verification is preserved — an attacker still cannot present a forged or wrong-CA cert. Only the hostname-string check is relaxed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(vercel): bundle RDS CA cert with Next.js apps for verified TLS Add outputFileTracingIncludes to apps/app and apps/portal next.config.ts so the rds-global-bundle.pem is included in Vercel's traced file output for each deployed function. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: deploy checklist for verified-TLS env vars Documents the NODE_EXTRA_CA_CERTS values to set in Vercel (both candidate paths), the Trigger.dev PRISMA_ALLOW_INSECURE_TLS removal commands, and notes that API Docker needs no action. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(prisma): lazy-init client to prevent TLS throw during Next.js build next build imports every route handler to analyze it, which previously triggered our strict-TLS throw at module load even though no queries run. Wrap the client in a Proxy that constructs the real PrismaClient on first property access. The strict check still fires — just at first use, not at import. * fix(db): point ssl-config types at dist (src/ is not published) cubic flagged: the subpath export's types entry pointed at ./src/ssl-config.ts, but the published package's files array only includes dist/. Downstream npm consumers would get broken type resolution. Workspace consumers were unaffected because @trycompai/db resolves to source via workspace:*. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 46d7e83 commit 2bde7ad

17 files changed

Lines changed: 3195 additions & 43 deletions

apps/api/caBundleExtension.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { BuildContext, BuildExtension, BuildManifest } from '@trigger.dev/build';
2+
import { existsSync } from 'node:fs';
3+
import { cp, mkdir } from 'node:fs/promises';
4+
import { dirname, join, resolve } from 'node:path';
5+
6+
// Path relative to the monorepo root (apps/api or apps/app → ../../packages/db/certs/...)
7+
const BUNDLE_RELATIVE_FROM_APP = '../../packages/db/certs/rds-global-bundle.pem';
8+
const BUNDLE_DEST_REL = 'certs/rds-global-bundle.pem';
9+
10+
function findBundleSrc(workingDir: string): string | undefined {
11+
// Walk up from workingDir to find the cert — handles both normal checkouts and git worktrees
12+
// where workspaceDir points to the main worktree root (wrong for us).
13+
const candidates = [
14+
resolve(workingDir, BUNDLE_RELATIVE_FROM_APP),
15+
resolve(workingDir, '../packages/db/certs/rds-global-bundle.pem'),
16+
resolve(workingDir, 'packages/db/certs/rds-global-bundle.pem'),
17+
];
18+
19+
return candidates.find((c) => existsSync(c));
20+
}
21+
22+
export function caBundleExtension(): BuildExtension {
23+
return {
24+
name: 'CABundleExtension',
25+
onBuildStart: (context) => {
26+
// Real OS env var at task spawn time — verified flow:
27+
// addLayer.deploy.env → manifest.deploy.sync.env → syncEnvVarsWithServer →
28+
// taskRunProcessProvider injects into worker env before Node TLS init.
29+
context.addLayer({
30+
id: 'ca-bundle-env',
31+
deploy: {
32+
env: { NODE_EXTRA_CA_CERTS: `/app/${BUNDLE_DEST_REL}` },
33+
override: true,
34+
},
35+
});
36+
},
37+
onBuildComplete: async (context: BuildContext, manifest: BuildManifest) => {
38+
const src = findBundleSrc(context.workingDir);
39+
if (!src) {
40+
throw new Error(
41+
`CABundleExtension: rds-global-bundle.pem not found. Searched relative to ${context.workingDir}`,
42+
);
43+
}
44+
const dest = join(manifest.outputPath, BUNDLE_DEST_REL);
45+
await mkdir(dirname(dest), { recursive: true });
46+
await cp(src, dest);
47+
context.logger.log(`Copied RDS CA bundle to ${BUNDLE_DEST_REL}`);
48+
},
49+
};
50+
}

apps/api/prisma/client.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { PrismaClient } from '@prisma/client';
22
import { PrismaPg } from '@prisma/adapter-pg';
33

4-
const globalForPrisma = global as unknown as { prisma: PrismaClient };
4+
const globalForPrisma = global as unknown as { prisma?: PrismaClient };
55

66
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
77

@@ -40,11 +40,18 @@ function createPrismaClient(): PrismaClient {
4040
// silently downgrading.
4141
const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS;
4242
const allowInsecure = process.env.PRISMA_ALLOW_INSECURE_TLS === '1';
43-
let ssl: undefined | true | { rejectUnauthorized: false };
43+
let ssl:
44+
| undefined
45+
| { checkServerIdentity: () => undefined }
46+
| { rejectUnauthorized: false };
4447
if (isLocalhost) {
4548
ssl = undefined;
4649
} else if (hasCABundle) {
47-
ssl = true;
50+
// Verified TLS: rely on Node's TLS context (NODE_EXTRA_CA_CERTS adds the AWS
51+
// RDS CA to the trust store). Skip hostname check because connections may
52+
// traverse an AWS NLB whose hostname isn't in the RDS Proxy cert's SAN list.
53+
// The chain check still rejects forged or wrong-CA certs.
54+
ssl = { checkServerIdentity: () => undefined };
4855
} else if (allowInsecure) {
4956
ssl = { rejectUnauthorized: false };
5057
} else {
@@ -63,6 +70,21 @@ function createPrismaClient(): PrismaClient {
6370
});
6471
}
6572

66-
export const db = globalForPrisma.prisma || createPrismaClient();
73+
// Lazy initialization. Importing this module does NOT construct a Prisma client
74+
// — that only happens on first property access on `db`. Critical so that
75+
// Next.js `next build` (which imports every route handler to analyze it) does
76+
// not trigger the strict TLS check at build time when no actual queries run.
77+
function getClient(): PrismaClient {
78+
if (!globalForPrisma.prisma) {
79+
globalForPrisma.prisma = createPrismaClient();
80+
}
81+
return globalForPrisma.prisma;
82+
}
6783

68-
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
84+
export const db = new Proxy({} as PrismaClient, {
85+
get(_target, prop, _receiver) {
86+
const client = getClient();
87+
const value = Reflect.get(client, prop, client);
88+
return typeof value === 'function' ? value.bind(client) : value;
89+
},
90+
});

apps/api/trigger.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { defineConfig } from '@trigger.dev/sdk';
2+
import { caBundleExtension } from './caBundleExtension';
23
import { prismaExtension } from './customPrismaExtension';
34
import { emailExtension } from './emailExtension';
45
import { integrationPlatformExtension } from './integrationPlatformExtension';
@@ -10,6 +11,7 @@ export default defineConfig({
1011
maxDuration: 300, // 5 minutes
1112
build: {
1213
extensions: [
14+
caBundleExtension(),
1315
prismaExtension({
1416
version: '7.6.0',
1517
dbPackageVersion: '^2.0.0',

apps/app/caBundleExtension.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { BuildContext, BuildExtension, BuildManifest } from '@trigger.dev/build';
2+
import { existsSync } from 'node:fs';
3+
import { cp, mkdir } from 'node:fs/promises';
4+
import { dirname, join, resolve } from 'node:path';
5+
6+
// Path relative to the monorepo root (apps/api or apps/app → ../../packages/db/certs/...)
7+
const BUNDLE_RELATIVE_FROM_APP = '../../packages/db/certs/rds-global-bundle.pem';
8+
const BUNDLE_DEST_REL = 'certs/rds-global-bundle.pem';
9+
10+
function findBundleSrc(workingDir: string): string | undefined {
11+
// Walk up from workingDir to find the cert — handles both normal checkouts and git worktrees
12+
// where workspaceDir points to the main worktree root (wrong for us).
13+
const candidates = [
14+
resolve(workingDir, BUNDLE_RELATIVE_FROM_APP),
15+
resolve(workingDir, '../packages/db/certs/rds-global-bundle.pem'),
16+
resolve(workingDir, 'packages/db/certs/rds-global-bundle.pem'),
17+
];
18+
19+
return candidates.find((c) => existsSync(c));
20+
}
21+
22+
export function caBundleExtension(): BuildExtension {
23+
return {
24+
name: 'CABundleExtension',
25+
onBuildStart: (context) => {
26+
// Real OS env var at task spawn time — verified flow:
27+
// addLayer.deploy.env → manifest.deploy.sync.env → syncEnvVarsWithServer →
28+
// taskRunProcessProvider injects into worker env before Node TLS init.
29+
context.addLayer({
30+
id: 'ca-bundle-env',
31+
deploy: {
32+
env: { NODE_EXTRA_CA_CERTS: `/app/${BUNDLE_DEST_REL}` },
33+
override: true,
34+
},
35+
});
36+
},
37+
onBuildComplete: async (context: BuildContext, manifest: BuildManifest) => {
38+
const src = findBundleSrc(context.workingDir);
39+
if (!src) {
40+
throw new Error(
41+
`CABundleExtension: rds-global-bundle.pem not found. Searched relative to ${context.workingDir}`,
42+
);
43+
}
44+
const dest = join(manifest.outputPath, BUNDLE_DEST_REL);
45+
await mkdir(dirname(dest), { recursive: true });
46+
await cp(src, dest);
47+
context.logger.log(`Copied RDS CA bundle to ${BUNDLE_DEST_REL}`);
48+
},
49+
};
50+
}

apps/app/next.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ const config: NextConfig = {
7676
webpackMemoryOptimizations: true,
7777
},
7878
outputFileTracingRoot: workspaceRoot,
79+
outputFileTracingIncludes: {
80+
'/**/*': ['../../packages/db/certs/rds-global-bundle.pem'],
81+
},
7982

8083
// Reduce memory usage during production build
8184
productionBrowserSourceMaps: false,

apps/app/prisma/client.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,52 @@
11
import { PrismaClient } from '@prisma/client';
22
import { PrismaPg } from '@prisma/adapter-pg';
33

4-
const globalForPrisma = global as unknown as { prisma: PrismaClient };
4+
const globalForPrisma = global as unknown as { prisma?: PrismaClient };
5+
6+
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
57

68
function stripSslMode(connectionString: string): string {
79
const url = new URL(connectionString);
810
url.searchParams.delete('sslmode');
911
return url.toString();
1012
}
1113

14+
function isLocalhostUrl(connectionString: string): boolean {
15+
try {
16+
const { hostname } = new URL(connectionString);
17+
const stripped = hostname.replace(/^\[/, '').replace(/\]$/, '');
18+
return LOCAL_HOSTNAMES.has(stripped);
19+
} catch {
20+
return false;
21+
}
22+
}
23+
1224
function createPrismaClient(): PrismaClient {
1325
const rawUrl = process.env.DATABASE_URL!;
14-
const isLocalhost = /localhost|127\.0\.0\.1|::1/.test(rawUrl);
15-
// Use verified SSL when NODE_EXTRA_CA_CERTS is set (Docker with RDS CA bundle),
16-
// otherwise fall back to unverified SSL (Trigger.dev, Vercel, other environments).
26+
const isLocalhost = isLocalhostUrl(rawUrl);
1727
const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS;
18-
const ssl = isLocalhost ? undefined : hasCABundle ? true : { rejectUnauthorized: false };
19-
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
28+
const allowInsecure = process.env.PRISMA_ALLOW_INSECURE_TLS === '1';
29+
30+
let ssl:
31+
| undefined
32+
| { checkServerIdentity: () => undefined }
33+
| { rejectUnauthorized: false };
34+
if (isLocalhost) {
35+
ssl = undefined;
36+
} else if (hasCABundle) {
37+
// Verified TLS: rely on Node's TLS context (NODE_EXTRA_CA_CERTS adds the AWS
38+
// RDS CA to the trust store). Skip hostname check because connections may
39+
// traverse an AWS NLB whose hostname isn't in the RDS Proxy cert's SAN list.
40+
// The chain check still rejects forged or wrong-CA certs.
41+
ssl = { checkServerIdentity: () => undefined };
42+
} else if (allowInsecure) {
43+
ssl = { rejectUnauthorized: false };
44+
} else {
45+
throw new Error(
46+
'Refusing to connect to a non-local Postgres without TLS verification. Set NODE_EXTRA_CA_CERTS to a CA bundle, or set PRISMA_ALLOW_INSECURE_TLS=1 if you intentionally want unverified TLS.',
47+
);
48+
}
49+
2050
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
2151
const adapter = new PrismaPg({ connectionString: url, ssl });
2252
return new PrismaClient({
@@ -27,6 +57,21 @@ function createPrismaClient(): PrismaClient {
2757
});
2858
}
2959

30-
export const db = globalForPrisma.prisma || createPrismaClient();
60+
// Lazy initialization. Importing this module does NOT construct a Prisma client
61+
// — that only happens on first property access on `db`. Critical so that
62+
// Next.js `next build` (which imports every route handler to analyze it) does
63+
// not trigger the strict TLS check at build time when no actual queries run.
64+
function getClient(): PrismaClient {
65+
if (!globalForPrisma.prisma) {
66+
globalForPrisma.prisma = createPrismaClient();
67+
}
68+
return globalForPrisma.prisma;
69+
}
3170

32-
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
71+
export const db = new Proxy({} as PrismaClient, {
72+
get(_target, prop, _receiver) {
73+
const client = getClient();
74+
const value = Reflect.get(client, prop, client);
75+
return typeof value === 'function' ? value.bind(client) : value;
76+
},
77+
});

apps/app/trigger.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { puppeteer } from '@trigger.dev/build/extensions/puppeteer';
22
import { defineConfig } from '@trigger.dev/sdk';
3+
import { caBundleExtension } from './caBundleExtension';
34
import { prismaExtension } from './customPrismaExtension';
45

56
export default defineConfig({
@@ -14,6 +15,7 @@ export default defineConfig({
1415
maxDuration: 300, // 5 minutes
1516
build: {
1617
extensions: [
18+
caBundleExtension(),
1719
prismaExtension({
1820
version: '7.6.0',
1921
dbPackageVersion: '^2.0.0',

apps/app/vitest.config.mts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ export default defineConfig({
99
environment: 'jsdom',
1010
globals: true,
1111
setupFiles: ['./src/test-utils/setup.ts'],
12-
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
12+
include: [
13+
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
14+
'prisma/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
15+
],
1316
exclude: ['node_modules', 'dist', '.next', 'e2e'],
1417
coverage: {
1518
reporter: ['text', 'json', 'html'],
Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,52 @@
11
import { PrismaClient } from '@prisma/client';
22
import { PrismaPg } from '@prisma/adapter-pg';
33

4-
const globalForPrisma = global as unknown as { prisma: PrismaClient };
4+
const globalForPrisma = global as unknown as { prisma?: PrismaClient };
5+
6+
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
57

68
function stripSslMode(connectionString: string): string {
79
const url = new URL(connectionString);
810
url.searchParams.delete('sslmode');
911
return url.toString();
1012
}
1113

14+
function isLocalhostUrl(connectionString: string): boolean {
15+
try {
16+
const { hostname } = new URL(connectionString);
17+
const stripped = hostname.replace(/^\[/, '').replace(/\]$/, '');
18+
return LOCAL_HOSTNAMES.has(stripped);
19+
} catch {
20+
return false;
21+
}
22+
}
23+
1224
function createPrismaClient(): PrismaClient {
1325
const rawUrl = process.env.DATABASE_URL!;
14-
const isLocalhost = /localhost|127\.0\.0\.1|::1/.test(rawUrl);
15-
// Use verified SSL when NODE_EXTRA_CA_CERTS is set (Docker with RDS CA bundle),
16-
// otherwise fall back to unverified SSL (Trigger.dev, Vercel, other environments).
26+
const isLocalhost = isLocalhostUrl(rawUrl);
1727
const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS;
18-
const ssl = isLocalhost ? undefined : hasCABundle ? true : { rejectUnauthorized: false };
19-
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
28+
const allowInsecure = process.env.PRISMA_ALLOW_INSECURE_TLS === '1';
29+
30+
let ssl:
31+
| undefined
32+
| { checkServerIdentity: () => undefined }
33+
| { rejectUnauthorized: false };
34+
if (isLocalhost) {
35+
ssl = undefined;
36+
} else if (hasCABundle) {
37+
// Verified TLS: rely on Node's TLS context (NODE_EXTRA_CA_CERTS adds the AWS
38+
// RDS CA to the trust store). Skip hostname check because connections may
39+
// traverse an AWS NLB whose hostname isn't in the RDS Proxy cert's SAN list.
40+
// The chain check still rejects forged or wrong-CA certs.
41+
ssl = { checkServerIdentity: () => undefined };
42+
} else if (allowInsecure) {
43+
ssl = { rejectUnauthorized: false };
44+
} else {
45+
throw new Error(
46+
'Refusing to connect to a non-local Postgres without TLS verification. Set NODE_EXTRA_CA_CERTS to a CA bundle, or set PRISMA_ALLOW_INSECURE_TLS=1 if you intentionally want unverified TLS.',
47+
);
48+
}
49+
2050
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
2151
const adapter = new PrismaPg({ connectionString: url, ssl });
2252
return new PrismaClient({
@@ -27,6 +57,21 @@ function createPrismaClient(): PrismaClient {
2757
});
2858
}
2959

30-
export const db = globalForPrisma.prisma || createPrismaClient();
60+
// Lazy initialization. Importing this module does NOT construct a Prisma client
61+
// — that only happens on first property access on `db`. Critical so that
62+
// Next.js `next build` (which imports every route handler to analyze it) does
63+
// not trigger the strict TLS check at build time when no actual queries run.
64+
function getClient(): PrismaClient {
65+
if (!globalForPrisma.prisma) {
66+
globalForPrisma.prisma = createPrismaClient();
67+
}
68+
return globalForPrisma.prisma;
69+
}
3170

32-
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
71+
export const db = new Proxy({} as PrismaClient, {
72+
get(_target, prop, _receiver) {
73+
const client = getClient();
74+
const value = Reflect.get(client, prop, client);
75+
return typeof value === 'function' ? value.bind(client) : value;
76+
},
77+
});

apps/portal/next.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ const config = {
6565
},
6666
skipTrailingSlashRedirect: true,
6767
outputFileTracingRoot: path.join(__dirname, '../../'),
68+
outputFileTracingIncludes: {
69+
'/**/*': ['../../packages/db/certs/rds-global-bundle.pem'],
70+
},
6871
...(isStandalone
6972
? {
7073
output: 'standalone' as const,

0 commit comments

Comments
 (0)