Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
4cd3b1c
fix(api): prevent proxy timeout on large-org ZIP exports
chasprowebdev May 27, 2026
83e8f9a
Merge branch 'main' into chas/download-large-evidence-pack
chasprowebdev May 27, 2026
1361bec
Merge branch 'main' into chas/download-large-evidence-pack
chasprowebdev May 28, 2026
5caf46a
feat(api): add OAuth provider for keyless hosted MCP auth
tofikwest May 28, 2026
0a3e2af
docs: require endpoint summary+description for MCP dynamic-toolset di…
tofikwest May 28, 2026
38ac017
fix(api): add OpenAPI summaries + descriptions to offboarding endpoints
tofikwest May 28, 2026
aa7fde0
fix(api): skip OAuth consent for first-party Gram MCP client
tofikwest May 28, 2026
81d11a5
feat(api): multi-org support for hosted MCP
tofikwest May 28, 2026
fbef686
docs(api): correct hosted-MCP endpoint paths to /mcp/* in comment
tofikwest May 28, 2026
8d4baea
feat(app): add MCP org picker to user settings
tofikwest May 28, 2026
28bbc11
fix(api): return 403 (not 401) when an MCP user must choose an org
tofikwest May 28, 2026
a7ccc9a
fix(api): block org-less users from the MCP entirely
tofikwest May 28, 2026
653fc3b
feat(api): gate MCP on app-access role, not just org membership
tofikwest May 28, 2026
bd6ef4c
docs: auto-attach MCP endpoint-contract rule + sync descriptions rule
tofikwest May 28, 2026
7b61ed5
fix(api): add PermissionGuard + app:read to MCP controller
tofikwest May 28, 2026
03dc24a
fix(api): address cubic review findings on the MCP auth path
tofikwest May 29, 2026
61c8016
fix: platform-admin MCP bypass + revalidate on save error
tofikwest May 29, 2026
c148506
Merge pull request #2955 from trycompai/feat/mcp-hosted-oauth
tofikwest May 29, 2026
7118c6e
fix(api): disable declaration emit in the build (TS4023 from mcp plugin)
tofikwest May 29, 2026
4ad4419
Merge pull request #2958 from trycompai/fix/api-build-ts4023
tofikwest May 29, 2026
5151214
chore: auto-sync OpenAPI source to Gram via CI (no manual uploads)
tofikwest May 29, 2026
3d19a73
fix(ci): correct gram push command (add GRAM_ORG, fix install PATH)
tofikwest May 29, 2026
e53e2af
fix(ci): set least-privilege GITHUB_TOKEN permissions (contents: read)
tofikwest May 29, 2026
ff12b81
ci: regenerated with OpenAPI Doc , Speakeasy CLI 1.768.2
speakeasybot May 29, 2026
fd1562a
empty commit to trigger [run-tests] workflow
speakeasy-github[bot] May 29, 2026
a270dad
Merge pull request #2959 from trycompai/chore/gram-mcp-source-sync
tofikwest May 29, 2026
e906b6d
feat(api): declare oauth2 security scheme for MCP per-user auth
tofikwest May 29, 2026
2bd0de6
Merge pull request #2961 from trycompai/feat/openapi-oauth2-mcp
tofikwest May 29, 2026
b5c7c96
Merge branch 'main' into chas/download-large-evidence-pack
tofikwest May 29, 2026
a10c586
feat: add banner to let user know to setup their trust portal
github-actions[bot] May 29, 2026
1d2112e
fix(integrations): record lastSyncAt after Google Workspace employee …
tofikwest May 29, 2026
8a1a7c3
Merge branch 'main' into speakeasy-sdk-regen-1780017440
tofikwest May 29, 2026
ba917e6
Merge branch 'main' into tofik/gws-sync-reactivate-lastsync
tofikwest May 29, 2026
9437f44
Merge pull request #2960 from trycompai/speakeasy-sdk-regen-1780017440
tofikwest May 29, 2026
6c7aef0
Merge branch 'main' into tofik/gws-sync-reactivate-lastsync
tofikwest May 29, 2026
3cdd82e
Merge pull request #2965 from trycompai/tofik/gws-sync-reactivate-las…
tofikwest May 29, 2026
f3038e1
style(trust): clearer trust portal setup nudge copy (#2966)
Marfuen May 29, 2026
e55fbfd
feat(trust): auto-publish trust portal for new orgs at creation
github-actions[bot] May 29, 2026
1c30c30
fix(portal): hide archived policies from employee policy lists
tofikwest May 29, 2026
27665d4
Merge branch 'main' into fix/portal-archived-policies-leak
tofikwest May 29, 2026
c1e1266
Merge pull request #2967 from trycompai/fix/portal-archived-policies-…
tofikwest May 29, 2026
574a3a0
Merge branch 'main' into chas/download-large-evidence-pack
chasprowebdev May 29, 2026
221454a
Merge branch 'release' into main
tofikwest May 29, 2026
60157de
Merge branch 'main' into chas/download-large-evidence-pack
chasprowebdev May 29, 2026
a6aa9e3
Merge pull request #2936 from trycompai/chas/download-large-evidence-…
tofikwest May 29, 2026
4fdb815
fix(api): reactivate the previously-deactivated user during GWS sync
chasprowebdev May 29, 2026
a1f5f24
Merge branch 'main' of https://github.com/trycompai/comp into chas/gw…
chasprowebdev May 29, 2026
97636c4
fix(api): clear offboardDate when reactivating user in GWS sync
chasprowebdev May 29, 2026
d682a94
Merge pull request #2969 from trycompai/chas/gws-sync-reactivate
tofikwest May 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions .claude/skills/api-endpoint-contract/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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.
9 changes: 8 additions & 1 deletion .cursor/rules/api-endpoint-contract.mdc
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
---
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
---

# API Endpoint Contract (MCP-friendly NestJS endpoints)

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

Expand Down Expand Up @@ -99,6 +100,12 @@ SSE streams (`@ApiProduces('text/event-stream')`) and binary file responses cann
disabled: true
```

### 11. Every endpoint MUST have a meaningful summary + description — it powers MCP discovery

`@ApiOperation({ summary, description })` is **not optional**. `openapi-docs.spec.ts` (via `collectPublicOpenApiIssues`) **fails CI** on empty `summary` (`missingSummaries`), missing `description`/metadata (`missingMetadata`), or SEO metadata outside 80–160 chars / title > 60 (`invalidSeo`).

The hosted MCP (Gram) uses **dynamic toolsets**: with 300+ tools the agent semantic-`search`es over names + descriptions and only loads matches. A weak/missing description = the tool is **undiscoverable**. Write it for the agent deciding whether to call the tool: what it does + when to use it (≤ 240 chars, Rule 4).

## Checklist when adding a body endpoint

1. `class` DTO, two decorator stacks per field, `@ApiBody({ type: DtoClass })` on the endpoint.
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/gram-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Sync MCP source to Gram

# Keeps the hosted MCP (Speakeasy Gram) in sync with the clean public OpenAPI
# spec. Runs whenever the spec (or the Gram deployment config) changes on main,
# and can be triggered manually. No manual uploads — the source is pushed
# declaratively from gram.json via the Gram CLI.
on:
push:
branches: [main]
paths:
- "packages/docs/openapi.json"
- "gram.json"
workflow_dispatch:

# Least privilege: this workflow only reads the repo to checkout + push the spec.
permissions:
contents: read

jobs:
push:
runs-on: ubuntu-latest
env:
# Org + project slugs are NOT secret — they appear in the Gram dashboard URL
# (app.getgram.ai/<org>/projects/<project>). Only the API key is a secret.
GRAM_ORG: comp-ai-f041
GRAM_PROJECT: default
GRAM_API_KEY: ${{ secrets.GRAM_API_KEY }}
steps:
- uses: actions/checkout@v4

- name: Install Gram CLI
# Installs to /usr/local/bin (already on PATH on GitHub runners).
run: curl -fsSL https://go.getgram.ai/cli.sh | bash

- name: Push OpenAPI source to Gram
# api-key/org/project are read from the env vars above; --method defaults
# to "merge" (updates the existing source in place by slug).
run: gram push --config gram.json
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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://<gram-domain>/oauth/<slug>/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=
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -126,6 +127,7 @@ import { OffboardingChecklistModule } from './offboarding-checklist/offboarding-
AdminFeatureFlagsModule,
TimelinesModule,
OffboardingChecklistModule,
McpModule,
],
controllers: [AppController],
providers: [
Expand Down
76 changes: 76 additions & 0 deletions apps/api/src/auth/app-access.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const mockOrgRoleFindMany = jest.fn();
jest.mock('@db', () => ({
db: {
organizationRole: {
findMany: (...a: unknown[]) => mockOrgRoleFindMany(...a),
},
},
}));

jest.mock('@trycompai/auth', () => ({
BUILT_IN_ROLE_PERMISSIONS: {
owner: { app: ['read'] },
admin: { app: ['read'] },
auditor: { app: ['read'] },
employee: { policy: ['read'], portal: ['read', 'update'] },
contractor: { policy: ['read'], portal: ['read', 'update'] },
},
}));

import { hasAppAccess } from './app-access';

describe('hasAppAccess', () => {
beforeEach(() => {
jest.clearAllMocks();
mockOrgRoleFindMany.mockResolvedValue([]);
});

it('grants for built-in app roles without a DB lookup', async () => {
expect(await hasAppAccess('org_1', 'owner')).toBe(true);
expect(await hasAppAccess('org_1', 'admin')).toBe(true);
expect(await hasAppAccess('org_1', 'auditor')).toBe(true);
expect(mockOrgRoleFindMany).not.toHaveBeenCalled();
});

it('denies for Portal-only built-in roles', async () => {
expect(await hasAppAccess('org_1', 'employee')).toBe(false);
expect(await hasAppAccess('org_1', 'contractor')).toBe(false);
});

it('treats comma-separated roles as a union (any granting role wins)', async () => {
expect(await hasAppAccess('org_1', 'employee,admin')).toBe(true);
});

it('grants for a custom role with app:read', async () => {
mockOrgRoleFindMany.mockResolvedValue([
{ permissions: JSON.stringify({ app: ['read'], control: ['read'] }) },
]);
expect(await hasAppAccess('org_1', 'Compliance Lead')).toBe(true);
});

it('denies for a custom role without app:read', async () => {
mockOrgRoleFindMany.mockResolvedValue([
{ permissions: JSON.stringify({ policy: ['read'], portal: ['read'] }) },
]);
expect(await hasAppAccess('org_1', 'Portal Role')).toBe(false);
});

it('denies for empty or null roles', async () => {
expect(await hasAppAccess('org_1', null)).toBe(false);
expect(await hasAppAccess('org_1', '')).toBe(false);
});

it('treats a role named like an Object prototype key (constructor) as custom', async () => {
// Must NOT be shadowed by Object.prototype.constructor — it's a custom role.
mockOrgRoleFindMany.mockResolvedValue([
{ permissions: JSON.stringify({ app: ['read'] }) },
]);
expect(await hasAppAccess('org_1', 'constructor')).toBe(true);
expect(mockOrgRoleFindMany).toHaveBeenCalled();
});

it('does not throw on malformed custom-role permissions', async () => {
mockOrgRoleFindMany.mockResolvedValue([{ permissions: '{not valid json' }]);
await expect(hasAppAccess('org_1', 'Broken Role')).resolves.toBe(false);
});
});
99 changes: 99 additions & 0 deletions apps/api/src/auth/app-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { BUILT_IN_ROLE_PERMISSIONS } from '@trycompai/auth';
import { db } from '@db';

/** Safely parse a custom role's stored permissions; malformed JSON → `{}` (never throws). */
function parsePermissions(raw: unknown): Record<string, string[]> {
if (raw && typeof raw === 'object') {
return raw as Record<string, string[]>;
}
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object'
? (parsed as Record<string, string[]>)
: {};
} catch {
return {};
}
}
return {};
}

function mergeInto(
target: Record<string, Set<string>>,
perms: Record<string, string[]>,
): void {
for (const [resource, actions] of Object.entries(perms)) {
if (!Array.isArray(actions)) continue;
(target[resource] ??= new Set<string>());
for (const action of actions) target[resource].add(action);
}
}

/**
* Merge the effective permissions (`{ resource: actions[] }`) for a set of role
* names in an org. Built-in roles resolve from `BUILT_IN_ROLE_PERMISSIONS`
* (own-property lookup only, so a custom role named e.g. `constructor` is not
* mistaken for a built-in); custom roles resolve from `organization_role` rows
* (malformed JSON is ignored, not thrown). Comma-separated roles are a union.
*/
export async function resolveRolePermissions(
organizationId: string,
roles: string[],
): Promise<Record<string, string[]>> {
const merged: Record<string, Set<string>> = {};
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<string, string[]> = {};
for (const [resource, actions] of Object.entries(merged)) {
result[resource] = [...actions];
}
return result;
}

/** Whether resolved permissions grant `resource:action`. */
export function permissionsGrant(
permissions: Record<string, string[]>,
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<boolean> {
if (!roleString) return false;
const roles = roleString
.split(',')
.map((r) => r.trim())
.filter(Boolean);
if (roles.length === 0) return false;

const perms = await resolveRolePermissions(organizationId, roles);
return permissionsGrant(perms, 'app', 'read');
}
Loading
Loading