@opengeni/api-router 2.6.4 → 2.10.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/access-grant-rls.d.ts +3 -0
  2. package/dist/app.d.ts +2 -1
  3. package/dist/app.js +3 -1
  4. package/dist/auth/organization-user-setup.d.ts +23 -0
  5. package/dist/{chunk-XTLI3CBH.js → chunk-XXH7DW34.js} +6866 -3554
  6. package/dist/chunk-XXH7DW34.js.map +1 -0
  7. package/dist/codemode.d.ts +6 -0
  8. package/dist/github-access.d.ts +25 -2
  9. package/dist/http/request-source.d.ts +14 -0
  10. package/dist/index.js +19 -3
  11. package/dist/index.js.map +1 -1
  12. package/dist/integrations/oauth-client.d.ts +2 -11
  13. package/dist/integrations/personal-github-repositories.d.ts +41 -2
  14. package/dist/integrations/slack-interactions.d.ts +1 -1
  15. package/dist/mcp/server.d.ts +158 -1
  16. package/dist/mcp/session-view.d.ts +56 -2
  17. package/dist/mcp/session-wait.d.ts +1 -1
  18. package/dist/mcp-oauth.d.ts +19 -0
  19. package/dist/model-catalog.d.ts +5 -31
  20. package/dist/routes/codex.d.ts +27 -2
  21. package/dist/routes/github.d.ts +2 -0
  22. package/dist/routes/organization-model-providers.d.ts +3 -0
  23. package/dist/routes/workspace-artifacts.d.ts +2 -1
  24. package/dist/work-discovery-observability.d.ts +1 -1
  25. package/dist/workspace-artifact-content.d.ts +28 -0
  26. package/dist/workspace-artifact-provenance.d.ts +7 -0
  27. package/dist/workspace-deletion.d.ts +6 -0
  28. package/dist/workspace-tool-gateway-observability.d.ts +17 -0
  29. package/dist/workspace-tool-gateway.d.ts +118 -0
  30. package/package.json +19 -18
  31. package/src/access-grant-rls.ts +40 -0
  32. package/src/app.ts +296 -53
  33. package/src/auth/organization-user-setup.ts +95 -5
  34. package/src/codemode.ts +33 -4
  35. package/src/github-access.ts +117 -1
  36. package/src/http/auth.ts +11 -0
  37. package/src/http/request-source.ts +37 -0
  38. package/src/http/sse.ts +15 -7
  39. package/src/index.ts +18 -2
  40. package/src/integrations/oauth-client.ts +168 -203
  41. package/src/integrations/personal-github-repositories.ts +238 -9
  42. package/src/integrations/slack-app-home.ts +2 -2
  43. package/src/integrations/slack-interactions.ts +77 -37
  44. package/src/mcp/company-brain-governed-writes.ts +2 -2
  45. package/src/mcp/company-profile-agent-admin.ts +1 -1
  46. package/src/mcp/remember.ts +1 -1
  47. package/src/mcp/server.ts +504 -133
  48. package/src/mcp/session-view.ts +20 -1
  49. package/src/mcp/session-wait.ts +3 -0
  50. package/src/mcp-oauth.ts +539 -0
  51. package/src/model-catalog.ts +45 -337
  52. package/src/routes/automations.ts +178 -19
  53. package/src/routes/codex.ts +242 -73
  54. package/src/routes/connections.ts +271 -16
  55. package/src/routes/documents.ts +67 -19
  56. package/src/routes/files.ts +54 -6
  57. package/src/routes/github.ts +116 -15
  58. package/src/routes/organization-memberships.ts +59 -16
  59. package/src/routes/organization-model-providers.ts +231 -0
  60. package/src/routes/packs.ts +1 -0
  61. package/src/routes/personal-github.ts +39 -0
  62. package/src/routes/pr-review.ts +72 -4
  63. package/src/routes/scheduled-tasks.ts +40 -13
  64. package/src/routes/sessions.ts +153 -65
  65. package/src/routes/supergrok.ts +3 -2
  66. package/src/routes/workspace-artifacts.ts +176 -67
  67. package/src/routes/workspaces.ts +405 -62
  68. package/src/sandbox/channel-a.ts +17 -4
  69. package/src/work-discovery-observability.ts +3 -3
  70. package/src/workspace-artifact-content.ts +163 -0
  71. package/src/workspace-artifact-provenance.ts +104 -0
  72. package/src/workspace-deletion.ts +64 -0
  73. package/src/workspace-tool-gateway-observability.ts +100 -0
  74. package/src/workspace-tool-gateway.ts +691 -0
  75. package/dist/chunk-XTLI3CBH.js.map +0 -1
@@ -17,11 +17,14 @@ import type {
17
17
  SessionEventPayloadMode,
18
18
  SessionEventReadDirection,
19
19
  SessionEventReadMode,
20
+ SessionMcpMonitoringSource,
21
+ SessionQueueSnapshot,
20
22
  } from "@opengeni/contracts";
21
23
  import {
22
24
  boundSessionEventPayload,
23
25
  measureSessionEventJson,
24
26
  sessionEventJsonBytes,
27
+ compactSessionMcpDetail,
25
28
  } from "@opengeni/contracts";
26
29
 
27
30
  export const SESSION_EVENT_MCP_MAX_BYTES = 64 * 1024;
@@ -528,7 +531,23 @@ function projectEffectiveToolPolicy(value: Session["effectiveToolPolicy"]): {
528
531
  };
529
532
  }
530
533
 
531
- /** Purpose-built, flat, model-facing detail projection for `session_get`. */
534
+ /** Compact management state; configuration is deliberately excluded, not truncated. */
535
+ export function boundSessionCompactDetailMcp(
536
+ session: Session,
537
+ monitoring: SessionMcpMonitoringSource,
538
+ queue: SessionQueueSnapshot | null,
539
+ maxBytes = SESSION_DETAIL_MCP_MAX_BYTES,
540
+ ) {
541
+ const result = compactSessionMcpDetail(session, monitoring, queue);
542
+ // Never drop a goal outcome or blocker to fit. The ordinary closed shape fits;
543
+ // an unsupported envelope must fail explicitly rather than erase management truth.
544
+ if (prettyJsonBytes(result) > maxBytes) {
545
+ throw new RangeError(`session_get compact projection exceeds its ${maxBytes}-byte envelope`);
546
+ }
547
+ return result;
548
+ }
549
+
550
+ /** Legacy bounded configuration projection for explicit `session_get detail=full`. */
532
551
  export function boundSessionDetailMcp(
533
552
  session: Session,
534
553
  effectiveControl: unknown = session.effectiveControl,
@@ -66,7 +66,10 @@ export const SESSION_WAIT_EVENT_TYPES = [
66
66
  "session.humanInput.requested",
67
67
  "session.control.paused",
68
68
  "session.control.resumed",
69
+ "session.wait.started",
70
+ "session.wait.finished",
69
71
  "tool.auth_needed",
72
+ "session.command.finished",
70
73
  "credential.auth_needed",
71
74
  "rig.setup.failed",
72
75
  "goal.set",
@@ -0,0 +1,539 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import {
3
+ MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS,
4
+ MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS,
5
+ MCP_OAUTH_CONSENT_TTL_SECONDS,
6
+ MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS,
7
+ MCP_OAUTH_SCOPE,
8
+ McpOAuthAuthorizationServerMetadata,
9
+ McpOAuthClientRegistrationRequest,
10
+ McpOAuthClientRegistrationResponse,
11
+ McpOAuthProtectedResourceMetadata,
12
+ McpOAuthTokenResponse,
13
+ type AccessGrant,
14
+ type ToolGatewayCatalog,
15
+ type ToolGatewayIdentity,
16
+ } from "@opengeni/contracts";
17
+ import {
18
+ consumeMcpOAuthAuthorizationRequest,
19
+ createMcpOAuthAuthorizationRequest,
20
+ deleteMcpOAuthAuthorizationRequest,
21
+ exchangeMcpOAuthAuthorizationCode,
22
+ getMcpOAuthAuthorizationRequest,
23
+ getMcpOAuthClient,
24
+ McpOAuthClientRegistrationRateLimitError,
25
+ registerMcpOAuthClient,
26
+ resolveLiveMcpOAuthGrant,
27
+ resolveMcpOAuthAccessToken,
28
+ rotateMcpOAuthRefreshToken,
29
+ } from "@opengeni/db";
30
+ import { requireAccessGrantAuthorization, type ApiRouteDeps } from "@opengeni/core";
31
+ import { Hono, type Context } from "hono";
32
+ import { HTTPException } from "hono/http-exception";
33
+ import { trustedRequestSourceAddress } from "./http/request-source";
34
+ import {
35
+ prepareWorkspaceToolGateway,
36
+ requireWorkspaceToolGatewayAuthorization,
37
+ } from "./workspace-tool-gateway";
38
+
39
+ const CLIENT_PREFIX = "ogmcp_client_";
40
+ const REQUEST_PREFIX = "ogmcp_req_";
41
+ const CODE_PREFIX = "ogmcp_code_";
42
+ const ACCESS_PREFIX = "ogmcp_at_";
43
+ const REFRESH_PREFIX = "ogmcp_rt_";
44
+ const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/u;
45
+ const PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/u;
46
+ const WORKSPACE_MCP_PATH = /^\/v1\/workspaces\/([0-9a-f-]{36})\/mcp(?:\/(docs|files))?$/u;
47
+
48
+ export type McpOAuthResource = {
49
+ resource: string;
50
+ workspaceId: string;
51
+ kind: "all" | "docs" | "files";
52
+ };
53
+
54
+ export type McpOAuthRouteAccess = {
55
+ grant: AccessGrant;
56
+ allowedToolIdentities: ToolGatewayIdentity[];
57
+ };
58
+
59
+ export function registerMcpOAuthRoutes(app: Hono, deps: ApiRouteDeps): void {
60
+ app.get("/.well-known/oauth-authorization-server", (c) => {
61
+ requireMcpOAuthEnabled(deps);
62
+ const issuer = mcpOAuthIssuer(deps);
63
+ c.header("cache-control", "public, max-age=300");
64
+ return c.json(
65
+ McpOAuthAuthorizationServerMetadata.parse({
66
+ issuer,
67
+ authorization_endpoint: `${issuer}/oauth/authorize`,
68
+ token_endpoint: `${issuer}/oauth/token`,
69
+ registration_endpoint: `${issuer}/oauth/register`,
70
+ response_types_supported: ["code"],
71
+ grant_types_supported: ["authorization_code", "refresh_token"],
72
+ code_challenge_methods_supported: ["S256"],
73
+ token_endpoint_auth_methods_supported: ["none"],
74
+ scopes_supported: [MCP_OAUTH_SCOPE],
75
+ authorization_response_iss_parameter_supported: true,
76
+ }),
77
+ );
78
+ });
79
+
80
+ app.get("/.well-known/oauth-protected-resource/*", (c) => {
81
+ requireMcpOAuthEnabled(deps);
82
+ const pathname = new URL(c.req.url).pathname.replace(
83
+ "/.well-known/oauth-protected-resource",
84
+ "",
85
+ );
86
+ const resource = parseMcpOAuthResource(deps, `${mcpOAuthIssuer(deps)}${pathname}`);
87
+ c.header("cache-control", "public, max-age=300");
88
+ return c.json(
89
+ McpOAuthProtectedResourceMetadata.parse({
90
+ resource: resource.resource,
91
+ authorization_servers: [mcpOAuthIssuer(deps)],
92
+ scopes_supported: [MCP_OAUTH_SCOPE],
93
+ bearer_methods_supported: ["header"],
94
+ }),
95
+ );
96
+ });
97
+
98
+ app.post("/oauth/register", async (c) => {
99
+ requireMcpOAuthEnabled(deps);
100
+ const parsed = McpOAuthClientRegistrationRequest.safeParse(
101
+ await c.req.json().catch(() => null),
102
+ );
103
+ if (!parsed.success) return oauthError(c, "invalid_client_metadata", 400);
104
+ let redirectUris: string[];
105
+ try {
106
+ redirectUris = [...new Set(parsed.data.redirect_uris.map(validateRedirectUri))];
107
+ } catch {
108
+ return oauthError(c, "invalid_redirect_uri", 400);
109
+ }
110
+ let client;
111
+ try {
112
+ client = await registerMcpOAuthClient(deps.db, {
113
+ clientId: opaque(CLIENT_PREFIX),
114
+ redirectUris,
115
+ clientName: parsed.data.client_name ?? null,
116
+ grantTypes: parsed.data.grant_types,
117
+ responseTypes: ["code"],
118
+ registrationScopeHash: tokenHash(
119
+ mcpOAuthRegistrationClientKey(c, deps.settings.mcpOauthTrustedProxyHops),
120
+ ),
121
+ });
122
+ } catch (error) {
123
+ if (error instanceof McpOAuthClientRegistrationRateLimitError) {
124
+ c.header("retry-after", "600");
125
+ return oauthError(c, "temporarily_unavailable", 429);
126
+ }
127
+ throw error;
128
+ }
129
+ c.header("cache-control", "no-store");
130
+ return c.json(
131
+ McpOAuthClientRegistrationResponse.parse({
132
+ client_id: client.clientId,
133
+ client_id_issued_at: Math.floor(client.createdAt.getTime() / 1_000),
134
+ redirect_uris: client.redirectUris,
135
+ ...(client.clientName ? { client_name: client.clientName } : {}),
136
+ ...(parsed.data.application_type ? { application_type: parsed.data.application_type } : {}),
137
+ ...(parsed.data.scope ? { scope: parsed.data.scope } : {}),
138
+ token_endpoint_auth_method: "none",
139
+ grant_types: client.grantTypes,
140
+ response_types: client.responseTypes,
141
+ }),
142
+ 201,
143
+ );
144
+ });
145
+
146
+ app.get("/oauth/authorize", async (c) => {
147
+ requireMcpOAuthEnabled(deps);
148
+ const query = new URL(c.req.url).searchParams;
149
+ const client = await requireAuthorizationClient(deps, query);
150
+ const resource = requireAuthorizationResource(deps, query);
151
+ const authorization = await requireAccessGrantAuthorization(
152
+ c,
153
+ deps,
154
+ resource.workspaceId,
155
+ "workspace:read",
156
+ );
157
+ const grant = requireWorkspaceToolGatewayAuthorization(authorization);
158
+ const prepared = await prepareWorkspaceToolGateway(deps, authorization);
159
+ const requestToken = opaque(REQUEST_PREFIX);
160
+ try {
161
+ await createMcpOAuthAuthorizationRequest(deps.db, {
162
+ requestHash: tokenHash(requestToken),
163
+ clientId: client.clientId,
164
+ accountId: grant.accountId,
165
+ workspaceId: grant.workspaceId,
166
+ subjectId: grant.subjectId,
167
+ resource: resource.resource,
168
+ redirectUri: requireRedirectUri(client.redirectUris, query.get("redirect_uri")),
169
+ codeChallenge: requireCodeChallenge(query),
170
+ state: boundedState(query.get("state")),
171
+ permissions: grant.permissions,
172
+ toolIdentities: mcpOAuthConsentToolIdentities(prepared.toolGatewayCatalog),
173
+ expiresAt: expiresIn(MCP_OAUTH_CONSENT_TTL_SECONDS),
174
+ });
175
+ } finally {
176
+ await prepared.close();
177
+ }
178
+ c.header(
179
+ "content-security-policy",
180
+ "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'",
181
+ );
182
+ c.header("cache-control", "no-store");
183
+ return c.html(consentHtml(client.clientName ?? client.clientId, resource, requestToken));
184
+ });
185
+
186
+ app.post("/oauth/authorize", async (c) => {
187
+ requireMcpOAuthEnabled(deps);
188
+ const form = new URLSearchParams(await c.req.text());
189
+ const requestToken = form.get("request") ?? "";
190
+ if (!requestToken.startsWith(REQUEST_PREFIX)) return oauthError(c, "invalid_request", 400);
191
+ const request = await getMcpOAuthAuthorizationRequest(deps.db, tokenHash(requestToken));
192
+ if (!request) return oauthError(c, "invalid_request", 400);
193
+ const authorization = await requireAccessGrantAuthorization(
194
+ c,
195
+ deps,
196
+ request.workspaceId,
197
+ "workspace:read",
198
+ );
199
+ const grant = requireWorkspaceToolGatewayAuthorization(authorization);
200
+ if (grant.subjectId !== request.subjectId || grant.accountId !== request.accountId) {
201
+ throw new HTTPException(403, {
202
+ message: "OAuth consent authority changed",
203
+ });
204
+ }
205
+ if (form.get("decision") !== "approve") {
206
+ await deleteMcpOAuthAuthorizationRequest(deps.db, request.requestHash);
207
+ return c.redirect(
208
+ authorizationRedirect(request.redirectUri, {
209
+ error: "access_denied",
210
+ iss: mcpOAuthIssuer(deps),
211
+ ...(request.state ? { state: request.state } : {}),
212
+ }),
213
+ );
214
+ }
215
+ const code = opaque(CODE_PREFIX);
216
+ const consumed = await consumeMcpOAuthAuthorizationRequest(deps.db, {
217
+ requestHash: request.requestHash,
218
+ subjectId: grant.subjectId,
219
+ codeHash: tokenHash(code),
220
+ codeExpiresAt: expiresIn(MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS),
221
+ });
222
+ if (!consumed) return oauthError(c, "invalid_request", 400);
223
+ return c.redirect(
224
+ authorizationRedirect(consumed.redirectUri, {
225
+ code,
226
+ iss: mcpOAuthIssuer(deps),
227
+ ...(consumed.state ? { state: consumed.state } : {}),
228
+ }),
229
+ );
230
+ });
231
+
232
+ app.post("/oauth/token", async (c) => {
233
+ requireMcpOAuthEnabled(deps);
234
+ const form = new URLSearchParams(await c.req.text());
235
+ const grantType = form.get("grant_type");
236
+ const clientId = form.get("client_id") ?? "";
237
+ const resource = form.get("resource");
238
+ const client = await getMcpOAuthClient(deps.db, clientId);
239
+ if (!client) return oauthError(c, "invalid_client", 401);
240
+ if (grantType !== "authorization_code" && grantType !== "refresh_token") {
241
+ return oauthError(c, "unsupported_grant_type", 400);
242
+ }
243
+ if (!client.grantTypes.includes(grantType)) {
244
+ return oauthError(c, "unauthorized_client", 400);
245
+ }
246
+ const requestedScope = form.get("scope");
247
+ if (requestedScope !== null && requestedScope !== MCP_OAUTH_SCOPE) {
248
+ return oauthError(c, "invalid_scope", 400);
249
+ }
250
+ if (!resource) return oauthError(c, "invalid_target", 400);
251
+ let parsedResource: McpOAuthResource;
252
+ try {
253
+ parsedResource = parseMcpOAuthResource(deps, resource);
254
+ } catch {
255
+ return oauthError(c, "invalid_target", 400);
256
+ }
257
+ const accessToken = opaque(ACCESS_PREFIX);
258
+ const refreshToken = client.grantTypes.includes("refresh_token")
259
+ ? opaque(REFRESH_PREFIX)
260
+ : null;
261
+ const tokenInput = {
262
+ clientId,
263
+ resource: parsedResource.resource,
264
+ accessTokenHash: tokenHash(accessToken),
265
+ accessExpiresAt: expiresIn(MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS),
266
+ refreshExpiresAt: expiresIn(MCP_OAUTH_REFRESH_TOKEN_TTL_SECONDS),
267
+ };
268
+ const access =
269
+ grantType === "authorization_code"
270
+ ? await exchangeAuthorizationCode(deps, client.redirectUris, form, {
271
+ ...tokenInput,
272
+ refreshTokenHash: refreshToken ? tokenHash(refreshToken) : null,
273
+ })
274
+ : refreshToken
275
+ ? await rotateRefreshToken(deps, form, {
276
+ ...tokenInput,
277
+ nextRefreshTokenHash: tokenHash(refreshToken),
278
+ })
279
+ : null;
280
+ if (!access) return oauthError(c, "invalid_grant", 400);
281
+ c.header("cache-control", "no-store");
282
+ c.header("pragma", "no-cache");
283
+ return c.json(
284
+ McpOAuthTokenResponse.parse({
285
+ access_token: accessToken,
286
+ token_type: "Bearer",
287
+ expires_in: MCP_OAUTH_ACCESS_TOKEN_TTL_SECONDS,
288
+ ...(refreshToken ? { refresh_token: refreshToken } : {}),
289
+ scope: MCP_OAUTH_SCOPE,
290
+ }),
291
+ );
292
+ });
293
+ }
294
+
295
+ export async function resolveMcpOAuthRouteAccess(
296
+ deps: ApiRouteDeps,
297
+ request: Request,
298
+ workspaceId: string,
299
+ ): Promise<McpOAuthRouteAccess | null> {
300
+ const token = mcpOAuthBearerToken(request);
301
+ if (!token) return null;
302
+ requireMcpOAuthEnabled(deps);
303
+ const access = await resolveMcpOAuthAccessToken(deps.db, tokenHash(token));
304
+ const requestResource = parseMcpOAuthResource(
305
+ deps,
306
+ `${mcpOAuthIssuer(deps)}${new URL(request.url).pathname}`,
307
+ );
308
+ if (
309
+ !access ||
310
+ access.workspaceId !== workspaceId ||
311
+ access.resource !== requestResource.resource
312
+ ) {
313
+ throw new HTTPException(401, { message: "invalid MCP OAuth access token" });
314
+ }
315
+ const grant = await resolveLiveMcpOAuthGrant(deps.db, access);
316
+ if (!grant)
317
+ throw new HTTPException(401, {
318
+ message: "MCP OAuth authority is no longer active",
319
+ });
320
+ const allowedToolIdentities = access.toolIdentities.filter((identity) =>
321
+ requestResource.kind === "all" ? true : identity.serverId === requestResource.kind,
322
+ );
323
+ return { grant, allowedToolIdentities };
324
+ }
325
+
326
+ export function mcpOAuthBearerToken(request: Request): string | null {
327
+ const authorization = request.headers.get("authorization");
328
+ const token = authorization?.match(/^Bearer +(\S+)$/iu)?.[1];
329
+ if (!token) return null;
330
+ return token.startsWith(ACCESS_PREFIX) ? token : null;
331
+ }
332
+
333
+ export function isMcpOAuthResourcePath(pathname: string): boolean {
334
+ return WORKSPACE_MCP_PATH.test(pathname);
335
+ }
336
+
337
+ export function mcpOAuthConsentToolIdentities(catalog: ToolGatewayCatalog): ToolGatewayIdentity[] {
338
+ return catalog.entries.map((entry) => entry.identity);
339
+ }
340
+
341
+ export function isMcpOAuthPublicProtocolPath(pathname: string): boolean {
342
+ return (
343
+ pathname === "/.well-known/oauth-authorization-server" ||
344
+ pathname.startsWith("/.well-known/oauth-protected-resource/") ||
345
+ pathname === "/oauth/register" ||
346
+ pathname === "/oauth/authorize" ||
347
+ pathname === "/oauth/token"
348
+ );
349
+ }
350
+
351
+ export function mcpOAuthAuthenticateHeader(deps: ApiRouteDeps, pathname: string): string {
352
+ return `Bearer resource_metadata="${mcpOAuthIssuer(deps)}/.well-known/oauth-protected-resource${pathname}", scope="${MCP_OAUTH_SCOPE}"`;
353
+ }
354
+
355
+ function requireMcpOAuthEnabled(deps: ApiRouteDeps): void {
356
+ if (!deps.settings.mcpOauthEnabled) throw new HTTPException(404, { message: "not found" });
357
+ }
358
+
359
+ function mcpOAuthIssuer(deps: ApiRouteDeps): string {
360
+ if (!deps.settings.publicBaseUrl) throw new Error("MCP OAuth public base URL is not configured");
361
+ return new URL(deps.settings.publicBaseUrl).origin;
362
+ }
363
+
364
+ function parseMcpOAuthResource(deps: ApiRouteDeps, value: string): McpOAuthResource {
365
+ let url: URL;
366
+ try {
367
+ url = new URL(value);
368
+ } catch {
369
+ throw new HTTPException(400, { message: "invalid OAuth resource" });
370
+ }
371
+ const issuer = mcpOAuthIssuer(deps);
372
+ const match = url.pathname.match(WORKSPACE_MCP_PATH);
373
+ if (url.origin !== issuer || url.username || url.password || url.search || url.hash || !match) {
374
+ throw new HTTPException(400, { message: "invalid OAuth resource" });
375
+ }
376
+ return {
377
+ resource: `${issuer}${url.pathname}`,
378
+ workspaceId: match[1]!,
379
+ kind: match[2] === "docs" ? "docs" : match[2] === "files" ? "files" : "all",
380
+ };
381
+ }
382
+
383
+ async function requireAuthorizationClient(deps: ApiRouteDeps, query: URLSearchParams) {
384
+ if (query.get("response_type") !== "code" || query.get("scope") !== MCP_OAUTH_SCOPE) {
385
+ throw new HTTPException(400, {
386
+ message: "unsupported OAuth authorization request",
387
+ });
388
+ }
389
+ const clientId = query.get("client_id") ?? "";
390
+ const client = await getMcpOAuthClient(deps.db, clientId);
391
+ if (!client || !client.grantTypes.includes("authorization_code")) {
392
+ throw new HTTPException(400, { message: "invalid OAuth client" });
393
+ }
394
+ requireRedirectUri(client.redirectUris, query.get("redirect_uri"));
395
+ requireCodeChallenge(query);
396
+ return client;
397
+ }
398
+
399
+ function requireAuthorizationResource(
400
+ deps: ApiRouteDeps,
401
+ query: URLSearchParams,
402
+ ): McpOAuthResource {
403
+ const resources = query.getAll("resource");
404
+ if (resources.length !== 1)
405
+ throw new HTTPException(400, { message: "one OAuth resource is required" });
406
+ return parseMcpOAuthResource(deps, resources[0]!);
407
+ }
408
+
409
+ function requireRedirectUri(registered: string[], value: string | null): string {
410
+ if (!value || !registered.includes(value)) {
411
+ throw new HTTPException(400, {
412
+ message: "OAuth redirect_uri is not registered",
413
+ });
414
+ }
415
+ return value;
416
+ }
417
+
418
+ function requireCodeChallenge(query: URLSearchParams): string {
419
+ const challenge = query.get("code_challenge") ?? "";
420
+ if (query.get("code_challenge_method") !== "S256" || !PKCE_CHALLENGE.test(challenge)) {
421
+ throw new HTTPException(400, { message: "PKCE S256 is required" });
422
+ }
423
+ return challenge;
424
+ }
425
+
426
+ async function exchangeAuthorizationCode(
427
+ deps: ApiRouteDeps,
428
+ redirectUris: string[],
429
+ form: URLSearchParams,
430
+ input: {
431
+ clientId: string;
432
+ resource: string;
433
+ accessTokenHash: string;
434
+ refreshTokenHash: string | null;
435
+ accessExpiresAt: Date;
436
+ refreshExpiresAt: Date;
437
+ },
438
+ ) {
439
+ const code = form.get("code") ?? "";
440
+ const verifier = form.get("code_verifier") ?? "";
441
+ if (!code.startsWith(CODE_PREFIX) || !PKCE_VERIFIER.test(verifier)) return null;
442
+ const redirectUri = form.get("redirect_uri");
443
+ if (!redirectUri || !redirectUris.includes(redirectUri)) return null;
444
+ return await exchangeMcpOAuthAuthorizationCode(deps.db, {
445
+ codeHash: tokenHash(code),
446
+ clientId: input.clientId,
447
+ redirectUri,
448
+ resource: input.resource,
449
+ codeChallenge: pkceChallenge(verifier),
450
+ accessTokenHash: input.accessTokenHash,
451
+ refreshTokenHash: input.refreshTokenHash,
452
+ accessExpiresAt: input.accessExpiresAt,
453
+ refreshExpiresAt: input.refreshExpiresAt,
454
+ });
455
+ }
456
+
457
+ async function rotateRefreshToken(
458
+ deps: ApiRouteDeps,
459
+ form: URLSearchParams,
460
+ input: {
461
+ clientId: string;
462
+ resource: string;
463
+ accessTokenHash: string;
464
+ nextRefreshTokenHash: string;
465
+ accessExpiresAt: Date;
466
+ refreshExpiresAt: Date;
467
+ },
468
+ ) {
469
+ const refreshToken = form.get("refresh_token") ?? "";
470
+ if (!refreshToken.startsWith(REFRESH_PREFIX)) return null;
471
+ return await rotateMcpOAuthRefreshToken(deps.db, {
472
+ refreshTokenHash: tokenHash(refreshToken),
473
+ ...input,
474
+ });
475
+ }
476
+
477
+ function validateRedirectUri(value: string): string {
478
+ const url = new URL(value);
479
+ const loopback =
480
+ url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
481
+ if (url.username || url.password || url.hash || (url.protocol !== "https:" && !loopback)) {
482
+ throw new HTTPException(400, {
483
+ message: "invalid public OAuth redirect URI",
484
+ });
485
+ }
486
+ return url.toString();
487
+ }
488
+
489
+ function boundedState(value: string | null): string | null {
490
+ if (value === null) return null;
491
+ if (!value || new TextEncoder().encode(value).byteLength > 1_024) {
492
+ throw new HTTPException(400, { message: "invalid OAuth state" });
493
+ }
494
+ return value;
495
+ }
496
+
497
+ function pkceChallenge(verifier: string): string {
498
+ return createHash("sha256").update(verifier, "ascii").digest("base64url");
499
+ }
500
+
501
+ function opaque(prefix: string): string {
502
+ return `${prefix}${randomBytes(32).toString("base64url")}`;
503
+ }
504
+
505
+ function tokenHash(value: string): string {
506
+ return createHash("sha256").update(value, "utf8").digest("hex");
507
+ }
508
+
509
+ function mcpOAuthRegistrationClientKey(c: Context, trustedProxyHops: number): string {
510
+ return `mcp-oauth-registration:${trustedRequestSourceAddress(c, trustedProxyHops)}`;
511
+ }
512
+
513
+ function expiresIn(seconds: number): Date {
514
+ return new Date(Date.now() + seconds * 1_000);
515
+ }
516
+
517
+ function authorizationRedirect(redirectUri: string, params: Record<string, string>): string {
518
+ const url = new URL(redirectUri);
519
+ for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
520
+ return url.toString();
521
+ }
522
+
523
+ function consentHtml(clientName: string, resource: McpOAuthResource, requestToken: string): string {
524
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Authorize OpenGeni tools</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:42rem;margin:4rem auto;padding:0 1.5rem;color:#18181b}main{border:1px solid #e4e4e7;border-radius:16px;padding:2rem}code{word-break:break-all;background:#f4f4f5;padding:.15rem .35rem;border-radius:4px}.actions{display:flex;gap:.75rem;margin-top:1.5rem}button{border:0;border-radius:8px;padding:.7rem 1rem;font:inherit;cursor:pointer}.approve{background:#18181b;color:white}.deny{background:#e4e4e7}</style></head><body><main><h1>Authorize workspace tools</h1><p><strong>${escapeHtml(clientName)}</strong> is requesting MCP access to this workspace.</p><p>Resource: <code>${escapeHtml(resource.resource)}</code></p><p>The grant is limited to the tools and permissions available now. OpenGeni rechecks live workspace authority on every request.</p><form method="post" action="/oauth/authorize"><input type="hidden" name="request" value="${escapeHtml(requestToken)}"><div class="actions"><button class="approve" name="decision" value="approve">Authorize</button><button class="deny" name="decision" value="deny">Deny</button></div></form></main></body></html>`;
525
+ }
526
+
527
+ function escapeHtml(value: string): string {
528
+ return value.replace(
529
+ /[&<>"']/gu,
530
+ (character) =>
531
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]!,
532
+ );
533
+ }
534
+
535
+ function oauthError(c: Context, error: string, status: 400 | 401 | 429) {
536
+ c.header("cache-control", "no-store");
537
+ c.header("pragma", "no-cache");
538
+ return c.json({ error }, status);
539
+ }