@opengeni/vaults-keeper 0.0.0-stage → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,681 @@
1
+ import {
2
+ type ProviderCallBudget,
3
+ type ProviderFieldValue,
4
+ type ProviderItemMeta,
5
+ type ProviderVault,
6
+ type SecretMaterial,
7
+ VAULT_LIMITS,
8
+ type VaultProvider,
9
+ type VaultProviderCapabilities,
10
+ type VaultProviderContext,
11
+ VaultProviderError,
12
+ type VaultProviderOperations,
13
+ } from "./contract";
14
+ import {
15
+ KEEPER_DEFAULT_ALLOWED_HOSTS,
16
+ type KeeperConfigValues,
17
+ assertAllowedHost,
18
+ memoryKeeperStorage,
19
+ parseKeeperConfiguration,
20
+ serializeKeeperConfiguration,
21
+ } from "./config";
22
+ import { type MutationTracker, mapKeeperError, mapKeeperMutationError } from "./errors";
23
+ import {
24
+ FILE_FIELD_PREFIX,
25
+ type MappedKeeperField,
26
+ type MappedKeeperRecord,
27
+ applyPatch,
28
+ buildCreateData,
29
+ fileSize,
30
+ mapRecord,
31
+ totpParamsFor,
32
+ } from "./mapping";
33
+ import { safeSecretName } from "./naming";
34
+ import { restrictToOperations } from "./operations";
35
+ import {
36
+ type CapturedConsole,
37
+ KEEPER_REQUEST_TIMEOUT_MS,
38
+ type KeeperFetch,
39
+ type KeeperFileFetcher,
40
+ type KeeperQueryFunction,
41
+ type KeeperRecord,
42
+ type KeeperSdk,
43
+ type KeeperSdkLoader,
44
+ type SecretManagerOptions,
45
+ createKeeperFetchTransport,
46
+ createKeeperFileFetcher,
47
+ createKeeperSdkLoader,
48
+ } from "./sdk";
49
+
50
+ export const KEEPER_VALUE_CACHE_MS = VAULT_LIMITS.providerValueCacheMs;
51
+
52
+ /** What KSM 17.6.0 can do for an application (see README for the evidence). */
53
+ export const KEEPER_OPERATIONS: VaultProviderOperations = {
54
+ createItemTypes: ["login", "note"],
55
+ updateItem: true,
56
+ removeField: true,
57
+ archiveItem: false,
58
+ archiveKind: "none",
59
+ deleteItem: false,
60
+ restoreItem: false,
61
+ readFiles: true,
62
+ writeFiles: false,
63
+ createTotp: true,
64
+ updateTotp: true,
65
+ createVault: false,
66
+ restoreFieldHistory: false,
67
+ };
68
+
69
+ /**
70
+ * Persists a changed configuration; the parent stores it encrypted with
71
+ * compare-and-swap. `configuration` is canonical JSON of a string record
72
+ * (binary entries are base64). `previous` is null for the first save after
73
+ * binding a one-time token.
74
+ */
75
+ export type PersistKeeperConfiguration = (
76
+ configuration: string,
77
+ context: { previous: string | null; reason: "bind" | "rotation" },
78
+ ) => Promise<void>;
79
+
80
+ export interface KeeperRuntimeOptions {
81
+ /** Hosts the configuration may name (default: Keeper's seven region hosts). */
82
+ allowedHosts?: readonly string[];
83
+ /** Per-request deadline handed to the SDK (default 15 s). */
84
+ requestTimeoutMs?: number;
85
+ /**
86
+ * The fetch both default transports use (KSM API and file downloads).
87
+ * Servers pass a DNS-pinned fetch; the per-call abort signal and deadline
88
+ * are still applied. Ignored where `queryFunction` or `fetchFile` is given.
89
+ */
90
+ fetch?: KeeperFetch;
91
+ /** SDK request function (tests); defaults to a bounded, no-redirect fetch transport. */
92
+ queryFunction?: KeeperQueryFunction;
93
+ /** Encrypted file download (tests); defaults to a bounded HTTPS fetch. */
94
+ fetchFile?: KeeperFileFetcher;
95
+ /** SDK loader (tests or bundlers that ship the SDK source themselves). */
96
+ sdk?: KeeperSdkLoader;
97
+ now?: () => number;
98
+ }
99
+
100
+ export interface KeeperVaultProviderOptions extends KeeperRuntimeOptions {
101
+ /** Operations verified at setup; the adapter narrows to them and never widens. */
102
+ operations?: VaultProviderOperations;
103
+ /**
104
+ * The bound KSM configuration from bootstrap or Keeper tooling: JSON, base64
105
+ * JSON, or the parsed string record.
106
+ */
107
+ configuration: string | Readonly<Record<string, string>>;
108
+ /** Awaited whenever the SDK changes the configuration (server key rotation). */
109
+ persistConfiguration: PersistKeeperConfiguration;
110
+ valueCacheMs?: number;
111
+ }
112
+
113
+ export type KeeperVaultProvider = VaultProvider & {
114
+ readonly id: "keeper";
115
+ /** The configuration as last persisted or pending persistence. */
116
+ currentConfiguration(): string;
117
+ close(): Promise<void>;
118
+ };
119
+
120
+ /** Keeper record and folder UIDs are 16-byte web-safe base64 (22 chars). */
121
+ const KEEPER_UID = /^[A-Za-z0-9_-]{16,64}$/;
122
+ function assertUids(...uids: string[]) {
123
+ for (const uid of uids) {
124
+ if (!KEEPER_UID.test(uid)) {
125
+ throw new VaultProviderError("provider_not_found", "No such Keeper shared folder or record");
126
+ }
127
+ }
128
+ }
129
+
130
+ export interface KeeperOperation {
131
+ sdk: KeeperSdk;
132
+ options: SecretManagerOptions;
133
+ console: CapturedConsole;
134
+ tracker: MutationTracker & { mutating: boolean };
135
+ }
136
+
137
+ /**
138
+ * Runs one operation in a fresh SDK instance over an in-memory copy of the
139
+ * configuration, with the adapter's host allowlist, deadline and throttle
140
+ * policy. Shared by the provider and bootstrap.
141
+ */
142
+ export async function runKeeperOperation<T>(input: {
143
+ runtime: KeeperRuntimeOptions;
144
+ loader: KeeperSdkLoader;
145
+ config: KeeperConfigValues;
146
+ signal?: AbortSignal;
147
+ work: (op: KeeperOperation, storage: ReturnType<typeof memoryKeeperStorage>) => Promise<T>;
148
+ beforeNetwork?: (storage: ReturnType<typeof memoryKeeperStorage>) => void;
149
+ /** Bootstrap sets the host inside `work`; every request is still checked. */
150
+ hostInConfig?: boolean;
151
+ }): Promise<{ result: T; storage: ReturnType<typeof memoryKeeperStorage> }> {
152
+ const allowed = (input.runtime.allowedHosts ?? KEEPER_DEFAULT_ALLOWED_HOSTS).map((host) =>
153
+ host.toLowerCase(),
154
+ );
155
+ const { sdk, console } = input.loader.instance();
156
+ const storage = memoryKeeperStorage(input.config);
157
+ const transport =
158
+ input.runtime.queryFunction ??
159
+ createKeeperFetchTransport({
160
+ ...(input.signal ? { signal: input.signal } : {}),
161
+ ...(input.runtime.fetch ? { fetch: input.runtime.fetch } : {}),
162
+ });
163
+ const tracker = { sent: 0, lastStatus: null as number | null, mutating: false };
164
+ const queryFunction: KeeperQueryFunction = async (
165
+ url,
166
+ transmissionKey,
167
+ payload,
168
+ allow,
169
+ timeoutMs,
170
+ ) => {
171
+ input.signal?.throwIfAborted();
172
+ const parsed = new URL(url);
173
+ if (parsed.protocol !== "https:" || !parsed.pathname.startsWith("/api/rest/sm/v1/")) {
174
+ throw new VaultProviderError("provider_unavailable", "Refused an unexpected Keeper request");
175
+ }
176
+ assertAllowedHost(parsed.hostname, allowed);
177
+ if (tracker.mutating) {
178
+ tracker.sent += 1;
179
+ tracker.lastStatus = null;
180
+ }
181
+ const response = await transport(url, transmissionKey, payload, false, timeoutMs);
182
+ if (tracker.mutating) tracker.lastStatus = response.statusCode;
183
+ return response;
184
+ };
185
+ const options: SecretManagerOptions = {
186
+ storage,
187
+ queryFunction,
188
+ requestTimeoutMs: input.runtime.requestTimeoutMs ?? KEEPER_REQUEST_TIMEOUT_MS,
189
+ // Never sleep inside a request: surface the server's back-off to the caller.
190
+ throttleSleep: (ms) =>
191
+ Promise.reject(
192
+ new VaultProviderError(
193
+ "provider_rate_limited",
194
+ "Keeper is rate limiting this application",
195
+ ms,
196
+ ),
197
+ ),
198
+ onDecryptionError: () => {
199
+ throw new VaultProviderError(
200
+ "provider_inventory_incomplete",
201
+ "Keeper returned a folder Opengeni could not decrypt",
202
+ );
203
+ },
204
+ };
205
+ input.beforeNetwork?.(storage);
206
+ if (input.hostInConfig !== false) assertAllowedHost(storage.values.get("hostname"), allowed);
207
+ const result = await input.work({ sdk, options, console, tracker }, storage);
208
+ return { result, storage };
209
+ }
210
+
211
+ type CachedRecords = { at: number; records: Promise<KeeperRecord[]> };
212
+ type CachedRecord = { at: number; record: Promise<MappedKeeperRecord> };
213
+
214
+ function cacheFor<V>(map: WeakMap<ProviderCallBudget, Map<string, V>>, budget: ProviderCallBudget) {
215
+ let cache = map.get(budget);
216
+ if (!cache) {
217
+ cache = new Map();
218
+ map.set(budget, cache);
219
+ }
220
+ return cache;
221
+ }
222
+
223
+ export function createKeeperVaultProvider(
224
+ options: KeeperVaultProviderOptions,
225
+ ): KeeperVaultProvider {
226
+ let config = parseKeeperConfiguration(options.configuration);
227
+ let persisted = serializeKeeperConfiguration(config);
228
+ let pending: string | null = null;
229
+ const loader = options.sdk ?? createKeeperSdkLoader();
230
+ const now = options.now ?? Date.now;
231
+ const valueCacheMs = options.valueCacheMs ?? KEEPER_VALUE_CACHE_MS;
232
+ const fetchFile = options.fetchFile ?? createKeeperFileFetcher(options.fetch);
233
+ let queue: Promise<unknown> = Promise.resolve();
234
+ // Per provider instance (one bound application) and per attempt budget.
235
+ // Core shares one budget across every provider in an attempt, so a
236
+ // module-level cache keyed only by budget would let one connection reuse
237
+ // another's records for the same folder and record uids.
238
+ const attemptListCaches = new WeakMap<ProviderCallBudget, Map<string, CachedRecords>>();
239
+ const attemptRecordCaches = new WeakMap<ProviderCallBudget, Map<string, CachedRecord>>();
240
+
241
+ async function flushPending() {
242
+ if (pending === null) return;
243
+ const next = pending;
244
+ try {
245
+ await options.persistConfiguration(next, { previous: persisted, reason: "rotation" });
246
+ } catch {
247
+ throw new VaultProviderError(
248
+ "provider_unavailable",
249
+ "Opengeni could not save the updated Keeper configuration",
250
+ );
251
+ }
252
+ persisted = next;
253
+ if (pending === next) pending = null;
254
+ }
255
+
256
+ function serialized<T>(work: () => Promise<T>): Promise<T> {
257
+ const run = queue.then(work, work);
258
+ queue = run.then(
259
+ () => undefined,
260
+ () => undefined,
261
+ );
262
+ return run;
263
+ }
264
+
265
+ /** One budgeted, serialized operation in its own SDK instance. */
266
+ function operation<T>(
267
+ ctx: VaultProviderContext,
268
+ op: "list" | "read" | "write",
269
+ subject: string,
270
+ work: (keeper: KeeperOperation) => Promise<T>,
271
+ ): Promise<T> {
272
+ ctx.signal?.throwIfAborted();
273
+ ctx.budget.take(op);
274
+ return serialized(async () => {
275
+ ctx.signal?.throwIfAborted();
276
+ await flushPending();
277
+ let outcome: { result: T; storage: ReturnType<typeof memoryKeeperStorage> } | null = null;
278
+ let failure: unknown = null;
279
+ let storageRef: ReturnType<typeof memoryKeeperStorage> | null = null;
280
+ let tracker: KeeperOperation["tracker"] | null = null;
281
+ try {
282
+ outcome = await runKeeperOperation({
283
+ runtime: options,
284
+ loader,
285
+ config,
286
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
287
+ beforeNetwork: (storage) => {
288
+ storageRef = storage;
289
+ },
290
+ work: (keeper) => {
291
+ tracker = keeper.tracker;
292
+ return work(keeper);
293
+ },
294
+ });
295
+ } catch (error) {
296
+ failure = error;
297
+ }
298
+ // Keep any configuration the SDK changed (for example a rotated server key id), even on failure.
299
+ const storage = outcome?.storage ?? storageRef;
300
+ let persistError: VaultProviderError | null = null;
301
+ if (storage?.changed()) {
302
+ config = new Map(storage.values);
303
+ pending = serializeKeeperConfiguration(config);
304
+ try {
305
+ await flushPending();
306
+ } catch (error) {
307
+ persistError = error as VaultProviderError;
308
+ }
309
+ }
310
+ if (failure !== null) {
311
+ const usedTracker = tracker as KeeperOperation["tracker"] | null;
312
+ if (op === "write" && usedTracker && (usedTracker.sent > 0 || usedTracker.mutating)) {
313
+ throw mapKeeperMutationError(failure, subject, usedTracker);
314
+ }
315
+ if (op !== "write" && failure instanceof Error && failure.name === "AbortError") {
316
+ ctx.signal?.throwIfAborted();
317
+ }
318
+ throw mapKeeperError(failure, subject);
319
+ }
320
+ // A completed write stays successful; its configuration is retried on the next call.
321
+ if (persistError && op !== "write") throw persistError;
322
+ return outcome!.result;
323
+ });
324
+ }
325
+
326
+ function reportFresh(ctx: VaultProviderContext) {
327
+ const at = new Date(now()).toISOString();
328
+ ctx.onFreshness?.({
329
+ state: "current",
330
+ lastCheckedAt: at,
331
+ sourceSyncedAt: at,
332
+ source: "live",
333
+ errorCode: null,
334
+ retryAt: null,
335
+ });
336
+ }
337
+
338
+ function incomplete(): never {
339
+ throw new VaultProviderError(
340
+ "provider_inventory_incomplete",
341
+ "Keeper returned records Opengeni could not decrypt; the listing is incomplete",
342
+ );
343
+ }
344
+
345
+ /** All records visible to the application, cached per attempt. */
346
+ function allRecords(ctx: VaultProviderContext): Promise<KeeperRecord[]> {
347
+ const cache = cacheFor(attemptListCaches, ctx.budget);
348
+ const key = ctx.cacheEpoch ?? "";
349
+ const cached = cache.get(key);
350
+ if (cached && !ctx.forceRefresh && now() - cached.at < valueCacheMs) return cached.records;
351
+ const records = operation(
352
+ ctx,
353
+ "list",
354
+ "list records",
355
+ async ({ sdk, options: sdkOptions, console }) => {
356
+ const secrets = await sdk.getSecrets2(sdkOptions);
357
+ if (console.skippedSomething) incomplete();
358
+ return secrets.records;
359
+ },
360
+ );
361
+ const entry = { at: now(), records };
362
+ cache.set(key, entry);
363
+ records.catch(() => {
364
+ if (cache.get(key) === entry) cache.delete(key);
365
+ });
366
+ return records;
367
+ }
368
+
369
+ async function fetchRecord(
370
+ keeper: KeeperOperation,
371
+ externalVaultId: string,
372
+ externalItemId: string,
373
+ ): Promise<KeeperRecord> {
374
+ const secrets = await keeper.sdk.getSecrets2(keeper.options, {
375
+ recordsFilter: [externalItemId],
376
+ });
377
+ const record = secrets.records.find((candidate) => candidate.recordUid === externalItemId);
378
+ if (!record) {
379
+ if (keeper.console.skippedSomething) {
380
+ throw new VaultProviderError(
381
+ "provider_unavailable",
382
+ "Opengeni could not decrypt this Keeper record",
383
+ );
384
+ }
385
+ throw new VaultProviderError("provider_not_found", "No such Keeper record");
386
+ }
387
+ // A record is addressed through its shared folder; never read across folders.
388
+ if (record.folderUid !== externalVaultId) {
389
+ throw new VaultProviderError(
390
+ "provider_not_found",
391
+ "No such Keeper record in this shared folder",
392
+ );
393
+ }
394
+ return record;
395
+ }
396
+
397
+ function materialFor(mapped: MappedKeeperRecord, field: MappedKeeperField): SecretMaterial {
398
+ if (field.kind === "totp") {
399
+ const params = totpParamsFor(field.value);
400
+ if (!params) {
401
+ throw new VaultProviderError(
402
+ "provider_unavailable",
403
+ `The one-time code in Keeper record "${mapped.meta.name}" is not a valid TOTP seed`,
404
+ );
405
+ }
406
+ return { type: "totp", params };
407
+ }
408
+ return { type: "text", value: field.value };
409
+ }
410
+
411
+ async function readFile(keeper: KeeperOperation, field: MappedKeeperField, signal?: AbortSignal) {
412
+ if (field.source.kind !== "file") throw new Error("not a file field");
413
+ const file = field.source.file;
414
+ const limit = VAULT_LIMITS.fileValueMaxBytes;
415
+ const size = fileSize(file);
416
+ if (size === null || size > limit) {
417
+ throw new VaultProviderError(
418
+ "provider_unavailable",
419
+ `"${field.name}" is larger than Opengeni reads from Keeper`,
420
+ );
421
+ }
422
+ if (!file.url)
423
+ throw new VaultProviderError("provider_unavailable", "Keeper returned no download URL");
424
+ const encrypted = await fetchFile(file.url, {
425
+ ...(signal ? { signal } : {}),
426
+ // AES-GCM adds a 12-byte nonce and a 16-byte tag.
427
+ maxBytes: limit + 28,
428
+ timeoutMs: options.requestTimeoutMs ?? KEEPER_REQUEST_TIMEOUT_MS,
429
+ });
430
+ // The file key was unwrapped into this instance's cache when the record was fetched.
431
+ const plain = await keeper.sdk.platform.decrypt(encrypted, file.fileUid);
432
+ if (plain.byteLength > limit) {
433
+ throw new VaultProviderError(
434
+ "provider_unavailable",
435
+ `"${field.name}" is larger than Opengeni reads from Keeper`,
436
+ );
437
+ }
438
+ return new Uint8Array(plain);
439
+ }
440
+
441
+ const provider: KeeperVaultProvider = {
442
+ id: "keeper",
443
+
444
+ capabilities(): VaultProviderCapabilities {
445
+ return {
446
+ write: true,
447
+ createVault: false,
448
+ totp: true,
449
+ // Legacy flag means file writes; reads are declared in operations.readFiles.
450
+ files: false,
451
+ groupGrants: false,
452
+ passkeys: false,
453
+ operations: {
454
+ ...KEEPER_OPERATIONS,
455
+ createItemTypes: [...KEEPER_OPERATIONS.createItemTypes],
456
+ },
457
+ };
458
+ },
459
+
460
+ async validate(ctx) {
461
+ const name = await operation(
462
+ ctx,
463
+ "list",
464
+ "validate",
465
+ async ({ sdk, options: sdkOptions }) => {
466
+ const secrets = await sdk.getSecrets2(sdkOptions);
467
+ const title = secrets.appData?.title;
468
+ return typeof title === "string" && title.trim() ? title.trim().slice(0, 200) : null;
469
+ },
470
+ );
471
+ return { accountName: name };
472
+ },
473
+
474
+ async listVaults(ctx): Promise<ProviderVault[]> {
475
+ const folders = await operation(
476
+ ctx,
477
+ "list",
478
+ "list shared folders",
479
+ async ({ sdk, options: sdkOptions, console }) => {
480
+ const result = await sdk.getFolders(sdkOptions);
481
+ if (console.skippedSomething) incomplete();
482
+ return result;
483
+ },
484
+ );
485
+ reportFresh(ctx);
486
+ return folders
487
+ .filter((folder) => !folder.parentUid)
488
+ .map((folder) => ({
489
+ externalVaultId: folder.folderUid,
490
+ name: safeSecretName(folder.name ?? "", folder.folderUid),
491
+ contentVersion: null,
492
+ itemCount: null,
493
+ // KSM does not tell an application whether a shared folder is editable.
494
+ writeAccess: "unknown" as const,
495
+ }));
496
+ },
497
+
498
+ async listItems(ctx, externalVaultId): Promise<ProviderItemMeta[]> {
499
+ assertUids(externalVaultId);
500
+ const records = await allRecords(ctx);
501
+ const metas = records
502
+ .filter((record) => record.folderUid === externalVaultId)
503
+ .map((record) => mapRecord(record).meta);
504
+ if (metas.length > VAULT_LIMITS.itemsPerVault) {
505
+ throw new VaultProviderError(
506
+ "provider_inventory_incomplete",
507
+ "The Keeper shared folder has more records than Opengeni mirrors at once",
508
+ );
509
+ }
510
+ for (const meta of metas) assertMirrorable(meta);
511
+ reportFresh(ctx);
512
+ return metas;
513
+ },
514
+
515
+ async readFields(ctx, externalVaultId, externalItemId, externalFieldIds) {
516
+ assertUids(externalVaultId, externalItemId);
517
+ const wantsFile = externalFieldIds.some((id) => id.startsWith("@file:"));
518
+ const cache = cacheFor(attemptRecordCaches, ctx.budget);
519
+ const key = `${ctx.cacheEpoch ?? ""}|${externalVaultId}/${externalItemId}`;
520
+ const cached = cache.get(key);
521
+ if (!wantsFile && cached && !ctx.forceRefresh && now() - cached.at < valueCacheMs) {
522
+ const mapped = await cached.record;
523
+ return externalFieldIds.map((externalFieldId) => {
524
+ const field = mapped.fields.find(
525
+ (candidate) => candidate.externalFieldId === externalFieldId,
526
+ );
527
+ if (!field)
528
+ throw new VaultProviderError(
529
+ "provider_not_found",
530
+ `Keeper record has no field ${externalFieldId}`,
531
+ );
532
+ return { externalFieldId, material: materialFor(mapped, field) };
533
+ });
534
+ }
535
+ let resolveMapped!: (value: MappedKeeperRecord) => void;
536
+ let rejectMapped!: (error: unknown) => void;
537
+ const mappedPromise = new Promise<MappedKeeperRecord>((resolve, reject) => {
538
+ resolveMapped = resolve;
539
+ rejectMapped = reject;
540
+ });
541
+ mappedPromise.catch(() => {});
542
+ const entry = { at: now(), record: mappedPromise };
543
+ cache.set(key, entry);
544
+ try {
545
+ return await operation(ctx, "read", "read record", async (keeper) => {
546
+ const record = await fetchRecord(keeper, externalVaultId, externalItemId);
547
+ const mapped = mapRecord(record);
548
+ // Files are read on demand and never cached.
549
+ resolveMapped({
550
+ ...mapped,
551
+ fields: mapped.fields.filter((field) => field.kind !== "file"),
552
+ });
553
+ const values: ProviderFieldValue[] = [];
554
+ for (const externalFieldId of externalFieldIds) {
555
+ const field = mapped.fields.find(
556
+ (candidate) => candidate.externalFieldId === externalFieldId,
557
+ );
558
+ if (!field) {
559
+ throw new VaultProviderError(
560
+ "provider_not_found",
561
+ `Keeper record "${mapped.meta.name}" has no field ${externalFieldId}`,
562
+ );
563
+ }
564
+ values.push({
565
+ externalFieldId,
566
+ material:
567
+ field.kind === "file"
568
+ ? { type: "bytes", value: await readFile(keeper, field, ctx.signal) }
569
+ : materialFor(mapped, field),
570
+ });
571
+ }
572
+ return values;
573
+ });
574
+ } catch (error) {
575
+ rejectMapped(error);
576
+ if (cache.get(key) === entry) cache.delete(key);
577
+ throw error;
578
+ }
579
+ },
580
+
581
+ async writeItem(ctx, externalVaultId, item): Promise<ProviderItemMeta> {
582
+ assertUids(externalVaultId);
583
+ const meta = await writeRecord(ctx, externalVaultId, item);
584
+ // Drop this attempt's cached listings and values; the record changed.
585
+ attemptRecordCaches.get(ctx.budget)?.clear();
586
+ attemptListCaches.get(ctx.budget)?.clear();
587
+ return meta;
588
+ },
589
+
590
+ async archiveItem() {
591
+ throw new VaultProviderError(
592
+ "provider_unavailable",
593
+ "Keeper Secrets Manager applications cannot archive or trash records; remove it in Keeper",
594
+ );
595
+ },
596
+
597
+ currentConfiguration: () => serializeKeeperConfiguration(config),
598
+ close: async () => {
599
+ await queue.catch(() => {});
600
+ },
601
+ };
602
+
603
+ function writeRecord(
604
+ ctx: VaultProviderContext,
605
+ externalVaultId: string,
606
+ item: Parameters<VaultProvider["writeItem"]>[2],
607
+ ): Promise<ProviderItemMeta> {
608
+ if (item.externalItemId === undefined) {
609
+ const data = buildCreateData(item);
610
+ return operation(ctx, "write", "create record", async (keeper) => {
611
+ const folders = await keeper.sdk.getFolders(keeper.options);
612
+ const folder = folders.find((candidate) => candidate.folderUid === externalVaultId);
613
+ if (!folder || folder.parentUid) {
614
+ throw new VaultProviderError("provider_not_found", "No such Keeper shared folder");
615
+ }
616
+ keeper.tracker.mutating = true;
617
+ const recordUid = await keeper.sdk.createSecret2(
618
+ keeper.options,
619
+ { folderUid: externalVaultId },
620
+ data,
621
+ );
622
+ keeper.tracker.mutating = false;
623
+ try {
624
+ return mapRecord(await fetchRecord(keeper, externalVaultId, recordUid)).meta;
625
+ } catch {
626
+ const meta = mapRecord({
627
+ recordUid,
628
+ folderUid: externalVaultId,
629
+ data,
630
+ revision: Number.NaN,
631
+ }).meta;
632
+ return { ...meta, version: null };
633
+ }
634
+ });
635
+ }
636
+ const recordUid = item.externalItemId;
637
+ assertUids(recordUid);
638
+ return operation(ctx, "write", "update record", async (keeper) => {
639
+ const record = await fetchRecord(keeper, externalVaultId, recordUid);
640
+ if (
641
+ item.expectedVersion !== undefined &&
642
+ item.expectedVersion !== null &&
643
+ String(record.revision) !== item.expectedVersion
644
+ ) {
645
+ throw new VaultProviderError(
646
+ "provider_conflict",
647
+ "The Keeper record changed since it was read; read it again before writing",
648
+ );
649
+ }
650
+ applyPatch(record, item);
651
+ keeper.tracker.mutating = true;
652
+ await keeper.sdk.updateSecret2(keeper.options, record);
653
+ keeper.tracker.mutating = false;
654
+ try {
655
+ return mapRecord(await fetchRecord(keeper, externalVaultId, recordUid)).meta;
656
+ } catch {
657
+ return { ...mapRecord(record).meta, version: null };
658
+ }
659
+ });
660
+ }
661
+
662
+ return restrictToOperations(provider, options.operations, FILE_FIELD_PREFIX);
663
+ }
664
+
665
+ /**
666
+ * Opengeni mirrors at most `fieldsPerItem` fields and `websitesPerItem`
667
+ * websites of up to `websiteMaxChars` each. A larger record would be cut down
668
+ * and then look complete, so the listing is reported incomplete instead.
669
+ */
670
+ function assertMirrorable(meta: ProviderItemMeta): void {
671
+ if (
672
+ meta.fields.length > VAULT_LIMITS.fieldsPerItem ||
673
+ meta.websites.length > VAULT_LIMITS.websitesPerItem ||
674
+ meta.websites.some((website) => website.length > VAULT_LIMITS.websiteMaxChars)
675
+ ) {
676
+ throw new VaultProviderError(
677
+ "provider_inventory_incomplete",
678
+ `A Keeper record has more than ${VAULT_LIMITS.fieldsPerItem} fields or ${VAULT_LIMITS.websitesPerItem} websites (or a website over ${VAULT_LIMITS.websiteMaxChars} characters), more than Opengeni mirrors`,
679
+ );
680
+ }
681
+ }