@opengeni/api-router 2.5.0 → 2.6.4-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 (70) hide show
  1. package/dist/app.d.ts +2 -1
  2. package/dist/app.js +3 -1
  3. package/dist/auth/managed-auth-attempt-context.d.ts +5 -1
  4. package/dist/auth/managed-auth.d.ts +24 -1
  5. package/dist/{chunk-QESX7HDK.js → chunk-UOI7KAP3.js} +3240 -791
  6. package/dist/chunk-UOI7KAP3.js.map +1 -0
  7. package/dist/http/sse.d.ts +9 -0
  8. package/dist/index.js +117 -5
  9. package/dist/index.js.map +1 -1
  10. package/dist/interaction-metrics.d.ts +2 -0
  11. package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
  12. package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
  13. package/dist/mcp/remember.d.ts +2 -2
  14. package/dist/mcp/scheduled-task-view.d.ts +3 -3
  15. package/dist/mcp/server.d.ts +1 -0
  16. package/dist/mcp/session-view.d.ts +1 -0
  17. package/dist/mcp/session-wait.d.ts +19 -0
  18. package/dist/model-catalog.d.ts +5 -31
  19. package/dist/routes/api-keys.d.ts +2 -0
  20. package/dist/routes/browser-sessions.d.ts +1 -0
  21. package/dist/routes/computer-sessions.d.ts +12 -0
  22. package/dist/routes/managed-auth-session-sets.d.ts +7 -0
  23. package/dist/routes/workspaces.d.ts +1 -1
  24. package/dist/sandbox/metrics-ingestion.d.ts +16 -0
  25. package/dist/workspace-delete-observability.d.ts +10 -0
  26. package/package.json +19 -19
  27. package/src/app.ts +206 -29
  28. package/src/auth/managed-auth-attempt-context.ts +40 -3
  29. package/src/auth/managed-auth-session-adapter.ts +1 -0
  30. package/src/auth/managed-auth.ts +164 -4
  31. package/src/http/sse.ts +279 -45
  32. package/src/index.ts +13 -1
  33. package/src/integrations/oauth-client.ts +8 -1
  34. package/src/integrations/provider-oauth.ts +12 -2
  35. package/src/integrations/slack-interactions.ts +77 -37
  36. package/src/interaction-metrics.ts +30 -0
  37. package/src/mcp/company-brain-governed-writes.ts +38 -23
  38. package/src/mcp/company-profile-agent-admin.ts +7 -7
  39. package/src/mcp/remember.ts +19 -8
  40. package/src/mcp/server.ts +328 -27
  41. package/src/mcp/session-wait.ts +56 -8
  42. package/src/model-catalog.ts +45 -337
  43. package/src/routes/api-integrations.ts +2 -2
  44. package/src/routes/api-keys.ts +149 -7
  45. package/src/routes/automations.ts +186 -19
  46. package/src/routes/browser-sessions.ts +10 -2
  47. package/src/routes/capabilities.ts +3 -3
  48. package/src/routes/codex.ts +483 -74
  49. package/src/routes/company-profile.ts +64 -0
  50. package/src/routes/computer-sessions.ts +103 -1
  51. package/src/routes/connections.ts +271 -16
  52. package/src/routes/github.ts +0 -8
  53. package/src/routes/integration-facets.ts +8 -5
  54. package/src/routes/interaction-resources.ts +7 -1
  55. package/src/routes/managed-auth-session-sets.ts +199 -2
  56. package/src/routes/organization-memberships.ts +28 -8
  57. package/src/routes/packs.ts +5 -5
  58. package/src/routes/plugins.ts +2 -2
  59. package/src/routes/pr-review.ts +80 -4
  60. package/src/routes/scheduled-tasks.ts +47 -11
  61. package/src/routes/sessions.ts +75 -53
  62. package/src/routes/skills.ts +3 -3
  63. package/src/routes/supergrok.ts +3 -2
  64. package/src/routes/workspaces.ts +665 -56
  65. package/src/sandbox/channel-a.ts +10 -4
  66. package/src/sandbox/machines.ts +13 -6
  67. package/src/sandbox/metrics-ingestion.ts +157 -3
  68. package/src/sandbox/viewer.ts +20 -1
  69. package/src/workspace-delete-observability.ts +75 -0
  70. package/dist/chunk-QESX7HDK.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import {
3
3
  ActivateCompanyProfileRevisionRequest,
4
+ CompanyProfileAgentPolicy,
4
5
  CompanyProfileConflictResponse,
5
6
  CompanyProfileDiffRequest,
6
7
  CompanyProfileDiffResponse,
@@ -11,6 +12,7 @@ import {
11
12
  CompanyProfileRevision,
12
13
  RollbackCompanyProfileRequest,
13
14
  UpdateCompanyProfileRequest,
15
+ UpdateCompanyProfileAgentPolicyRequest,
14
16
  } from "@opengeni/contracts";
15
17
  import {
16
18
  requireAccessGrant,
@@ -20,15 +22,18 @@ import {
20
22
  } from "@opengeni/core";
21
23
  import {
22
24
  activateCompanyProfileRevision,
25
+ CompanyProfileAgentPolicyError,
23
26
  CompanyProfileConflictError,
24
27
  CompanyProfileInvalidOperationError,
25
28
  CompanyProfileNotFoundError,
26
29
  CompanyProfileOperationReuseError,
27
30
  diffCompanyProfileRevisions,
28
31
  getCompanyProfileRevision,
32
+ getCompanyProfileAgentPolicy,
29
33
  listCompanyProfile,
30
34
  rollbackCompanyProfileRevision,
31
35
  updateCompanyProfile,
36
+ updateCompanyProfileAgentPolicy,
32
37
  } from "@opengeni/db";
33
38
  import type { Context, Hono } from "hono";
34
39
  import { HTTPException } from "hono/http-exception";
@@ -84,6 +89,15 @@ function profileError(context: Context, error: unknown): Response {
84
89
  if (error instanceof CompanyProfileInvalidOperationError) {
85
90
  return context.json({ code: "INVALID_COMPANY_PROFILE_OPERATION", message: error.message }, 422);
86
91
  }
92
+ if (error instanceof CompanyProfileAgentPolicyError) {
93
+ if (error.code === "authority_unavailable") {
94
+ throw new HTTPException(403, { message: error.message });
95
+ }
96
+ if (error.code === "policy_conflict" || error.code === "operation_reused") {
97
+ throw new HTTPException(409, { message: error.message });
98
+ }
99
+ throw new HTTPException(422, { message: error.message });
100
+ }
87
101
  throw error;
88
102
  }
89
103
 
@@ -97,6 +111,56 @@ function revisionId(context: Context): string {
97
111
  export function registerCompanyProfileRoutes(app: Hono, deps: ApiRouteDeps): void {
98
112
  const base = "/v1/workspaces/:workspaceId/company-profile";
99
113
 
114
+ app.get(`${base}/agent-policy`, async (context) => {
115
+ const workspaceId = context.req.param("workspaceId");
116
+ const access = await requireAccessGrantAuthorization(
117
+ context,
118
+ deps,
119
+ workspaceId,
120
+ "workspace:read",
121
+ );
122
+ requireDirectAccountAdmin(access);
123
+ try {
124
+ return context.json(
125
+ CompanyProfileAgentPolicy.parse(
126
+ await getCompanyProfileAgentPolicy(deps.db, {
127
+ accountId: access.grant.accountId,
128
+ workspaceId,
129
+ actorSubjectId: access.grant.subjectId,
130
+ }),
131
+ ),
132
+ );
133
+ } catch (error) {
134
+ return profileError(context, error);
135
+ }
136
+ });
137
+
138
+ app.patch(`${base}/agent-policy`, async (context) => {
139
+ const workspaceId = context.req.param("workspaceId");
140
+ const access = await requireAccessGrantAuthorization(
141
+ context,
142
+ deps,
143
+ workspaceId,
144
+ "workspace:read",
145
+ );
146
+ requireDirectAccountAdmin(access);
147
+ const request = await parseBody(context, UpdateCompanyProfileAgentPolicyRequest);
148
+ try {
149
+ return context.json(
150
+ CompanyProfileAgentPolicy.parse(
151
+ await updateCompanyProfileAgentPolicy(deps.db, {
152
+ accountId: access.grant.accountId,
153
+ workspaceId,
154
+ actorSubjectId: access.grant.subjectId,
155
+ ...request,
156
+ }),
157
+ ),
158
+ );
159
+ } catch (error) {
160
+ return profileError(context, error);
161
+ }
162
+ });
163
+
100
164
  app.get(base, async (context) => {
101
165
  const workspaceId = context.req.param("workspaceId");
102
166
  const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:read");
@@ -68,6 +68,7 @@ import {
68
68
  BrowserControlServerUnsupportedError,
69
69
  BrowserControlTransportError,
70
70
  BrowserControlUnsupportedError,
71
+ ComputerFrameEvidenceMismatchError,
71
72
  buildSelfhostedBackendSession,
72
73
  buildStreamUrl,
73
74
  exposedPortEndpointFromUrl,
@@ -75,8 +76,11 @@ import {
75
76
  NatsControlRpc,
76
77
  NatsOpStreamTransport,
77
78
  provisionBrowserControlClient,
79
+ validateComputerControlFrameEvidence,
78
80
  renewSandboxProviderExpiration,
79
81
  type BrowserControlPlacementSession,
82
+ type ComputerControlFrame,
83
+ type ExpectedComputerFrameEvidence,
80
84
  } from "@opengeni/runtime/sandbox";
81
85
  import type { Context, Hono } from "hono";
82
86
  import { HTTPException } from "hono/http-exception";
@@ -101,7 +105,11 @@ import {
101
105
  createInteractionFrameProxyAttachment,
102
106
  placementUsesInteractionFrameProxy,
103
107
  } from "../interaction-frame-proxy";
104
- import { observeComputerActionResult, observeLifecycleResult } from "../interaction-metrics";
108
+ import {
109
+ observeComputerActionResult,
110
+ observeComputerFrameEvidenceMismatch,
111
+ observeLifecycleResult,
112
+ } from "../interaction-metrics";
105
113
  import { withChannelA, withChannelARead, type ChannelAOperation } from "../sandbox/channel-a";
106
114
 
107
115
  type ComputerPlacement = {
@@ -111,6 +119,59 @@ type ComputerPlacement = {
111
119
  lease: LeaseSnapshot | null;
112
120
  };
113
121
 
122
+ const MODEL_COMPUTER_FRAME_MAX_BYTES = 256 * 1024;
123
+
124
+ type ComputerFrameCaptureClient = {
125
+ capture(
126
+ targetId: string,
127
+ options: {
128
+ format?: "jpeg" | "png";
129
+ quality?: number;
130
+ maxWidth?: number;
131
+ maxHeight?: number;
132
+ },
133
+ ): Promise<ComputerControlFrame>;
134
+ };
135
+
136
+ export function validateComputerFrameForApi(
137
+ frame: Pick<ComputerControlFrame, "data" | "mediaType" | "metadataHeader">,
138
+ expected: ExpectedComputerFrameEvidence,
139
+ ): ComputerControlFrame {
140
+ return validateComputerControlFrameEvidence(frame, expected);
141
+ }
142
+
143
+ export async function captureModelComputerFrame(
144
+ sessionClient: ComputerFrameCaptureClient,
145
+ expected: ExpectedComputerFrameEvidence,
146
+ ): Promise<ComputerControlFrame> {
147
+ let captured = validateComputerFrameForApi(
148
+ await sessionClient.capture(expected.targetId, {
149
+ format: "jpeg",
150
+ quality: 55,
151
+ maxWidth: 1_024,
152
+ maxHeight: 768,
153
+ }),
154
+ expected,
155
+ );
156
+ if (captured.data.byteLength > MODEL_COMPUTER_FRAME_MAX_BYTES) {
157
+ captured = validateComputerFrameForApi(
158
+ await sessionClient.capture(expected.targetId, {
159
+ format: "jpeg",
160
+ quality: 30,
161
+ maxWidth: 640,
162
+ maxHeight: 480,
163
+ }),
164
+ expected,
165
+ );
166
+ }
167
+ if (captured.data.byteLength > MODEL_COMPUTER_FRAME_MAX_BYTES) {
168
+ throw new BrowserControlProtocolError(
169
+ "computer screenshot could not honor the model image byte bound",
170
+ );
171
+ }
172
+ return captured;
173
+ }
174
+
114
175
  /** Public ComputerSession resource surface. Physical app/window authority stays
115
176
  * in the same placement controller used by BrowserSession; this route owns only
116
177
  * durable authorization, placement fencing, lifecycle receipts, and routing. */
@@ -389,6 +450,47 @@ export function registerComputerSessionRoutes(app: Hono, deps: ApiRouteDeps): vo
389
450
  },
390
451
  );
391
452
 
453
+ app.get(
454
+ "/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/targets/:targetId/screenshot",
455
+ async (context) => {
456
+ const { workspaceId, grant, computerSessionId } = await routePreamble(
457
+ context,
458
+ "sessions:read",
459
+ );
460
+ const targetId = requireOpaqueParam(context, "targetId");
461
+ const frame = await withActiveComputerController(
462
+ context,
463
+ grant,
464
+ workspaceId,
465
+ computerSessionId,
466
+ "session.read",
467
+ "computer.read",
468
+ async ({ sessionClient, binding }) => {
469
+ try {
470
+ return await captureModelComputerFrame(sessionClient, {
471
+ computerSessionId,
472
+ controllerGeneration: binding.controllerGeneration,
473
+ targetId,
474
+ });
475
+ } catch (error) {
476
+ if (error instanceof ComputerFrameEvidenceMismatchError) {
477
+ observeComputerFrameEvidenceMismatch(deps.observability, error.reason);
478
+ }
479
+ throw error;
480
+ }
481
+ },
482
+ );
483
+ return new Response(frame.data.slice().buffer, {
484
+ status: 200,
485
+ headers: {
486
+ "cache-control": "no-store",
487
+ "content-type": frame.mediaType,
488
+ "x-opengeni-computer-frame": frame.metadataHeader,
489
+ },
490
+ });
491
+ },
492
+ );
493
+
392
494
  app.get(
393
495
  "/v1/workspaces/:workspaceId/computer-sessions/:computerSessionId/clipboard",
394
496
  async (context) => {
@@ -1,4 +1,10 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, createHmac } from "node:crypto";
2
+ import {
3
+ WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
4
+ WORKSPACE_OPENROUTER_CONNECTION_ROLE,
5
+ VERCEL_AI_GATEWAY_CONNECTION_DOMAIN,
6
+ VERCEL_AI_GATEWAY_CONNECTION_ROLE,
7
+ } from "@opengeni/config";
2
8
  import {
3
9
  ATLASSIAN_PROVIDER_DOMAIN,
4
10
  AtlassianConnectionMetadata,
@@ -21,11 +27,16 @@ import {
21
27
  IntegrationClientMetadata,
22
28
  ListSlackInstallationBindingsResponse,
23
29
  ListConnectionsResponse,
30
+ OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
31
+ OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY,
24
32
  OpenGeniSlackBotInstallRequest,
25
33
  OpenGeniSlackBotInstallStart,
26
34
  OAuthStartRequest,
27
35
  OAuthStartResponse,
28
36
  UpdateConnectionRequest,
37
+ VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY,
38
+ VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_ID_METADATA_KEY,
39
+ stableJson,
29
40
  } from "@opengeni/contracts";
30
41
  import {
31
42
  bindConnectorDocumentDestination,
@@ -69,11 +80,17 @@ import {
69
80
  persistSlackBotInstallationWithSuccessAudit,
70
81
  recordSlackBotInstallCallbackFailure,
71
82
  revokeConnection,
83
+ revokeWorkspaceOpenRouterConnections,
84
+ revokeWorkspaceVercelAiGatewayConnections,
72
85
  revokeConnectionWithSlackBotSuccessAudit,
86
+ rotateWorkspaceVercelAiGatewayConnection,
87
+ rotateWorkspaceOpenRouterConnection,
73
88
  SlackBotLifecycleSuccessAuditError,
74
89
  SlackInstallationBindingConflictError,
75
90
  updateConnection,
76
91
  updateSlackBotDocumentDestination,
92
+ upsertWorkspaceVercelAiGatewayConnection,
93
+ upsertWorkspaceOpenRouterConnection,
77
94
  type SlackBotInstallCallbackFailureReason,
78
95
  type SlackBotInstallCallbackFailureStage,
79
96
  } from "@opengeni/db";
@@ -193,18 +210,69 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
193
210
  assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
194
211
  assertNotDirectAtlassianOAuth(providerDomain, payload.kind, payload.metadata);
195
212
  assertNotDirectPersonalGitHubOAuth(providerDomain, payload.kind, payload.metadata);
196
- const connection = await createConnection(db, {
197
- accountId: grant.accountId,
198
- workspaceId,
213
+ const credentialEncrypted = encryptCredentialBundle(key, payload.credential);
214
+ const workspaceProviderKind = workspaceProviderApiKeyConnectionKind({
199
215
  subjectId,
200
216
  providerDomain,
201
217
  kind: payload.kind,
202
- credentialEncrypted: encryptCredentialBundle(key, payload.credential),
203
- grantedScopes: payload.grantedScopes,
204
- expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
205
218
  metadata: payload.metadata,
206
- createdBySubjectId: grant.subjectId,
207
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,
243
+ grantedScopes: payload.grantedScopes,
244
+ expiresAt: expiresAt?.toISOString() ?? null,
245
+ metadata,
246
+ }),
247
+ credentialEncrypted,
248
+ 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
+ });
208
276
  return c.json(ConnectionResponse.parse({ connection }), 201);
209
277
  });
210
278
 
@@ -794,6 +862,24 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
794
862
  payload.providerDomain ?? existing.providerDomain,
795
863
  );
796
864
  const kind = payload.kind ?? existing.kind;
865
+ const subjectId =
866
+ payload.subjectId === undefined
867
+ ? existing.subjectId
868
+ : writableSubjectId(payload.subjectId, grant.subjectId);
869
+ const targetWorkspaceProviderKind = workspaceProviderApiKeyConnectionKind({
870
+ subjectId,
871
+ providerDomain,
872
+ kind,
873
+ metadata: payload.metadata ?? existing.metadata,
874
+ });
875
+ if (!workspaceProviderApiKeyConnectionKind(existing) && targetWorkspaceProviderKind) {
876
+ throw new HTTPException(422, {
877
+ message:
878
+ targetWorkspaceProviderKind === "vercel_gateway"
879
+ ? "use the Vercel AI Gateway connect flow to create this connection"
880
+ : "use the OpenRouter connect flow to create this connection",
881
+ });
882
+ }
797
883
  assertNotDirectPersonalSlackOAuth(providerDomain, kind);
798
884
  assertNotDirectGoogleDriveOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
799
885
  assertNotDirectAtlassianOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
@@ -819,6 +905,86 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
819
905
  });
820
906
  }
821
907
  }
908
+ const existingWorkspaceProviderKind = existing
909
+ ? workspaceProviderApiKeyConnectionKind(existing)
910
+ : null;
911
+ if (existing && existingWorkspaceProviderKind) {
912
+ const provider = workspaceProviderApiKeyConnectionSpec(existingWorkspaceProviderKind);
913
+ const providerDomain = canonicalProviderDomain(
914
+ payload.providerDomain ?? existing.providerDomain,
915
+ );
916
+ const subjectId =
917
+ payload.subjectId === undefined
918
+ ? existing.subjectId
919
+ : writableSubjectId(payload.subjectId, grant.subjectId);
920
+ const kind = payload.kind ?? existing.kind;
921
+ if (
922
+ subjectId !== null ||
923
+ providerDomain !== provider.providerDomain ||
924
+ kind !== "api_key" ||
925
+ (payload.metadata?.credentialRole !== undefined &&
926
+ payload.metadata.credentialRole !== provider.credentialRole)
927
+ ) {
928
+ throw new HTTPException(422, {
929
+ message: `${provider.label} connection identity cannot be changed`,
930
+ });
931
+ }
932
+ if (payload.credential === undefined) {
933
+ throw new HTTPException(400, {
934
+ message: `updating a ${provider.label} connection requires a new credential`,
935
+ });
936
+ }
937
+ if (payload.expectedVersion === undefined || payload.operationId === undefined) {
938
+ throw new HTTPException(400, {
939
+ message: `updating a ${provider.label} connection requires expectedVersion and operationId`,
940
+ });
941
+ }
942
+ const key = requireEnvironmentEncryption(settings);
943
+ const grantedScopes = payload.grantedScopes ?? existing.grantedScopes;
944
+ const expiresAt =
945
+ payload.expiresAt !== undefined
946
+ ? payload.expiresAt
947
+ ? new Date(payload.expiresAt)
948
+ : null
949
+ : existing.expiresAt
950
+ ? new Date(existing.expiresAt)
951
+ : null;
952
+ const metadata = workspaceProviderCredentialMetadata(existingWorkspaceProviderKind, {
953
+ ...existing.metadata,
954
+ ...(payload.metadata ?? {}),
955
+ });
956
+ const input = {
957
+ accountId: grant.accountId,
958
+ workspaceId,
959
+ connectionId: existing.id,
960
+ expectedVersion: payload.expectedVersion,
961
+ operationId: payload.operationId,
962
+ requestDigest: workspaceProviderCredentialRequestDigest(key, {
963
+ action: "rotate",
964
+ connectionId: existing.id,
965
+ expectedVersion: payload.expectedVersion,
966
+ credential: payload.credential,
967
+ grantedScopes,
968
+ expiresAt: expiresAt?.toISOString() ?? null,
969
+ metadata,
970
+ }),
971
+ credentialEncrypted: encryptCredentialBundle(key, payload.credential),
972
+ grantedScopes,
973
+ expiresAt,
974
+ metadata,
975
+ updatedBySubjectId: grant.subjectId,
976
+ };
977
+ const connection =
978
+ existingWorkspaceProviderKind === "vercel_gateway"
979
+ ? await rotateWorkspaceVercelAiGatewayConnection(db, input)
980
+ : await rotateWorkspaceOpenRouterConnection(db, input);
981
+ if (!connection) {
982
+ throw new HTTPException(409, {
983
+ message: `${provider.label} connection changed; reload before replacing its key`,
984
+ });
985
+ }
986
+ return c.json(ConnectionResponse.parse({ connection }));
987
+ }
822
988
  const key = payload.credential === undefined ? null : requireEnvironmentEncryption(settings);
823
989
  const subjectId =
824
990
  payload.subjectId === undefined
@@ -879,6 +1045,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
879
1045
  existing.providerDomain === ATLASSIAN_PROVIDER_DOMAIN &&
880
1046
  existing.kind === "oauth2" &&
881
1047
  AtlassianConnectionMetadata.safeParse(existing.metadata).success;
1048
+ const workspaceProviderKind = workspaceProviderApiKeyConnectionKind(existing);
882
1049
  const disconnectPayload = await c.req.json().catch(() => null);
883
1050
  const googleDriveDisconnect = isGoogleDrive
884
1051
  ? GoogleDriveDisconnectRequest.safeParse(disconnectPayload)
@@ -909,7 +1076,13 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
909
1076
  "invalid personal GitHub disconnect request",
910
1077
  });
911
1078
  }
912
- if (existing.status === "revoked" && !isGoogleDrive && !isAtlassian && !isPersonalGitHub) {
1079
+ if (
1080
+ existing.status === "revoked" &&
1081
+ !isGoogleDrive &&
1082
+ !isAtlassian &&
1083
+ !isPersonalGitHub &&
1084
+ !workspaceProviderKind
1085
+ ) {
913
1086
  return c.json(ConnectionResponse.parse({ connection: existing }));
914
1087
  }
915
1088
  const connection = isGoogleDrive
@@ -944,13 +1117,29 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
944
1117
  credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
945
1118
  slackTeamId: openGeniSlackBotMetadata(existing.metadata)!.slackTeamId,
946
1119
  })
947
- : await revokeConnection(
948
- db,
949
- workspaceId,
950
- connectionId,
951
- grant.subjectId,
952
- existing.version,
953
- );
1120
+ : workspaceProviderKind
1121
+ ? workspaceProviderKind === "vercel_gateway"
1122
+ ? await revokeWorkspaceVercelAiGatewayConnections(db, {
1123
+ accountId: grant.accountId,
1124
+ workspaceId,
1125
+ connectionId,
1126
+ expectedVersion: existing.version,
1127
+ updatedBySubjectId: grant.subjectId,
1128
+ })
1129
+ : await revokeWorkspaceOpenRouterConnections(db, {
1130
+ accountId: grant.accountId,
1131
+ workspaceId,
1132
+ connectionId,
1133
+ expectedVersion: existing.version,
1134
+ updatedBySubjectId: grant.subjectId,
1135
+ })
1136
+ : await revokeConnection(
1137
+ db,
1138
+ workspaceId,
1139
+ connectionId,
1140
+ grant.subjectId,
1141
+ existing.version,
1142
+ );
954
1143
  if (!connection) {
955
1144
  throw new HTTPException(409, { message: "connection changed during disconnect; try again" });
956
1145
  }
@@ -1367,6 +1556,51 @@ function assertNotReservedPersonalGitHubMetadata(
1367
1556
  }
1368
1557
  }
1369
1558
 
1559
+ type WorkspaceProviderApiKeyConnectionKind = "vercel_gateway" | "openrouter";
1560
+
1561
+ function workspaceProviderApiKeyConnectionSpec(
1562
+ providerKind: WorkspaceProviderApiKeyConnectionKind,
1563
+ ): {
1564
+ providerDomain: string;
1565
+ credentialRole: string;
1566
+ label: string;
1567
+ } {
1568
+ return providerKind === "vercel_gateway"
1569
+ ? {
1570
+ providerDomain: VERCEL_AI_GATEWAY_CONNECTION_DOMAIN,
1571
+ credentialRole: VERCEL_AI_GATEWAY_CONNECTION_ROLE,
1572
+ label: "Vercel AI Gateway",
1573
+ }
1574
+ : {
1575
+ providerDomain: WORKSPACE_OPENROUTER_CONNECTION_DOMAIN,
1576
+ credentialRole: WORKSPACE_OPENROUTER_CONNECTION_ROLE,
1577
+ label: "OpenRouter",
1578
+ };
1579
+ }
1580
+
1581
+ function workspaceProviderApiKeyConnectionKind(input: {
1582
+ subjectId?: string | null;
1583
+ providerDomain: string;
1584
+ kind: string;
1585
+ metadata?: Record<string, unknown>;
1586
+ }): WorkspaceProviderApiKeyConnectionKind | null {
1587
+ if (input.subjectId != null || input.kind !== "api_key") return null;
1588
+ const providerDomain = input.providerDomain.toLowerCase();
1589
+ if (
1590
+ providerDomain === VERCEL_AI_GATEWAY_CONNECTION_DOMAIN &&
1591
+ input.metadata?.credentialRole === VERCEL_AI_GATEWAY_CONNECTION_ROLE
1592
+ ) {
1593
+ return "vercel_gateway";
1594
+ }
1595
+ if (
1596
+ providerDomain === WORKSPACE_OPENROUTER_CONNECTION_DOMAIN &&
1597
+ input.metadata?.credentialRole === WORKSPACE_OPENROUTER_CONNECTION_ROLE
1598
+ ) {
1599
+ return "openrouter";
1600
+ }
1601
+ return null;
1602
+ }
1603
+
1370
1604
  function assertNotReservedSlackBotMetadata(metadata: Record<string, unknown> | undefined): void {
1371
1605
  if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
1372
1606
  throw new HTTPException(422, {
@@ -1426,6 +1660,27 @@ function encryptCredentialBundle(key: Uint8Array, credential: Record<string, unk
1426
1660
  return encryptEnvironmentValue(key, JSON.stringify(credential));
1427
1661
  }
1428
1662
 
1663
+ function workspaceProviderCredentialMetadata(
1664
+ providerKind: WorkspaceProviderApiKeyConnectionKind,
1665
+ metadata: Record<string, unknown> | undefined,
1666
+ ): Record<string, unknown> {
1667
+ const {
1668
+ [OPENROUTER_CREDENTIAL_OPERATION_ID_METADATA_KEY]: _openRouterOperationId,
1669
+ [OPENROUTER_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY]: _openRouterOperationDigest,
1670
+ [VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_ID_METADATA_KEY]: _operationId,
1671
+ [VERCEL_AI_GATEWAY_CREDENTIAL_OPERATION_DIGEST_METADATA_KEY]: _operationDigest,
1672
+ ...effectiveMetadata
1673
+ } = metadata ?? {};
1674
+ return {
1675
+ ...effectiveMetadata,
1676
+ credentialRole: workspaceProviderApiKeyConnectionSpec(providerKind).credentialRole,
1677
+ };
1678
+ }
1679
+
1680
+ function workspaceProviderCredentialRequestDigest(key: Uint8Array, value: unknown): string {
1681
+ return createHmac("sha256", key).update(stableJson(value), "utf8").digest("hex");
1682
+ }
1683
+
1429
1684
  function slackBotCredentialBundle(token: string): Record<string, unknown> {
1430
1685
  const headerName = ["author", "ization"].join("");
1431
1686
  const scheme = ["Bear", "er"].join("");
@@ -428,14 +428,6 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
428
428
  if (candidates.length === 0) {
429
429
  return redirectToGitHubInstallation(c, deps, selectionState);
430
430
  }
431
- if (candidates.length === 1) {
432
- return redirectToExactGitHubAuthorization(
433
- c,
434
- deps,
435
- selectionState,
436
- candidates[0]!.installation.installationId,
437
- );
438
- }
439
431
  setGitHubStateCookie(c, deps, selectionState);
440
432
  return c.html(
441
433
  githubInstallationChooserHtml(
@@ -66,7 +66,7 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
66
66
  c,
67
67
  deps,
68
68
  workspaceId,
69
- "workspace:admin",
69
+ "capabilities:manage",
70
70
  );
71
71
  const { grant } = authorization;
72
72
  try {
@@ -82,7 +82,10 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
82
82
  payload: await c.req.json(),
83
83
  canManageOrganizationDestination:
84
84
  authorization.accountGrant?.permissions.includes("account:admin") === true,
85
- canManageWorkspaceDestination: hasPermission(grant.permissions, "workspace:admin"),
85
+ canManageWorkspaceDestination: hasPermission(
86
+ grant.permissions,
87
+ "capabilities:manage",
88
+ ),
86
89
  canManagePersonalDestination:
87
90
  authorization.contextIntegrity &&
88
91
  authorization.authenticatedSubjectId === grant.subjectId,
@@ -122,7 +125,7 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
122
125
  "/v1/workspaces/:workspaceId/integrations/:capabilityId/instances/:instanceKey/facets/:facetKey",
123
126
  async (c) => {
124
127
  const workspaceId = c.req.param("workspaceId");
125
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
128
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
126
129
  const payload = UpsertIntegrationFacetRequest.parse(await c.req.json());
127
130
  const capabilityId = decoded(c.req.param("capabilityId"));
128
131
  const instanceKey = decoded(c.req.param("instanceKey"));
@@ -204,7 +207,7 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
204
207
  `/v1/workspaces/:workspaceId/integrations/:capabilityId/instances/:instanceKey/facets/:facetKey/${action}`,
205
208
  async (c) => {
206
209
  const workspaceId = c.req.param("workspaceId");
207
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
210
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
208
211
  const payload = MutateIntegrationFacetRequest.parse(await c.req.json());
209
212
  try {
210
213
  return c.json(
@@ -233,7 +236,7 @@ export function registerIntegrationFacetRoutes(app: Hono, deps: ApiRouteDeps): v
233
236
  "/v1/workspaces/:workspaceId/integrations/:capabilityId/instances/:instanceKey/facets/:facetKey",
234
237
  async (c) => {
235
238
  const workspaceId = c.req.param("workspaceId");
236
- const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
239
+ const grant = await requireAccessGrant(c, deps, workspaceId, "capabilities:manage");
237
240
  const payload = MutateIntegrationFacetRequest.parse(await c.req.json());
238
241
  try {
239
242
  return c.json(