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
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -45,6 +47,7 @@ import { GenericDeviceSyncService } from './services/generic-device-sync.service
DynamicIntegrationsController,
ChecksController,
InternalChecksController,
InternalIntegrationDebugController,
VariablesController,
TaskIntegrationsController,
WebhookController,
Expand All @@ -62,6 +65,7 @@ import { GenericDeviceSyncService } from './services/generic-device-sync.service
DynamicManifestLoaderService,
TaskIntegrationChecksService,
ConnectionCheckRunnerService,
InternalIntegrationDebugService,
IntegrationSyncLoggerService,
GenericEmployeeSyncService,
GenericDeviceSyncService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -51,6 +55,102 @@ export class ConnectionCheckRunnerService {
}): Promise<RunAllChecksResult> {
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<RunAllChecksResult> {
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');
Expand All @@ -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<ReturnType<typeof getManifest>>,
) {
const connectionId = connection.id;

const credentials =
await this.credentialVaultService.getDecryptedCredentials(connectionId);
Expand Down Expand Up @@ -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 };
}
}
Loading
Loading