project-mcp.ts252 lines · main
| 1 | import { Hono, type Context, type MiddlewareHandler } from 'hono'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { NotFoundError } from '@briven/shared'; |
| 5 | |
| 6 | import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js'; |
| 7 | import { projectRateLimit } from '../middleware/rate-limit.js'; |
| 8 | import { hashIp } from '../services/audit.js'; |
| 9 | import { mcpKeyScope, type ProjectTier } from '../db/schema.js'; |
| 10 | import { |
| 11 | defaultMcpAccessDeps, |
| 12 | defaultMcpVerifyDeps, |
| 13 | deleteRevokedKey, |
| 14 | disableForProject, |
| 15 | enableForProject, |
| 16 | getGlobalEnabled, |
| 17 | isPlanEligibleForMcp, |
| 18 | issueKey, |
| 19 | listKeysForProject, |
| 20 | McpKeyNotRevokedError, |
| 21 | McpPlanRequiredError, |
| 22 | revokeKey, |
| 23 | type MaskedMcpKey, |
| 24 | type McpAccessDeps, |
| 25 | type McpActor, |
| 26 | } from '../services/mcp-access.js'; |
| 27 | import type { ProjectAppEnv as AppEnv } from '../types/app-env.js'; |
| 28 | |
| 29 | /** |
| 30 | * USER-scoped per-project MCP / Agent-Access surface. |
| 31 | * |
| 32 | * Same capability as the super-admin cockpit (`/v1/admin/mcp*`) — turn MCP on/ |
| 33 | * off and issue/revoke `pk_briven_mcp_` keys — but PROJECT-scoped and reachable |
| 34 | * by the project's OWN admin, so a paying Pro/Team customer self-activates it |
| 35 | * without any platform-admin involvement. Every route is gated by |
| 36 | * `requireProjectAuth()` + `requireProjectRole('admin')`. |
| 37 | * |
| 38 | * The platform-wide GLOBAL kill-switch stays admin-only and is NOT exposed here |
| 39 | * — users only ever see its effect: when it's off, enabling/issuing is refused |
| 40 | * (defence-in-depth) and the UI shows MCP as unavailable. |
| 41 | * |
| 42 | * Gating model (enforced SERVER-SIDE, so the UI hiding a button is never the |
| 43 | * gate): |
| 44 | * - enable → 403 `mcp_global_disabled` if the global switch is off; |
| 45 | * 403 `mcp_plan_required` if the project isn't Pro/Team. |
| 46 | * - disable → always allowed (no gate). |
| 47 | * - issue key → same as enable, plus 409 `mcp_not_enabled` if MCP isn't on |
| 48 | * for the project yet. |
| 49 | * - revoke → 404 if the key is unknown; 403 `cross_project` if the key |
| 50 | * belongs to a DIFFERENT project than the URL `:id` |
| 51 | * (no cross-project revoke). |
| 52 | * - delete → same cross-project guard as revoke, plus REVOKE-THEN-DELETE: |
| 53 | * 409 `mcp_key_not_revoked` if the key is still active (a live |
| 54 | * key must be revoked before it can be deleted). |
| 55 | * Every mutation is audited inside the mcp-access service (`mcp.*` actions, |
| 56 | * actor = the acting user id, project = `:id`). |
| 57 | */ |
| 58 | |
| 59 | /** Read-side dependencies not already covered by `McpAccessDeps`. Injectable so the router is unit-testable without a DB. */ |
| 60 | export interface ProjectMcpReadDeps { |
| 61 | getGlobalEnabled(): Promise<boolean>; |
| 62 | isProjectEnabled(projectId: string): Promise<boolean>; |
| 63 | listKeysForProject(projectId: string): Promise<MaskedMcpKey[]>; |
| 64 | } |
| 65 | |
| 66 | const defaultReadDeps: ProjectMcpReadDeps = { |
| 67 | getGlobalEnabled, |
| 68 | isProjectEnabled: (projectId) => defaultMcpVerifyDeps.isProjectEnabled(projectId), |
| 69 | listKeysForProject, |
| 70 | }; |
| 71 | |
| 72 | function ipHash(c: Context<AppEnv>): string | null { |
| 73 | const fwd = c.req.raw.headers.get('x-forwarded-for'); |
| 74 | const ip = fwd ? fwd.split(',')[0]!.trim() : null; |
| 75 | return hashIp(ip); |
| 76 | } |
| 77 | |
| 78 | /** Build the audit actor from the request — acting user id + hashed IP + UA. */ |
| 79 | function mcpActor(c: Context<AppEnv>): McpActor { |
| 80 | const user = c.get('user'); |
| 81 | return { |
| 82 | id: user?.id ?? null, |
| 83 | ipHash: ipHash(c), |
| 84 | userAgent: c.req.header('user-agent') ?? null, |
| 85 | }; |
| 86 | } |
| 87 | |
| 88 | const issueBody = z.object({ |
| 89 | name: z.string().min(1).max(120), |
| 90 | scope: z.enum(mcpKeyScope), |
| 91 | }); |
| 92 | |
| 93 | function planRequired(c: Context<AppEnv>, err: McpPlanRequiredError) { |
| 94 | return c.json( |
| 95 | { code: err.code, message: 'Agent Access requires a Pro or Team plan', tier: err.tier }, |
| 96 | 403, |
| 97 | ); |
| 98 | } |
| 99 | |
| 100 | /** |
| 101 | * Build the per-project MCP router. The auth middleware and the service deps |
| 102 | * are injectable so the route logic (gating + cross-project isolation) can be |
| 103 | * exercised in tests without a live DB; production uses the real middleware |
| 104 | * chain + DB-backed deps. |
| 105 | */ |
| 106 | export function buildProjectMcpRouter(opts?: { |
| 107 | middleware?: MiddlewareHandler[]; |
| 108 | accessDeps?: McpAccessDeps; |
| 109 | readDeps?: ProjectMcpReadDeps; |
| 110 | }): Hono<AppEnv> { |
| 111 | const accessDeps = opts?.accessDeps ?? defaultMcpAccessDeps; |
| 112 | const read = opts?.readDeps ?? defaultReadDeps; |
| 113 | const middleware = opts?.middleware ?? [requireProjectAuth(), requireProjectRole('admin')]; |
| 114 | |
| 115 | const router = new Hono<AppEnv>(); |
| 116 | |
| 117 | // Project-admin only. API keys minted at admin pass by design (a project key |
| 118 | // already has equivalent authority over its own project). |
| 119 | router.use('/v1/projects/:id/mcp', ...middleware); |
| 120 | router.use('/v1/projects/:id/mcp/*', ...middleware); |
| 121 | |
| 122 | /** Status for THIS project only: global flag, plan, eligibility, on/off, keys. */ |
| 123 | router.get('/v1/projects/:id/mcp', async (c) => { |
| 124 | const projectId = c.req.param('id'); |
| 125 | const [globalEnabled, tier, mcpEnabled, keys] = await Promise.all([ |
| 126 | read.getGlobalEnabled(), |
| 127 | accessDeps.getProjectPlanTier(projectId), |
| 128 | read.isProjectEnabled(projectId), |
| 129 | read.listKeysForProject(projectId), |
| 130 | ]); |
| 131 | const planTier: ProjectTier = tier ?? 'free'; |
| 132 | return c.json({ |
| 133 | globalEnabled, |
| 134 | planTier, |
| 135 | eligible: isPlanEligibleForMcp(planTier), |
| 136 | mcpEnabled, |
| 137 | keys, |
| 138 | }); |
| 139 | }); |
| 140 | |
| 141 | /** Enable MCP for the project. Gates: global ON + Pro/Team plan. */ |
| 142 | router.post('/v1/projects/:id/mcp/enable', projectRateLimit('mutate'), async (c) => { |
| 143 | const projectId = c.req.param('id'); |
| 144 | if (!(await read.getGlobalEnabled())) { |
| 145 | return c.json( |
| 146 | { code: 'mcp_global_disabled', message: 'Agent access is currently disabled platform-wide' }, |
| 147 | 403, |
| 148 | ); |
| 149 | } |
| 150 | try { |
| 151 | const result = await enableForProject(projectId, mcpActor(c), accessDeps); |
| 152 | return c.json(result); |
| 153 | } catch (err) { |
| 154 | if (err instanceof McpPlanRequiredError) return planRequired(c, err); |
| 155 | throw err; |
| 156 | } |
| 157 | }); |
| 158 | |
| 159 | /** Disable MCP for the project (always allowed — no plan gate). */ |
| 160 | router.post('/v1/projects/:id/mcp/disable', projectRateLimit('mutate'), async (c) => { |
| 161 | const projectId = c.req.param('id'); |
| 162 | const result = await disableForProject(projectId, mcpActor(c), accessDeps); |
| 163 | return c.json(result); |
| 164 | }); |
| 165 | |
| 166 | /** Issue a key — returns the FULL plaintext EXACTLY once. Same gates as enable + project must be enabled. */ |
| 167 | router.post('/v1/projects/:id/mcp/keys', projectRateLimit('mutate'), async (c) => { |
| 168 | const projectId = c.req.param('id'); |
| 169 | const body = await c.req.json().catch(() => null); |
| 170 | const parsed = issueBody.safeParse(body); |
| 171 | if (!parsed.success) { |
| 172 | return c.json({ code: 'validation_failed', issues: parsed.error.issues }, 400); |
| 173 | } |
| 174 | if (!(await read.getGlobalEnabled())) { |
| 175 | return c.json( |
| 176 | { code: 'mcp_global_disabled', message: 'Agent access is currently disabled platform-wide' }, |
| 177 | 403, |
| 178 | ); |
| 179 | } |
| 180 | if (!(await read.isProjectEnabled(projectId))) { |
| 181 | return c.json( |
| 182 | { code: 'mcp_not_enabled', message: 'enable Agent Access for this project first' }, |
| 183 | 409, |
| 184 | ); |
| 185 | } |
| 186 | try { |
| 187 | const result = await issueKey( |
| 188 | { projectId, name: parsed.data.name, scope: parsed.data.scope }, |
| 189 | mcpActor(c), |
| 190 | accessDeps, |
| 191 | ); |
| 192 | return c.json(result, 201); |
| 193 | } catch (err) { |
| 194 | if (err instanceof McpPlanRequiredError) return planRequired(c, err); |
| 195 | throw err; |
| 196 | } |
| 197 | }); |
| 198 | |
| 199 | /** Revoke a key — refuses any key that belongs to a DIFFERENT project (no cross-project revoke). */ |
| 200 | router.post('/v1/projects/:id/mcp/keys/:keyId/revoke', projectRateLimit('mutate'), async (c) => { |
| 201 | const projectId = c.req.param('id'); |
| 202 | const keyId = c.req.param('keyId'); |
| 203 | const row = await accessDeps.getKeyById(keyId); |
| 204 | if (!row) return c.json({ code: 'not_found', message: 'key not found' }, 404); |
| 205 | if (row.projectId !== projectId) { |
| 206 | return c.json( |
| 207 | { code: 'cross_project', message: 'key does not belong to this project' }, |
| 208 | 403, |
| 209 | ); |
| 210 | } |
| 211 | try { |
| 212 | const result = await revokeKey(keyId, mcpActor(c), accessDeps); |
| 213 | return c.json(result); |
| 214 | } catch (err) { |
| 215 | if (err instanceof NotFoundError) return c.json({ code: 'not_found' }, 404); |
| 216 | throw err; |
| 217 | } |
| 218 | }); |
| 219 | |
| 220 | /** |
| 221 | * Delete a key — same cross-project guard as revoke, plus REVOKE-THEN-DELETE: |
| 222 | * only an already-revoked key may be removed (409 `mcp_key_not_revoked` for an |
| 223 | * active key). Unknown / other-project → 404 / 403 `cross_project`. |
| 224 | */ |
| 225 | router.post('/v1/projects/:id/mcp/keys/:keyId/delete', projectRateLimit('mutate'), async (c) => { |
| 226 | const projectId = c.req.param('id'); |
| 227 | const keyId = c.req.param('keyId'); |
| 228 | const row = await accessDeps.getKeyById(keyId); |
| 229 | if (!row) return c.json({ code: 'not_found', message: 'key not found' }, 404); |
| 230 | if (row.projectId !== projectId) { |
| 231 | return c.json( |
| 232 | { code: 'cross_project', message: 'key does not belong to this project' }, |
| 233 | 403, |
| 234 | ); |
| 235 | } |
| 236 | try { |
| 237 | const result = await deleteRevokedKey(keyId, mcpActor(c), accessDeps); |
| 238 | return c.json(result); |
| 239 | } catch (err) { |
| 240 | if (err instanceof McpKeyNotRevokedError) { |
| 241 | return c.json({ code: err.code, message: 'revoke this key before deleting it' }, 409); |
| 242 | } |
| 243 | if (err instanceof NotFoundError) return c.json({ code: 'not_found' }, 404); |
| 244 | throw err; |
| 245 | } |
| 246 | }); |
| 247 | |
| 248 | return router; |
| 249 | } |
| 250 | |
| 251 | /** The production instance — real middleware + DB-backed deps. */ |
| 252 | export const projectMcpRouter = buildProjectMcpRouter(); |