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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,8 @@ scripts/sync-release-branch.sh
# from .github/workflows/ at the repo root. We move them up. See
# .github/workflows/sdk_generation.yaml + sdk_publish.yaml (the real ones).
apps/mcp-server/.github/

# Ad-hoc read-only prod ops scripts (contain DB creds) + any exported customer data.
# These must never be committed. Unanchored so nested paths are covered too.
dbq_*.js
*.csv
62 changes: 62 additions & 0 deletions apps/api/src/trigger/integration-platform/dynamic-provider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Mock @db so importing the helper doesn't open a Postgres connection.
const findUnique = jest.fn();
jest.mock('@db', () => ({
db: { dynamicIntegration: { findUnique: (...args: unknown[]) => findUnique(...args) } },
}));

import { isActiveDynamicProvider, shouldRunOnServer } from './dynamic-provider';

describe('shouldRunOnServer', () => {
it('always delegates AWS (VPC egress), with or without a manifest', () => {
expect(
shouldRunOnServer({ providerSlug: 'aws', hasManifest: true, isActiveDynamic: false }),
).toBe(true);
expect(
shouldRunOnServer({ providerSlug: 'aws', hasManifest: false, isActiveDynamic: false }),
).toBe(true);
});

it('runs static providers (manifest present) in-process', () => {
expect(
shouldRunOnServer({ providerSlug: 'github', hasManifest: true, isActiveDynamic: false }),
).toBe(false);
// Even if a dynamic row somehow also exists, a present manifest wins (static).
expect(
shouldRunOnServer({ providerSlug: 'vercel', hasManifest: true, isActiveDynamic: true }),
).toBe(false);
});

it('delegates active dynamic providers (no local manifest) to the server', () => {
expect(
shouldRunOnServer({ providerSlug: 'keeper-security', hasManifest: false, isActiveDynamic: true }),
).toBe(true);
expect(
shouldRunOnServer({ providerSlug: 'supabase', hasManifest: false, isActiveDynamic: true }),
).toBe(true);
});

it('does NOT delegate an unknown provider (no manifest, not dynamic)', () => {
expect(
shouldRunOnServer({ providerSlug: 'deleted-thing', hasManifest: false, isActiveDynamic: false }),
).toBe(false);
});
});

describe('isActiveDynamicProvider', () => {
beforeEach(() => findUnique.mockReset());

it('is true only for an active dynamic integration row', async () => {
findUnique.mockResolvedValue({ isActive: true });
await expect(isActiveDynamicProvider('keeper-security')).resolves.toBe(true);
});

it('is false for an inactive dynamic integration', async () => {
findUnique.mockResolvedValue({ isActive: false });
await expect(isActiveDynamicProvider('paused-thing')).resolves.toBe(false);
});

it('is false when no dynamic row exists (static or unknown slug)', async () => {
findUnique.mockResolvedValue(null);
await expect(isActiveDynamicProvider('github')).resolves.toBe(false);
});
});
48 changes: 48 additions & 0 deletions apps/api/src/trigger/integration-platform/dynamic-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { db } from '@db';

/**
* Dynamic (DB-backed) integrations are NOT present in the Trigger.dev runtime's
* manifest registry: the registry singleton is seeded only with the static code
* manifests, and the loader that merges DB-backed manifests in
* (`DynamicManifestLoaderService`) is a NestJS lifecycle service that never runs
* in the Trigger.dev process. So `getManifest(slug)` returns `undefined` here
* for dynamic providers, and their checks cannot execute in-process.
*
* The fix (mirroring AWS) is to run those checks ON OUR SERVER, where the loader
* HAS populated the registry — see `runChecksOnServer` and the internal endpoint
* it calls. These helpers decide when to delegate.
*/

/** True when `slug` is an active DB-backed (dynamic) integration. */
export async function isActiveDynamicProvider(slug: string): Promise<boolean> {
const row = await db.dynamicIntegration.findUnique({
where: { slug },
select: { isActive: true },
});
return row?.isActive === true;
}

/**
* Whether a provider's checks must run on the API server instead of in the
* Trigger.dev runtime.
*
* - AWS → always on the server (its S3 calls must egress our VPC, not
* Trigger.dev's, whose endpoint policy blocks the cross-account read).
* - Has a manifest here → static code integration → run in-process (unchanged).
* - No manifest but an active dynamic integration → on the server (the manifest
* only exists in the API process).
* - No manifest and not dynamic → unknown provider → do NOT delegate (the caller
* surfaces "manifest not found" instead of sending a doomed request).
*
* Pure so it can be unit-tested without the DB.
*/
export function shouldRunOnServer(params: {
providerSlug: string;
hasManifest: boolean;
isActiveDynamic: boolean;
}): boolean {
const { providerSlug, hasManifest, isActiveDynamic } = params;
if (providerSlug === 'aws') return true;
if (hasManifest) return false;
return isActiveDynamic;
}
152 changes: 92 additions & 60 deletions apps/api/src/trigger/integration-platform/run-connection-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import {
requestValidCredentials,
type IntegrationCredentialValues,
} from './ensure-valid-credentials';
import { runChecksOnServer } from './run-checks-on-server';
import {
runChecksOnServer,
type RunAllChecksResult,
} from './run-checks-on-server';
import {
isActiveDynamicProvider,
shouldRunOnServer,
} from './dynamic-provider';

/**
* Trigger task that runs all checks for a connection.
Expand Down Expand Up @@ -37,12 +44,24 @@ export const runConnectionChecks = task({

const manifest = getManifest(providerSlug);

if (!manifest) {
// Dynamic (DB-backed) providers have no manifest in the Trigger.dev runtime,
// so run their checks ON OUR SERVER (like AWS), where the dynamic-manifest
// loader has populated the registry. Static providers keep running here.
const isDynamic = manifest
? false
: await isActiveDynamicProvider(providerSlug);
const runOnServer = shouldRunOnServer({
providerSlug,
hasManifest: !!manifest,
isActiveDynamic: isDynamic,
});

if (!manifest && !runOnServer) {
logger.error(`Manifest not found for provider: ${providerSlug}`);
return { success: false, error: `Manifest not found: ${providerSlug}` };
}

if (!manifest.checks || manifest.checks.length === 0) {
if (manifest && (!manifest.checks || manifest.checks.length === 0)) {
logger.info(`No checks defined for provider: ${providerSlug}`);
return { success: true, reason: 'No checks defined' };
}
Expand All @@ -57,53 +76,59 @@ export const runConnectionChecks = task({
return { success: false, error: 'Connection not found or inactive' };
}

// Check if all required variables are configured
const requiredVariables = new Set<string>();
for (const check of manifest.checks) {
if (check.variables) {
for (const variable of check.variables) {
if (variable.required) {
requiredVariables.add(variable.id);
// Check if all required variables are configured. Only possible for
// in-process (static) providers — for server-delegated dynamic providers the
// manifest (and thus its variable definitions) isn't available here, so the
// server runs every check and reports any that are unconfigured as results.
if (manifest) {
const requiredVariables = new Set<string>();
for (const check of manifest.checks ?? []) {
if (check.variables) {
for (const variable of check.variables) {
if (variable.required) {
requiredVariables.add(variable.id);
}
}
}
}
}

const configuredVariables =
(connection.variables as Record<string, unknown>) || {};
const missingVariables: string[] = [];
const configuredVariables =
(connection.variables as Record<string, unknown>) || {};
const missingVariables: string[] = [];

for (const requiredVar of requiredVariables) {
const value = configuredVariables[requiredVar];
if (value === undefined || value === null || value === '') {
missingVariables.push(requiredVar);
}
if (Array.isArray(value) && value.length === 0) {
missingVariables.push(requiredVar);
for (const requiredVar of requiredVariables) {
const value = configuredVariables[requiredVar];
if (value === undefined || value === null || value === '') {
missingVariables.push(requiredVar);
}
if (Array.isArray(value) && value.length === 0) {
missingVariables.push(requiredVar);
}
}
}

if (missingVariables.length > 0) {
logger.info(
`Skipping auto-run: missing required variables: ${missingVariables.join(', ')}`,
);
return {
success: true,
reason: `Missing required variables: ${missingVariables.join(', ')}`,
};
if (missingVariables.length > 0) {
logger.info(
`Skipping auto-run: missing required variables: ${missingVariables.join(', ')}`,
);
return {
success: true,
reason: `Missing required variables: ${missingVariables.join(', ')}`,
};
}
}

const apiUrl = process.env.BASE_URL || 'http://localhost:3333';

// AWS checks run ON OUR SERVER (see below), which decrypts the credentials
// and assumes the cross-account role there. Skip the Trigger-side credential
// preflight for AWS — running it would add redundant failure points (a
// transient preflight error would falsely fail an AWS run that
// `runChecksOnServer` could have completed).
// Server-delegated checks (AWS + dynamic providers) decrypt credentials and
// run ON OUR SERVER, so the Trigger-side credential preflight is skipped for
// them — running it would add redundant failure points (a transient
// preflight error would falsely fail a run that `runChecksOnServer` could
// have completed). The `&& manifest` is a no-op at runtime (a non-delegated
// provider always has a manifest by here) that narrows the type below.
let credentials: IntegrationCredentialValues = {};
let handleTokenRefresh: (() => Promise<string | null>) | undefined;

if (providerSlug !== 'aws') {
if (!runOnServer && manifest) {
logger.info('Ensuring valid credentials...');
const credentialsResult = await requestValidCredentials({
apiUrl,
Expand Down Expand Up @@ -177,30 +202,37 @@ export const runConnectionChecks = task({
let totalPassing = 0;

try {
// AWS checks run ON OUR SERVER so their S3 calls egress our VPC (allowed)
// instead of Trigger.dev's (blocked). Every other provider keeps running
// here in the Trigger.dev runtime, unchanged. Same result shape either
// way, so the persistence below is shared.
const result =
providerSlug === 'aws'
? await runChecksOnServer({ apiUrl, connectionId, organizationId })
: await runAllChecks({
manifest,
accessToken: getAccessToken(credentials),
credentials,
variables,
connectionId,
organizationId,
onTokenRefresh:
manifest.auth.type === 'oauth2'
? handleTokenRefresh
: undefined,
logger: {
info: (msg, data) => logger.info(msg, data),
warn: (msg, data) => logger.warn(msg, data),
error: (msg, data) => logger.error(msg, data),
},
});
// Server-delegated providers (AWS + dynamic) run ON OUR SERVER so their
// checks egress our VPC / resolve their DB-backed manifest there. Static
// providers keep running here in the Trigger.dev runtime, unchanged. Same
// result shape either way, so the persistence below is shared.
let result: RunAllChecksResult;
if (runOnServer) {
result = await runChecksOnServer({
apiUrl,
connectionId,
organizationId,
});
} else if (manifest) {
result = await runAllChecks({
manifest,
accessToken: getAccessToken(credentials),
credentials,
variables,
connectionId,
organizationId,
onTokenRefresh:
manifest.auth.type === 'oauth2' ? handleTokenRefresh : undefined,
logger: {
info: (msg, data) => logger.info(msg, data),
warn: (msg, data) => logger.warn(msg, data),
error: (msg, data) => logger.error(msg, data),
},
});
} else {
// Unreachable: guarded at the top (no manifest ⇒ runOnServer).
throw new Error(`Manifest not found: ${providerSlug}`);
}

totalFindings = result.totalFindings;
totalPassing = result.totalPassing;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { TaskFrequency } from '@trycompai/db';
import { filterDueTasks } from './run-integration-checks-schedule';
import {
filterDueTasks,
resolveProviderChecks,
} from './run-integration-checks-schedule';

// Mock @db at the module boundary so importing the orchestrator does not try
// to connect to Postgres. We never call the scheduled `run` function itself
Expand All @@ -9,6 +12,7 @@ jest.mock('@db', () => ({
db: {
integrationConnection: { findMany: jest.fn() },
task: { findMany: jest.fn(), update: jest.fn() },
dynamicIntegration: { findMany: jest.fn() },
},
TaskFrequency: {
daily: 'daily',
Expand Down Expand Up @@ -119,3 +123,52 @@ describe('filterDueTasks (integration orchestrator)', () => {
expect(dueTasks).toEqual([]);
});
});

describe('resolveProviderChecks (static vs dynamic)', () => {
it('uses the static code manifest when present (and ignores any dynamic map)', () => {
const checks = resolveProviderChecks({
manifest: {
checks: [
{ id: 'two_factor_auth', taskMapping: 'tpl_mfa' },
{ id: 'branch_protection', taskMapping: null },
],
},
dynamicChecks: [{ id: 'should_not_be_used', taskMapping: 'tpl_x' }],
});

expect(checks).toEqual([
{ id: 'two_factor_auth', taskMapping: 'tpl_mfa' },
{ id: 'branch_protection', taskMapping: null },
]);
});

it('falls back to the dynamic DB map when there is no manifest (the fix)', () => {
const checks = resolveProviderChecks({
manifest: undefined,
dynamicChecks: [
{ id: 'mfa_enforcement', taskMapping: 'frk_tt_mfa' },
{ id: 'supabase_mfa', taskMapping: 'frk_tt_mfa' },
],
});

expect(checks).toEqual([
{ id: 'mfa_enforcement', taskMapping: 'frk_tt_mfa' },
{ id: 'supabase_mfa', taskMapping: 'frk_tt_mfa' },
]);
});

it('returns [] for an unknown provider (no manifest, no dynamic entry)', () => {
expect(
resolveProviderChecks({ manifest: undefined, dynamicChecks: undefined }),
).toEqual([]);
});

it('normalizes an undefined manifest taskMapping to null', () => {
const checks = resolveProviderChecks({
manifest: { checks: [{ id: 'c1', taskMapping: undefined }] },
dynamicChecks: undefined,
});

expect(checks).toEqual([{ id: 'c1', taskMapping: null }]);
});
});
Loading
Loading