@opengeni/api-router 0.30.3 → 2.2.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 (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +161 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +353 -4
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -3,7 +3,6 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
3
3
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
4
  import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
5
5
  import {
6
- OPENGENI_PERSONAL_SLACK_MCP_URL,
7
6
  OAuthStartResponse,
8
7
  selectCanonicalPersonalSlackConnection,
9
8
  type ConnectionOwnership,
@@ -17,6 +16,7 @@ import {
17
16
  decryptEnvironmentValue,
18
17
  encryptEnvironmentValue,
19
18
  getConnectionMetadata,
19
+ getGlobalCatalogOAuthProfile,
20
20
  getWorkspaceGrant,
21
21
  listConnectionsMetadata,
22
22
  loadIntegrationOAuthClient,
@@ -40,21 +40,37 @@ import {
40
40
  import { Buffer } from "node:buffer";
41
41
  import { createHash, randomBytes } from "node:crypto";
42
42
  import { HTTPException } from "hono/http-exception";
43
+ import {
44
+ assertConnectionOwnershipAllowedForPrincipal,
45
+ personalOwnerStateAccepted,
46
+ personalOwnerVerifiedInState,
47
+ PERSONAL_CONNECTION_PRINCIPAL_MESSAGE,
48
+ PERSONAL_OWNER_VERIFIED_STATE_CLAIM,
49
+ } from "../connection-ownership";
43
50
  import { ApiHttpError } from "../http/api-error";
51
+ import {
52
+ DEFAULT_OAUTH_PROFILE,
53
+ DEPLOYMENT_MANAGED_CLIENTS,
54
+ builtInOAuthProfileByKey,
55
+ assertAuthorizationServerNotReserved,
56
+ assertAuthorizationServerPins,
57
+ assertOwnershipAllowed,
58
+ builtInOAuthProfileFor,
59
+ catalogMcpUrlKey,
60
+ defaultOwnershipFor,
61
+ deploymentManagedClientFor,
62
+ oauthProfileFromCatalog,
63
+ type OAuthProviderProfile,
64
+ type PinnableAuthorizationServer,
65
+ } from "./oauth-profiles";
44
66
  import { canonicalProviderDomain } from "./provider-domain";
45
67
 
46
68
  export const oauthStateTtlMs = 10 * 60 * 1000;
47
- export const OFFICIAL_SLACK_MCP_URL = OPENGENI_PERSONAL_SLACK_MCP_URL;
48
- export const OFFICIAL_GMAIL_MCP_URL = "https://gmailmcp.googleapis.com/mcp/v1";
49
- export const OFFICIAL_GMAIL_MCP_SCOPES = [
50
- "https://www.googleapis.com/auth/gmail.readonly",
51
- "https://www.googleapis.com/auth/gmail.compose",
52
- "https://www.googleapis.com/auth/gmail.modify",
53
- ] as const;
54
- const SLACK_OAUTH_ORIGIN = "https://slack.com";
55
- const SLACK_MCP_ORIGIN = "https://mcp.slack.com";
56
- const GOOGLE_OAUTH_ISSUER_ORIGIN = "https://accounts.google.com";
57
- const GOOGLE_TOKEN_ORIGIN = "https://oauth2.googleapis.com";
69
+ export {
70
+ OFFICIAL_GMAIL_MCP_SCOPES,
71
+ OFFICIAL_GMAIL_MCP_URL,
72
+ OFFICIAL_SLACK_MCP_URL,
73
+ } from "./oauth-profiles";
58
74
  export { OAUTH_MAX_RESPONSE_BYTES } from "@opengeni/network";
59
75
 
60
76
  type OAuthClientDeps = {
@@ -69,6 +85,12 @@ export type OAuthStartContext = {
69
85
  accountId: string;
70
86
  workspaceId: string;
71
87
  subjectId: string;
88
+ /**
89
+ * False for every principal that cannot own a personal Connection (API keys,
90
+ * the configured key, services, agent attempts). Resolved by the route from
91
+ * the live authenticated principal, never inferred here.
92
+ */
93
+ personalOwnershipAllowed: boolean;
72
94
  requestUrl: string;
73
95
  payload: OAuthStartRequest;
74
96
  };
@@ -116,6 +138,8 @@ type OAuthStatePayload = {
116
138
  workspaceId: string;
117
139
  subjectId: string;
118
140
  ownership: ConnectionOwnership;
141
+ /** Signed proof that a live managed human authorized personal ownership. */
142
+ personalOwnerVerified: boolean;
119
143
  providerDomain: string;
120
144
  mcpUrl: string;
121
145
  resource: string;
@@ -305,15 +329,29 @@ async function startMcpOAuthWithinDeadline(
305
329
  ): Promise<OAuthStartResponse> {
306
330
  const { db, settings } = deps;
307
331
  const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
308
- const officialGmailResource = mcpUrl === OFFICIAL_GMAIL_MCP_URL;
309
- const officialSlackResource = mcpUrl === OFFICIAL_SLACK_MCP_URL;
310
- const providerDomain = officialSlackResource
311
- ? "slack.com"
312
- : canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
313
- const hostedSlackMcp = officialSlackResource || providerDomain === "slack.com";
314
- assertHostedSlackMcpOAuthStart(settings, context.payload, mcpUrl, hostedSlackMcp);
315
- const requestedOwnership: ConnectionOwnership = context.payload.ownership ?? "workspace";
316
- assertOfficialGmailPersonalOwnership(mcpUrl, requestedOwnership);
332
+ const urlProfile = builtInOAuthProfileFor({ mcpUrl });
333
+ const providerDomain =
334
+ urlProfile?.canonicalProviderDomain ??
335
+ canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
336
+ const builtInProfile = urlProfile ?? builtInOAuthProfileFor({ mcpUrl, providerDomain });
337
+ const profile =
338
+ builtInProfile ??
339
+ (await deadline.run("connection_lookup", async () => {
340
+ const raw = await getGlobalCatalogOAuthProfile(
341
+ db,
342
+ context.workspaceId,
343
+ catalogMcpUrlKey(mcpUrl),
344
+ );
345
+ return raw === null ? null : oauthProfileFromCatalog(mcpUrl, raw);
346
+ })) ??
347
+ DEFAULT_OAUTH_PROFILE;
348
+ assertOAuthStartProfile(settings, context.payload, mcpUrl, profile);
349
+ // Personal-only resources default an omitted ownership to personal, matching
350
+ // the fence that existed before #1240; an explicit non-personal value is the
351
+ // only thing rejected. Everything else defaults to workspace as before.
352
+ const requestedOwnership: ConnectionOwnership =
353
+ context.payload.ownership ?? defaultOwnershipFor(profile);
354
+ assertOwnershipAllowed(profile, requestedOwnership);
317
355
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
318
356
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
319
357
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -324,8 +362,8 @@ async function startMcpOAuthWithinDeadline(
324
362
  subjectId: context.subjectId,
325
363
  providerDomain,
326
364
  mcpUrl,
327
- hostedSlackMcp,
328
- exactMcpBinding: hostedSlackMcp || officialGmailResource,
365
+ connectionSelection: profile.connectionSelection,
366
+ exactMcpBinding: profile.exactMcpBinding,
329
367
  connectionId: context.payload.connectionId,
330
368
  requestedOwnership: context.payload.ownership,
331
369
  newConnectionOwnership: requestedOwnership,
@@ -337,27 +375,25 @@ async function startMcpOAuthWithinDeadline(
337
375
  const ownership = existing
338
376
  ? ownershipForConnection(existing.subjectId, context.subjectId)
339
377
  : requestedOwnership;
378
+ // A reconnect of a legacy row must not renew an ownership the profile no
379
+ // longer allows (e.g. shared authority over one human's hosted-Slack grant).
380
+ assertOwnershipAllowed(profile, ownership);
381
+ // Only a managed human can own a personal Connection: personal-authority
382
+ // execution resolves through a delegation snapshot frozen on a human's causal
383
+ // turn, and migration 0256 can mint the `user` authority scope only for a
384
+ // subject that holds an active organization membership. For a personal-only
385
+ // profile (Gmail, hosted Slack MCP) this refuses the whole flow rather than
386
+ // silently downgrading to the workspace ownership those profiles forbid.
387
+ assertConnectionOwnershipAllowedForPrincipal(ownership, context.personalOwnershipAllowed);
340
388
 
341
389
  const discovery = await discoverMcpOAuth(mcpUrl, settings, deadline);
342
- if (hostedSlackMcp && !isLocalTestEnvironment(settings.environment)) {
343
- assertSlackAuthorizationServer(discovery.as);
344
- }
345
- const googleAuthorizationServer = hasGoogleAuthorizationServerIdentity(discovery.as);
346
- if (officialGmailResource || googleAuthorizationServer) {
347
- if (!officialGmailResource || providerDomain !== "gmailmcp.googleapis.com") {
348
- throw new HTTPException(422, {
349
- message: `Google OAuth is allowed only for ${OFFICIAL_GMAIL_MCP_URL}`,
350
- });
351
- }
352
- assertGoogleAuthorizationServer(discovery.as);
353
- }
354
- const resourceParameterSupported = !officialGmailResource;
390
+ assertDiscoveredAuthorizationServer(settings, discovery.as, profile, providerDomain);
391
+ const resourceParameterSupported = profile.sendResourceParameter;
355
392
  const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
356
393
  const verifier = randomPkceVerifier();
357
- // The Gmail PRM advertises broader grants, including full-mail access. The
358
- // reviewed connector never lets a caller widen the capability contract.
359
- const authorizeScopes = chooseMcpAuthorizeScopes({
360
- mcpUrl,
394
+ // A profile's exact scope override wins outright: the reviewed connector
395
+ // never lets a caller widen the capability contract.
396
+ const authorizeScopes = chooseProfileAuthorizeScopes(profile, {
361
397
  requested: context.payload.requestedScopes,
362
398
  challenged: discovery.challenge.scope,
363
399
  supported: discovery.prm.scopesSupported,
@@ -371,6 +407,7 @@ async function startMcpOAuthWithinDeadline(
371
407
  redirectUri,
372
408
  authorizeScopes,
373
409
  context.payload.oauthClient,
410
+ profile,
374
411
  signal,
375
412
  ),
376
413
  );
@@ -380,11 +417,14 @@ async function startMcpOAuthWithinDeadline(
380
417
  workspaceId: context.workspaceId,
381
418
  subjectId: context.subjectId,
382
419
  ownership,
420
+ // Signed record that a live principal was checked; the callback has no
421
+ // principal of its own and enforces exactly this decision.
422
+ [PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: context.personalOwnershipAllowed,
383
423
  providerDomain,
384
424
  mcpUrl,
385
425
  resource,
386
- requestedScopes: officialGmailResource
387
- ? [...OFFICIAL_GMAIL_MCP_SCOPES]
426
+ requestedScopes: profile.requestedScopes
427
+ ? [...profile.requestedScopes]
388
428
  : uniqueStrings(context.payload.requestedScopes ?? []),
389
429
  authorizeScopes,
390
430
  encryptedPkceVerifier: encryptEnvironmentValue(key, verifier),
@@ -413,6 +453,7 @@ async function startMcpOAuthWithinDeadline(
413
453
  verifier,
414
454
  scopes: authorizeScopes,
415
455
  resourceParameterSupported,
456
+ ...(profile.extraAuthorizeParams ? { extraParams: profile.extraAuthorizeParams } : {}),
416
457
  });
417
458
  return OAuthStartResponse.parse({
418
459
  state,
@@ -421,18 +462,94 @@ async function startMcpOAuthWithinDeadline(
421
462
  });
422
463
  }
423
464
 
465
+ /** Ownership fence for the official Gmail profile, keyed by exact MCP URL. */
424
466
  export function assertOfficialGmailPersonalOwnership(
425
467
  mcpUrl: string,
426
468
  ownership: ConnectionOwnership,
427
469
  ): void {
428
- if (mcpUrl === OFFICIAL_GMAIL_MCP_URL && ownership !== "personal") {
429
- throw new HTTPException(422, {
430
- message:
431
- "Gmail connections are personal only; each workspace member must connect their own mailbox",
432
- });
470
+ const profile = builtInOAuthProfileFor({ mcpUrl });
471
+ if (profile?.key === "official-gmail") {
472
+ assertOwnershipAllowed(profile, ownership);
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Slack's hosted MCP issues user tokens only. Sharing one human's grant as
478
+ * workspace authority made every shared agent act as a named employee; the
479
+ * OpenGeni workspace bot owns shared Slack access instead (bot-token search
480
+ * covers public channels, files, and users).
481
+ */
482
+ export function assertHostedSlackMcpPersonalOwnership(
483
+ hostedSlackMcp: boolean,
484
+ ownership: ConnectionOwnership,
485
+ ): void {
486
+ if (hostedSlackMcp) {
487
+ assertOwnershipAllowed(builtInOAuthProfileByKey("hosted-slack-mcp"), ownership);
488
+ }
489
+ }
490
+
491
+ export function isHostedSlackMcpTarget(providerDomain: string, mcpUrl: string): boolean {
492
+ return builtInOAuthProfileFor({ mcpUrl, providerDomain })?.key === "hosted-slack-mcp";
493
+ }
494
+
495
+ /** Start-time payload fences declared by the resolved profile. */
496
+ function assertOAuthStartProfile(
497
+ settings: Settings,
498
+ payload: OAuthStartRequest,
499
+ mcpUrl: string,
500
+ profile: OAuthProviderProfile,
501
+ ): void {
502
+ if (profile.rejectCallerOAuthClient && payload.oauthClient) {
503
+ throw new HTTPException(422, { message: profile.rejectCallerOAuthClient.message });
504
+ }
505
+ if (
506
+ profile.requireProviderDomain &&
507
+ payload.providerDomain &&
508
+ canonicalProviderDomain(payload.providerDomain) !== profile.requireProviderDomain.domain
509
+ ) {
510
+ throw new HTTPException(422, { message: profile.requireProviderDomain.message });
511
+ }
512
+ if (
513
+ profile.requireExactMcpUrl &&
514
+ !isLocalTestEnvironment(settings.environment) &&
515
+ mcpUrl !== profile.requireExactMcpUrl.url
516
+ ) {
517
+ throw new HTTPException(422, { message: profile.requireExactMcpUrl.message });
518
+ }
519
+ if (
520
+ profile.requireDeploymentClient &&
521
+ !deploymentManagedClientFor(settings, profile.requireDeploymentClient.key)
522
+ ) {
523
+ throw new HTTPException(503, { message: profile.requireDeploymentClient.message });
433
524
  }
434
525
  }
435
526
 
527
+ /**
528
+ * Post-discovery fences, in an order that preserves the historical error
529
+ * surface: the profile's provider identity, then its authorization-server
530
+ * origin pins, then the reserved-server guard for everything else.
531
+ */
532
+ function assertDiscoveredAuthorizationServer(
533
+ settings: Settings,
534
+ as: PinnableAuthorizationServer,
535
+ profile: OAuthProviderProfile,
536
+ providerDomain: string,
537
+ ): void {
538
+ if (
539
+ profile.postDiscoveryProviderDomain &&
540
+ providerDomain !== profile.postDiscoveryProviderDomain.domain
541
+ ) {
542
+ throw new HTTPException(422, { message: profile.postDiscoveryProviderDomain.message });
543
+ }
544
+ if (
545
+ profile.authorizationServer &&
546
+ !(profile.authorizationServer.skipInLocalTest && isLocalTestEnvironment(settings.environment))
547
+ ) {
548
+ assertAuthorizationServerPins(as, profile.authorizationServer);
549
+ }
550
+ assertAuthorizationServerNotReserved(as, profile);
551
+ }
552
+
436
553
  export async function completeMcpOAuthCallback(
437
554
  deps: OAuthClientDeps,
438
555
  input: {
@@ -479,8 +596,27 @@ async function completeMcpOAuthCallbackWithinDeadline(
479
596
  try {
480
597
  state = readOAuthState(input.state, settings);
481
598
  // Fence state minted by an older deployment too: a rolling update must not
482
- // let a still-valid workspace-owned Gmail callback persist shared authority.
483
- assertOfficialGmailPersonalOwnership(state.mcpUrl, state.ownership);
599
+ // let a still-valid callback persist an ownership the target's profile no
600
+ // longer allows (workspace-owned Gmail or hosted Slack, for example).
601
+ const builtInCallbackProfile = builtInOAuthProfileFor({
602
+ mcpUrl: state.mcpUrl,
603
+ providerDomain: state.providerDomain,
604
+ });
605
+ if (builtInCallbackProfile) {
606
+ assertOwnershipAllowed(builtInCallbackProfile, state.ownership);
607
+ }
608
+ // Only a managed human may own a personal Connection. This callback has no
609
+ // live principal, so it enforces the signed start-time decision; a state
610
+ // minted before that fence existed carries no claim and is refused, which
611
+ // closes the in-flight window across a rolling deploy. The legacy
612
+ // `?? "personal"` decode above therefore cannot land a machine-owned row.
613
+ if (!personalOwnerStateAccepted(state)) {
614
+ throw new OAuthCallbackStageError(
615
+ "state_verify",
616
+ "state_invalid",
617
+ new HTTPException(422, { message: PERSONAL_CONNECTION_PRINCIPAL_MESSAGE }),
618
+ );
619
+ }
484
620
  if (!input.code) {
485
621
  return {
486
622
  redirectTo: callbackReturnPath(state.returnPath, "error", {
@@ -495,6 +631,18 @@ async function completeMcpOAuthCallbackWithinDeadline(
495
631
  db,
496
632
  async (scopedDb) => {
497
633
  await requireOAuthCallbackGrant(scopedDb, state!);
634
+ if (!builtInCallbackProfile) {
635
+ // Catalog-declared ownership fences get the same rolling-update
636
+ // treatment as the built-in ones.
637
+ const raw = await getGlobalCatalogOAuthProfile(
638
+ scopedDb,
639
+ state!.workspaceId,
640
+ catalogMcpUrlKey(state!.mcpUrl),
641
+ );
642
+ if (raw !== null) {
643
+ assertOwnershipAllowed(oauthProfileFromCatalog(state!.mcpUrl, raw), state!.ownership);
644
+ }
645
+ }
498
646
  return await consumeIntegrationOAuthStateNonce(scopedDb, {
499
647
  accountId: state!.accountId,
500
648
  workspaceId: state!.workspaceId,
@@ -656,68 +804,19 @@ async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload)
656
804
  }
657
805
  }
658
806
 
659
- function assertHostedSlackMcpOAuthStart(
660
- settings: Settings,
661
- payload: OAuthStartRequest,
662
- mcpUrl: string,
663
- hostedSlackMcp: boolean,
664
- ): void {
665
- if (!hostedSlackMcp) return;
666
- if (payload.oauthClient) {
667
- throw new HTTPException(422, {
668
- message: "Slack OAuth client credentials are deployment-managed",
669
- });
670
- }
671
- if (payload.providerDomain && canonicalProviderDomain(payload.providerDomain) !== "slack.com") {
672
- throw new HTTPException(422, {
673
- message: "Slack provider identity does not match slack.com",
674
- });
675
- }
676
- if (!isLocalTestEnvironment(settings.environment) && mcpUrl !== OFFICIAL_SLACK_MCP_URL) {
677
- throw new HTTPException(422, {
678
- message: `Slack MCP OAuth must use ${OFFICIAL_SLACK_MCP_URL}`,
679
- });
680
- }
681
- if (!settings.slackClientId?.trim() || !settings.slackClientSecret?.trim()) {
682
- throw new HTTPException(503, {
683
- message: "Slack MCP OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
684
- });
685
- }
686
- }
687
-
807
+ /** The hosted-Slack profile's origin pins, kept exported for its tests. */
688
808
  export function assertSlackAuthorizationServer(as: AuthorizationServerMetadata): void {
689
- const issuerOrigins = [as.issuer, as.authorizationServer].map((value) => new URL(value).origin);
690
- const endpointOrigins = [as.authorizationEndpoint, as.tokenEndpoint].map(
691
- (value) => new URL(value).origin,
692
- );
693
- if (
694
- issuerOrigins.some((origin) => origin !== SLACK_OAUTH_ORIGIN && origin !== SLACK_MCP_ORIGIN) ||
695
- endpointOrigins.some((origin) => origin !== SLACK_OAUTH_ORIGIN)
696
- ) {
697
- throw new HTTPException(422, {
698
- message: "Slack MCP authorization metadata did not remain bound to slack.com",
699
- });
809
+ const pins = builtInOAuthProfileByKey("hosted-slack-mcp").authorizationServer;
810
+ if (pins) {
811
+ assertAuthorizationServerPins(as, pins);
700
812
  }
701
813
  }
702
814
 
703
- function hasGoogleAuthorizationServerIdentity(as: AuthorizationServerMetadata): boolean {
704
- return [as.issuer, as.authorizationServer].some(
705
- (value) => new URL(value).origin === GOOGLE_OAUTH_ISSUER_ORIGIN,
706
- );
707
- }
708
-
815
+ /** The official-Gmail profile's origin pins, kept exported for its tests. */
709
816
  export function assertGoogleAuthorizationServer(as: AuthorizationServerMetadata): void {
710
- const issuerOrigins = [as.issuer, as.authorizationServer].map((value) => new URL(value).origin);
711
- const authorizationOrigin = new URL(as.authorizationEndpoint).origin;
712
- const tokenOrigin = new URL(as.tokenEndpoint).origin;
713
- if (
714
- issuerOrigins.some((origin) => origin !== GOOGLE_OAUTH_ISSUER_ORIGIN) ||
715
- authorizationOrigin !== GOOGLE_OAUTH_ISSUER_ORIGIN ||
716
- tokenOrigin !== GOOGLE_TOKEN_ORIGIN
717
- ) {
718
- throw new HTTPException(422, {
719
- message: "Gmail MCP authorization metadata did not remain bound to Google",
720
- });
817
+ const pins = builtInOAuthProfileByKey("official-gmail").authorizationServer;
818
+ if (pins) {
819
+ assertAuthorizationServerPins(as, pins);
721
820
  }
722
821
  }
723
822
 
@@ -884,16 +983,15 @@ async function registerOAuthClient(
884
983
  redirectUri: string,
885
984
  scopes: string[],
886
985
  manual: OAuthStartRequest["oauthClient"],
986
+ profile: OAuthProviderProfile,
887
987
  signal: AbortSignal,
888
988
  ): Promise<OAuthClientRegistration> {
889
989
  const operator = operatorClientForAs(settings, as);
890
990
  if (operator) {
891
991
  return operator;
892
992
  }
893
- // Linear currently advertises CIMD but rejects its client metadata URL at
894
- // the authorization endpoint. Its documented interactive setup uses DCR,
895
- // so prefer the simultaneously advertised registration endpoint.
896
- if (prefersDynamicClientRegistration(as)) {
993
+ const selfRegistration = preferredOAuthSelfRegistration(as, profile.clientSource);
994
+ if (selfRegistration === "dcr") {
897
995
  return await getOrCreateDynamicClientRegistration(
898
996
  db,
899
997
  settings,
@@ -903,7 +1001,7 @@ async function registerOAuthClient(
903
1001
  signal,
904
1002
  );
905
1003
  }
906
- if (as.clientIdMetadataDocumentSupported) {
1004
+ if (selfRegistration === "cimd") {
907
1005
  return {
908
1006
  method: "cimd",
909
1007
  issuer: as.issuer,
@@ -928,10 +1026,22 @@ async function registerOAuthClient(
928
1026
  return await getOrCreateDynamicClientRegistration(db, settings, as, redirectUri, scopes, signal);
929
1027
  }
930
1028
 
931
- function prefersDynamicClientRegistration(as: AuthorizationServerMetadata): boolean {
932
- return Boolean(
933
- as.registrationEndpoint && normalizedIssuerKey(as.issuer) === "https://mcp.linear.app",
934
- );
1029
+ export function preferredOAuthSelfRegistration(
1030
+ as: Pick<
1031
+ AuthorizationServerMetadata,
1032
+ "clientIdMetadataDocumentSupported" | "registrationEndpoint"
1033
+ >,
1034
+ clientSource: OAuthProviderProfile["clientSource"],
1035
+ ): "cimd" | "dcr" | null {
1036
+ if (clientSource === "dcr") return "dcr";
1037
+ if (clientSource === "cimd" && as.clientIdMetadataDocumentSupported) return "cimd";
1038
+ // DCR produces an authorization-server-issued client id and is therefore the
1039
+ // interoperable default whenever the server advertises both registration
1040
+ // mechanisms. A reviewed provider profile may explicitly force CIMD for a
1041
+ // server whose registration endpoint is unsuitable.
1042
+ if (clientSource !== "cimd" && as.registrationEndpoint) return "dcr";
1043
+ if (as.clientIdMetadataDocumentSupported) return "cimd";
1044
+ return null;
935
1045
  }
936
1046
 
937
1047
  async function getOrCreateDynamicClientRegistration(
@@ -1106,23 +1216,21 @@ function operatorClientEntryFor(
1106
1216
  candidates: string[],
1107
1217
  ): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
1108
1218
  const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
1109
- if (
1110
- candidates.some((candidate) => {
1111
- try {
1112
- const origin = new URL(candidate).origin;
1113
- return origin === SLACK_OAUTH_ORIGIN || origin === SLACK_MCP_ORIGIN;
1114
- } catch {
1115
- return false;
1116
- }
1117
- }) &&
1118
- settings.slackClientId?.trim() &&
1119
- settings.slackClientSecret?.trim()
1120
- ) {
1121
- return {
1122
- clientId: settings.slackClientId.trim(),
1123
- clientSecret: settings.slackClientSecret.trim(),
1124
- tokenEndpointAuthMethod: "client_secret_post",
1125
- };
1219
+ const candidateOrigins = candidates.flatMap((candidate) => {
1220
+ try {
1221
+ return [new URL(candidate).origin];
1222
+ } catch {
1223
+ return [];
1224
+ }
1225
+ });
1226
+ for (const entry of DEPLOYMENT_MANAGED_CLIENTS) {
1227
+ if (!candidateOrigins.some((origin) => entry.issuerOrigins.includes(origin))) {
1228
+ continue;
1229
+ }
1230
+ const resolved = entry.resolve(settings);
1231
+ if (resolved) {
1232
+ return resolved;
1233
+ }
1126
1234
  }
1127
1235
  const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
1128
1236
  const exactKeys = uniqueStrings(
@@ -1210,7 +1318,7 @@ async function existingOAuthConnectionForStart(
1210
1318
  subjectId: string;
1211
1319
  providerDomain: string;
1212
1320
  mcpUrl: string;
1213
- hostedSlackMcp: boolean;
1321
+ connectionSelection: OAuthProviderProfile["connectionSelection"];
1214
1322
  exactMcpBinding: boolean;
1215
1323
  connectionId?: string | undefined;
1216
1324
  requestedOwnership?: ConnectionOwnership | undefined;
@@ -1247,7 +1355,7 @@ async function existingOAuthConnectionForStart(
1247
1355
  connection.providerDomain === input.providerDomain &&
1248
1356
  (!input.exactMcpBinding || connection.metadata.mcpUrl === input.mcpUrl),
1249
1357
  );
1250
- if (input.hostedSlackMcp && input.newConnectionOwnership === "personal") {
1358
+ if (input.connectionSelection === "canonical_personal") {
1251
1359
  return selectCanonicalPersonalSlackConnection(matching);
1252
1360
  }
1253
1361
  return matching.find((connection) => connection.status === "active") ?? null;
@@ -1272,6 +1380,8 @@ export function buildAuthorizationUrl(input: {
1272
1380
  verifier: string;
1273
1381
  scopes: string[];
1274
1382
  resourceParameterSupported: boolean;
1383
+ /** Profile-declared extra authorize parameters, applied last. */
1384
+ extraParams?: Readonly<Record<string, string>> | undefined;
1275
1385
  }): string {
1276
1386
  const endpoint = oauthEndpointUrl(input.endpoint, input.settings, "OAuth authorization endpoint");
1277
1387
  const url = new URL(endpoint);
@@ -1280,20 +1390,25 @@ export function buildAuthorizationUrl(input: {
1280
1390
  url.searchParams.set("redirect_uri", input.redirectUri);
1281
1391
  url.searchParams.set("state", input.state);
1282
1392
  if (input.resourceParameterSupported) {
1393
+ // Suppressing RFC 8707 is a profile fact (Google's endpoints reject the
1394
+ // parameter); any provider-specific replacement parameters, such as
1395
+ // Google's offline-consent options, are that profile's
1396
+ // `extraAuthorizeParams` rather than behavior implied by the suppression.
1283
1397
  url.searchParams.set("resource", input.resource);
1284
- } else {
1285
- // Google's OAuth endpoints do not implement RFC 8707's `resource`
1286
- // parameter. Explicit offline consent is required to obtain the refresh
1287
- // token used by the durable connection broker.
1288
- url.searchParams.set("access_type", "offline");
1289
- url.searchParams.set("include_granted_scopes", "true");
1290
- url.searchParams.set("prompt", "consent");
1291
1398
  }
1292
1399
  url.searchParams.set("code_challenge_method", "S256");
1293
1400
  url.searchParams.set("code_challenge", pkceChallenge(input.verifier));
1294
1401
  if (input.scopes.length > 0) {
1295
1402
  url.searchParams.set("scope", input.scopes.join(" "));
1296
1403
  }
1404
+ for (const [name, value] of Object.entries(input.extraParams ?? {})) {
1405
+ // Defense in depth behind the schema/curation validation: an extra
1406
+ // parameter can never displace a security parameter the client already
1407
+ // set (state, PKCE, scope, resource, redirect_uri, ...).
1408
+ if (!url.searchParams.has(name)) {
1409
+ url.searchParams.set(name, value);
1410
+ }
1411
+ }
1297
1412
  return url.toString();
1298
1413
  }
1299
1414
 
@@ -1318,6 +1433,8 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
1318
1433
  // OAuth states minted before ownership was explicit were always personal.
1319
1434
  // Preserve that meaning for in-flight reconnects during a rolling deploy.
1320
1435
  ownership: connectionOwnership(payload.ownership) ?? "personal",
1436
+ // Absent on a legacy state, which therefore cannot land a personal owner.
1437
+ personalOwnerVerified: personalOwnerVerifiedInState(payload),
1321
1438
  providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
1322
1439
  mcpUrl: stringValue(payload.mcpUrl) ?? resource,
1323
1440
  resource,
@@ -2061,8 +2178,20 @@ export function chooseMcpAuthorizeScopes(input: {
2061
2178
  challenged: string[] | undefined;
2062
2179
  supported: string[];
2063
2180
  }): string[] {
2064
- return input.mcpUrl === OFFICIAL_GMAIL_MCP_URL
2065
- ? [...OFFICIAL_GMAIL_MCP_SCOPES]
2181
+ const profile = builtInOAuthProfileFor({ mcpUrl: input.mcpUrl }) ?? DEFAULT_OAUTH_PROFILE;
2182
+ return chooseProfileAuthorizeScopes(profile, input);
2183
+ }
2184
+
2185
+ function chooseProfileAuthorizeScopes(
2186
+ profile: OAuthProviderProfile,
2187
+ input: {
2188
+ requested: string[] | undefined;
2189
+ challenged: string[] | undefined;
2190
+ supported: string[];
2191
+ },
2192
+ ): string[] {
2193
+ return profile.requestedScopes
2194
+ ? [...profile.requestedScopes]
2066
2195
  : chooseAuthorizeScopes(input.requested, input.challenged, input.supported);
2067
2196
  }
2068
2197