@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
@@ -0,0 +1,1869 @@
1
+ import { createHash, createHmac, randomUUID } from "node:crypto";
2
+ import type { Hono } from "hono";
3
+ import { HTTPException } from "hono/http-exception";
4
+ import {
5
+ AdvanceConnectRequest,
6
+ BeginConnectRequest,
7
+ ConnectOperationRequest,
8
+ ConnectProvider,
9
+ ConnectAccount,
10
+ } from "@opengeni/contracts/connect";
11
+ import {
12
+ API_INTEGRATION_OAUTH_CREDENTIAL_ROLE,
13
+ InstallApiIntegrationRequest,
14
+ stableJson,
15
+ OPENGENI_PERSONAL_SLACK_MCP_URL,
16
+ OPENGENI_PR_REVIEW_PACK_ID,
17
+ } from "@opengeni/contracts";
18
+ import {
19
+ CORE_INTEGRATION_DEFINITIONS,
20
+ createPinnedIntegrationTransport,
21
+ } from "@opengeni/capabilities";
22
+ import {
23
+ beginConnectAttempt,
24
+ getConnectAttempt,
25
+ listPendingConnectAttempts,
26
+ withWorkspaceSubjectRls,
27
+ installApiIntegration,
28
+ listConnectionsMetadata,
29
+ getConnectionMetadata,
30
+ createConnection,
31
+ updateConnection,
32
+ encryptEnvironmentValue,
33
+ normalizedHostCredentialHeaders,
34
+ listGitHubInstallationAccessForWorkspace,
35
+ getPackInstallation,
36
+ listPrReviewAppRegistrations,
37
+ listSocialConnections,
38
+ getSocialConnection,
39
+ } from "@opengeni/db";
40
+ import {
41
+ externalActorContinuationForAuthorization,
42
+ requireAccessGrantAuthorization,
43
+ hasPermission,
44
+ requireEnvironmentEncryption,
45
+ isFikenConnection,
46
+ isOpenGeniSlackBotConnection,
47
+ prepareCapabilityEnable,
48
+ buildCapabilityCatalog,
49
+ type ApiRouteDeps,
50
+ } from "@opengeni/core";
51
+ import { isPersonalConnectionOwnerPrincipal } from "../connection-ownership";
52
+ import { requireConnectOwnerAuthority } from "../integrations/connect-authority";
53
+ import { prepareFikenTokenInstall, startFikenOAuth } from "../integrations/fiken";
54
+ import { startAtlassianOAuth } from "../integrations/atlassian";
55
+ import { startSlackBotInstall } from "../integrations/slack-install";
56
+ import { startPersonalGitHubOAuth } from "../integrations/personal-github";
57
+ import { socialOAuthClientFor, startSocialOAuth } from "../integrations/social-oauth";
58
+ import {
59
+ githubAppConnectNavigation,
60
+ prepareGitHubAppConnectAction,
61
+ } from "../integrations/github-app-connect";
62
+ import { githubAppMissingSettings, prReviewGitHubAppMissingSettings } from "@opengeni/github";
63
+ import { requireGitHubLensConnect } from "../integrations/github-lens-connect";
64
+ import { isPersonalGitHubConnection } from "@opengeni/contracts/personal-github";
65
+ import { startGoogleDriveOAuth } from "../integrations/google-drive";
66
+ import {
67
+ GOOGLE_DRIVE_CREDENTIAL_ROLE,
68
+ googleDriveScopesAllowCapability,
69
+ } from "@opengeni/contracts/google-drive";
70
+ import { ATLASSIAN_CREDENTIAL_ROLE } from "@opengeni/contracts/atlassian";
71
+ import {
72
+ curatedOAuthReadiness,
73
+ startApiIntegrationProviderOAuth,
74
+ } from "../integrations/provider-oauth";
75
+ import { resolveForRoute, validatedIntegrationInstallInput } from "./api-integrations";
76
+ import { executeConnectOperation } from "@opengeni/core";
77
+ import { startMcpOAuth, requireIntegrationsStateSecret } from "../integrations/oauth-client";
78
+ import { z } from "zod";
79
+ import {
80
+ WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
81
+ VERCEL_AI_GATEWAY_CONNECTION_DOMAIN,
82
+ } from "@opengeni/config";
83
+
84
+ /** Shared durable setup entry. Provider completion is distinct from
85
+ * subsequent preview/install; never report an OAuth token as a ready integration. */
86
+ export function registerConnectRoutes(app: Hono, deps: ApiRouteDeps): void {
87
+ const canReadAttempt = (permissions: Parameters<typeof hasPermission>[0], providerId: string) =>
88
+ ["x", "reddit", "mcp-install"].includes(providerId)
89
+ ? hasPermission(permissions, "workspace:read")
90
+ : providerId === "github-app"
91
+ ? hasPermission(permissions, "github:use") || hasPermission(permissions, "github:manage")
92
+ : providerId === "github-lens"
93
+ ? hasPermission(permissions, "workspace:read")
94
+ : hasPermission(permissions, "connections:read");
95
+ app.get("/v1/workspaces/:workspaceId/connect/catalog", async (c) => {
96
+ const authorization = await requireAccessGrantAuthorization(
97
+ c,
98
+ deps,
99
+ c.req.param("workspaceId"),
100
+ "workspace:read",
101
+ );
102
+ const external = authorization.contextIntegrity;
103
+ const canWrite = hasPermission(authorization.grant.permissions, "connections:write");
104
+ const personal = isPersonalConnectionOwnerPrincipal(authorization);
105
+ const lensPack = await getPackInstallation(
106
+ deps.db,
107
+ authorization.grant.workspaceId,
108
+ OPENGENI_PR_REVIEW_PACK_ID,
109
+ );
110
+ let mcpConfigured = false;
111
+ let credentialConfigured = false;
112
+ try {
113
+ requireEnvironmentEncryption(deps.settings);
114
+ credentialConfigured = deps.settings.integrationsEnabled;
115
+ requireIntegrationsStateSecret(deps.settings);
116
+ mcpConfigured = deps.settings.integrationsEnabled;
117
+ } catch {
118
+ /* Missing operator configuration is catalog readiness, not actor authority. */
119
+ }
120
+ return c.json([
121
+ ...(["x", "reddit"] as const).map((providerId) => {
122
+ let configured = mcpConfigured;
123
+ try {
124
+ socialOAuthClientFor(deps.settings, providerId);
125
+ } catch {
126
+ configured = false;
127
+ }
128
+ const ownership = [
129
+ ...(hasPermission(authorization.grant.permissions, "workspace:admin")
130
+ ? ["workspace" as const]
131
+ : []),
132
+ ...(personal ? ["personal" as const] : []),
133
+ ];
134
+ return ConnectProvider.parse({
135
+ id: providerId,
136
+ label: providerId === "x" ? "X" : "Reddit",
137
+ family: "social",
138
+ readiness:
139
+ !external || !ownership.length
140
+ ? "unsupported"
141
+ : configured
142
+ ? "available"
143
+ : "needs_configuration",
144
+ ownership,
145
+ setup: ["oauth"],
146
+ });
147
+ }),
148
+ ConnectProvider.parse({
149
+ id: "mcp-install",
150
+ label: "Install MCP capability",
151
+ family: "mcp",
152
+ readiness:
153
+ !external || !hasPermission(authorization.grant.permissions, "capabilities:manage")
154
+ ? "unsupported"
155
+ : deps.settings.integrationsEnabled
156
+ ? "available"
157
+ : "needs_configuration",
158
+ ownership: ["workspace"],
159
+ setup: ["installation"],
160
+ }),
161
+ ...CORE_INTEGRATION_DEFINITIONS.map((definition) => {
162
+ const readiness = curatedOAuthReadiness(deps.settings, definition);
163
+ return ConnectProvider.parse({
164
+ id: definition.id,
165
+ label: definition.name,
166
+ family: definition.provider.id,
167
+ readiness:
168
+ !external || !canWrite
169
+ ? "unsupported"
170
+ : readiness.configured
171
+ ? "available"
172
+ : "needs_configuration",
173
+ ...(!external
174
+ ? { reason: "A verified user is required by this Connect adapter" }
175
+ : !canWrite
176
+ ? { reason: "connections:write permission required" }
177
+ : !readiness.configured
178
+ ? { reason: "Deployment OAuth or encryption configuration is incomplete" }
179
+ : {}),
180
+ ownership: readiness.ownership.filter((owner) => owner !== "personal" || personal),
181
+ setup: ["oauth"],
182
+ });
183
+ }),
184
+ ...(["openapi", "graphql"] as const).map((protocol) =>
185
+ ConnectProvider.parse({
186
+ id: protocol,
187
+ label: protocol === "openapi" ? "OpenAPI service" : "GraphQL service",
188
+ family: protocol,
189
+ readiness:
190
+ !external || !canWrite
191
+ ? "unsupported"
192
+ : deps.settings.integrationsEnabled
193
+ ? "available"
194
+ : "needs_configuration",
195
+ ownership: personal ? ["workspace", "personal"] : ["workspace"],
196
+ setup: [protocol, "installation"],
197
+ }),
198
+ ),
199
+ ConnectProvider.parse({
200
+ id: "atlassian",
201
+ label: "Atlassian (Jira and Confluence)",
202
+ family: "atlassian",
203
+ readiness:
204
+ !external || !canWrite || !personal
205
+ ? "unsupported"
206
+ : mcpConfigured &&
207
+ deps.settings.atlassianClientId?.trim() &&
208
+ deps.settings.atlassianClientSecret?.trim()
209
+ ? "available"
210
+ : "needs_configuration",
211
+ ownership: personal ? ["personal"] : [],
212
+ setup: ["oauth"],
213
+ }),
214
+ ConnectProvider.parse({
215
+ id: "slack-bot",
216
+ label: "Slack workspace bot",
217
+ family: "slack",
218
+ readiness:
219
+ !external || !canWrite
220
+ ? "unsupported"
221
+ : mcpConfigured &&
222
+ deps.settings.slackClientId?.trim() &&
223
+ deps.settings.slackClientSecret?.trim() &&
224
+ deps.settings.slackSigningSecret?.trim()
225
+ ? "available"
226
+ : "needs_configuration",
227
+ ownership: ["workspace"],
228
+ setup: ["oauth"],
229
+ }),
230
+ ConnectProvider.parse({
231
+ id: "slack-personal",
232
+ label: "My Slack account",
233
+ family: "slack",
234
+ readiness:
235
+ !external || !canWrite || !personal
236
+ ? "unsupported"
237
+ : mcpConfigured &&
238
+ deps.settings.slackClientId?.trim() &&
239
+ deps.settings.slackClientSecret?.trim()
240
+ ? "available"
241
+ : "needs_configuration",
242
+ ownership: personal ? ["personal"] : [],
243
+ setup: ["oauth"],
244
+ }),
245
+ ConnectProvider.parse({
246
+ id: "github-personal",
247
+ label: "My GitHub account",
248
+ family: "github",
249
+ readiness:
250
+ !external || !canWrite || !personal
251
+ ? "unsupported"
252
+ : mcpConfigured &&
253
+ deps.settings.githubPersonalOauthEnabled &&
254
+ deps.settings.githubPersonalOauthClientId?.trim() &&
255
+ deps.settings.githubPersonalOauthClientSecret?.trim()
256
+ ? "available"
257
+ : "needs_configuration",
258
+ ownership: personal ? ["personal"] : [],
259
+ setup: ["oauth"],
260
+ }),
261
+ ConnectProvider.parse({
262
+ id: "github-app",
263
+ label: "GitHub App installation",
264
+ family: "github",
265
+ readiness:
266
+ !external || !hasPermission(authorization.grant.permissions, "github:manage")
267
+ ? "unsupported"
268
+ : githubAppMissingSettings(deps.settings).length === 0 &&
269
+ deps.settings.githubAppSlug?.trim()
270
+ ? "available"
271
+ : "needs_configuration",
272
+ ownership: ["workspace"],
273
+ setup: ["oauth", "installation"],
274
+ }),
275
+ ConnectProvider.parse({
276
+ id: "github-lens",
277
+ label: "OpenGeni Lens PR review",
278
+ family: "github-lens",
279
+ readiness:
280
+ !external ||
281
+ !hasPermission(authorization.grant.permissions, "workspace:admin") ||
282
+ !hasPermission(authorization.grant.permissions, "secrets:write")
283
+ ? "unsupported"
284
+ : credentialConfigured &&
285
+ lensPack?.status === "active" &&
286
+ deps.settings.sandboxBackend !== "selfhosted" &&
287
+ prReviewGitHubAppMissingSettings(deps.settings).length === 0
288
+ ? "available"
289
+ : "needs_configuration",
290
+ ownership: ["workspace"],
291
+ setup: ["oauth", "installation"],
292
+ }),
293
+ ...(["google-drive-knowledge", "google-drive-publish"] as const).map((id) =>
294
+ ConnectProvider.parse({
295
+ id,
296
+ label:
297
+ id === "google-drive-publish"
298
+ ? "Google Drive publishing"
299
+ : "Google Drive knowledge sources",
300
+ family: "google-drive",
301
+ readiness:
302
+ !external || !canWrite || !personal
303
+ ? "unsupported"
304
+ : mcpConfigured &&
305
+ deps.settings.googleDriveClientId?.trim() &&
306
+ deps.settings.googleDriveClientSecret?.trim()
307
+ ? "available"
308
+ : "needs_configuration",
309
+ ownership: personal ? ["personal"] : [],
310
+ setup: ["oauth"],
311
+ }),
312
+ ),
313
+ ConnectProvider.parse({
314
+ id: "fiken-token",
315
+ label: "Fiken",
316
+ family: "fiken",
317
+ readiness:
318
+ !external || !canWrite
319
+ ? "unsupported"
320
+ : credentialConfigured
321
+ ? "available"
322
+ : "needs_configuration",
323
+ ownership: ["workspace"],
324
+ setup: ["credentials"],
325
+ }),
326
+ ConnectProvider.parse({
327
+ id: "fiken-oauth",
328
+ label: "Fiken (OAuth)",
329
+ family: "fiken",
330
+ readiness:
331
+ !external || !canWrite
332
+ ? "unsupported"
333
+ : mcpConfigured &&
334
+ deps.settings.fikenClientId?.trim() &&
335
+ deps.settings.fikenClientSecret?.trim()
336
+ ? "available"
337
+ : "needs_configuration",
338
+ ownership: ["workspace"],
339
+ setup: ["oauth"],
340
+ }),
341
+ ConnectProvider.parse({
342
+ id: "mcp-oauth",
343
+ label: "MCP server (OAuth)",
344
+ family: "mcp",
345
+ readiness:
346
+ !external || !canWrite
347
+ ? "unsupported"
348
+ : mcpConfigured
349
+ ? "available"
350
+ : "needs_configuration",
351
+ ownership: personal ? ["workspace", "personal"] : ["workspace"],
352
+ setup: ["credentials", "oauth"],
353
+ }),
354
+ ConnectProvider.parse({
355
+ id: "mcp-headers",
356
+ label: "MCP server (custom headers)",
357
+ family: "mcp",
358
+ readiness:
359
+ !external || !canWrite
360
+ ? "unsupported"
361
+ : credentialConfigured
362
+ ? "available"
363
+ : "needs_configuration",
364
+ ownership: personal ? ["workspace", "personal"] : ["workspace"],
365
+ setup: ["credentials"],
366
+ }),
367
+ ConnectProvider.parse({
368
+ id: "mcp-bearer",
369
+ label: "MCP server (bearer credential)",
370
+ family: "mcp",
371
+ readiness:
372
+ !external || !canWrite
373
+ ? "unsupported"
374
+ : credentialConfigured
375
+ ? "available"
376
+ : "needs_configuration",
377
+ ownership: personal ? ["workspace", "personal"] : ["workspace"],
378
+ setup: ["credentials"],
379
+ }),
380
+ ]);
381
+ });
382
+ app.get("/v1/workspaces/:workspaceId/connect/accounts", async (c) => {
383
+ const workspaceId = c.req.param("workspaceId");
384
+ const { grant } = await requireAccessGrantAuthorization(c, deps, workspaceId, "workspace:read");
385
+ const connections = hasPermission(grant.permissions, "connections:read")
386
+ ? await listConnectionsMetadata(deps.db, workspaceId, grant.subjectId)
387
+ : [];
388
+ const githubInstallations =
389
+ hasPermission(grant.permissions, "github:use") ||
390
+ hasPermission(grant.permissions, "github:manage")
391
+ ? await listGitHubInstallationAccessForWorkspace(deps.db, workspaceId)
392
+ : [];
393
+ const lensRegistrations = await listPrReviewAppRegistrations(
394
+ deps.db,
395
+ grant.accountId,
396
+ workspaceId,
397
+ );
398
+ const socialConnections = await listSocialConnections(
399
+ deps.db,
400
+ workspaceId,
401
+ 200,
402
+ grant.subjectId,
403
+ );
404
+ return c.json([
405
+ ...socialConnections
406
+ .filter((connection) => ["x", "reddit"].includes(connection.provider))
407
+ .map((connection) =>
408
+ ConnectAccount.parse({
409
+ id: `social:${connection.id}`,
410
+ version: connection.version,
411
+ providerId: connection.provider,
412
+ label: connection.accountHandle,
413
+ ownership: connection.ownership,
414
+ status:
415
+ connection.status === "connected"
416
+ ? "connected"
417
+ : connection.status === "disabled"
418
+ ? "disabled"
419
+ : "auth_needed",
420
+ }),
421
+ ),
422
+ ...lensRegistrations
423
+ .filter((registration) => registration.credentialKind === "managed_github_app")
424
+ .map((registration) =>
425
+ ConnectAccount.parse({
426
+ id: `lens-registration:${registration.id}`,
427
+ providerId: "github-lens",
428
+ label: registration.providerAccountLogin ?? "Lens installation",
429
+ ownership: "workspace",
430
+ status: registration.status === "active" ? "connected" : "disabled",
431
+ }),
432
+ ),
433
+ ...githubInstallations.map((installation) =>
434
+ ConnectAccount.parse({
435
+ id: `github-installation:${installation.installationId}`,
436
+ providerId: "github-app",
437
+ label: installation.accountLogin ?? `Installation ${installation.installationId}`,
438
+ ownership: "workspace",
439
+ status: "connected",
440
+ }),
441
+ ),
442
+ ...connections.flatMap((connection) => {
443
+ if (isPersonalGitHubConnection(connection))
444
+ return [
445
+ ConnectAccount.parse({
446
+ id: connection.id,
447
+ version: connection.version,
448
+ providerId: "github-personal",
449
+ label: String(connection.metadata.githubLogin ?? "GitHub"),
450
+ ownership: "personal",
451
+ status:
452
+ connection.status === "revoked"
453
+ ? "disabled"
454
+ : connection.status === "active"
455
+ ? "connected"
456
+ : "auth_needed",
457
+ }),
458
+ ];
459
+ if (isOpenGeniSlackBotConnection(connection))
460
+ return [
461
+ ConnectAccount.parse({
462
+ id: connection.id,
463
+ version: connection.version,
464
+ providerId: "slack-bot",
465
+ label: String(connection.metadata.slackTeamName ?? "Slack workspace bot"),
466
+ ownership: "workspace",
467
+ status:
468
+ connection.status === "revoked"
469
+ ? "disabled"
470
+ : connection.status === "active"
471
+ ? "connected"
472
+ : "auth_needed",
473
+ }),
474
+ ];
475
+ if (connection.metadata.credentialRole === GOOGLE_DRIVE_CREDENTIAL_ROLE)
476
+ return [
477
+ ConnectAccount.parse({
478
+ id: connection.id,
479
+ version: connection.version,
480
+ providerId: googleDriveScopesAllowCapability(connection.grantedScopes, "publish_file")
481
+ ? "google-drive-publish"
482
+ : "google-drive-knowledge",
483
+ label: String(
484
+ connection.metadata.googleEmail ??
485
+ connection.metadata.googleDisplayName ??
486
+ "Google Drive",
487
+ ),
488
+ ownership: "personal",
489
+ status:
490
+ connection.status === "revoked"
491
+ ? "disabled"
492
+ : connection.status === "active"
493
+ ? "connected"
494
+ : "auth_needed",
495
+ }),
496
+ ];
497
+ if (connection.metadata.credentialRole === ATLASSIAN_CREDENTIAL_ROLE)
498
+ return [
499
+ ConnectAccount.parse({
500
+ id: connection.id,
501
+ version: connection.version,
502
+ providerId: "atlassian",
503
+ label: String(connection.metadata.displayName ?? "Atlassian"),
504
+ ownership: "personal",
505
+ status:
506
+ connection.status === "revoked"
507
+ ? "disabled"
508
+ : connection.status === "active"
509
+ ? "connected"
510
+ : "auth_needed",
511
+ }),
512
+ ];
513
+ if (isFikenConnection(connection))
514
+ return [
515
+ ConnectAccount.parse({
516
+ id: connection.id,
517
+ version: connection.version,
518
+ providerId: connection.kind === "oauth2" ? "fiken-oauth" : "fiken-token",
519
+ label: "Fiken",
520
+ ownership: "workspace",
521
+ status:
522
+ connection.status === "revoked"
523
+ ? "disabled"
524
+ : connection.status === "active"
525
+ ? "connected"
526
+ : "auth_needed",
527
+ }),
528
+ ];
529
+ if (
530
+ connection.kind === "api_key" &&
531
+ ["mcp-bearer", "mcp-headers"].includes(String(connection.metadata.connectAdapter))
532
+ )
533
+ return [
534
+ ConnectAccount.parse({
535
+ id: connection.id,
536
+ version: connection.version,
537
+ providerId: connection.metadata.connectAdapter,
538
+ label: connection.providerDomain,
539
+ ownership: connection.subjectId === null ? "workspace" : "personal",
540
+ status:
541
+ connection.status === "revoked"
542
+ ? "disabled"
543
+ : connection.status === "active"
544
+ ? "connected"
545
+ : "auth_needed",
546
+ }),
547
+ ];
548
+ if (
549
+ connection.kind === "oauth2" &&
550
+ connection.metadata.oauthDiscovery &&
551
+ typeof connection.metadata.mcpUrl === "string"
552
+ )
553
+ return [
554
+ ConnectAccount.parse({
555
+ id: connection.id,
556
+ version: connection.version,
557
+ providerId:
558
+ connection.metadata.mcpUrl === OPENGENI_PERSONAL_SLACK_MCP_URL
559
+ ? "slack-personal"
560
+ : "mcp-oauth",
561
+ label: connection.providerDomain,
562
+ ownership: connection.subjectId === null ? "workspace" : "personal",
563
+ status:
564
+ connection.status === "revoked"
565
+ ? "disabled"
566
+ : connection.status === "active"
567
+ ? "connected"
568
+ : "auth_needed",
569
+ }),
570
+ ];
571
+ if (connection.metadata.credentialRole !== API_INTEGRATION_OAUTH_CREDENTIAL_ROLE) return [];
572
+ const ids = connection.metadata.authorizedDefinitionIds;
573
+ if (!Array.isArray(ids)) return [];
574
+ return CORE_INTEGRATION_DEFINITIONS.filter(
575
+ (definition) =>
576
+ ids.includes(definition.id) &&
577
+ definition.provider.id === connection.metadata.providerFamily,
578
+ ).map((definition) =>
579
+ ConnectAccount.parse({
580
+ id: connection.id,
581
+ version: connection.version,
582
+ providerId: definition.id,
583
+ label: String(
584
+ connection.metadata.providerDisplayName ??
585
+ connection.metadata.providerEmail ??
586
+ connection.metadata.providerPrincipalId ??
587
+ connection.id,
588
+ ).slice(0, 256),
589
+ ownership: connection.subjectId === null ? "workspace" : "personal",
590
+ status:
591
+ connection.status === "revoked"
592
+ ? "disabled"
593
+ : connection.status !== "active"
594
+ ? "auth_needed"
595
+ : "connected",
596
+ }),
597
+ );
598
+ }),
599
+ ]);
600
+ });
601
+ const transport = createPinnedIntegrationTransport({
602
+ network: deps.settings,
603
+ ...(deps.apiIntegrationSourceFetch ? { fetchImpl: deps.apiIntegrationSourceFetch } : {}),
604
+ });
605
+ app.post("/v1/workspaces/:workspaceId/connect/attempts/:attemptId/advance", async (c) => {
606
+ const input = AdvanceConnectRequest.parse(await c.req.json());
607
+ const action = input.action;
608
+ const workspaceId = c.req.param("workspaceId");
609
+ const permission =
610
+ action.type === "install"
611
+ ? "capabilities:manage"
612
+ : action.type === "credentials"
613
+ ? "connections:write"
614
+ : "workspace:read";
615
+ // Resolve the authenticated workspace actor first, then enforce the exact
616
+ // stored provider/action permission below. Setup managers need not also
617
+ // carry the unrelated workspace metadata read permission.
618
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
619
+ const continuation = externalActorContinuationForAuthorization(authorization);
620
+ if (!authorization.contextIntegrity)
621
+ throw new HTTPException(403, { message: "Verified Connect authority required" });
622
+ const scope = {
623
+ accountId: authorization.grant.accountId,
624
+ workspaceId,
625
+ subjectId: authorization.grant.subjectId,
626
+ personalOwnerVerified: isPersonalConnectionOwnerPrincipal(authorization),
627
+ ...(continuation ? { externalContinuation: continuation } : {}),
628
+ };
629
+ // Reject invalid caller selections before taking a durable operation claim.
630
+ // Older revisions still go through the receipt-aware claim path for replay.
631
+ const stored = await getConnectAttempt(deps.db, scope, c.req.param("attemptId"));
632
+ const before = stored.attempt;
633
+ const advancePermission =
634
+ before.providerId === "mcp-install" ? "capabilities:manage" : permission;
635
+ if (!hasPermission(authorization.grant.permissions, advancePermission))
636
+ throw new HTTPException(403, { message: `${advancePermission} required` });
637
+ if (before.providerId === "mcp-install") {
638
+ if (action.type !== "credentials")
639
+ throw new HTTPException(422, { message: "Choose an MCP capability" });
640
+ const values = z
641
+ .object({ capabilityId: z.string().min(1).max(512) })
642
+ .strict()
643
+ .parse(action.values);
644
+ return c.json(
645
+ await executeConnectOperation({
646
+ db: deps.db,
647
+ scope,
648
+ attemptId: before.id,
649
+ expectedRevision: input.expectedRevision,
650
+ operationId: input.idempotencyKey,
651
+ inputDigest: createHash("sha256").update(stableJson(input)).digest("hex"),
652
+ authorize: (tx, _attempt, origin) =>
653
+ requireConnectOwnerAuthority(tx, scope, "capabilities:manage", origin),
654
+ execute: async (attempt) => {
655
+ if (attempt.state !== "credential_input")
656
+ throw new HTTPException(409, { message: "Reload MCP setup" });
657
+ const prepared = await prepareCapabilityEnable({
658
+ db: deps.db,
659
+ settings: deps.settings,
660
+ grant: authorization.grant,
661
+ accountId: scope.accountId,
662
+ workspaceId,
663
+ capabilityId: values.capabilityId,
664
+ payload: { config: {}, metadata: {}, headers: {} },
665
+ });
666
+ return {
667
+ commit: async (tx, current) => {
668
+ await prepared.commit(tx);
669
+ return {
670
+ ...current,
671
+ revision: current.revision + 1,
672
+ state: "complete",
673
+ completionRequirement: "provider_setup",
674
+ integrationInstalled: true,
675
+ nextAction: { type: "none" },
676
+ };
677
+ },
678
+ };
679
+ },
680
+ }),
681
+ );
682
+ }
683
+ if (["github-app", "github-lens"].includes(before.providerId)) {
684
+ const installationPermission =
685
+ before.providerId === "github-lens" ? "workspace:admin" : "github:manage";
686
+ if (
687
+ !hasPermission(authorization.grant.permissions, installationPermission) ||
688
+ (before.providerId === "github-lens" &&
689
+ !hasPermission(authorization.grant.permissions, "secrets:write"))
690
+ )
691
+ throw new HTTPException(403, { message: "Installation management permission required" });
692
+ return c.json(
693
+ await executeConnectOperation({
694
+ db: deps.db,
695
+ scope,
696
+ attemptId: before.id,
697
+ expectedRevision: input.expectedRevision,
698
+ operationId: input.idempotencyKey,
699
+ inputDigest: createHash("sha256").update(stableJson(input)).digest("hex"),
700
+ authorize: async (tx, _attempt, origin) => {
701
+ await requireConnectOwnerAuthority(tx, scope, installationPermission, origin);
702
+ if (before.providerId === "github-lens") {
703
+ await requireConnectOwnerAuthority(tx, scope, "secrets:write", origin);
704
+ await requireGitHubLensConnect({ ...deps, db: tx }, workspaceId);
705
+ }
706
+ },
707
+ execute: async (attempt) =>
708
+ prepareGitHubAppConnectAction(deps, scope, c.req.url, attempt, action),
709
+ }),
710
+ );
711
+ }
712
+ if (["openapi", "graphql"].includes(before.providerId) && action.type === "credentials") {
713
+ const values = z
714
+ .object({
715
+ url: z.string().url().max(2048),
716
+ connectionId: z.string().uuid().or(z.literal("")).optional(),
717
+ })
718
+ .strict()
719
+ .parse(action.values);
720
+ const source =
721
+ before.providerId === "openapi"
722
+ ? { kind: "openapi" as const, url: values.url }
723
+ : { kind: "graphql" as const, endpoint: values.url };
724
+ const connectionId = values.connectionId || undefined;
725
+ return c.json(
726
+ await executeConnectOperation({
727
+ db: deps.db,
728
+ scope,
729
+ attemptId: before.id,
730
+ expectedRevision: input.expectedRevision,
731
+ operationId: input.idempotencyKey,
732
+ inputDigest: createHash("sha256").update(stableJson(input)).digest("hex"),
733
+ authorize: (tx, _attempt, origin) =>
734
+ requireConnectOwnerAuthority(tx, scope, "connections:write", origin),
735
+ execute: async (attempt) => {
736
+ if (attempt.state !== "credential_input")
737
+ throw new HTTPException(409, { message: "Reload setup before changing the source" });
738
+ const resolved = await resolveForRoute({
739
+ deps,
740
+ transport,
741
+ workspaceId,
742
+ subjectId: scope.subjectId,
743
+ payload: {
744
+ source,
745
+ ownership: attempt.ownership,
746
+ ...(connectionId ? { connectionId } : {}),
747
+ },
748
+ });
749
+ const account = connectionId
750
+ ? await getConnectionMetadata(deps.db, workspaceId, connectionId, scope.subjectId)
751
+ : null;
752
+ if (
753
+ connectionId &&
754
+ (!account ||
755
+ (account.subjectId === null ? "workspace" : "personal") !== attempt.ownership)
756
+ )
757
+ throw new HTTPException(404, {
758
+ message: "Connection unavailable for the requested ownership",
759
+ });
760
+ if (!account && attempt.ownership === "personal")
761
+ throw new HTTPException(422, {
762
+ message: "Personal installation requires a personal connection",
763
+ });
764
+ if (!account && resolved.preview.auth.kind !== "none")
765
+ throw new HTTPException(422, {
766
+ message: "Select a connection for this authenticated service",
767
+ });
768
+ return {
769
+ commit: async (_tx, current) => ({
770
+ ...current,
771
+ source,
772
+ revision: current.revision + 1,
773
+ state: "preview",
774
+ credentialsCommitted: !!account,
775
+ ...(account
776
+ ? {
777
+ account: {
778
+ id: account.id,
779
+ version: account.version,
780
+ providerId: attempt.providerId,
781
+ label: account.providerDomain,
782
+ ownership: attempt.ownership,
783
+ status: "connected" as const,
784
+ },
785
+ }
786
+ : {}),
787
+ nextAction: {
788
+ type: "preview",
789
+ previewId: resolved.preview.revisionId,
790
+ contentHash: resolved.preview.contentSha256,
791
+ operations: resolved.preview.tools.map((tool) => ({
792
+ id: tool.id,
793
+ label: tool.name,
794
+ kind: tool.safety,
795
+ })),
796
+ },
797
+ }),
798
+ };
799
+ },
800
+ }),
801
+ );
802
+ }
803
+ if (before.providerId === "fiken-token") {
804
+ if (action.type !== "credentials" || before.ownership !== "workspace")
805
+ throw new HTTPException(422, { message: "Fiken requires workspace-owned token setup" });
806
+ const values = z
807
+ .object({
808
+ apiToken: z.string().min(1).max(16_384),
809
+ defaultCompanySlug: z.string().max(128).optional(),
810
+ })
811
+ .strict()
812
+ .parse(action.values);
813
+ return c.json(
814
+ await executeConnectOperation({
815
+ db: deps.db,
816
+ scope,
817
+ attemptId: before.id,
818
+ expectedRevision: input.expectedRevision,
819
+ operationId: input.idempotencyKey,
820
+ inputDigest: createHmac("sha256", requireEnvironmentEncryption(deps.settings))
821
+ .update(stableJson(input))
822
+ .digest("hex"),
823
+ authorize: (tx, _attempt, origin) =>
824
+ requireConnectOwnerAuthority(tx, scope, "connections:write", origin),
825
+ execute: async (attempt) => {
826
+ if (attempt.state !== "credential_input")
827
+ throw new HTTPException(409, { message: "Reload Fiken setup" });
828
+ const persist = await prepareFikenTokenInstall(
829
+ deps,
830
+ scope,
831
+ {
832
+ apiToken: values.apiToken,
833
+ ...(values.defaultCompanySlug
834
+ ? { defaultCompanySlug: values.defaultCompanySlug }
835
+ : {}),
836
+ ...(attempt.account ? { connectionId: attempt.account.id } : {}),
837
+ },
838
+ attempt.account?.version,
839
+ );
840
+ return {
841
+ commit: async (tx, current) => {
842
+ const connection = await persist(tx);
843
+ return {
844
+ ...current,
845
+ revision: current.revision + 1,
846
+ state: "complete",
847
+ credentialsCommitted: true,
848
+ nextAction: { type: "none" },
849
+ account: {
850
+ id: connection.id,
851
+ version: connection.version,
852
+ providerId: "fiken-token",
853
+ label: "Fiken",
854
+ ownership: "workspace",
855
+ status: "connected",
856
+ },
857
+ };
858
+ },
859
+ };
860
+ },
861
+ }),
862
+ );
863
+ }
864
+ if (["mcp-bearer", "mcp-headers"].includes(before.providerId)) {
865
+ if (action.type !== "credentials")
866
+ throw new HTTPException(422, { message: "MCP credentials required" });
867
+ const values =
868
+ before.providerId === "mcp-headers"
869
+ ? z
870
+ .object({
871
+ mcpUrl: z.string().url().max(4096),
872
+ headers: z.string().min(2).max(65_536),
873
+ })
874
+ .strict()
875
+ .parse(action.values)
876
+ : z
877
+ .object({
878
+ mcpUrl: z.string().url().max(4096),
879
+ token: z
880
+ .string()
881
+ .min(1)
882
+ .max(16_384)
883
+ .regex(/^[^\u0000-\u0020\u007f]+$/),
884
+ })
885
+ .strict()
886
+ .parse(action.values);
887
+ let credentialHeaders: Record<string, string>;
888
+ try {
889
+ credentialHeaders =
890
+ "headers" in values
891
+ ? normalizedHostCredentialHeaders(
892
+ z.record(z.string(), z.string()).parse(JSON.parse(values.headers)),
893
+ )
894
+ : { authorization: `Bearer ${values.token}` };
895
+ } catch {
896
+ throw new HTTPException(422, {
897
+ message: "Supply a JSON object of valid credential headers",
898
+ });
899
+ }
900
+ const destination = new URL(values.mcpUrl);
901
+ if (
902
+ [WORKSPACE_OPENROUTER_CONNECTION_DOMAIN, VERCEL_AI_GATEWAY_CONNECTION_DOMAIN].some(
903
+ (domain) => domain === destination.hostname,
904
+ )
905
+ )
906
+ throw new HTTPException(422, {
907
+ message: "Use the dedicated workspace provider credential flow",
908
+ });
909
+ if (
910
+ destination.protocol !== "https:" ||
911
+ destination.username ||
912
+ destination.password ||
913
+ destination.hash
914
+ )
915
+ throw new HTTPException(422, {
916
+ message: "MCP credential destination requires HTTPS without userinfo or fragment",
917
+ });
918
+ const encryptionKey = requireEnvironmentEncryption(deps.settings);
919
+ const mcpUrl = destination.toString();
920
+ return c.json(
921
+ await executeConnectOperation({
922
+ db: deps.db,
923
+ scope,
924
+ attemptId: before.id,
925
+ expectedRevision: input.expectedRevision,
926
+ operationId: input.idempotencyKey,
927
+ inputDigest: createHmac("sha256", encryptionKey).update(stableJson(input)).digest("hex"),
928
+ authorize: (tx, _attempt, origin) =>
929
+ requireConnectOwnerAuthority(tx, scope, "connections:write", origin),
930
+ execute: async (attempt) => {
931
+ if (attempt.state !== "credential_input")
932
+ throw new HTTPException(409, { message: "Reload the current credential setup" });
933
+ const credentialEncrypted = encryptEnvironmentValue(
934
+ encryptionKey,
935
+ JSON.stringify({ headers: credentialHeaders }),
936
+ );
937
+ return {
938
+ commit: async (tx, current) => {
939
+ const existing = current.account
940
+ ? await getConnectionMetadata(
941
+ tx,
942
+ workspaceId,
943
+ current.account.id,
944
+ scope.subjectId,
945
+ )
946
+ : null;
947
+ if (
948
+ current.account &&
949
+ (!existing ||
950
+ existing.metadata.connectAdapter !== current.providerId ||
951
+ existing.metadata.mcpUrl !== mcpUrl ||
952
+ existing.version !== current.account.version ||
953
+ (existing.subjectId === null ? "workspace" : "personal") !== current.ownership)
954
+ )
955
+ throw new HTTPException(409, {
956
+ message: "MCP account changed; reload before replacing credentials",
957
+ });
958
+ const metadata = { connectAdapter: current.providerId, mcpUrl, resource: mcpUrl };
959
+ const subjectId = current.ownership === "personal" ? scope.subjectId : null;
960
+ const connection = existing
961
+ ? await updateConnection(tx, {
962
+ workspaceId,
963
+ connectionId: existing.id,
964
+ expectedVersion: existing.version,
965
+ visibleToSubjectId: scope.subjectId,
966
+ subjectId,
967
+ providerDomain: destination.hostname,
968
+ kind: "api_key",
969
+ status: "active",
970
+ credentialEncrypted,
971
+ grantedScopes: [],
972
+ expiresAt: null,
973
+ metadata,
974
+ updatedBySubjectId: scope.subjectId,
975
+ })
976
+ : await createConnection(tx, {
977
+ accountId: scope.accountId,
978
+ workspaceId,
979
+ subjectId,
980
+ providerDomain: destination.hostname,
981
+ kind: "api_key",
982
+ credentialEncrypted,
983
+ grantedScopes: [],
984
+ expiresAt: null,
985
+ metadata,
986
+ createdBySubjectId: scope.subjectId,
987
+ });
988
+ if (!connection) throw new HTTPException(409, { message: "MCP account changed" });
989
+ return {
990
+ ...current,
991
+ revision: current.revision + 1,
992
+ state: "complete",
993
+ credentialsCommitted: true,
994
+ nextAction: { type: "none" },
995
+ account: {
996
+ id: connection.id,
997
+ version: connection.version,
998
+ providerId: current.providerId,
999
+ label: connection.providerDomain,
1000
+ ownership: current.ownership,
1001
+ status: "connected",
1002
+ },
1003
+ };
1004
+ },
1005
+ };
1006
+ },
1007
+ }),
1008
+ );
1009
+ }
1010
+ if (["mcp-oauth", "slack-personal"].includes(before.providerId)) {
1011
+ if (action.type !== "credentials")
1012
+ throw new HTTPException(422, { message: "MCP OAuth requires a server URL" });
1013
+ const values =
1014
+ before.providerId === "slack-personal"
1015
+ ? { mcpUrl: OPENGENI_PERSONAL_SLACK_MCP_URL }
1016
+ : z
1017
+ .object({ mcpUrl: z.string().url().max(4096) })
1018
+ .strict()
1019
+ .parse(action.values);
1020
+ return c.json(
1021
+ await executeConnectOperation({
1022
+ db: deps.db,
1023
+ scope,
1024
+ attemptId: before.id,
1025
+ expectedRevision: input.expectedRevision,
1026
+ operationId: input.idempotencyKey,
1027
+ inputDigest: createHmac("sha256", requireIntegrationsStateSecret(deps.settings))
1028
+ .update(stableJson(input))
1029
+ .digest("hex"),
1030
+ authorize: (tx, _attempt, origin) =>
1031
+ requireConnectOwnerAuthority(tx, scope, "connections:write", origin),
1032
+ execute: async (attempt) => {
1033
+ if (attempt.state !== "credential_input")
1034
+ throw new HTTPException(409, { message: "Reload the current connection setup" });
1035
+ const started = await startMcpOAuth(deps, {
1036
+ ...scope,
1037
+ ...(continuation ? { externalContinuation: continuation } : {}),
1038
+ connectAttemptId: attempt.id,
1039
+ personalOwnershipAllowed: isPersonalConnectionOwnerPrincipal(authorization),
1040
+ requestUrl: c.req.url,
1041
+ payload: {
1042
+ requestedScopes: [],
1043
+ mcpUrl: values.mcpUrl,
1044
+ ownership: attempt.ownership,
1045
+ returnUrl: stored.returnUrl,
1046
+ ...(attempt.account ? { connectionId: attempt.account.id } : {}),
1047
+ },
1048
+ });
1049
+ const authorizationUrl = started.authorizationUrl;
1050
+ if (!authorizationUrl)
1051
+ throw new HTTPException(502, {
1052
+ message: "provider did not return an authorization URL",
1053
+ });
1054
+ return {
1055
+ commit: async (_tx, current) => ({
1056
+ ...current,
1057
+ revision: current.revision + 1,
1058
+ state: "requires_user_action",
1059
+ nextAction: { type: "authorize", url: authorizationUrl },
1060
+ }),
1061
+ };
1062
+ },
1063
+ }),
1064
+ );
1065
+ }
1066
+ if (action.type !== "retry" && action.type !== "install")
1067
+ throw new HTTPException(422, {
1068
+ message: "this provider action is not supported by curated OAuth",
1069
+ });
1070
+ if (before.revision === input.expectedRevision) {
1071
+ if (
1072
+ (!before.source && (!before.credentialsCommitted || !before.account)) ||
1073
+ !["connected_but_incomplete", "preview"].includes(before.state)
1074
+ )
1075
+ throw new HTTPException(409, { message: "connection is not ready for preview or install" });
1076
+ if (action.type === "install") {
1077
+ const preview = before.nextAction;
1078
+ if (
1079
+ preview.type !== "preview" ||
1080
+ preview.previewId !== action.previewId ||
1081
+ preview.contentHash !== action.contentHash ||
1082
+ action.operationIds.some(
1083
+ (id) => !preview.operations.some((operation) => operation.id === id),
1084
+ )
1085
+ )
1086
+ throw new HTTPException(409, {
1087
+ message: "review the current preview before installation",
1088
+ });
1089
+ }
1090
+ }
1091
+ return c.json(
1092
+ await executeConnectOperation({
1093
+ db: deps.db,
1094
+ scope,
1095
+ attemptId: c.req.param("attemptId"),
1096
+ expectedRevision: input.expectedRevision,
1097
+ operationId: input.idempotencyKey,
1098
+ inputDigest: createHash("sha256").update(stableJson(input)).digest("hex"),
1099
+ authorize: (tx, _attempt, origin) =>
1100
+ requireConnectOwnerAuthority(tx, scope, permission, origin),
1101
+ execute: async (attempt) => {
1102
+ if (
1103
+ (!attempt.source && (!attempt.credentialsCommitted || !attempt.account)) ||
1104
+ !["connected_but_incomplete", "preview"].includes(attempt.state)
1105
+ )
1106
+ throw new HTTPException(409, {
1107
+ message: "connection is not ready for preview or install",
1108
+ });
1109
+ if (
1110
+ action.type === "install" &&
1111
+ (attempt.nextAction.type !== "preview" ||
1112
+ attempt.nextAction.previewId !== action.previewId ||
1113
+ attempt.nextAction.contentHash !== action.contentHash)
1114
+ )
1115
+ throw new HTTPException(409, {
1116
+ message: "review the current preview before installation",
1117
+ });
1118
+ const previewRequest = {
1119
+ source: attempt.source ?? {
1120
+ kind: "definition" as const,
1121
+ definitionId: attempt.providerId,
1122
+ },
1123
+ ...(attempt.account ? { connectionId: attempt.account.id } : {}),
1124
+ ownership: attempt.ownership,
1125
+ };
1126
+ const resolved = await resolveForRoute({
1127
+ deps,
1128
+ transport,
1129
+ workspaceId,
1130
+ subjectId: scope.subjectId,
1131
+ payload: previewRequest,
1132
+ });
1133
+ const preparedPreview = (changed = false) => ({
1134
+ commit: async () => ({
1135
+ ...attempt,
1136
+ error: changed
1137
+ ? {
1138
+ code: "source_changed",
1139
+ message: "The source changed; review the new operations before installing.",
1140
+ retryable: true,
1141
+ }
1142
+ : undefined,
1143
+ revision: attempt.revision + 1,
1144
+ state: "preview" as const,
1145
+ nextAction: {
1146
+ type: "preview" as const,
1147
+ previewId: resolved.preview.revisionId,
1148
+ contentHash: resolved.preview.contentSha256,
1149
+ operations: resolved.preview.tools.map((tool) => ({
1150
+ id: tool.id,
1151
+ label: tool.name,
1152
+ kind: tool.safety,
1153
+ })),
1154
+ },
1155
+ }),
1156
+ });
1157
+ if (action.type === "retry") return preparedPreview();
1158
+ // Always pass the explicit selection; omission must never select all tools.
1159
+ const payload = InstallApiIntegrationRequest.parse({
1160
+ ...previewRequest,
1161
+ expectedRevisionId: action.previewId,
1162
+ expectedContentSha256: action.contentHash,
1163
+ allowedTools: action.operationIds,
1164
+ ...attempt.installationTarget,
1165
+ });
1166
+ let installation: ReturnType<typeof validatedIntegrationInstallInput>;
1167
+ try {
1168
+ installation = validatedIntegrationInstallInput(
1169
+ authorization.grant,
1170
+ workspaceId,
1171
+ payload,
1172
+ resolved,
1173
+ );
1174
+ } catch (error) {
1175
+ // The shared validator's 409 means the reviewed source changed.
1176
+ // No installation write has run; persist a fresh, unselected preview.
1177
+ if (error instanceof HTTPException && error.status === 409)
1178
+ return preparedPreview(true);
1179
+ throw error;
1180
+ }
1181
+ return {
1182
+ commit: async (tx) => {
1183
+ await installApiIntegration(tx, installation);
1184
+ return {
1185
+ ...attempt,
1186
+ error: undefined,
1187
+ revision: attempt.revision + 1,
1188
+ state: "complete" as const,
1189
+ integrationInstalled: true,
1190
+ nextAction: { type: "none" as const },
1191
+ };
1192
+ },
1193
+ };
1194
+ },
1195
+ }),
1196
+ );
1197
+ });
1198
+ app.post("/v1/workspaces/:workspaceId/connect/attempts/:attemptId/cancel", async (c) => {
1199
+ const input = ConnectOperationRequest.parse(await c.req.json());
1200
+ const workspaceId = c.req.param("workspaceId");
1201
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
1202
+ const continuation = externalActorContinuationForAuthorization(authorization);
1203
+ if (!authorization.contextIntegrity)
1204
+ throw new HTTPException(403, { message: "Verified Connect authority required" });
1205
+ const scope = {
1206
+ accountId: authorization.grant.accountId,
1207
+ workspaceId,
1208
+ subjectId: authorization.grant.subjectId,
1209
+ personalOwnerVerified: isPersonalConnectionOwnerPrincipal(authorization),
1210
+ ...(continuation ? { externalContinuation: continuation } : {}),
1211
+ };
1212
+ return c.json(
1213
+ await executeConnectOperation({
1214
+ db: deps.db,
1215
+ scope,
1216
+ attemptId: c.req.param("attemptId"),
1217
+ expectedRevision: input.expectedRevision,
1218
+ operationId: input.idempotencyKey,
1219
+ inputDigest: createHash("sha256")
1220
+ .update(stableJson({ ...input, action: "cancel" }))
1221
+ .digest("hex"),
1222
+ authorize: async (tx, attempt, origin) => {
1223
+ const permission =
1224
+ attempt.providerId === "mcp-install"
1225
+ ? "capabilities:manage"
1226
+ : ["x", "reddit"].includes(attempt.providerId)
1227
+ ? attempt.ownership === "workspace"
1228
+ ? "workspace:admin"
1229
+ : "workspace:read"
1230
+ : attempt.providerId === "github-app"
1231
+ ? "github:manage"
1232
+ : attempt.providerId === "github-lens"
1233
+ ? "workspace:admin"
1234
+ : "connections:write";
1235
+ if (
1236
+ !hasPermission(authorization.grant.permissions, permission) ||
1237
+ (attempt.providerId === "github-lens" &&
1238
+ !hasPermission(authorization.grant.permissions, "secrets:write"))
1239
+ )
1240
+ throw new HTTPException(403, { message: "Connect management permission required" });
1241
+ await requireConnectOwnerAuthority(tx, scope, permission, origin);
1242
+ if (attempt.providerId === "github-lens")
1243
+ await requireConnectOwnerAuthority(tx, scope, "secrets:write", origin);
1244
+ },
1245
+ execute: async () => ({
1246
+ commit: async (_tx, current) => ({
1247
+ ...current,
1248
+ revision: current.revision + 1,
1249
+ state: "cancelled",
1250
+ nextAction: { type: "none" },
1251
+ }),
1252
+ }),
1253
+ }),
1254
+ );
1255
+ });
1256
+ app.post("/v1/workspaces/:workspaceId/connect/attempts", async (c) => {
1257
+ if (!deps.settings.integrationsEnabled)
1258
+ throw new HTTPException(403, { message: "integrations disabled" });
1259
+ const workspaceId = c.req.param("workspaceId");
1260
+ const input = BeginConnectRequest.parse(await c.req.json());
1261
+ const setupPermission =
1262
+ input.providerId === "mcp-install"
1263
+ ? "capabilities:manage"
1264
+ : ["x", "reddit"].includes(input.providerId)
1265
+ ? input.ownership === "workspace"
1266
+ ? "workspace:admin"
1267
+ : "workspace:read"
1268
+ : input.providerId === "github-app"
1269
+ ? "github:manage"
1270
+ : input.providerId === "github-lens"
1271
+ ? "workspace:admin"
1272
+ : "connections:write";
1273
+ const authorization = await requireAccessGrantAuthorization(
1274
+ c,
1275
+ deps,
1276
+ workspaceId,
1277
+ setupPermission,
1278
+ );
1279
+ const continuation = externalActorContinuationForAuthorization(authorization);
1280
+ if (!authorization.contextIntegrity)
1281
+ throw new HTTPException(403, {
1282
+ message: "Verified Connect authority required",
1283
+ });
1284
+ let target: URL;
1285
+ try {
1286
+ target = new URL(input.returnUrl);
1287
+ } catch {
1288
+ throw new HTTPException(400, { message: "invalid return URL" });
1289
+ }
1290
+ if (
1291
+ !["https:", "http:"].includes(target.protocol) ||
1292
+ target.username ||
1293
+ target.password ||
1294
+ /[\u0000-\u0020\u007f]/.test(input.returnUrl)
1295
+ )
1296
+ throw new HTTPException(400, { message: "invalid return URL" });
1297
+ const scope = {
1298
+ accountId: authorization.grant.accountId,
1299
+ workspaceId,
1300
+ subjectId: authorization.grant.subjectId,
1301
+ personalOwnerVerified: isPersonalConnectionOwnerPrincipal(authorization),
1302
+ ...(continuation ? { externalContinuation: continuation } : {}),
1303
+ };
1304
+ return c.json(
1305
+ await withWorkspaceSubjectRls(deps.db, workspaceId, scope.subjectId, async (tx) => {
1306
+ await requireConnectOwnerAuthority(tx, scope, setupPermission);
1307
+ const id = randomUUID();
1308
+ if (input.providerId === "mcp-install") {
1309
+ if (
1310
+ input.ownership !== "workspace" ||
1311
+ input.reconnectAccountId ||
1312
+ input.installationTarget
1313
+ )
1314
+ throw new HTTPException(422, {
1315
+ message: "MCP capabilities are workspace installations, not credential accounts",
1316
+ });
1317
+ const catalog = await buildCapabilityCatalog({
1318
+ db: tx,
1319
+ workspaceId,
1320
+ settings: deps.settings,
1321
+ subjectId: scope.subjectId,
1322
+ });
1323
+ const options = catalog.items
1324
+ .filter(
1325
+ (item) =>
1326
+ item.kind === "mcp" &&
1327
+ item.surfaceType !== "codex_apps" &&
1328
+ item.runtime.available &&
1329
+ item.authKind === "none",
1330
+ )
1331
+ .map((item) => ({ value: item.id, label: item.name }));
1332
+ if (options.length > 1000)
1333
+ throw new HTTPException(422, {
1334
+ message: "MCP catalog exceeds the setup chooser limit",
1335
+ });
1336
+ return beginConnectAttempt(tx, scope, {
1337
+ ...(continuation ? { externalContinuation: continuation } : {}),
1338
+ idempotencyKey: input.idempotencyKey,
1339
+ requestDigest: createHash("sha256").update(stableJson(input)).digest("hex"),
1340
+ returnUrl: input.returnUrl,
1341
+ attempt: {
1342
+ id,
1343
+ workspaceId,
1344
+ providerId: input.providerId,
1345
+ ownership: "workspace",
1346
+ revision: 1,
1347
+ state: "credential_input",
1348
+ credentialsCommitted: false,
1349
+ integrationInstalled: false,
1350
+ completionRequirement: "provider_setup",
1351
+ nextAction: {
1352
+ type: "credentials",
1353
+ fields: [
1354
+ {
1355
+ name: "capabilityId",
1356
+ label: "MCP capability",
1357
+ required: true,
1358
+ secret: false,
1359
+ options,
1360
+ },
1361
+ ],
1362
+ },
1363
+ expiresAt: new Date(Date.now() + 30 * 60_000).toISOString(),
1364
+ },
1365
+ });
1366
+ }
1367
+ if (input.providerId === "x" || input.providerId === "reddit") {
1368
+ if (input.installationTarget)
1369
+ throw new HTTPException(422, {
1370
+ message: "Social setup does not create an API installation",
1371
+ });
1372
+ if (input.ownership === "personal" && !scope.personalOwnerVerified)
1373
+ throw new HTTPException(403, { message: "Personal ownership unavailable" });
1374
+ const reconnectId = input.reconnectAccountId?.startsWith("social:")
1375
+ ? input.reconnectAccountId.slice("social:".length)
1376
+ : null;
1377
+ const existing =
1378
+ reconnectId && z.string().uuid().safeParse(reconnectId).success
1379
+ ? await getSocialConnection(tx, workspaceId, reconnectId, scope.subjectId)
1380
+ : null;
1381
+ if (
1382
+ input.reconnectAccountId &&
1383
+ (!existing ||
1384
+ existing.provider !== input.providerId ||
1385
+ existing.ownership !== input.ownership ||
1386
+ !existing.version ||
1387
+ !existing.externalAccountId)
1388
+ )
1389
+ throw new HTTPException(409, {
1390
+ message: "Social account changed or cannot be safely reconnected",
1391
+ });
1392
+ const started = await startSocialOAuth(
1393
+ { db: tx, settings: deps.settings, observability: deps.observability },
1394
+ {
1395
+ accountId: scope.accountId,
1396
+ workspaceId,
1397
+ subjectId: scope.subjectId,
1398
+ personalOwnershipAllowed: scope.personalOwnerVerified,
1399
+ connectAttemptId: id,
1400
+ requestUrl: c.req.url,
1401
+ payload: { provider: input.providerId, ownership: input.ownership },
1402
+ },
1403
+ );
1404
+ if (!started.authorizationUrl)
1405
+ throw new HTTPException(503, { message: "Social authorization is unavailable" });
1406
+ return beginConnectAttempt(tx, scope, {
1407
+ ...(continuation ? { externalContinuation: continuation } : {}),
1408
+ idempotencyKey: input.idempotencyKey,
1409
+ requestDigest: createHash("sha256").update(stableJson(input)).digest("hex"),
1410
+ returnUrl: input.returnUrl,
1411
+ attempt: {
1412
+ id,
1413
+ workspaceId,
1414
+ providerId: input.providerId,
1415
+ ownership: input.ownership,
1416
+ revision: 1,
1417
+ state: "requires_user_action",
1418
+ credentialsCommitted: false,
1419
+ integrationInstalled: false,
1420
+ completionRequirement: "connection",
1421
+ ...(existing
1422
+ ? {
1423
+ account: {
1424
+ id: `social:${existing.id}`,
1425
+ version: existing.version!,
1426
+ providerId: input.providerId,
1427
+ label: existing.accountHandle,
1428
+ ownership: input.ownership,
1429
+ status: "auth_needed" as const,
1430
+ },
1431
+ }
1432
+ : {}),
1433
+ nextAction: { type: "authorize", url: started.authorizationUrl },
1434
+ expiresAt: started.expiresAt,
1435
+ },
1436
+ });
1437
+ }
1438
+ if (["github-app", "github-lens"].includes(input.providerId)) {
1439
+ if (
1440
+ input.ownership !== "workspace" ||
1441
+ input.installationTarget ||
1442
+ input.reconnectAccountId
1443
+ )
1444
+ throw new HTTPException(422, {
1445
+ message:
1446
+ "GitHub App setup requires workspace ownership and explicit installation selection",
1447
+ });
1448
+ if (input.providerId === "github-lens") {
1449
+ if (!hasPermission(authorization.grant.permissions, "secrets:write"))
1450
+ throw new HTTPException(403, { message: "secrets:write required" });
1451
+ await requireConnectOwnerAuthority(tx, scope, "secrets:write");
1452
+ await requireGitHubLensConnect({ ...deps, db: tx }, workspaceId);
1453
+ }
1454
+ const navigation = githubAppConnectNavigation(
1455
+ deps,
1456
+ scope,
1457
+ id,
1458
+ c.req.url,
1459
+ "discover",
1460
+ undefined,
1461
+ input.providerId === "github-lens" ? "github-lens" : "github-app",
1462
+ );
1463
+ return beginConnectAttempt(tx, scope, {
1464
+ ...(continuation ? { externalContinuation: continuation } : {}),
1465
+ idempotencyKey: input.idempotencyKey,
1466
+ requestDigest: createHash("sha256").update(stableJson(input)).digest("hex"),
1467
+ returnUrl: input.returnUrl,
1468
+ attempt: {
1469
+ id,
1470
+ workspaceId,
1471
+ providerId: input.providerId,
1472
+ ownership: "workspace",
1473
+ revision: 1,
1474
+ state: "requires_user_action",
1475
+ credentialsCommitted: false,
1476
+ integrationInstalled: false,
1477
+ completionRequirement: "provider_setup",
1478
+ nextAction: { type: "authorize", url: navigation.authorizationUrl },
1479
+ expiresAt: navigation.expiresAt,
1480
+ },
1481
+ });
1482
+ }
1483
+ if (["openapi", "graphql"].includes(input.providerId)) {
1484
+ if (input.reconnectAccountId)
1485
+ throw new HTTPException(422, { message: "Select the service connection during setup" });
1486
+ if (input.ownership === "personal" && !isPersonalConnectionOwnerPrincipal(authorization))
1487
+ throw new HTTPException(403, { message: "personal ownership unavailable" });
1488
+ const choices = hasPermission(authorization.grant.permissions, "connections:read")
1489
+ ? (await listConnectionsMetadata(tx, workspaceId, scope.subjectId))
1490
+ .filter(
1491
+ (connection) =>
1492
+ connection.status === "active" &&
1493
+ (input.ownership === "workspace"
1494
+ ? connection.subjectId === null
1495
+ : connection.subjectId === scope.subjectId),
1496
+ )
1497
+ .map((connection) => ({
1498
+ value: connection.id,
1499
+ label: `${connection.providerDomain} · ${connection.id}`,
1500
+ }))
1501
+ : [];
1502
+ if (choices.length > 1000)
1503
+ throw new HTTPException(422, {
1504
+ message: "Account inventory exceeds the setup chooser limit",
1505
+ });
1506
+ return beginConnectAttempt(tx, scope, {
1507
+ ...(continuation ? { externalContinuation: continuation } : {}),
1508
+ idempotencyKey: input.idempotencyKey,
1509
+ requestDigest: createHash("sha256").update(stableJson(input)).digest("hex"),
1510
+ returnUrl: input.returnUrl,
1511
+ attempt: {
1512
+ id,
1513
+ workspaceId,
1514
+ providerId: input.providerId,
1515
+ ownership: input.ownership,
1516
+ revision: 1,
1517
+ state: "credential_input",
1518
+ credentialsCommitted: false,
1519
+ integrationInstalled: false,
1520
+ completionRequirement: "integration",
1521
+ installationTarget: input.installationTarget ?? {
1522
+ instanceKey: `account-${id}`,
1523
+ displayName: input.providerId === "openapi" ? "OpenAPI service" : "GraphQL service",
1524
+ },
1525
+ nextAction: {
1526
+ type: "credentials",
1527
+ fields: [
1528
+ {
1529
+ name: "url",
1530
+ label:
1531
+ input.providerId === "openapi"
1532
+ ? "OpenAPI document URL"
1533
+ : "GraphQL endpoint URL",
1534
+ required: true,
1535
+ secret: false,
1536
+ },
1537
+ {
1538
+ name: "connectionId",
1539
+ label: "Service account (optional for public services)",
1540
+ required: false,
1541
+ secret: false,
1542
+ options: choices,
1543
+ },
1544
+ ],
1545
+ },
1546
+ expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
1547
+ },
1548
+ });
1549
+ }
1550
+ if (
1551
+ input.installationTarget &&
1552
+ [
1553
+ "mcp-oauth",
1554
+ "slack-personal",
1555
+ "mcp-bearer",
1556
+ "mcp-headers",
1557
+ "fiken-token",
1558
+ "fiken-oauth",
1559
+ "atlassian",
1560
+ "google-drive-knowledge",
1561
+ "google-drive-publish",
1562
+ "slack-bot",
1563
+ "github-personal",
1564
+ ].includes(input.providerId)
1565
+ )
1566
+ throw new HTTPException(422, {
1567
+ message: "MCP credential setup does not install an API integration instance",
1568
+ });
1569
+ if (
1570
+ ["mcp-oauth", "slack-personal", "mcp-bearer", "mcp-headers", "fiken-token"].includes(
1571
+ input.providerId,
1572
+ )
1573
+ ) {
1574
+ if (input.providerId === "slack-personal" && input.ownership !== "personal")
1575
+ throw new HTTPException(422, {
1576
+ message: "Hosted Slack MCP requires personal ownership",
1577
+ });
1578
+ if (input.providerId === "fiken-token" && input.ownership !== "workspace")
1579
+ throw new HTTPException(422, { message: "Fiken is workspace-owned" });
1580
+ requireEnvironmentEncryption(deps.settings);
1581
+ if (["mcp-oauth", "slack-personal"].includes(input.providerId))
1582
+ requireIntegrationsStateSecret(deps.settings);
1583
+ const existing = input.reconnectAccountId
1584
+ ? await getConnectionMetadata(
1585
+ tx,
1586
+ workspaceId,
1587
+ input.reconnectAccountId,
1588
+ scope.subjectId,
1589
+ )
1590
+ : null;
1591
+ if (
1592
+ input.reconnectAccountId &&
1593
+ (!existing ||
1594
+ (input.providerId === "fiken-token"
1595
+ ? !isFikenConnection(existing)
1596
+ : ["mcp-oauth", "slack-personal"].includes(input.providerId)
1597
+ ? existing.kind !== "oauth2" ||
1598
+ !existing.metadata.oauthDiscovery ||
1599
+ (input.providerId === "slack-personal" &&
1600
+ existing.metadata.mcpUrl !== OPENGENI_PERSONAL_SLACK_MCP_URL)
1601
+ : existing.kind !== "api_key" ||
1602
+ existing.metadata.connectAdapter !== input.providerId) ||
1603
+ (existing.subjectId === null ? "workspace" : "personal") !== input.ownership)
1604
+ )
1605
+ throw new HTTPException(404, { message: "MCP account not found for this ownership" });
1606
+ if (input.ownership === "personal" && !isPersonalConnectionOwnerPrincipal(authorization))
1607
+ throw new HTTPException(403, { message: "personal ownership unavailable" });
1608
+ return beginConnectAttempt(tx, scope, {
1609
+ ...(continuation ? { externalContinuation: continuation } : {}),
1610
+ idempotencyKey: input.idempotencyKey,
1611
+ requestDigest: createHash("sha256").update(stableJson(input)).digest("hex"),
1612
+ returnUrl: input.returnUrl,
1613
+ attempt: {
1614
+ id,
1615
+ workspaceId,
1616
+ providerId: input.providerId,
1617
+ ownership: input.ownership,
1618
+ revision: 1,
1619
+ state: "credential_input",
1620
+ credentialsCommitted: false,
1621
+ integrationInstalled: false,
1622
+ completionRequirement: "connection",
1623
+ ...(existing
1624
+ ? {
1625
+ account: {
1626
+ id: existing.id,
1627
+ version: existing.version,
1628
+ providerId: input.providerId,
1629
+ label: existing.providerDomain,
1630
+ ownership: input.ownership,
1631
+ status: "auth_needed" as const,
1632
+ },
1633
+ }
1634
+ : {}),
1635
+ nextAction: {
1636
+ type: "credentials",
1637
+ fields:
1638
+ input.providerId === "slack-personal"
1639
+ ? []
1640
+ : input.providerId === "fiken-token"
1641
+ ? [
1642
+ {
1643
+ name: "apiToken",
1644
+ label: "Fiken API token",
1645
+ required: true,
1646
+ secret: true,
1647
+ },
1648
+ {
1649
+ name: "defaultCompanySlug",
1650
+ label: "Default company slug (optional)",
1651
+ required: false,
1652
+ secret: false,
1653
+ },
1654
+ ]
1655
+ : [
1656
+ {
1657
+ name: "mcpUrl",
1658
+ label: "MCP server URL",
1659
+ required: true,
1660
+ secret: false,
1661
+ },
1662
+ ...(input.providerId === "mcp-bearer"
1663
+ ? [
1664
+ {
1665
+ name: "token",
1666
+ label: "Bearer credential",
1667
+ required: true,
1668
+ secret: true,
1669
+ },
1670
+ ]
1671
+ : input.providerId === "mcp-headers"
1672
+ ? [
1673
+ {
1674
+ name: "headers",
1675
+ label: "Credential headers (JSON)",
1676
+ required: true,
1677
+ secret: true,
1678
+ },
1679
+ ]
1680
+ : []),
1681
+ ],
1682
+ },
1683
+ expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
1684
+ },
1685
+ });
1686
+ }
1687
+ if (
1688
+ ["fiken-oauth", "slack-bot"].includes(input.providerId) &&
1689
+ input.ownership !== "workspace"
1690
+ )
1691
+ throw new HTTPException(422, { message: "This connector is workspace-owned" });
1692
+ if (
1693
+ [
1694
+ "atlassian",
1695
+ "google-drive-knowledge",
1696
+ "google-drive-publish",
1697
+ "github-personal",
1698
+ ].includes(input.providerId) &&
1699
+ (input.ownership !== "personal" || !isPersonalConnectionOwnerPrincipal(authorization))
1700
+ )
1701
+ throw new HTTPException(422, { message: "This connector requires personal ownership" });
1702
+ const started =
1703
+ input.providerId === "github-personal"
1704
+ ? await startPersonalGitHubOAuth(
1705
+ { ...deps, db: tx },
1706
+ {
1707
+ access: authorization,
1708
+ workspaceId,
1709
+ connectAttemptId: id,
1710
+ ...(input.reconnectAccountId ? { connectionId: input.reconnectAccountId } : {}),
1711
+ },
1712
+ )
1713
+ : input.providerId === "slack-bot"
1714
+ ? await startSlackBotInstall(
1715
+ { ...deps, db: tx },
1716
+ {
1717
+ ...scope,
1718
+ requestUrl: c.req.url,
1719
+ connectAttemptId: id,
1720
+ ...(input.reconnectAccountId ? { connectionId: input.reconnectAccountId } : {}),
1721
+ },
1722
+ )
1723
+ : ["google-drive-knowledge", "google-drive-publish"].includes(input.providerId)
1724
+ ? await startGoogleDriveOAuth(
1725
+ { ...deps, db: tx },
1726
+ {
1727
+ ...scope,
1728
+ requestUrl: c.req.url,
1729
+ connectAttemptId: id,
1730
+ payload: {
1731
+ capability:
1732
+ input.providerId === "google-drive-publish" ? "publish" : "source_read",
1733
+ ...(input.reconnectAccountId
1734
+ ? { connectionId: input.reconnectAccountId }
1735
+ : {}),
1736
+ },
1737
+ },
1738
+ )
1739
+ : input.providerId === "atlassian"
1740
+ ? await startAtlassianOAuth(
1741
+ { ...deps, db: tx },
1742
+ {
1743
+ ...scope,
1744
+ requestUrl: c.req.url,
1745
+ connectAttemptId: id,
1746
+ payload: input.reconnectAccountId
1747
+ ? { connectionId: input.reconnectAccountId }
1748
+ : {},
1749
+ },
1750
+ )
1751
+ : input.providerId === "fiken-oauth"
1752
+ ? await startFikenOAuth(
1753
+ { ...deps, db: tx },
1754
+ {
1755
+ ...scope,
1756
+ requestUrl: c.req.url,
1757
+ connectAttemptId: id,
1758
+ payload: input.reconnectAccountId
1759
+ ? { connectionId: input.reconnectAccountId }
1760
+ : {},
1761
+ },
1762
+ )
1763
+ : await startApiIntegrationProviderOAuth(
1764
+ { ...deps, db: tx },
1765
+ {
1766
+ ...scope,
1767
+ personalOwnershipAllowed:
1768
+ isPersonalConnectionOwnerPrincipal(authorization),
1769
+ ...(continuation ? { externalContinuation: continuation } : {}),
1770
+ connectAttemptId: id,
1771
+ requestUrl: c.req.url,
1772
+ payload: {
1773
+ definitionId: input.providerId,
1774
+ ownership: input.ownership,
1775
+ ...(input.reconnectAccountId
1776
+ ? { connectionId: input.reconnectAccountId }
1777
+ : {}),
1778
+ },
1779
+ },
1780
+ );
1781
+ if (!started.authorizationUrl)
1782
+ throw new HTTPException(502, { message: "provider did not return an authorization URL" });
1783
+ const reconnect = input.reconnectAccountId
1784
+ ? await getConnectionMetadata(tx, workspaceId, input.reconnectAccountId, scope.subjectId)
1785
+ : null;
1786
+ return beginConnectAttempt(tx, scope, {
1787
+ ...(continuation ? { externalContinuation: continuation } : {}),
1788
+ idempotencyKey: input.idempotencyKey,
1789
+ requestDigest: createHash("sha256").update(stableJson(input)).digest("hex"),
1790
+ returnUrl: input.returnUrl,
1791
+ attempt: {
1792
+ id,
1793
+ workspaceId,
1794
+ providerId: input.providerId,
1795
+ ownership: input.ownership,
1796
+ revision: 1,
1797
+ state: "requires_user_action",
1798
+ credentialsCommitted: false,
1799
+ integrationInstalled: false,
1800
+ completionRequirement: [
1801
+ "github-personal",
1802
+ "slack-bot",
1803
+ "fiken-oauth",
1804
+ "atlassian",
1805
+ "google-drive-knowledge",
1806
+ "google-drive-publish",
1807
+ ].includes(input.providerId)
1808
+ ? "connection"
1809
+ : "integration",
1810
+ ...([
1811
+ "github-personal",
1812
+ "slack-bot",
1813
+ "fiken-oauth",
1814
+ "atlassian",
1815
+ "google-drive-knowledge",
1816
+ "google-drive-publish",
1817
+ ].includes(input.providerId)
1818
+ ? {}
1819
+ : {
1820
+ installationTarget: input.installationTarget ?? {
1821
+ instanceKey: `account-${id}`,
1822
+ displayName: input.providerId,
1823
+ },
1824
+ }),
1825
+ ...(reconnect
1826
+ ? {
1827
+ account: {
1828
+ id: reconnect.id,
1829
+ version: reconnect.version,
1830
+ providerId: input.providerId,
1831
+ label: reconnect.providerDomain,
1832
+ ownership: input.ownership,
1833
+ status: "auth_needed" as const,
1834
+ },
1835
+ }
1836
+ : {}),
1837
+ nextAction: { type: "authorize", url: started.authorizationUrl },
1838
+ expiresAt: started.expiresAt,
1839
+ },
1840
+ });
1841
+ }),
1842
+ );
1843
+ });
1844
+ app.get("/v1/workspaces/:workspaceId/connect/attempts", async (c) => {
1845
+ const workspaceId = c.req.param("workspaceId");
1846
+ const { grant } = await requireAccessGrantAuthorization(c, deps, workspaceId);
1847
+ return c.json(
1848
+ (
1849
+ await listPendingConnectAttempts(deps.db, {
1850
+ accountId: grant.accountId,
1851
+ workspaceId,
1852
+ subjectId: grant.subjectId,
1853
+ })
1854
+ ).filter((attempt) => canReadAttempt(grant.permissions, attempt.providerId)),
1855
+ );
1856
+ });
1857
+ app.get("/v1/workspaces/:workspaceId/connect/attempts/:attemptId", async (c) => {
1858
+ const workspaceId = c.req.param("workspaceId");
1859
+ const { grant } = await requireAccessGrantAuthorization(c, deps, workspaceId);
1860
+ const { attempt } = await getConnectAttempt(
1861
+ deps.db,
1862
+ { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
1863
+ c.req.param("attemptId"),
1864
+ );
1865
+ if (!canReadAttempt(grant.permissions, attempt.providerId))
1866
+ throw new HTTPException(403, { message: "Connect read permission required" });
1867
+ return c.json(attempt);
1868
+ });
1869
+ }