@opengeni/api-router 2.11.3 → 2.12.3-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 (66) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-5X3XM6CE.js → chunk-4AQMKVQM.js} +17727 -14427
  3. package/dist/chunk-4AQMKVQM.js.map +1 -0
  4. package/dist/connection-ownership.d.ts +5 -1
  5. package/dist/index.js +3 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/integrations/atlassian.d.ts +4 -0
  8. package/dist/integrations/connect-authority.d.ts +1 -0
  9. package/dist/integrations/connect-callback-return.d.ts +6 -0
  10. package/dist/integrations/fiken.d.ts +7 -0
  11. package/dist/integrations/github-app-connect.d.ts +47 -0
  12. package/dist/integrations/github-installation-proof.d.ts +3 -0
  13. package/dist/integrations/github-lens-connect.d.ts +153 -0
  14. package/dist/integrations/google-drive.d.ts +4 -0
  15. package/dist/integrations/oauth-client.d.ts +4 -0
  16. package/dist/integrations/oauth-return-path.d.ts +1 -0
  17. package/dist/integrations/personal-github.d.ts +2 -0
  18. package/dist/integrations/provider-oauth.d.ts +11 -1
  19. package/dist/integrations/slack-install.d.ts +20 -0
  20. package/dist/integrations/social-oauth.d.ts +2 -0
  21. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  22. package/dist/routes/api-integrations.d.ts +14 -0
  23. package/dist/routes/connect.d.ts +5 -0
  24. package/dist/routes/external-identity-links.d.ts +6 -0
  25. package/dist/routes/host-mcp-bindings.d.ts +5 -0
  26. package/dist/sandbox/rematerialize.d.ts +1 -1
  27. package/dist/workspace-tool-gateway.d.ts +1 -0
  28. package/package.json +19 -19
  29. package/src/app.ts +22 -2
  30. package/src/connection-ownership.ts +14 -1
  31. package/src/index.ts +2 -0
  32. package/src/integrations/atlassian.ts +143 -39
  33. package/src/integrations/connect-authority.ts +2 -0
  34. package/src/integrations/connect-callback-return.ts +86 -0
  35. package/src/integrations/fiken.ts +222 -40
  36. package/src/integrations/github-app-connect.ts +389 -0
  37. package/src/integrations/github-installation-proof.ts +60 -0
  38. package/src/integrations/github-lens-connect.ts +97 -0
  39. package/src/integrations/google-drive.ts +152 -47
  40. package/src/integrations/oauth-client.ts +180 -84
  41. package/src/integrations/oauth-return-path.ts +18 -0
  42. package/src/integrations/personal-github.ts +146 -52
  43. package/src/integrations/provider-oauth.ts +132 -21
  44. package/src/integrations/slack-install.ts +83 -0
  45. package/src/integrations/social-oauth.ts +142 -1
  46. package/src/mcp/documents.ts +1 -1
  47. package/src/mcp/server.ts +64 -17
  48. package/src/routes/api-integrations.ts +74 -64
  49. package/src/routes/connect.ts +1869 -0
  50. package/src/routes/connections.ts +263 -221
  51. package/src/routes/documents.ts +2 -2
  52. package/src/routes/external-identity-links.ts +200 -0
  53. package/src/routes/github.ts +39 -64
  54. package/src/routes/host-mcp-bindings.ts +122 -0
  55. package/src/routes/organization-memberships.ts +23 -0
  56. package/src/routes/organization-sessions.ts +2 -2
  57. package/src/routes/personal-github.ts +8 -1
  58. package/src/routes/pr-review-github.ts +30 -3
  59. package/src/routes/scheduled-tasks.ts +29 -4
  60. package/src/routes/sessions.ts +61 -32
  61. package/src/routes/social.ts +5 -1
  62. package/src/routes/supergrok.ts +90 -3
  63. package/src/routes/workspaces.ts +16 -0
  64. package/src/sandbox/rematerialize.ts +33 -1
  65. package/src/workspace-tool-gateway.ts +136 -24
  66. package/dist/chunk-5X3XM6CE.js.map +0 -1
@@ -1,4 +1,6 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
3
+ import { requireConnectOwnerAuthority } from "./connect-authority";
2
4
  import { personalGitHubOAuthCallbackUrl, type Settings } from "@opengeni/config";
3
5
  import {
4
6
  PERSONAL_GITHUB_AUTHORIZATION_URL,
@@ -14,22 +16,24 @@ import {
14
16
  isPersonalGitHubConnection,
15
17
  } from "@opengeni/contracts/personal-github";
16
18
  import {
17
- hasPermission,
19
+ externalActorContinuationForAuthorization,
18
20
  requireEnvironmentEncryption,
19
21
  type AccessGrantAuthorization,
20
22
  type ApiRouteDeps,
21
23
  } from "@opengeni/core";
22
24
  import {
23
25
  consumeIntegrationOAuthStateNonce,
26
+ claimConnectOperation,
27
+ finishConnectOperation,
28
+ getConnectAttempt,
29
+ type Database,
24
30
  ConnectionDisconnectGenerationError,
25
31
  ConnectionDisconnectIdempotencyError,
26
32
  decryptEnvironmentValue,
27
33
  disconnectConnectionIdempotently,
28
34
  encryptEnvironmentValue,
29
35
  getConnectionMetadata,
30
- getWorkspaceGrant,
31
36
  listConnectionsMetadata,
32
- namedSubjectHasLiveWorkspaceAuthority,
33
37
  persistProviderOAuthConnection,
34
38
  } from "@opengeni/db";
35
39
  import { createSignedState, readSignedState } from "@opengeni/github";
@@ -53,6 +57,8 @@ const GITHUB_REQUEST_TIMEOUT_MS = 10_000;
53
57
  const GITHUB_API_VERSION = "2022-11-28";
54
58
 
55
59
  type PersonalGitHubOAuthState = {
60
+ connectAttemptId?: string;
61
+ externalContinuation?: ExternalActorContinuation;
56
62
  accountId: string;
57
63
  workspaceId: string;
58
64
  subjectId: string;
@@ -205,6 +211,7 @@ export async function startPersonalGitHubOAuth(
205
211
  workspaceId: string;
206
212
  connectionId?: string;
207
213
  returnPath?: string;
214
+ connectAttemptId?: string;
208
215
  },
209
216
  ) {
210
217
  const oauth = requirePersonalGitHubOAuthSettings(deps.settings);
@@ -232,6 +239,15 @@ export async function startPersonalGitHubOAuth(
232
239
  workspaceId: input.workspaceId,
233
240
  subjectId: grant.subjectId,
234
241
  kind: PERSONAL_GITHUB_OAUTH_STATE_KIND,
242
+ ...(input.connectAttemptId ? { connectAttemptId: input.connectAttemptId } : {}),
243
+ ...(externalActorContinuationForAuthorization(input.access)
244
+ ? {
245
+ encryptedExternalContinuation: encryptEnvironmentValue(
246
+ key,
247
+ JSON.stringify(externalActorContinuationForAuthorization(input.access)),
248
+ ),
249
+ }
250
+ : {}),
235
251
  [PERSONAL_OWNER_VERIFIED_STATE_CLAIM]: true,
236
252
  canonicalManagedHumanSession: input.access.canonicalManagedHumanSession,
237
253
  returnPath,
@@ -264,12 +280,35 @@ export async function completePersonalGitHubOAuthCallback(
264
280
  error?: string;
265
281
  requestUrl: string;
266
282
  },
267
- ): Promise<{ redirectTo: string }> {
283
+ ): Promise<{ redirectTo: string; exactReturn?: boolean }> {
268
284
  const apiBaseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
269
285
  const returnBaseUrl = deps.settings.webBaseUrl?.replace(/\/+$/u, "") ?? apiBaseUrl;
270
286
  let state: PersonalGitHubOAuthState | null = null;
287
+ let exactReturnUrl: string | undefined;
288
+ let operation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
271
289
  try {
272
290
  state = readPersonalGitHubOAuthState(input.state, deps.settings);
291
+ if (state.connectAttemptId) {
292
+ const stored = await getConnectAttempt(deps.db, state, state.connectAttemptId);
293
+ if (
294
+ stored.attempt.providerId !== "github-personal" ||
295
+ stored.attempt.ownership !== "personal"
296
+ )
297
+ throw new PersonalGitHubCallbackError("invalid_state");
298
+ exactReturnUrl = stored.returnUrl;
299
+ operation = {
300
+ attemptId: state.connectAttemptId,
301
+ operationId: `oauth:${state.nonce}`,
302
+ inputDigest: createHash("sha256").update(input.state!).digest("hex"),
303
+ };
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
+ });
310
+ if (claim.status === "replayed") return { redirectTo: exactReturnUrl, exactReturn: true };
311
+ }
273
312
  await requirePersonalGitHubCallbackGrant(deps, state);
274
313
  const consumed = await consumeIntegrationOAuthStateNonce(deps.db, {
275
314
  accountId: state.accountId,
@@ -280,6 +319,25 @@ export async function completePersonalGitHubOAuthCallback(
280
319
  now: new Date(),
281
320
  });
282
321
  if (!consumed) throw new PersonalGitHubCallbackError("state_replayed");
322
+ if (operation && (input.error || !input.code)) {
323
+ await finishConnectOperation(deps.db, state, {
324
+ ...operation,
325
+ authorize: (tx, _attempt, origin) =>
326
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
327
+ commit: async (_tx, current) => ({
328
+ ...current,
329
+ revision: current.revision + 1,
330
+ state: input.error === "access_denied" ? "cancelled" : "failed",
331
+ nextAction: { type: "none" },
332
+ error: {
333
+ code: input.error ? "provider_denied" : "missing_code",
334
+ message: "Authorization was not completed. Start a new connection attempt.",
335
+ retryable: false,
336
+ },
337
+ }),
338
+ });
339
+ return { redirectTo: exactReturnUrl!, exactReturn: true };
340
+ }
283
341
  if (input.error) throw new PersonalGitHubCallbackError("provider_denied");
284
342
  if (!input.code) throw new PersonalGitHubCallbackError("missing_code");
285
343
 
@@ -376,35 +434,73 @@ export async function completePersonalGitHubOAuthCallback(
376
434
  scope_parameter_supported: false,
377
435
  }),
378
436
  );
379
- const connection = await persistProviderOAuthConnection(deps.db, {
380
- accountId: state.accountId,
381
- workspaceId: state.workspaceId,
382
- subjectId: state.subjectId,
383
- visibleToSubjectId: state.subjectId,
384
- providerDomain: PERSONAL_GITHUB_PROVIDER_DOMAIN,
385
- kind: "oauth2",
386
- status: "active",
387
- credentialEncrypted,
388
- grantedScopes: token.scopes,
389
- expiresAt: token.expiresAt,
390
- metadata,
391
- createdBySubjectId: state.subjectId,
392
- updatedBySubjectId: state.subjectId,
393
- credentialRole: PERSONAL_GITHUB_CREDENTIAL_ROLE,
394
- providerFamily: PERSONAL_GITHUB_PROVIDER_FAMILY,
395
- providerPrincipalId: identity.id,
396
- requireLiveUserAuthority: true,
397
- requiredLiveUserPermission: "connections:write",
398
- allowCanonicalPersonalWorkspaceOwner: state.canonicalManagedHumanSession,
399
- exclusiveProviderPrincipalPerOwner: true,
400
- preserveExistingMetadataKeys: ["credentialBindingId", "connectedAt"],
401
- ...(state.connectionId
402
- ? {
403
- requestedConnectionId: state.connectionId,
404
- requestedConnectionVersion: state.connectionVersion,
405
- }
406
- : {}),
407
- });
437
+ const acceptedState = state;
438
+ const persist = (tx: Database) => {
439
+ // Capture the validated immutable callback state for delayed persistence.
440
+ // eslint-disable-next-line no-shadow
441
+ const state = acceptedState;
442
+ return persistProviderOAuthConnection(tx, {
443
+ accountId: state.accountId,
444
+ workspaceId: state.workspaceId,
445
+ subjectId: state.subjectId,
446
+ visibleToSubjectId: state.subjectId,
447
+ providerDomain: PERSONAL_GITHUB_PROVIDER_DOMAIN,
448
+ kind: "oauth2",
449
+ status: "active",
450
+ credentialEncrypted,
451
+ grantedScopes: token.scopes,
452
+ expiresAt: token.expiresAt,
453
+ metadata,
454
+ createdBySubjectId: state.subjectId,
455
+ updatedBySubjectId: state.subjectId,
456
+ credentialRole: PERSONAL_GITHUB_CREDENTIAL_ROLE,
457
+ providerFamily: PERSONAL_GITHUB_PROVIDER_FAMILY,
458
+ providerPrincipalId: identity.id,
459
+ requireLiveUserAuthority: true,
460
+ requiredLiveUserPermission: "connections:write",
461
+ // The external lane has passed its own owning-user proof; it does not
462
+ // impersonate a native login. Both use the same personal workspace anchor.
463
+ allowCanonicalPersonalWorkspaceOwner:
464
+ state.canonicalManagedHumanSession || Boolean(state.externalContinuation),
465
+ authorize: (locked) => requireConnectOwnerAuthority(locked, state, "connections:write"),
466
+ exclusiveProviderPrincipalPerOwner: true,
467
+ preserveExistingMetadataKeys: ["credentialBindingId", "connectedAt"],
468
+ ...(state.connectionId
469
+ ? {
470
+ requestedConnectionId: state.connectionId,
471
+ requestedConnectionVersion: state.connectionVersion,
472
+ }
473
+ : {}),
474
+ });
475
+ };
476
+ 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
+ };
499
+ },
500
+ });
501
+ return { redirectTo: exactReturnUrl!, exactReturn: true };
502
+ }
503
+ const connection = await persist(deps.db);
408
504
  if (!connection) throw new PersonalGitHubCallbackError("connection_conflict");
409
505
  return {
410
506
  redirectTo: personalGitHubCallbackReturnUrl(returnBaseUrl, state.returnPath, "success", {
@@ -412,6 +508,7 @@ export async function completePersonalGitHubOAuthCallback(
412
508
  }),
413
509
  };
414
510
  } catch (error) {
511
+ if (exactReturnUrl) return { redirectTo: exactReturnUrl, exactReturn: true };
415
512
  return {
416
513
  redirectTo: personalGitHubCallbackReturnUrl(
417
514
  returnBaseUrl,
@@ -468,6 +565,21 @@ function readPersonalGitHubOAuthState(
468
565
  subjectId: requiredString(payload.subjectId),
469
566
  personalOwnerVerified: personalOwnerVerifiedInState(payload),
470
567
  canonicalManagedHumanSession: payload.canonicalManagedHumanSession === true,
568
+ ...(typeof payload.connectAttemptId === "string"
569
+ ? { connectAttemptId: payload.connectAttemptId }
570
+ : {}),
571
+ ...(typeof payload.encryptedExternalContinuation === "string"
572
+ ? {
573
+ externalContinuation: ExternalActorContinuation.parse(
574
+ JSON.parse(
575
+ decryptEnvironmentValue(
576
+ requireEnvironmentEncryption(settings),
577
+ payload.encryptedExternalContinuation,
578
+ ),
579
+ ),
580
+ ),
581
+ }
582
+ : {}),
471
583
  returnPath: personalGitHubReturnPath(workspaceId, requiredString(payload.returnPath)),
472
584
  encryptedPkceVerifier: requiredString(payload.encryptedPkceVerifier),
473
585
  oauthEnvironment: requiredString(payload.oauthEnvironment),
@@ -491,25 +603,7 @@ async function requirePersonalGitHubCallbackGrant(
491
603
  ) {
492
604
  throw new PersonalGitHubCallbackError("not_authorized");
493
605
  }
494
- const grant = await getWorkspaceGrant(deps.db, state.subjectId, state.workspaceId);
495
- if (
496
- grant &&
497
- grant.accountId === state.accountId &&
498
- hasPermission(grant.permissions, "connections:write")
499
- ) {
500
- return;
501
- }
502
- if (
503
- state.canonicalManagedHumanSession &&
504
- (await namedSubjectHasLiveWorkspaceAuthority(deps.db, {
505
- accountId: state.accountId,
506
- workspaceId: state.workspaceId,
507
- subjectId: state.subjectId,
508
- }))
509
- ) {
510
- return;
511
- }
512
- throw new PersonalGitHubCallbackError("not_authorized");
606
+ await requireConnectOwnerAuthority(deps.db, state, "connections:write");
513
607
  }
514
608
 
515
609
  async function exchangePersonalGitHubAuthorizationCode(
@@ -18,16 +18,18 @@ import {
18
18
  type ApiIntegrationOAuthStartRequest,
19
19
  type ConnectionOwnership,
20
20
  } from "@opengeni/contracts";
21
- import { hasPermission, requireEnvironmentEncryption, type ApiRouteDeps } from "@opengeni/core";
21
+ import { requireEnvironmentEncryption, type ApiRouteDeps } from "@opengeni/core";
22
+ import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
22
23
  import {
23
24
  consumeIntegrationOAuthStateNonce,
24
25
  decryptEnvironmentValue,
25
26
  encryptEnvironmentValue,
26
27
  getConnectionMetadata,
27
- getWorkspaceGrant,
28
28
  loadConnectionCredentialForBroker,
29
29
  persistProviderOAuthConnection,
30
- resolveNamedManagedPersonalWorkspaceGrant,
30
+ getConnectAttempt,
31
+ claimConnectOperation,
32
+ finishConnectOperation,
31
33
  } from "@opengeni/db";
32
34
  import { createSignedState, readSignedState } from "@opengeni/github";
33
35
  import {
@@ -37,6 +39,7 @@ import {
37
39
  type FetchLike,
38
40
  } from "@opengeni/network";
39
41
  import { HTTPException } from "hono/http-exception";
42
+ import { requireConnectOwnerAuthority } from "./connect-authority";
40
43
 
41
44
  import {
42
45
  assertConnectionOwnershipAllowedForPrincipal,
@@ -69,6 +72,8 @@ type ProviderOAuthClient = {
69
72
  };
70
73
 
71
74
  type ProviderOAuthState = {
75
+ connectAttemptId?: string;
76
+ externalContinuation?: ExternalActorContinuation;
72
77
  accountId: string;
73
78
  workspaceId: string;
74
79
  subjectId: string;
@@ -137,6 +142,8 @@ export async function startApiIntegrationProviderOAuth(
137
142
  * route from the live authenticated principal, never inferred here.
138
143
  */
139
144
  personalOwnershipAllowed: boolean;
145
+ externalContinuation?: ExternalActorContinuation;
146
+ connectAttemptId?: string;
140
147
  requestUrl: string;
141
148
  payload: ApiIntegrationOAuthStartRequest;
142
149
  },
@@ -230,6 +237,15 @@ export async function startApiIntegrationProviderOAuth(
230
237
  clientId: client.clientId,
231
238
  tokenEndpointAuthMethod: client.tokenEndpointAuthMethod,
232
239
  returnPath,
240
+ ...(input.connectAttemptId ? { connectAttemptId: input.connectAttemptId } : {}),
241
+ ...(input.externalContinuation
242
+ ? {
243
+ encryptedExternalContinuation: encryptEnvironmentValue(
244
+ key,
245
+ JSON.stringify(ExternalActorContinuation.parse(input.externalContinuation)),
246
+ ),
247
+ }
248
+ : {}),
233
249
  ...(existing
234
250
  ? {
235
251
  connectionId: existing.id,
@@ -270,12 +286,35 @@ export async function completeApiIntegrationProviderOAuth(
270
286
  error?: string | undefined;
271
287
  requestUrl: string;
272
288
  },
273
- ): Promise<{ redirectTo: string }> {
289
+ ): Promise<{ redirectTo: string; exactReturn?: boolean }> {
274
290
  const apiBaseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
275
291
  const returnBaseUrl = deps.settings.webBaseUrl?.replace(/\/+$/, "") ?? apiBaseUrl;
276
292
  let state: ProviderOAuthState | null = null;
293
+ let exactReturnUrl: string | undefined;
294
+ let connectOperation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
277
295
  try {
278
296
  state = readProviderOAuthState(input.state, deps.settings);
297
+ if (state.connectAttemptId) {
298
+ const stored = await getConnectAttempt(deps.db, state, state.connectAttemptId);
299
+ if (
300
+ stored.attempt.providerId !== state.definitionId ||
301
+ stored.attempt.ownership !== state.ownership
302
+ )
303
+ throw new ProviderOAuthCallbackError("state_invalid");
304
+ exactReturnUrl = stored.returnUrl;
305
+ connectOperation = {
306
+ attemptId: state.connectAttemptId,
307
+ operationId: `oauth:${state.nonce}`,
308
+ inputDigest: createHash("sha256").update(input.state!).digest("hex"),
309
+ };
310
+ const claim = await claimConnectOperation(deps.db, state, {
311
+ ...connectOperation,
312
+ expectedRevision: stored.attempt.revision,
313
+ authorize: (tx, _attempt, origin) =>
314
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
315
+ });
316
+ if (claim.status === "replayed") return { redirectTo: exactReturnUrl, exactReturn: true };
317
+ }
279
318
  requireProviderOAuthOwner(state);
280
319
  await requireProviderOAuthGrant(deps, state);
281
320
  const consumed = await consumeIntegrationOAuthStateNonce(deps.db, {
@@ -287,6 +326,25 @@ export async function completeApiIntegrationProviderOAuth(
287
326
  now: new Date(),
288
327
  });
289
328
  if (!consumed) throw new ProviderOAuthCallbackError("state_replayed");
329
+ if (connectOperation && (input.error || !input.code)) {
330
+ await finishConnectOperation(deps.db, state, {
331
+ ...connectOperation,
332
+ authorize: (tx, _attempt, origin) =>
333
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
334
+ commit: async (_tx, current) => ({
335
+ ...current,
336
+ revision: current.revision + 1,
337
+ state: input.error === "access_denied" ? "cancelled" : "failed",
338
+ nextAction: { type: "none" },
339
+ error: {
340
+ code: input.error ? "provider_denied" : "missing_code",
341
+ message: "Authorization was not completed. Start a new connection attempt.",
342
+ retryable: false,
343
+ },
344
+ }),
345
+ });
346
+ return { redirectTo: exactReturnUrl!, exactReturn: true };
347
+ }
290
348
  if (input.error) throw new ProviderOAuthCallbackError("provider_denied");
291
349
  if (!input.code) throw new ProviderOAuthCallbackError("missing_code");
292
350
 
@@ -389,7 +447,8 @@ export async function completeApiIntegrationProviderOAuth(
389
447
  ]),
390
448
  verifiedAt: new Date().toISOString(),
391
449
  });
392
- const connection = await persistProviderOAuthConnection(deps.db, {
450
+ const persistenceInput: import("@opengeni/db").PersistProviderOAuthConnectionInput = {
451
+ authorize: (tx: import("@opengeni/db").Database) => requireConnectOwnerAuthority(tx, state!),
393
452
  accountId: state.accountId,
394
453
  workspaceId: state.workspaceId,
395
454
  subjectId: ownerSubjectId,
@@ -412,7 +471,36 @@ export async function completeApiIntegrationProviderOAuth(
412
471
  requestedConnectionVersion: state.connectionVersion,
413
472
  }
414
473
  : {}),
415
- });
474
+ };
475
+ const persist = (database: import("@opengeni/db").Database) =>
476
+ persistProviderOAuthConnection(database, persistenceInput);
477
+ if (connectOperation) {
478
+ await finishConnectOperation(deps.db, state, {
479
+ ...connectOperation,
480
+ authorize: (tx, _attempt, origin) =>
481
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
482
+ commit: async (tx, current) => {
483
+ const connection = await persist(tx);
484
+ if (!connection) throw new ProviderOAuthCallbackError("connection_conflict");
485
+ return {
486
+ ...current,
487
+ revision: current.revision + 1,
488
+ state: "connected_but_incomplete",
489
+ credentialsCommitted: true,
490
+ nextAction: { type: "none" },
491
+ account: {
492
+ id: connection.id,
493
+ providerId: current.providerId,
494
+ label: identity.displayName ?? identity.email ?? identity.principalId,
495
+ ownership: current.ownership,
496
+ status: "connected",
497
+ },
498
+ };
499
+ },
500
+ });
501
+ return { redirectTo: exactReturnUrl!, exactReturn: true };
502
+ }
503
+ const connection = await persist(deps.db);
416
504
  if (!connection) throw new ProviderOAuthCallbackError("connection_conflict");
417
505
  return {
418
506
  redirectTo: providerOAuthReturnUrl(returnBaseUrl, state.returnPath, "success", {
@@ -423,6 +511,7 @@ export async function completeApiIntegrationProviderOAuth(
423
511
  }),
424
512
  };
425
513
  } catch (error) {
514
+ if (exactReturnUrl !== undefined) return { redirectTo: exactReturnUrl, exactReturn: true };
426
515
  return {
427
516
  redirectTo: providerOAuthReturnUrl(
428
517
  returnBaseUrl,
@@ -465,6 +554,26 @@ function requiredDefinition(id: string): IntegrationDefinition {
465
554
  return definition;
466
555
  }
467
556
 
557
+ /** Configuration readiness only: consent and installation are still separate. */
558
+ export function curatedOAuthReadiness(
559
+ settings: Settings,
560
+ definition: IntegrationDefinition,
561
+ ): { configured: boolean; ownership: ConnectionOwnership[] } {
562
+ const ownership = [
563
+ ...providerOAuthProfile(definition, integrationDefinitionProviderDomain(definition))
564
+ .allowedOwnership,
565
+ ];
566
+ try {
567
+ if (!settings.integrationsEnabled) return { configured: false, ownership };
568
+ requireEnvironmentEncryption(settings);
569
+ requireIntegrationsStateSecret(settings);
570
+ providerClientForDefinition(settings, definition);
571
+ return { configured: true, ownership };
572
+ } catch {
573
+ return { configured: false, ownership };
574
+ }
575
+ }
576
+
468
577
  function providerClientForDefinition(
469
578
  settings: Settings,
470
579
  definition: IntegrationDefinition,
@@ -594,6 +703,21 @@ function readProviderOAuthState(raw: string | undefined, settings: Settings): Pr
594
703
  }
595
704
  return {
596
705
  accountId: requiredStateString(payload.accountId),
706
+ ...(optionalString(payload.connectAttemptId)
707
+ ? { connectAttemptId: optionalString(payload.connectAttemptId)! }
708
+ : {}),
709
+ ...(typeof payload.encryptedExternalContinuation === "string"
710
+ ? {
711
+ externalContinuation: ExternalActorContinuation.parse(
712
+ JSON.parse(
713
+ decryptEnvironmentValue(
714
+ requireEnvironmentEncryption(settings),
715
+ payload.encryptedExternalContinuation,
716
+ ),
717
+ ),
718
+ ),
719
+ }
720
+ : {}),
597
721
  workspaceId: requiredStateString(payload.workspaceId),
598
722
  subjectId: requiredStateString(payload.subjectId),
599
723
  ownership,
@@ -742,23 +866,10 @@ async function requireProviderOAuthGrant(
742
866
  deps: ApiRouteDeps,
743
867
  state: Pick<
744
868
  ProviderOAuthState,
745
- "accountId" | "workspaceId" | "subjectId" | "personalOwnerVerified"
869
+ "accountId" | "workspaceId" | "subjectId" | "personalOwnerVerified" | "externalContinuation"
746
870
  >,
747
871
  ): Promise<void> {
748
- const membershipGrant = await getWorkspaceGrant(deps.db, state.subjectId, state.workspaceId);
749
- const grant =
750
- membershipGrant?.accountId === state.accountId
751
- ? membershipGrant
752
- : state.personalOwnerVerified
753
- ? await resolveNamedManagedPersonalWorkspaceGrant(deps.db, state)
754
- : null;
755
- if (
756
- !grant ||
757
- grant.accountId !== state.accountId ||
758
- !hasPermission(grant.permissions, "connections:write")
759
- ) {
760
- throw new ProviderOAuthCallbackError("connection_conflict");
761
- }
872
+ await deps.db.transaction((tx) => requireConnectOwnerAuthority(tx, state));
762
873
  }
763
874
 
764
875
  /**
@@ -0,0 +1,83 @@
1
+ import {
2
+ OpenGeniSlackBotInstallStart,
3
+ OPENGENI_SLACK_BOT_REQUESTED_SCOPES,
4
+ } from "@opengeni/contracts";
5
+ import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
6
+ import {
7
+ isOpenGeniSlackBotConnection,
8
+ requireEnvironmentEncryption,
9
+ type ApiRouteDeps,
10
+ } from "@opengeni/core";
11
+ import { encryptEnvironmentValue, getConnectionMetadata } from "@opengeni/db";
12
+ import { createSignedState } from "@opengeni/github";
13
+ import { HTTPException } from "hono/http-exception";
14
+ import {
15
+ integrationBaseUrl,
16
+ oauthStateTtlMs,
17
+ requireIntegrationsStateSecret,
18
+ } from "./oauth-client";
19
+
20
+ export function requireOpenGeniSlackOAuthSettings(settings: ApiRouteDeps["settings"]) {
21
+ const clientId = settings.slackClientId?.trim();
22
+ const clientSecret = settings.slackClientSecret?.trim();
23
+ if (!clientId || !clientSecret || !settings.slackSigningSecret?.trim())
24
+ throw new HTTPException(503, {
25
+ message:
26
+ "OpenGeni Slack bot installation requires OPENGENI_SLACK_CLIENT_ID, OPENGENI_SLACK_CLIENT_SECRET, and OPENGENI_SLACK_SIGNING_SECRET",
27
+ });
28
+ return { clientId, clientSecret };
29
+ }
30
+
31
+ /** Only authenticated route admission may invoke this workspace-bot starter.
32
+ * Hosted personal Slack MCP deliberately uses its own OAuth profile. */
33
+ export async function startSlackBotInstall(
34
+ deps: ApiRouteDeps,
35
+ input: {
36
+ accountId: string;
37
+ workspaceId: string;
38
+ subjectId: string;
39
+ requestUrl: string;
40
+ connectionId?: string;
41
+ connectAttemptId?: string;
42
+ externalContinuation?: ExternalActorContinuation;
43
+ },
44
+ ) {
45
+ const slack = requireOpenGeniSlackOAuthSettings(deps.settings);
46
+ const key = requireEnvironmentEncryption(deps.settings);
47
+ const existing = input.connectionId
48
+ ? await getConnectionMetadata(deps.db, input.workspaceId, input.connectionId, input.subjectId)
49
+ : null;
50
+ if (input.connectionId && !existing)
51
+ throw new HTTPException(404, { message: "connection not found" });
52
+ if (existing && !isOpenGeniSlackBotConnection(existing))
53
+ throw new HTTPException(422, {
54
+ message: "connectionId is not an OpenGeni Slack bot connection",
55
+ });
56
+ const redirectUri = `${integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl)}/v1/integrations/slack/callback`;
57
+ const state = createSignedState(requireIntegrationsStateSecret(deps.settings), {
58
+ kind: "slack_bot_install",
59
+ accountId: input.accountId,
60
+ workspaceId: input.workspaceId,
61
+ subjectId: input.subjectId,
62
+ returnPath: `/workspaces/${input.workspaceId}/capabilities`,
63
+ ...(input.connectAttemptId ? { connectAttemptId: input.connectAttemptId } : {}),
64
+ ...(input.externalContinuation
65
+ ? {
66
+ encryptedExternalContinuation: encryptEnvironmentValue(
67
+ key,
68
+ JSON.stringify(ExternalActorContinuation.parse(input.externalContinuation)),
69
+ ),
70
+ }
71
+ : {}),
72
+ ...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
73
+ });
74
+ const url = new URL("https://slack.com/oauth/v2/authorize");
75
+ url.searchParams.set("client_id", slack.clientId);
76
+ url.searchParams.set("scope", OPENGENI_SLACK_BOT_REQUESTED_SCOPES.join(","));
77
+ url.searchParams.set("redirect_uri", redirectUri);
78
+ url.searchParams.set("state", state);
79
+ return OpenGeniSlackBotInstallStart.parse({
80
+ authorizationUrl: url.toString(),
81
+ expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
82
+ });
83
+ }