Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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');
}
51 changes: 51 additions & 0 deletions apps/api/src/auth/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
bearer,
emailOTP,
magicLink,
mcp,
multiSession,
organization,
} from 'better-auth/plugins';
Expand Down Expand Up @@ -188,6 +189,41 @@ if (

const cookieDomain = getCookieDomain();

// ── Hosted MCP (Speakeasy Gram) OAuth ────────────────────────────────────────
// The MCP server is hosted on Gram. Gram obtains an OAuth access token from this
// API (better-auth as the authorization server) so users authenticate with
// "Sign in with Google" instead of pasting an API key.
//
// Gram's OAuth Proxy registers as a single static client and handles Dynamic
// Client Registration toward MCP clients on our behalf — so we keep public DCR
// off for now and register Gram as a trusted client. Configured via env so the
// secret isn't committed and the plugin is inert in envs where hosted MCP isn't
// set up yet.
const gramMcpClient =
process.env.GRAM_OAUTH_CLIENT_ID &&
process.env.GRAM_OAUTH_CLIENT_SECRET &&
process.env.GRAM_OAUTH_REDIRECT_URI
? {
clientId: process.env.GRAM_OAUTH_CLIENT_ID,
clientSecret: process.env.GRAM_OAUTH_CLIENT_SECRET,
name: 'Comp AI MCP (Gram)',
type: 'web' as const,
disabled: false,
redirectUrls: [process.env.GRAM_OAUTH_REDIRECT_URI],
metadata: null,
// First-party client: Gram is Comp AI's own hosted MCP, so the user's
// login (Sign in with Google) IS the authorization — no separate consent
// screen is needed. This also avoids having to build a consent page UI.
skipConsent: true,
}
: null;

// Where better-auth sends the user to authenticate during the OAuth flow.
// Must point at the app's sign-in page. Override per environment via env.
const mcpLoginPage =
process.env.MCP_OAUTH_LOGIN_PAGE ||
`${process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.trycomp.ai'}/auth`;

// =============================================================================
// Security Validation
// =============================================================================
Expand Down Expand Up @@ -488,6 +524,21 @@ export const auth = betterAuth({
admin({
defaultRole: 'user',
}),
// OAuth 2.0 / OIDC provider for hosted MCP (Gram). Wraps oidcProvider and
// exposes /api/auth/mcp/* (authorize, token, register) + the two
// /api/auth/.well-known/* discovery docs, plus the auth.api.getMcpSession()
// helper used by HybridAuthGuard. (Gram points its OAuth proxy at /mcp/*.)
mcp({
loginPage: mcpLoginPage,
...(process.env.MCP_RESOURCE_URL
? { resource: process.env.MCP_RESOURCE_URL }
: {}),
oidcConfig: {
loginPage: mcpLoginPage,
allowDynamicClientRegistration: false,
...(gramMcpClient ? { trustedClients: [gramMcpClient] } : {}),
},
}),
],
socialProviders,
user: {
Expand Down
Loading
Loading