diff --git a/apps/api/src/integration-platform/utils/disabled-task-checks.spec.ts b/apps/api/src/integration-platform/utils/disabled-task-checks.spec.ts index 5d16e5665..10c2d0aff 100644 --- a/apps/api/src/integration-platform/utils/disabled-task-checks.spec.ts +++ b/apps/api/src/integration-platform/utils/disabled-task-checks.spec.ts @@ -1,5 +1,6 @@ import { DISABLED_TASK_CHECKS_KEY, + ENABLED_TASK_CHECKS_KEY, isCheckDisabledForTask, parseDisabledTaskChecks, withCheckDisabled, @@ -103,6 +104,39 @@ describe('disabled-task-checks utils', () => { false, ); }); + + it('defaults environment-separation checks to disconnected until reconnected', () => { + for (const checkId of ['aws-environment-separation', 'gcp-environment-separation', 'azure-environment-separation']) { + expect(isCheckDisabledForTask(null, 'tsk_abc', checkId)).toBe(true); + } + + expect( + isCheckDisabledForTask( + { + [ENABLED_TASK_CHECKS_KEY]: { + tsk_abc: ['gcp-environment-separation'], + }, + }, + 'tsk_abc', + 'gcp-environment-separation', + ), + ).toBe(false); + + expect( + isCheckDisabledForTask( + { + [DISABLED_TASK_CHECKS_KEY]: { + tsk_abc: ['gcp-environment-separation'], + }, + [ENABLED_TASK_CHECKS_KEY]: { + tsk_abc: ['gcp-environment-separation'], + }, + }, + 'tsk_abc', + 'gcp-environment-separation', + ), + ).toBe(true); + }); }); describe('withCheckDisabled', () => { @@ -178,6 +212,20 @@ describe('disabled-task-checks utils', () => { tsk_xyz: ['sanitized_inputs'], }); }); + + it('removes environment-separation opt-in when disconnecting', () => { + const metadata = { + [ENABLED_TASK_CHECKS_KEY]: { + tsk_abc: ['gcp-environment-separation'], + }, + }; + const result = withCheckDisabled( + metadata, + 'tsk_abc', + 'gcp-environment-separation', + ); + expect(result[ENABLED_TASK_CHECKS_KEY]).toBeUndefined(); + }); }); describe('withCheckEnabled', () => { @@ -240,5 +288,12 @@ describe('disabled-task-checks utils', () => { withCheckEnabled(metadata, 'tsk_abc', 'branch_protection'); expect(JSON.stringify(metadata)).toBe(snapshot); }); + + it('records opt-in when reconnecting environment-separation checks', () => { + const result = withCheckEnabled(null, 'tsk_abc', 'gcp-environment-separation'); + expect(result[ENABLED_TASK_CHECKS_KEY]).toEqual({ + tsk_abc: ['gcp-environment-separation'], + }); + }); }); }); diff --git a/apps/api/src/integration-platform/utils/disabled-task-checks.ts b/apps/api/src/integration-platform/utils/disabled-task-checks.ts index 21c93414e..6dc1af210 100644 --- a/apps/api/src/integration-platform/utils/disabled-task-checks.ts +++ b/apps/api/src/integration-platform/utils/disabled-task-checks.ts @@ -20,21 +20,32 @@ */ export const DISABLED_TASK_CHECKS_KEY = 'disabledTaskChecks'; +export const ENABLED_TASK_CHECKS_KEY = 'enabledTaskChecks'; export type DisabledTaskChecksMap = Record; +const DEFAULT_DISCONNECTED_CHECK_IDS = new Set([ + 'aws-environment-separation', + 'gcp-environment-separation', + 'azure-environment-separation', +]); + /** - * Parse the disabled task checks map from a connection's metadata JSON blob. + * Parse a task-check map from a connection's metadata JSON blob. * Returns an empty map if the metadata is missing, malformed, or doesn't - * contain a `disabledTaskChecks` entry. Never throws. + * contain the requested entry. Never throws. */ -export function parseDisabledTaskChecks( - metadata: unknown, -): DisabledTaskChecksMap { +function parseTaskChecksMap({ + metadata, + key, +}: { + metadata: unknown; + key: string; +}): DisabledTaskChecksMap { if (!metadata || typeof metadata !== 'object') { return {}; } - const raw = (metadata as Record)[DISABLED_TASK_CHECKS_KEY]; + const raw = (metadata as Record)[key]; if (!raw || typeof raw !== 'object') { return {}; } @@ -54,6 +65,84 @@ export function parseDisabledTaskChecks( return result; } +export function parseDisabledTaskChecks( + metadata: unknown, +): DisabledTaskChecksMap { + return parseTaskChecksMap({ metadata, key: DISABLED_TASK_CHECKS_KEY }); +} + +export function parseEnabledTaskChecks( + metadata: unknown, +): DisabledTaskChecksMap { + return parseTaskChecksMap({ metadata, key: ENABLED_TASK_CHECKS_KEY }); +} + +function hasCheck({ + map, + taskId, + checkId, +}: { + map: DisabledTaskChecksMap; + taskId: string; + checkId: string; +}): boolean { + return map[taskId]?.includes(checkId) ?? false; +} + +function addCheck({ + map, + taskId, + checkId, +}: { + map: DisabledTaskChecksMap; + taskId: string; + checkId: string; +}): DisabledTaskChecksMap { + const current = map[taskId] ?? []; + if (current.includes(checkId)) return map; + return { ...map, [taskId]: [...current, checkId] }; +} + +function removeCheck({ + map, + taskId, + checkId, +}: { + map: DisabledTaskChecksMap; + taskId: string; + checkId: string; +}): DisabledTaskChecksMap { + const current = map[taskId]; + if (!current?.includes(checkId)) return map; + + const nextChecks = current.filter((id) => id !== checkId); + const nextMap: DisabledTaskChecksMap = { ...map }; + if (nextChecks.length === 0) { + delete nextMap[taskId]; + } else { + nextMap[taskId] = nextChecks; + } + return nextMap; +} + +function assignMap({ + metadata, + key, + map, + deleteWhenEmpty = false, +}: { + metadata: Record; + key: string; + map: DisabledTaskChecksMap; + deleteWhenEmpty?: boolean; +}) { + if (deleteWhenEmpty && Object.keys(map).length === 0) { + delete metadata[key]; + return; + } + metadata[key] = map; +} + /** * Returns true if the given checkId is disabled for the given taskId on this * connection's metadata. @@ -63,9 +152,13 @@ export function isCheckDisabledForTask( taskId: string, checkId: string, ): boolean { - const map = parseDisabledTaskChecks(metadata); - const disabled = map[taskId]; - return Array.isArray(disabled) && disabled.includes(checkId); + if (hasCheck({ map: parseDisabledTaskChecks(metadata), taskId, checkId })) { + return true; + } + if (!DEFAULT_DISCONNECTED_CHECK_IDS.has(checkId)) { + return false; + } + return !hasCheck({ map: parseEnabledTaskChecks(metadata), taskId, checkId }); } /** @@ -83,20 +176,69 @@ export function withCheckDisabled( ? { ...(metadata as Record) } : {}; const map = parseDisabledTaskChecks(base); - const current = map[taskId] ?? []; - if (current.includes(checkId)) { - // Already disabled — return a merged copy so callers can safely write back. - base[DISABLED_TASK_CHECKS_KEY] = map; - return base; - } - const nextMap: DisabledTaskChecksMap = { - ...map, - [taskId]: [...current, checkId], - }; - base[DISABLED_TASK_CHECKS_KEY] = nextMap; + assignMap({ + metadata: base, + key: DISABLED_TASK_CHECKS_KEY, + map: addCheck({ + map, + taskId, + checkId, + }), + }); + assignMap({ + metadata: base, + key: ENABLED_TASK_CHECKS_KEY, + map: removeCheck({ + map: parseEnabledTaskChecks(base), + taskId, + checkId, + }), + deleteWhenEmpty: true, + }); return base; } +function enableDefaultDisconnectedCheck({ + metadata, + taskId, + checkId, +}: { + metadata: Record; + taskId: string; + checkId: string; +}) { + assignMap({ + metadata, + key: ENABLED_TASK_CHECKS_KEY, + map: addCheck({ + map: parseEnabledTaskChecks(metadata), + taskId, + checkId, + }), + deleteWhenEmpty: true, + }); +} + +function removeFromDisabledChecks({ + metadata, + taskId, + checkId, +}: { + metadata: Record; + taskId: string; + checkId: string; +}) { + assignMap({ + metadata, + key: DISABLED_TASK_CHECKS_KEY, + map: removeCheck({ + map: parseDisabledTaskChecks(metadata), + taskId, + checkId, + }), + }); +} + /** * Returns a new metadata object with the given check re-enabled for the given * task. Cleans up empty arrays. If the check wasn't disabled, returns a merged @@ -111,19 +253,10 @@ export function withCheckEnabled( metadata && typeof metadata === 'object' ? { ...(metadata as Record) } : {}; - const map = parseDisabledTaskChecks(base); - const current = map[taskId]; - if (!current || !current.includes(checkId)) { - base[DISABLED_TASK_CHECKS_KEY] = map; - return base; - } - const nextChecks = current.filter((id) => id !== checkId); - const nextMap: DisabledTaskChecksMap = { ...map }; - if (nextChecks.length === 0) { - delete nextMap[taskId]; - } else { - nextMap[taskId] = nextChecks; + + removeFromDisabledChecks({ metadata: base, taskId, checkId }); + if (DEFAULT_DISCONNECTED_CHECK_IDS.has(checkId)) { + enableDefaultDisconnectedCheck({ metadata: base, taskId, checkId }); } - base[DISABLED_TASK_CHECKS_KEY] = nextMap; return base; } diff --git a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts index bb2fd5694..5819bde16 100644 --- a/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts +++ b/apps/api/src/trigger/integration-platform/run-integration-checks-schedule.ts @@ -3,7 +3,7 @@ import { db, TaskFrequency } from '@db'; import { logger, schedules } from '@trigger.dev/sdk'; import { runTaskIntegrationChecks } from './run-task-integration-checks'; import { runDeviceSync } from './run-device-sync'; -import { parseDisabledTaskChecks } from '../../integration-platform/utils/disabled-task-checks'; +import { isCheckDisabledForTask } from '../../integration-platform/utils/disabled-task-checks'; import { isDueToday } from '../shared/is-due-today'; /** @@ -175,20 +175,13 @@ export const integrationChecksSchedule = schedules.task({ ); } - // Per-task disabled checks are stored on the connection's metadata so - // users can disconnect individual checks from individual tasks without - // tearing down the whole integration. Resolve once per connection. - const disabledByTask = parseDisabledTaskChecks(connection.metadata); - for (const t of tasks) { - const disabledForThisTask = new Set(disabledByTask[t.id] ?? []); - // Find which checks apply to this task, minus any the user disabled const checksForTask = checks .filter( (c) => c.taskMapping === t.taskTemplateId && - !disabledForThisTask.has(c.id), + !isCheckDisabledForTask(connection.metadata, t.id, c.id), ) .map((c) => c.id); diff --git a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts index 609db0087..ce5da8e12 100644 --- a/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts +++ b/apps/api/src/trigger/integration-platform/run-task-integration-checks.ts @@ -4,7 +4,7 @@ import { logger, tags, task } from '@trigger.dev/sdk'; import { triggerEmail } from '../../email/trigger-email'; import { TaskStatusChangedEmail } from '../../email/templates/task-status-changed'; import { isUserUnsubscribed } from '@trycompai/email'; -import { parseDisabledTaskChecks } from '../../integration-platform/utils/disabled-task-checks'; +import { isCheckDisabledForTask } from '../../integration-platform/utils/disabled-task-checks'; import { getAccessToken, requestValidCredentials, @@ -342,11 +342,8 @@ export const runTaskIntegrationChecks = task({ // metadata and skip anything that's now disabled. The rest of the flow // (lastSyncAt update, task status evaluation, return payload) runs as // before — just over the filtered list instead of the original one. - const disabledForThisTask = new Set( - parseDisabledTaskChecks(connection.metadata)[taskId] ?? [], - ); const effectiveCheckIds = checkIds.filter( - (id) => !disabledForThisTask.has(id), + (id) => !isCheckDisabledForTask(connection.metadata, taskId, id), ); if (effectiveCheckIds.length < checkIds.length) { logger.info(