From a7ffbb792b2e5fdb881708ed0c35f0f62d8018f0 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 5 Jun 2026 10:20:14 -0400 Subject: [PATCH 1/7] fix(api): accept presigned s3Key for MCP uploads (attachment/document/evidence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer-reported: the MCP server times out on uploads. Root cause is the documented one — these tools take the whole file as base64 inside the tool argument, so the LLM must emit the entire file token-by-token, which is impractically slow and hits the client's ~4-min timeout. Questionnaire and policy-PDF were already migrated to the presigned-upload pattern; the rest were not. Migrates the three tools the customer hit to the same pattern: accept an optional `s3Key` (from POST /v1/uploads/presign) alongside `fileData`. The service resolves the bytes from whichever is provided via UploadsService.readUploadAsBase64, which enforces that the key belongs to the caller's org. The MCP overlay strips `fileData` from these tools so agents must use create-upload-url -> PUT to S3 -> pass the s3Key; the base spec keeps fileData for the web UI / direct callers. - attachments (upload-task-attachment): DTO + service + module - knowledge-base (upload-document): DTO + service + module; also added the missing @ApiProperty decorators so the MCP tool finally has a real schema - offboarding-checklist (complete-checklist-item, upload-evidence): DTO + service; delegates to the now-fixed AttachmentsService - UploadPurpose: added `document` - regenerated packages/docs/openapi.json (carries the new s3Key fields; also synced some pre-existing drift that was stale in the committed spec) Tests: new attachments.service.spec (s3Key path + neither->400); extended knowledge-base.service.spec (s3Key path + neither->400); offboarding specs still green. typecheck clean; AppModule boots (DI wiring verified). The MCP generator stays healthy: 0 operations declare more than one security scheme. Out of scope (separate issues): create-version automation script-generation also times out, but that is heavy synchronous AI work, not base64 — needs an async job pattern. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/src/attachments/attachments.module.ts | 4 +- .../attachments/attachments.service.spec.ts | 86 +++ .../src/attachments/attachments.service.ts | 23 +- .../src/attachments/upload-attachment.dto.ts | 18 +- .../knowledge-base/dto/upload-document.dto.ts | 26 +- .../knowledge-base/knowledge-base.module.ts | 3 +- .../knowledge-base.service.spec.ts | 54 +- .../knowledge-base/knowledge-base.service.ts | 25 +- .../dto/complete-checklist-item.dto.ts | 12 +- .../offboarding-checklist.service.ts | 19 +- .../src/uploads/dto/create-upload-url.dto.ts | 1 + .../.speakeasy/mcp-uploads-overlay.yaml | 14 + packages/docs/openapi.json | 642 +++++++++++------- 13 files changed, 657 insertions(+), 270 deletions(-) create mode 100644 apps/api/src/attachments/attachments.service.spec.ts diff --git a/apps/api/src/attachments/attachments.module.ts b/apps/api/src/attachments/attachments.module.ts index 52999437e8..dc8cab4b32 100644 --- a/apps/api/src/attachments/attachments.module.ts +++ b/apps/api/src/attachments/attachments.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { UploadsModule } from '../uploads/uploads.module'; import { AttachmentsController } from './attachments.controller'; import { AttachmentsService } from './attachments.service'; @Module({ - imports: [AuthModule], // Import AuthModule for HybridAuthGuard dependencies + // AuthModule: HybridAuthGuard deps. UploadsModule: presigned-upload s3Key reads. + imports: [AuthModule, UploadsModule], controllers: [AttachmentsController], providers: [AttachmentsService], exports: [AttachmentsService], diff --git a/apps/api/src/attachments/attachments.service.spec.ts b/apps/api/src/attachments/attachments.service.spec.ts new file mode 100644 index 0000000000..cde9ffe991 --- /dev/null +++ b/apps/api/src/attachments/attachments.service.spec.ts @@ -0,0 +1,86 @@ +import { BadRequestException } from '@nestjs/common'; + +// Mocks must be declared before importing the service under test. +jest.mock('@/app/s3', () => ({ + s3Client: { send: jest.fn().mockResolvedValue({}) }, + getSignedUrl: jest.fn().mockResolvedValue('https://signed.example/file'), +})); + +jest.mock('@db', () => ({ + db: { attachment: { create: jest.fn() } }, + AttachmentType: { + image: 'image', + video: 'video', + audio: 'audio', + document: 'document', + other: 'other', + }, + AttachmentEntityType: { task: 'task', offboarding_checklist: 'offboarding_checklist' }, +})); + +jest.mock('../utils/file-type-validation', () => ({ + validateFileContent: jest.fn(), +})); + +import { db } from '@db'; +import { AttachmentsService } from './attachments.service'; + +const mockUploadsService = { readUploadAsBase64: jest.fn() }; + +describe('AttachmentsService — presigned s3Key uploads', () => { + let service: AttachmentsService; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.APP_AWS_BUCKET_NAME = 'test-bucket'; + service = new AttachmentsService(mockUploadsService as never); + }); + + it('resolves the file from s3Key (presigned) — no base64 through the LLM — and uploads it', async () => { + mockUploadsService.readUploadAsBase64.mockResolvedValue( + Buffer.from('hello world').toString('base64'), + ); + (db.attachment.create as jest.Mock).mockResolvedValue({ + id: 'att_1', + name: 'rbac.pdf', + type: 'document', + url: 'org_1/attachments/task/tsk_1/key', + createdAt: new Date(), + }); + + const result = await service.uploadAttachment( + 'org_1', + 'tsk_1', + 'task' as never, + { + fileName: 'rbac.pdf', + fileType: 'application/pdf', + s3Key: 'org_1/uploads/attachment/123-rbac.pdf', + } as never, + 'usr_1', + ); + + // Fetched the bytes from the org-scoped presigned key instead of base64. + expect(mockUploadsService.readUploadAsBase64).toHaveBeenCalledWith( + 'org_1', + 'org_1/uploads/attachment/123-rbac.pdf', + ); + expect(db.attachment.create).toHaveBeenCalled(); + expect(result.id).toBe('att_1'); + }); + + it('throws when neither fileData nor s3Key is provided', async () => { + await expect( + service.uploadAttachment( + 'org_1', + 'tsk_1', + 'task' as never, + { fileName: 'rbac.pdf', fileType: 'application/pdf' } as never, + 'usr_1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(mockUploadsService.readUploadAsBase64).not.toHaveBeenCalled(); + expect(db.attachment.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/attachments/attachments.service.ts b/apps/api/src/attachments/attachments.service.ts index 6bb88bed94..849037373a 100644 --- a/apps/api/src/attachments/attachments.service.ts +++ b/apps/api/src/attachments/attachments.service.ts @@ -15,6 +15,7 @@ import { import { randomBytes } from 'crypto'; import { AttachmentResponseDto } from '../tasks/dto/task-responses.dto'; import { UploadAttachmentDto } from './upload-attachment.dto'; +import { UploadsService } from '../uploads/uploads.service'; import { validateFileContent } from '../utils/file-type-validation'; @Injectable() @@ -24,7 +25,7 @@ export class AttachmentsService { private readonly MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; // 100MB private readonly SIGNED_URL_EXPIRY = 900; // 15 minutes - constructor() { + constructor(private readonly uploadsService: UploadsService) { // AWS configuration is validated at startup via ConfigModule // Safe to access environment variables directly since they're validated this.bucketName = process.env.APP_AWS_BUCKET_NAME!; @@ -115,8 +116,26 @@ export class AttachmentsService { ); } + // Resolve the file content from either inline base64 (UI/direct callers) + // or a presigned-upload s3Key (AI/MCP clients — avoids slow base64 through + // an LLM). readUploadAsBase64 enforces that the key belongs to this org. + const fileData = + uploadDto.fileData ?? + (uploadDto.s3Key + ? await this.uploadsService.readUploadAsBase64( + organizationId, + uploadDto.s3Key, + ) + : undefined); + + if (!fileData) { + throw new BadRequestException( + 'Provide either fileData (base64) or s3Key from /v1/uploads/presign.', + ); + } + // Validate file size - const fileBuffer = Buffer.from(uploadDto.fileData, 'base64'); + const fileBuffer = Buffer.from(fileData, 'base64'); if (fileBuffer.length > this.MAX_FILE_SIZE_BYTES) { throw new BadRequestException( `File size exceeds maximum allowed size of ${this.MAX_FILE_SIZE_BYTES / (1024 * 1024)}MB`, diff --git a/apps/api/src/attachments/upload-attachment.dto.ts b/apps/api/src/attachments/upload-attachment.dto.ts index 6b950486ec..8f8db20a9e 100644 --- a/apps/api/src/attachments/upload-attachment.dto.ts +++ b/apps/api/src/attachments/upload-attachment.dto.ts @@ -29,15 +29,29 @@ export class UploadAttachmentDto { fileType: string; @ApiProperty({ - description: 'Base64 encoded file data', + description: + 'Base64-encoded file contents. For the web UI / direct callers. AI/MCP clients should instead upload via /v1/uploads/presign (purpose=attachment) and pass `s3Key` — base64 through an LLM is impractically slow and times out. Provide exactly one of fileData or s3Key.', + required: false, example: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', }) + @IsOptional() @IsString() @IsNotEmpty() @MaxLength(134_217_728) @IsBase64() - fileData: string; + fileData?: string; + + @ApiProperty({ + description: + 'Key of a file already uploaded via /v1/uploads/presign (purpose=attachment). The server fetches the bytes from storage — no base64 needed. Provide exactly one of fileData or s3Key.', + required: false, + example: 'org_abc123/uploads/attachment/1700000000000-rbac-matrix.xlsx', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + s3Key?: string; @ApiProperty({ description: 'Description of the attachment', diff --git a/apps/api/src/knowledge-base/dto/upload-document.dto.ts b/apps/api/src/knowledge-base/dto/upload-document.dto.ts index 5240521176..557d2a0d13 100644 --- a/apps/api/src/knowledge-base/dto/upload-document.dto.ts +++ b/apps/api/src/knowledge-base/dto/upload-document.dto.ts @@ -1,18 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsOptional, IsString } from 'class-validator'; export class UploadDocumentDto { + @ApiProperty({ description: 'Organization ID that owns the document' }) @IsString() organizationId!: string; + @ApiProperty({ description: 'File name', example: 'rbac-matrix.xlsx' }) @IsString() fileName!: string; + @ApiProperty({ + description: 'MIME type of the file', + example: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }) @IsString() fileType!: string; + @ApiProperty({ + description: + 'Base64-encoded file contents. For the web UI / direct callers. AI/MCP clients should instead upload via /v1/uploads/presign (purpose=document) and pass `s3Key` — base64 through an LLM is impractically slow and times out. Provide exactly one of fileData or s3Key.', + required: false, + }) + @IsOptional() + @IsString() + fileData?: string; // base64 encoded + + @ApiProperty({ + description: + 'Key of a file already uploaded via /v1/uploads/presign (purpose=document). The server fetches the bytes from storage — no base64 needed. Provide exactly one of fileData or s3Key.', + required: false, + }) + @IsOptional() @IsString() - fileData!: string; // base64 encoded + s3Key?: string; + @ApiProperty({ description: 'Optional description', required: false }) @IsOptional() @IsString() description?: string; diff --git a/apps/api/src/knowledge-base/knowledge-base.module.ts b/apps/api/src/knowledge-base/knowledge-base.module.ts index bce607bd61..2774e28ed3 100644 --- a/apps/api/src/knowledge-base/knowledge-base.module.ts +++ b/apps/api/src/knowledge-base/knowledge-base.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { UploadsModule } from '../uploads/uploads.module'; import { KnowledgeBaseController } from './knowledge-base.controller'; import { KnowledgeBaseService } from './knowledge-base.service'; @Module({ - imports: [AuthModule], + imports: [AuthModule, UploadsModule], controllers: [KnowledgeBaseController], providers: [KnowledgeBaseService], }) diff --git a/apps/api/src/knowledge-base/knowledge-base.service.spec.ts b/apps/api/src/knowledge-base/knowledge-base.service.spec.ts index 3b829abce4..efe03abc0c 100644 --- a/apps/api/src/knowledge-base/knowledge-base.service.spec.ts +++ b/apps/api/src/knowledge-base/knowledge-base.service.spec.ts @@ -1,5 +1,11 @@ +import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { KnowledgeBaseService } from './knowledge-base.service'; +import { UploadsService } from '../uploads/uploads.service'; + +const mockUploadsService = { + readUploadAsBase64: jest.fn(), +}; jest.mock('@db', () => ({ db: { @@ -68,7 +74,10 @@ describe('KnowledgeBaseService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [KnowledgeBaseService], + providers: [ + KnowledgeBaseService, + { provide: UploadsService, useValue: mockUploadsService }, + ], }).compile(); service = module.get(KnowledgeBaseService); @@ -215,6 +224,49 @@ describe('KnowledgeBaseService', () => { 'base64data', ); }); + + it('resolves content from s3Key (presigned upload) when no fileData', async () => { + mockUploadsService.readUploadAsBase64.mockResolvedValue('fromS3base64'); + (uploadToS3 as jest.Mock).mockResolvedValue({ + s3Key: 'org_1/doc.pdf', + fileSize: 2048, + }); + (mockDb.knowledgeBaseDocument.create as jest.Mock).mockResolvedValue({ + id: 'd2', + name: 'doc.pdf', + s3Key: 'org_1/doc.pdf', + }); + + await service.uploadDocument({ + organizationId: 'org_1', + fileName: 'doc.pdf', + fileType: 'application/pdf', + s3Key: 'org_1/uploads/document/123-doc.pdf', + } as any); + + // Fetched the bytes from the presigned key (org-scoped, no base64 via LLM) + expect(mockUploadsService.readUploadAsBase64).toHaveBeenCalledWith( + 'org_1', + 'org_1/uploads/document/123-doc.pdf', + ); + expect(uploadToS3).toHaveBeenCalledWith( + 'org_1', + 'doc.pdf', + 'application/pdf', + 'fromS3base64', + ); + }); + + it('throws when neither fileData nor s3Key is provided', async () => { + await expect( + service.uploadDocument({ + organizationId: 'org_1', + fileName: 'doc.pdf', + fileType: 'application/pdf', + } as any), + ).rejects.toBeInstanceOf(BadRequestException); + expect(uploadToS3).not.toHaveBeenCalled(); + }); }); describe('getDownloadUrl', () => { diff --git a/apps/api/src/knowledge-base/knowledge-base.service.ts b/apps/api/src/knowledge-base/knowledge-base.service.ts index 1b754192db..beb2612b98 100644 --- a/apps/api/src/knowledge-base/knowledge-base.service.ts +++ b/apps/api/src/knowledge-base/knowledge-base.service.ts @@ -1,8 +1,9 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { db } from '@db'; import { tasks, auth } from '@trigger.dev/sdk'; import { syncManualAnswerToVector } from '@/vector-store/lib'; import { UploadDocumentDto } from './dto/upload-document.dto'; +import { UploadsService } from '../uploads/uploads.service'; import { DeleteDocumentDto } from './dto/delete-document.dto'; import { GetDocumentUrlDto } from './dto/get-document-url.dto'; import { ProcessDocumentsDto } from './dto/process-documents.dto'; @@ -25,6 +26,8 @@ import { export class KnowledgeBaseService { private readonly logger = new Logger(KnowledgeBaseService.name); + constructor(private readonly uploadsService: UploadsService) {} + async listDocuments(organizationId: string) { return db.knowledgeBaseDocument.findMany({ where: { organizationId }, @@ -44,12 +47,30 @@ export class KnowledgeBaseService { } async uploadDocument(dto: UploadDocumentDto) { + // Resolve content from inline base64 (UI/direct) or a presigned-upload + // s3Key (AI/MCP clients — avoids slow base64 through an LLM). The read + // enforces that the key belongs to this org. + const fileData = + dto.fileData ?? + (dto.s3Key + ? await this.uploadsService.readUploadAsBase64( + dto.organizationId, + dto.s3Key, + ) + : undefined); + + if (!fileData) { + throw new BadRequestException( + 'Provide either fileData (base64) or s3Key from /v1/uploads/presign.', + ); + } + // Upload to S3 const { s3Key, fileSize } = await uploadToS3( dto.organizationId, dto.fileName, dto.fileType, - dto.fileData, + fileData, ); // Create database record diff --git a/apps/api/src/offboarding-checklist/dto/complete-checklist-item.dto.ts b/apps/api/src/offboarding-checklist/dto/complete-checklist-item.dto.ts index 017b7614a2..ced0b9f064 100644 --- a/apps/api/src/offboarding-checklist/dto/complete-checklist-item.dto.ts +++ b/apps/api/src/offboarding-checklist/dto/complete-checklist-item.dto.ts @@ -19,7 +19,8 @@ export class CompleteChecklistItemDto { fileType?: string; @ApiProperty({ - description: 'Base64 encoded evidence file', + description: + 'Base64-encoded evidence file. For the web UI / direct callers. AI/MCP clients should instead upload via /v1/uploads/presign (purpose=evidence) and pass `s3Key` — base64 through an LLM is impractically slow and times out. Provide fileData or s3Key (not both).', required: false, }) @IsOptional() @@ -27,4 +28,13 @@ export class CompleteChecklistItemDto { @MaxLength(134_217_728) @IsBase64() fileData?: string; + + @ApiProperty({ + description: + 'Key of an evidence file already uploaded via /v1/uploads/presign (purpose=evidence). The server fetches the bytes from storage — no base64 needed. Provide fileData or s3Key (not both).', + required: false, + }) + @IsOptional() + @IsString() + s3Key?: string; } diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts index 09a4378478..23fadea7cc 100644 --- a/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts @@ -13,12 +13,15 @@ interface CompleteChecklistItemDto { fileName?: string; fileType?: string; fileData?: string; + s3Key?: string; } interface UploadEvidenceDto { fileName: string; fileType: string; - fileData: string; + // Either inline base64 (UI/direct) or a presigned-upload s3Key (AI/MCP). + fileData?: string; + s3Key?: string; description?: string; } @@ -211,7 +214,14 @@ export class OffboardingChecklistService { throw new NotFoundException('Template item not found'); } - if (template.evidenceRequired && (!dto.fileData || !dto.fileName || !dto.fileType)) { + // Evidence can arrive as inline base64 (fileData) or a presigned-upload + // s3Key (AI/MCP clients — avoids slow base64 through an LLM). + const hasEvidenceFile = Boolean(dto.fileData || dto.s3Key); + + if ( + template.evidenceRequired && + (!hasEvidenceFile || !dto.fileName || !dto.fileType) + ) { throw new BadRequestException('Evidence is required to complete this item'); } @@ -225,8 +235,10 @@ export class OffboardingChecklistService { }, }); - if (dto.fileName && dto.fileData && dto.fileType) { + if (dto.fileName && hasEvidenceFile && dto.fileType) { try { + // AttachmentsService.uploadAttachment resolves the bytes from whichever + // of fileData / s3Key is provided. await this.attachmentsService.uploadAttachment( organizationId, completion.id, @@ -234,6 +246,7 @@ export class OffboardingChecklistService { { fileName: dto.fileName, fileData: dto.fileData, + s3Key: dto.s3Key, fileType: dto.fileType, }, completedById, diff --git a/apps/api/src/uploads/dto/create-upload-url.dto.ts b/apps/api/src/uploads/dto/create-upload-url.dto.ts index 34776c779b..af2cf2a1e1 100644 --- a/apps/api/src/uploads/dto/create-upload-url.dto.ts +++ b/apps/api/src/uploads/dto/create-upload-url.dto.ts @@ -11,6 +11,7 @@ export enum UploadPurpose { policyPdf = 'policy_pdf', evidence = 'evidence', attachment = 'attachment', + document = 'document', general = 'general', } diff --git a/apps/mcp-server/.speakeasy/mcp-uploads-overlay.yaml b/apps/mcp-server/.speakeasy/mcp-uploads-overlay.yaml index 8c6e95b18e..083332aba5 100644 --- a/apps/mcp-server/.speakeasy/mcp-uploads-overlay.yaml +++ b/apps/mcp-server/.speakeasy/mcp-uploads-overlay.yaml @@ -79,3 +79,17 @@ actions: update: x-speakeasy-mcp: disabled: true + + # 7-9. Force presigned uploads for task attachments, knowledge-base documents, + # and offboarding evidence — same reason as the questionnaire tools above: + # base64 through an LLM is impractically slow and times out (customer-reported). + # Strip the inline base64 `fileData` from the MCP tool surface so agents must + # use create-upload-url (purpose=attachment|document|evidence) -> PUT to S3 -> + # pass the returned `s3Key`. The base spec keeps `fileData` for the web UI / + # direct callers; only the MCP tools lose it. + - target: "$.components.schemas.UploadAttachmentDto.properties.fileData" + remove: true + - target: "$.components.schemas.UploadDocumentDto.properties.fileData" + remove: true + - target: "$.components.schemas.CompleteChecklistItemDto.properties.fileData" + remove: true diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 99f0678f1c..da59719315 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -2118,6 +2118,14 @@ "example": "mem_abc123def456", "type": "string" } + }, + { + "name": "skipOffboarding", + "required": true, + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { @@ -3033,6 +3041,66 @@ } } }, + "/v1/uploads/presign": { + "post": { + "description": "Returns a presigned S3 URL plus the s3Key the file lands at. PUT the raw file bytes to that URL, then call the feature tool (e.g. upload-and-parse) with the s3Key instead of sending file data. Bytes never pass through the LLM.", + "operationId": "UploadsController_createUploadUrl_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUploadUrlDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadUrlResponseDto" + } + } + } + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Get a presigned URL to upload a file", + "tags": [ + "Uploads" + ], + "x-mint": { + "metadata": { + "title": "Get a presigned URL to upload a file | Comp AI API", + "sidebarTitle": "Get a presigned URL to upload a file", + "description": "Returns a presigned S3 URL plus the s3Key the file lands at. PUT the raw file bytes to that URL, then call the feature tool (e.g. upload-and-parse) with the.", + "og:title": "Get a presigned URL to upload a file | Comp AI API", + "og:description": "Returns a presigned S3 URL plus the s3Key the file lands at. PUT the raw file bytes to that URL, then call the feature tool (e.g. upload-and-parse) with the." + } + }, + "x-speakeasy-mcp": { + "name": "create-upload-url" + } + } + }, "/v1/timelines": { "get": { "operationId": "TimelinesController_findAll_v1", @@ -3269,18 +3337,11 @@ "name": "department", "required": false, "in": "query", - "description": "Filter by department", + "description": "Filter by department. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.", "schema": { - "type": "string", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ] + "maxLength": 64, + "example": "it", + "type": "string" } }, { @@ -7106,9 +7167,9 @@ "metadata": { "title": "List compliance policies | Comp AI API", "sidebarTitle": "List compliance policies", - "description": "Lists active compliance policies by default. Use includeArchived=true to include archived rows.", + "description": "Lists active compliance policies by default. Use includeArchived=true to include archived rows and excludeContent=true when you only need policy metadata.", "og:title": "List compliance policies | Comp AI API", - "og:description": "Lists active compliance policies by default. Use includeArchived=true to include archived rows." + "og:description": "Lists active compliance policies by default. Use includeArchived=true to include archived rows and excludeContent=true when you only need policy metadata." } }, "x-codeSamples": [ @@ -7184,7 +7245,6 @@ "signedBy": [], "reviewDate": "2024-12-31T00:00:00.000Z", "isArchived": false, - "archivedAt": null, "createdAt": "2024-01-01T00:00:00.000Z", "updatedAt": "2024-01-15T00:00:00.000Z", "organizationId": "org_abc123def456", @@ -8261,7 +8321,6 @@ ], "reviewDate": "2024-12-31T00:00:00.000Z", "isArchived": false, - "archivedAt": null, "createdAt": "2024-01-01T00:00:00.000Z", "updatedAt": "2024-01-15T00:00:00.000Z", "organizationId": "org_abc123def456", @@ -10086,66 +10145,6 @@ } } }, - "/v1/uploads/presign": { - "post": { - "description": "Returns a presigned S3 URL plus the s3Key the file lands at. PUT the raw file bytes to that URL, then call the feature tool (e.g. upload-and-parse) with the s3Key instead of sending file data. Bytes never pass through the LLM.", - "operationId": "UploadsController_createUploadUrl_v1", - "parameters": [ - { - "name": "X-Organization-Id", - "in": "header", - "description": "Organization ID (required for session auth, optional for API key auth)", - "required": false, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateUploadUrlDto" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UploadUrlResponseDto" - } - } - } - } - }, - "security": [ - { - "apikey": [] - } - ], - "summary": "Get a presigned URL to upload a file", - "tags": [ - "Uploads" - ], - "x-mint": { - "metadata": { - "title": "Get a presigned URL to upload a file | Comp AI API", - "sidebarTitle": "Get a presigned URL to upload a file", - "description": "Returns a presigned S3 URL plus the s3Key the file lands at. PUT the raw file bytes to that URL, then call the feature tool (e.g. upload-and-parse) with the.", - "og:title": "Get a presigned URL to upload a file | Comp AI API", - "og:description": "Returns a presigned S3 URL plus the s3Key the file lands at. PUT the raw file bytes to that URL, then call the feature tool (e.g. upload-and-parse) with the." - } - }, - "x-speakeasy-mcp": { - "name": "create-upload-url" - } - } - }, "/v1/tasks": { "get": { "description": "List compliance tasks with assignments and status so teams can track audit readiness, evidence work, and control implementation.", @@ -10269,17 +10268,10 @@ }, "department": { "type": "string", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ], "nullable": true, - "example": "it" + "example": "it", + "maxLength": 64, + "description": "Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted." }, "controlIds": { "type": "array", @@ -10980,16 +10972,9 @@ }, "department": { "type": "string", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ], - "example": "it" + "example": "it", + "maxLength": 64, + "description": "Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted." }, "reviewDate": { "type": "string", @@ -17421,6 +17406,49 @@ } } }, + "/v1/soa/get-setup": { + "post": { + "operationId": "SOAController_getSetup_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnsureSOASetupDto" + } + } + } + }, + "responses": { + "200": { + "description": "Setup returned (configuration/document may be null)" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Read SOA configuration and document without creating either", + "tags": [ + "SOA" + ], + "description": "Read SOA configuration and document without creating either in Comp AI. Create, auto-fill, review, approve, and export ISO 27001 Statement of Applicability documents.", + "x-mint": { + "metadata": { + "title": "Read SOA configuration and document without | Comp AI API", + "sidebarTitle": "Read SOA configuration and document without creating either", + "description": "Read SOA configuration and document without creating either in Comp AI. Create, auto-fill, review, approve, and export ISO 27001 Statement of Applicability.", + "og:title": "Read SOA configuration and document without | Comp AI API", + "og:description": "Read SOA configuration and document without creating either in Comp AI. Create, auto-fill, review, approve, and export ISO 27001 Statement of Applicability." + } + }, + "x-speakeasy-mcp": { + "name": "get-setup" + } + } + }, "/v1/soa/approve": { "post": { "operationId": "SOAController_approveDocument_v1", @@ -19102,10 +19130,83 @@ } } }, + "/v1/integrations/sync/device-sync-provider": { + "get": { + "operationId": "SyncController_getDeviceSyncProvider_v1", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Get the currently configured device sync provider", + "tags": [ + "Integrations" + ], + "description": "Get the currently configured device sync provider in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage variables, and collect automated evidence.", + "x-mint": { + "metadata": { + "title": "Get the currently configured device sync | Comp AI API", + "sidebarTitle": "Get the currently configured device sync provider", + "description": "Get the currently configured device sync provider in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage.", + "og:title": "Get the currently configured device sync | Comp AI API", + "og:description": "Get the currently configured device sync provider in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage." + } + }, + "x-speakeasy-mcp": { + "name": "get-device-sync-provider" + } + }, + "post": { + "operationId": "SyncController_setDeviceSyncProvider_v1", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Set the device sync provider", + "tags": [ + "Integrations" + ], + "description": "Set the device sync provider in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage variables, and collect automated evidence.", + "x-mint": { + "metadata": { + "title": "Set the device sync provider | Comp AI API", + "sidebarTitle": "Set the device sync provider", + "description": "Set the device sync provider in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage variables, and collect.", + "og:title": "Set the device sync provider | Comp AI API", + "og:description": "Set the device sync provider in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage variables, and collect." + } + }, + "x-speakeasy-mcp": { + "name": "set-device-sync-provider" + } + } + }, "/v1/integrations/sync/available-providers": { "get": { "operationId": "SyncController_getAvailableSyncProviders_v1", - "parameters": [], + "parameters": [ + { + "name": "syncType", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "" @@ -19116,18 +19217,18 @@ "apikey": [] } ], - "summary": "List employee sync providers available to the org", + "summary": "List sync providers available to the org", "tags": [ "Integrations" ], - "description": "List employee sync providers available to the org in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage variables, and collect automated evidence.", + "description": "List sync providers available to the org in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage variables, and collect automated evidence.", "x-mint": { "metadata": { - "title": "List employee sync providers available to the | Comp AI API", - "sidebarTitle": "List employee sync providers available to the org", - "description": "List employee sync providers available to the org in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage.", - "og:title": "List employee sync providers available to the | Comp AI API", - "og:description": "List employee sync providers available to the org in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage." + "title": "List sync providers available to the org | Comp AI API", + "sidebarTitle": "List sync providers available to the org", + "description": "List sync providers available to the org in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage variables.", + "og:title": "List sync providers available to the org | Comp AI API", + "og:description": "List sync providers available to the org in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage variables." } }, "x-speakeasy-mcp": { @@ -19185,6 +19286,56 @@ } } }, + "/v1/integrations/sync/dynamic/{providerSlug}/devices": { + "post": { + "operationId": "SyncController_syncDynamicProviderDevices_v1", + "parameters": [ + { + "name": "providerSlug", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "connectionId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Sync devices for a dynamic provider", + "tags": [ + "Integrations" + ], + "description": "Sync devices for a dynamic provider in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage variables, and collect automated evidence.", + "x-mint": { + "metadata": { + "title": "Sync devices for a dynamic provider | Comp AI API", + "sidebarTitle": "Sync devices for a dynamic provider", + "description": "Sync devices for a dynamic provider in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage variables.", + "og:title": "Sync devices for a dynamic provider | Comp AI API", + "og:description": "Sync devices for a dynamic provider in Comp AI. Connect vendor systems, configure OAuth apps, run compliance checks, sync employees, manage variables." + } + }, + "x-speakeasy-mcp": { + "name": "sync-dynamic-provider-devices" + } + } + }, "/v1/cloud-security/activity": { "get": { "operationId": "CloudSecurityController_getActivity_v1", @@ -21318,7 +21469,7 @@ }, "/v1/evidence-forms/{formType}/upload-submission": { "post": { - "description": "Upload a file as an evidence submission in Comp AI. Collect, review, upload, and export structured evidence submissions for compliance tasks and document requirements.", + "description": "Upload a PDF or image file and create a submission for the given form type, bypassing form-specific validation. Accepts session, API key, or service token auth. For API key / service token callers without an explicit user attribution, the.", "operationId": "EvidenceFormsController_uploadSubmission_v1", "parameters": [ { @@ -21357,9 +21508,9 @@ "metadata": { "title": "Upload a file as an evidence submission | Comp AI API", "sidebarTitle": "Upload a file as an evidence submission", - "description": "Upload a file as an evidence submission in Comp AI. Collect, review, upload, and export structured evidence submissions for compliance tasks and document.", + "description": "Upload a PDF or image file and create a submission for the given form type, bypassing form-specific validation. Accepts session, API key, or service token.", "og:title": "Upload a file as an evidence submission | Comp AI API", - "og:description": "Upload a file as an evidence submission in Comp AI. Collect, review, upload, and export structured evidence submissions for compliance tasks and document." + "og:description": "Upload a PDF or image file and create a submission for the given form type, bypassing form-specific validation. Accepts session, API key, or service token." } }, "x-speakeasy-mcp": { @@ -24262,16 +24413,7 @@ }, "department": { "type": "string", - "description": "Member department", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ], + "description": "Member department. May be one of the built-in values (none, admin, gov, hr, it, itsm, qms) or a custom department name.", "example": "it" }, "jobTitle": { @@ -24381,17 +24523,9 @@ }, "department": { "type": "string", - "description": "Member department", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ], - "example": "it" + "description": "Member department. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.", + "example": "it", + "maxLength": 64 }, "isActive": { "type": "boolean", @@ -24459,17 +24593,9 @@ }, "department": { "type": "string", - "description": "Member department", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ], - "example": "it" + "description": "Member department. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.", + "example": "it", + "maxLength": 64 }, "isActive": { "type": "boolean", @@ -24544,9 +24670,14 @@ }, "fileData": { "type": "string", - "description": "Base64 encoded file data", + "description": "Base64-encoded file contents. For the web UI / direct callers. AI/MCP clients should instead upload via /v1/uploads/presign (purpose=attachment) and pass `s3Key` — base64 through an LLM is impractically slow and times out. Provide exactly one of fileData or s3Key.", "example": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" }, + "s3Key": { + "type": "string", + "description": "Key of a file already uploaded via /v1/uploads/presign (purpose=attachment). The server fetches the bytes from storage — no base64 needed. Provide exactly one of fileData or s3Key.", + "example": "org_abc123/uploads/attachment/1700000000000-rbac-matrix.xlsx" + }, "description": { "type": "string", "description": "Description of the attachment", @@ -24561,8 +24692,7 @@ }, "required": [ "fileName", - "fileType", - "fileData" + "fileType" ] }, "EmailPreferencesDto": { @@ -24645,9 +24775,14 @@ }, "fileData": { "type": "string", - "description": "Base64 encoded file data", + "description": "Base64-encoded file contents. For the web UI / direct callers. AI/MCP clients should instead upload via /v1/uploads/presign (purpose=attachment) and pass `s3Key` — base64 through an LLM is impractically slow and times out. Provide exactly one of fileData or s3Key.", "example": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" }, + "s3Key": { + "type": "string", + "description": "Key of a file already uploaded via /v1/uploads/presign (purpose=attachment). The server fetches the bytes from storage — no base64 needed. Provide exactly one of fileData or s3Key.", + "example": "org_abc123/uploads/attachment/1700000000000-rbac-matrix.xlsx" + }, "description": { "type": "string", "description": "Description of the attachment", @@ -24677,7 +24812,6 @@ "required": [ "fileName", "fileType", - "fileData", "entityId", "entityType" ] @@ -24726,6 +24860,64 @@ "createdAt" ] }, + "CreateUploadUrlDto": { + "type": "object", + "properties": { + "purpose": { + "type": "string", + "enum": [ + "questionnaire", + "policy_pdf", + "evidence", + "attachment", + "document", + "general" + ], + "description": "What the file is for. Controls where the file is stored and which feature is expected to consume the returned s3Key.", + "example": "questionnaire" + }, + "fileName": { + "type": "string", + "description": "Original filename, used for the stored object name. Non-alphanumeric characters are replaced with underscores.", + "example": "vendor-security-questionnaire.xlsx" + }, + "fileType": { + "type": "string", + "description": "MIME type of the file (e.g. application/pdf, text/csv). Recorded as metadata and passed to the feature endpoint; the PUT itself is content-type agnostic, so the upload never fails on a header mismatch.", + "example": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + } + }, + "required": [ + "purpose", + "fileName", + "fileType" + ] + }, + "UploadUrlResponseDto": { + "type": "object", + "properties": { + "uploadUrl": { + "type": "string", + "description": "Presigned S3 URL. Send the raw file bytes with a plain HTTP PUT to this URL — no Content-Type or auth headers are required (the signature is in the URL). Then call the feature endpoint with the s3Key below.", + "example": "https://bucket.s3.us-east-1.amazonaws.com/org_x/uploads/...?X-Amz-Signature=..." + }, + "s3Key": { + "type": "string", + "description": "The S3 key the file will land at. Pass this to the feature endpoint (e.g. questionnaire upload-and-parse) instead of base64 file data.", + "example": "org_abc/uploads/questionnaire/1735000000-questionnaire.xlsx" + }, + "expiresIn": { + "type": "number", + "description": "Seconds until the presigned URL expires.", + "example": 900 + } + }, + "required": [ + "uploadUrl", + "s3Key", + "expiresIn" + ] + }, "CreateRiskDto": { "type": "object", "properties": { @@ -24759,17 +24951,9 @@ }, "department": { "type": "string", - "description": "Department responsible for the risk", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ], - "example": "it" + "description": "Department responsible for the risk. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.", + "example": "it", + "maxLength": 64 }, "status": { "type": "string", @@ -24903,17 +25087,9 @@ }, "department": { "type": "string", - "description": "Department responsible for the risk", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ], - "example": "it" + "description": "Department responsible for the risk. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.", + "example": "it", + "maxLength": 64 }, "status": { "type": "string", @@ -25978,16 +26154,7 @@ }, "department": { "type": "string", - "description": "Department this policy applies to", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ], + "description": "Department this policy applies to. May be one of the built-in values (none, admin, gov, hr, it, itsm, qms) or a custom department name.", "example": "it", "nullable": true }, @@ -26236,17 +26403,9 @@ }, "department": { "type": "string", - "description": "Department this policy applies to", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ], - "example": "it" + "description": "Department this policy applies to. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.", + "example": "it", + "maxLength": 64 }, "isRequiredToSign": { "type": "boolean", @@ -26360,17 +26519,9 @@ }, "department": { "type": "string", - "description": "Department this policy applies to", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ], - "example": "it" + "description": "Department this policy applies to. Built-in values: none, admin, gov, hr, it, itsm, qms. Custom department names are also accepted.", + "example": "it", + "maxLength": 64 }, "isRequiredToSign": { "type": "boolean", @@ -26561,63 +26712,6 @@ "type": "object", "properties": {} }, - "CreateUploadUrlDto": { - "type": "object", - "properties": { - "purpose": { - "type": "string", - "enum": [ - "questionnaire", - "policy_pdf", - "evidence", - "attachment", - "general" - ], - "description": "What the file is for. Controls where the file is stored and which feature is expected to consume the returned s3Key.", - "example": "questionnaire" - }, - "fileName": { - "type": "string", - "description": "Original filename, used for the stored object name. Non-alphanumeric characters are replaced with underscores.", - "example": "vendor-security-questionnaire.xlsx" - }, - "fileType": { - "type": "string", - "description": "MIME type of the file (e.g. application/pdf, text/csv). Recorded as metadata and passed to the feature endpoint; the PUT itself is content-type agnostic, so the upload never fails on a header mismatch.", - "example": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - } - }, - "required": [ - "purpose", - "fileName", - "fileType" - ] - }, - "UploadUrlResponseDto": { - "type": "object", - "properties": { - "uploadUrl": { - "type": "string", - "description": "Presigned S3 URL. Send the raw file bytes with a plain HTTP PUT to this URL — no Content-Type or auth headers are required (the signature is in the URL). Then call the feature endpoint with the s3Key below.", - "example": "https://bucket.s3.us-east-1.amazonaws.com/org_x/uploads/...?X-Amz-Signature=..." - }, - "s3Key": { - "type": "string", - "description": "The S3 key the file will land at. Pass this to the feature endpoint (e.g. questionnaire upload-and-parse) instead of base64 file data.", - "example": "org_abc/uploads/questionnaire/1735000000-questionnaire.xlsx" - }, - "expiresIn": { - "type": "number", - "description": "Seconds until the presigned URL expires.", - "example": 900 - } - }, - "required": [ - "uploadUrl", - "s3Key", - "expiresIn" - ] - }, "TaskResponseDto": { "type": "object", "properties": { @@ -27679,7 +27773,39 @@ }, "UploadDocumentDto": { "type": "object", - "properties": {} + "properties": { + "organizationId": { + "type": "string", + "description": "Organization ID that owns the document" + }, + "fileName": { + "type": "string", + "description": "File name", + "example": "rbac-matrix.xlsx" + }, + "fileType": { + "type": "string", + "description": "MIME type of the file", + "example": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + }, + "fileData": { + "type": "string", + "description": "Base64-encoded file contents. For the web UI / direct callers. AI/MCP clients should instead upload via /v1/uploads/presign (purpose=document) and pass `s3Key` — base64 through an LLM is impractically slow and times out. Provide exactly one of fileData or s3Key." + }, + "s3Key": { + "type": "string", + "description": "Key of a file already uploaded via /v1/uploads/presign (purpose=document). The server fetches the bytes from storage — no base64 needed. Provide exactly one of fileData or s3Key." + }, + "description": { + "type": "string", + "description": "Optional description" + } + }, + "required": [ + "organizationId", + "fileName", + "fileType" + ] }, "ProcessDocumentsDto": { "type": "object", @@ -28711,7 +28837,11 @@ }, "fileData": { "type": "string", - "description": "Base64 encoded evidence file" + "description": "Base64-encoded evidence file. For the web UI / direct callers. AI/MCP clients should instead upload via /v1/uploads/presign (purpose=evidence) and pass `s3Key` — base64 through an LLM is impractically slow and times out. Provide fileData or s3Key (not both)." + }, + "s3Key": { + "type": "string", + "description": "Key of an evidence file already uploaded via /v1/uploads/presign (purpose=evidence). The server fetches the bytes from storage — no base64 needed. Provide fileData or s3Key (not both)." } } } From 98a4fa20a38047f8d5d6d6e3c68f332139f99179 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 5 Jun 2026 10:52:19 -0400 Subject: [PATCH 2/7] fix(cloud-security): scope cloud tests findings to the selected account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customers with multiple AWS accounts couldn't filter the findings list down to one account — selecting a different account in the connection selector did nothing, the list always showed every account's findings merged. Root cause: the per-section findings filter used `f.providerSlug === providerSlug || f.connectionId === connectionId`. The first clause matches EVERY finding of the provider, so the connection (account) clause never narrowed anything. The selector switched the active connectionId, but the filter ignored it. - Extract `filterFindingsByConnection(findings, connectionId)` and use it for both the findings list and the project-name pills in CloudTestsSection, so the view is scoped strictly to the selected connection (= selected account). Every finding carries a required connectionId and each section renders with the selected connection's id, so this is safe (nothing is hidden). - Test: filterFindingsByConnection scopes to one account and does not leak another account of the same provider (regression guard). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/CloudTestsSection.tsx | 19 ++++----- .../components/finding-filters.test.ts | 41 +++++++++++++++++++ .../cloud-tests/components/finding-filters.ts | 22 ++++++++++ 3 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/finding-filters.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/cloud-tests/components/finding-filters.ts diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx index ec3d58383d..1d1b4cc9d8 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudTestsSection.tsx @@ -47,6 +47,7 @@ import type { Finding } from '../types'; import { CheckDefinitionPanel } from './CheckDefinitionPanel'; import { CheckGroupBlock } from './CheckGroupBlock'; import { buildCheckGroups } from './check-groups'; +import { filterFindingsByConnection } from './finding-filters'; import { EvidenceJsonViewer } from './EvidenceJsonViewer'; import { MarkExceptionModal } from './MarkExceptionModal'; import { RemediationSection } from './RemediationSection'; @@ -285,28 +286,26 @@ export function CloudTestsSection({ ); const findings = useMemo(() => { - return allFindings - .filter((f) => f.providerSlug === providerSlug || f.connectionId === connectionId) + // Scope to the selected connection (= the selected cloud account) so + // picking a different account narrows the list to that account's findings. + return filterFindingsByConnection(allFindings, connectionId) .filter((f) => !projectFilter || f.projectDisplayName === projectFilter) .sort( (a, b) => (SEVERITY_ORDER[a.severity ?? 'info'] ?? 5) - (SEVERITY_ORDER[b.severity ?? 'info'] ?? 5), ); - }, [allFindings, providerSlug, connectionId, projectFilter]); + }, [allFindings, connectionId, projectFilter]); - // Unique project names across all findings (for filter pills) + // Unique project names for the selected connection (for filter pills) const projectNames = useMemo(() => { const names = new Set(); - for (const f of allFindings) { - if ( - (f.providerSlug === providerSlug || f.connectionId === connectionId) && - f.projectDisplayName - ) { + for (const f of filterFindingsByConnection(allFindings, connectionId)) { + if (f.projectDisplayName) { names.add(f.projectDisplayName); } } return [...names].sort((a, b) => a.localeCompare(b)); - }, [allFindings, providerSlug, connectionId]); + }, [allFindings, connectionId]); const failedFindings = findings.filter((f) => f.status === 'failed' || f.status === 'FAILED'); const passedFindings = findings.filter((f) => f.status === 'passed' || f.status === 'success'); diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/finding-filters.test.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/finding-filters.test.ts new file mode 100644 index 0000000000..5d350bdaf0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/finding-filters.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { filterFindingsByConnection } from './finding-filters'; + +type TestFinding = { + connectionId: string; + providerSlug: string; + title: string; +}; + +const findings: TestFinding[] = [ + { connectionId: 'aws-acct-a', providerSlug: 'aws', title: 'a-1' }, + { connectionId: 'aws-acct-a', providerSlug: 'aws', title: 'a-2' }, + { connectionId: 'aws-acct-b', providerSlug: 'aws', title: 'b-1' }, + { connectionId: 'gcp-conn-1', providerSlug: 'gcp', title: 'g-1' }, +]; + +describe('filterFindingsByConnection', () => { + it('returns only the selected connection (account) findings', () => { + const result = filterFindingsByConnection(findings, 'aws-acct-a'); + expect(result.map((f) => f.title)).toEqual(['a-1', 'a-2']); + }); + + it('does NOT leak findings from another account of the same provider (the bug)', () => { + // Regression guard: the old `providerSlug === providerSlug || ...` filter + // returned every AWS finding regardless of the selected account. + const result = filterFindingsByConnection(findings, 'aws-acct-b'); + expect(result).toHaveLength(1); + expect(result[0].title).toBe('b-1'); + expect(result.some((f) => f.connectionId === 'aws-acct-a')).toBe(false); + }); + + it('scopes to a single connection across providers', () => { + expect(filterFindingsByConnection(findings, 'gcp-conn-1').map((f) => f.title)).toEqual([ + 'g-1', + ]); + }); + + it('returns an empty array when no finding matches the connection', () => { + expect(filterFindingsByConnection(findings, 'does-not-exist')).toEqual([]); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/finding-filters.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/finding-filters.ts new file mode 100644 index 0000000000..1e9bed6ca0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/finding-filters.ts @@ -0,0 +1,22 @@ +/** + * Scope a flat list of findings to a single connection (= one cloud account). + * + * Cloud Tests fetches findings for every connected account in one call, and + * each provider section renders only the *selected* account's findings. The + * scoping was previously written as + * `f.providerSlug === providerSlug || f.connectionId === connectionId` + * — an OR whose first clause matches EVERY finding of the provider, so the + * second clause never narrowed anything. With multiple AWS accounts, picking a + * different account in the connection selector therefore did nothing: the list + * always showed all accounts' findings merged together. + * + * Scoping strictly by `connectionId` is correct because every finding carries a + * required `connectionId` (see `types.ts`) and each section is rendered with the + * selected connection's id (see `ProviderTabs`). + */ +export function filterFindingsByConnection( + findings: T[], + connectionId: string, +): T[] { + return findings.filter((finding) => finding.connectionId === connectionId); +} From 0f92e4e6b9add712e9a1513ac586f8d99e55ccc4 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 5 Jun 2026 10:57:23 -0400 Subject: [PATCH 3/7] fix(api): harden task automations for MCP/API clients Three related hardening fixes for the automation endpoints that customers hit through the MCP server. 1. create-version no longer 500s on a malformed body. The endpoint used an inline, untyped @Body() that the global ValidationPipe could not see, so a missing version/scriptKey reached Prisma and threw a non-null violation (raw 500). Add a validated, documented CreateVersionDto and map Prisma failures to clean responses: duplicate version -> 409, missing -> 404. 2. Bake a finite default request timeout (x-speakeasy-timeout = 120s) into the generated SDK and MCP server. Without it the generated request functions resolve their timeout to -1 ("no timeout"), so a hung upstream call leaves the MCP fetch dangling forever and the client marks the whole connection unhealthy. 120s covers our slowest endpoints while staying under the ALB's 300s idle timeout. 3. Hide the two automation-authoring tools (create-automation, create-version) from the MCP surface. A working automation needs its evidence script generated, and that step lives only in the enterprise-api browser chat, so over MCP these POSTs only ever create an empty shell plus a version row pointing at a script that was never generated. Disable them via the generation overlay until script generation is a first-class endpoint; the HTTP endpoints stay live for the web UI. openapi.json is edited surgically (root key only) to avoid clobbering the open uploads PR (#3042); the full create-version body appears on the next regen. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/openapi-docs.spec.ts | 9 ++ apps/api/src/openapi/public-docs-metadata.ts | 31 +++++++ .../automations/automations.controller.ts | 5 +- .../automations/automations.service.spec.ts | 83 +++++++++++++++++++ .../tasks/automations/automations.service.ts | 63 +++++++++----- .../dto/create-version.dto.spec.ts | 49 +++++++++++ .../automations/dto/create-version.dto.ts | 35 ++++++++ .../.speakeasy/mcp-uploads-overlay.yaml | 19 +++++ packages/docs/openapi.json | 1 + 9 files changed, 272 insertions(+), 23 deletions(-) create mode 100644 apps/api/src/tasks/automations/automations.service.spec.ts create mode 100644 apps/api/src/tasks/automations/dto/create-version.dto.spec.ts create mode 100644 apps/api/src/tasks/automations/dto/create-version.dto.ts diff --git a/apps/api/src/openapi-docs.spec.ts b/apps/api/src/openapi-docs.spec.ts index 74f95f62d4..d9d42d89ae 100644 --- a/apps/api/src/openapi-docs.spec.ts +++ b/apps/api/src/openapi-docs.spec.ts @@ -113,6 +113,7 @@ import { AppModule } from './app.module'; import { applyPublicOpenApiMetadata, PUBLIC_OPENAPI_DESCRIPTION, + PUBLIC_OPENAPI_TIMEOUT_MS, PUBLIC_OPENAPI_TITLE, PUBLIC_SERVER_URL, } from './openapi/public-docs-metadata'; @@ -156,6 +157,14 @@ describe('OpenAPI document', () => { ]); }); + it('bakes a finite default request timeout into the generated SDK + MCP server', () => { + // Without x-speakeasy-timeout the generated request funcs use -1 ("no + // timeout") and a hung upstream wedges the MCP connection forever. + expect( + (document as { 'x-speakeasy-timeout'?: number })['x-speakeasy-timeout'], + ).toBe(PUBLIC_OPENAPI_TIMEOUT_MS); + }); + it('keeps the public spec complete, SEO-ready, and free of private surfaces', () => { const issues = collectPublicOpenApiIssues(document); diff --git a/apps/api/src/openapi/public-docs-metadata.ts b/apps/api/src/openapi/public-docs-metadata.ts index d2c8b9ad91..b3454be8d8 100644 --- a/apps/api/src/openapi/public-docs-metadata.ts +++ b/apps/api/src/openapi/public-docs-metadata.ts @@ -26,6 +26,31 @@ export const PUBLIC_OPENAPI_DESCRIPTION = export const PUBLIC_SERVER_URL = 'https://api.trycomp.ai'; +/** + * Default request timeout (ms) baked into the generated SDK + MCP server via the + * `x-speakeasy-timeout` document-root extension. + * + * Speakeasy-generated request functions resolve their timeout to + * `operationTimeoutMs || clientTimeoutMs || -1`, and `-1` means "no timeout". + * Without this, a slow/hung upstream call leaves the MCP server's fetch dangling + * forever; the MCP client eventually gives up and marks the whole connection + * unhealthy (customer-reported wedging). A finite timeout makes the SDK abort + * the request and return a clean error instead, keeping the connection alive. + * + * 120s comfortably covers our slowest endpoints while staying under the ALB's + * 300s idle timeout (comp-private infra/modules/loadbalancer.ts). + */ +export const PUBLIC_OPENAPI_TIMEOUT_MS = 120_000; + +/** + * OpenAPIObject (from @nestjs/swagger) has no index signature for `x-*` + * extensions at the document root, so we widen it locally instead of reaching + * for `as any`. + */ +type OpenApiDocumentWithExtensions = OpenAPIObject & { + 'x-speakeasy-timeout'?: number; +}; + function getVisibilityForOperation( operation: OpenApiOperation, metadata?: PublicOperationMetadata, @@ -284,6 +309,12 @@ export function applyPublicOpenApiMetadata(document: OpenAPIObject): void { }, ]; + // Bake a finite default request timeout into the generated SDK + MCP server + // so a hung upstream call can never wedge the MCP connection. See + // PUBLIC_OPENAPI_TIMEOUT_MS for the full rationale. + (document as OpenApiDocumentWithExtensions)['x-speakeasy-timeout'] = + PUBLIC_OPENAPI_TIMEOUT_MS; + const paths = document.paths as Record< string, Record diff --git a/apps/api/src/tasks/automations/automations.controller.ts b/apps/api/src/tasks/automations/automations.controller.ts index 05ce95b6cc..c07c3f0434 100644 --- a/apps/api/src/tasks/automations/automations.controller.ts +++ b/apps/api/src/tasks/automations/automations.controller.ts @@ -10,6 +10,7 @@ import { UseGuards, } from '@nestjs/common'; import { + ApiBody, ApiOperation, ApiParam, ApiResponse, @@ -22,6 +23,7 @@ import { PermissionGuard } from '../../auth/permission.guard'; import { RequirePermission } from '../../auth/require-permission.decorator'; import { TasksService } from '../tasks.service'; import { AutomationsService } from './automations.service'; +import { CreateVersionDto } from './dto/create-version.dto'; import { UpdateAutomationDto } from './dto/update-automation.dto'; import { AUTOMATION_OPERATIONS } from './schemas/automation-operations'; import { CREATE_AUTOMATION_RESPONSES } from './schemas/create-automation.responses'; @@ -255,11 +257,12 @@ export class AutomationsController { }) @ApiParam({ name: 'taskId', description: 'Task ID' }) @ApiParam({ name: 'automationId', description: 'Automation ID' }) + @ApiBody({ type: CreateVersionDto }) async createVersion( @OrganizationId() organizationId: string, @Param('taskId') taskId: string, @Param('automationId') automationId: string, - @Body() body: { version: number; scriptKey: string; changelog?: string }, + @Body() body: CreateVersionDto, ) { await this.tasksService.verifyTaskAccess(organizationId, taskId); return this.automationsService.createVersion(automationId, body); diff --git a/apps/api/src/tasks/automations/automations.service.spec.ts b/apps/api/src/tasks/automations/automations.service.spec.ts new file mode 100644 index 0000000000..f7a2746ae2 --- /dev/null +++ b/apps/api/src/tasks/automations/automations.service.spec.ts @@ -0,0 +1,83 @@ +import { ConflictException, NotFoundException } from '@nestjs/common'; + +// Mock the DB layer before importing the service. We also provide a stand-in +// Prisma.PrismaClientKnownRequestError so the service's `instanceof` checks and +// error-code branches can be exercised without a real database. +jest.mock('@db', () => { + class PrismaClientKnownRequestError extends Error { + code: string; + constructor(message: string, { code }: { code: string }) { + super(message); + this.code = code; + this.name = 'PrismaClientKnownRequestError'; + } + } + + return { + db: { + $transaction: jest.fn(), + evidenceAutomationVersion: { create: jest.fn() }, + evidenceAutomation: { update: jest.fn() }, + }, + Prisma: { PrismaClientKnownRequestError }, + }; +}); + +import { db, Prisma } from '@db'; +import { AutomationsService } from './automations.service'; + +const prismaError = (code: string) => + new Prisma.PrismaClientKnownRequestError(code, { + code, + clientVersion: '5.0.0', + }); + +describe('AutomationsService.createVersion — error mapping', () => { + let service: AutomationsService; + const input = { version: 1, scriptKey: 'org_1/tsk_1/aut_1.v1.js' }; + + beforeEach(() => { + jest.clearAllMocks(); + service = new AutomationsService(); + }); + + it('records the version and returns it on success', async () => { + const created = { id: 'eav_1', version: 1, scriptKey: input.scriptKey }; + (db.$transaction as jest.Mock).mockResolvedValue([created, { id: 'aut_1' }]); + + const result = await service.createVersion('aut_1', input); + + expect(result).toEqual({ success: true, version: created }); + }); + + it('maps a duplicate version (P2002) to a 409 ConflictException', async () => { + (db.$transaction as jest.Mock).mockRejectedValue(prismaError('P2002')); + + await expect(service.createVersion('aut_1', input)).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it('maps a missing automation (P2003 FK violation) to a 404 NotFoundException', async () => { + (db.$transaction as jest.Mock).mockRejectedValue(prismaError('P2003')); + + await expect( + service.createVersion('missing', input), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('maps a missing automation (P2025 record not found) to a 404 NotFoundException', async () => { + (db.$transaction as jest.Mock).mockRejectedValue(prismaError('P2025')); + + await expect( + service.createVersion('missing', input), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rethrows unexpected errors untouched (no masking real 500s)', async () => { + const boom = new Error('db exploded'); + (db.$transaction as jest.Mock).mockRejectedValue(boom); + + await expect(service.createVersion('aut_1', input)).rejects.toBe(boom); + }); +}); diff --git a/apps/api/src/tasks/automations/automations.service.ts b/apps/api/src/tasks/automations/automations.service.ts index 86a6fd8ee9..1e8bf00254 100644 --- a/apps/api/src/tasks/automations/automations.service.ts +++ b/apps/api/src/tasks/automations/automations.service.ts @@ -1,5 +1,10 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { db } from '@db'; +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db, Prisma } from '@db'; +import { CreateVersionDto } from './dto/create-version.dto'; import { UpdateAutomationDto } from './dto/update-automation.dto'; @Injectable() @@ -135,26 +140,40 @@ export class AutomationsService { }; } - async createVersion( - automationId: string, - data: { version: number; scriptKey: string; changelog?: string }, - ) { - const [version] = await db.$transaction([ - db.evidenceAutomationVersion.create({ - data: { - evidenceAutomationId: automationId, - version: data.version, - scriptKey: data.scriptKey, - changelog: data.changelog, - }, - }), - // Enable automation on publish if not already enabled - db.evidenceAutomation.update({ - where: { id: automationId }, - data: { isEnabled: true }, - }), - ]); - return { success: true, version }; + async createVersion(automationId: string, data: CreateVersionDto) { + try { + const [version] = await db.$transaction([ + db.evidenceAutomationVersion.create({ + data: { + evidenceAutomationId: automationId, + version: data.version, + scriptKey: data.scriptKey, + changelog: data.changelog, + }, + }), + // Enable automation on publish if not already enabled + db.evidenceAutomation.update({ + where: { id: automationId }, + data: { isEnabled: true }, + }), + ]); + return { success: true, version }; + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError) { + // Duplicate (evidenceAutomationId, version) — version already published. + if (err.code === 'P2002') { + throw new ConflictException( + `Version ${data.version} already exists for this automation`, + ); + } + // Automation row missing — FK on create (P2003) or update target gone + // (P2025). Surface a clean 404 instead of a raw 500. + if (err.code === 'P2003' || err.code === 'P2025') { + throw new NotFoundException(`Automation ${automationId} not found`); + } + } + throw err; + } } async findRunsByAutomationId(automationId: string) { diff --git a/apps/api/src/tasks/automations/dto/create-version.dto.spec.ts b/apps/api/src/tasks/automations/dto/create-version.dto.spec.ts new file mode 100644 index 0000000000..84957afb01 --- /dev/null +++ b/apps/api/src/tasks/automations/dto/create-version.dto.spec.ts @@ -0,0 +1,49 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { CreateVersionDto } from './create-version.dto'; + +/** + * The original endpoint accepted an inline, untyped `@Body()` — invisible to the + * ValidationPipe — so a missing `version`/`scriptKey` slipped through and blew up + * with a Prisma non-null violation (500). These tests prove the DTO now rejects + * those payloads at the validation layer (400) before they reach the service. + */ +describe('CreateVersionDto', () => { + async function validatePayload(payload: Record) { + return validate(plainToInstance(CreateVersionDto, payload)); + } + + it('accepts a valid payload', async () => { + const errors = await validatePayload({ + version: 1, + scriptKey: 'org_1/tsk_1/aut_1.v1.js', + changelog: 'initial publish', + }); + expect(errors).toHaveLength(0); + }); + + it('rejects a missing version (previously a 500)', async () => { + const errors = await validatePayload({ scriptKey: 'k' }); + expect(errors.some((e) => e.property === 'version')).toBe(true); + }); + + it('rejects a missing scriptKey (previously a 500)', async () => { + const errors = await validatePayload({ version: 1 }); + expect(errors.some((e) => e.property === 'scriptKey')).toBe(true); + }); + + it('rejects a version below 1', async () => { + const errors = await validatePayload({ version: 0, scriptKey: 'k' }); + expect(errors.some((e) => e.property === 'version')).toBe(true); + }); + + it('rejects an empty scriptKey', async () => { + const errors = await validatePayload({ version: 1, scriptKey: '' }); + expect(errors.some((e) => e.property === 'scriptKey')).toBe(true); + }); + + it('treats changelog as optional', async () => { + const errors = await validatePayload({ version: 2, scriptKey: 'k' }); + expect(errors.some((e) => e.property === 'changelog')).toBe(false); + }); +}); diff --git a/apps/api/src/tasks/automations/dto/create-version.dto.ts b/apps/api/src/tasks/automations/dto/create-version.dto.ts new file mode 100644 index 0000000000..a44e74cc6d --- /dev/null +++ b/apps/api/src/tasks/automations/dto/create-version.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator'; + +/** + * Records that an automation script has been generated + published to S3. + * `version` and `scriptKey` are REQUIRED — the row references an already-stored + * script. The web UI's publish flow supplies both from the enterprise publish + * step; calling this without them used to 500 (Prisma non-null violation). + */ +export class CreateVersionDto { + @ApiProperty({ + description: 'Version number for this published script', + example: 1, + }) + @IsInt() + @Min(1) + version!: number; + + @ApiProperty({ + description: + 'S3 key of the already-generated & published automation script (returned by the publish step).', + example: 'org_abc123/tsk_abc123/aut_abc123.v1.js', + }) + @IsString() + @IsNotEmpty() + scriptKey!: string; + + @ApiProperty({ + description: 'Optional changelog describing this version', + required: false, + }) + @IsOptional() + @IsString() + changelog?: string; +} diff --git a/apps/mcp-server/.speakeasy/mcp-uploads-overlay.yaml b/apps/mcp-server/.speakeasy/mcp-uploads-overlay.yaml index 8c6e95b18e..f0b0d393df 100644 --- a/apps/mcp-server/.speakeasy/mcp-uploads-overlay.yaml +++ b/apps/mcp-server/.speakeasy/mcp-uploads-overlay.yaml @@ -79,3 +79,22 @@ actions: update: x-speakeasy-mcp: disabled: true + + # 7-8. Disable the automation-authoring tools for MCP. Creating a working + # automation requires generating its evidence-collection SCRIPT, and that + # step lives ONLY in the enterprise-api browser chat (an AI tool writes the + # .draft.js to S3, then publish copies it to .vN.js). Neither the public API + # nor MCP can produce that script. So over MCP these two POSTs only ever + # create an empty automation shell + a version row pointing at a script that + # was never generated — a dead end that confuses agents. Hide them until the + # script-generation step is exposed as a first-class (async) endpoint. + # The HTTP endpoints stay live for the web UI, which drives the full flow. + # Read/list/update automation tools remain available over MCP. + - target: "$.paths['/v1/tasks/{taskId}/automations'].post" + update: + x-speakeasy-mcp: + disabled: true + - target: "$.paths['/v1/tasks/{taskId}/automations/{automationId}/versions'].post" + update: + x-speakeasy-mcp: + disabled: true diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 99f0678f1c..307557782f 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -1,5 +1,6 @@ { "openapi": "3.0.0", + "x-speakeasy-timeout": 120000, "paths": { "/v1/organization": { "get": { From 2d1404ed5abfcc0fa28a95ada5680365ee55535b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 5 Jun 2026 10:57:47 -0400 Subject: [PATCH 4/7] feat(cloud-security): label the cloud tests connection selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connection selector doubles as the account filter, but it had no label, so it wasn't obvious that picking an item scopes the findings to that account. Add a provider-aware label next to it — "Account" for AWS, "Subscription" for Azure, "Connection" for GCP — and use the same term in the placeholder. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cloud-tests/components/ProviderTabs.tsx | 56 ++++++++++++------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx index 516da323d1..3da7e2c21f 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx @@ -279,6 +279,15 @@ export function ProviderTabs({ {providerTypes.map((providerType) => { const connections = providerGroups[providerType] || []; const activeConnId = activeConnectionTabs[providerType] || connections[0]?.id; + // The connection selector also filters the findings list, so label it + // with the provider's term for a connection (AWS account, Azure + // subscription, etc.) to make that clear. + const connectionNoun = + providerType === 'aws' + ? 'Account' + : providerType === 'azure' + ? 'Subscription' + : 'Connection'; if (connections.length === 0) { return ; @@ -292,27 +301,32 @@ export function ProviderTabs({ onValueChange={(value) => onConnectionTabChange(providerType, value)} >
- +
+ + {connectionNoun}: + + +
{/* Only show "Add connection" button for providers that support multiple connections */} {canAddConnection !== false && connections.some((c) => c.supportsMultipleConnections) && (