diff --git a/apps/api/src/integration-platform/controllers/task-integrations.controller.spec.ts b/apps/api/src/integration-platform/controllers/task-integrations.controller.spec.ts index 089f881575..f9080b939b 100644 --- a/apps/api/src/integration-platform/controllers/task-integrations.controller.spec.ts +++ b/apps/api/src/integration-platform/controllers/task-integrations.controller.spec.ts @@ -220,6 +220,7 @@ describe('TaskIntegrationsController', () => { complete: jest.fn(), addResults: jest.fn(), findLatestPerConnectionAndCheckByTask: jest.fn(), + findLastAttemptPerConnectionAndCheckByTask: jest.fn(), countExceptedFailures: jest.fn(), }; const mockCredentialVaultService = { getDecryptedCredentials: jest.fn() }; @@ -290,6 +291,10 @@ describe('TaskIntegrationsController', () => { // Default: nothing excepted (no count query needed). Exception tests // override this to the exact excepted-failure count for the run. mockCheckRunRepository.countExceptedFailures.mockResolvedValue(0); + // Default: no last-attempt rows (tests that care set their own). + mockCheckRunRepository.findLastAttemptPerConnectionAndCheckByTask.mockResolvedValue( + [], + ); mockCredentialVaultService.getDecryptedCredentials.mockResolvedValue( VALID_CREDS, ); @@ -891,6 +896,30 @@ describe('TaskIntegrationsController', () => { ).not.toHaveBeenCalled(); }); + it('returns lastAttempts (incl. held runs) so "Last ran" stays truthful (CS-753)', async () => { + // The visible runs list can be days older than the newest attempt when + // recent runs were held (they're excluded from `runs`). The endpoint + // must surface WHEN each (connection, check) last ran — timestamps only. + mockCheckRunRepository.findLatestPerConnectionAndCheckByTask.mockResolvedValue( + [], + ); + const attempt = { + connectionId: 'conn_1', + checkId: 'entra_id_mfa', + lastAttemptAt: new Date('2026-07-16T06:00:00Z'), + }; + mockCheckRunRepository.findLastAttemptPerConnectionAndCheckByTask.mockResolvedValue( + [attempt], + ); + + const response = await controller.getTaskCheckRuns('task_1', 'org_1'); + + expect(response.lastAttempts).toEqual([attempt]); + expect( + mockCheckRunRepository.findLastAttemptPerConnectionAndCheckByTask, + ).toHaveBeenCalledWith('task_1'); + }); + it('bounds a run with a huge result set + logs so the payload stays small (CS-588)', async () => { // Defense-in-depth response cap: even if a run somehow carries a large // result/log set, the serialized response is bounded (results per 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 9e57913819..4e9fe519c6 100644 --- a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts @@ -925,6 +925,15 @@ export class TaskIntegrationsController { }), ); - return { runs: mappedRuns }; + // WHEN each (connection, check) last ran, INCLUDING runs held as + // 'inconclusive' (which are excluded from `runs`). Timestamps only — held + // outcomes stay hidden. Without this the UI's "Last ran" froze at the last + // visible run while the daily schedule kept running (CS-753). + const lastAttempts = + await this.checkRunRepository.findLastAttemptPerConnectionAndCheckByTask( + taskId, + ); + + return { runs: mappedRuns, lastAttempts }; } } diff --git a/apps/api/src/integration-platform/repositories/check-run.repository.spec.ts b/apps/api/src/integration-platform/repositories/check-run.repository.spec.ts index ee4fbba814..ca09485f97 100644 --- a/apps/api/src/integration-platform/repositories/check-run.repository.spec.ts +++ b/apps/api/src/integration-platform/repositories/check-run.repository.spec.ts @@ -266,6 +266,55 @@ describe('CheckRunRepository.findLatestPerConnectionAndCheckByTask', () => { }); }); +describe('CheckRunRepository.findLastAttemptPerConnectionAndCheckByTask', () => { + const repo = new CheckRunRepository(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns each group’s latest attempt timestamp, INCLUDING held runs (CS-753)', async () => { + mockGroupBy.mockResolvedValue([ + { + connectionId: 'A', + checkId: 'entra_id_mfa', + _max: { createdAt: new Date('2026-07-16T06:00:00Z') }, + }, + ]); + + const attempts = + await repo.findLastAttemptPerConnectionAndCheckByTask('task_1'); + + expect(attempts).toEqual([ + { + connectionId: 'A', + checkId: 'entra_id_mfa', + lastAttemptAt: new Date('2026-07-16T06:00:00Z'), + }, + ]); + // The whole point: NO status filter — a run held as 'inconclusive' still + // counts as an attempt. Disconnected connections stay excluded. + const where = mockGroupBy.mock.calls[0][0].where; + expect(where.status).toBeUndefined(); + expect(where.connection).toEqual({ status: { not: 'disconnected' } }); + expect(where.taskId).toBe('task_1'); + }); + + it('drops groups without a timestamp and returns [] for a task with no runs', async () => { + mockGroupBy.mockResolvedValue([ + { connectionId: 'A', checkId: 'c', _max: { createdAt: null } }, + ]); + expect( + await repo.findLastAttemptPerConnectionAndCheckByTask('task_1'), + ).toEqual([]); + + mockGroupBy.mockResolvedValue([]); + expect( + await repo.findLastAttemptPerConnectionAndCheckByTask('task_1'), + ).toEqual([]); + }); +}); + describe('CheckRunRepository.countExceptedFailures', () => { const repo = new CheckRunRepository(); diff --git a/apps/api/src/integration-platform/repositories/check-run.repository.ts b/apps/api/src/integration-platform/repositories/check-run.repository.ts index f58c46a750..517026c841 100644 --- a/apps/api/src/integration-platform/repositories/check-run.repository.ts +++ b/apps/api/src/integration-platform/repositories/check-run.repository.ts @@ -266,6 +266,39 @@ export class CheckRunRepository { ); } + /** + * WHEN each (connection, check) last ran for a task — INCLUDING runs held as + * `inconclusive`. Timestamps only: no status, no counts, no results, so held + * outcomes stay hidden. + * + * `findLatestPerConnectionAndCheckByTask` above excludes held runs, which is + * right for RESULTS — but using that list for the "Last ran" label froze the + * label at the last visible run while a scheduled check kept running (and + * being held) every day. Customers read that as "the schedule stopped" + * (CS-753). This gives the UI the true last-attempt time per group. + */ + async findLastAttemptPerConnectionAndCheckByTask(taskId: string) { + const groups = await db.integrationCheckRun.groupBy({ + by: ['connectionId', 'checkId'], + where: { + taskId, + connection: { status: { not: 'disconnected' } }, + }, + _max: { createdAt: true }, + }); + return groups.flatMap((g) => + g._max.createdAt + ? [ + { + connectionId: g.connectionId, + checkId: g.checkId, + lastAttemptAt: g._max.createdAt, + }, + ] + : [], + ); + } + /** * Count a run's FAILING results whose resourceId is under an active exception. * Lets the task UI compute the effective (non-excepted) failure count exactly diff --git a/apps/api/src/trust-portal/trust-portal.controller.ts b/apps/api/src/trust-portal/trust-portal.controller.ts index 1f9d8cd87b..f4bffa09b1 100644 --- a/apps/api/src/trust-portal/trust-portal.controller.ts +++ b/apps/api/src/trust-portal/trust-portal.controller.ts @@ -378,6 +378,28 @@ export class TrustPortalController { @Put('settings/faqs') @RequirePermission('trust', 'update') @ApiOperation({ summary: 'Update trust portal FAQs' }) + // organizationId comes from the authenticated context (@OrganizationId), so the + // body only carries faqs. Without an explicit @ApiBody the generated spec has no + // request body and the MCP tool can't send FAQs at all. (CS-752) + @ApiBody({ + schema: { + type: 'object', + required: ['faqs'], + properties: { + faqs: { + type: 'array', + items: { + type: 'object', + required: ['question', 'answer'], + properties: { + question: { type: 'string' }, + answer: { type: 'string' }, + }, + }, + }, + }, + }, + }) async updateFaqs( @OrganizationId() organizationId: string, @Body() body: { faqs: Array<{ question: string; answer: string }> }, @@ -551,6 +573,27 @@ export class TrustPortalController { @ApiOperation({ summary: 'Update trust portal overview section', }) + // Mirrors UpdateTrustOverviewSchema plus the organizationId the handler reads + // from the body. Without an explicit @ApiBody the generated OpenAPI spec has + // no request body, so the MCP tool sends none, organizationId arrives + // undefined, and assertOrganizationAccess 400s for API-key callers. (CS-752) + @ApiBody({ + schema: { + type: 'object', + required: ['organizationId'], + properties: { + organizationId: { + type: 'string', + description: + "Organization that owns the trust portal. Must match the authenticated API key's organization.", + example: 'org_6914cd0e16e4c7dccbb54426', + }, + overviewTitle: { type: 'string', maxLength: 200, nullable: true }, + overviewContent: { type: 'string', maxLength: 10000, nullable: true }, + showOverview: { type: 'boolean' }, + }, + }, + }) @ApiResponse({ status: HttpStatus.OK, description: 'Overview updated successfully', @@ -585,6 +628,25 @@ export class TrustPortalController { @ApiOperation({ summary: 'Create a custom link for trust portal', }) + // Mirrors CreateCustomLinkSchema plus the organizationId the handler reads + // from the body (see updateOverview for why the explicit body matters). (CS-752) + @ApiBody({ + schema: { + type: 'object', + required: ['organizationId', 'title', 'url'], + properties: { + organizationId: { + type: 'string', + description: + "Organization that owns the trust portal. Must match the authenticated API key's organization.", + example: 'org_6914cd0e16e4c7dccbb54426', + }, + title: { type: 'string', minLength: 1, maxLength: 100 }, + description: { type: 'string', maxLength: 500, nullable: true }, + url: { type: 'string', format: 'uri', maxLength: 2000 }, + }, + }, + }) @ApiResponse({ status: HttpStatus.CREATED, description: 'Custom link created successfully', @@ -647,6 +709,27 @@ export class TrustPortalController { @ApiOperation({ summary: 'Reorder custom links', }) + // Mirrors ReorderCustomLinksSchema plus the organizationId the handler reads + // from the body (see updateOverview for why the explicit body matters). (CS-752) + @ApiBody({ + schema: { + type: 'object', + required: ['organizationId', 'linkIds'], + properties: { + organizationId: { + type: 'string', + description: + "Organization that owns the trust portal. Must match the authenticated API key's organization.", + example: 'org_6914cd0e16e4c7dccbb54426', + }, + linkIds: { + type: 'array', + items: { type: 'string' }, + description: 'Custom link IDs in the desired display order.', + }, + }, + }, + }) @ApiResponse({ status: HttpStatus.OK, description: 'Custom links reordered successfully', diff --git a/apps/api/src/trust-portal/trust-portal.openapi.spec.ts b/apps/api/src/trust-portal/trust-portal.openapi.spec.ts new file mode 100644 index 0000000000..e69daba53f --- /dev/null +++ b/apps/api/src/trust-portal/trust-portal.openapi.spec.ts @@ -0,0 +1,163 @@ +// Regression coverage for CS-752. +// +// The Trust Center config-write endpoints read organizationId (overview, +// custom-links) from the request body but declared no `@ApiBody`, so the +// generated OpenAPI spec had `requestBody: undefined` (body=None). The MCP +// server generates its tool input from that spec, so API-key/MCP callers sent +// an empty body — `body.organizationId` was undefined and +// `assertOrganizationAccess` threw 400 "Organization mismatch". FAQs had the +// same body=None gap, leaving the tool unable to send any FAQs. +// +// These tests build the OpenAPI document from the controller and assert each +// request body is now exposed. They fail before the `@ApiBody` decorators are +// added and pass after. + +// Mock ESM-only / connection-making modules so this spec can build the OpenAPI +// document without a live DB, Redis, or better-auth runtime (same approach as +// gen-openapi.spec.ts / openapi-docs.spec.ts). +jest.mock('../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, + getTrustedOrigins: () => [], + isTrustedOrigin: async () => false, + isStaticTrustedOrigin: () => false, +})); + +jest.mock('@trycompai/auth', () => ({ + statement: { trust: ['create', 'read', 'update', 'delete'] }, + BUILT_IN_ROLE_PERMISSIONS: {}, + RESTRICTED_ROLES: ['employee', 'contractor'], + PRIVILEGED_ROLES: ['owner', 'admin', 'auditor'], +})); + +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(), + trust: { findMany: jest.fn().mockResolvedValue([]) }, + }, + }; +}); + +jest.mock('@upstash/redis', () => ({ + Redis: jest.fn().mockImplementation(() => ({ + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue('OK'), + })), +})); + +// Env vars must be set before the imports below run (the transitive `../app/s3` +// import reads them at module load). +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 { INestApplication, VersioningType } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + DocumentBuilder, + SwaggerModule, + type OpenAPIObject, +} from '@nestjs/swagger'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { TrustPortalController } from './trust-portal.controller'; +import { TrustPortalService } from './trust-portal.service'; +import { TrustCustomFrameworkService } from './trust-custom-framework.service'; +import { TrustCustomFrameworkBadgeService } from './trust-custom-framework-badge.service'; + +// Minimal shapes to read the generated request-body schema without `any`. +interface JsonSchema { + type?: string; + required?: string[]; + properties?: Record; + items?: JsonSchema; +} +interface OperationWithBody { + requestBody?: { + content?: { 'application/json'?: { schema?: JsonSchema } }; + }; +} + +describe('TrustPortalController OpenAPI request bodies (MCP contract)', () => { + let app: INestApplication; + let document: OpenAPIObject; + + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; + const noopService = {} as unknown; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TrustPortalController], + providers: [ + { provide: TrustPortalService, useValue: noopService }, + { provide: TrustCustomFrameworkService, useValue: noopService }, + { provide: TrustCustomFrameworkBadgeService, useValue: noopService }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue(mockGuard) + .overrideGuard(PermissionGuard) + .useValue(mockGuard) + .compile(); + + app = module.createNestApplication(); + // Match main.ts so generated paths are prefixed with /v1 like the real spec. + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' }); + await app.init(); + + const config = new DocumentBuilder() + .setTitle('Trust Portal') + .setVersion('1.0') + .build(); + document = SwaggerModule.createDocument(app, config); + }); + + afterAll(async () => { + if (app) await app.close(); + }); + + const bodySchema = ( + routePath: string, + method: 'post' | 'put', + ): JsonSchema | undefined => { + const op = document.paths[routePath]?.[method] as + | OperationWithBody + | undefined; + return op?.requestBody?.content?.['application/json']?.schema; + }; + + it('exposes organizationId in the update-overview request body', () => { + const schema = bodySchema('/v1/trust-portal/overview', 'post'); + expect(schema).toBeDefined(); + expect(schema?.properties?.organizationId).toBeDefined(); + expect(schema?.required).toContain('organizationId'); + }); + + it('exposes organizationId in the create-custom-link request body', () => { + const schema = bodySchema('/v1/trust-portal/custom-links', 'post'); + expect(schema).toBeDefined(); + expect(schema?.properties?.organizationId).toBeDefined(); + expect(schema?.required).toContain('organizationId'); + }); + + it('exposes organizationId in the reorder-custom-links request body', () => { + const schema = bodySchema('/v1/trust-portal/custom-links/reorder', 'post'); + expect(schema).toBeDefined(); + expect(schema?.properties?.organizationId).toBeDefined(); + expect(schema?.required).toContain('organizationId'); + }); + + it('exposes the faqs array in the update-faqs request body', () => { + const schema = bodySchema('/v1/trust-portal/settings/faqs', 'put'); + expect(schema).toBeDefined(); + expect(schema?.properties?.faqs).toBeDefined(); + expect(schema?.required).toContain('faqs'); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.test.tsx index 3239816d49..e6be330ef2 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.test.tsx @@ -1,5 +1,6 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { createRequire } from 'node:module'; import { beforeAll, describe, expect, it, vi } from 'vitest'; import { MultiRoleCombobox } from './MultiRoleCombobox'; @@ -20,16 +21,18 @@ const CUSTOM_ROLES = [ { id: 'orole_1', name: 'Security Reviewer', permissions: { control: ['read'] } }, ]; -describe('MultiRoleCombobox (CS-748)', () => { - it('forces pointer-events on the popover content so roles stay clickable inside a modal dialog', async () => { - // This combobox is used inside the modal "Add User" Radix Dialog, which - // locks `body { pointer-events: none }`. A Radix dismissable-layer version - // skew (react-dialog -> 1.1.15 vs react-popover -> 1.1.11) puts the two in - // separate module-level layer contexts, so the portaled popover never - // re-enables pointer events and its role items inherit `none` — unclickable - // and unhoverable. The fix forces pointer-events on the content. jsdom can't - // hit-test, so we assert the fix is present here; the end-to-end click - // behavior inside the modal is verified in a real browser. +describe('MultiRoleCombobox (CS-748 / CS-755)', () => { + it('opens as a modal popover so cmdk role items stay interactive inside a modal dialog', async () => { + // This combobox renders inside the modal "Add User" / "Edit Roles" Radix + // Dialog. A non-modal popover dismisses itself whenever focus lands outside + // its content, and the Dialog's focus trap yanks focus out the moment the + // popover autofocuses its search input — in Safari (which never focuses + // buttons on click) that focus lands on a non-trigger element, so the + // picker closed the instant it opened. That repro is browser-only — jsdom + // click tests pass even when the flow is broken (which is why CS-748 + // shipped a fix that didn't hold and CS-755 reopened it). The fix is a + // *modal* Popover, and the observable DOM contract of a modal Radix + // dismissable layer is that it disables outside pointer events on the body. const user = userEvent.setup(); render( { await user.click(screen.getByRole('combobox')); + // Only a modal dismissable layer locks the rest of the page. A non-modal + // popover leaves the body untouched, so this fails before the fix. + await waitFor(() => { + expect(document.body.style.pointerEvents).toBe('none'); + }); + + // ...and the modal layer re-enables pointer events on its own content, so + // the role items remain clickable. const content = document.querySelector('[data-slot="popover-content"]'); expect(content).not.toBeNull(); expect(content).toHaveStyle({ pointerEvents: 'auto' }); }); + it('popover and dialog share one copy of the Radix layer/focus singleton modules', () => { + // Root cause of CS-748/CS-755: packages/ui pinned @radix-ui/react-popover + // to a version whose react-dismissable-layer / react-focus-scope deps + // differed from react-dialog's, so two copies were installed and bundled. + // These modules coordinate through module-level singletons (layer stack, + // focus-scope stack) — with two copies, dialog and popover cannot see each + // other's layers: Escape closed both, the two focus traps fought, and in + // Safari the picker dismissed itself on open. `modal` only behaves + // correctly when both primitives resolve to the SAME module instance, so + // assert module identity against the installed tree (this fails if a + // future version bump reintroduces the skew). + const req = createRequire(import.meta.url); + const resolveDep = (fromPkg: string, dep: string) => + createRequire(req.resolve(fromPkg)).resolve(dep); + for (const dep of ['@radix-ui/react-dismissable-layer', '@radix-ui/react-focus-scope']) { + expect(resolveDep('@radix-ui/react-popover', dep)).toBe( + resolveDep('@radix-ui/react-dialog', dep), + ); + } + }); + it('selects a built-in role when clicked', async () => { const handleChange = vi.fn(); const user = userEvent.setup(); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx index 7e2e19619e..efaee91c07 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx @@ -154,7 +154,18 @@ export function MultiRoleCombobox({ }); return ( - + // `modal` is required: this Popover renders inside the modal "Add User" / + // "Edit Roles" Radix Dialog. A non-modal popover dismisses itself on any + // focus outside its content, and the Dialog's focus trap yanks focus out + // the moment the popover autofocuses its search input. In Safari (which, + // unlike Chrome, never focuses buttons on click) that focus lands on a + // non-trigger element, so the picker closed the instant it opened + // (CS-748/CS-755). `modal` opts out of focus-outside dismissal and traps + // focus in the popover. Matches packages/ui combobox-dropdown.tsx. This + // only works while react-popover and react-dialog share one copy of the + // Radix focus-scope/dismissable-layer singletons — guarded by the module + // identity test in MultiRoleCombobox.test.tsx. +
- {/* - This Popover is portaled to while rendered inside a modal Radix - Dialog (the invite "Add User" dialog). The Dialog locks - `body { pointer-events: none }`, and a Radix version skew - (react-dialog -> react-dismissable-layer@1.1.15 vs - react-popover -> react-dismissable-layer@1.1.11) means the two live in - separate module-level layer contexts, so the popover never re-enables - pointer events on itself and its items inherit `none` — making every - role unclickable/unhoverable. Forcing pointer-events here restores - interactivity for the whole popover subtree. See CS-748. - */} - + a.checkId === check.checkId), + ); const isRunning = runningCheck === check.checkId; const isExpanded = expandedCheck === check.checkId; const needsConfig = check.needsConfiguration; diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.test.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.test.ts index 1816b21cb2..f0c5abceb2 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.test.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.test.ts @@ -90,4 +90,48 @@ describe('summarizeLatestPerAccount', () => { hasSucceeded: false, }); }); + + // CS-753: a check whose recent runs are all held server-side (hidden from + // the runs list) still RAN — "Last ran" must advance to the newest attempt, + // while counts/status keep reflecting the latest visible run. + it('advances lastRunAt to a newer held attempt without touching counts/status', () => { + const runs = [ + makeRun({ + id: 'a1', + connectionId: 'A', + status: 'failed', + passedCount: 34, + failedCount: 10, + createdAt: '2026-07-13T10:00:00Z', + }), + ]; + const summary = summarizeLatestPerAccount(runs, [ + { connectionId: 'A', checkId: 'aws-s3-encryption', lastAttemptAt: '2026-07-16T06:00:00Z' }, + ]); + expect(summary.lastRunAt).toBe('2026-07-16T06:00:00Z'); + // Results shown are still the latest VISIBLE run's. + expect(summary.passed).toBe(34); + expect(summary.failed).toBe(10); + expect(summary.hasFailed).toBe(true); + }); + + it('ignores attempts older than the latest visible run', () => { + const runs = [ + makeRun({ id: 'a1', connectionId: 'A', createdAt: '2026-07-16T06:00:00Z' }), + ]; + const summary = summarizeLatestPerAccount(runs, [ + { connectionId: 'A', checkId: 'aws-s3-encryption', lastAttemptAt: '2026-07-13T06:00:00Z' }, + ]); + expect(summary.lastRunAt).toBe('2026-07-16T06:00:00Z'); + }); + + it('keeps "Not run yet" (null) when a check has only ever been held', () => { + // Held-only checks have no visible runs; their outcomes stay hidden by + // design, so the attempt timestamp alone must not fabricate a summary. + const summary = summarizeLatestPerAccount([], [ + { connectionId: 'A', checkId: 'aws-s3-encryption', lastAttemptAt: '2026-07-16T06:00:00Z' }, + ]); + expect(summary.lastRunAt).toBeNull(); + expect(summary.accountCount).toBe(0); + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.ts index 7ff52e945b..82e2f1b720 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/check-run-grouping.ts @@ -1,4 +1,7 @@ -import type { StoredCheckRun } from '../hooks/useIntegrationChecks'; +import type { + CheckRunAttempt, + StoredCheckRun, +} from '../hooks/useIntegrationChecks'; export interface AccountRunGroup { connectionId: string; @@ -46,9 +49,18 @@ export interface RunsSummary { /** * Aggregate the latest run per account for the card header — so a multi-account * check shows totals across all accounts, not just the most recently run one. + * + * `lastAttempts` (optional, already filtered to this check) carries WHEN each + * account last ran INCLUDING runs the server held back from `runs`. A check + * whose recent runs are all held still ran — without this, "Last ran" froze at + * the older visible run and customers read it as "the schedule stopped" + * (CS-753). Counts/status always come from visible runs only, and a check with + * no visible run at all keeps `lastRunAt: null` ("Not run yet") — held + * outcomes stay hidden; only the timestamp advances. */ export function summarizeLatestPerAccount( runs: StoredCheckRun[], + lastAttempts: CheckRunAttempt[] = [], ): RunsSummary { const groups = groupRunsByConnection(runs); let passed = 0; @@ -72,6 +84,17 @@ export function summarizeLatestPerAccount( } } + if (lastRunAt) { + for (const attempt of lastAttempts) { + if ( + attempt.lastAttemptAt && + new Date(attempt.lastAttemptAt) > new Date(lastRunAt) + ) { + lastRunAt = attempt.lastAttemptAt; + } + } + } + return { accountCount: groups.length, passed, diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.test.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.test.tsx index 3c33d33c3a..40fd15b897 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.test.tsx @@ -94,6 +94,38 @@ describe('useIntegrationChecks', () => { expect(result.current.checks[1]!.isDisabledForTask).toBe(true); }); + it('exposes lastAttempts from the runs endpoint, defaulting to [] when absent (CS-753)', async () => { + const attempt = { + connectionId: 'icn_1', + checkId: 'branch_protection', + lastAttemptAt: '2026-07-16T06:00:00.000Z', + }; + fetchMock.mockImplementation((url: string) => { + if (url.includes('/checks')) { + return Promise.resolve( + createJsonResponse({ + checks: [makeCheck()], + task: { id: TASK_ID, title: 'Test', templateId: 'tpl_1' }, + }), + ); + } + if (url.includes('/runs')) { + return Promise.resolve( + createJsonResponse({ runs: [], lastAttempts: [attempt] }), + ); + } + return Promise.resolve(createJsonResponse({})); + }); + + const { result } = renderHook( + () => useIntegrationChecks({ taskId: TASK_ID, orgId: ORG_ID }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.lastAttempts).toEqual([attempt]); + }); + it('disconnectCheckFromTask POSTs to the disconnect endpoint and updates the cache', async () => { mockInitialLoad([makeCheck({ checkId: 'branch_protection' })]); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.ts index 4b1abd56af..fd4b8995f9 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/useIntegrationChecks.ts @@ -70,7 +70,17 @@ interface StoredCheckRun { createdAt: string; } -export type { StoredCheckRun, TaskIntegrationCheck }; +/** + * When a (connection, check) last ran — INCLUDING runs held server-side (which + * never appear in `runs`). Timestamp only; held outcomes stay hidden. + */ +interface CheckRunAttempt { + connectionId: string; + checkId: string; + lastAttemptAt: string; +} + +export type { CheckRunAttempt, StoredCheckRun, TaskIntegrationCheck }; export const integrationChecksKey = (taskId: string, orgId: string) => ['/v1/integrations/tasks/checks', taskId, orgId] as const; @@ -105,23 +115,29 @@ export function useIntegrationChecks({ taskId, orgId }: UseIntegrationChecksOpti ); const { - data: runs, + data: runsData, error: runsError, isLoading: runsLoading, mutate: mutateRuns, } = useSWR( integrationRunsKey(taskId, orgId), async () => { - const response = await api.get<{ runs: StoredCheckRun[] }>( - `/v1/integrations/tasks/${taskId}/runs?organizationId=${orgId}`, - ); + const response = await api.get<{ + runs: StoredCheckRun[]; + lastAttempts?: CheckRunAttempt[]; + }>(`/v1/integrations/tasks/${taskId}/runs?organizationId=${orgId}`); if (response.error) throw new Error(response.error); - return response.data?.runs ?? []; + return { + runs: response.data?.runs ?? [], + lastAttempts: response.data?.lastAttempts ?? [], + }; }, { revalidateOnFocus: false, }, ); + const runs = runsData?.runs; + const lastAttempts = runsData?.lastAttempts; const runCheck = async ( connectionId: string, @@ -251,6 +267,7 @@ export function useIntegrationChecks({ taskId, orgId }: UseIntegrationChecksOpti return { checks: Array.isArray(checks) ? checks : [], runs: Array.isArray(runs) ? runs : [], + lastAttempts: Array.isArray(lastAttempts) ? lastAttempts : [], isLoading: checksLoading || runsLoading, error: checksError?.message || runsError?.message || null, mutateChecks, diff --git a/apps/mcp-server/.speakeasy/gen.lock b/apps/mcp-server/.speakeasy/gen.lock index b8bc8dd288..0e4543bda3 100644 --- a/apps/mcp-server/.speakeasy/gen.lock +++ b/apps/mcp-server/.speakeasy/gen.lock @@ -1,20 +1,20 @@ lockVersion: 2.0.0 id: f7130d09-dac4-4515-9162-6095782b6bb6 management: - docChecksum: 9b3f35eeaeb08f05b06fea2f6154a875 + docChecksum: 61167898f739bf2f5fcd9e8166b05cd2 docVersion: "1.0" speakeasyVersion: 1.790.2 generationVersion: 2.918.3 - releaseVersion: 0.2.2 - configChecksum: 1095fa29de62e9a2928c0d8534c33233 + releaseVersion: 0.2.3 + configChecksum: 89aa425dd93b9bb5fa91f752eb9133df repoURL: https://github.com/trycompai/comp.git repoSubDirectory: apps/mcp-server installationURL: https://github.com/trycompai/comp published: true persistentEdits: - generation_id: d7920931-a226-48a6-92fa-b83bd7b8a238 - pristine_commit_hash: fb0cf3327881a785350e6f98b3d172e7eb84b479 - pristine_tree_hash: 1f040b8b98a5d0e175e6fd279c09f35d85efea67 + generation_id: b86d62d5-24f7-4616-9e0a-c8f730fecd89 + pristine_commit_hash: 11eaf20c84cfa39c09c3d9f5747acbf6839936f1 + pristine_tree_hash: 14225b46fd2ed6f1e92ce6b25759ace22bb8bb41 features: mcp-typescript: additionalDependencies: 0.1.0 @@ -51,12 +51,12 @@ trackedFiles: pristine_git_object: 4f9e60a9462fc4def738d60c3aaadf8232ef185f manifest.json: id: ca642a226869 - last_write_checksum: sha1:632161db068db159f28849b586913f833701af8a - pristine_git_object: 175c3d2daaf268f31c9e3fce6f83ce8be88f8c61 + last_write_checksum: sha1:8e6495d3bdba45c7091e9e6947faeba0ef78ebad + pristine_git_object: f417237872c1bbc425e1cdc9960e714e589948c3 package.json: id: 7030d0b2f71b - last_write_checksum: sha1:ef3d175a93c88be00fa6023dbf1532831fa3df13 - pristine_git_object: 241bfb6192a7bbc6b09e93c997660de0040d375b + last_write_checksum: sha1:6ab6bd99f7c213dfb5b1c95436aa96e48d83c83d + pristine_git_object: 13aa5cd1a14d30fa8978d1779b43a62af12da170 src/core.ts: id: f431fdbcd144 last_write_checksum: sha1:3c1fe2275a0f345cf54298150f100299284b3f0e @@ -1719,16 +1719,16 @@ trackedFiles: pristine_git_object: 9c36bf01332084f735909a71036c3544cc4c7e3a src/landing-page.ts: id: ef64a6ee46d7 - last_write_checksum: sha1:a61d09d627d7b1aa6f25a8c13d274403acd99f34 - pristine_git_object: b759398a35d6ef1510359b8f41d732c3349c5b23 + last_write_checksum: sha1:78f41a26ec831467e422324cac0883b109aab006 + pristine_git_object: c5c0b22284fb31632d312bfb60dc34bbea755ac9 src/lib/base64.ts: id: "598522066688" last_write_checksum: sha1:e9f04a037018040361043104960982f7c22db52d pristine_git_object: d4bd8b341290e7a828a171d840bd0b0fff7c7cd7 src/lib/config.ts: id: 320761608fb3 - last_write_checksum: sha1:4a40df3d798d07b275e7552e6d8f0504b8c0a01f - pristine_git_object: 062b0ac8bf0f9c80bbdfc29a8d8a1cdebe596b7e + last_write_checksum: sha1:4605239be4fc5185c7dedf4f98612c90b98479fe + pristine_git_object: 1e35fc294653344b3b7853b91354e39c78f9cd8c src/lib/encodings.ts: id: 3bd8ead98afd last_write_checksum: sha1:3ff4ad6809b749125820fd39c01427b84d3fe0d6 @@ -1815,8 +1815,8 @@ trackedFiles: pristine_git_object: 35c8713b6f8c7f17e2545423d3e74401ff77d04b src/mcp-server/mcp-server.ts: id: aabbc4ab07c1 - last_write_checksum: sha1:599d8a0be6249c50917792a5df8e2a16f518060e - pristine_git_object: 47c95984c93b1a363baf1e1eb87d31f4d9ed349e + last_write_checksum: sha1:316e25298ae3a2de32f9e913bc5a0f3170c711e0 + pristine_git_object: a8425607e4fc4e80ac453f8b0ca9024bf58eeb9e src/mcp-server/prompts.ts: id: 26f3d73cbf31 last_write_checksum: sha1:cadb036e04534a6d9d765809eebb266d188c499b @@ -1831,8 +1831,8 @@ trackedFiles: pristine_git_object: c25696d4c4f70e081fa5d87ad6891874c509a577 src/mcp-server/server.ts: id: 2784dd48e82a - last_write_checksum: sha1:0ac9f3dcb5c8146c570f3fce808d1845c8945835 - pristine_git_object: a3610a1c6cd064c9b1451d2aa346ffa79ad07023 + last_write_checksum: sha1:957fad16111767561af188b17ee34cf28fde3e9c + pristine_git_object: fa33b10cdf08d072ce7b3b0ffa01fe0a2366baa9 src/mcp-server/shared.ts: id: 074e80d4be1e last_write_checksum: sha1:19c9034032819a14f15c430de4350c8aba99d725 @@ -4339,8 +4339,8 @@ trackedFiles: pristine_git_object: 02853679ca7b14da43e377d54e71d2e0deba12c6 src/models/peoplecontrollerupdatememberv1op.ts: id: f3e9190b63c9 - last_write_checksum: sha1:3be585709b1ae7415e750717ffae2913e2c659fb - pristine_git_object: 994e0a4d31598539269d8d678d1534c48c7a7e98 + last_write_checksum: sha1:a0fa2d5e9cef7b601079c5e85cb8f169192d62bf + pristine_git_object: 623af92ca4733e0004db5b1c527891cbd7fa4a9d src/models/peoplecontrolleruploademploymentevidencev1op.ts: id: b604a7ca00a1 last_write_checksum: sha1:8476ce09a55589c5bd90a39c047ce978a3c58e7e @@ -5387,6 +5387,8 @@ examples: application/json: {"message": "Member with ID mem_abc123def456 not found in organization org_abc123def456"} "500": application/json: {"message": "Internal server error"} + "409": + application/json: {"message": "That email is already used by another account"} PeopleController_deleteMember_v1: speakeasy-default-people-controller-delete-member-v1: parameters: @@ -7734,4 +7736,6 @@ examples: requestBody: application/json: {"name": "Internal Controls"} examplesVersion: 1.0.2 -releaseNotes: "## Mcp-typescript SDK Changes:\n* `CompAi.Organization.OrganizationController_updateOrganization_v1()`: `request` **Changed** (Breaking ⚠️)\n* `CompAi.Integrations.TwoFactorSourceController_getAvailableTwoFactorSources_v1()`: **Added**\n* `CompAi.Integrations.TwoFactorSourceController_setTwoFactorSource_v1()`: **Added**\n* `CompAi.People.PeopleController_getMemberAccess_v1()`: **Added**\n* `CompAi.Integrations.TwoFactorSourceController_getTwoFactorStatuses_v1()`: **Added**\n* `CompAi.Trust Portal.TrustPortalController_updateSecurityQuestionnaire_v1()`: **Added**\n* `CompAi.Trust Access.TrustAccessController_getPublicSecurityQuestionnaire_v1()`: **Added**\n* `CompAi.ISMS.IsmsController_getVersions_v1()`: **Added**\n* `CompAi.Frameworks.FrameworksController_updateCustom_v1()`: **Added**\n* `CompAi.Integrations.TwoFactorSourceController_getTwoFactorSource_v1()`: **Added**\n* `CompAi.ISMS.IsmsController_exportDocument_v1()`: \n * `request.body.versionId` **Added**\n* `CompAi.ISMS.IsmsRegistersController_createRow_v1()`: \n * `request.body` **Changed**\n* `CompAi.ISMS.IsmsRegistersController_updateRow_v1()`: \n * `request.body` **Changed**\n" +releaseNotes: | + ## Mcp-typescript SDK Changes: + * `CompAi.People.PeopleController_updateMember_v1()`: `response.status[409]` **Added** (Breaking ⚠️) diff --git a/apps/mcp-server/.speakeasy/gen.yaml b/apps/mcp-server/.speakeasy/gen.yaml index bb51bd1f3f..1a91338724 100644 --- a/apps/mcp-server/.speakeasy/gen.yaml +++ b/apps/mcp-server/.speakeasy/gen.yaml @@ -31,7 +31,7 @@ generation: generateNewTests: true skipResponseBodyAssertions: false mcp-typescript: - version: 0.2.2 + version: 0.2.3 additionalDependencies: dependencies: {} devDependencies: {} diff --git a/apps/mcp-server/.speakeasy/out.openapi.yaml b/apps/mcp-server/.speakeasy/out.openapi.yaml index c66c4c1717..c542f08552 100644 --- a/apps/mcp-server/.speakeasy/out.openapi.yaml +++ b/apps/mcp-server/.speakeasy/out.openapi.yaml @@ -1430,6 +1430,16 @@ paths: message: type: "string" example: "Member with ID mem_abc123def456 not found in organization org_abc123def456" + "409": + description: "Conflict - Login email already used by another account, or the member belongs to other organizations" + content: + application/json: + schema: + type: "object" + properties: + message: + type: "string" + example: "That email is already used by another account" "500": description: "Internal server error" content: diff --git a/apps/mcp-server/.speakeasy/workflow.lock b/apps/mcp-server/.speakeasy/workflow.lock index 3db1206b33..e0deee8442 100644 --- a/apps/mcp-server/.speakeasy/workflow.lock +++ b/apps/mcp-server/.speakeasy/workflow.lock @@ -2,8 +2,8 @@ speakeasyVersion: 1.790.2 sources: Comp AI API: sourceNamespace: comp-ai-api - sourceRevisionDigest: sha256:bff3fda8efabdd1da47d6371310d9adc8f39965857964f6be21aa15256179d7d - sourceBlobDigest: sha256:e69605f5f3f0c12dba244c4fe4ac767dd0129a5a01ce4f90086b06158eebdf73 + sourceRevisionDigest: sha256:089adaac5b23d8ae8fa3e6e45c03e8a85db9aa97b33a6680e8c052847b20b3dd + sourceBlobDigest: sha256:a226d51f75650d11a488aab15dec345fcebcd840c12fbcb10f0c544492b2f42e tags: - latest - "1.0" @@ -11,8 +11,8 @@ targets: comp-ai: source: Comp AI API sourceNamespace: comp-ai-api - sourceRevisionDigest: sha256:bff3fda8efabdd1da47d6371310d9adc8f39965857964f6be21aa15256179d7d - sourceBlobDigest: sha256:e69605f5f3f0c12dba244c4fe4ac767dd0129a5a01ce4f90086b06158eebdf73 + sourceRevisionDigest: sha256:089adaac5b23d8ae8fa3e6e45c03e8a85db9aa97b33a6680e8c052847b20b3dd + sourceBlobDigest: sha256:a226d51f75650d11a488aab15dec345fcebcd840c12fbcb10f0c544492b2f42e workflow: workflowVersion: 1.0.0 speakeasyVersion: latest diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index c86f1ecc08..78e81e6429 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -30,9 +30,9 @@ Comp AI API: Compliance automation API for SOC 2, ISO 27001, HIPAA, GDPR, eviden
Claude Desktop -Install the MCP server as a Desktop Extension using the pre-built [`mcp-server.mcpb`](https://github.com/trycompai/comp/releases/download/v0.2.2/mcp-server.mcpb) file: +Install the MCP server as a Desktop Extension using the pre-built [`mcp-server.mcpb`](https://github.com/trycompai/comp/releases/download/v0.2.3/mcp-server.mcpb) file: -Simply drag and drop the [`mcp-server.mcpb`](https://github.com/trycompai/comp/releases/download/v0.2.2/mcp-server.mcpb) file onto Claude Desktop to install the extension. +Simply drag and drop the [`mcp-server.mcpb`](https://github.com/trycompai/comp/releases/download/v0.2.3/mcp-server.mcpb) file onto Claude Desktop to install the extension. The MCP bundle package includes the MCP server and all necessary configuration. Once installed, the server will be available without additional setup. diff --git a/apps/mcp-server/RELEASES.md b/apps/mcp-server/RELEASES.md index a7e05b3f38..c3ccb704c9 100644 --- a/apps/mcp-server/RELEASES.md +++ b/apps/mcp-server/RELEASES.md @@ -54,4 +54,12 @@ Based on: - OpenAPI Doc - Speakeasy CLI 1.790.2 (2.918.3) https://github.com/speakeasy-api/speakeasy ### Generated -- [mcp-typescript v0.2.2] apps/mcp-server \ No newline at end of file +- [mcp-typescript v0.2.2] apps/mcp-server + +## 2026-07-16 21:15:17 +### Changes +Based on: +- OpenAPI Doc +- Speakeasy CLI 1.790.2 (2.918.3) https://github.com/speakeasy-api/speakeasy +### Generated +- [mcp-typescript v0.2.3] apps/mcp-server \ No newline at end of file diff --git a/apps/mcp-server/manifest.json b/apps/mcp-server/manifest.json index 175c3d2daa..f417237872 100644 --- a/apps/mcp-server/manifest.json +++ b/apps/mcp-server/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": "0.3", "name": "@trycompai/mcp-server", - "version": "0.2.2", + "version": "0.2.3", "description": "", "long_description": "Comp AI API: Compliance automation API for SOC 2, ISO 27001, HIPAA, GDPR, evidence collection, policy workflows, Trust Access, security questionnaires, integrations, cloud checks, and device compliance.", "author": { diff --git a/apps/mcp-server/package-lock.json b/apps/mcp-server/package-lock.json index d2754de3e6..23dac74809 100644 --- a/apps/mcp-server/package-lock.json +++ b/apps/mcp-server/package-lock.json @@ -1,12 +1,12 @@ { "name": "@trycompai/mcp-server", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trycompai/mcp-server", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@modelcontextprotocol/sdk": "1.26.0", "@stricli/core": "^1.1.2", diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index 241bfb6192..13aa5cd1a1 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@trycompai/mcp-server", - "version": "0.2.2", + "version": "0.2.3", "author": "Comp AI", "type": "module", "sideEffects": false, diff --git a/apps/mcp-server/src/landing-page.ts b/apps/mcp-server/src/landing-page.ts index b759398a35..c5c0b22284 100644 --- a/apps/mcp-server/src/landing-page.ts +++ b/apps/mcp-server/src/landing-page.ts @@ -930,7 +930,7 @@ http_headers = { "apikey" = "YOUR_APIKEY" }`;

Instructions

One-click installation for Claude Desktop users

diff --git a/apps/mcp-server/src/lib/config.ts b/apps/mcp-server/src/lib/config.ts index 062b0ac8bf..1e35fc2946 100644 --- a/apps/mcp-server/src/lib/config.ts +++ b/apps/mcp-server/src/lib/config.ts @@ -65,8 +65,8 @@ export function serverURLFromOptions(options: SDKOptions): URL | null { export const SDK_METADATA = { language: "typescript", openapiDocVersion: "1.0", - sdkVersion: "0.2.2", + sdkVersion: "0.2.3", genVersion: "2.918.3", userAgent: - "speakeasy-sdk/mcp-typescript 0.2.2 2.918.3 1.0 @trycompai/mcp-server", + "speakeasy-sdk/mcp-typescript 0.2.3 2.918.3 1.0 @trycompai/mcp-server", } as const; diff --git a/apps/mcp-server/src/mcp-server/mcp-server.ts b/apps/mcp-server/src/mcp-server/mcp-server.ts index 47c95984c9..a8425607e4 100644 --- a/apps/mcp-server/src/mcp-server/mcp-server.ts +++ b/apps/mcp-server/src/mcp-server/mcp-server.ts @@ -21,7 +21,7 @@ const routes = buildRouteMap({ export const app = buildApplication(routes, { name: "mcp", versionInfo: { - currentVersion: "0.2.2", + currentVersion: "0.2.3", }, }); diff --git a/apps/mcp-server/src/mcp-server/server.ts b/apps/mcp-server/src/mcp-server/server.ts index a3610a1c6c..fa33b10cdf 100644 --- a/apps/mcp-server/src/mcp-server/server.ts +++ b/apps/mcp-server/src/mcp-server/server.ts @@ -403,7 +403,7 @@ export function createMCPServer(deps: { }) { const server = new McpServer({ name: "CompAi", - version: "0.2.2", + version: "0.2.3", }); const getClient = deps.getSDK || (() => diff --git a/apps/mcp-server/src/models/peoplecontrollerupdatememberv1op.ts b/apps/mcp-server/src/models/peoplecontrollerupdatememberv1op.ts index 994e0a4d31..623af92ca4 100644 --- a/apps/mcp-server/src/models/peoplecontrollerupdatememberv1op.ts +++ b/apps/mcp-server/src/models/peoplecontrollerupdatememberv1op.ts @@ -37,6 +37,20 @@ export const PeopleControllerUpdateMemberV1InternalServerErrorResponseBody$zodSc message: z.string().optional(), }).describe("Internal server error"); +/** + * Conflict - Login email already used by another account, or the member belongs to other organizations + */ +export type PeopleControllerUpdateMemberV1ConflictResponseBody = { + message?: string | undefined; +}; + +export const PeopleControllerUpdateMemberV1ConflictResponseBody$zodSchema: + z.ZodType = z.object({ + message: z.string().optional(), + }).describe( + "Conflict - Login email already used by another account, or the member belongs to other organizations", + ); + /** * Organization, member, or user not found */ @@ -80,6 +94,7 @@ export type PeopleControllerUpdateMemberV1Response = | PeopleControllerUpdateMemberV1BadRequestResponseBody | PeopleControllerUpdateMemberV1UnauthorizedResponseBody | PeopleControllerUpdateMemberV1NotFoundResponseBody + | PeopleControllerUpdateMemberV1ConflictResponseBody | PeopleControllerUpdateMemberV1InternalServerErrorResponseBody; export const PeopleControllerUpdateMemberV1Response$zodSchema: z.ZodType< @@ -91,6 +106,7 @@ export const PeopleControllerUpdateMemberV1Response$zodSchema: z.ZodType< PeopleControllerUpdateMemberV1UnauthorizedResponseBody$zodSchema ), z.lazy(() => PeopleControllerUpdateMemberV1NotFoundResponseBody$zodSchema), + z.lazy(() => PeopleControllerUpdateMemberV1ConflictResponseBody$zodSchema), z.lazy(() => PeopleControllerUpdateMemberV1InternalServerErrorResponseBody$zodSchema ), diff --git a/bun.lock b/bun.lock index 3f183d1d89..9428056d21 100644 --- a/bun.lock +++ b/bun.lock @@ -712,7 +712,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-navigation-menu": "1.2.13", - "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popover": "^1.1.19", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "1.3.7", "@radix-ui/react-scroll-area": "^1.2.10", @@ -2088,7 +2088,7 @@ "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g=="], - "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.3", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ=="], "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.3", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.2.0", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g=="], @@ -6908,32 +6908,6 @@ "@radix-ui/react-navigation-menu/@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], - "@radix-ui/react-popover/@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], - - "@radix-ui/react-popover/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - - "@radix-ui/react-popover/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], - - "@radix-ui/react-popover/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], - - "@radix-ui/react-popover/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], - - "@radix-ui/react-popover/@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - - "@radix-ui/react-popover/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], - - "@radix-ui/react-popover/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], - - "@radix-ui/react-popover/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], - - "@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-popover/@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], - "@radix-ui/react-popper/@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="], "@radix-ui/react-radio-group/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="], @@ -8592,30 +8566,6 @@ "@radix-ui/react-navigation-menu/@radix-ui/react-use-controllable-state/@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], - "@radix-ui/react-popover/@radix-ui/react-dismissable-layer/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], - - "@radix-ui/react-popover/@radix-ui/react-focus-scope/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], - - "@radix-ui/react-popover/@radix-ui/react-id/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - - "@radix-ui/react-popover/@radix-ui/react-popper/@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], - - "@radix-ui/react-popover/@radix-ui/react-popper/@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], - - "@radix-ui/react-popover/@radix-ui/react-popper/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - - "@radix-ui/react-popover/@radix-ui/react-popper/@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], - - "@radix-ui/react-popover/@radix-ui/react-popper/@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - - "@radix-ui/react-popover/@radix-ui/react-portal/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - - "@radix-ui/react-popover/@radix-ui/react-presence/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - - "@radix-ui/react-popover/@radix-ui/react-use-controllable-state/@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], - - "@radix-ui/react-popover/@radix-ui/react-use-controllable-state/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - "@radix-ui/react-radio-group/@radix-ui/react-presence/@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], "@radix-ui/react-radio-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/packages/integration-platform/src/manifests/github/checks/__tests__/code-scanning.test.ts b/packages/integration-platform/src/manifests/github/checks/__tests__/code-scanning.test.ts new file mode 100644 index 0000000000..9eb7be46cf --- /dev/null +++ b/packages/integration-platform/src/manifests/github/checks/__tests__/code-scanning.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'bun:test'; +import type { CheckContext } from '../../../../types'; +import type { GitHubRepo } from '../../types'; +import { codeScanningCheck } from '../code-scanning'; + +interface RepoConfig { + private: boolean; + /** + * `advanced_security.status`. `undefined` means GitHub omitted the + * `security_and_analysis` block entirely — which is what happens over an OAuth + * connection whose user lacks repo-admin visibility. + */ + ghas?: 'enabled' | 'disabled'; + /** Whether the code-scanning default-setup API 403s (permission/GHAS gate). */ + defaultSetup403?: boolean; +} + +const makeRepo = (fullName: string, config: RepoConfig): GitHubRepo => { + const [owner, name] = fullName.split('/'); + const repo: GitHubRepo = { + id: 1, + name: name!, + full_name: fullName, + html_url: `https://github.com/${fullName}`, + private: config.private, + default_branch: 'main', + owner: { login: owner!, type: 'Organization' }, + }; + if (config.ghas) { + repo.security_and_analysis = { advanced_security: { status: config.ghas } }; + } + return repo; +}; + +interface RunResult { + passed: Array<{ resourceId: string; title: string }>; + failed: Array<{ resourceId: string; title: string }>; +} + +async function runCheck(repos: Record): Promise { + const passed: RunResult['passed'] = []; + const failed: RunResult['failed'] = []; + + const ctx = { + accessToken: 'tok', + credentials: {}, + variables: { target_repos: Object.keys(repos) }, + connectionId: 'conn_1', + organizationId: 'org_1', + metadata: {}, + log: () => {}, + warn: () => {}, + pass: (result) => { + passed.push({ resourceId: result.resourceId ?? '', title: result.title }); + }, + fail: (result) => { + failed.push({ resourceId: result.resourceId ?? '', title: result.title }); + }, + fetch: (async (path: string): Promise => { + // /repos// + const repoMatch = path.match(/^\/repos\/([^/]+\/[^/]+)$/); + if (repoMatch) { + const fullName = repoMatch[1]!; + const config = repos[fullName]; + if (!config) throw new Error(`404 ${path}`); + return makeRepo(fullName, config) as unknown as T; + } + + // /repos///code-scanning/default-setup + const setupMatch = path.match(/^\/repos\/([^/]+\/[^/]+)\/code-scanning\/default-setup$/); + if (setupMatch) { + const config = repos[setupMatch[1]!]!; + // GitHub 403s this endpoint for private repos when the token lacks the + // repo-admin visibility the code-scanning API requires (and, separately, + // when GHAS is off). Both surface identically to us. + if (config.defaultSetup403) throw new Error(`403 Forbidden: ${path}`); + return { state: 'not-configured' } as unknown as T; + } + + // /repos///git/trees/?recursive=1 — no workflow files + // (default-setup repos carry no CodeQL .yml). + if (/^\/repos\/[^/]+\/[^/]+\/git\/trees\/.+/.test(path)) { + return { sha: 'x', url: '', truncated: false, tree: [] } as unknown as T; + } + + throw new Error(`Unexpected fetch: ${path}`); + }) as CheckContext['fetch'], + fetchAllPages: (async () => []) as CheckContext['fetchAllPages'], + fetchWithCursor: (async () => []) as CheckContext['fetchWithCursor'], + fetchWithLinkHeader: (async () => []) as CheckContext['fetchWithLinkHeader'], + graphql: (async () => ({})) as CheckContext['graphql'], + getState: (async () => null) as CheckContext['getState'], + setState: (async () => {}) as CheckContext['setState'], + } as CheckContext; + + await codeScanningCheck.run(ctx); + return { passed, failed }; +} + +describe('codeScanningCheck GHAS visibility (regression: CS-756)', () => { + it('does NOT report GHAS required when GHAS status is unknown (OAuth non-admin: 403 + private + no security_and_analysis block)', async () => { + // Saltbox repro: OAuth token without repo-admin visibility on a private repo + // that actually HAS GHAS + CodeQL default setup enabled. GitHub omits the + // `security_and_analysis` block AND 403s the code-scanning API. We cannot + // confirm GHAS is off, so we must not falsely claim it is. + const { passed, failed } = await runCheck({ + 'saltbox/s1-app-monitor': { private: true, defaultSetup403: true }, // ghas block omitted + }); + + expect(passed).toHaveLength(0); + expect(failed).toHaveLength(1); + // Before the fix this was: + // "Code scanning requires GitHub Advanced Security for s1-app-monitor" + expect(failed[0]!.title).toBe('Cannot access code scanning configuration for s1-app-monitor'); + expect(failed[0]!.title).not.toContain('requires GitHub Advanced Security'); + }); + + it('still reports GHAS required when GHAS is positively disabled on a private repo', async () => { + const { failed } = await runCheck({ + 'acme/no-ghas': { private: true, ghas: 'disabled', defaultSetup403: true }, + }); + + expect(failed).toHaveLength(1); + expect(failed[0]!.title).toBe('Code scanning requires GitHub Advanced Security for no-ghas'); + }); + + it('reports permission-denied (not GHAS required) when GHAS is enabled but the API still 403s', async () => { + const { failed } = await runCheck({ + 'acme/ghas-on': { private: true, ghas: 'enabled', defaultSetup403: true }, + }); + + expect(failed).toHaveLength(1); + expect(failed[0]!.title).toBe('Cannot access code scanning configuration for ghas-on'); + }); +}); diff --git a/packages/integration-platform/src/manifests/github/checks/code-scanning.ts b/packages/integration-platform/src/manifests/github/checks/code-scanning.ts index 8126f1dc64..b33c4f5c54 100644 --- a/packages/integration-platform/src/manifests/github/checks/code-scanning.ts +++ b/packages/integration-platform/src/manifests/github/checks/code-scanning.ts @@ -46,6 +46,14 @@ type CodeScanningStatus = | { status: 'permission-denied'; isPrivate: boolean } | { status: 'ghas-required' }; +/** + * Whether GitHub Advanced Security is enabled for a repo. `unknown` covers the + * case where GitHub omits the repo's `security_and_analysis` block because the + * token lacks repo-admin visibility (common over OAuth connections) — we must + * NOT treat that as `disabled`. + */ +type GhasStatus = 'enabled' | 'disabled' | 'unknown'; + const decodeFile = (file: GitHubFileResponse): string => { if (!file?.content) return ''; if (file.encoding === 'base64') { @@ -149,12 +157,12 @@ export const codeScanningCheck: IntegrationCheck = { repoName, tree, isPrivate, - isGhasEnabled, + ghasStatus, }: { repoName: string; tree: GitHubTreeEntry[]; isPrivate: boolean; - isGhasEnabled: boolean; + ghasStatus: GhasStatus; }): Promise => { let apiGot403 = false; @@ -178,7 +186,7 @@ export const codeScanningCheck: IntegrationCheck = { // workflow file contents only requires contents:read. A 403 here does // not mean we can't check for code scanning workflows. ctx.log( - `Code scanning API returned 403 for ${repoName} (private: ${isPrivate}, ghas: ${isGhasEnabled}). Falling back to workflow file scanning.`, + `Code scanning API returned 403 for ${repoName} (private: ${isPrivate}, ghas: ${ghasStatus}). Falling back to workflow file scanning.`, ); apiGot403 = true; } else { @@ -200,10 +208,14 @@ export const codeScanningCheck: IntegrationCheck = { } if (apiGot403) { - if (isPrivate) { - if (isGhasEnabled) { - return { status: 'permission-denied', isPrivate }; - } + // A 403 from the code-scanning API on a private repo can mean either GHAS + // is off (CodeQL genuinely can't be configured) OR the token lacks the + // repo-admin visibility the API requires. Only claim "GHAS required" when + // we can positively confirm GHAS is disabled. Over an OAuth connection + // without repo admin, GitHub omits the repo's `security_and_analysis` + // block, so ghasStatus is 'unknown' — treat that (and 'enabled') as + // permission-denied rather than falsely reporting GHAS is not enabled. + if (isPrivate && ghasStatus === 'disabled') { return { status: 'ghas-required' }; } return { status: 'permission-denied', isPrivate }; @@ -217,13 +229,17 @@ export const codeScanningCheck: IntegrationCheck = { if (!repo) continue; const tree = await fetchRepoTree(repo.full_name, repo.default_branch); - const isGhasEnabled = repo.security_and_analysis?.advanced_security?.status === 'enabled'; + // `security_and_analysis` is only returned to repo admins, so over an OAuth + // connection without admin visibility this is undefined — 'unknown', NOT + // 'disabled'. Conflating the two is what made us falsely report GHAS off. + const ghasStatus: GhasStatus = + repo.security_and_analysis?.advanced_security?.status ?? 'unknown'; const codeScanningStatus = await getCodeScanningStatus({ repoName: repo.full_name, tree, isPrivate: repo.private, - isGhasEnabled, + ghasStatus, }); switch (codeScanningStatus.status) { diff --git a/packages/ui/package.json b/packages/ui/package.json index 789d1b4ded..865e282717 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -21,7 +21,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-navigation-menu": "1.2.13", - "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popover": "^1.1.19", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "1.3.7", "@radix-ui/react-scroll-area": "^1.2.10",