Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e577b94
chore(db): commit AWS RDS global CA bundle for verified TLS
Marfuen May 6, 2026
dabc3cc
feat(db): strict TLS gating in shared prisma client
Marfuen May 6, 2026
ad1e07f
refactor(db): extract resolveSslConfig and use bun:test for consistency
Marfuen May 6, 2026
4259bc3
feat(app): strict TLS gating in app prisma client
Marfuen May 6, 2026
641fc4b
refactor(db): expose resolveSslConfig via subpath export; dedupe in a…
Marfuen May 6, 2026
4c5d75b
feat(portal): strict TLS gating in prisma client
Marfuen May 6, 2026
6db369c
feat(framework-editor): strict TLS gating in prisma client
Marfuen May 6, 2026
1a8557d
feat(trigger): add caBundleExtension for verified-TLS Postgres
Marfuen May 6, 2026
23db526
fix(prisma): inline TLS gating in app clients to avoid published-pack…
Marfuen May 6, 2026
ca3b0f5
fix(prisma): skip hostname check when CA bundle is set (NLB compatibi…
Marfuen May 6, 2026
7558777
feat(vercel): bundle RDS CA cert with Next.js apps for verified TLS
Marfuen May 6, 2026
00e0a3f
docs: deploy checklist for verified-TLS env vars
Marfuen May 6, 2026
55dd766
fix(prisma): lazy-init client to prevent TLS throw during Next.js build
Marfuen May 6, 2026
60a4452
fix(db): point ssl-config types at dist (src/ is not published)
Marfuen May 6, 2026
31f8e1a
chore: temporary debug endpoint to verify Vercel cert path
Marfuen May 6, 2026
74bde8b
chore: rename debug-tls route (avoid Next.js private-folder rule)
Marfuen May 6, 2026
915a33a
chore: remove debug-tls route after verifying Vercel cert path
Marfuen May 6, 2026
b2c82b9
feat(db): ship RDS CA bundle in published @trycompai/db package
Marfuen May 6, 2026
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
50 changes: 50 additions & 0 deletions apps/api/caBundleExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { BuildContext, BuildExtension, BuildManifest } from '@trigger.dev/build';
import { existsSync } from 'node:fs';
import { cp, mkdir } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';

// Path relative to the monorepo root (apps/api or apps/app → ../../packages/db/certs/...)
const BUNDLE_RELATIVE_FROM_APP = '../../packages/db/certs/rds-global-bundle.pem';
const BUNDLE_DEST_REL = 'certs/rds-global-bundle.pem';

function findBundleSrc(workingDir: string): string | undefined {
// Walk up from workingDir to find the cert — handles both normal checkouts and git worktrees
// where workspaceDir points to the main worktree root (wrong for us).
const candidates = [
resolve(workingDir, BUNDLE_RELATIVE_FROM_APP),
resolve(workingDir, '../packages/db/certs/rds-global-bundle.pem'),
resolve(workingDir, 'packages/db/certs/rds-global-bundle.pem'),
];

return candidates.find((c) => existsSync(c));
}

export function caBundleExtension(): BuildExtension {
return {
name: 'CABundleExtension',
onBuildStart: (context) => {
// Real OS env var at task spawn time — verified flow:
// addLayer.deploy.env → manifest.deploy.sync.env → syncEnvVarsWithServer →
// taskRunProcessProvider injects into worker env before Node TLS init.
context.addLayer({
id: 'ca-bundle-env',
deploy: {
env: { NODE_EXTRA_CA_CERTS: `/app/${BUNDLE_DEST_REL}` },
override: true,
},
});
},
onBuildComplete: async (context: BuildContext, manifest: BuildManifest) => {
const src = findBundleSrc(context.workingDir);
if (!src) {
throw new Error(
`CABundleExtension: rds-global-bundle.pem not found. Searched relative to ${context.workingDir}`,
);
}
const dest = join(manifest.outputPath, BUNDLE_DEST_REL);
await mkdir(dirname(dest), { recursive: true });
await cp(src, dest);
context.logger.log(`Copied RDS CA bundle to ${BUNDLE_DEST_REL}`);
},
};
}
32 changes: 27 additions & 5 deletions apps/api/prisma/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';

const globalForPrisma = global as unknown as { prisma: PrismaClient };
const globalForPrisma = global as unknown as { prisma?: PrismaClient };

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

Expand Down Expand Up @@ -40,11 +40,18 @@ function createPrismaClient(): PrismaClient {
// silently downgrading.
const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS;
const allowInsecure = process.env.PRISMA_ALLOW_INSECURE_TLS === '1';
let ssl: undefined | true | { rejectUnauthorized: false };
let ssl:
| undefined
| { checkServerIdentity: () => undefined }
| { rejectUnauthorized: false };
if (isLocalhost) {
ssl = undefined;
} else if (hasCABundle) {
ssl = true;
// Verified TLS: rely on Node's TLS context (NODE_EXTRA_CA_CERTS adds the AWS
// RDS CA to the trust store). Skip hostname check because connections may
// traverse an AWS NLB whose hostname isn't in the RDS Proxy cert's SAN list.
// The chain check still rejects forged or wrong-CA certs.
ssl = { checkServerIdentity: () => undefined };
} else if (allowInsecure) {
ssl = { rejectUnauthorized: false };
} else {
Expand All @@ -63,6 +70,21 @@ function createPrismaClient(): PrismaClient {
});
}

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

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
export const db = new Proxy({} as PrismaClient, {
get(_target, prop, _receiver) {
const client = getClient();
const value = Reflect.get(client, prop, client);
return typeof value === 'function' ? value.bind(client) : value;
},
});
2 changes: 2 additions & 0 deletions apps/api/trigger.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineConfig } from '@trigger.dev/sdk';
import { caBundleExtension } from './caBundleExtension';
import { prismaExtension } from './customPrismaExtension';
import { emailExtension } from './emailExtension';
import { integrationPlatformExtension } from './integrationPlatformExtension';
Expand All @@ -10,6 +11,7 @@ export default defineConfig({
maxDuration: 300, // 5 minutes
build: {
extensions: [
caBundleExtension(),
prismaExtension({
version: '7.6.0',
dbPackageVersion: '^2.0.0',
Expand Down
50 changes: 50 additions & 0 deletions apps/app/caBundleExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { BuildContext, BuildExtension, BuildManifest } from '@trigger.dev/build';
import { existsSync } from 'node:fs';
import { cp, mkdir } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';

// Path relative to the monorepo root (apps/api or apps/app → ../../packages/db/certs/...)
const BUNDLE_RELATIVE_FROM_APP = '../../packages/db/certs/rds-global-bundle.pem';
const BUNDLE_DEST_REL = 'certs/rds-global-bundle.pem';

function findBundleSrc(workingDir: string): string | undefined {
// Walk up from workingDir to find the cert — handles both normal checkouts and git worktrees
// where workspaceDir points to the main worktree root (wrong for us).
const candidates = [
resolve(workingDir, BUNDLE_RELATIVE_FROM_APP),
resolve(workingDir, '../packages/db/certs/rds-global-bundle.pem'),
resolve(workingDir, 'packages/db/certs/rds-global-bundle.pem'),
];

return candidates.find((c) => existsSync(c));
}

export function caBundleExtension(): BuildExtension {
return {
name: 'CABundleExtension',
onBuildStart: (context) => {
// Real OS env var at task spawn time — verified flow:
// addLayer.deploy.env → manifest.deploy.sync.env → syncEnvVarsWithServer →
// taskRunProcessProvider injects into worker env before Node TLS init.
context.addLayer({
id: 'ca-bundle-env',
deploy: {
env: { NODE_EXTRA_CA_CERTS: `/app/${BUNDLE_DEST_REL}` },
override: true,
},
});
},
onBuildComplete: async (context: BuildContext, manifest: BuildManifest) => {
const src = findBundleSrc(context.workingDir);
if (!src) {
throw new Error(
`CABundleExtension: rds-global-bundle.pem not found. Searched relative to ${context.workingDir}`,
);
}
const dest = join(manifest.outputPath, BUNDLE_DEST_REL);
await mkdir(dirname(dest), { recursive: true });
await cp(src, dest);
context.logger.log(`Copied RDS CA bundle to ${BUNDLE_DEST_REL}`);
},
};
}
3 changes: 3 additions & 0 deletions apps/app/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ const config: NextConfig = {
webpackMemoryOptimizations: true,
},
outputFileTracingRoot: workspaceRoot,
outputFileTracingIncludes: {
'/**/*': ['../../packages/db/certs/rds-global-bundle.pem'],
},

// Reduce memory usage during production build
productionBrowserSourceMaps: false,
Expand Down
61 changes: 53 additions & 8 deletions apps/app/prisma/client.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,52 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';

const globalForPrisma = global as unknown as { prisma: PrismaClient };
const globalForPrisma = global as unknown as { prisma?: PrismaClient };

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

function stripSslMode(connectionString: string): string {
const url = new URL(connectionString);
url.searchParams.delete('sslmode');
return url.toString();
}

function isLocalhostUrl(connectionString: string): boolean {
try {
const { hostname } = new URL(connectionString);
const stripped = hostname.replace(/^\[/, '').replace(/\]$/, '');
return LOCAL_HOSTNAMES.has(stripped);
} catch {
return false;
}
}

function createPrismaClient(): PrismaClient {
const rawUrl = process.env.DATABASE_URL!;
const isLocalhost = /localhost|127\.0\.0\.1|::1/.test(rawUrl);
// Use verified SSL when NODE_EXTRA_CA_CERTS is set (Docker with RDS CA bundle),
// otherwise fall back to unverified SSL (Trigger.dev, Vercel, other environments).
const isLocalhost = isLocalhostUrl(rawUrl);
const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS;
const ssl = isLocalhost ? undefined : hasCABundle ? true : { rejectUnauthorized: false };
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
const allowInsecure = process.env.PRISMA_ALLOW_INSECURE_TLS === '1';

let ssl:
| undefined
| { checkServerIdentity: () => undefined }
| { rejectUnauthorized: false };
if (isLocalhost) {
ssl = undefined;
} else if (hasCABundle) {
// Verified TLS: rely on Node's TLS context (NODE_EXTRA_CA_CERTS adds the AWS
// RDS CA to the trust store). Skip hostname check because connections may
// traverse an AWS NLB whose hostname isn't in the RDS Proxy cert's SAN list.
// The chain check still rejects forged or wrong-CA certs.
ssl = { checkServerIdentity: () => undefined };
} else if (allowInsecure) {
ssl = { rejectUnauthorized: false };
} else {
throw new Error(
'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.',
);
}

const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
const adapter = new PrismaPg({ connectionString: url, ssl });
return new PrismaClient({
Expand All @@ -27,6 +57,21 @@ function createPrismaClient(): PrismaClient {
});
}

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

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
export const db = new Proxy({} as PrismaClient, {
get(_target, prop, _receiver) {
const client = getClient();
const value = Reflect.get(client, prop, client);
return typeof value === 'function' ? value.bind(client) : value;
},
});
2 changes: 2 additions & 0 deletions apps/app/trigger.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { puppeteer } from '@trigger.dev/build/extensions/puppeteer';
import { defineConfig } from '@trigger.dev/sdk';
import { caBundleExtension } from './caBundleExtension';
import { prismaExtension } from './customPrismaExtension';

export default defineConfig({
Expand All @@ -14,6 +15,7 @@ export default defineConfig({
maxDuration: 300, // 5 minutes
build: {
extensions: [
caBundleExtension(),
prismaExtension({
version: '7.6.0',
dbPackageVersion: '^2.0.0',
Expand Down
5 changes: 4 additions & 1 deletion apps/app/vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-utils/setup.ts'],
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
include: [
'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'prisma/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
],
exclude: ['node_modules', 'dist', '.next', 'e2e'],
coverage: {
reporter: ['text', 'json', 'html'],
Expand Down
61 changes: 53 additions & 8 deletions apps/framework-editor/prisma/client.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,52 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';

const globalForPrisma = global as unknown as { prisma: PrismaClient };
const globalForPrisma = global as unknown as { prisma?: PrismaClient };

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

function stripSslMode(connectionString: string): string {
const url = new URL(connectionString);
url.searchParams.delete('sslmode');
return url.toString();
}

function isLocalhostUrl(connectionString: string): boolean {
try {
const { hostname } = new URL(connectionString);
const stripped = hostname.replace(/^\[/, '').replace(/\]$/, '');
return LOCAL_HOSTNAMES.has(stripped);
} catch {
return false;
}
}

function createPrismaClient(): PrismaClient {
const rawUrl = process.env.DATABASE_URL!;
const isLocalhost = /localhost|127\.0\.0\.1|::1/.test(rawUrl);
// Use verified SSL when NODE_EXTRA_CA_CERTS is set (Docker with RDS CA bundle),
// otherwise fall back to unverified SSL (Trigger.dev, Vercel, other environments).
const isLocalhost = isLocalhostUrl(rawUrl);
const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS;
const ssl = isLocalhost ? undefined : hasCABundle ? true : { rejectUnauthorized: false };
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
const allowInsecure = process.env.PRISMA_ALLOW_INSECURE_TLS === '1';

let ssl:
| undefined
| { checkServerIdentity: () => undefined }
| { rejectUnauthorized: false };
if (isLocalhost) {
ssl = undefined;
} else if (hasCABundle) {
// Verified TLS: rely on Node's TLS context (NODE_EXTRA_CA_CERTS adds the AWS
// RDS CA to the trust store). Skip hostname check because connections may
// traverse an AWS NLB whose hostname isn't in the RDS Proxy cert's SAN list.
// The chain check still rejects forged or wrong-CA certs.
ssl = { checkServerIdentity: () => undefined };
} else if (allowInsecure) {
ssl = { rejectUnauthorized: false };
} else {
throw new Error(
'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.',
);
}

const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
const adapter = new PrismaPg({ connectionString: url, ssl });
return new PrismaClient({
Expand All @@ -27,6 +57,21 @@ function createPrismaClient(): PrismaClient {
});
}

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

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
export const db = new Proxy({} as PrismaClient, {
get(_target, prop, _receiver) {
const client = getClient();
const value = Reflect.get(client, prop, client);
return typeof value === 'function' ? value.bind(client) : value;
},
});
3 changes: 3 additions & 0 deletions apps/portal/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ const config = {
},
skipTrailingSlashRedirect: true,
outputFileTracingRoot: path.join(__dirname, '../../'),
outputFileTracingIncludes: {
'/**/*': ['../../packages/db/certs/rds-global-bundle.pem'],
},
...(isStandalone
? {
output: 'standalone' as const,
Expand Down
Loading
Loading