@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.
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import {
2
3
  generatePassword,
3
4
  isValidTotpInput,
@@ -5,6 +6,7 @@ import {
5
6
  type VaultFieldInput,
6
7
  type VaultFieldKind,
7
8
  type VaultItemType,
9
+ type VaultProviderOperations,
8
10
  } from "@opengeni/contracts";
9
11
  import {
10
12
  getProviderVaultItemRef,
@@ -16,19 +18,36 @@ import {
16
18
  } from "@opengeni/db";
17
19
  import {
18
20
  isVaultProviderError,
21
+ providerOperations,
22
+ readProviderItemInventory,
19
23
  type ProviderCallBudget,
20
24
  type ProviderWriteField,
21
25
  type VaultProvider,
26
+ type VaultProviderContext,
22
27
  type VaultProviderRegistry,
23
28
  } from "@opengeni/vaults";
29
+ import {
30
+ intersectProviderOperations,
31
+ providerOperationsForMode,
32
+ storedProviderOperations,
33
+ withoutProviderWrites,
34
+ } from "./mapping";
24
35
  import { vaultErrorCodeForProvider } from "./provider-registry";
25
- import { syncLinkedProviderVault } from "./provider-sync";
36
+ import { syncLinkedProviderVault, type ProviderVaultSyncResult } from "./provider-sync";
26
37
 
27
38
  /**
28
- * Edits of items in a read-write connected vault (OP-3): the change is written
29
- * to the provider (1Password `items.get` → patch → `items.put`, with the
30
- * mirrored version as the expected version), then the metadata mirror is
31
- * refreshed. Values never touch Opengeni storage.
39
+ * Edits of items in a read-write connected vault (OP-3, external vaults §10):
40
+ * the change is written to the provider (with the mirrored version as the
41
+ * expected version), then the metadata mirror is refreshed. Values never
42
+ * touch Opengeni storage.
43
+ *
44
+ * Every write is checked against the vault's effective operations first (the
45
+ * provider's current capabilities, narrowed by what it last reported for the
46
+ * vault and by the link mode), before any reference is resolved or value
47
+ * generated. A provider mutation is never retried: when its outcome is
48
+ * unknown the caller is told to check the vault, and a write that succeeded
49
+ * but could not be listed yet is reported as saved, never as a failure that
50
+ * invites saving it again.
32
51
  */
33
52
 
34
53
  /** One field change; the REST `VaultFieldInput` and the agent `secret_save` field both fit. */
@@ -60,6 +79,33 @@ export interface ProviderWriteDeps {
60
79
  budget?: ProviderCallBudget;
61
80
  }
62
81
 
82
+ /** An operation a connected vault may not allow; reported in `vault_read_only` details. */
83
+ export type ProviderWriteOperation =
84
+ | "createItem"
85
+ | "updateItem"
86
+ | "removeField"
87
+ | "createTotp"
88
+ | "updateTotp"
89
+ | "archiveItem"
90
+ | "deleteItem"
91
+ | "restoreItem"
92
+ | "writeFiles"
93
+ | "restoreFieldHistory";
94
+
95
+ /** The result of a provider write once the mirror was refreshed. */
96
+ export type ProviderWriteOutcome =
97
+ | { status: "listed"; itemId: string }
98
+ | {
99
+ /**
100
+ * The provider confirmed the write, but the refreshed mirror does not
101
+ * show it yet (for example the refresh was rate limited). It must not be
102
+ * saved again; it appears after the next successful sync.
103
+ */
104
+ status: "saved_not_listed";
105
+ vaultId: string;
106
+ sync: ProviderVaultSyncResult["status"] | "error";
107
+ };
108
+
63
109
  const TEXT_FIELD_NAMES = ["username", "user", "email", "login", "account"];
64
110
 
65
111
  function defaultKind(name: string): VaultFieldKind {
@@ -79,14 +125,76 @@ export function assertProviderVaultWritable(vault: VaultRow): void {
79
125
  }
80
126
  }
81
127
 
82
- async function providerFieldWrites(
128
+ const NO_PROVIDER_OPERATIONS: VaultProviderOperations = withoutProviderWrites({
129
+ createItemTypes: [],
130
+ updateItem: false,
131
+ removeField: false,
132
+ archiveItem: false,
133
+ archiveKind: "none",
134
+ deleteItem: false,
135
+ restoreItem: false,
136
+ readFiles: false,
137
+ writeFiles: false,
138
+ createTotp: false,
139
+ updateTotp: false,
140
+ createVault: false,
141
+ restoreFieldHistory: false,
142
+ });
143
+
144
+ /**
145
+ * What a connected vault allows now: the provider's current capabilities,
146
+ * narrowed by the operations it last reported for this vault (a read-only
147
+ * share, for example) and by the link mode. Permanent deletion is never
148
+ * allowed.
149
+ */
150
+ export function effectiveProviderOperations(
151
+ vault: VaultRow,
152
+ provider: Pick<VaultProvider, "capabilities"> | null,
153
+ ): VaultProviderOperations {
154
+ if (vault.source === "opengeni") return NO_PROVIDER_OPERATIONS;
155
+ const stored = storedProviderOperations(vault);
156
+ const live = provider ? providerOperations(provider.capabilities()) : null;
157
+ const operations = live && stored ? intersectProviderOperations(live, stored) : (stored ?? live);
158
+ return operations ? providerOperationsForMode(operations, vault.mode) : NO_PROVIDER_OPERATIONS;
159
+ }
160
+
161
+ function refuse(
162
+ vault: VaultRow,
163
+ operation: ProviderWriteOperation,
164
+ message: string,
165
+ details: Record<string, unknown> = {},
166
+ ): never {
167
+ throw new VaultError("vault_read_only", message, { vaultId: vault.id, operation, ...details });
168
+ }
169
+
170
+ /** Refuses a provider operation Opengeni never performs, with the reason. */
171
+ export function refuseProviderOperation(
172
+ vault: VaultRow,
173
+ operation: Extract<ProviderWriteOperation, "deleteItem" | "restoreItem" | "restoreFieldHistory">,
174
+ ): never {
175
+ const messages = {
176
+ deleteItem: `Opengeni never permanently deletes items in ${vault.name}. Archive the item, or delete it in the provider itself.`,
177
+ restoreItem: `Items archived in ${vault.name} are restored in the provider itself; the restored item appears after the next sync.`,
178
+ restoreFieldHistory: `${vault.name} keeps field history in the provider; restore an earlier value there.`,
179
+ } as const;
180
+ return refuse(vault, operation, messages[operation]);
181
+ }
182
+
183
+ type PlannedField = {
184
+ input: ProviderFieldChange;
185
+ base: ProviderWriteField;
186
+ kind: VaultFieldKind;
187
+ };
188
+
189
+ /** Checks every field change against the vault's operations, before any value exists. */
190
+ function planFieldWrites(
191
+ vault: VaultRow,
192
+ operations: VaultProviderOperations,
193
+ creating: boolean,
83
194
  fields: readonly ProviderFieldChange[],
84
195
  existing: readonly VaultFieldRow[],
85
- resolveReference: (raw: string) => Promise<string>,
86
- generated: string[],
87
- ): Promise<ProviderWriteField[]> {
88
- const writes: ProviderWriteField[] = [];
89
- for (const input of fields) {
196
+ ): PlannedField[] {
197
+ return fields.map((input) => {
90
198
  const current = existing.find(
91
199
  (field) =>
92
200
  (input.fieldId !== undefined && field.id === input.fieldId) ||
@@ -98,24 +206,56 @@ async function providerFieldWrites(
98
206
  name: input.name,
99
207
  kind,
100
208
  };
101
- if (input.remove) {
102
- writes.push({ ...base, remove: true });
103
- continue;
104
- }
105
209
  if (input.restoreVersion !== undefined) {
106
- throw new VaultError(
107
- "provider_unavailable",
108
- "connected vaults keep their history in the provider; restore it there",
109
- { field: input.name },
110
- );
210
+ return refuseProviderOperation(vault, "restoreFieldHistory");
111
211
  }
112
- if (input.fileBase64 !== undefined || kind === "file") {
212
+ if (input.fileBase64 !== undefined || kind === "file" || current?.kind === "file") {
113
213
  throw new VaultError(
114
214
  "provider_unavailable",
115
215
  "files in connected vaults are read-only in Opengeni",
116
- { field: input.name },
216
+ { field: input.name, vaultId: vault.id, operation: "writeFiles" },
117
217
  );
118
218
  }
219
+ const totp = kind === "totp" || current?.kind === "totp";
220
+ if (input.remove) {
221
+ if (!operations.removeField) {
222
+ refuse(vault, "removeField", `${vault.name} does not allow removing fields here`, {
223
+ field: input.name,
224
+ });
225
+ }
226
+ if (totp && !operations.updateTotp) {
227
+ refuse(vault, "updateTotp", `${vault.name} does not allow changing one-time passwords`, {
228
+ field: input.name,
229
+ });
230
+ }
231
+ return { input, base, kind };
232
+ }
233
+ if (totp) {
234
+ const allowed = creating ? operations.createTotp : operations.updateTotp;
235
+ if (!allowed) {
236
+ refuse(
237
+ vault,
238
+ creating ? "createTotp" : "updateTotp",
239
+ `${vault.name} does not allow ${creating ? "saving" : "changing"} one-time passwords`,
240
+ { field: input.name },
241
+ );
242
+ }
243
+ }
244
+ return { input, base, kind };
245
+ });
246
+ }
247
+
248
+ async function providerFieldValues(
249
+ planned: readonly PlannedField[],
250
+ resolveReference: (raw: string) => Promise<string>,
251
+ generated: string[],
252
+ ): Promise<ProviderWriteField[]> {
253
+ const writes: ProviderWriteField[] = [];
254
+ for (const { input, base, kind } of planned) {
255
+ if (input.remove) {
256
+ writes.push({ ...base, remove: true });
257
+ continue;
258
+ }
119
259
  let value: string;
120
260
  if (input.generate !== undefined) {
121
261
  value = generatePassword(input.generate);
@@ -147,6 +287,23 @@ function providerFailure(error: unknown): never {
147
287
  throw error;
148
288
  }
149
289
 
290
+ /**
291
+ * A provider mutation that failed. Only a provider's own definitive answer is
292
+ * a failure; anything else (a timeout, a dropped connection, an adapter
293
+ * defect, or a provider that says so) leaves the outcome unknown, and the
294
+ * mutation is not retried.
295
+ */
296
+ function mutationFailure(vault: VaultRow, error: unknown, what: string): never {
297
+ if (isVaultProviderError(error) && error.code !== "provider_outcome_unknown") {
298
+ return providerFailure(error);
299
+ }
300
+ throw new VaultError(
301
+ "provider_outcome_unknown",
302
+ `${vault.name} did not confirm ${what}. It may have been applied: check the vault before trying again.`,
303
+ { vaultId: vault.id },
304
+ );
305
+ }
306
+
150
307
  async function providerFor(deps: ProviderWriteDeps, vault: VaultRow): Promise<VaultProvider> {
151
308
  try {
152
309
  return await deps.providers.forVault({
@@ -158,39 +315,62 @@ async function providerFor(deps: ProviderWriteDeps, vault: VaultRow): Promise<Va
158
315
  }
159
316
  }
160
317
 
318
+ function mutationContext(deps: ProviderWriteDeps, vault: VaultRow): VaultProviderContext {
319
+ return {
320
+ connectionId: vault.providerConnectionId,
321
+ budget: deps.budget ?? { take() {} },
322
+ cacheEpoch: vault.providerCacheEpoch ?? null,
323
+ // Read-before-write must see the provider's current item, never a cached copy.
324
+ forceRefresh: true,
325
+ idempotencyKey: randomUUID(),
326
+ };
327
+ }
328
+
329
+ /** Refresh after a confirmed write; never turns the confirmed write into a failure. */
330
+ async function refreshAfterWrite(
331
+ deps: ProviderWriteDeps,
332
+ vault: VaultRow,
333
+ ): Promise<ProviderVaultSyncResult["status"] | "error"> {
334
+ try {
335
+ const result = await syncLinkedProviderVault({
336
+ db: deps.db,
337
+ context: deps.context,
338
+ vaultId: vault.id,
339
+ providers: deps.providers,
340
+ force: true,
341
+ ...(deps.budget ? { budget: deps.budget } : {}),
342
+ });
343
+ return result.status;
344
+ } catch {
345
+ return "error";
346
+ }
347
+ }
348
+
161
349
  async function refreshAndFind(
162
350
  deps: ProviderWriteDeps,
163
351
  vault: VaultRow,
164
352
  providerItemRef: string,
165
- ): Promise<string> {
166
- await syncLinkedProviderVault({
167
- db: deps.db,
168
- context: deps.context,
169
- vaultId: vault.id,
170
- providers: deps.providers,
171
- force: true,
172
- ...(deps.budget ? { budget: deps.budget } : {}),
173
- });
353
+ ): Promise<ProviderWriteOutcome> {
354
+ const sync = await refreshAfterWrite(deps, vault);
174
355
  try {
175
356
  const ref = await getProviderVaultItemRef(deps.db, deps.context, {
176
357
  vaultId: vault.id,
177
358
  providerItemRef,
178
359
  });
179
- return ref.itemId;
360
+ return { status: "listed", itemId: ref.itemId };
180
361
  } catch (error) {
181
362
  if (error instanceof VaultError && error.code === "vault_item_not_found") {
182
- throw new VaultError(
183
- "provider_unavailable",
184
- "saved in the provider; Opengeni will show it after the next sync",
185
- { vaultId: vault.id },
186
- );
363
+ return { status: "saved_not_listed", vaultId: vault.id, sync };
187
364
  }
188
365
  throw error;
189
366
  }
190
367
  }
191
368
 
192
- /** Create (existing = null) or update an item in a connected read-write vault; returns its item id. */
193
- export async function writeProviderVaultItem(
369
+ /**
370
+ * Create (existing = null) or update an item in a connected read-write vault
371
+ * and report whether the refreshed mirror lists it.
372
+ */
373
+ export async function writeProviderVaultItemOutcome(
194
374
  deps: ProviderWriteDeps,
195
375
  input: {
196
376
  vault: VaultRow;
@@ -206,12 +386,29 @@ export async function writeProviderVaultItem(
206
386
  /** Receives the names of fields whose values were generated. */
207
387
  generated?: string[];
208
388
  },
209
- ): Promise<string> {
389
+ ): Promise<ProviderWriteOutcome> {
210
390
  const { vault, existing, change } = input;
211
391
  assertProviderVaultWritable(vault);
212
- if ((change.type ?? existing?.type) === "passkey") {
392
+ const type = change.type ?? existing?.type ?? "login";
393
+ if (type === "passkey") {
213
394
  throw new VaultError("vault_permission_denied", "passkeys live only in Opengeni vaults");
214
395
  }
396
+ const provider = await providerFor(deps, vault);
397
+ const operations = effectiveProviderOperations(vault, provider);
398
+ if (existing) {
399
+ if (!operations.updateItem) {
400
+ refuse(vault, "updateItem", `${vault.name} does not allow changing items here`);
401
+ }
402
+ } else if (!operations.createItemTypes.includes(type)) {
403
+ refuse(
404
+ vault,
405
+ "createItem",
406
+ operations.createItemTypes.length === 0
407
+ ? `${vault.name} does not allow creating items here`
408
+ : `${vault.name} cannot hold ${type} items; it accepts ${operations.createItemTypes.join(", ")}`,
409
+ { itemType: type, allowedItemTypes: operations.createItemTypes },
410
+ );
411
+ }
215
412
  let externalItemId: string | undefined;
216
413
  let expectedVersion: string | null | undefined;
217
414
  if (existing) {
@@ -231,58 +428,205 @@ export async function writeProviderVaultItem(
231
428
  externalItemId = ref.providerItemRef;
232
429
  expectedVersion = ref.providerVersion;
233
430
  }
234
- const fields = await providerFieldWrites(
431
+ const planned = planFieldWrites(
432
+ vault,
433
+ operations,
434
+ existing === null,
235
435
  change.fields ?? [],
236
436
  existing?.fields ?? [],
237
- deps.resolveReference,
238
- input.generated ?? [],
239
437
  );
240
- const provider = await providerFor(deps, vault);
438
+ const fields = await providerFieldValues(planned, deps.resolveReference, input.generated ?? []);
439
+ const name = change.name ?? existing?.name ?? "";
241
440
  let written;
242
441
  try {
243
- written = await provider.writeItem(
244
- { connectionId: vault.providerConnectionId, budget: deps.budget ?? { take() {} } },
245
- vault.providerVaultRef ?? "",
442
+ written = await provider.writeItem(mutationContext(deps, vault), vault.providerVaultRef ?? "", {
443
+ ...(externalItemId ? { externalItemId, expectedVersion: expectedVersion ?? null } : {}),
444
+ name,
445
+ type,
446
+ websites: change.websites ?? existing?.websites ?? [],
447
+ fields,
448
+ });
449
+ } catch (error) {
450
+ return mutationFailure(vault, error, existing ? `the change to ${name}` : `saving ${name}`);
451
+ }
452
+ return await refreshAndFind(deps, vault, written.externalItemId);
453
+ }
454
+
455
+ /** Thrown when a confirmed provider write is not listed yet: it must not be saved again. */
456
+ export function savedNotListedError(
457
+ vault: VaultRow,
458
+ itemName: string,
459
+ outcome: Extract<ProviderWriteOutcome, { status: "saved_not_listed" }>,
460
+ ): VaultError {
461
+ return new VaultError(
462
+ "provider_inventory_incomplete",
463
+ `Saved ${itemName} in ${vault.name}, but Opengeni could not list it yet. Do not save it again: it appears after the next successful sync.`,
464
+ { saved: true, vaultId: outcome.vaultId, itemName, sync: outcome.sync },
465
+ );
466
+ }
467
+
468
+ /**
469
+ * Create (existing = null) or update an item in a connected read-write vault;
470
+ * returns its item id. A confirmed write that is not listed yet throws
471
+ * `provider_inventory_incomplete` with `details.saved = true`.
472
+ */
473
+ export async function writeProviderVaultItem(
474
+ deps: ProviderWriteDeps,
475
+ input: Parameters<typeof writeProviderVaultItemOutcome>[1],
476
+ ): Promise<string> {
477
+ const outcome = await writeProviderVaultItemOutcome(deps, input);
478
+ if (outcome.status === "listed") return outcome.itemId;
479
+ throw savedNotListedError(
480
+ input.vault,
481
+ input.change.name ?? input.existing?.name ?? "the item",
482
+ outcome,
483
+ );
484
+ }
485
+
486
+ /** Whether an item can leave this vault recoverably (moves need it before anything is written). */
487
+ export function providerArchiveKind(
488
+ vault: VaultRow,
489
+ provider: Pick<VaultProvider, "capabilities"> | null,
490
+ ): "archive" | "trash" | null {
491
+ const operations = effectiveProviderOperations(vault, provider);
492
+ if (!operations.archiveItem) return null;
493
+ return operations.archiveKind === "archive" || operations.archiveKind === "trash"
494
+ ? operations.archiveKind
495
+ : null;
496
+ }
497
+
498
+ /**
499
+ * Refuses, before any value is read or written, an item a connected vault
500
+ * cannot receive as a new item (its type, a one-time password, or a file).
501
+ */
502
+ export async function assertProviderVaultAccepts(
503
+ deps: ProviderWriteDeps,
504
+ vault: VaultRow,
505
+ item: { type: VaultItemType; fields: ReadonlyArray<{ kind: VaultFieldKind }> },
506
+ ): Promise<void> {
507
+ assertProviderVaultWritable(vault);
508
+ if (item.type === "passkey") {
509
+ throw new VaultError("vault_permission_denied", "passkeys live only in Opengeni vaults");
510
+ }
511
+ if (item.fields.some((field) => field.kind === "file")) {
512
+ throw new VaultError(
513
+ "provider_unavailable",
514
+ "files in connected vaults are read-only in Opengeni; copy the item into an Opengeni vault instead",
515
+ { vaultId: vault.id, operation: "writeFiles" },
516
+ );
517
+ }
518
+ const operations = effectiveProviderOperations(vault, await providerFor(deps, vault));
519
+ if (!operations.createItemTypes.includes(item.type)) {
520
+ refuse(
521
+ vault,
522
+ "createItem",
523
+ operations.createItemTypes.length === 0
524
+ ? `${vault.name} does not allow creating items here`
525
+ : `${vault.name} cannot hold ${item.type} items; it accepts ${operations.createItemTypes.join(", ")}`,
526
+ { itemType: item.type, allowedItemTypes: operations.createItemTypes },
527
+ );
528
+ }
529
+ if (item.fields.some((field) => field.kind === "totp") && !operations.createTotp) {
530
+ refuse(vault, "createTotp", `${vault.name} does not allow saving one-time passwords`);
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Refuses a move out of a connected vault that cannot archive the original
536
+ * recoverably (copy instead); returns where the original will go.
537
+ */
538
+ export async function assertProviderVaultReleases(
539
+ deps: ProviderWriteDeps,
540
+ vault: VaultRow,
541
+ ): Promise<"archive" | "trash"> {
542
+ assertProviderVaultWritable(vault);
543
+ const archiveKind = providerArchiveKind(vault, await providerFor(deps, vault));
544
+ if (archiveKind === null) {
545
+ refuse(
546
+ vault,
547
+ "archiveItem",
548
+ `${vault.name} cannot archive the original recoverably, so the item cannot be moved out of it; copy it instead`,
549
+ );
550
+ }
551
+ return archiveKind;
552
+ }
553
+
554
+ /** What a fresh provider listing says about one mirrored item. */
555
+ export type ProviderItemObservation =
556
+ | { listed: true; version: string | null }
557
+ | { listed: false; consistent: boolean };
558
+
559
+ /**
560
+ * The provider's current version of one item, from a fresh listing of its
561
+ * vault (provider caches bypassed). `version` is null when the provider does
562
+ * not version items. A provider failure is thrown as a `VaultError`.
563
+ *
564
+ * The provider contract has no conditional (compare-and-archive) operation,
565
+ * so this only narrows the window between reading an item and archiving it;
566
+ * it cannot close it.
567
+ */
568
+ export async function observeProviderItemVersion(
569
+ deps: ProviderWriteDeps,
570
+ input: { vault: VaultRow; providerItemRef: string },
571
+ ): Promise<ProviderItemObservation> {
572
+ const { vault } = input;
573
+ const provider = await providerFor(deps, vault);
574
+ try {
575
+ const inventory = await readProviderItemInventory(
576
+ provider,
246
577
  {
247
- ...(externalItemId ? { externalItemId, expectedVersion: expectedVersion ?? null } : {}),
248
- name: change.name ?? existing?.name ?? "",
249
- type: change.type ?? existing?.type ?? "login",
250
- websites: change.websites ?? existing?.websites ?? [],
251
- fields,
578
+ connectionId: vault.providerConnectionId,
579
+ budget: deps.budget ?? { take() {} },
580
+ cacheEpoch: vault.providerCacheEpoch ?? null,
581
+ forceRefresh: true,
252
582
  },
583
+ vault.providerVaultRef ?? "",
253
584
  );
585
+ const item = inventory.items.find((meta) => meta.externalItemId === input.providerItemRef);
586
+ if (!item) return { listed: false, consistent: inventory.consistent };
587
+ return {
588
+ listed: true,
589
+ version: typeof item.version === "string" && item.version.length > 0 ? item.version : null,
590
+ };
254
591
  } catch (error) {
255
- return providerFailure(error);
592
+ if (isVaultProviderError(error)) {
593
+ throw new VaultError(vaultErrorCodeForProvider(error.code, "vault"), error.message, {
594
+ vaultId: vault.id,
595
+ ...(error.retryAfterMs ? { retryAfterMs: error.retryAfterMs } : {}),
596
+ });
597
+ }
598
+ throw error;
256
599
  }
257
- return await refreshAndFind(deps, vault, written.externalItemId);
258
600
  }
259
601
 
260
- /** Archive an item in a connected read-write vault (the provider keeps it recoverable). */
602
+ /**
603
+ * Archive an item in a connected read-write vault. Only a recoverable archive
604
+ * (the provider's archive or trash) is ever used; permanent deletion is not.
605
+ */
261
606
  export async function archiveProviderVaultItem(
262
607
  deps: ProviderWriteDeps,
263
608
  input: { vault: VaultRow; itemId: string },
264
- ): Promise<void> {
265
- assertProviderVaultWritable(input.vault);
609
+ ): Promise<{ archiveKind: "archive" | "trash" }> {
610
+ const { vault } = input;
611
+ assertProviderVaultWritable(vault);
266
612
  const ref = await getProviderVaultItemRef(deps.db, deps.context, {
267
- vaultId: input.vault.id,
613
+ vaultId: vault.id,
268
614
  itemId: input.itemId,
269
615
  });
270
- const provider = await providerFor(deps, input.vault);
616
+ const provider = await providerFor(deps, vault);
617
+ const archiveKind = providerArchiveKind(vault, provider);
618
+ if (archiveKind === null) {
619
+ refuse(vault, "archiveItem", `${vault.name} does not allow archiving items here`);
620
+ }
271
621
  try {
272
622
  await provider.archiveItem(
273
- { connectionId: input.vault.providerConnectionId, budget: deps.budget ?? { take() {} } },
274
- input.vault.providerVaultRef ?? "",
623
+ mutationContext(deps, vault),
624
+ vault.providerVaultRef ?? "",
275
625
  ref.providerItemRef,
276
626
  );
277
627
  } catch (error) {
278
- providerFailure(error);
628
+ mutationFailure(vault, error, "archiving the item");
279
629
  }
280
- await syncLinkedProviderVault({
281
- db: deps.db,
282
- context: deps.context,
283
- vaultId: input.vault.id,
284
- providers: deps.providers,
285
- force: true,
286
- ...(deps.budget ? { budget: deps.budget } : {}),
287
- });
630
+ await refreshAfterWrite(deps, vault);
631
+ return { archiveKind };
288
632
  }