diff --git a/.claude/skills/api-endpoint-contract/SKILL.md b/.claude/skills/api-endpoint-contract/SKILL.md index 32d292d85c..811051d57b 100644 --- a/.claude/skills/api-endpoint-contract/SKILL.md +++ b/.claude/skills/api-endpoint-contract/SKILL.md @@ -13,7 +13,7 @@ Every customer-facing endpoint in `apps/api/src/` ends up in three places: If any one of these three is wrong, the endpoint either silently breaks for agents (Claude Desktop, Cursor, Codex, etc.) or fails validation at runtime. **Follow this contract on every body-accepting endpoint.** -## The 10 rules +## The 11 rules ### 1. DTOs MUST be classes — never interfaces, never inline types @@ -156,10 +156,30 @@ SSE streams (`@ApiProduces('text/event-stream')`) and binary file responses (`@R disabled: true ``` +### 11. Every endpoint MUST have a meaningful summary + description — it powers MCP discovery + +`@ApiOperation({ summary, description })` is **not optional**. `openapi-docs.spec.ts` (via `collectPublicOpenApiIssues` in `apps/api/src/openapi/public-docs-quality.ts`) **fails CI** if any non-excluded operation has: +- an empty `summary` → `missingSummaries` +- a missing `description` or SEO metadata → `missingMetadata` +- SEO metadata outside 80–160 chars, or a title > 60 chars → `invalidSeo` + +This matters more now that the hosted MCP (Gram) uses **dynamic toolsets**: with 300+ tools the agent never sees them all — it runs a semantic `search` over tool **names + descriptions** and only loads matches. A tool with a weak or missing description is effectively **undiscoverable**. The description is the tool's only chance of being found. + +```ts +@ApiOperation({ + summary: 'List compliance policies', // concise tool title + description: + "Returns the organization's compliance policies (SOC 2, ISO 27001, …) " + + 'with status and owner. Use to review or audit policy coverage.', // what it does + when to use it +}) +``` + +Write the description for the agent deciding *whether to call this tool*: state what it does and when to use it. (Keep it ≤ 240 chars — see Rule 4.) + ## Workflow checklist when adding a body endpoint 1. Define a `class` DTO. Two decorator stacks on every field. Add `@ApiBody({ type: DtoClass })` on the endpoint. -2. Keep `@ApiOperation.description` ≤ 240 chars. +2. Give the endpoint a meaningful `@ApiOperation({ summary, description })` — both required, CI-enforced by `openapi-docs.spec.ts`, and they power MCP dynamic-toolset discovery (Rule 11). Keep the description ≤ 240 chars (Rule 4). 3. If the auto-derived MCP tool name is ugly, set `@ApiExtension('x-speakeasy-mcp', { name: '...' })`. 4. If the endpoint requires session auth, decide: remove `SessionOnlyGuard`, or disable it for MCP via the overlay. 5. For long-running work, return a run handle and document the poll target. @@ -186,5 +206,6 @@ Every bug below was a real customer-visible MCP failure caught during the May 20 | Agent uploads stuck for 15+ min on base64 encoding | Tool accepted `fileData` as the only file input | Rule 8 | | Agent calls SSE auto-answer and hangs | Tool was generated from `@ApiProduces('text/event-stream')` | Rule 10 | | Agent tries to start OAuth and gets 403 | Endpoint was behind `SessionOnlyGuard` but generated as MCP tool | Rule 6 | +| Agent can't find a tool that exists (dynamic toolsets) | Endpoint had a missing/weak description → invisible to semantic search | Rule 11 | Follow the 10 rules and you avoid every one of these. diff --git a/.cursor/rules/api-endpoint-contract.mdc b/.cursor/rules/api-endpoint-contract.mdc index 2832e236af..9c3d3ef6a6 100644 --- a/.cursor/rules/api-endpoint-contract.mdc +++ b/.cursor/rules/api-endpoint-contract.mdc @@ -1,5 +1,6 @@ --- description: Use when writing/editing NestJS API endpoints, DTOs, or @Body() params under apps/api/src/. Ensures every endpoint is correct for OpenAPI, the MCP server (@trycompai/mcp-server), and the ValidationPipe. +globs: apps/api/src/**/*.controller.ts,apps/api/src/**/*.dto.ts alwaysApply: false --- @@ -7,7 +8,7 @@ alwaysApply: false Every customer-facing endpoint in `apps/api/src/` flows into three systems: the OpenAPI spec (`packages/docs/openapi.json`), the MCP server (`@trycompai/mcp-server` on npm), and the runtime `ValidationPipe`. If any one is wrong, the endpoint silently breaks for agents or fails validation at runtime. -## The 10 rules +## The 11 rules ### 1. DTOs MUST be classes — never interfaces, never inline types @@ -99,6 +100,12 @@ SSE streams (`@ApiProduces('text/event-stream')`) and binary file responses cann disabled: true ``` +### 11. Every endpoint MUST have a meaningful summary + description — it powers MCP discovery + +`@ApiOperation({ summary, description })` is **not optional**. `openapi-docs.spec.ts` (via `collectPublicOpenApiIssues`) **fails CI** on empty `summary` (`missingSummaries`), missing `description`/metadata (`missingMetadata`), or SEO metadata outside 80–160 chars / title > 60 (`invalidSeo`). + +The hosted MCP (Gram) uses **dynamic toolsets**: with 300+ tools the agent semantic-`search`es over names + descriptions and only loads matches. A weak/missing description = the tool is **undiscoverable**. Write it for the agent deciding whether to call the tool: what it does + when to use it (≤ 240 chars, Rule 4). + ## Checklist when adding a body endpoint 1. `class` DTO, two decorator stacks per field, `@ApiBody({ type: DtoClass })` on the endpoint. diff --git a/.github/workflows/gram-sync.yml b/.github/workflows/gram-sync.yml new file mode 100644 index 0000000000..0dff80faba --- /dev/null +++ b/.github/workflows/gram-sync.yml @@ -0,0 +1,38 @@ +name: Sync MCP source to Gram + +# Keeps the hosted MCP (Speakeasy Gram) in sync with the clean public OpenAPI +# spec. Runs whenever the spec (or the Gram deployment config) changes on main, +# and can be triggered manually. No manual uploads — the source is pushed +# declaratively from gram.json via the Gram CLI. +on: + push: + branches: [main] + paths: + - "packages/docs/openapi.json" + - "gram.json" + workflow_dispatch: + +# Least privilege: this workflow only reads the repo to checkout + push the spec. +permissions: + contents: read + +jobs: + push: + runs-on: ubuntu-latest + env: + # Org + project slugs are NOT secret — they appear in the Gram dashboard URL + # (app.getgram.ai//projects/). Only the API key is a secret. + GRAM_ORG: comp-ai-f041 + GRAM_PROJECT: default + GRAM_API_KEY: ${{ secrets.GRAM_API_KEY }} + steps: + - uses: actions/checkout@v4 + + - name: Install Gram CLI + # Installs to /usr/local/bin (already on PATH on GitHub runners). + run: curl -fsSL https://go.getgram.ai/cli.sh | bash + + - name: Push OpenAPI source to Gram + # api-key/org/project are read from the env vars above; --method defaults + # to "merge" (updates the existing source in place by slug). + run: gram push --config gram.json diff --git a/AGENTS.md b/AGENTS.md index cd95151c0d..be4d3e28bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,6 +82,7 @@ Every customer-facing endpoint in `apps/api/src/` flows into three systems: the 8. **File uploads from agents use presigned URLs** — accept an `s3Key` field (read via `UploadsService.readUploadAsBase64`); never accept inline base64 from the MCP tool. 9. **Sensitive paths (e.g. `/credentials`)** are deny-listed from public docs in `apps/api/src/openapi/public-docs-quality.ts` — that's intentional, don't fight it. 10. **SSE / binary responses** can't be consumed by MCP — disable the tool in `apps/mcp-server/.speakeasy/mcp-uploads-overlay.yaml` while keeping the HTTP endpoint for the web UI. +11. **Every endpoint needs a meaningful `@ApiOperation({ summary, description })`** — required and **CI-enforced** (`openapi-docs.spec.ts` fails the build if a public op is missing one). The hosted MCP uses **dynamic toolsets**: the agent finds a tool by semantic-searching names + descriptions, so a missing/weak description makes the tool effectively undiscoverable. Write the description for the agent deciding whether to call the tool — what it does + when to use it. After adding an endpoint: `bun run --filter '@trycompai/api' dev` regenerates `packages/docs/openapi.json` on boot — **commit it with your PR**. The daily Speakeasy CI reads from that file; if it's stale, your endpoint never reaches MCP customers. diff --git a/apps/api/.env.example b/apps/api/.env.example index 9e8714d2b7..d39ca5dc1d 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -18,6 +18,14 @@ AUTH_MICROSOFT_CLIENT_ID= AUTH_MICROSOFT_CLIENT_SECRET= AUTH_MICROSOFT_TENANT_ID= # 'common' (default), 'organizations', or your tenant GUID +# Hosted MCP (Speakeasy Gram) OAuth — better-auth acts as the OAuth provider. +# Leave unset where hosted MCP isn't configured; the trusted client is then inert. +GRAM_OAUTH_CLIENT_ID= # OAuth client id registered for the Gram MCP server +GRAM_OAUTH_CLIENT_SECRET= # OAuth client secret for the Gram MCP server +GRAM_OAUTH_REDIRECT_URI= # Gram callback, e.g. https:///oauth//callback +MCP_OAUTH_LOGIN_PAGE= # App sign-in page (defaults to ${NEXT_PUBLIC_APP_URL}/auth) +MCP_RESOURCE_URL= # Optional: the hosted MCP resource identifier (Gram server URL) + DATABASE_URL= NOVU_API_KEY= diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 613d2484f3..ccb8410374 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -47,6 +47,7 @@ import { FrameworkVersionsModule } from './framework-editor-versions/framework-v import { AuditModule } from './audit/audit.module'; import { ControlsModule } from './controls/controls.module'; import { RolesModule } from './roles/roles.module'; +import { McpModule } from './mcp/mcp.module'; import { EmailModule } from './email/email.module'; import { SecretsModule } from './secrets/secrets.module'; import { SecurityPenetrationTestsModule } from './security-penetration-tests/security-penetration-tests.module'; @@ -126,6 +127,7 @@ import { OffboardingChecklistModule } from './offboarding-checklist/offboarding- AdminFeatureFlagsModule, TimelinesModule, OffboardingChecklistModule, + McpModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/auth/app-access.spec.ts b/apps/api/src/auth/app-access.spec.ts new file mode 100644 index 0000000000..85524669e8 --- /dev/null +++ b/apps/api/src/auth/app-access.spec.ts @@ -0,0 +1,76 @@ +const mockOrgRoleFindMany = jest.fn(); +jest.mock('@db', () => ({ + db: { + organizationRole: { + findMany: (...a: unknown[]) => mockOrgRoleFindMany(...a), + }, + }, +})); + +jest.mock('@trycompai/auth', () => ({ + BUILT_IN_ROLE_PERMISSIONS: { + owner: { app: ['read'] }, + admin: { app: ['read'] }, + auditor: { app: ['read'] }, + employee: { policy: ['read'], portal: ['read', 'update'] }, + contractor: { policy: ['read'], portal: ['read', 'update'] }, + }, +})); + +import { hasAppAccess } from './app-access'; + +describe('hasAppAccess', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOrgRoleFindMany.mockResolvedValue([]); + }); + + it('grants for built-in app roles without a DB lookup', async () => { + expect(await hasAppAccess('org_1', 'owner')).toBe(true); + expect(await hasAppAccess('org_1', 'admin')).toBe(true); + expect(await hasAppAccess('org_1', 'auditor')).toBe(true); + expect(mockOrgRoleFindMany).not.toHaveBeenCalled(); + }); + + it('denies for Portal-only built-in roles', async () => { + expect(await hasAppAccess('org_1', 'employee')).toBe(false); + expect(await hasAppAccess('org_1', 'contractor')).toBe(false); + }); + + it('treats comma-separated roles as a union (any granting role wins)', async () => { + expect(await hasAppAccess('org_1', 'employee,admin')).toBe(true); + }); + + it('grants for a custom role with app:read', async () => { + mockOrgRoleFindMany.mockResolvedValue([ + { permissions: JSON.stringify({ app: ['read'], control: ['read'] }) }, + ]); + expect(await hasAppAccess('org_1', 'Compliance Lead')).toBe(true); + }); + + it('denies for a custom role without app:read', async () => { + mockOrgRoleFindMany.mockResolvedValue([ + { permissions: JSON.stringify({ policy: ['read'], portal: ['read'] }) }, + ]); + expect(await hasAppAccess('org_1', 'Portal Role')).toBe(false); + }); + + it('denies for empty or null roles', async () => { + expect(await hasAppAccess('org_1', null)).toBe(false); + expect(await hasAppAccess('org_1', '')).toBe(false); + }); + + it('treats a role named like an Object prototype key (constructor) as custom', async () => { + // Must NOT be shadowed by Object.prototype.constructor — it's a custom role. + mockOrgRoleFindMany.mockResolvedValue([ + { permissions: JSON.stringify({ app: ['read'] }) }, + ]); + expect(await hasAppAccess('org_1', 'constructor')).toBe(true); + expect(mockOrgRoleFindMany).toHaveBeenCalled(); + }); + + it('does not throw on malformed custom-role permissions', async () => { + mockOrgRoleFindMany.mockResolvedValue([{ permissions: '{not valid json' }]); + await expect(hasAppAccess('org_1', 'Broken Role')).resolves.toBe(false); + }); +}); diff --git a/apps/api/src/auth/app-access.ts b/apps/api/src/auth/app-access.ts new file mode 100644 index 0000000000..b8bce4303d --- /dev/null +++ b/apps/api/src/auth/app-access.ts @@ -0,0 +1,99 @@ +import { BUILT_IN_ROLE_PERMISSIONS } from '@trycompai/auth'; +import { db } from '@db'; + +/** Safely parse a custom role's stored permissions; malformed JSON → `{}` (never throws). */ +function parsePermissions(raw: unknown): Record { + if (raw && typeof raw === 'object') { + return raw as Record; + } + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' + ? (parsed as Record) + : {}; + } catch { + return {}; + } + } + return {}; +} + +function mergeInto( + target: Record>, + perms: Record, +): void { + for (const [resource, actions] of Object.entries(perms)) { + if (!Array.isArray(actions)) continue; + (target[resource] ??= new Set()); + for (const action of actions) target[resource].add(action); + } +} + +/** + * Merge the effective permissions (`{ resource: actions[] }`) for a set of role + * names in an org. Built-in roles resolve from `BUILT_IN_ROLE_PERMISSIONS` + * (own-property lookup only, so a custom role named e.g. `constructor` is not + * mistaken for a built-in); custom roles resolve from `organization_role` rows + * (malformed JSON is ignored, not thrown). Comma-separated roles are a union. + */ +export async function resolveRolePermissions( + organizationId: string, + roles: string[], +): Promise> { + const merged: Record> = {}; + const customRoleNames: string[] = []; + + for (const role of roles) { + if (Object.prototype.hasOwnProperty.call(BUILT_IN_ROLE_PERMISSIONS, role)) { + mergeInto(merged, BUILT_IN_ROLE_PERMISSIONS[role]); + } else if (role) { + customRoleNames.push(role); + } + } + + if (customRoleNames.length > 0) { + const customRoles = await db.organizationRole.findMany({ + where: { organizationId, name: { in: customRoleNames } }, + select: { permissions: true }, + }); + for (const customRole of customRoles) { + mergeInto(merged, parsePermissions(customRole.permissions)); + } + } + + const result: Record = {}; + for (const [resource, actions] of Object.entries(merged)) { + result[resource] = [...actions]; + } + return result; +} + +/** Whether resolved permissions grant `resource:action`. */ +export function permissionsGrant( + permissions: Record, + resource: string, + action: string, +): boolean { + return permissions[resource]?.includes(action) ?? false; +} + +/** + * Whether a member's role(s) grant **app access** (`app:read`) in the given org + * — the same gate the web app uses (owner/admin/auditor + custom roles with the + * "App Access" toggle), excluding Portal-only roles (employee/contractor). + */ +export async function hasAppAccess( + organizationId: string, + roleString: string | null, +): Promise { + if (!roleString) return false; + const roles = roleString + .split(',') + .map((r) => r.trim()) + .filter(Boolean); + if (roles.length === 0) return false; + + const perms = await resolveRolePermissions(organizationId, roles); + return permissionsGrant(perms, 'app', 'read'); +} diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index 3f7a8f4611..83f6d1bd68 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -10,6 +10,7 @@ import { bearer, emailOTP, magicLink, + mcp, multiSession, organization, } from 'better-auth/plugins'; @@ -188,6 +189,41 @@ if ( const cookieDomain = getCookieDomain(); +// ── Hosted MCP (Speakeasy Gram) OAuth ──────────────────────────────────────── +// The MCP server is hosted on Gram. Gram obtains an OAuth access token from this +// API (better-auth as the authorization server) so users authenticate with +// "Sign in with Google" instead of pasting an API key. +// +// Gram's OAuth Proxy registers as a single static client and handles Dynamic +// Client Registration toward MCP clients on our behalf — so we keep public DCR +// off for now and register Gram as a trusted client. Configured via env so the +// secret isn't committed and the plugin is inert in envs where hosted MCP isn't +// set up yet. +const gramMcpClient = + process.env.GRAM_OAUTH_CLIENT_ID && + process.env.GRAM_OAUTH_CLIENT_SECRET && + process.env.GRAM_OAUTH_REDIRECT_URI + ? { + clientId: process.env.GRAM_OAUTH_CLIENT_ID, + clientSecret: process.env.GRAM_OAUTH_CLIENT_SECRET, + name: 'Comp AI MCP (Gram)', + type: 'web' as const, + disabled: false, + redirectUrls: [process.env.GRAM_OAUTH_REDIRECT_URI], + metadata: null, + // First-party client: Gram is Comp AI's own hosted MCP, so the user's + // login (Sign in with Google) IS the authorization — no separate consent + // screen is needed. This also avoids having to build a consent page UI. + skipConsent: true, + } + : null; + +// Where better-auth sends the user to authenticate during the OAuth flow. +// Must point at the app's sign-in page. Override per environment via env. +const mcpLoginPage = + process.env.MCP_OAUTH_LOGIN_PAGE || + `${process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.trycomp.ai'}/auth`; + // ============================================================================= // Security Validation // ============================================================================= @@ -488,6 +524,21 @@ export const auth = betterAuth({ admin({ defaultRole: 'user', }), + // OAuth 2.0 / OIDC provider for hosted MCP (Gram). Wraps oidcProvider and + // exposes /api/auth/mcp/* (authorize, token, register) + the two + // /api/auth/.well-known/* discovery docs, plus the auth.api.getMcpSession() + // helper used by HybridAuthGuard. (Gram points its OAuth proxy at /mcp/*.) + mcp({ + loginPage: mcpLoginPage, + ...(process.env.MCP_RESOURCE_URL + ? { resource: process.env.MCP_RESOURCE_URL } + : {}), + oidcConfig: { + loginPage: mcpLoginPage, + allowDynamicClientRegistration: false, + ...(gramMcpClient ? { trustedClients: [gramMcpClient] } : {}), + }, + }), ], socialProviders, user: { diff --git a/apps/api/src/auth/hybrid-auth.guard.spec.ts b/apps/api/src/auth/hybrid-auth.guard.spec.ts new file mode 100644 index 0000000000..ec4b7ce528 --- /dev/null +++ b/apps/api/src/auth/hybrid-auth.guard.spec.ts @@ -0,0 +1,364 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { HybridAuthGuard } from './hybrid-auth.guard'; +import { ApiKeyService } from './api-key.service'; +import { SKIP_ORG_CHECK_KEY } from './skip-org-check.decorator'; + +// Mock auth.server — only the two session resolvers the guard uses. +const mockGetSession = jest.fn(); +const mockGetMcpSession = jest.fn(); +jest.mock('./auth.server', () => ({ + auth: { + api: { + getSession: (...args: unknown[]) => mockGetSession(...args), + getMcpSession: (...args: unknown[]) => mockGetMcpSession(...args), + }, + }, +})); + +// Mock @db — the guard resolves the user, then enumerates active memberships +// (device-agent style) to bind the organization for the MCP OAuth path. +const mockUserFindUnique = jest.fn(); +const mockMemberFindMany = jest.fn(); +const mockMcpBindingFindUnique = jest.fn(); +const mockOrgRoleFindMany = jest.fn(); +jest.mock('@db', () => ({ + db: { + user: { findUnique: (...args: unknown[]) => mockUserFindUnique(...args) }, + member: { findMany: (...args: unknown[]) => mockMemberFindMany(...args) }, + mcpOrgBinding: { + findUnique: (...args: unknown[]) => mockMcpBindingFindUnique(...args), + }, + organizationRole: { + findMany: (...args: unknown[]) => mockOrgRoleFindMany(...args), + }, + }, +})); + +// Mock @trycompai/auth — the app-access gate reads BUILT_IN_ROLE_PERMISSIONS to +// decide which roles grant app access. owner/admin/auditor do; employee does not. +jest.mock('@trycompai/auth', () => ({ + BUILT_IN_ROLE_PERMISSIONS: { + owner: { app: ['read'] }, + admin: { app: ['read'] }, + auditor: { app: ['read'] }, + employee: { policy: ['read'], portal: ['read', 'update'] }, + contractor: { policy: ['read'], portal: ['read', 'update'] }, + }, +})); + +describe('HybridAuthGuard — MCP OAuth path', () => { + let guard: HybridAuthGuard; + let reflector: Reflector; + + // A real object so the guard's mutations (userId, userRoles, …) are observable. + const createContext = ( + headers: Record, + ): { context: ExecutionContext; request: Record } => { + const request: Record = { headers }; + const context = { + switchToHttp: () => ({ getRequest: () => request }), + getHandler: () => jest.fn(), + getClass: () => jest.fn(), + } as unknown as ExecutionContext; + return { context, request }; + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + HybridAuthGuard, + { + provide: ApiKeyService, + useValue: { extractApiKey: jest.fn(), validateApiKey: jest.fn() }, + }, + Reflector, + ], + }).compile(); + + guard = module.get(HybridAuthGuard); + reflector = module.get(Reflector); + // Not public, and don't skip the org check. + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(false); + // No cookie/regular session → forces the MCP OAuth fallback. + mockGetSession.mockResolvedValue(null); + // No org binding by default; individual tests override. + mockMcpBindingFindUnique.mockResolvedValue(null); + // No custom roles by default (built-in roles resolve without a DB call). + mockOrgRoleFindMany.mockResolvedValue([]); + }); + + it('authenticates a single-org user (admin) and binds org + roles', async () => { + mockGetMcpSession.mockResolvedValue({ userId: 'usr_1', scopes: 'openid' }); + mockUserFindUnique.mockResolvedValue({ + id: 'usr_1', + email: 'admin@acme.com', + role: 'user', + }); + mockMemberFindMany.mockResolvedValue([ + { id: 'mem_1', role: 'owner,admin', department: 'it', organizationId: 'org_1' }, + ]); + + const { context, request } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(request.userId).toBe('usr_1'); + expect(request.organizationId).toBe('org_1'); + expect(request.userRoles).toEqual(['owner', 'admin']); + expect(request.authType).toBe('session'); + expect(request.isApiKey).toBe(false); + expect(request.memberId).toBe('mem_1'); + }); + + it('authenticates a read-only member and surfaces their role for RBAC', async () => { + mockGetMcpSession.mockResolvedValue({ userId: 'usr_2', scopes: 'openid' }); + mockUserFindUnique.mockResolvedValue({ + id: 'usr_2', + email: 'auditor@acme.com', + role: 'user', + }); + mockMemberFindMany.mockResolvedValue([ + { id: 'mem_2', role: 'auditor', department: 'none', organizationId: 'org_1' }, + ]); + + const { context, request } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(request.userRoles).toEqual(['auditor']); + expect(request.organizationId).toBe('org_1'); + }); + + it('rejects when the bearer token is not a valid MCP OAuth token', async () => { + mockGetMcpSession.mockResolvedValue(null); + + const { context } = createContext({ + authorization: 'Bearer not_a_token', + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + UnauthorizedException, + ); + expect(mockUserFindUnique).not.toHaveBeenCalled(); + }); + + it('rejects a valid token whose user has no organization', async () => { + mockGetMcpSession.mockResolvedValue({ userId: 'usr_3', scopes: 'openid' }); + mockUserFindUnique.mockResolvedValue({ + id: 'usr_3', + email: 'orphan@acme.com', + role: 'user', + }); + mockMemberFindMany.mockResolvedValue([]); + + const { context } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + // 403 (authenticated, but no org) — not a 401 that would trigger re-auth. + await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); + }); + + it('blocks an org-less user even on org-agnostic (skipOrgCheck) endpoints', async () => { + // skipOrgCheck = true for this request, but the user belongs to no org — + // a "foreign" user must not be able to use the MCP at all. + jest + .spyOn(reflector, 'getAllAndOverride') + .mockImplementation((key: unknown) => key === SKIP_ORG_CHECK_KEY); + mockGetMcpSession.mockResolvedValue({ userId: 'usr_x', scopes: 'openid' }); + mockUserFindUnique.mockResolvedValue({ + id: 'usr_x', + email: 'stranger@example.com', + role: 'user', + }); + mockMemberFindMany.mockResolvedValue([]); // member of nothing + + const { context } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); + }); + + it('multi-org with no saved choice → asks them to pick (no silent tenant)', async () => { + mockGetMcpSession.mockResolvedValue({ userId: 'usr_5', scopes: 'openid' }); + mockUserFindUnique.mockResolvedValue({ + id: 'usr_5', + email: 'consultant@acme.com', + role: 'user', + }); + mockMemberFindMany.mockResolvedValue([ + { id: 'mem_a', role: 'admin', department: 'none', organizationId: 'org_a' }, + { id: 'mem_b', role: 'owner', department: 'none', organizationId: 'org_b' }, + ]); + mockMcpBindingFindUnique.mockResolvedValue(null); + + const { context, request } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + // 403 (token is valid — user just needs to pick an org), not a 401. + await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); + // No tenant must have been bound. + expect(request.organizationId).toBe(''); + }); + + it('multi-org with a saved choice → binds the chosen org', async () => { + mockGetMcpSession.mockResolvedValue({ userId: 'usr_6', scopes: 'openid' }); + mockUserFindUnique.mockResolvedValue({ + id: 'usr_6', + email: 'consultant@acme.com', + role: 'user', + }); + mockMemberFindMany.mockResolvedValue([ + { id: 'mem_a', role: 'admin', department: 'none', organizationId: 'org_a' }, + { id: 'mem_b', role: 'owner', department: 'it', organizationId: 'org_b' }, + ]); + mockMcpBindingFindUnique.mockResolvedValue({ organizationId: 'org_b' }); + + const { context, request } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(request.organizationId).toBe('org_b'); + expect(request.memberId).toBe('mem_b'); + expect(request.userRoles).toEqual(['owner']); + }); + + it('multi-org with a stale choice (no longer a member) → asks them to pick', async () => { + mockGetMcpSession.mockResolvedValue({ userId: 'usr_7', scopes: 'openid' }); + mockUserFindUnique.mockResolvedValue({ + id: 'usr_7', + email: 'consultant@acme.com', + role: 'user', + }); + mockMemberFindMany.mockResolvedValue([ + { id: 'mem_a', role: 'admin', department: 'none', organizationId: 'org_a' }, + { id: 'mem_b', role: 'owner', department: 'none', organizationId: 'org_b' }, + ]); + // Bound to an org they were removed from. + mockMcpBindingFindUnique.mockResolvedValue({ organizationId: 'org_gone' }); + + const { context } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + // 403 (token is valid — user just needs to pick an org), not a 401. + await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); + }); + + it('marks platform admins from the user role', async () => { + mockGetMcpSession.mockResolvedValue({ userId: 'usr_4', scopes: 'openid' }); + mockUserFindUnique.mockResolvedValue({ + id: 'usr_4', + email: 'staff@trycomp.ai', + role: 'admin', + }); + mockMemberFindMany.mockResolvedValue([ + { id: 'mem_4', role: 'owner', department: 'none', organizationId: 'org_1' }, + ]); + + const { context, request } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(request.isPlatformAdmin).toBe(true); + }); + + it('lets a platform admin through even with a non-app-access member role', async () => { + // Platform admin (user.role='admin') who is only an employee in the org — + // should bypass the app-access gate, consistent with PermissionGuard. + mockGetMcpSession.mockResolvedValue({ userId: 'usr_pa', scopes: 'openid' }); + mockUserFindUnique.mockResolvedValue({ + id: 'usr_pa', + email: 'staff@trycomp.ai', + role: 'admin', + }); + mockMemberFindMany.mockResolvedValue([ + { id: 'mem_pa', role: 'employee', department: 'none', organizationId: 'org_1' }, + ]); + + const { context, request } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(request.organizationId).toBe('org_1'); + expect(request.isPlatformAdmin).toBe(true); + }); + + it('blocks a Portal-only role (employee) — no app access, no MCP', async () => { + mockGetMcpSession.mockResolvedValue({ userId: 'usr_e', scopes: 'openid' }); + mockUserFindUnique.mockResolvedValue({ + id: 'usr_e', + email: 'employee@acme.com', + role: 'user', + }); + mockMemberFindMany.mockResolvedValue([ + { id: 'mem_e', role: 'employee', department: 'none', organizationId: 'org_1' }, + ]); + + const { context, request } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); + expect(request.organizationId).toBe(''); + }); + + it('allows a custom role that grants app access', async () => { + mockGetMcpSession.mockResolvedValue({ userId: 'usr_c', scopes: 'openid' }); + mockUserFindUnique.mockResolvedValue({ + id: 'usr_c', + email: 'custom@acme.com', + role: 'user', + }); + mockMemberFindMany.mockResolvedValue([ + { id: 'mem_c', role: 'Compliance Lead', department: 'none', organizationId: 'org_1' }, + ]); + // Custom role resolved from organization_role with app access granted. + mockOrgRoleFindMany.mockResolvedValue([ + { permissions: JSON.stringify({ app: ['read'], control: ['read'] }) }, + ]); + + const { context, request } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(request.organizationId).toBe('org_1'); + expect(request.userRoles).toEqual(['Compliance Lead']); + }); + + it('blocks a custom role that lacks app access', async () => { + mockGetMcpSession.mockResolvedValue({ userId: 'usr_d', scopes: 'openid' }); + mockUserFindUnique.mockResolvedValue({ + id: 'usr_d', + email: 'limited@acme.com', + role: 'user', + }); + mockMemberFindMany.mockResolvedValue([ + { id: 'mem_d', role: 'Read Only Portal', department: 'none', organizationId: 'org_1' }, + ]); + mockOrgRoleFindMany.mockResolvedValue([ + { permissions: JSON.stringify({ policy: ['read'], portal: ['read'] }) }, + ]); + + const { context } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); + }); +}); diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index c97f2b43bf..0937b91d3b 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -1,6 +1,8 @@ import { CanActivate, ExecutionContext, + ForbiddenException, + HttpException, Injectable, Logger, UnauthorizedException, @@ -8,6 +10,7 @@ import { import { Reflector } from '@nestjs/core'; import { db } from '@db'; import { ApiKeyService } from './api-key.service'; +import { hasAppAccess } from './app-access'; import { auth } from './auth.server'; import { IS_PUBLIC_KEY } from './public.decorator'; import { SKIP_ORG_CHECK_KEY } from './skip-org-check.decorator'; @@ -169,6 +172,11 @@ export class HybridAuthGuard implements CanActivate { const session = await auth.api.getSession({ headers }); if (!session) { + // Fallback: the hosted MCP server (Gram) sends an OAuth access token as a + // Bearer token, which getSession does not resolve. Try the MCP OAuth path. + if (await this.tryMcpOAuthAuth(request, headers)) { + return true; + } throw new UnauthorizedException('Invalid or expired session'); } @@ -240,7 +248,9 @@ export class HybridAuthGuard implements CanActivate { return true; } catch (error) { - if (error instanceof UnauthorizedException) { + // Re-throw deliberate auth/permission errors as-is (e.g. the 403 from the + // MCP org-resolution path). Only unexpected failures collapse to a 401. + if (error instanceof HttpException) { throw error; } @@ -248,4 +258,120 @@ export class HybridAuthGuard implements CanActivate { throw new UnauthorizedException('Invalid or expired session'); } } + + /** + * Resolve a hosted-MCP OAuth access token (issued by better-auth's mcp/oidc + * provider and forwarded by the Gram-hosted MCP server). Populates the request + * context and returns true on success; returns false when the bearer token is + * not a valid MCP OAuth token (so the caller throws the generic 401). Throws + * when no organization can be resolved. + * + * The token carries the user identity only. The organization is resolved + * explicitly from the user's active memberships (device-agent style), never a + * "most recent" guess. One org → used directly. Multiple orgs → the org the + * user chose for MCP (McpOrgBinding, set at connect time) is used if they're + * still a member; otherwise we ask them to choose rather than guess a tenant. + * Roles come from the resolved member so the existing PermissionGuard enforces + * RBAC unchanged. + * + * Two hard gates (both 403, never 401): the user must (1) be a member of an + * organization at all — strangers who merely completed sign-in are rejected — + * and (2) hold a role with app access (`app:read`) in the operative org, the + * same rule the web app uses. Portal-only roles (employee/contractor) cannot + * use the MCP. + */ + private async tryMcpOAuthAuth( + request: AuthenticatedRequest, + headers: Headers, + ): Promise { + const token = await auth.api.getMcpSession({ headers }).catch(() => null); + if (!token?.userId) { + return false; + } + + const userId = token.userId; + const user = await db.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true, role: true }, + }); + if (!user) { + return false; + } + + request.userId = user.id; + request.userEmail = user.email; + request.userRoles = null; + request.organizationId = ''; + request.authType = 'session'; + request.isApiKey = false; + request.isServiceToken = false; + request.isMcpOAuth = true; + request.isPlatformAdmin = user.role === 'admin'; + + // An MCP token is only usable by a member of at least one organization. + // Enumerate active memberships up front (device-agent style) so a user with + // none — e.g. someone who completed Google sign-in but was never invited to + // any org, or who was removed from all of them — is blocked from EVERY MCP + // tool, including the org-agnostic (skipOrgCheck) ones. + const memberships = await db.member.findMany({ + where: { userId, deactivated: false }, + select: { id: true, role: true, department: true, organizationId: true }, + }); + + if (memberships.length === 0) { + // Authenticated, but a member of nothing — not an auth failure, so 403 + // (not 401) keeps the MCP client from looping on re-authentication. + throw new ForbiddenException( + 'This account is not a member of any organization, so it cannot use the MCP.', + ); + } + + let member = memberships[0]; + if (memberships.length > 1) { + // Multi-org: use the org the user chose for MCP (set at connect time), + // as long as they're still a member of it. No saved/valid choice → ask + // them to pick rather than guessing a tenant. + const binding = await db.mcpOrgBinding.findUnique({ + where: { userId }, + select: { organizationId: true }, + }); + const chosen = binding + ? memberships.find((m) => m.organizationId === binding.organizationId) + : undefined; + if (!chosen) { + // 403 (not 401): the token is valid — the user just needs to pick an + // org. A 401 would make the MCP client re-run sign-in in a loop. + throw new ForbiddenException( + 'This account belongs to multiple organizations. Choose your ' + + 'organization for AI/MCP access in Comp AI settings, then try again.', + ); + } + member = chosen; + } + + // App-access gate: MCP follows the same rule as the web app — only roles + // that grant app access (`app:read`) may use it. Portal-only roles + // (employee/contractor, or custom roles without app access) are rejected. + // Platform admins bypass this, consistent with PermissionGuard's own + // isPlatformAdmin bypass on the normal session path. + if ( + !request.isPlatformAdmin && + !(await hasAppAccess(member.organizationId, member.role)) + ) { + throw new ForbiddenException( + "Your role doesn't have access to the app, so it can't use the MCP. " + + 'Ask an organization admin for access.', + ); + } + + request.organizationId = member.organizationId; + request.memberId = member.id; + request.memberDepartment = member.department; + request.userRoles = member.role ? member.role.split(',') : null; + + this.logger.log( + `MCP OAuth token authenticated for user ${user.id} (org ${member.organizationId})`, + ); + return true; + } } diff --git a/apps/api/src/auth/permission.guard.spec.ts b/apps/api/src/auth/permission.guard.spec.ts index 46ac2ec522..7287392fae 100644 --- a/apps/api/src/auth/permission.guard.spec.ts +++ b/apps/api/src/auth/permission.guard.spec.ts @@ -19,6 +19,20 @@ jest.mock('@trycompai/auth', () => ({ PRIVILEGED_ROLES: ['owner', 'admin', 'auditor'], })); +// Mock ./app-access (used to authorize MCP OAuth requests). Mocked here so the +// spec doesn't pull in @db via the real module; permissionsGrant uses the real +// (trivial) logic so only resolveRolePermissions needs stubbing. +const mockResolveRolePermissions = jest.fn(); +jest.mock('./app-access', () => ({ + resolveRolePermissions: (...args: unknown[]) => + mockResolveRolePermissions(...args), + permissionsGrant: ( + perms: Record, + resource: string, + action: string, + ) => perms?.[resource]?.includes(action) ?? false, +})); + describe('PermissionGuard', () => { let guard: PermissionGuard; let reflector: Reflector; @@ -26,6 +40,8 @@ describe('PermissionGuard', () => { const createMockExecutionContext = ( request: Partial<{ isApiKey: boolean; + isMcpOAuth: boolean; + isPlatformAdmin: boolean; apiKeyScopes: string[] | undefined; userRoles: string[] | null; headers: Record; @@ -60,6 +76,48 @@ describe('PermissionGuard', () => { guard = module.get(PermissionGuard); reflector = module.get(Reflector); mockHasPermission.mockReset(); + mockResolveRolePermissions.mockReset(); + }); + + describe('MCP OAuth authorization', () => { + it('authorizes via resolved roles, not session hasPermission', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue([{ resource: 'control', actions: ['read'] }]); + mockResolveRolePermissions.mockResolvedValue({ + control: ['read', 'create'], + }); + + const context = createMockExecutionContext({ + isMcpOAuth: true, + userRoles: ['admin'], + organizationId: 'org_1', + }); + + await expect(guard.canActivate(context)).resolves.toBe(true); + // Session-based hasPermission must NOT be used for MCP OAuth tokens. + expect(mockHasPermission).not.toHaveBeenCalled(); + expect(mockResolveRolePermissions).toHaveBeenCalledWith('org_1', [ + 'admin', + ]); + }); + + it('denies MCP OAuth when resolved roles lack the permission', async () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue([{ resource: 'control', actions: ['delete'] }]); + mockResolveRolePermissions.mockResolvedValue({ control: ['read'] }); + + const context = createMockExecutionContext({ + isMcpOAuth: true, + userRoles: ['auditor'], + organizationId: 'org_1', + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + ForbiddenException, + ); + }); }); describe('canActivate', () => { diff --git a/apps/api/src/auth/permission.guard.ts b/apps/api/src/auth/permission.guard.ts index 033922d499..e8d0434e2b 100644 --- a/apps/api/src/auth/permission.guard.ts +++ b/apps/api/src/auth/permission.guard.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { RESTRICTED_ROLES, PRIVILEGED_ROLES } from '@trycompai/auth'; +import { permissionsGrant, resolveRolePermissions } from './app-access'; import { auth } from './auth.server'; import { resolveServiceByName } from './service-token.config'; import { AuthenticatedRequest } from './types'; @@ -129,6 +130,27 @@ export class PermissionGuard implements CanActivate { : perm.actions; } + // MCP OAuth tokens are not better-auth sessions, so `hasPermission` (which + // resolves a session + active org) can't authorize them. Check the required + // permissions against the roles HybridAuthGuard already resolved for the + // bound org. (Mirrors better-auth's union-of-roles semantics.) + if (request.isMcpOAuth) { + const perms = await resolveRolePermissions( + request.organizationId, + request.userRoles ?? [], + ); + const granted = Object.entries(permissionBody).every(([resource, actions]) => + actions.every((action) => permissionsGrant(perms, resource, action)), + ); + if (!granted) { + this.logger.warn( + `[PermissionGuard] MCP OAuth access denied for ${request.method} ${request.url}. Required: ${JSON.stringify(permissionBody)}`, + ); + throw new ForbiddenException('Access denied'); + } + return true; + } + try { const hasPermission = await this.checkPermission(request, permissionBody); diff --git a/apps/api/src/auth/types.ts b/apps/api/src/auth/types.ts index 2e89f19102..a7a416ad01 100644 --- a/apps/api/src/auth/types.ts +++ b/apps/api/src/auth/types.ts @@ -20,6 +20,7 @@ export interface AuthenticatedRequest extends Request { impersonatedBy?: string; // User ID of the admin who initiated impersonation (only set during impersonation sessions) sessionId?: string; // Session ID (only set for session auth) sessionDeviceAgent?: boolean; // Whether the session is a device-agent session (only set for session auth) + isMcpOAuth?: boolean; // True when authenticated via a hosted-MCP OAuth token (no real session). PermissionGuard checks RBAC from userRoles instead of better-auth's session-based hasPermission. } export interface AuthContext { diff --git a/apps/api/src/integration-platform/controllers/sync-gws.controller.spec.ts b/apps/api/src/integration-platform/controllers/sync-gws.controller.spec.ts index 68a7286112..fa250967fa 100644 --- a/apps/api/src/integration-platform/controllers/sync-gws.controller.spec.ts +++ b/apps/api/src/integration-platform/controllers/sync-gws.controller.spec.ts @@ -81,6 +81,7 @@ describe('SyncController - Google Workspace employees', () => { mockConnectionRepo = { findById: jest.fn(), findBySlugAndOrg: jest.fn(), + update: jest.fn(), } as unknown as jest.Mocked; mockCredentialVault = { @@ -271,19 +272,26 @@ describe('SyncController - Google Workspace employees', () => { }); }); - // ── Deactivated members must NOT be reactivated ──────────────── + // ── Deactivated members get reactivated when they reappear in GWS ── + // Mirrors the JumpCloud + Rippling sync behavior so a user + // un-suspended in Google Workspace returns to the People tab on the + // next sync instead of staying invisible forever. Trade-off: a member + // manually deactivated by an admin will also be reactivated if they + // are still an active GWS user — admins must remove the user from + // GWS (or add them to `sync_excluded_emails`) to keep them deactivated. - describe('deactivated member handling (no reactivation)', () => { - it('should NOT reactivate a member deactivated manually by an admin', async () => { - setupSync({ gwUsers: [makeGwUser('manual@example.com')] }); + describe('reactivation of deactivated members', () => { + it('should reactivate a deactivated member who is active in GWS', async () => { + setupSync({ gwUsers: [makeGwUser('back@example.com')] }); (mockedDb.user.findUnique as jest.Mock).mockResolvedValue({ - id: 'user_manual', - email: 'manual@example.com', + id: 'user_back', + email: 'back@example.com', }); (mockedDb.member.findFirst as jest.Mock).mockResolvedValue( - makeMember('manual@example.com', { - userId: 'user_manual', + makeMember('back@example.com', { + id: 'mem_back', + userId: 'user_back', deactivated: true, }), ); @@ -294,49 +302,24 @@ describe('SyncController - Google Workspace employees', () => { connectionId, ); - expect(result.reactivated).toBe(0); - expect(result.skipped).toBe(1); - expect(mockedDb.member.update).not.toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ deactivated: false }), - }), - ); - }); - - it('should NOT reactivate a member previously deactivated by sync', async () => { - setupSync({ gwUsers: [makeGwUser('synced@example.com')] }); - - (mockedDb.user.findUnique as jest.Mock).mockResolvedValue({ - id: 'user_synced', - email: 'synced@example.com', + expect(result.reactivated).toBe(1); + expect(result.skipped).toBe(0); + expect(mockedDb.member.update).toHaveBeenCalledWith({ + where: { id: 'mem_back' }, + data: { deactivated: false, isActive: true }, }); - (mockedDb.member.findFirst as jest.Mock).mockResolvedValue( - makeMember('synced@example.com', { - userId: 'user_synced', - deactivated: true, - }), - ); - (mockedDb.member.findMany as jest.Mock).mockResolvedValue([]); - - const result = await controller.syncGoogleWorkspaceEmployees( - orgId, - connectionId, - ); - - expect(result.reactivated).toBe(0); - expect(result.skipped).toBe(1); }); - it('should report correct skip reason for deactivated members', async () => { - setupSync({ gwUsers: [makeGwUser('deact@example.com')] }); + it('should report the reactivation in details with a clear reason', async () => { + setupSync({ gwUsers: [makeGwUser('back@example.com')] }); (mockedDb.user.findUnique as jest.Mock).mockResolvedValue({ - id: 'user_deact', - email: 'deact@example.com', + id: 'user_back', + email: 'back@example.com', }); (mockedDb.member.findFirst as jest.Mock).mockResolvedValue( - makeMember('deact@example.com', { - userId: 'user_deact', + makeMember('back@example.com', { + userId: 'user_back', deactivated: true, }), ); @@ -348,12 +331,12 @@ describe('SyncController - Google Workspace employees', () => { ); const detail = result.details.find( - (d) => d.email === 'deact@example.com', + (d) => d.email === 'back@example.com', ); expect(detail).toEqual({ - email: 'deact@example.com', - status: 'skipped', - reason: 'Member is deactivated', + email: 'back@example.com', + status: 'reactivated', + reason: 'User is active again in Google Workspace', }); }); @@ -748,8 +731,8 @@ describe('SyncController - Google Workspace employees', () => { ); expect(result.imported).toBe(1); // new@example.com - expect(result.skipped).toBe(2); // active + deactivated - expect(result.reactivated).toBe(0); // deactivated stays deactivated + expect(result.skipped).toBe(1); // active@example.com + expect(result.reactivated).toBe(1); // deactivated@example.com comes back expect(result.deactivated).toBe(1); // suspended@example.com }); }); @@ -782,6 +765,42 @@ describe('SyncController - Google Workspace employees', () => { }); }); + // ── lastSyncAt bookkeeping ───────────────────────────────────── + + describe('lastSyncAt update', () => { + it('should record lastSyncAt on the connection after a successful sync', async () => { + setupSync({ gwUsers: [makeGwUser('new@example.com')] }); + + (mockedDb.user.findUnique as jest.Mock).mockResolvedValue(null); + (mockedDb.user.create as jest.Mock).mockResolvedValue({ + id: 'user_new', + email: 'new@example.com', + }); + (mockedDb.member.findFirst as jest.Mock).mockResolvedValue(null); + (mockedDb.member.create as jest.Mock).mockResolvedValue({ id: 'mem_new' }); + (mockedDb.member.findMany as jest.Mock).mockResolvedValue([]); + + await controller.syncGoogleWorkspaceEmployees(orgId, connectionId); + + expect(mockConnectionRepo.update).toHaveBeenCalledWith( + connectionId, + expect.objectContaining({ lastSyncAt: expect.any(Date) }), + ); + }); + + it('should record lastSyncAt even when the sync finds zero users', async () => { + setupSync({ gwUsers: [] }); + (mockedDb.member.findMany as jest.Mock).mockResolvedValue([]); + + await controller.syncGoogleWorkspaceEmployees(orgId, connectionId); + + expect(mockConnectionRepo.update).toHaveBeenCalledWith( + connectionId, + expect.objectContaining({ lastSyncAt: expect.any(Date) }), + ); + }); + }); + // ── Error handling ───────────────────────────────────────────── describe('error handling', () => { diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 3a16387529..c018449fee 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -423,14 +423,25 @@ export class SyncController { }); } } - results.skipped++; - results.details.push({ - email: normalizedEmail, - status: 'skipped', - reason: existingMember.deactivated - ? 'Member is deactivated' - : 'Already a member', - }); + if (existingMember.deactivated) { + await db.member.update({ + where: { id: existingMember.id }, + data: { deactivated: false, isActive: true, offboardDate: null }, + }); + results.reactivated++; + results.details.push({ + email: normalizedEmail, + status: 'reactivated', + reason: 'User is active again in Google Workspace', + }); + } else { + results.skipped++; + results.details.push({ + email: normalizedEmail, + status: 'skipped', + reason: 'Already a member', + }); + } continue; } @@ -561,6 +572,13 @@ export class SyncController { `Google Workspace sync complete: ${results.imported} imported, ${results.reactivated} reactivated, ${results.deactivated} deactivated, ${results.skipped} skipped, ${results.errors} errors`, ); + // Record that an employee sync ran. The People page reads + // connection.lastSyncAt as "Last sync"; without this it would only ever + // reflect the last check run, making a working daily sync look stuck. + await this.connectionRepository.update(connectionId, { + lastSyncAt: new Date(), + }); + return { success: true, totalFound: activeUsers.length, @@ -899,7 +917,7 @@ export class SyncController { if (existingMember.deactivated) { await db.member.update({ where: { id: existingMember.id }, - data: { deactivated: false, isActive: true }, + data: { deactivated: false, isActive: true, offboardDate: null }, }); results.reactivated++; results.details.push({ @@ -1406,7 +1424,7 @@ export class SyncController { if (existingMember.deactivated) { await db.member.update({ where: { id: existingMember.id }, - data: { deactivated: false, isActive: true }, + data: { deactivated: false, isActive: true, offboardDate: null }, }); results.reactivated++; results.details.push({ diff --git a/apps/api/src/mcp/dto/set-mcp-organization.dto.ts b/apps/api/src/mcp/dto/set-mcp-organization.dto.ts new file mode 100644 index 0000000000..81e1130bff --- /dev/null +++ b/apps/api/src/mcp/dto/set-mcp-organization.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class SetMcpOrganizationDto { + @ApiProperty({ + description: + 'The organization the MCP/AI connection should act on. Must be one you are a member of.', + example: 'org_abc123', + }) + @IsString() + @IsNotEmpty() + organizationId!: string; +} diff --git a/apps/api/src/mcp/mcp.controller.ts b/apps/api/src/mcp/mcp.controller.ts new file mode 100644 index 0000000000..1da8feb87d --- /dev/null +++ b/apps/api/src/mcp/mcp.controller.ts @@ -0,0 +1,54 @@ +import { Body, Controller, Get, Put, UseGuards } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { UserId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import { SessionOnlyGuard } from '../auth/session-only.guard'; +import { SetMcpOrganizationDto } from './dto/set-mcp-organization.dto'; +import { McpService } from './mcp.service'; + +/** + * MCP account-management endpoints (web app only — excluded from the public + * OpenAPI spec / MCP tools via the deny-list in public-docs-quality.ts). + * + * Session-only (these are user self-management actions — `SessionOnlyGuard` + * rejects API keys / service tokens with a clean 403 instead of `@UserId()` + * throwing a 500), and gated on app access (`app:read`) like the rest of the + * product. Going through PermissionGuard + @RequirePermission also records the + * PUT mutation in the audit log (the AuditLogInterceptor only logs when + * @RequirePermission is present). + */ +@ApiTags('MCP') +@Controller({ path: 'mcp', version: '1' }) +@UseGuards(HybridAuthGuard, SessionOnlyGuard, PermissionGuard) +@ApiSecurity('apikey') +export class McpController { + constructor(private readonly mcpService: McpService) {} + + @Get('organization') + @RequirePermission('app', 'read') + @ApiOperation({ + summary: 'Get your MCP organization selection', + description: + 'Returns the organizations you belong to and which one your AI/MCP connection currently acts on.', + }) + async getOrganization(@UserId() userId: string) { + return this.mcpService.getOrganizationSelection(userId); + } + + @Put('organization') + @RequirePermission('app', 'read') + @ApiOperation({ + summary: 'Set your MCP organization', + description: + 'Sets which organization your AI/MCP connection acts on when you belong to more than one. Validated against your memberships.', + }) + @ApiBody({ type: SetMcpOrganizationDto }) + async setOrganization( + @UserId() userId: string, + @Body() dto: SetMcpOrganizationDto, + ) { + return this.mcpService.setOrganization(userId, dto.organizationId); + } +} diff --git a/apps/api/src/mcp/mcp.module.ts b/apps/api/src/mcp/mcp.module.ts new file mode 100644 index 0000000000..4bc05118f9 --- /dev/null +++ b/apps/api/src/mcp/mcp.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { McpController } from './mcp.controller'; +import { McpService } from './mcp.service'; + +@Module({ + imports: [AuthModule], + controllers: [McpController], + providers: [McpService], + exports: [McpService], +}) +export class McpModule {} diff --git a/apps/api/src/mcp/mcp.service.spec.ts b/apps/api/src/mcp/mcp.service.spec.ts new file mode 100644 index 0000000000..ea00801885 --- /dev/null +++ b/apps/api/src/mcp/mcp.service.spec.ts @@ -0,0 +1,121 @@ +import { ForbiddenException } from '@nestjs/common'; + +const mockMemberFindMany = jest.fn(); +const mockMemberFindFirst = jest.fn(); +const mockBindingFindUnique = jest.fn(); +const mockBindingUpsert = jest.fn(); +const mockOrgRoleFindMany = jest.fn(); +jest.mock('@db', () => ({ + db: { + member: { + findMany: (...a: unknown[]) => mockMemberFindMany(...a), + findFirst: (...a: unknown[]) => mockMemberFindFirst(...a), + }, + mcpOrgBinding: { + findUnique: (...a: unknown[]) => mockBindingFindUnique(...a), + upsert: (...a: unknown[]) => mockBindingUpsert(...a), + }, + organizationRole: { + findMany: (...a: unknown[]) => mockOrgRoleFindMany(...a), + }, + }, +})); + +jest.mock('@trycompai/auth', () => ({ + BUILT_IN_ROLE_PERMISSIONS: { + owner: { app: ['read'] }, + admin: { app: ['read'] }, + auditor: { app: ['read'] }, + employee: { policy: ['read'], portal: ['read', 'update'] }, + contractor: { policy: ['read'], portal: ['read', 'update'] }, + }, +})); + +import { McpService } from './mcp.service'; + +describe('McpService', () => { + let service: McpService; + + beforeEach(() => { + jest.clearAllMocks(); + mockOrgRoleFindMany.mockResolvedValue([]); + service = new McpService(); + }); + + describe('getOrganizationSelection', () => { + it('returns only app-access orgs and the current selection', async () => { + mockMemberFindMany.mockResolvedValue([ + { role: 'owner', organization: { id: 'org_a', name: 'Acme' } }, + // Portal-only — must be excluded. + { role: 'employee', organization: { id: 'org_b', name: 'Beta' } }, + { role: 'admin', organization: { id: 'org_c', name: 'Gamma' } }, + ]); + mockBindingFindUnique.mockResolvedValue({ organizationId: 'org_c' }); + + const result = await service.getOrganizationSelection('usr_1'); + + expect(result.organizations).toEqual([ + { id: 'org_a', name: 'Acme' }, + { id: 'org_c', name: 'Gamma' }, + ]); + expect(result.selectedOrganizationId).toBe('org_c'); + }); + + it('drops a selection the user can no longer use', async () => { + mockMemberFindMany.mockResolvedValue([ + { role: 'owner', organization: { id: 'org_a', name: 'Acme' } }, + ]); + mockBindingFindUnique.mockResolvedValue({ organizationId: 'org_gone' }); + + const result = await service.getOrganizationSelection('usr_1'); + + expect(result.selectedOrganizationId).toBeNull(); + }); + + it('excludes Portal-only orgs entirely', async () => { + mockMemberFindMany.mockResolvedValue([ + { role: 'employee', organization: { id: 'org_b', name: 'Beta' } }, + ]); + mockBindingFindUnique.mockResolvedValue(null); + + const result = await service.getOrganizationSelection('usr_1'); + + expect(result.organizations).toEqual([]); + expect(result.selectedOrganizationId).toBeNull(); + }); + }); + + describe('setOrganization', () => { + it('saves when the user is a member with app access', async () => { + mockMemberFindFirst.mockResolvedValue({ role: 'admin' }); + mockBindingUpsert.mockResolvedValue({}); + + const result = await service.setOrganization('usr_1', 'org_a'); + + expect(result).toEqual({ organizationId: 'org_a' }); + expect(mockBindingUpsert).toHaveBeenCalledWith({ + where: { userId: 'usr_1' }, + create: { userId: 'usr_1', organizationId: 'org_a' }, + update: { organizationId: 'org_a' }, + }); + }); + + it('rejects when the user is not a member of the org', async () => { + mockMemberFindFirst.mockResolvedValue(null); + + await expect(service.setOrganization('usr_1', 'org_x')).rejects.toThrow( + ForbiddenException, + ); + expect(mockBindingUpsert).not.toHaveBeenCalled(); + }); + + it('rejects a member whose role lacks app access', async () => { + mockMemberFindFirst.mockResolvedValue({ role: 'employee' }); + + await expect(service.setOrganization('usr_1', 'org_b')).rejects.toThrow( + ForbiddenException, + ); + expect(mockBindingUpsert).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/mcp/mcp.service.ts b/apps/api/src/mcp/mcp.service.ts new file mode 100644 index 0000000000..9f6aa2a3d2 --- /dev/null +++ b/apps/api/src/mcp/mcp.service.ts @@ -0,0 +1,72 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { db } from '@db'; +import { hasAppAccess } from '../auth/app-access'; + +@Injectable() +export class McpService { + /** + * The organizations the user can choose from for MCP access, plus their + * current selection (null when unset or no longer valid). Only orgs where the + * user's role grants app access are offered — picking one without it wouldn't + * work (the MCP guard would reject it). + */ + async getOrganizationSelection(userId: string) { + const memberships = await db.member.findMany({ + where: { userId, deactivated: false }, + select: { role: true, organization: { select: { id: true, name: true } } }, + }); + + // Resolve app-access for every membership concurrently (avoid serial N+1). + const checks = await Promise.all( + memberships.map(async (membership) => ({ + org: { + id: membership.organization.id, + name: membership.organization.name, + }, + allowed: await hasAppAccess(membership.organization.id, membership.role), + })), + ); + const organizations = checks.filter((c) => c.allowed).map((c) => c.org); + + const binding = await db.mcpOrgBinding.findUnique({ + where: { userId }, + select: { organizationId: true }, + }); + // Drop a stale selection if the user is no longer a member of that org. + const selectedOrganizationId = + binding && organizations.some((o) => o.id === binding.organizationId) + ? binding.organizationId + : null; + + return { organizations, selectedOrganizationId }; + } + + /** + * Set which organization the user's MCP/OAuth token acts on. Validates that + * the user is an active member of the chosen org before saving. + */ + async setOrganization(userId: string, organizationId: string) { + const member = await db.member.findFirst({ + where: { userId, organizationId, deactivated: false }, + select: { role: true }, + }); + if (!member) { + throw new ForbiddenException( + 'You are not a member of the selected organization.', + ); + } + if (!(await hasAppAccess(organizationId, member.role))) { + throw new ForbiddenException( + "Your role in that organization doesn't have app access, so it can't be used for the MCP.", + ); + } + + await db.mcpOrgBinding.upsert({ + where: { userId }, + create: { userId, organizationId }, + update: { organizationId }, + }); + + return { organizationId }; + } +} diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts index 32d042499c..054e251fe9 100644 --- a/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts @@ -44,7 +44,11 @@ export class OffboardingChecklistController { @Get('pending') @RequirePermission('member', 'read') - @ApiOperation({ summary: 'Get members with pending offboarding checklists' }) + @ApiOperation({ + summary: 'Get members with pending offboarding checklists', + description: + 'Lists members whose offboarding checklist is still incomplete, with their outstanding items, so you can track and finish departing-employee offboarding.', + }) async getPendingOffboardings( @OrganizationId() organizationId: string, ) { @@ -55,12 +59,22 @@ export class OffboardingChecklistController { @Get('template') @RequirePermission('member', 'read') + @ApiOperation({ + summary: 'Get the offboarding checklist template', + description: + "Returns the organization's offboarding checklist template: the ordered set of items every departing member must complete during their offboarding.", + }) async getTemplate(@OrganizationId() organizationId: string) { return this.offboardingChecklistService.getTemplate(organizationId); } @Post('template') @RequirePermission('member', 'update') + @ApiOperation({ + summary: 'Add an offboarding checklist template item', + description: + "Creates a new item in the organization's offboarding checklist template so it appears on every member's offboarding checklist from now on.", + }) async createTemplateItem( @OrganizationId() organizationId: string, @Body() dto: CreateTemplateItemDto, @@ -73,6 +87,11 @@ export class OffboardingChecklistController { @Patch('template/:id') @RequirePermission('member', 'update') + @ApiOperation({ + summary: 'Update an offboarding checklist template item', + description: + "Updates an existing offboarding checklist template item by id, changing its label, description, or settings on the organization's offboarding template.", + }) async updateTemplateItem( @OrganizationId() organizationId: string, @Param('id') id: string, @@ -87,6 +106,11 @@ export class OffboardingChecklistController { @Delete('template/:id') @RequirePermission('member', 'update') + @ApiOperation({ + summary: 'Delete an offboarding checklist template item', + description: + "Removes an item from the organization's offboarding checklist template by id so it no longer appears on members' offboarding checklists.", + }) async deleteTemplateItem( @OrganizationId() organizationId: string, @Param('id') id: string, @@ -99,6 +123,11 @@ export class OffboardingChecklistController { @Get('member/:memberId') @RequirePermission('member', 'read') + @ApiOperation({ + summary: "Get a member's offboarding checklist", + description: + 'Returns the offboarding checklist for a specific member, including each item and whether it has been completed, to track that person\'s offboarding progress.', + }) async getMemberChecklist( @OrganizationId() organizationId: string, @Param('memberId') memberId: string, @@ -111,7 +140,11 @@ export class OffboardingChecklistController { @Get('export-all') @RequirePermission('member', 'read') - @ApiOperation({ summary: 'Export all offboarding evidence as a zip file' }) + @ApiOperation({ + summary: 'Export all offboarding evidence as a zip file', + description: + 'Exports a zip archive containing the offboarding checklist evidence for every member in the organization, for audits, handovers, or record-keeping.', + }) async exportAllEvidence( @OrganizationId() organizationId: string, @Res() res: Response, @@ -134,7 +167,11 @@ export class OffboardingChecklistController { @Get('member/:memberId/export') @RequirePermission('member', 'read') - @ApiOperation({ summary: 'Export offboarding evidence as a zip file' }) + @ApiOperation({ + summary: 'Export offboarding evidence as a zip file', + description: + 'Exports a zip archive of the offboarding checklist evidence collected for a single member, for audit, handover, or record-keeping purposes.', + }) @ApiParam({ name: 'memberId', description: 'Member ID' }) async exportEvidence( @Param('memberId') memberId: string, @@ -166,6 +203,11 @@ export class OffboardingChecklistController { @Post('member/:memberId/item/:templateItemId/complete') @RequirePermission('member', 'update') + @ApiOperation({ + summary: 'Complete an offboarding checklist item', + description: + "Marks a specific offboarding checklist item complete for a member, recording who completed it and when, as part of finishing that member's offboarding.", + }) async completeItem( @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, @@ -184,6 +226,11 @@ export class OffboardingChecklistController { @Delete('member/:memberId/item/:templateItemId/complete') @RequirePermission('member', 'update') + @ApiOperation({ + summary: 'Reopen an offboarding checklist item', + description: + 'Reverts a previously completed offboarding checklist item back to incomplete for a member, in case the step was marked done by mistake.', + }) async uncompleteItem( @OrganizationId() organizationId: string, @Param('memberId') memberId: string, @@ -198,6 +245,11 @@ export class OffboardingChecklistController { @Post('member/:memberId/item/:templateItemId/evidence') @RequirePermission('member', 'update') + @ApiOperation({ + summary: 'Upload evidence for an offboarding checklist item', + description: + "Attaches a supporting evidence file to a member's completed offboarding checklist item, documenting that the offboarding step was actually carried out.", + }) async uploadEvidence( @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, @@ -218,6 +270,8 @@ export class OffboardingChecklistController { @RequirePermission('member', 'read') @ApiOperation({ summary: 'Get vendor access revocation status for a member', + description: + 'Lists the vendors a departing member had access to and whether each has been revoked, so you can confirm all vendor access is removed during offboarding.', }) @ApiParam({ name: 'memberId', description: 'Member ID' }) async getAccessRevocations( @@ -232,7 +286,11 @@ export class OffboardingChecklistController { @Post('member/:memberId/access-revocations/confirm-all') @RequirePermission('member', 'update') - @ApiOperation({ summary: 'Confirm all vendor access as revoked' }) + @ApiOperation({ + summary: 'Confirm all vendor access as revoked', + description: + "Marks every vendor access record for a departing member as revoked in one step, recording who confirmed it, to complete access removal during offboarding.", + }) @ApiParam({ name: 'memberId', description: 'Member ID' }) async revokeAllVendorAccess( @OrganizationId() organizationId: string, @@ -248,7 +306,11 @@ export class OffboardingChecklistController { @Post('member/:memberId/access-revocations/:vendorId') @RequirePermission('member', 'update') - @ApiOperation({ summary: 'Mark vendor access as revoked' }) + @ApiOperation({ + summary: 'Mark vendor access as revoked', + description: + "Marks a single vendor's access for a departing member as revoked, optionally attaching evidence and notes, as part of offboarding access removal.", + }) @ApiParam({ name: 'memberId', description: 'Member ID' }) @ApiParam({ name: 'vendorId', description: 'Vendor ID' }) async revokeVendorAccess( @@ -278,7 +340,11 @@ export class OffboardingChecklistController { @Delete('member/:memberId/access-revocations/:vendorId') @RequirePermission('member', 'update') - @ApiOperation({ summary: 'Undo vendor access revocation' }) + @ApiOperation({ + summary: 'Undo vendor access revocation', + description: + "Reverses a vendor access revocation for a member, marking that vendor's access as not revoked again, in case it was confirmed by mistake during offboarding.", + }) @ApiParam({ name: 'memberId', description: 'Member ID' }) @ApiParam({ name: 'vendorId', description: 'Vendor ID' }) async undoVendorRevocation( diff --git a/apps/api/src/openapi-docs.spec.ts b/apps/api/src/openapi-docs.spec.ts index da66189b30..1766e6e8aa 100644 --- a/apps/api/src/openapi-docs.spec.ts +++ b/apps/api/src/openapi-docs.spec.ts @@ -201,4 +201,60 @@ describe('OpenAPI document', () => { ); }); }); + + describe('MCP OAuth security', () => { + it('declares an oauth2 authorization-code scheme pointed at the Comp AI auth server', () => { + const scheme = document.components?.securitySchemes?.oauth2 as + | { + type?: string; + flows?: { + authorizationCode?: { + authorizationUrl?: string; + tokenUrl?: string; + scopes?: Record; + }; + }; + } + | undefined; + + expect(scheme?.type).toBe('oauth2'); + expect(scheme?.flows?.authorizationCode?.authorizationUrl).toBe( + `${PUBLIC_SERVER_URL}/api/auth/mcp/authorize`, + ); + expect(scheme?.flows?.authorizationCode?.tokenUrl).toBe( + `${PUBLIC_SERVER_URL}/api/auth/mcp/token`, + ); + }); + + it('offers oauth2 alongside the API key on every authenticated operation', () => { + const operations = Object.values(document.paths).flatMap((methods) => + Object.values(methods as Record), + ); + + const hasReq = (security: unknown, scheme: string): boolean => + Array.isArray(security) && + security.some((req) => req && typeof req === 'object' && scheme in req); + + const apiKeyOps = operations.filter((op) => + hasReq(op?.security, 'apikey'), + ); + + // Sanity: the spec really does gate operations behind the API key. + expect(apiKeyOps.length).toBeGreaterThan(0); + + // Every API-key operation must also accept oauth2 (OR semantics) so MCP + // callers authenticate per-user instead of via a shared key. + const missingOAuth = apiKeyOps.filter( + (op) => !hasReq(op?.security, 'oauth2'), + ); + expect(missingOAuth).toHaveLength(0); + + // And oauth2 is never offered on an endpoint that isn't API-key gated. + const oauthWithoutApiKey = operations.filter( + (op) => + hasReq(op?.security, 'oauth2') && !hasReq(op?.security, 'apikey'), + ); + expect(oauthWithoutApiKey).toHaveLength(0); + }); + }); }); diff --git a/apps/api/src/openapi/public-docs-metadata.ts b/apps/api/src/openapi/public-docs-metadata.ts index d2c8b9ad91..0a7a4ff9e5 100644 --- a/apps/api/src/openapi/public-docs-metadata.ts +++ b/apps/api/src/openapi/public-docs-metadata.ts @@ -26,6 +26,15 @@ export const PUBLIC_OPENAPI_DESCRIPTION = export const PUBLIC_SERVER_URL = 'https://api.trycomp.ai'; +/** + * Name of the OAuth2 security scheme advertised in the public spec. MCP hosts + * (e.g. Speakeasy Gram) only surface "Sign in with Comp AI" + forward the + * caller's bearer token to the API when the spec declares an oauth2 scheme; + * with only the API key, every MCP user would hit the API as one shared + * identity, bypassing per-user RBAC. + */ +export const MCP_OAUTH_SECURITY_SCHEME = 'oauth2'; + function getVisibilityForOperation( operation: OpenApiOperation, metadata?: PublicOperationMetadata, @@ -274,6 +283,67 @@ function applyMcpToolNames( } } +/** + * Declare the OAuth2 (authorization code) security scheme and offer it on every + * operation that already accepts the API key. The scheme points at the + * better-auth MCP authorization server; the per-operation `security` entries use + * OR semantics, so an API key OR a Comp AI OAuth token satisfies the request. + * This is what lets MCP hosts forward each user's bearer token to the API so the + * existing per-user/per-org RBAC applies, rather than a single shared key. + */ +function applyMcpOAuthSecurity(document: OpenAPIObject): void { + document.components ??= {}; + document.components.securitySchemes ??= {}; + document.components.securitySchemes[MCP_OAUTH_SECURITY_SCHEME] = { + type: 'oauth2', + description: + 'OAuth 2.1 authorization code flow. Sign in with your Comp AI account — tokens are issued by the Comp AI authorization server and scoped to your organization, role, and permissions.', + flows: { + authorizationCode: { + authorizationUrl: `${PUBLIC_SERVER_URL}/api/auth/mcp/authorize`, + tokenUrl: `${PUBLIC_SERVER_URL}/api/auth/mcp/token`, + refreshUrl: `${PUBLIC_SERVER_URL}/api/auth/mcp/token`, + scopes: { + openid: 'OpenID Connect authentication', + profile: 'Basic profile information', + email: 'Email address', + offline_access: 'Maintain access via refresh tokens', + }, + }, + }, + }; + + for (const methods of Object.values(document.paths)) { + for (const operation of Object.values( + methods as Record, + )) { + if (!operation || typeof operation !== 'object') { + continue; + } + + const security = operation.security; + if (!Array.isArray(security)) { + continue; + } + + const requirements = security as Array>; + const hasApiKey = requirements.some( + (req) => req && typeof req === 'object' && 'apikey' in req, + ); + const hasOAuth = requirements.some( + (req) => + req && typeof req === 'object' && MCP_OAUTH_SECURITY_SCHEME in req, + ); + + // Mirror OAuth onto API-key operations only — endpoints that are + // intentionally public (empty security) must stay unauthenticated. + if (hasApiKey && !hasOAuth) { + requirements.push({ [MCP_OAUTH_SECURITY_SCHEME]: [] }); + } + } + } +} + export function applyPublicOpenApiMetadata(document: OpenAPIObject): void { document.info.title = PUBLIC_OPENAPI_TITLE; document.info.description = PUBLIC_OPENAPI_DESCRIPTION; @@ -328,4 +398,7 @@ export function applyPublicOpenApiMetadata(document: OpenAPIObject): void { addTagMetadata(document); removeUnusedSchemas(document); sanitizePublicSchemas(document); + + // Add OAuth last so its security scheme isn't touched by schema pruning. + applyMcpOAuthSecurity(document); } diff --git a/apps/api/src/openapi/public-docs-quality.ts b/apps/api/src/openapi/public-docs-quality.ts index bf1ee5f50e..e133d52e3e 100644 --- a/apps/api/src/openapi/public-docs-quality.ts +++ b/apps/api/src/openapi/public-docs-quality.ts @@ -4,6 +4,7 @@ export const PUBLIC_DOCS_EXCLUDED_PREFIXES = [ '/v1/auth', '/v1/admin', '/v1/internal', + '/v1/mcp', '/v1/framework-editor', '/v1/browserbase', '/v1/assistant-chat', diff --git a/apps/api/src/tasks/evidence-export/evidence-export.controller.spec.ts b/apps/api/src/tasks/evidence-export/evidence-export.controller.spec.ts index e9f29c13e1..5c6f651ff5 100644 --- a/apps/api/src/tasks/evidence-export/evidence-export.controller.spec.ts +++ b/apps/api/src/tasks/evidence-export/evidence-export.controller.spec.ts @@ -53,6 +53,7 @@ function makeFakeResponse() { const emitter = new EventEmitter(); const res = Object.assign(emitter, { setHeader: jest.fn(), + flushHeaders: jest.fn(), status: jest.fn(function (this: unknown) { return res; }), @@ -135,6 +136,7 @@ describe('EvidenceExportController', () => { 'Content-Disposition', `attachment; filename="acme_mytask_evidence_2026-04-22.zip"`, ); + expect(res.flushHeaders).toHaveBeenCalledTimes(1); expect(archive.pipe).toHaveBeenCalledWith(res); }); @@ -273,6 +275,7 @@ describe('AuditorEvidenceExportController', () => { 'Content-Disposition', `attachment; filename="acme_all-evidence_2026-04-22.zip"`, ); + expect(res.flushHeaders).toHaveBeenCalledTimes(1); expect(archive.pipe).toHaveBeenCalledWith(res); }); }); diff --git a/apps/api/src/tasks/evidence-export/evidence-export.controller.ts b/apps/api/src/tasks/evidence-export/evidence-export.controller.ts index 3a5cd924d6..fd516e93e9 100644 --- a/apps/api/src/tasks/evidence-export/evidence-export.controller.ts +++ b/apps/api/src/tasks/evidence-export/evidence-export.controller.ts @@ -188,6 +188,11 @@ export class EvidenceExportController { 'Content-Disposition', `attachment; filename="${filename}"`, ); + // Push the response status line + headers to the wire immediately so + // upstream proxies (Cloudflare, ALB, etc.) don't apply their idle-timeout + // while we assemble the first archive entry — a TTFB > ~60s on a large + // org otherwise surfaces in the browser as `TypeError: Failed to fetch`. + res.flushHeaders(); pipeArchiveToResponse({ archive, @@ -261,6 +266,9 @@ export class AuditorEvidenceExportController { 'Content-Disposition', `attachment; filename="${filename}"`, ); + // See note on the task variant above — flush early so a slow first task + // doesn't blow past the proxy idle timeout for large orgs. + res.flushHeaders(); pipeArchiveToResponse({ archive, diff --git a/apps/api/src/tasks/evidence-export/evidence-export.service.spec.ts b/apps/api/src/tasks/evidence-export/evidence-export.service.spec.ts index 8b47882611..bf8c63290c 100644 --- a/apps/api/src/tasks/evidence-export/evidence-export.service.spec.ts +++ b/apps/api/src/tasks/evidence-export/evidence-export.service.spec.ts @@ -192,10 +192,15 @@ describe('EvidenceExportService — streaming ZIPs', () => { await mock.finalized; const paths = mock.appendCalls.map((c) => c.options.name); + // EXPORT_INFO.txt is appended first to flush a ZIP byte through proxies + // before the slow per-task data load runs. expect(paths[0]).toBe( - 'acme-corp_soc-2-access-review_evidence/00-summary.pdf', + 'acme-corp_soc-2-access-review_evidence/EXPORT_INFO.txt', ); expect(paths[1]).toBe( + 'acme-corp_soc-2-access-review_evidence/00-summary.pdf', + ); + expect(paths[2]).toBe( 'acme-corp_soc-2-access-review_evidence/01-attachments/contract.pdf', ); @@ -592,6 +597,7 @@ describe('EvidenceExportService — streaming ZIPs', () => { const paths = mock.appendCalls.map((c) => c.options.name); expect(paths).toEqual([ + 'acme-corp_soc-2-access-review_evidence/EXPORT_INFO.txt', 'acme-corp_soc-2-access-review_evidence/00-summary.pdf', ]); expect(s3Client!.send).not.toHaveBeenCalled(); diff --git a/apps/api/src/tasks/evidence-export/evidence-export.service.ts b/apps/api/src/tasks/evidence-export/evidence-export.service.ts index 9781f06296..67f4d6f641 100644 --- a/apps/api/src/tasks/evidence-export/evidence-export.service.ts +++ b/apps/api/src/tasks/evidence-export/evidence-export.service.ts @@ -35,6 +35,30 @@ const safeStringify = configureStringify({ deterministic: false, }); +function buildExportInfo( + info: + | { kind: 'task'; taskId: string } + | { + kind: 'organization'; + organizationName: string; + organizationId: string; + taskCount: number; + }, +): string { + const lines = [ + 'Evidence export', + `Started at: ${new Date().toISOString()}`, + ]; + if (info.kind === 'task') { + lines.push(`Task ID: ${info.taskId}`); + } else { + lines.push(`Organization: ${info.organizationName}`); + lines.push(`Organization ID: ${info.organizationId}`); + lines.push(`Tasks included: ${info.taskCount}`); + } + return lines.join('\n') + '\n'; +} + @Injectable() export class EvidenceExportService { private readonly logger = new Logger(EvidenceExportService.name); @@ -198,6 +222,17 @@ export class EvidenceExportService { }): Promise { const { archive, organizationId, taskId, folderName, options } = params; + // Force the archiver to emit a real ZIP byte immediately, before the + // per-task data load runs. Combined with res.flushHeaders() upstream this + // keeps the response visibly alive through any proxy idle timer. + archive.append( + Buffer.from( + buildExportInfo({ kind: 'task', taskId }), + 'utf-8', + ), + { name: `${folderName}/EXPORT_INFO.txt` }, + ); + const [headers, attachments] = await Promise.all([ getAutomationHeaders({ organizationId, taskId }), getTaskAttachments(organizationId, taskId), @@ -355,6 +390,21 @@ export class EvidenceExportService { options, } = params; + // Push the first ZIP byte out immediately so proxies see a live stream + // before the slow per-task loop begins. See populateTaskArchive note. + archive.append( + Buffer.from( + buildExportInfo({ + kind: 'organization', + organizationName, + organizationId, + taskCount: taskIds.length, + }), + 'utf-8', + ), + { name: `${orgFolder}/EXPORT_INFO.txt` }, + ); + const manifestEntries: Array<{ id: string; title: string; diff --git a/apps/api/src/trust-portal/is-trust-portal-configured.spec.ts b/apps/api/src/trust-portal/is-trust-portal-configured.spec.ts new file mode 100644 index 0000000000..b0fff76e5a --- /dev/null +++ b/apps/api/src/trust-portal/is-trust-portal-configured.spec.ts @@ -0,0 +1,45 @@ +import { isTrustPortalConfigured } from './is-trust-portal-configured'; + +const DEFAULTS = { + domain: null, + contactEmail: null, + overviewContent: null, + favicon: null, + faqs: null, + frameworkFlags: [false, false, false], + documentCount: 0, + resourceCount: 0, + customLinkCount: 0, +}; + +describe('isTrustPortalConfigured', () => { + it('returns false for a fresh portal on all defaults', () => { + expect(isTrustPortalConfigured(DEFAULTS)).toBe(false); + }); + + it('returns false when faqs is an empty array', () => { + expect(isTrustPortalConfigured({ ...DEFAULTS, faqs: [] })).toBe(false); + }); + + it.each([ + ['domain', { domain: 'trust.acme.com' }], + ['contactEmail', { contactEmail: 'security@acme.com' }], + ['overviewContent', { overviewContent: 'We are secure.' }], + ['favicon', { favicon: 'org/favicon.png' }], + ['faqs', { faqs: [{ question: 'q', answer: 'a', order: 0 }] }], + ['a framework flag', { frameworkFlags: [false, true, false] }], + ['a document', { documentCount: 1 }], + ['a compliance resource (certificate)', { resourceCount: 1 }], + ['a custom link', { customLinkCount: 1 }], + ])('returns true when %s is set', (_label, override) => { + expect(isTrustPortalConfigured({ ...DEFAULTS, ...override })).toBe(true); + }); + + it('ignores non-array faqs values', () => { + expect(isTrustPortalConfigured({ ...DEFAULTS, faqs: 'not-an-array' })).toBe(false); + }); + + it('returns false when frameworkFlags is an empty array', () => { + expect(isTrustPortalConfigured({ ...DEFAULTS, frameworkFlags: [] })).toBe(false); + }); +}); diff --git a/apps/api/src/trust-portal/is-trust-portal-configured.ts b/apps/api/src/trust-portal/is-trust-portal-configured.ts new file mode 100644 index 0000000000..cd5c06ccb7 --- /dev/null +++ b/apps/api/src/trust-portal/is-trust-portal-configured.ts @@ -0,0 +1,42 @@ +export interface TrustPortalConfiguredInput { + domain?: string | null; + contactEmail?: string | null; + overviewContent?: string | null; + favicon?: string | null; + /** Organization.trustPortalFaqs — Json?, expected to be an array when set. */ + faqs?: unknown; + /** + * Raw Trust framework "enabled" boolean columns (soc2, soc2type1, soc2type2, + * soc3, iso27001, iso42001, nen7510, gdpr, hipaa, pci_dss, iso9001, pipeda, + * ccpa). Order is irrelevant — any `true` counts as configured. The caller is + * responsible for passing all of them; a dropped column silently weakens the + * signal. Distinct from `resourceCount` (uploaded certificate files). + */ + frameworkFlags: boolean[]; + documentCount: number; + resourceCount: number; + customLinkCount: number; +} + +/** + * A Trust Portal is "configured" once the org has done anything beyond the + * shared-domain defaults. Used to decide whether to nudge the customer to set + * it up. Computed from RAW values (the settings endpoint substitutes a Context + * Hub default for overviewContent — do not pass the substituted value here). + */ +export function isTrustPortalConfigured(input: TrustPortalConfiguredInput): boolean { + const hasFaqs = Array.isArray(input.faqs) && input.faqs.length > 0; + const hasFramework = input.frameworkFlags.some(Boolean); + + return Boolean( + input.domain || + input.contactEmail || + input.overviewContent || + input.favicon || + hasFaqs || + hasFramework || + input.documentCount > 0 || + input.resourceCount > 0 || + input.customLinkCount > 0, + ); +} diff --git a/apps/api/src/trust-portal/trust-portal.service.ts b/apps/api/src/trust-portal/trust-portal.service.ts index f15e1a0dca..e89cb6b6da 100644 --- a/apps/api/src/trust-portal/trust-portal.service.ts +++ b/apps/api/src/trust-portal/trust-portal.service.ts @@ -36,6 +36,7 @@ import { TrustDocumentUrlResponseDto, UploadTrustDocumentDto, } from './dto/trust-document.dto'; +import { isTrustPortalConfigured } from './is-trust-portal-configured'; interface VercelDomainVerification { type: string; @@ -1533,8 +1534,42 @@ export class TrustPortalService { defaultOverviewContent = missionContext?.answer ?? null; } + const [trustDocumentCount, trustResourceCount, trustCustomLinkCount] = + await Promise.all([ + db.trustDocument.count({ where: { organizationId } }), + db.trustResource.count({ where: { organizationId } }), + db.trustCustomLink.count({ where: { organizationId } }), + ]); + + const isConfigured = isTrustPortalConfigured({ + domain: trust.domain, + contactEmail: trust.contactEmail, + overviewContent: trust.overviewContent, // raw column, not the Context fallback + favicon: trust.favicon, + faqs: org.trustPortalFaqs, + frameworkFlags: [ + trust.soc2, // legacy column; folded into soc2type2 in the response but still a "configured" signal + trust.soc2type1, + trust.soc2type2, + trust.soc3, + trust.iso27001, + trust.iso42001, + trust.nen7510, + trust.gdpr, + trust.hipaa, + trust.pci_dss, + trust.iso9001, + trust.pipeda, + trust.ccpa, + ], + documentCount: trustDocumentCount, + resourceCount: trustResourceCount, + customLinkCount: trustCustomLinkCount, + }); + return { enabled: trust.status === 'published', + isConfigured, friendlyUrl: trust.friendlyUrl, domain: trust.domain ?? '', domainVerified: trust.domainVerified ?? false, diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json index 64f86c6bd2..e369407cc6 100644 --- a/apps/api/tsconfig.build.json +++ b/apps/api/tsconfig.build.json @@ -1,4 +1,7 @@ { "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false + }, "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesBanner.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesCard.tsx similarity index 92% rename from apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesBanner.tsx rename to apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesCard.tsx index 772aecdfa9..484bf1d1b3 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesBanner.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/FrameworkUpdatesCard.tsx @@ -15,7 +15,12 @@ import { ChevronUp, Upgrade } from '@trycompai/design-system/icons'; import { useParams, useRouter } from 'next/navigation'; import { useState } from 'react'; -export function FrameworkUpdatesBanner() { +/** + * The framework-updates card, with NO outer layout wrapper — the host (e.g. the + * Overview nudge stack) owns width/spacing. Returns null when there are no + * updates, so it's safe to mount unconditionally. + */ +export function FrameworkUpdatesCard() { const { data: statuses } = useFrameworkUpdateStatuses(); const { hasPermission } = usePermissions(); const router = useRouter(); @@ -29,7 +34,6 @@ export function FrameworkUpdatesBanner() { const count = statuses.length; return ( -
@@ -88,6 +92,5 @@ export function FrameworkUpdatesBanner() {
-
); } diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx deleted file mode 100644 index 5537fbdb9d..0000000000 --- a/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client'; - -import { useApiSWR } from '@/hooks/use-api-swr'; -import { WarningAlt, Close } from '@trycompai/design-system/icons'; -import Link from 'next/link'; -import { useParams } from 'next/navigation'; -import { useState } from 'react'; - -interface PendingMember { - memberId: string; - name: string; -} - -interface PendingResponse { - members: PendingMember[]; -} - -export function OffboardingBanner() { - const params = useParams<{ orgId: string }>(); - const { data, error } = useApiSWR( - '/v1/offboarding-checklist/pending', - ); - const members = data?.data?.members ?? []; - const [dismissed, setDismissed] = useState(false); - - if (error || dismissed || members.length === 0) return null; - - const link = members.length === 1 - ? `/${params.orgId}/people/${members[0].memberId}?tab=offboarding` - : `/${params.orgId}/people`; - - return ( -
-
- - - - {members.length} employee{members.length !== 1 ? 's' : ''} - {' '} - require{members.length === 1 ? 's' : ''} offboarding completion - -
-
- - View details - - -
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx index 1cdd083538..ddfe0746f3 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx @@ -4,7 +4,6 @@ import { FrameworkEditorFramework, Policy, Task } from '@db'; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; import { ComplianceOverview } from './ComplianceOverview'; import { FrameworksOverview } from './FrameworksOverview'; -import { OffboardingBanner } from './OffboardingBanner'; import { ToDoOverview } from './ToDoOverview'; import { FrameworkInstanceWithComplianceScore } from './types'; @@ -71,7 +70,6 @@ export const Overview = ({ return (
-
0, + // The card keeps its own look and has no dismiss affordance, so ignore onDismiss. + render: () => , + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/NudgeCenter.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/NudgeCenter.tsx new file mode 100644 index 0000000000..cda2a0e3ff --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/NudgeCenter.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { ChevronDown, ChevronUp } from '@trycompai/design-system/icons'; +import type { ReactNode } from 'react'; + +const MAX_PEEK_LAYERS = 2; + +/** + * Groups multiple notification nudges. Collapsed, it renders the top nudge on a + * "pile" — a sliver of the cards behind it peeks out — with a toggle chip + * overlaid on the bottom edge to fan the whole stack open. Expanded, every + * nudge is shown in a vertical list. + */ +export function NudgeCenter({ + count, + expanded, + onToggle, + children, +}: { + count: number; + expanded: boolean; + onToggle: () => void; + children: ReactNode; +}) { + const peekLayers = Math.min(count - 1, MAX_PEEK_LAYERS); + + const toggle = (positionClass: string) => ( + + ); + + if (expanded) { + return ( +
+
{children}
+ {toggle('')} +
+ ); + } + + // Collapsed: top nudge on a pile, with the toggle chip overlaid on the + // bottom-center edge. Padding reserves room for the peeks + the chip overhang. + return ( +
+ {/* `isolate` keeps the stack's own stacking context so the peek layers + sit just behind the top card (not behind an ancestor background). */} +
+ {Array.from({ length: peekLayers }).map((_, i) => { + const depth = i + 1; + return ( +
+ ); + })} +
{children}
+ {toggle( + 'absolute bottom-0 left-1/2 z-20 -translate-x-1/2 translate-y-[65%]', + )} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/OffboardingNudge.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/OffboardingNudge.tsx new file mode 100644 index 0000000000..910cc070e3 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/OffboardingNudge.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useApiSWR } from '@/hooks/use-api-swr'; +import { Alert, AlertAction, AlertTitle, Button } from '@trycompai/design-system'; +import { ArrowRight, Close } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import type { NudgeState } from './types'; + +interface PendingMember { + memberId: string; + name: string; +} + +interface PendingResponse { + members: PendingMember[]; +} + +export function useOffboardingNudge(): NudgeState { + const { orgId } = useParams<{ orgId: string }>(); + const { data, error } = useApiSWR( + '/v1/offboarding-checklist/pending', + ); + const members = data?.data?.members ?? []; + + return { + id: 'offboarding', + priority: 10, + persistDismissal: false, + ready: data !== undefined || error !== undefined, + eligible: !error && members.length > 0, + render: (onDismiss) => ( + + ), + }; +} + +function OffboardingNudgeView({ + orgId, + members, + onDismiss, +}: { + orgId: string; + members: PendingMember[]; + onDismiss: () => void; +}) { + const link = + members.length === 1 + ? `/${orgId}/people/${members[0].memberId}?tab=offboarding` + : `/${orgId}/people`; + + return ( + + + + {`${members.length} employee${members.length !== 1 ? 's' : ''} require${ + members.length === 1 ? 's' : '' + } offboarding completion`} + + +
+ +
+ + + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.test.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.test.tsx new file mode 100644 index 0000000000..e540807db7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.test.tsx @@ -0,0 +1,196 @@ +import { mockHasPermission, setMockPermissions } from '@/test-utils/mocks/permissions'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/hooks/use-permissions', () => ({ + usePermissions: () => ({ permissions: {}, hasPermission: mockHasPermission }), +})); + +const mockUseApiSWR = vi.fn(); +vi.mock('@/hooks/use-api-swr', () => ({ + useApiSWR: () => mockUseApiSWR(), +})); + +const mockUseFrameworkUpdateStatuses = vi.fn(); +vi.mock('@/hooks/use-framework-update-statuses', () => ({ + useFrameworkUpdateStatuses: () => mockUseFrameworkUpdateStatuses(), +})); + +vi.mock('../components/FrameworkUpdatesCard', () => ({ + FrameworkUpdatesCard: () =>
framework updates available
, +})); + +vi.mock('next/navigation', () => ({ + useParams: () => ({ orgId: 'org_123' }), + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock('next/link', () => ({ + default: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})); + +vi.mock('@trycompai/design-system', () => ({ + Alert: ({ children }: any) =>
{children}
, + AlertAction: ({ children }: any) =>
{children}
, + AlertTitle: ({ children }: any) =>
{children}
, + AlertDescription: ({ children }: any) =>
{children}
, + Button: ({ children }: any) => {children}, +})); + +vi.mock('@trycompai/design-system/icons', () => ({ + Close: () => x, + WarningAlt: () => !, + ChevronDown: () => v, + ChevronUp: () => ^, + ArrowRight: () => , +})); + +import { OverviewNudges } from './OverviewNudges'; + +const TRUST_PERMS = { trust: ['read', 'update'] }; + +function setOffboarding(members: { memberId: string; name: string }[]) { + mockUseApiSWR.mockReturnValue({ data: { data: { members } }, error: undefined }); +} + +function setFrameworkUpdates(items: { frameworkInstanceId: string }[]) { + mockUseFrameworkUpdateStatuses.mockReturnValue({ data: items, error: undefined }); +} + +describe('OverviewNudges', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + setOffboarding([]); // default: no offboarding + setFrameworkUpdates([]); // default: no framework updates + setMockPermissions(TRUST_PERMS); + }); + + const server = (over?: Partial<{ isTrustNdaEnabled: boolean; isConfigured: boolean }>) => ({ + trust: { isTrustNdaEnabled: true, isConfigured: false, ...over }, + }); + + it('shows the trust nudge when enabled, not configured, and user can update', () => { + render(); + expect(screen.getByText('Set up your Trust Portal')).toBeInTheDocument(); + }); + + it('hides the trust nudge when already configured', () => { + render(); + expect(screen.queryByText('Set up your Trust Portal')).not.toBeInTheDocument(); + }); + + it('hides the trust nudge when the feature flag is off', () => { + render(); + expect(screen.queryByText('Set up your Trust Portal')).not.toBeInTheDocument(); + }); + + it('hides the trust nudge without trust:update', () => { + setMockPermissions({ trust: ['read'] }); + render(); + expect(screen.queryByText('Set up your Trust Portal')).not.toBeInTheDocument(); + }); + + it('collapses to the top nudge with a stack control when several apply', () => { + setOffboarding([{ memberId: 'm1', name: 'Jo' }]); + render(); + // Offboarding (priority 10) is shown; trust waits behind the stack. + expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); + expect(screen.queryByText('Set up your Trust Portal')).not.toBeInTheDocument(); + // The user is told more are waiting. + expect(screen.getByText('2 notices')).toBeInTheDocument(); + }); + + it('expands the stack to reveal every waiting nudge, then collapses', () => { + setOffboarding([{ memberId: 'm1', name: 'Jo' }]); + render(); + + fireEvent.click(screen.getByText('2 notices')); + expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); + expect(screen.getByText('Set up your Trust Portal')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Show less')); + expect(screen.queryByText('Set up your Trust Portal')).not.toBeInTheDocument(); + expect(screen.getByText('2 notices')).toBeInTheDocument(); + }); + + it('shows no stack control when only one nudge applies', () => { + render(); + expect(screen.getByText('Set up your Trust Portal')).toBeInTheDocument(); + expect(screen.queryByText(/\d+ notices/)).not.toBeInTheDocument(); + }); + + it('dismissing the trust nudge hides it and persists', () => { + const { unmount } = render(); + fireEvent.click(screen.getByLabelText('Dismiss')); + expect(screen.queryByText('Set up your Trust Portal')).not.toBeInTheDocument(); + unmount(); + render(); + expect(screen.queryByText('Set up your Trust Portal')).not.toBeInTheDocument(); + }); + + it('dismissing offboarding hides it for the session without persisting', () => { + setOffboarding([{ memberId: 'm1', name: 'Jo' }]); + // isConfigured: true so the trust nudge does not appear after offboarding is dismissed + const { unmount } = render( + , + ); + expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Dismiss')); + expect(screen.queryByText(/offboarding completion/)).not.toBeInTheDocument(); + expect(window.localStorage.getItem('overview-nudge-dismissed:offboarding:org_123')).toBeNull(); + + // Not persisted → reappears on remount. + unmount(); + render(); + expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); + }); + + it('renders nothing while offboarding is loading and trust is ineligible', () => { + mockUseApiSWR.mockReturnValue({ data: undefined, error: undefined }); // SWR loading + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows the framework updates nudge when it is the only one eligible', () => { + setFrameworkUpdates([{ frameworkInstanceId: 'fi_1' }]); + // isConfigured: true → trust off; no offboarding → framework is the only nudge. + render(); + expect(screen.getByText('framework updates available')).toBeInTheDocument(); + expect(screen.queryByText(/\d+ notices/)).not.toBeInTheDocument(); + }); + + it('orders framework updates last in the stack (offboarding, trust, framework)', () => { + setOffboarding([{ memberId: 'm1', name: 'Jo' }]); + setFrameworkUpdates([{ frameworkInstanceId: 'fi_1' }]); + render(); + + // Collapsed: only offboarding (priority 10) on top; the other two wait. + expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); + expect(screen.queryByText('framework updates available')).not.toBeInTheDocument(); + expect(screen.queryByText('Set up your Trust Portal')).not.toBeInTheDocument(); + expect(screen.getByText('3 notices')).toBeInTheDocument(); + + // Expanded: all three shown, framework updates rendered after the trust nudge. + fireEvent.click(screen.getByText('3 notices')); + expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); + const trustEl = screen.getByText('Set up your Trust Portal'); + const frameworkEl = screen.getByText('framework updates available'); + expect( + trustEl.compareDocumentPosition(frameworkEl) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('excludes framework updates from the count while its data is loading', () => { + setOffboarding([{ memberId: 'm1', name: 'Jo' }]); + mockUseFrameworkUpdateStatuses.mockReturnValue({ data: undefined, error: undefined }); + render(); + // offboarding + trust = 2; framework not ready, so it doesn't inflate the count. + expect(screen.getByText('2 notices')).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.tsx new file mode 100644 index 0000000000..d3eab5128a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useFrameworkUpdatesNudge } from './FrameworkUpdatesNudge'; +import { NudgeCenter } from './NudgeCenter'; +import { useOffboardingNudge } from './OffboardingNudge'; +import { useTrustPortalSetupNudge } from './TrustPortalSetupNudge'; +import type { NudgeState, ServerNudgeData } from './types'; + +const dismissKey = (id: string, orgId: string) => + `overview-nudge-dismissed:${id}:${orgId}`; + +export function OverviewNudges({ + orgId, + server, +}: { + orgId: string; + server: ServerNudgeData; +}) { + // Hooks called unconditionally, in stable priority order. + const offboarding = useOffboardingNudge(); + const frameworkUpdates = useFrameworkUpdatesNudge(); + const trust = useTrustPortalSetupNudge({ orgId, server }); + const candidates = [offboarding, frameworkUpdates, trust]; + + // Stable across renders unless a persistable nudge is added/removed. + const persistableIds = candidates + .filter((c) => c.persistDismissal) + .map((c) => c.id) + .join(','); + + const [dismissed, setDismissed] = useState>(new Set()); + const [expanded, setExpanded] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + const next = new Set(); + for (const id of persistableIds.split(',').filter(Boolean)) { + if (window.localStorage.getItem(dismissKey(id, orgId)) === '1') { + next.add(id); + } + } + setDismissed(next); + }, [orgId, persistableIds]); + + const visible = candidates + .filter((c) => c.ready && c.eligible && !dismissed.has(c.id)) + .sort((a, b) => a.priority - b.priority); + + // Collapse the tray whenever there's no longer more than one to fan out. + useEffect(() => { + if (visible.length <= 1 && expanded) setExpanded(false); + }, [visible.length, expanded]); + + if (!mounted || visible.length === 0) return null; + + const dismiss = (nudge: NudgeState) => () => { + if (nudge.persistDismissal) { + window.localStorage.setItem(dismissKey(nudge.id, orgId), '1'); + } + setDismissed((prev) => new Set(prev).add(nudge.id)); + }; + + const body = + visible.length === 1 ? ( + visible[0].render(dismiss(visible[0])) + ) : ( + setExpanded((prev) => !prev)} + > + {(expanded ? visible : visible.slice(0, 1)).map((nudge) => ( +
{nudge.render(dismiss(nudge))}
+ ))} +
+ ); + + // Match the page's centered content width so nudges align with everything else. + return
{body}
; +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx new file mode 100644 index 0000000000..50e7532734 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { usePermissions } from '@/hooks/use-permissions'; +import { Alert, AlertAction, AlertDescription, AlertTitle, Button } from '@trycompai/design-system'; +import { ArrowRight, Close } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import type { NudgeState, ServerNudgeData } from './types'; + +export function useTrustPortalSetupNudge({ + orgId, + server, +}: { + orgId: string; + server: ServerNudgeData; +}): NudgeState { + const { hasPermission } = usePermissions(); + const canSetup = hasPermission('trust', 'update'); + const { isTrustNdaEnabled, isConfigured } = server.trust; + + return { + id: 'trust-portal-setup', + priority: 20, + persistDismissal: true, + ready: true, // server data already resolved + eligible: isTrustNdaEnabled && !isConfigured && canSetup, + render: (onDismiss) => , + }; +} + +function TrustPortalSetupNudgeView({ orgId, onDismiss }: { orgId: string; onDismiss: () => void }) { + return ( + + + Set up your Trust Portal + + + + Customize and publish your Trust Portal so prospects and vendors can view your compliance + progress. + + +
+ +
+ + + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/types.ts b/apps/app/src/app/(app)/[orgId]/overview/nudges/types.ts new file mode 100644 index 0000000000..f3d4c92c3d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/types.ts @@ -0,0 +1,27 @@ +import type { ReactNode } from 'react'; + +/** Server-resolved data the Overview page passes into the nudge host. */ +export interface ServerNudgeData { + trust: { + isTrustNdaEnabled: boolean; + isConfigured: boolean; + }; +} + +/** + * One candidate nudge. The host picks the lowest-`priority` candidate that is + * `ready && eligible && !dismissed` and renders it — at most one at a time. + */ +export interface NudgeState { + id: string; + /** Lower number wins (shown first). */ + priority: number; + /** true → dismissal persists in localStorage; false → session-only. */ + persistDismissal: boolean; + /** false while underlying data is still loading. */ + ready: boolean; + /** Has something to show AND the user is allowed to act on it. */ + eligible: boolean; + /** Renders the nudge UI; called once for the single visible nudge. */ + render: (onDismiss: () => void) => ReactNode; +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/page.tsx b/apps/app/src/app/(app)/[orgId]/overview/page.tsx index 221477b79d..4a2dc4c64b 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/page.tsx @@ -1,9 +1,12 @@ +import { getFeatureFlags } from '@/app/posthog'; import { serverApi } from '@/lib/api-server'; +import { auth } from '@/utils/auth'; import type { FrameworkEditorFramework, Policy, Task } from '@db'; import { PageHeader, PageLayout } from '@trycompai/design-system'; -import { FrameworkUpdatesBanner } from './components/FrameworkUpdatesBanner'; +import { headers } from 'next/headers'; import { Overview } from './components/Overview'; import { OverviewTabs } from './components/OverviewTabs'; +import { OverviewNudges } from './nudges/OverviewNudges'; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; export async function generateMetadata() { @@ -34,16 +37,32 @@ interface ScoresResponse { export default async function OverviewPage({ params }: { params: Promise<{ orgId: string }> }) { const { orgId: organizationId } = await params; - const [scoresRes, frameworksRes, availableRes] = await Promise.all([ + const requestHeaders = await headers(); + const session = await auth.api.getSession({ headers: requestHeaders }); + + const [scoresRes, frameworksRes, availableRes, settingsRes] = await Promise.all([ serverApi.get('/v1/frameworks/scores'), serverApi.get<{ data: FrameworkWithScore[] }>('/v1/frameworks?includeControls=true&includeScores=true'), serverApi.get<{ data: FrameworkEditorFramework[] }>('/v1/frameworks/available'), + serverApi.get<{ isConfigured?: boolean }>('/v1/trust-portal/settings'), ]); const scores = scoresRes.data; const frameworksData = frameworksRes.data?.data ?? []; const allFrameworks = availableRes.data?.data ?? []; + let isTrustNdaEnabled = false; + if (session?.user?.id) { + const flags = await getFeatureFlags(session.user.id, { + groups: { organization: organizationId }, + }); + isTrustNdaEnabled = + flags['is-trust-nda-enabled'] === true || flags['is-trust-nda-enabled'] === 'true'; + } + + // Fail closed: if we can't determine state, don't nudge. + const isTrustConfigured = settingsRes.data?.isConfigured ?? true; + const frameworksWithControls = frameworksData.map( ({ complianceScore: _score, ...fw }: FrameworkWithScore) => fw, ); @@ -54,7 +73,10 @@ export default async function OverviewPage({ params }: { params: Promise<{ orgId return ( <> - + } />}> (savedOrgId); + const [saving, setSaving] = useState(false); + + // Only relevant for users who belong to more than one organization — + // single-org users always act on their one org automatically. + if (organizations.length <= 1) { + return null; + } + + const handleSave = async () => { + if (!selectedOrgId) { + toast.error('Select an organization first.'); + return; + } + setSaving(true); + try { + await saveOrganization(selectedOrgId); + toast.success('AI / MCP organization updated.'); + } catch { + toast.error('Failed to update organization. Please try again.'); + } finally { + setSaving(false); + } + }; + + return ( +
+ {saving ? 'Saving…' : 'Save'} + + } + > +
+ {!savedOrgId ? ( + + + Pick an organization to start using your AI assistant. Until you + choose one, AI / MCP requests can't act on your data. + + + ) : null} +
+ +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/settings/user/hooks/useMcpOrganization.ts b/apps/app/src/app/(app)/[orgId]/settings/user/hooks/useMcpOrganization.ts new file mode 100644 index 0000000000..7842224fb0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/settings/user/hooks/useMcpOrganization.ts @@ -0,0 +1,64 @@ +'use client'; + +import { apiClient } from '@/lib/api-client'; +import useSWR from 'swr'; + +export interface McpOrganizationData { + organizations: Array<{ id: string; name: string }>; + selectedOrganizationId: string | null; +} + +export const mcpOrganizationKey = () => ['/v1/mcp/organization'] as const; + +interface UseMcpOrganizationOptions { + initialData?: McpOrganizationData; +} + +export function useMcpOrganization(options?: UseMcpOrganizationOptions) { + const { initialData } = options ?? {}; + + const { data, error, isLoading, mutate } = useSWR( + mcpOrganizationKey(), + async () => { + const response = await apiClient.get( + '/v1/mcp/organization', + ); + if (response.error) throw new Error(response.error); + return response.data ?? null; + }, + { + fallbackData: initialData, + revalidateOnMount: !initialData, + revalidateOnFocus: false, + }, + ); + + const saveOrganization = async (organizationId: string) => { + const previous = data ?? initialData ?? null; + // Optimistic update (guard against undefined per SWR safety). + if (previous) { + await mutate({ ...previous, selectedOrganizationId: organizationId }, false); + } + + try { + const response = await apiClient.put('/v1/mcp/organization', { + organizationId, + }); + if (response.error) throw new Error(response.error); + await mutate(); + } catch (err) { + // Revalidate from the server rather than restoring a possibly-stale + // snapshot (another tab/request may have changed the selection). + await mutate(); + throw err; + } + }; + + return { + data: data ?? initialData ?? null, + isLoading: isLoading && !data, + error, + mutate, + saveOrganization, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/settings/user/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/user/page.tsx index e8dc3ec5f9..dd8cf4431f 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/user/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/user/page.tsx @@ -1,6 +1,8 @@ import { serverApi } from '@/lib/api-server'; import type { Metadata } from 'next'; import { EmailNotificationPreferences } from './components/EmailNotificationPreferences'; +import { McpOrganizationSelector } from './components/McpOrganizationSelector'; +import type { McpOrganizationData } from './hooks/useMcpOrganization'; export default async function UserSettings({ params, @@ -9,38 +11,46 @@ export default async function UserSettings({ }) { const { orgId } = await params; - const res = await serverApi.get<{ - email: string; - preferences: { - policyNotifications: boolean; - taskReminders: boolean; - weeklyTaskDigest: boolean; - unassignedItemsNotifications: boolean; - taskMentions: boolean; - taskAssignments: boolean; - }; - isAdminOrOwner: boolean; - roleNotifications: { - policyNotifications: boolean; - taskReminders: boolean; - taskAssignments: boolean; - taskMentions: boolean; - weeklyTaskDigest: boolean; - findingNotifications: boolean; - } | null; - }>('/v1/people/me/email-preferences'); + const [emailRes, mcpRes] = await Promise.all([ + serverApi.get<{ + email: string; + preferences: { + policyNotifications: boolean; + taskReminders: boolean; + weeklyTaskDigest: boolean; + unassignedItemsNotifications: boolean; + taskMentions: boolean; + taskAssignments: boolean; + }; + isAdminOrOwner: boolean; + roleNotifications: { + policyNotifications: boolean; + taskReminders: boolean; + taskAssignments: boolean; + taskMentions: boolean; + weeklyTaskDigest: boolean; + findingNotifications: boolean; + } | null; + }>('/v1/people/me/email-preferences'), + serverApi.get('/v1/mcp/organization'), + ]); - if (!res.data?.email) { + if (!emailRes.data?.email) { return null; } return ( - +
+ + {mcpRes.data ? ( + + ) : null} +
); } diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.test.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.test.tsx new file mode 100644 index 0000000000..1205920628 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('next/link', () => ({ + default: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})); + +vi.mock('@trycompai/design-system', () => ({ + Alert: ({ children }: any) =>
{children}
, + AlertTitle: ({ children }: any) =>
{children}
, + AlertDescription: ({ children }: any) =>
{children}
, +})); + +import { TrustPortalGettingStarted } from './TrustPortalGettingStarted'; + +describe('TrustPortalGettingStarted', () => { + it('renders the live shared portal URL', () => { + render(); + expect(screen.getByText(/trust.inc\/org_123/)).toBeInTheDocument(); + }); + + it('renders the getting-started heading', () => { + render(); + expect( + screen.getByText(/finish setting up your trust portal/i), + ).toBeInTheDocument(); + }); + + it('renders the setup steps', () => { + render(); + expect(screen.getByText(/frameworks you/i)).toBeInTheDocument(); + expect(screen.getByText(/published policies show automatically/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.tsx b/apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.tsx new file mode 100644 index 0000000000..f8e5fcd65f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.tsx @@ -0,0 +1,34 @@ +import { Alert, AlertDescription, AlertTitle } from '@trycompai/design-system'; +import Link from 'next/link'; + +const STEPS = [ + 'Enable the frameworks you’re working toward to show prospects and vendors your compliance progress — no certificate needed yet.', + 'Your published policies show automatically — drafts and in-progress updates stay private.', + 'Add a custom domain and contact email to make it your own.', +]; + +export function TrustPortalGettingStarted({ portalUrl }: { portalUrl: string }) { + return ( + + + Finish setting up your Trust Portal + + + + Your Trust Portal is at{' '} + + {portalUrl} + + . Put it to work so prospects and vendors can see where you stand: + + + {/* variant="info" renders an icon, so Alert is a 2-col grid; place the + list in the text column like the title/description slots above. */} +
    + {STEPS.map((step) => ( +
  • {step}
  • + ))} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/trust/page.tsx b/apps/app/src/app/(app)/[orgId]/trust/page.tsx index b980b263c3..2dac763620 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/page.tsx @@ -3,6 +3,7 @@ import { Button, PageHeader, PageLayout } from '@trycompai/design-system'; import { Launch } from '@trycompai/design-system/icons'; import type { Metadata } from 'next'; import Link from 'next/link'; +import { TrustPortalGettingStarted } from './components/TrustPortalGettingStarted'; import { TrustPortalSwitch } from './portal-settings/components/TrustPortalSwitch'; export default async function TrustPage({ @@ -26,6 +27,7 @@ export default async function TrustPage({ ]); const settings = settingsRes.data as any; + const isTrustConfigured = settings?.isConfigured ?? true; const customLinks = Array.isArray(customLinksRes.data) ? customLinksRes.data : []; @@ -96,6 +98,7 @@ export default async function TrustPage({ /> } > + {!isTrustConfigured && } 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.0.1/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.0.2/mcp-server.mcpb) file: -Simply drag and drop the [`mcp-server.mcpb`](https://github.com/trycompai/comp/releases/download/v0.0.1/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.0.2/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 5628111405..24a374a976 100644 --- a/apps/mcp-server/RELEASES.md +++ b/apps/mcp-server/RELEASES.md @@ -6,4 +6,12 @@ Based on: - OpenAPI Doc - Speakeasy CLI 1.768.2 (2.889.1) https://github.com/speakeasy-api/speakeasy ### Generated -- [mcp-typescript v0.0.1] apps/mcp-server \ No newline at end of file +- [mcp-typescript v0.0.1] apps/mcp-server + +## 2026-05-29 01:16:45 +### Changes +Based on: +- OpenAPI Doc +- Speakeasy CLI 1.768.2 (2.889.1) https://github.com/speakeasy-api/speakeasy +### Generated +- [mcp-typescript v0.0.2] apps/mcp-server \ No newline at end of file diff --git a/apps/mcp-server/manifest.json b/apps/mcp-server/manifest.json index f817bd1d5b..59e02e90e9 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.0.1", + "version": "0.0.2", "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": { @@ -1322,63 +1322,63 @@ }, { "name": "get-pending-offboardings", - "description": "Get members with pending offboarding checklists\n\nGet members with pending offboarding checklists in Comp AI." + "description": "Get members with pending offboarding checklists\n\nLists members whose offboarding checklist is still incomplete, with their outstanding items, so you can track and finish departing-employee offboarding." }, { "name": "get-template", - "description": "" + "description": "Get the offboarding checklist template\n\nReturns the organization's offboarding checklist template: the ordered set of items every departing member must complete during their offboarding." }, { "name": "create-template-item", - "description": "" + "description": "Add an offboarding checklist template item\n\nCreates a new item in the organization's offboarding checklist template so it appears on every member's offboarding checklist from now on." }, { "name": "update-template-item", - "description": "" + "description": "Update an offboarding checklist template item\n\nUpdates an existing offboarding checklist template item by id, changing its label, description, or settings on the organization's offboarding template." }, { "name": "delete-template-item", - "description": "" + "description": "Delete an offboarding checklist template item\n\nRemoves an item from the organization's offboarding checklist template by id so it no longer appears on members' offboarding checklists." }, { "name": "get-member-checklist", - "description": "" + "description": "Get a member's offboarding checklist\n\nReturns the offboarding checklist for a specific member, including each item and whether it has been completed, to track that person's offboarding progress." }, { "name": "offboarding-checklist-export-all-evidence", - "description": "Export all offboarding evidence as a zip file\n\nExport all offboarding evidence as a zip file in Comp AI." + "description": "Export all offboarding evidence as a zip file\n\nExports a zip archive containing the offboarding checklist evidence for every member in the organization, for audits, handovers, or record-keeping." }, { "name": "export-evidence", - "description": "Export offboarding evidence as a zip file\n\nExport offboarding evidence as a zip file in Comp AI." + "description": "Export offboarding evidence as a zip file\n\nExports a zip archive of the offboarding checklist evidence collected for a single member, for audit, handover, or record-keeping purposes." }, { "name": "complete-item", - "description": "" + "description": "Complete an offboarding checklist item\n\nMarks a specific offboarding checklist item complete for a member, recording who completed it and when, as part of finishing that member's offboarding." }, { "name": "uncomplete-item", - "description": "" + "description": "Reopen an offboarding checklist item\n\nReverts a previously completed offboarding checklist item back to incomplete for a member, in case the step was marked done by mistake." }, { "name": "upload-evidence", - "description": "" + "description": "Upload evidence for an offboarding checklist item\n\nAttaches a supporting evidence file to a member's completed offboarding checklist item, documenting that the offboarding step was actually carried out." }, { "name": "get-access-revocations", - "description": "Get vendor access revocation status for a member\n\nGet vendor access revocation status for a member in Comp AI." + "description": "Get vendor access revocation status for a member\n\nLists the vendors a departing member had access to and whether each has been revoked, so you can confirm all vendor access is removed during offboarding." }, { "name": "revoke-all-vendor-access", - "description": "Confirm all vendor access as revoked\n\nConfirm all vendor access as revoked in Comp AI." + "description": "Confirm all vendor access as revoked\n\nMarks every vendor access record for a departing member as revoked in one step, recording who confirmed it, to complete access removal during offboarding." }, { "name": "revoke-vendor-access", - "description": "Mark vendor access as revoked\n\nMark vendor access as revoked in Comp AI." + "description": "Mark vendor access as revoked\n\nMarks a single vendor's access for a departing member as revoked, optionally attaching evidence and notes, as part of offboarding access removal." }, { "name": "undo-vendor-revocation", - "description": "Undo vendor access revocation\n\nUndo vendor access revocation in Comp AI." + "description": "Undo vendor access revocation\n\nReverses a vendor access revocation for a member, marking that vendor's access as not revoked again, in case it was confirmed by mistake during offboarding." } ] } \ No newline at end of file diff --git a/apps/mcp-server/package-lock.json b/apps/mcp-server/package-lock.json index 8dd75a1736..879c09bb9a 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.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trycompai/mcp-server", - "version": "0.0.1", + "version": "0.0.2", "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 71a260c59e..d0ea244896 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@trycompai/mcp-server", - "version": "0.0.1", + "version": "0.0.2", "author": "Comp AI", "type": "module", "sideEffects": false, diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerCompleteItemV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerCompleteItemV1.ts index 67db73a2cf..8102063b59 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerCompleteItemV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerCompleteItemV1.ts @@ -26,6 +26,11 @@ import { APICall, APIPromise } from "../types/async.js"; import { Result } from "../types/fp.js"; /** + * Complete an offboarding checklist item + * + * @remarks + * Marks a specific offboarding checklist item complete for a member, recording who completed it and when, as part of finishing that member's offboarding. + * * If set, this operation will use {@link Security.apikey} from the global security. */ export function offboardingChecklistOffboardingChecklistControllerCompleteItemV1( diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerCreateTemplateItemV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerCreateTemplateItemV1.ts index 6b0dd312fc..c7bd5691a1 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerCreateTemplateItemV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerCreateTemplateItemV1.ts @@ -26,6 +26,11 @@ import { APICall, APIPromise } from "../types/async.js"; import { Result } from "../types/fp.js"; /** + * Add an offboarding checklist template item + * + * @remarks + * Creates a new item in the organization's offboarding checklist template so it appears on every member's offboarding checklist from now on. + * * If set, this operation will use {@link Security.apikey} from the global security. */ export function offboardingChecklistOffboardingChecklistControllerCreateTemplateItemV1( diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerDeleteTemplateItemV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerDeleteTemplateItemV1.ts index a3d999c523..05ace31b2e 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerDeleteTemplateItemV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerDeleteTemplateItemV1.ts @@ -26,6 +26,11 @@ import { APICall, APIPromise } from "../types/async.js"; import { Result } from "../types/fp.js"; /** + * Delete an offboarding checklist template item + * + * @remarks + * Removes an item from the organization's offboarding checklist template by id so it no longer appears on members' offboarding checklists. + * * If set, this operation will use {@link Security.apikey} from the global security. */ export function offboardingChecklistOffboardingChecklistControllerDeleteTemplateItemV1( diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerExportAllEvidenceV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerExportAllEvidenceV1.ts index ef577d0101..6003c47f41 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerExportAllEvidenceV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerExportAllEvidenceV1.ts @@ -23,7 +23,7 @@ import { Result } from "../types/fp.js"; * Export all offboarding evidence as a zip file * * @remarks - * Export all offboarding evidence as a zip file in Comp AI. + * Exports a zip archive containing the offboarding checklist evidence for every member in the organization, for audits, handovers, or record-keeping. * * If set, this operation will use {@link Security.apikey} from the global security. */ diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerExportEvidenceV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerExportEvidenceV1.ts index 7c9113b062..bf2e4fff14 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerExportEvidenceV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerExportEvidenceV1.ts @@ -29,7 +29,7 @@ import { Result } from "../types/fp.js"; * Export offboarding evidence as a zip file * * @remarks - * Export offboarding evidence as a zip file in Comp AI. + * Exports a zip archive of the offboarding checklist evidence collected for a single member, for audit, handover, or record-keeping purposes. * * If set, this operation will use {@link Security.apikey} from the global security. */ diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetAccessRevocationsV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetAccessRevocationsV1.ts index e68175e7ce..a0459b0a7c 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetAccessRevocationsV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetAccessRevocationsV1.ts @@ -29,7 +29,7 @@ import { Result } from "../types/fp.js"; * Get vendor access revocation status for a member * * @remarks - * Get vendor access revocation status for a member in Comp AI. + * Lists the vendors a departing member had access to and whether each has been revoked, so you can confirm all vendor access is removed during offboarding. * * If set, this operation will use {@link Security.apikey} from the global security. */ diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetMemberChecklistV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetMemberChecklistV1.ts index ab91ca2d08..f22d020b57 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetMemberChecklistV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetMemberChecklistV1.ts @@ -26,6 +26,11 @@ import { APICall, APIPromise } from "../types/async.js"; import { Result } from "../types/fp.js"; /** + * Get a member's offboarding checklist + * + * @remarks + * Returns the offboarding checklist for a specific member, including each item and whether it has been completed, to track that person's offboarding progress. + * * If set, this operation will use {@link Security.apikey} from the global security. */ export function offboardingChecklistOffboardingChecklistControllerGetMemberChecklistV1( diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetPendingOffboardingsV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetPendingOffboardingsV1.ts index bc70b083f2..d6b832efb1 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetPendingOffboardingsV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetPendingOffboardingsV1.ts @@ -23,7 +23,7 @@ import { Result } from "../types/fp.js"; * Get members with pending offboarding checklists * * @remarks - * Get members with pending offboarding checklists in Comp AI. + * Lists members whose offboarding checklist is still incomplete, with their outstanding items, so you can track and finish departing-employee offboarding. * * If set, this operation will use {@link Security.apikey} from the global security. */ diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetTemplateV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetTemplateV1.ts index acb33aeacd..c868c96f2b 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetTemplateV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerGetTemplateV1.ts @@ -20,6 +20,11 @@ import { APICall, APIPromise } from "../types/async.js"; import { Result } from "../types/fp.js"; /** + * Get the offboarding checklist template + * + * @remarks + * Returns the organization's offboarding checklist template: the ordered set of items every departing member must complete during their offboarding. + * * If set, this operation will use {@link Security.apikey} from the global security. */ export function offboardingChecklistOffboardingChecklistControllerGetTemplateV1( diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerRevokeAllVendorAccessV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerRevokeAllVendorAccessV1.ts index ea003f2faf..f6e270a399 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerRevokeAllVendorAccessV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerRevokeAllVendorAccessV1.ts @@ -29,7 +29,7 @@ import { Result } from "../types/fp.js"; * Confirm all vendor access as revoked * * @remarks - * Confirm all vendor access as revoked in Comp AI. + * Marks every vendor access record for a departing member as revoked in one step, recording who confirmed it, to complete access removal during offboarding. * * If set, this operation will use {@link Security.apikey} from the global security. */ diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerRevokeVendorAccessV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerRevokeVendorAccessV1.ts index dfe5cbc720..f22502134c 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerRevokeVendorAccessV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerRevokeVendorAccessV1.ts @@ -29,7 +29,7 @@ import { Result } from "../types/fp.js"; * Mark vendor access as revoked * * @remarks - * Mark vendor access as revoked in Comp AI. + * Marks a single vendor's access for a departing member as revoked, optionally attaching evidence and notes, as part of offboarding access removal. * * If set, this operation will use {@link Security.apikey} from the global security. */ diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUncompleteItemV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUncompleteItemV1.ts index 6eac0cf6f9..74100e4dc2 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUncompleteItemV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUncompleteItemV1.ts @@ -26,6 +26,11 @@ import { APICall, APIPromise } from "../types/async.js"; import { Result } from "../types/fp.js"; /** + * Reopen an offboarding checklist item + * + * @remarks + * Reverts a previously completed offboarding checklist item back to incomplete for a member, in case the step was marked done by mistake. + * * If set, this operation will use {@link Security.apikey} from the global security. */ export function offboardingChecklistOffboardingChecklistControllerUncompleteItemV1( diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUndoVendorRevocationV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUndoVendorRevocationV1.ts index 1d771bfb97..d62155e3d0 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUndoVendorRevocationV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUndoVendorRevocationV1.ts @@ -29,7 +29,7 @@ import { Result } from "../types/fp.js"; * Undo vendor access revocation * * @remarks - * Undo vendor access revocation in Comp AI. + * Reverses a vendor access revocation for a member, marking that vendor's access as not revoked again, in case it was confirmed by mistake during offboarding. * * If set, this operation will use {@link Security.apikey} from the global security. */ diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUpdateTemplateItemV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUpdateTemplateItemV1.ts index 1e231d8e4b..d416545efd 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUpdateTemplateItemV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUpdateTemplateItemV1.ts @@ -26,6 +26,11 @@ import { APICall, APIPromise } from "../types/async.js"; import { Result } from "../types/fp.js"; /** + * Update an offboarding checklist template item + * + * @remarks + * Updates an existing offboarding checklist template item by id, changing its label, description, or settings on the organization's offboarding template. + * * If set, this operation will use {@link Security.apikey} from the global security. */ export function offboardingChecklistOffboardingChecklistControllerUpdateTemplateItemV1( diff --git a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUploadEvidenceV1.ts b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUploadEvidenceV1.ts index 08af3f0891..45808dd152 100644 --- a/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUploadEvidenceV1.ts +++ b/apps/mcp-server/src/funcs/offboardingChecklistOffboardingChecklistControllerUploadEvidenceV1.ts @@ -26,6 +26,11 @@ import { APICall, APIPromise } from "../types/async.js"; import { Result } from "../types/fp.js"; /** + * Upload evidence for an offboarding checklist item + * + * @remarks + * Attaches a supporting evidence file to a member's completed offboarding checklist item, documenting that the offboarding step was actually carried out. + * * If set, this operation will use {@link Security.apikey} from the global security. */ export function offboardingChecklistOffboardingChecklistControllerUploadEvidenceV1( diff --git a/apps/mcp-server/src/hooks/hooks.ts b/apps/mcp-server/src/hooks/hooks.ts index a4220b5e2f..7709c66d85 100644 --- a/apps/mcp-server/src/hooks/hooks.ts +++ b/apps/mcp-server/src/hooks/hooks.ts @@ -2,6 +2,7 @@ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. */ +import { initHooks } from "./registration.js"; import { AfterErrorContext, AfterErrorHook, @@ -46,6 +47,7 @@ export class SDKHooks implements Hooks { this.registerAfterErrorHook(hook); } } + initHooks(this); } registerSDKInitHook(hook: SDKInitHook) { diff --git a/apps/mcp-server/src/hooks/registration.ts b/apps/mcp-server/src/hooks/registration.ts new file mode 100644 index 0000000000..70649734e8 --- /dev/null +++ b/apps/mcp-server/src/hooks/registration.ts @@ -0,0 +1,14 @@ +import { Hooks } from "./types.js"; + +/* + * This file is only ever generated once on the first generation and then is free to be modified. + * Any hooks you wish to add should be registered in the initHooks function. Feel free to define them + * in this file or in separate files in the hooks folder. + */ + +// @ts-expect-error remove this line when you add your first hook and hooks is used +export function initHooks(hooks: Hooks) { + // Add hooks by calling hooks.register{ClientInit/BeforeCreateRequest/BeforeRequest/AfterSuccess/AfterError}Hook + // with an instance of a hook that implements that specific Hook interface + // Hooks are registered per SDK instance, and are valid for the lifetime of the SDK instance +} diff --git a/apps/mcp-server/src/landing-page.ts b/apps/mcp-server/src/landing-page.ts index 1546d49129..c1835e9b05 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 a5273b8d55..447fe5655b 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.0.1", + sdkVersion: "0.0.2", genVersion: "2.889.1", userAgent: - "speakeasy-sdk/mcp-typescript 0.0.1 2.889.1 1.0 @trycompai/mcp-server", + "speakeasy-sdk/mcp-typescript 0.0.2 2.889.1 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 5cbc4d8256..a97bfc6bd1 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.0.1", + currentVersion: "0.0.2", }, }); diff --git a/apps/mcp-server/src/mcp-server/server.ts b/apps/mcp-server/src/mcp-server/server.ts index 6eb7f4371b..892d8599de 100644 --- a/apps/mcp-server/src/mcp-server/server.ts +++ b/apps/mcp-server/src/mcp-server/server.ts @@ -366,7 +366,7 @@ export function createMCPServer(deps: { }) { const server = new McpServer({ name: "CompAi", - version: "0.0.1", + version: "0.0.2", }); const getClient = deps.getSDK || (() => diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerCompleteItemV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerCompleteItemV1.ts index 7d594b0f04..d92ca3d779 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerCompleteItemV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerCompleteItemV1.ts @@ -13,7 +13,9 @@ const args = { export const tool$offboardingChecklistOffboardingChecklistControllerCompleteItemV1: ToolDefinition = { name: "complete-item", - description: ``, + description: `Complete an offboarding checklist item + +Marks a specific offboarding checklist item complete for a member, recording who completed it and when, as part of finishing that member's offboarding.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerCreateTemplateItemV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerCreateTemplateItemV1.ts index e6db09378e..50f3b3546a 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerCreateTemplateItemV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerCreateTemplateItemV1.ts @@ -13,7 +13,9 @@ const args = { export const tool$offboardingChecklistOffboardingChecklistControllerCreateTemplateItemV1: ToolDefinition = { name: "create-template-item", - description: ``, + description: `Add an offboarding checklist template item + +Creates a new item in the organization's offboarding checklist template so it appears on every member's offboarding checklist from now on.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerDeleteTemplateItemV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerDeleteTemplateItemV1.ts index 07f0e1d754..2a8ca445f2 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerDeleteTemplateItemV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerDeleteTemplateItemV1.ts @@ -13,7 +13,9 @@ const args = { export const tool$offboardingChecklistOffboardingChecklistControllerDeleteTemplateItemV1: ToolDefinition = { name: "delete-template-item", - description: ``, + description: `Delete an offboarding checklist template item + +Removes an item from the organization's offboarding checklist template by id so it no longer appears on members' offboarding checklists.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerExportAllEvidenceV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerExportAllEvidenceV1.ts index 704be88936..db107ec0dd 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerExportAllEvidenceV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerExportAllEvidenceV1.ts @@ -10,7 +10,7 @@ export const tool$offboardingChecklistOffboardingChecklistControllerExportAllEvi name: "offboarding-checklist-export-all-evidence", description: `Export all offboarding evidence as a zip file -Export all offboarding evidence as a zip file in Comp AI.`, +Exports a zip archive containing the offboarding checklist evidence for every member in the organization, for audits, handovers, or record-keeping.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerExportEvidenceV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerExportEvidenceV1.ts index e7b274a532..387f72c640 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerExportEvidenceV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerExportEvidenceV1.ts @@ -15,7 +15,7 @@ export const tool$offboardingChecklistOffboardingChecklistControllerExportEviden name: "export-evidence", description: `Export offboarding evidence as a zip file -Export offboarding evidence as a zip file in Comp AI.`, +Exports a zip archive of the offboarding checklist evidence collected for a single member, for audit, handover, or record-keeping purposes.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetAccessRevocationsV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetAccessRevocationsV1.ts index ec593e797d..5857c318b8 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetAccessRevocationsV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetAccessRevocationsV1.ts @@ -16,7 +16,7 @@ export const tool$offboardingChecklistOffboardingChecklistControllerGetAccessRev name: "get-access-revocations", description: `Get vendor access revocation status for a member -Get vendor access revocation status for a member in Comp AI.`, +Lists the vendors a departing member had access to and whether each has been revoked, so you can confirm all vendor access is removed during offboarding.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetMemberChecklistV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetMemberChecklistV1.ts index 2d76e815a2..b97797c044 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetMemberChecklistV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetMemberChecklistV1.ts @@ -13,7 +13,9 @@ const args = { export const tool$offboardingChecklistOffboardingChecklistControllerGetMemberChecklistV1: ToolDefinition = { name: "get-member-checklist", - description: ``, + description: `Get a member's offboarding checklist + +Returns the offboarding checklist for a specific member, including each item and whether it has been completed, to track that person's offboarding progress.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetPendingOffboardingsV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetPendingOffboardingsV1.ts index 3c9a5869d9..ceed27ad3a 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetPendingOffboardingsV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetPendingOffboardingsV1.ts @@ -10,7 +10,7 @@ export const tool$offboardingChecklistOffboardingChecklistControllerGetPendingOf name: "get-pending-offboardings", description: `Get members with pending offboarding checklists -Get members with pending offboarding checklists in Comp AI.`, +Lists members whose offboarding checklist is still incomplete, with their outstanding items, so you can track and finish departing-employee offboarding.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetTemplateV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetTemplateV1.ts index 9e9ca38574..34fb6a9290 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetTemplateV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetTemplateV1.ts @@ -8,7 +8,9 @@ import { formatResult, ToolDefinition } from "../tools.js"; export const tool$offboardingChecklistOffboardingChecklistControllerGetTemplateV1: ToolDefinition = { name: "get-template", - description: ``, + description: `Get the offboarding checklist template + +Returns the organization's offboarding checklist template: the ordered set of items every departing member must complete during their offboarding.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerRevokeAllVendorAccessV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerRevokeAllVendorAccessV1.ts index 0185052e4b..6ead278774 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerRevokeAllVendorAccessV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerRevokeAllVendorAccessV1.ts @@ -16,7 +16,7 @@ export const tool$offboardingChecklistOffboardingChecklistControllerRevokeAllVen name: "revoke-all-vendor-access", description: `Confirm all vendor access as revoked -Confirm all vendor access as revoked in Comp AI.`, +Marks every vendor access record for a departing member as revoked in one step, recording who confirmed it, to complete access removal during offboarding.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerRevokeVendorAccessV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerRevokeVendorAccessV1.ts index 529d1e7c89..bbb00d734a 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerRevokeVendorAccessV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerRevokeVendorAccessV1.ts @@ -15,7 +15,7 @@ export const tool$offboardingChecklistOffboardingChecklistControllerRevokeVendor name: "revoke-vendor-access", description: `Mark vendor access as revoked -Mark vendor access as revoked in Comp AI.`, +Marks a single vendor's access for a departing member as revoked, optionally attaching evidence and notes, as part of offboarding access removal.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUncompleteItemV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUncompleteItemV1.ts index eef6ba2d91..8e438e7f5c 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUncompleteItemV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUncompleteItemV1.ts @@ -13,7 +13,9 @@ const args = { export const tool$offboardingChecklistOffboardingChecklistControllerUncompleteItemV1: ToolDefinition = { name: "uncomplete-item", - description: ``, + description: `Reopen an offboarding checklist item + +Reverts a previously completed offboarding checklist item back to incomplete for a member, in case the step was marked done by mistake.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUndoVendorRevocationV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUndoVendorRevocationV1.ts index 88020d62b1..d07d74b1c4 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUndoVendorRevocationV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUndoVendorRevocationV1.ts @@ -16,7 +16,7 @@ export const tool$offboardingChecklistOffboardingChecklistControllerUndoVendorRe name: "undo-vendor-revocation", description: `Undo vendor access revocation -Undo vendor access revocation in Comp AI.`, +Reverses a vendor access revocation for a member, marking that vendor's access as not revoked again, in case it was confirmed by mistake during offboarding.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUpdateTemplateItemV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUpdateTemplateItemV1.ts index 9016851388..aa0e9aded7 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUpdateTemplateItemV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUpdateTemplateItemV1.ts @@ -13,7 +13,9 @@ const args = { export const tool$offboardingChecklistOffboardingChecklistControllerUpdateTemplateItemV1: ToolDefinition = { name: "update-template-item", - description: ``, + description: `Update an offboarding checklist template item + +Updates an existing offboarding checklist template item by id, changing its label, description, or settings on the organization's offboarding template.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUploadEvidenceV1.ts b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUploadEvidenceV1.ts index 0140406d9f..7859c23ebe 100644 --- a/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUploadEvidenceV1.ts +++ b/apps/mcp-server/src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUploadEvidenceV1.ts @@ -13,7 +13,9 @@ const args = { export const tool$offboardingChecklistOffboardingChecklistControllerUploadEvidenceV1: ToolDefinition = { name: "upload-evidence", - description: ``, + description: `Upload evidence for an offboarding checklist item + +Attaches a supporting evidence file to a member's completed offboarding checklist item, documenting that the offboarding step was actually carried out.`, annotations: { "title": "", "destructiveHint": false, diff --git a/apps/mcp-server/src/models/controlscontrollerunlinkdocumenttypev1op.ts b/apps/mcp-server/src/models/controlscontrollerunlinkdocumenttypev1op.ts index 06691eddbc..c582816881 100644 --- a/apps/mcp-server/src/models/controlscontrollerunlinkdocumenttypev1op.ts +++ b/apps/mcp-server/src/models/controlscontrollerunlinkdocumenttypev1op.ts @@ -53,8 +53,8 @@ export const ControlsControllerUnlinkDocumentTypeV1FormType$zodSchema = z.enum([ export type ControlsControllerUnlinkDocumentTypeV1Request = { id: string; - formType: ControlsControllerUnlinkDocumentTypeV1FormType; frameworkInstanceId: string; + formType: ControlsControllerUnlinkDocumentTypeV1FormType; }; export const ControlsControllerUnlinkDocumentTypeV1Request$zodSchema: z.ZodType< diff --git a/apps/mcp-server/src/tool-names.ts b/apps/mcp-server/src/tool-names.ts index 20735d322c..0bdb58d611 100644 --- a/apps/mcp-server/src/tool-names.ts +++ b/apps/mcp-server/src/tool-names.ts @@ -1282,62 +1282,62 @@ export const toolNames: Array<{ name: string; description: string }>= [ }, { "name": "get-pending-offboardings", - "description": "Get members with pending offboarding checklists\n\nGet members with pending offboarding checklists in Comp AI." + "description": "Get members with pending offboarding checklists\n\nLists members whose offboarding checklist is still incomplete, with their outstanding items, so you can track and finish departing-employee offboarding." }, { "name": "get-template", - "description": "" + "description": "Get the offboarding checklist template\n\nReturns the organization's offboarding checklist template: the ordered set of items every departing member must complete during their offboarding." }, { "name": "create-template-item", - "description": "" + "description": "Add an offboarding checklist template item\n\nCreates a new item in the organization's offboarding checklist template so it appears on every member's offboarding checklist from now on." }, { "name": "update-template-item", - "description": "" + "description": "Update an offboarding checklist template item\n\nUpdates an existing offboarding checklist template item by id, changing its label, description, or settings on the organization's offboarding template." }, { "name": "delete-template-item", - "description": "" + "description": "Delete an offboarding checklist template item\n\nRemoves an item from the organization's offboarding checklist template by id so it no longer appears on members' offboarding checklists." }, { "name": "get-member-checklist", - "description": "" + "description": "Get a member's offboarding checklist\n\nReturns the offboarding checklist for a specific member, including each item and whether it has been completed, to track that person's offboarding progress." }, { "name": "offboarding-checklist-export-all-evidence", - "description": "Export all offboarding evidence as a zip file\n\nExport all offboarding evidence as a zip file in Comp AI." + "description": "Export all offboarding evidence as a zip file\n\nExports a zip archive containing the offboarding checklist evidence for every member in the organization, for audits, handovers, or record-keeping." }, { "name": "export-evidence", - "description": "Export offboarding evidence as a zip file\n\nExport offboarding evidence as a zip file in Comp AI." + "description": "Export offboarding evidence as a zip file\n\nExports a zip archive of the offboarding checklist evidence collected for a single member, for audit, handover, or record-keeping purposes." }, { "name": "complete-item", - "description": "" + "description": "Complete an offboarding checklist item\n\nMarks a specific offboarding checklist item complete for a member, recording who completed it and when, as part of finishing that member's offboarding." }, { "name": "uncomplete-item", - "description": "" + "description": "Reopen an offboarding checklist item\n\nReverts a previously completed offboarding checklist item back to incomplete for a member, in case the step was marked done by mistake." }, { "name": "upload-evidence", - "description": "" + "description": "Upload evidence for an offboarding checklist item\n\nAttaches a supporting evidence file to a member's completed offboarding checklist item, documenting that the offboarding step was actually carried out." }, { "name": "get-access-revocations", - "description": "Get vendor access revocation status for a member\n\nGet vendor access revocation status for a member in Comp AI." + "description": "Get vendor access revocation status for a member\n\nLists the vendors a departing member had access to and whether each has been revoked, so you can confirm all vendor access is removed during offboarding." }, { "name": "revoke-all-vendor-access", - "description": "Confirm all vendor access as revoked\n\nConfirm all vendor access as revoked in Comp AI." + "description": "Confirm all vendor access as revoked\n\nMarks every vendor access record for a departing member as revoked in one step, recording who confirmed it, to complete access removal during offboarding." }, { "name": "revoke-vendor-access", - "description": "Mark vendor access as revoked\n\nMark vendor access as revoked in Comp AI." + "description": "Mark vendor access as revoked\n\nMarks a single vendor's access for a departing member as revoked, optionally attaching evidence and notes, as part of offboarding access removal." }, { "name": "undo-vendor-revocation", - "description": "Undo vendor access revocation\n\nUndo vendor access revocation in Comp AI." + "description": "Undo vendor access revocation\n\nReverses a vendor access revocation for a member, marking that vendor's access as not revoked again, in case it was confirmed by mistake during offboarding." } ]; diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/OrganizationDashboard.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/OrganizationDashboard.tsx index d2d5b89727..ed4353e86e 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/OrganizationDashboard.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/OrganizationDashboard.tsx @@ -38,6 +38,11 @@ export async function OrganizationDashboard({ organizationId: organizationId, isRequiredToSign: true, status: 'published', + // Hide policies archived by the user or by a framework version sync. + // A sync sets `archivedAt` but leaves `status: 'published'`, so both + // flags must be checked. See packages/db Policy schema. + isArchived: false, + archivedAt: null, }, include: { currentVersion: { diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx index cc9aa43853..dddec63b3d 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx @@ -49,6 +49,7 @@ export default async function SignedPoliciesPage({ status: 'published', isRequiredToSign: true, isArchived: false, + archivedAt: null, signedBy: { has: member.id }, }, select: { diff --git a/gram.json b/gram.json new file mode 100644 index 0000000000..98798482e2 --- /dev/null +++ b/gram.json @@ -0,0 +1,12 @@ +{ + "schema_version": "1.0.0", + "type": "deployment", + "sources": [ + { + "type": "openapiv3", + "location": "packages/docs/openapi.json", + "name": "Comp AI API", + "slug": "comp-ai-api" + } + ] +} diff --git a/packages/db/prisma/migrations/20260528213310_add_oauth_provider_tables/migration.sql b/packages/db/prisma/migrations/20260528213310_add_oauth_provider_tables/migration.sql new file mode 100644 index 0000000000..f9e6b253e2 --- /dev/null +++ b/packages/db/prisma/migrations/20260528213310_add_oauth_provider_tables/migration.sql @@ -0,0 +1,85 @@ +-- CreateTable +CREATE TABLE "oauth_application" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('oap'::text), + "name" TEXT NOT NULL, + "icon" TEXT, + "metadata" TEXT, + "clientId" TEXT NOT NULL, + "clientSecret" TEXT, + "redirectUrls" TEXT NOT NULL, + "type" TEXT NOT NULL, + "disabled" BOOLEAN DEFAULT false, + "userId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "oauth_application_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "oauth_access_token" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('oat'::text), + "accessToken" TEXT NOT NULL, + "refreshToken" TEXT NOT NULL, + "accessTokenExpiresAt" TIMESTAMP(3) NOT NULL, + "refreshTokenExpiresAt" TIMESTAMP(3) NOT NULL, + "clientId" TEXT NOT NULL, + "userId" TEXT, + "scopes" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "oauth_access_token_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "oauth_consent" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('oac'::text), + "clientId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "scopes" TEXT NOT NULL, + "consentGiven" BOOLEAN NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "oauth_consent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "oauth_application_clientId_key" ON "oauth_application"("clientId"); + +-- CreateIndex +CREATE INDEX "oauth_application_userId_idx" ON "oauth_application"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "oauth_access_token_accessToken_key" ON "oauth_access_token"("accessToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "oauth_access_token_refreshToken_key" ON "oauth_access_token"("refreshToken"); + +-- CreateIndex +CREATE INDEX "oauth_access_token_clientId_idx" ON "oauth_access_token"("clientId"); + +-- CreateIndex +CREATE INDEX "oauth_access_token_userId_idx" ON "oauth_access_token"("userId"); + +-- CreateIndex +CREATE INDEX "oauth_consent_clientId_idx" ON "oauth_consent"("clientId"); + +-- CreateIndex +CREATE INDEX "oauth_consent_userId_idx" ON "oauth_consent"("userId"); + +-- AddForeignKey +ALTER TABLE "oauth_application" ADD CONSTRAINT "oauth_application_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "oauth_application"("clientId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "oauth_access_token" ADD CONSTRAINT "oauth_access_token_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "oauth_application"("clientId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "oauth_consent" ADD CONSTRAINT "oauth_consent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260528222357_add_mcp_org_binding/migration.sql b/packages/db/prisma/migrations/20260528222357_add_mcp_org_binding/migration.sql new file mode 100644 index 0000000000..a79ba3eb24 --- /dev/null +++ b/packages/db/prisma/migrations/20260528222357_add_mcp_org_binding/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "mcp_org_binding" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('mob'::text), + "userId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "mcp_org_binding_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "mcp_org_binding_userId_key" ON "mcp_org_binding"("userId"); + +-- CreateIndex +CREATE INDEX "mcp_org_binding_organizationId_idx" ON "mcp_org_binding"("organizationId"); + +-- AddForeignKey +ALTER TABLE "mcp_org_binding" ADD CONSTRAINT "mcp_org_binding_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "mcp_org_binding" ADD CONSTRAINT "mcp_org_binding_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 45b82fba9f..6c9c12383c 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -32,6 +32,11 @@ model User { offboardingChecklistCompletions OffboardingChecklistCompletion[] @relation("OffboardingChecklistCompletedBy") offboardingAccessRevocations OffboardingAccessRevocation[] @relation("AccessRevocationRevokedBy") + oauthApplications OauthApplication[] + oauthAccessTokens OauthAccessToken[] + oauthConsents OauthConsent[] + mcpOrgBinding McpOrgBinding? + @@unique([email]) } @@ -95,6 +100,85 @@ model Verification { updatedAt DateTime @updatedAt } +// ── OAuth 2.0 / OIDC Provider — required by better-auth's mcp/oidcProvider plugin. +// The MCP server is hosted on Speakeasy Gram; Gram obtains an OAuth access token +// from this API (better-auth as the authorization server) so end users sign in +// with Google instead of pasting an API key. +// Field names MUST match better-auth's expected schema (clientId, redirectUrls, …). +model OauthApplication { + id String @id @default(dbgenerated("generate_prefixed_cuid('oap'::text)")) + name String + icon String? + metadata String? + clientId String @unique + clientSecret String? + redirectUrls String + type String + disabled Boolean? @default(false) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + accessTokens OauthAccessToken[] + consents OauthConsent[] + + @@index([userId]) + @@map("oauth_application") +} + +model OauthAccessToken { + id String @id @default(dbgenerated("generate_prefixed_cuid('oat'::text)")) + accessToken String @unique + refreshToken String @unique + accessTokenExpiresAt DateTime + refreshTokenExpiresAt DateTime + clientId String + application OauthApplication @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + scopes String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([clientId]) + @@index([userId]) + @@map("oauth_access_token") +} + +model OauthConsent { + id String @id @default(dbgenerated("generate_prefixed_cuid('oac'::text)")) + clientId String + application OauthApplication @relation(fields: [clientId], references: [clientId], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + scopes String + consentGiven Boolean + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([clientId]) + @@index([userId]) + @@map("oauth_consent") +} + +// Per-user organization selection for the hosted MCP. A user who belongs to +// multiple orgs picks which one their MCP/OAuth token acts on (chosen at connect +// time); HybridAuthGuard reads this for MCP OAuth requests. Single-org users +// don't need a row — their one org is used automatically. +model McpOrgBinding { + id String @id @default(dbgenerated("generate_prefixed_cuid('mob'::text)")) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId]) + @@map("mcp_org_binding") +} + // JWT Plugin - Required by Better Auth JWT plugin // https://www.better-auth.com/docs/plugins/jwt model Jwks { diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index 080176465b..5fbb485609 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -26,6 +26,7 @@ model Organization { employeeSyncProvider String? apiKeys ApiKey[] + mcpOrgBindings McpOrgBinding[] auditLog AuditLog[] controls Control[] frameworkInstances FrameworkInstance[] diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 6f3d2f65a2..68ada0dd85 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -122,6 +122,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get organization profile", @@ -364,6 +367,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update organization", @@ -464,6 +470,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete organization", @@ -496,6 +505,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get organization onboarding status", @@ -631,6 +643,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Transfer organization ownership", @@ -716,6 +731,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update role notification settings", @@ -747,6 +765,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get role notification settings", @@ -780,6 +801,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List API keys", @@ -811,6 +835,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create API key", @@ -844,6 +871,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List API key scopes", @@ -943,6 +973,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get organization brand color", @@ -975,6 +1008,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload organization logo", @@ -1006,6 +1042,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Remove organization logo", @@ -1039,6 +1078,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Revoke API key", @@ -1111,6 +1153,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Invite workforce members", @@ -1306,6 +1351,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List workforce members", @@ -1439,6 +1487,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create a new member", @@ -1471,6 +1522,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get all employee devices with fleet compliance data", @@ -1504,6 +1558,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get integration test statistics grouped by assignee", @@ -1737,6 +1794,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Add multiple members to organization", @@ -1778,6 +1838,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get members who can read a specific resource type", @@ -1822,6 +1885,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Reactivate a deactivated member", @@ -1942,6 +2008,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get person by ID", @@ -2086,6 +2155,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update workforce member", @@ -2218,6 +2290,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete member", @@ -2261,6 +2336,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get training video completions for a member", @@ -2305,6 +2383,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get fleet compliance", @@ -2430,6 +2511,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Remove host (device) from Fleet", @@ -2473,6 +2557,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Resend portal invite email to a member", @@ -2609,6 +2696,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Unlink device from member", @@ -2664,6 +2754,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get employment evidence attachments", @@ -2728,6 +2821,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload employment evidence", @@ -2793,6 +2889,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete employment evidence", @@ -2826,6 +2925,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get current user email notification preferences", @@ -2867,6 +2969,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update current user email notification preferences", @@ -2950,6 +3055,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload an attachment to any supported entity", @@ -3013,6 +3121,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get shared attachment download URL", @@ -3483,6 +3594,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List organization risks", @@ -3769,6 +3883,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create organization risk", @@ -3801,6 +3918,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get risk statistics grouped by assignee", @@ -3834,6 +3954,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get risk counts grouped by department", @@ -4108,6 +4231,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get organization risk", @@ -4405,6 +4531,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update organization risk", @@ -4545,6 +4674,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete organization risk", @@ -4587,6 +4719,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Search global vendors", @@ -4816,6 +4951,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List vendors", @@ -5076,6 +5214,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create vendor", @@ -5306,6 +5447,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get vendor details", @@ -5577,6 +5721,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update vendor record", @@ -5717,6 +5864,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete vendor", @@ -5760,6 +5910,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Trigger vendor risk assessment", @@ -5978,6 +6131,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List organization context", @@ -6204,6 +6360,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create a new context entry", @@ -6381,6 +6540,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get organization context", @@ -6519,6 +6681,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update organization context", @@ -6668,6 +6833,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete context entry", @@ -6792,6 +6960,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List managed devices", @@ -6878,6 +7049,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get devices by member ID", @@ -6928,6 +7102,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete device", @@ -7083,6 +7260,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List compliance policies", @@ -7214,6 +7394,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create compliance policy", @@ -7256,6 +7439,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Publish all draft policies", @@ -7303,6 +7489,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Download all published policies", @@ -7355,6 +7544,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get mapped and all controls for a policy", @@ -7406,6 +7598,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Map controls to a policy", @@ -7459,6 +7654,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get tasks that serve as evidence for a policy, grouped by control", @@ -7512,6 +7710,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Regenerate policy with AI", @@ -7573,6 +7774,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get a signed URL for the policy PDF", @@ -7728,6 +7932,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload a PDF to a policy version (UI-only)", @@ -7788,6 +7995,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete a policy version PDF", @@ -7858,6 +8068,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Request a presigned URL to upload a policy PDF", @@ -7921,6 +8134,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Confirm a policy PDF upload completed", @@ -7981,6 +8197,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get signed URL for policy PDF (alternate path)", @@ -8042,6 +8261,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Remove a control mapping from a policy", @@ -8156,6 +8378,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get compliance policy", @@ -8306,6 +8531,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update compliance policy", @@ -8426,6 +8654,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete compliance policy", @@ -8534,6 +8765,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get policy versions", @@ -8659,6 +8893,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create policy version", @@ -8774,6 +9011,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get policy version by ID", @@ -8906,6 +9146,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update version content", @@ -9027,6 +9270,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete policy version", @@ -9154,6 +9400,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Publish policy version", @@ -9280,6 +9529,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Set active policy version", @@ -9417,6 +9669,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Submit version for approval", @@ -9469,6 +9724,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Accept pending policy changes and publish the version", @@ -9522,6 +9780,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Deny pending policy changes", @@ -9599,6 +9860,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Chat with AI about a policy", @@ -9954,6 +10218,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Download macOS Device Agent", @@ -10012,6 +10279,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Download Windows Device Agent ZIP", @@ -10109,6 +10379,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get a presigned URL to upload a file", @@ -10188,6 +10461,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List compliance tasks", @@ -10311,6 +10587,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create compliance task", @@ -10354,6 +10633,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get task templates", @@ -10452,6 +10734,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update status for multiple tasks", @@ -10524,6 +10809,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete multiple tasks", @@ -10604,6 +10892,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update assignee for multiple tasks", @@ -10685,6 +10976,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Reorder tasks", @@ -10752,6 +11046,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Bulk submit tasks for review", @@ -10784,6 +11081,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get page options for tasks overview", @@ -10863,6 +11163,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get task by ID", @@ -11010,6 +11313,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update a task", @@ -11072,6 +11378,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete a task", @@ -11115,6 +11424,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get policies that reference a task via shared controls", @@ -11179,6 +11491,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get task activity", @@ -11229,6 +11544,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Regenerate task from template", @@ -11296,6 +11614,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Submit task for review", @@ -11346,6 +11667,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Approve a task", @@ -11396,6 +11720,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Reject a task review", @@ -11492,6 +11819,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get task attachments", @@ -11609,6 +11939,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload task evidence", @@ -11714,6 +12047,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get task attachment download URL", @@ -11821,6 +12157,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete task attachment", @@ -11865,6 +12204,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get all automations for a task", @@ -11981,6 +12323,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create evidence automation", @@ -12035,6 +12380,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get automation details", @@ -12175,6 +12523,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update an existing automation", @@ -12227,6 +12578,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete an automation", @@ -12279,6 +12633,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get all runs for a specific automation", @@ -12388,6 +12745,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get all versions for an automation", @@ -12437,6 +12797,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create a published version record for an automation", @@ -12528,6 +12891,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get all automation runs for a task", @@ -12575,6 +12941,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get task evidence summary", @@ -12633,6 +13002,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Export automation evidence as PDF", @@ -12691,6 +13063,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Export task evidence as ZIP", @@ -12740,6 +13115,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Export all organization evidence as ZIP (Auditor only)", @@ -12810,6 +13188,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get comments for an entity", @@ -12858,6 +13239,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create a new comment", @@ -12919,6 +13303,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update a comment", @@ -13032,6 +13419,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete a comment", @@ -13064,6 +13454,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get Trust Center settings", @@ -13097,6 +13490,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload a favicon for the trust portal", @@ -13128,6 +13524,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Remove the trust portal favicon", @@ -13186,6 +13585,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get domain verification status", @@ -13239,6 +13641,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload compliance certificate", @@ -13288,6 +13693,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Generate a temporary signed URL for a compliance certificate", @@ -13341,6 +13749,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List uploaded compliance certificates for the organization", @@ -13392,6 +13803,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload an additional trust portal document", @@ -13444,6 +13858,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List additional trust portal documents for the organization", @@ -13503,6 +13920,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Generate a temporary signed URL for a trust portal document", @@ -13567,6 +13987,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete (deactivate) a trust portal document", @@ -13600,6 +14023,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Enable or disable the trust portal", @@ -13633,6 +14059,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Add or update a custom domain for the trust portal", @@ -13666,6 +14095,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Check DNS records for a custom domain", @@ -13699,6 +14131,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update trust portal FAQs", @@ -13732,6 +14167,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update allowed domains for the trust portal", @@ -13765,6 +14203,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update trust portal framework settings", @@ -13798,6 +14239,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update Trust Center overview", @@ -13838,6 +14282,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get trust portal overview", @@ -13871,6 +14318,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create a custom link for trust portal", @@ -13911,6 +14361,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List custom links for trust portal", @@ -13953,6 +14406,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update a custom link", @@ -13995,6 +14451,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete a custom link", @@ -14028,6 +14487,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Reorder custom links", @@ -14070,6 +14532,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update vendor trust portal settings", @@ -14113,6 +14578,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List vendors configured for trust portal", @@ -14210,6 +14678,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List Trust Access requests", @@ -14252,6 +14723,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get Trust Access request", @@ -14304,6 +14778,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Approve Trust Access request", @@ -14356,6 +14833,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Deny Trust Access request", @@ -14389,6 +14869,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List Trust Access grants", @@ -14441,6 +14924,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Revoke Trust Access grant", @@ -14483,6 +14969,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Resend Trust Access email", @@ -14525,6 +15014,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Resend Trust Access NDA", @@ -14567,6 +15059,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Preview Trust Access NDA", @@ -15018,6 +15513,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List audit findings", @@ -15059,6 +15557,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create audit finding", @@ -15107,6 +15608,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List all findings for the organization", @@ -15150,6 +15654,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get finding by ID", @@ -15201,6 +15708,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update a finding (status transition rules apply)", @@ -15242,6 +15752,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete a finding (auditor or platform admin only)", @@ -15285,6 +15798,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get activity history for a finding", @@ -15376,6 +15892,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create a custom role", @@ -15464,6 +15983,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List all roles", @@ -15527,6 +16049,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Resolve permissions for custom roles", @@ -15591,6 +16116,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get obligations for a built-in role", @@ -15666,6 +16194,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update obligations for a built-in role", @@ -15745,6 +16276,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get a role by ID", @@ -15838,6 +16372,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update a custom role", @@ -15904,6 +16441,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete a custom role", @@ -15936,6 +16476,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List security questionnaires", @@ -15979,6 +16522,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get security questionnaire details", @@ -16020,6 +16566,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete a security questionnaire", @@ -16071,6 +16620,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Generate answers for a questionnaire", @@ -16120,6 +16672,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Parse questionnaire content", @@ -16201,6 +16756,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Answer one questionnaire question", @@ -16261,6 +16819,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Save questionnaire answer", @@ -16321,6 +16882,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete questionnaire answer", @@ -16365,6 +16929,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Export a security questionnaire", @@ -16424,6 +16991,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Start questionnaire parsing", @@ -16507,6 +17077,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload and parse questionnaire file", @@ -16585,6 +17158,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Auto-answer uploaded questionnaire", @@ -16629,6 +17205,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Export generated questionnaire answers", @@ -16698,6 +17277,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload and export generated answers", @@ -16742,6 +17324,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Stream generated questionnaire answers", @@ -16776,6 +17361,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List knowledge base documents", @@ -16809,6 +17397,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List all manual answers for an organization", @@ -16850,6 +17441,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Save reusable manual answer", @@ -16893,6 +17487,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload knowledge base document", @@ -16935,6 +17532,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get a signed download URL for a document", @@ -16977,6 +17577,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get a signed view URL for a document", @@ -17019,6 +17622,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete a knowledge base document", @@ -17062,6 +17668,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Process knowledge base documents", @@ -17104,6 +17713,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create a public access token for a run", @@ -17156,6 +17768,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete a manual answer", @@ -17199,6 +17814,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete all manual answers for an organization", @@ -17254,6 +17872,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Save a SOA answer", @@ -17298,6 +17919,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Auto-fill ISO 27001 SOA", @@ -17340,6 +17964,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create a new SOA document", @@ -17383,6 +18010,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Ensure SOA configuration and document exist", @@ -17426,6 +18056,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Approve a SOA document", @@ -17469,6 +18102,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Decline a SOA document", @@ -17512,6 +18148,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Submit SOA document for approval", @@ -17555,6 +18194,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Export ISO 27001 SOA", @@ -17597,6 +18239,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List integration providers", @@ -17639,6 +18284,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get an integration provider by slug", @@ -17672,6 +18320,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List integration connections", @@ -17713,6 +18364,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create integration connection", @@ -17755,6 +18409,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get an integration connection by ID", @@ -17795,6 +18452,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete an integration connection", @@ -17845,6 +18505,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update an integration connection", @@ -17887,6 +18550,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Test an integration connection", @@ -17929,6 +18595,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Pause an integration connection", @@ -17971,6 +18640,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Resume an integration connection", @@ -18013,6 +18685,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Disconnect an integration", @@ -18065,6 +18740,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Set services enabled on a connection", @@ -18105,6 +18783,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List services enabled on a connection", @@ -18147,6 +18828,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List check definitions for a provider", @@ -18189,6 +18873,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List checks for a connection", @@ -18241,6 +18928,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Run integration checks", @@ -18291,6 +18981,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Run a single check on a connection", @@ -18333,6 +19026,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List variable definitions for a provider", @@ -18375,6 +19071,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List connection variables", @@ -18425,6 +19124,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update connection variables", @@ -18475,6 +19177,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get options for a connection variable", @@ -18517,6 +19222,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List checks for a task template", @@ -18559,6 +19267,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List checks attached to a task", @@ -18611,6 +19322,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Run a check for a task", @@ -18663,6 +19377,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Disconnect checks from a task", @@ -18715,6 +19432,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Reconnect checks to a task", @@ -18765,6 +19485,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List check runs for a task", @@ -18807,6 +19530,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Sync Google Workspace employees", @@ -18840,6 +19566,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get Google Workspace sync status", @@ -18882,6 +19611,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Sync Rippling employees", @@ -18915,6 +19647,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get Rippling sync status", @@ -18957,6 +19692,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Sync JumpCloud employees", @@ -18990,6 +19728,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get JumpCloud sync status", @@ -19023,6 +19764,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get the currently configured employee sync provider", @@ -19064,6 +19808,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Set the employee sync provider", @@ -19097,6 +19844,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List employee sync providers available to the org", @@ -19147,6 +19897,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Sync employees for a dynamic provider", @@ -19913,6 +20666,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get task items statistics for an entity", @@ -20079,6 +20835,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get task items for an entity", @@ -20127,6 +20886,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create a new task item", @@ -20188,6 +20950,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update a task item", @@ -20230,6 +20995,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete a task item", @@ -20280,6 +21048,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload attachment to task item", @@ -20324,6 +21095,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete attachment from task item", @@ -20368,6 +21142,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get task item activity log", @@ -20401,6 +21178,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List training completions", @@ -20443,6 +21223,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Mark a training video as complete", @@ -20493,6 +21276,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Send training completion email with certificate", @@ -20539,6 +21325,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Generate training certificate", @@ -20585,6 +21374,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Generate HIPAA training certificate PDF", @@ -20627,6 +21419,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get the organization chart", @@ -20668,6 +21463,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create or update an interactive organization chart", @@ -20709,6 +21507,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete the organization chart", @@ -20762,6 +21563,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload an image as the organization chart", @@ -20806,6 +21610,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List evidence forms", @@ -20849,6 +21656,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get submission statuses for all forms", @@ -20892,6 +21702,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get document relevance settings", @@ -20943,6 +21756,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update document relevance setting", @@ -20994,6 +21810,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get current user submissions", @@ -21037,6 +21856,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get pending submission count for current user", @@ -21112,6 +21934,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get form definition and submissions", @@ -21171,6 +21996,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get a single submission", @@ -21228,6 +22056,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete a submission", @@ -21279,6 +22110,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Submit evidence form", @@ -21330,6 +22164,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload a file as an evidence submission", @@ -21389,6 +22226,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Review evidence submission", @@ -21432,6 +22272,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload evidence form file", @@ -21483,6 +22326,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Export evidence submissions", @@ -22311,6 +23157,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List audit logs", @@ -22819,6 +23668,14 @@ "type": "string" } }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "formType", "required": true, @@ -22841,14 +23698,6 @@ ], "type": "string" } - }, - { - "name": "frameworkInstanceId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } } ], "responses": { @@ -22903,6 +23752,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "List penetration test runs", @@ -22957,6 +23809,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Create penetration test", @@ -23011,6 +23866,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get penetration test status", @@ -23062,6 +23920,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get penetration test progress", @@ -23113,6 +23974,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get penetration test issues", @@ -23164,6 +24028,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get penetration test agent events", @@ -23215,6 +24082,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get penetration test output", @@ -23266,6 +24136,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get penetration test PDF", @@ -23288,6 +24161,7 @@ }, "/v1/offboarding-checklist/pending": { "get": { + "description": "Lists members whose offboarding checklist is still incomplete, with their outstanding items, so you can track and finish departing-employee offboarding.", "operationId": "OffboardingChecklistController_getPendingOffboardings_v1", "parameters": [], "responses": { @@ -23298,20 +24172,22 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get members with pending offboarding checklists", "tags": [ "Offboarding Checklist" ], - "description": "Get members with pending offboarding checklists in Comp AI.", "x-mint": { "metadata": { "title": "Get members with pending offboarding | Comp AI API", "sidebarTitle": "Get members with pending offboarding checklists", - "description": "Get members with pending offboarding checklists in Comp AI.", + "description": "Lists members whose offboarding checklist is still incomplete, with their outstanding items, so you can track and finish departing-employee offboarding.", "og:title": "Get members with pending offboarding | Comp AI API", - "og:description": "Get members with pending offboarding checklists in Comp AI." + "og:description": "Lists members whose offboarding checklist is still incomplete, with their outstanding items, so you can track and finish departing-employee offboarding." } }, "x-speakeasy-mcp": { @@ -23321,6 +24197,7 @@ }, "/v1/offboarding-checklist/template": { "get": { + "description": "Returns the organization's offboarding checklist template: the ordered set of items every departing member must complete during their offboarding.", "operationId": "OffboardingChecklistController_getTemplate_v1", "parameters": [], "responses": { @@ -23331,16 +24208,30 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], + "summary": "Get the offboarding checklist template", "tags": [ "Offboarding Checklist" ], + "x-mint": { + "metadata": { + "title": "Get the offboarding checklist template | Comp AI API", + "sidebarTitle": "Get the offboarding checklist template", + "description": "Returns the organization's offboarding checklist template: the ordered set of items every departing member must complete during their offboarding.", + "og:title": "Get the offboarding checklist template | Comp AI API", + "og:description": "Returns the organization's offboarding checklist template: the ordered set of items every departing member must complete during their offboarding." + } + }, "x-speakeasy-mcp": { "name": "get-template" } }, "post": { + "description": "Creates a new item in the organization's offboarding checklist template so it appears on every member's offboarding checklist from now on.", "operationId": "OffboardingChecklistController_createTemplateItem_v1", "parameters": [], "requestBody": { @@ -23361,11 +24252,24 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], + "summary": "Add an offboarding checklist template item", "tags": [ "Offboarding Checklist" ], + "x-mint": { + "metadata": { + "title": "Add an offboarding checklist template item | Comp AI API", + "sidebarTitle": "Add an offboarding checklist template item", + "description": "Creates a new item in the organization's offboarding checklist template so it appears on every member's offboarding checklist from now on.", + "og:title": "Add an offboarding checklist template item | Comp AI API", + "og:description": "Creates a new item in the organization's offboarding checklist template so it appears on every member's offboarding checklist from now on." + } + }, "x-speakeasy-mcp": { "name": "create-template-item" } @@ -23373,6 +24277,7 @@ }, "/v1/offboarding-checklist/template/{id}": { "patch": { + "description": "Updates an existing offboarding checklist template item by id, changing its label, description, or settings on the organization's offboarding template.", "operationId": "OffboardingChecklistController_updateTemplateItem_v1", "parameters": [ { @@ -23402,16 +24307,30 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], + "summary": "Update an offboarding checklist template item", "tags": [ "Offboarding Checklist" ], + "x-mint": { + "metadata": { + "title": "Update an offboarding checklist template item | Comp AI API", + "sidebarTitle": "Update an offboarding checklist template item", + "description": "Updates an existing offboarding checklist template item by id, changing its label, description, or settings on the organization's offboarding template.", + "og:title": "Update an offboarding checklist template item | Comp AI API", + "og:description": "Updates an existing offboarding checklist template item by id, changing its label, description, or settings on the organization's offboarding template." + } + }, "x-speakeasy-mcp": { "name": "update-template-item" } }, "delete": { + "description": "Removes an item from the organization's offboarding checklist template by id so it no longer appears on members' offboarding checklists.", "operationId": "OffboardingChecklistController_deleteTemplateItem_v1", "parameters": [ { @@ -23431,11 +24350,24 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], + "summary": "Delete an offboarding checklist template item", "tags": [ "Offboarding Checklist" ], + "x-mint": { + "metadata": { + "title": "Delete an offboarding checklist template item | Comp AI API", + "sidebarTitle": "Delete an offboarding checklist template item", + "description": "Removes an item from the organization's offboarding checklist template by id so it no longer appears on members' offboarding checklists.", + "og:title": "Delete an offboarding checklist template item | Comp AI API", + "og:description": "Removes an item from the organization's offboarding checklist template by id so it no longer appears on members' offboarding checklists." + } + }, "x-speakeasy-mcp": { "name": "delete-template-item" } @@ -23443,6 +24375,7 @@ }, "/v1/offboarding-checklist/member/{memberId}": { "get": { + "description": "Returns the offboarding checklist for a specific member, including each item and whether it has been completed, to track that person's offboarding progress.", "operationId": "OffboardingChecklistController_getMemberChecklist_v1", "parameters": [ { @@ -23462,11 +24395,24 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], + "summary": "Get a member's offboarding checklist", "tags": [ "Offboarding Checklist" ], + "x-mint": { + "metadata": { + "title": "Get a member's offboarding checklist | Comp AI API", + "sidebarTitle": "Get a member's offboarding checklist", + "description": "Returns the offboarding checklist for a specific member, including each item and whether it has been completed, to track that person's offboarding progress.", + "og:title": "Get a member's offboarding checklist | Comp AI API", + "og:description": "Returns the offboarding checklist for a specific member, including each item and whether it has been completed, to track that person's offboarding progress." + } + }, "x-speakeasy-mcp": { "name": "get-member-checklist" } @@ -23474,6 +24420,7 @@ }, "/v1/offboarding-checklist/export-all": { "get": { + "description": "Exports a zip archive containing the offboarding checklist evidence for every member in the organization, for audits, handovers, or record-keeping.", "operationId": "OffboardingChecklistController_exportAllEvidence_v1", "parameters": [], "responses": { @@ -23484,20 +24431,22 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Export all offboarding evidence as a zip file", "tags": [ "Offboarding Checklist" ], - "description": "Export all offboarding evidence as a zip file in Comp AI.", "x-mint": { "metadata": { "title": "Export all offboarding evidence as a zip file | Comp AI API", "sidebarTitle": "Export all offboarding evidence as a zip file", - "description": "Export all offboarding evidence as a zip file in Comp AI.", + "description": "Exports a zip archive containing the offboarding checklist evidence for every member in the organization, for audits, handovers, or record-keeping.", "og:title": "Export all offboarding evidence as a zip file | Comp AI API", - "og:description": "Export all offboarding evidence as a zip file in Comp AI." + "og:description": "Exports a zip archive containing the offboarding checklist evidence for every member in the organization, for audits, handovers, or record-keeping." } }, "x-speakeasy-mcp": { @@ -23507,6 +24456,7 @@ }, "/v1/offboarding-checklist/member/{memberId}/export": { "get": { + "description": "Exports a zip archive of the offboarding checklist evidence collected for a single member, for audit, handover, or record-keeping purposes.", "operationId": "OffboardingChecklistController_exportEvidence_v1", "parameters": [ { @@ -23527,20 +24477,22 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Export offboarding evidence as a zip file", "tags": [ "Offboarding Checklist" ], - "description": "Export offboarding evidence as a zip file in Comp AI.", "x-mint": { "metadata": { "title": "Export offboarding evidence as a zip file | Comp AI API", "sidebarTitle": "Export offboarding evidence as a zip file", - "description": "Export offboarding evidence as a zip file in Comp AI.", + "description": "Exports a zip archive of the offboarding checklist evidence collected for a single member, for audit, handover, or record-keeping purposes.", "og:title": "Export offboarding evidence as a zip file | Comp AI API", - "og:description": "Export offboarding evidence as a zip file in Comp AI." + "og:description": "Exports a zip archive of the offboarding checklist evidence collected for a single member, for audit, handover, or record-keeping purposes." } }, "x-speakeasy-mcp": { @@ -23550,6 +24502,7 @@ }, "/v1/offboarding-checklist/member/{memberId}/item/{templateItemId}/complete": { "post": { + "description": "Marks a specific offboarding checklist item complete for a member, recording who completed it and when, as part of finishing that member's offboarding.", "operationId": "OffboardingChecklistController_completeItem_v1", "parameters": [ { @@ -23587,16 +24540,30 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], + "summary": "Complete an offboarding checklist item", "tags": [ "Offboarding Checklist" ], + "x-mint": { + "metadata": { + "title": "Complete an offboarding checklist item | Comp AI API", + "sidebarTitle": "Complete an offboarding checklist item", + "description": "Marks a specific offboarding checklist item complete for a member, recording who completed it and when, as part of finishing that member's offboarding.", + "og:title": "Complete an offboarding checklist item | Comp AI API", + "og:description": "Marks a specific offboarding checklist item complete for a member, recording who completed it and when, as part of finishing that member's offboarding." + } + }, "x-speakeasy-mcp": { "name": "complete-item" } }, "delete": { + "description": "Reverts a previously completed offboarding checklist item back to incomplete for a member, in case the step was marked done by mistake.", "operationId": "OffboardingChecklistController_uncompleteItem_v1", "parameters": [ { @@ -23624,11 +24591,24 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], + "summary": "Reopen an offboarding checklist item", "tags": [ "Offboarding Checklist" ], + "x-mint": { + "metadata": { + "title": "Reopen an offboarding checklist item | Comp AI API", + "sidebarTitle": "Reopen an offboarding checklist item", + "description": "Reverts a previously completed offboarding checklist item back to incomplete for a member, in case the step was marked done by mistake.", + "og:title": "Reopen an offboarding checklist item | Comp AI API", + "og:description": "Reverts a previously completed offboarding checklist item back to incomplete for a member, in case the step was marked done by mistake." + } + }, "x-speakeasy-mcp": { "name": "uncomplete-item" } @@ -23636,6 +24616,7 @@ }, "/v1/offboarding-checklist/member/{memberId}/item/{templateItemId}/evidence": { "post": { + "description": "Attaches a supporting evidence file to a member's completed offboarding checklist item, documenting that the offboarding step was actually carried out.", "operationId": "OffboardingChecklistController_uploadEvidence_v1", "parameters": [ { @@ -23673,11 +24654,24 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], + "summary": "Upload evidence for an offboarding checklist item", "tags": [ "Offboarding Checklist" ], + "x-mint": { + "metadata": { + "title": "Upload evidence for an offboarding checklist | Comp AI API", + "sidebarTitle": "Upload evidence for an offboarding checklist item", + "description": "Attaches a supporting evidence file to a member's completed offboarding checklist item, documenting that the offboarding step was actually carried out.", + "og:title": "Upload evidence for an offboarding checklist | Comp AI API", + "og:description": "Attaches a supporting evidence file to a member's completed offboarding checklist item, documenting that the offboarding step was actually carried out." + } + }, "x-speakeasy-mcp": { "name": "upload-evidence" } @@ -23685,6 +24679,7 @@ }, "/v1/offboarding-checklist/member/{memberId}/access-revocations": { "get": { + "description": "Lists the vendors a departing member had access to and whether each has been revoked, so you can confirm all vendor access is removed during offboarding.", "operationId": "OffboardingChecklistController_getAccessRevocations_v1", "parameters": [ { @@ -23705,20 +24700,22 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get vendor access revocation status for a member", "tags": [ "Offboarding Checklist" ], - "description": "Get vendor access revocation status for a member in Comp AI.", "x-mint": { "metadata": { "title": "Get vendor access revocation status for a | Comp AI API", "sidebarTitle": "Get vendor access revocation status for a member", - "description": "Get vendor access revocation status for a member in Comp AI.", + "description": "Lists the vendors a departing member had access to and whether each has been revoked, so you can confirm all vendor access is removed during offboarding.", "og:title": "Get vendor access revocation status for a | Comp AI API", - "og:description": "Get vendor access revocation status for a member in Comp AI." + "og:description": "Lists the vendors a departing member had access to and whether each has been revoked, so you can confirm all vendor access is removed during offboarding." } }, "x-speakeasy-mcp": { @@ -23728,6 +24725,7 @@ }, "/v1/offboarding-checklist/member/{memberId}/access-revocations/confirm-all": { "post": { + "description": "Marks every vendor access record for a departing member as revoked in one step, recording who confirmed it, to complete access removal during offboarding.", "operationId": "OffboardingChecklistController_revokeAllVendorAccess_v1", "parameters": [ { @@ -23748,20 +24746,22 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Confirm all vendor access as revoked", "tags": [ "Offboarding Checklist" ], - "description": "Confirm all vendor access as revoked in Comp AI.", "x-mint": { "metadata": { "title": "Confirm all vendor access as revoked | Comp AI API", "sidebarTitle": "Confirm all vendor access as revoked", - "description": "Confirm all vendor access as revoked in Comp AI.", + "description": "Marks every vendor access record for a departing member as revoked in one step, recording who confirmed it, to complete access removal during offboarding.", "og:title": "Confirm all vendor access as revoked | Comp AI API", - "og:description": "Confirm all vendor access as revoked in Comp AI." + "og:description": "Marks every vendor access record for a departing member as revoked in one step, recording who confirmed it, to complete access removal during offboarding." } }, "x-speakeasy-mcp": { @@ -23771,6 +24771,7 @@ }, "/v1/offboarding-checklist/member/{memberId}/access-revocations/{vendorId}": { "post": { + "description": "Marks a single vendor's access for a departing member as revoked, optionally attaching evidence and notes, as part of offboarding access removal.", "operationId": "OffboardingChecklistController_revokeVendorAccess_v1", "parameters": [ { @@ -23800,20 +24801,22 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Mark vendor access as revoked", "tags": [ "Offboarding Checklist" ], - "description": "Mark vendor access as revoked in Comp AI.", "x-mint": { "metadata": { "title": "Mark vendor access as revoked | Comp AI API", "sidebarTitle": "Mark vendor access as revoked", - "description": "Mark vendor access as revoked in Comp AI.", + "description": "Marks a single vendor's access for a departing member as revoked, optionally attaching evidence and notes, as part of offboarding access removal.", "og:title": "Mark vendor access as revoked | Comp AI API", - "og:description": "Mark vendor access as revoked in Comp AI." + "og:description": "Marks a single vendor's access for a departing member as revoked, optionally attaching evidence and notes, as part of offboarding access removal." } }, "x-speakeasy-mcp": { @@ -23821,6 +24824,7 @@ } }, "delete": { + "description": "Reverses a vendor access revocation for a member, marking that vendor's access as not revoked again, in case it was confirmed by mistake during offboarding.", "operationId": "OffboardingChecklistController_undoVendorRevocation_v1", "parameters": [ { @@ -23850,20 +24854,22 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Undo vendor access revocation", "tags": [ "Offboarding Checklist" ], - "description": "Undo vendor access revocation in Comp AI.", "x-mint": { "metadata": { "title": "Undo vendor access revocation | Comp AI API", "sidebarTitle": "Undo vendor access revocation", - "description": "Undo vendor access revocation in Comp AI.", + "description": "Reverses a vendor access revocation for a member, marking that vendor's access as not revoked again, in case it was confirmed by mistake during offboarding.", "og:title": "Undo vendor access revocation | Comp AI API", - "og:description": "Undo vendor access revocation in Comp AI." + "og:description": "Reverses a vendor access revocation for a member, marking that vendor's access as not revoked again, in case it was confirmed by mistake during offboarding." } }, "x-speakeasy-mcp": { @@ -24029,6 +25035,23 @@ "in": "header", "name": "X-API-Key", "description": "API key for authentication" + }, + "oauth2": { + "type": "oauth2", + "description": "OAuth 2.1 authorization code flow. Sign in with your Comp AI account — tokens are issued by the Comp AI authorization server and scoped to your organization, role, and permissions.", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://api.trycomp.ai/api/auth/mcp/authorize", + "tokenUrl": "https://api.trycomp.ai/api/auth/mcp/token", + "refreshUrl": "https://api.trycomp.ai/api/auth/mcp/token", + "scopes": { + "openid": "OpenID Connect authentication", + "profile": "Basic profile information", + "email": "Email address", + "offline_access": "Maintain access via refresh tokens" + } + } + } } }, "schemas": {