@opengeni/api-router 3.0.0-canary.0 → 3.0.1-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 (39) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-7IAWEYBH.js → chunk-XZGTKYOA.js} +3626 -2525
  3. package/dist/{chunk-7IAWEYBH.js.map → chunk-XZGTKYOA.js.map} +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/integrations/fiken.d.ts +3 -1
  6. package/dist/integrations/github-lens-connect.d.ts +2 -2
  7. package/dist/integrations/integration-commit-authority.d.ts +13 -0
  8. package/dist/integrations/oauth-client.d.ts +118 -1
  9. package/dist/integrations/slack-interactions.d.ts +1 -1
  10. package/dist/mcp/first-party-tool-permissions.d.ts +4 -0
  11. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  12. package/dist/routes/api-integrations.d.ts +1 -0
  13. package/dist/routes/organization-integration-policy.d.ts +3 -0
  14. package/package.json +19 -19
  15. package/src/app.ts +7 -2
  16. package/src/integrations/atlassian.ts +135 -65
  17. package/src/integrations/fiken.ts +57 -35
  18. package/src/integrations/github-app-connect.ts +34 -15
  19. package/src/integrations/google-drive.ts +295 -124
  20. package/src/integrations/integration-commit-authority.ts +69 -0
  21. package/src/integrations/oauth-client.ts +220 -65
  22. package/src/integrations/personal-github.ts +55 -31
  23. package/src/integrations/provider-oauth.ts +54 -31
  24. package/src/integrations/social-oauth.ts +83 -56
  25. package/src/mcp/first-party-tool-permissions.ts +1 -0
  26. package/src/mcp/knowledge-entries.ts +23 -6
  27. package/src/routes/api-integrations.ts +116 -4
  28. package/src/routes/billing.ts +52 -0
  29. package/src/routes/capabilities.ts +51 -1
  30. package/src/routes/connect.ts +161 -57
  31. package/src/routes/connections.ts +430 -305
  32. package/src/routes/github.ts +48 -19
  33. package/src/routes/integration-facets.ts +66 -25
  34. package/src/routes/knowledge.ts +48 -1
  35. package/src/routes/organization-integration-policy.ts +113 -0
  36. package/src/routes/plugins.ts +12 -2
  37. package/src/routes/pr-review-github.ts +60 -30
  38. package/src/routes/pr-review.ts +69 -45
  39. package/src/routes/social.ts +24 -13
@@ -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,6 +68,7 @@ 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,
@@ -95,6 +101,8 @@ type OAuthClientDeps = {
95
101
  };
96
102
 
97
103
  export type OAuthStartContext = {
104
+ /** Server-selected dedicated adapter identity; never copied from request metadata. */
105
+ integrationKey?: "gmail" | "slack-personal";
98
106
  connectAttemptId?: string;
99
107
  externalContinuation?: ExternalActorContinuation;
100
108
  accountId: string;
@@ -129,6 +137,7 @@ type OAuthClientRegistration = {
129
137
  };
130
138
 
131
139
  type OAuthStatePayload = {
140
+ integrationKey?: "gmail" | "slack-personal";
132
141
  connectAttemptId?: string;
133
142
  externalContinuation?: ExternalActorContinuation;
134
143
  returnUrl?: string;
@@ -312,10 +321,90 @@ class OAuthCallbackDeadline {
312
321
  }
313
322
  }
314
323
 
324
+ /** Policy is the outermost lock. Receipt replay still runs normal authorization,
325
+ * but the DB calls authorizeAcquisition only for uncommitted progress. Provider
326
+ * requests must happen after this transaction, never inside either wrapper. */
327
+ export async function claimOAuthAcquisition(
328
+ db: Database,
329
+ scope: Parameters<typeof claimConnectOperation>[1],
330
+ input: Parameters<typeof claimConnectOperation>[2],
331
+ integrationKey: string | null,
332
+ acquiring = true,
333
+ ) {
334
+ if (!acquiring) return claimConnectOperation(db, scope, input);
335
+ return withOrganizationIntegrationPolicyFence(db, scope, (tx, policy) =>
336
+ claimConnectOperation(tx, scope, {
337
+ ...input,
338
+ authorizeAcquisition: async (locked, attempt) => {
339
+ assertOrganizationIntegrationAllowed(policy, integrationKey);
340
+ await input.authorizeAcquisition?.(locked, attempt);
341
+ },
342
+ }),
343
+ );
344
+ }
345
+
346
+ type IntegrationSourceSelection = Record<string, unknown> & {
347
+ id: string;
348
+ syncEnabled: boolean;
349
+ readPolicy: "allow" | "ask" | "block";
350
+ };
351
+
352
+ /** Compare server-bound selections, not provider classification. Removing a
353
+ * source, disabling sync, or narrowing its read permission acquires no authority.
354
+ * Generation/timestamp bookkeeping does not turn an unchanged save into setup. */
355
+ export function integrationSourceSelectionRequiresAcquisition(
356
+ previous: readonly IntegrationSourceSelection[],
357
+ requested: readonly IntegrationSourceSelection[],
358
+ ): boolean {
359
+ const rank = { block: 0, ask: 1, allow: 2 };
360
+ const config = (source: IntegrationSourceSelection) => {
361
+ const {
362
+ selectedAt: _selectedAt,
363
+ configGeneration: _generation,
364
+ syncEnabled: _enabled,
365
+ readPolicy: _readPolicy,
366
+ ...binding
367
+ } = source;
368
+ return stableJson(binding);
369
+ };
370
+ return requested.some((source) => {
371
+ const existing = previous.find((candidate) => candidate.id === source.id);
372
+ return (
373
+ !existing ||
374
+ (!existing.syncEnabled && source.syncEnabled) ||
375
+ rank[source.readPolicy] > rank[existing.readPolicy] ||
376
+ config(existing) !== config(source)
377
+ );
378
+ });
379
+ }
380
+
381
+ export async function finishOAuthAcquisition(
382
+ db: Database,
383
+ scope: Parameters<typeof finishConnectOperation>[1],
384
+ input: Parameters<typeof finishConnectOperation>[2],
385
+ integrationKey: string | null,
386
+ ) {
387
+ return withOrganizationIntegrationPolicyFence(db, scope, (tx, policy) =>
388
+ finishConnectOperation(tx, scope, {
389
+ ...input,
390
+ authorizeAcquisition: async (locked, attempt) => {
391
+ assertOrganizationIntegrationAllowed(policy, integrationKey);
392
+ await input.authorizeAcquisition?.(locked, attempt);
393
+ },
394
+ }),
395
+ );
396
+ }
397
+
315
398
  export async function startMcpOAuth(
316
399
  deps: OAuthClientDeps,
317
400
  context: OAuthStartContext,
318
401
  ): Promise<OAuthStartResponse> {
402
+ await withOrganizationIntegrationAcquisition(
403
+ deps.db,
404
+ context,
405
+ [context.integrationKey ?? "custom:mcp"],
406
+ async () => {},
407
+ );
319
408
  const deadline = new OAuthStartDeadline(deps.oauthStartDeadlineMs ?? OAUTH_START_DEADLINE_MS);
320
409
  try {
321
410
  return await startMcpOAuthWithinDeadline(deps, context, deadline);
@@ -439,10 +528,12 @@ async function startMcpOAuthWithinDeadline(
439
528
  context.payload.oauthClient,
440
529
  profile,
441
530
  signal,
531
+ context,
442
532
  ),
443
533
  );
444
534
  const key = requireEnvironmentEncryption(settings);
445
535
  const state = createSignedState(requireIntegrationsStateSecret(settings), {
536
+ ...(context.integrationKey ? { integrationKey: context.integrationKey } : {}),
446
537
  ...(context.connectAttemptId ? { connectAttemptId: context.connectAttemptId } : {}),
447
538
  accountId: context.accountId,
448
539
  workspaceId: context.workspaceId,
@@ -627,6 +718,7 @@ async function completeMcpOAuthCallbackWithinDeadline(
627
718
  ): Promise<OAuthCallbackResult> {
628
719
  const { db, settings, observability } = deps;
629
720
  let state: OAuthStatePayload | null = null;
721
+ let integrationKey: string | null = "custom:mcp";
630
722
  let connectOperation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
631
723
  if (!input.state) {
632
724
  const error = new OAuthCallbackStageError(
@@ -644,10 +736,22 @@ async function completeMcpOAuthCallbackWithinDeadline(
644
736
  }
645
737
  try {
646
738
  state = readOAuthState(input.state, settings);
739
+ integrationKey = state.integrationKey ?? "custom:mcp";
647
740
  if (state.connectAttemptId) {
648
741
  const stored = await getConnectAttempt(db, state, state.connectAttemptId);
742
+ // Native Gmail setup uses its own provider id, but shares this callback.
743
+ // Bind that id to the exact reviewed personal Gmail destination before
744
+ // claiming an operation (including receipt replay), not to arbitrary MCP.
745
+ const gmailAttemptMatches =
746
+ stored.attempt.providerId === "gmail" &&
747
+ state.mcpUrl === OFFICIAL_GMAIL_MCP_URL &&
748
+ state.providerDomain === "gmailmcp.googleapis.com" &&
749
+ builtInOAuthProfileFor(state)?.key === "official-gmail" &&
750
+ state.ownership === "personal" &&
751
+ personalOwnerStateAccepted(state);
649
752
  if (
650
- !["mcp-oauth", "slack-personal"].includes(stored.attempt.providerId) ||
753
+ (!["mcp-oauth", "slack-personal"].includes(stored.attempt.providerId) &&
754
+ !gmailAttemptMatches) ||
651
755
  stored.attempt.ownership !== state.ownership ||
652
756
  stored.returnUrl !== state.returnUrl
653
757
  )
@@ -657,12 +761,19 @@ async function completeMcpOAuthCallbackWithinDeadline(
657
761
  operationId: `oauth:${state.nonce}`,
658
762
  inputDigest: createHash("sha256").update(input.state).digest("hex"),
659
763
  };
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
- });
764
+ integrationKey = integrationKeyForConnectProvider(stored.attempt.providerId);
765
+ const claim = await claimOAuthAcquisition(
766
+ db,
767
+ state,
768
+ {
769
+ ...connectOperation,
770
+ expectedRevision: stored.attempt.revision,
771
+ authorize: (tx, _attempt, origin) =>
772
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
773
+ },
774
+ integrationKey,
775
+ Boolean(input.code),
776
+ );
666
777
  if (claim.status === "replayed") return { redirectTo: stored.returnUrl, exactReturn: true };
667
778
  }
668
779
  // Fence state minted by an older deployment too: a rolling update must not
@@ -711,6 +822,7 @@ async function completeMcpOAuthCallbackWithinDeadline(
711
822
  reason: "missing_code",
712
823
  });
713
824
  }
825
+ await withOrganizationIntegrationAcquisition(db, state, [integrationKey], async () => {});
714
826
  const consumed = await runCallbackDatabaseStage(
715
827
  deadline,
716
828
  "state_verify",
@@ -843,38 +955,45 @@ async function completeMcpOAuthCallbackWithinDeadline(
843
955
  };
844
956
  if (connectOperation) {
845
957
  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
- };
958
+ finishOAuthAcquisition(
959
+ scopedDb,
960
+ state!,
961
+ {
962
+ ...connectOperation!,
963
+ authorize: (tx, _attempt, origin) =>
964
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
965
+ commit: async (tx, current) => {
966
+ const connection = await persist(tx);
967
+ if (!connection)
968
+ throw new HTTPException(409, {
969
+ message: "connection changed during OAuth reconnect",
970
+ });
971
+ return {
972
+ ...current,
973
+ revision: current.revision + 1,
974
+ state: "complete",
975
+ completionRequirement: "connection",
976
+ credentialsCommitted: true,
977
+ nextAction: { type: "none" },
978
+ account: {
979
+ id: connection.id,
980
+ version: connection.version,
981
+ providerId: current.providerId,
982
+ label: state!.providerDomain,
983
+ ownership: current.ownership,
984
+ status: "connected",
985
+ },
986
+ };
987
+ },
872
988
  },
873
- }),
989
+ integrationKey,
990
+ ),
874
991
  );
875
992
  return { redirectTo: state.returnUrl!, exactReturn: true };
876
993
  }
877
- const connection = await runCallbackDatabaseStage(deadline, "persist", db, persist);
994
+ const connection = await runCallbackDatabaseStage(deadline, "persist", db, (scopedDb) =>
995
+ withOrganizationIntegrationAcquisition(scopedDb, state!, [integrationKey], persist),
996
+ );
878
997
  if (!connection) {
879
998
  throw new HTTPException(409, {
880
999
  message: "connection changed during OAuth reconnect; start again",
@@ -1206,6 +1325,7 @@ async function registerOAuthClient(
1206
1325
  manual: OAuthStartRequest["oauthClient"],
1207
1326
  profile: OAuthProviderProfile,
1208
1327
  signal: AbortSignal,
1328
+ acquisition: Pick<OAuthStartContext, "accountId" | "workspaceId" | "integrationKey">,
1209
1329
  ): Promise<OAuthClientRegistration> {
1210
1330
  const operator = operatorClientForAs(settings, as);
1211
1331
  if (operator) {
@@ -1220,6 +1340,7 @@ async function registerOAuthClient(
1220
1340
  redirectUri,
1221
1341
  scopes,
1222
1342
  signal,
1343
+ acquisition,
1223
1344
  );
1224
1345
  }
1225
1346
  if (selfRegistration === "cimd") {
@@ -1244,7 +1365,15 @@ async function registerOAuthClient(
1244
1365
  ),
1245
1366
  };
1246
1367
  }
1247
- return await getOrCreateDynamicClientRegistration(db, settings, as, redirectUri, scopes, signal);
1368
+ return await getOrCreateDynamicClientRegistration(
1369
+ db,
1370
+ settings,
1371
+ as,
1372
+ redirectUri,
1373
+ scopes,
1374
+ signal,
1375
+ acquisition,
1376
+ );
1248
1377
  }
1249
1378
 
1250
1379
  export function preferredOAuthSelfRegistration(
@@ -1272,6 +1401,7 @@ async function getOrCreateDynamicClientRegistration(
1272
1401
  redirectUri: string,
1273
1402
  scopes: string[],
1274
1403
  signal: AbortSignal,
1404
+ acquisition: Pick<OAuthStartContext, "accountId" | "workspaceId" | "integrationKey">,
1275
1405
  ): Promise<OAuthClientRegistration> {
1276
1406
  const storedClient = await loadIntegrationOAuthClient(db, settings, as.issuer);
1277
1407
  if (storedClient && storedDcrClientSatisfiesPolicy(storedClient, as, redirectUri, scopes)) {
@@ -1292,6 +1422,12 @@ async function getOrCreateDynamicClientRegistration(
1292
1422
  message: "manual OAuth client credentials are required for this authorization server",
1293
1423
  });
1294
1424
  }
1425
+ await withOrganizationIntegrationAcquisition(
1426
+ db,
1427
+ acquisition,
1428
+ [acquisition.integrationKey ?? "custom:mcp"],
1429
+ async () => {},
1430
+ );
1295
1431
  const dcr = await dynamicClientRegistration(settings, as, redirectUri, scopes, signal);
1296
1432
  const key = dcr.clientSecret ? requireEnvironmentEncryption(settings) : null;
1297
1433
  const storeInput = {
@@ -1303,34 +1439,43 @@ async function getOrCreateDynamicClientRegistration(
1303
1439
  tokenEndpointAuthMethod: dcr.tokenEndpointAuthMethod,
1304
1440
  metadata: registrationMetadata(as, redirectUri, scopes),
1305
1441
  };
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);
1442
+ // Registration has already happened outside this transaction. Fence only the
1443
+ // durable client credential; a restriction racing DCR must not persist it.
1444
+ return withOrganizationIntegrationAcquisition(
1445
+ db,
1446
+ acquisition,
1447
+ [acquisition.integrationKey ?? "custom:mcp"],
1448
+ async (tx) => {
1449
+ if (storedClient) {
1450
+ const replaced = await replaceIntegrationOAuthClientIfCurrent(tx, {
1451
+ ...storeInput,
1452
+ expectedClientId: storedClient.clientId,
1453
+ });
1454
+ if (replaced?.clientId === dcr.clientId) {
1455
+ return dcr;
1456
+ }
1457
+ return await loadCompatibleDcrWinner(tx, settings, as, redirectUri, scopes);
1458
+ }
1459
+ const storedWinner = await storeIntegrationOAuthClient(tx, storeInput);
1460
+ if (storedWinner.clientId === dcr.clientId) {
1461
+ return dcr;
1462
+ }
1463
+ const winner = await loadIntegrationOAuthClient(tx, settings, as.issuer);
1464
+ if (winner && storedDcrClientSatisfiesPolicy(winner, as, redirectUri, scopes)) {
1465
+ return dcrRegistrationFromStored(winner);
1466
+ }
1467
+ if (winner) {
1468
+ const replaced = await replaceIntegrationOAuthClientIfCurrent(tx, {
1469
+ ...storeInput,
1470
+ expectedClientId: winner.clientId,
1471
+ });
1472
+ if (replaced?.clientId === dcr.clientId) {
1473
+ return dcr;
1474
+ }
1475
+ }
1476
+ return await loadCompatibleDcrWinner(tx, settings, as, redirectUri, scopes);
1477
+ },
1478
+ );
1334
1479
  }
1335
1480
 
1336
1481
  function storedDcrClientSatisfiesPolicy(
@@ -1641,6 +1786,13 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
1641
1786
  if (!payload) {
1642
1787
  throw new HTTPException(400, { message: "invalid or expired OAuth state" });
1643
1788
  }
1789
+ if (
1790
+ payload.integrationKey !== undefined &&
1791
+ payload.integrationKey !== "gmail" &&
1792
+ payload.integrationKey !== "slack-personal"
1793
+ ) {
1794
+ throw new HTTPException(400, { message: "invalid OAuth integration identity" });
1795
+ }
1644
1796
  const nowSeconds = Math.floor(Date.now() / 1000);
1645
1797
  const iat = numberValue(payload.iat);
1646
1798
  if (iat === undefined || nowSeconds - iat > oauthStateTtlMs / 1000 || nowSeconds < iat) {
@@ -1697,6 +1849,9 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
1697
1849
  ownership: connectionOwnership(payload.ownership) ?? "personal",
1698
1850
  // Absent on a legacy state, which therefore cannot land a personal owner.
1699
1851
  personalOwnerVerified: personalOwnerVerifiedInState(payload),
1852
+ ...(payload.integrationKey
1853
+ ? { integrationKey: payload.integrationKey as "gmail" | "slack-personal" }
1854
+ : {}),
1700
1855
  providerDomain: requiredString(payload.providerDomain, "state.providerDomain"),
1701
1856
  mcpUrl: stringValue(payload.mcpUrl) ?? resource,
1702
1857
  resource,
@@ -1,3 +1,5 @@
1
+ import { withOrganizationIntegrationAcquisition } from "@opengeni/db/organization-integration-policy";
2
+ import { claimOAuthAcquisition, finishOAuthAcquisition } from "./oauth-client";
1
3
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
4
  import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
3
5
  import { requireConnectOwnerAuthority } from "./connect-authority";
@@ -23,7 +25,6 @@ import {
23
25
  } from "@opengeni/core";
24
26
  import {
25
27
  consumeIntegrationOAuthStateNonce,
26
- claimConnectOperation,
27
28
  finishConnectOperation,
28
29
  getConnectAttempt,
29
30
  type Database,
@@ -216,6 +217,7 @@ export async function startPersonalGitHubOAuth(
216
217
  ) {
217
218
  const oauth = requirePersonalGitHubOAuthSettings(deps.settings);
218
219
  const { grant } = input.access;
220
+ await withOrganizationIntegrationAcquisition(deps.db, grant, ["github-personal"], async () => {});
219
221
  const existing = input.connectionId
220
222
  ? await getConnectionMetadata(deps.db, input.workspaceId, input.connectionId, grant.subjectId)
221
223
  : null;
@@ -301,12 +303,18 @@ export async function completePersonalGitHubOAuthCallback(
301
303
  operationId: `oauth:${state.nonce}`,
302
304
  inputDigest: createHash("sha256").update(input.state!).digest("hex"),
303
305
  };
304
- const claim = await claimConnectOperation(deps.db, state, {
305
- ...operation,
306
- expectedRevision: stored.attempt.revision,
307
- authorize: (tx, _attempt, origin) =>
308
- requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
309
- });
306
+ const claim = await claimOAuthAcquisition(
307
+ deps.db,
308
+ state,
309
+ {
310
+ ...operation,
311
+ expectedRevision: stored.attempt.revision,
312
+ authorize: (tx, _attempt, origin) =>
313
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
314
+ },
315
+ "github-personal",
316
+ Boolean(input.code && !input.error),
317
+ );
310
318
  if (claim.status === "replayed") return { redirectTo: exactReturnUrl, exactReturn: true };
311
319
  }
312
320
  await requirePersonalGitHubCallbackGrant(deps, state);
@@ -340,6 +348,12 @@ export async function completePersonalGitHubOAuthCallback(
340
348
  }
341
349
  if (input.error) throw new PersonalGitHubCallbackError("provider_denied");
342
350
  if (!input.code) throw new PersonalGitHubCallbackError("missing_code");
351
+ await withOrganizationIntegrationAcquisition(
352
+ deps.db,
353
+ state,
354
+ ["github-personal"],
355
+ async () => {},
356
+ );
343
357
 
344
358
  const oauth = requirePersonalGitHubOAuthSettings(deps.settings);
345
359
  if (
@@ -474,33 +488,43 @@ export async function completePersonalGitHubOAuthCallback(
474
488
  });
475
489
  };
476
490
  if (operation) {
477
- await finishConnectOperation(deps.db, acceptedState, {
478
- ...operation,
479
- authorize: (tx, _attempt, origin) =>
480
- requireConnectOwnerAuthority(tx, acceptedState, "connections:write", origin),
481
- commit: async (tx, current) => {
482
- const connection = await persist(tx);
483
- if (!connection) throw new PersonalGitHubCallbackError("connection_conflict");
484
- return {
485
- ...current,
486
- revision: current.revision + 1,
487
- state: "complete",
488
- credentialsCommitted: true,
489
- nextAction: { type: "none" },
490
- account: {
491
- id: connection.id,
492
- version: connection.version,
493
- providerId: "github-personal",
494
- label: identity.login,
495
- ownership: "personal",
496
- status: "connected",
497
- },
498
- };
491
+ await finishOAuthAcquisition(
492
+ deps.db,
493
+ acceptedState,
494
+ {
495
+ ...operation,
496
+ authorize: (tx, _attempt, origin) =>
497
+ requireConnectOwnerAuthority(tx, acceptedState, "connections:write", origin),
498
+ commit: async (tx, current) => {
499
+ const connection = await persist(tx);
500
+ if (!connection) throw new PersonalGitHubCallbackError("connection_conflict");
501
+ return {
502
+ ...current,
503
+ revision: current.revision + 1,
504
+ state: "complete",
505
+ credentialsCommitted: true,
506
+ nextAction: { type: "none" },
507
+ account: {
508
+ id: connection.id,
509
+ version: connection.version,
510
+ providerId: "github-personal",
511
+ label: identity.login,
512
+ ownership: "personal",
513
+ status: "connected",
514
+ },
515
+ };
516
+ },
499
517
  },
500
- });
518
+ "github-personal",
519
+ );
501
520
  return { redirectTo: exactReturnUrl!, exactReturn: true };
502
521
  }
503
- const connection = await persist(deps.db);
522
+ const connection = await withOrganizationIntegrationAcquisition(
523
+ deps.db,
524
+ acceptedState,
525
+ ["github-personal"],
526
+ persist,
527
+ );
504
528
  if (!connection) throw new PersonalGitHubCallbackError("connection_conflict");
505
529
  return {
506
530
  redirectTo: personalGitHubCallbackReturnUrl(returnBaseUrl, state.returnPath, "success", {