@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,9 @@
1
1
  import { createHash, createHmac } from "node:crypto";
2
+ import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
3
+ import {
4
+ startSlackBotInstall,
5
+ requireOpenGeniSlackOAuthSettings,
6
+ } from "../integrations/slack-install";
2
7
  import {
3
8
  WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
4
9
  WORKSPACE_OPENROUTER_CONNECTION_ROLE,
@@ -18,9 +23,6 @@ import {
18
23
  ApiIntegrationOAuthStartRequest,
19
24
  ConnectionResponse,
20
25
  CreateConnectionRequest,
21
- FIKEN_CREDENTIAL_LABEL,
22
- FIKEN_CREDENTIAL_ROLE,
23
- FIKEN_PROVIDER_DOMAIN,
24
26
  FikenInstallRequest,
25
27
  FikenOAuthStartRequest,
26
28
  FikenOAuthStartResponse,
@@ -30,7 +32,6 @@ import {
30
32
  OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
31
33
  OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY,
32
34
  OpenGeniSlackBotInstallRequest,
33
- OpenGeniSlackBotInstallStart,
34
35
  OAuthStartRequest,
35
36
  OAuthStartResponse,
36
37
  UpdateConnectionRequest,
@@ -57,24 +58,27 @@ import {
57
58
  isPersonalGitHubConnection,
58
59
  } from "@opengeni/contracts/personal-github";
59
60
  import {
60
- fikenConnectionMetadata,
61
61
  hasPermission,
62
62
  hasReservedFikenMetadata,
63
63
  hasReservedOpenGeniSlackBotMetadata,
64
- isFikenConnection,
65
64
  isOpenGeniSlackBotConnection,
66
65
  openGeniSlackBotMetadata,
67
66
  requireAccessGrant,
68
67
  requireAccessGrantAuthorization,
69
68
  requireEnvironmentEncryption,
70
- resolveFikenDefaultCompanySlug,
69
+ externalContinuationCommitAuthorizer,
71
70
  } from "@opengeni/core";
72
71
  import {
73
72
  consumeIntegrationOAuthStateNonce,
73
+ claimConnectOperation,
74
+ finishConnectOperation,
75
+ getConnectAttempt,
76
+ decryptEnvironmentValue,
74
77
  createConnection,
78
+ withWorkspaceSubjectRls,
79
+ type Database,
75
80
  encryptEnvironmentValue,
76
81
  getConnectionMetadata,
77
- getWorkspaceGrant,
78
82
  listConnectionsMetadata,
79
83
  listSlackInstallationBindings,
80
84
  persistSlackBotInstallationWithSuccessAudit,
@@ -127,8 +131,10 @@ import {
127
131
  import {
128
132
  assertPersonalConnectionOwnerPrincipal,
129
133
  isPersonalConnectionOwnerPrincipal,
134
+ requireLegacyOAuthActor,
130
135
  } from "../connection-ownership";
131
136
  import { canonicalProviderDomain } from "../integrations/provider-domain";
137
+ import { externalActorContinuationForAuthorization } from "@opengeni/core";
132
138
  import {
133
139
  exchangeOpenGeniSlackAuthorizationCode,
134
140
  SlackBotCredentialVerificationError,
@@ -136,19 +142,20 @@ import {
136
142
  } from "../integrations/slack-bot";
137
143
  import {
138
144
  completeFikenOAuthCallback,
139
- fikenCredentialBundle,
140
145
  startFikenOAuth,
141
- verifyFikenApiToken,
146
+ prepareFikenTokenInstall,
142
147
  } from "../integrations/fiken";
148
+ import { requireConnectOwnerAuthority } from "../integrations/connect-authority";
143
149
  import {
144
150
  OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
145
151
  OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
146
- OPENGENI_SLACK_BOT_REQUESTED_SCOPES,
147
152
  } from "@opengeni/contracts";
148
- import { createSignedState, readSignedState } from "@opengeni/github";
153
+ import { readSignedState } from "@opengeni/github";
149
154
  import { oauthStateTtlMs, requireIntegrationsStateSecret } from "../integrations/oauth-client";
150
155
 
151
156
  type OpenGeniSlackInstallState = {
157
+ connectAttemptId?: string;
158
+ externalContinuation?: ExternalActorContinuation;
152
159
  accountId: string;
153
160
  workspaceId: string;
154
161
  subjectId: string;
@@ -195,122 +202,111 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
195
202
  const workspaceId = c.req.param("workspaceId");
196
203
  const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
197
204
  const grant = access.grant;
205
+ const beforeCommit = externalContinuationCommitAuthorizer(access);
198
206
  const payload = CreateConnectionRequest.parse(await c.req.json());
199
- assertNotReservedSlackBotMetadata(payload.metadata);
200
- assertNotReservedFikenMetadata(payload.metadata);
201
- assertNotReservedApiIntegrationOAuthMetadata(payload.metadata);
202
- assertNotReservedPersonalGitHubMetadata(payload.metadata);
203
- const key = requireEnvironmentEncryption(settings);
204
- const subjectId = createConnectionSubjectId(payload, grant.subjectId);
205
- if (subjectId !== null) {
206
- assertPersonalConnectionOwnerPrincipal(access);
207
- }
208
- const providerDomain = canonicalProviderDomain(payload.providerDomain);
209
- assertNotDirectPersonalSlackOAuth(providerDomain, payload.kind);
210
- assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
211
- assertNotDirectAtlassianOAuth(providerDomain, payload.kind, payload.metadata);
212
- assertNotDirectPersonalGitHubOAuth(providerDomain, payload.kind, payload.metadata);
213
- const credentialEncrypted = encryptCredentialBundle(key, payload.credential);
214
- const workspaceProviderKind = workspaceProviderApiKeyConnectionKind({
215
- subjectId,
216
- providerDomain,
217
- kind: payload.kind,
218
- metadata: payload.metadata,
219
- });
220
- const connection = workspaceProviderKind
221
- ? await (async () => {
222
- const provider = workspaceProviderApiKeyConnectionSpec(workspaceProviderKind);
223
- if (!payload.operationId) {
224
- throw new HTTPException(400, {
225
- message: `connecting ${provider.label} requires an operationId`,
226
- });
227
- }
228
- const expiresAt = payload.expiresAt ? new Date(payload.expiresAt) : null;
229
- const metadata = workspaceProviderCredentialMetadata(
230
- workspaceProviderKind,
231
- payload.metadata,
232
- );
233
- const input = {
234
- accountId: grant.accountId,
235
- workspaceId,
236
- operationId: payload.operationId,
237
- requestDigest: workspaceProviderCredentialRequestDigest(key, {
238
- action: "create",
239
- providerDomain,
240
- kind: payload.kind,
241
- subjectId,
242
- credential: payload.credential,
207
+ // All writes in this closure must use the caller-owned scoped transaction.
208
+ // eslint-disable-next-line no-shadow
209
+ const persist = async (db: Database) => {
210
+ assertNotReservedSlackBotMetadata(payload.metadata);
211
+ assertNotReservedFikenMetadata(payload.metadata);
212
+ assertNotReservedApiIntegrationOAuthMetadata(payload.metadata);
213
+ assertNotReservedPersonalGitHubMetadata(payload.metadata);
214
+ const key = requireEnvironmentEncryption(settings);
215
+ const subjectId = createConnectionSubjectId(payload, grant.subjectId);
216
+ if (subjectId !== null) {
217
+ assertPersonalConnectionOwnerPrincipal(access);
218
+ }
219
+ const providerDomain = canonicalProviderDomain(payload.providerDomain);
220
+ assertNotDirectPersonalSlackOAuth(providerDomain, payload.kind);
221
+ assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
222
+ assertNotDirectAtlassianOAuth(providerDomain, payload.kind, payload.metadata);
223
+ assertNotDirectPersonalGitHubOAuth(providerDomain, payload.kind, payload.metadata);
224
+ const credentialEncrypted = encryptCredentialBundle(key, payload.credential);
225
+ const workspaceProviderKind = workspaceProviderApiKeyConnectionKind({
226
+ subjectId,
227
+ providerDomain,
228
+ kind: payload.kind,
229
+ metadata: payload.metadata,
230
+ });
231
+ const connection = workspaceProviderKind
232
+ ? await (async () => {
233
+ const provider = workspaceProviderApiKeyConnectionSpec(workspaceProviderKind);
234
+ if (!payload.operationId) {
235
+ throw new HTTPException(400, {
236
+ message: `connecting ${provider.label} requires an operationId`,
237
+ });
238
+ }
239
+ const expiresAt = payload.expiresAt ? new Date(payload.expiresAt) : null;
240
+ const metadata = workspaceProviderCredentialMetadata(
241
+ workspaceProviderKind,
242
+ payload.metadata,
243
+ );
244
+ const input = {
245
+ accountId: grant.accountId,
246
+ workspaceId,
247
+ operationId: payload.operationId,
248
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
249
+ action: "create",
250
+ providerDomain,
251
+ kind: payload.kind,
252
+ subjectId,
253
+ credential: payload.credential,
254
+ grantedScopes: payload.grantedScopes,
255
+ expiresAt: expiresAt?.toISOString() ?? null,
256
+ metadata,
257
+ }),
258
+ credentialEncrypted,
243
259
  grantedScopes: payload.grantedScopes,
244
- expiresAt: expiresAt?.toISOString() ?? null,
260
+ expiresAt,
245
261
  metadata,
246
- }),
262
+ updatedBySubjectId: grant.subjectId,
263
+ };
264
+ const created =
265
+ workspaceProviderKind === "vercel_gateway"
266
+ ? await upsertWorkspaceVercelAiGatewayConnection(db, input)
267
+ : await upsertWorkspaceOpenRouterConnection(db, input);
268
+ if (!created || created.status === "revoked") {
269
+ throw new HTTPException(409, {
270
+ message: `${provider.label} is already connected; reload before replacing its key`,
271
+ });
272
+ }
273
+ return created;
274
+ })()
275
+ : await createConnection(db, {
276
+ accountId: grant.accountId,
277
+ workspaceId,
278
+ subjectId,
279
+ providerDomain,
280
+ kind: payload.kind,
247
281
  credentialEncrypted,
248
282
  grantedScopes: payload.grantedScopes,
249
- expiresAt,
250
- metadata,
251
- updatedBySubjectId: grant.subjectId,
252
- };
253
- const created =
254
- workspaceProviderKind === "vercel_gateway"
255
- ? await upsertWorkspaceVercelAiGatewayConnection(db, input)
256
- : await upsertWorkspaceOpenRouterConnection(db, input);
257
- if (!created || created.status === "revoked") {
258
- throw new HTTPException(409, {
259
- message: `${provider.label} is already connected; reload before replacing its key`,
260
- });
261
- }
262
- return created;
263
- })()
264
- : await createConnection(db, {
265
- accountId: grant.accountId,
266
- workspaceId,
267
- subjectId,
268
- providerDomain,
269
- kind: payload.kind,
270
- credentialEncrypted,
271
- grantedScopes: payload.grantedScopes,
272
- expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
273
- metadata: payload.metadata,
274
- createdBySubjectId: grant.subjectId,
275
- });
276
- return c.json(ConnectionResponse.parse({ connection }), 201);
283
+ expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
284
+ metadata: payload.metadata,
285
+ createdBySubjectId: grant.subjectId,
286
+ });
287
+ return c.json(ConnectionResponse.parse({ connection }), 201);
288
+ };
289
+ return beforeCommit
290
+ ? withWorkspaceSubjectRls(db, workspaceId, grant.subjectId, async (tx) => {
291
+ await beforeCommit(tx);
292
+ return persist(tx);
293
+ })
294
+ : persist(db);
277
295
  });
278
296
 
279
297
  app.post("/v1/workspaces/:workspaceId/connections/slack-bot/install", async (c) => {
280
298
  const workspaceId = c.req.param("workspaceId");
281
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
299
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
300
+ requireLegacyOAuthActor(access);
301
+ const grant = access.grant;
282
302
  const payload = OpenGeniSlackBotInstallRequest.parse(await c.req.json());
283
- const slack = requireOpenGeniSlackOAuthSettings(settings);
284
- const existing = payload.connectionId
285
- ? await getConnectionMetadata(db, workspaceId, payload.connectionId, grant.subjectId)
286
- : null;
287
- if (payload.connectionId && !existing) {
288
- throw new HTTPException(404, { message: "connection not found" });
289
- }
290
- if (existing && !isOpenGeniSlackBotConnection(existing)) {
291
- throw new HTTPException(422, {
292
- message: "connectionId is not an OpenGeni Slack bot connection",
293
- });
294
- }
295
- const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
296
- const redirectUri = `${baseUrl}/v1/integrations/slack/callback`;
297
- const returnPath = `/workspaces/${workspaceId}/capabilities`;
298
- const state = createSignedState(requireIntegrationsStateSecret(settings), {
299
- accountId: grant.accountId,
300
- workspaceId,
301
- subjectId: grant.subjectId,
302
- returnPath,
303
- ...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
304
- });
305
- const authorizationUrl = new URL("https://slack.com/oauth/v2/authorize");
306
- authorizationUrl.searchParams.set("client_id", slack.clientId);
307
- authorizationUrl.searchParams.set("scope", OPENGENI_SLACK_BOT_REQUESTED_SCOPES.join(","));
308
- authorizationUrl.searchParams.set("redirect_uri", redirectUri);
309
- authorizationUrl.searchParams.set("state", state);
310
303
  return c.json(
311
- OpenGeniSlackBotInstallStart.parse({
312
- authorizationUrl: authorizationUrl.toString(),
313
- expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
304
+ await startSlackBotInstall(deps, {
305
+ accountId: grant.accountId,
306
+ workspaceId,
307
+ subjectId: grant.subjectId,
308
+ requestUrl: c.req.url,
309
+ ...(payload.connectionId ? { connectionId: payload.connectionId } : {}),
314
310
  }),
315
311
  );
316
312
  });
@@ -318,9 +314,30 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
318
314
  app.get("/v1/integrations/slack/callback", async (c) => {
319
315
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
320
316
  let state: OpenGeniSlackInstallState | null = null;
317
+ let exactReturnUrl: string | undefined;
318
+ let operation: { attemptId: string; operationId: string; inputDigest: string } | undefined;
321
319
  let stage: SlackBotInstallCallbackFailureStage = "permission_check";
322
320
  try {
323
321
  state = readOpenGeniSlackInstallState(c.req.query("state"), settings);
322
+ if (state.connectAttemptId) {
323
+ const stored = await getConnectAttempt(db, state, state.connectAttemptId);
324
+ if (stored.attempt.providerId !== "slack-bot" || stored.attempt.ownership !== "workspace")
325
+ throw new HTTPException(403, { message: "Slack attempt mismatch" });
326
+ exactReturnUrl = stored.returnUrl;
327
+ operation = {
328
+ attemptId: state.connectAttemptId,
329
+ operationId: `oauth:${state.nonce}`,
330
+ inputDigest: createHash("sha256").update(c.req.query("state")!).digest("hex"),
331
+ };
332
+ const claim = await claimConnectOperation(db, state, {
333
+ ...operation,
334
+ expectedRevision: stored.attempt.revision,
335
+ authorize: (tx, _attempt, origin) =>
336
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
337
+ });
338
+ if (claim.status === "replayed")
339
+ return new Response(null, { status: 302, headers: { Location: exactReturnUrl } });
340
+ }
324
341
  await requireSlackInstallCallbackGrant(db, state);
325
342
  stage = "nonce_consume";
326
343
  const consumed = await consumeIntegrationOAuthStateNonce(db, {
@@ -338,6 +355,25 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
338
355
  "Slack installation state has already been used",
339
356
  );
340
357
  }
358
+ if (operation && (c.req.query("error") || !c.req.query("code"))) {
359
+ await finishConnectOperation(db, state, {
360
+ ...operation,
361
+ authorize: (tx, _attempt, origin) =>
362
+ requireConnectOwnerAuthority(tx, state!, "connections:write", origin),
363
+ commit: async (_tx, current) => ({
364
+ ...current,
365
+ revision: current.revision + 1,
366
+ state: c.req.query("error") === "access_denied" ? "cancelled" : "failed",
367
+ nextAction: { type: "none" },
368
+ error: {
369
+ code: c.req.query("error") ? "provider_denied" : "missing_code",
370
+ message: "Authorization was not completed. Start a new connection attempt.",
371
+ retryable: false,
372
+ },
373
+ }),
374
+ });
375
+ return new Response(null, { status: 302, headers: { Location: exactReturnUrl! } });
376
+ }
341
377
  if (c.req.query("error")) {
342
378
  stage = "provider_denial";
343
379
  throw new SlackInstallCallbackError(
@@ -379,17 +415,51 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
379
415
  stage = "permission_recheck";
380
416
  await requireSlackInstallCallbackGrant(db, state);
381
417
  stage = "persistence";
382
- const connection = await persistOpenGeniSlackBotConnection({
383
- deps,
384
- state,
385
- token: authorization.accessToken,
386
- verified,
418
+ const acceptedState = state;
419
+ const persist = (tx: Database) =>
420
+ persistOpenGeniSlackBotConnection({
421
+ deps: { ...deps, db: tx },
422
+ state: acceptedState,
423
+ token: authorization.accessToken,
424
+ verified,
425
+ });
426
+ if (operation) {
427
+ await finishConnectOperation(db, acceptedState, {
428
+ ...operation,
429
+ authorize: (tx, _attempt, origin) =>
430
+ requireConnectOwnerAuthority(tx, acceptedState, "connections:write", origin),
431
+ commit: async (tx, current) => {
432
+ const connection = await persist(tx);
433
+ return {
434
+ ...current,
435
+ revision: current.revision + 1,
436
+ state: "complete",
437
+ credentialsCommitted: true,
438
+ nextAction: { type: "none" },
439
+ account: {
440
+ id: connection.id,
441
+ version: connection.version,
442
+ providerId: "slack-bot",
443
+ label: "Slack workspace bot",
444
+ ownership: "workspace",
445
+ status: "connected",
446
+ },
447
+ };
448
+ },
449
+ });
450
+ return new Response(null, { status: 302, headers: { Location: exactReturnUrl! } });
451
+ }
452
+ const connection = await db.transaction(async (tx) => {
453
+ await requireSlackInstallCallbackGrant(tx, acceptedState);
454
+ return persist(tx);
387
455
  });
388
456
  return c.redirect(
389
457
  slackInstallReturnUrl(baseUrl, state.returnPath, "connected", connection.id),
390
458
  302,
391
459
  );
392
460
  } catch (error) {
461
+ if (exactReturnUrl)
462
+ return new Response(null, { status: 302, headers: { Location: exactReturnUrl } });
393
463
  if (state) {
394
464
  const failure = slackInstallCallbackFailure(stage, error);
395
465
  try {
@@ -422,80 +492,27 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
422
492
  app.post("/v1/workspaces/:workspaceId/connections/fiken/install", async (c) => {
423
493
  assertIntegrationsEnabled();
424
494
  const workspaceId = c.req.param("workspaceId");
425
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
426
- const payload = FikenInstallRequest.parse(await c.req.json());
427
- const key = requireEnvironmentEncryption(settings);
428
- const existing = payload.connectionId
429
- ? await getConnectionMetadata(db, workspaceId, payload.connectionId, null)
430
- : null;
431
- if (payload.connectionId && !existing) {
432
- throw new HTTPException(404, { message: "connection not found" });
433
- }
434
- if (existing && !isFikenConnection(existing)) {
435
- throw new HTTPException(422, { message: "connectionId is not a Fiken connection" });
436
- }
437
- const verified = await verifyFikenApiToken(payload.apiToken, deps.fikenFetch ?? fetch);
438
- const previousDefault = existing
439
- ? (fikenConnectionMetadata(existing.metadata)?.defaultCompanySlug ?? null)
440
- : null;
441
- const defaultCompanySlug = resolveFikenDefaultCompanySlug({
442
- requested: payload.defaultCompanySlug ?? null,
443
- previous: previousDefault,
444
- companies: verified.companies,
445
- });
446
- if (payload.defaultCompanySlug && defaultCompanySlug !== payload.defaultCompanySlug) {
447
- throw new HTTPException(422, {
448
- message: `defaultCompanySlug is not among the companies this token can access: ${verified.companies
449
- .map((company) => company.slug)
450
- .join(", ")}`,
451
- });
452
- }
453
- const metadata = {
454
- credentialRole: FIKEN_CREDENTIAL_ROLE,
455
- credentialLabel: FIKEN_CREDENTIAL_LABEL,
456
- companies: verified.companies,
457
- defaultCompanySlug,
458
- verifiedAt: new Date().toISOString(),
459
- };
460
- const credentialEncrypted = encryptCredentialBundle(
461
- key,
462
- fikenCredentialBundle(payload.apiToken),
495
+ const authorization = await requireAccessGrantAuthorization(
496
+ c,
497
+ deps,
498
+ workspaceId,
499
+ "connections:write",
463
500
  );
464
- if (existing) {
465
- // Rewrites the whole credential identity: a token pasted over an OAuth
466
- // row must also flip kind and clear the OAuth expiry, or the broker
467
- // keeps treating the api_key bundle as a refreshable oauth2 credential.
468
- const updated = await updateConnection(db, {
501
+ const grant = authorization.grant;
502
+ const payload = FikenInstallRequest.parse(await c.req.json());
503
+ const persist = await prepareFikenTokenInstall(deps, grant, payload);
504
+ const continuation = externalActorContinuationForAuthorization(authorization);
505
+ const connection = await db.transaction(async (tx) => {
506
+ await requireConnectOwnerAuthority(tx, {
507
+ accountId: grant.accountId,
469
508
  workspaceId,
470
- connectionId: existing.id,
471
- visibleToSubjectId: null,
472
- expectedVersion: existing.version,
473
- kind: "api_key",
474
- status: "active",
475
- credentialEncrypted,
476
- grantedScopes: [],
477
- expiresAt: null,
478
- metadata,
479
- updatedBySubjectId: grant.subjectId,
509
+ subjectId: grant.subjectId,
510
+ personalOwnerVerified: isPersonalConnectionOwnerPrincipal(authorization),
511
+ ...(continuation ? { externalContinuation: continuation } : {}),
480
512
  });
481
- if (!updated) {
482
- throw new HTTPException(409, { message: "the Fiken connection changed; retry" });
483
- }
484
- return c.json(ConnectionResponse.parse({ connection: updated }));
485
- }
486
- const connection = await createConnection(db, {
487
- accountId: grant.accountId,
488
- workspaceId,
489
- subjectId: null,
490
- providerDomain: FIKEN_PROVIDER_DOMAIN,
491
- kind: "api_key",
492
- credentialEncrypted,
493
- grantedScopes: [],
494
- expiresAt: null,
495
- metadata,
496
- createdBySubjectId: grant.subjectId,
513
+ return persist(tx);
497
514
  });
498
- return c.json(ConnectionResponse.parse({ connection }), 201);
515
+ return c.json(ConnectionResponse.parse({ connection }), payload.connectionId ? 200 : 201);
499
516
  });
500
517
 
501
518
  // Fiken OAuth (registered app) start. Both Fiken lanes produce the same
@@ -503,7 +520,9 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
503
520
  app.post("/v1/workspaces/:workspaceId/connections/fiken/oauth/start", async (c) => {
504
521
  assertIntegrationsEnabled();
505
522
  const workspaceId = c.req.param("workspaceId");
506
- const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
523
+ const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
524
+ requireLegacyOAuthActor(access);
525
+ const grant = access.grant;
507
526
  const payload = FikenOAuthStartRequest.parse(await c.req.json());
508
527
  requireEnvironmentEncryption(settings);
509
528
  return c.json(
@@ -527,6 +546,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
527
546
  ...(c.req.query("error") ? { error: c.req.query("error") } : {}),
528
547
  requestUrl: c.req.url,
529
548
  });
549
+ if (result.exactReturn)
550
+ return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
530
551
  return c.redirect(result.redirectTo, 302);
531
552
  });
532
553
 
@@ -537,6 +558,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
537
558
  // human may start it.
538
559
  const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
539
560
  assertPersonalConnectionOwnerPrincipal(access, "Google Drive");
561
+ requireLegacyOAuthActor(access);
540
562
  const grant = access.grant;
541
563
  const parsed = GoogleDriveOAuthStartRequest.safeParse(await c.req.json());
542
564
  if (!parsed.success) {
@@ -562,6 +584,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
562
584
  // managed human may start it.
563
585
  const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
564
586
  assertPersonalConnectionOwnerPrincipal(access, "Atlassian");
587
+ requireLegacyOAuthActor(access);
565
588
  const grant = access.grant;
566
589
  const parsed = AtlassianOAuthStartRequest.safeParse(await c.req.json());
567
590
  if (!parsed.success) {
@@ -588,6 +611,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
588
611
  ...(c.req.query("error") ? { error: c.req.query("error") } : {}),
589
612
  requestUrl: c.req.url,
590
613
  });
614
+ if (result.exactReturn)
615
+ return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
591
616
  return c.redirect(result.redirectTo, 302);
592
617
  });
593
618
 
@@ -663,6 +688,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
663
688
  requestUrl: c.req.url,
664
689
  };
665
690
  const result = await completeGoogleDriveOAuthCallback(deps, input);
691
+ if (result.exactReturn)
692
+ return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
666
693
  return c.redirect(result.redirectTo, 302);
667
694
  });
668
695
 
@@ -1024,6 +1051,15 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
1024
1051
  if (!existing) {
1025
1052
  throw new HTTPException(404, { message: "connection not found" });
1026
1053
  }
1054
+ const expectedVersion = c.req.query("expectedVersion");
1055
+ if (expectedVersion !== undefined) {
1056
+ if (!/^[1-9]\d*$/.test(expectedVersion) || !Number.isSafeInteger(Number(expectedVersion))) {
1057
+ throw new HTTPException(400, { message: "invalid expected connection version" });
1058
+ }
1059
+ if (Number(expectedVersion) !== existing.version) {
1060
+ throw new HTTPException(409, { message: "connection changed; refresh before disconnect" });
1061
+ }
1062
+ }
1027
1063
  const isPersonalGitHub =
1028
1064
  existing.subjectId === grant.subjectId && isPersonalGitHubConnection(existing);
1029
1065
  if (isPersonalGitHub) {
@@ -1161,6 +1197,9 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
1161
1197
  const result = await startMcpOAuth(
1162
1198
  { db, settings, observability, oauthStartDeadlineMs: deps.oauthStartDeadlineMs },
1163
1199
  {
1200
+ ...(externalActorContinuationForAuthorization(access)
1201
+ ? { externalContinuation: externalActorContinuationForAuthorization(access)! }
1202
+ : {}),
1164
1203
  accountId: grant.accountId,
1165
1204
  workspaceId,
1166
1205
  subjectId: grant.subjectId,
@@ -1186,6 +1225,9 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
1186
1225
  return c.json(
1187
1226
  OAuthStartResponse.parse(
1188
1227
  await startApiIntegrationProviderOAuth(deps, {
1228
+ ...(externalActorContinuationForAuthorization(access)
1229
+ ? { externalContinuation: externalActorContinuationForAuthorization(access)! }
1230
+ : {}),
1189
1231
  accountId: grant.accountId,
1190
1232
  workspaceId,
1191
1233
  subjectId: grant.subjectId,
@@ -1211,6 +1253,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
1211
1253
  { db, settings, observability, oauthCallbackDeadlineMs: deps.oauthCallbackDeadlineMs },
1212
1254
  input,
1213
1255
  );
1256
+ if ("exactReturn" in result && result.exactReturn)
1257
+ return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
1214
1258
  const redirectTo = settings.webBaseUrl
1215
1259
  ? new URL(result.redirectTo, settings.webBaseUrl).toString()
1216
1260
  : result.redirectTo;
@@ -1225,6 +1269,8 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
1225
1269
  ...(c.req.query("error") ? { error: c.req.query("error") } : {}),
1226
1270
  requestUrl: c.req.url,
1227
1271
  });
1272
+ if (result.exactReturn)
1273
+ return new Response(null, { status: 302, headers: { Location: result.redirectTo } });
1228
1274
  return c.redirect(result.redirectTo, 302);
1229
1275
  });
1230
1276
 
@@ -1341,22 +1387,6 @@ async function persistOpenGeniSlackBotConnection(input: {
1341
1387
  }
1342
1388
  }
1343
1389
 
1344
- function requireOpenGeniSlackOAuthSettings(settings: ApiRouteDeps["settings"]): {
1345
- clientId: string;
1346
- clientSecret: string;
1347
- } {
1348
- const clientId = settings.slackClientId?.trim();
1349
- const clientSecret = settings.slackClientSecret?.trim();
1350
- const signingSecret = settings.slackSigningSecret?.trim();
1351
- if (!clientId || !clientSecret || !signingSecret) {
1352
- throw new HTTPException(503, {
1353
- message:
1354
- "OpenGeni Slack bot installation requires OPENGENI_SLACK_CLIENT_ID, OPENGENI_SLACK_CLIENT_SECRET, and OPENGENI_SLACK_SIGNING_SECRET",
1355
- });
1356
- }
1357
- return { clientId, clientSecret };
1358
- }
1359
-
1360
1390
  function readOpenGeniSlackInstallState(
1361
1391
  rawState: string | undefined,
1362
1392
  settings: ApiRouteDeps["settings"],
@@ -1371,6 +1401,11 @@ function readOpenGeniSlackInstallState(
1371
1401
  if (!payload) {
1372
1402
  throw new HTTPException(400, { message: "invalid or expired Slack installation state" });
1373
1403
  }
1404
+ if (
1405
+ (payload.kind !== undefined && payload.kind !== "slack_bot_install") ||
1406
+ (payload.connectAttemptId && payload.kind !== "slack_bot_install")
1407
+ )
1408
+ throw new HTTPException(400, { message: "invalid Slack installation state kind" });
1374
1409
  const requiredString = (value: unknown, label: string): string => {
1375
1410
  if (typeof value !== "string" || value.length === 0) {
1376
1411
  throw new HTTPException(400, { message: `invalid Slack installation ${label}` });
@@ -1405,6 +1440,21 @@ function readOpenGeniSlackInstallState(
1405
1440
  workspaceId,
1406
1441
  subjectId,
1407
1442
  returnPath,
1443
+ ...(typeof payload.connectAttemptId === "string"
1444
+ ? { connectAttemptId: payload.connectAttemptId }
1445
+ : {}),
1446
+ ...(typeof payload.encryptedExternalContinuation === "string"
1447
+ ? {
1448
+ externalContinuation: ExternalActorContinuation.parse(
1449
+ JSON.parse(
1450
+ decryptEnvironmentValue(
1451
+ requireEnvironmentEncryption(settings),
1452
+ payload.encryptedExternalContinuation,
1453
+ ),
1454
+ ),
1455
+ ),
1456
+ }
1457
+ : {}),
1408
1458
  ...(connectionId ? { connectionId, connectionVersion: connectionVersion! } : {}),
1409
1459
  nonce: requiredString(payload.nonce, "nonce"),
1410
1460
  iat:
@@ -1456,6 +1506,9 @@ function slackInstallCallbackFailure(
1456
1506
  if (error instanceof SlackBotLifecycleSuccessAuditError) {
1457
1507
  return { stage: "persistence", reason: "success_audit_failed" };
1458
1508
  }
1509
+ if (stage === "permission_check" && error instanceof HTTPException && error.status === 403) {
1510
+ return { stage, reason: "permission_lost" };
1511
+ }
1459
1512
  if (stage === "code_exchange") {
1460
1513
  return { stage, reason: "exchange_failed" };
1461
1514
  }
@@ -1479,18 +1532,7 @@ async function requireSlackInstallCallbackGrant(
1479
1532
  db: ApiRouteDeps["db"],
1480
1533
  state: OpenGeniSlackInstallState,
1481
1534
  ): Promise<void> {
1482
- const grant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
1483
- if (
1484
- !grant ||
1485
- grant.accountId !== state.accountId ||
1486
- !hasPermission(grant.permissions, "connections:write")
1487
- ) {
1488
- throw new SlackInstallCallbackError(
1489
- 403,
1490
- "permission_lost",
1491
- "Slack installation subject no longer has permission for this workspace",
1492
- );
1493
- }
1535
+ await requireConnectOwnerAuthority(db, state, "connections:write");
1494
1536
  }
1495
1537
 
1496
1538
  function assertNotDirectPersonalSlackOAuth(providerDomain: string, kind: string): void {