@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
|
@@ -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
|
|
29
|
-
* to the provider (
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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<
|
|
166
|
-
await
|
|
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
|
-
|
|
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
|
-
/**
|
|
193
|
-
|
|
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<
|
|
389
|
+
): Promise<ProviderWriteOutcome> {
|
|
210
390
|
const { vault, existing, change } = input;
|
|
211
391
|
assertProviderVaultWritable(vault);
|
|
212
|
-
|
|
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
|
|
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
|
|
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
|
-
{
|
|
245
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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<
|
|
265
|
-
|
|
609
|
+
): Promise<{ archiveKind: "archive" | "trash" }> {
|
|
610
|
+
const { vault } = input;
|
|
611
|
+
assertProviderVaultWritable(vault);
|
|
266
612
|
const ref = await getProviderVaultItemRef(deps.db, deps.context, {
|
|
267
|
-
vaultId:
|
|
613
|
+
vaultId: vault.id,
|
|
268
614
|
itemId: input.itemId,
|
|
269
615
|
});
|
|
270
|
-
const provider = await providerFor(deps,
|
|
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
|
-
|
|
274
|
-
|
|
623
|
+
mutationContext(deps, vault),
|
|
624
|
+
vault.providerVaultRef ?? "",
|
|
275
625
|
ref.providerItemRef,
|
|
276
626
|
);
|
|
277
627
|
} catch (error) {
|
|
278
|
-
|
|
628
|
+
mutationFailure(vault, error, "archiving the item");
|
|
279
629
|
}
|
|
280
|
-
await
|
|
281
|
-
|
|
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
|
}
|