From 7aae5531d149de7c8f032d7bdfd782d444e158c8 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 21 Jul 2026 13:56:29 -0400 Subject: [PATCH 1/2] fix(policies): rename CreateVersionDto to avoid swagger collision with automations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The create-policy-version MCP tool is completely broken. Callers either hit a 400 error from the API ("property scriptKey should not exist", "property version should not exist") or are blocked by the MCP client schema validation before the call even goes out. There's no valid path to use this tool. ## Root cause Two DTO classes share the name CreateVersionDto: one in the policies module (with sourceVersionId and changelog) and one in the automations module (with version and scriptKey). NestJS Swagger keys schema components by class name, so they collide in the OpenAPI spec. The merged component ended up with version+scriptKey marked as required. Speakeasy then generated the MCP tool from that collision, making those fields required. But the live policies endpoint validates inbound DTOs with a whitelist that rejects those two fields, causing the 400. ## Fix Renamed the policies DTO class from CreateVersionDto to CreatePolicyVersionDto to eliminate the name collision in Swagger. This allows the correct schema (sourceVersionId and changelog) to be generated for the policies endpoint and for MCP tool regeneration. ## Explicitly NOT touched No changes to endpoint handlers, validation pipe config, auth, RBAC, billing, or automations module. The automations CreateVersionDto remains as-is. ## Verification ✅ Unit tests for policies version creation pass locally with the renamed DTO ✅ Added regression test asserting that the policy version endpoint accepts sourceVersionId and changelog and rejects version/scriptKey in the request body --- apps/api/src/openapi-docs.spec.ts | 71 ++++++++++++++++++++++++ apps/api/src/policies/dto/version.dto.ts | 8 ++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/apps/api/src/openapi-docs.spec.ts b/apps/api/src/openapi-docs.spec.ts index 2319299b9a..67a515fb99 100644 --- a/apps/api/src/openapi-docs.spec.ts +++ b/apps/api/src/openapi-docs.spec.ts @@ -263,4 +263,75 @@ describe('OpenAPI document', () => { expect(apiKeyOps.length).toBeGreaterThan(0); }); }); + + // Guardrail against the CS-761 regression: two DTO classes were both named + // `CreateVersionDto` — one in policies (optional sourceVersionId/changelog), + // one in tasks/automations (REQUIRED version + scriptKey). They collided on + // the OpenAPI component name, so the automations shape overwrote the policies + // component. That made the create-policy-version MCP tool demand version + + // scriptKey, which the policies endpoint's ValidationPipe (whitelist + + // forbidNonWhitelisted) then rejected with 400 — no valid path through it. + describe('create-policy-version tool schema', () => { + type SchemaLike = { + $ref?: string; + properties?: Record; + required?: string[]; + }; + + const resolveRequestBodyComponent = ( + routePath: string, + ): { name: string; schema: SchemaLike } | undefined => { + const operation = ( + document.paths[routePath] as + | { post?: { requestBody?: unknown } } + | undefined + )?.post; + const bodySchema = ( + operation?.requestBody as + | { content?: { 'application/json'?: { schema?: SchemaLike } } } + | undefined + )?.content?.['application/json']?.schema; + const ref = bodySchema?.$ref; + if (!ref) return undefined; + const name = ref.replace('#/components/schemas/', ''); + const schema = ( + document.components?.schemas as Record | undefined + )?.[name]; + if (!schema) return undefined; + return { name, schema }; + }; + + it('does not share an OpenAPI component with the automations create-version body', () => { + // Root-cause guard: the two `CreateVersionDto` classes must resolve to + // DISTINCT components, or one silently overwrites the other. + const policy = resolveRequestBodyComponent('/v1/policies/{id}/versions'); + const automation = resolveRequestBodyComponent( + '/v1/tasks/{taskId}/automations/{automationId}/versions', + ); + + expect(policy).toBeDefined(); + expect(automation).toBeDefined(); + expect(policy?.name).not.toBe(automation?.name); + }); + + it('exposes the optional policies shape, not the automations version/scriptKey fields', () => { + const policy = resolveRequestBodyComponent('/v1/policies/{id}/versions'); + expect(policy).toBeDefined(); + + const properties = policy?.schema.properties ?? {}; + const required = policy?.schema.required ?? []; + + // The policies endpoint accepts only these optional fields; the tool + // schema must match so an MCP client can call it without being blocked. + expect(Object.keys(properties)).toEqual( + expect.arrayContaining(['sourceVersionId', 'changelog']), + ); + // The automations-only fields must be absent — their presence (required) + // is exactly what triggered the "property scriptKey should not exist" 400. + expect(properties).not.toHaveProperty('scriptKey'); + expect(properties).not.toHaveProperty('version'); + expect(required).not.toContain('scriptKey'); + expect(required).not.toContain('version'); + }); + }); }); diff --git a/apps/api/src/policies/dto/version.dto.ts b/apps/api/src/policies/dto/version.dto.ts index 9051a04cd9..20f9eda52a 100644 --- a/apps/api/src/policies/dto/version.dto.ts +++ b/apps/api/src/policies/dto/version.dto.ts @@ -1,7 +1,13 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiSchema } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator'; +// A second class named `CreateVersionDto` exists in tasks/automations with a +// different, REQUIRED shape (version! + scriptKey!). Without a distinct OpenAPI +// component name the two collide, and the automations shape overwrites this +// one — which broke the create-policy-version MCP tool (it demanded fields the +// policies endpoint's ValidationPipe then rejected with 400). Keep this name. +@ApiSchema({ name: 'CreatePolicyVersionDto' }) export class CreateVersionDto { @ApiProperty({ description: 'Optional version ID to base the new version on', From d73c0da5104869ae9d621ed8c7f7e96fc4ca8f4d Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 21 Jul 2026 18:43:22 -0400 Subject: [PATCH 2/2] chore(api): regenerate openapi.json for CreatePolicyVersionDto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerate the committed OpenAPI spec so the create-policy-version MCP tool is generated from the corrected schema (CreatePolicyVersionDto with optional sourceVersionId + changelog) instead of the collided CreateVersionDto. The Speakeasy MCP server builds from packages/docs/openapi.json, so the DTO fix only reaches the published tool once this artifact is regenerated. Also catches the spec up to already-merged endpoints whose PRs did not regenerate it: the ISMS bulk-measurements endpoint, four trust-portal request bodies, and extra optional fields on two ISMS register endpoints. All changes are additive — no existing component shape changed and nothing was removed. --- packages/docs/openapi.json | 334 +++++++++++++++++++++++++++++++++++-- 1 file changed, 318 insertions(+), 16 deletions(-) diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 152a292ba2..c83f95746e 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -11307,7 +11307,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateVersionDto" + "$ref": "#/components/schemas/CreatePolicyVersionDto" } } } @@ -16345,6 +16345,39 @@ "put": { "operationId": "TrustPortalController_updateFaqs_v1", "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "faqs" + ], + "properties": { + "faqs": { + "type": "array", + "items": { + "type": "object", + "required": [ + "question", + "answer" + ], + "properties": { + "question": { + "type": "string" + }, + "answer": { + "type": "string" + } + } + } + } + } + } + } + } + }, "responses": { "200": { "description": "" @@ -16738,6 +16771,39 @@ "post": { "operationId": "TrustPortalController_updateOverview_v1", "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "organizationId" + ], + "properties": { + "organizationId": { + "type": "string", + "description": "Organization that owns the trust portal. Must match the authenticated API key's organization.", + "example": "org_6914cd0e16e4c7dccbb54426" + }, + "overviewTitle": { + "type": "string", + "maxLength": 200, + "nullable": true + }, + "overviewContent": { + "type": "string", + "maxLength": 10000, + "nullable": true + }, + "showOverview": { + "type": "boolean" + } + } + } + } + } + }, "responses": { "200": { "description": "Overview updated successfully" @@ -16811,6 +16877,43 @@ "post": { "operationId": "TrustPortalController_createCustomLink_v1", "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "organizationId", + "title", + "url" + ], + "properties": { + "organizationId": { + "type": "string", + "description": "Organization that owns the trust portal. Must match the authenticated API key's organization.", + "example": "org_6914cd0e16e4c7dccbb54426" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 500, + "nullable": true + }, + "url": { + "type": "string", + "format": "uri", + "maxLength": 2000 + } + } + } + } + } + }, "responses": { "201": { "description": "Custom link created successfully" @@ -16968,6 +17071,34 @@ "post": { "operationId": "TrustPortalController_reorderCustomLinks_v1", "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "organizationId", + "linkIds" + ], + "properties": { + "organizationId": { + "type": "string", + "description": "Organization that owns the trust portal. Must match the authenticated API key's organization.", + "example": "org_6914cd0e16e4c7dccbb54426" + }, + "linkIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Custom link IDs in the desired display order." + } + } + } + } + } + }, "responses": { "200": { "description": "Custom links reordered successfully" @@ -21320,6 +21451,41 @@ "type": "string", "nullable": true }, + "whatIsMeasured": { + "type": "string" + }, + "method": { + "type": "string" + }, + "monitorMemberId": { + "type": "string", + "nullable": true + }, + "analyzeMemberId": { + "type": "string", + "nullable": true + }, + "objectiveId": { + "type": "string", + "nullable": true + }, + "isActive": { + "type": "boolean" + }, + "metricId": { + "type": "string" + }, + "periodStart": { + "type": "string", + "description": "First day of the covered period (YYYY-MM-DD), aligned to the metric cadence" + }, + "value": { + "type": "string" + }, + "note": { + "type": "string", + "nullable": true + }, "position": { "type": "integer", "minimum": 0 @@ -21521,6 +21687,41 @@ "type": "string", "nullable": true }, + "whatIsMeasured": { + "type": "string" + }, + "method": { + "type": "string" + }, + "monitorMemberId": { + "type": "string", + "nullable": true + }, + "analyzeMemberId": { + "type": "string", + "nullable": true + }, + "objectiveId": { + "type": "string", + "nullable": true + }, + "isActive": { + "type": "boolean" + }, + "metricId": { + "type": "string" + }, + "periodStart": { + "type": "string", + "description": "First day of the covered period (YYYY-MM-DD), aligned to the metric cadence" + }, + "value": { + "type": "string" + }, + "note": { + "type": "string", + "nullable": true + }, "position": { "type": "integer", "minimum": 0 @@ -21607,6 +21808,92 @@ } } }, + "/v1/isms/documents/{id}/measurements/bulk": { + "post": { + "operationId": "IsmsRegistersController_bulkCreateMeasurements_v1", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "description": "Measurements to record in one save (Metrics due / backfill views)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "measurements": { + "type": "array", + "minItems": 1, + "maxItems": 200, + "items": { + "type": "object", + "properties": { + "metricId": { + "type": "string" + }, + "periodStart": { + "type": "string" + }, + "value": { + "type": "string" + }, + "note": { + "type": "string", + "nullable": true + } + }, + "required": [ + "metricId", + "periodStart", + "value" + ] + } + } + }, + "required": [ + "measurements" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Measurements recorded" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Record measurements for several metrics/periods in one save (Metrics due / backfill)", + "tags": [ + "ISMS" + ], + "description": "Record measurements for several metrics/periods in one save (Metrics due / backfill) in Comp AI.", + "x-mint": { + "metadata": { + "title": "Record measurements for several metrics/period | Comp AI API", + "sidebarTitle": "Record measurements for several metrics/periods in one save (Metrics due / backfill)", + "description": "Record measurements for several metrics/periods in one save (Metrics due / backfill) in Comp AI.", + "og:title": "Record measurements for several metrics/period | Comp AI API", + "og:description": "Record measurements for several metrics/periods in one save (Metrics due / backfill) in Comp AI." + } + }, + "x-speakeasy-mcp": { + "name": "bulk-create-measurements" + } + } + }, "/v1/isms/documents/{id}/narrative": { "post": { "operationId": "IsmsRegistersController_saveNarrative_v1", @@ -28836,28 +29123,20 @@ } } }, - "CreateVersionDto": { + "CreatePolicyVersionDto": { "type": "object", "properties": { - "version": { - "type": "number", - "description": "Version number for this published script", - "example": 1 - }, - "scriptKey": { + "sourceVersionId": { "type": "string", - "description": "S3 key of the already-generated & published automation script (returned by the publish step).", - "example": "org_abc123/tsk_abc123/aut_abc123.v1.js" + "description": "Optional version ID to base the new version on", + "example": "pv_abc123def456" }, "changelog": { "type": "string", - "description": "Optional changelog describing this version" + "description": "Optional changelog to associate with the new version", + "example": "Initial draft for quarterly updates" } - }, - "required": [ - "version", - "scriptKey" - ] + } }, "UpdateVersionContentDto": { "type": "object", @@ -29089,6 +29368,29 @@ } } }, + "CreateVersionDto": { + "type": "object", + "properties": { + "version": { + "type": "number", + "description": "Version number for this published script", + "example": 1 + }, + "scriptKey": { + "type": "string", + "description": "S3 key of the already-generated & published automation script (returned by the publish step).", + "example": "org_abc123/tsk_abc123/aut_abc123.v1.js" + }, + "changelog": { + "type": "string", + "description": "Optional changelog describing this version" + } + }, + "required": [ + "version", + "scriptKey" + ] + }, "AuthorResponseDto": { "type": "object", "properties": {