@opengeni/api-router 3.1.3-canary.38890816741001 → 3.3.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 (47) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/{chunk-OT56OY4V.js → chunk-6CECHDZP.js} +1569 -399
  3. package/dist/{chunk-OT56OY4V.js.map → chunk-6CECHDZP.js.map} +1 -1
  4. package/dist/index.js +50 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/integrations/external-vault-providers.d.ts +29 -0
  7. package/dist/interaction-secrets-vault.d.ts +2 -2
  8. package/dist/mcp/action-descriptions.d.ts +1 -1
  9. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  10. package/dist/routes/vault-providers.d.ts +4 -2
  11. package/dist/sandbox/channel-a.d.ts +10 -1
  12. package/dist/shutdown.d.ts +14 -0
  13. package/dist/site-publishing/agent-data-tools.d.ts +5 -5
  14. package/dist/site-publishing/agent-session.d.ts +3 -2
  15. package/dist/site-publishing/runtime.d.ts +7 -6
  16. package/dist/site-publishing/service.d.ts +8 -11
  17. package/dist/site-publishing/session-request-authority.d.ts +3 -0
  18. package/dist/workspace-tool-gateway-secrets.d.ts +1 -1
  19. package/package.json +25 -22
  20. package/src/access-grant-rls.ts +1 -0
  21. package/src/app.ts +14 -2
  22. package/src/http/sse.ts +5 -1
  23. package/src/index.ts +13 -1
  24. package/src/integrations/external-vault-providers.ts +136 -0
  25. package/src/interaction-secrets-vault.ts +6 -2
  26. package/src/mcp/action-catalog.gen.ts +253 -4
  27. package/src/mcp/action-descriptions.ts +32 -2
  28. package/src/mcp/action-schema-imports.gen.ts +3 -1
  29. package/src/routes/browser-sessions.ts +61 -17
  30. package/src/routes/computer-sessions.ts +102 -39
  31. package/src/routes/connections.ts +17 -0
  32. package/src/routes/insights-usage.ts +22 -0
  33. package/src/routes/sessions.ts +22 -4
  34. package/src/routes/site-runtime.ts +62 -20
  35. package/src/routes/vault-providers.ts +788 -29
  36. package/src/routes/vaults.ts +5 -1
  37. package/src/sandbox/channel-a.ts +18 -1
  38. package/src/sandbox/metrics-ingestion.ts +1 -0
  39. package/src/shutdown.ts +54 -0
  40. package/src/site-frame-guard.ts +11 -2
  41. package/src/site-publishing/agent-data-tools.ts +16 -8
  42. package/src/site-publishing/agent-session.ts +24 -6
  43. package/src/site-publishing/runtime.ts +29 -7
  44. package/src/site-publishing/service.ts +65 -77
  45. package/src/site-publishing/session-request-authority.ts +14 -0
  46. package/src/workspace-tool-gateway-secrets.ts +9 -2
  47. package/src/workspace-tool-gateway.ts +6 -2
@@ -1,5 +1,7 @@
1
1
  import type { Context, Hono } from "hono";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import { HTTPException } from "hono/http-exception";
4
+ import { createNativeExternalVaultFactories } from "../integrations/external-vault-providers";
3
5
  import {
4
6
  CreateManagedProviderVaultRequest,
5
7
  DisconnectOnePasswordResponse,
@@ -10,17 +12,28 @@ import {
10
12
  OnePasswordConnectionSummary,
11
13
  ProviderVaultMeta,
12
14
  ReplaceOnePasswordTokenRequest,
15
+ InstallVaultProviderRequest,
16
+ InstallVaultProviderResponse,
17
+ ListVaultProviderConnectionsResponse,
18
+ ReplaceVaultProviderCredentialsRequest,
19
+ DisconnectVaultProviderResponse,
13
20
  VAULT_ERROR_HTTP_STATUS,
14
21
  type VaultApiError,
15
22
  type VaultSummary,
23
+ type ExternalVaultProviderKind,
24
+ type VaultProviderCredentials,
25
+ type VaultProviderConnectionSummary,
26
+ type ConnectionMetadata,
16
27
  } from "@opengeni/contracts";
17
28
  import {
18
29
  ONEPASSWORD_CREDENTIAL_ROLE,
19
30
  ONEPASSWORD_PROVIDER_DOMAIN,
20
31
  createWorkspaceVaultProviderRegistry,
32
+ createRemoteExternalVaultFactories,
21
33
  externalActorContinuationForAuthorization,
22
34
  hasPermission,
23
35
  isVaultError,
36
+ VaultError,
24
37
  providerSecretName,
25
38
  requirePermission,
26
39
  syncLinkedProviderVault,
@@ -37,6 +50,16 @@ import {
37
50
  type ApiRouteDeps,
38
51
  type OnePasswordConnectionMetadata,
39
52
  type OnePasswordVaultProviderFactory,
53
+ externalVaultConnectionDetails,
54
+ externalVaultConnectionMetadata,
55
+ externalVaultProviderDomain,
56
+ externalVaultProviderForConnection,
57
+ isExternalVaultConnection,
58
+ StoredVaultProviderCredentials,
59
+ type ExternalVaultConnectionDetails,
60
+ type ExternalVaultProviderFactories,
61
+ loadExternalVaultConnection,
62
+ pendingVaultCredentialBundle,
40
63
  } from "@opengeni/core";
41
64
  import {
42
65
  createConnection,
@@ -45,6 +68,7 @@ import {
45
68
  getConnectionMetadata,
46
69
  getVault,
47
70
  linkProviderVault,
71
+ preflightProviderVaultLink,
48
72
  listConnectionsMetadata,
49
73
  listVaultsForSubject,
50
74
  revokeConnection,
@@ -59,6 +83,10 @@ import {
59
83
  type VaultProvider,
60
84
  type VaultProviderContext,
61
85
  type VaultProviderRegistry,
86
+ type VaultProviderAuthority,
87
+ createProviderCallBudget,
88
+ listProviderVaults,
89
+ providerOperations,
62
90
  } from "@opengeni/vaults";
63
91
  import { isPersonalConnectionOwnerPrincipal } from "../connection-ownership";
64
92
  import { parseRequestJson } from "../http/request-body";
@@ -132,7 +160,14 @@ export async function onePasswordProviderForConnection(
132
160
  */
133
161
  export function vaultProviderRegistryFor(
134
162
  deps: ApiRouteDeps,
135
- input: { workspaceId: string; subjectId: string | null },
163
+ input: {
164
+ workspaceId: string;
165
+ subjectId: string | null;
166
+ executionContext?: Pick<
167
+ VaultProviderAuthority,
168
+ "sessionId" | "turnId" | "attemptId" | "siteId"
169
+ >;
170
+ },
136
171
  ): VaultProviderRegistry {
137
172
  return createWorkspaceVaultProviderRegistry({
138
173
  db: deps.db,
@@ -142,9 +177,62 @@ export function vaultProviderRegistryFor(
142
177
  hostVaultProvider: deps.connectionCredentials?.vaultProvider ?? null,
143
178
  onePasswordVaultProvider:
144
179
  deps.onePasswordVaultProvider === null ? null : onePasswordFactoryFor(deps),
180
+ externalVaultProviders: externalVaultFactoriesFor(deps),
181
+ ...(input.executionContext ? { executionContext: input.executionContext } : {}),
145
182
  });
146
183
  }
147
184
 
185
+ const defaultExternalVaultFactories = new WeakMap<ApiRouteDeps, ExternalVaultProviderFactories>();
186
+
187
+ export function externalVaultFactoriesFor(deps: ApiRouteDeps): ExternalVaultProviderFactories {
188
+ if (deps.externalVaultProviders !== undefined) return deps.externalVaultProviders ?? {};
189
+ let factories = defaultExternalVaultFactories.get(deps);
190
+ if (!factories) {
191
+ factories = {
192
+ ...createRemoteExternalVaultFactories(deps.settings),
193
+ ...createNativeExternalVaultFactories(deps.settings),
194
+ };
195
+ defaultExternalVaultFactories.set(deps, factories);
196
+ }
197
+ return factories;
198
+ }
199
+
200
+ /** Both native 1Password and the generic connection lifecycle use this catalog. */
201
+ async function providerForConnection(
202
+ deps: ApiRouteDeps,
203
+ input: { workspaceId: string; connectionId: string; subjectId: string | null },
204
+ ): Promise<{ provider: VaultProvider; source: "onepassword" | ExternalVaultProviderKind }> {
205
+ const connection = await getConnectionMetadata(
206
+ deps.db,
207
+ input.workspaceId,
208
+ input.connectionId,
209
+ input.subjectId,
210
+ );
211
+ if (connection && isOnePasswordConnection(connection)) {
212
+ return { ...(await onePasswordProviderForConnection(deps, input)), source: "onepassword" };
213
+ }
214
+ if (!connection || !isExternalVaultConnection(connection) || connection.status === "revoked") {
215
+ throw new HTTPException(404, { message: "Vault connection not found" });
216
+ }
217
+ const details = externalVaultConnectionDetails(connection.metadata)!;
218
+ const factory = externalVaultFactoriesFor(deps)[details.provider];
219
+ if (!factory)
220
+ throw new VaultProviderError(
221
+ "provider_unavailable",
222
+ "This vault provider is not available in this deployment.",
223
+ );
224
+ return {
225
+ source: details.provider,
226
+ provider: await externalVaultProviderForConnection({
227
+ ...input,
228
+ db: deps.db,
229
+ settings: deps.settings,
230
+ provider: details.provider,
231
+ factory,
232
+ }),
233
+ };
234
+ }
235
+
148
236
  function vaultContextFor(
149
237
  authorization: AccessGrantAuthorization,
150
238
  workspaceId: string,
@@ -286,7 +374,7 @@ export function registerVaultProviderRoutes(
286
374
  rows
287
375
  .filter(
288
376
  (row) =>
289
- row.source === "onepassword" &&
377
+ row.source !== "opengeni" &&
290
378
  row.providerConnectionId === connectionId &&
291
379
  row.providerVaultRef,
292
380
  )
@@ -332,7 +420,7 @@ export function registerVaultProviderRoutes(
332
420
  planTier: payload.planTier,
333
421
  allowFakeTokens: settings.onePasswordFakeTokens,
334
422
  });
335
- vaults = await provider.listVaults(routeProviderContext(null));
423
+ vaults = await listProviderVaults(provider, routeProviderContext(null));
336
424
  } catch (error) {
337
425
  return providerErrorResponse(c, error);
338
426
  }
@@ -398,13 +486,13 @@ export function registerVaultProviderRoutes(
398
486
  );
399
487
  const { grant } = authorization;
400
488
  try {
401
- const { provider } = await onePasswordProviderForConnection(deps, {
489
+ const { provider } = await providerForConnection(deps, {
402
490
  workspaceId,
403
491
  connectionId,
404
492
  subjectId: grant.subjectId,
405
493
  });
406
494
  const [vaults, linked] = await Promise.all([
407
- provider.listVaults(routeProviderContext(connectionId)),
495
+ listProviderVaults(provider, routeProviderContext(connectionId)),
408
496
  linkedVaults({
409
497
  workspaceId,
410
498
  connectionId,
@@ -418,6 +506,8 @@ export function registerVaultProviderRoutes(
418
506
  name: vault.name,
419
507
  itemCount: vault.itemCount,
420
508
  linkedVaultId: linked.get(vault.externalVaultId) ?? null,
509
+ writeAccess: vault.writeAccess ?? "unknown",
510
+ operations: providerOperations(provider.capabilities(), vault),
421
511
  }),
422
512
  ),
423
513
  });
@@ -441,15 +531,20 @@ export function registerVaultProviderRoutes(
441
531
  const context = vaultContextFor(authorization, workspaceId);
442
532
  try {
443
533
  let provider: VaultProvider;
444
- if (payload.provider === "onepassword") {
534
+ if (payload.provider !== "host") {
445
535
  if (!payload.connectionId) {
446
- throw new HTTPException(422, { message: "connectionId is required for 1Password" });
536
+ throw new HTTPException(422, {
537
+ message: "connectionId is required for a connected provider",
538
+ });
447
539
  }
448
- ({ provider } = await onePasswordProviderForConnection(deps, {
540
+ const resolved = await providerForConnection(deps, {
449
541
  workspaceId,
450
542
  connectionId: payload.connectionId,
451
543
  subjectId: authorization.grant.subjectId,
452
- }));
544
+ });
545
+ if (resolved.source !== payload.provider)
546
+ throw new HTTPException(422, { message: "Connection provider does not match" });
547
+ provider = resolved.provider;
453
548
  } else {
454
549
  const host = deps.connectionCredentials?.vaultProvider;
455
550
  if (!host) {
@@ -460,14 +555,14 @@ export function registerVaultProviderRoutes(
460
555
  }
461
556
  provider = host;
462
557
  }
463
- const connectionId = payload.provider === "onepassword" ? payload.connectionId! : null;
558
+ const connectionId = payload.provider !== "host" ? payload.connectionId! : null;
464
559
  const providerVault = findProviderVault(
465
- await provider.listVaults(routeProviderContext(connectionId)),
560
+ await listProviderVaults(provider, routeProviderContext(connectionId)),
466
561
  payload.externalVaultId,
467
562
  );
468
563
  const row = await linkProviderVault(db, context, {
469
564
  ownerKind: payload.ownerKind,
470
- name: payload.name ?? providerSecretName(providerVault.name, "1Password vault"),
565
+ name: payload.name ?? providerSecretName(providerVault.name, "Connected vault"),
471
566
  source: payload.provider,
472
567
  providerConnectionId: connectionId,
473
568
  providerVaultRef: providerVault.externalVaultId,
@@ -509,6 +604,7 @@ export function registerVaultProviderRoutes(
509
604
  workspaceId,
510
605
  subjectId: authorization.grant.subjectId,
511
606
  }),
607
+ force: true,
512
608
  });
513
609
  const rateLimitedUntil =
514
610
  result.status === "skipped_rate_limited" ||
@@ -541,7 +637,40 @@ export function registerVaultProviderRoutes(
541
637
  throw new HTTPException(403, { message: "missing permission: account:admin" });
542
638
  }
543
639
  const context = vaultContextFor(authorization, workspaceId);
640
+ let created: ProviderVault | undefined;
641
+ let linked: VaultRow | undefined;
642
+ let sharingFailure: VaultProviderError | undefined;
544
643
  try {
644
+ // Match link_provider_vault's ownership gate before making a remote
645
+ // vault. A refused local link must not leave a newly created vault behind.
646
+ const { connection, metadata } = await requireOnePasswordConnection(
647
+ deps,
648
+ authorization,
649
+ workspaceId,
650
+ connectionId,
651
+ );
652
+ const ownership = connection.subjectId !== null ? "user" : metadata.ownership;
653
+ if (
654
+ ownership !== payload.ownerKind ||
655
+ (ownership === "user" && connection.subjectId !== authorization.grant.subjectId)
656
+ ) {
657
+ throw new VaultError(
658
+ "vault_permission_denied",
659
+ `A ${ownership} 1Password connection backs only ${ownership} vaults`,
660
+ { connectionId, connectionOwnership: ownership, ownerKind: payload.ownerKind },
661
+ );
662
+ }
663
+ const link = {
664
+ ownerKind: payload.ownerKind,
665
+ name: payload.name,
666
+ source: "onepassword" as const,
667
+ providerConnectionId: connectionId,
668
+ mode: "read_write" as const,
669
+ allowOrganization: isAccountAdmin(authorization),
670
+ };
671
+ // Exact SQL policy, including names, quotas and personal authority; all
672
+ // effects roll back. This is not a reservation over the provider call.
673
+ await preflightProviderVaultLink(db, context, link);
545
674
  const { provider } = await onePasswordProviderForConnection(deps, {
546
675
  workspaceId,
547
676
  connectionId,
@@ -553,33 +682,70 @@ export function registerVaultProviderRoutes(
553
682
  "this 1Password connection cannot create vaults",
554
683
  );
555
684
  }
556
- const created = await provider.createVault(routeProviderContext(connectionId), {
557
- name: payload.name,
558
- description: "Managed by Opengeni",
559
- allowAdminsAccess: true,
560
- ...(payload.groupId ? { groupId: payload.groupId } : {}),
561
- });
562
- const row = await linkProviderVault(db, context, {
563
- ownerKind: payload.ownerKind,
564
- name: payload.name,
565
- source: "onepassword",
566
- providerConnectionId: connectionId,
685
+ const { OnePasswordVaultCreatedError } = await loadOnePasswordModule();
686
+ try {
687
+ created = await provider.createVault(routeProviderContext(connectionId), {
688
+ name: payload.name,
689
+ description: "Managed by Opengeni",
690
+ allowAdminsAccess: true,
691
+ ...(payload.groupId ? { groupId: payload.groupId } : {}),
692
+ });
693
+ } catch (error) {
694
+ if (error instanceof OnePasswordVaultCreatedError) {
695
+ created = error.createdVault;
696
+ sharingFailure = error;
697
+ } else if (isVaultProviderError(error)) {
698
+ throw error;
699
+ } else {
700
+ throw new VaultProviderError(
701
+ "provider_outcome_unknown",
702
+ "1Password did not confirm vault creation; it may have been applied. Check the connection's vault list before creating again.",
703
+ );
704
+ }
705
+ }
706
+ linked = await linkProviderVault(db, context, {
707
+ ...link,
567
708
  providerVaultRef: created.externalVaultId,
568
- mode: "read_write",
569
- allowOrganization: isAccountAdmin(authorization),
570
709
  });
571
710
  await syncLinkedProviderVault({
572
711
  db,
573
712
  context,
574
- vaultId: row.id,
713
+ vaultId: linked.id,
575
714
  providers: vaultProviderRegistryFor(deps, {
576
715
  workspaceId,
577
716
  subjectId: authorization.grant.subjectId,
578
717
  }),
579
718
  force: true,
580
719
  });
581
- return c.json(await vaultSummaryFor(deps, authorization, workspaceId, row.id), 201);
720
+ if (sharingFailure) throw sharingFailure;
721
+ return c.json(await vaultSummaryFor(deps, authorization, workspaceId, linked.id), 201);
582
722
  } catch (error) {
723
+ if (created) {
724
+ const local = isVaultError(error) ? error : vaultErrorFromDatabase(error);
725
+ const code =
726
+ local?.code ??
727
+ (isVaultProviderError(error)
728
+ ? vaultErrorCodeForProvider(error.code, "write")
729
+ : "provider_unavailable");
730
+ const message = linked
731
+ ? sharingFailure
732
+ ? "The vault was created and linked, but its group access could not be confirmed. Check sharing in 1Password. Do not create it again."
733
+ : "The vault was created and linked, but its refresh could not be confirmed. Refresh the linked vault. Do not create it again."
734
+ : "The vault was created in 1Password, but its local link could not be confirmed. Use Link on the existing provider vault after resolving the local refusal. Do not create it again.";
735
+ return vaultRouteError(
736
+ c,
737
+ new VaultError(code, message, {
738
+ created: true,
739
+ // A lost DB acknowledgement is not proof that the link rolled back.
740
+ linked: linked ? true : local ? false : null,
741
+ connectionId,
742
+ externalVaultId: created.externalVaultId,
743
+ ...(linked ? { vaultId: linked.id } : {}),
744
+ ...(sharingFailure ? { groupGrantConfirmed: false } : {}),
745
+ }),
746
+ true,
747
+ );
748
+ }
583
749
  return vaultRouteError(c, error, true);
584
750
  }
585
751
  });
@@ -653,7 +819,7 @@ export function registerVaultProviderRoutes(
653
819
  planTier,
654
820
  allowFakeTokens: settings.onePasswordFakeTokens,
655
821
  });
656
- vaults = await provider.listVaults(routeProviderContext(connectionId));
822
+ vaults = await listProviderVaults(provider, routeProviderContext(connectionId));
657
823
  } catch (error) {
658
824
  return providerErrorResponse(c, error);
659
825
  }
@@ -766,6 +932,599 @@ export function registerVaultProviderRoutes(
766
932
  return c.json(DisconnectOnePasswordResponse.parse({ unlinkedVaultIds }));
767
933
  });
768
934
 
935
+ /** Metadata-only unified catalog; listing never decrypts a provider token. */
936
+ app.get("/v1/workspaces/:workspaceId/vault-providers/connections", async (c) => {
937
+ const workspaceId = c.req.param("workspaceId");
938
+ const authorization = await requireAccessGrantAuthorization(
939
+ c,
940
+ deps,
941
+ workspaceId,
942
+ "secrets:list",
943
+ );
944
+ const factories = externalVaultFactoriesFor(deps);
945
+ const rows = await listConnectionsMetadata(db, workspaceId, authorization.grant.subjectId);
946
+ const connections = rows
947
+ .filter((row) => row.status !== "revoked")
948
+ .flatMap<VaultProviderConnectionSummary>((row) => {
949
+ if (isOnePasswordConnection(row)) {
950
+ return [
951
+ {
952
+ ...onePasswordConnectionSummary(authorization, row),
953
+ provider: "onepassword" as const,
954
+ // The published legacy 1Password port has no granular override.
955
+ operations: providerOperations({
956
+ write: true,
957
+ createVault: true,
958
+ totp: true,
959
+ files: false,
960
+ groupGrants: true,
961
+ passkeys: false,
962
+ }),
963
+ available: deps.onePasswordVaultProvider !== null,
964
+ },
965
+ ];
966
+ }
967
+ if (!isExternalVaultConnection(row)) return [];
968
+ const details = externalVaultConnectionDetails(row.metadata)!;
969
+ return [
970
+ {
971
+ id: row.id,
972
+ provider: details.provider,
973
+ ownership: details.ownership,
974
+ accountName: details.accountName,
975
+ label: details.label,
976
+ verifiedAt: details.verifiedAt,
977
+ createdAt: row.createdAt,
978
+ createdBySubjectId: row.createdBySubjectId,
979
+ canManage: canManageExternal(authorization, row, details),
980
+ operations: details.operations,
981
+ available: Boolean(factories[details.provider]),
982
+ verificationStatus: details.verificationStatus ?? "ready",
983
+ },
984
+ ];
985
+ });
986
+ const providers = (
987
+ ["onepassword", "external", "protonpass", "keeper", "bitwarden"] as const
988
+ ).map((provider) => {
989
+ const available =
990
+ provider === "onepassword"
991
+ ? deps.onePasswordVaultProvider !== null
992
+ : Boolean(factories[provider]);
993
+ return { provider, available, reason: available ? null : ("not_enabled" as const) };
994
+ });
995
+ return c.json(ListVaultProviderConnectionsResponse.parse({ connections, providers }));
996
+ });
997
+
998
+ app.post("/v1/workspaces/:workspaceId/vault-providers/connections", async (c) => {
999
+ const workspaceId = c.req.param("workspaceId");
1000
+ const authorization = await requireSetupAuthority(c, workspaceId);
1001
+ const { grant } = authorization;
1002
+ const payload = await parseRequestJson(c, InstallVaultProviderRequest);
1003
+ requireOwnership(authorization, payload.ownership);
1004
+ const key = requireEnvironmentEncryption(settings);
1005
+ if (!externalVaultFactoriesFor(deps)[payload.credentials.provider]) {
1006
+ return vaultRouteError(
1007
+ c,
1008
+ new VaultProviderError(
1009
+ "provider_unavailable",
1010
+ "This vault provider is not available in this deployment.",
1011
+ ),
1012
+ );
1013
+ }
1014
+ const details: ExternalVaultConnectionDetails = {
1015
+ version: 1,
1016
+ provider: payload.credentials.provider,
1017
+ ownership: payload.ownership,
1018
+ accountId: null,
1019
+ accountName: null,
1020
+ label: payload.label ?? null,
1021
+ verifiedAt: new Date().toISOString(),
1022
+ verificationStatus: "pending",
1023
+ operations: providerOperations({
1024
+ write: false,
1025
+ createVault: false,
1026
+ totp: false,
1027
+ files: false,
1028
+ groupGrants: false,
1029
+ passkeys: false,
1030
+ }),
1031
+ };
1032
+ const continuation = externalActorContinuationForAuthorization(authorization);
1033
+ // Reserve identity before a remote call or one-time-token binding. No
1034
+ // usable credential (in particular no bootstrap token) is stored here.
1035
+ const connection = await db.transaction(async (tx) => {
1036
+ await requireConnectOwnerAuthority(tx, {
1037
+ accountId: grant.accountId,
1038
+ workspaceId,
1039
+ subjectId: grant.subjectId,
1040
+ personalOwnerVerified: isPersonalConnectionOwnerPrincipal(authorization),
1041
+ ...(continuation ? { externalContinuation: continuation } : {}),
1042
+ });
1043
+ return createConnection(tx, {
1044
+ accountId: grant.accountId,
1045
+ workspaceId,
1046
+ subjectId: payload.ownership === "user" ? grant.subjectId : null,
1047
+ providerDomain: externalVaultProviderDomain(details.provider),
1048
+ kind: "api_key",
1049
+ status: "needs_reauth",
1050
+ credentialEncrypted: encryptEnvironmentValue(key, "{}"),
1051
+ grantedScopes: [],
1052
+ expiresAt: null,
1053
+ metadata: externalVaultConnectionMetadata(details),
1054
+ createdBySubjectId: grant.subjectId,
1055
+ });
1056
+ });
1057
+ try {
1058
+ const result = await finishExternalSetup({
1059
+ authorization,
1060
+ workspaceId,
1061
+ connection,
1062
+ credentials: payload.credentials,
1063
+ currentCredentials: null,
1064
+ isNew: true,
1065
+ });
1066
+ return c.json(result.body, result.status);
1067
+ } catch (error) {
1068
+ return vaultRouteError(c, error);
1069
+ }
1070
+ });
1071
+
1072
+ app.put(
1073
+ "/v1/workspaces/:workspaceId/vault-providers/connections/:connectionId/credentials",
1074
+ async (c) => {
1075
+ const workspaceId = c.req.param("workspaceId");
1076
+ const connectionId = c.req.param("connectionId");
1077
+ const authorization = await requireSetupAuthority(c, workspaceId);
1078
+ const { grant } = authorization;
1079
+ const connection = await getConnectionMetadata(
1080
+ db,
1081
+ workspaceId,
1082
+ connectionId,
1083
+ grant.subjectId,
1084
+ );
1085
+ if (
1086
+ !connection ||
1087
+ !isExternalVaultConnection(connection) ||
1088
+ connection.status === "revoked"
1089
+ ) {
1090
+ throw new HTTPException(404, { message: "Vault connection not found" });
1091
+ }
1092
+ const details = externalVaultConnectionDetails(connection.metadata)!;
1093
+ if (!canManageExternal(authorization, connection, details))
1094
+ throw new HTTPException(403, { message: "You cannot change this vault connection" });
1095
+ requireOwnership(authorization, details.ownership);
1096
+ const payload = await parseRequestJson(c, ReplaceVaultProviderCredentialsRequest);
1097
+ if (payload.credentials.provider !== details.provider)
1098
+ throw new HTTPException(422, { message: "Connection provider does not match" });
1099
+ try {
1100
+ const loaded = await loadExternalVaultConnection(db, settings, {
1101
+ workspaceId,
1102
+ connectionId,
1103
+ subjectId: grant.subjectId,
1104
+ provider: details.provider,
1105
+ allowPendingVerification: true,
1106
+ });
1107
+ const result = await finishExternalSetup({
1108
+ authorization,
1109
+ workspaceId,
1110
+ connection: loaded.connection,
1111
+ credentials: payload.credentials,
1112
+ currentCredentials: loaded.currentCredentials,
1113
+ pendingCredentials: loaded.pendingCredentials,
1114
+ isNew: false,
1115
+ });
1116
+ return c.json(result.body, result.status);
1117
+ } catch (error) {
1118
+ return vaultRouteError(c, error);
1119
+ }
1120
+ },
1121
+ );
1122
+
1123
+ app.post(
1124
+ "/v1/workspaces/:workspaceId/vault-providers/connections/:connectionId/verify",
1125
+ async (c) => {
1126
+ const workspaceId = c.req.param("workspaceId");
1127
+ const connectionId = c.req.param("connectionId");
1128
+ const authorization = await requireSetupAuthority(c, workspaceId);
1129
+ const connection = await getConnectionMetadata(
1130
+ db,
1131
+ workspaceId,
1132
+ connectionId,
1133
+ authorization.grant.subjectId,
1134
+ );
1135
+ if (!connection || !isExternalVaultConnection(connection) || connection.status === "revoked")
1136
+ throw new HTTPException(404, { message: "Vault connection not found" });
1137
+ const details = externalVaultConnectionDetails(connection.metadata)!;
1138
+ if (!canManageExternal(authorization, connection, details))
1139
+ throw new HTTPException(403, { message: "You cannot verify this vault connection" });
1140
+ requireOwnership(authorization, details.ownership);
1141
+ try {
1142
+ const loaded = await loadExternalVaultConnection(db, settings, {
1143
+ workspaceId,
1144
+ connectionId,
1145
+ subjectId: authorization.grant.subjectId,
1146
+ provider: details.provider,
1147
+ allowPendingVerification: true,
1148
+ });
1149
+ const result = await finishExternalSetup({
1150
+ authorization,
1151
+ workspaceId,
1152
+ connection: loaded.connection,
1153
+ credentials: loaded.credentials,
1154
+ currentCredentials: loaded.currentCredentials,
1155
+ pendingCredentials: loaded.pendingCredentials,
1156
+ isNew: false,
1157
+ });
1158
+ return c.json(result.body, result.status);
1159
+ } catch (error) {
1160
+ return vaultRouteError(c, error);
1161
+ }
1162
+ },
1163
+ );
1164
+
1165
+ app.delete("/v1/workspaces/:workspaceId/vault-providers/connections/:connectionId", async (c) => {
1166
+ const workspaceId = c.req.param("workspaceId");
1167
+ const connectionId = c.req.param("connectionId");
1168
+ const authorization = await requireSetupAuthority(c, workspaceId);
1169
+ const { grant } = authorization;
1170
+ const connection = await getConnectionMetadata(db, workspaceId, connectionId, grant.subjectId);
1171
+ if (!connection || connection.status === "revoked")
1172
+ throw new HTTPException(404, { message: "Vault connection not found" });
1173
+ if (isOnePasswordConnection(connection)) {
1174
+ requireManageOnePasswordConnection(
1175
+ authorization,
1176
+ connection,
1177
+ onePasswordConnectionMetadata(connection.metadata)!,
1178
+ );
1179
+ } else {
1180
+ const details = externalVaultConnectionDetails(connection.metadata);
1181
+ if (!details || !isExternalVaultConnection(connection))
1182
+ throw new HTTPException(404, { message: "Vault connection not found" });
1183
+ if (!canManageExternal(authorization, connection, details))
1184
+ throw new HTTPException(403, { message: "You cannot disconnect this vault connection" });
1185
+ }
1186
+ try {
1187
+ // Local unlink and credential revocation commit together; no vendor mutation.
1188
+ const unlinkedVaultIds = await db.transaction(async (tx) => {
1189
+ const context = vaultContextFor(authorization, workspaceId);
1190
+ const rows = await listVaultsForSubject(tx, context, { workspaceId });
1191
+ const unlinked: string[] = [];
1192
+ for (const row of rows.filter(
1193
+ (candidate) => candidate.providerConnectionId === connectionId,
1194
+ )) {
1195
+ await deleteVault(tx, context, {
1196
+ vaultId: row.id,
1197
+ confirmName: row.name,
1198
+ allowOrganization: isAccountAdmin(authorization),
1199
+ });
1200
+ unlinked.push(row.id);
1201
+ }
1202
+ const revoked = await revokeConnection(
1203
+ tx,
1204
+ workspaceId,
1205
+ connectionId,
1206
+ grant.subjectId,
1207
+ connection.version,
1208
+ );
1209
+ if (!revoked)
1210
+ throw new HTTPException(409, {
1211
+ message: "This vault connection changed. Reload it before disconnecting.",
1212
+ });
1213
+ return unlinked;
1214
+ });
1215
+ return c.json(DisconnectVaultProviderResponse.parse({ unlinkedVaultIds }));
1216
+ } catch (error) {
1217
+ return vaultRouteError(c, error);
1218
+ }
1219
+ });
1220
+
1221
+ async function requireSetupAuthority(
1222
+ c: Context,
1223
+ workspaceId: string,
1224
+ ): Promise<AccessGrantAuthorization> {
1225
+ const authorization = await requireAccessGrantAuthorization(
1226
+ c,
1227
+ deps,
1228
+ workspaceId,
1229
+ "connections:write",
1230
+ );
1231
+ requirePermission(authorization.grant, "secrets:write");
1232
+ return authorization;
1233
+ }
1234
+
1235
+ function requireOwnership(
1236
+ authorization: AccessGrantAuthorization,
1237
+ ownership: "user" | "workspace" | "organization",
1238
+ ): void {
1239
+ if (ownership === "organization" && !isAccountAdmin(authorization))
1240
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
1241
+ if (ownership === "user" && !isPersonalConnectionOwnerPrincipal(authorization))
1242
+ throw new HTTPException(403, {
1243
+ message: "Only a signed-in person can manage a personal vault connection",
1244
+ });
1245
+ }
1246
+
1247
+ function canManageExternal(
1248
+ authorization: AccessGrantAuthorization,
1249
+ connection: OnePasswordConnectionRow,
1250
+ details: ExternalVaultConnectionDetails,
1251
+ ): boolean {
1252
+ const grant = authorization.grant;
1253
+ return (
1254
+ hasPermission(grant.permissions, "connections:write", grant.permissionMode) &&
1255
+ hasPermission(grant.permissions, "secrets:write", grant.permissionMode) &&
1256
+ (connection.subjectId !== null
1257
+ ? connection.subjectId === grant.subjectId &&
1258
+ isPersonalConnectionOwnerPrincipal(authorization)
1259
+ : details.ownership !== "organization" || isAccountAdmin(authorization))
1260
+ );
1261
+ }
1262
+
1263
+ function catalogVault(
1264
+ provider: VaultProvider,
1265
+ vault: ProviderVault,
1266
+ linkedVaultId: string | null,
1267
+ ): ProviderVaultMeta {
1268
+ return {
1269
+ externalVaultId: vault.externalVaultId,
1270
+ name: vault.name,
1271
+ itemCount: vault.itemCount,
1272
+ linkedVaultId,
1273
+ writeAccess: vault.writeAccess ?? "unknown",
1274
+ operations: providerOperations(provider.capabilities(), vault),
1275
+ };
1276
+ }
1277
+
1278
+ async function finishExternalSetup(input: {
1279
+ authorization: AccessGrantAuthorization;
1280
+ workspaceId: string;
1281
+ connection: ConnectionMetadata;
1282
+ credentials: VaultProviderCredentials | StoredVaultProviderCredentials;
1283
+ currentCredentials: StoredVaultProviderCredentials | null;
1284
+ pendingCredentials?: StoredVaultProviderCredentials | null;
1285
+ isNew: boolean;
1286
+ }): Promise<{ body: InstallVaultProviderResponse; status: 200 | 201 | 202 }> {
1287
+ const { authorization, workspaceId } = input;
1288
+ const { grant } = authorization;
1289
+ const key = requireEnvironmentEncryption(settings);
1290
+ const details = externalVaultConnectionDetails(input.connection.metadata)!;
1291
+ const context = vaultContextFor(authorization, workspaceId);
1292
+ const linked = await linkedVaultRows(context, workspaceId, input.connection.id);
1293
+ let connection = input.connection;
1294
+ let saved = false;
1295
+ let hasPending = Boolean(input.pendingCredentials);
1296
+ let rejectedAccount: VaultProviderError | undefined;
1297
+ let storedBundle: unknown = input.pendingCredentials
1298
+ ? pendingVaultCredentialBundle(input.currentCredentials, input.pendingCredentials)
1299
+ : (input.currentCredentials ?? {});
1300
+ const continuation = externalActorContinuationForAuthorization(authorization);
1301
+ const persist = async (
1302
+ credentials: StoredVaultProviderCredentials,
1303
+ ready?: ExternalVaultConnectionDetails,
1304
+ ) => {
1305
+ const unchangedCurrent =
1306
+ input.currentCredentials !== null &&
1307
+ isDeepStrictEqual(credentials, input.currentCredentials);
1308
+ const pending = !ready && !unchangedCurrent;
1309
+ const nextMetadata = ready ?? {
1310
+ ...details,
1311
+ verificationStatus: pending ? ("pending" as const) : ("ready" as const),
1312
+ };
1313
+ const nextBundle = pending
1314
+ ? pendingVaultCredentialBundle(input.currentCredentials, credentials)
1315
+ : credentials;
1316
+ const nextStatus = pending ? connection.status : ("active" as const);
1317
+ const previousDetails = externalVaultConnectionDetails(connection.metadata);
1318
+ // A read-only verification must not invalidate in-flight provider calls.
1319
+ // Actual credential, status or capability changes still advance the fence.
1320
+ const generationChanged =
1321
+ !isDeepStrictEqual(nextBundle, storedBundle) ||
1322
+ nextStatus !== connection.status ||
1323
+ !isDeepStrictEqual(previousDetails?.operations, nextMetadata.operations);
1324
+ if (!generationChanged && isDeepStrictEqual(previousDetails, nextMetadata)) {
1325
+ saved = true;
1326
+ hasPending = pending;
1327
+ return;
1328
+ }
1329
+ const updated = await db.transaction(async (tx) => {
1330
+ await requireConnectOwnerAuthority(tx, {
1331
+ accountId: grant.accountId,
1332
+ workspaceId,
1333
+ subjectId: grant.subjectId,
1334
+ personalOwnerVerified: isPersonalConnectionOwnerPrincipal(authorization),
1335
+ ...(continuation ? { externalContinuation: continuation } : {}),
1336
+ });
1337
+ return updateConnection(tx, {
1338
+ workspaceId,
1339
+ connectionId: connection.id,
1340
+ visibleToSubjectId: grant.subjectId,
1341
+ expectedVersion: connection.version,
1342
+ status: nextStatus,
1343
+ ...(generationChanged
1344
+ ? { credentialEncrypted: encryptEnvironmentValue(key, JSON.stringify(nextBundle)) }
1345
+ : {}),
1346
+ metadata: { ...connection.metadata, ...externalVaultConnectionMetadata(nextMetadata) },
1347
+ updatedBySubjectId: grant.subjectId,
1348
+ });
1349
+ });
1350
+ if (!updated)
1351
+ throw new HTTPException(409, {
1352
+ message: "This vault connection changed. Reload it before continuing setup.",
1353
+ });
1354
+ connection = updated;
1355
+ saved = true;
1356
+ hasPending = pending;
1357
+ storedBundle = nextBundle;
1358
+ };
1359
+ try {
1360
+ const checked = await prepareExternal(
1361
+ input.credentials,
1362
+ authorization,
1363
+ workspaceId,
1364
+ connection.id,
1365
+ persist,
1366
+ );
1367
+ const sameAccount =
1368
+ details.accountId !== null
1369
+ ? checked.accountId === details.accountId
1370
+ : linked.length === 0 ||
1371
+ linked.every((row) =>
1372
+ checked.vaults.some((vault) => vault.externalVaultId === row.providerVaultRef),
1373
+ );
1374
+ if (!sameAccount) {
1375
+ rejectedAccount = new VaultProviderError(
1376
+ "provider_conflict",
1377
+ "These credentials do not reach the same account and linked vaults. Connect them separately instead.",
1378
+ );
1379
+ throw rejectedAccount;
1380
+ }
1381
+ const ready: ExternalVaultConnectionDetails = {
1382
+ ...details,
1383
+ accountId: checked.accountId ?? details.accountId,
1384
+ accountName: checked.accountName,
1385
+ operations: checked.operations,
1386
+ verifiedAt: new Date().toISOString(),
1387
+ verificationStatus: "ready",
1388
+ };
1389
+ await persist(checked.credentials, ready);
1390
+ const providers = vaultProviderRegistryFor(deps, { workspaceId, subjectId: grant.subjectId });
1391
+ for (const row of linked) {
1392
+ await syncLinkedProviderVault({
1393
+ db,
1394
+ context,
1395
+ vaultId: row.id,
1396
+ providers,
1397
+ force: true,
1398
+ }).catch(() => undefined);
1399
+ }
1400
+ const linkedIds = new Map(linked.map((row) => [row.providerVaultRef, row.id]));
1401
+ return {
1402
+ status: input.isNew ? 201 : 200,
1403
+ body: InstallVaultProviderResponse.parse({
1404
+ connectionId: connection.id,
1405
+ provider: details.provider,
1406
+ accountName: ready.accountName,
1407
+ operations: ready.operations,
1408
+ vaults: checked.vaults.map((vault) =>
1409
+ catalogVault(checked.provider, vault, linkedIds.get(vault.externalVaultId) ?? null),
1410
+ ),
1411
+ }),
1412
+ };
1413
+ } catch (error) {
1414
+ // Only our explicit identity rejection discards the candidate. An
1415
+ // adapter conflict during discovery can happen after a one-time bind;
1416
+ // preserve that bound key so verification can finish without rebinding.
1417
+ if (rejectedAccount && error === rejectedAccount && hasPending) {
1418
+ if (input.currentCredentials) {
1419
+ // Reject the candidate, not the working connection. CAS prevents this
1420
+ // rollback from overwriting a concurrent reconnect or disconnect.
1421
+ await persist(input.currentCredentials, { ...details, verificationStatus: "ready" });
1422
+ } else {
1423
+ await revokeConnection(
1424
+ db,
1425
+ workspaceId,
1426
+ connection.id,
1427
+ grant.subjectId,
1428
+ connection.version,
1429
+ );
1430
+ }
1431
+ throw error;
1432
+ }
1433
+ // A bound key is durable even if later discovery fails. Never tell the
1434
+ // person to repeat a consumed bootstrap token or overwrite working keys.
1435
+ if (saved && hasPending && isVaultProviderError(error)) {
1436
+ return {
1437
+ status: 202,
1438
+ body: InstallVaultProviderResponse.parse({
1439
+ connectionId: connection.id,
1440
+ provider: details.provider,
1441
+ accountName: details.accountName,
1442
+ operations: details.operations,
1443
+ vaults: [],
1444
+ discoveryPending: true,
1445
+ errorCode: error.code,
1446
+ }),
1447
+ };
1448
+ }
1449
+ if (!saved && input.isNew) {
1450
+ await revokeConnection(db, workspaceId, connection.id, grant.subjectId, connection.version);
1451
+ }
1452
+ throw error;
1453
+ }
1454
+ }
1455
+
1456
+ async function prepareExternal(
1457
+ credentials: VaultProviderCredentials | StoredVaultProviderCredentials,
1458
+ authorization: AccessGrantAuthorization,
1459
+ workspaceId: string,
1460
+ connectionId: string | null,
1461
+ persistCredentials: (credentials: StoredVaultProviderCredentials) => Promise<void>,
1462
+ ) {
1463
+ const factory = externalVaultFactoriesFor(deps)[credentials.provider];
1464
+ if (!factory)
1465
+ throw new VaultProviderError(
1466
+ "provider_unavailable",
1467
+ "This vault provider is not available in this deployment.",
1468
+ );
1469
+ let rotated: StoredVaultProviderCredentials | undefined;
1470
+ const signal = AbortSignal.timeout(30_000);
1471
+ const prepared = await factory({
1472
+ credentials,
1473
+ ...(connectionId ? { connectionId } : {}),
1474
+ signal,
1475
+ persistCredentials: async (next) => {
1476
+ const parsed = StoredVaultProviderCredentials.safeParse(next);
1477
+ if (!parsed.success || parsed.data.provider !== credentials.provider)
1478
+ throw new VaultProviderError(
1479
+ "provider_unavailable",
1480
+ "The provider returned invalid connection state.",
1481
+ );
1482
+ rotated = parsed.data;
1483
+ await persistCredentials(parsed.data);
1484
+ },
1485
+ });
1486
+ if (prepared.provider.id !== credentials.provider)
1487
+ throw new VaultProviderError(
1488
+ "provider_unavailable",
1489
+ "The configured adapter does not match this connection.",
1490
+ );
1491
+ const preparedCredentials = StoredVaultProviderCredentials.safeParse(prepared.credentials);
1492
+ if (!preparedCredentials.success || preparedCredentials.data.provider !== credentials.provider)
1493
+ throw new VaultProviderError(
1494
+ "provider_unavailable",
1495
+ "The provider returned invalid connection state.",
1496
+ );
1497
+ if (!rotated) await persistCredentials(preparedCredentials.data);
1498
+ const ctx: VaultProviderContext = {
1499
+ connectionId,
1500
+ budget: createProviderCallBudget(50),
1501
+ signal,
1502
+ forceRefresh: true,
1503
+ authority: {
1504
+ accountId: authorization.grant.accountId,
1505
+ workspaceId,
1506
+ connectionWorkspaceId: workspaceId,
1507
+ subjectId: authorization.grant.subjectId,
1508
+ },
1509
+ };
1510
+ const validated = await prepared.provider.validate(ctx);
1511
+ const vaults = await listProviderVaults(prepared.provider, ctx);
1512
+ const stored = StoredVaultProviderCredentials.safeParse(rotated ?? prepared.credentials);
1513
+ if (!stored.success || stored.data.provider !== credentials.provider)
1514
+ throw new VaultProviderError(
1515
+ "provider_unavailable",
1516
+ "The provider returned invalid connection state.",
1517
+ );
1518
+ return {
1519
+ provider: prepared.provider,
1520
+ credentials: stored.data,
1521
+ vaults,
1522
+ accountId: validated.accountId ?? null,
1523
+ accountName: validated.accountName,
1524
+ operations: providerOperations(prepared.provider.capabilities()),
1525
+ };
1526
+ }
1527
+
769
1528
  async function linkedVaultRows(
770
1529
  context: VaultDbContext,
771
1530
  workspaceId: string,
@@ -774,7 +1533,7 @@ export function registerVaultProviderRoutes(
774
1533
  const rows = await listVaultsForSubject(db, context, { workspaceId });
775
1534
  return rows.filter(
776
1535
  (row): row is VaultRow & { providerVaultRef: string } =>
777
- row.source === "onepassword" &&
1536
+ row.source !== "opengeni" &&
778
1537
  row.providerConnectionId === connectionId &&
779
1538
  row.providerVaultRef !== null,
780
1539
  );