@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.
- package/dist/dependencies.d.ts +3 -0
- package/dist/domain/vaults/external-provider-connections.d.ts +126 -0
- package/dist/domain/vaults/index.d.ts +2 -1
- package/dist/domain/vaults/mapping.d.ts +18 -2
- package/dist/domain/vaults/provider-registry.d.ts +4 -1
- package/dist/domain/vaults/provider-sync.d.ts +135 -21
- package/dist/domain/vaults/provider-writes.d.ts +102 -12
- package/dist/domain/vaults/remote-provider-runtime.d.ts +7 -0
- package/dist/domain/vaults/secret-resolver.d.ts +29 -1
- package/dist/goal-admission.d.ts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1432 -375
- package/dist/index.js.map +1 -1
- package/package.json +13 -13
- package/src/dependencies.ts +3 -0
- package/src/domain/realtime-voice-billing.ts +4 -1
- package/src/domain/vaults/external-provider-connections.ts +395 -0
- package/src/domain/vaults/index.ts +8 -0
- package/src/domain/vaults/mapping.ts +95 -1
- package/src/domain/vaults/provider-registry.ts +29 -0
- package/src/domain/vaults/provider-sync.ts +492 -136
- package/src/domain/vaults/provider-writes.ts +415 -71
- package/src/domain/vaults/remote-provider-runtime.ts +129 -0
- package/src/domain/vaults/secret-resolver.ts +203 -33
- package/src/domain/vaults/vault-service.ts +226 -34
- package/src/domain/voice-input-billing.ts +5 -1
- package/src/index.ts +2 -0
|
@@ -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
|
|
28
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
79
|
-
|
|
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 =
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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:
|
|
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
|
|
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
|
|
280
|
-
const vault = vaults.find(
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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.
|
|
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.
|
|
303
|
-
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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.
|
|
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
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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(
|
|
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,
|
|
405
|
-
await store.
|
|
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
|
|
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
|
|
480
|
-
*
|
|
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
|
-
*
|
|
483
|
-
* item listing only when the provider
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
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
|
|
782
|
+
export async function refreshLinkedVaults(input: {
|
|
491
783
|
db: Database;
|
|
492
784
|
context: VaultDbContext;
|
|
493
785
|
vaults: readonly LinkedVaultFreshness[];
|
|
494
786
|
providers: VaultProviderRegistry;
|
|
495
|
-
|
|
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<
|
|
792
|
+
}): Promise<LinkedVaultRefreshReport> {
|
|
499
793
|
const now = (input.now ?? (() => new Date()))();
|
|
500
|
-
const
|
|
501
|
-
|
|
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 =
|
|
504
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
}
|