@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.
@@ -4,17 +4,23 @@ import {
4
4
  VAULT_LIMITS,
5
5
  type VaultFieldKind,
6
6
  type VaultItemType,
7
+ type VaultProviderFreshness,
8
+ type VaultProviderOperations,
7
9
  type VaultSyncStatus,
8
10
  } from "@opengeni/contracts";
9
11
  import {
10
12
  getProviderVaultSyncTarget,
13
+ isVaultError,
11
14
  setVaultSyncStatus,
12
- upsertProviderVaultItems,
13
15
  type Database,
16
+ type StoredProviderFreshness,
14
17
  type VaultDbContext,
15
18
  } from "@opengeni/db";
16
19
  import {
17
20
  isVaultProviderError,
21
+ providerOperations,
22
+ readProviderItemInventory,
23
+ readProviderVaultInventory,
18
24
  VaultProviderError,
19
25
  type ProviderItemMeta,
20
26
  type VaultProvider,
@@ -24,13 +30,20 @@ import {
24
30
  } from "@opengeni/vaults";
25
31
 
26
32
  /**
27
- * Provider vault metadata mirror (design §10.9, OP-1/OP-4). A linked
28
- * 1Password or host vault keeps a metadata-only mirror (item names, types,
33
+ * Provider vault metadata mirror (design §10.9, OP-1/OP-4; external vaults
34
+ * §8–§9). A linked vault keeps a metadata-only mirror (item names, types,
29
35
  * websites, field names and kinds); values are always read live. A sync costs
30
- * one `listVaults` call and skips `listItems` entirely when the provider's
36
+ * one vault listing and skips the item listing entirely when the provider's
31
37
  * `contentVersion` is unchanged, so FAMILY-plan quotas are not burnt on
32
38
  * no-op refreshes.
33
39
  *
40
+ * Only a complete, consistent traversal replaces the mirror: interrupted,
41
+ * malformed or over-limit inventories (more than `VAULT_LIMITS.itemsPerVault`
42
+ * items, or an item beyond the per-item field and website limits) and
43
+ * multi-page listings without a snapshot keep the previous mirror and record
44
+ * `provider_inventory_incomplete`. Nothing is cut down to fit. An active
45
+ * provider rate limit is never bypassed, not even by a forced refresh.
46
+ *
34
47
  * The engine is storage-agnostic: the vault service binds `ProviderMirrorStore`
35
48
  * to its tables (`vault_items`, `vault_item_fields`, `vaults.sync_status`,
36
49
  * `vaults.rate_limited_until`).
@@ -61,7 +74,12 @@ export interface ProviderMirrorItem {
61
74
  export interface ProviderMirrorPlan {
62
75
  contentVersion: string | null;
63
76
  items: ProviderMirrorItem[];
64
- /** Items beyond `VAULT_LIMITS.itemsPerVault` that were not mirrored. */
77
+ /**
78
+ * Always 0. A provider vault with more than `VAULT_LIMITS.itemsPerVault`
79
+ * items, or an item beyond the field or website limits, is refused whole
80
+ * (`provider_inventory_incomplete`), never mirrored as its first items or
81
+ * fields. Kept for compatibility with earlier callers.
82
+ */
65
83
  truncated: number;
66
84
  }
67
85
 
@@ -72,12 +90,22 @@ export interface ProviderSyncStatusUpdate {
72
90
  rateLimitedUntil: Date | null;
73
91
  /** Set only when the provider was reached and the mirror is current. */
74
92
  lastSyncedAt: Date | null;
93
+ /**
94
+ * The provider's effective operations for this vault, recorded on every
95
+ * successful check (absent: keep the stored operations).
96
+ */
97
+ operations?: VaultProviderOperations | null;
98
+ /**
99
+ * The provider's own upstream freshness observation from this check; null
100
+ * when it reported none (absent: keep the stored observation). Never
101
+ * Opengeni's own check time.
102
+ */
103
+ freshness?: StoredProviderFreshness | null;
75
104
  }
76
105
 
77
106
  export interface ProviderMirrorStore {
78
- /** Replace the mirror for this vault in one transaction (upsert by external id, drop missing). */
79
- replaceMirror(plan: ProviderMirrorPlan): Promise<void>;
80
- setSyncStatus(update: ProviderSyncStatusUpdate): Promise<void>;
107
+ /** Atomically commit mirror and status if the pre-I/O epoch still matches. */
108
+ settleRefresh(update: ProviderSyncStatusUpdate, plan?: ProviderMirrorPlan): Promise<boolean>;
81
109
  }
82
110
 
83
111
  export interface ProviderVaultSyncTarget {
@@ -85,11 +113,15 @@ export interface ProviderVaultSyncTarget {
85
113
  /** `contentVersion` recorded by the last successful mirror, if any. */
86
114
  mirroredContentVersion: string | null;
87
115
  rateLimitedUntil: Date | null;
116
+ /** The vault's durable value-cache epoch (0789), passed to the provider as `cacheEpoch`. */
117
+ cacheEpoch?: string | null;
88
118
  }
89
119
 
90
120
  export type ProviderVaultSyncResult =
91
121
  | { status: "synced"; contentVersion: string | null; itemCount: number; truncated: number }
92
122
  | { status: "unchanged"; contentVersion: string | null }
123
+ /** Another refresh settled after this one's read; no state was overwritten. */
124
+ | { status: "superseded" }
93
125
  | { status: "skipped_rate_limited"; rateLimitedUntil: Date }
94
126
  | {
95
127
  status: "failed";
@@ -101,6 +133,50 @@ export type ProviderVaultSyncResult =
101
133
  /** When the provider gives no retry hint, back off this long after a 429. */
102
134
  export const PROVIDER_RATE_LIMIT_DEFAULT_BACKOFF_MS = 15 * 60_000;
103
135
 
136
+ function incompleteInventory(): VaultProviderError {
137
+ return new VaultProviderError(
138
+ "provider_inventory_incomplete",
139
+ `This vault has more than ${VAULT_LIMITS.itemsPerVault} items, more than Opengeni mirrors. The previous inventory was kept.`,
140
+ );
141
+ }
142
+
143
+ function oversizedItem(): VaultProviderError {
144
+ // Never name the item: provider names are untrusted and may be sensitive.
145
+ return new VaultProviderError(
146
+ "provider_inventory_incomplete",
147
+ `An item in this vault has more than ${VAULT_LIMITS.fieldsPerItem} fields or ${VAULT_LIMITS.websitesPerItem} websites (or a website over ${VAULT_LIMITS.websiteMaxChars} characters), more than Opengeni mirrors. The previous inventory was kept.`,
148
+ );
149
+ }
150
+
151
+ function unprovenInventory(listing: "vaults" | "items"): VaultProviderError {
152
+ return new VaultProviderError(
153
+ "provider_inventory_incomplete",
154
+ listing === "items"
155
+ ? "The provider listed this vault in several pages without a snapshot, so Opengeni cannot tell which items were removed. The previous inventory was kept."
156
+ : "The provider listed its vaults in several pages without a snapshot and this vault was not among them, so Opengeni cannot confirm it was removed.",
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Every item fits the mirror as it is: at most `fieldsPerItem` fields and
162
+ * `websitesPerItem` websites of at most `websiteMaxChars` each (the limits
163
+ * the native adapters and the remote protocol enforce). Cutting an item down
164
+ * would present it as complete without the fields or websites it lost.
165
+ */
166
+ function assertMirrorable(item: ProviderItemMeta): void {
167
+ if (
168
+ !Array.isArray(item.fields) ||
169
+ !Array.isArray(item.websites) ||
170
+ item.fields.length > VAULT_LIMITS.fieldsPerItem ||
171
+ item.websites.length > VAULT_LIMITS.websitesPerItem ||
172
+ item.websites.some(
173
+ (website) => typeof website !== "string" || website.length > VAULT_LIMITS.websiteMaxChars,
174
+ )
175
+ ) {
176
+ throw oversizedItem();
177
+ }
178
+ }
179
+
104
180
  // Same forbidden set as `isValidSecretName` (Q-10).
105
181
  // oxlint-disable-next-line no-control-regex
106
182
  const FORBIDDEN_NAME_CHARACTERS = /[/{}#\\\u0000-\u001f\u007f-\u009f]/g;
@@ -169,44 +245,45 @@ export function providerPrimaryFieldId(
169
245
  * Deterministic mirror projection. Items are ordered by provider name then
170
246
  * external id before de-duplication, so the same provider state always yields
171
247
  * the same Opengeni names; field names are de-duplicated within each item.
248
+ * More than `VAULT_LIMITS.itemsPerVault` items, or any item beyond the field
249
+ * or website limits, is refused with `provider_inventory_incomplete`: a
250
+ * partial mirror would present the first items (or fields, or websites) of a
251
+ * larger vault as its complete contents.
172
252
  */
173
253
  export function planProviderMirror(
174
254
  items: readonly ProviderItemMeta[],
175
255
  contentVersion: string | null,
176
256
  ): ProviderMirrorPlan {
257
+ if (items.length > VAULT_LIMITS.itemsPerVault) throw incompleteInventory();
258
+ for (const item of items) assertMirrorable(item);
177
259
  const ordered = [...items].sort(
178
260
  (a, b) =>
179
261
  nameKey(a.name).localeCompare(nameKey(b.name)) ||
180
262
  a.externalItemId.localeCompare(b.externalItemId),
181
263
  );
182
- const kept = ordered.slice(0, VAULT_LIMITS.itemsPerVault);
183
264
  const takenItems = new Set<string>();
184
- const mirrored = kept.map((item): ProviderMirrorItem => {
265
+ const mirrored = ordered.map((item): ProviderMirrorItem => {
185
266
  const name = uniqueProviderName(providerSecretName(item.name), takenItems);
186
267
  const takenFields = new Set<string>();
187
- const fields = item.fields
188
- .slice(0, VAULT_LIMITS.fieldsPerItem)
189
- .map((field, position): ProviderMirrorField => {
190
- let fieldName = providerSecretName(
191
- field.name,
192
- field.kind === "totp" ? "one-time password" : "field",
193
- );
194
- if (nameKey(fieldName) === "otp") fieldName = "otp code";
195
- return {
196
- externalFieldId: field.externalFieldId,
197
- name: uniqueProviderName(fieldName, takenFields),
198
- kind: field.kind,
199
- position,
200
- };
201
- });
268
+ const fields = item.fields.map((field, position): ProviderMirrorField => {
269
+ let fieldName = providerSecretName(
270
+ field.name,
271
+ field.kind === "totp" ? "one-time password" : "field",
272
+ );
273
+ if (nameKey(fieldName) === "otp") fieldName = "otp code";
274
+ return {
275
+ externalFieldId: field.externalFieldId,
276
+ name: uniqueProviderName(fieldName, takenFields),
277
+ kind: field.kind,
278
+ position,
279
+ };
280
+ });
202
281
  return {
203
282
  externalItemId: item.externalItemId,
204
283
  name,
205
284
  providerName: item.name,
206
285
  type: item.type,
207
- websites: item.websites
208
- .filter((website) => website.length <= VAULT_LIMITS.websiteMaxChars)
209
- .slice(0, VAULT_LIMITS.websitesPerItem),
286
+ websites: [...item.websites],
210
287
  fields,
211
288
  primaryExternalFieldId: providerPrimaryFieldId({ type: item.type, fields: item.fields }),
212
289
  hasTotp: item.fields.some((field) => field.kind === "totp"),
@@ -214,7 +291,46 @@ export function planProviderMirror(
214
291
  updatedAt: item.updatedAt,
215
292
  };
216
293
  });
217
- return { contentVersion, items: mirrored, truncated: ordered.length - kept.length };
294
+ return { contentVersion, items: mirrored, truncated: 0 };
295
+ }
296
+
297
+ function validTime(value: unknown): number | null {
298
+ if (typeof value !== "string" || value.length > 64) return null;
299
+ const time = Date.parse(value);
300
+ return Number.isFinite(time) ? time : null;
301
+ }
302
+
303
+ /**
304
+ * One freshness observation for a whole check: `cache` when any page came
305
+ * from a cache, `live` only when every page was live, otherwise `unknown`.
306
+ * `sourceSyncedAt` is the oldest upstream synchronization any page reported
307
+ * (never later than the check itself), and unknown (null) when a non-live
308
+ * page did not say. Null when the provider reported nothing: Opengeni's own
309
+ * check time never stands in for the provider's.
310
+ */
311
+ export function aggregateProviderFreshness(
312
+ observations: readonly VaultProviderFreshness[],
313
+ checkedAt: Date,
314
+ ): StoredProviderFreshness | null {
315
+ if (observations.length === 0) return null;
316
+ let cache = false;
317
+ let unknown = false;
318
+ let unknownTime = false;
319
+ let oldest: number | null = null;
320
+ for (const observation of observations) {
321
+ const source = observation?.source;
322
+ if (source === "cache") cache = true;
323
+ else if (source !== "live") unknown = true;
324
+ const time = validTime(observation?.sourceSyncedAt);
325
+ if (time !== null) oldest = oldest === null ? time : Math.min(oldest, time);
326
+ else if (source !== "live") unknownTime = true;
327
+ }
328
+ const syncedAt =
329
+ unknownTime || oldest === null ? null : new Date(Math.min(oldest, checkedAt.getTime()));
330
+ return {
331
+ source: cache ? "cache" : unknown ? "unknown" : "live",
332
+ sourceSyncedAt: syncedAt ? syncedAt.toISOString() : null,
333
+ };
218
334
  }
219
335
 
220
336
  /** Sync status for a provider failure; rate limits persist `rateLimitedUntil`. */
@@ -253,9 +369,33 @@ export function providerSyncFailure(
253
369
  };
254
370
  }
255
371
 
372
+ /**
373
+ * A provider call whose failure is recorded as sync state. Errors that are not
374
+ * provider errors (adapter defects, timeouts, aborted requests) become a safe
375
+ * `provider_unavailable`; their messages are never stored or shown.
376
+ */
377
+ async function providerStep<T>(call: () => Promise<T>): Promise<T> {
378
+ try {
379
+ return await call();
380
+ } catch (error) {
381
+ if (isVaultProviderError(error)) throw error;
382
+ throw new VaultProviderError(
383
+ "provider_unavailable",
384
+ "the vault provider could not be reached or gave an invalid answer",
385
+ );
386
+ }
387
+ }
388
+
256
389
  /**
257
390
  * Refresh one linked vault's mirror. While `rateLimitedUntil` is in the future
258
- * the provider is not called at all unless `force` is set.
391
+ * the provider is not called at all, even when `force` is set.
392
+ *
393
+ * `force` re-lists items even when `contentVersion` is unchanged and asks the
394
+ * provider to bypass its caches (`forceRefresh`). Every successful check
395
+ * records the provider's effective operations and its own freshness
396
+ * observation, whether or not the mirror changed. Mirror and status settle
397
+ * together against the pre-I/O epoch; an intervening settlement returns
398
+ * `superseded` without overwriting it or retrying provider calls.
259
399
  */
260
400
  export async function syncProviderVault(input: {
261
401
  provider: VaultProvider;
@@ -263,30 +403,56 @@ export async function syncProviderVault(input: {
263
403
  target: ProviderVaultSyncTarget;
264
404
  store: ProviderMirrorStore;
265
405
  now?: () => Date;
266
- /** Re-list items even when `contentVersion` is unchanged. */
406
+ /** Re-list items even when `contentVersion` is unchanged, bypassing provider caches. */
267
407
  force?: boolean;
268
408
  }): Promise<ProviderVaultSyncResult> {
269
409
  const now = input.now ?? (() => new Date());
270
410
  const { target } = input;
271
- if (
272
- target.rateLimitedUntil &&
273
- target.rateLimitedUntil.getTime() > now().getTime() &&
274
- !input.force
275
- ) {
411
+ if (target.rateLimitedUntil && target.rateLimitedUntil.getTime() > now().getTime()) {
276
412
  return { status: "skipped_rate_limited", rateLimitedUntil: target.rateLimitedUntil };
277
413
  }
414
+ const observations: VaultProviderFreshness[] = [];
415
+ const outer = input.ctx.onFreshness;
416
+ const ctx: VaultProviderContext = {
417
+ ...input.ctx,
418
+ ...(target.cacheEpoch !== undefined && input.ctx.cacheEpoch === undefined
419
+ ? { cacheEpoch: target.cacheEpoch }
420
+ : {}),
421
+ ...(input.force ? { forceRefresh: true } : {}),
422
+ onFreshness(freshness) {
423
+ observations.push(freshness);
424
+ outer?.(freshness);
425
+ },
426
+ };
427
+ const succeeded = (): ProviderSyncStatusUpdate => {
428
+ const checkedAt = now();
429
+ return {
430
+ syncStatus: "ok",
431
+ syncErrorCode: null,
432
+ syncErrorMessage: null,
433
+ rateLimitedUntil: null,
434
+ lastSyncedAt: checkedAt,
435
+ freshness: aggregateProviderFreshness(observations, checkedAt),
436
+ };
437
+ };
278
438
  try {
279
- const vaults = await input.provider.listVaults(input.ctx);
280
- const vault = vaults.find((candidate) => candidate.externalVaultId === target.externalVaultId);
439
+ const vaults = await providerStep(() => readProviderVaultInventory(input.provider, ctx));
440
+ const vault = vaults.items.find(
441
+ (candidate) => candidate.externalVaultId === target.externalVaultId,
442
+ );
281
443
  if (!vault) {
444
+ // Absence is concluded only from a consistent listing; a multi-page
445
+ // listing without a snapshot may have skipped the vault.
282
446
  const failure = providerSyncFailure(
283
- new VaultProviderError(
284
- "provider_not_found",
285
- "the linked provider vault no longer exists or is no longer shared with this connection",
286
- ),
447
+ vaults.consistent
448
+ ? new VaultProviderError(
449
+ "provider_not_found",
450
+ "the linked provider vault no longer exists or is no longer shared with this connection",
451
+ )
452
+ : unprovenInventory("vaults"),
287
453
  now(),
288
454
  );
289
- await input.store.setSyncStatus(failure);
455
+ if (!(await input.store.settleRefresh(failure))) return { status: "superseded" };
290
456
  return {
291
457
  status: "failed",
292
458
  syncStatus: failure.syncStatus,
@@ -294,30 +460,25 @@ export async function syncProviderVault(input: {
294
460
  rateLimitedUntil: null,
295
461
  };
296
462
  }
463
+ const operations = providerOperations(input.provider.capabilities(), vault);
297
464
  if (
298
465
  !input.force &&
299
466
  vault.contentVersion !== null &&
300
467
  vault.contentVersion === target.mirroredContentVersion
301
468
  ) {
302
- await input.store.setSyncStatus({
303
- syncStatus: "ok",
304
- syncErrorCode: null,
305
- syncErrorMessage: null,
306
- rateLimitedUntil: null,
307
- lastSyncedAt: now(),
308
- });
469
+ if (!(await input.store.settleRefresh({ ...succeeded(), operations })))
470
+ return { status: "superseded" };
309
471
  return { status: "unchanged", contentVersion: vault.contentVersion };
310
472
  }
311
- const items = await input.provider.listItems(input.ctx, target.externalVaultId);
312
- const plan = planProviderMirror(items, vault.contentVersion);
313
- await input.store.replaceMirror(plan);
314
- await input.store.setSyncStatus({
315
- syncStatus: "ok",
316
- syncErrorCode: null,
317
- syncErrorMessage: null,
318
- rateLimitedUntil: null,
319
- lastSyncedAt: now(),
320
- });
473
+ const inventory = await providerStep(() =>
474
+ readProviderItemInventory(input.provider, ctx, target.externalVaultId),
475
+ );
476
+ // Replacing the mirror drops every item the listing lacks: an
477
+ // inconsistent listing may lack items that still exist.
478
+ if (!inventory.consistent) throw unprovenInventory("items");
479
+ const plan = planProviderMirror(inventory.items, vault.contentVersion);
480
+ if (!(await input.store.settleRefresh({ ...succeeded(), operations }, plan)))
481
+ return { status: "superseded" };
321
482
  return {
322
483
  status: "synced",
323
484
  contentVersion: plan.contentVersion,
@@ -327,7 +488,7 @@ export async function syncProviderVault(input: {
327
488
  } catch (error) {
328
489
  if (!isVaultProviderError(error)) throw error;
329
490
  const failure = providerSyncFailure(error, now());
330
- await input.store.setSyncStatus(failure);
491
+ if (!(await input.store.settleRefresh(failure))) return { status: "superseded" };
331
492
  return {
332
493
  status: "failed",
333
494
  syncStatus: failure.syncStatus,
@@ -337,43 +498,58 @@ export async function syncProviderVault(input: {
337
498
  }
338
499
  }
339
500
 
340
- /** Binds the mirror store to the 0739 routines for one connected vault. */
501
+ /** Binds atomic settlement to the epoch read before provider I/O (0789). */
341
502
  export function databaseProviderMirrorStore(
342
503
  db: Database,
343
504
  context: VaultDbContext,
344
505
  vaultId: string,
506
+ expectedCacheEpoch: string,
345
507
  ): ProviderMirrorStore {
346
508
  return {
347
- async replaceMirror(plan) {
348
- await upsertProviderVaultItems(db, context, {
349
- vaultId,
350
- contentVersion: plan.contentVersion,
351
- items: plan.items.map((item) => ({
352
- providerItemRef: item.externalItemId,
353
- providerVersion: item.version,
354
- // Passkeys live only in hosted vaults (D20); providers never mirror them.
355
- type: item.type === "passkey" ? "login" : item.type,
356
- // The base name: the routine de-duplicates against the stored mirror
357
- // so names stay sticky per provider item.
358
- name: providerSecretName(item.providerName),
359
- websites: item.websites,
360
- primaryFieldRef: item.primaryExternalFieldId,
361
- fields: item.fields.map((field) => ({
362
- providerFieldRef: field.externalFieldId,
363
- name: field.name,
364
- kind: field.kind,
365
- })),
366
- })),
367
- });
368
- },
369
- async setSyncStatus(update) {
370
- await setVaultSyncStatus(db, context, {
371
- vaultId,
372
- status: update.syncStatus,
373
- errorCode: update.syncErrorCode,
374
- errorMessage: update.syncErrorMessage,
375
- rateLimitedUntil: update.rateLimitedUntil?.toISOString() ?? null,
376
- });
509
+ async settleRefresh(update, plan) {
510
+ const mirror = plan
511
+ ? {
512
+ contentVersion: plan.contentVersion,
513
+ items: plan.items.map((item) => ({
514
+ providerItemRef: item.externalItemId,
515
+ providerVersion: item.version,
516
+ // Passkeys live only in hosted vaults (D20); providers never mirror them.
517
+ type: item.type === "passkey" ? "login" : item.type,
518
+ // The base name: the routine de-duplicates against the stored mirror
519
+ // so names stay sticky per provider item.
520
+ name: providerSecretName(item.providerName),
521
+ websites: item.websites,
522
+ primaryFieldRef: item.primaryExternalFieldId,
523
+ fields: item.fields.map((field) => ({
524
+ providerFieldRef: field.externalFieldId,
525
+ name: field.name,
526
+ kind: field.kind,
527
+ })),
528
+ })),
529
+ }
530
+ : undefined;
531
+ try {
532
+ await setVaultSyncStatus(db, context, {
533
+ vaultId,
534
+ expectedCacheEpoch,
535
+ ...(mirror ? { mirror } : {}),
536
+ status: update.syncStatus,
537
+ errorCode: update.syncErrorCode,
538
+ errorMessage: update.syncErrorMessage,
539
+ rateLimitedUntil: update.rateLimitedUntil?.toISOString() ?? null,
540
+ ...(update.operations !== undefined ? { operations: update.operations } : {}),
541
+ ...(update.freshness !== undefined ? { freshness: update.freshness } : {}),
542
+ });
543
+ return true;
544
+ } catch (error) {
545
+ if (
546
+ isVaultError(error) &&
547
+ error.code === "provider_conflict" &&
548
+ error.details?.superseded === true
549
+ )
550
+ return false;
551
+ throw error;
552
+ }
377
553
  },
378
554
  };
379
555
  }
@@ -381,6 +557,8 @@ export function databaseProviderMirrorStore(
381
557
  /**
382
558
  * Sync one linked vault the caller reaches: load its provider refs and mirror
383
559
  * version, resolve its provider, refresh the mirror and record sync status.
560
+ * The provider context carries the vault's durable cache epoch; the
561
+ * registry binds the trusted authority, never this caller.
384
562
  */
385
563
  export async function syncLinkedProviderVault(input: {
386
564
  db: Database;
@@ -388,11 +566,26 @@ export async function syncLinkedProviderVault(input: {
388
566
  vaultId: string;
389
567
  providers: VaultProviderRegistry;
390
568
  budget?: VaultProviderContext["budget"];
569
+ /** Re-list items and bypass provider caches; an active rate limit still applies. */
391
570
  force?: boolean;
392
571
  now?: () => Date;
572
+ signal?: AbortSignal;
573
+ /** Runtime-only observation of the provider's freshness reports. */
574
+ onFreshness?: VaultProviderContext["onFreshness"];
393
575
  }): Promise<ProviderVaultSyncResult> {
394
576
  const target = await getProviderVaultSyncTarget(input.db, input.context, input.vaultId);
395
- const store = databaseProviderMirrorStore(input.db, input.context, input.vaultId);
577
+ const store = databaseProviderMirrorStore(
578
+ input.db,
579
+ input.context,
580
+ input.vaultId,
581
+ target.providerCacheEpoch ?? "0",
582
+ );
583
+ const now = input.now ?? (() => new Date());
584
+ const rateLimitedUntil = target.rateLimitedUntil ? new Date(target.rateLimitedUntil) : null;
585
+ // Do not even load a provider (and its credential) while the provider asked us to wait.
586
+ if (rateLimitedUntil && rateLimitedUntil.getTime() > now().getTime()) {
587
+ return { status: "skipped_rate_limited", rateLimitedUntil };
588
+ }
396
589
  let provider: VaultProvider;
397
590
  try {
398
591
  provider = await input.providers.forVault({
@@ -401,8 +594,8 @@ export async function syncLinkedProviderVault(input: {
401
594
  });
402
595
  } catch (error) {
403
596
  if (!isVaultProviderError(error)) throw error;
404
- const failure = providerSyncFailure(error, (input.now ?? (() => new Date()))());
405
- await store.setSyncStatus(failure);
597
+ const failure = providerSyncFailure(error, now());
598
+ if (!(await store.settleRefresh(failure))) return { status: "superseded" };
406
599
  return {
407
600
  status: "failed",
408
601
  syncStatus: failure.syncStatus,
@@ -415,11 +608,15 @@ export async function syncLinkedProviderVault(input: {
415
608
  ctx: {
416
609
  connectionId: target.providerConnectionId,
417
610
  budget: input.budget ?? { take() {} },
611
+ cacheEpoch: target.providerCacheEpoch ?? null,
612
+ ...(input.signal ? { signal: input.signal } : {}),
613
+ ...(input.onFreshness ? { onFreshness: input.onFreshness } : {}),
418
614
  },
419
615
  target: {
420
616
  externalVaultId: target.providerVaultRef,
421
617
  mirroredContentVersion: target.providerContentVersion,
422
- rateLimitedUntil: target.rateLimitedUntil ? new Date(target.rateLimitedUntil) : null,
618
+ rateLimitedUntil,
619
+ cacheEpoch: target.providerCacheEpoch ?? null,
423
620
  },
424
621
  store,
425
622
  ...(input.now ? { now: input.now } : {}),
@@ -439,6 +636,12 @@ export const LINKED_VAULT_REFRESH_WAIT_MS = 8_000;
439
636
  * the background and the next load shows it.
440
637
  */
441
638
  export const LINKED_VAULT_OPEN_WAIT_MS = 3_000;
639
+ /**
640
+ * How long one background refresh may run. Afterwards it is abandoned for
641
+ * de-duplication, so one unresponsive provider cannot pin a vault as
642
+ * "refreshing" for every later caller.
643
+ */
644
+ export const LINKED_VAULT_REFRESH_TIMEOUT_MS = 60_000;
442
645
 
443
646
  /** The vault fields a freshness check needs (a `VaultRow` has them all). */
444
647
  export type LinkedVaultFreshness = {
@@ -448,6 +651,8 @@ export type LinkedVaultFreshness = {
448
651
  lastSyncedAt: string | null;
449
652
  rateLimitedUntil: string | null;
450
653
  updatedAt: string;
654
+ syncErrorCode?: string | null;
655
+ providerFreshness?: StoredProviderFreshness | null;
451
656
  };
452
657
 
453
658
  /**
@@ -473,59 +678,210 @@ export function linkedVaultNeedsRefresh(
473
678
  return now.getTime() - new Date(last).getTime() >= maxAgeMs;
474
679
  }
475
680
 
681
+ function isoOrNull(value: string | null | undefined): string | null {
682
+ const time = validTime(value);
683
+ return time === null ? null : new Date(time).toISOString();
684
+ }
685
+
686
+ /**
687
+ * The freshness a person or agent sees for a linked vault (design §9);
688
+ * undefined for hosted vaults. `lastCheckedAt` is Opengeni's last successful
689
+ * check; `source` and `sourceSyncedAt` are only what the provider itself
690
+ * reported then. `current` means checked successfully within `maxAgeMs`;
691
+ * `stale` means not recently checked (or a rate limit just lifted);
692
+ * `refreshing` means a check is running; `unavailable` means the last check
693
+ * failed, with its safe error code and, under a rate limit, the retry time.
694
+ */
695
+ export function providerVaultFreshness(
696
+ vault: LinkedVaultFreshness,
697
+ now: Date,
698
+ options: { maxAgeMs?: number; refreshing?: boolean } = {},
699
+ ): VaultProviderFreshness | undefined {
700
+ if (vault.source === "opengeni") return undefined;
701
+ const stored = vault.providerFreshness ?? null;
702
+ const failed = vault.syncStatus !== "ok" && vault.syncStatus !== "syncing";
703
+ const base = {
704
+ lastCheckedAt: isoOrNull(vault.lastSyncedAt),
705
+ sourceSyncedAt: isoOrNull(stored?.sourceSyncedAt),
706
+ source:
707
+ stored?.source === "live" || stored?.source === "cache"
708
+ ? stored.source
709
+ : ("unknown" as const),
710
+ errorCode: failed && vault.syncErrorCode ? vault.syncErrorCode.slice(0, 100) : null,
711
+ retryAt: null as string | null,
712
+ };
713
+ if (options.refreshing || vault.syncStatus === "syncing") return { state: "refreshing", ...base };
714
+ if (vault.syncStatus === "rate_limited") {
715
+ const retryAt = isoOrNull(vault.rateLimitedUntil);
716
+ if (retryAt !== null && Date.parse(retryAt) > now.getTime()) {
717
+ return { state: "unavailable", ...base, retryAt };
718
+ }
719
+ return { state: "stale", ...base };
720
+ }
721
+ if (failed) return { state: "unavailable", ...base };
722
+ const checked = base.lastCheckedAt === null ? null : Date.parse(base.lastCheckedAt);
723
+ const maxAgeMs = options.maxAgeMs ?? LINKED_VAULT_SEARCH_MAX_AGE_MS;
724
+ if (checked === null || now.getTime() - checked >= maxAgeMs) return { state: "stale", ...base };
725
+ return { state: "current", ...base };
726
+ }
727
+
728
+ /**
729
+ * Whose refresh this is: concurrent refreshes share one provider call only
730
+ * within the same tenant, workspace and acting principal, because the
731
+ * provider registry (and its credential) is bound to that authority.
732
+ */
733
+ export function vaultRefreshScopeKey(context: VaultDbContext): string {
734
+ if (context.kind === "subject") {
735
+ return `${context.accountId}|${context.workspaceId}|subject|${context.subjectId}`;
736
+ }
737
+ const principal = context.principal;
738
+ switch (principal.kind) {
739
+ case "attempt":
740
+ return `${principal.accountId}|${principal.workspaceId}|attempt|${principal.attemptId}|${principal.executionGeneration}`;
741
+ case "site_viewer":
742
+ return `${principal.accountId}|${principal.workspaceId}|site|${principal.siteId}|${principal.siteVersion}|${principal.subjectId}`;
743
+ case "human":
744
+ return `${principal.accountId}|${principal.workspaceId}|human|${principal.subjectId}|${principal.viaApiKey ? "key" : principal.verifiedHuman ? "verified" : "unverified"}`;
745
+ }
746
+ }
747
+
476
748
  const linkedVaultRefreshes = new Map<string, Promise<ProviderVaultSyncResult | null>>();
477
749
 
750
+ export type LinkedVaultRefreshOutcome = {
751
+ vaultId: string;
752
+ /**
753
+ * `not_due`: hosted, recently checked, or under an active rate limit (no
754
+ * provider call); `pending`: still running when the wait ended; `done`:
755
+ * finished with `result`; `failed`: the refresh itself failed unexpectedly.
756
+ */
757
+ state: "not_due" | "pending" | "done" | "failed";
758
+ result: ProviderVaultSyncResult | null;
759
+ };
760
+
761
+ export type LinkedVaultRefreshReport = {
762
+ outcomes: LinkedVaultRefreshOutcome[];
763
+ /** Vaults whose mirror changed before the wait ended. */
764
+ changed: string[];
765
+ /** Vaults whose refresh was still running when the wait ended. */
766
+ pending: string[];
767
+ };
768
+
478
769
  /**
479
- * Bring stale linked vault mirrors up to date, so items added in the provider
480
- * (for example straight in 1Password) are found without anyone pressing Sync.
770
+ * Bring linked vault mirrors up to date, so items added in the provider (for
771
+ * example straight in 1Password) are found without anyone pressing Sync.
481
772
  *
482
- * Each refresh is the ordinary non-forced sync: one `listVaults` call, and an
483
- * item listing only when the provider says the vault changed. Refreshes of the
484
- * same vault share one call per process. The caller waits at most `waitMs`;
485
- * slower refreshes finish in the background and record their own status.
486
- * Never throws: failures are recorded as the vault's sync status.
487
- *
488
- * Returns the ids of the vaults whose mirror changed before the wait ended.
773
+ * Without `force`, only vaults older than `maxAgeMs` refresh, with the
774
+ * ordinary sync (one vault listing; an item listing only when the provider
775
+ * says the vault changed). `force` re-lists every given provider vault and
776
+ * bypasses provider caches, but never an active rate limit. Refreshes of the
777
+ * same vault by the same principal share one call per process. The caller
778
+ * waits at most `waitMs`; slower refreshes finish in the background and
779
+ * record their own status. Never throws: failures are recorded as the vault's
780
+ * sync status.
489
781
  */
490
- export async function refreshStaleLinkedVaults(input: {
782
+ export async function refreshLinkedVaults(input: {
491
783
  db: Database;
492
784
  context: VaultDbContext;
493
785
  vaults: readonly LinkedVaultFreshness[];
494
786
  providers: VaultProviderRegistry;
495
- maxAgeMs: number;
787
+ /** Refresh vaults whose last check is at least this old (default: the search age). */
788
+ maxAgeMs?: number;
789
+ force?: boolean;
496
790
  waitMs?: number;
497
791
  now?: () => Date;
498
- }): Promise<string[]> {
792
+ }): Promise<LinkedVaultRefreshReport> {
499
793
  const now = (input.now ?? (() => new Date()))();
500
- const stale = input.vaults.filter((vault) => linkedVaultNeedsRefresh(vault, input.maxAgeMs, now));
501
- if (stale.length === 0) return [];
794
+ const maxAgeMs = input.maxAgeMs ?? LINKED_VAULT_SEARCH_MAX_AGE_MS;
795
+ const scope = vaultRefreshScopeKey(input.context);
796
+ const outcomes = new Map<string, LinkedVaultRefreshOutcome>();
502
797
  const changed: string[] = [];
503
- const runs = stale.map((vault) => {
504
- let run = linkedVaultRefreshes.get(vault.id);
798
+ const runs: Promise<void>[] = [];
799
+ for (const vault of input.vaults) {
800
+ if (outcomes.has(vault.id)) continue;
801
+ const limited =
802
+ vault.rateLimitedUntil !== null && new Date(vault.rateLimitedUntil).getTime() > now.getTime();
803
+ const due =
804
+ vault.source !== "opengeni" &&
805
+ !limited &&
806
+ (input.force || linkedVaultNeedsRefresh(vault, maxAgeMs, now));
807
+ const outcome: LinkedVaultRefreshOutcome = {
808
+ vaultId: vault.id,
809
+ state: due ? "pending" : "not_due",
810
+ result: null,
811
+ };
812
+ outcomes.set(vault.id, outcome);
813
+ if (!due) continue;
814
+ const key = `${scope}|${vault.id}|${input.force ? "force" : "stale"}`;
815
+ let run = linkedVaultRefreshes.get(key);
505
816
  if (!run) {
506
- run = syncLinkedProviderVault({
507
- db: input.db,
508
- context: input.context,
509
- vaultId: vault.id,
510
- providers: input.providers,
511
- ...(input.now ? { now: input.now } : {}),
512
- })
513
- .catch(() => null)
514
- .finally(() => linkedVaultRefreshes.delete(vault.id));
515
- linkedVaultRefreshes.set(vault.id, run);
817
+ let timer: ReturnType<typeof setTimeout> | undefined;
818
+ run = Promise.race([
819
+ syncLinkedProviderVault({
820
+ db: input.db,
821
+ context: input.context,
822
+ vaultId: vault.id,
823
+ providers: input.providers,
824
+ signal: AbortSignal.timeout(LINKED_VAULT_REFRESH_TIMEOUT_MS),
825
+ ...(input.force ? { force: true } : {}),
826
+ ...(input.now ? { now: input.now } : {}),
827
+ }),
828
+ new Promise<null>((resolve) => {
829
+ timer = setTimeout(() => resolve(null), LINKED_VAULT_REFRESH_TIMEOUT_MS);
830
+ timer.unref?.();
831
+ }),
832
+ ])
833
+ .catch(() => {
834
+ outcome.state = "failed";
835
+ return null;
836
+ })
837
+ .finally(() => {
838
+ if (timer) clearTimeout(timer);
839
+ linkedVaultRefreshes.delete(key);
840
+ });
841
+ linkedVaultRefreshes.set(key, run);
516
842
  }
517
- return run.then((result) => {
518
- if (result?.status === "synced") changed.push(vault.id);
519
- });
520
- });
521
- let timer: ReturnType<typeof setTimeout> | undefined;
522
- await Promise.race([
523
- Promise.all(runs),
524
- new Promise<void>((resolve) => {
525
- timer = setTimeout(resolve, input.waitMs ?? LINKED_VAULT_REFRESH_WAIT_MS);
526
- timer.unref?.();
527
- }),
528
- ]);
529
- if (timer) clearTimeout(timer);
530
- return [...changed];
843
+ runs.push(
844
+ run.then((result) => {
845
+ if (outcome.state === "pending") outcome.state = result ? "done" : "failed";
846
+ outcome.result = result;
847
+ if (result?.status === "synced") changed.push(vault.id);
848
+ }),
849
+ );
850
+ }
851
+ if (runs.length > 0) {
852
+ let timer: ReturnType<typeof setTimeout> | undefined;
853
+ await Promise.race([
854
+ Promise.all(runs),
855
+ new Promise<void>((resolve) => {
856
+ timer = setTimeout(resolve, input.waitMs ?? LINKED_VAULT_REFRESH_WAIT_MS);
857
+ timer.unref?.();
858
+ }),
859
+ ]);
860
+ if (timer) clearTimeout(timer);
861
+ }
862
+ const list = [...outcomes.values()].map((outcome) => ({ ...outcome }));
863
+ return {
864
+ outcomes: list,
865
+ changed: [...changed],
866
+ pending: list
867
+ .filter((outcome) => outcome.state === "pending")
868
+ .map((outcome) => outcome.vaultId),
869
+ };
870
+ }
871
+
872
+ /**
873
+ * The ordinary stale refresh (`refreshLinkedVaults` without `force`).
874
+ * Returns the ids of the vaults whose mirror changed before the wait ended.
875
+ */
876
+ export async function refreshStaleLinkedVaults(input: {
877
+ db: Database;
878
+ context: VaultDbContext;
879
+ vaults: readonly LinkedVaultFreshness[];
880
+ providers: VaultProviderRegistry;
881
+ maxAgeMs: number;
882
+ waitMs?: number;
883
+ now?: () => Date;
884
+ }): Promise<string[]> {
885
+ const report = await refreshLinkedVaults(input);
886
+ return report.changed;
531
887
  }