Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0cd9952
chore: merge release v3.43.1 back to main [skip ci]
github-actions[bot] May 5, 2026
b80bcf0
Merge pull request #2753 from trycompai/mariano/fix-trust-framework-t…
github-actions[bot] May 5, 2026
05d87d4
fix(billing): surface wallet credits to pentest + bg-check UIs
tofikwest May 5, 2026
672e901
Merge branch 'main' into fix/billing-credit-wallet-ui-gating
tofikwest May 5, 2026
64813d9
Merge pull request #2755 from trycompai/fix/billing-credit-wallet-ui-…
tofikwest May 5, 2026
250a392
refactor(stripe): move upgrade-page auto-approval into API
tofikwest May 5, 2026
e42e6ef
fix(upgrade): keep self-hosted check on the page to avoid OSS regression
tofikwest May 5, 2026
17a8d92
Merge branch 'main' into fix/move-stripe-auto-approve-to-api
tofikwest May 5, 2026
8247ed3
Merge pull request #2756 from trycompai/fix/move-stripe-auto-approve-…
tofikwest May 5, 2026
1a97746
feat(risks): treatment plan as first-class + vendor AI widening + mat…
github-actions[bot] May 6, 2026
3c96a1d
fix(api): make submission endpoints accessible as an employee
github-actions[bot] May 6, 2026
46d7e83
fix(treatment-plan): cap linked-work lists and treatment plan body h…
github-actions[bot] May 6, 2026
2bde7ad
feat: verified-TLS to RDS from every runtime (#2761)
Marfuen May 6, 2026
9d5e2c2
[dev] [Marfuen] mariano/secure-rds-tls (#2762)
github-actions[bot] May 6, 2026
06e218b
[dev] [Marfuen] mariano/secure-rds-tls (#2763)
github-actions[bot] May 6, 2026
8a1c46f
fix(treatment-plan): cap linked-work lists and treatment plan body he…
github-actions[bot] May 6, 2026
e999c72
feat(vendors): refine inherent risk score after research lands postur…
github-actions[bot] May 6, 2026
2ab5b78
feat(integration-platform): remove code-based jumpcloud, route via DIP
tofikwest May 6, 2026
84da90c
feat(db): ship CA bundle with @trycompai/db, clean up debug routes (#…
Marfuen May 6, 2026
0775aaf
Merge branch 'main' into feat/remove-jumpcloud-code-based
tofikwest May 6, 2026
ed9561f
fix(api): correct the total number of active members from overview sc…
chasprowebdev May 6, 2026
f47eaf6
Merge pull request #2768 from trycompai/chas/people-total-number
tofikwest May 6, 2026
0a1016e
Merge branch 'main' into feat/remove-jumpcloud-code-based
tofikwest May 6, 2026
5008b0a
Merge pull request #2766 from trycompai/feat/remove-jumpcloud-code-based
tofikwest 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
8 changes: 8 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"trigger": {
"command": "bunx",
"args": ["trigger.dev@latest", "mcp"]
}
}
}
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}`);
},
};
}
62 changes: 60 additions & 2 deletions apps/api/prisma/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,66 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.db = void 0;
const client_1 = require("@prisma/client");
const adapter_pg_1 = require("@prisma/adapter-pg");
const globalForPrisma = global;
exports.db = globalForPrisma.prisma || new client_1.PrismaClient();
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
function stripSslMode(connectionString) {
const url = new URL(connectionString);
url.searchParams.delete('sslmode');
return url.toString();
}
function isLocalhostUrl(connectionString) {
try {
const { hostname } = new URL(connectionString);
// Strip square brackets from IPv6 host form (e.g. [::1] → ::1)
const stripped = hostname.replace(/^\[/, '').replace(/\]$/, '');
return LOCAL_HOSTNAMES.has(stripped);
}
catch {
// Malformed URL — be conservative and treat as remote so we don't
// accidentally disable TLS verification.
return false;
}
}
function createPrismaClient() {
const rawUrl = process.env.DATABASE_URL;
const isLocalhost = isLocalhostUrl(rawUrl);
// Strategy:
// - Localhost: TLS off (typical dev Postgres has no cert).
// - Remote with NODE_EXTRA_CA_CERTS set: verified TLS using that bundle
// (e.g. Docker with the RDS CA bundle baked in).
// - Remote in explicit opt-out mode (PRISMA_ALLOW_INSECURE_TLS=1):
// unverified TLS — used by Trigger.dev / Vercel envs that connect via
// a tunneled proxy whose cert can't be pinned. Must be set deliberately;
// the previous default ("just turn off verification") silently exposed
// prod connections to MITM. (Cubic finding #1 on PR #2671.)
// - Remote with neither: throw at boot — surface the misconfig instead of
// silently downgrading.
const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS;
const allowInsecure = process.env.PRISMA_ALLOW_INSECURE_TLS === '1';
let ssl;
if (isLocalhost) {
ssl = undefined;
}
else if (hasCABundle) {
ssl = true;
}
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.');
}
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
const adapter = new adapter_pg_1.PrismaPg({ connectionString: url, ssl });
return new client_1.PrismaClient({
adapter,
transactionOptions: {
timeout: 60000,
},
});
}
exports.db = globalForPrisma.prisma || createPrismaClient();
if (process.env.NODE_ENV !== 'production')
globalForPrisma.prisma = exports.db;
//# sourceMappingURL=client.js.map
72 changes: 65 additions & 7 deletions apps/api/prisma/client.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,64 @@
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);
// Strip square brackets from IPv6 host form (e.g. [::1] → ::1)
const stripped = hostname.replace(/^\[/, '').replace(/\]$/, '');
return LOCAL_HOSTNAMES.has(stripped);
} catch {
// Malformed URL — be conservative and treat as remote so we don't
// accidentally disable TLS verification.
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);
// Strategy:
// - Localhost: TLS off (typical dev Postgres has no cert).
// - Remote with NODE_EXTRA_CA_CERTS set: verified TLS using that bundle
// (e.g. Docker with the RDS CA bundle baked in).
// - Remote in explicit opt-out mode (PRISMA_ALLOW_INSECURE_TLS=1):
// unverified TLS — used by Trigger.dev / Vercel envs that connect via
// a tunneled proxy whose cert can't be pinned. Must be set deliberately;
// the previous default ("just turn off verification") silently exposed
// prod connections to MITM. (Cubic finding #1 on PR #2671.)
// - Remote with neither: throw at boot — surface the misconfig instead of
// silently downgrading.
const hasCABundle = !!process.env.NODE_EXTRA_CA_CERTS;
const ssl = isLocalhost ? undefined : hasCABundle ? true : { rejectUnauthorized: false };
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.',
);
}
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
const adapter = new PrismaPg({ connectionString: url, ssl });
Expand All @@ -27,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;
},
});
1 change: 0 additions & 1 deletion apps/api/prisma/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,3 @@ exports.db = void 0;
__exportStar(require("@prisma/client"), exports);
var client_1 = require("./client");
Object.defineProperty(exports, "db", { enumerable: true, get: function () { return client_1.db; } });
//# sourceMappingURL=index.js.map
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { awsConfig } from './config/aws.config';
import { betterAuthConfig } from './config/better-auth.config';
import { HealthModule } from './health/health.module';
import { OrganizationModule } from './organization/organization.module';
import { OrganizationAccessModule } from './organization-access/organization-access.module';
import { PoliciesModule } from './policies/policies.module';
import { RisksModule } from './risks/risks.module';
import { TasksModule } from './tasks/tasks.module';
Expand Down Expand Up @@ -74,6 +75,7 @@ import { BillingModule } from './billing/billing.module';
]),
AuthModule,
OrganizationModule,
OrganizationAccessModule,
PeopleModule,
RisksModule,
VendorsModule,
Expand Down
13 changes: 7 additions & 6 deletions apps/api/src/auth/permission.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,12 +187,13 @@ export class PermissionGuard implements CanActivate {
// the schema rejects every request with `[body] Invalid input`, the
// catch in canActivate turns that into a generic "Unable to verify
// permissions" 403, and EVERY cookie-authenticated request returns 403.
// Reproduced repo-side via `bun run zod-repro.mjs`. Discovered on
// ENG-221 and the same fix applies here.
const result = await auth.api.hasPermission({
headers,
body: { permissions, permission: undefined },
});
//
// Spell the body out via a separate variable so TypeScript's excess-
// property check (only applied to fresh object literals) doesn't
// reject the extra `permission` key — the runtime accepts the wider
// shape per the union schema.
const body = { permissions, permission: undefined };
const result = await auth.api.hasPermission({ headers, body });

return result.success === true;
}
Expand Down
81 changes: 81 additions & 0 deletions apps/api/src/billing/billing.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,87 @@ describe('BillingService', () => {
);
});

it('aggregates wallet credit balances per product in getStatus', async () => {
// The customer-facing /v1/billing/status response is the only
// surface the pentest + BG-check UIs read from. If we ever stop
// including creditBalances here, both UIs silently regress to
// paywalling users whose admin-granted credits would be consumed
// by the create endpoint. Lock the contract with this test.
const listBalances = jest.fn().mockResolvedValue([
{
id: 'bcb_1',
productKey: 'pentest',
skuKey: null,
balance: 3,
totalGranted: 5,
totalConsumed: 2,
totalRefunded: 0,
lastSource: 'manual',
updatedAt: '2026-05-01T00:00:00.000Z',
},
{
id: 'bcb_2',
productKey: 'pentest',
skuKey: 'pentest_monthly_1',
balance: 2,
totalGranted: 2,
totalConsumed: 0,
totalRefunded: 0,
lastSource: 'topup',
updatedAt: '2026-05-01T00:00:00.000Z',
},
{
id: 'bcb_3',
productKey: 'background_check',
skuKey: null,
balance: 4,
totalGranted: 4,
totalConsumed: 0,
totalRefunded: 0,
lastSource: 'manual',
updatedAt: '2026-05-01T00:00:00.000Z',
},
]);
const service = new BillingService(
mockStripeService({
invoices: { list: jest.fn().mockResolvedValue({ data: [] }) },
customers: { retrieve: jest.fn().mockResolvedValue({}) },
paymentMethods: { retrieve: jest.fn() },
}),
{ syncSubscriptionItem: jest.fn() } as never,
{ listBalances } as never,
);

const result = await service.getStatus('org_1');

expect(listBalances).toHaveBeenCalledWith('org_1');
expect(result.creditBalances).toEqual(
expect.arrayContaining([
{ productKey: 'pentest', balance: 5 },
{ productKey: 'background_check', balance: 4 },
]),
);
expect(result.creditBalances).toHaveLength(2);
});

it('returns an empty creditBalances array when no credits service is wired in', async () => {
// BillingCreditsService is @Optional() so unit tests can keep
// hand-constructing BillingService without it. Verify the absent-
// dependency branch produces a typesafe empty array, not undefined.
const service = new BillingService(
mockStripeService({
invoices: { list: jest.fn().mockResolvedValue({ data: [] }) },
customers: { retrieve: jest.fn().mockResolvedValue({}) },
paymentMethods: { retrieve: jest.fn() },
}),
{ syncSubscriptionItem: jest.fn() } as never,
);

const result = await service.getStatus('org_1');

expect(result.creditBalances).toEqual([]);
});

it('marks trial eligibility false after any product subscription history', async () => {
organizationBillingSubscriptionFindMany.mockResolvedValue([
{
Expand Down
Loading
Loading