@opengeni/vaults-protonpass 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,690 @@
1
+ import { lstatSync, readFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ type ProviderCallBudget,
5
+ type ProviderFieldValue,
6
+ type ProviderItemMeta,
7
+ type ProviderVault,
8
+ type ProviderWriteField,
9
+ type ProviderWriteItem,
10
+ type SecretMaterial,
11
+ VAULT_LIMITS,
12
+ type VaultProvider,
13
+ type VaultProviderCapabilities,
14
+ type VaultProviderContext,
15
+ VaultProviderError,
16
+ type VaultProviderOperations,
17
+ parseTotpInput,
18
+ } from "./contract";
19
+ import { FILE_FIELD_PREFIX, type MappedField, type MappedItem, mapItem } from "./mapping";
20
+ import { nameKey, safeSecretName, totpUri } from "./naming";
21
+ import { restrictToOperations } from "./operations";
22
+ import {
23
+ FullItemList,
24
+ ITEM_HAS_FILES,
25
+ type Item,
26
+ ItemDetails,
27
+ ItemSummaryList,
28
+ SHARE_PERMISSION,
29
+ ShareListOutput,
30
+ type ShareRole,
31
+ VaultListOutput,
32
+ } from "./schema";
33
+ import { type ProtonCliOptions, type ProtonCommand, ProtonCliSession, parseJson } from "./session";
34
+
35
+ export const PROTON_PASS_VALUE_CACHE_MS = VAULT_LIMITS.providerValueCacheMs;
36
+ /** Agent tokens cannot list with secrets, so each item is viewed; bound that fan-out. */
37
+ export const PROTON_PASS_MAX_ITEM_VIEWS_PER_LIST = 500;
38
+
39
+ /** Operations pass-cli 2.4.2 supports without secrets on argv (see README). */
40
+ export const PROTON_PASS_OPERATIONS: VaultProviderOperations = {
41
+ createItemTypes: ["login", "note", "api_key"],
42
+ updateItem: false,
43
+ removeField: false,
44
+ archiveItem: true,
45
+ archiveKind: "trash",
46
+ deleteItem: false,
47
+ restoreItem: false,
48
+ readFiles: true,
49
+ writeFiles: false,
50
+ createTotp: true,
51
+ updateTotp: false,
52
+ createVault: false,
53
+ restoreFieldHistory: false,
54
+ };
55
+
56
+ /** The adapter's capabilities; the same for every token (the PAT scope is enforced upstream). */
57
+ export function protonPassCapabilities(): VaultProviderCapabilities {
58
+ return {
59
+ write: true,
60
+ createVault: false,
61
+ totp: true,
62
+ // Legacy flag means file writes; reads are declared in operations.readFiles.
63
+ files: false,
64
+ groupGrants: false,
65
+ passkeys: false,
66
+ operations: {
67
+ ...PROTON_PASS_OPERATIONS,
68
+ createItemTypes: [...PROTON_PASS_OPERATIONS.createItemTypes],
69
+ },
70
+ };
71
+ }
72
+
73
+ export type ProtonPassVaultProviderOptions = ProtonCliOptions & {
74
+ /** Operations verified at setup; the adapter narrows to them and never widens. */
75
+ operations?: VaultProviderOperations;
76
+ /** Personal access token `pst_…::…`, scoped by the customer to vaults or items. */
77
+ token: string;
78
+ valueCacheMs?: number;
79
+ maxItemViewsPerList?: number;
80
+ };
81
+
82
+ export type ProtonPassVaultProvider = VaultProvider & {
83
+ readonly id: "protonpass";
84
+ /** Logs the CLI session out and wipes its private state directory. */
85
+ close(): Promise<void>;
86
+ };
87
+
88
+ /** Proton share and item ids are URL-safe base64; anything else cannot exist. */
89
+ const PROTON_ID = /^[A-Za-z0-9_=+/][A-Za-z0-9_=+/-]{0,255}$/;
90
+ function assertIds(...ids: string[]) {
91
+ for (const id of ids) {
92
+ if (!PROTON_ID.test(id)) {
93
+ throw new VaultProviderError("provider_not_found", "No such Proton Pass vault or item");
94
+ }
95
+ }
96
+ }
97
+
98
+ const TOKEN_SHAPE = /^[\x21-\x7e]{8,4096}$/;
99
+
100
+ type CachedDetails = { at: number; details: Promise<ItemDetails> };
101
+
102
+ function writeAccessFor(role: ShareRole): "read_only" | "read_write" {
103
+ if (typeof role === "string") return role === "Viewer" ? "read_only" : "read_write";
104
+ return (role.Custom.permission & SHARE_PERMISSION.create) !== 0 ? "read_write" : "read_only";
105
+ }
106
+
107
+ /** Narrows a custom role's operations to its permission flags; built-in roles keep all. */
108
+ function operationsFor(role: ShareRole): VaultProviderOperations | undefined {
109
+ if (typeof role === "string") return undefined;
110
+ const permission = role.Custom.permission;
111
+ const canCreate = (permission & SHARE_PERMISSION.create) !== 0;
112
+ const canTrash = (permission & SHARE_PERMISSION.trash) !== 0;
113
+ return {
114
+ ...PROTON_PASS_OPERATIONS,
115
+ createItemTypes: canCreate ? [...PROTON_PASS_OPERATIONS.createItemTypes] : [],
116
+ createTotp: canCreate,
117
+ archiveItem: canTrash,
118
+ archiveKind: canTrash ? "trash" : "none",
119
+ };
120
+ }
121
+
122
+ function utf8Bytes(value: string): number {
123
+ return Buffer.byteLength(value, "utf8");
124
+ }
125
+
126
+ function refuse(message: string): never {
127
+ throw new VaultProviderError("provider_unavailable", message);
128
+ }
129
+
130
+ function fieldValue(field: ProviderWriteField): string {
131
+ if (field.remove) refuse("Proton Pass items cannot have fields removed from Opengeni");
132
+ const value = field.value ?? "";
133
+ if (utf8Bytes(value) > VAULT_LIMITS.textValueMaxBytes) {
134
+ refuse(`The value of "${field.name}" is larger than Opengeni stores`);
135
+ }
136
+ return value;
137
+ }
138
+
139
+ function totpValue(field: ProviderWriteField, label: string): string {
140
+ try {
141
+ return totpUri(parseTotpInput(fieldValue(field)), label);
142
+ } catch (error) {
143
+ if (error instanceof VaultProviderError) throw error;
144
+ throw new VaultProviderError(
145
+ "provider_unavailable",
146
+ `"${field.name}" is not a TOTP seed or otpauth:// URI`,
147
+ );
148
+ }
149
+ }
150
+
151
+ type CreatePlan = {
152
+ kind: "login" | "note" | "custom";
153
+ template: Record<string, unknown>;
154
+ /** The same content as an Item, for metadata when the follow-up read fails. */
155
+ synthetic: Item["content"]["content"];
156
+ note: string;
157
+ extraSections: Array<{ section_name: string; section_fields: Item["content"]["extra_fields"] }>;
158
+ };
159
+
160
+ /** Builds the `--from-template -` JSON for a create; values travel only on stdin. */
161
+ export function planCreate(item: ProviderWriteItem): CreatePlan {
162
+ const title = item.name;
163
+ if (item.type === "login") {
164
+ let username: string | null = null;
165
+ let email: string | null = null;
166
+ let password: string | null = null;
167
+ let totp: string | null = null;
168
+ for (const field of item.fields) {
169
+ const key = nameKey(field.name);
170
+ if (field.kind === "totp") {
171
+ if (totp !== null) refuse("Proton Pass logins hold one one-time password");
172
+ totp = totpValue(field, title);
173
+ } else if (field.kind === "text" && key === "username" && username === null) {
174
+ username = fieldValue(field);
175
+ } else if (field.kind === "text" && key === "email" && email === null) {
176
+ email = fieldValue(field);
177
+ } else if (field.kind === "concealed" && key === "password" && password === null) {
178
+ // Only a field named "password" becomes the login password; any other
179
+ // name is refused before the create rather than silently renamed.
180
+ password = fieldValue(field);
181
+ } else {
182
+ refuse(
183
+ `Proton Pass logins created from Opengeni hold a username, email, password and one one-time password; "${field.name}" needs an API key (custom) item`,
184
+ );
185
+ }
186
+ }
187
+ const urls = item.websites.filter((url) => url.trim().length > 0);
188
+ return {
189
+ kind: "login",
190
+ template: { title, username, email, password, totp_uri: totp, urls },
191
+ synthetic: {
192
+ Login: {
193
+ username: username ?? "",
194
+ email: email ?? "",
195
+ password: password ?? "",
196
+ totp_uri: totp ?? "",
197
+ urls,
198
+ },
199
+ },
200
+ note: "",
201
+ extraSections: [],
202
+ };
203
+ }
204
+ if (item.type === "note") {
205
+ if (item.fields.length > 1) refuse("A Proton Pass note holds one note field");
206
+ const field = item.fields[0];
207
+ if (field && field.kind !== "text" && field.kind !== "concealed") {
208
+ refuse("A Proton Pass note holds text only");
209
+ }
210
+ const note = field ? fieldValue(field) : "";
211
+ return {
212
+ kind: "note",
213
+ template: { title, note },
214
+ synthetic: { Note: null },
215
+ note,
216
+ extraSections: [],
217
+ };
218
+ }
219
+ if (item.type === "api_key") {
220
+ const fields = item.fields.map((field) => {
221
+ if (field.kind === "file") refuse("Proton Pass files cannot be written from Opengeni");
222
+ const fieldType =
223
+ field.kind === "totp" ? "totp" : field.kind === "concealed" ? "hidden" : "text";
224
+ const value = field.kind === "totp" ? totpValue(field, title) : fieldValue(field);
225
+ return { field_name: field.name, field_type: fieldType, value };
226
+ });
227
+ const sections = fields.length ? [{ section_name: "Fields", fields }] : [];
228
+ return {
229
+ kind: "custom",
230
+ template: { title, note: null, sections },
231
+ synthetic: {
232
+ Custom: {
233
+ sections: sections.map((section) => ({
234
+ section_name: section.section_name,
235
+ section_fields: section.fields.map((field) => ({
236
+ name: field.field_name,
237
+ content:
238
+ field.field_type === "totp"
239
+ ? { Totp: field.value }
240
+ : field.field_type === "hidden"
241
+ ? { Hidden: field.value }
242
+ : { Text: field.value },
243
+ })),
244
+ })),
245
+ },
246
+ },
247
+ note: "",
248
+ extraSections: [],
249
+ };
250
+ }
251
+ return refuse(
252
+ item.type === "passkey"
253
+ ? "Passkeys live only in Opengeni vaults"
254
+ : "Proton Pass file items cannot be created from Opengeni",
255
+ );
256
+ }
257
+
258
+ const CREATE_CONTEXT = {
259
+ login: /Error creating login item/,
260
+ note: /Error creating note item/,
261
+ custom: /Error creating custom item/,
262
+ } as const;
263
+
264
+ export function createProtonPassVaultProvider(
265
+ options: ProtonPassVaultProviderOptions,
266
+ ): ProtonPassVaultProvider {
267
+ const token = options.token.trim();
268
+ if (!TOKEN_SHAPE.test(token)) {
269
+ throw new VaultProviderError(
270
+ "provider_auth_invalid",
271
+ "A Proton Pass personal access token looks like pst_…::…",
272
+ );
273
+ }
274
+ const session = new ProtonCliSession(token, options);
275
+ const now = options.now ?? Date.now;
276
+ const valueCacheMs = options.valueCacheMs ?? PROTON_PASS_VALUE_CACHE_MS;
277
+ // Per provider instance (one token, one session) and per attempt budget.
278
+ // Core shares one budget across every provider in an attempt, so a
279
+ // module-level cache keyed only by budget would let one connection read
280
+ // another's values for the same share and item ids.
281
+ const attemptCaches = new WeakMap<ProviderCallBudget, Map<string, CachedDetails>>();
282
+ const maxViews = options.maxItemViewsPerList ?? PROTON_PASS_MAX_ITEM_VIEWS_PER_LIST;
283
+ /** Last observed access per share; lets a known read-only vault refuse writes locally. */
284
+ const shareAccess = new Map<string, "read_only" | "read_write" | "unknown">();
285
+
286
+ function cli(
287
+ ctx: VaultProviderContext,
288
+ op: "list" | "read" | "write",
289
+ command: Omit<ProtonCommand, "authority" | "signal">,
290
+ ): Promise<string> {
291
+ ctx.signal?.throwIfAborted();
292
+ ctx.budget.take(op);
293
+ return session.run({
294
+ ...command,
295
+ ...(ctx.authority ? { authority: ctx.authority } : {}),
296
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
297
+ });
298
+ }
299
+
300
+ function parseOutput<T>(
301
+ stdout: string,
302
+ schema: { safeParse(value: unknown): { success: true; data: T } | { success: false } },
303
+ code: "provider_inventory_incomplete" | "provider_unavailable",
304
+ ): T {
305
+ const parsed = schema.safeParse(parseJson(stdout));
306
+ if (!parsed.success) {
307
+ throw new VaultProviderError(
308
+ code,
309
+ "pass-cli returned output this Opengeni release does not understand",
310
+ );
311
+ }
312
+ return parsed.data;
313
+ }
314
+
315
+ function viewItem(
316
+ ctx: VaultProviderContext,
317
+ shareId: string,
318
+ itemId: string,
319
+ subject: string,
320
+ ): Promise<ItemDetails> {
321
+ return cli(ctx, "read", {
322
+ args: ["item", "view", `--share-id=${shareId}`, `--item-id=${itemId}`, "--output", "json"],
323
+ subject,
324
+ }).then((stdout) => parseOutput(stdout, ItemDetails, "provider_unavailable"));
325
+ }
326
+
327
+ /** Live item read, cached per attempt (keyed by its budget) with single-flight. */
328
+ function cachedItem(ctx: VaultProviderContext, shareId: string, itemId: string) {
329
+ let cache = attemptCaches.get(ctx.budget);
330
+ if (!cache) {
331
+ cache = new Map();
332
+ attemptCaches.set(ctx.budget, cache);
333
+ }
334
+ const key = `${ctx.cacheEpoch ?? ""}|${shareId}/${itemId}`;
335
+ const cached = cache.get(key);
336
+ if (cached && !ctx.forceRefresh && now() - cached.at < valueCacheMs) return cached.details;
337
+ const details = viewItem(ctx, shareId, itemId, "read item");
338
+ const entry = { at: now(), details };
339
+ cache.set(key, entry);
340
+ details.catch(() => {
341
+ if (cache.get(key) === entry) cache.delete(key);
342
+ });
343
+ return details;
344
+ }
345
+
346
+ function forgetItem(ctx: VaultProviderContext, shareId: string, itemId: string) {
347
+ const cache = attemptCaches.get(ctx.budget);
348
+ if (!cache) return;
349
+ for (const key of cache.keys()) if (key.endsWith(`|${shareId}/${itemId}`)) cache.delete(key);
350
+ }
351
+
352
+ function reportFresh(ctx: VaultProviderContext) {
353
+ const at = new Date(now()).toISOString();
354
+ ctx.onFreshness?.({
355
+ state: "current",
356
+ lastCheckedAt: at,
357
+ sourceSyncedAt: at,
358
+ source: "live",
359
+ errorCode: null,
360
+ retryAt: null,
361
+ });
362
+ }
363
+
364
+ async function downloadFile(
365
+ ctx: VaultProviderContext,
366
+ shareId: string,
367
+ itemId: string,
368
+ field: MappedField & { value: { type: "file" } },
369
+ ): Promise<Uint8Array> {
370
+ const attachment = field.value.attachment;
371
+ assertIds(attachment.id);
372
+ if (attachment.size > VAULT_LIMITS.fileValueMaxBytes) {
373
+ refuse(`"${field.name}" is larger than Opengeni reads from Proton Pass`);
374
+ }
375
+ const dir = session.privateScratchDir();
376
+ const target = join(dir, "attachment");
377
+ try {
378
+ await cli(ctx, "read", {
379
+ args: [
380
+ "item",
381
+ "attachment",
382
+ "download",
383
+ `--share-id=${shareId}`,
384
+ `--item-id=${itemId}`,
385
+ `--attachment-id=${attachment.id}`,
386
+ `--output=${target}`,
387
+ ],
388
+ subject: "download attachment",
389
+ maxStdoutBytes: 64 * 1024,
390
+ });
391
+ let size: number;
392
+ try {
393
+ const stat = lstatSync(target);
394
+ if (!stat.isFile()) throw new Error("not a file");
395
+ size = stat.size;
396
+ } catch {
397
+ return refuse("pass-cli did not produce the attachment");
398
+ }
399
+ if (size > VAULT_LIMITS.fileValueMaxBytes) {
400
+ refuse(`"${field.name}" is larger than Opengeni reads from Proton Pass`);
401
+ }
402
+ return new Uint8Array(readFileSync(target));
403
+ } finally {
404
+ rmSync(dir, { recursive: true, force: true });
405
+ }
406
+ }
407
+
408
+ async function material(
409
+ ctx: VaultProviderContext,
410
+ shareId: string,
411
+ itemId: string,
412
+ mapped: MappedItem,
413
+ externalFieldId: string,
414
+ ): Promise<SecretMaterial> {
415
+ const field = mapped.fields.find((candidate) => candidate.externalFieldId === externalFieldId);
416
+ if (!field) {
417
+ throw new VaultProviderError(
418
+ "provider_not_found",
419
+ `Proton Pass item "${mapped.meta.name}" has no field ${externalFieldId}`,
420
+ );
421
+ }
422
+ const value = field.value;
423
+ if (value.type === "text") return { type: "text", value: value.value };
424
+ if (value.type === "totp") {
425
+ try {
426
+ return { type: "totp", params: parseTotpInput(value.uri) };
427
+ } catch {
428
+ return refuse(`The one-time password in "${mapped.meta.name}" is not a valid TOTP seed`);
429
+ }
430
+ }
431
+ const bytes = await downloadFile(ctx, shareId, itemId, { ...field, value });
432
+ return { type: "bytes", value: bytes };
433
+ }
434
+
435
+ const provider: ProtonPassVaultProvider = {
436
+ id: "protonpass",
437
+
438
+ capabilities: protonPassCapabilities,
439
+
440
+ async validate(ctx) {
441
+ ctx.signal?.throwIfAborted();
442
+ const info = await session.ensureSession(ctx.signal);
443
+ await provider.listVaults(ctx);
444
+ return { accountName: info.tokenName };
445
+ },
446
+
447
+ async listVaults(ctx): Promise<ProviderVault[]> {
448
+ let shares: ShareListOutput;
449
+ try {
450
+ shares = parseOutput(
451
+ await cli(ctx, "list", {
452
+ args: ["share", "list", "--only-vaults=true", "--output", "json"],
453
+ subject: "list vaults",
454
+ overflowCode: "provider_inventory_incomplete",
455
+ }),
456
+ ShareListOutput,
457
+ "provider_inventory_incomplete",
458
+ );
459
+ } catch (error) {
460
+ // Roles refine access; when shares are refused or unreadable, list vaults with unknown access.
461
+ if (
462
+ !(error instanceof VaultProviderError) ||
463
+ ![
464
+ "provider_permission_denied",
465
+ "provider_not_found",
466
+ "provider_inventory_incomplete",
467
+ ].includes(error.code)
468
+ ) {
469
+ throw error;
470
+ }
471
+ const vaults = parseOutput(
472
+ await cli(ctx, "list", {
473
+ args: ["vault", "list", "--output", "json"],
474
+ subject: "list vaults",
475
+ overflowCode: "provider_inventory_incomplete",
476
+ }),
477
+ VaultListOutput,
478
+ "provider_inventory_incomplete",
479
+ );
480
+ reportFresh(ctx);
481
+ return vaults.vaults.map((vault) => {
482
+ shareAccess.set(vault.share_id, "unknown");
483
+ return {
484
+ externalVaultId: vault.share_id,
485
+ name: safeSecretName(vault.name, vault.share_id),
486
+ contentVersion: null,
487
+ itemCount: null,
488
+ writeAccess: "unknown" as const,
489
+ };
490
+ });
491
+ }
492
+ reportFresh(ctx);
493
+ return shares.shares
494
+ .filter((share) => share.share_type === "Vault")
495
+ .map((share) => {
496
+ const writeAccess = writeAccessFor(share.share_role);
497
+ shareAccess.set(share.id, writeAccess);
498
+ const operations = operationsFor(share.share_role);
499
+ return {
500
+ externalVaultId: share.id,
501
+ name: safeSecretName(share.name, share.id),
502
+ contentVersion: null,
503
+ itemCount: null,
504
+ writeAccess,
505
+ ...(operations ? { operations } : {}),
506
+ };
507
+ });
508
+ },
509
+
510
+ async listItems(ctx, externalVaultId): Promise<ProviderItemMeta[]> {
511
+ assertIds(externalVaultId);
512
+ await session.ensureSession(ctx.signal);
513
+ const agent = session.sessionInfo?.agent ?? false;
514
+ const base = ["item", "list", `--share-id=${externalVaultId}`, "--filter-state", "active"];
515
+ const metas: ProviderItemMeta[] = [];
516
+ if (!agent) {
517
+ const list = parseOutput(
518
+ await cli(ctx, "list", {
519
+ args: [...base, "--show-secrets", "--output", "json"],
520
+ subject: "list items",
521
+ overflowCode: "provider_inventory_incomplete",
522
+ }),
523
+ FullItemList,
524
+ "provider_inventory_incomplete",
525
+ );
526
+ // Only this share's items are viewed, so only they count against the view budget.
527
+ const withFiles = list.items.filter(
528
+ (item) => item.share_id === externalVaultId && item.flags.includes(ITEM_HAS_FILES),
529
+ );
530
+ if (withFiles.length > maxViews) incomplete();
531
+ for (const item of list.items) {
532
+ if (item.share_id !== externalVaultId) continue;
533
+ if (item.flags.includes(ITEM_HAS_FILES)) {
534
+ const details = await viewItem(ctx, externalVaultId, item.id, "list items");
535
+ metas.push(mapItem(details.item, details.attachments).meta);
536
+ } else {
537
+ metas.push(mapItem(item).meta);
538
+ }
539
+ }
540
+ } else {
541
+ // Agent sessions refuse --show-secrets; each item is viewed (and audited) instead.
542
+ const list = parseOutput(
543
+ await cli(ctx, "list", {
544
+ args: [...base, "--output", "json"],
545
+ subject: "list items",
546
+ overflowCode: "provider_inventory_incomplete",
547
+ }),
548
+ ItemSummaryList,
549
+ "provider_inventory_incomplete",
550
+ );
551
+ const summaries = list.items.filter((item) => item.share_id === externalVaultId);
552
+ if (summaries.length > maxViews) incomplete();
553
+ for (const summary of summaries) {
554
+ const details = await viewItem(ctx, externalVaultId, summary.id, "list items");
555
+ metas.push(mapItem(details.item, details.attachments).meta);
556
+ }
557
+ }
558
+ if (metas.length > VAULT_LIMITS.itemsPerVault) incomplete();
559
+ for (const meta of metas) assertMirrorable(meta);
560
+ reportFresh(ctx);
561
+ return metas;
562
+ },
563
+
564
+ async readFields(ctx, externalVaultId, externalItemId, externalFieldIds) {
565
+ assertIds(externalVaultId, externalItemId);
566
+ const details = await cachedItem(ctx, externalVaultId, externalItemId);
567
+ if (details.item.share_id !== externalVaultId || details.item.state !== "Active") {
568
+ throw new VaultProviderError("provider_not_found", "No such Proton Pass vault or item");
569
+ }
570
+ const mapped = mapItem(details.item, details.attachments);
571
+ const values: ProviderFieldValue[] = [];
572
+ for (const externalFieldId of externalFieldIds) {
573
+ values.push({
574
+ externalFieldId,
575
+ material: await material(ctx, externalVaultId, externalItemId, mapped, externalFieldId),
576
+ });
577
+ }
578
+ return values;
579
+ },
580
+
581
+ async writeItem(ctx, externalVaultId, item): Promise<ProviderItemMeta> {
582
+ assertIds(externalVaultId);
583
+ if (item.externalItemId !== undefined) {
584
+ throw new VaultProviderError(
585
+ "provider_unavailable",
586
+ "Proton Pass items cannot be updated from Opengeni: pass-cli 2.4.2 takes new values only as command-line arguments and cannot edit one-time passwords",
587
+ );
588
+ }
589
+ if (shareAccess.get(externalVaultId) === "read_only") {
590
+ throw new VaultProviderError(
591
+ "provider_permission_denied",
592
+ "The Proton Pass token can only read this vault",
593
+ );
594
+ }
595
+ const plan = planCreate(item);
596
+ const stdout = await cli(ctx, "write", {
597
+ args: [
598
+ "item",
599
+ "create",
600
+ plan.kind,
601
+ `--share-id=${externalVaultId}`,
602
+ "--from-template",
603
+ "-",
604
+ ],
605
+ stdin: JSON.stringify(plan.template),
606
+ subject: `create ${plan.kind} item`,
607
+ mutationContext: CREATE_CONTEXT[plan.kind],
608
+ maxStdoutBytes: 64 * 1024,
609
+ });
610
+ const lines = stdout
611
+ .split("\n")
612
+ .map((line) => line.trim())
613
+ .filter(Boolean);
614
+ const itemId = lines.at(-1);
615
+ if (!itemId || !PROTON_ID.test(itemId)) {
616
+ throw new VaultProviderError(
617
+ "provider_outcome_unknown",
618
+ "Proton Pass did not confirm the new item; check the vault before retrying",
619
+ );
620
+ }
621
+ try {
622
+ const details = await viewItem(ctx, externalVaultId, itemId, "read created item");
623
+ return mapItem(details.item, details.attachments).meta;
624
+ } catch {
625
+ // The item exists; when the follow-up read fails, describe it from what was sent.
626
+ const synthetic = mapItem({
627
+ id: itemId,
628
+ share_id: externalVaultId,
629
+ content: {
630
+ title: item.name,
631
+ note: plan.note,
632
+ content: plan.synthetic,
633
+ extra_fields: [],
634
+ },
635
+ state: "Active",
636
+ flags: [],
637
+ create_time: "1970-01-01T00:00:00",
638
+ modify_time: "1970-01-01T00:00:00",
639
+ }).meta;
640
+ return { ...synthetic, version: null, updatedAt: null };
641
+ }
642
+ },
643
+
644
+ async archiveItem(ctx, externalVaultId, externalItemId) {
645
+ assertIds(externalVaultId, externalItemId);
646
+ if (shareAccess.get(externalVaultId) === "read_only") {
647
+ throw new VaultProviderError(
648
+ "provider_permission_denied",
649
+ "The Proton Pass token can only read this vault",
650
+ );
651
+ }
652
+ // Trash is recoverable in Proton Pass; `item delete` (permanent) is never used.
653
+ await cli(ctx, "write", {
654
+ args: ["item", "trash", `--share-id=${externalVaultId}`, `--item-id=${externalItemId}`],
655
+ subject: "trash item",
656
+ mutationContext: /Error trashing item/,
657
+ maxStdoutBytes: 64 * 1024,
658
+ });
659
+ forgetItem(ctx, externalVaultId, externalItemId);
660
+ },
661
+
662
+ close: () => session.close(),
663
+ };
664
+ return restrictToOperations(provider, options.operations, FILE_FIELD_PREFIX);
665
+ }
666
+
667
+ function incomplete(): never {
668
+ throw new VaultProviderError(
669
+ "provider_inventory_incomplete",
670
+ "The Proton Pass vault has more items than Opengeni mirrors at once",
671
+ );
672
+ }
673
+
674
+ /**
675
+ * Opengeni mirrors at most `fieldsPerItem` fields and `websitesPerItem`
676
+ * websites of up to `websiteMaxChars` each. A larger item would be cut down
677
+ * and then look complete, so the listing is reported incomplete instead.
678
+ */
679
+ function assertMirrorable(meta: ProviderItemMeta): void {
680
+ if (
681
+ meta.fields.length > VAULT_LIMITS.fieldsPerItem ||
682
+ meta.websites.length > VAULT_LIMITS.websitesPerItem ||
683
+ meta.websites.some((website) => website.length > VAULT_LIMITS.websiteMaxChars)
684
+ ) {
685
+ throw new VaultProviderError(
686
+ "provider_inventory_incomplete",
687
+ `A Proton Pass item has more than ${VAULT_LIMITS.fieldsPerItem} fields or ${VAULT_LIMITS.websitesPerItem} websites (or a website over ${VAULT_LIMITS.websiteMaxChars} characters), more than Opengeni mirrors`,
688
+ );
689
+ }
690
+ }