@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
@@ -1,4 +1,9 @@
1
1
  import { safeReturnPath } from "./oauth-return-path";
2
+ import { assertOrganizationIntegrationAllowed, stableJson } from "@opengeni/contracts";
3
+ import {
4
+ withOrganizationIntegrationAcquisition,
5
+ withOrganizationIntegrationPolicyFence,
6
+ } from "@opengeni/db/organization-integration-policy";
2
7
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
8
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
9
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
@@ -9,7 +14,7 @@ import {
9
14
  type ConnectionOwnership,
10
15
  type OAuthStartRequest,
11
16
  } from "@opengeni/contracts";
12
- import { requireEnvironmentEncryption } from "@opengeni/core";
17
+ import { requireEnvironmentEncryption, integrationKeyForConnectProvider } from "@opengeni/core";
13
18
  import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
14
19
  import type { Observability } from "@opengeni/observability";
15
20
  import {
@@ -63,11 +68,11 @@ import { ApiHttpError } from "../http/api-error";
63
68
  import { requireConnectOwnerAuthority } from "./connect-authority";
64
69
  import {
65
70
  DEFAULT_OAUTH_PROFILE,
71
+ OFFICIAL_GMAIL_MCP_URL,
66
72
  DEPLOYMENT_MANAGED_CLIENTS,
67
73
  builtInOAuthProfileByKey,
68
74
  assertAuthorizationServerNotReserved,
69
75
  assertAuthorizationServerPins,
70
- assertOwnershipAllowed,
71
76
  builtInOAuthProfileFor,
72
77
  catalogMcpUrlKey,
73
78
  defaultOwnershipFor,
@@ -95,6 +100,8 @@ type OAuthClientDeps = {
95
100
  };
96
101
 
97
102
  export type OAuthStartContext = {
103
+ /** Server-selected dedicated adapter identity; never copied from request metadata. */
104
+ integrationKey?: "gmail" | "slack-personal";
98
105
  connectAttemptId?: string;
99
106
  externalContinuation?: ExternalActorContinuation;
100
107
  accountId: string;
@@ -129,6 +136,7 @@ type OAuthClientRegistration = {
129
136
  };
130
137
 
131
138
  type OAuthStatePayload = {
139
+ integrationKey?: "gmail" | "slack-personal";
132
140
  connectAttemptId?: string;
133
141
  externalContinuation?: ExternalActorContinuation;
134
142
  returnUrl?: string;
@@ -312,10 +320,90 @@ class OAuthCallbackDeadline {
312
320
  }
313
321
  }
314
322
 
323
+ /** Policy is the outermost lock. Receipt replay still runs normal authorization,
324
+ * but the DB calls authorizeAcquisition only for uncommitted progress. Provider
325
+ * requests must happen after this transaction, never inside either wrapper. */
326
+ export async function claimOAuthAcquisition(
327
+ db: Database,
328
+ scope: Parameters<typeof claimConnectOperation>[1],
329
+ input: Parameters<typeof claimConnectOperation>[2],
330
+ integrationKey: string | null,
331
+ acquiring = true,
332
+ ) {
333
+ if (!acquiring) return claimConnectOperation(db, scope, input);
334
+ return withOrganizationIntegrationPolicyFence(db, scope, (tx, policy) =>
335
+ claimConnectOperation(tx, scope, {
336
+ ...input,
337
+ authorizeAcquisition: async (locked, attempt) => {
338
+ assertOrganizationIntegrationAllowed(policy, integrationKey);
339
+ await input.authorizeAcquisition?.(locked, attempt);
340
+ },
341
+ }),
342
+ );
343
+ }
344
+
345
+ type IntegrationSourceSelection = Record<string, unknown> & {
346
+ id: string;
347
+ syncEnabled: boolean;
348
+ readPolicy: "allow" | "ask" | "block";
349
+ };
350
+
351
+ /** Compare server-bound selections, not provider classification. Removing a
352
+ * source, disabling sync, or narrowing its read permission acquires no authority.
353
+ * Generation/timestamp bookkeeping does not turn an unchanged save into setup. */
354
+ export function integrationSourceSelectionRequiresAcquisition(
355
+ previous: readonly IntegrationSourceSelection[],
356
+ requested: readonly IntegrationSourceSelection[],
357
+ ): boolean {
358
+ const rank = { block: 0, ask: 1, allow: 2 };
359
+ const config = (source: IntegrationSourceSelection) => {
360
+ const {
361
+ selectedAt: _selectedAt,
362
+ configGeneration: _generation,
363
+ syncEnabled: _enabled,
364
+ readPolicy: _readPolicy,
365
+ ...binding
366
+ } = source;
367
+ return stableJson(binding);
368
+ };
369
+ return requested.some((source) => {
370
+ const existing = previous.find((candidate) => candidate.id === source.id);
371
+ return (
372
+ !existing ||
373
+ (!existing.syncEnabled && source.syncEnabled) ||
374
+ rank[source.readPolicy] > rank[existing.readPolicy] ||
375
+ config(existing) !== config(source)
376
+ );
377
+ });
378
+ }
379
+
380
+ export async function finishOAuthAcquisition(
381
+ db: Database,
382
+ scope: Parameters<typeof finishConnectOperation>[1],
383
+ input: Parameters<typeof finishConnectOperation>[2],
384
+ integrationKey: string | null,
385
+ ) {
386
+ return withOrganizationIntegrationPolicyFence(db, scope, (tx, policy) =>
387
+ finishConnectOperation(tx, scope, {
388
+ ...input,
389
+ authorizeAcquisition: async (locked, attempt) => {
390
+ assertOrganizationIntegrationAllowed(policy, integrationKey);
391
+ await input.authorizeAcquisition?.(locked, attempt);
392
+ },
393
+ }),
394
+ );
395
+ }
396
+
315
397
  export async function startMcpOAuth(
316
398
  deps: OAuthClientDeps,
317
399
  context: OAuthStartContext,
318
400
  ): Promise<OAuthStartResponse> {
401
+ await withOrganizationIntegrationAcquisition(
402
+ deps.db,
403
+ context,
404
+ [context.integrationKey ?? "custom:mcp"],
405
+ async () => {},
406
+ );
319
407
  const deadline = new OAuthStartDeadline(deps.oauthStartDeadlineMs ?? OAUTH_START_DEADLINE_MS);
320
408
  try {
321
409
  return await startMcpOAuthWithinDeadline(deps, context, deadline);
@@ -375,12 +463,9 @@ async function startMcpOAuthWithinDeadline(
375
463
  })) ??
376
464
  DEFAULT_OAUTH_PROFILE;
377
465
  assertOAuthStartProfile(settings, context.payload, mcpUrl, profile);
378
- // Personal-only resources default an omitted ownership to personal, matching
379
- // the fence that existed before #1240; an explicit non-personal value is the
380
- // only thing rejected. Everything else defaults to workspace as before.
466
+ // Catalog defaults are setup preferences; an explicit ownership always wins.
381
467
  const requestedOwnership: ConnectionOwnership =
382
468
  context.payload.ownership ?? defaultOwnershipFor(profile);
383
- assertOwnershipAllowed(profile, requestedOwnership);
384
469
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
385
470
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
386
471
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -404,15 +489,11 @@ async function startMcpOAuthWithinDeadline(
404
489
  const ownership = existing
405
490
  ? ownershipForConnection(existing.subjectId, context.subjectId)
406
491
  : requestedOwnership;
407
- // A reconnect of a legacy row must not renew an ownership the profile no
408
- // longer allows (e.g. shared authority over one human's hosted-Slack grant).
409
- assertOwnershipAllowed(profile, ownership);
410
492
  // Only a managed human can own a personal Connection: personal-authority
411
493
  // execution resolves through a delegation snapshot frozen on a human's causal
412
494
  // turn, and migration 0256 can mint the `user` authority scope only for a
413
- // subject that holds an active organization membership. For a personal-only
414
- // profile (Gmail, hosted Slack MCP) this refuses the whole flow rather than
415
- // silently downgrading to the workspace ownership those profiles forbid.
495
+ // subject that holds an active organization membership. An explicit personal
496
+ // choice is never silently downgraded to workspace ownership.
416
497
  assertConnectionOwnershipAllowedForPrincipal(ownership, context.personalOwnershipAllowed);
417
498
 
418
499
  const discovery = await discoverMcpOAuth(mcpUrl, settings, deadline);
@@ -439,10 +520,12 @@ async function startMcpOAuthWithinDeadline(
439
520
  context.payload.oauthClient,
440
521
  profile,
441
522
  signal,
523
+ context,
442
524
  ),
443
525
  );
444
526
  const key = requireEnvironmentEncryption(settings);
445
527
  const state = createSignedState(requireIntegrationsStateSecret(settings), {
528
+ ...(context.integrationKey ? { integrationKey: context.integrationKey } : {}),
446
529
  ...(context.connectAttemptId ? { connectAttemptId: context.connectAttemptId } : {}),
447
530
  accountId: context.accountId,
448
531
  workspaceId: context.workspaceId,
@@ -510,32 +593,6 @@ async function startMcpOAuthWithinDeadline(
510
593
  });
511
594
  }
512
595
 
513
- /** Ownership fence for the official Gmail profile, keyed by exact MCP URL. */
514
- export function assertOfficialGmailPersonalOwnership(
515
- mcpUrl: string,
516
- ownership: ConnectionOwnership,
517
- ): void {
518
- const profile = builtInOAuthProfileFor({ mcpUrl });
519
- if (profile?.key === "official-gmail") {
520
- assertOwnershipAllowed(profile, ownership);
521
- }
522
- }
523
-
524
- /**
525
- * Slack's hosted MCP issues user tokens only. Sharing one human's grant as
526
- * workspace authority made every shared agent act as a named employee; the
527
- * OpenGeni workspace bot owns shared Slack access instead (bot-token search
528
- * covers public channels, files, and users).
529
- */
530
- export function assertHostedSlackMcpPersonalOwnership(
531
- hostedSlackMcp: boolean,
532
- ownership: ConnectionOwnership,
533
- ): void {
534
- if (hostedSlackMcp) {
535
- assertOwnershipAllowed(builtInOAuthProfileByKey("hosted-slack-mcp"), ownership);
536
- }
537
- }
538
-
539
596
  export function isHostedSlackMcpTarget(providerDomain: string, mcpUrl: string): boolean {
540
597
  return builtInOAuthProfileFor({ mcpUrl, providerDomain })?.key === "hosted-slack-mcp";
541
598
  }
@@ -627,6 +684,7 @@ async function completeMcpOAuthCallbackWithinDeadline(
627
684
  ): Promise<OAuthCallbackResult> {
628
685
  const { db, settings, observability } = deps;
629
686
  let state: OAuthStatePayload | null = null;
687
+ let integrationKey: string | null = "custom:mcp";
630
688
  let connectOperation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
631
689
  if (!input.state) {
632
690
  const error = new OAuthCallbackStageError(
@@ -644,10 +702,21 @@ async function completeMcpOAuthCallbackWithinDeadline(
644
702
  }
645
703
  try {
646
704
  state = readOAuthState(input.state, settings);
705
+ integrationKey = state.integrationKey ?? "custom:mcp";
647
706
  if (state.connectAttemptId) {
648
707
  const stored = await getConnectAttempt(db, state, state.connectAttemptId);
708
+ // Native Gmail setup uses its own provider id, but shares this callback.
709
+ // Bind that id to the exact reviewed Gmail destination before
710
+ // claiming an operation (including receipt replay), not to arbitrary MCP.
711
+ const gmailAttemptMatches =
712
+ stored.attempt.providerId === "gmail" &&
713
+ state.mcpUrl === OFFICIAL_GMAIL_MCP_URL &&
714
+ state.providerDomain === "gmailmcp.googleapis.com" &&
715
+ builtInOAuthProfileFor(state)?.key === "official-gmail" &&
716
+ personalOwnerStateAccepted(state);
649
717
  if (
650
- !["mcp-oauth", "slack-personal"].includes(stored.attempt.providerId) ||
718
+ (!["mcp-oauth", "slack-personal"].includes(stored.attempt.providerId) &&
719
+ !gmailAttemptMatches) ||
651
720
  stored.attempt.ownership !== state.ownership ||
652
721
  stored.returnUrl !== state.returnUrl
653
722
  )
@@ -657,29 +726,23 @@ async function completeMcpOAuthCallbackWithinDeadline(
657
726
  operationId: `oauth:${state.nonce}`,
658
727
  inputDigest: createHash("sha256").update(input.state).digest("hex"),
659
728
  };
660
- const claim = await claimConnectOperation(db, state, {
661
- ...connectOperation,
662
- expectedRevision: stored.attempt.revision,
663
- authorize: (tx, _attempt, origin) =>
664
- requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
665
- });
729
+ integrationKey = integrationKeyForConnectProvider(stored.attempt.providerId);
730
+ const claim = await claimOAuthAcquisition(
731
+ db,
732
+ state,
733
+ {
734
+ ...connectOperation,
735
+ expectedRevision: stored.attempt.revision,
736
+ authorize: (tx, _attempt, origin) =>
737
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
738
+ },
739
+ integrationKey,
740
+ Boolean(input.code),
741
+ );
666
742
  if (claim.status === "replayed") return { redirectTo: stored.returnUrl, exactReturn: true };
667
743
  }
668
- // Fence state minted by an older deployment too: a rolling update must not
669
- // let a still-valid callback persist an ownership the target's profile no
670
- // longer allows (workspace-owned Gmail or hosted Slack, for example).
671
- const builtInCallbackProfile = builtInOAuthProfileFor({
672
- mcpUrl: state.mcpUrl,
673
- providerDomain: state.providerDomain,
674
- });
675
- if (builtInCallbackProfile) {
676
- assertOwnershipAllowed(builtInCallbackProfile, state.ownership);
677
- }
678
- // Only a managed human may own a personal Connection. This callback has no
679
- // live principal, so it enforces the signed start-time decision; a state
680
- // minted before that fence existed carries no claim and is refused, which
681
- // closes the in-flight window across a rolling deploy. The legacy
682
- // `?? "personal"` decode above therefore cannot land a machine-owned row.
744
+ // The callback enforces the signed start-time principal decision.
745
+ // Workspace ownership requires no personal-owner claim.
683
746
  if (!personalOwnerStateAccepted(state)) {
684
747
  throw new OAuthCallbackStageError(
685
748
  "state_verify",
@@ -711,24 +774,13 @@ async function completeMcpOAuthCallbackWithinDeadline(
711
774
  reason: "missing_code",
712
775
  });
713
776
  }
777
+ await withOrganizationIntegrationAcquisition(db, state, [integrationKey], async () => {});
714
778
  const consumed = await runCallbackDatabaseStage(
715
779
  deadline,
716
780
  "state_verify",
717
781
  db,
718
782
  async (scopedDb) => {
719
783
  await requireOAuthCallbackGrant(scopedDb, state!);
720
- if (!builtInCallbackProfile) {
721
- // Catalog-declared ownership fences get the same rolling-update
722
- // treatment as the built-in ones.
723
- const raw = await getGlobalCatalogOAuthProfile(
724
- scopedDb,
725
- state!.workspaceId,
726
- catalogMcpUrlKey(state!.mcpUrl),
727
- );
728
- if (raw !== null) {
729
- assertOwnershipAllowed(oauthProfileFromCatalog(state!.mcpUrl, raw), state!.ownership);
730
- }
731
- }
732
784
  return await consumeIntegrationOAuthStateNonce(scopedDb, {
733
785
  accountId: state!.accountId,
734
786
  workspaceId: state!.workspaceId,
@@ -843,38 +895,45 @@ async function completeMcpOAuthCallbackWithinDeadline(
843
895
  };
844
896
  if (connectOperation) {
845
897
  await runCallbackDatabaseStage(deadline, "persist", db, (scopedDb) =>
846
- finishConnectOperation(scopedDb, state!, {
847
- ...connectOperation!,
848
- authorize: (tx, _attempt, origin) =>
849
- requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
850
- commit: async (tx, current) => {
851
- const connection = await persist(tx);
852
- if (!connection)
853
- throw new HTTPException(409, {
854
- message: "connection changed during OAuth reconnect",
855
- });
856
- return {
857
- ...current,
858
- revision: current.revision + 1,
859
- state: "complete",
860
- completionRequirement: "connection",
861
- credentialsCommitted: true,
862
- nextAction: { type: "none" },
863
- account: {
864
- id: connection.id,
865
- version: connection.version,
866
- providerId: current.providerId,
867
- label: state!.providerDomain,
868
- ownership: current.ownership,
869
- status: "connected",
870
- },
871
- };
898
+ finishOAuthAcquisition(
899
+ scopedDb,
900
+ state!,
901
+ {
902
+ ...connectOperation!,
903
+ authorize: (tx, _attempt, origin) =>
904
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
905
+ commit: async (tx, current) => {
906
+ const connection = await persist(tx);
907
+ if (!connection)
908
+ throw new HTTPException(409, {
909
+ message: "connection changed during OAuth reconnect",
910
+ });
911
+ return {
912
+ ...current,
913
+ revision: current.revision + 1,
914
+ state: "complete",
915
+ completionRequirement: "connection",
916
+ credentialsCommitted: true,
917
+ nextAction: { type: "none" },
918
+ account: {
919
+ id: connection.id,
920
+ version: connection.version,
921
+ providerId: current.providerId,
922
+ label: state!.providerDomain,
923
+ ownership: current.ownership,
924
+ status: "connected",
925
+ },
926
+ };
927
+ },
872
928
  },
873
- }),
929
+ integrationKey,
930
+ ),
874
931
  );
875
932
  return { redirectTo: state.returnUrl!, exactReturn: true };
876
933
  }
877
- const connection = await runCallbackDatabaseStage(deadline, "persist", db, persist);
934
+ const connection = await runCallbackDatabaseStage(deadline, "persist", db, (scopedDb) =>
935
+ withOrganizationIntegrationAcquisition(scopedDb, state!, [integrationKey], persist),
936
+ );
878
937
  if (!connection) {
879
938
  throw new HTTPException(409, {
880
939
  message: "connection changed during OAuth reconnect; start again",
@@ -1206,6 +1265,7 @@ async function registerOAuthClient(
1206
1265
  manual: OAuthStartRequest["oauthClient"],
1207
1266
  profile: OAuthProviderProfile,
1208
1267
  signal: AbortSignal,
1268
+ acquisition: Pick<OAuthStartContext, "accountId" | "workspaceId" | "integrationKey">,
1209
1269
  ): Promise<OAuthClientRegistration> {
1210
1270
  const operator = operatorClientForAs(settings, as);
1211
1271
  if (operator) {
@@ -1220,6 +1280,7 @@ async function registerOAuthClient(
1220
1280
  redirectUri,
1221
1281
  scopes,
1222
1282
  signal,
1283
+ acquisition,
1223
1284
  );
1224
1285
  }
1225
1286
  if (selfRegistration === "cimd") {
@@ -1244,7 +1305,15 @@ async function registerOAuthClient(
1244
1305
  ),
1245
1306
  };
1246
1307
  }
1247
- return await getOrCreateDynamicClientRegistration(db, settings, as, redirectUri, scopes, signal);
1308
+ return await getOrCreateDynamicClientRegistration(
1309
+ db,
1310
+ settings,
1311
+ as,
1312
+ redirectUri,
1313
+ scopes,
1314
+ signal,
1315
+ acquisition,
1316
+ );
1248
1317
  }
1249
1318
 
1250
1319
  export function preferredOAuthSelfRegistration(
@@ -1272,6 +1341,7 @@ async function getOrCreateDynamicClientRegistration(
1272
1341
  redirectUri: string,
1273
1342
  scopes: string[],
1274
1343
  signal: AbortSignal,
1344
+ acquisition: Pick<OAuthStartContext, "accountId" | "workspaceId" | "integrationKey">,
1275
1345
  ): Promise<OAuthClientRegistration> {
1276
1346
  const storedClient = await loadIntegrationOAuthClient(db, settings, as.issuer);
1277
1347
  if (storedClient && storedDcrClientSatisfiesPolicy(storedClient, as, redirectUri, scopes)) {
@@ -1292,6 +1362,12 @@ async function getOrCreateDynamicClientRegistration(
1292
1362
  message: "manual OAuth client credentials are required for this authorization server",
1293
1363
  });
1294
1364
  }
1365
+ await withOrganizationIntegrationAcquisition(
1366
+ db,
1367
+ acquisition,
1368
+ [acquisition.integrationKey ?? "custom:mcp"],
1369
+ async () => {},
1370
+ );
1295
1371
  const dcr = await dynamicClientRegistration(settings, as, redirectUri, scopes, signal);
1296
1372
  const key = dcr.clientSecret ? requireEnvironmentEncryption(settings) : null;
1297
1373
  const storeInput = {
@@ -1303,34 +1379,43 @@ async function getOrCreateDynamicClientRegistration(
1303
1379
  tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod,
1304
1380
  metadata: registrationMetadata(as, redirectUri, scopes),
1305
1381
  };
1306
- if (storedClient) {
1307
- const replaced = await replaceIntegrationOAuthClientIfCurrent(db, {
1308
- ...storeInput,
1309
- expectedClientId: storedClient.clientId,
1310
- });
1311
- if (replaced?.clientId === dcr.clientId) {
1312
- return dcr;
1313
- }
1314
- return await loadCompatibleDcrWinner(db, settings, as, redirectUri, scopes);
1315
- }
1316
- const storedWinner = await storeIntegrationOAuthClient(db, storeInput);
1317
- if (storedWinner.clientId === dcr.clientId) {
1318
- return dcr;
1319
- }
1320
- const winner = await loadIntegrationOAuthClient(db, settings, as.issuer);
1321
- if (winner && storedDcrClientSatisfiesPolicy(winner, as, redirectUri, scopes)) {
1322
- return dcrRegistrationFromStored(winner);
1323
- }
1324
- if (winner) {
1325
- const replaced = await replaceIntegrationOAuthClientIfCurrent(db, {
1326
- ...storeInput,
1327
- expectedClientId: winner.clientId,
1328
- });
1329
- if (replaced?.clientId === dcr.clientId) {
1330
- return dcr;
1331
- }
1332
- }
1333
- return await loadCompatibleDcrWinner(db, settings, as, redirectUri, scopes);
1382
+ // Registration has already happened outside this transaction. Fence only the
1383
+ // durable client credential; a restriction racing DCR must not persist it.
1384
+ return withOrganizationIntegrationAcquisition(
1385
+ db,
1386
+ acquisition,
1387
+ [acquisition.integrationKey ?? "custom:mcp"],
1388
+ async (tx) => {
1389
+ if (storedClient) {
1390
+ const replaced = await replaceIntegrationOAuthClientIfCurrent(tx, {
1391
+ ...storeInput,
1392
+ expectedClientId: storedClient.clientId,
1393
+ });
1394
+ if (replaced?.clientId === dcr.clientId) {
1395
+ return dcr;
1396
+ }
1397
+ return await loadCompatibleDcrWinner(tx, settings, as, redirectUri, scopes);
1398
+ }
1399
+ const storedWinner = await storeIntegrationOAuthClient(tx, storeInput);
1400
+ if (storedWinner.clientId === dcr.clientId) {
1401
+ return dcr;
1402
+ }
1403
+ const winner = await loadIntegrationOAuthClient(tx, settings, as.issuer);
1404
+ if (winner && storedDcrClientSatisfiesPolicy(winner, as, redirectUri, scopes)) {
1405
+ return dcrRegistrationFromStored(winner);
1406
+ }
1407
+ if (winner) {
1408
+ const replaced = await replaceIntegrationOAuthClientIfCurrent(tx, {
1409
+ ...storeInput,
1410
+ expectedClientId: winner.clientId,
1411
+ });
1412
+ if (replaced?.clientId === dcr.clientId) {
1413
+ return dcr;
1414
+ }
1415
+ }
1416
+ return await loadCompatibleDcrWinner(tx, settings, as, redirectUri, scopes);
1417
+ },
1418
+ );
1334
1419
  }
1335
1420
 
1336
1421
  function storedDcrClientSatisfiesPolicy(
@@ -1641,6 +1726,13 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
1641
1726
  if (!payload) {
1642
1727
  throw new HTTPException(400, { message: "invalid or expired OAuth state" });
1643
1728
  }
1729
+ if (
1730
+ payload.integrationKey !== undefined &&
1731
+ payload.integrationKey !== "gmail" &&
1732
+ payload.integrationKey !== "slack-personal"
1733
+ ) {
1734
+ throw new HTTPException(400, { message: "invalid OAuth integration identity" });
1735
+ }
1644
1736
  const nowSeconds = Math.floor(Date.now() / 1000);
1645
1737
  const iat = numberValue(payload.iat);
1646
1738
  if (iat === undefined || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) {
@@ -1670,6 +1762,12 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
1670
1762
  if (discoveryMode === "legacy_2025_03_26_metadata" && resourceParameterSupported) {
1671
1763
  throw new HTTPException(400, { message: "invalid legacy OAuth resource parameter state" });
1672
1764
  }
1765
+ const ownership = connectionOwnership(payload.ownership);
1766
+ if (!ownership) {
1767
+ throw new HTTPException(400, {
1768
+ message: "missing or invalid OAuth ownership; restart connection setup",
1769
+ });
1770
+ }
1673
1771
  const parsed = {
1674
1772
  accountId: requiredString(payload.accountId, "state.accountId"),
1675
1773
  workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
@@ -1692,11 +1790,12 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
1692
1790
  ...(stringValue(payload.connectAttemptId)
1693
1791
  ? { connectAttemptId: requiredString(payload.connectAttemptId, "state.connectAttemptId") }
1694
1792
  : {}),
1695
- // OAuth states minted before ownership was explicit were always personal.
1696
- // Preserve that meaning for in-flight reconnects during a rolling deploy.
1697
- ownership: connectionOwnership(payload.ownership) ?? "personal",
1793
+ ownership,
1698
1794
  // Absent on a legacy state, which therefore cannot land a personal owner.
1699
1795
  personalOwnerVerified: personalOwnerVerifiedInState(payload),
1796
+ ...(payload.integrationKey
1797
+ ? { integrationKey: payload.integrationKey as "gmail" | "slack-personal" }
1798
+ : {}),
1700
1799
  providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
1701
1800
  mcpUrl: stringValue(payload.mcpUrl) ?? resource,
1702
1801
  resource,
@@ -16,10 +16,9 @@ import { canonicalProviderDomain } from "./provider-domain";
16
16
  *
17
17
  * Profiles come from two layers:
18
18
  *
19
- * 1. Built-in profiles below, for the providers whose fences are security
20
- * invariants (hosted Slack MCP and official Gmail are personal-only, their
21
- * authorization servers are origin-pinned). These live in code as data so
22
- * the fences never depend on catalog import state.
19
+ * 1. Built-in profiles pin known authorization servers and protocol quirks.
20
+ * Ownership defaults guide setup; users may choose personal or workspace.
21
+ * Authorization-server pins never depend on catalog import state.
23
22
  * 2. A validated `oauthProfile` object on a global catalog row (curated
24
23
  * overlay -> importer -> `capability_catalog_items.metadata`). A catalog
25
24
  * profile applies only when no built-in matches, and it can only narrow the
@@ -81,10 +80,8 @@ export type OAuthProviderProfile = {
81
80
  requireExactMcpUrl?: { url: string; message: string };
82
81
  /** Deployment-managed client credentials must be configured (503 otherwise). */
83
82
  requireDeploymentClient?: { key: DeploymentManagedClientKey; message: string };
84
- /** Ownerships a connection may take; a singleton also sets the default. */
85
- allowedOwnership: readonly ConnectionOwnership[];
86
- /** Exact 422 message when an explicit disallowed ownership is requested. */
87
- ownershipMessage?: string;
83
+ /** Suggested ownership; an explicit user choice always wins. */
84
+ defaultOwnership?: ConnectionOwnership;
88
85
  /** Bind reconnect/dedupe to the exact mcpUrl, not just the provider domain. */
89
86
  exactMcpBinding: boolean;
90
87
  /** How an existing connection is chosen for reconnect coalescing. */
@@ -113,7 +110,6 @@ export type OAuthProviderProfile = {
113
110
  export const DEFAULT_OAUTH_PROFILE: OAuthProviderProfile = {
114
111
  key: "default",
115
112
  match: {},
116
- allowedOwnership: ["workspace", "personal"],
117
113
  exactMcpBinding: false,
118
114
  connectionSelection: "first_active",
119
115
  sendResourceParameter: true,
@@ -138,9 +134,6 @@ const HOSTED_SLACK_PROFILE: OAuthProviderProfile = {
138
134
  key: "slack",
139
135
  message: "Slack MCP OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
140
136
  },
141
- allowedOwnership: ["personal"],
142
- ownershipMessage:
143
- "Slack's hosted MCP connection is personal only; install the OpenGeni Slack bot for workspace access",
144
137
  exactMcpBinding: true,
145
138
  connectionSelection: "canonical_personal",
146
139
  authorizationServer: {
@@ -156,9 +149,7 @@ const HOSTED_SLACK_PROFILE: OAuthProviderProfile = {
156
149
  const OFFICIAL_GMAIL_PROFILE: OAuthProviderProfile = {
157
150
  key: "official-gmail",
158
151
  match: { mcpUrls: [OFFICIAL_GMAIL_MCP_URL] },
159
- allowedOwnership: ["personal"],
160
- ownershipMessage:
161
- "Gmail connections are personal only; each workspace member must connect their own mailbox",
152
+ defaultOwnership: "personal",
162
153
  exactMcpBinding: true,
163
154
  connectionSelection: "first_active",
164
155
  authorizationServer: {
@@ -285,24 +276,7 @@ export function builtInOAuthProfileFor(input: {
285
276
 
286
277
  /** Ownership an omitted request defaults to under a profile. */
287
278
  export function defaultOwnershipFor(profile: OAuthProviderProfile): ConnectionOwnership {
288
- return profile.allowedOwnership.length === 1 && profile.allowedOwnership[0] === "personal"
289
- ? "personal"
290
- : "workspace";
291
- }
292
-
293
- /** Rejects an ownership outside the profile's allowed set with its exact message. */
294
- export function assertOwnershipAllowed(
295
- profile: OAuthProviderProfile,
296
- ownership: ConnectionOwnership,
297
- ): void {
298
- if (profile.allowedOwnership.includes(ownership)) {
299
- return;
300
- }
301
- throw new HTTPException(422, {
302
- message:
303
- profile.ownershipMessage ??
304
- `this integration allows only ${profile.allowedOwnership.join(" or ")} connections`,
305
- });
279
+ return profile.defaultOwnership ?? "workspace";
306
280
  }
307
281
 
308
282
  /** Enforces a profile's authorization-server origin pins with its exact message. */
@@ -375,10 +349,7 @@ export const catalogOAuthProfileSchema = z
375
349
  pinnedIssuerOrigins: z.array(z.string().url()).min(1).optional(),
376
350
  pinnedEndpointOrigins: z.array(z.string().url()).min(1).optional(),
377
351
  sendResourceParameter: z.boolean().optional(),
378
- allowedOwnership: z
379
- .array(z.enum(["personal", "workspace"]))
380
- .min(1)
381
- .optional(),
352
+ defaultOwnership: z.enum(["personal", "workspace"]).optional(),
382
353
  requestedScopes: z.array(z.string().min(1)).min(1).optional(),
383
354
  extraAuthorizeParams: z
384
355
  .record(z.string(), z.string())
@@ -425,7 +396,7 @@ export function catalogMcpUrlKey(value: string): string {
425
396
  *
426
397
  * A present-but-invalid profile fails closed with a 422: the row's operator
427
398
  * declared constraints, and silently degrading to the default profile would
428
- * drop an ownership fence or origin pin on a JSON typo.
399
+ * drop an origin pin on a JSON typo.
429
400
  */
430
401
  export function oauthProfileFromCatalog(mcpUrl: string, raw: unknown): OAuthProviderProfile {
431
402
  const parsed = catalogOAuthProfileSchema.safeParse(raw);
@@ -451,13 +422,7 @@ export function oauthProfileFromCatalog(mcpUrl: string, raw: unknown): OAuthProv
451
422
  return {
452
423
  key: `catalog:${mcpUrl}`,
453
424
  match: { mcpUrls: [mcpUrl] },
454
- allowedOwnership: data.allowedOwnership ?? DEFAULT_OAUTH_PROFILE.allowedOwnership,
455
- ...(data.allowedOwnership && !data.allowedOwnership.includes("workspace")
456
- ? {
457
- ownershipMessage:
458
- "this integration allows only personal connections; each workspace member must connect their own account",
459
- }
460
- : {}),
425
+ ...(data.defaultOwnership ? { defaultOwnership: data.defaultOwnership } : {}),
461
426
  exactMcpBinding: Boolean(data.exactMcpUrl),
462
427
  ...(data.exactMcpUrl
463
428
  ? {