From 4cd3b1ccd409550ec03ee19483d557a4262ee138 Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Wed, 27 May 2026 14:15:41 -0400 Subject: [PATCH 01/29] fix(api): prevent proxy timeout on large-org ZIP exports --- .../evidence-export.controller.spec.ts | 3 ++ .../evidence-export.controller.ts | 8 +++ .../evidence-export.service.spec.ts | 8 ++- .../evidence-export.service.ts | 50 +++++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) 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 ab6de156d8..568552dbdd 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 @@ -180,10 +180,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', ); @@ -580,6 +585,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 cc77678d57..20a6d955bc 100644 --- a/apps/api/src/tasks/evidence-export/evidence-export.service.ts +++ b/apps/api/src/tasks/evidence-export/evidence-export.service.ts @@ -33,6 +33,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); @@ -196,6 +220,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), @@ -337,6 +372,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; From 5caf46a24b92e4a10f7fa24ffff6c6bcbc13c1da Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 17:42:42 -0400 Subject: [PATCH 02/29] feat(api): add OAuth provider for keyless hosted MCP auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable better-auth's mcp plugin (wraps oidcProvider) so the Gram-hosted MCP server authenticates users via OAuth ("Sign in with Google") instead of a pasted API key. - add OauthApplication/OauthAccessToken/OauthConsent tables (+ migration) - register Gram as an env-driven trusted OAuth client (inert when env unset) - HybridAuthGuard: validate MCP OAuth tokens via getMcpSession and bind org explicitly from active memberships (device-agent pattern) — single org binds, multiple orgs fail closed to avoid wrong-tenant access - add 6 tests for the MCP OAuth path Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/.env.example | 8 + apps/api/src/auth/auth.server.ts | 47 +++++ apps/api/src/auth/hybrid-auth.guard.spec.ts | 187 ++++++++++++++++++ apps/api/src/auth/hybrid-auth.guard.ts | 84 ++++++++ .../migration.sql | 85 ++++++++ packages/db/prisma/schema/auth.prisma | 66 +++++++ 6 files changed, 477 insertions(+) create mode 100644 apps/api/src/auth/hybrid-auth.guard.spec.ts create mode 100644 packages/db/prisma/migrations/20260528213310_add_oauth_provider_tables/migration.sql 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/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index 3f7a8f4611..46e9de293b 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,38 @@ 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, + skipConsent: false, + } + : 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 +521,20 @@ export const auth = betterAuth({ admin({ defaultRole: 'user', }), + // OAuth 2.0 / OIDC provider for hosted MCP (Gram). Wraps oidcProvider and + // exposes /api/auth/oauth2/* + /api/auth/.well-known/* and the + // auth.api.getMcpSession() helper used by HybridAuthGuard. + 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..a4d6c50f5f --- /dev/null +++ b/apps/api/src/auth/hybrid-auth.guard.spec.ts @@ -0,0 +1,187 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { HybridAuthGuard } from './hybrid-auth.guard'; +import { ApiKeyService } from './api-key.service'; + +// 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(); +jest.mock('@db', () => ({ + db: { + user: { findUnique: (...args: unknown[]) => mockUserFindUnique(...args) }, + member: { findMany: (...args: unknown[]) => mockMemberFindMany(...args) }, + }, +})); + +// Avoid ESM issues from the api-key.service import chain (it imports @trycompai/auth). +jest.mock('@trycompai/auth', () => ({})); + +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); + }); + + 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', + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + 'No active organization', + ); + }); + + it('fails closed for a multi-org user (no silent tenant selection)', 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' }, + ]); + + const { context, request } = createContext({ + authorization: 'Bearer mcp_access_token', + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + 'multiple organizations', + ); + // No tenant must have been bound. + expect(request.organizationId).toBe(''); + }); + + 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); + }); +}); diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index c97f2b43bf..09b87c1680 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -169,6 +169,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, skipOrgCheck)) { + return true; + } throw new UnauthorizedException('Invalid or expired session'); } @@ -248,4 +253,83 @@ 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 — the same approach as the + * device-agent (enumerate memberships, then bind to one), not a "most recent" + * guess. One org is used directly; multiple orgs fail closed because MCP org + * selection isn't supported yet (avoids silently acting on the wrong tenant). + * Roles come from the resolved member so the existing PermissionGuard enforces + * RBAC unchanged. + */ + private async tryMcpOAuthAuth( + request: AuthenticatedRequest, + headers: Headers, + skipOrgCheck: boolean, + ): 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.isPlatformAdmin = user.role === 'admin'; + + if (skipOrgCheck) { + this.logger.log(`MCP OAuth token authenticated for user ${user.id}`); + return true; + } + + // Bind the organization explicitly by enumerating active memberships, + // mirroring the device-agent's getMyOrganizations + explicit selection. + const memberships = await db.member.findMany({ + where: { userId, deactivated: false }, + select: { id: true, role: true, department: true, organizationId: true }, + }); + + if (memberships.length === 0) { + throw new UnauthorizedException( + 'No active organization for this MCP token.', + ); + } + if (memberships.length > 1) { + throw new UnauthorizedException( + 'This account belongs to multiple organizations. Selecting an ' + + 'organization for MCP access is not supported yet.', + ); + } + + const member = memberships[0]; + 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/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/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 45b82fba9f..2f16e5b550 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -32,6 +32,10 @@ model User { offboardingChecklistCompletions OffboardingChecklistCompletion[] @relation("OffboardingChecklistCompletedBy") offboardingAccessRevocations OffboardingAccessRevocation[] @relation("AccessRevocationRevokedBy") + oauthApplications OauthApplication[] + oauthAccessTokens OauthAccessToken[] + oauthConsents OauthConsent[] + @@unique([email]) } @@ -95,6 +99,68 @@ 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") +} + // JWT Plugin - Required by Better Auth JWT plugin // https://www.better-auth.com/docs/plugins/jwt model Jwks { From 0a3e2af6038b61158ee7bced9af486957d3e3e7c Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 17:58:32 -0400 Subject: [PATCH 03/29] docs: require endpoint summary+description for MCP dynamic-toolset discovery Add Rule 11 to the api-endpoint-contract skill + cursor rule: every endpoint needs a meaningful @ApiOperation summary/description (already enforced by openapi-docs.spec.ts), now correctness-critical because Gram dynamic toolsets discover tools via semantic search over descriptions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/api-endpoint-contract/SKILL.md | 25 +++++++++++++++++-- .cursor/rules/api-endpoint-contract.mdc | 8 +++++- 2 files changed, 30 insertions(+), 3 deletions(-) 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..d8d2187232 100644 --- a/.cursor/rules/api-endpoint-contract.mdc +++ b/.cursor/rules/api-endpoint-contract.mdc @@ -7,7 +7,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 +99,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. From 38ac017170d27d317be0a3c2862176822d9b779e Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 18:01:30 -0400 Subject: [PATCH 04/29] fix(api): add OpenAPI summaries + descriptions to offboarding endpoints The offboarding-checklist endpoints were missing @ApiOperation summaries/descriptions, failing the OpenAPI quality contract (openapi-docs.spec.ts: missingSummaries + invalidSeo). Added a meaningful summary + ~120-155 char description to all 15 endpoints (the SEO check requires the generated description to be >= 80 chars) and regenerated packages/docs/openapi.json. Descriptions also power the hosted MCP's dynamic-toolset semantic search, so these tools are now discoverable by agents. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../offboarding-checklist.controller.ts | 78 +++++++++- packages/docs/openapi.json | 146 ++++++++++++++---- 2 files changed, 189 insertions(+), 35 deletions(-) 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/packages/docs/openapi.json b/packages/docs/openapi.json index 6f3d2f65a2..39f275a872 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -22819,6 +22819,14 @@ "type": "string" } }, + { + "name": "frameworkInstanceId", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "formType", "required": true, @@ -22841,14 +22849,6 @@ ], "type": "string" } - }, - { - "name": "frameworkInstanceId", - "required": true, - "in": "query", - "schema": { - "type": "string" - } } ], "responses": { @@ -23288,6 +23288,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": { @@ -23304,14 +23305,13 @@ "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 +23321,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": { @@ -23333,14 +23334,25 @@ "apikey": [] } ], + "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": { @@ -23363,9 +23375,19 @@ "apikey": [] } ], + "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 +23395,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": [ { @@ -23404,14 +23427,25 @@ "apikey": [] } ], + "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": [ { @@ -23433,9 +23467,19 @@ "apikey": [] } ], + "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 +23487,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": [ { @@ -23464,9 +23509,19 @@ "apikey": [] } ], + "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 +23529,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": { @@ -23490,14 +23546,13 @@ "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 +23562,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": [ { @@ -23533,14 +23589,13 @@ "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 +23605,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": [ { @@ -23589,14 +23645,25 @@ "apikey": [] } ], + "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": [ { @@ -23626,9 +23693,19 @@ "apikey": [] } ], + "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 +23713,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": [ { @@ -23675,9 +23753,19 @@ "apikey": [] } ], + "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 +23773,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": [ { @@ -23711,14 +23800,13 @@ "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 +23816,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": [ { @@ -23754,14 +23843,13 @@ "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 +23859,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": [ { @@ -23806,14 +23895,13 @@ "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 +23909,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": [ { @@ -23856,14 +23945,13 @@ "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": { From aa7fde027d21d1c187ee43b24e38c1f803bbf648 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 18:06:32 -0400 Subject: [PATCH 05/29] fix(api): skip OAuth consent for first-party Gram MCP client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gram is Comp AI's own hosted MCP client, so the user's login is the authorization — no consent screen needed. Avoids building a consent page UI for v1. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/auth/auth.server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index 46e9de293b..c0b506aff3 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -211,7 +211,10 @@ const gramMcpClient = disabled: false, redirectUrls: [process.env.GRAM_OAUTH_REDIRECT_URI], metadata: null, - skipConsent: false, + // 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; From 81d11a5d51f55b95045990107045366c37aeaa0b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 18:27:56 -0400 Subject: [PATCH 06/29] feat(api): multi-org support for hosted MCP Multi-org users can now use the MCP instead of being blocked. A per-user McpOrgBinding (set at connect time) records which org their MCP/OAuth token acts on; HybridAuthGuard uses it: single-org binds automatically, multi-org uses the saved choice (if still a member), otherwise returns a helpful 'choose your org' error instead of a dead 401. Adds GET/PUT /v1/mcp/organization (web-app management endpoints, deny-listed from the public spec + MCP tools so the agent can't switch tenants). Tests: guard binding cases + service. Migration is additive (1 table). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/app.module.ts | 2 + apps/api/src/auth/hybrid-auth.guard.spec.ts | 55 ++++++++++- apps/api/src/auth/hybrid-auth.guard.ts | 33 +++++-- .../src/mcp/dto/set-mcp-organization.dto.ts | 13 +++ apps/api/src/mcp/mcp.controller.ts | 46 +++++++++ apps/api/src/mcp/mcp.module.ts | 12 +++ apps/api/src/mcp/mcp.service.spec.ts | 94 +++++++++++++++++++ apps/api/src/mcp/mcp.service.ts | 56 +++++++++++ apps/api/src/openapi/public-docs-quality.ts | 1 + .../migration.sql | 22 +++++ packages/db/prisma/schema/auth.prisma | 18 ++++ packages/db/prisma/schema/organization.prisma | 1 + 12 files changed, 342 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/mcp/dto/set-mcp-organization.dto.ts create mode 100644 apps/api/src/mcp/mcp.controller.ts create mode 100644 apps/api/src/mcp/mcp.module.ts create mode 100644 apps/api/src/mcp/mcp.service.spec.ts create mode 100644 apps/api/src/mcp/mcp.service.ts create mode 100644 packages/db/prisma/migrations/20260528222357_add_mcp_org_binding/migration.sql 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/hybrid-auth.guard.spec.ts b/apps/api/src/auth/hybrid-auth.guard.spec.ts index a4d6c50f5f..22b0d3cb85 100644 --- a/apps/api/src/auth/hybrid-auth.guard.spec.ts +++ b/apps/api/src/auth/hybrid-auth.guard.spec.ts @@ -20,10 +20,14 @@ jest.mock('./auth.server', () => ({ // (device-agent style) to bind the organization for the MCP OAuth path. const mockUserFindUnique = jest.fn(); const mockMemberFindMany = jest.fn(); +const mockMcpBindingFindUnique = jest.fn(); jest.mock('@db', () => ({ db: { user: { findUnique: (...args: unknown[]) => mockUserFindUnique(...args) }, member: { findMany: (...args: unknown[]) => mockMemberFindMany(...args) }, + mcpOrgBinding: { + findUnique: (...args: unknown[]) => mockMcpBindingFindUnique(...args), + }, }, })); @@ -66,6 +70,8 @@ describe('HybridAuthGuard — MCP OAuth path', () => { 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); }); it('authenticates a single-org user (admin) and binds org + roles', async () => { @@ -143,7 +149,7 @@ describe('HybridAuthGuard — MCP OAuth path', () => { ); }); - it('fails closed for a multi-org user (no silent tenant selection)', async () => { + 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', @@ -154,6 +160,7 @@ describe('HybridAuthGuard — MCP OAuth path', () => { { 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', @@ -166,6 +173,52 @@ describe('HybridAuthGuard — MCP OAuth path', () => { 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', + }); + + await expect(guard.canActivate(context)).rejects.toThrow( + 'multiple organizations', + ); + }); + it('marks platform admins from the user role', async () => { mockGetMcpSession.mockResolvedValue({ userId: 'usr_4', scopes: 'openid' }); mockUserFindUnique.mockResolvedValue({ diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index 09b87c1680..f5abe2fa41 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -262,10 +262,10 @@ export class HybridAuthGuard implements CanActivate { * when no organization can be resolved. * * The token carries the user identity only. The organization is resolved - * explicitly from the user's active memberships — the same approach as the - * device-agent (enumerate memberships, then bind to one), not a "most recent" - * guess. One org is used directly; multiple orgs fail closed because MCP org - * selection isn't supported yet (avoids silently acting on the wrong tenant). + * 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. */ @@ -314,14 +314,27 @@ export class HybridAuthGuard implements CanActivate { 'No active organization for this MCP token.', ); } + + let member = memberships[0]; if (memberships.length > 1) { - throw new UnauthorizedException( - 'This account belongs to multiple organizations. Selecting an ' + - 'organization for MCP access is not supported yet.', - ); + // 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) { + throw new UnauthorizedException( + 'This account belongs to multiple organizations. Choose your ' + + 'organization for AI/MCP access in Comp AI settings, then reconnect.', + ); + } + member = chosen; } - - const member = memberships[0]; request.organizationId = member.organizationId; request.memberId = member.id; request.memberDepartment = member.department; 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..1e3f62e212 --- /dev/null +++ b/apps/api/src/mcp/mcp.controller.ts @@ -0,0 +1,46 @@ +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 { SkipOrgCheck } from '../auth/skip-org-check.decorator'; +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). + * @SkipOrgCheck because choosing among your organizations isn't scoped to one. + */ +@ApiTags('MCP') +@Controller({ path: 'mcp', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +export class McpController { + constructor(private readonly mcpService: McpService) {} + + @Get('organization') + @SkipOrgCheck() + @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') + @SkipOrgCheck() + @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..af47634362 --- /dev/null +++ b/apps/api/src/mcp/mcp.service.spec.ts @@ -0,0 +1,94 @@ +import { ForbiddenException } from '@nestjs/common'; + +const mockMemberFindMany = jest.fn(); +const mockMemberFindFirst = jest.fn(); +const mockBindingFindUnique = jest.fn(); +const mockBindingUpsert = 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), + }, + }, +})); + +import { McpService } from './mcp.service'; + +describe('McpService', () => { + let service: McpService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new McpService(); + }); + + describe('getOrganizationSelection', () => { + it('returns the user orgs and the current valid selection', async () => { + mockMemberFindMany.mockResolvedValue([ + { organization: { id: 'org_a', name: 'Acme' } }, + { organization: { id: 'org_b', name: 'Beta' } }, + ]); + mockBindingFindUnique.mockResolvedValue({ organizationId: 'org_b' }); + + const result = await service.getOrganizationSelection('usr_1'); + + expect(result.organizations).toEqual([ + { id: 'org_a', name: 'Acme' }, + { id: 'org_b', name: 'Beta' }, + ]); + expect(result.selectedOrganizationId).toBe('org_b'); + }); + + it('drops a stale selection the user is no longer a member of', async () => { + mockMemberFindMany.mockResolvedValue([ + { organization: { id: 'org_a', name: 'Acme' } }, + ]); + mockBindingFindUnique.mockResolvedValue({ organizationId: 'org_gone' }); + + const result = await service.getOrganizationSelection('usr_1'); + + expect(result.selectedOrganizationId).toBeNull(); + }); + + it('returns null selection when none is set', async () => { + mockMemberFindMany.mockResolvedValue([ + { organization: { id: 'org_a', name: 'Acme' } }, + ]); + mockBindingFindUnique.mockResolvedValue(null); + + const result = await service.getOrganizationSelection('usr_1'); + + expect(result.selectedOrganizationId).toBeNull(); + }); + }); + + describe('setOrganization', () => { + it('upserts the binding when the user is a member', async () => { + mockMemberFindFirst.mockResolvedValue({ id: 'mem_1' }); + 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(); + }); + }); +}); diff --git a/apps/api/src/mcp/mcp.service.ts b/apps/api/src/mcp/mcp.service.ts new file mode 100644 index 0000000000..ba1e47ee85 --- /dev/null +++ b/apps/api/src/mcp/mcp.service.ts @@ -0,0 +1,56 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { db } from '@db'; + +@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). + */ + async getOrganizationSelection(userId: string) { + const memberships = await db.member.findMany({ + where: { userId, deactivated: false }, + select: { organization: { select: { id: true, name: true } } }, + }); + const organizations = memberships.map((m) => ({ + id: m.organization.id, + name: m.organization.name, + })); + + 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: { id: true }, + }); + if (!member) { + throw new ForbiddenException( + 'You are not a member of the selected organization.', + ); + } + + await db.mcpOrgBinding.upsert({ + where: { userId }, + create: { userId, organizationId }, + update: { organizationId }, + }); + + return { organizationId }; + } +} 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/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 2f16e5b550..6c9c12383c 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -35,6 +35,7 @@ model User { oauthApplications OauthApplication[] oauthAccessTokens OauthAccessToken[] oauthConsents OauthConsent[] + mcpOrgBinding McpOrgBinding? @@unique([email]) } @@ -161,6 +162,23 @@ model OauthConsent { @@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[] From fbef686690afff245330a560e2b0a10583d7bba0 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 18:28:22 -0400 Subject: [PATCH 07/29] docs(api): correct hosted-MCP endpoint paths to /mcp/* in comment The mcp plugin registers /api/auth/mcp/{authorize,token,register}, not /oauth2/* (those aren't registered). Gram's OAuth proxy targets /mcp/*. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/auth/auth.server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index c0b506aff3..83f6d1bd68 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -525,8 +525,9 @@ export const auth = betterAuth({ defaultRole: 'user', }), // OAuth 2.0 / OIDC provider for hosted MCP (Gram). Wraps oidcProvider and - // exposes /api/auth/oauth2/* + /api/auth/.well-known/* and the - // auth.api.getMcpSession() helper used by HybridAuthGuard. + // 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 From 8d4baea0425d2a33eb5533fceccecfc367cc7348 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 18:39:48 -0400 Subject: [PATCH 08/29] feat(app): add MCP org picker to user settings Adds an AI / MCP organization section to User Settings so multi-org users can choose which org their MCP connection acts on, and switch it anytime (applies on the next request, no reconnect). Renders only for users in more than one org; calls GET/PUT /v1/mcp/organization via a useSWR hook with optimistic update + rollback. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/McpOrganizationSelector.tsx | 86 +++++++++++++++++++ .../settings/user/hooks/useMcpOrganization.ts | 65 ++++++++++++++ .../app/(app)/[orgId]/settings/user/page.tsx | 64 ++++++++------ 3 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/settings/user/components/McpOrganizationSelector.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/settings/user/hooks/useMcpOrganization.ts diff --git a/apps/app/src/app/(app)/[orgId]/settings/user/components/McpOrganizationSelector.tsx b/apps/app/src/app/(app)/[orgId]/settings/user/components/McpOrganizationSelector.tsx new file mode 100644 index 0000000000..e38ebdaf3e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/settings/user/components/McpOrganizationSelector.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { + Button, + Section, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@trycompai/design-system'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { + type McpOrganizationData, + useMcpOrganization, +} from '../hooks/useMcpOrganization'; + +interface Props { + initialData: McpOrganizationData; +} + +export function McpOrganizationSelector({ initialData }: Props) { + const { data, saveOrganization } = useMcpOrganization({ initialData }); + const organizations = data?.organizations ?? []; + const savedOrgId = data?.selectedOrganizationId ?? null; + + const [selectedOrgId, setSelectedOrgId] = useState(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'} + + } + > +
+ +
+
+ ); +} 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..2a356a1d7d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/settings/user/hooks/useMcpOrganization.ts @@ -0,0 +1,65 @@ +'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) { + // Roll back to the last known good value. + if (previous) { + await mutate(previous, false); + } + 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} +
); } From 28bbc112214d95ee32510b3292aa261cf6697dc6 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 19:16:00 -0400 Subject: [PATCH 09/29] fix(api): return 403 (not 401) when an MCP user must choose an org A 401 makes MCP clients re-run sign-in in a loop; the token is valid and the user just needs to pick an org, so 403 is correct and surfaces the message to the agent. Also re-throw HttpExceptions from the session-auth catch so the 403 propagates instead of collapsing to 401. Updates the message to 'try again' (no reconnect needed). Adds a proactive 'pick an organization' warning callout in the User Settings AI/MCP section when nothing is selected yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/auth/hybrid-auth.guard.spec.ts | 21 +++++---- apps/api/src/auth/hybrid-auth.guard.ts | 18 +++++--- .../components/McpOrganizationSelector.tsx | 46 ++++++++++++------- 3 files changed, 52 insertions(+), 33 deletions(-) diff --git a/apps/api/src/auth/hybrid-auth.guard.spec.ts b/apps/api/src/auth/hybrid-auth.guard.spec.ts index 22b0d3cb85..bf3b3715b5 100644 --- a/apps/api/src/auth/hybrid-auth.guard.spec.ts +++ b/apps/api/src/auth/hybrid-auth.guard.spec.ts @@ -1,5 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { + ExecutionContext, + ForbiddenException, + UnauthorizedException, +} from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { HybridAuthGuard } from './hybrid-auth.guard'; import { ApiKeyService } from './api-key.service'; @@ -144,9 +148,8 @@ describe('HybridAuthGuard — MCP OAuth path', () => { authorization: 'Bearer mcp_access_token', }); - await expect(guard.canActivate(context)).rejects.toThrow( - 'No active organization', - ); + // 403 (authenticated, but no org) — not a 401 that would trigger re-auth. + await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); }); it('multi-org with no saved choice → asks them to pick (no silent tenant)', async () => { @@ -166,9 +169,8 @@ describe('HybridAuthGuard — MCP OAuth path', () => { authorization: 'Bearer mcp_access_token', }); - await expect(guard.canActivate(context)).rejects.toThrow( - 'multiple organizations', - ); + // 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(''); }); @@ -214,9 +216,8 @@ describe('HybridAuthGuard — MCP OAuth path', () => { authorization: 'Bearer mcp_access_token', }); - await expect(guard.canActivate(context)).rejects.toThrow( - 'multiple organizations', - ); + // 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 () => { diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index f5abe2fa41..676611ce6b 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, @@ -245,7 +247,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; } @@ -310,9 +314,9 @@ export class HybridAuthGuard implements CanActivate { }); if (memberships.length === 0) { - throw new UnauthorizedException( - 'No active organization for this MCP token.', - ); + // Authenticated, but the user has no organization — not an auth failure, + // so 403 (not 401) keeps the MCP client from looping on re-authentication. + throw new ForbiddenException('No active organization for this MCP token.'); } let member = memberships[0]; @@ -328,9 +332,11 @@ export class HybridAuthGuard implements CanActivate { ? memberships.find((m) => m.organizationId === binding.organizationId) : undefined; if (!chosen) { - throw new UnauthorizedException( + // 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 reconnect.', + 'organization for AI/MCP access in Comp AI settings, then try again.', ); } member = chosen; diff --git a/apps/app/src/app/(app)/[orgId]/settings/user/components/McpOrganizationSelector.tsx b/apps/app/src/app/(app)/[orgId]/settings/user/components/McpOrganizationSelector.tsx index e38ebdaf3e..f0c05c646d 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/user/components/McpOrganizationSelector.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/user/components/McpOrganizationSelector.tsx @@ -1,6 +1,8 @@ 'use client'; import { + Alert, + AlertDescription, Button, Section, Select, @@ -63,23 +65,33 @@ export function McpOrganizationSelector({ initialData }: Props) { } > -
- +
+ {!savedOrgId ? ( + + + Pick an organization to start using your AI assistant. Until you + choose one, AI / MCP requests can't act on your data. + + + ) : null} +
+ +
); From a7ccc9a6e4996647cecb912213d21bd110ac92b8 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 19:22:10 -0400 Subject: [PATCH 10/29] fix(api): block org-less users from the MCP entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An MCP token is only usable by a member of at least one organization. The membership check now runs before the skipOrgCheck shortcut, so a user with zero memberships (a stranger who completed Google sign-in, or someone removed from all orgs) is rejected with 403 on EVERY MCP tool — including org-agnostic ones. They can never reach any org's data, and a user can only ever act on orgs they belong to. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/auth/hybrid-auth.guard.spec.ts | 22 +++++++++++++++++ apps/api/src/auth/hybrid-auth.guard.ts | 27 +++++++++++++-------- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/apps/api/src/auth/hybrid-auth.guard.spec.ts b/apps/api/src/auth/hybrid-auth.guard.spec.ts index bf3b3715b5..2e6acda4b8 100644 --- a/apps/api/src/auth/hybrid-auth.guard.spec.ts +++ b/apps/api/src/auth/hybrid-auth.guard.spec.ts @@ -7,6 +7,7 @@ import { 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(); @@ -152,6 +153,27 @@ describe('HybridAuthGuard — MCP OAuth path', () => { 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({ diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index 676611ce6b..a8832b6329 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -301,22 +301,29 @@ export class HybridAuthGuard implements CanActivate { request.isServiceToken = false; request.isPlatformAdmin = user.role === 'admin'; - if (skipOrgCheck) { - this.logger.log(`MCP OAuth token authenticated for user ${user.id}`); - return true; - } - - // Bind the organization explicitly by enumerating active memberships, - // mirroring the device-agent's getMyOrganizations + explicit selection. + // 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 the user has no organization — not an auth failure, - // so 403 (not 401) keeps the MCP client from looping on re-authentication. - throw new ForbiddenException('No active organization for this MCP token.'); + // 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.', + ); + } + + // The user has an org. Endpoints that don't need a *specific* one + // (onboarding lookups) can proceed now. + if (skipOrgCheck) { + this.logger.log(`MCP OAuth token authenticated for user ${user.id}`); + return true; } let member = memberships[0]; From 653fc3be1ecc3243fcecf330cb71450a5ed5039b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 19:30:43 -0400 Subject: [PATCH 11/29] feat(api): gate MCP on app-access role, not just org membership MCP now follows the same access rule as the web app: a user can only use it if their role grants app access (app:read) in the operative org. Owner/admin/auditor and custom roles with the App Access toggle qualify; Portal-only roles (employee/contractor) are rejected with 403. Combined with the existing 'must be a member of an org' check, this means: only customers, and only app-access roles, can use the MCP. Adds a reusable hasAppAccess(orgId, roleString) helper (built-in + custom roles, comma-separated union). The settings picker now only offers app-access orgs, and setOrganization validates app access before saving. 24 unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/auth/app-access.spec.ts | 62 +++++++++++++++ apps/api/src/auth/app-access.ts | 51 +++++++++++++ apps/api/src/auth/hybrid-auth.guard.spec.ts | 83 ++++++++++++++++++++- apps/api/src/auth/hybrid-auth.guard.ts | 28 ++++--- apps/api/src/mcp/mcp.service.spec.ts | 51 ++++++++++--- apps/api/src/mcp/mcp.service.ts | 28 +++++-- 6 files changed, 273 insertions(+), 30 deletions(-) create mode 100644 apps/api/src/auth/app-access.spec.ts create mode 100644 apps/api/src/auth/app-access.ts 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..2c9741aa6c --- /dev/null +++ b/apps/api/src/auth/app-access.spec.ts @@ -0,0 +1,62 @@ +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); + }); +}); diff --git a/apps/api/src/auth/app-access.ts b/apps/api/src/auth/app-access.ts new file mode 100644 index 0000000000..49830dc954 --- /dev/null +++ b/apps/api/src/auth/app-access.ts @@ -0,0 +1,51 @@ +import { BUILT_IN_ROLE_PERMISSIONS } from '@trycompai/auth'; +import { db } from '@db'; + +/** + * Whether a member's role(s) grant **app access** (`app:read`) in the given org. + * + * This is the same gate the web app uses to decide who can use the product + * (owner/admin/auditor + custom roles with the "App Access" toggle) versus + * Portal-only roles (employee/contractor). Built-in roles resolve from the + * static `BUILT_IN_ROLE_PERMISSIONS` map; custom roles from the org's + * `organization_role` rows. `member.role` is comma-separated and treated as a + * union — ANY granting role is sufficient. + */ +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 customRoleNames: string[] = []; + for (const role of roles) { + const builtIn = BUILT_IN_ROLE_PERMISSIONS[role]; + if (builtIn) { + if (builtIn['app']?.includes('read')) return true; + } else { + customRoleNames.push(role); + } + } + + if (customRoleNames.length === 0) return false; + + const customRoles = await db.organizationRole.findMany({ + where: { organizationId, name: { in: customRoleNames } }, + select: { permissions: true }, + }); + for (const customRole of customRoles) { + const perms = + typeof customRole.permissions === 'string' + ? (JSON.parse(customRole.permissions) as Record) + : (customRole.permissions as Record); + if (perms?.['app']?.includes('read')) return true; + } + + return false; +} diff --git a/apps/api/src/auth/hybrid-auth.guard.spec.ts b/apps/api/src/auth/hybrid-auth.guard.spec.ts index 2e6acda4b8..e526ca6e5f 100644 --- a/apps/api/src/auth/hybrid-auth.guard.spec.ts +++ b/apps/api/src/auth/hybrid-auth.guard.spec.ts @@ -26,6 +26,7 @@ jest.mock('./auth.server', () => ({ 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) }, @@ -33,11 +34,23 @@ jest.mock('@db', () => ({ mcpOrgBinding: { findUnique: (...args: unknown[]) => mockMcpBindingFindUnique(...args), }, + organizationRole: { + findMany: (...args: unknown[]) => mockOrgRoleFindMany(...args), + }, }, })); -// Avoid ESM issues from the api-key.service import chain (it imports @trycompai/auth). -jest.mock('@trycompai/auth', () => ({})); +// 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; @@ -77,6 +90,8 @@ describe('HybridAuthGuard — MCP OAuth path', () => { 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 () => { @@ -260,4 +275,68 @@ describe('HybridAuthGuard — MCP OAuth path', () => { await expect(guard.canActivate(context)).resolves.toBe(true); 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 a8832b6329..4c73a7a3df 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -10,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'; @@ -173,7 +174,7 @@ export class HybridAuthGuard implements CanActivate { 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, skipOrgCheck)) { + if (await this.tryMcpOAuthAuth(request, headers)) { return true; } throw new UnauthorizedException('Invalid or expired session'); @@ -272,11 +273,16 @@ export class HybridAuthGuard implements CanActivate { * 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, - skipOrgCheck: boolean, ): Promise { const token = await auth.api.getMcpSession({ headers }).catch(() => null); if (!token?.userId) { @@ -319,13 +325,6 @@ export class HybridAuthGuard implements CanActivate { ); } - // The user has an org. Endpoints that don't need a *specific* one - // (onboarding lookups) can proceed now. - if (skipOrgCheck) { - this.logger.log(`MCP OAuth token authenticated for user ${user.id}`); - return true; - } - let member = memberships[0]; if (memberships.length > 1) { // Multi-org: use the org the user chose for MCP (set at connect time), @@ -348,6 +347,17 @@ export class HybridAuthGuard implements CanActivate { } 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. + if (!(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; diff --git a/apps/api/src/mcp/mcp.service.spec.ts b/apps/api/src/mcp/mcp.service.spec.ts index af47634362..ea00801885 100644 --- a/apps/api/src/mcp/mcp.service.spec.ts +++ b/apps/api/src/mcp/mcp.service.spec.ts @@ -4,6 +4,7 @@ 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: { @@ -14,6 +15,19 @@ jest.mock('@db', () => ({ 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'] }, }, })); @@ -24,29 +38,32 @@ describe('McpService', () => { beforeEach(() => { jest.clearAllMocks(); + mockOrgRoleFindMany.mockResolvedValue([]); service = new McpService(); }); describe('getOrganizationSelection', () => { - it('returns the user orgs and the current valid selection', async () => { + it('returns only app-access orgs and the current selection', async () => { mockMemberFindMany.mockResolvedValue([ - { organization: { id: 'org_a', name: 'Acme' } }, - { organization: { id: 'org_b', name: 'Beta' } }, + { 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_b' }); + mockBindingFindUnique.mockResolvedValue({ organizationId: 'org_c' }); const result = await service.getOrganizationSelection('usr_1'); expect(result.organizations).toEqual([ { id: 'org_a', name: 'Acme' }, - { id: 'org_b', name: 'Beta' }, + { id: 'org_c', name: 'Gamma' }, ]); - expect(result.selectedOrganizationId).toBe('org_b'); + expect(result.selectedOrganizationId).toBe('org_c'); }); - it('drops a stale selection the user is no longer a member of', async () => { + it('drops a selection the user can no longer use', async () => { mockMemberFindMany.mockResolvedValue([ - { organization: { id: 'org_a', name: 'Acme' } }, + { role: 'owner', organization: { id: 'org_a', name: 'Acme' } }, ]); mockBindingFindUnique.mockResolvedValue({ organizationId: 'org_gone' }); @@ -55,21 +72,22 @@ describe('McpService', () => { expect(result.selectedOrganizationId).toBeNull(); }); - it('returns null selection when none is set', async () => { + it('excludes Portal-only orgs entirely', async () => { mockMemberFindMany.mockResolvedValue([ - { organization: { id: 'org_a', name: 'Acme' } }, + { 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('upserts the binding when the user is a member', async () => { - mockMemberFindFirst.mockResolvedValue({ id: 'mem_1' }); + 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'); @@ -90,5 +108,14 @@ describe('McpService', () => { ); 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 index ba1e47ee85..618279f08c 100644 --- a/apps/api/src/mcp/mcp.service.ts +++ b/apps/api/src/mcp/mcp.service.ts @@ -1,21 +1,30 @@ 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). + * 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: { organization: { select: { id: true, name: true } } }, + select: { role: true, organization: { select: { id: true, name: true } } }, }); - const organizations = memberships.map((m) => ({ - id: m.organization.id, - name: m.organization.name, - })); + + const organizations: Array<{ id: string; name: string }> = []; + for (const membership of memberships) { + if (await hasAppAccess(membership.organization.id, membership.role)) { + organizations.push({ + id: membership.organization.id, + name: membership.organization.name, + }); + } + } const binding = await db.mcpOrgBinding.findUnique({ where: { userId }, @@ -37,13 +46,18 @@ export class McpService { async setOrganization(userId: string, organizationId: string) { const member = await db.member.findFirst({ where: { userId, organizationId, deactivated: false }, - select: { id: true }, + 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 }, From bd6ef4cb00695b8511da9c185adaada7210f74ef Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 19:41:05 -0400 Subject: [PATCH 12/29] docs: auto-attach MCP endpoint-contract rule + sync descriptions rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor rule now auto-attaches on apps/api controllers + DTOs via globs (not just description-triggered), so it applies reliably when editing endpoints. AGENTS.md (Codex/agents) gains the rule that every endpoint needs a meaningful @ApiOperation summary/description — CI-enforced and required for dynamic-toolset discovery — keeping it in sync with the Claude skill + Cursor rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- .cursor/rules/api-endpoint-contract.mdc | 1 + AGENTS.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.cursor/rules/api-endpoint-contract.mdc b/.cursor/rules/api-endpoint-contract.mdc index d8d2187232..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 --- 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. From 7b61ed56b94835ec7fcd47b08802ad1cd4567200 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 19:48:46 -0400 Subject: [PATCH 13/29] fix(api): add PermissionGuard + app:read to MCP controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses cubic P1: the MCP controller had HybridAuthGuard only, so the PUT bypassed API-key scope enforcement and the mutation audit hook (AuditLogInterceptor only logs when @RequirePermission is present). Now matches the rest of the API (e.g. people controller): class-level HybridAuthGuard + PermissionGuard, with @RequirePermission('app','read') on both endpoints — gating on app access (consistent with the MCP access rule) and logging the PUT mutation. Dropped @SkipOrgCheck (these are web-only, deny-listed from MCP; an active org is present for web sessions, which PermissionGuard + the audit log need). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/mcp/mcp.controller.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/api/src/mcp/mcp.controller.ts b/apps/api/src/mcp/mcp.controller.ts index 1e3f62e212..bf91207984 100644 --- a/apps/api/src/mcp/mcp.controller.ts +++ b/apps/api/src/mcp/mcp.controller.ts @@ -2,24 +2,30 @@ 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 { SkipOrgCheck } from '../auth/skip-org-check.decorator'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; 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). - * @SkipOrgCheck because choosing among your organizations isn't scoped to one. + * + * Gated on app access (`app:read`) like the rest of the product: only roles that + * can use the app may manage its MCP settings. Going through PermissionGuard + + * @RequirePermission also enforces API-key scopes and records the mutation in + * the audit log (the AuditLogInterceptor only logs when @RequirePermission is + * present). */ @ApiTags('MCP') @Controller({ path: 'mcp', version: '1' }) -@UseGuards(HybridAuthGuard) +@UseGuards(HybridAuthGuard, PermissionGuard) @ApiSecurity('apikey') export class McpController { constructor(private readonly mcpService: McpService) {} @Get('organization') - @SkipOrgCheck() + @RequirePermission('app', 'read') @ApiOperation({ summary: 'Get your MCP organization selection', description: @@ -30,7 +36,7 @@ export class McpController { } @Put('organization') - @SkipOrgCheck() + @RequirePermission('app', 'read') @ApiOperation({ summary: 'Set your MCP organization', description: From 03dc24a2b2cc6fb86714c8856587bc3f458328bc Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 20:08:52 -0400 Subject: [PATCH 14/29] fix(api): address cubic review findings on the MCP auth path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: PermissionGuard rejected MCP OAuth calls — it authorizes via session-based auth.api.hasPermission, but OAuth tokens aren't sessions, so every permission-gated MCP call would 403. Added an isMcpOAuth marker + a guard branch that authorizes from the roles already resolved by HybridAuthGuard (via resolveRolePermissions). This was the critical one — the whole OAuth flow was broken without it. P1: MCP controller used @UserId() with no session-only guard, so a scoped API key would pass the guards then crash at @UserId() with a 500. Added SessionOnlyGuard (clean 403 for API keys/service tokens). P2: app-access.ts hardened — guarded JSON.parse (malformed custom-role perms no longer throw) and own-property role lookup (a custom role named e.g. 'constructor' is no longer misclassified as built-in). P2: mcp.service resolves app-access concurrently (Promise.all) instead of a serial N+1 loop. Adds tests for the MCP OAuth authorization path + the robustness cases (46 auth/mcp tests green). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/auth/app-access.spec.ts | 14 +++ apps/api/src/auth/app-access.ts | 116 +++++++++++++++------ apps/api/src/auth/hybrid-auth.guard.ts | 1 + apps/api/src/auth/permission.guard.spec.ts | 58 +++++++++++ apps/api/src/auth/permission.guard.ts | 22 ++++ apps/api/src/auth/types.ts | 1 + apps/api/src/mcp/mcp.controller.ts | 14 +-- apps/api/src/mcp/mcp.service.ts | 16 +-- 8 files changed, 195 insertions(+), 47 deletions(-) diff --git a/apps/api/src/auth/app-access.spec.ts b/apps/api/src/auth/app-access.spec.ts index 2c9741aa6c..85524669e8 100644 --- a/apps/api/src/auth/app-access.spec.ts +++ b/apps/api/src/auth/app-access.spec.ts @@ -59,4 +59,18 @@ describe('hasAppAccess', () => { 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 index 49830dc954..b8bce4303d 100644 --- a/apps/api/src/auth/app-access.ts +++ b/apps/api/src/auth/app-access.ts @@ -1,51 +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); + } +} + /** - * Whether a member's role(s) grant **app access** (`app:read`) in the given org. - * - * This is the same gate the web app uses to decide who can use the product - * (owner/admin/auditor + custom roles with the "App Access" toggle) versus - * Portal-only roles (employee/contractor). Built-in roles resolve from the - * static `BUILT_IN_ROLE_PERMISSIONS` map; custom roles from the org's - * `organization_role` rows. `member.role` is comma-separated and treated as a - * union — ANY granting role is sufficient. + * 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 customRoleNames: string[] = []; - for (const role of roles) { - const builtIn = BUILT_IN_ROLE_PERMISSIONS[role]; - if (builtIn) { - if (builtIn['app']?.includes('read')) return true; - } else { - customRoleNames.push(role); - } - } - - if (customRoleNames.length === 0) return false; - - const customRoles = await db.organizationRole.findMany({ - where: { organizationId, name: { in: customRoleNames } }, - select: { permissions: true }, - }); - for (const customRole of customRoles) { - const perms = - typeof customRole.permissions === 'string' - ? (JSON.parse(customRole.permissions) as Record) - : (customRole.permissions as Record); - if (perms?.['app']?.includes('read')) return true; - } - - return false; + const perms = await resolveRolePermissions(organizationId, roles); + return permissionsGrant(perms, 'app', 'read'); } diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index 4c73a7a3df..6fc0883088 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -305,6 +305,7 @@ export class HybridAuthGuard implements CanActivate { 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. 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/mcp/mcp.controller.ts b/apps/api/src/mcp/mcp.controller.ts index bf91207984..1da8feb87d 100644 --- a/apps/api/src/mcp/mcp.controller.ts +++ b/apps/api/src/mcp/mcp.controller.ts @@ -4,6 +4,7 @@ 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'; @@ -11,15 +12,16 @@ 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). * - * Gated on app access (`app:read`) like the rest of the product: only roles that - * can use the app may manage its MCP settings. Going through PermissionGuard + - * @RequirePermission also enforces API-key scopes and records the mutation in - * the audit log (the AuditLogInterceptor only logs when @RequirePermission is - * present). + * 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, PermissionGuard) +@UseGuards(HybridAuthGuard, SessionOnlyGuard, PermissionGuard) @ApiSecurity('apikey') export class McpController { constructor(private readonly mcpService: McpService) {} diff --git a/apps/api/src/mcp/mcp.service.ts b/apps/api/src/mcp/mcp.service.ts index 618279f08c..9f6aa2a3d2 100644 --- a/apps/api/src/mcp/mcp.service.ts +++ b/apps/api/src/mcp/mcp.service.ts @@ -16,15 +16,17 @@ export class McpService { select: { role: true, organization: { select: { id: true, name: true } } }, }); - const organizations: Array<{ id: string; name: string }> = []; - for (const membership of memberships) { - if (await hasAppAccess(membership.organization.id, membership.role)) { - organizations.push({ + // 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 }, From 61c8016fad7fe0bf8e4cac4a6c0077e6168698ca Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 20:17:02 -0400 Subject: [PATCH 15/29] fix: platform-admin MCP bypass + revalidate on save error P2: platform admins were blocked by the MCP app-access gate in HybridAuthGuard before PermissionGuard's isPlatformAdmin bypass could apply, incorrectly denying privileged users whose member role lacks app access. Now the gate is skipped for platform admins, consistent with the normal session path. P2: useMcpOrganization rolled back to a snapshot on save error, which can restore a stale selection. Now revalidates from the server instead (matches the team's SWR norm). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/auth/hybrid-auth.guard.spec.ts | 22 +++++++++++++++++++ apps/api/src/auth/hybrid-auth.guard.ts | 7 +++++- .../settings/user/hooks/useMcpOrganization.ts | 7 +++--- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/api/src/auth/hybrid-auth.guard.spec.ts b/apps/api/src/auth/hybrid-auth.guard.spec.ts index e526ca6e5f..ec4b7ce528 100644 --- a/apps/api/src/auth/hybrid-auth.guard.spec.ts +++ b/apps/api/src/auth/hybrid-auth.guard.spec.ts @@ -276,6 +276,28 @@ describe('HybridAuthGuard — MCP OAuth path', () => { 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({ diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index 6fc0883088..0937b91d3b 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -352,7 +352,12 @@ export class HybridAuthGuard implements CanActivate { // 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. - if (!(await hasAppAccess(member.organizationId, member.role))) { + // 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.', 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 index 2a356a1d7d..7842224fb0 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/user/hooks/useMcpOrganization.ts +++ b/apps/app/src/app/(app)/[orgId]/settings/user/hooks/useMcpOrganization.ts @@ -47,10 +47,9 @@ export function useMcpOrganization(options?: UseMcpOrganizationOptions) { if (response.error) throw new Error(response.error); await mutate(); } catch (err) { - // Roll back to the last known good value. - if (previous) { - await mutate(previous, false); - } + // Revalidate from the server rather than restoring a possibly-stale + // snapshot (another tab/request may have changed the selection). + await mutate(); throw err; } }; From 7118c6e7db83752e82f2e28321bbbcbb1a4f510d Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 20:32:02 -0400 Subject: [PATCH 16/29] fix(api): disable declaration emit in the build (TS4023 from mcp plugin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nest build emits .d.ts (declaration:true), and the better-auth mcp plugin's internal MCPOptions type leaks into the inferred type of the exported 'auth' but isn't exported/nameable, so declaration emit fails with TS4023 and breaks the Docker build. The API is an app, not a library — it never consumes its own .d.ts — so declaration emit is unnecessary. Disabled it in tsconfig.build.json (type-checking via tsc --noEmit is unaffected; this also future-proofs against other plugins leaking internal option types). Verified with tsc -p tsconfig.build.json (the path nest build uses). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/tsconfig.build.json | 3 +++ 1 file changed, 3 insertions(+) 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"] } From 5151214d315ab61983e6928cf67188c3e19e6206 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 21:05:56 -0400 Subject: [PATCH 17/29] chore: auto-sync OpenAPI source to Gram via CI (no manual uploads) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds gram.json (declarative Gram deployment pointing at the clean public spec packages/docs/openapi.json) + a workflow that runs 'gram push' whenever the spec changes on main (and on manual dispatch). Keeps the hosted MCP's tools in sync with the API automatically — no UI uploads. Requires repo secrets GRAM_API_KEY (provider-scoped Gram key) and GRAM_PROJECT (project slug). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gram-sync.yml | 31 +++++++++++++++++++++++++++++++ gram.json | 12 ++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 .github/workflows/gram-sync.yml create mode 100644 gram.json diff --git a/.github/workflows/gram-sync.yml b/.github/workflows/gram-sync.yml new file mode 100644 index 0000000000..29bb731bff --- /dev/null +++ b/.github/workflows/gram-sync.yml @@ -0,0 +1,31 @@ +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: + +jobs: + push: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Gram CLI + run: | + curl -fsSL https://go.getgram.ai/cli.sh | bash + # The installer puts the binary under ~/.gram; expose it on PATH. + echo "$HOME/.gram/bin" >> "$GITHUB_PATH" + + - name: Push OpenAPI source to Gram + run: gram push --api-key "$GRAM_API_KEY" --project "$GRAM_PROJECT" --config gram.json + env: + GRAM_API_KEY: ${{ secrets.GRAM_API_KEY }} + GRAM_PROJECT: ${{ secrets.GRAM_PROJECT }} 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" + } + ] +} From 3d19a73cd51c5ea9cf2b91cc87856d475f665083 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 21:13:21 -0400 Subject: [PATCH 18/29] fix(ci): correct gram push command (add GRAM_ORG, fix install PATH) Verified against the Gram CLI source (speakeasy-api/gram): 'gram push' requires --org (GRAM_ORG) in addition to --project; the installer drops the binary in /usr/local/bin (already on PATH), so the manual GITHUB_PATH line was wrong. Org+project slugs aren't secret (they're in the dashboard URL), so they're hardcoded as env; only GRAM_API_KEY stays a secret. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gram-sync.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/gram-sync.yml b/.github/workflows/gram-sync.yml index 29bb731bff..baba1f4385 100644 --- a/.github/workflows/gram-sync.yml +++ b/.github/workflows/gram-sync.yml @@ -15,17 +15,20 @@ on: 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 - run: | - curl -fsSL https://go.getgram.ai/cli.sh | bash - # The installer puts the binary under ~/.gram; expose it on PATH. - echo "$HOME/.gram/bin" >> "$GITHUB_PATH" + # 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 - run: gram push --api-key "$GRAM_API_KEY" --project "$GRAM_PROJECT" --config gram.json - env: - GRAM_API_KEY: ${{ secrets.GRAM_API_KEY }} - GRAM_PROJECT: ${{ secrets.GRAM_PROJECT }} + # 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 From e53e2afa38253df7cf5ba7b319584bbfb25becbc Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 21:16:10 -0400 Subject: [PATCH 19/29] fix(ci): set least-privilege GITHUB_TOKEN permissions (contents: read) Addresses cubic + CodeQL findings: the workflow only checks out the repo to push the spec, so it needs read-only contents access, not the default (often write) token permissions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/gram-sync.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/gram-sync.yml b/.github/workflows/gram-sync.yml index baba1f4385..0dff80faba 100644 --- a/.github/workflows/gram-sync.yml +++ b/.github/workflows/gram-sync.yml @@ -12,6 +12,10 @@ on: - "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 From ff12b81ca902ea19603f0cd1150fb63d0ae8e819 Mon Sep 17 00:00:00 2001 From: speakeasybot Date: Fri, 29 May 2026 01:18:21 +0000 Subject: [PATCH 20/29] ci: regenerated with OpenAPI Doc , Speakeasy CLI 1.768.2 --- apps/mcp-server/.speakeasy/gen.lock | 168 +++++++++--------- apps/mcp-server/.speakeasy/gen.yaml | 2 +- apps/mcp-server/.speakeasy/out.openapi.yaml | 124 ++++++++++--- apps/mcp-server/.speakeasy/workflow.lock | 8 +- apps/mcp-server/README.md | 4 +- apps/mcp-server/RELEASES.md | 10 +- apps/mcp-server/manifest.json | 32 ++-- apps/mcp-server/package-lock.json | 4 +- apps/mcp-server/package.json | 2 +- ...ardingChecklistControllerCompleteItemV1.ts | 5 + ...ChecklistControllerCreateTemplateItemV1.ts | 5 + ...ChecklistControllerDeleteTemplateItemV1.ts | 5 + ...gChecklistControllerExportAllEvidenceV1.ts | 2 +- ...dingChecklistControllerExportEvidenceV1.ts | 2 +- ...ecklistControllerGetAccessRevocationsV1.ts | 2 +- ...ChecklistControllerGetMemberChecklistV1.ts | 5 + ...klistControllerGetPendingOffboardingsV1.ts | 2 +- ...oardingChecklistControllerGetTemplateV1.ts | 5 + ...cklistControllerRevokeAllVendorAccessV1.ts | 2 +- ...ChecklistControllerRevokeVendorAccessV1.ts | 2 +- ...dingChecklistControllerUncompleteItemV1.ts | 5 + ...ecklistControllerUndoVendorRevocationV1.ts | 2 +- ...ChecklistControllerUpdateTemplateItemV1.ts | 5 + ...dingChecklistControllerUploadEvidenceV1.ts | 5 + apps/mcp-server/src/hooks/hooks.ts | 2 + apps/mcp-server/src/hooks/registration.ts | 14 ++ apps/mcp-server/src/landing-page.ts | 2 +- apps/mcp-server/src/lib/config.ts | 4 +- apps/mcp-server/src/mcp-server/mcp-server.ts | 2 +- apps/mcp-server/src/mcp-server/server.ts | 2 +- ...ardingChecklistControllerCompleteItemV1.ts | 4 +- ...ChecklistControllerCreateTemplateItemV1.ts | 4 +- ...ChecklistControllerDeleteTemplateItemV1.ts | 4 +- ...gChecklistControllerExportAllEvidenceV1.ts | 2 +- ...dingChecklistControllerExportEvidenceV1.ts | 2 +- ...ecklistControllerGetAccessRevocationsV1.ts | 2 +- ...ChecklistControllerGetMemberChecklistV1.ts | 4 +- ...klistControllerGetPendingOffboardingsV1.ts | 2 +- ...oardingChecklistControllerGetTemplateV1.ts | 4 +- ...cklistControllerRevokeAllVendorAccessV1.ts | 2 +- ...ChecklistControllerRevokeVendorAccessV1.ts | 2 +- ...dingChecklistControllerUncompleteItemV1.ts | 4 +- ...ecklistControllerUndoVendorRevocationV1.ts | 2 +- ...ChecklistControllerUpdateTemplateItemV1.ts | 4 +- ...dingChecklistControllerUploadEvidenceV1.ts | 4 +- ...ontrolscontrollerunlinkdocumenttypev1op.ts | 2 +- apps/mcp-server/src/tool-names.ts | 30 ++-- 47 files changed, 332 insertions(+), 180 deletions(-) create mode 100644 apps/mcp-server/src/hooks/registration.ts diff --git a/apps/mcp-server/.speakeasy/gen.lock b/apps/mcp-server/.speakeasy/gen.lock index 296c794940..1c54d2bcdc 100644 --- a/apps/mcp-server/.speakeasy/gen.lock +++ b/apps/mcp-server/.speakeasy/gen.lock @@ -1,20 +1,20 @@ lockVersion: 2.0.0 id: f7130d09-dac4-4515-9162-6095782b6bb6 management: - docChecksum: 4d14a84457854f6883f35052b65f6c2a + docChecksum: 168b4c41897b8f5abffdc17ff97a05a3 docVersion: "1.0" speakeasyVersion: 1.768.2 generationVersion: 2.889.1 - releaseVersion: 0.0.1 - configChecksum: ff38e76b5d4f6bbf312203110c17f67c + releaseVersion: 0.0.2 + configChecksum: 7ddc47fd982c5d0d611eb9e1186a9181 repoURL: https://github.com/trycompai/comp.git repoSubDirectory: apps/mcp-server installationURL: https://github.com/trycompai/comp published: true persistentEdits: - generation_id: ba406fcd-ff39-4968-a5a7-e967cc09372f - pristine_commit_hash: bdb358f648aaa9c5083a069fb7a348d2ac8ac63e - pristine_tree_hash: 6c5a288f7168e3bc498afc3f03de6e72ae436deb + generation_id: 2009b26c-98fa-47b6-8d1d-b5b0f7886d7b + pristine_commit_hash: 65444167cba8e031ff3673603bce6cc69b36ede4 + pristine_tree_hash: 298662eb9f6395847372db6704ff73fa259798b4 features: mcp-typescript: additionalDependencies: 0.1.0 @@ -50,12 +50,12 @@ trackedFiles: pristine_git_object: 4f9e60a9462fc4def738d60c3aaadf8232ef185f manifest.json: id: ca642a226869 - last_write_checksum: sha1:3aba4051642022dc5ef027d2411666c4423373f7 - pristine_git_object: f817bd1d5b71c57c626a0a0fe7206c1b9b34c7b6 + last_write_checksum: sha1:beeffc0fe73dc4f29a043030a5b91fc05cd56415 + pristine_git_object: 59e02e90e95927a75762c1c85a5af0de3d094453 package.json: id: 7030d0b2f71b - last_write_checksum: sha1:47d89de4610d873c8acde6c05b107bd1f233ca8a - pristine_git_object: 71a260c59ee1825c767f052dfbae93c8104b0e4b + last_write_checksum: sha1:107e731390e35b922f5c42776966d853ab5d12ca + pristine_git_object: d0ea244896b9860d1cb116fde9ab646373022bed src/core.ts: id: f431fdbcd144 last_write_checksum: sha1:3c1fe2275a0f345cf54298150f100299284b3f0e @@ -638,64 +638,64 @@ trackedFiles: pristine_git_object: c34e72cb9e7f29a48eaec417d22553d4341e106e src/funcs/offboardingChecklistOffboardingChecklistControllerCompleteItemV1.ts: id: 8380dab05953 - last_write_checksum: sha1:fce3f3a2445143a4ec6070aa032fa2545bf02b56 - pristine_git_object: 67db73a2cf5711172bc1ccdd06ee267956a4b066 + last_write_checksum: sha1:f6eea16d8635aa7a19a566fd4d7d7b344131fdef + pristine_git_object: 8102063b599adadd12cf50efe7908187a698e7a9 src/funcs/offboardingChecklistOffboardingChecklistControllerCreateTemplateItemV1.ts: id: 4be5c1cc3983 - last_write_checksum: sha1:a21bd223a88fd6220f507a452314ea15430a6b75 - pristine_git_object: 6b0dd312fc1040d123d760fcb7682d612e1552e3 + last_write_checksum: sha1:8d637b949365b3ca341cb065ab16a7d05ba199a4 + pristine_git_object: c7bd5691a122d2abd265272dafedf52e1145e942 src/funcs/offboardingChecklistOffboardingChecklistControllerDeleteTemplateItemV1.ts: id: eef94e38ef1e - last_write_checksum: sha1:a865f2eda070da9413ba7b1f99b2eeb8ee4eb3c4 - pristine_git_object: a3d999c523e62ded6a04bb94e4bb1b236c1f6ef4 + last_write_checksum: sha1:be6c16779c338c71e62a03a511582fd7a06fb0c3 + pristine_git_object: 05ace31b2ebfdb6940266466c03744cd9058caa1 src/funcs/offboardingChecklistOffboardingChecklistControllerExportAllEvidenceV1.ts: id: c8926ac9b0e3 - last_write_checksum: sha1:d9f536a18046a10fb20c7d2d11ab939e4538c870 - pristine_git_object: ef577d010143b408f938c5eb5275b5ff6f160f02 + last_write_checksum: sha1:4296f21412fa1e6f3cebe595f4427ef66aa2cf82 + pristine_git_object: 6003c47f4137efd03e4d8b5c122bb66c8b41f40b src/funcs/offboardingChecklistOffboardingChecklistControllerExportEvidenceV1.ts: id: ee3ffde03f06 - last_write_checksum: sha1:4681961182ac490d8b8ae34e1637d1bcc7e25a9a - pristine_git_object: 7c9113b062e38263e3cd675b0481f5751acc886b + last_write_checksum: sha1:440936860c2643628787570990d34c93befce3b6 + pristine_git_object: bf2e4fff14c95acd73781260b8507a8c9e7beebe src/funcs/offboardingChecklistOffboardingChecklistControllerGetAccessRevocationsV1.ts: id: b12607a85fda - last_write_checksum: sha1:d967fa4e5f8fedfe8fff6f267e7b9b4f505fe0b1 - pristine_git_object: e68175e7cec357460fcfa450ec261566ecf96402 + last_write_checksum: sha1:f604339432653c0cedff32a1a2a2d1ff25682e5a + pristine_git_object: a0459b0a7cee6a3bf3a5f023bf2e483f4a9ce9d6 src/funcs/offboardingChecklistOffboardingChecklistControllerGetMemberChecklistV1.ts: id: e90c045384de - last_write_checksum: sha1:229493ae14fab8599ea90cf773e7ff07951fb137 - pristine_git_object: ab91ca2d0857ea1a5f7f95233ebdf08fa9a6b511 + last_write_checksum: sha1:f4180d03d730cdf31882386e7a567e0bc461e18f + pristine_git_object: f22d020b57bea13789de58f97cfe361dd4e45207 src/funcs/offboardingChecklistOffboardingChecklistControllerGetPendingOffboardingsV1.ts: id: c19254ff4619 - last_write_checksum: sha1:259d03426a7059094a1949bb277ae339e80fe829 - pristine_git_object: bc70b083f2ab7271217325cdd3b2ba809f07c1ae + last_write_checksum: sha1:1547c4e838b5b35640d568108930ad287ce85419 + pristine_git_object: d6b832efb1599673f9aa1d51edf038c6ae511e90 src/funcs/offboardingChecklistOffboardingChecklistControllerGetTemplateV1.ts: id: aa73fa14b822 - last_write_checksum: sha1:d99de302c3f053eb512ddf0b0b9c1fed1840a3fa - pristine_git_object: acb33aeacd663e0c60f165973cecf43dd0d766f5 + last_write_checksum: sha1:8dfbfb01602b7ce36e4d3842b96759bcbfe8c60b + pristine_git_object: c868c96f2bc68c4e2f5bea44c2918a7c31123942 src/funcs/offboardingChecklistOffboardingChecklistControllerRevokeAllVendorAccessV1.ts: id: d3dcba722c21 - last_write_checksum: sha1:47fdc498c007d82e26a74870a50d777be11d4d53 - pristine_git_object: ea003f2faff292dccdc86fd682e018f5f7c46cb3 + last_write_checksum: sha1:7996b3498c4872360921ddf156e267aef79d10cb + pristine_git_object: f6e270a399592a6b9335b25e9fd06641a1063915 src/funcs/offboardingChecklistOffboardingChecklistControllerRevokeVendorAccessV1.ts: id: 478df4904566 - last_write_checksum: sha1:50edca92bfe14554e5237468b352b81f5e1e5c14 - pristine_git_object: dfe5cbc7201a0ce8fc664885cf70382e8b18d44e + last_write_checksum: sha1:49c4555229aacefe4ab9f8a790be1092b5cb772b + pristine_git_object: f22502134c20a1ce32993bd915fdc350d4d55b14 src/funcs/offboardingChecklistOffboardingChecklistControllerUncompleteItemV1.ts: id: 7629c301c3f8 - last_write_checksum: sha1:74db7c80e11c5f2a7cd953853d7663a761026615 - pristine_git_object: 6eac0cf6f9c5d335aff1e0eeb1a947a18452f231 + last_write_checksum: sha1:8c2cb7903ed1070f94908815d302d47268173409 + pristine_git_object: 74100e4dc240deff70bcd839b8260510a88b42f1 src/funcs/offboardingChecklistOffboardingChecklistControllerUndoVendorRevocationV1.ts: id: 63ee97352da6 - last_write_checksum: sha1:d312a0d58dd983ef6b2cb6569ce0c34cf842f3b4 - pristine_git_object: 1d771bfb97020ff611aa5b1837d2ec37690d5900 + last_write_checksum: sha1:692274e2651524a0cbe87027913e79c6d7cdd4ff + pristine_git_object: d62155e3d0b0bcbccd2f91fe8500b68ae21c2743 src/funcs/offboardingChecklistOffboardingChecklistControllerUpdateTemplateItemV1.ts: id: 9b7ce2aa29be - last_write_checksum: sha1:14dab451c3f978dfde0ab3c2ff50c297db6b2cff - pristine_git_object: 1e231d8e4ba06d5b9938d4e54c8bde9907fdb6ed + last_write_checksum: sha1:655ce0d235bd9b33f7d3c9c76391784d55f2504b + pristine_git_object: d416545efda3a2f7132f6e859c9f57b710c887d9 src/funcs/offboardingChecklistOffboardingChecklistControllerUploadEvidenceV1.ts: id: 69368c23fd44 - last_write_checksum: sha1:82e0a8c40fd27339eb07dfe7ac29714c07cb5f0b - pristine_git_object: 08af3f08913d831ea801d89db65abed1b097f231 + last_write_checksum: sha1:eb014a1a045c208b1c879b91def611fc00bb3990 + pristine_git_object: 45808dd1526f28c7c045ae4e925f41e6234e34ee src/funcs/orgChartOrgChartControllerDeleteOrgChartV1.ts: id: f37dc9fdac40 last_write_checksum: sha1:d9f725bfbb82df2105de5e9df81ee7a27b5ac73b @@ -1550,24 +1550,24 @@ trackedFiles: pristine_git_object: d9e74540b5452becadb4a8bfeddf18b16c087493 src/hooks/hooks.ts: id: a2463fc6f69b - last_write_checksum: sha1:141e45a4b047589e6ec69eef5da26fab909f44e9 - pristine_git_object: a4220b5e2f8fc59c2862870fc6bf2c38bd8efcff + last_write_checksum: sha1:327ed0fa581be991d633155a30a13e502caa80ef + pristine_git_object: 7709c66d855a856043daf58e863129c4dd437b6f src/hooks/types.ts: id: 2a02d86ec24f last_write_checksum: sha1:27a3444c4839bec58ae89820fd8c2483edafc3f4 pristine_git_object: 9c36bf01332084f735909a71036c3544cc4c7e3a src/landing-page.ts: id: ef64a6ee46d7 - last_write_checksum: sha1:c5b437c9b91afb147a29f27c9842a0355f75933f - pristine_git_object: 1546d49129f6ba454d8b20fd5e8f9ac782b850b0 + last_write_checksum: sha1:978345d7e4e9068faff49cd8e2b66021db5fcf93 + pristine_git_object: c1835e9b058d0fb49496f8da3644be6fcdcf20a3 src/lib/base64.ts: id: "598522066688" last_write_checksum: sha1:e9f04a037018040361043104960982f7c22db52d pristine_git_object: d4bd8b341290e7a828a171d840bd0b0fff7c7cd7 src/lib/config.ts: id: 320761608fb3 - last_write_checksum: sha1:c2aa01b346abec7fad7eac5297f68db2205ae93e - pristine_git_object: a5273b8d557ddf1575b8e01cfa1a7b6d6e72a6cb + last_write_checksum: sha1:d6fe284ea05ca965ad72da95792d91dbdd1e9665 + pristine_git_object: 447fe5655b5546b1f9faf47766a9794e1a048496 src/lib/dlv.ts: id: b1988214835a last_write_checksum: sha1:1dd3e3fbb4550c4bf31f5ef997faff355d6f3250 @@ -1662,8 +1662,8 @@ trackedFiles: pristine_git_object: 35c8713b6f8c7f17e2545423d3e74401ff77d04b src/mcp-server/mcp-server.ts: id: aabbc4ab07c1 - last_write_checksum: sha1:6d4719e3e2a858d323dd23b113461625af60962a - pristine_git_object: 5cbc4d82567cd17b91a0f3ed3d944b448523c69a + last_write_checksum: sha1:48b132d86040a131b63f6ca93ceb876a51f46635 + pristine_git_object: a97bfc6bd1c6b011bd88e1dbbf52d380a0daf7e2 src/mcp-server/prompts.ts: id: 26f3d73cbf31 last_write_checksum: sha1:cadb036e04534a6d9d765809eebb266d188c499b @@ -1678,8 +1678,8 @@ trackedFiles: pristine_git_object: c25696d4c4f70e081fa5d87ad6891874c509a577 src/mcp-server/server.ts: id: 2784dd48e82a - last_write_checksum: sha1:8d0699a5be45cd61f4d48e76c8b40816da337703 - pristine_git_object: 6eb7f4371bb9f2b544d3f6c0fa9f529ac360dea4 + last_write_checksum: sha1:14e81bd043d72b24e458dcb860972e7f39534cad + pristine_git_object: 892d8599ded0ddc69230d26ac36930458c909970 src/mcp-server/shared.ts: id: 074e80d4be1e last_write_checksum: sha1:19c9034032819a14f15c430de4350c8aba99d725 @@ -2158,64 +2158,64 @@ trackedFiles: pristine_git_object: 92999204af8f4e89eef3ec93acaed727830c3fd2 src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerCompleteItemV1.ts: id: ea90e963cb19 - last_write_checksum: sha1:ff6eebdd24e0f55ad3e22494d50c0120c880b7a0 - pristine_git_object: 7d594b0f04736f44f02c1aba11e51b64127ae1fa + last_write_checksum: sha1:c2ac527d90760274e3d280355c9b276f75bb15b8 + pristine_git_object: d92ca3d7799492dd5a41e7bfb0fcc704f534e4da src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerCreateTemplateItemV1.ts: id: f206c8770390 - last_write_checksum: sha1:ea1375eb7edb96cefbeb47a1cf286eda14fe0419 - pristine_git_object: e6db09378e8c40021ac2849678918df496229241 + last_write_checksum: sha1:69cb3bbef14bfdc06dcc05e19ba629983b025e50 + pristine_git_object: 50f3b3546a54bd5ee039a327d1547207f720dc84 src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerDeleteTemplateItemV1.ts: id: 8ca73a50db09 - last_write_checksum: sha1:ecd72defe9adf9a17e1555d616d1c0a1ea42eb0b - pristine_git_object: 07f0e1d754cbd30ba7153558c11be1b3638bb75b + last_write_checksum: sha1:a26eebe09b6d39af9d533f1b6f8e4a91b7acd489 + pristine_git_object: 2a8ca445f275594fca72b2e2e29503ceec558fbd src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerExportAllEvidenceV1.ts: id: 7ca2989ce2b4 - last_write_checksum: sha1:564865de811b226373262141f89ed3b2a97d6f29 - pristine_git_object: 704be8893608e1837e5ebea39901932a0e1a97d4 + last_write_checksum: sha1:2c1c808a12d31613b3af5dd5cc71c77a2b4ec447 + pristine_git_object: db107ec0dd87afdec72126061e356ef577ae57ae src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerExportEvidenceV1.ts: id: 305e216617e4 - last_write_checksum: sha1:5794b181c234561b7c779d438eb67451425748c9 - pristine_git_object: e7b274a53233f92d605d1cfb7cccef7a80b3b53c + last_write_checksum: sha1:8d12685818fc0664bb700eeb63d95863917b65c7 + pristine_git_object: 387f72c6406bf4b3ab074d9ce3f6a3ee42b52b49 src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetAccessRevocationsV1.ts: id: a0d7abf1778c - last_write_checksum: sha1:8a56346bf828927f5d7849ad859b7c355073050a - pristine_git_object: ec593e797d711c0bdb445ab1366ef47ebc227139 + last_write_checksum: sha1:9b6f593cb1ed51157adfa4ee041023fd69fdc4bf + pristine_git_object: 5857c318b8e3fb54471bd0c255cb778173048560 src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetMemberChecklistV1.ts: id: 1313a795c449 - last_write_checksum: sha1:702a6f2fe326f2e0487f5a06bcb623a30d9275ef - pristine_git_object: 2d76e815a225c440b0255a72986454b47535f6af + last_write_checksum: sha1:78e356ab4ac05709863f2053ab5ed9d5afe21f34 + pristine_git_object: b97797c04402c9df6c305b7511bdfc1abff4faa1 src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetPendingOffboardingsV1.ts: id: 44e15d7f2c53 - last_write_checksum: sha1:e196be79a7c6239d24d276af88153bd7bfb543f5 - pristine_git_object: 3c9a5869d9bfbb2695fd104f9de6d64f596d35be + last_write_checksum: sha1:e618b60d184ec1b5520f81cc91942ed9373aab30 + pristine_git_object: ceed27ad3a420008778318b79a07d56269dd3535 src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerGetTemplateV1.ts: id: 8ca8b61daf16 - last_write_checksum: sha1:0e3bdca3f6b1cd05fc566cc451c31145389a2e6a - pristine_git_object: 9e9ca385742fb43f8a69ef6a2dd6b7547b558ff6 + last_write_checksum: sha1:039b936658cc5001bfbbeed99754f6af1a10009e + pristine_git_object: 34fb6a9290e837010f7304d6f2dcb32b88b5f9a7 src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerRevokeAllVendorAccessV1.ts: id: c47cb2828a5d - last_write_checksum: sha1:c84ae736dabfabc178d29d9cdd23710047f1d97c - pristine_git_object: 0185052e4bd75e914b3f08ec487694f7698e0778 + last_write_checksum: sha1:76f31e0532501d4551ac9f459011efb7a06e07fb + pristine_git_object: 6ead278774be42756d9c10296c064bdc62c6da50 src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerRevokeVendorAccessV1.ts: id: 995de5ec0abe - last_write_checksum: sha1:8136d36a1f1d81959710befc0c508c808cc15cd8 - pristine_git_object: 529d1e7c893aafd042c454ba7744cebfe6d0d9b3 + last_write_checksum: sha1:31f1f4618c64f1172f617b155e6e9cc6760460cf + pristine_git_object: bbb00d734a4a9758a11cc977694ae8a30cc05993 src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUncompleteItemV1.ts: id: d5051500f7f3 - last_write_checksum: sha1:60e42c6dd876b972af0d7a2e4d7459a896c07c6e - pristine_git_object: eef6ba2d914afb5c0e23eb3cd249036bebbbe70c + last_write_checksum: sha1:c2be7072f1be25cbf9a0fc66d305075edb86dedd + pristine_git_object: 8e438e7f5c3d86b050ef8428e6af5da88457482d src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUndoVendorRevocationV1.ts: id: 26e442b5aeed - last_write_checksum: sha1:8a622fd43b442b0b4f0eb9ae020eab7d90f8714c - pristine_git_object: 88020d62b1bad62ca73928e7ae50a09762ac1e2a + last_write_checksum: sha1:a9e3696ff7035c20b4a1b92f9af8f3233ff1616d + pristine_git_object: d07d74b1c45f0df003c08cea1f0ad54fd4c332a5 src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUpdateTemplateItemV1.ts: id: 10d369c8f04c - last_write_checksum: sha1:29c747737da6ac30f73be55f987945ec37ce2855 - pristine_git_object: 90168513887cf775a2a0d92c0cf5a3227d4e1887 + last_write_checksum: sha1:e574e31bf896a836712dd9114476d9284d1d5819 + pristine_git_object: aa0e9aded7ad50f311656c60052fb82df0e94bb3 src/mcp-server/tools/offboardingChecklistOffboardingChecklistControllerUploadEvidenceV1.ts: id: 6abcacc63f2e - last_write_checksum: sha1:bdd174a8d87a69814d9559bbfd193baf3d507dd0 - pristine_git_object: 0140406d9fc0257fc883d5a1b82a1efc33d1a9f5 + last_write_checksum: sha1:0c5bfda7856e370ff365c0de8f4c7d315bfeb072 + pristine_git_object: 7859c23ebe53c61921ac2c21bd4940b4c1ded9b4 src/mcp-server/tools/orgChartOrgChartControllerDeleteOrgChartV1.ts: id: 32aeb43c6cca last_write_checksum: sha1:d58f72c45dbfa30e5a1188b433cb2e4696a555b9 @@ -3350,8 +3350,8 @@ trackedFiles: pristine_git_object: e6e7b767b88afe5e60a347e05c7274ee1737f8a0 src/models/controlscontrollerunlinkdocumenttypev1op.ts: id: edc19b966a6a - last_write_checksum: sha1:87eb023cdee5984ada03fcb28dc2e08039e1d919 - pristine_git_object: 06691eddbccd2e4d89075085a8d6ac803d34cbe3 + last_write_checksum: sha1:34541c7a6990e2f83f09d98d6ed17720486d8eaa + pristine_git_object: c582816881750ec47ce97b15f1cb24aac2410ffd src/models/createaccessrequestdto.ts: id: c099827d5497 last_write_checksum: sha1:06ccfeae9414d617517491bb34f55bba1e7251f1 @@ -4734,8 +4734,8 @@ trackedFiles: pristine_git_object: d89990b1a39878e9651513ee19fcba40b01670b4 src/tool-names.ts: id: a9977280f9eb - last_write_checksum: sha1:dc699ef2f0e8765c035668fa6775d8f9ff8aec57 - pristine_git_object: 20735d322c8207235a0423f7d80c89d98654aff6 + last_write_checksum: sha1:ed627f1fd2552b15a24b48d2d0be0bff9e824cab + pristine_git_object: 0bdb58d6119cef6e0370f0195f450647554fc3b3 src/types/async.ts: id: fac8da972f86 last_write_checksum: sha1:3ff07b3feaf390ec1aeb18ff938e139c6c4a9585 diff --git a/apps/mcp-server/.speakeasy/gen.yaml b/apps/mcp-server/.speakeasy/gen.yaml index 4689fcfb9a..8d479bfcc7 100644 --- a/apps/mcp-server/.speakeasy/gen.yaml +++ b/apps/mcp-server/.speakeasy/gen.yaml @@ -31,7 +31,7 @@ generation: generateNewTests: true skipResponseBodyAssertions: false mcp-typescript: - version: 0.0.1 + version: 0.0.2 additionalDependencies: dependencies: {} devDependencies: {} diff --git a/apps/mcp-server/.speakeasy/out.openapi.yaml b/apps/mcp-server/.speakeasy/out.openapi.yaml index 73e909b528..e9d4da5988 100644 --- a/apps/mcp-server/.speakeasy/out.openapi.yaml +++ b/apps/mcp-server/.speakeasy/out.openapi.yaml @@ -15223,6 +15223,11 @@ paths: schema: example: "ctl_abc123def456" type: "string" + - name: "frameworkInstanceId" + required: true + in: "query" + schema: + type: "string" - name: "formType" required: true in: "path" @@ -15242,11 +15247,6 @@ paths: - "network_diagram" - "tabletop_exercise" type: "string" - - name: "frameworkInstanceId" - required: true - in: "query" - schema: - type: "string" responses: "200": description: "" @@ -15530,6 +15530,7 @@ paths: name: "get-pdf" /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: @@ -15540,18 +15541,18 @@ paths: 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: name: "get-pending-offboardings" /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: @@ -15559,11 +15560,20 @@ paths: description: "" security: - apikey: [] + 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: @@ -15577,12 +15587,21 @@ paths: description: "" security: - apikey: [] + 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" /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: - name: "id" @@ -15601,11 +15620,20 @@ paths: description: "" security: - apikey: [] + 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: - name: "id" @@ -15618,12 +15646,21 @@ paths: description: "" security: - apikey: [] + 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" /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: - name: "memberId" @@ -15636,12 +15673,21 @@ paths: description: "" security: - apikey: [] + 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" /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: @@ -15652,18 +15698,18 @@ paths: 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: name: "offboarding-checklist-export-all-evidence" /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: - name: "memberId" @@ -15680,18 +15726,18 @@ paths: 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: name: "export-evidence" /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: - name: "memberId" @@ -15715,11 +15761,20 @@ paths: description: "" security: - apikey: [] + 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: - name: "memberId" @@ -15737,12 +15792,21 @@ paths: description: "" security: - apikey: [] + 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" /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: - name: "memberId" @@ -15766,12 +15830,21 @@ paths: description: "" security: - apikey: [] + 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" /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: - name: "memberId" @@ -15788,18 +15861,18 @@ paths: 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: name: "get-access-revocations" /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: - name: "memberId" @@ -15816,18 +15889,18 @@ paths: 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: name: "revoke-all-vendor-access" /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: - name: "memberId" @@ -15850,17 +15923,17 @@ paths: 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: name: "revoke-vendor-access" 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: - name: "memberId" @@ -15883,14 +15956,13 @@ paths: 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: name: "undo-vendor-revocation" info: diff --git a/apps/mcp-server/.speakeasy/workflow.lock b/apps/mcp-server/.speakeasy/workflow.lock index d08f09ed3d..5e36249c6f 100644 --- a/apps/mcp-server/.speakeasy/workflow.lock +++ b/apps/mcp-server/.speakeasy/workflow.lock @@ -2,8 +2,8 @@ speakeasyVersion: 1.768.2 sources: Comp AI API: sourceNamespace: comp-ai-api - sourceRevisionDigest: sha256:b302e5300e40d0fab3877a0e5b05f1f6e2cb6e8f49feb04bf63ceae6ca1cad2e - sourceBlobDigest: sha256:52e0508a584fd65b385c007c981b9a37de6d514edbb7fa29be203c7c944c197c + sourceRevisionDigest: sha256:3678269f476c4d11c58ede057372813c93c98576bfbf500f1a1d8fd2cbb4ca87 + sourceBlobDigest: sha256:c4762701cbb74fa26feb130e88dd5ef7354ca495d27e4c17dea71535b6c94f30 tags: - latest - "1.0" @@ -11,8 +11,8 @@ targets: comp-ai: source: Comp AI API sourceNamespace: comp-ai-api - sourceRevisionDigest: sha256:b302e5300e40d0fab3877a0e5b05f1f6e2cb6e8f49feb04bf63ceae6ca1cad2e - sourceBlobDigest: sha256:52e0508a584fd65b385c007c981b9a37de6d514edbb7fa29be203c7c944c197c + sourceRevisionDigest: sha256:3678269f476c4d11c58ede057372813c93c98576bfbf500f1a1d8fd2cbb4ca87 + sourceBlobDigest: sha256:c4762701cbb74fa26feb130e88dd5ef7354ca495d27e4c17dea71535b6c94f30 workflow: workflowVersion: 1.0.0 speakeasyVersion: latest diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 776a59e17b..b5a2157b69 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -30,9 +30,9 @@ Comp AI API: Compliance automation API for SOC 2, ISO 27001, HIPAA, GDPR, eviden
Claude Desktop -Install the MCP server as a Desktop Extension using the pre-built [`mcp-server.mcpb`](https://github.com/trycompai/comp/releases/download/v0.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." } ]; From fd1562a00e4f52b82eb34fdff6367268b49e4b0d Mon Sep 17 00:00:00 2001 From: "speakeasy-github[bot]" <128539517+speakeasy-github[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 01:19:00 +0000 Subject: [PATCH 21/29] empty commit to trigger [run-tests] workflow From e906b6de87817f4f71d556c6c9fe3abeab9f3ef0 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 28 May 2026 21:44:23 -0400 Subject: [PATCH 22/29] feat(api): declare oauth2 security scheme for MCP per-user auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosted MCP (Speakeasy Gram) only surfaces a customer-facing 'Sign in with Comp AI' flow and forwards each user's bearer token to the API when the OpenAPI spec declares an oauth2 scheme. With only the X-API-Key scheme, Gram would call the API with one shared key for every user, bypassing the per-user/per-org RBAC the API already enforces. Adds an oauth2 (authorization code) scheme pointed at the better-auth MCP authorization server and offers it alongside the API key on every authenticated operation (OR semantics — either credential satisfies a request, so existing API-key integrations are unaffected). Centralized in applyPublicOpenApiMetadata so all three spec-generation paths stay consistent; committed openapi.json regenerated (verified byte-identical to a GEN_OPENAPI=1 run). Once synced to Gram, oauth2SecurityCount>0 unlocks the External/Proxy OAuth wizard. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/openapi-docs.spec.ts | 56 ++ apps/api/src/openapi/public-docs-metadata.ts | 73 ++ packages/docs/openapi.json | 935 +++++++++++++++++++ 3 files changed, 1064 insertions(+) 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/packages/docs/openapi.json b/packages/docs/openapi.json index 39f275a872..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", @@ -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", @@ -23299,6 +24172,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get members with pending offboarding checklists", @@ -23332,6 +24208,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get the offboarding checklist template", @@ -23373,6 +24252,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Add an offboarding checklist template item", @@ -23425,6 +24307,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Update an offboarding checklist template item", @@ -23465,6 +24350,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Delete an offboarding checklist template item", @@ -23507,6 +24395,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get a member's offboarding checklist", @@ -23540,6 +24431,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Export all offboarding evidence as a zip file", @@ -23583,6 +24477,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Export offboarding evidence as a zip file", @@ -23643,6 +24540,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Complete an offboarding checklist item", @@ -23691,6 +24591,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Reopen an offboarding checklist item", @@ -23751,6 +24654,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Upload evidence for an offboarding checklist item", @@ -23794,6 +24700,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Get vendor access revocation status for a member", @@ -23837,6 +24746,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Confirm all vendor access as revoked", @@ -23889,6 +24801,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Mark vendor access as revoked", @@ -23939,6 +24854,9 @@ "security": [ { "apikey": [] + }, + { + "oauth2": [] } ], "summary": "Undo vendor access revocation", @@ -24117,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": { From a10c58686ec3d736238852ddc3956d971f25367c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 12:20:47 -0400 Subject: [PATCH 23/29] feat: add banner to let user know to setup their trust portal [dev] [Marfuen] mariano/sale-63-trust-center-adoption --- .../is-trust-portal-configured.spec.ts | 45 ++++ .../is-trust-portal-configured.ts | 42 ++++ .../src/trust-portal/trust-portal.service.ts | 35 +++ ...tesBanner.tsx => FrameworkUpdatesCard.tsx} | 9 +- .../overview/components/OffboardingBanner.tsx | 60 ------ .../[orgId]/overview/components/Overview.tsx | 2 - .../overview/nudges/FrameworkUpdatesNudge.tsx | 24 +++ .../[orgId]/overview/nudges/NudgeCenter.tsx | 92 ++++++++ .../overview/nudges/OffboardingNudge.tsx | 78 +++++++ .../overview/nudges/OverviewNudges.test.tsx | 200 ++++++++++++++++++ .../overview/nudges/OverviewNudges.tsx | 82 +++++++ .../overview/nudges/TrustPortalSetupNudge.tsx | 74 +++++++ .../(app)/[orgId]/overview/nudges/types.ts | 27 +++ .../src/app/(app)/[orgId]/overview/page.tsx | 28 ++- .../TrustPortalGettingStarted.test.tsx | 36 ++++ .../components/TrustPortalGettingStarted.tsx | 34 +++ apps/app/src/app/(app)/[orgId]/trust/page.tsx | 3 + 17 files changed, 803 insertions(+), 68 deletions(-) create mode 100644 apps/api/src/trust-portal/is-trust-portal-configured.spec.ts create mode 100644 apps/api/src/trust-portal/is-trust-portal-configured.ts rename apps/app/src/app/(app)/[orgId]/overview/components/{FrameworkUpdatesBanner.tsx => FrameworkUpdatesCard.tsx} (92%) delete mode 100644 apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/overview/nudges/FrameworkUpdatesNudge.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/overview/nudges/NudgeCenter.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/overview/nudges/OffboardingNudge.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/overview/nudges/types.ts create mode 100644 apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/trust/components/TrustPortalGettingStarted.tsx 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/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..10d61f5005 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.test.tsx @@ -0,0 +1,200 @@ +import { render, screen } from '@testing-library/react'; +import { fireEvent } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mockHasPermission, setMockPermissions } from '@/test-utils/mocks/permissions'; + +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('Showcase your security posture')).toBeInTheDocument(); + }); + + it('hides the trust nudge when already configured', () => { + render(); + expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + }); + + it('hides the trust nudge when the feature flag is off', () => { + render(); + expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + }); + + it('hides the trust nudge without trust:update', () => { + setMockPermissions({ trust: ['read'] }); + render(); + expect(screen.queryByText('Showcase your security posture')).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('Showcase your security posture')).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('Showcase your security posture')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Show less')); + expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + expect(screen.getByText('2 notices')).toBeInTheDocument(); + }); + + it('shows no stack control when only one nudge applies', () => { + render(); + expect(screen.getByText('Showcase your security posture')).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('Showcase your security posture')).not.toBeInTheDocument(); + unmount(); + render(); + expect(screen.queryByText('Showcase your security posture')).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('Showcase your security posture')).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('Showcase your security posture'); + 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..ac4edb4f2f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx @@ -0,0 +1,74 @@ +'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 ( + + + Showcase your security posture + + + + Show prospects and vendors your compliance progress — enable the + frameworks you’re working on and your published policies appear + automatically. + + +
+ +
+ + + +
+ ); +} 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 ( <> - + } />}> ({ + 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 && } Date: Fri, 29 May 2026 12:23:34 -0400 Subject: [PATCH 24/29] fix(integrations): record lastSyncAt after Google Workspace employee sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Google Workspace employee-sync endpoint processed users but never wrote connection.lastSyncAt. The People page reads that field as "Last sync", so a sync that runs successfully every day still showed a frozen timestamp (only ever bumped by check runs) — making a working sync look stuck and prompting "sync is active but doesn't run" reports. Add a connection.update({ lastSyncAt }) after a successful sync. Tests cover the happy path and the zero-users path. Scope note: Rippling and JumpCloud sync endpoints have the identical gap (left untouched here, intentionally surgical). The GWS "do not reactivate a deactivated member" behaviour is intentional and tested, so it is deliberately unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/sync-gws.controller.spec.ts | 37 +++++++++++++++++++ .../controllers/sync.controller.ts | 7 ++++ 2 files changed, 44 insertions(+) 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..fc221b4764 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 = { @@ -782,6 +783,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..5ad5bf00ad 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -561,6 +561,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, From f3038e1cf7e062daf9bb1615e86dd86fe81632f3 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Fri, 29 May 2026 12:40:43 -0400 Subject: [PATCH 25/29] style(trust): clearer trust portal setup nudge copy (#2966) --- .../overview/nudges/OverviewNudges.test.tsx | 36 +++++++++---------- .../overview/nudges/TrustPortalSetupNudge.tsx | 27 ++++---------- 2 files changed, 22 insertions(+), 41 deletions(-) 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 index 10d61f5005..e540807db7 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/OverviewNudges.test.tsx @@ -1,7 +1,6 @@ -import { render, screen } from '@testing-library/react'; -import { fireEvent } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; 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 }), @@ -75,23 +74,23 @@ describe('OverviewNudges', () => { it('shows the trust nudge when enabled, not configured, and user can update', () => { render(); - expect(screen.getByText('Showcase your security posture')).toBeInTheDocument(); + expect(screen.getByText('Set up your Trust Portal')).toBeInTheDocument(); }); it('hides the trust nudge when already configured', () => { render(); - expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + 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('Showcase your security posture')).not.toBeInTheDocument(); + 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('Showcase your security posture')).not.toBeInTheDocument(); + expect(screen.queryByText('Set up your Trust Portal')).not.toBeInTheDocument(); }); it('collapses to the top nudge with a stack control when several apply', () => { @@ -99,7 +98,7 @@ describe('OverviewNudges', () => { render(); // Offboarding (priority 10) is shown; trust waits behind the stack. expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); - expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + expect(screen.queryByText('Set up your Trust Portal')).not.toBeInTheDocument(); // The user is told more are waiting. expect(screen.getByText('2 notices')).toBeInTheDocument(); }); @@ -110,26 +109,26 @@ describe('OverviewNudges', () => { fireEvent.click(screen.getByText('2 notices')); expect(screen.getByText(/offboarding completion/)).toBeInTheDocument(); - expect(screen.getByText('Showcase your security posture')).toBeInTheDocument(); + expect(screen.getByText('Set up your Trust Portal')).toBeInTheDocument(); fireEvent.click(screen.getByText('Show less')); - expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + 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('Showcase your security posture')).toBeInTheDocument(); + 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('Showcase your security posture')).not.toBeInTheDocument(); + expect(screen.queryByText('Set up your Trust Portal')).not.toBeInTheDocument(); unmount(); render(); - expect(screen.queryByText('Showcase your security posture')).not.toBeInTheDocument(); + expect(screen.queryByText('Set up your Trust Portal')).not.toBeInTheDocument(); }); it('dismissing offboarding hides it for the session without persisting', () => { @@ -142,9 +141,7 @@ describe('OverviewNudges', () => { fireEvent.click(screen.getByLabelText('Dismiss')); expect(screen.queryByText(/offboarding completion/)).not.toBeInTheDocument(); - expect( - window.localStorage.getItem('overview-nudge-dismissed:offboarding:org_123'), - ).toBeNull(); + expect(window.localStorage.getItem('overview-nudge-dismissed:offboarding:org_123')).toBeNull(); // Not persisted → reappears on remount. unmount(); @@ -176,17 +173,16 @@ describe('OverviewNudges', () => { // 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('Showcase your security posture')).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('Showcase your security posture'); + const trustEl = screen.getByText('Set up your Trust Portal'); const frameworkEl = screen.getByText('framework updates available'); expect( - trustEl.compareDocumentPosition(frameworkEl) & - Node.DOCUMENT_POSITION_FOLLOWING, + trustEl.compareDocumentPosition(frameworkEl) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); }); diff --git a/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx b/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx index ac4edb4f2f..50e7532734 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/nudges/TrustPortalSetupNudge.tsx @@ -1,13 +1,7 @@ 'use client'; import { usePermissions } from '@/hooks/use-permissions'; -import { - Alert, - AlertAction, - AlertDescription, - AlertTitle, - Button, -} from '@trycompai/design-system'; +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'; @@ -29,29 +23,20 @@ export function useTrustPortalSetupNudge({ persistDismissal: true, ready: true, // server data already resolved eligible: isTrustNdaEnabled && !isConfigured && canSetup, - render: (onDismiss) => ( - - ), + render: (onDismiss) => , }; } -function TrustPortalSetupNudgeView({ - orgId, - onDismiss, -}: { - orgId: string; - onDismiss: () => void; -}) { +function TrustPortalSetupNudgeView({ orgId, onDismiss }: { orgId: string; onDismiss: () => void }) { return ( - Showcase your security posture + Set up your Trust Portal - Show prospects and vendors your compliance progress — enable the - frameworks you’re working on and your published policies appear - automatically. + Customize and publish your Trust Portal so prospects and vendors can view your compliance + progress.
From e55fbfd7fb92ed7c5a5bc9f0814452ede8f5f0cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 12:47:12 -0400 Subject: [PATCH 26/29] feat(trust): auto-publish trust portal for new orgs at creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(trust): auto-publish trust portal for new orgs at creation - Shared client-injected ensureTrustForOrganization (@trycompai/db/trust): idempotent, never rewrites an existing friendlyUrl, never republishes a drafted portal, P2002-safe. - Eager creation in both org-creation server actions (non-fatal so creation never breaks). - Integration tests for the helper (create / idempotent / no-rewrite / draft-safe / collision). * fix(trust): publish new-org portal via guarded API, not app-side db Addresses cubic review on #2964: - Drop the @trycompai/db/trust helper + package export (P0: ran from .ts source). - Org-creation actions now warm GET /v1/trust-portal/settings after setActiveOrganization, which lazily creates a published Trust row (slug) through HybridAuthGuard + trust:read — no direct app-side db mutation (P1). * fix(trust): surface trust-portal publish failures via serverApi error field Addresses cubic: serverApi.get returns { error } instead of throwing, so the try/catch swallowed non-2xx failures. Check response.error explicitly (non-fatal). --------- Co-authored-by: Mariano Fuentes --- .../setup/actions/create-organization-minimal.ts | 15 +++++++++++++++ .../(app)/setup/actions/create-organization.ts | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index c84c6af096..3c70ff27d6 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -3,6 +3,7 @@ import { initializeOrganization } from '@/actions/organization/lib/initialize-organization'; import { authActionClientWithoutOrg } from '@/actions/safe-action'; import { env } from '@/env.mjs'; +import { serverApi } from '@/lib/api-server'; import { createTrainingVideoEntries } from '@/lib/db/employee'; import { auth } from '@/utils/auth'; import { db } from '@db/server'; @@ -103,6 +104,12 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }, }); + // Publish the trust portal via the guarded API (non-fatal). + const trustPortalResponse = await serverApi.get('/v1/trust-portal/settings'); + if (trustPortalResponse.error) { + console.error('Non-critical: failed to publish trust portal:', trustPortalResponse.error); + } + return { success: true, organizationId: existingOrg.id, @@ -196,6 +203,14 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }); createdOrgId = undefined; // Org is fully initialized, disable cleanup + // Publish the trust portal so trust.inc/{slug} is live immediately, even + // while empty. Goes through the guarded API (GET settings lazily creates a + // published Trust row with a slug). Non-fatal — org creation must not depend on it. + const trustPortalResponse = await serverApi.get('/v1/trust-portal/settings'); + if (trustPortalResponse.error) { + console.error('Non-critical: failed to publish trust portal:', trustPortalResponse.error); + } + // Revalidate paths (non-critical, don't let failures kill the flow) try { const headersList = await headers(); diff --git a/apps/app/src/app/(app)/setup/actions/create-organization.ts b/apps/app/src/app/(app)/setup/actions/create-organization.ts index e0adedae9a..2557f201e8 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization.ts @@ -2,6 +2,7 @@ import { initializeOrganization } from '@/actions/organization/lib/initialize-organization'; import { authActionClientWithoutOrg } from '@/actions/safe-action'; +import { serverApi } from '@/lib/api-server'; import { createTrainingVideoEntries } from '@/lib/db/employee'; import { createFleetLabelForOrg } from '@/trigger/tasks/device/create-fleet-label-for-org'; import { onboardOrganization as onboardOrganizationTask } from '@/trigger/tasks/onboarding/onboard-organization'; @@ -116,6 +117,14 @@ export const createOrganization = authActionClientWithoutOrg }, }); + // Publish the trust portal so trust.inc/{slug} is live immediately, even + // while empty. Goes through the guarded API (GET settings lazily creates a + // published Trust row with a slug). Non-fatal — onboarding must still run. + const trustPortalResponse = await serverApi.get('/v1/trust-portal/settings'); + if (trustPortalResponse.error) { + console.error('Non-critical: failed to publish trust portal:', trustPortalResponse.error); + } + const userOrgs = await db.member.findMany({ where: { userId: session.user.id, From 1c30c301ae7ec579bfc79bbbaf525f0d2929f675 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 29 May 2026 12:56:43 -0400 Subject: [PATCH 27/29] fix(portal): hide archived policies from employee policy lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The employee portal showed policies that had been archived/replaced by a framework version sync. The sync stamps `archivedAt` on superseded policies but leaves `status: 'published'` and `isRequiredToSign: true` untouched (framework-sync-apply.ts), so the portal's queries — which filtered on `status` + `isRequiredToSign` only — kept rendering them. The main app's API (policies.service.ts findAll) and the public Trust Center already filter `isArchived: false, archivedAt: null`; the portal was the lone divergent surface. Add both archive filters to the two portal policy-list queries: - OrganizationDashboard (the "Accept All" review list) - Signed Policies page (was missing only `archivedAt: null`) This matches the rule documented on the Policy schema: hide a policy if EITHER `isArchived` or `archivedAt` is set. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../(home)/[orgId]/components/OrganizationDashboard.tsx | 5 +++++ apps/portal/src/app/(app)/(home)/[orgId]/policies/page.tsx | 1 + 2 files changed, 6 insertions(+) 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: { From 4fdb815883b2aac17717016f57364075cbc7950b Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 29 May 2026 13:27:05 -0400 Subject: [PATCH 28/29] fix(api): reactivate the previously-deactivated user during GWS sync --- .../controllers/sync-gws.controller.spec.ts | 84 ++++++++----------- .../controllers/sync.controller.ts | 27 ++++-- 2 files changed, 52 insertions(+), 59 deletions(-) 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..2c4045aa94 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 @@ -271,19 +271,26 @@ describe('SyncController - Google Workspace employees', () => { }); }); - // ── Deactivated members must NOT be reactivated ──────────────── - - describe('deactivated member handling (no reactivation)', () => { - it('should NOT reactivate a member deactivated manually by an admin', async () => { - setupSync({ gwUsers: [makeGwUser('manual@example.com')] }); + // ── 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('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 +301,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 +330,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 +730,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 }); }); diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 3a16387529..7f99c13256 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 }, + }); + 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; } From 97636c4ea8c56c522f8d3a005e0e1348df4f815e Mon Sep 17 00:00:00 2001 From: chasprowebdev Date: Fri, 29 May 2026 13:44:39 -0400 Subject: [PATCH 29/29] fix(api): clear offboardDate when reactivating user in GWS sync --- .../src/integration-platform/controllers/sync.controller.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index f33746dea0..c018449fee 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -426,7 +426,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({ @@ -917,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({ @@ -1424,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({