@opengeni/api-router 3.0.1 → 4.0.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 (96) hide show
  1. package/dist/admission-trace.d.ts +8 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth-attempt-context.d.ts +2 -0
  4. package/dist/auth/managed-auth-session-adapter.d.ts +2 -0
  5. package/dist/auth/managed-auth.d.ts +1 -0
  6. package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
  7. package/dist/{chunk-AJ62T5QN.js → chunk-E7TZFJ65.js} +22300 -21113
  8. package/dist/{chunk-AJ62T5QN.js.map → chunk-E7TZFJ65.js.map} +1 -1
  9. package/dist/connection-ownership.d.ts +1 -2
  10. package/dist/fatal-process-boundary.d.ts +1 -1
  11. package/dist/index.js +24 -6
  12. package/dist/index.js.map +1 -1
  13. package/dist/integrations/atlassian.d.ts +8 -3
  14. package/dist/integrations/fiken.d.ts +3 -1
  15. package/dist/integrations/github-lens-connect.d.ts +5 -65
  16. package/dist/integrations/google-drive.d.ts +2 -0
  17. package/dist/integrations/integration-commit-authority.d.ts +13 -0
  18. package/dist/integrations/oauth-client.d.ts +119 -11
  19. package/dist/integrations/oauth-profiles.d.ts +8 -13
  20. package/dist/integrations/slack-interactions.d.ts +1 -1
  21. package/dist/mcp/capability-account-readiness.d.ts +7 -0
  22. package/dist/mcp/first-party-tool-permissions.d.ts +4 -0
  23. package/dist/mcp-oauth.d.ts +22 -1
  24. package/dist/routes/api-integrations.d.ts +1 -0
  25. package/dist/routes/codex.d.ts +28 -0
  26. package/dist/routes/managed-sign-in-methods.d.ts +6 -0
  27. package/dist/routes/organization-integration-policy.d.ts +3 -0
  28. package/dist/routes/sessions.d.ts +24 -2
  29. package/dist/scheduled-task-deletion.d.ts +3 -4
  30. package/dist/workspace-tool-gateway.d.ts +3 -1
  31. package/package.json +15 -15
  32. package/src/admission-trace.ts +14 -0
  33. package/src/app.ts +141 -94
  34. package/src/auth/managed-auth-attempt-context.ts +9 -0
  35. package/src/auth/managed-auth-session-adapter.ts +11 -0
  36. package/src/auth/managed-auth.ts +39 -2
  37. package/src/auth/managed-sign-in-notifications.ts +81 -0
  38. package/src/connection-ownership.ts +2 -9
  39. package/src/editable-artifact-production.ts +6 -0
  40. package/src/fatal-process-boundary.ts +17 -2
  41. package/src/index.ts +3 -4
  42. package/src/integrations/atlassian.ts +184 -76
  43. package/src/integrations/fiken.ts +57 -35
  44. package/src/integrations/github-app-connect.ts +51 -15
  45. package/src/integrations/github-lens-connect.ts +7 -28
  46. package/src/integrations/google-drive.ts +296 -125
  47. package/src/integrations/integration-commit-authority.ts +69 -0
  48. package/src/integrations/oauth-client.ts +231 -132
  49. package/src/integrations/oauth-profiles.ts +10 -45
  50. package/src/integrations/personal-github.ts +55 -31
  51. package/src/integrations/provider-oauth.ts +57 -82
  52. package/src/integrations/social-oauth.ts +83 -56
  53. package/src/mcp/capability-account-readiness.ts +17 -0
  54. package/src/mcp/editable-artifacts.ts +7 -2
  55. package/src/mcp/first-party-tool-permissions.ts +1 -0
  56. package/src/mcp/knowledge-entries.ts +25 -8
  57. package/src/mcp/server.ts +135 -43
  58. package/src/mcp-oauth.ts +336 -34
  59. package/src/routes/api-integrations.ts +116 -4
  60. package/src/routes/automations.ts +5 -13
  61. package/src/routes/billing.ts +52 -0
  62. package/src/routes/canonical-human-identities.ts +61 -0
  63. package/src/routes/capabilities.ts +51 -1
  64. package/src/routes/codex.ts +4 -2
  65. package/src/routes/connect.ts +90 -31
  66. package/src/routes/connections.ts +487 -307
  67. package/src/routes/files.ts +21 -12
  68. package/src/routes/github.ts +48 -19
  69. package/src/routes/insights.ts +17 -7
  70. package/src/routes/integration-facets.ts +66 -25
  71. package/src/routes/knowledge.ts +48 -1
  72. package/src/routes/managed-sign-in-methods.ts +433 -0
  73. package/src/routes/organization-integration-policy.ts +113 -0
  74. package/src/routes/organization-memberships.ts +22 -2
  75. package/src/routes/plugins.ts +42 -4
  76. package/src/routes/pr-review-github.ts +67 -57
  77. package/src/routes/pr-review.ts +74 -71
  78. package/src/routes/rigs.ts +32 -13
  79. package/src/routes/scheduled-tasks.ts +6 -6
  80. package/src/routes/sessions.ts +233 -11
  81. package/src/routes/skill-content.ts +19 -3
  82. package/src/routes/social.ts +24 -13
  83. package/src/sandbox/channel-a.ts +7 -1
  84. package/src/sandbox/rematerialize.ts +2 -1
  85. package/src/scheduled-task-deletion.ts +13 -3
  86. package/src/workspace-tool-gateway.ts +31 -31
  87. package/dist/connection-authority-owner.d.ts +0 -59
  88. package/dist/routes/connection-authorities.d.ts +0 -3
  89. package/dist/routes/host-mcp-bindings.d.ts +0 -5
  90. package/dist/routes/host-mcp-resolvers.d.ts +0 -3
  91. package/dist/routes/packs.d.ts +0 -3
  92. package/src/connection-authority-owner.ts +0 -61
  93. package/src/routes/connection-authorities.ts +0 -139
  94. package/src/routes/host-mcp-bindings.ts +0 -122
  95. package/src/routes/host-mcp-resolvers.ts +0 -41
  96. package/src/routes/packs.ts +0 -784
package/src/mcp-oauth.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  McpOAuthClientRegistrationResponse,
11
11
  McpOAuthProtectedResourceMetadata,
12
12
  McpOAuthTokenResponse,
13
+ type AccessContext,
13
14
  type AccessGrant,
14
15
  type ToolGatewayCatalog,
15
16
  type ToolGatewayIdentity,
@@ -19,15 +20,25 @@ import {
19
20
  createMcpOAuthAuthorizationRequest,
20
21
  deleteMcpOAuthAuthorizationRequest,
21
22
  exchangeMcpOAuthAuthorizationCode,
23
+ getManagedAccount,
22
24
  getMcpOAuthAuthorizationRequest,
23
25
  getMcpOAuthClient,
26
+ getWorkspace,
27
+ listSharedWorkspacesForAccount,
28
+ listWorkspacesForSubject,
24
29
  McpOAuthClientRegistrationRateLimitError,
30
+ rebindMcpOAuthAuthorizationRequest,
25
31
  registerMcpOAuthClient,
26
32
  resolveLiveMcpOAuthGrant,
27
33
  resolveMcpOAuthAccessToken,
28
34
  rotateMcpOAuthRefreshToken,
29
35
  } from "@opengeni/db";
30
- import { requireAccessGrantAuthorization, type ApiRouteDeps } from "@opengeni/core";
36
+ import {
37
+ hasPermission,
38
+ requireAccessContext,
39
+ requireAccessGrantAuthorization,
40
+ type ApiRouteDeps,
41
+ } from "@opengeni/core";
31
42
  import { Hono, type Context } from "hono";
32
43
  import { HTTPException } from "hono/http-exception";
33
44
  import { trustedRequestSourceAddress } from "./http/request-source";
@@ -148,10 +159,17 @@ export function registerMcpOAuthRoutes(app: Hono, deps: ApiRouteDeps): void {
148
159
  const query = new URL(c.req.url).searchParams;
149
160
  const client = await requireAuthorizationClient(deps, query);
150
161
  const resource = requireAuthorizationResource(deps, query);
162
+ const context = await requireAccessContext(c, deps);
163
+ const workspaces = await listConsentWorkspaces(deps, context);
164
+ if (workspaces.length === 0) {
165
+ throw new HTTPException(403, { message: "no workspace is available for MCP OAuth" });
166
+ }
167
+ const selectedWorkspace =
168
+ workspaces.find((workspace) => workspace.id === resource.workspaceId) ?? workspaces[0]!;
151
169
  const authorization = await requireAccessGrantAuthorization(
152
170
  c,
153
171
  deps,
154
- resource.workspaceId,
172
+ selectedWorkspace.id,
155
173
  "workspace:read",
156
174
  );
157
175
  const grant = requireWorkspaceToolGatewayAuthorization(authorization);
@@ -175,42 +193,101 @@ export function registerMcpOAuthRoutes(app: Hono, deps: ApiRouteDeps): void {
175
193
  } finally {
176
194
  await prepared.close();
177
195
  }
196
+ const accounts = await consentAccountsForWorkspaces(deps, workspaces);
178
197
  c.header(
179
198
  "content-security-policy",
180
- "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'",
199
+ "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'",
181
200
  );
182
201
  c.header("cache-control", "no-store");
183
- return c.html(consentHtml(client.clientName ?? client.clientId, resource, requestToken));
202
+ return c.html(
203
+ renderMcpOAuthConsentPage({
204
+ clientName: client.clientName ?? "MCP client",
205
+ requestToken,
206
+ accounts,
207
+ workspaces: workspaces.map((workspace) => ({
208
+ id: workspace.id,
209
+ accountId: workspace.accountId,
210
+ name: workspace.name,
211
+ kind: workspace.kind,
212
+ })),
213
+ selectedWorkspaceId: grant.workspaceId,
214
+ }),
215
+ );
184
216
  });
185
217
 
186
218
  app.post("/oauth/authorize", async (c) => {
187
219
  requireMcpOAuthEnabled(deps);
188
220
  const form = new URLSearchParams(await c.req.text());
189
221
  const requestToken = form.get("request") ?? "";
190
- if (!requestToken.startsWith(REQUEST_PREFIX)) return oauthError(c, "invalid_request", 400);
222
+ if (!requestToken.startsWith(REQUEST_PREFIX)) {
223
+ return oauthAuthorizeBrowserError(
224
+ c,
225
+ "This authorization request is missing or already used.",
226
+ );
227
+ }
191
228
  const request = await getMcpOAuthAuthorizationRequest(deps.db, tokenHash(requestToken));
192
- if (!request) return oauthError(c, "invalid_request", 400);
229
+ if (!request) {
230
+ return oauthAuthorizeBrowserError(
231
+ c,
232
+ "This authorization request expired or was already approved.",
233
+ );
234
+ }
235
+ if (form.get("decision") !== "approve") {
236
+ const authorization = await requireAccessGrantAuthorization(
237
+ c,
238
+ deps,
239
+ request.workspaceId,
240
+ "workspace:read",
241
+ );
242
+ if (requireWorkspaceToolGatewayAuthorization(authorization).subjectId !== request.subjectId) {
243
+ throw new HTTPException(403, { message: "OAuth consent authority changed" });
244
+ }
245
+ await deleteMcpOAuthAuthorizationRequest(deps.db, request.requestHash);
246
+ return completeAuthorizationRedirect(
247
+ c,
248
+ authorizationRedirect(request.redirectUri, {
249
+ error: "access_denied",
250
+ iss: mcpOAuthIssuer(deps),
251
+ ...(request.state ? { state: request.state } : {}),
252
+ }),
253
+ );
254
+ }
255
+ const selectedWorkspaceId = form.get("workspace_id") || request.workspaceId;
256
+ if (!isWorkspaceId(selectedWorkspaceId)) {
257
+ return oauthAuthorizeBrowserError(c, "Choose a workspace before authorizing this client.");
258
+ }
193
259
  const authorization = await requireAccessGrantAuthorization(
194
260
  c,
195
261
  deps,
196
- request.workspaceId,
262
+ selectedWorkspaceId,
197
263
  "workspace:read",
198
264
  );
199
265
  const grant = requireWorkspaceToolGatewayAuthorization(authorization);
200
- if (grant.subjectId !== request.subjectId || grant.accountId !== request.accountId) {
266
+ if (grant.subjectId !== request.subjectId) {
201
267
  throw new HTTPException(403, {
202
268
  message: "OAuth consent authority changed",
203
269
  });
204
270
  }
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
- );
271
+ if (grant.workspaceId !== request.workspaceId || grant.accountId !== request.accountId) {
272
+ const prepared = await prepareWorkspaceToolGateway(deps, authorization);
273
+ try {
274
+ const rebound = await rebindMcpOAuthAuthorizationRequest(deps.db, {
275
+ requestHash: request.requestHash,
276
+ subjectId: grant.subjectId,
277
+ accountId: grant.accountId,
278
+ workspaceId: grant.workspaceId,
279
+ permissions: grant.permissions,
280
+ toolIdentities: mcpOAuthConsentToolIdentities(prepared.toolGatewayCatalog),
281
+ });
282
+ if (!rebound) {
283
+ return oauthAuthorizeBrowserError(
284
+ c,
285
+ "This authorization request expired or was already approved.",
286
+ );
287
+ }
288
+ } finally {
289
+ await prepared.close();
290
+ }
214
291
  }
215
292
  const code = opaque(CODE_PREFIX);
216
293
  const consumed = await consumeMcpOAuthAuthorizationRequest(deps.db, {
@@ -219,8 +296,14 @@ export function registerMcpOAuthRoutes(app: Hono, deps: ApiRouteDeps): void {
219
296
  codeHash: tokenHash(code),
220
297
  codeExpiresAt: expiresIn(MCP_OAUTH_AUTHORIZATION_CODE_TTL_SECONDS),
221
298
  });
222
- if (!consumed) return oauthError(c, "invalid_request", 400);
223
- return c.redirect(
299
+ if (!consumed) {
300
+ return oauthAuthorizeBrowserError(
301
+ c,
302
+ "This authorization request expired or was already approved.",
303
+ );
304
+ }
305
+ return completeAuthorizationRedirect(
306
+ c,
224
307
  authorizationRedirect(consumed.redirectUri, {
225
308
  code,
226
309
  iss: mcpOAuthIssuer(deps),
@@ -305,9 +388,11 @@ export async function resolveMcpOAuthRouteAccess(
305
388
  deps,
306
389
  `${mcpOAuthIssuer(deps)}${new URL(request.url).pathname}`,
307
390
  );
391
+ // The client URL is the resource indicator. Consent may bind a different
392
+ // workspace than the path UUID; the token grant is the authorized workspace.
308
393
  if (
309
394
  !access ||
310
- access.workspaceId !== workspaceId ||
395
+ requestResource.workspaceId !== workspaceId ||
311
396
  access.resource !== requestResource.resource
312
397
  ) {
313
398
  throw new HTTPException(401, { message: "invalid MCP OAuth access token" });
@@ -441,17 +526,26 @@ async function exchangeAuthorizationCode(
441
526
  if (!code.startsWith(CODE_PREFIX) || !PKCE_VERIFIER.test(verifier)) return null;
442
527
  const redirectUri = form.get("redirect_uri");
443
528
  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
- });
529
+ for (const candidate of mcpOAuthRedirectUriCandidates(redirectUris, redirectUri)) {
530
+ const access = await exchangeMcpOAuthAuthorizationCode(deps.db, {
531
+ codeHash: tokenHash(code),
532
+ clientId: input.clientId,
533
+ redirectUri: candidate,
534
+ resource: input.resource,
535
+ codeChallenge: pkceChallenge(verifier),
536
+ accessTokenHash: input.accessTokenHash,
537
+ refreshTokenHash: input.refreshTokenHash,
538
+ accessExpiresAt: input.accessExpiresAt,
539
+ refreshExpiresAt: input.refreshExpiresAt,
540
+ });
541
+ if (access) return access;
542
+ }
543
+ return null;
544
+ }
545
+
546
+ export function mcpOAuthRedirectUriCandidates(registered: string[], requested: string): string[] {
547
+ if (!registered.includes(requested)) return [];
548
+ return [requested, ...registered.filter((uri) => uri !== requested)];
455
549
  }
456
550
 
457
551
  async function rotateRefreshToken(
@@ -474,11 +568,37 @@ async function rotateRefreshToken(
474
568
  });
475
569
  }
476
570
 
571
+ const BLOCKED_REDIRECT_PROTOCOLS = new Set([
572
+ "javascript:",
573
+ "data:",
574
+ "file:",
575
+ "about:",
576
+ "blob:",
577
+ "vbscript:",
578
+ ]);
579
+
580
+ function isNativeAppRedirect(url: URL): boolean {
581
+ return (
582
+ /^[a-z][a-z0-9+.-]*:$/u.test(url.protocol) &&
583
+ !BLOCKED_REDIRECT_PROTOCOLS.has(url.protocol) &&
584
+ url.protocol !== "http:" &&
585
+ url.protocol !== "https:" &&
586
+ Boolean(url.hostname) &&
587
+ url.pathname.startsWith("/") &&
588
+ url.pathname !== "/"
589
+ );
590
+ }
591
+
477
592
  function validateRedirectUri(value: string): string {
478
593
  const url = new URL(value);
479
594
  const loopback =
480
595
  url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
481
- if (url.username || url.password || url.hash || (url.protocol !== "https:" && !loopback)) {
596
+ if (
597
+ url.username ||
598
+ url.password ||
599
+ url.hash ||
600
+ (url.protocol !== "https:" && !loopback && !isNativeAppRedirect(url))
601
+ ) {
482
602
  throw new HTTPException(400, {
483
603
  message: "invalid public OAuth redirect URI",
484
604
  });
@@ -520,8 +640,190 @@ function authorizationRedirect(redirectUri: string, params: Record<string, strin
520
640
  return url.toString();
521
641
  }
522
642
 
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>`;
643
+ export function completeAuthorizationRedirect(c: Context, redirectTo: string) {
644
+ // Consent posts under CSP form-action 'self'. A 302 to the client's
645
+ // registered redirect_uri (loopback HTTP or a custom scheme) is blocked,
646
+ // so the waiter never receives the code. Return 200 HTML that navigates
647
+ // to the exact requested URI from script and Refresh.
648
+ c.header("cache-control", "no-store");
649
+ c.header(
650
+ "content-security-policy",
651
+ "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; frame-ancestors 'none'",
652
+ );
653
+ c.header("refresh", `0;url=${redirectTo}`);
654
+ return c.html(renderMcpOAuthContinuePage(redirectTo));
655
+ }
656
+
657
+ function oauthAuthorizeBrowserError(c: Context, message: string) {
658
+ c.header("cache-control", "no-store");
659
+ c.header("pragma", "no-cache");
660
+ c.header(
661
+ "content-security-policy",
662
+ "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'",
663
+ );
664
+ return c.html(renderMcpOAuthExpiredPage(message), 400);
665
+ }
666
+
667
+ export type McpOAuthConsentAccount = {
668
+ id: string;
669
+ name: string;
670
+ };
671
+
672
+ export type McpOAuthConsentWorkspace = {
673
+ id: string;
674
+ accountId: string;
675
+ name: string;
676
+ kind: "personal" | "shared";
677
+ };
678
+
679
+ export function renderMcpOAuthConsentPage(input: {
680
+ clientName: string;
681
+ requestToken: string;
682
+ accounts: McpOAuthConsentAccount[];
683
+ workspaces: McpOAuthConsentWorkspace[];
684
+ selectedWorkspaceId: string;
685
+ }): string {
686
+ const selectedWorkspace =
687
+ input.workspaces.find((workspace) => workspace.id === input.selectedWorkspaceId) ??
688
+ input.workspaces[0];
689
+ if (!selectedWorkspace) {
690
+ throw new Error("MCP OAuth consent requires at least one workspace");
691
+ }
692
+ const selectedAccountId = selectedWorkspace.accountId;
693
+ const accountOptions = input.accounts
694
+ .map(
695
+ (account) =>
696
+ `<option value="${escapeHtml(account.id)}"${account.id === selectedAccountId ? " selected" : ""}>${escapeHtml(account.name)}</option>`,
697
+ )
698
+ .join("");
699
+ const workspaceOptions = input.workspaces
700
+ .filter((workspace) => workspace.accountId === selectedAccountId)
701
+ .map(
702
+ (workspace) =>
703
+ `<option value="${escapeHtml(workspace.id)}"${workspace.id === selectedWorkspace.id ? " selected" : ""}>${escapeHtml(consentWorkspaceLabel(workspace))}</option>`,
704
+ )
705
+ .join("");
706
+ const workspacePayload = input.workspaces.map((workspace) => ({
707
+ id: workspace.id,
708
+ accountId: workspace.accountId,
709
+ label: consentWorkspaceLabel(workspace),
710
+ }));
711
+ const body = `<p class="lede"><strong>${escapeHtml(input.clientName)}</strong> wants MCP access to the organization and workspace you choose.</p>
712
+ <form method="post" action="/oauth/authorize" onsubmit="var submitter=event.submitter;if(submitter&&submitter.name){var input=document.createElement('input');input.type='hidden';input.name=submitter.name;input.value=submitter.value;this.appendChild(input)}this.querySelectorAll('button').forEach(function(button){button.disabled=true})">
713
+ <input type="hidden" name="request" value="${escapeHtml(input.requestToken)}">
714
+ <label class="field"><span>Organization</span><select id="organization" name="organization" autocomplete="off">${accountOptions}</select></label>
715
+ <label class="field"><span>Workspace</span><select id="workspace_id" name="workspace_id" autocomplete="off">${workspaceOptions}</select></label>
716
+ <p class="note">The grant is limited to tools available in the workspace you authorize. OpenGeni rechecks live authority on every request.</p>
717
+ <div class="actions"><button class="approve" name="decision" value="approve">Authorize</button><button class="deny" name="decision" value="deny">Deny</button></div>
718
+ </form>
719
+ <script type="application/json" id="mcp-oauth-workspaces">${jsonForScript(workspacePayload)}</script>
720
+ <script>
721
+ (function () {
722
+ var data = JSON.parse(document.getElementById("mcp-oauth-workspaces").textContent);
723
+ var account = document.getElementById("organization");
724
+ var workspace = document.getElementById("workspace_id");
725
+ function sync() {
726
+ var selected = workspace.value;
727
+ var items = data.filter(function (item) { return item.accountId === account.value; });
728
+ workspace.replaceChildren();
729
+ items.forEach(function (item) {
730
+ var option = document.createElement("option");
731
+ option.value = item.id;
732
+ option.textContent = item.label;
733
+ workspace.appendChild(option);
734
+ });
735
+ if (items.some(function (item) { return item.id === selected; })) workspace.value = selected;
736
+ }
737
+ account.addEventListener("change", sync);
738
+ })();
739
+ </script>`;
740
+ return oauthDocument({
741
+ title: "Authorize MCP access",
742
+ heading: "Authorize this client",
743
+ body,
744
+ });
745
+ }
746
+
747
+ export function renderMcpOAuthContinuePage(redirectTo: string): string {
748
+ return oauthDocument({
749
+ title: "Returning to the app",
750
+ heading: "Returning to the app",
751
+ head: `<meta http-equiv="refresh" content="0;url=${escapeHtml(redirectTo)}">`,
752
+ body: `<p class="lede">Authorization succeeded. You can close this window.</p><script>(function (url) { try { location.replace(url); } catch (error) {} try { location.href = url; } catch (error) {} var frame = document.createElement("iframe"); frame.src = url; frame.style.display = "none"; document.body.appendChild(frame); })(${JSON.stringify(redirectTo)});</script>`,
753
+ });
754
+ }
755
+
756
+ export function renderMcpOAuthExpiredPage(message: string): string {
757
+ return oauthDocument({
758
+ title: "Authorization expired",
759
+ heading: "Authorization expired",
760
+ body: `<p class="lede">${escapeHtml(message)}</p><p class="note">Close this window and start authorization again from the client.</p>`,
761
+ });
762
+ }
763
+
764
+ async function listConsentWorkspaces(deps: ApiRouteDeps, context: AccessContext) {
765
+ const readableWorkspaceIds = [
766
+ ...new Set(
767
+ context.workspaceGrants
768
+ .filter((grant) => hasPermission(grant.permissions, "workspace:read"))
769
+ .map((grant) => grant.workspaceId),
770
+ ),
771
+ ];
772
+ const readableAccountIds = context.accountGrants
773
+ .filter((grant) => hasPermission(grant.permissions, "workspace:read"))
774
+ .map((grant) => grant.accountId);
775
+ const [fromGrants, memberships, shared] = await Promise.all([
776
+ Promise.all(readableWorkspaceIds.map((workspaceId) => getWorkspace(deps.db, workspaceId))),
777
+ listWorkspacesForSubject(deps.db, context.subjectId),
778
+ Promise.all(
779
+ readableAccountIds.map((accountId) => listSharedWorkspacesForAccount(deps.db, accountId)),
780
+ ),
781
+ ]);
782
+ const byId = new Map<string, NonNullable<(typeof fromGrants)[number]>>();
783
+ for (const workspace of [...fromGrants, ...memberships, ...shared.flat()]) {
784
+ if (workspace) byId.set(workspace.id, workspace);
785
+ }
786
+ return [...byId.values()].sort((left, right) => left.name.localeCompare(right.name));
787
+ }
788
+
789
+ async function consentAccountsForWorkspaces(
790
+ deps: ApiRouteDeps,
791
+ workspaces: Array<{ accountId: string }>,
792
+ ): Promise<McpOAuthConsentAccount[]> {
793
+ const accountIds = [...new Set(workspaces.map((workspace) => workspace.accountId))];
794
+ return (
795
+ await Promise.all(
796
+ accountIds.map(async (accountId) => {
797
+ const account = await getManagedAccount(deps.db, accountId);
798
+ return { id: accountId, name: account?.name.trim() || "Organization" };
799
+ }),
800
+ )
801
+ ).sort((left, right) => left.name.localeCompare(right.name));
802
+ }
803
+
804
+ function consentWorkspaceLabel(workspace: McpOAuthConsentWorkspace): string {
805
+ const name = workspace.name.trim() || "Workspace";
806
+ return workspace.kind === "personal" ? `${name} (Personal)` : name;
807
+ }
808
+
809
+ function isWorkspaceId(value: string): boolean {
810
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value);
811
+ }
812
+
813
+ function oauthDocument(input: {
814
+ title: string;
815
+ heading: string;
816
+ body: string;
817
+ head?: string;
818
+ }): string {
819
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)}</title>${input.head ?? ""}<style>${OAUTH_PAGE_CSS}</style></head><body><main><p class="mark">OpenGeni</p><h1>${escapeHtml(input.heading)}</h1>${input.body}</main></body></html>`;
820
+ }
821
+
822
+ const OAUTH_PAGE_CSS =
823
+ 'html,body{margin:0}body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;background:#f4f4f5;color:#18181b;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;font-size:15px;line-height:1.5;-webkit-font-smoothing:antialiased}main{width:min(100%,28rem);background:#fff;border:1px solid #e4e4e7;border-radius:20px;padding:24px}h1{margin:8px 0 0;font-size:22px;line-height:1.3;font-weight:650}p{margin:12px 0 0}strong{font-weight:600}.mark{margin:0;font-size:12px;font-weight:600;color:#71717a}.lede,.note{color:#52525b}.field{display:flex;flex-direction:column;gap:6px;margin-top:16px;font-size:13px;color:#71717a}select{width:100%;border:1px solid #e4e4e7;border-radius:10px;background:#f4f4f5;color:#18181b;padding:10px 12px;font:inherit}.actions{display:flex;gap:12px;margin-top:20px}button{flex:1;min-height:40px;border:0;border-radius:10px;padding:10px 16px;font:inherit;font-weight:600;cursor:pointer}.approve{background:#18181b;color:#fff}.deny{background:#fff;color:#18181b;border:1px solid #e4e4e7}button:disabled{opacity:0.6;cursor:wait}@media(prefers-color-scheme:dark){body{background:#09090b;color:#fafafa}main{background:#18181b;border-color:#3f3f46}h1{color:#fafafa}.mark{color:#a1a1aa}.lede,.note{color:#a1a1aa}select{background:#27272a;border-color:#3f3f46;color:#fafafa}.approve{background:#fafafa;color:#18181b}.deny{background:#27272a;color:#fafafa;border-color:#3f3f46}}';
824
+
825
+ function jsonForScript(value: unknown): string {
826
+ return JSON.stringify(value).replace(/</gu, "\\u003c");
525
827
  }
526
828
 
527
829
  function escapeHtml(value: string): string {
@@ -7,6 +7,7 @@ import {
7
7
  } from "@opengeni/capabilities";
8
8
  import {
9
9
  ApiIntegrationPreview,
10
+ OrganizationIntegrationDeniedError,
10
11
  ListIntegrationDefinitionsResponse,
11
12
  ApiIntegrationUninstallPreview,
12
13
  InstallApiIntegrationRequest,
@@ -17,12 +18,18 @@ import {
17
18
  UninstallApiIntegrationResult,
18
19
  type AccessGrant,
19
20
  } from "@opengeni/contracts";
20
- import { requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
21
+ import {
22
+ requireAccessGrant,
23
+ integrationSourceForOrganizationPolicy,
24
+ type ApiRouteDeps,
25
+ } from "@opengeni/core";
26
+ import { withOrganizationIntegrationPolicyFence } from "@opengeni/db/organization-integration-policy";
21
27
  import {
22
28
  ApiIntegrationInstallationVersionConflictError,
23
29
  buildConnectionTokenResolver,
24
30
  getApiIntegrationUninstallPreview,
25
31
  getConnectionMetadata,
32
+ getApiIntegrationReconciliationSnapshot,
26
33
  installApiIntegration,
27
34
  IntegrationFacetBindingOwnershipConflictError,
28
35
  IntegrationFacetBindingVersionConflictError,
@@ -209,6 +216,7 @@ export function registerApiIntegrationRoutes(
209
216
  const resolved = await resolveForRoute({
210
217
  deps,
211
218
  transport,
219
+ accountId: grant.accountId,
212
220
  workspaceId,
213
221
  subjectId: grant.subjectId,
214
222
  payload,
@@ -223,6 +231,7 @@ export function registerApiIntegrationRoutes(
223
231
  const resolved = await resolveForRoute({
224
232
  deps,
225
233
  transport,
234
+ accountId: grant.accountId,
226
235
  workspaceId,
227
236
  subjectId: grant.subjectId,
228
237
  payload,
@@ -308,21 +317,45 @@ export function registerApiIntegrationRoutes(
308
317
  export async function resolveForRoute(input: {
309
318
  deps: ApiRouteDeps;
310
319
  transport: ReturnType<typeof createPinnedIntegrationTransport>;
320
+ accountId: string;
311
321
  workspaceId: string;
312
322
  subjectId: string;
313
323
  payload: PreviewApiIntegrationRequest | InstallApiIntegrationRequest;
314
324
  }): ReturnType<typeof resolveApiIntegrationPreview> {
325
+ const payload = structuredClone(input.payload);
326
+ const preparation = await withOrganizationIntegrationPolicyFence(
327
+ input.deps.db,
328
+ input,
329
+ async (tx, policy) => {
330
+ try {
331
+ return { source: integrationSourceForOrganizationPolicy(policy, payload.source) };
332
+ } catch (error) {
333
+ if (!(error instanceof OrganizationIntegrationDeniedError)) throw error;
334
+ const install = InstallApiIntegrationRequest.safeParse(payload);
335
+ if (install.success) {
336
+ const resolved = await storedReconciliationPreview(
337
+ { ...input, deps: { ...input.deps, db: tx } },
338
+ install.data,
339
+ );
340
+ if (resolved) return { resolved };
341
+ }
342
+ throw error;
343
+ }
344
+ },
345
+ );
346
+ if (preparation.resolved) return preparation.resolved;
347
+ const source = preparation.source!;
315
348
  try {
316
- const connection = input.payload.connectionId
349
+ const connection = payload.connectionId
317
350
  ? await requireVisibleConnection(
318
351
  input.deps,
319
352
  input.workspaceId,
320
353
  input.subjectId,
321
- input.payload.connectionId,
354
+ payload.connectionId,
322
355
  )
323
356
  : null;
324
357
  return await resolveApiIntegrationPreview({
325
- source: input.payload.source,
358
+ source,
326
359
  connection: connectionDescriptor(connection),
327
360
  transport: input.transport,
328
361
  authority: {
@@ -353,6 +386,85 @@ export async function resolveForRoute(input: {
353
386
  }
354
387
  }
355
388
 
389
+ async function storedReconciliationPreview(
390
+ input: { deps: ApiRouteDeps; accountId: string; workspaceId: string; subjectId: string },
391
+ payload: InstallApiIntegrationRequest,
392
+ ): Promise<ResolvedApiIntegrationPreview | null> {
393
+ const snapshot = await getApiIntegrationReconciliationSnapshot(input.deps.db, {
394
+ accountId: input.accountId,
395
+ workspaceId: input.workspaceId,
396
+ subjectId: input.subjectId,
397
+ source: payload.source,
398
+ expectedRevisionId: payload.expectedRevisionId,
399
+ expectedContentSha256: payload.expectedContentSha256,
400
+ ...(payload.connectionId !== undefined ? { connectionId: payload.connectionId } : {}),
401
+ ...(payload.instanceKey !== undefined ? { instanceKey: payload.instanceKey } : {}),
402
+ ...(payload.allowedTools !== undefined ? { allowedTools: payload.allowedTools } : {}),
403
+ });
404
+ if (!snapshot) return null;
405
+ const { runtime, baseServerId, name, provider, requiredScopes } = snapshot;
406
+ const connection = payload.connectionId
407
+ ? await requireVisibleConnection(
408
+ input.deps,
409
+ input.workspaceId,
410
+ input.subjectId,
411
+ payload.connectionId,
412
+ )
413
+ : null;
414
+ const scheme = runtime.authScheme;
415
+ const auth = !apiIntegrationRequiresConnection(scheme)
416
+ ? { kind: "none" }
417
+ : scheme.kind === "oauth2"
418
+ ? { kind: "oauth2", providerDomain: runtime.providerDomain, scopes: requiredScopes }
419
+ : scheme.kind === "api_key"
420
+ ? {
421
+ kind: "api_key",
422
+ providerDomain: runtime.providerDomain,
423
+ carrier: scheme.carrier,
424
+ name: scheme.name,
425
+ }
426
+ : scheme.kind === "http"
427
+ ? { kind: "http", providerDomain: runtime.providerDomain, scheme: scheme.scheme }
428
+ : null;
429
+ if (!auth) return null;
430
+ return {
431
+ preview: ApiIntegrationPreview.parse({
432
+ source: payload.source,
433
+ definitionId: runtime.definitionId,
434
+ definitionProvenance: runtime.definitionProvenance,
435
+ protocol: runtime.protocol,
436
+ capabilityId: runtime.capabilityId,
437
+ pluginKey: runtime.pluginKey,
438
+ serverId: baseServerId,
439
+ name,
440
+ description: runtime.description,
441
+ provider,
442
+ providerDomain: runtime.providerDomain,
443
+ baseUrl: runtime.baseUrl,
444
+ sourceUrl: runtime.sourceUrl,
445
+ revisionId: runtime.revision.id,
446
+ contentSha256: runtime.revision.contentSha256,
447
+ auth,
448
+ connectionId: connection?.id ?? null,
449
+ connectionOwnership: connection ? (connection.subjectId ? "personal" : "workspace") : null,
450
+ tools: runtime.revision.tools.map((tool) => ({
451
+ id: tool.id,
452
+ operationKey: tool.operationKey,
453
+ name: tool.name,
454
+ description: tool.description,
455
+ safety: tool.safety,
456
+ approvalMode: tool.approvalMode,
457
+ deprecated: tool.deprecated,
458
+ })),
459
+ warnings: [],
460
+ }),
461
+ revision: runtime.revision,
462
+ provider,
463
+ requiredScopes,
464
+ authScheme: runtime.authScheme,
465
+ };
466
+ }
467
+
356
468
  async function requireVisibleConnection(
357
469
  deps: ApiRouteDeps,
358
470
  workspaceId: string,