From 83e614819aeb9c84005901900ce44338204f3468 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 16 Apr 2026 17:33:08 -0400 Subject: [PATCH 1/6] fix(automations): show schedule in UTC and next run in user tz (CS-97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Schedule and Next Run fields on the automation overview page were hardcoded to "Every Day 9:00 AM" and "Tomorrow 9:00 AM" with no timezone label. The backend cron actually runs at 09:00 UTC (see comp-private/apps/enterprise-api/src/trigger/automation/run-automations-schedule.ts), so a customer in UTC+3 would see the automation fire at noon local even though the UI said 9:00 AM. The "Tomorrow" literal was also wrong whenever today's 09:00 UTC hadn't passed yet. Label the schedule as "Every day at 9:00 AM UTC" and compute the next 09:00 UTC occurrence as a real Date, then render it in the user's locale/timezone — matching the existing pattern in BrowserAutomationsList and ScheduledScanPopover. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/MetricsSection.test.tsx | 67 +++++++++++++++++++ .../overview/components/MetricsSection.tsx | 34 +++++++++- 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx new file mode 100644 index 0000000000..aa0df43644 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MetricsSection } from './MetricsSection'; + +describe('MetricsSection (CS-97)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('labels the schedule as 9:00 AM UTC (no ambiguous local time)', () => { + vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); + render(); + expect(screen.getByText('Every day at 9:00 AM UTC')).toBeInTheDocument(); + }); + + it('renders the next run as a concrete weekday + time rather than the old hardcoded "Tomorrow 9:00 AM"', () => { + vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); + render(); + + // The old hardcoded literals must be gone. + expect(screen.queryByText('Every Day 9:00 AM')).not.toBeInTheDocument(); + expect(screen.queryByText('Tomorrow 9:00 AM')).not.toBeInTheDocument(); + + // The next-run line should be a real locale string formatted as + // "Weekday H:MM AM/PM", which is NOT the word "Tomorrow". + const nextRunLabels = screen.getAllByText(/\d{1,2}:\d{2}\s(AM|PM)$/); + expect(nextRunLabels.length).toBeGreaterThan(0); + }); + + it('picks today (UTC) when the current time is before 09:00 UTC', () => { + // 2026-04-16 07:00 UTC → next run is 2026-04-16 09:00 UTC (same day). + vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); + render(); + + const expected = new Date('2026-04-16T09:00:00Z').toLocaleString( + undefined, + { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }, + ); + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + + it('picks the next day (UTC) when the current time is past 09:00 UTC', () => { + // 2026-04-16 10:00 UTC → today's run already happened, next is 2026-04-17 09:00 UTC. + vi.setSystemTime(new Date('2026-04-16T10:00:00Z')); + render(); + + const expected = new Date('2026-04-17T09:00:00Z').toLocaleString( + undefined, + { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }, + ); + expect(screen.getByText(expected)).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx index 434f4a3035..ff8f62effe 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx @@ -29,6 +29,29 @@ export function MetricsSection({ const successRateColor = successRate >= 90 ? 'text-primary' : successRate >= 60 ? 'text-warning' : 'text-destructive'; const latestRun = initialRuns[0]; + // Automations run daily at 09:00 UTC (see + // comp-private/apps/enterprise-api/src/trigger/automation/run-automations-schedule.ts). + // Render the schedule explicitly in UTC and the next run in the user's + // local timezone so the label matches when it actually fires. + const nextRun = useMemo(() => { + const now = new Date(); + const next = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + 9, + 0, + 0, + 0, + ), + ); + if (next.getTime() <= now.getTime()) { + next.setUTCDate(next.getUTCDate() + 1); + } + return next; + }, []); + return (
@@ -41,12 +64,19 @@ export function MetricsSection({

Schedule

-

Every Day 9:00 AM

+

Every day at 9:00 AM UTC

Next Run

-

Tomorrow 9:00 AM

+

+ {nextRun.toLocaleString(undefined, { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + hour12: true, + })} +

From b016d606474f1d73b5bf7462cdef4fd9260330e9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:40:36 -0400 Subject: [PATCH 2/6] chore: cleanup api docs [dev] [Marfuen] mariano/eng-202-create-separate-docs-or-api-for-admin-routes --- apps/api/package.json | 7 +- apps/api/scripts/audit-openapi-summaries.ts | 62 + .../admin-context.controller.ts | 3 +- .../admin-evidence.controller.ts | 3 +- .../admin-findings.controller.ts | 3 +- .../admin-organizations.controller.ts | 3 +- .../admin-policies.controller.ts | 3 +- .../admin-tasks.controller.ts | 3 +- .../admin-vendors.controller.ts | 3 +- apps/api/src/auth/auth.controller.ts | 3 +- .../cloud-security.controller.ts | 17 + .../cloud-security/remediation.controller.ts | 10 + .../device-agent/device-agent.controller.ts | 8 + apps/api/src/email/email.controller.ts | 2 + .../control-template.controller.ts | 13 +- .../framework/framework.controller.ts | 16 +- .../policy-template.controller.ts | 8 +- .../requirement/requirement.controller.ts | 6 +- apps/api/src/gen-openapi.spec.ts | 173 ++ .../admin-integrations.controller.ts | 2 + .../controllers/checks.controller.ts | 6 +- .../controllers/connections.controller.ts | 16 +- .../dynamic-integrations.controller.ts | 2 + .../controllers/oauth-apps.controller.ts | 6 +- .../controllers/oauth.controller.ts | 5 +- .../controllers/services.controller.ts | 3 +- .../controllers/sync.controller.ts | 12 +- .../task-integrations.controller.ts | 8 +- .../controllers/variables.controller.ts | 6 +- .../controllers/webhook.controller.ts | 3 + apps/api/src/main.ts | 15 +- apps/api/src/openapi-docs.spec.ts | 155 ++ .../organization/organization.controller.ts | 1 + .../questionnaire/questionnaire.controller.ts | 16 + .../internal-vendor-automation.controller.ts | 2 + packages/docs/openapi.json | 2310 +++-------------- 36 files changed, 899 insertions(+), 2015 deletions(-) create mode 100644 apps/api/scripts/audit-openapi-summaries.ts create mode 100644 apps/api/src/gen-openapi.spec.ts create mode 100644 apps/api/src/openapi-docs.spec.ts diff --git a/apps/api/package.json b/apps/api/package.json index 27434ff51e..c55d68cb37 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -162,7 +162,12 @@ "testEnvironment": "node", "moduleNameMapper": { "^@db$": "/../prisma/index", - "^@/(.*)$": "/$1" + "^@/(.*)$": "/$1", + "^@trycompai/auth$": "/../../../packages/auth/src/index.ts", + "^@trycompai/company$": "/../../../packages/company/src/index.ts", + "^@trycompai/db$": "@prisma/client", + "^@trycompai/email$": "/../../../packages/email/index.ts", + "^@trycompai/integration-platform$": "/../../../packages/integration-platform/src/index.ts" } }, "license": "UNLICENSED", diff --git a/apps/api/scripts/audit-openapi-summaries.ts b/apps/api/scripts/audit-openapi-summaries.ts new file mode 100644 index 0000000000..4809d9dd14 --- /dev/null +++ b/apps/api/scripts/audit-openapi-summaries.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +interface Operation { + summary?: string; + description?: string; + operationId?: string; + tags?: string[]; +} + +const openapiPath = path.join(__dirname, '../../../packages/docs/openapi.json'); +const doc = JSON.parse(readFileSync(openapiPath, 'utf8')) as { + paths: Record>; +}; + +type Row = { + method: string; + path: string; + summary: string; + operationId: string; + tag: string; + flag: string; +}; + +const rows: Row[] = []; + +for (const [routePath, methods] of Object.entries(doc.paths)) { + for (const [method, op] of Object.entries(methods)) { + if (typeof op !== 'object' || !op) continue; + const summary = op.summary ?? ''; + const operationId = op.operationId ?? ''; + const tag = op.tags?.[0] ?? '(no tag)'; + + let flag = ''; + if (!summary) flag = 'MISSING'; + else if ( + /^(Get|Post|Put|Patch|Delete)\b.*v1/i.test(summary) || + summary === operationId || + /Controller_/.test(summary) + ) { + flag = 'AUTO_GEN'; + } + + rows.push({ method: method.toUpperCase(), path: routePath, summary, operationId, tag, flag }); + } +} + +rows.sort((a, b) => (a.tag + a.path).localeCompare(b.tag + b.path)); + +const flagged = rows.filter((r) => r.flag); +console.log(`Total operations: ${rows.length}`); +console.log(`Flagged: ${flagged.length}`); +console.log(); + +let currentTag = ''; +for (const r of flagged) { + if (r.tag !== currentTag) { + currentTag = r.tag; + console.log(`\n## ${currentTag}`); + } + console.log(` [${r.flag.padEnd(9)}] ${r.method.padEnd(6)} ${r.path} — "${r.summary}"`); +} diff --git a/apps/api/src/admin-organizations/admin-context.controller.ts b/apps/api/src/admin-organizations/admin-context.controller.ts index 2084bfbc99..9eb9a48b57 100644 --- a/apps/api/src/admin-organizations/admin-context.controller.ts +++ b/apps/api/src/admin-organizations/admin-context.controller.ts @@ -11,7 +11,7 @@ import { UsePipes, ValidationPipe, } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { PlatformAdminGuard } from '../auth/platform-admin.guard'; import { ContextService } from '../context/context.service'; @@ -19,6 +19,7 @@ import { CreateContextDto } from '../context/dto/create-context.dto'; import { UpdateContextDto } from '../context/dto/update-context.dto'; import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; +@ApiExcludeController() @ApiTags('Admin - Context') @Controller({ path: 'admin/organizations', version: '1' }) @UseGuards(PlatformAdminGuard) diff --git a/apps/api/src/admin-organizations/admin-evidence.controller.ts b/apps/api/src/admin-organizations/admin-evidence.controller.ts index 42cb24d970..09c2a710b0 100644 --- a/apps/api/src/admin-organizations/admin-evidence.controller.ts +++ b/apps/api/src/admin-organizations/admin-evidence.controller.ts @@ -8,7 +8,7 @@ import { UseInterceptors, BadRequestException, } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { PlatformAdminGuard } from '../auth/platform-admin.guard'; import { EvidenceFormsService } from '../evidence-forms/evidence-forms.service'; @@ -18,6 +18,7 @@ import { buildPlatformAdminAuthContext, } from './platform-admin-auth-context'; +@ApiExcludeController() @ApiTags('Admin - Evidence') @Controller({ path: 'admin/organizations', version: '1' }) @UseGuards(PlatformAdminGuard) diff --git a/apps/api/src/admin-organizations/admin-findings.controller.ts b/apps/api/src/admin-organizations/admin-findings.controller.ts index 0b2b6965e5..c0139d6cea 100644 --- a/apps/api/src/admin-organizations/admin-findings.controller.ts +++ b/apps/api/src/admin-organizations/admin-findings.controller.ts @@ -13,7 +13,7 @@ import { ValidationPipe, BadRequestException, } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { FindingStatus } from '@db'; import { PlatformAdminGuard } from '../auth/platform-admin.guard'; @@ -23,6 +23,7 @@ import { UpdateFindingDto } from '../findings/dto/update-finding.dto'; import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; import type { AdminRequest } from './platform-admin-auth-context'; +@ApiExcludeController() @ApiTags('Admin - Findings') @Controller({ path: 'admin/organizations', version: '1' }) @UseGuards(PlatformAdminGuard) diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.ts b/apps/api/src/admin-organizations/admin-organizations.controller.ts index fea7a03a99..7233022631 100644 --- a/apps/api/src/admin-organizations/admin-organizations.controller.ts +++ b/apps/api/src/admin-organizations/admin-organizations.controller.ts @@ -13,13 +13,14 @@ import { UsePipes, ValidationPipe, } from '@nestjs/common'; -import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { PlatformAdminGuard } from '../auth/platform-admin.guard'; import { AdminOrganizationsService } from './admin-organizations.service'; import { AdminAuditLogInterceptor } from './admin-audit-log.interceptor'; import { InviteMemberDto } from './dto/invite-member.dto'; +@ApiExcludeController() @ApiTags('Admin - Organizations') @Controller({ path: 'admin/organizations', version: '1' }) @UseGuards(PlatformAdminGuard) diff --git a/apps/api/src/admin-organizations/admin-policies.controller.ts b/apps/api/src/admin-organizations/admin-policies.controller.ts index 090ec175e1..81bb2389a3 100644 --- a/apps/api/src/admin-organizations/admin-policies.controller.ts +++ b/apps/api/src/admin-organizations/admin-policies.controller.ts @@ -11,7 +11,7 @@ import { ValidationPipe, BadRequestException, } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { db } from '@db'; import { @@ -32,6 +32,7 @@ interface UpdatePolicyBody { frequency?: string | null; } +@ApiExcludeController() @ApiTags('Admin - Policies') @Controller({ path: 'admin/organizations', version: '1' }) @UseGuards(PlatformAdminGuard) diff --git a/apps/api/src/admin-organizations/admin-tasks.controller.ts b/apps/api/src/admin-organizations/admin-tasks.controller.ts index e13026fb47..9d99496357 100644 --- a/apps/api/src/admin-organizations/admin-tasks.controller.ts +++ b/apps/api/src/admin-organizations/admin-tasks.controller.ts @@ -12,7 +12,7 @@ import { ValidationPipe, BadRequestException, } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { TaskStatus, @@ -36,6 +36,7 @@ interface UpdateTaskBody { frequency?: string | null; } +@ApiExcludeController() @ApiTags('Admin - Tasks') @Controller({ path: 'admin/organizations', version: '1' }) @UseGuards(PlatformAdminGuard) diff --git a/apps/api/src/admin-organizations/admin-vendors.controller.ts b/apps/api/src/admin-organizations/admin-vendors.controller.ts index 2aea33f513..d9464e61cb 100644 --- a/apps/api/src/admin-organizations/admin-vendors.controller.ts +++ b/apps/api/src/admin-organizations/admin-vendors.controller.ts @@ -12,7 +12,7 @@ import { ValidationPipe, BadRequestException, } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { VendorCategory, VendorStatus } from '@db'; import { PlatformAdminGuard } from '../auth/platform-admin.guard'; @@ -26,6 +26,7 @@ interface UpdateVendorBody { category?: string; } +@ApiExcludeController() @ApiTags('Admin - Vendors') @Controller({ path: 'admin/organizations', version: '1' }) @UseGuards(PlatformAdminGuard) diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index eab2e593bd..77d4126c3d 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -8,7 +8,7 @@ import { Param, UseGuards, } from '@nestjs/common'; -import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { ApiExcludeController, ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger'; import { db } from '@db'; import { OrganizationId } from './auth-context.decorator'; import { PermissionGuard } from './permission.guard'; @@ -18,6 +18,7 @@ import { HybridAuthGuard } from './hybrid-auth.guard'; import { SkipOrgCheck } from './skip-org-check.decorator'; import type { AuthContext as AuthContextType } from './types'; +@ApiExcludeController() @ApiTags('Auth') @Controller({ path: 'auth', version: '1' }) @UseGuards(HybridAuthGuard) diff --git a/apps/api/src/cloud-security/cloud-security.controller.ts b/apps/api/src/cloud-security/cloud-security.controller.ts index 98d6e363f8..e2ba7e4b55 100644 --- a/apps/api/src/cloud-security/cloud-security.controller.ts +++ b/apps/api/src/cloud-security/cloud-security.controller.ts @@ -12,6 +12,7 @@ import { UseGuards, Req, } from '@nestjs/common'; +import { ApiOperation } from '@nestjs/swagger'; import { SkipThrottle } from '@nestjs/throttler'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; @@ -49,6 +50,7 @@ export class CloudSecurityController { @SkipThrottle() @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'read') + @ApiOperation({ summary: 'List recent cloud security activity' }) async getActivity( @Query('connectionId') connectionId: string, @Query('take') take: string | undefined, @@ -78,6 +80,7 @@ export class CloudSecurityController { @SkipThrottle() @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'read') + @ApiOperation({ summary: 'List supported cloud providers' }) async getProviders(@OrganizationId() organizationId: string) { const providers = await this.queryService.getProviders(organizationId); return { data: providers, count: providers.length }; @@ -87,6 +90,7 @@ export class CloudSecurityController { @SkipThrottle() @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'read') + @ApiOperation({ summary: 'List cloud security findings' }) async getFindings(@OrganizationId() organizationId: string) { const findings = await this.queryService.getFindings(organizationId); return { data: findings, count: findings.length }; @@ -95,6 +99,7 @@ export class CloudSecurityController { @Post('scan/:connectionId') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'update') + @ApiOperation({ summary: 'Trigger a security scan for a connection' }) async scan( @Param('connectionId') connectionId: string, @OrganizationId() organizationId: string, @@ -165,6 +170,7 @@ export class CloudSecurityController { @Post('detect-services/:connectionId') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'read') + @ApiOperation({ summary: 'Detect available cloud services for a connection' }) async detectServices( @Param('connectionId') connectionId: string, @OrganizationId() organizationId: string, @@ -188,6 +194,7 @@ export class CloudSecurityController { @Post('detect-gcp-org/:connectionId') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'read') + @ApiOperation({ summary: 'Detect the GCP organization for a connection' }) async detectGcpOrg( @Param('connectionId') connectionId: string, @OrganizationId() organizationId: string, @@ -262,6 +269,7 @@ export class CloudSecurityController { @Post('select-gcp-projects/:connectionId') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'update') + @ApiOperation({ summary: 'Select GCP projects for a connection' }) async selectGcpProjects( @Param('connectionId') connectionId: string, @Body() @@ -306,6 +314,7 @@ export class CloudSecurityController { @Post('setup-gcp/:connectionId') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'update') + @ApiOperation({ summary: 'Set up GCP for a connection' }) async setupGcp( @Param('connectionId') connectionId: string, @Body() body: { projectId?: string }, @@ -341,6 +350,7 @@ export class CloudSecurityController { @Post('setup-gcp/:connectionId/resolve-step') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'update') + @ApiOperation({ summary: 'Resolve a GCP setup step' }) async resolveGcpSetupStep( @Param('connectionId') connectionId: string, @Body() body: { stepId: GcpSetupStepId }, @@ -475,6 +485,7 @@ export class CloudSecurityController { @Post('setup-azure/:connectionId') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'update') + @ApiOperation({ summary: 'Set up Azure for a connection' }) async setupAzure( @Param('connectionId') connectionId: string, @OrganizationId() organizationId: string, @@ -638,6 +649,7 @@ export class CloudSecurityController { @Post('validate-azure/:connectionId') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'read') + @ApiOperation({ summary: 'Validate Azure credentials for a connection' }) async validateAzure( @Param('connectionId') connectionId: string, @OrganizationId() organizationId: string, @@ -749,6 +761,7 @@ export class CloudSecurityController { @Post('trigger/:connectionId') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'update') + @ApiOperation({ summary: 'Trigger a cloud security run for a connection' }) async triggerScan( @Param('connectionId') connectionId: string, @OrganizationId() organizationId: string, @@ -773,6 +786,7 @@ export class CloudSecurityController { @Get('runs/:runId') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'read') + @ApiOperation({ summary: 'Get a cloud security scan run by ID' }) async getRunStatus( @Param('runId') runId: string, @Query('connectionId') connectionId: string, @@ -804,6 +818,7 @@ export class CloudSecurityController { @Post('legacy/connect') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'create') + @ApiOperation({ summary: 'Create a legacy cloud integration' }) async connectLegacy( @OrganizationId() organizationId: string, @Body() @@ -823,6 +838,7 @@ export class CloudSecurityController { @Post('legacy/validate-aws') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'read') + @ApiOperation({ summary: 'Validate legacy AWS credentials' }) async validateAwsCredentials( @Body() body: { accessKeyId: string; secretAccessKey: string }, ) { @@ -840,6 +856,7 @@ export class CloudSecurityController { @Delete('legacy/:integrationId') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'delete') + @ApiOperation({ summary: 'Delete a legacy cloud integration' }) async disconnectLegacy( @Param('integrationId') integrationId: string, @OrganizationId() organizationId: string, diff --git a/apps/api/src/cloud-security/remediation.controller.ts b/apps/api/src/cloud-security/remediation.controller.ts index ca7e9f82c0..7a64c24a4e 100644 --- a/apps/api/src/cloud-security/remediation.controller.ts +++ b/apps/api/src/cloud-security/remediation.controller.ts @@ -11,6 +11,7 @@ import { HttpStatus, UseGuards, } from '@nestjs/common'; +import { ApiOperation } from '@nestjs/swagger'; import { SkipThrottle } from '@nestjs/throttler'; import { db } from '@db'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; @@ -30,6 +31,7 @@ export class RemediationController { @Get('capabilities') @SkipThrottle() @RequirePermission('integration', 'read') + @ApiOperation({ summary: 'List remediation capabilities' }) async getCapabilities( @Query('connectionId') connectionId: string, @OrganizationId() organizationId: string, @@ -55,6 +57,7 @@ export class RemediationController { @Post('preview') @RequirePermission('integration', 'update') + @ApiOperation({ summary: 'Preview a remediation' }) async preview( @Body() body: { @@ -82,6 +85,7 @@ export class RemediationController { @Post('execute') @RequirePermission('integration', 'update') + @ApiOperation({ summary: 'Execute a remediation' }) async execute( @Body() body: { @@ -168,6 +172,7 @@ export class RemediationController { @Post(':actionId/rollback') @RequirePermission('integration', 'update') + @ApiOperation({ summary: 'Roll back a remediation action' }) async rollback( @Param('actionId') actionId: string, @OrganizationId() organizationId: string, @@ -239,6 +244,7 @@ export class RemediationController { @Get('actions') @RequirePermission('integration', 'read') + @ApiOperation({ summary: 'List remediation actions' }) async getActions( @Query('connectionId') connectionId: string, @OrganizationId() organizationId: string, @@ -268,6 +274,7 @@ export class RemediationController { /** Get active batch for a connection (if any). */ @Get('batch/active') @RequirePermission('integration', 'read') + @ApiOperation({ summary: 'Get the active remediation batch' }) async getActiveBatch( @Query('connectionId') connectionId: string, @OrganizationId() organizationId: string, @@ -286,6 +293,7 @@ export class RemediationController { /** Create a new batch record (called before triggering the task). */ @Post('batch') @RequirePermission('integration', 'update') + @ApiOperation({ summary: 'Create a remediation batch' }) async createBatch( @Body() body: { @@ -327,6 +335,7 @@ export class RemediationController { /** Update a batch (set triggerRunId after task starts). */ @Patch('batch/:batchId') @RequirePermission('integration', 'update') + @ApiOperation({ summary: 'Update a remediation batch' }) async updateBatch( @Param('batchId') batchId: string, @Body() body: { triggerRunId?: string; status?: string }, @@ -345,6 +354,7 @@ export class RemediationController { /** Skip a specific finding in an active batch. */ @Post('batch/:batchId/skip/:findingId') @RequirePermission('integration', 'update') + @ApiOperation({ summary: 'Skip a finding in a remediation batch' }) async skipFinding( @Param('batchId') batchId: string, @Param('findingId') findingId: string, diff --git a/apps/api/src/device-agent/device-agent.controller.ts b/apps/api/src/device-agent/device-agent.controller.ts index b765b8c73e..25d3f95d0a 100644 --- a/apps/api/src/device-agent/device-agent.controller.ts +++ b/apps/api/src/device-agent/device-agent.controller.ts @@ -52,12 +52,14 @@ export class DeviceAgentController { @Post('exchange-code') @Public() + @ApiOperation({ summary: 'Exchange an auth code for device credentials' }) async exchangeCode(@Body() dto: ExchangeCodeDto) { return this.deviceAgentAuthService.exchangeCode({ code: dto.code }); } @Get('updates/:filename') @Public() + @ApiOperation({ summary: 'Download a device-agent update' }) async getUpdateFile( @Param('filename') filename: string, @Response({ passthrough: true }) res: ExpressResponse, @@ -77,6 +79,7 @@ export class DeviceAgentController { @Head('updates/:filename') @Public() + @ApiOperation({ summary: "Check a device-agent update's metadata" }) async headUpdateFile( @Param('filename') filename: string, @Response({ passthrough: true }) res: ExpressResponse, @@ -99,6 +102,7 @@ export class DeviceAgentController { @Post('auth-code') @UseGuards(HybridAuthGuard) @SkipOrgCheck() + @ApiOperation({ summary: 'Create a device-agent auth code' }) async generateAuthCode(@Req() req: ExpressRequest, @Body() dto: AuthCodeDto) { // Construct Web API Headers from Express IncomingHttpHeaders const headers = new Headers(); @@ -116,6 +120,7 @@ export class DeviceAgentController { @Get('my-organizations') @UseGuards(HybridAuthGuard) @SkipOrgCheck() + @ApiOperation({ summary: 'List organizations for the current device' }) async getMyOrganizations(@UserId() userId: string) { return this.deviceAgentAuthService.getMyOrganizations({ userId }); } @@ -123,6 +128,7 @@ export class DeviceAgentController { @Post('register') @UseGuards(HybridAuthGuard) @SkipOrgCheck() + @ApiOperation({ summary: 'Register a device agent' }) async registerDevice( @UserId() userId: string, @Body() dto: RegisterDeviceDto, @@ -133,6 +139,7 @@ export class DeviceAgentController { @Post('check-in') @UseGuards(HybridAuthGuard) @SkipOrgCheck() + @ApiOperation({ summary: 'Submit a device check-in' }) async checkIn(@UserId() userId: string, @Body() dto: CheckInDto) { return this.deviceAgentAuthService.checkIn({ userId, dto }); } @@ -140,6 +147,7 @@ export class DeviceAgentController { @Get('status') @UseGuards(HybridAuthGuard) @SkipOrgCheck() + @ApiOperation({ summary: 'Get device-agent status' }) async getDeviceStatus( @UserId() userId: string, @Query('deviceId') deviceId?: string, diff --git a/apps/api/src/email/email.controller.ts b/apps/api/src/email/email.controller.ts index c5cbb71b0b..fd62a77649 100644 --- a/apps/api/src/email/email.controller.ts +++ b/apps/api/src/email/email.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common'; import { + ApiExcludeController, ApiOperation, ApiResponse, ApiSecurity, @@ -12,6 +13,7 @@ import { RequirePermission } from '../auth/require-permission.decorator'; import { SendEmailDto } from './dto/send-email.dto'; import type { sendEmailTask } from '../trigger/email/send-email'; +@ApiExcludeController() @ApiTags('Internal - Email') @Controller({ path: 'internal/email', version: '1' }) @UseGuards(HybridAuthGuard, PermissionGuard) diff --git a/apps/api/src/framework-editor/control-template/control-template.controller.ts b/apps/api/src/framework-editor/control-template/control-template.controller.ts index b108f5a17f..6b8003ecf1 100644 --- a/apps/api/src/framework-editor/control-template/control-template.controller.ts +++ b/apps/api/src/framework-editor/control-template/control-template.controller.ts @@ -11,7 +11,7 @@ import { UsePipes, ValidationPipe, } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; import { CreateControlTemplateDto } from './dto/create-control-template.dto'; import { UpdateControlTemplateDto } from './dto/update-control-template.dto'; @@ -24,6 +24,7 @@ export class ControlTemplateController { constructor(private readonly service: ControlTemplateService) {} @Get() + @ApiOperation({ summary: 'List control templates' }) async findAll( @Query('take') take?: string, @Query('skip') skip?: string, @@ -35,11 +36,13 @@ export class ControlTemplateController { } @Get(':id') + @ApiOperation({ summary: 'Get a control template by ID' }) async findOne(@Param('id') id: string) { return this.service.findById(id); } @Post() + @ApiOperation({ summary: 'Create a control template' }) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async create( @Body() dto: CreateControlTemplateDto, @@ -49,17 +52,20 @@ export class ControlTemplateController { } @Patch(':id') + @ApiOperation({ summary: 'Update a control template' }) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async update(@Param('id') id: string, @Body() dto: UpdateControlTemplateDto) { return this.service.update(id, dto); } @Delete(':id') + @ApiOperation({ summary: 'Delete a control template' }) async delete(@Param('id') id: string) { return this.service.delete(id); } @Post(':id/requirements/:reqId') + @ApiOperation({ summary: 'Link a requirement to a control template' }) async linkRequirement( @Param('id') id: string, @Param('reqId') reqId: string, @@ -68,6 +74,7 @@ export class ControlTemplateController { } @Delete(':id/requirements/:reqId') + @ApiOperation({ summary: 'Unlink a requirement from a control template' }) async unlinkRequirement( @Param('id') id: string, @Param('reqId') reqId: string, @@ -76,6 +83,7 @@ export class ControlTemplateController { } @Post(':id/policy-templates/:ptId') + @ApiOperation({ summary: 'Link a policy template to a control template' }) async linkPolicyTemplate( @Param('id') id: string, @Param('ptId') ptId: string, @@ -84,6 +92,7 @@ export class ControlTemplateController { } @Delete(':id/policy-templates/:ptId') + @ApiOperation({ summary: 'Unlink a policy template from a control template' }) async unlinkPolicyTemplate( @Param('id') id: string, @Param('ptId') ptId: string, @@ -92,11 +101,13 @@ export class ControlTemplateController { } @Post(':id/task-templates/:ttId') + @ApiOperation({ summary: 'Link a task template to a control template' }) async linkTaskTemplate(@Param('id') id: string, @Param('ttId') ttId: string) { return this.service.linkTaskTemplate(id, ttId); } @Delete(':id/task-templates/:ttId') + @ApiOperation({ summary: 'Unlink a task template from a control template' }) async unlinkTaskTemplate( @Param('id') id: string, @Param('ttId') ttId: string, diff --git a/apps/api/src/framework-editor/framework/framework.controller.ts b/apps/api/src/framework-editor/framework/framework.controller.ts index 8d05c3a9ef..0eb5733074 100644 --- a/apps/api/src/framework-editor/framework/framework.controller.ts +++ b/apps/api/src/framework-editor/framework/framework.controller.ts @@ -11,7 +11,7 @@ import { UsePipes, ValidationPipe, } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; import { CreateFrameworkDto } from './dto/create-framework.dto'; import { ImportFrameworkDto } from './dto/import-framework.dto'; @@ -29,6 +29,7 @@ export class FrameworkEditorFrameworkController { ) {} @Get() + @ApiOperation({ summary: 'List frameworks' }) async findAll(@Query('take') take?: string, @Query('skip') skip?: string) { const limit = Math.min(Number(take) || 500, 500); const offset = Number(skip) || 0; @@ -36,59 +37,70 @@ export class FrameworkEditorFrameworkController { } @Get(':id') + @ApiOperation({ summary: 'Get a framework by ID' }) async findById(@Param('id') id: string) { return this.frameworkService.findById(id); } @Post() + @ApiOperation({ summary: 'Create a framework' }) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async create(@Body() dto: CreateFrameworkDto) { return this.frameworkService.create(dto); } @Post('import') + @ApiOperation({ summary: 'Import a framework definition' }) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async importFramework(@Body() dto: ImportFrameworkDto) { return this.exportService.import(dto); } @Get(':id/export') + @ApiOperation({ summary: 'Export a framework definition' }) async exportFramework(@Param('id') id: string) { return this.exportService.export(id); } @Patch(':id') + @ApiOperation({ summary: 'Update a framework' }) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async update(@Param('id') id: string, @Body() dto: UpdateFrameworkDto) { return this.frameworkService.update(id, dto); } @Delete(':id') + @ApiOperation({ summary: 'Delete a framework' }) async delete(@Param('id') id: string) { return this.frameworkService.delete(id); } @Get(':id/controls') + @ApiOperation({ summary: 'List controls for a framework' }) async getControls(@Param('id') id: string) { return this.frameworkService.getControls(id); } @Get(':id/policies') + @ApiOperation({ summary: 'List policy templates for a framework' }) async getPolicies(@Param('id') id: string) { return this.frameworkService.getPolicies(id); } @Get(':id/tasks') + @ApiOperation({ summary: 'List task templates for a framework' }) async getTasks(@Param('id') id: string) { return this.frameworkService.getTasks(id); } @Get(':id/documents') + @ApiOperation({ summary: 'List documents for a framework' }) async getDocuments(@Param('id') id: string) { return this.frameworkService.getDocuments(id); } @Post(':id/link-control/:controlId') + @ApiOperation({ summary: 'Link a control to a framework' }) async linkControl( @Param('id') id: string, @Param('controlId') controlId: string, @@ -97,11 +109,13 @@ export class FrameworkEditorFrameworkController { } @Post(':id/link-task/:taskId') + @ApiOperation({ summary: 'Link a task template to a framework' }) async linkTask(@Param('id') id: string, @Param('taskId') taskId: string) { return this.frameworkService.linkTask(id, taskId); } @Post(':id/link-policy/:policyId') + @ApiOperation({ summary: 'Link a policy template to a framework' }) async linkPolicy( @Param('id') id: string, @Param('policyId') policyId: string, diff --git a/apps/api/src/framework-editor/policy-template/policy-template.controller.ts b/apps/api/src/framework-editor/policy-template/policy-template.controller.ts index 939f6b9ddb..5213ecd5b0 100644 --- a/apps/api/src/framework-editor/policy-template/policy-template.controller.ts +++ b/apps/api/src/framework-editor/policy-template/policy-template.controller.ts @@ -11,7 +11,7 @@ import { UsePipes, ValidationPipe, } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; import { CreatePolicyTemplateDto } from './dto/create-policy-template.dto'; import { UpdatePolicyContentDto } from './dto/update-policy-content.dto'; @@ -25,6 +25,7 @@ export class PolicyTemplateController { constructor(private readonly service: PolicyTemplateService) {} @Get() + @ApiOperation({ summary: 'List policy templates' }) async findAll( @Query('take') take?: string, @Query('skip') skip?: string, @@ -36,11 +37,13 @@ export class PolicyTemplateController { } @Get(':id') + @ApiOperation({ summary: 'Get a policy template by ID' }) async findById(@Param('id') id: string) { return this.service.findById(id); } @Post() + @ApiOperation({ summary: 'Create a policy template' }) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async create( @Body() dto: CreatePolicyTemplateDto, @@ -50,12 +53,14 @@ export class PolicyTemplateController { } @Patch(':id') + @ApiOperation({ summary: 'Update a policy template' }) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async update(@Param('id') id: string, @Body() dto: UpdatePolicyTemplateDto) { return this.service.update(id, dto); } @Patch(':id/content') + @ApiOperation({ summary: 'Update policy template content' }) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async updateContent( @Param('id') id: string, @@ -65,6 +70,7 @@ export class PolicyTemplateController { } @Delete(':id') + @ApiOperation({ summary: 'Delete a policy template' }) async delete(@Param('id') id: string) { return this.service.delete(id); } diff --git a/apps/api/src/framework-editor/requirement/requirement.controller.ts b/apps/api/src/framework-editor/requirement/requirement.controller.ts index cf8a56980c..cc24d046cd 100644 --- a/apps/api/src/framework-editor/requirement/requirement.controller.ts +++ b/apps/api/src/framework-editor/requirement/requirement.controller.ts @@ -11,7 +11,7 @@ import { UsePipes, ValidationPipe, } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { PlatformAdminGuard } from '../../auth/platform-admin.guard'; import { CreateRequirementDto } from './dto/create-requirement.dto'; import { UpdateRequirementDto } from './dto/update-requirement.dto'; @@ -24,6 +24,7 @@ export class RequirementController { constructor(private readonly service: RequirementService) {} @Get() + @ApiOperation({ summary: 'List requirements' }) async findAll(@Query('take') take?: string, @Query('skip') skip?: string) { const limit = Math.min(Number(take) || 500, 500); const offset = Number(skip) || 0; @@ -31,18 +32,21 @@ export class RequirementController { } @Post() + @ApiOperation({ summary: 'Create a requirement' }) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async create(@Body() dto: CreateRequirementDto) { return this.service.create(dto); } @Patch(':id') + @ApiOperation({ summary: 'Update a requirement' }) @UsePipes(new ValidationPipe({ whitelist: true, transform: true })) async update(@Param('id') id: string, @Body() dto: UpdateRequirementDto) { return this.service.update(id, dto); } @Delete(':id') + @ApiOperation({ summary: 'Delete a requirement' }) async delete(@Param('id') id: string) { return this.service.delete(id); } diff --git a/apps/api/src/gen-openapi.spec.ts b/apps/api/src/gen-openapi.spec.ts new file mode 100644 index 0000000000..423ea2bc83 --- /dev/null +++ b/apps/api/src/gen-openapi.spec.ts @@ -0,0 +1,173 @@ +// Script-style Jest spec: generates packages/docs/openapi.json using the same +// mocks as openapi-docs.spec.ts (no live DB or env vars needed). +// Skipped by default to avoid side effects in CI. +// Run manually with: cd apps/api && GEN_OPENAPI=1 npx jest src/gen-openapi.spec.ts + +// Mock better-auth ESM-only modules so Jest (CJS) can import AppModule's transitive AuthModule. +jest.mock('./auth/auth.server', () => ({ + auth: { + api: {}, + handler: async () => new Response(null, { status: 204 }), + options: {}, + }, + getTrustedOrigins: () => [], + isTrustedOrigin: async () => false, + isStaticTrustedOrigin: () => false, +})); + +jest.mock('@thallesp/nestjs-better-auth', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Module } = require('@nestjs/common'); + @Module({}) + class AuthModuleStub { + static forRoot() { + return { module: AuthModuleStub, imports: [], providers: [], exports: [] }; + } + } + return { AuthModule: AuthModuleStub }; +}); + +jest.mock('better-auth/plugins/access', () => ({ + createAccessControl: () => ({ + newRole: () => ({}), + statement: {}, + }), +})); +jest.mock('better-auth/plugins/organization/access', () => ({ + defaultStatements: {}, + adminAc: {}, + ownerAc: {}, +})); + +jest.mock('@trycompai/auth', () => { + const emptyRole = { statements: {} }; + const roles = { + owner: emptyRole, + admin: emptyRole, + auditor: emptyRole, + employee: emptyRole, + contractor: emptyRole, + }; + return { + ac: { newRole: () => emptyRole }, + statement: {}, + allRoles: roles, + ...roles, + ROLE_HIERARCHY: ['contractor', 'employee', 'auditor', 'admin', 'owner'], + RESTRICTED_ROLES: ['employee', 'contractor'], + PRIVILEGED_ROLES: ['owner', 'admin', 'auditor'], + BUILT_IN_ROLE_PERMISSIONS: {}, + BUILT_IN_ROLE_OBLIGATIONS: {}, + }; +}); + +jest.mock('@db', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const prismaClient = require('@prisma/client'); + return { + ...prismaClient, + db: { + $connect: jest.fn(), + $disconnect: jest.fn(), + organization: { findFirst: jest.fn(), findMany: jest.fn() }, + auditLog: { create: jest.fn() }, + trust: { findMany: jest.fn().mockResolvedValue([]) }, + apiKey: { findFirst: jest.fn() }, + session: { findFirst: jest.fn() }, + member: { findFirst: jest.fn() }, + }, + }; +}); + +jest.mock('@upstash/redis', () => ({ + Redis: jest.fn().mockImplementation(() => ({ + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue('OK'), + })), +})); + +process.env.SECRET_KEY = 'test-secret-key-at-least-16-chars'; +process.env.BASE_URL = 'http://localhost:3333'; +process.env.APP_AWS_ACCESS_KEY_ID = 'test-access-key-id'; +process.env.APP_AWS_SECRET_ACCESS_KEY = 'test-secret-access-key'; +process.env.APP_AWS_BUCKET_NAME = 'test-bucket'; +process.env.APP_AWS_REGION = 'us-east-1'; + +import path from 'path'; +import { writeFileSync, mkdirSync, existsSync } from 'fs'; +import { Test } from '@nestjs/testing'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { INestApplication, VersioningType } from '@nestjs/common'; +import { AppModule } from './app.module'; + +const shouldRun = process.env.GEN_OPENAPI === '1'; +const maybeDescribe = shouldRun ? describe : describe.skip; + +maybeDescribe('Generate openapi.json', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleRef.createNestApplication(); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); + await app.init(); + }, 60000); + + afterAll(async () => { + if (app) await app.close(); + }); + + it('writes openapi.json without excluded paths', () => { + const baseUrl = process.env.BASE_URL ?? 'http://localhost:3333'; + const serverDescription = baseUrl.includes('api.staging.trycomp.ai') + ? 'Staging API Server' + : baseUrl.includes('api.trycomp.ai') + ? 'Production API Server' + : baseUrl.startsWith('http://localhost') + ? 'Local API Server' + : 'API Server'; + + const config = new DocumentBuilder() + .setTitle('API Documentation') + .setDescription('The API documentation for this application') + .setVersion('1.0') + .addApiKey( + { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + description: 'API key for authentication', + }, + 'apikey', + ) + .addServer(baseUrl, serverDescription) + .build(); + + const document = SwaggerModule.createDocument(app, config); + + const openapiPath = path.join( + __dirname, + '../../../packages/docs/openapi.json', + ); + + const docsDir = path.dirname(openapiPath); + if (!existsSync(docsDir)) { + mkdirSync(docsDir, { recursive: true }); + } + + writeFileSync(openapiPath, JSON.stringify(document, null, 2)); + console.log(`OpenAPI documentation written to ${openapiPath}`); + + // Verify excluded paths are absent + const hiddenPrefixes = ['/v1/auth', '/v1/admin', '/v1/internal']; + for (const prefix of hiddenPrefixes) { + const exposed = Object.keys(document.paths).filter((p) => + p.startsWith(prefix), + ); + expect(exposed).toEqual([]); + } + }); +}); diff --git a/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts b/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts index bf3526a08e..ccf49286f7 100644 --- a/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts @@ -13,6 +13,7 @@ import { Req, } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; +import { ApiExcludeController } from '@nestjs/swagger'; import { OAuthCredentialsService } from '../services/oauth-credentials.service'; import { PlatformCredentialRepository } from '../repositories/platform-credential.repository'; import { getAllManifests, getManifest } from '@trycompai/integration-platform'; @@ -27,6 +28,7 @@ interface SavePlatformCredentialDto { customSettings?: Record; } +@ApiExcludeController() @Controller({ path: 'admin/integrations', version: '1' }) @UseGuards(PlatformAdminGuard) @UseInterceptors(PlatformAuditLogInterceptor) diff --git a/apps/api/src/integration-platform/controllers/checks.controller.ts b/apps/api/src/integration-platform/controllers/checks.controller.ts index ccd5134a67..d52179d41c 100644 --- a/apps/api/src/integration-platform/controllers/checks.controller.ts +++ b/apps/api/src/integration-platform/controllers/checks.controller.ts @@ -9,7 +9,7 @@ import { Logger, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiSecurity } from '@nestjs/swagger'; +import { ApiTags, ApiSecurity, ApiOperation } from '@nestjs/swagger'; import type { Prisma } from '@db'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import { PermissionGuard } from '../../auth/permission.guard'; @@ -50,6 +50,7 @@ export class ChecksController { * List available checks for a provider */ @Get('providers/:providerSlug') + @ApiOperation({ summary: 'List check definitions for a provider' }) @RequirePermission('integration', 'read') async listProviderChecks(@Param('providerSlug') providerSlug: string) { const manifest = getManifest(providerSlug); @@ -71,6 +72,7 @@ export class ChecksController { * List available checks for a connection */ @Get('connections/:connectionId') + @ApiOperation({ summary: 'List checks for a connection' }) @RequirePermission('integration', 'read') async listConnectionChecks( @Param('connectionId') connectionId: string, @@ -113,6 +115,7 @@ export class ChecksController { * Run checks for a connection */ @Post('connections/:connectionId/run') + @ApiOperation({ summary: 'Run all checks for a connection' }) @RequirePermission('integration', 'update') async runConnectionChecks( @Param('connectionId') connectionId: string, @@ -345,6 +348,7 @@ export class ChecksController { * Run a specific check for a connection */ @Post('connections/:connectionId/run/:checkId') + @ApiOperation({ summary: 'Run a single check on a connection' }) @RequirePermission('integration', 'update') async runSingleCheck( @Param('connectionId') connectionId: string, diff --git a/apps/api/src/integration-platform/controllers/connections.controller.ts b/apps/api/src/integration-platform/controllers/connections.controller.ts index f605b8e05e..440ad91fa9 100644 --- a/apps/api/src/integration-platform/controllers/connections.controller.ts +++ b/apps/api/src/integration-platform/controllers/connections.controller.ts @@ -13,7 +13,7 @@ import { Logger, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiSecurity } from '@nestjs/swagger'; +import { ApiTags, ApiSecurity, ApiOperation } from '@nestjs/swagger'; import { db } from '@db'; import { AssumeRoleCommand, @@ -72,6 +72,7 @@ export class ConnectionsController { * List all available integration providers */ @Get('providers') + @ApiOperation({ summary: 'List available integration providers' }) @RequirePermission('integration', 'read') async listProviders(@Query('activeOnly') activeOnly?: string) { const manifests = @@ -174,6 +175,7 @@ export class ConnectionsController { * Get a specific provider's details */ @Get('providers/:slug') + @ApiOperation({ summary: 'Get an integration provider by slug' }) @RequirePermission('integration', 'read') async getProvider(@Param('slug') slug: string) { const manifest = getManifest(slug); @@ -265,6 +267,7 @@ export class ConnectionsController { * List connections for an organization (excludes soft-deleted/disconnected) */ @Get() + @ApiOperation({ summary: 'List integration connections' }) @RequirePermission('integration', 'read') async listConnections(@OrganizationId() organizationId: string) { const connections = @@ -292,6 +295,7 @@ export class ConnectionsController { * Get a specific connection */ @Get(':id') + @ApiOperation({ summary: 'Get an integration connection by ID' }) @RequirePermission('integration', 'read') async getConnection( @Param('id') id: string, @@ -382,6 +386,7 @@ export class ConnectionsController { * Create a new connection with API key credentials */ @Post() + @ApiOperation({ summary: 'Create an integration connection' }) @RequirePermission('integration', 'create') async createConnection( @OrganizationId() organizationId: string, @@ -682,6 +687,7 @@ export class ConnectionsController { * Test a connection's credentials */ @Post(':id/test') + @ApiOperation({ summary: 'Test an integration connection' }) @RequirePermission('integration', 'update') async testConnection( @Param('id') id: string, @@ -778,6 +784,7 @@ export class ConnectionsController { * Pause a connection */ @Post(':id/pause') + @ApiOperation({ summary: 'Pause an integration connection' }) @RequirePermission('integration', 'update') async pauseConnection( @Param('id') id: string, @@ -792,6 +799,7 @@ export class ConnectionsController { * Resume a paused connection */ @Post(':id/resume') + @ApiOperation({ summary: 'Resume an integration connection' }) @RequirePermission('integration', 'update') async resumeConnection( @Param('id') id: string, @@ -806,6 +814,7 @@ export class ConnectionsController { * Disconnect (soft delete) a connection */ @Post(':id/disconnect') + @ApiOperation({ summary: 'Disconnect an integration' }) @RequirePermission('integration', 'delete') async disconnectConnection( @Param('id') id: string, @@ -820,6 +829,7 @@ export class ConnectionsController { * Delete a connection permanently */ @Delete(':id') + @ApiOperation({ summary: 'Delete an integration connection' }) @RequirePermission('integration', 'delete') async deleteConnection( @Param('id') id: string, @@ -834,6 +844,7 @@ export class ConnectionsController { * Update connection metadata (connectionName, regions, etc.) */ @Patch(':id') + @ApiOperation({ summary: 'Update an integration connection' }) @RequirePermission('integration', 'update') async updateConnection( @Param('id') id: string, @@ -867,6 +878,7 @@ export class ConnectionsController { * Used by scheduled jobs to ensure tokens are valid before running checks. */ @Post(':id/ensure-valid-credentials') + @ApiOperation({ summary: 'Ensure valid credentials for a connection' }) @RequirePermission('integration', 'update') async ensureValidCredentials( @Param('id') id: string, @@ -1029,6 +1041,7 @@ export class ConnectionsController { * Update enabled services for a connection */ @Put(':id/services') + @ApiOperation({ summary: 'Set services enabled on a connection' }) @RequirePermission('integration', 'update') async updateConnectionServices( @Param('id') id: string, @@ -1097,6 +1110,7 @@ export class ConnectionsController { * Update credentials for a custom auth connection */ @Put(':id/credentials') + @ApiOperation({ summary: 'Update integration credentials' }) @RequirePermission('integration', 'update') async updateCredentials( @Param('id') id: string, diff --git a/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts b/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts index 5a7b61546c..93786aae7f 100644 --- a/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts @@ -12,6 +12,7 @@ import { Logger, UseGuards, } from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; import type { Prisma } from '@db'; import { db } from '@db'; import { InternalTokenGuard } from '../../auth/internal-token.guard'; @@ -25,6 +26,7 @@ import { SyncDefinitionSchema, } from '@trycompai/integration-platform'; +@ApiExcludeController() @Controller({ path: 'internal/dynamic-integrations', version: '1' }) @UseGuards(InternalTokenGuard) export class DynamicIntegrationsController { diff --git a/apps/api/src/integration-platform/controllers/oauth-apps.controller.ts b/apps/api/src/integration-platform/controllers/oauth-apps.controller.ts index 4b71d37054..fe3f9ecac5 100644 --- a/apps/api/src/integration-platform/controllers/oauth-apps.controller.ts +++ b/apps/api/src/integration-platform/controllers/oauth-apps.controller.ts @@ -11,7 +11,7 @@ import { Logger, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiSecurity } from '@nestjs/swagger'; +import { ApiTags, ApiSecurity, ApiOperation } from '@nestjs/swagger'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import { PermissionGuard } from '../../auth/permission.guard'; import { RequirePermission } from '../../auth/require-permission.decorator'; @@ -43,6 +43,7 @@ export class OAuthAppsController { * List custom OAuth apps for an organization */ @Get() + @ApiOperation({ summary: 'List configured OAuth apps' }) @RequirePermission('integration', 'read') async listOAuthApps(@OrganizationId() organizationId: string) { const apps = @@ -62,6 +63,7 @@ export class OAuthAppsController { * Get OAuth app setup info for a provider */ @Get('setup/:providerSlug') + @ApiOperation({ summary: 'Get OAuth app setup details' }) @RequirePermission('integration', 'read') async getSetupInfo( @Param('providerSlug') providerSlug: string, @@ -106,6 +108,7 @@ export class OAuthAppsController { * Save custom OAuth app credentials for an organization */ @Post() + @ApiOperation({ summary: 'Create an OAuth app configuration' }) @RequirePermission('integration', 'create') async saveOAuthApp( @OrganizationId() organizationId: string, @@ -156,6 +159,7 @@ export class OAuthAppsController { * Delete custom OAuth app credentials for an organization */ @Delete(':providerSlug') + @ApiOperation({ summary: 'Delete an OAuth app configuration' }) @RequirePermission('integration', 'delete') async deleteOAuthApp( @Param('providerSlug') providerSlug: string, diff --git a/apps/api/src/integration-platform/controllers/oauth.controller.ts b/apps/api/src/integration-platform/controllers/oauth.controller.ts index 8c820e9d50..6b92e1e82b 100644 --- a/apps/api/src/integration-platform/controllers/oauth.controller.ts +++ b/apps/api/src/integration-platform/controllers/oauth.controller.ts @@ -10,7 +10,7 @@ import { Logger, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiSecurity } from '@nestjs/swagger'; +import { ApiTags, ApiSecurity, ApiOperation } from '@nestjs/swagger'; import type { Response } from 'express'; import { randomBytes, createHash } from 'crypto'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; @@ -59,6 +59,7 @@ export class OAuthController { * Check if OAuth credentials are available for a provider */ @Get('availability') + @ApiOperation({ summary: 'Check OAuth provider availability' }) @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'read') async checkAvailability( @@ -82,6 +83,7 @@ export class OAuthController { * Start OAuth flow - returns authorization URL */ @Post('start') + @ApiOperation({ summary: 'Start an OAuth authorization flow' }) @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'create') async startOAuth( @@ -213,6 +215,7 @@ export class OAuthController { * OAuth callback - exchanges code for tokens */ @Get('callback') + @ApiOperation({ summary: 'Handle OAuth provider callback' }) async oauthCallback( @Query() query: OAuthCallbackQuery, @Res() res: Response, diff --git a/apps/api/src/integration-platform/controllers/services.controller.ts b/apps/api/src/integration-platform/controllers/services.controller.ts index c00061ecc9..b29db30499 100644 --- a/apps/api/src/integration-platform/controllers/services.controller.ts +++ b/apps/api/src/integration-platform/controllers/services.controller.ts @@ -6,7 +6,7 @@ import { HttpStatus, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiSecurity } from '@nestjs/swagger'; +import { ApiTags, ApiSecurity, ApiOperation } from '@nestjs/swagger'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import { PermissionGuard } from '../../auth/permission.guard'; import { RequirePermission } from '../../auth/require-permission.decorator'; @@ -25,6 +25,7 @@ export class ServicesController { * Get services for a connection with their enabled state */ @Get(':id/services') + @ApiOperation({ summary: 'List services enabled on a connection' }) @RequirePermission('integration', 'read') async getConnectionServices( @Param('id') id: string, diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 852599c604..c893a27068 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -10,7 +10,7 @@ import { Logger, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiSecurity } from '@nestjs/swagger'; +import { ApiTags, ApiSecurity, ApiOperation } from '@nestjs/swagger'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import { PermissionGuard } from '../../auth/permission.guard'; import { RequirePermission } from '../../auth/require-permission.decorator'; @@ -81,6 +81,7 @@ export class SyncController { * Sync employees from Google Workspace */ @Post('google-workspace/employees') + @ApiOperation({ summary: 'Sync Google Workspace employees' }) @RequirePermission('integration', 'update') async syncGoogleWorkspaceEmployees( @OrganizationId() organizationId: string, @@ -525,6 +526,7 @@ export class SyncController { * Check if Google Workspace is connected for an organization */ @Post('google-workspace/status') + @ApiOperation({ summary: 'Get Google Workspace sync status' }) @RequirePermission('integration', 'read') async getGoogleWorkspaceStatus(@OrganizationId() organizationId: string) { const connection = await this.connectionRepository.findBySlugAndOrg( @@ -553,6 +555,7 @@ export class SyncController { * Sync employees from Rippling */ @Post('rippling/employees') + @ApiOperation({ summary: 'Sync Rippling employees' }) @RequirePermission('integration', 'update') async syncRipplingEmployees( @OrganizationId() organizationId: string, @@ -923,6 +926,7 @@ export class SyncController { * Check if Rippling is connected for an organization */ @Post('rippling/status') + @ApiOperation({ summary: 'Get Rippling sync status' }) @RequirePermission('integration', 'read') async getRipplingStatus(@OrganizationId() organizationId: string) { const connection = await this.connectionRepository.findBySlugAndOrg( @@ -951,6 +955,7 @@ export class SyncController { * Sync employees from JumpCloud */ @Post('jumpcloud/employees') + @ApiOperation({ summary: 'Sync JumpCloud employees' }) @RequirePermission('integration', 'update') async syncJumpCloudEmployees( @OrganizationId() organizationId: string, @@ -1461,6 +1466,7 @@ export class SyncController { * Check if JumpCloud is connected for an organization */ @Post('jumpcloud/status') + @ApiOperation({ summary: 'Get JumpCloud sync status' }) @RequirePermission('integration', 'read') async getJumpCloudStatus(@OrganizationId() organizationId: string) { const connection = await this.connectionRepository.findBySlugAndOrg( @@ -1489,6 +1495,7 @@ export class SyncController { * Get the current employee sync provider for an organization */ @Get('employee-sync-provider') + @ApiOperation({ summary: 'Get the currently configured employee sync provider' }) @RequirePermission('integration', 'read') async getEmployeeSyncProvider(@OrganizationId() organizationId: string) { const org = await db.organization.findUnique({ @@ -1518,6 +1525,7 @@ export class SyncController { * Set the employee sync provider for an organization */ @Post('employee-sync-provider') + @ApiOperation({ summary: 'Set the employee sync provider' }) @RequirePermission('integration', 'update') async setEmployeeSyncProvider( @OrganizationId() organizationId: string, @@ -1576,6 +1584,7 @@ export class SyncController { * Used by the frontend to render the provider selector dynamically. */ @Get('available-providers') + @ApiOperation({ summary: 'List employee sync providers available to the org' }) @RequirePermission('integration', 'read') async getAvailableSyncProviders(@OrganizationId() organizationId: string) { const allManifests = registry.getActiveManifests(); @@ -1622,6 +1631,7 @@ export class SyncController { * This only matches for slugs that don't match the 4 built-in providers. */ @Post('dynamic/:providerSlug/employees') + @ApiOperation({ summary: 'Sync employees for a dynamic provider' }) @RequirePermission('integration', 'update') async syncDynamicProviderEmployees( @OrganizationId() organizationId: string, diff --git a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts index f0e4cd9a40..985a74d1fa 100644 --- a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts @@ -10,7 +10,7 @@ import { Logger, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiSecurity } from '@nestjs/swagger'; +import { ApiTags, ApiSecurity, ApiOperation } from '@nestjs/swagger'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import { PermissionGuard } from '../../auth/permission.guard'; import { RequirePermission } from '../../auth/require-permission.decorator'; @@ -87,6 +87,7 @@ export class TaskIntegrationsController { * which checks have been manually disconnected from that task. */ @Get('template/:templateId/checks') + @ApiOperation({ summary: 'List checks for a task template' }) @RequirePermission('integration', 'read') async getChecksForTaskTemplate( @Param('templateId') templateId: string, @@ -183,6 +184,7 @@ export class TaskIntegrationsController { * Get integration checks for a specific task (by task ID) */ @Get(':taskId/checks') + @ApiOperation({ summary: 'List checks attached to a task' }) @RequirePermission('integration', 'read') async getChecksForTask( @Param('taskId') taskId: string, @@ -225,6 +227,7 @@ export class TaskIntegrationsController { * Run a specific check for a task and store results */ @Post(':taskId/run-check') + @ApiOperation({ summary: 'Run a check for a task' }) @RequirePermission('integration', 'update') async runCheckForTask( @Param('taskId') taskId: string, @@ -529,6 +532,7 @@ export class TaskIntegrationsController { * skip this (task, check) pair until it is reconnected. */ @Post(':taskId/checks/disconnect') + @ApiOperation({ summary: 'Disconnect checks from a task' }) @RequirePermission('integration', 'update') async disconnectCheckFromTask( @Param('taskId') taskId: string, @@ -549,6 +553,7 @@ export class TaskIntegrationsController { * task. Inverse of the disconnect endpoint. */ @Post(':taskId/checks/reconnect') + @ApiOperation({ summary: 'Reconnect checks to a task' }) @RequirePermission('integration', 'update') async reconnectCheckToTask( @Param('taskId') taskId: string, @@ -568,6 +573,7 @@ export class TaskIntegrationsController { * Get check run history for a task */ @Get(':taskId/runs') + @ApiOperation({ summary: 'List check runs for a task' }) @RequirePermission('integration', 'read') async getTaskCheckRuns( @Param('taskId') taskId: string, diff --git a/apps/api/src/integration-platform/controllers/variables.controller.ts b/apps/api/src/integration-platform/controllers/variables.controller.ts index b218606c15..08fba57ad6 100644 --- a/apps/api/src/integration-platform/controllers/variables.controller.ts +++ b/apps/api/src/integration-platform/controllers/variables.controller.ts @@ -9,7 +9,7 @@ import { Logger, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiSecurity } from '@nestjs/swagger'; +import { ApiTags, ApiSecurity, ApiOperation } from '@nestjs/swagger'; import { HybridAuthGuard } from '../../auth/hybrid-auth.guard'; import { PermissionGuard } from '../../auth/permission.guard'; import { RequirePermission } from '../../auth/require-permission.decorator'; @@ -64,6 +64,7 @@ export class VariablesController { * Get all variables required for a provider's checks */ @Get('providers/:providerSlug') + @ApiOperation({ summary: 'List variable definitions for a provider' }) @RequirePermission('integration', 'read') async getProviderVariables( @Param('providerSlug') providerSlug: string, @@ -114,6 +115,7 @@ export class VariablesController { * Get variables for a specific connection (with current values) */ @Get('connections/:connectionId') + @ApiOperation({ summary: 'List connection variables' }) @RequirePermission('integration', 'read') async getConnectionVariables( @Param('connectionId') connectionId: string, @@ -189,6 +191,7 @@ export class VariablesController { * Fetch dynamic options for a variable (requires active connection) */ @Get('connections/:connectionId/options/:variableId') + @ApiOperation({ summary: 'Get options for a connection variable' }) @RequirePermission('integration', 'read') async fetchVariableOptions( @Param('connectionId') connectionId: string, @@ -402,6 +405,7 @@ export class VariablesController { * Save variable values for a connection */ @Post('connections/:connectionId') + @ApiOperation({ summary: 'Update connection variables' }) @RequirePermission('integration', 'update') async saveConnectionVariables( @Param('connectionId') connectionId: string, diff --git a/apps/api/src/integration-platform/controllers/webhook.controller.ts b/apps/api/src/integration-platform/controllers/webhook.controller.ts index 653d44ee05..089ad8263e 100644 --- a/apps/api/src/integration-platform/controllers/webhook.controller.ts +++ b/apps/api/src/integration-platform/controllers/webhook.controller.ts @@ -12,6 +12,7 @@ import { } from '@nestjs/common'; import { Request } from 'express'; import { createHmac, timingSafeEqual } from 'crypto'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { getManifest } from '@trycompai/integration-platform'; import type { WebhookConfig } from '@trycompai/integration-platform'; import { ConnectionRepository } from '../repositories/connection.repository'; @@ -60,12 +61,14 @@ function getEventType(headers: Record): string { } @Controller({ path: 'integrations/webhooks', version: '1' }) +@ApiTags('Webhook') export class WebhookController { private readonly logger = new Logger(WebhookController.name); constructor(private readonly connectionRepository: ConnectionRepository) {} @Post(':providerSlug/:connectionId') + @ApiOperation({ summary: 'Receive a provider webhook event' }) async handleWebhook( @Param('providerSlug') providerSlug: string, @Param('connectionId') connectionId: string, diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0aebc23a2c..75585eb752 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -15,6 +15,13 @@ import { mkdirSync, writeFileSync, existsSync } from 'fs'; let app: INestApplication | null = null; +function describeServer(baseUrl: string): string { + if (baseUrl.includes('api.staging.trycomp.ai')) return 'Staging API Server'; + if (baseUrl.includes('api.trycomp.ai')) return 'Production API Server'; + if (baseUrl.startsWith('http://localhost')) return 'Local API Server'; + return 'API Server'; +} + async function bootstrap(): Promise { // Disable body parser - required for better-auth NestJS integration // The library will re-add body parsers after handling auth routes @@ -113,12 +120,14 @@ async function bootstrap(): Promise { // Get server configuration from environment variables const port = process.env.PORT ?? 3333; - // Swagger/OpenAPI configuration + // Swagger/OpenAPI configuration — single server derived from BASE_URL + const baseUrl = process.env.BASE_URL ?? `http://localhost:${port}`; + const serverDescription = describeServer(baseUrl); + const config = new DocumentBuilder() .setTitle('API Documentation') .setDescription('The API documentation for this application') .setVersion('1.0') - .addServer('http://localhost:3333', 'Local API Server') .addApiKey( { type: 'apiKey', @@ -128,7 +137,7 @@ async function bootstrap(): Promise { }, 'apikey', ) - .addServer('https://api.trycomp.ai', 'API Server') + .addServer(baseUrl, serverDescription) .build(); const document: OpenAPIObject = SwaggerModule.createDocument(app, config); diff --git a/apps/api/src/openapi-docs.spec.ts b/apps/api/src/openapi-docs.spec.ts new file mode 100644 index 0000000000..6317293e92 --- /dev/null +++ b/apps/api/src/openapi-docs.spec.ts @@ -0,0 +1,155 @@ +// Mock better-auth ESM-only modules so Jest (CJS) can import AppModule's transitive AuthModule. +// These must appear before any imports so that Jest hoists them before module evaluation. + +// Stub the auth instance so auth.server.ts never runs its top-level side effects +// (validateSecurityConfig, betterAuth(), Redis connection, etc.) +jest.mock('./auth/auth.server', () => ({ + auth: { + api: {}, + handler: async () => new Response(null, { status: 204 }), + options: {}, + }, + getTrustedOrigins: () => [], + isTrustedOrigin: async () => false, + isStaticTrustedOrigin: () => false, +})); + +// Stub the NestJS better-auth integration module +jest.mock('@thallesp/nestjs-better-auth', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Module } = require('@nestjs/common'); + @Module({}) + class AuthModuleStub { + static forRoot() { + return { module: AuthModuleStub, imports: [], providers: [], exports: [] }; + } + } + return { AuthModule: AuthModuleStub }; +}); + +// Stub better-auth ESM-only packages (loaded by @trycompai/auth package) +jest.mock('better-auth/plugins/access', () => ({ + createAccessControl: () => ({ + newRole: () => ({}), + statement: {}, + }), +})); +jest.mock('better-auth/plugins/organization/access', () => ({ + defaultStatements: {}, + adminAc: {}, + ownerAc: {}, +})); + +// Stub @trycompai/auth (dist/ not built in worktree; avoids resolving better-auth ESM) +jest.mock('@trycompai/auth', () => { + const emptyRole = { statements: {} }; + const roles = { + owner: emptyRole, + admin: emptyRole, + auditor: emptyRole, + employee: emptyRole, + contractor: emptyRole, + }; + return { + ac: { newRole: () => emptyRole }, + statement: {}, + allRoles: roles, + ...roles, + ROLE_HIERARCHY: ['contractor', 'employee', 'auditor', 'admin', 'owner'], + RESTRICTED_ROLES: ['employee', 'contractor'], + PRIVILEGED_ROLES: ['owner', 'admin', 'auditor'], + BUILT_IN_ROLE_PERMISSIONS: {}, + BUILT_IN_ROLE_OBLIGATIONS: {}, + }; +}); + +// Mock @db — keep all Prisma enums from @prisma/client, replace only the db instance +// so that module-level enum references (e.g. IsEnum(CommentEntityType)) resolve correctly +jest.mock('@db', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const prismaClient = require('@prisma/client'); + return { + ...prismaClient, + db: { + $connect: jest.fn(), + $disconnect: jest.fn(), + organization: { findFirst: jest.fn(), findMany: jest.fn() }, + auditLog: { create: jest.fn() }, + trust: { findMany: jest.fn().mockResolvedValue([]) }, + apiKey: { findFirst: jest.fn() }, + session: { findFirst: jest.fn() }, + member: { findFirst: jest.fn() }, + }, + }; +}); + +// Mock @upstash/redis to avoid real Redis connections during tests +jest.mock('@upstash/redis', () => ({ + Redis: jest.fn().mockImplementation(() => ({ + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue('OK'), + })), +})); + +// Set required env vars before any module-level code runs. +// These prevent config factories (aws.config.ts, better-auth, etc.) from throwing. +process.env.SECRET_KEY = 'test-secret-key-at-least-16-chars'; +process.env.BASE_URL = 'http://localhost:3333'; +process.env.APP_AWS_ACCESS_KEY_ID = 'test-access-key-id'; +process.env.APP_AWS_SECRET_ACCESS_KEY = 'test-secret-access-key'; +process.env.APP_AWS_BUCKET_NAME = 'test-bucket'; +process.env.APP_AWS_REGION = 'us-east-1'; + +import { Test } from '@nestjs/testing'; +import { DocumentBuilder, SwaggerModule, type OpenAPIObject } from '@nestjs/swagger'; +import { INestApplication, VersioningType } from '@nestjs/common'; +import { AppModule } from './app.module'; + +describe('OpenAPI document', () => { + let app: INestApplication; + let document: OpenAPIObject; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleRef.createNestApplication(); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); + await app.init(); + + const config = new DocumentBuilder() + .setTitle('Test') + .setVersion('1.0') + .build(); + document = SwaggerModule.createDocument(app, config); + }); + + afterAll(async () => { + if (app) await app.close(); + }); + + const hiddenPrefixes = ['/v1/auth', '/v1/admin', '/v1/internal']; + + for (const prefix of hiddenPrefixes) { + it(`does not expose any path starting with ${prefix}`, () => { + const exposed = Object.keys(document.paths).filter((p) => p.startsWith(prefix)); + expect(exposed).toEqual([]); + }); + } + + describe('summaries', () => { + it('every public operation declares a non-empty summary', () => { + const missing: string[] = []; + for (const [routePath, methods] of Object.entries(document.paths)) { + for (const [method, op] of Object.entries(methods as Record)) { + if (typeof op !== 'object' || !op) continue; + if (!op.summary || op.summary.trim() === '') { + missing.push(`${method.toUpperCase()} ${routePath}`); + } + } + } + expect(missing).toEqual([]); + }); + }); +}); diff --git a/apps/api/src/organization/organization.controller.ts b/apps/api/src/organization/organization.controller.ts index 9c9730bd65..a93c9044df 100644 --- a/apps/api/src/organization/organization.controller.ts +++ b/apps/api/src/organization/organization.controller.ts @@ -92,6 +92,7 @@ export class OrganizationController { @Get('onboarding') @RequirePermission('organization', 'read') + @ApiOperation({ summary: 'Get organization onboarding status' }) async getOnboarding(@OrganizationId() organizationId: string) { return this.organizationService.findOnboarding(organizationId); } diff --git a/apps/api/src/questionnaire/questionnaire.controller.ts b/apps/api/src/questionnaire/questionnaire.controller.ts index 4f7f5146a4..567d11b110 100644 --- a/apps/api/src/questionnaire/questionnaire.controller.ts +++ b/apps/api/src/questionnaire/questionnaire.controller.ts @@ -20,6 +20,7 @@ import { ApiBody, ApiConsumes, ApiOkResponse, + ApiOperation, ApiProduces, ApiQuery, ApiSecurity, @@ -73,6 +74,7 @@ export class QuestionnaireController { @Get() @RequirePermission('questionnaire', 'read') + @ApiOperation({ summary: 'List questionnaires' }) @ApiOkResponse({ description: 'List of questionnaires' }) async findAll( @OrganizationId() organizationId: string, @@ -95,6 +97,7 @@ export class QuestionnaireController { @Get(':id') @RequirePermission('questionnaire', 'read') + @ApiOperation({ summary: 'Get a questionnaire by ID' }) @ApiOkResponse({ description: 'Questionnaire details' }) async findById( @Param('id') id: string, @@ -123,6 +126,7 @@ export class QuestionnaireController { @Delete(':id') @RequirePermission('questionnaire', 'delete') + @ApiOperation({ summary: 'Delete a questionnaire' }) @ApiOkResponse({ description: 'Questionnaire deleted' }) async deleteById( @Param('id') id: string, @@ -133,6 +137,7 @@ export class QuestionnaireController { @Post('parse') @RequirePermission('questionnaire', 'read') + @ApiOperation({ summary: 'Parse an uploaded questionnaire file' }) @ApiConsumes('application/json') @ApiOkResponse({ description: 'Parsed questionnaire content', @@ -146,6 +151,7 @@ export class QuestionnaireController { @Post('answer-single') @RequirePermission('questionnaire', 'update') + @ApiOperation({ summary: 'Answer a single questionnaire question' }) @ApiConsumes('application/json') @ApiOkResponse({ description: 'Generated single answer result', @@ -186,6 +192,7 @@ export class QuestionnaireController { @Post('save-answer') @RequirePermission('questionnaire', 'update') + @ApiOperation({ summary: 'Save a questionnaire answer' }) @ApiConsumes('application/json') @ApiOkResponse({ description: 'Save manual or generated answer', @@ -207,6 +214,7 @@ export class QuestionnaireController { @Post('delete-answer') @RequirePermission('questionnaire', 'delete') + @ApiOperation({ summary: 'Delete a questionnaire answer' }) @ApiConsumes('application/json') @ApiOkResponse({ description: 'Delete questionnaire answer', @@ -229,6 +237,7 @@ export class QuestionnaireController { @Post('export') @RequirePermission('questionnaire', 'read') @AuditRead() + @ApiOperation({ summary: 'Export a questionnaire' }) @ApiConsumes('application/json') @ApiProduces( 'application/pdf', @@ -257,6 +266,7 @@ export class QuestionnaireController { @Post('upload-and-parse') @RequirePermission('questionnaire', 'create') + @ApiOperation({ summary: 'Upload and parse a questionnaire file' }) @ApiConsumes('application/json') @ApiOkResponse({ description: @@ -279,6 +289,7 @@ export class QuestionnaireController { @Post('upload-and-parse/upload') @RequirePermission('questionnaire', 'create') + @ApiOperation({ summary: 'Upload a questionnaire file and parse its questions' }) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiBody({ @@ -341,6 +352,7 @@ export class QuestionnaireController { @Post('parse/upload') @RequirePermission('questionnaire', 'create') + @ApiOperation({ summary: 'Upload a questionnaire file and auto-answer with export' }) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiBody({ @@ -421,6 +433,7 @@ export class QuestionnaireController { @Post('parse/upload/token') @Public() @UseGuards() // Override class-level guards — this endpoint uses token-based auth + @ApiOperation({ summary: 'Upload and auto-answer a questionnaire via trust portal token' }) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiQuery({ @@ -500,6 +513,7 @@ export class QuestionnaireController { @Post('answers/export') @RequirePermission('questionnaire', 'read') @AuditRead() + @ApiOperation({ summary: 'Export questionnaire answers' }) @ApiConsumes('application/json') @ApiProduces( 'application/pdf', @@ -529,6 +543,7 @@ export class QuestionnaireController { @Post('answers/export/upload') @RequirePermission('questionnaire', 'create') + @ApiOperation({ summary: 'Upload a questionnaire file and export auto-generated answers' }) @UseInterceptors(FileInterceptor('file')) @ApiConsumes('multipart/form-data') @ApiBody({ @@ -595,6 +610,7 @@ export class QuestionnaireController { @Post('auto-answer') @RequirePermission('questionnaire', 'update') + @ApiOperation({ summary: 'Auto-answer a questionnaire' }) @ApiConsumes('application/json') @ApiProduces('text/event-stream') async autoAnswer( diff --git a/apps/api/src/vendors/internal-vendor-automation.controller.ts b/apps/api/src/vendors/internal-vendor-automation.controller.ts index 9526d7fb0b..c5790c3633 100644 --- a/apps/api/src/vendors/internal-vendor-automation.controller.ts +++ b/apps/api/src/vendors/internal-vendor-automation.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common'; import { + ApiExcludeController, ApiOperation, ApiResponse, ApiSecurity, @@ -15,6 +16,7 @@ import { TriggerSingleVendorRiskAssessmentDto, } from './dto/trigger-vendor-risk-assessment.dto'; +@ApiExcludeController() @ApiTags('Internal - Vendors') @Controller({ path: 'internal/vendors', version: '1' }) @UseGuards(HybridAuthGuard, PermissionGuard) diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index f424fb4785..238cca83ef 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -1,76 +1,6 @@ { "openapi": "3.0.0", "paths": { - "/v1/auth/me": { - "get": { - "operationId": "AuthController_getMe_v1", - "parameters": [], - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "apikey": [] - } - ], - "summary": "Get current user info, organizations, and pending invitations", - "tags": [ - "Auth" - ] - } - }, - "/v1/auth/invitations": { - "get": { - "operationId": "AuthController_listInvitations_v1", - "parameters": [], - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "apikey": [] - } - ], - "summary": "List pending invitations for the organization", - "tags": [ - "Auth" - ] - } - }, - "/v1/auth/invitations/{id}": { - "delete": { - "operationId": "AuthController_deleteInvitation_v1", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "description": "Invitation ID", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "apikey": [] - } - ], - "summary": "Revoke a pending invitation", - "tags": [ - "Auth" - ] - } - }, "/v1/organization": { "get": { "description": "Returns detailed information about the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).", @@ -515,6 +445,7 @@ "apikey": [] } ], + "summary": "Get organization onboarding status", "tags": [ "Organization" ] @@ -4712,66 +4643,6 @@ ] } }, - "/v1/internal/vendors/risk-assessment/trigger-batch": { - "post": { - "operationId": "InternalVendorAutomationController_triggerVendorRiskAssessmentBatch_v1", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TriggerVendorRiskAssessmentBatchDto" - } - } - } - }, - "responses": { - "200": { - "description": "Tasks triggered" - } - }, - "security": [ - { - "apikey": [] - } - ], - "summary": "Trigger vendor risk assessment tasks for a batch of vendors (internal)", - "tags": [ - "Internal - Vendors" - ] - } - }, - "/v1/internal/vendors/risk-assessment/trigger-single": { - "post": { - "operationId": "InternalVendorAutomationController_triggerSingleVendorRiskAssessment_v1", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TriggerSingleVendorRiskAssessmentDto" - } - } - } - }, - "responses": { - "200": { - "description": "Task triggered with run info for real-time tracking" - } - }, - "security": [ - { - "apikey": [] - } - ], - "summary": "Trigger vendor risk assessment for a single vendor and return run info (internal)", - "tags": [ - "Internal - Vendors" - ] - } - }, "/v1/context": { "get": { "description": "Returns all context entries for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).", @@ -7914,6 +7785,7 @@ "description": "" } }, + "summary": "Exchange an auth code for device credentials", "tags": [ "Device Agent" ] @@ -7937,6 +7809,7 @@ "description": "" } }, + "summary": "Download a device-agent update", "tags": [ "Device Agent" ] @@ -7958,6 +7831,7 @@ "description": "" } }, + "summary": "Check a device-agent update's metadata", "tags": [ "Device Agent" ] @@ -7982,6 +7856,7 @@ "description": "" } }, + "summary": "Create a device-agent auth code", "tags": [ "Device Agent" ] @@ -7996,6 +7871,7 @@ "description": "" } }, + "summary": "List organizations for the current device", "tags": [ "Device Agent" ] @@ -8020,6 +7896,7 @@ "description": "" } }, + "summary": "Register a device agent", "tags": [ "Device Agent" ] @@ -8044,6 +7921,7 @@ "description": "" } }, + "summary": "Submit a device check-in", "tags": [ "Device Agent" ] @@ -8075,6 +7953,7 @@ "description": "" } }, + "summary": "Get device-agent status", "tags": [ "Device Agent" ] @@ -12293,6 +12172,7 @@ "description": "" } }, + "summary": "List control templates", "tags": [ "Framework Editor Control Templates" ] @@ -12324,6 +12204,7 @@ "description": "" } }, + "summary": "Create a control template", "tags": [ "Framework Editor Control Templates" ] @@ -12347,6 +12228,7 @@ "description": "" } }, + "summary": "Get a control template by ID", "tags": [ "Framework Editor Control Templates" ] @@ -12378,6 +12260,7 @@ "description": "" } }, + "summary": "Update a control template", "tags": [ "Framework Editor Control Templates" ] @@ -12399,6 +12282,7 @@ "description": "" } }, + "summary": "Delete a control template", "tags": [ "Framework Editor Control Templates" ] @@ -12430,6 +12314,7 @@ "description": "" } }, + "summary": "Link a requirement to a control template", "tags": [ "Framework Editor Control Templates" ] @@ -12459,6 +12344,7 @@ "description": "" } }, + "summary": "Unlink a requirement from a control template", "tags": [ "Framework Editor Control Templates" ] @@ -12490,6 +12376,7 @@ "description": "" } }, + "summary": "Link a policy template to a control template", "tags": [ "Framework Editor Control Templates" ] @@ -12519,6 +12406,7 @@ "description": "" } }, + "summary": "Unlink a policy template from a control template", "tags": [ "Framework Editor Control Templates" ] @@ -12550,6 +12438,7 @@ "description": "" } }, + "summary": "Link a task template to a control template", "tags": [ "Framework Editor Control Templates" ] @@ -12579,6 +12468,7 @@ "description": "" } }, + "summary": "Unlink a task template from a control template", "tags": [ "Framework Editor Control Templates" ] @@ -12610,6 +12500,7 @@ "description": "" } }, + "summary": "List frameworks", "tags": [ "Framework Editor Frameworks" ] @@ -12632,6 +12523,7 @@ "description": "" } }, + "summary": "Create a framework", "tags": [ "Framework Editor Frameworks" ] @@ -12655,6 +12547,7 @@ "description": "" } }, + "summary": "Get a framework by ID", "tags": [ "Framework Editor Frameworks" ] @@ -12686,6 +12579,7 @@ "description": "" } }, + "summary": "Update a framework", "tags": [ "Framework Editor Frameworks" ] @@ -12707,6 +12601,7 @@ "description": "" } }, + "summary": "Delete a framework", "tags": [ "Framework Editor Frameworks" ] @@ -12731,6 +12626,7 @@ "description": "" } }, + "summary": "Import a framework definition", "tags": [ "Framework Editor Frameworks" ] @@ -12754,6 +12650,7 @@ "description": "" } }, + "summary": "Export a framework definition", "tags": [ "Framework Editor Frameworks" ] @@ -12777,6 +12674,7 @@ "description": "" } }, + "summary": "List controls for a framework", "tags": [ "Framework Editor Frameworks" ] @@ -12800,6 +12698,7 @@ "description": "" } }, + "summary": "List policy templates for a framework", "tags": [ "Framework Editor Frameworks" ] @@ -12823,6 +12722,7 @@ "description": "" } }, + "summary": "List task templates for a framework", "tags": [ "Framework Editor Frameworks" ] @@ -12846,6 +12746,7 @@ "description": "" } }, + "summary": "List documents for a framework", "tags": [ "Framework Editor Frameworks" ] @@ -12877,6 +12778,7 @@ "description": "" } }, + "summary": "Link a control to a framework", "tags": [ "Framework Editor Frameworks" ] @@ -12908,6 +12810,7 @@ "description": "" } }, + "summary": "Link a task template to a framework", "tags": [ "Framework Editor Frameworks" ] @@ -12939,6 +12842,7 @@ "description": "" } }, + "summary": "Link a policy template to a framework", "tags": [ "Framework Editor Frameworks" ] @@ -12978,6 +12882,7 @@ "description": "" } }, + "summary": "List policy templates", "tags": [ "Framework Editor Policy Templates" ] @@ -13009,6 +12914,7 @@ "description": "" } }, + "summary": "Create a policy template", "tags": [ "Framework Editor Policy Templates" ] @@ -13032,6 +12938,7 @@ "description": "" } }, + "summary": "Get a policy template by ID", "tags": [ "Framework Editor Policy Templates" ] @@ -13063,6 +12970,7 @@ "description": "" } }, + "summary": "Update a policy template", "tags": [ "Framework Editor Policy Templates" ] @@ -13084,6 +12992,7 @@ "description": "" } }, + "summary": "Delete a policy template", "tags": [ "Framework Editor Policy Templates" ] @@ -13117,6 +13026,7 @@ "description": "" } }, + "summary": "Update policy template content", "tags": [ "Framework Editor Policy Templates" ] @@ -13148,6 +13058,7 @@ "description": "" } }, + "summary": "List requirements", "tags": [ "Framework Editor Requirements" ] @@ -13170,6 +13081,7 @@ "description": "" } }, + "summary": "Create a requirement", "tags": [ "Framework Editor Requirements" ] @@ -13203,6 +13115,7 @@ "description": "" } }, + "summary": "Update a requirement", "tags": [ "Framework Editor Requirements" ] @@ -13224,6 +13137,7 @@ "description": "" } }, + "summary": "Delete a requirement", "tags": [ "Framework Editor Requirements" ] @@ -14124,6 +14038,7 @@ "apikey": [] } ], + "summary": "List questionnaires", "tags": [ "Questionnaire" ] @@ -14152,6 +14067,7 @@ "apikey": [] } ], + "summary": "Get a questionnaire by ID", "tags": [ "Questionnaire" ] @@ -14178,6 +14094,7 @@ "apikey": [] } ], + "summary": "Delete a questionnaire", "tags": [ "Questionnaire" ] @@ -14214,6 +14131,7 @@ "apikey": [] } ], + "summary": "Parse an uploaded questionnaire file", "tags": [ "Questionnaire" ] @@ -14280,6 +14198,7 @@ "apikey": [] } ], + "summary": "Answer a single questionnaire question", "tags": [ "Questionnaire" ] @@ -14325,6 +14244,7 @@ "apikey": [] } ], + "summary": "Save a questionnaire answer", "tags": [ "Questionnaire" ] @@ -14370,6 +14290,7 @@ "apikey": [] } ], + "summary": "Delete a questionnaire answer", "tags": [ "Questionnaire" ] @@ -14399,6 +14320,7 @@ "apikey": [] } ], + "summary": "Export a questionnaire", "tags": [ "Questionnaire" ] @@ -14443,6 +14365,7 @@ "apikey": [] } ], + "summary": "Upload and parse a questionnaire file", "tags": [ "Questionnaire" ] @@ -14511,6 +14434,7 @@ "apikey": [] } ], + "summary": "Upload a questionnaire file and parse its questions", "tags": [ "Questionnaire" ] @@ -14574,6 +14498,7 @@ "apikey": [] } ], + "summary": "Upload a questionnaire file and auto-answer with export", "tags": [ "Questionnaire" ] @@ -14633,6 +14558,7 @@ "apikey": [] } ], + "summary": "Upload and auto-answer a questionnaire via trust portal token", "tags": [ "Questionnaire" ] @@ -14662,6 +14588,7 @@ "apikey": [] } ], + "summary": "Export questionnaire answers", "tags": [ "Questionnaire" ] @@ -14716,6 +14643,7 @@ "apikey": [] } ], + "summary": "Upload a questionnaire file and export auto-generated answers", "tags": [ "Questionnaire" ] @@ -14745,6 +14673,7 @@ "apikey": [] } ], + "summary": "Auto-answer a questionnaire", "tags": [ "Questionnaire" ] @@ -15309,6 +15238,7 @@ "apikey": [] } ], + "summary": "Check OAuth provider availability", "tags": [ "Integrations" ] @@ -15328,6 +15258,7 @@ "apikey": [] } ], + "summary": "Start an OAuth authorization flow", "tags": [ "Integrations" ] @@ -15347,6 +15278,7 @@ "apikey": [] } ], + "summary": "Handle OAuth provider callback", "tags": [ "Integrations" ] @@ -15366,6 +15298,7 @@ "apikey": [] } ], + "summary": "List configured OAuth apps", "tags": [ "Integrations" ] @@ -15383,6 +15316,7 @@ "apikey": [] } ], + "summary": "Create an OAuth app configuration", "tags": [ "Integrations" ] @@ -15411,6 +15345,7 @@ "apikey": [] } ], + "summary": "Get OAuth app setup details", "tags": [ "Integrations" ] @@ -15439,6 +15374,7 @@ "apikey": [] } ], + "summary": "Delete an OAuth app configuration", "tags": [ "Integrations" ] @@ -15467,6 +15403,7 @@ "apikey": [] } ], + "summary": "List available integration providers", "tags": [ "Integrations" ] @@ -15495,6 +15432,7 @@ "apikey": [] } ], + "summary": "Get an integration provider by slug", "tags": [ "Integrations" ] @@ -15514,6 +15452,7 @@ "apikey": [] } ], + "summary": "List integration connections", "tags": [ "Integrations" ] @@ -15531,6 +15470,7 @@ "apikey": [] } ], + "summary": "Create an integration connection", "tags": [ "Integrations" ] @@ -15559,6 +15499,7 @@ "apikey": [] } ], + "summary": "Get an integration connection by ID", "tags": [ "Integrations" ] @@ -15585,6 +15526,7 @@ "apikey": [] } ], + "summary": "Delete an integration connection", "tags": [ "Integrations" ] @@ -15611,6 +15553,7 @@ "apikey": [] } ], + "summary": "Update an integration connection", "tags": [ "Integrations" ] @@ -15639,6 +15582,7 @@ "apikey": [] } ], + "summary": "Test an integration connection", "tags": [ "Integrations" ] @@ -15667,6 +15611,7 @@ "apikey": [] } ], + "summary": "Pause an integration connection", "tags": [ "Integrations" ] @@ -15695,6 +15640,7 @@ "apikey": [] } ], + "summary": "Resume an integration connection", "tags": [ "Integrations" ] @@ -15723,6 +15669,7 @@ "apikey": [] } ], + "summary": "Disconnect an integration", "tags": [ "Integrations" ] @@ -15751,6 +15698,7 @@ "apikey": [] } ], + "summary": "Ensure valid credentials for a connection", "tags": [ "Integrations" ] @@ -15779,6 +15727,7 @@ "apikey": [] } ], + "summary": "Set services enabled on a connection", "tags": [ "Integrations" ] @@ -15805,6 +15754,7 @@ "apikey": [] } ], + "summary": "List services enabled on a connection", "tags": [ "Integrations" ] @@ -15833,28 +15783,15 @@ "apikey": [] } ], + "summary": "Update integration credentials", "tags": [ "Integrations" ] } }, - "/v1/admin/integrations": { - "get": { - "operationId": "AdminIntegrationsController_listIntegrations_v1", - "parameters": [], - "responses": { - "200": { - "description": "" - } - }, - "tags": [ - "AdminIntegrations" - ] - } - }, - "/v1/admin/integrations/{providerSlug}": { + "/v1/integrations/checks/providers/{providerSlug}": { "get": { - "operationId": "AdminIntegrationsController_getIntegration_v1", + "operationId": "ChecksController_listProviderChecks_v1", "parameters": [ { "name": "providerSlug", @@ -15870,31 +15807,23 @@ "description": "" } }, - "tags": [ - "AdminIntegrations" - ] - } - }, - "/v1/admin/integrations/credentials": { - "post": { - "operationId": "AdminIntegrationsController_savePlatformCredentials_v1", - "parameters": [], - "responses": { - "201": { - "description": "" + "security": [ + { + "apikey": [] } - }, + ], + "summary": "List check definitions for a provider", "tags": [ - "AdminIntegrations" + "Integrations" ] } }, - "/v1/admin/integrations/credentials/{providerSlug}": { - "delete": { - "operationId": "AdminIntegrationsController_deletePlatformCredentials_v1", + "/v1/integrations/checks/connections/{connectionId}": { + "get": { + "operationId": "ChecksController_listConnectionChecks_v1", "parameters": [ { - "name": "providerSlug", + "name": "connectionId", "required": true, "in": "path", "schema": { @@ -15907,55 +15836,60 @@ "description": "" } }, + "security": [ + { + "apikey": [] + } + ], + "summary": "List checks for a connection", "tags": [ - "AdminIntegrations" + "Integrations" ] } }, - "/v1/internal/dynamic-integrations": { - "put": { - "operationId": "DynamicIntegrationsController_upsert_v1", - "parameters": [], - "responses": { - "200": { - "description": "" - } - }, - "tags": [ - "DynamicIntegrations" - ] - }, + "/v1/integrations/checks/connections/{connectionId}/run": { "post": { - "operationId": "DynamicIntegrationsController_create_v1", - "parameters": [], + "operationId": "ChecksController_runConnectionChecks_v1", + "parameters": [ + { + "name": "connectionId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], "responses": { "201": { "description": "" } }, - "tags": [ - "DynamicIntegrations" - ] - }, - "get": { - "operationId": "DynamicIntegrationsController_list_v1", - "parameters": [], - "responses": { - "200": { - "description": "" + "security": [ + { + "apikey": [] } - }, + ], + "summary": "Run all checks for a connection", "tags": [ - "DynamicIntegrations" + "Integrations" ] } }, - "/v1/internal/dynamic-integrations/{id}": { - "get": { - "operationId": "DynamicIntegrationsController_getById_v1", + "/v1/integrations/checks/connections/{connectionId}/run/{checkId}": { + "post": { + "operationId": "ChecksController_runSingleCheck_v1", "parameters": [ { - "name": "id", + "name": "connectionId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "checkId", "required": true, "in": "path", "schema": { @@ -15964,19 +15898,27 @@ } ], "responses": { - "200": { + "201": { "description": "" } }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Run a single check on a connection", "tags": [ - "DynamicIntegrations" + "Integrations" ] - }, - "patch": { - "operationId": "DynamicIntegrationsController_update_v1", + } + }, + "/v1/integrations/variables/providers/{providerSlug}": { + "get": { + "operationId": "VariablesController_getProviderVariables_v1", "parameters": [ { - "name": "id", + "name": "providerSlug", "required": true, "in": "path", "schema": { @@ -15989,15 +15931,23 @@ "description": "" } }, + "security": [ + { + "apikey": [] + } + ], + "summary": "List variable definitions for a provider", "tags": [ - "DynamicIntegrations" + "Integrations" ] - }, - "delete": { - "operationId": "DynamicIntegrationsController_remove_v1", + } + }, + "/v1/integrations/variables/connections/{connectionId}": { + "get": { + "operationId": "VariablesController_getConnectionVariables_v1", "parameters": [ { - "name": "id", + "name": "connectionId", "required": true, "in": "path", "schema": { @@ -16010,17 +15960,21 @@ "description": "" } }, + "security": [ + { + "apikey": [] + } + ], + "summary": "List connection variables", "tags": [ - "DynamicIntegrations" + "Integrations" ] - } - }, - "/v1/internal/dynamic-integrations/{id}/checks": { + }, "post": { - "operationId": "DynamicIntegrationsController_addCheck_v1", + "operationId": "VariablesController_saveConnectionVariables_v1", "parameters": [ { - "name": "id", + "name": "connectionId", "required": true, "in": "path", "schema": { @@ -16033,17 +15987,23 @@ "description": "" } }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Update connection variables", "tags": [ - "DynamicIntegrations" + "Integrations" ] } }, - "/v1/internal/dynamic-integrations/{id}/checks/{checkId}": { - "patch": { - "operationId": "DynamicIntegrationsController_updateCheck_v1", + "/v1/integrations/variables/connections/{connectionId}/options/{variableId}": { + "get": { + "operationId": "VariablesController_fetchVariableOptions_v1", "parameters": [ { - "name": "id", + "name": "connectionId", "required": true, "in": "path", "schema": { @@ -16051,7 +16011,7 @@ } }, { - "name": "checkId", + "name": "variableId", "required": true, "in": "path", "schema": { @@ -16064,46 +16024,23 @@ "description": "" } }, - "tags": [ - "DynamicIntegrations" - ] - }, - "delete": { - "operationId": "DynamicIntegrationsController_removeCheck_v1", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, + "security": [ { - "name": "checkId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } + "apikey": [] } ], - "responses": { - "200": { - "description": "" - } - }, + "summary": "Get options for a connection variable", "tags": [ - "DynamicIntegrations" + "Integrations" ] } }, - "/v1/internal/dynamic-integrations/{id}/activate": { - "post": { - "operationId": "DynamicIntegrationsController_activate_v1", + "/v1/integrations/tasks/template/{templateId}/checks": { + "get": { + "operationId": "TaskIntegrationsController_getChecksForTaskTemplate_v1", "parameters": [ { - "name": "id", + "name": "templateId", "required": true, "in": "path", "schema": { @@ -16112,58 +16049,27 @@ } ], "responses": { - "201": { + "200": { "description": "" } }, - "tags": [ - "DynamicIntegrations" - ] - } - }, - "/v1/internal/dynamic-integrations/{id}/deactivate": { - "post": { - "operationId": "DynamicIntegrationsController_deactivate_v1", - "parameters": [ + "security": [ { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } + "apikey": [] } ], - "responses": { - "201": { - "description": "" - } - }, - "tags": [ - "DynamicIntegrations" - ] - } - }, - "/v1/internal/dynamic-integrations/validate": { - "post": { - "operationId": "DynamicIntegrationsController_validate_v1", - "parameters": [], - "responses": { - "201": { - "description": "" - } - }, + "summary": "List checks for a task template", "tags": [ - "DynamicIntegrations" + "Integrations" ] } }, - "/v1/internal/dynamic-integrations/{id}/check-runs": { + "/v1/integrations/tasks/{taskId}/checks": { "get": { - "operationId": "DynamicIntegrationsController_getCheckRuns_v1", + "operationId": "TaskIntegrationsController_getChecksForTask_v1", "parameters": [ { - "name": "id", + "name": "taskId", "required": true, "in": "path", "schema": { @@ -16176,40 +16082,23 @@ "description": "" } }, - "tags": [ - "DynamicIntegrations" - ] - } - }, - "/v1/internal/dynamic-integrations/check-runs/{runId}": { - "get": { - "operationId": "DynamicIntegrationsController_getCheckRunById_v1", - "parameters": [ + "security": [ { - "name": "runId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } + "apikey": [] } ], - "responses": { - "200": { - "description": "" - } - }, + "summary": "List checks attached to a task", "tags": [ - "DynamicIntegrations" + "Integrations" ] } }, - "/v1/integrations/checks/providers/{providerSlug}": { - "get": { - "operationId": "ChecksController_listProviderChecks_v1", + "/v1/integrations/tasks/{taskId}/run-check": { + "post": { + "operationId": "TaskIntegrationsController_runCheckForTask_v1", "parameters": [ { - "name": "providerSlug", + "name": "taskId", "required": true, "in": "path", "schema": { @@ -16218,7 +16107,7 @@ } ], "responses": { - "200": { + "201": { "description": "" } }, @@ -16227,17 +16116,18 @@ "apikey": [] } ], + "summary": "Run a check for a task", "tags": [ "Integrations" ] } }, - "/v1/integrations/checks/connections/{connectionId}": { - "get": { - "operationId": "ChecksController_listConnectionChecks_v1", + "/v1/integrations/tasks/{taskId}/checks/disconnect": { + "post": { + "operationId": "TaskIntegrationsController_disconnectCheckFromTask_v1", "parameters": [ { - "name": "connectionId", + "name": "taskId", "required": true, "in": "path", "schema": { @@ -16246,7 +16136,7 @@ } ], "responses": { - "200": { + "201": { "description": "" } }, @@ -16255,17 +16145,18 @@ "apikey": [] } ], + "summary": "Disconnect checks from a task", "tags": [ "Integrations" ] } }, - "/v1/integrations/checks/connections/{connectionId}/run": { + "/v1/integrations/tasks/{taskId}/checks/reconnect": { "post": { - "operationId": "ChecksController_runConnectionChecks_v1", + "operationId": "TaskIntegrationsController_reconnectCheckToTask_v1", "parameters": [ { - "name": "connectionId", + "name": "taskId", "required": true, "in": "path", "schema": { @@ -16283,17 +16174,18 @@ "apikey": [] } ], + "summary": "Reconnect checks to a task", "tags": [ "Integrations" ] } }, - "/v1/integrations/checks/connections/{connectionId}/run/{checkId}": { - "post": { - "operationId": "ChecksController_runSingleCheck_v1", + "/v1/integrations/tasks/{taskId}/runs": { + "get": { + "operationId": "TaskIntegrationsController_getTaskCheckRuns_v1", "parameters": [ { - "name": "connectionId", + "name": "taskId", "required": true, "in": "path", "schema": { @@ -16301,16 +16193,16 @@ } }, { - "name": "checkId", + "name": "limit", "required": true, - "in": "path", + "in": "query", "schema": { "type": "string" } } ], "responses": { - "201": { + "200": { "description": "" } }, @@ -16319,14 +16211,15 @@ "apikey": [] } ], + "summary": "List check runs for a task", "tags": [ "Integrations" ] } }, - "/v1/integrations/variables/providers/{providerSlug}": { - "get": { - "operationId": "VariablesController_getProviderVariables_v1", + "/v1/integrations/webhooks/{providerSlug}/{connectionId}": { + "post": { + "operationId": "WebhookController_handleWebhook_v1", "parameters": [ { "name": "providerSlug", @@ -16335,27 +16228,7 @@ "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "apikey": [] - } - ], - "tags": [ - "Integrations" - ] - } - }, - "/v1/integrations/variables/connections/{connectionId}": { - "get": { - "operationId": "VariablesController_getConnectionVariables_v1", - "parameters": [ + }, { "name": "connectionId", "required": true, @@ -16366,26 +16239,24 @@ } ], "responses": { - "200": { + "201": { "description": "" } }, - "security": [ - { - "apikey": [] - } - ], + "summary": "Receive a provider webhook event", "tags": [ - "Integrations" + "Webhook" ] - }, + } + }, + "/v1/integrations/sync/google-workspace/employees": { "post": { - "operationId": "VariablesController_saveConnectionVariables_v1", + "operationId": "SyncController_syncGoogleWorkspaceEmployees_v1", "parameters": [ { "name": "connectionId", "required": true, - "in": "path", + "in": "query", "schema": { "type": "string" } @@ -16401,34 +16272,18 @@ "apikey": [] } ], + "summary": "Sync Google Workspace employees", "tags": [ "Integrations" ] } }, - "/v1/integrations/variables/connections/{connectionId}/options/{variableId}": { - "get": { - "operationId": "VariablesController_fetchVariableOptions_v1", - "parameters": [ - { - "name": "connectionId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "variableId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], + "/v1/integrations/sync/google-workspace/status": { + "post": { + "operationId": "SyncController_getGoogleWorkspaceStatus_v1", + "parameters": [], "responses": { - "200": { + "201": { "description": "" } }, @@ -16437,26 +16292,27 @@ "apikey": [] } ], + "summary": "Get Google Workspace sync status", "tags": [ "Integrations" ] } }, - "/v1/integrations/tasks/template/{templateId}/checks": { - "get": { - "operationId": "TaskIntegrationsController_getChecksForTaskTemplate_v1", + "/v1/integrations/sync/rippling/employees": { + "post": { + "operationId": "SyncController_syncRipplingEmployees_v1", "parameters": [ { - "name": "templateId", + "name": "connectionId", "required": true, - "in": "path", + "in": "query", "schema": { "type": "string" } } ], "responses": { - "200": { + "201": { "description": "" } }, @@ -16465,26 +16321,18 @@ "apikey": [] } ], + "summary": "Sync Rippling employees", "tags": [ "Integrations" ] } }, - "/v1/integrations/tasks/{taskId}/checks": { - "get": { - "operationId": "TaskIntegrationsController_getChecksForTask_v1", - "parameters": [ - { - "name": "taskId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], + "/v1/integrations/sync/rippling/status": { + "post": { + "operationId": "SyncController_getRipplingStatus_v1", + "parameters": [], "responses": { - "200": { + "201": { "description": "" } }, @@ -16493,19 +16341,20 @@ "apikey": [] } ], + "summary": "Get Rippling sync status", "tags": [ "Integrations" ] } }, - "/v1/integrations/tasks/{taskId}/run-check": { + "/v1/integrations/sync/jumpcloud/employees": { "post": { - "operationId": "TaskIntegrationsController_runCheckForTask_v1", + "operationId": "SyncController_syncJumpCloudEmployees_v1", "parameters": [ { - "name": "taskId", + "name": "connectionId", "required": true, - "in": "path", + "in": "query", "schema": { "type": "string" } @@ -16521,260 +16370,16 @@ "apikey": [] } ], + "summary": "Sync JumpCloud employees", "tags": [ "Integrations" ] } }, - "/v1/integrations/tasks/{taskId}/checks/disconnect": { + "/v1/integrations/sync/jumpcloud/status": { "post": { - "operationId": "TaskIntegrationsController_disconnectCheckFromTask_v1", - "parameters": [ - { - "name": "taskId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "apikey": [] - } - ], - "tags": [ - "Integrations" - ] - } - }, - "/v1/integrations/tasks/{taskId}/checks/reconnect": { - "post": { - "operationId": "TaskIntegrationsController_reconnectCheckToTask_v1", - "parameters": [ - { - "name": "taskId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "apikey": [] - } - ], - "tags": [ - "Integrations" - ] - } - }, - "/v1/integrations/tasks/{taskId}/runs": { - "get": { - "operationId": "TaskIntegrationsController_getTaskCheckRuns_v1", - "parameters": [ - { - "name": "taskId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "apikey": [] - } - ], - "tags": [ - "Integrations" - ] - } - }, - "/v1/integrations/webhooks/{providerSlug}/{connectionId}": { - "post": { - "operationId": "WebhookController_handleWebhook_v1", - "parameters": [ - { - "name": "providerSlug", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "connectionId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "" - } - }, - "tags": [ - "Webhook" - ] - } - }, - "/v1/integrations/sync/google-workspace/employees": { - "post": { - "operationId": "SyncController_syncGoogleWorkspaceEmployees_v1", - "parameters": [ - { - "name": "connectionId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "apikey": [] - } - ], - "tags": [ - "Integrations" - ] - } - }, - "/v1/integrations/sync/google-workspace/status": { - "post": { - "operationId": "SyncController_getGoogleWorkspaceStatus_v1", - "parameters": [], - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "apikey": [] - } - ], - "tags": [ - "Integrations" - ] - } - }, - "/v1/integrations/sync/rippling/employees": { - "post": { - "operationId": "SyncController_syncRipplingEmployees_v1", - "parameters": [ - { - "name": "connectionId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "apikey": [] - } - ], - "tags": [ - "Integrations" - ] - } - }, - "/v1/integrations/sync/rippling/status": { - "post": { - "operationId": "SyncController_getRipplingStatus_v1", - "parameters": [], - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "apikey": [] - } - ], - "tags": [ - "Integrations" - ] - } - }, - "/v1/integrations/sync/jumpcloud/employees": { - "post": { - "operationId": "SyncController_syncJumpCloudEmployees_v1", - "parameters": [ - { - "name": "connectionId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "apikey": [] - } - ], - "tags": [ - "Integrations" - ] - } - }, - "/v1/integrations/sync/jumpcloud/status": { - "post": { - "operationId": "SyncController_getJumpCloudStatus_v1", - "parameters": [], + "operationId": "SyncController_getJumpCloudStatus_v1", + "parameters": [], "responses": { "201": { "description": "" @@ -16785,6 +16390,7 @@ "apikey": [] } ], + "summary": "Get JumpCloud sync status", "tags": [ "Integrations" ] @@ -16804,6 +16410,7 @@ "apikey": [] } ], + "summary": "Get the currently configured employee sync provider", "tags": [ "Integrations" ] @@ -16821,6 +16428,7 @@ "apikey": [] } ], + "summary": "Set the employee sync provider", "tags": [ "Integrations" ] @@ -16840,6 +16448,7 @@ "apikey": [] } ], + "summary": "List employee sync providers available to the org", "tags": [ "Integrations" ] @@ -16876,6 +16485,7 @@ "apikey": [] } ], + "summary": "Sync employees for a dynamic provider", "tags": [ "Integrations" ] @@ -16899,6 +16509,7 @@ "description": "" } }, + "summary": "List recent cloud security activity", "tags": [ "CloudSecurity" ] @@ -16913,6 +16524,7 @@ "description": "" } }, + "summary": "List supported cloud providers", "tags": [ "CloudSecurity" ] @@ -16927,6 +16539,7 @@ "description": "" } }, + "summary": "List cloud security findings", "tags": [ "CloudSecurity" ] @@ -16950,6 +16563,7 @@ "description": "" } }, + "summary": "Trigger a security scan for a connection", "tags": [ "CloudSecurity" ] @@ -16973,6 +16587,7 @@ "description": "" } }, + "summary": "Detect available cloud services for a connection", "tags": [ "CloudSecurity" ] @@ -16996,6 +16611,7 @@ "description": "" } }, + "summary": "Detect the GCP organization for a connection", "tags": [ "CloudSecurity" ] @@ -17019,6 +16635,7 @@ "description": "" } }, + "summary": "Select GCP projects for a connection", "tags": [ "CloudSecurity" ] @@ -17042,6 +16659,7 @@ "description": "" } }, + "summary": "Set up GCP for a connection", "tags": [ "CloudSecurity" ] @@ -17065,6 +16683,7 @@ "description": "" } }, + "summary": "Resolve a GCP setup step", "tags": [ "CloudSecurity" ] @@ -17088,6 +16707,7 @@ "description": "" } }, + "summary": "Set up Azure for a connection", "tags": [ "CloudSecurity" ] @@ -17111,6 +16731,7 @@ "description": "" } }, + "summary": "Validate Azure credentials for a connection", "tags": [ "CloudSecurity" ] @@ -17134,6 +16755,7 @@ "description": "" } }, + "summary": "Trigger a cloud security run for a connection", "tags": [ "CloudSecurity" ] @@ -17165,6 +16787,7 @@ "description": "" } }, + "summary": "Get a cloud security scan run by ID", "tags": [ "CloudSecurity" ] @@ -17179,6 +16802,7 @@ "description": "" } }, + "summary": "Create a legacy cloud integration", "tags": [ "CloudSecurity" ] @@ -17193,6 +16817,7 @@ "description": "" } }, + "summary": "Validate legacy AWS credentials", "tags": [ "CloudSecurity" ] @@ -17216,6 +16841,7 @@ "description": "" } }, + "summary": "Delete a legacy cloud integration", "tags": [ "CloudSecurity" ] @@ -17239,6 +16865,7 @@ "description": "" } }, + "summary": "List remediation capabilities", "tags": [ "Remediation" ] @@ -17253,6 +16880,7 @@ "description": "" } }, + "summary": "Preview a remediation", "tags": [ "Remediation" ] @@ -17267,6 +16895,7 @@ "description": "" } }, + "summary": "Execute a remediation", "tags": [ "Remediation" ] @@ -17290,6 +16919,7 @@ "description": "" } }, + "summary": "Roll back a remediation action", "tags": [ "Remediation" ] @@ -17313,6 +16943,7 @@ "description": "" } }, + "summary": "List remediation actions", "tags": [ "Remediation" ] @@ -17336,6 +16967,7 @@ "description": "" } }, + "summary": "Get the active remediation batch", "tags": [ "Remediation" ] @@ -17350,6 +16982,7 @@ "description": "" } }, + "summary": "Create a remediation batch", "tags": [ "Remediation" ] @@ -17373,6 +17006,7 @@ "description": "" } }, + "summary": "Update a remediation batch", "tags": [ "Remediation" ] @@ -17404,6 +17038,7 @@ "description": "" } }, + "summary": "Skip a finding in a remediation batch", "tags": [ "Remediation" ] @@ -20105,40 +19740,10 @@ ] } }, - "/v1/internal/email/send": { + "/v1/email/unsubscribe": { "post": { - "operationId": "EmailController_sendEmail_v1", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SendEmailDto" - } - } - } - }, - "responses": { - "200": { - "description": "Email task triggered" - } - }, - "security": [ - { - "apikey": [] - } - ], - "summary": "Send an email via the centralized Trigger task (internal)", - "tags": [ - "Internal - Email" - ] - } - }, - "/v1/email/unsubscribe": { - "post": { - "operationId": "UnsubscribeController_unsubscribe_v1", - "parameters": [ + "operationId": "UnsubscribeController_unsubscribe_v1", + "parameters": [ { "name": "email", "required": true, @@ -20731,1001 +20336,30 @@ "tags": [ "Pentest Billing" ] - } - }, - "/v1/pentest-billing/charge": { - "post": { - "operationId": "PentestBillingController_charge_v1", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChargeDto" - } - } - } - }, - "responses": { - "200": { - "description": "Charge result returned" - } - }, - "summary": "Check and charge overage for a pentest run", - "tags": [ - "Pentest Billing" - ] - } - }, - "/v1/admin/organizations": { - "get": { - "operationId": "AdminOrganizationsController_list_v1", - "parameters": [ - { - "name": "search", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "List all organizations (platform admin)", - "tags": [ - "Admin - Organizations" - ] - } - }, - "/v1/admin/organizations/activity": { - "get": { - "operationId": "AdminOrganizationsController_activity_v1", - "parameters": [ - { - "name": "inactiveDays", - "required": false, - "in": "query", - "description": "Filter orgs with no session in N days (default: 90)", - "schema": { - "type": "string" - } - }, - { - "name": "hasAccess", - "required": false, - "in": "query", - "description": "Filter by hasAccess (true/false)", - "schema": { - "type": "string" - } - }, - { - "name": "onboarded", - "required": false, - "in": "query", - "description": "Filter by onboardingCompleted (true/false)", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "Organization activity report - shows last session per org (platform admin)", - "tags": [ - "Admin - Organizations" - ] - } - }, - "/v1/admin/organizations/{id}": { - "get": { - "operationId": "AdminOrganizationsController_get_v1", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "Get organization details (platform admin)", - "tags": [ - "Admin - Organizations" - ] - } - }, - "/v1/admin/organizations/{id}/activate": { - "patch": { - "operationId": "AdminOrganizationsController_activate_v1", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "Activate organization access (platform admin)", - "tags": [ - "Admin - Organizations" - ] - } - }, - "/v1/admin/organizations/{id}/deactivate": { - "patch": { - "operationId": "AdminOrganizationsController_deactivate_v1", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "Deactivate organization access (platform admin)", - "tags": [ - "Admin - Organizations" - ] - } - }, - "/v1/admin/organizations/{id}/invite": { - "post": { - "operationId": "AdminOrganizationsController_inviteMember_v1", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InviteMemberDto" - } - } - } - }, - "responses": { - "201": { - "description": "" - } - }, - "summary": "Invite member to organization (platform admin)", - "tags": [ - "Admin - Organizations" - ] - } - }, - "/v1/admin/organizations/{id}/audit-logs": { - "get": { - "operationId": "AdminOrganizationsController_getAuditLogs_v1", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "entityType", - "required": false, - "in": "query", - "description": "Filter by entity type (e.g. policy, task)", - "schema": { - "type": "string" - } - }, - { - "name": "take", - "required": false, - "in": "query", - "description": "Number of logs to return (max 100, default 100)", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "Get audit logs for an organization (platform admin)", - "tags": [ - "Admin - Organizations" - ] - } - }, - "/v1/admin/organizations/{id}/invitations": { - "get": { - "operationId": "AdminOrganizationsController_listInvitations_v1", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "List pending invitations (platform admin)", - "tags": [ - "Admin - Organizations" - ] - } - }, - "/v1/admin/organizations/{id}/invitations/{invId}": { - "delete": { - "operationId": "AdminOrganizationsController_revokeInvitation_v1", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "invId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "Revoke invitation (platform admin)", - "tags": [ - "Admin - Organizations" - ] - } - }, - "/v1/admin/organizations/{orgId}/findings": { - "get": { - "operationId": "AdminFindingsController_list_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "status", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "List all findings for an organization (admin)", - "tags": [ - "Admin - Findings" - ] - }, - "post": { - "operationId": "AdminFindingsController_create_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateFindingDto" - } - } - } - }, - "responses": { - "201": { - "description": "" - } - }, - "summary": "Create a finding for an organization (admin)", - "tags": [ - "Admin - Findings" - ] - } - }, - "/v1/admin/organizations/{orgId}/findings/{findingId}": { - "patch": { - "operationId": "AdminFindingsController_update_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "findingId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateFindingDto" - } - } - } - }, - "responses": { - "200": { - "description": "" - } - }, - "summary": "Update a finding for an organization (admin)", - "tags": [ - "Admin - Findings" - ] - } - }, - "/v1/admin/organizations/{orgId}/policies": { - "get": { - "operationId": "AdminPoliciesController_list_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "List all policies for an organization (admin)", - "tags": [ - "Admin - Policies" - ] - }, - "post": { - "operationId": "AdminPoliciesController_create_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateAdminPolicyDto" - } - } - } - }, - "responses": { - "201": { - "description": "" - } - }, - "summary": "Create a policy for an organization (admin)", - "tags": [ - "Admin - Policies" - ] - } - }, - "/v1/admin/organizations/{orgId}/policies/{policyId}": { - "patch": { - "operationId": "AdminPoliciesController_update_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "policyId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "Update a policy for an organization (admin)", - "tags": [ - "Admin - Policies" - ] - } - }, - "/v1/admin/organizations/{orgId}/policies/{policyId}/regenerate": { - "post": { - "operationId": "AdminPoliciesController_regenerate_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "policyId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "" - } - }, - "summary": "Regenerate policy content using AI (admin)", - "tags": [ - "Admin - Policies" - ] - } - }, - "/v1/admin/organizations/{orgId}/tasks": { - "get": { - "operationId": "AdminTasksController_list_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "List all tasks for an organization (admin)", - "tags": [ - "Admin - Tasks" - ] - }, - "post": { - "operationId": "AdminTasksController_create_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateAdminTaskDto" - } - } - } - }, - "responses": { - "201": { - "description": "" - } - }, - "summary": "Create a task for an organization (admin)", - "tags": [ - "Admin - Tasks" - ] - } - }, - "/v1/admin/organizations/{orgId}/tasks/{taskId}/details": { - "get": { - "operationId": "AdminTasksController_getDetails_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "taskId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "Get task details with comments, attachments, and evidence (admin)", - "tags": [ - "Admin - Tasks" - ] - } - }, - "/v1/admin/organizations/{orgId}/tasks/{taskId}": { - "patch": { - "operationId": "AdminTasksController_update_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "taskId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "Update a task for an organization (admin)", - "tags": [ - "Admin - Tasks" - ] - } - }, - "/v1/admin/organizations/{orgId}/vendors": { - "get": { - "operationId": "AdminVendorsController_list_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "List all vendors for an organization (admin)", - "tags": [ - "Admin - Vendors" - ] - }, - "post": { - "operationId": "AdminVendorsController_create_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateAdminVendorDto" - } - } - } - }, - "responses": { - "201": { - "description": "" - } - }, - "summary": "Create a vendor for an organization (admin)", - "tags": [ - "Admin - Vendors" - ] - } - }, - "/v1/admin/organizations/{orgId}/vendors/{vendorId}": { - "patch": { - "operationId": "AdminVendorsController_update_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "vendorId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "Update a vendor for an organization (admin)", - "tags": [ - "Admin - Vendors" - ] - } - }, - "/v1/admin/organizations/{orgId}/vendors/{vendorId}/trigger-assessment": { - "post": { - "operationId": "AdminVendorsController_triggerAssessment_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "vendorId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "" - } - }, - "summary": "Trigger vendor risk assessment (admin)", - "tags": [ - "Admin - Vendors" - ] - } - }, - "/v1/admin/organizations/{orgId}/context": { - "get": { - "operationId": "AdminContextController_list_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "search", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "page", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "perPage", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "List context entries for an organization (admin)", - "tags": [ - "Admin - Context" - ] - }, - "post": { - "operationId": "AdminContextController_create_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateContextDto" - } - } - } - }, - "responses": { - "201": { - "description": "" - } - }, - "summary": "Create a context entry for an organization (admin)", - "tags": [ - "Admin - Context" - ] - } - }, - "/v1/admin/organizations/{orgId}/context/{contextId}": { - "patch": { - "operationId": "AdminContextController_update_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "contextId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateContextDto" - } - } - } - }, - "responses": { - "200": { - "description": "" - } - }, - "summary": "Update a context entry for an organization (admin)", - "tags": [ - "Admin - Context" - ] - } - }, - "/v1/admin/organizations/{orgId}/evidence-forms": { - "get": { - "operationId": "AdminEvidenceController_listFormStatuses_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "summary": "List evidence form statuses for an organization (admin)", - "tags": [ - "Admin - Evidence" - ] - } - }, - "/v1/admin/organizations/{orgId}/evidence-forms/{formType}": { - "get": { - "operationId": "AdminEvidenceController_getFormWithSubmissions_v1", - "parameters": [ - { - "name": "orgId", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "formType", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - }, - { - "name": "search", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "limit", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "offset", - "required": true, - "in": "query", - "schema": { - "type": "string" + } + }, + "/v1/pentest-billing/charge": { + "post": { + "operationId": "PentestBillingController_charge_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChargeDto" + } } } - ], + }, "responses": { "200": { - "description": "" + "description": "Charge result returned" } }, - "summary": "Get evidence form with submissions (admin)", + "summary": "Check and charge overage for a pentest run", "tags": [ - "Admin - Evidence" + "Pentest Billing" ] } } @@ -21741,10 +20375,6 @@ { "url": "http://localhost:3333", "description": "Local API Server" - }, - { - "url": "https://api.trycomp.ai", - "description": "API Server" } ], "components": { @@ -22620,89 +21250,6 @@ } } }, - "TriggerVendorRiskAssessmentVendorDto": { - "type": "object", - "properties": { - "vendorId": { - "type": "string", - "description": "Vendor ID", - "example": "vnd_abc123" - }, - "vendorName": { - "type": "string", - "description": "Vendor name", - "example": "CloudTech Solutions" - }, - "vendorWebsite": { - "type": "object", - "description": "Vendor website (optional)", - "example": "https://cloudtechsolutions.com" - } - }, - "required": [ - "vendorId", - "vendorName" - ] - }, - "TriggerVendorRiskAssessmentBatchDto": { - "type": "object", - "properties": { - "organizationId": { - "type": "string", - "description": "Organization ID (deprecated — use auth context)", - "example": "org_abc123" - }, - "withResearch": { - "type": "boolean", - "description": "If false, skips Firecrawl research (cheaper). Defaults to true.", - "default": true - }, - "vendors": { - "description": "Vendors to trigger risk assessment for", - "type": "array", - "items": { - "$ref": "#/components/schemas/TriggerVendorRiskAssessmentVendorDto" - } - } - }, - "required": [ - "vendors" - ] - }, - "TriggerSingleVendorRiskAssessmentDto": { - "type": "object", - "properties": { - "organizationId": { - "type": "string", - "description": "Organization ID (deprecated — use auth context)", - "example": "org_abc123" - }, - "vendorId": { - "type": "string", - "description": "Vendor ID", - "example": "vnd_abc123" - }, - "vendorName": { - "type": "string", - "description": "Vendor name", - "example": "CloudTech Solutions" - }, - "vendorWebsite": { - "type": "string", - "description": "Vendor website", - "example": "https://cloudtechsolutions.com" - }, - "createdByUserId": { - "type": "object", - "description": "User ID who triggered the assessment (optional)" - } - }, - "required": [ - "vendorId", - "vendorName", - "vendorWebsite" - ] - }, "CreateContextDto": { "type": "object", "properties": { @@ -24358,7 +22905,12 @@ "cnameTarget": { "type": "string", "description": "The recommended CNAME target for this domain from Vercel", - "example": "cname.vercel-dns.com" + "example": "3a69a5bb27875189.vercel-dns-016.com" + }, + "misconfigured": { + "type": "object", + "description": "Whether Vercel's /v6/domains/{d}/config call reports the domain as misconfigured. Null when Vercel could not be reached.", + "nullable": true } }, "required": [ @@ -26215,51 +24767,6 @@ "description" ] }, - "SendEmailDto": { - "type": "object", - "properties": { - "to": { - "type": "string", - "description": "Recipient email address" - }, - "subject": { - "type": "string", - "description": "Email subject line" - }, - "html": { - "type": "string", - "description": "Pre-rendered HTML content" - }, - "from": { - "type": "string", - "description": "Explicit FROM address override" - }, - "system": { - "type": "boolean", - "description": "Use system sender address (RESEND_FROM_SYSTEM)" - }, - "cc": { - "type": "object", - "description": "CC recipients" - }, - "scheduledAt": { - "type": "string", - "description": "Schedule email for later delivery" - }, - "attachments": { - "description": "File attachments", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "to", - "subject", - "html" - ] - }, "CreatePenetrationTestDto": { "type": "object", "properties": { @@ -26319,185 +24826,6 @@ "ChargeDto": { "type": "object", "properties": {} - }, - "InviteMemberDto": { - "type": "object", - "properties": { - "email": { - "type": "string", - "description": "Email address of the user to invite", - "example": "user@example.com" - }, - "role": { - "type": "string", - "description": "Role to assign to the invited member", - "enum": [ - "admin", - "auditor", - "employee", - "contractor" - ], - "example": "admin" - } - }, - "required": [ - "email", - "role" - ] - }, - "CreateAdminPolicyDto": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the policy", - "example": "Data Privacy Policy" - }, - "description": { - "type": "string", - "description": "Description of the policy", - "example": "Outlines data handling procedures" - }, - "status": { - "type": "string", - "description": "Status of the policy", - "enum": [ - "draft", - "published", - "needs_review" - ], - "example": "draft" - }, - "frequency": { - "type": "string", - "description": "Review frequency", - "enum": [ - "monthly", - "quarterly", - "yearly" - ], - "example": "yearly" - }, - "department": { - "type": "string", - "description": "Department this policy applies to", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ], - "example": "it" - } - }, - "required": [ - "name" - ] - }, - "CreateAdminTaskDto": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "Title of the task", - "example": "Review access controls" - }, - "description": { - "type": "string", - "description": "Description of the task", - "example": "Review and update access control policies quarterly" - }, - "status": { - "type": "string", - "description": "Task status", - "enum": [ - "todo", - "in_progress", - "in_review", - "done", - "not_relevant", - "failed" - ] - }, - "frequency": { - "type": "string", - "description": "Task frequency", - "enum": [ - "daily", - "weekly", - "monthly", - "quarterly", - "yearly" - ] - }, - "department": { - "type": "string", - "description": "Department", - "enum": [ - "none", - "admin", - "gov", - "hr", - "it", - "itsm", - "qms" - ] - } - }, - "required": [ - "title", - "description" - ] - }, - "CreateAdminVendorDto": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Vendor name", - "example": "CloudTech Solutions" - }, - "description": { - "type": "string", - "description": "Description of the vendor and services", - "example": "Cloud infrastructure provider for compute and storage" - }, - "category": { - "type": "string", - "description": "Vendor category", - "enum": [ - "cloud", - "infrastructure", - "software_as_a_service", - "finance", - "marketing", - "sales", - "hr", - "other" - ] - }, - "status": { - "type": "string", - "description": "Assessment status", - "enum": [ - "not_assessed", - "in_progress", - "assessed" - ] - }, - "website": { - "type": "string", - "description": "Vendor website URL", - "example": "https://example.com" - } - }, - "required": [ - "name", - "description" - ] } } } From 86357dfcdcfcd42c5f91c75fbf302799b8d32f92 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 16 Apr 2026 17:42:06 -0400 Subject: [PATCH 3/6] fix(tasks): render task description markdown in the main app (CS-98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Descriptions authored in the Framework Editor are markdown — bullets, bold, headings, blockquotes — and the editor preview renders them via ReactMarkdown. But the main app rendered the same string as plain text with whiteSpace: 'pre-line', so users saw literal "**" and "- " characters instead of formatted content. PR #2327 previously added whiteSpace: 'pre-line' which fixed bare newlines, but left markdown syntax raw. Swap the plain for the existing MarkdownRenderer (already used in the task automation chat). Click-to-edit behavior preserved by wrapping in a clickable div. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tasks/[taskId]/components/SingleTask.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx index b5b9b5893b..00c9a12acc 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx @@ -2,6 +2,7 @@ import { SelectAssignee } from '@/components/SelectAssignee'; import { RecentAuditLogs } from '@/components/RecentAuditLogs'; +import { MarkdownRenderer } from '../automation/[automationId]/components/markdown-renderer/markdown-renderer'; import { useAuditLogs } from '@/hooks/use-audit-logs'; import { useOrganizationMembers } from '@/hooks/use-organization-members'; import { downloadTaskEvidenceZip } from '@/lib/evidence-download'; @@ -289,15 +290,21 @@ export function SingleTask({ autoFocus /> ) : ( - - {task.description || 'Add a description...'} - + {task.description ? ( + + ) : ( + 'Add a description...' + )} +
)} From c6058f2eee52ff692376e9938073b7f63d918a2c Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 16 Apr 2026 17:59:05 -0400 Subject: [PATCH 4/6] fix(policies): restore the AI-tailoring UI on the Policies page (ENG-108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy PoliciesTable + PolicyFilters wiring was removed when we migrated to PoliciesTableDS in Jan 2026 (commit 8974418c6). Since then the page wrapped in PolicyTailoringProvider with an empty statuses map, so the per-row "Tailoring/Queued/Preparing" UI that PoliciesTableDS already renders had no data to show. Vendors and Risks were restored at the time; Policies was left missing. Wires it back up without touching the trigger.dev pipeline (which still emits policiesTotal/policiesCompleted/policy__status as before): 1. policies/(overview)/hooks/use-policy-onboarding-status.ts — new hook mirroring the risks/vendors ones, using useRealtimeRun. Handles the policies/policy singular/plural conversion correctly (the shared hook's `itemType.slice(0,-1)` would have produced "policie_"). 2. policies/(overview)/page.tsx — fetch /v1/organization/onboarding alongside /v1/policies (same pattern as risks/vendors) and pass the trigger run id into PolicyFilters. Move PolicyTailoringProvider into PolicyFilters since that's where the realtime data lives. 3. PolicyFilters.tsx — accept onboardingRunId, subscribe via the hook, wrap children in PolicyTailoringProvider with live statuses, render the "Tailoring your policies – Personalized N/M" banner while the run is active. Orphaned apps/app/.../policies/all/components/policies-table.tsx (the pre-migration table, now dead code) is left in place for a follow-up cleanup PR so this diff stays scoped. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hooks/use-policy-onboarding-status.ts | 95 ++++++++++++ .../[orgId]/policies/(overview)/page.tsx | 23 ++- .../policies/all/components/PolicyFilters.tsx | 139 +++++++++++------- 3 files changed, 194 insertions(+), 63 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/policies/(overview)/hooks/use-policy-onboarding-status.ts diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/hooks/use-policy-onboarding-status.ts b/apps/app/src/app/(app)/[orgId]/policies/(overview)/hooks/use-policy-onboarding-status.ts new file mode 100644 index 0000000000..9ecf5944f8 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/hooks/use-policy-onboarding-status.ts @@ -0,0 +1,95 @@ +'use client'; + +import { useRealtimeRun } from '@trigger.dev/react-hooks'; +import { useMemo } from 'react'; +import type { PolicyTailoringStatus } from '../../all/components/policy-tailoring-context'; + +export interface PolicyOnboardingItemInfo { + id: string; + name: string; +} + +/** + * Subscribe to the onboarding trigger.dev run and derive per-policy tailoring + * status plus overall progress. Mirrors use-onboarding-status in risk/ and + * vendors/ but handles the `policies` → `policy__status` singular + * conversion correctly (the shared hook's `itemType.slice(0, -1)` would + * produce `policie_`). + */ +export function usePolicyOnboardingStatus( + onboardingRunId: string | null | undefined, +) { + const shouldSubscribe = Boolean(onboardingRunId); + const { run } = useRealtimeRun(shouldSubscribe ? onboardingRunId! : '', { + enabled: shouldSubscribe, + }); + + const itemStatuses = useMemo>(() => { + if (!run?.metadata) return {}; + + const meta = run.metadata as Record; + const itemsInfo = + (meta.policiesInfo as Array<{ id: string; name: string }>) || []; + + return itemsInfo.reduce>( + (acc, item) => { + const status = meta[`policy_${item.id}_status`]; + if ( + status === 'queued' || + status === 'pending' || + status === 'processing' || + status === 'completed' + ) { + acc[item.id] = status; + } + return acc; + }, + {}, + ); + }, [run?.metadata]); + + const progress = useMemo(() => { + if (!run?.metadata) return null; + + const meta = run.metadata as Record; + const total = typeof meta.policiesTotal === 'number' ? meta.policiesTotal : 0; + const completed = + typeof meta.policiesCompleted === 'number' ? meta.policiesCompleted : 0; + + if (total === 0) return null; + return { total, completed }; + }, [run?.metadata]); + + const itemsInfo = useMemo(() => { + if (!run?.metadata) return []; + const meta = run.metadata as Record; + return (meta.policiesInfo as Array<{ id: string; name: string }>) || []; + }, [run?.metadata]); + + // Active if any item is not yet completed + const hasActiveItems = useMemo( + () => + Object.values(itemStatuses).some( + (status) => status !== 'completed' && status !== undefined, + ), + [itemStatuses], + ); + + const isRunActive = useMemo(() => { + if (!run) return false; + return ['EXECUTING', 'QUEUED', 'WAITING'].includes(run.status); + }, [run]); + + const hasActiveProgress = + progress !== null && progress.completed < progress.total; + const isActive = isRunActive || hasActiveProgress || hasActiveItems; + + return { + itemStatuses, + progress, + itemsInfo, + isActive, + isLoading: shouldSubscribe && !run, + runStatus: run?.status, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx index 5b49e3437d..efa640c196 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx @@ -3,7 +3,6 @@ import type { Policy } from '@db'; import { PageHeader, PageLayout, Stack } from '@trycompai/design-system'; import type { Metadata } from 'next'; import { Suspense } from 'react'; -import { PolicyTailoringProvider } from '../all/components/policy-tailoring-context'; import { PolicyFilters } from '../all/components/PolicyFilters'; import { PolicyPageActions } from '../all/components/PolicyPageActions'; import { PolicyChartsClient } from './components/PolicyChartsClient'; @@ -21,9 +20,18 @@ interface PoliciesPageProps { export default async function PoliciesPage({ params }: PoliciesPageProps) { const { orgId } = await params; - const policiesRes = await serverApi.get<{ data: PolicyWithAssignee[] }>( - '/v1/policies', - ); + // ENG-108: fetch the active onboarding run id alongside policies so the + // client-side filters component can subscribe and surface "tailoring your + // policies…" status during first-run AI generation. Mirrors the pattern + // used by the risks and vendors pages. + const [policiesRes, onboardingRes] = await Promise.all([ + serverApi.get<{ data: PolicyWithAssignee[] }>('/v1/policies'), + serverApi.get<{ + triggerJobId: string | null; + triggerJobCompleted: boolean; + } | null>('/v1/organization/onboarding'), + ]); + const policies = Array.isArray(policiesRes.data?.data) ? policiesRes.data.data : []; @@ -49,9 +57,10 @@ export default async function PoliciesPage({ params }: PoliciesPageProps) { }> - - - + ); diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyFilters.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyFilters.tsx index bd1a18ceb4..aef118f3f3 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyFilters.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/all/components/PolicyFilters.tsx @@ -14,12 +14,16 @@ import { Stack, } from '@trycompai/design-system'; import { Search } from '@trycompai/design-system/icons'; +import { Loader2 } from 'lucide-react'; import { useMemo, useState } from 'react'; +import { usePolicyOnboardingStatus } from '../../(overview)/hooks/use-policy-onboarding-status'; import { PoliciesTableDS } from './PoliciesTableDS'; +import { PolicyTailoringProvider } from './policy-tailoring-context'; import { comparePoliciesByName } from './policy-name-sort'; interface PolicyFiltersProps { policies: Policy[]; + onboardingRunId?: string | null; } const STATUS_OPTIONS: { value: PolicyStatus | 'all' | 'archived'; label: string }[] = [ @@ -30,13 +34,21 @@ const STATUS_OPTIONS: { value: PolicyStatus | 'all' | 'archived'; label: string { value: 'archived', label: 'Archived' }, ]; -export function PolicyFilters({ policies }: PolicyFiltersProps) { +export function PolicyFilters({ policies, onboardingRunId }: PolicyFiltersProps) { const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [departmentFilter, setDepartmentFilter] = useState('all'); const [sortColumn, setSortColumn] = useState<'name' | 'status' | 'updatedAt'>('name'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + // ENG-108: subscribe to the onboarding run so PoliciesTableDS can surface + // per-row "Tailoring/Queued/Preparing" state and we can render the banner + // while AI is still personalizing the policy pack. Mirrors the existing + // pattern in RisksTable/VendorsTable. + const { itemStatuses, progress, isActive } = usePolicyOnboardingStatus(onboardingRunId); + const hasActivePolicies = policies.length > 0; + const showTailoringBanner = isActive && hasActivePolicies && progress !== null; + // Get unique departments from policies const departments = useMemo(() => { const depts = new Set(); @@ -103,63 +115,78 @@ export function PolicyFilters({ policies }: PolicyFiltersProps) { departmentFilter === 'all' ? 'All Departments' : formatDepartment(departmentFilter); return ( - -
- {/* Search - full width on mobile, constrained on desktop */} -
- - - - - setSearchQuery(e.target.value)} - /> - -
- {/* Filters - side by side on mobile, inline with search on desktop */} -
-
- + + +
+ {/* Search - full width on mobile, constrained on desktop */} +
+ + + + + setSearchQuery(e.target.value)} + /> +
-
- + {/* Filters - side by side on mobile, inline with search on desktop */} +
+
+ +
+
+ +
-
- -
+ {showTailoringBanner && progress !== null && ( +
+
+ +
+
+ Tailoring your policies + + Personalized {progress.completed}/{progress.total} policies + +
+
+ )} + + +
); } From 212e532137f795f3ce77fdcd88eaaba91d2dc339 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 16 Apr 2026 18:03:25 -0400 Subject: [PATCH 5/6] fix(automations): defer next-run label to post-mount to avoid hydration mismatch (CS-97 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic flagged a real hydration risk on PR #2579: the Next Run label was computed synchronously during render with `new Date()` + `toLocaleString(undefined, …)`. Node.js renders in the server's timezone and locale (typically UTC) while the browser renders in the user's, so the HTML from SSR could differ from the first client render — especially around 09:00 UTC where `new Date()` between the two renders could flip the computed day. Switch from useMemo to useState + useEffect so the label is only computed on the client, after mount. Render an em-dash placeholder during SSR; it's replaced with the real weekday/time on mount. The initial client HTML now matches the server HTML exactly. Adds an SSR-safe-placeholder test using renderToString to lock in the property that the initial render contains no formatted weekday or time. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/MetricsSection.test.tsx | 38 ++++++++++++++----- .../overview/components/MetricsSection.tsx | 31 +++++++++------ 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx index aa0df43644..cfebe9831d 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx @@ -4,7 +4,9 @@ import { MetricsSection } from './MetricsSection'; describe('MetricsSection (CS-97)', () => { beforeEach(() => { - vi.useFakeTimers(); + // shouldAdvanceTime lets React effects flush on their normal tick + // while still letting us pin `new Date()` with vi.setSystemTime. + vi.useFakeTimers({ shouldAdvanceTime: true }); }); afterEach(() => { @@ -17,21 +19,37 @@ describe('MetricsSection (CS-97)', () => { expect(screen.getByText('Every day at 9:00 AM UTC')).toBeInTheDocument(); }); - it('renders the next run as a concrete weekday + time rather than the old hardcoded "Tomorrow 9:00 AM"', () => { + it('uses an SSR-safe placeholder for the next run (defers date formatting to post-mount)', () => { + // Verify the initial JSX does NOT synchronously format a Date — that's + // the property that keeps SSR and hydration outputs identical. We + // simulate "server-side" rendering with renderToString and assert the + // next-run cell contains the em-dash placeholder rather than a formatted + // weekday/time. + const { renderToString } = require('react-dom/server') as typeof import('react-dom/server'); + vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); + const html = renderToString( + , + ); + expect(html).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/); + expect(html).toContain('—'); + }); + + it('fills in the next-run label after mount with a concrete weekday + time', async () => { vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); render(); - // The old hardcoded literals must be gone. + // Old hardcoded literals must never appear. expect(screen.queryByText('Every Day 9:00 AM')).not.toBeInTheDocument(); expect(screen.queryByText('Tomorrow 9:00 AM')).not.toBeInTheDocument(); - // The next-run line should be a real locale string formatted as - // "Weekday H:MM AM/PM", which is NOT the word "Tomorrow". - const nextRunLabels = screen.getAllByText(/\d{1,2}:\d{2}\s(AM|PM)$/); + // After mount the effect runs and the real label shows up. + const nextRunLabels = await screen.findAllByText( + /\d{1,2}:\d{2}\s(AM|PM)$/, + ); expect(nextRunLabels.length).toBeGreaterThan(0); }); - it('picks today (UTC) when the current time is before 09:00 UTC', () => { + it('picks today (UTC) when the current time is before 09:00 UTC', async () => { // 2026-04-16 07:00 UTC → next run is 2026-04-16 09:00 UTC (same day). vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); render(); @@ -45,10 +63,10 @@ describe('MetricsSection (CS-97)', () => { hour12: true, }, ); - expect(screen.getByText(expected)).toBeInTheDocument(); + expect(await screen.findByText(expected)).toBeInTheDocument(); }); - it('picks the next day (UTC) when the current time is past 09:00 UTC', () => { + it('picks the next day (UTC) when the current time is past 09:00 UTC', async () => { // 2026-04-16 10:00 UTC → today's run already happened, next is 2026-04-17 09:00 UTC. vi.setSystemTime(new Date('2026-04-16T10:00:00Z')); render(); @@ -62,6 +80,6 @@ describe('MetricsSection (CS-97)', () => { hour12: true, }, ); - expect(screen.getByText(expected)).toBeInTheDocument(); + expect(await screen.findByText(expected)).toBeInTheDocument(); }); }); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx index ff8f62effe..47eadac1d4 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx @@ -1,7 +1,7 @@ 'use client'; import type { EvidenceAutomationRun, EvidenceAutomationVersion } from '@db'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; interface MetricsSectionProps { initialVersions: EvidenceAutomationVersion[]; @@ -33,7 +33,16 @@ export function MetricsSection({ // comp-private/apps/enterprise-api/src/trigger/automation/run-automations-schedule.ts). // Render the schedule explicitly in UTC and the next run in the user's // local timezone so the label matches when it actually fires. - const nextRun = useMemo(() => { + // + // The next-run label is computed on the client only (useState + useEffect + // instead of useMemo) to avoid a hydration mismatch: Node.js renders in + // the server's timezone + locale (typically UTC) while the browser renders + // in the user's, and `new Date()` can also tick across 09:00 UTC between + // the two renders and produce a different weekday. We render `—` during + // SSR and fill it in once mounted. + const [nextRunLabel, setNextRunLabel] = useState(null); + + useEffect(() => { const now = new Date(); const next = new Date( Date.UTC( @@ -49,7 +58,14 @@ export function MetricsSection({ if (next.getTime() <= now.getTime()) { next.setUTCDate(next.getUTCDate() + 1); } - return next; + setNextRunLabel( + next.toLocaleString(undefined, { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }), + ); }, []); return ( @@ -69,14 +85,7 @@ export function MetricsSection({

Next Run

-

- {nextRun.toLocaleString(undefined, { - weekday: 'short', - hour: 'numeric', - minute: '2-digit', - hour12: true, - })} -

+

{nextRunLabel ?? '—'}

From 6e7429be627991d8cb081fcc10ccbf470018def3 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 16 Apr 2026 18:10:10 -0400 Subject: [PATCH 6/6] fix(automations): add timezone to next-run label + tighten placeholder test (CS-97 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from cubic on PR #2579: 1. P2: The next-run label rendered "Fri 12:00 PM" with no timezone indicator, which is ambiguous right next to the "9:00 AM UTC" Schedule card — users can't tell if it's local or UTC, the exact confusion this PR was supposed to fix. Add `timeZoneName: 'short'` so the output reads e.g. "Thu, 5:00 AM EDT" (local) or "Thu 9:00 AM UTC" (for a UTC viewer). 2. P3: The SSR-placeholder test asserted `expect(html).toContain('—')` which also matches the Success Rate card's `—` when there are no runs, so it would pass even if the Next Run placeholder were gone. Scope the assertion to the Next Run cell via a regex that matches the "Next Run" label's adjacent `

` value. Test regex also updated to tolerate a comma after the weekday — Node and some browser locales emit "Thu, 5:00 AM EDT" rather than a bare "Thu 5:00 AM EDT". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/MetricsSection.test.tsx | 27 ++++++++++++++----- .../overview/components/MetricsSection.tsx | 3 +++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx index cfebe9831d..805db6a275 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.test.tsx @@ -23,18 +23,28 @@ describe('MetricsSection (CS-97)', () => { // Verify the initial JSX does NOT synchronously format a Date — that's // the property that keeps SSR and hydration outputs identical. We // simulate "server-side" rendering with renderToString and assert the - // next-run cell contains the em-dash placeholder rather than a formatted - // weekday/time. + // Next Run cell specifically contains the em-dash placeholder rather + // than a formatted weekday/time. We scope the assertion to the Next Run + // card because Success Rate also renders `—` when there are no runs. const { renderToString } = require('react-dom/server') as typeof import('react-dom/server'); vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); const html = renderToString( , ); + + // No weekday should appear anywhere in SSR output. expect(html).not.toMatch(/Mon|Tue|Wed|Thu|Fri|Sat|Sun/); - expect(html).toContain('—'); + + // Locate the Next Run cell and assert its value paragraph contains `—`. + // Matches:

Next Run

+ const nextRunCellMatch = html.match( + /Next Run[^<]*<\/p>\s*]*>([^<]*)<\/p>/, + ); + expect(nextRunCellMatch).not.toBeNull(); + expect(nextRunCellMatch?.[1]).toBe('—'); }); - it('fills in the next-run label after mount with a concrete weekday + time', async () => { + it('fills in the next-run label after mount with a concrete weekday, time, and timezone', async () => { vi.setSystemTime(new Date('2026-04-16T07:00:00Z')); render(); @@ -42,9 +52,12 @@ describe('MetricsSection (CS-97)', () => { expect(screen.queryByText('Every Day 9:00 AM')).not.toBeInTheDocument(); expect(screen.queryByText('Tomorrow 9:00 AM')).not.toBeInTheDocument(); - // After mount the effect runs and the real label shows up. + // After mount the effect runs and the real label shows up. It must + // include an explicit timezone so it's not confused with the UTC + // Schedule card next to it — e.g. "Thu 9:00 AM UTC" or "Thu, 12:00 PM EST". + // Node/browser locales may insert a comma after the weekday; allow both. const nextRunLabels = await screen.findAllByText( - /\d{1,2}:\d{2}\s(AM|PM)$/, + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun),?\s\d{1,2}:\d{2}\s(AM|PM)\s.+/, ); expect(nextRunLabels.length).toBeGreaterThan(0); }); @@ -61,6 +74,7 @@ describe('MetricsSection (CS-97)', () => { hour: 'numeric', minute: '2-digit', hour12: true, + timeZoneName: 'short', }, ); expect(await screen.findByText(expected)).toBeInTheDocument(); @@ -78,6 +92,7 @@ describe('MetricsSection (CS-97)', () => { hour: 'numeric', minute: '2-digit', hour12: true, + timeZoneName: 'short', }, ); expect(await screen.findByText(expected)).toBeInTheDocument(); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx index 47eadac1d4..2d85e4871d 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automations/[automationId]/overview/components/MetricsSection.tsx @@ -58,12 +58,15 @@ export function MetricsSection({ if (next.getTime() <= now.getTime()) { next.setUTCDate(next.getUTCDate() + 1); } + // Include timeZoneName so the label is unambiguous alongside the UTC + // Schedule card — otherwise "Fri 12:00 PM" could be mistaken for UTC. setNextRunLabel( next.toLocaleString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit', hour12: true, + timeZoneName: 'short', }), ); }, []);