@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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import {
|
|
3
|
+
DestinationPolicyError,
|
|
4
|
+
ResponseBodyLimitError,
|
|
5
|
+
isLocalTestEnvironment,
|
|
6
|
+
pinnedFetch,
|
|
7
|
+
readResponseTextBounded,
|
|
8
|
+
validateHttpUrl,
|
|
9
|
+
type OutboundNetworkSettings,
|
|
10
|
+
} from "@opengeni/network";
|
|
11
|
+
import {
|
|
12
|
+
RemoteVaultTransportError,
|
|
13
|
+
VaultProviderError,
|
|
14
|
+
createRemoteVaultProvider,
|
|
15
|
+
type RemoteVaultTransport,
|
|
16
|
+
} from "@opengeni/vaults";
|
|
17
|
+
import type { ExternalVaultProviderFactories } from "./external-provider-connections";
|
|
18
|
+
|
|
19
|
+
/** Shared by API and worker. No credential-bearing request uses ambient fetch. */
|
|
20
|
+
export function createPinnedRemoteVaultTransport(
|
|
21
|
+
settings: OutboundNetworkSettings,
|
|
22
|
+
fetchImpl: typeof pinnedFetch = pinnedFetch,
|
|
23
|
+
): RemoteVaultTransport {
|
|
24
|
+
return {
|
|
25
|
+
async post(request) {
|
|
26
|
+
if (request.signal?.aborted) throw new RemoteVaultTransportError("aborted", false);
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
let timedOut = false;
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
timedOut = true;
|
|
31
|
+
controller.abort();
|
|
32
|
+
}, request.timeoutMs);
|
|
33
|
+
const onAbort = () => controller.abort();
|
|
34
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
35
|
+
let rejectAborted: (() => void) | undefined;
|
|
36
|
+
try {
|
|
37
|
+
// The deadline includes DNS, connect, headers and the complete body.
|
|
38
|
+
// Racing DNS does not retry it: pinnedFetch receives the aborted signal
|
|
39
|
+
// and cannot send a later request if lookup completes after the deadline.
|
|
40
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
41
|
+
rejectAborted = () =>
|
|
42
|
+
reject(new RemoteVaultTransportError(timedOut ? "timeout" : "aborted", true));
|
|
43
|
+
controller.signal.addEventListener("abort", rejectAborted, { once: true });
|
|
44
|
+
if (controller.signal.aborted) rejectAborted();
|
|
45
|
+
});
|
|
46
|
+
const exchange = async () => {
|
|
47
|
+
const response = await fetchImpl(
|
|
48
|
+
request.url,
|
|
49
|
+
{
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { ...request.headers },
|
|
52
|
+
body: request.body,
|
|
53
|
+
redirect: "manual",
|
|
54
|
+
signal: controller.signal,
|
|
55
|
+
},
|
|
56
|
+
settings,
|
|
57
|
+
{ label: "Vault provider", requireHttpsOutsideLocalTest: true },
|
|
58
|
+
);
|
|
59
|
+
if (response.status >= 300 && response.status < 400) {
|
|
60
|
+
await response.body?.cancel().catch(() => undefined);
|
|
61
|
+
throw new RemoteVaultTransportError("redirect", true);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
status: response.status,
|
|
65
|
+
headers: response.headers,
|
|
66
|
+
body: await readResponseTextBounded(
|
|
67
|
+
response,
|
|
68
|
+
request.maxResponseBytes,
|
|
69
|
+
"Vault provider",
|
|
70
|
+
{ signal: controller.signal },
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
return await Promise.race([exchange(), aborted]);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error instanceof RemoteVaultTransportError) throw error;
|
|
77
|
+
if (error instanceof DestinationPolicyError)
|
|
78
|
+
throw new RemoteVaultTransportError("refused", false);
|
|
79
|
+
if (error instanceof ResponseBodyLimitError)
|
|
80
|
+
throw new RemoteVaultTransportError("too_large", true);
|
|
81
|
+
throw new RemoteVaultTransportError(
|
|
82
|
+
timedOut ? "timeout" : controller.signal.aborted ? "aborted" : "network",
|
|
83
|
+
true,
|
|
84
|
+
);
|
|
85
|
+
} finally {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
88
|
+
if (rejectAborted) controller.signal.removeEventListener("abort", rejectAborted);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createRemoteExternalVaultFactories(
|
|
95
|
+
settings: Pick<Settings, "environment" | "integrationsAllowPrivateNetworkTargets">,
|
|
96
|
+
): ExternalVaultProviderFactories {
|
|
97
|
+
const transport = createPinnedRemoteVaultTransport(settings);
|
|
98
|
+
const factory: NonNullable<ExternalVaultProviderFactories["external"]> = async ({
|
|
99
|
+
credentials,
|
|
100
|
+
operations,
|
|
101
|
+
}) => {
|
|
102
|
+
if (credentials.provider !== "external" && credentials.provider !== "bitwarden")
|
|
103
|
+
throw new VaultProviderError("provider_auth_invalid", "Vault provider does not match.");
|
|
104
|
+
let endpoint: string;
|
|
105
|
+
try {
|
|
106
|
+
endpoint = validateHttpUrl(credentials.endpoint, {
|
|
107
|
+
allowLoopbackHttp: isLocalTestEnvironment(settings.environment),
|
|
108
|
+
label: "Vault provider",
|
|
109
|
+
});
|
|
110
|
+
} catch {
|
|
111
|
+
throw new VaultProviderError(
|
|
112
|
+
"provider_auth_invalid",
|
|
113
|
+
"Use an HTTPS vault endpoint without URL credentials or a fragment.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
credentials: { ...credentials, endpoint },
|
|
118
|
+
provider: createRemoteVaultProvider({
|
|
119
|
+
provider: credentials.provider,
|
|
120
|
+
endpoint,
|
|
121
|
+
signingSecret: credentials.signingSecret,
|
|
122
|
+
transport,
|
|
123
|
+
allowInsecureHttp: isLocalTestEnvironment(settings.environment),
|
|
124
|
+
...(operations ? { operations } : {}),
|
|
125
|
+
}),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
return { external: factory, bitwarden: factory };
|
|
129
|
+
}
|
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
* chosen here (archived dropped, lowest tier, names over aliases, otherwise
|
|
5
5
|
* ambiguous), provider values are fetched, hosted values decrypted, and the
|
|
6
6
|
* batch is recorded. A call resolves all of its references or none (I2).
|
|
7
|
+
*
|
|
8
|
+
* A reference that finds nothing first refreshes the connected vaults that
|
|
9
|
+
* could change the answer: those that could hold it at the priority tier that
|
|
10
|
+
* decided the lookup or above, even when a hosted item matched without the
|
|
11
|
+
* field. When one of those could not be checked (unreachable, rate limited,
|
|
12
|
+
* needing reconnection or still refreshing), the failure names that source
|
|
13
|
+
* instead of reporting the secret missing, so no missing-secret ask card is
|
|
14
|
+
* made for a secret that may exist (external vaults §9). Hosted and
|
|
15
|
+
* explicitly named healthy vaults are never blocked by unrelated outages, and
|
|
16
|
+
* lower-priority vaults never block a miss a higher tier decided.
|
|
7
17
|
*/
|
|
8
18
|
import {
|
|
9
19
|
buildMissingSecretAskCard,
|
|
@@ -11,6 +21,7 @@ import {
|
|
|
11
21
|
isValidTotpInput,
|
|
12
22
|
parseTotpInput,
|
|
13
23
|
referenceKey,
|
|
24
|
+
secretNameKey,
|
|
14
25
|
SecretReferenceError,
|
|
15
26
|
VAULT_LIMITS,
|
|
16
27
|
type ResolvedSecretValue,
|
|
@@ -20,6 +31,7 @@ import {
|
|
|
20
31
|
type SecretReferenceFailure,
|
|
21
32
|
type SecretResolver,
|
|
22
33
|
type SecretUseContext,
|
|
34
|
+
type VaultProviderFreshness,
|
|
23
35
|
} from "@opengeni/contracts";
|
|
24
36
|
import {
|
|
25
37
|
decryptEnvironmentValue,
|
|
@@ -29,6 +41,7 @@ import {
|
|
|
29
41
|
type SecretReferenceCandidate,
|
|
30
42
|
type SecretReferenceLookup,
|
|
31
43
|
type SecretResolutionMeta,
|
|
44
|
+
type VaultRow,
|
|
32
45
|
} from "@opengeni/db";
|
|
33
46
|
import {
|
|
34
47
|
createProviderCallBudget,
|
|
@@ -38,7 +51,11 @@ import {
|
|
|
38
51
|
type VaultProviderErrorCode,
|
|
39
52
|
type VaultProviderRegistry,
|
|
40
53
|
} from "@opengeni/vaults";
|
|
41
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
LINKED_VAULT_MISS_MAX_AGE_MS,
|
|
56
|
+
providerVaultFreshness,
|
|
57
|
+
refreshLinkedVaults,
|
|
58
|
+
} from "./provider-sync";
|
|
42
59
|
import type { VaultServiceDeps } from "./types";
|
|
43
60
|
|
|
44
61
|
export type SecretResolverOptions = {
|
|
@@ -204,6 +221,108 @@ function providerFailureCode(code: VaultProviderErrorCode): SecretReferenceFailu
|
|
|
204
221
|
return "provider_unavailable";
|
|
205
222
|
}
|
|
206
223
|
|
|
224
|
+
/**
|
|
225
|
+
* The connected vaults whose mirror decides a reference that found nothing.
|
|
226
|
+
*
|
|
227
|
+
* Where the item could be: the vault a three-segment reference names; for two
|
|
228
|
+
* segments the named vault when the first segment names one (else every
|
|
229
|
+
* connected vault, since it may be `Item/field`); every connected vault for a
|
|
230
|
+
* bare item name; none for an ID.
|
|
231
|
+
*
|
|
232
|
+
* When some item matched but lacks the field (or the one-time password), the
|
|
233
|
+
* lookup keeps the highest-priority tier, so only vaults at that tier or above
|
|
234
|
+
* can change the answer: the connected vaults of matched items there that lack
|
|
235
|
+
* the field, and the connected vaults that could hold the item there but have
|
|
236
|
+
* no match yet. A hosted match never hides a higher-priority connected vault
|
|
237
|
+
* whose provider gained the item or its one-time password, and a vault of a
|
|
238
|
+
* lower-priority tier that is out of date or unavailable never turns a
|
|
239
|
+
* definite miss into an unchecked one.
|
|
240
|
+
*/
|
|
241
|
+
export function relevantProviderVaults(
|
|
242
|
+
reference: SecretReference,
|
|
243
|
+
candidates: readonly SecretReferenceCandidate[],
|
|
244
|
+
reachable: readonly VaultRow[],
|
|
245
|
+
): VaultRow[] {
|
|
246
|
+
const connected = reachable.filter((vault) => vault.source !== "opengeni");
|
|
247
|
+
if (connected.length === 0) return [];
|
|
248
|
+
const where = possibleProviderVaults(reference, reachable, connected);
|
|
249
|
+
const live = candidates.filter((candidate) => !candidate.archived);
|
|
250
|
+
if (live.length === 0) return where;
|
|
251
|
+
const deciding = Math.min(...live.map((candidate) => candidate.tier));
|
|
252
|
+
const lacking = new Set(
|
|
253
|
+
live
|
|
254
|
+
.filter(
|
|
255
|
+
(candidate) =>
|
|
256
|
+
candidate.tier <= deciding &&
|
|
257
|
+
candidate.source !== "opengeni" &&
|
|
258
|
+
(candidate.missing === "field" || candidate.missing === "totp_field"),
|
|
259
|
+
)
|
|
260
|
+
.map((candidate) => candidate.vaultId),
|
|
261
|
+
);
|
|
262
|
+
const matched = new Set(live.map((candidate) => candidate.vaultId));
|
|
263
|
+
const unmatched = new Set(
|
|
264
|
+
where
|
|
265
|
+
.filter((vault) => vault.tier <= deciding && !matched.has(vault.id))
|
|
266
|
+
.map((vault) => vault.id),
|
|
267
|
+
);
|
|
268
|
+
return connected.filter((vault) => lacking.has(vault.id) || unmatched.has(vault.id));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The connected vaults a reference's path could point into, matched or not. */
|
|
272
|
+
function possibleProviderVaults(
|
|
273
|
+
reference: SecretReference,
|
|
274
|
+
reachable: readonly VaultRow[],
|
|
275
|
+
connected: readonly VaultRow[],
|
|
276
|
+
): VaultRow[] {
|
|
277
|
+
if (reference.target.form !== "path") return [];
|
|
278
|
+
const segments = reference.target.segments;
|
|
279
|
+
if (segments.length === 1) return [...connected];
|
|
280
|
+
const nameKey = secretNameKey(segments[0]!);
|
|
281
|
+
const named = reachable.filter((vault) => secretNameKey(vault.name) === nameKey);
|
|
282
|
+
if (segments.length === 2 && named.length === 0) return [...connected];
|
|
283
|
+
return named.filter((vault) => vault.source !== "opengeni");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A miss that cannot be trusted because a relevant connected vault was not checked. */
|
|
287
|
+
function uncheckedSourceFailure(
|
|
288
|
+
reference: SecretReference,
|
|
289
|
+
unchecked: ReadonlyArray<{ vault: VaultRow; freshness: VaultProviderFreshness }>,
|
|
290
|
+
): SecretReferenceFailure {
|
|
291
|
+
const authExpired = unchecked.filter(({ vault }) => vault.syncStatus === "auth_expired");
|
|
292
|
+
const rateLimited = unchecked.filter(
|
|
293
|
+
({ freshness }) => freshness.state === "unavailable" && freshness.retryAt !== null,
|
|
294
|
+
);
|
|
295
|
+
const [code, named, why] =
|
|
296
|
+
authExpired.length > 0
|
|
297
|
+
? (["provider_auth_expired", authExpired, "needs to be reconnected"] as const)
|
|
298
|
+
: rateLimited.length > 0
|
|
299
|
+
? (["provider_rate_limited", rateLimited, "is rate limited by its provider"] as const)
|
|
300
|
+
: ([
|
|
301
|
+
"provider_unavailable",
|
|
302
|
+
unchecked,
|
|
303
|
+
unchecked.every(({ freshness }) => freshness.state === "refreshing")
|
|
304
|
+
? "is still refreshing"
|
|
305
|
+
: "could not be checked",
|
|
306
|
+
] as const);
|
|
307
|
+
const names = named
|
|
308
|
+
.slice(0, 3)
|
|
309
|
+
.map(({ vault }) => vault.name)
|
|
310
|
+
.join(", ");
|
|
311
|
+
const retryAt = rateLimited
|
|
312
|
+
.map(({ freshness }) => freshness.retryAt!)
|
|
313
|
+
.sort()
|
|
314
|
+
.at(0);
|
|
315
|
+
return {
|
|
316
|
+
raw: reference.raw,
|
|
317
|
+
code,
|
|
318
|
+
message:
|
|
319
|
+
`not found in the vaults that could be checked, but ${names} ${why}, so it may be there` +
|
|
320
|
+
(code === "provider_rate_limited" && retryAt
|
|
321
|
+
? `; try again after ${retryAt}`
|
|
322
|
+
: "; try again later"),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
207
326
|
function decodeBase64(value: string): Uint8Array {
|
|
208
327
|
return new Uint8Array(Buffer.from(value, "base64"));
|
|
209
328
|
}
|
|
@@ -292,38 +411,86 @@ export function createSecretResolver(
|
|
|
292
411
|
const { meta, candidates } = await resolveSecretReferenceCandidates(deps.db, context, {
|
|
293
412
|
refs: references.map((reference, index) => lookupFor(reference, index)),
|
|
294
413
|
});
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const outcome = chooseSecretCandidate(
|
|
299
|
-
reference,
|
|
300
|
-
candidates.filter((candidate) => candidate.refIndex === index),
|
|
301
|
-
meta,
|
|
302
|
-
);
|
|
303
|
-
if ("failure" in outcome) failures.push(outcome.failure);
|
|
304
|
-
else chosen.push({ reference, candidate: outcome.candidate });
|
|
414
|
+
const outcomes = references.map((reference, index) => {
|
|
415
|
+
const own = candidates.filter((candidate) => candidate.refIndex === index);
|
|
416
|
+
return { reference, candidates: own, outcome: chooseSecretCandidate(reference, own, meta) };
|
|
305
417
|
});
|
|
306
|
-
return { meta,
|
|
418
|
+
return { meta, outcomes };
|
|
419
|
+
};
|
|
420
|
+
let { meta, outcomes } = await choose();
|
|
421
|
+
// One provider registry per resolve, made only when a connected vault is needed.
|
|
422
|
+
let registry: VaultProviderRegistry | null | undefined;
|
|
423
|
+
const providers = (): VaultProviderRegistry | null => {
|
|
424
|
+
if (registry === undefined) {
|
|
425
|
+
registry = deps.providers ?? opts.providersForSubject?.(meta.subjectId) ?? null;
|
|
426
|
+
}
|
|
427
|
+
return registry;
|
|
307
428
|
};
|
|
308
|
-
|
|
429
|
+
const misses = () =>
|
|
430
|
+
outcomes.filter(
|
|
431
|
+
(entry) => "failure" in entry.outcome && entry.outcome.failure.code === "not_found",
|
|
432
|
+
);
|
|
309
433
|
// A reference to an item added straight in a connected provider (1Password)
|
|
310
|
-
// finds nothing until the vault's mirror catches up: refresh
|
|
311
|
-
// vaults once and look again before reporting it missing.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
434
|
+
// finds nothing until the vault's mirror catches up: refresh the relevant
|
|
435
|
+
// stale linked vaults once and look again before reporting it missing.
|
|
436
|
+
let reachable: VaultRow[] | null = null;
|
|
437
|
+
let refreshing = new Set<string>();
|
|
438
|
+
if (misses().length > 0) {
|
|
439
|
+
reachable = await listVaultsForSubject(deps.db, context, {
|
|
440
|
+
workspaceId: principal.workspaceId,
|
|
441
|
+
});
|
|
442
|
+
const relevant = new Map<string, VaultRow>();
|
|
443
|
+
for (const entry of misses()) {
|
|
444
|
+
for (const vault of relevantProviderVaults(entry.reference, entry.candidates, reachable)) {
|
|
445
|
+
relevant.set(vault.id, vault);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const refreshProviders = relevant.size > 0 ? providers() : null;
|
|
449
|
+
if (refreshProviders) {
|
|
450
|
+
const report = await refreshLinkedVaults({
|
|
319
451
|
db: deps.db,
|
|
320
452
|
context,
|
|
321
|
-
vaults:
|
|
322
|
-
providers:
|
|
453
|
+
vaults: [...relevant.values()],
|
|
454
|
+
providers: refreshProviders,
|
|
323
455
|
maxAgeMs: LINKED_VAULT_MISS_MAX_AGE_MS,
|
|
324
456
|
});
|
|
325
|
-
|
|
457
|
+
refreshing = new Set(report.pending);
|
|
458
|
+
if (
|
|
459
|
+
report.changed.length > 0 ||
|
|
460
|
+
report.outcomes.some((outcome) => outcome.result?.status === "superseded")
|
|
461
|
+
)
|
|
462
|
+
({ meta, outcomes } = await choose());
|
|
463
|
+
if (
|
|
464
|
+
report.outcomes.some((outcome) => outcome.state === "done" || outcome.state === "failed")
|
|
465
|
+
) {
|
|
466
|
+
reachable = await listVaultsForSubject(deps.db, context, {
|
|
467
|
+
workspaceId: principal.workspaceId,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const failures: SecretReferenceFailure[] = [];
|
|
473
|
+
const chosen: Chosen[] = [];
|
|
474
|
+
const checkedAt = new Date();
|
|
475
|
+
for (const entry of outcomes) {
|
|
476
|
+
if ("candidate" in entry.outcome) {
|
|
477
|
+
chosen.push({ reference: entry.reference, candidate: entry.outcome.candidate });
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
let failure = entry.outcome.failure;
|
|
481
|
+
if (failure.code === "not_found" && reachable) {
|
|
482
|
+
const unchecked = relevantProviderVaults(entry.reference, entry.candidates, reachable)
|
|
483
|
+
.map((vault) => ({
|
|
484
|
+
vault,
|
|
485
|
+
freshness: providerVaultFreshness(vault, checkedAt, {
|
|
486
|
+
maxAgeMs: LINKED_VAULT_MISS_MAX_AGE_MS,
|
|
487
|
+
refreshing: refreshing.has(vault.id),
|
|
488
|
+
})!,
|
|
489
|
+
}))
|
|
490
|
+
.filter(({ freshness }) => freshness.state !== "current");
|
|
491
|
+
if (unchecked.length > 0) failure = uncheckedSourceFailure(entry.reference, unchecked);
|
|
326
492
|
}
|
|
493
|
+
failures.push(failure);
|
|
327
494
|
}
|
|
328
495
|
const hasActingHuman = meta.humanExpected && meta.humanValid;
|
|
329
496
|
if (failures.length > 0) {
|
|
@@ -344,17 +511,18 @@ export function createSecretResolver(
|
|
|
344
511
|
for (const group of providerGroups.values()) {
|
|
345
512
|
const first = group[0]!.candidate;
|
|
346
513
|
try {
|
|
347
|
-
const
|
|
348
|
-
deps.providers ??
|
|
349
|
-
opts.providersForSubject?.(meta.subjectId) ??
|
|
350
|
-
EMPTY_VAULT_PROVIDER_REGISTRY;
|
|
351
|
-
const provider = await registry.forVault({
|
|
514
|
+
const provider = await (providers() ?? EMPTY_VAULT_PROVIDER_REGISTRY).forVault({
|
|
352
515
|
source: first.source,
|
|
353
516
|
providerConnectionId: first.providerConnectionId,
|
|
354
517
|
});
|
|
355
|
-
// The provider charges `budget` per call (one readFields per item).
|
|
518
|
+
// The provider charges `budget` per call (one readFields per item). The
|
|
519
|
+
// durable epoch invalidates its value cache after a refresh anywhere.
|
|
356
520
|
const values = await provider.readFields(
|
|
357
|
-
{
|
|
521
|
+
{
|
|
522
|
+
connectionId: first.providerConnectionId,
|
|
523
|
+
budget,
|
|
524
|
+
cacheEpoch: first.providerCacheEpoch ?? null,
|
|
525
|
+
},
|
|
358
526
|
first.providerVaultRef ?? "",
|
|
359
527
|
first.providerItemRef ?? "",
|
|
360
528
|
[...new Set(group.map((entry) => entry.candidate.providerFieldRef ?? ""))],
|
|
@@ -375,7 +543,9 @@ export function createSecretResolver(
|
|
|
375
543
|
? "the connected vault is rate limited; try again later"
|
|
376
544
|
: code === "provider_auth_expired"
|
|
377
545
|
? "the connected vault needs to be reconnected"
|
|
378
|
-
:
|
|
546
|
+
: code === "not_found"
|
|
547
|
+
? "the connected vault no longer has this item"
|
|
548
|
+
: "the connected vault is unavailable",
|
|
379
549
|
});
|
|
380
550
|
}
|
|
381
551
|
}
|