@opengeni/api-router 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/admission-trace.d.ts +8 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth-attempt-context.d.ts +2 -0
  4. package/dist/auth/managed-auth-session-adapter.d.ts +2 -0
  5. package/dist/auth/managed-auth.d.ts +1 -0
  6. package/dist/auth/managed-sign-in-notifications.d.ts +4 -0
  7. package/dist/{chunk-AJ62T5QN.js → chunk-E7TZFJ65.js} +22300 -21113
  8. package/dist/{chunk-AJ62T5QN.js.map → chunk-E7TZFJ65.js.map} +1 -1
  9. package/dist/connection-ownership.d.ts +1 -2
  10. package/dist/fatal-process-boundary.d.ts +1 -1
  11. package/dist/index.js +24 -6
  12. package/dist/index.js.map +1 -1
  13. package/dist/integrations/atlassian.d.ts +8 -3
  14. package/dist/integrations/fiken.d.ts +3 -1
  15. package/dist/integrations/github-lens-connect.d.ts +5 -65
  16. package/dist/integrations/google-drive.d.ts +2 -0
  17. package/dist/integrations/integration-commit-authority.d.ts +13 -0
  18. package/dist/integrations/oauth-client.d.ts +119 -11
  19. package/dist/integrations/oauth-profiles.d.ts +8 -13
  20. package/dist/integrations/slack-interactions.d.ts +1 -1
  21. package/dist/mcp/capability-account-readiness.d.ts +7 -0
  22. package/dist/mcp/first-party-tool-permissions.d.ts +4 -0
  23. package/dist/mcp-oauth.d.ts +22 -1
  24. package/dist/routes/api-integrations.d.ts +1 -0
  25. package/dist/routes/codex.d.ts +28 -0
  26. package/dist/routes/managed-sign-in-methods.d.ts +6 -0
  27. package/dist/routes/organization-integration-policy.d.ts +3 -0
  28. package/dist/routes/sessions.d.ts +24 -2
  29. package/dist/scheduled-task-deletion.d.ts +3 -4
  30. package/dist/workspace-tool-gateway.d.ts +3 -1
  31. package/package.json +15 -15
  32. package/src/admission-trace.ts +14 -0
  33. package/src/app.ts +141 -94
  34. package/src/auth/managed-auth-attempt-context.ts +9 -0
  35. package/src/auth/managed-auth-session-adapter.ts +11 -0
  36. package/src/auth/managed-auth.ts +39 -2
  37. package/src/auth/managed-sign-in-notifications.ts +81 -0
  38. package/src/connection-ownership.ts +2 -9
  39. package/src/editable-artifact-production.ts +6 -0
  40. package/src/fatal-process-boundary.ts +17 -2
  41. package/src/index.ts +3 -4
  42. package/src/integrations/atlassian.ts +184 -76
  43. package/src/integrations/fiken.ts +57 -35
  44. package/src/integrations/github-app-connect.ts +51 -15
  45. package/src/integrations/github-lens-connect.ts +7 -28
  46. package/src/integrations/google-drive.ts +296 -125
  47. package/src/integrations/integration-commit-authority.ts +69 -0
  48. package/src/integrations/oauth-client.ts +231 -132
  49. package/src/integrations/oauth-profiles.ts +10 -45
  50. package/src/integrations/personal-github.ts +55 -31
  51. package/src/integrations/provider-oauth.ts +57 -82
  52. package/src/integrations/social-oauth.ts +83 -56
  53. package/src/mcp/capability-account-readiness.ts +17 -0
  54. package/src/mcp/editable-artifacts.ts +7 -2
  55. package/src/mcp/first-party-tool-permissions.ts +1 -0
  56. package/src/mcp/knowledge-entries.ts +25 -8
  57. package/src/mcp/server.ts +135 -43
  58. package/src/mcp-oauth.ts +336 -34
  59. package/src/routes/api-integrations.ts +116 -4
  60. package/src/routes/automations.ts +5 -13
  61. package/src/routes/billing.ts +52 -0
  62. package/src/routes/canonical-human-identities.ts +61 -0
  63. package/src/routes/capabilities.ts +51 -1
  64. package/src/routes/codex.ts +4 -2
  65. package/src/routes/connect.ts +90 -31
  66. package/src/routes/connections.ts +487 -307
  67. package/src/routes/files.ts +21 -12
  68. package/src/routes/github.ts +48 -19
  69. package/src/routes/insights.ts +17 -7
  70. package/src/routes/integration-facets.ts +66 -25
  71. package/src/routes/knowledge.ts +48 -1
  72. package/src/routes/managed-sign-in-methods.ts +433 -0
  73. package/src/routes/organization-integration-policy.ts +113 -0
  74. package/src/routes/organization-memberships.ts +22 -2
  75. package/src/routes/plugins.ts +42 -4
  76. package/src/routes/pr-review-github.ts +67 -57
  77. package/src/routes/pr-review.ts +74 -71
  78. package/src/routes/rigs.ts +32 -13
  79. package/src/routes/scheduled-tasks.ts +6 -6
  80. package/src/routes/sessions.ts +233 -11
  81. package/src/routes/skill-content.ts +19 -3
  82. package/src/routes/social.ts +24 -13
  83. package/src/sandbox/channel-a.ts +7 -1
  84. package/src/sandbox/rematerialize.ts +2 -1
  85. package/src/scheduled-task-deletion.ts +13 -3
  86. package/src/workspace-tool-gateway.ts +31 -31
  87. package/dist/connection-authority-owner.d.ts +0 -59
  88. package/dist/routes/connection-authorities.d.ts +0 -3
  89. package/dist/routes/host-mcp-bindings.d.ts +0 -5
  90. package/dist/routes/host-mcp-resolvers.d.ts +0 -3
  91. package/dist/routes/packs.d.ts +0 -3
  92. package/src/connection-authority-owner.ts +0 -61
  93. package/src/routes/connection-authorities.ts +0 -139
  94. package/src/routes/host-mcp-bindings.ts +0 -122
  95. package/src/routes/host-mcp-resolvers.ts +0 -41
  96. package/src/routes/packs.ts +0 -784
@@ -1,4 +1,15 @@
1
1
  import { createHash, createHmac } from "node:crypto";
2
+ import {
3
+ createConnectionIdempotently,
4
+ getConnectionCreationResult,
5
+ ConnectionCreateIdempotencyError,
6
+ } from "@opengeni/db";
7
+ import { sql } from "drizzle-orm";
8
+ import { assertOrganizationIntegrationAllowed } from "@opengeni/contracts";
9
+ import {
10
+ withOrganizationIntegrationAcquisition,
11
+ withOrganizationIntegrationPolicyFence,
12
+ } from "@opengeni/db/organization-integration-policy";
2
13
  import { ExternalActorContinuation } from "@opengeni/contracts/external-identities";
3
14
  import {
4
15
  startSlackBotInstall,
@@ -59,6 +70,7 @@ import {
59
70
  } from "@opengeni/contracts/personal-github";
60
71
  import {
61
72
  hasPermission,
73
+ listOwnConnectionAccountsForGrant,
62
74
  hasReservedFikenMetadata,
63
75
  hasReservedOpenGeniSlackBotMetadata,
64
76
  isOpenGeniSlackBotConnection,
@@ -74,7 +86,6 @@ import {
74
86
  finishConnectOperation,
75
87
  getConnectAttempt,
76
88
  decryptEnvironmentValue,
77
- createConnection,
78
89
  withWorkspaceSubjectRls,
79
90
  type Database,
80
91
  encryptEnvironmentValue,
@@ -185,6 +196,16 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
185
196
  );
186
197
  });
187
198
 
199
+ app.get("/v1/workspaces/:workspaceId/connections/accounts", async (c) => {
200
+ const workspaceId = c.req.param("workspaceId");
201
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
202
+ return c.json(
203
+ ListConnectionsResponse.parse({
204
+ connections: await listOwnConnectionAccountsForGrant(db, grant),
205
+ }),
206
+ );
207
+ });
208
+
188
209
  app.get("/v1/workspaces/:workspaceId/connections/slack-bot/bindings", async (c) => {
189
210
  const workspaceId = c.req.param("workspaceId");
190
211
  const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
@@ -198,6 +219,22 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
198
219
  );
199
220
  });
200
221
 
222
+ app.get("/v1/workspaces/:workspaceId/connections/operations/:operationId", async (c) => {
223
+ const workspaceId = c.req.param("workspaceId");
224
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
225
+ const operationId = CreateConnectionRequest.shape.operationId
226
+ .unwrap()
227
+ .parse(c.req.param("operationId"));
228
+ const connection = await getConnectionCreationResult(db, {
229
+ accountId: grant.accountId,
230
+ workspaceId,
231
+ subjectId: grant.subjectId,
232
+ operationId,
233
+ });
234
+ if (!connection) throw new HTTPException(404, { message: "Connection operation not found" });
235
+ return c.json(ConnectionResponse.parse({ connection }));
236
+ });
237
+
201
238
  app.post("/v1/workspaces/:workspaceId/connections", async (c) => {
202
239
  const workspaceId = c.req.param("workspaceId");
203
240
  const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "connections:write");
@@ -272,7 +309,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
272
309
  }
273
310
  return created;
274
311
  })()
275
- : await createConnection(db, {
312
+ : await createConnectionIdempotently(db, {
276
313
  accountId: grant.accountId,
277
314
  workspaceId,
278
315
  subjectId,
@@ -283,15 +320,56 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
283
320
  expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
284
321
  metadata: payload.metadata,
285
322
  createdBySubjectId: grant.subjectId,
323
+ ...(payload.operationId
324
+ ? {
325
+ operation: {
326
+ id: payload.operationId,
327
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
328
+ action: "create",
329
+ providerDomain,
330
+ kind: payload.kind,
331
+ subjectId,
332
+ credential: payload.credential,
333
+ grantedScopes: payload.grantedScopes,
334
+ expiresAt: payload.expiresAt
335
+ ? new Date(payload.expiresAt).toISOString()
336
+ : null,
337
+ metadata: payload.metadata,
338
+ }),
339
+ },
340
+ }
341
+ : {}),
342
+ }).catch((error: unknown) => {
343
+ if (error instanceof ConnectionCreateIdempotencyError) {
344
+ throw new HTTPException(409, { message: error.message });
345
+ }
346
+ throw error;
286
347
  });
287
348
  return c.json(ConnectionResponse.parse({ connection }), 201);
288
349
  };
289
- return beforeCommit
290
- ? withWorkspaceSubjectRls(db, workspaceId, grant.subjectId, async (tx) => {
291
- await beforeCommit(tx);
292
- return persist(tx);
293
- })
294
- : persist(db);
350
+ const commit = (database: Database) =>
351
+ beforeCommit
352
+ ? withWorkspaceSubjectRls(database, workspaceId, grant.subjectId, async (tx) => {
353
+ await beforeCommit(tx);
354
+ return persist(tx);
355
+ })
356
+ : persist(database);
357
+ // Only the canonical workspace model-key lane is outside integration policy.
358
+ // Generic credentials have no validated protocol/curated target in this contract.
359
+ const modelKey = workspaceProviderApiKeyConnectionKind({
360
+ subjectId: createConnectionSubjectId(payload, grant.subjectId),
361
+ providerDomain: canonicalProviderDomain(payload.providerDomain),
362
+ kind: payload.kind,
363
+ metadata: payload.metadata,
364
+ });
365
+ return modelKey
366
+ ? commit(db)
367
+ : withOrganizationIntegrationAcquisition(
368
+ db,
369
+ { accountId: grant.accountId, workspaceId },
370
+ [null],
371
+ commit,
372
+ );
295
373
  });
296
374
 
297
375
  app.post("/v1/workspaces/:workspaceId/connections/slack-bot/install", async (c) => {
@@ -301,13 +379,22 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
301
379
  const grant = access.grant;
302
380
  const payload = OpenGeniSlackBotInstallRequest.parse(await c.req.json());
303
381
  return c.json(
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 } : {}),
310
- }),
382
+ await withOrganizationIntegrationAcquisition(
383
+ db,
384
+ { accountId: grant.accountId, workspaceId },
385
+ ["slack-bot"],
386
+ (tx) =>
387
+ startSlackBotInstall(
388
+ { ...deps, db: tx },
389
+ {
390
+ accountId: grant.accountId,
391
+ workspaceId,
392
+ subjectId: grant.subjectId,
393
+ requestUrl: c.req.url,
394
+ ...(payload.connectionId ? { connectionId: payload.connectionId } : {}),
395
+ },
396
+ ),
397
+ ),
311
398
  );
312
399
  });
313
400
 
@@ -329,12 +416,25 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
329
416
  operationId: `oauth:${state.nonce}`,
330
417
  inputDigest: createHash("sha256").update(c.req.query("state")!).digest("hex"),
331
418
  };
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
- });
419
+ const callbackState = state;
420
+ const callbackOperation = operation;
421
+ const claim = await withOrganizationIntegrationPolicyFence(
422
+ db,
423
+ callbackState,
424
+ (policyTx, policy) =>
425
+ claimConnectOperation(policyTx, callbackState, {
426
+ ...callbackOperation,
427
+ expectedRevision: stored.attempt.revision,
428
+ authorize: (tx, _attempt, origin) =>
429
+ requireConnectOwnerAuthority(tx, callbackState, "connections:write", origin),
430
+ authorizeAcquisition: async () => {
431
+ // Provider denial/cancellation acquires nothing; completed exact
432
+ // receipts replay before this callback is invoked.
433
+ if (!c.req.query("error") && c.req.query("code"))
434
+ assertOrganizationIntegrationAllowed(policy, "slack-bot");
435
+ },
436
+ }),
437
+ );
338
438
  if (claim.status === "replayed")
339
439
  return new Response(null, { status: 302, headers: { Location: exactReturnUrl } });
340
440
  }
@@ -391,6 +491,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
391
491
  "Slack installation callback is missing code",
392
492
  );
393
493
  }
494
+ await withOrganizationIntegrationAcquisition(db, state, ["slack-bot"], async () => {});
394
495
  const slack = requireOpenGeniSlackOAuthSettings(settings);
395
496
  const redirectUri = `${baseUrl}/v1/integrations/slack/callback`;
396
497
  const authorization = await exchangeOpenGeniSlackAuthorizationCode(
@@ -424,35 +525,44 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
424
525
  verified,
425
526
  });
426
527
  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
- });
528
+ const acceptedOperation = operation;
529
+ await withOrganizationIntegrationPolicyFence(db, acceptedState, (policyTx, policy) =>
530
+ finishConnectOperation(policyTx, acceptedState, {
531
+ ...acceptedOperation,
532
+ authorize: (tx, _attempt, origin) =>
533
+ requireConnectOwnerAuthority(tx, acceptedState, "connections:write", origin),
534
+ commit: async (tx, current) => {
535
+ assertOrganizationIntegrationAllowed(policy, "slack-bot");
536
+ const connection = await persist(tx);
537
+ return {
538
+ ...current,
539
+ revision: current.revision + 1,
540
+ state: "complete",
541
+ credentialsCommitted: true,
542
+ nextAction: { type: "none" },
543
+ account: {
544
+ id: connection.id,
545
+ version: connection.version,
546
+ providerId: "slack-bot",
547
+ label: "Slack workspace bot",
548
+ ownership: "workspace",
549
+ status: "connected",
550
+ },
551
+ };
552
+ },
553
+ }),
554
+ );
450
555
  return new Response(null, { status: 302, headers: { Location: exactReturnUrl! } });
451
556
  }
452
- const connection = await db.transaction(async (tx) => {
453
- await requireSlackInstallCallbackGrant(tx, acceptedState);
454
- return persist(tx);
455
- });
557
+ const connection = await withOrganizationIntegrationAcquisition(
558
+ db,
559
+ acceptedState,
560
+ ["slack-bot"],
561
+ async (tx) => {
562
+ await requireSlackInstallCallbackGrant(tx, acceptedState);
563
+ return persist(tx);
564
+ },
565
+ );
456
566
  return c.redirect(
457
567
  slackInstallReturnUrl(baseUrl, state.returnPath, "connected", connection.id),
458
568
  302,
@@ -502,16 +612,21 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
502
612
  const payload = FikenInstallRequest.parse(await c.req.json());
503
613
  const persist = await prepareFikenTokenInstall(deps, grant, payload);
504
614
  const continuation = externalActorContinuationForAuthorization(authorization);
505
- const connection = await db.transaction(async (tx) => {
506
- await requireConnectOwnerAuthority(tx, {
507
- accountId: grant.accountId,
508
- workspaceId,
509
- subjectId: grant.subjectId,
510
- personalOwnerVerified: isPersonalConnectionOwnerPrincipal(authorization),
511
- ...(continuation ? { externalContinuation: continuation } : {}),
512
- });
513
- return persist(tx);
514
- });
615
+ const connection = await withOrganizationIntegrationAcquisition(
616
+ db,
617
+ { accountId: grant.accountId, workspaceId },
618
+ ["fiken"],
619
+ async (tx) => {
620
+ await requireConnectOwnerAuthority(tx, {
621
+ accountId: grant.accountId,
622
+ workspaceId,
623
+ subjectId: grant.subjectId,
624
+ personalOwnerVerified: isPersonalConnectionOwnerPrincipal(authorization),
625
+ ...(continuation ? { externalContinuation: continuation } : {}),
626
+ });
627
+ return persist(tx);
628
+ },
629
+ );
515
630
  return c.json(ConnectionResponse.parse({ connection }), payload.connectionId ? 200 : 201);
516
631
  });
517
632
 
@@ -789,258 +904,323 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
789
904
  );
790
905
  const { grant } = authorization;
791
906
  const payload = UpdateConnectionRequest.parse(await c.req.json());
792
- assertNotReservedSlackBotMetadata(payload.metadata);
793
- assertNotReservedFikenMetadata(payload.metadata);
794
- assertNotReservedApiIntegrationOAuthMetadata(payload.metadata);
795
- assertNotReservedPersonalGitHubMetadata(payload.metadata);
796
- const existing = await getConnectionMetadata(
907
+ // Policy first, then the existing tenancy prefix and exact credential row.
908
+ // The locked live identity makes unchanged/reducing updates safe against a
909
+ // concurrent replacement; all final writes below use this same transaction.
910
+ return withOrganizationIntegrationPolicyFence(
797
911
  db,
798
- workspaceId,
799
- c.req.param("connectionId"),
800
- grant.subjectId,
801
- );
802
- if (existing && isOpenGeniSlackBotConnection(existing)) {
803
- const destination = ConnectorDocumentDestinationSelection.safeParse(
804
- payload.metadata?.documentDestination,
805
- );
806
- const destinationOnlyUpdate =
807
- payload.metadata !== undefined &&
808
- Object.keys(payload.metadata).length === 1 &&
809
- payload.providerDomain === undefined &&
810
- payload.subjectId === undefined &&
811
- payload.kind === undefined &&
812
- payload.status === undefined &&
813
- payload.credential === undefined &&
814
- payload.grantedScopes === undefined &&
815
- payload.expiresAt === undefined;
816
- if (!destination.success || !destinationOnlyUpdate) {
817
- throw new HTTPException(422, {
818
- message: "use the dedicated OpenGeni Slack bot reinstall flow to update this connection",
819
- });
820
- }
821
- const destinationSelection = destination.data;
822
- if (
823
- destinationSelection.authorityKind === "organization" &&
824
- authorization.accountGrant?.permissions.includes("account:admin") !== true
825
- ) {
826
- throw new HTTPException(403, { message: "missing permission: account:admin" });
827
- }
828
- if (
829
- destinationSelection.authorityKind === "workspace" &&
830
- !hasPermission(grant.permissions, "workspace:admin")
831
- ) {
832
- throw new HTTPException(403, { message: "missing permission: workspace:admin" });
833
- }
834
- if (
835
- destinationSelection.authorityKind === "personal" &&
836
- (!authorization.contextIntegrity ||
837
- authorization.authenticatedSubjectId !== grant.subjectId)
838
- ) {
839
- throw new HTTPException(403, {
840
- message: "personal destination requires the exact actor",
841
- });
842
- }
843
- const metadata = {
844
- ...existing.metadata,
845
- documentDestination: bindConnectorDocumentDestination(destinationSelection, {
846
- accountId: grant.accountId,
847
- workspaceId,
848
- initiatingSubjectId: grant.subjectId,
849
- }),
850
- };
851
- const connection = await updateSlackBotDocumentDestination(db, {
852
- accountId: grant.accountId,
853
- workspaceId,
854
- connectionId: existing.id,
855
- visibleToSubjectId: grant.subjectId,
856
- expectedVersion: existing.version,
857
- metadata,
858
- updatedBySubjectId: grant.subjectId,
859
- });
860
- if (!connection) {
861
- throw new HTTPException(409, {
862
- message: "Slack bot connection changed; reload before saving its destination",
863
- });
864
- }
865
- return c.json(ConnectionResponse.parse({ connection }));
866
- }
867
- if (existing && GoogleDriveConnectionMetadata.safeParse(existing.metadata).success) {
868
- throw new HTTPException(422, {
869
- message: "use the dedicated Google Drive reconnect or source-selection flow",
870
- });
871
- }
872
- if (existing?.metadata.credentialRole === API_INTEGRATION_OAUTH_CREDENTIAL_ROLE) {
873
- throw new HTTPException(422, {
874
- message: "use the dedicated Integration provider OAuth flow to update this connection",
875
- });
876
- }
877
- if (existing && AtlassianConnectionMetadata.safeParse(existing.metadata).success) {
878
- throw new HTTPException(422, {
879
- message: "use the dedicated Atlassian reconnect or source-selection flow",
880
- });
881
- }
882
- if (existing && isPersonalGitHubConnection(existing)) {
883
- throw new HTTPException(422, {
884
- message: "use the dedicated personal GitHub reconnect flow to update this connection",
885
- });
886
- }
887
- if (existing) {
888
- const providerDomain = canonicalProviderDomain(
889
- payload.providerDomain ?? existing.providerDomain,
890
- );
891
- const kind = payload.kind ?? existing.kind;
892
- const subjectId =
893
- payload.subjectId === undefined
894
- ? existing.subjectId
895
- : writableSubjectId(payload.subjectId, grant.subjectId);
896
- const targetWorkspaceProviderKind = workspaceProviderApiKeyConnectionKind({
897
- subjectId,
898
- providerDomain,
899
- kind,
900
- metadata: payload.metadata ?? existing.metadata,
901
- });
902
- if (!workspaceProviderApiKeyConnectionKind(existing) && targetWorkspaceProviderKind) {
903
- throw new HTTPException(422, {
904
- message:
905
- targetWorkspaceProviderKind === "vercel_gateway"
906
- ? "use the Vercel AI Gateway connect flow to create this connection"
907
- : "use the OpenRouter connect flow to create this connection",
908
- });
909
- }
910
- assertNotDirectPersonalSlackOAuth(providerDomain, kind);
911
- assertNotDirectGoogleDriveOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
912
- assertNotDirectAtlassianOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
913
- assertNotDirectPersonalGitHubOAuth(
914
- providerDomain,
915
- kind,
916
- payload.metadata ?? existing.metadata,
917
- );
918
- }
919
- // Status is not a free-form field: revocation goes through DELETE, and the
920
- // broker owns needs_reauth/error. Reactivating a connection is only
921
- // meaningful together with a fresh credential bundle — otherwise a PATCH
922
- // could clear the broker's re-auth signal while stale tokens stay in place.
923
- if (payload.status !== undefined) {
924
- if (payload.status !== "active") {
925
- throw new HTTPException(400, {
926
- message: 'status can only be set to "active"; use DELETE to revoke',
927
- });
928
- }
929
- if (payload.credential === undefined) {
930
- throw new HTTPException(400, {
931
- message: "reactivating a connection requires a new credential",
932
- });
933
- }
934
- }
935
- const existingWorkspaceProviderKind = existing
936
- ? workspaceProviderApiKeyConnectionKind(existing)
937
- : null;
938
- if (existing && existingWorkspaceProviderKind) {
939
- const provider = workspaceProviderApiKeyConnectionSpec(existingWorkspaceProviderKind);
940
- const providerDomain = canonicalProviderDomain(
941
- payload.providerDomain ?? existing.providerDomain,
942
- );
943
- const subjectId =
944
- payload.subjectId === undefined
945
- ? existing.subjectId
946
- : writableSubjectId(payload.subjectId, grant.subjectId);
947
- const kind = payload.kind ?? existing.kind;
948
- if (
949
- subjectId !== null ||
950
- providerDomain !== provider.providerDomain ||
951
- kind !== "api_key" ||
952
- (payload.metadata?.credentialRole !== undefined &&
953
- payload.metadata.credentialRole !== provider.credentialRole)
954
- ) {
955
- throw new HTTPException(422, {
956
- message: `${provider.label} connection identity cannot be changed`,
957
- });
958
- }
959
- if (payload.credential === undefined) {
960
- throw new HTTPException(400, {
961
- message: `updating a ${provider.label} connection requires a new credential`,
962
- });
963
- }
964
- if (payload.expectedVersion === undefined || payload.operationId === undefined) {
965
- throw new HTTPException(400, {
966
- message: `updating a ${provider.label} connection requires expectedVersion and operationId`,
967
- });
968
- }
969
- const key = requireEnvironmentEncryption(settings);
970
- const grantedScopes = payload.grantedScopes ?? existing.grantedScopes;
971
- const expiresAt =
972
- payload.expiresAt !== undefined
973
- ? payload.expiresAt
974
- ? new Date(payload.expiresAt)
975
- : null
976
- : existing.expiresAt
977
- ? new Date(existing.expiresAt)
912
+ { accountId: grant.accountId, workspaceId },
913
+ (policyTx, policy) =>
914
+ // eslint-disable-next-line no-shadow -- every existing final writer must use this exact scoped transaction
915
+ withWorkspaceSubjectRls(policyTx, workspaceId, grant.subjectId, async (db) => {
916
+ const initial = await getConnectionMetadata(
917
+ db,
918
+ workspaceId,
919
+ c.req.param("connectionId"),
920
+ grant.subjectId,
921
+ );
922
+ // Model-key rotation owns an advisory -> credential-row prefix. Do
923
+ // not take its row first or change that excluded lane's lock order.
924
+ if (!initial || !workspaceProviderApiKeyConnectionKind(initial)) {
925
+ await db.execute(
926
+ sql`select id from connections where workspace_id = ${workspaceId}::uuid and id = ${c.req.param("connectionId")}::uuid and (subject_id is null or subject_id = ${grant.subjectId}) for update`,
927
+ );
928
+ }
929
+ assertNotReservedSlackBotMetadata(payload.metadata);
930
+ assertNotReservedFikenMetadata(payload.metadata);
931
+ assertNotReservedApiIntegrationOAuthMetadata(payload.metadata);
932
+ assertNotReservedPersonalGitHubMetadata(payload.metadata);
933
+ const existing = await getConnectionMetadata(
934
+ db,
935
+ workspaceId,
936
+ c.req.param("connectionId"),
937
+ grant.subjectId,
938
+ );
939
+ if (existing && isOpenGeniSlackBotConnection(existing)) {
940
+ const destination = ConnectorDocumentDestinationSelection.safeParse(
941
+ payload.metadata?.documentDestination,
942
+ );
943
+ const destinationOnlyUpdate =
944
+ payload.metadata !== undefined &&
945
+ Object.keys(payload.metadata).length === 1 &&
946
+ payload.providerDomain === undefined &&
947
+ payload.subjectId === undefined &&
948
+ payload.kind === undefined &&
949
+ payload.status === undefined &&
950
+ payload.credential === undefined &&
951
+ payload.grantedScopes === undefined &&
952
+ payload.expiresAt === undefined;
953
+ if (!destination.success || !destinationOnlyUpdate) {
954
+ throw new HTTPException(422, {
955
+ message:
956
+ "use the dedicated OpenGeni Slack bot reinstall flow to update this connection",
957
+ });
958
+ }
959
+ const destinationSelection = destination.data;
960
+ if (
961
+ destinationSelection.authorityKind === "organization" &&
962
+ authorization.accountGrant?.permissions.includes("account:admin") !== true
963
+ ) {
964
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
965
+ }
966
+ if (
967
+ destinationSelection.authorityKind === "workspace" &&
968
+ !hasPermission(grant.permissions, "workspace:admin")
969
+ ) {
970
+ throw new HTTPException(403, { message: "missing permission: workspace:admin" });
971
+ }
972
+ if (
973
+ destinationSelection.authorityKind === "personal" &&
974
+ (!authorization.contextIntegrity ||
975
+ authorization.authenticatedSubjectId !== grant.subjectId)
976
+ ) {
977
+ throw new HTTPException(403, {
978
+ message: "personal destination requires the exact actor",
979
+ });
980
+ }
981
+ const metadata = {
982
+ ...existing.metadata,
983
+ documentDestination: bindConnectorDocumentDestination(destinationSelection, {
984
+ accountId: grant.accountId,
985
+ workspaceId,
986
+ initiatingSubjectId: grant.subjectId,
987
+ }),
988
+ };
989
+ if (
990
+ stableJson(metadata.documentDestination) !==
991
+ stableJson(existing.metadata.documentDestination)
992
+ ) {
993
+ assertOrganizationIntegrationAllowed(policy, "slack-bot");
994
+ }
995
+ const connection = await updateSlackBotDocumentDestination(db, {
996
+ accountId: grant.accountId,
997
+ workspaceId,
998
+ connectionId: existing.id,
999
+ visibleToSubjectId: grant.subjectId,
1000
+ expectedVersion: existing.version,
1001
+ metadata,
1002
+ updatedBySubjectId: grant.subjectId,
1003
+ });
1004
+ if (!connection) {
1005
+ throw new HTTPException(409, {
1006
+ message: "Slack bot connection changed; reload before saving its destination",
1007
+ });
1008
+ }
1009
+ return c.json(ConnectionResponse.parse({ connection }));
1010
+ }
1011
+ if (existing && GoogleDriveConnectionMetadata.safeParse(existing.metadata).success) {
1012
+ throw new HTTPException(422, {
1013
+ message: "use the dedicated Google Drive reconnect or source-selection flow",
1014
+ });
1015
+ }
1016
+ if (existing?.metadata.credentialRole === API_INTEGRATION_OAUTH_CREDENTIAL_ROLE) {
1017
+ throw new HTTPException(422, {
1018
+ message:
1019
+ "use the dedicated Integration provider OAuth flow to update this connection",
1020
+ });
1021
+ }
1022
+ if (existing && AtlassianConnectionMetadata.safeParse(existing.metadata).success) {
1023
+ throw new HTTPException(422, {
1024
+ message: "use the dedicated Atlassian reconnect or source-selection flow",
1025
+ });
1026
+ }
1027
+ if (existing && isPersonalGitHubConnection(existing)) {
1028
+ throw new HTTPException(422, {
1029
+ message: "use the dedicated personal GitHub reconnect flow to update this connection",
1030
+ });
1031
+ }
1032
+ if (existing) {
1033
+ const providerDomain = canonicalProviderDomain(
1034
+ payload.providerDomain ?? existing.providerDomain,
1035
+ );
1036
+ const kind = payload.kind ?? existing.kind;
1037
+ const subjectId =
1038
+ payload.subjectId === undefined
1039
+ ? existing.subjectId
1040
+ : writableSubjectId(payload.subjectId, grant.subjectId);
1041
+ const targetWorkspaceProviderKind = workspaceProviderApiKeyConnectionKind({
1042
+ subjectId,
1043
+ providerDomain,
1044
+ kind,
1045
+ metadata: payload.metadata ?? existing.metadata,
1046
+ });
1047
+ if (!workspaceProviderApiKeyConnectionKind(existing) && targetWorkspaceProviderKind) {
1048
+ throw new HTTPException(422, {
1049
+ message:
1050
+ targetWorkspaceProviderKind === "vercel_gateway"
1051
+ ? "use the Vercel AI Gateway connect flow to create this connection"
1052
+ : "use the OpenRouter connect flow to create this connection",
1053
+ });
1054
+ }
1055
+ assertNotDirectPersonalSlackOAuth(providerDomain, kind);
1056
+ assertNotDirectGoogleDriveOAuth(
1057
+ providerDomain,
1058
+ kind,
1059
+ payload.metadata ?? existing.metadata,
1060
+ );
1061
+ assertNotDirectAtlassianOAuth(
1062
+ providerDomain,
1063
+ kind,
1064
+ payload.metadata ?? existing.metadata,
1065
+ );
1066
+ assertNotDirectPersonalGitHubOAuth(
1067
+ providerDomain,
1068
+ kind,
1069
+ payload.metadata ?? existing.metadata,
1070
+ );
1071
+ }
1072
+ // Status is not a free-form field: revocation goes through DELETE, and the
1073
+ // broker owns needs_reauth/error. Reactivating a connection is only
1074
+ // meaningful together with a fresh credential bundle — otherwise a PATCH
1075
+ // could clear the broker's re-auth signal while stale tokens stay in place.
1076
+ if (payload.status !== undefined) {
1077
+ if (payload.status !== "active") {
1078
+ throw new HTTPException(400, {
1079
+ message: 'status can only be set to "active"; use DELETE to revoke',
1080
+ });
1081
+ }
1082
+ if (payload.credential === undefined) {
1083
+ throw new HTTPException(400, {
1084
+ message: "reactivating a connection requires a new credential",
1085
+ });
1086
+ }
1087
+ }
1088
+ const existingWorkspaceProviderKind = existing
1089
+ ? workspaceProviderApiKeyConnectionKind(existing)
978
1090
  : null;
979
- const metadata = workspaceProviderCredentialMetadata(existingWorkspaceProviderKind, {
980
- ...existing.metadata,
981
- ...(payload.metadata ?? {}),
982
- });
983
- const input = {
984
- accountId: grant.accountId,
985
- workspaceId,
986
- connectionId: existing.id,
987
- expectedVersion: payload.expectedVersion,
988
- operationId: payload.operationId,
989
- requestDigest: workspaceProviderCredentialRequestDigest(key, {
990
- action: "rotate",
991
- connectionId: existing.id,
992
- expectedVersion: payload.expectedVersion,
993
- credential: payload.credential,
994
- grantedScopes,
995
- expiresAt: expiresAt?.toISOString() ?? null,
996
- metadata,
1091
+ if (existing && existingWorkspaceProviderKind) {
1092
+ const provider = workspaceProviderApiKeyConnectionSpec(existingWorkspaceProviderKind);
1093
+ const providerDomain = canonicalProviderDomain(
1094
+ payload.providerDomain ?? existing.providerDomain,
1095
+ );
1096
+ const subjectId =
1097
+ payload.subjectId === undefined
1098
+ ? existing.subjectId
1099
+ : writableSubjectId(payload.subjectId, grant.subjectId);
1100
+ const kind = payload.kind ?? existing.kind;
1101
+ if (
1102
+ subjectId !== null ||
1103
+ providerDomain !== provider.providerDomain ||
1104
+ kind !== "api_key" ||
1105
+ (payload.metadata?.credentialRole !== undefined &&
1106
+ payload.metadata.credentialRole !== provider.credentialRole)
1107
+ ) {
1108
+ throw new HTTPException(422, {
1109
+ message: `${provider.label} connection identity cannot be changed`,
1110
+ });
1111
+ }
1112
+ if (payload.credential === undefined) {
1113
+ throw new HTTPException(400, {
1114
+ message: `updating a ${provider.label} connection requires a new credential`,
1115
+ });
1116
+ }
1117
+ if (payload.expectedVersion === undefined || payload.operationId === undefined) {
1118
+ throw new HTTPException(400, {
1119
+ message: `updating a ${provider.label} connection requires expectedVersion and operationId`,
1120
+ });
1121
+ }
1122
+ const key = requireEnvironmentEncryption(settings);
1123
+ const grantedScopes = payload.grantedScopes ?? existing.grantedScopes;
1124
+ const expiresAt =
1125
+ payload.expiresAt !== undefined
1126
+ ? payload.expiresAt
1127
+ ? new Date(payload.expiresAt)
1128
+ : null
1129
+ : existing.expiresAt
1130
+ ? new Date(existing.expiresAt)
1131
+ : null;
1132
+ const metadata = workspaceProviderCredentialMetadata(existingWorkspaceProviderKind, {
1133
+ ...existing.metadata,
1134
+ ...(payload.metadata ?? {}),
1135
+ });
1136
+ const input = {
1137
+ accountId: grant.accountId,
1138
+ workspaceId,
1139
+ connectionId: existing.id,
1140
+ expectedVersion: payload.expectedVersion,
1141
+ operationId: payload.operationId,
1142
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
1143
+ action: "rotate",
1144
+ connectionId: existing.id,
1145
+ expectedVersion: payload.expectedVersion,
1146
+ credential: payload.credential,
1147
+ grantedScopes,
1148
+ expiresAt: expiresAt?.toISOString() ?? null,
1149
+ metadata,
1150
+ }),
1151
+ credentialEncrypted: encryptCredentialBundle(key, payload.credential),
1152
+ grantedScopes,
1153
+ expiresAt,
1154
+ metadata,
1155
+ updatedBySubjectId: grant.subjectId,
1156
+ };
1157
+ const connection =
1158
+ existingWorkspaceProviderKind === "vercel_gateway"
1159
+ ? await rotateWorkspaceVercelAiGatewayConnection(db, input)
1160
+ : await rotateWorkspaceOpenRouterConnection(db, input);
1161
+ if (!connection) {
1162
+ throw new HTTPException(409, {
1163
+ message: `${provider.label} connection changed; reload before replacing its key`,
1164
+ });
1165
+ }
1166
+ return c.json(ConnectionResponse.parse({ connection }));
1167
+ }
1168
+ const key =
1169
+ payload.credential === undefined ? null : requireEnvironmentEncryption(settings);
1170
+ const subjectId =
1171
+ payload.subjectId === undefined
1172
+ ? undefined
1173
+ : writableSubjectId(payload.subjectId, grant.subjectId);
1174
+ if (
1175
+ existing &&
1176
+ (payload.credential !== undefined ||
1177
+ (payload.providerDomain !== undefined &&
1178
+ canonicalProviderDomain(payload.providerDomain) !== existing.providerDomain) ||
1179
+ (payload.kind !== undefined && payload.kind !== existing.kind) ||
1180
+ (subjectId !== undefined && subjectId !== existing.subjectId) ||
1181
+ payload.grantedScopes?.some((scope) => !existing.grantedScopes.includes(scope)) ||
1182
+ (payload.metadata !== undefined &&
1183
+ ["mcpUrl", "resource", "credentialRole"].some(
1184
+ (field) =>
1185
+ stableJson(payload.metadata?.[field] ?? null) !==
1186
+ stableJson(existing.metadata[field] ?? null),
1187
+ )) ||
1188
+ (payload.expiresAt !== undefined &&
1189
+ existing.expiresAt !== null &&
1190
+ (payload.expiresAt === null ||
1191
+ Date.parse(payload.expiresAt) > Date.parse(existing.expiresAt))) ||
1192
+ (payload.status === "active" && existing.status !== "active"))
1193
+ ) {
1194
+ assertOrganizationIntegrationAllowed(policy, null);
1195
+ }
1196
+ const connection = await updateConnection(db, {
1197
+ workspaceId,
1198
+ connectionId: c.req.param("connectionId"),
1199
+ visibleToSubjectId: grant.subjectId,
1200
+ updatedBySubjectId: grant.subjectId,
1201
+ ...(payload.providerDomain !== undefined
1202
+ ? { providerDomain: canonicalProviderDomain(payload.providerDomain) }
1203
+ : {}),
1204
+ ...(subjectId !== undefined ? { subjectId } : {}),
1205
+ ...(payload.kind !== undefined ? { kind: payload.kind } : {}),
1206
+ ...(payload.status !== undefined ? { status: payload.status } : {}),
1207
+ ...(payload.credential !== undefined && key
1208
+ ? { credentialEncrypted: encryptCredentialBundle(key, payload.credential) }
1209
+ : {}),
1210
+ ...(payload.grantedScopes !== undefined
1211
+ ? { grantedScopes: payload.grantedScopes }
1212
+ : {}),
1213
+ ...(payload.expiresAt !== undefined
1214
+ ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null }
1215
+ : {}),
1216
+ ...(payload.metadata !== undefined ? { metadata: payload.metadata } : {}),
1217
+ });
1218
+ if (!connection) {
1219
+ throw new HTTPException(404, { message: "connection not found" });
1220
+ }
1221
+ return c.json(ConnectionResponse.parse({ connection }));
997
1222
  }),
998
- credentialEncrypted: encryptCredentialBundle(key, payload.credential),
999
- grantedScopes,
1000
- expiresAt,
1001
- metadata,
1002
- updatedBySubjectId: grant.subjectId,
1003
- };
1004
- const connection =
1005
- existingWorkspaceProviderKind === "vercel_gateway"
1006
- ? await rotateWorkspaceVercelAiGatewayConnection(db, input)
1007
- : await rotateWorkspaceOpenRouterConnection(db, input);
1008
- if (!connection) {
1009
- throw new HTTPException(409, {
1010
- message: `${provider.label} connection changed; reload before replacing its key`,
1011
- });
1012
- }
1013
- return c.json(ConnectionResponse.parse({ connection }));
1014
- }
1015
- const key = payload.credential === undefined ? null : requireEnvironmentEncryption(settings);
1016
- const subjectId =
1017
- payload.subjectId === undefined
1018
- ? undefined
1019
- : writableSubjectId(payload.subjectId, grant.subjectId);
1020
- const connection = await updateConnection(db, {
1021
- workspaceId,
1022
- connectionId: c.req.param("connectionId"),
1023
- visibleToSubjectId: grant.subjectId,
1024
- updatedBySubjectId: grant.subjectId,
1025
- ...(payload.providerDomain !== undefined
1026
- ? { providerDomain: canonicalProviderDomain(payload.providerDomain) }
1027
- : {}),
1028
- ...(subjectId !== undefined ? { subjectId } : {}),
1029
- ...(payload.kind !== undefined ? { kind: payload.kind } : {}),
1030
- ...(payload.status !== undefined ? { status: payload.status } : {}),
1031
- ...(payload.credential !== undefined && key
1032
- ? { credentialEncrypted: encryptCredentialBundle(key, payload.credential) }
1033
- : {}),
1034
- ...(payload.grantedScopes !== undefined ? { grantedScopes: payload.grantedScopes } : {}),
1035
- ...(payload.expiresAt !== undefined
1036
- ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null }
1037
- : {}),
1038
- ...(payload.metadata !== undefined ? { metadata: payload.metadata } : {}),
1039
- });
1040
- if (!connection) {
1041
- throw new HTTPException(404, { message: "connection not found" });
1042
- }
1043
- return c.json(ConnectionResponse.parse({ connection }));
1223
+ );
1044
1224
  });
1045
1225
 
1046
1226
  app.delete("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {