project-mcp.ts252 lines · main
1import { Hono, type Context, type MiddlewareHandler } from 'hono';
2import { z } from 'zod';
3
4import { NotFoundError } from '@briven/shared';
5
6import { requireProjectAuth, requireProjectRole } from '../middleware/project-auth.js';
7import { projectRateLimit } from '../middleware/rate-limit.js';
8import { hashIp } from '../services/audit.js';
9import { mcpKeyScope, type ProjectTier } from '../db/schema.js';
10import {
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';
27import 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. */
60export interface ProjectMcpReadDeps {
61 getGlobalEnabled(): Promise<boolean>;
62 isProjectEnabled(projectId: string): Promise<boolean>;
63 listKeysForProject(projectId: string): Promise<MaskedMcpKey[]>;
64}
65
66const defaultReadDeps: ProjectMcpReadDeps = {
67 getGlobalEnabled,
68 isProjectEnabled: (projectId) => defaultMcpVerifyDeps.isProjectEnabled(projectId),
69 listKeysForProject,
70};
71
72function 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. */
79function 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
88const issueBody = z.object({
89 name: z.string().min(1).max(120),
90 scope: z.enum(mcpKeyScope),
91});
92
93function 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 */
106export 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. */
252export const projectMcpRouter = buildProjectMcpRouter();