mcp-access.ts594 lines · main
| 1 | import { createHash, randomBytes } from 'node:crypto'; |
| 2 | |
| 3 | import { newId, NotFoundError } from '@briven/shared'; |
| 4 | |
| 5 | import { desc, eq, inArray, isNull, like } from 'drizzle-orm'; |
| 6 | |
| 7 | import { getDb } from '../db/client.js'; |
| 8 | import { |
| 9 | mcpKeys, |
| 10 | platformSettings, |
| 11 | projects, |
| 12 | type McpKey, |
| 13 | type McpKeyScope, |
| 14 | type NewMcpKey, |
| 15 | type ProjectTier, |
| 16 | } from '../db/schema.js'; |
| 17 | import { audit, listAuditByActionPrefix, type AuditEntry, type AuditRow } from './audit.js'; |
| 18 | import { getTierForOrg } from './billing.js'; |
| 19 | import { getPlatformSetting, setPlatformSetting } from './platform-settings.js'; |
| 20 | |
| 21 | /** |
| 22 | * B Phase 5 — MCP / Agent-Access control surface. |
| 23 | * |
| 24 | * This is the on/off + key-issuing + audit SURFACE for the future MCP server. |
| 25 | * It does NOT speak the MCP wire protocol — the socket server that consumes |
| 26 | * these keys is a separate track. What lives here: |
| 27 | * - a GLOBAL kill-switch (platform_settings `mcp.enabled`) that gates every |
| 28 | * agent's access at once; |
| 29 | * - per-project enablement (platform_settings `mcp.project.<id>`), behind a |
| 30 | * SERVER-SIDE plan gate — only paying Pro/Team projects qualify; |
| 31 | * - per-key issue (one-time plaintext reveal, sha-256 hash stored) + revoke; |
| 32 | * - the mcp.* audit trail, written through the shared audit() helper into the |
| 33 | * existing audit_logs table (no second audit table). |
| 34 | * |
| 35 | * Nothing security-sensitive is cached: the global flag read goes through |
| 36 | * platform-settings' short TTL cache (a flag flip, not a secret), but key |
| 37 | * lookups and the plan gate always hit the DB fresh. |
| 38 | */ |
| 39 | |
| 40 | /* ─── constants ──────────────────────────────────────────────────────────── */ |
| 41 | |
| 42 | /** Platform-settings key for the global MCP kill-switch. */ |
| 43 | export const MCP_GLOBAL_FLAG = 'mcp.enabled'; |
| 44 | /** Plaintext prefix for issued keys — recognisable in logs + grep. */ |
| 45 | export const MCP_KEY_PREFIX = 'pk_briven_mcp_'; |
| 46 | const KEY_ENTROPY_BYTES = 32; // 256 bits |
| 47 | /** Tiers that may turn on MCP access. Free never qualifies. */ |
| 48 | export const MCP_PAID_TIERS = ['pro', 'team'] as const satisfies readonly ProjectTier[]; |
| 49 | |
| 50 | /** Per-project platform-settings key. */ |
| 51 | function projectFlagKey(projectId: string): string { |
| 52 | return `mcp.project.${projectId}`; |
| 53 | } |
| 54 | |
| 55 | /* ─── typed errors ───────────────────────────────────────────────────────── */ |
| 56 | |
| 57 | /** |
| 58 | * Thrown when MCP enable / key-issue is attempted for a project that is NOT on |
| 59 | * a paying plan. The route maps this to a 4xx so a direct API call can't slip |
| 60 | * past the UI gate. |
| 61 | */ |
| 62 | export class McpPlanRequiredError extends Error { |
| 63 | readonly code = 'mcp_plan_required' as const; |
| 64 | constructor( |
| 65 | readonly projectId: string, |
| 66 | readonly tier: ProjectTier | null, |
| 67 | ) { |
| 68 | super('MCP access requires a Pro or Team plan'); |
| 69 | this.name = 'McpPlanRequiredError'; |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * Thrown when a DELETE is attempted on a key that is still ACTIVE (revoked_at is |
| 75 | * null). Delete is a tidy-up of an already-cut key — a live key must be revoked |
| 76 | * first, so it can never be removed by accident. Routes map this to a 409. |
| 77 | */ |
| 78 | export class McpKeyNotRevokedError extends Error { |
| 79 | readonly code = 'mcp_key_not_revoked' as const; |
| 80 | constructor(readonly keyId: string) { |
| 81 | super('cannot delete an active key — revoke it first'); |
| 82 | this.name = 'McpKeyNotRevokedError'; |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | /* ─── pure helpers (unit-testable without a DB) ──────────────────────────── */ |
| 87 | |
| 88 | /** The plan gate, as a pure rule: only Pro / Team qualify. */ |
| 89 | export function isPlanEligibleForMcp(tier: ProjectTier | null | undefined): boolean { |
| 90 | return tier === 'pro' || tier === 'team'; |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Generate a fresh MCP key. The plaintext is returned to the caller exactly |
| 95 | * once; only the sha-256 hash is ever persisted. Mirrors api-keys.ts. |
| 96 | */ |
| 97 | export function generateMcpKey(): { |
| 98 | plaintext: string; |
| 99 | hash: string; |
| 100 | prefix: string; |
| 101 | suffix: string; |
| 102 | } { |
| 103 | const raw = randomBytes(KEY_ENTROPY_BYTES).toString('base64url'); |
| 104 | const plaintext = `${MCP_KEY_PREFIX}${raw}`; |
| 105 | const hash = createHash('sha256').update(plaintext).digest('hex'); |
| 106 | const suffix = plaintext.slice(-4); |
| 107 | return { plaintext, hash, prefix: MCP_KEY_PREFIX, suffix }; |
| 108 | } |
| 109 | |
| 110 | export interface MaskedMcpKey { |
| 111 | id: string; |
| 112 | name: string; |
| 113 | prefix: string; |
| 114 | suffix: string; |
| 115 | scope: McpKeyScope; |
| 116 | enabled: boolean; |
| 117 | createdAt: Date; |
| 118 | lastUsedAt: Date | null; |
| 119 | revokedAt: Date | null; |
| 120 | } |
| 121 | |
| 122 | /** Strip the hash; never return it. The dashboard only ever sees prefix…suffix. */ |
| 123 | export function maskKey(row: McpKey): MaskedMcpKey { |
| 124 | return { |
| 125 | id: row.id, |
| 126 | name: row.name, |
| 127 | prefix: row.prefix, |
| 128 | suffix: row.suffix, |
| 129 | scope: row.scope, |
| 130 | enabled: row.enabled, |
| 131 | createdAt: row.createdAt, |
| 132 | lastUsedAt: row.lastUsedAt, |
| 133 | revokedAt: row.revokedAt, |
| 134 | }; |
| 135 | } |
| 136 | |
| 137 | /* ─── injectable seam (so the mutators are unit-testable without a DB) ────── */ |
| 138 | |
| 139 | /** The actor behind a state change — threaded into the audit row. */ |
| 140 | export interface McpActor { |
| 141 | id: string | null; |
| 142 | ipHash: string | null; |
| 143 | userAgent: string | null; |
| 144 | } |
| 145 | |
| 146 | export interface McpAccessDeps { |
| 147 | /** Persist the global kill-switch flag. */ |
| 148 | setGlobalSetting(on: boolean, actorId: string | null): Promise<void>; |
| 149 | /** |
| 150 | * Effective paid plan tier for a project, from the SAME source Phase 3 used |
| 151 | * (the org's non-canceled subscriptions table). Returns null when the |
| 152 | * project does not exist / is deleted; 'free' when no paid subscription. |
| 153 | */ |
| 154 | getProjectPlanTier(projectId: string): Promise<ProjectTier | null>; |
| 155 | /** Persist per-project enablement. */ |
| 156 | setProjectEnabled(projectId: string, on: boolean, actorId: string | null): Promise<void>; |
| 157 | insertKey(row: NewMcpKey): Promise<McpKey>; |
| 158 | getKeyById(keyId: string): Promise<McpKey | null>; |
| 159 | /** Set revoked_at = now() AND enabled = false. */ |
| 160 | setKeyRevoked(keyId: string): Promise<void>; |
| 161 | /** Hard-delete the key row. Only ever called on an already-revoked key. */ |
| 162 | deleteKey(keyId: string): Promise<void>; |
| 163 | audit(entry: AuditEntry): Promise<void>; |
| 164 | } |
| 165 | |
| 166 | /** Real, DB-backed dependencies. */ |
| 167 | export const defaultMcpAccessDeps: McpAccessDeps = { |
| 168 | async setGlobalSetting(on, actorId) { |
| 169 | await setPlatformSetting(MCP_GLOBAL_FLAG, on, actorId); |
| 170 | }, |
| 171 | async getProjectPlanTier(projectId) { |
| 172 | const db = getDb(); |
| 173 | const [proj] = await db |
| 174 | .select({ orgId: projects.orgId, deletedAt: projects.deletedAt }) |
| 175 | .from(projects) |
| 176 | .where(eq(projects.id, projectId)) |
| 177 | .limit(1); |
| 178 | if (!proj || proj.deletedAt) return null; |
| 179 | // Resolve through getTierForOrg so the founder comp (owner-email → team) |
| 180 | // counts for MCP exactly as it does for /billing + project creation. A real |
| 181 | // customer still resolves to their live subscription tier; a non-comped free |
| 182 | // org still resolves to 'free' and stays gated. |
| 183 | return getTierForOrg(proj.orgId); |
| 184 | }, |
| 185 | async setProjectEnabled(projectId, on, actorId) { |
| 186 | await setPlatformSetting(projectFlagKey(projectId), on, actorId); |
| 187 | }, |
| 188 | async insertKey(row) { |
| 189 | const db = getDb(); |
| 190 | const [record] = await db.insert(mcpKeys).values(row).returning(); |
| 191 | if (!record) throw new Error('mcp key insert returned no row'); |
| 192 | return record; |
| 193 | }, |
| 194 | async getKeyById(keyId) { |
| 195 | const db = getDb(); |
| 196 | const [row] = await db.select().from(mcpKeys).where(eq(mcpKeys.id, keyId)).limit(1); |
| 197 | return row ?? null; |
| 198 | }, |
| 199 | async setKeyRevoked(keyId) { |
| 200 | const db = getDb(); |
| 201 | await db |
| 202 | .update(mcpKeys) |
| 203 | .set({ revokedAt: new Date(), enabled: false }) |
| 204 | .where(eq(mcpKeys.id, keyId)); |
| 205 | }, |
| 206 | async deleteKey(keyId) { |
| 207 | const db = getDb(); |
| 208 | await db.delete(mcpKeys).where(eq(mcpKeys.id, keyId)); |
| 209 | }, |
| 210 | async audit(entry) { |
| 211 | await audit(entry); |
| 212 | }, |
| 213 | }; |
| 214 | |
| 215 | /* ─── global kill-switch ─────────────────────────────────────────────────── */ |
| 216 | |
| 217 | /** Read the global MCP on/off flag (defaults OFF). */ |
| 218 | export async function getGlobalEnabled(): Promise<boolean> { |
| 219 | const value = await getPlatformSetting<unknown>(MCP_GLOBAL_FLAG, false); |
| 220 | return value === true; |
| 221 | } |
| 222 | |
| 223 | /** Flip the global MCP kill-switch + audit it. */ |
| 224 | export async function setGlobalEnabled( |
| 225 | on: boolean, |
| 226 | actor: McpActor, |
| 227 | deps: McpAccessDeps = defaultMcpAccessDeps, |
| 228 | ): Promise<{ enabled: boolean }> { |
| 229 | await deps.setGlobalSetting(on, actor.id); |
| 230 | await deps.audit({ |
| 231 | actorId: actor.id, |
| 232 | projectId: null, |
| 233 | action: 'mcp.global.toggle', |
| 234 | ipHash: actor.ipHash, |
| 235 | userAgent: actor.userAgent, |
| 236 | metadata: { enabled: on }, |
| 237 | }); |
| 238 | return { enabled: on }; |
| 239 | } |
| 240 | |
| 241 | /* ─── per-project enablement (server-side plan gate) ─────────────────────── */ |
| 242 | |
| 243 | /** |
| 244 | * Turn MCP access ON for a project. SERVER-SIDE plan gate: a free-tier (or |
| 245 | * unknown) project is rejected with McpPlanRequiredError even when called |
| 246 | * directly, so the UI hiding the button is defence-in-depth, not the gate. |
| 247 | */ |
| 248 | export async function enableForProject( |
| 249 | projectId: string, |
| 250 | actor: McpActor, |
| 251 | deps: McpAccessDeps = defaultMcpAccessDeps, |
| 252 | ): Promise<{ projectId: string; enabled: true }> { |
| 253 | const tier = await deps.getProjectPlanTier(projectId); |
| 254 | if (!isPlanEligibleForMcp(tier)) { |
| 255 | throw new McpPlanRequiredError(projectId, tier); |
| 256 | } |
| 257 | await deps.setProjectEnabled(projectId, true, actor.id); |
| 258 | await deps.audit({ |
| 259 | actorId: actor.id, |
| 260 | projectId, |
| 261 | action: 'mcp.project.enable', |
| 262 | ipHash: actor.ipHash, |
| 263 | userAgent: actor.userAgent, |
| 264 | metadata: { projectId, tier }, |
| 265 | }); |
| 266 | return { projectId, enabled: true }; |
| 267 | } |
| 268 | |
| 269 | /** Turn MCP access OFF for a project (no plan gate — disabling is always allowed). */ |
| 270 | export async function disableForProject( |
| 271 | projectId: string, |
| 272 | actor: McpActor, |
| 273 | deps: McpAccessDeps = defaultMcpAccessDeps, |
| 274 | ): Promise<{ projectId: string; enabled: false }> { |
| 275 | await deps.setProjectEnabled(projectId, false, actor.id); |
| 276 | await deps.audit({ |
| 277 | actorId: actor.id, |
| 278 | projectId, |
| 279 | action: 'mcp.project.disable', |
| 280 | ipHash: actor.ipHash, |
| 281 | userAgent: actor.userAgent, |
| 282 | metadata: { projectId }, |
| 283 | }); |
| 284 | return { projectId, enabled: false }; |
| 285 | } |
| 286 | |
| 287 | /* ─── key issue / revoke ─────────────────────────────────────────────────── */ |
| 288 | |
| 289 | export interface IssuedMcpKey { |
| 290 | key: MaskedMcpKey; |
| 291 | /** Full plaintext — returned EXACTLY once, never stored, never returned again. */ |
| 292 | plaintext: string; |
| 293 | } |
| 294 | |
| 295 | /** |
| 296 | * Issue a new MCP key for a project. Re-checks the plan gate (defence in |
| 297 | * depth — a key must never exist for a non-paying project) before generating. |
| 298 | * The plaintext is returned once; only the hash is persisted. |
| 299 | */ |
| 300 | export async function issueKey( |
| 301 | input: { projectId: string; name: string; scope: McpKeyScope }, |
| 302 | actor: McpActor, |
| 303 | deps: McpAccessDeps = defaultMcpAccessDeps, |
| 304 | ): Promise<IssuedMcpKey> { |
| 305 | const tier = await deps.getProjectPlanTier(input.projectId); |
| 306 | if (!isPlanEligibleForMcp(tier)) { |
| 307 | throw new McpPlanRequiredError(input.projectId, tier); |
| 308 | } |
| 309 | if (!actor.id) throw new Error('issueKey requires an actor id (created_by)'); |
| 310 | |
| 311 | const { plaintext, hash, prefix, suffix } = generateMcpKey(); |
| 312 | const record = await deps.insertKey({ |
| 313 | id: newId('mck'), |
| 314 | projectId: input.projectId, |
| 315 | name: input.name, |
| 316 | hash, |
| 317 | prefix, |
| 318 | suffix, |
| 319 | scope: input.scope, |
| 320 | enabled: true, |
| 321 | createdBy: actor.id, |
| 322 | }); |
| 323 | await deps.audit({ |
| 324 | actorId: actor.id, |
| 325 | projectId: input.projectId, |
| 326 | action: 'mcp.key.issue', |
| 327 | ipHash: actor.ipHash, |
| 328 | userAgent: actor.userAgent, |
| 329 | metadata: { projectId: input.projectId, keyId: record.id, scope: input.scope, suffix }, |
| 330 | }); |
| 331 | return { key: maskKey(record), plaintext }; |
| 332 | } |
| 333 | |
| 334 | /** Revoke a key: stamps revoked_at + flips enabled false. Idempotent-safe. */ |
| 335 | export async function revokeKey( |
| 336 | keyId: string, |
| 337 | actor: McpActor, |
| 338 | deps: McpAccessDeps = defaultMcpAccessDeps, |
| 339 | ): Promise<{ keyId: string; revoked: true }> { |
| 340 | const row = await deps.getKeyById(keyId); |
| 341 | if (!row) throw new NotFoundError('mcp_key', keyId); |
| 342 | if (!row.revokedAt) { |
| 343 | await deps.setKeyRevoked(keyId); |
| 344 | } |
| 345 | await deps.audit({ |
| 346 | actorId: actor.id, |
| 347 | projectId: row.projectId, |
| 348 | action: 'mcp.key.revoke', |
| 349 | ipHash: actor.ipHash, |
| 350 | userAgent: actor.userAgent, |
| 351 | metadata: { projectId: row.projectId, keyId }, |
| 352 | }); |
| 353 | return { keyId, revoked: true }; |
| 354 | } |
| 355 | |
| 356 | /** |
| 357 | * Hard-delete a key — but ONLY if it is already revoked. REVOKE-THEN-DELETE: a |
| 358 | * live key must be cut (revoked) before it can be tidied away, so a deletion can |
| 359 | * never remove an in-use key by accident. |
| 360 | * - unknown key → NotFoundError (route → 404) |
| 361 | * - still active → McpKeyNotRevokedError (route → 409) |
| 362 | * - already revoked → row hard-deleted + mcp.key.deleted audited |
| 363 | */ |
| 364 | export async function deleteRevokedKey( |
| 365 | keyId: string, |
| 366 | actor: McpActor, |
| 367 | deps: McpAccessDeps = defaultMcpAccessDeps, |
| 368 | ): Promise<{ keyId: string; deleted: true }> { |
| 369 | const row = await deps.getKeyById(keyId); |
| 370 | if (!row) throw new NotFoundError('mcp_key', keyId); |
| 371 | if (!row.revokedAt) throw new McpKeyNotRevokedError(keyId); |
| 372 | await deps.deleteKey(keyId); |
| 373 | await deps.audit({ |
| 374 | actorId: actor.id, |
| 375 | projectId: row.projectId, |
| 376 | action: 'mcp.key.deleted', |
| 377 | ipHash: actor.ipHash, |
| 378 | userAgent: actor.userAgent, |
| 379 | metadata: { projectId: row.projectId, keyId }, |
| 380 | }); |
| 381 | return { keyId, deleted: true }; |
| 382 | } |
| 383 | |
| 384 | /* ─── read surfaces (cockpit) ────────────────────────────────────────────── */ |
| 385 | |
| 386 | export interface ProjectAccessRow { |
| 387 | projectId: string; |
| 388 | projectName: string; |
| 389 | /** Effective paid tier (free | pro | team) — drives the UI enable gate. */ |
| 390 | planTier: ProjectTier; |
| 391 | /** Whether MCP is currently enabled for this project. */ |
| 392 | mcpEnabled: boolean; |
| 393 | /** Whether the plan qualifies (Pro/Team) — the UI offers "enable" only when true. */ |
| 394 | eligible: boolean; |
| 395 | keys: MaskedMcpKey[]; |
| 396 | } |
| 397 | |
| 398 | /** |
| 399 | * Every non-deleted project with its effective plan tier, MCP-enabled flag, and |
| 400 | * issued keys (masked). One payload the cockpit renders the whole per-project |
| 401 | * list from: enabled projects show keys + disable; paying-but-not-enabled show |
| 402 | * an enable button; free-tier show a muted "requires Pro/Team" note. |
| 403 | */ |
| 404 | export async function listProjectAccess(limit = 500): Promise<ProjectAccessRow[]> { |
| 405 | const db = getDb(); |
| 406 | |
| 407 | const projectRows = await db |
| 408 | .select({ |
| 409 | id: projects.id, |
| 410 | name: projects.name, |
| 411 | orgId: projects.orgId, |
| 412 | }) |
| 413 | .from(projects) |
| 414 | .where(isNull(projects.deletedAt)) |
| 415 | .orderBy(desc(projects.createdAt)) |
| 416 | .limit(limit); |
| 417 | if (projectRows.length === 0) return []; |
| 418 | |
| 419 | const orgIds = [...new Set(projectRows.map((p) => p.orgId))]; |
| 420 | const projectIds = projectRows.map((p) => p.id); |
| 421 | |
| 422 | // Resolve each org's tier through getTierForOrg so the founder comp |
| 423 | // (owner-email → team) is honoured here exactly as on the gate + /billing — |
| 424 | // otherwise a comped owner's projects wrongly show "requires Pro/Team" and get |
| 425 | // no enable button. Real customers resolve to their live subscription tier. |
| 426 | const tierByOrg = new Map<string, ProjectTier>(); |
| 427 | await Promise.all( |
| 428 | orgIds.map(async (orgId) => { |
| 429 | tierByOrg.set(orgId, await getTierForOrg(orgId)); |
| 430 | }), |
| 431 | ); |
| 432 | |
| 433 | // Per-project enablement lives in platform_settings under mcp.project.<id>. |
| 434 | const flagRows = await db |
| 435 | .select({ key: platformSettings.key, value: platformSettings.value }) |
| 436 | .from(platformSettings) |
| 437 | .where(like(platformSettings.key, 'mcp.project.%')); |
| 438 | const enabledByProject = new Set<string>(); |
| 439 | for (const r of flagRows) { |
| 440 | if (r.value === true) enabledByProject.add(r.key.slice('mcp.project.'.length)); |
| 441 | } |
| 442 | |
| 443 | const keyRows = await db |
| 444 | .select() |
| 445 | .from(mcpKeys) |
| 446 | .where(inArray(mcpKeys.projectId, projectIds)) |
| 447 | .orderBy(desc(mcpKeys.createdAt)); |
| 448 | const keysByProject = new Map<string, MaskedMcpKey[]>(); |
| 449 | for (const k of keyRows) { |
| 450 | const list = keysByProject.get(k.projectId) ?? []; |
| 451 | list.push(maskKey(k)); |
| 452 | keysByProject.set(k.projectId, list); |
| 453 | } |
| 454 | |
| 455 | return projectRows.map((p) => { |
| 456 | const planTier = tierByOrg.get(p.orgId) ?? 'free'; |
| 457 | return { |
| 458 | projectId: p.id, |
| 459 | projectName: p.name, |
| 460 | planTier, |
| 461 | mcpEnabled: enabledByProject.has(p.id), |
| 462 | eligible: isPlanEligibleForMcp(planTier), |
| 463 | keys: keysByProject.get(p.id) ?? [], |
| 464 | }; |
| 465 | }); |
| 466 | } |
| 467 | |
| 468 | /** |
| 469 | * Masked keys for a SINGLE project, newest first. Powers the per-project user |
| 470 | * dashboard (the project admin's own Agent-Access tab) — the project-scoped |
| 471 | * read that mirrors `listProjectAccess`'s key column without loading every |
| 472 | * project. Same query style + `maskKey()` so a hash never leaves the service. |
| 473 | */ |
| 474 | export async function listKeysForProject(projectId: string): Promise<MaskedMcpKey[]> { |
| 475 | const db = getDb(); |
| 476 | const rows = await db |
| 477 | .select() |
| 478 | .from(mcpKeys) |
| 479 | .where(eq(mcpKeys.projectId, projectId)) |
| 480 | .orderBy(desc(mcpKeys.createdAt)); |
| 481 | return rows.map(maskKey); |
| 482 | } |
| 483 | |
| 484 | /** Recent mcp.* audit rows for the cockpit audit-trail table. */ |
| 485 | export async function listMcpAudit(limit = 200): Promise<AuditRow[]> { |
| 486 | return listAuditByActionPrefix('mcp.', limit); |
| 487 | } |
| 488 | |
| 489 | /* ─── key verification (the live MCP-server auth gate) ───────────────────── */ |
| 490 | |
| 491 | /** |
| 492 | * Hash a presented plaintext key EXACTLY the way `generateMcpKey()` does, so a |
| 493 | * lookup by hash finds the row issued for that plaintext. sha-256 hex of the |
| 494 | * full plaintext (prefix included). Kept as its own helper so the hashing rule |
| 495 | * lives in one place and the verifier can never drift from the issuer. |
| 496 | */ |
| 497 | export function hashMcpKey(plaintext: string): string { |
| 498 | return createHash('sha256').update(plaintext).digest('hex'); |
| 499 | } |
| 500 | |
| 501 | /** Why a presented key was refused. 401 = auth (bad key); 403 = gate (plan/switch). */ |
| 502 | export type McpVerifyFailure = |
| 503 | | 'missing_key' |
| 504 | | 'malformed_key' |
| 505 | | 'unknown_key' |
| 506 | | 'revoked_key' |
| 507 | | 'global_disabled' |
| 508 | | 'project_disabled' |
| 509 | | 'plan_ineligible'; |
| 510 | |
| 511 | /** |
| 512 | * Result of verifying a presented MCP key. On success it carries the ONLY |
| 513 | * facts the MCP server is allowed to trust: which key, which project it is |
| 514 | * hard-locked to, and what it may do. There is deliberately no way for a |
| 515 | * caller to widen `projectId` — it comes from the stored row, never the wire. |
| 516 | */ |
| 517 | export type McpVerifyResult = |
| 518 | | { ok: true; keyId: string; projectId: string; scope: McpKeyScope } |
| 519 | | { ok: false; status: 401 | 403; reason: McpVerifyFailure }; |
| 520 | |
| 521 | /** Injectable seam so the verifier is unit-testable without a live DB. */ |
| 522 | export interface McpVerifyDeps { |
| 523 | getKeyByHash(hash: string): Promise<McpKey | null>; |
| 524 | touchKeyLastUsed(keyId: string): Promise<void>; |
| 525 | isGlobalEnabled(): Promise<boolean>; |
| 526 | isProjectEnabled(projectId: string): Promise<boolean>; |
| 527 | getProjectPlanTier(projectId: string): Promise<ProjectTier | null>; |
| 528 | } |
| 529 | |
| 530 | /** Real, DB-backed verification dependencies. */ |
| 531 | export const defaultMcpVerifyDeps: McpVerifyDeps = { |
| 532 | async getKeyByHash(hash) { |
| 533 | const db = getDb(); |
| 534 | const [row] = await db.select().from(mcpKeys).where(eq(mcpKeys.hash, hash)).limit(1); |
| 535 | return row ?? null; |
| 536 | }, |
| 537 | async touchKeyLastUsed(keyId) { |
| 538 | const db = getDb(); |
| 539 | await db.update(mcpKeys).set({ lastUsedAt: new Date() }).where(eq(mcpKeys.id, keyId)); |
| 540 | }, |
| 541 | isGlobalEnabled() { |
| 542 | return getGlobalEnabled(); |
| 543 | }, |
| 544 | async isProjectEnabled(projectId) { |
| 545 | const value = await getPlatformSetting<unknown>(projectFlagKey(projectId), false); |
| 546 | return value === true; |
| 547 | }, |
| 548 | getProjectPlanTier(projectId) { |
| 549 | return defaultMcpAccessDeps.getProjectPlanTier(projectId); |
| 550 | }, |
| 551 | }; |
| 552 | |
| 553 | /** |
| 554 | * Verify a presented plaintext key and resolve its project binding + scope. |
| 555 | * |
| 556 | * Order matters — auth first (is this a real, live key?), then the gates (is |
| 557 | * MCP even on for this project, and does the plan still qualify?): |
| 558 | * 1. present + well-formed prefix → else 401 missing/malformed |
| 559 | * 2. hash matches a stored row → else 401 unknown |
| 560 | * 3. not revoked and still enabled → else 401 revoked |
| 561 | * 4. global kill-switch is ON → else 403 global_disabled |
| 562 | * 5. project is enabled for MCP → else 403 project_disabled |
| 563 | * 6. project's plan is Pro/Team → else 403 plan_ineligible |
| 564 | * |
| 565 | * The plaintext key is NEVER logged or returned. On success `lastUsedAt` is |
| 566 | * stamped. The caller binds the returned `projectId`/`scope` to the request — |
| 567 | * no tool ever sees a project id from the wire. |
| 568 | */ |
| 569 | export async function verifyMcpKey( |
| 570 | presented: string | null | undefined, |
| 571 | deps: McpVerifyDeps = defaultMcpVerifyDeps, |
| 572 | ): Promise<McpVerifyResult> { |
| 573 | if (!presented) return { ok: false, status: 401, reason: 'missing_key' }; |
| 574 | if (!presented.startsWith(MCP_KEY_PREFIX)) { |
| 575 | return { ok: false, status: 401, reason: 'malformed_key' }; |
| 576 | } |
| 577 | const row = await deps.getKeyByHash(hashMcpKey(presented)); |
| 578 | if (!row) return { ok: false, status: 401, reason: 'unknown_key' }; |
| 579 | if (row.revokedAt || !row.enabled) return { ok: false, status: 401, reason: 'revoked_key' }; |
| 580 | |
| 581 | if (!(await deps.isGlobalEnabled())) { |
| 582 | return { ok: false, status: 403, reason: 'global_disabled' }; |
| 583 | } |
| 584 | if (!(await deps.isProjectEnabled(row.projectId))) { |
| 585 | return { ok: false, status: 403, reason: 'project_disabled' }; |
| 586 | } |
| 587 | const tier = await deps.getProjectPlanTier(row.projectId); |
| 588 | if (!isPlanEligibleForMcp(tier)) { |
| 589 | return { ok: false, status: 403, reason: 'plan_ineligible' }; |
| 590 | } |
| 591 | |
| 592 | await deps.touchKeyLastUsed(row.id); |
| 593 | return { ok: true, keyId: row.id, projectId: row.projectId, scope: row.scope }; |
| 594 | } |