@opengeni/core 3.1.2 → 3.2.2

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.
@@ -56,8 +56,10 @@ import {
56
56
  ensureTaskVault,
57
57
  getSession,
58
58
  getSessionSecretsView,
59
+ getProviderVaultItemRef,
59
60
  getVault,
60
61
  getVaultItem,
62
+ isVaultError,
61
63
  listVaultFieldVersions,
62
64
  listVaultItems,
63
65
  listVaultsForSubject,
@@ -95,12 +97,21 @@ import { vaultErrorCodeForProvider } from "./provider-registry";
95
97
  import {
96
98
  LINKED_VAULT_OPEN_WAIT_MS,
97
99
  LINKED_VAULT_SEARCH_MAX_AGE_MS,
100
+ providerVaultFreshness,
101
+ refreshLinkedVaults,
98
102
  refreshStaleLinkedVaults,
99
103
  } from "./provider-sync";
100
104
  import {
101
105
  archiveProviderVaultItem,
106
+ assertProviderVaultAccepts,
107
+ assertProviderVaultReleases,
108
+ observeProviderItemVersion,
109
+ refuseProviderOperation,
110
+ savedNotListedError,
102
111
  writeProviderVaultItem,
112
+ writeProviderVaultItemOutcome,
103
113
  type ProviderWriteDeps,
114
+ type ProviderWriteOutcome,
104
115
  } from "./provider-writes";
105
116
  import { chooseSecretCandidate, createSecretResolver } from "./secret-resolver";
106
117
  import type { VaultRequestActor, VaultServiceDeps } from "./types";
@@ -423,6 +434,21 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
423
434
  return deps.encryptionKey;
424
435
  }
425
436
 
437
+ /**
438
+ * Restore and permanent deletion are Opengeni-hosted operations. Connected
439
+ * items are archived recoverably in their provider and restored there, so
440
+ * these refuse with the reason instead of reaching any provider.
441
+ */
442
+ async function refuseForConnectedItem(
443
+ context: VaultDbContext,
444
+ itemId: string,
445
+ operation: "restoreItem" | "deleteItem",
446
+ ): Promise<void> {
447
+ const item = await getVaultItem(deps.db, context, { itemId });
448
+ if (item.vaultSource === "opengeni") return;
449
+ refuseProviderOperation(await getVault(deps.db, context, { vaultId: item.vaultId }), operation);
450
+ }
451
+
426
452
  /**
427
453
  * Move or copy an item when either side is a connected (provider) vault.
428
454
  * Values never live in both stores' databases at once: each field is read
@@ -430,6 +456,25 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
430
456
  * written to the target through its own path, and a move then archives the
431
457
  * source so the old item stays recoverable. Opengeni-to-Opengeni moves keep
432
458
  * the single-statement database move, which preserves the item id.
459
+ *
460
+ * Everything the target must accept (item type, one-time password, files)
461
+ * and, for a move, a recoverable archive of the original is checked before
462
+ * any value is read or written. The original is archived only after the
463
+ * target confirmed the write; an unknown or failed target write leaves it
464
+ * untouched, and a confirmed copy whose original could not be archived is
465
+ * reported as saved, so the move is not repeated.
466
+ *
467
+ * A move out of a connected vault records the provider's version of the
468
+ * original from a fresh listing before anything is copied. A version that
469
+ * differs from the mirrored one (whose field list the copy follows) is
470
+ * refused with `provider_conflict` before anything is saved: the vault must
471
+ * be synced first. The original is listed again just before archiving; if
472
+ * the provider then reports another version, no longer lists it, or cannot
473
+ * be asked, the original is kept and the copy is reported as saved with
474
+ * `sourceArchived: false`. These are separate checks, not an atomic read:
475
+ * values are read live between them, and providers offer no conditional
476
+ * archive, so a change landing after the last check is still archived
477
+ * (recoverably). A provider that does not version items gets no drift check.
433
478
  */
434
479
  async function moveAcrossProviders(input: {
435
480
  context: VaultDbContext;
@@ -465,6 +510,53 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
465
510
  },
466
511
  );
467
512
  }
513
+ if (target.source !== "opengeni") {
514
+ await assertProviderVaultAccepts(input.providerWrites, target, source);
515
+ }
516
+ if (input.mode === "move" && source.vaultSource !== "opengeni") {
517
+ await assertProviderVaultReleases(input.providerWrites, sourceVault);
518
+ }
519
+ // The provider's version before anything is copied. It must equal the
520
+ // mirrored version the field list below comes from; an edit after this
521
+ // observation makes the pre-archive check differ and keeps the original.
522
+ // Values are read live afterwards, so this is not an atomic snapshot.
523
+ let pinned: { providerItemRef: string; version: string } | null = null;
524
+ if (input.mode === "move" && source.vaultSource !== "opengeni") {
525
+ const mirrored = await getProviderVaultItemRef(deps.db, input.context, {
526
+ vaultId: sourceVault.id,
527
+ itemId: source.id,
528
+ });
529
+ const { providerItemRef } = mirrored;
530
+ const observed = await observeProviderItemVersion(input.providerWrites, {
531
+ vault: sourceVault,
532
+ providerItemRef,
533
+ });
534
+ if (!observed.listed) {
535
+ throw new VaultError(
536
+ "vault_item_not_found",
537
+ `${source.name} is not listed in ${sourceVault.name} right now; sync the vault and try again`,
538
+ { itemId: source.id, vaultId: sourceVault.id },
539
+ );
540
+ }
541
+ if (observed.version !== null) {
542
+ if (
543
+ mirrored.providerVersion !== observed.version ||
544
+ mirrored.generation !== source.generation
545
+ ) {
546
+ throw new VaultError(
547
+ "provider_conflict",
548
+ `${source.name} changed in ${sourceVault.name} since Opengeni last synced it, so its fields here may be out of date. Nothing was moved: sync ${sourceVault.name}, then move it again.`,
549
+ {
550
+ itemId: source.id,
551
+ vaultId: sourceVault.id,
552
+ mirroredVersion: mirrored.providerVersion,
553
+ providerVersion: observed.version,
554
+ },
555
+ );
556
+ }
557
+ pinned = { providerItemRef, version: observed.version };
558
+ }
559
+ }
468
560
  const name = input.newName ?? source.name;
469
561
  const fields: FieldSource[] = [];
470
562
  for (const field of source.fields) {
@@ -493,14 +585,20 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
493
585
  ...(source.expiresAt ? { expiresAt: source.expiresAt } : {}),
494
586
  fields,
495
587
  };
496
- let created: VaultItemRow;
588
+ let created: VaultItemRow | null = null;
589
+ let notListed: Extract<ProviderWriteOutcome, { status: "saved_not_listed" }> | null = null;
497
590
  if (target.source !== "opengeni") {
498
- const itemId = await writeProviderVaultItem(input.providerWrites, {
591
+ // A failed or unknown target write throws here, before the original is touched.
592
+ const outcome = await writeProviderVaultItemOutcome(input.providerWrites, {
499
593
  vault: target,
500
594
  existing: null,
501
595
  change,
502
596
  });
503
- created = await getVaultItem(deps.db, input.context, { itemId });
597
+ if (outcome.status === "listed") {
598
+ created = await getVaultItem(deps.db, input.context, { itemId: outcome.itemId });
599
+ } else {
600
+ notListed = outcome;
601
+ }
504
602
  } else {
505
603
  const write = await itemWrite(
506
604
  change,
@@ -515,16 +613,67 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
515
613
  });
516
614
  }
517
615
  if (input.mode === "move") {
518
- if (source.vaultSource !== "opengeni") {
519
- await archiveProviderVaultItem(input.providerWrites, {
520
- vault: sourceVault,
521
- itemId: source.id,
522
- });
523
- } else {
524
- await archiveVaultItem(deps.db, input.context, { itemId: source.id });
616
+ try {
617
+ if (source.vaultSource !== "opengeni") {
618
+ if (pinned) await assertSourceUnchanged(input.providerWrites, sourceVault, pinned);
619
+ await archiveProviderVaultItem(input.providerWrites, {
620
+ vault: sourceVault,
621
+ itemId: source.id,
622
+ });
623
+ } else {
624
+ await archiveVaultItem(deps.db, input.context, { itemId: source.id });
625
+ }
626
+ } catch (error) {
627
+ const changed = isVaultError(error) && error.details?.sourceChanged === true;
628
+ throw new VaultError(
629
+ isVaultError(error) ? error.code : "provider_outcome_unknown",
630
+ changed
631
+ ? `Saved ${name} in ${target.name} with the values that were read, but the original in ${sourceVault.name} ${error.message}, so it was kept. Do not move it again: compare the two, then update the copy or archive the original in ${sourceVault.name}.`
632
+ : `Saved ${name} in ${target.name}, but archiving the original in ${sourceVault.name} did not complete${isVaultError(error) ? ` (${error.message})` : ""}. Do not move it again: check ${sourceVault.name} and archive the original there if it is still listed.`,
633
+ {
634
+ saved: true,
635
+ sourceArchived: false,
636
+ ...(changed ? { sourceChanged: true } : {}),
637
+ vaultId: target.id,
638
+ ...(created ? { itemId: created.id } : {}),
639
+ sourceItemId: source.id,
640
+ sourceVaultId: sourceVault.id,
641
+ },
642
+ );
525
643
  }
526
644
  }
527
- return created;
645
+ if (notListed) {
646
+ const error = savedNotListedError(target, name, notListed);
647
+ throw new VaultError(error.code, error.message, {
648
+ ...error.details,
649
+ ...(input.mode === "move" ? { sourceArchived: true } : {}),
650
+ });
651
+ }
652
+ return created!;
653
+ }
654
+
655
+ /**
656
+ * Refuses (before archiving) an original the provider no longer reports at
657
+ * the version its values were copied from. A failed listing throws its own
658
+ * provider error, so the original is kept either way.
659
+ */
660
+ async function assertSourceUnchanged(
661
+ providerWrites: ProviderWriteDeps,
662
+ vault: VaultRow,
663
+ pinned: { providerItemRef: string; version: string },
664
+ ): Promise<void> {
665
+ const now = await observeProviderItemVersion(providerWrites, {
666
+ vault,
667
+ providerItemRef: pinned.providerItemRef,
668
+ });
669
+ if (now.listed && now.version === pinned.version) return;
670
+ throw new VaultError(
671
+ now.listed ? "vault_generation_conflict" : "vault_item_not_found",
672
+ now.listed
673
+ ? "changed after its values were read"
674
+ : "is no longer listed at the version that was read",
675
+ { sourceChanged: true, vaultId: vault.id },
676
+ );
528
677
  }
529
678
 
530
679
  function toItem(row: VaultItemRow): VaultItem {
@@ -550,7 +699,12 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
550
699
  // Providers charge the budget per call themselves.
551
700
  const budget = createProviderCallBudget(VAULT_LIMITS.providerReadsPerAttempt);
552
701
  const [value] = await provider.readFields(
553
- { connectionId: read.providerConnectionId, budget },
702
+ // The durable epoch invalidates provider value caches after a refresh anywhere.
703
+ {
704
+ connectionId: read.providerConnectionId,
705
+ budget,
706
+ cacheEpoch: read.providerCacheEpoch ?? null,
707
+ },
554
708
  read.providerVaultRef ?? "",
555
709
  read.providerItemRef ?? "",
556
710
  [read.providerFieldRef ?? ""],
@@ -618,6 +772,10 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
618
772
  const vaults = await listVaultsForSubject(deps.db, principalContext(principal), {
619
773
  workspaceId: principal.workspaceId,
620
774
  });
775
+ return agentVaultFrom(vaults, nameOrId);
776
+ }
777
+
778
+ function agentVaultFrom(vaults: readonly VaultRow[], nameOrId: string): VaultRow {
621
779
  const byId = vaults.find((vault) => vault.id === nameOrId.toLowerCase());
622
780
  if (byId) return byId;
623
781
  const matches = vaults.filter((vault) => secretNameKey(vault.name) === secretNameKey(nameOrId));
@@ -874,13 +1032,16 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
874
1032
  },
875
1033
 
876
1034
  async restoreItem(actor, workspaceId, itemId) {
877
- return toItem(
878
- await restoreVaultItem(deps.db, subjectContext(actor, workspaceId), { itemId }),
879
- );
1035
+ const context = subjectContext(actor, workspaceId);
1036
+ await refuseForConnectedItem(context, itemId, "restoreItem");
1037
+ return toItem(await restoreVaultItem(deps.db, context, { itemId }));
880
1038
  },
881
1039
 
882
1040
  async deleteItemPermanently(actor, workspaceId, itemId) {
883
- await deleteVaultItemPermanently(deps.db, subjectContext(actor, workspaceId), { itemId });
1041
+ const context = subjectContext(actor, workspaceId);
1042
+ // Never a permanent provider deletion: Archive is the only way an item leaves.
1043
+ await refuseForConnectedItem(context, itemId, "deleteItem");
1044
+ await deleteVaultItemPermanently(deps.db, context, { itemId });
884
1045
  },
885
1046
 
886
1047
  async moveItem(actor, workspaceId, itemId, req) {
@@ -924,6 +1085,12 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
924
1085
  async restoreFieldVersion(actor, workspaceId, itemId, fieldId, version) {
925
1086
  const context = subjectContext(actor, workspaceId);
926
1087
  const existing = await getVaultItem(deps.db, context, { itemId });
1088
+ if (existing.vaultSource !== "opengeni") {
1089
+ refuseProviderOperation(
1090
+ await getVault(deps.db, context, { vaultId: existing.vaultId }),
1091
+ "restoreFieldHistory",
1092
+ );
1093
+ }
927
1094
  const field = existing.fields.find((candidate) => candidate.id === fieldId);
928
1095
  if (!field) throw new VaultError("vault_field_not_found", "field not found", { fieldId });
929
1096
  return toItem(
@@ -963,29 +1130,48 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
963
1130
  let reachable = await listVaultsForSubject(deps.db, context, {
964
1131
  workspaceId: principal.workspaceId,
965
1132
  });
1133
+ let scoped: VaultRow[] =
1134
+ input.vault !== undefined ? [agentVaultFrom(reachable, input.vault)] : reachable;
966
1135
  // Items added straight in a connected provider (1Password) appear here
967
- // without anyone pressing Sync: stale mirrors refresh before the scan.
968
- if (deps.providers) {
969
- const changed = await refreshStaleLinkedVaults({
1136
+ // without anyone pressing Sync: stale mirrors in the search's scope
1137
+ // refresh before the scan. `refresh` (first page only) re-lists them and
1138
+ // bypasses provider caches for later reads, never an active rate limit.
1139
+ let refreshing = new Set<string>();
1140
+ const connected = scoped.filter((vault) => vault.source !== "opengeni");
1141
+ if (deps.providers && connected.length > 0) {
1142
+ const report = await refreshLinkedVaults({
970
1143
  db: deps.db,
971
1144
  context,
972
- vaults: reachable,
1145
+ vaults: connected,
973
1146
  providers: deps.providers,
974
1147
  maxAgeMs: LINKED_VAULT_SEARCH_MAX_AGE_MS,
1148
+ ...(input.refresh && input.cursor === undefined ? { force: true } : {}),
975
1149
  });
976
- if (changed.length > 0) {
1150
+ refreshing = new Set(report.pending);
1151
+ if (
1152
+ report.outcomes.some((outcome) => outcome.state === "done" || outcome.state === "failed")
1153
+ ) {
977
1154
  reachable = await listVaultsForSubject(deps.db, context, {
978
1155
  workspaceId: principal.workspaceId,
979
1156
  });
1157
+ scoped = scoped.map((vault) => reachable.find((row) => row.id === vault.id) ?? vault);
980
1158
  }
981
1159
  }
1160
+ // Every page says which connected sources were checked, so an empty or
1161
+ // short result is never mistaken for proof that a secret does not exist.
1162
+ const checkedAt = new Date();
1163
+ const freshnessOf = (vault: VaultRow) =>
1164
+ providerVaultFreshness(vault, checkedAt, { refreshing: refreshing.has(vault.id) });
1165
+ const sources = scoped.flatMap((vault) => {
1166
+ const freshness = freshnessOf(vault);
1167
+ return freshness ? [{ vaultId: vault.id, freshness }] : [];
1168
+ });
1169
+ const complete = sources.every((source) => source.freshness.state === "current");
982
1170
  const byTier = (vaults: VaultRow[]) =>
983
1171
  [...vaults].sort(
984
1172
  (left, right) => left.tier - right.tier || left.name.localeCompare(right.name),
985
1173
  );
986
- const vaults = byTier(
987
- input.vault !== undefined ? [await agentVault(principal, input.vault)] : reachable,
988
- );
1174
+ const vaults = byTier(scoped);
989
1175
  const limit = input.limit ?? 20;
990
1176
  const filters = agentSearchFilterKey(input);
991
1177
  // Resume exactly after the last item the previous page examined.
@@ -1060,16 +1246,22 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
1060
1246
  nextCursor: next === null ? null : encodeAgentSearchCursor(next),
1061
1247
  ...(input.cursor === undefined
1062
1248
  ? {
1063
- vaults: byTier(reachable).map((vault) => ({
1064
- id: vault.id,
1065
- name: vault.name,
1066
- ownerKind: vault.ownerKind,
1067
- source: vault.source,
1068
- mode: vault.mode,
1069
- itemCount: vault.itemCount,
1070
- })),
1249
+ vaults: byTier(reachable).map((vault) => {
1250
+ const freshness = freshnessOf(vault);
1251
+ return {
1252
+ id: vault.id,
1253
+ name: vault.name,
1254
+ ownerKind: vault.ownerKind,
1255
+ source: vault.source,
1256
+ mode: vault.mode,
1257
+ itemCount: vault.itemCount,
1258
+ ...(freshness ? { freshness } : {}),
1259
+ };
1260
+ }),
1071
1261
  }
1072
1262
  : {}),
1263
+ complete,
1264
+ sources,
1073
1265
  };
1074
1266
 
1075
1267
  function agentSearchResult(vault: VaultRow, row: VaultItemRow) {
@@ -1225,7 +1417,7 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
1225
1417
  const current = await getVaultItem(deps.db, context, { itemId: target.itemId });
1226
1418
  if (current.vaultSource !== "opengeni") {
1227
1419
  const vault = await getVault(deps.db, context, { vaultId: current.vaultId });
1228
- await archiveProviderVaultItem(
1420
+ const { archiveKind } = await archiveProviderVaultItem(
1229
1421
  {
1230
1422
  db: deps.db,
1231
1423
  context,
@@ -1236,7 +1428,7 @@ export function createVaultService(deps: VaultServiceDeps): VaultService {
1236
1428
  );
1237
1429
  return {
1238
1430
  archived: true,
1239
- restoreHint: `A person can restore ${current.name} from the provider's archive.`,
1431
+ restoreHint: `A person can restore ${current.name} from the ${archiveKind === "trash" ? "trash" : "archive"} in ${vault.name}'s provider; it reappears here after the next sync.`,
1240
1432
  };
1241
1433
  }
1242
1434
  const row = await archiveVaultItem(deps.db, context, {
@@ -201,7 +201,11 @@ export function createVoiceInputBilling(deps: {
201
201
  usage: input.usage,
202
202
  trustedDurationSeconds: input.billing.trustedDurationSeconds,
203
203
  });
204
- const cost = calculateVoiceInputCost(input.pricing, usage);
204
+ const cost = calculateVoiceInputCost(
205
+ input.pricing,
206
+ usage,
207
+ deps.settings.creditMarginBpsOverride,
208
+ );
205
209
  return await settleVoiceInputUsage(deps.db, {
206
210
  accountId: input.accountId,
207
211
  workspaceId: input.workspaceId,
package/src/index.ts CHANGED
@@ -40,6 +40,8 @@ export * from "./domain/realtime-voice-billing";
40
40
  // SessionWorkflowClient, DocumentIndexClient, ObjectStorageDependency).
41
41
  export * from "./dependencies";
42
42
  export * from "./domain/vaults/provider-registry";
43
+ export * from "./domain/vaults/external-provider-connections";
44
+ export * from "./domain/vaults/remote-provider-runtime";
43
45
  export * from "./domain/vaults/provider-sync";
44
46
  export * from "./workflow-wake-contract";
45
47
  export * from "./user-presence";