diff --git a/apps/api/src/integration-platform/controllers/internal-integration-debug.controller.ts b/apps/api/src/integration-platform/controllers/internal-integration-debug.controller.ts new file mode 100644 index 000000000..91d1e3e1f --- /dev/null +++ b/apps/api/src/integration-platform/controllers/internal-integration-debug.controller.ts @@ -0,0 +1,128 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { InternalTokenGuard } from '../../auth/internal-token.guard'; +import { InternalIntegrationDebugService } from '../services/internal-integration-debug.service'; + +/** Parse an optional numeric query param, dropping non-numeric input (no NaN). */ +function parseOptionalInt(value?: string): number | undefined { + if (!value) return undefined; + const n = parseInt(value, 10); + return Number.isFinite(n) ? n : undefined; +} + +class RunChecksBody { + @IsOptional() + @IsString() + checkId?: string; +} + +class TestCandidateBody { + /** Candidate check code to run instead of the saved version. */ + @IsString() + @IsNotEmpty() + code!: string; + + /** Optional: the checkId this candidate is for (labelling only). */ + @IsOptional() + @IsString() + checkId?: string; +} + +/** + * Internal-token-gated diagnostic toolkit for dynamic integrations. Lets an + * operator/agent do the full debug loop over HTTP — inspect any connection, + * view its credential shape (never the secret values) and recent run logs, and + * run its checks on the real runtime — without a database tunnel and without + * impersonating the customer's organization. + * + * Mutations (create/update/delete integrations + checks) and run history already + * live on the sibling `internal/dynamic-integrations` controller; this one adds + * the connection-scoped, org-agnostic read + on-demand run that were missing. + * + * A distinct base path (`internal/integration-debug`) is used deliberately so a + * literal `connections` segment can never be swallowed by the sibling's + * `GET /internal/dynamic-integrations/:id` param route. + */ +@ApiExcludeController() +@Controller({ path: 'internal/integration-debug', version: '1' }) +@UseGuards(InternalTokenGuard) +export class InternalIntegrationDebugController { + constructor(private readonly debugService: InternalIntegrationDebugService) {} + + /** + * List connections (filter by org / provider / id) with a non-sensitive + * credential view and the latest run summary for each. + */ + @Get('connections') + async listConnections( + @Query('organizationId') organizationId?: string, + @Query('providerSlug') providerSlug?: string, + @Query('connectionId') connectionId?: string, + @Query('limit') limit?: string, + ) { + return this.debugService.listConnections({ + organizationId, + providerSlug, + connectionId, + limit: parseOptionalInt(limit), + }); + } + + /** + * Full detail for one connection: credential shape + recent runs (logs + + * results) for debugging. + */ + @Get('connections/:connectionId') + async getConnection( + @Param('connectionId') connectionId: string, + @Query('runLimit') runLimit?: string, + ) { + return this.debugService.getConnection( + connectionId, + parseOptionalInt(runLimit), + ); + } + + /** + * Run a connection's checks on the real runtime and return findings + + * passing results + logs. Never persists — purely for verification. + * Pass `checkId` to run a single check; omit to run all. + */ + @Post('connections/:connectionId/run') + async runConnectionChecks( + @Param('connectionId') connectionId: string, + @Body() body: RunChecksBody, + ) { + return this.debugService.runConnectionChecks({ + connectionId, + checkId: body?.checkId, + }); + } + + /** + * Run CANDIDATE check code against this connection's real credentials on the + * real runtime, returning findings + passing results + logs. Persists nothing + * and never touches the live shared check — the safe way to validate a fix + * BEFORE applying it via `PATCH /internal/dynamic-integrations/:id/checks/:checkId`. + */ + @Post('connections/:connectionId/test') + async testCandidateCode( + @Param('connectionId') connectionId: string, + @Body() body: TestCandidateBody, + ) { + return this.debugService.testCandidateCode({ + connectionId, + code: body.code, + checkId: body.checkId, + }); + } +} diff --git a/apps/api/src/integration-platform/integration-platform.module.ts b/apps/api/src/integration-platform/integration-platform.module.ts index 87c029e79..23fc72dd4 100644 --- a/apps/api/src/integration-platform/integration-platform.module.ts +++ b/apps/api/src/integration-platform/integration-platform.module.ts @@ -8,6 +8,7 @@ import { AdminIntegrationsController } from './controllers/admin-integrations.co import { DynamicIntegrationsController } from './controllers/dynamic-integrations.controller'; import { ChecksController } from './controllers/checks.controller'; import { InternalChecksController } from './controllers/internal-checks.controller'; +import { InternalIntegrationDebugController } from './controllers/internal-integration-debug.controller'; import { VariablesController } from './controllers/variables.controller'; import { TaskIntegrationsController } from './controllers/task-integrations.controller'; import { WebhookController } from './controllers/webhook.controller'; @@ -22,6 +23,7 @@ import { OAuthTokenRevocationService } from './services/oauth-token-revocation.s import { DynamicManifestLoaderService } from './services/dynamic-manifest-loader.service'; import { TaskIntegrationChecksService } from './services/task-integration-checks.service'; import { ConnectionCheckRunnerService } from './services/connection-check-runner.service'; +import { InternalIntegrationDebugService } from './services/internal-integration-debug.service'; import { ProviderRepository } from './repositories/provider.repository'; import { ConnectionRepository } from './repositories/connection.repository'; import { CredentialRepository } from './repositories/credential.repository'; @@ -45,6 +47,7 @@ import { GenericDeviceSyncService } from './services/generic-device-sync.service DynamicIntegrationsController, ChecksController, InternalChecksController, + InternalIntegrationDebugController, VariablesController, TaskIntegrationsController, WebhookController, @@ -62,6 +65,7 @@ import { GenericDeviceSyncService } from './services/generic-device-sync.service DynamicManifestLoaderService, TaskIntegrationChecksService, ConnectionCheckRunnerService, + InternalIntegrationDebugService, IntegrationSyncLoggerService, GenericEmployeeSyncService, GenericDeviceSyncService, diff --git a/apps/api/src/integration-platform/services/connection-check-runner.service.ts b/apps/api/src/integration-platform/services/connection-check-runner.service.ts index 123cb4f7a..fbbb226b2 100644 --- a/apps/api/src/integration-platform/services/connection-check-runner.service.ts +++ b/apps/api/src/integration-platform/services/connection-check-runner.service.ts @@ -4,7 +4,11 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { getManifest, runAllChecks } from '@trycompai/integration-platform'; +import { + getManifest, + interpretDeclarativeCheck, + runAllChecks, +} from '@trycompai/integration-platform'; import { ConnectionRepository } from '../repositories/connection.repository'; import { ProviderRepository } from '../repositories/provider.repository'; import { CredentialVaultService } from './credential-vault.service'; @@ -51,6 +55,102 @@ export class ConnectionCheckRunnerService { }): Promise { const { connectionId, organizationId, checkId } = params; + const { connection, provider, manifest } = + await this.loadConnectionContext(connectionId, organizationId); + if (!manifest.checks || manifest.checks.length === 0) { + throw new BadRequestException(`No checks defined for ${provider.slug}`); + } + + const { credentials, variables, accessToken, onTokenRefresh } = + await this.resolveExecutionInputs( + connection, + organizationId, + provider, + manifest, + ); + + return runAllChecks({ + manifest, + accessToken, + credentials, + variables, + connectionId, + organizationId, + checkId, + onTokenRefresh, + logger: { + info: (msg, data) => this.logger.log(msg, data), + warn: (msg, data) => this.logger.warn(msg, data), + error: (msg, data) => this.logger.error(msg, data), + }, + }); + } + + /** + * Run CANDIDATE check code against a connection's real credentials on the + * real runtime, returning findings + passing results + logs. The candidate is + * executed as the integration's ONLY check but keeps the real manifest's + * auth/baseUrl/defaultHeaders, so it authenticates and behaves exactly as it + * would once saved — yet NOTHING is persisted and the live, shared check is + * never touched. This is the safe way to validate a fix BEFORE applying it. + */ + async runCandidateCheck(params: { + connectionId: string; + organizationId: string; + code: string; + checkId?: string; + }): Promise { + const { connectionId, organizationId, code, checkId } = params; + if (typeof code !== 'string' || code.trim().length === 0) { + throw new BadRequestException('Candidate code is required'); + } + + const { connection, provider, manifest } = + await this.loadConnectionContext(connectionId, organizationId); + + const { credentials, variables, accessToken, onTokenRefresh } = + await this.resolveExecutionInputs( + connection, + organizationId, + provider, + manifest, + ); + + const candidateCheck = interpretDeclarativeCheck({ + id: checkId || 'candidate', + name: checkId ? `Candidate: ${checkId}` : 'Candidate check', + description: 'Candidate code dry-run (not persisted)', + definition: { steps: [{ type: 'code', code }] }, + defaultSeverity: 'medium', + }); + + const candidateManifest = { ...manifest, checks: [candidateCheck] }; + + return runAllChecks({ + manifest: candidateManifest, + accessToken, + credentials, + variables, + connectionId, + organizationId, + onTokenRefresh, + logger: { + info: (msg, data) => this.logger.log(msg, data), + warn: (msg, data) => this.logger.warn(msg, data), + error: (msg, data) => this.logger.error(msg, data), + }, + }); + } + + /** + * Resolve + validate the connection, provider and manifest for a run. + * Shared by runChecks and runCandidateCheck (behaviour identical to the + * original inline logic). + */ + private async loadConnectionContext( + connectionId: string, + organizationId: string, + ) { const connection = await this.connectionRepository.findById(connectionId); if (!connection || connection.organizationId !== organizationId) { throw new NotFoundException('Connection not found'); @@ -72,9 +172,22 @@ export class ConnectionCheckRunnerService { if (!manifest) { throw new NotFoundException(`Manifest for ${provider.slug} not found`); } - if (!manifest.checks || manifest.checks.length === 0) { - throw new BadRequestException(`No checks defined for ${provider.slug}`); - } + + return { connection, provider, manifest }; + } + + /** + * Decrypt + validate credentials, resolve variables, and build the OAuth + * refresh callback. Shared by runChecks and runCandidateCheck (behaviour + * identical to the original inline logic). + */ + private async resolveExecutionInputs( + connection: { id: string; variables: unknown }, + organizationId: string, + provider: { slug: string }, + manifest: NonNullable>, + ) { + const connectionId = connection.id; const credentials = await this.credentialVaultService.getDecryptedCredentials(connectionId); @@ -160,20 +273,6 @@ export class ConnectionCheckRunnerService { } } - return runAllChecks({ - manifest, - accessToken, - credentials, - variables, - connectionId, - organizationId, - checkId, - onTokenRefresh, - logger: { - info: (msg, data) => this.logger.log(msg, data), - warn: (msg, data) => this.logger.warn(msg, data), - error: (msg, data) => this.logger.error(msg, data), - }, - }); + return { credentials, variables, accessToken, onTokenRefresh }; } } diff --git a/apps/api/src/integration-platform/services/internal-integration-debug.service.spec.ts b/apps/api/src/integration-platform/services/internal-integration-debug.service.spec.ts new file mode 100644 index 000000000..8e6d6f017 --- /dev/null +++ b/apps/api/src/integration-platform/services/internal-integration-debug.service.spec.ts @@ -0,0 +1,270 @@ +jest.mock('@db', () => ({ + db: { + integrationConnection: { findMany: jest.fn(), findUnique: jest.fn() }, + integrationCredentialVersion: { findUnique: jest.fn(), findMany: jest.fn() }, + integrationCheckRun: { findFirst: jest.fn(), findMany: jest.fn() }, + }, +})); + +import { NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { InternalIntegrationDebugService } from './internal-integration-debug.service'; +import type { ConnectionCheckRunnerService } from './connection-check-runner.service'; + +const encryptedBlob = { + encrypted: 'Y2lwaGVydGV4dA==', + iv: 'aXY=', + tag: 'dGFn', + salt: 'c2FsdA==', +}; + +const mockedDb = db as unknown as { + integrationConnection: { findMany: jest.Mock; findUnique: jest.Mock }; + integrationCredentialVersion: { findUnique: jest.Mock; findMany: jest.Mock }; + integrationCheckRun: { findFirst: jest.Mock; findMany: jest.Mock }; +}; + +const makeService = (runner: Partial = {}) => + new InternalIntegrationDebugService( + runner as ConnectionCheckRunnerService, + ); + +describe('InternalIntegrationDebugService', () => { + afterEach(() => jest.clearAllMocks()); + + describe('credential metadata (never leaks secrets)', () => { + it('masks encrypted blobs and secret-named fields, exposes only non-secret routing values', async () => { + mockedDb.integrationConnection.findMany.mockResolvedValue([ + { + id: 'icn_1', + organizationId: 'org_1', + provider: { slug: 'zoho-crm', name: 'Zoho CRM' }, + status: 'active', + errorMessage: null, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + variables: null, + activeCredentialVersionId: 'icv_1', + }, + ]); + mockedDb.integrationCredentialVersion.findMany.mockResolvedValue([ + { + id: 'icv_1', + version: 14, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt: new Date(Date.now() + 3_600_000), + encryptedPayload: { + access_token: encryptedBlob, + refresh_token: encryptedBlob, + client_secret: 'should-be-masked-even-though-plaintext', + api_domain: 'https://www.zohoapis.eu', + scope: 'ZohoCRM.users.READ', + region: 'us2.ninjarmm.com', + token_type: 'Bearer', + }, + }, + ]); + mockedDb.integrationCheckRun.findMany.mockResolvedValue([]); + + const service = makeService(); + const { connections } = await service.listConnections({ + organizationId: 'org_1', + }); + const fields = connections[0].credential!.fields as Record< + string, + Record + >; + + // Secrets: never a raw value. + expect(fields.access_token).toEqual({ present: true, encrypted: true }); + expect(fields.refresh_token).toEqual({ present: true, encrypted: true }); + // Plaintext but secret-named → masked, not exposed. + expect(fields.client_secret).toEqual({ present: true, masked: true }); + expect(fields.client_secret).not.toHaveProperty('value'); + // Non-secret routing fields → exposed for debugging. + expect(fields.api_domain).toEqual({ + present: true, + value: 'https://www.zohoapis.eu', + }); + expect(fields.scope).toEqual({ present: true, value: 'ZohoCRM.users.READ' }); + expect(fields.region).toEqual({ present: true, value: 'us2.ninjarmm.com' }); + + // Absolutely no plaintext secret value anywhere in the response. + expect(JSON.stringify(connections)).not.toContain('should-be-masked'); + expect(connections[0].credential!.expired).toBe(false); + }); + + it('flags an expired credential version', async () => { + mockedDb.integrationConnection.findMany.mockResolvedValue([ + { + id: 'icn_2', + organizationId: 'org_1', + provider: { slug: 'zoho-crm', name: 'Zoho CRM' }, + status: 'active', + errorMessage: null, + updatedAt: new Date(), + variables: null, + activeCredentialVersionId: 'icv_2', + }, + ]); + mockedDb.integrationCredentialVersion.findMany.mockResolvedValue([ + { + id: 'icv_2', + version: 1, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt: new Date(Date.now() - 1_000), + encryptedPayload: { access_token: encryptedBlob }, + }, + ]); + mockedDb.integrationCheckRun.findMany.mockResolvedValue([]); + + const service = makeService(); + const { connections } = await service.listConnections({}); + expect(connections[0].credential!.expired).toBe(true); + }); + + it('returns null credential when the connection has no active version', async () => { + mockedDb.integrationConnection.findMany.mockResolvedValue([ + { + id: 'icn_3', + organizationId: 'org_1', + provider: { slug: 'x', name: 'X' }, + status: 'pending', + errorMessage: null, + updatedAt: new Date(), + variables: null, + activeCredentialVersionId: null, + }, + ]); + mockedDb.integrationCheckRun.findMany.mockResolvedValue([]); + + const service = makeService(); + const { connections } = await service.listConnections({}); + expect(connections[0].credential).toBeNull(); + // No active version id → we never query credential versions at all. + expect( + mockedDb.integrationCredentialVersion.findMany, + ).not.toHaveBeenCalled(); + }); + }); + + describe('listConnections input guards + batching', () => { + it('tolerates a non-numeric limit (no NaN to the DB) and maps the latest run per connection', async () => { + mockedDb.integrationConnection.findMany.mockResolvedValue([ + { + id: 'icn_a', + organizationId: 'org_1', + provider: { slug: 'zoho-crm', name: 'Zoho CRM' }, + status: 'active', + errorMessage: null, + updatedAt: new Date(), + variables: null, + activeCredentialVersionId: null, + }, + ]); + mockedDb.integrationCheckRun.findMany.mockResolvedValue([ + { + id: 'run_1', + connectionId: 'icn_a', + checkId: 'c', + status: 'failed', + passedCount: 0, + failedCount: 1, + completedAt: new Date(), + errorMessage: 'boom', + }, + ]); + + const service = makeService(); + const { connections } = await service.listConnections({ + limit: Number('not-a-number'), + }); + + expect(connections[0].latestRun).toMatchObject({ + id: 'run_1', + status: 'failed', + }); + // The NaN limit must be normalized before it reaches Prisma's `take`. + const take = + mockedDb.integrationConnection.findMany.mock.calls[0][0].take; + expect(Number.isFinite(take)).toBe(true); + }); + }); + + describe('runConnectionChecks', () => { + it('resolves the org from the connection and delegates to the runner (no persistence)', async () => { + mockedDb.integrationConnection.findUnique.mockResolvedValue({ + organizationId: 'org_42', + }); + const runChecks = jest.fn().mockResolvedValue({ + results: [], + totalFindings: 0, + totalPassing: 0, + durationMs: 5, + }); + const service = makeService({ runChecks }); + + const result = await service.runConnectionChecks({ + connectionId: 'icn_9', + checkId: 'zoho_crm_employee_access', + }); + + expect(runChecks).toHaveBeenCalledWith({ + connectionId: 'icn_9', + organizationId: 'org_42', + checkId: 'zoho_crm_employee_access', + }); + expect(result.totalFindings).toBe(0); + }); + + it('throws NotFound when the connection does not exist', async () => { + mockedDb.integrationConnection.findUnique.mockResolvedValue(null); + const runChecks = jest.fn(); + const service = makeService({ runChecks }); + + await expect( + service.runConnectionChecks({ connectionId: 'missing' }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(runChecks).not.toHaveBeenCalled(); + }); + }); + + describe('testCandidateCode', () => { + it('resolves the org and delegates candidate code to the runner (no persistence, no live edit)', async () => { + mockedDb.integrationConnection.findUnique.mockResolvedValue({ + organizationId: 'org_42', + }); + const runCandidateCheck = jest.fn().mockResolvedValue({ + results: [{ checkId: 'candidate', result: { findings: [], passingResults: [{ title: 'ok' }], logs: [] } }], + totalFindings: 0, + totalPassing: 1, + durationMs: 7, + }); + const service = makeService({ runCandidateCheck }); + + const result = await service.testCandidateCode({ + connectionId: 'icn_9', + code: 'ctx.pass({ title: "ok", resourceType: "app", resourceId: "x" });', + checkId: 'zoho_crm_employee_access', + }); + + expect(runCandidateCheck).toHaveBeenCalledWith({ + connectionId: 'icn_9', + organizationId: 'org_42', + code: 'ctx.pass({ title: "ok", resourceType: "app", resourceId: "x" });', + checkId: 'zoho_crm_employee_access', + }); + expect(result.totalPassing).toBe(1); + }); + + it('throws NotFound when the connection does not exist', async () => { + mockedDb.integrationConnection.findUnique.mockResolvedValue(null); + const runCandidateCheck = jest.fn(); + const service = makeService({ runCandidateCheck }); + + await expect( + service.testCandidateCode({ connectionId: 'missing', code: 'x' }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(runCandidateCheck).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/integration-platform/services/internal-integration-debug.service.ts b/apps/api/src/integration-platform/services/internal-integration-debug.service.ts new file mode 100644 index 000000000..f1b74fd8c --- /dev/null +++ b/apps/api/src/integration-platform/services/internal-integration-debug.service.ts @@ -0,0 +1,316 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { + ConnectionCheckRunnerService, + type RunAllChecksResult, +} from './connection-check-runner.service'; + +/** + * Read-only/diagnostic toolkit for dynamic integrations, used by internal + * operators (and AI agents) to debug a customer's connection end-to-end WITHOUT + * a direct database tunnel and WITHOUT ever exposing secret credential values. + * + * Credential metadata is derived from the stored payload WITHOUT decrypting it: + * secrets are stored as encrypted blobs (shown only as `{ present, encrypted }`) + * while non-secret routing fields (e.g. `api_domain`, `scope`, `region`, + * `token_type`, `expires_at`) are stored in plaintext and surfaced directly. + * As defense-in-depth, any plaintext field whose NAME looks secret is masked + * too, so an accidentally-plaintext secret still never leaves the API. + */ +@Injectable() +export class InternalIntegrationDebugService { + private readonly logger = new Logger(InternalIntegrationDebugService.name); + + // Field NAMES that must never have their value returned, even if (wrongly) + // stored as plaintext. Encrypted blobs are masked regardless of name. + private static readonly SECRET_KEY_RE = + /(secret|password|passwd|pwd|private|access[_-]?token|refresh[_-]?token|client[_-]?secret|api[_-]?key|apikey|bearer|signing)/i; + + constructor(private readonly runner: ConnectionCheckRunnerService) {} + + private isEncryptedData(value: unknown): boolean { + return ( + value !== null && + typeof value === 'object' && + 'encrypted' in (value as Record) && + 'iv' in (value as Record) && + 'tag' in (value as Record) && + 'salt' in (value as Record) + ); + } + + /** + * Build a non-sensitive view of a credential payload. Never decrypts; never + * returns a secret value. + */ + private buildCredentialMetadata(version: { + version: number; + createdAt: Date; + expiresAt: Date | null; + encryptedPayload: unknown; + } | null) { + if (!version) return null; + const payload = + version.encryptedPayload && typeof version.encryptedPayload === 'object' + ? (version.encryptedPayload as Record) + : {}; + + const fields: Record = {}; + for (const [key, value] of Object.entries(payload)) { + if (this.isEncryptedData(value)) { + fields[key] = { present: true, encrypted: true }; + } else if (Array.isArray(value)) { + const hasEncrypted = value.some((item) => this.isEncryptedData(item)); + fields[key] = hasEncrypted + ? { present: true, encrypted: true, count: value.length } + : InternalIntegrationDebugService.SECRET_KEY_RE.test(key) + ? { present: true, masked: true } + : { present: true, value }; + } else if (InternalIntegrationDebugService.SECRET_KEY_RE.test(key)) { + fields[key] = { present: true, masked: true }; + } else { + fields[key] = { present: true, value }; + } + } + + return { + version: version.version, + createdAt: version.createdAt, + expiresAt: version.expiresAt, + expired: version.expiresAt ? version.expiresAt.getTime() < Date.now() : false, + fields, + }; + } + + private async getActiveCredentialMetadata( + activeCredentialVersionId: string | null, + ) { + if (!activeCredentialVersionId) return null; + const version = await db.integrationCredentialVersion.findUnique({ + where: { id: activeCredentialVersionId }, + select: { + version: true, + createdAt: true, + expiresAt: true, + encryptedPayload: true, + }, + }); + return this.buildCredentialMetadata(version); + } + + /** + * List connections, filterable by org / provider / connection id, with a + * non-sensitive credential view and the most recent run summary for each. + * Related data is batch-fetched (no N+1). + */ + async listConnections(params: { + organizationId?: string; + providerSlug?: string; + connectionId?: string; + limit?: number; + }) { + const { organizationId, providerSlug, connectionId } = params; + const rawLimit = params.limit ?? NaN; + const limit = Number.isFinite(rawLimit) + ? Math.min(Math.max(rawLimit, 1), 200) + : 50; + + const connections = await db.integrationConnection.findMany({ + where: { + ...(organizationId ? { organizationId } : {}), + ...(connectionId ? { id: connectionId } : {}), + ...(providerSlug ? { provider: { slug: providerSlug } } : {}), + }, + include: { provider: { select: { slug: true, name: true } } }, + orderBy: { updatedAt: 'desc' }, + take: limit, + }); + + // Batch-fetch the active credential versions and the latest run per + // connection in two queries instead of two-per-connection. + const versionIds = connections + .map((c) => c.activeCredentialVersionId) + .filter((id): id is string => Boolean(id)); + const connectionIds = connections.map((c) => c.id); + + const [versions, latestRuns] = await Promise.all([ + versionIds.length + ? db.integrationCredentialVersion.findMany({ + where: { id: { in: versionIds } }, + select: { + id: true, + version: true, + createdAt: true, + expiresAt: true, + encryptedPayload: true, + }, + }) + : Promise.resolve([]), + connectionIds.length + ? db.integrationCheckRun.findMany({ + where: { connectionId: { in: connectionIds } }, + orderBy: { createdAt: 'desc' }, + distinct: ['connectionId'], + select: { + id: true, + connectionId: true, + checkId: true, + status: true, + passedCount: true, + failedCount: true, + completedAt: true, + errorMessage: true, + }, + }) + : Promise.resolve([]), + ]); + + const versionById = new Map(versions.map((v) => [v.id, v] as const)); + const latestRunByConn = new Map( + latestRuns.map((r) => [r.connectionId, r] as const), + ); + + const items = connections.map((conn) => ({ + id: conn.id, + organizationId: conn.organizationId, + provider: conn.provider + ? { slug: conn.provider.slug, name: conn.provider.name } + : null, + status: conn.status, + errorMessage: conn.errorMessage, + updatedAt: conn.updatedAt, + variables: conn.variables ?? null, + credential: conn.activeCredentialVersionId + ? this.buildCredentialMetadata( + versionById.get(conn.activeCredentialVersionId) ?? null, + ) + : null, + latestRun: latestRunByConn.get(conn.id) ?? null, + })); + + return { connections: items, total: items.length }; + } + + /** + * Full detail for a single connection: non-sensitive credential view plus the + * most recent runs (with logs + results) for debugging. + */ + async getConnection(connectionId: string, runLimit = 5) { + const conn = await db.integrationConnection.findUnique({ + where: { id: connectionId }, + include: { provider: { select: { slug: true, name: true } } }, + }); + if (!conn) { + throw new NotFoundException(`Connection ${connectionId} not found`); + } + + const take = Number.isFinite(runLimit) + ? Math.min(Math.max(runLimit, 1), 20) + : 5; + const recentRuns = await db.integrationCheckRun.findMany({ + where: { connectionId }, + orderBy: { createdAt: 'desc' }, + take, + include: { + results: { + select: { + id: true, + passed: true, + title: true, + resourceType: true, + resourceId: true, + severity: true, + }, + }, + }, + }); + + return { + id: conn.id, + organizationId: conn.organizationId, + provider: conn.provider + ? { slug: conn.provider.slug, name: conn.provider.name } + : null, + status: conn.status, + errorMessage: conn.errorMessage, + createdAt: conn.createdAt, + updatedAt: conn.updatedAt, + variables: conn.variables ?? null, + credential: await this.getActiveCredentialMetadata( + conn.activeCredentialVersionId, + ), + recentRuns: recentRuns.map((run) => ({ + id: run.id, + checkId: run.checkId, + checkName: run.checkName, + status: run.status, + startedAt: run.startedAt, + completedAt: run.completedAt, + durationMs: run.durationMs, + passedCount: run.passedCount, + failedCount: run.failedCount, + errorMessage: run.errorMessage, + logs: run.logs, + results: run.results, + })), + }; + } + + /** + * Run a connection's checks on the real runtime (the same path the in-app + * "Run" uses) and return findings + passing results + logs. NEVER persists — + * this is purely for verification/debugging, so it cannot pollute the + * customer's dashboard. + */ + async runConnectionChecks(params: { + connectionId: string; + checkId?: string; + }): Promise { + const { connectionId, checkId } = params; + const connection = await db.integrationConnection.findUnique({ + where: { id: connectionId }, + select: { organizationId: true }, + }); + if (!connection) { + throw new NotFoundException(`Connection ${connectionId} not found`); + } + this.logger.log( + `Internal dry-run for connection ${connectionId}${checkId ? ` (check: ${checkId})` : ''}`, + ); + return this.runner.runChecks({ + connectionId, + organizationId: connection.organizationId, + checkId, + }); + } + + /** + * Run CANDIDATE check code against this connection's real credentials on the + * real runtime, persisting nothing and never touching the live shared check. + * This is the safe way to validate a fix BEFORE applying it via + * `PATCH /internal/dynamic-integrations/:id/checks/:checkId`. + */ + async testCandidateCode(params: { + connectionId: string; + code: string; + checkId?: string; + }): Promise { + const { connectionId, code, checkId } = params; + const connection = await db.integrationConnection.findUnique({ + where: { id: connectionId }, + select: { organizationId: true }, + }); + if (!connection) { + throw new NotFoundException(`Connection ${connectionId} not found`); + } + this.logger.log( + `Internal candidate-code test for connection ${connectionId}${checkId ? ` (check: ${checkId})` : ''}`, + ); + return this.runner.runCandidateCheck({ + connectionId, + organizationId: connection.organizationId, + code, + checkId, + }); + } +}