@dotrino/identity 0.117.2 → 0.119.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.
@@ -8,7 +8,7 @@
8
8
  export const MANIFESTS = Object.freeze({
9
9
  "dotrino": {
10
10
  "manifest": "dotrino",
11
- "seq": 28,
11
+ "seq": 29,
12
12
  "issued": "2026-10-15",
13
13
  "note": "Registro comun de compatibilidad del ecosistema. Se edita a mano en este repo y se consulta con @dotrino/roadmap. Rangos: ver @dotrino/compat/ranges (x.y.z, >= > <= <, «a - b», «*», o una lista que es un O).",
14
14
  "products": {
@@ -31,7 +31,7 @@ export const MANIFESTS = Object.freeze({
31
31
  "identity": {
32
32
  "repo": "imdotrino/dotrino-identity",
33
33
  "npm": "@dotrino/identity",
34
- "current": "0.117.1",
34
+ "current": "0.118.0",
35
35
  "protocol": 1,
36
36
  "requires": {}
37
37
  },
@@ -47,11 +47,11 @@ export const MANIFESTS = Object.freeze({
47
47
  },
48
48
  "messenger": {
49
49
  "repo": "imdotrino/dotrino-messenger",
50
- "current": "0.8.0",
50
+ "current": "0.8.1",
51
51
  "protocol": 2,
52
52
  "requires": {
53
53
  "identity": ">=0.117.0",
54
- "proxy-client": ">=0.35.0",
54
+ "proxy-client": ">=0.36.1",
55
55
  "store": ">=0.12.3",
56
56
  "proxy": "1.6.1+"
57
57
  }
@@ -107,19 +107,19 @@ export const MANIFESTS = Object.freeze({
107
107
  },
108
108
  "proxy": {
109
109
  "repo": "imdotrino/dotrino-proxy",
110
- "current": "1.6.1",
110
+ "current": "1.6.3",
111
111
  "protocol": 2,
112
112
  "requires": {
113
- "vault": ">=0.86.0",
114
- "identity": ">=0.116.0",
115
- "proxy-client": ">=0.23.1",
113
+ "vault": ">=0.87.0",
114
+ "identity": ">=0.117.2",
115
+ "proxy-client": ">=0.36.1",
116
116
  "content": "0.5.3+"
117
117
  }
118
118
  },
119
119
  "proxy-client": {
120
120
  "repo": "imdotrino/dotrino-proxy-client",
121
121
  "npm": "@dotrino/proxy-client",
122
- "current": "0.35.0",
122
+ "current": "0.36.1",
123
123
  "protocol": 2,
124
124
  "requires": {
125
125
  "identity": ">=0.53.0",
@@ -129,25 +129,25 @@ export const MANIFESTS = Object.freeze({
129
129
  "remote-agent": {
130
130
  "repo": "imdotrino/dotrino-remote-agent",
131
131
  "npm": "@dotrino/remote-agent",
132
- "current": "0.19.1",
132
+ "current": "0.20.1",
133
133
  "protocol": 1,
134
134
  "requires": {
135
- "identity": ">=0.115.0",
135
+ "identity": ">=0.117.2",
136
136
  "vault": ">=0.85.0",
137
- "proxy-client": ">=0.30.0",
137
+ "proxy-client": ">=0.36.1",
138
138
  "vaultd": ">=0.154.0"
139
139
  }
140
140
  },
141
141
  "terminal-agent": {
142
142
  "repo": "imdotrino/dotrino-terminal",
143
143
  "npm": "@dotrino/terminal-agent",
144
- "current": "0.47.4",
144
+ "current": "0.48.0",
145
145
  "protocol": 2,
146
146
  "requires": {
147
- "remote-agent": ">=0.18.3",
148
- "identity": ">=0.116.0",
149
- "vault": ">=0.86.0",
150
- "proxy-client": ">=0.31.1",
147
+ "remote-agent": ">=0.20.1",
148
+ "identity": ">=0.117.2",
149
+ "vault": ">=0.87.0",
150
+ "proxy-client": ">=0.36.1",
151
151
  "vaultd": ">=0.142.0"
152
152
  }
153
153
  },
@@ -169,7 +169,7 @@ export const MANIFESTS = Object.freeze({
169
169
  },
170
170
  "terminal-desktop": {
171
171
  "repo": "imdotrino/dotrino-terminal",
172
- "current": "0.2.74",
172
+ "current": "0.2.77",
173
173
  "protocol": 1,
174
174
  "requires": {
175
175
  "terminal-agent": ">=0.37.0"
@@ -179,42 +179,43 @@ export const MANIFESTS = Object.freeze({
179
179
  "repo": "imdotrino/dotrino-ia",
180
180
  "npm": "@dotrino/ia-agent",
181
181
  "deprecated": "2026-10-07: retired, replaced by terminal-agent (dotrino-terminal)",
182
- "current": "0.8.1",
182
+ "current": "0.9.0",
183
183
  "protocol": 1,
184
184
  "requires": {
185
- "remote-agent": ">=0.13.0",
186
- "identity": ">=0.92.0",
187
- "vault": ">=0.75.0"
185
+ "remote-agent": ">=0.20.1",
186
+ "identity": ">=0.117.2",
187
+ "vault": ">=0.87.0",
188
+ "proxy-client": ">=0.36.1"
188
189
  }
189
190
  },
190
191
  "content": {
191
192
  "repo": "imdotrino/dotrino-content",
192
193
  "npm": "@dotrino/content",
193
- "current": "0.5.3",
194
+ "current": "0.6.0",
194
195
  "protocol": 1,
195
196
  "requires": {
196
- "remote-agent": ">=0.13.0",
197
- "identity": ">=0.116.0",
198
- "vault": ">=0.86.0",
199
- "proxy-client": ">=0.23.1"
197
+ "remote-agent": ">=0.20.1",
198
+ "identity": ">=0.117.2",
199
+ "vault": ">=0.87.0",
200
+ "proxy-client": ">=0.36.1"
200
201
  }
201
202
  },
202
203
  "sealers": {
203
204
  "repo": "imdotrino/dotrino-sealers",
204
205
  "npm": "@dotrino/sealers",
205
- "current": "0.9.2",
206
+ "current": "0.10.0",
206
207
  "protocol": 1,
207
208
  "requires": {
208
- "remote-agent": ">=0.13.0",
209
- "identity": ">=0.116.0",
210
- "vault": ">=0.86.0",
211
- "proxy-client": ">=0.23.1"
209
+ "remote-agent": ">=0.20.1",
210
+ "identity": ">=0.117.2",
211
+ "vault": ">=0.87.0",
212
+ "proxy-client": ">=0.36.1"
212
213
  }
213
214
  },
214
215
  "roadmap": {
215
216
  "repo": "imdotrino/dotrino-roadmap",
216
217
  "npm": "@dotrino/roadmap",
217
- "current": "0.4.14",
218
+ "current": "0.4.29",
218
219
  "protocol": 1,
219
220
  "requires": {
220
221
  "compat": "0.4.0+"
@@ -250,32 +251,32 @@ export const MANIFESTS = Object.freeze({
250
251
  "vault": {
251
252
  "repo": "imdotrino/dotrino-vault",
252
253
  "npm": "@dotrino/vault",
253
- "current": "0.86.0",
254
+ "current": "0.89.0",
254
255
  "protocol": 1,
255
256
  "requires": {
256
257
  "vaultd": ">=0.105.0",
257
- "proxy-client": "0.24.0+",
258
- "identity": ">=0.116.0",
258
+ "proxy-client": ">=0.36.1",
259
+ "identity": ">=0.118.0",
259
260
  "opaque": ">=0.1.0",
260
261
  "compat": ">=0.4.0",
261
- "roadmap": ">=0.4.13",
262
- "passmanager": ">=0.17.0",
262
+ "roadmap": ">=0.4.26",
263
+ "passmanager": ">=0.18.4",
263
264
  "store": ">=0.11.0"
264
265
  }
265
266
  },
266
267
  "vaultd": {
267
268
  "repo": "imdotrino/dotrino-vault",
268
269
  "npm": "@dotrino/vaultd",
269
- "current": "0.157.0",
270
+ "current": "0.161.0",
270
271
  "protocol": 1,
271
272
  "requires": {
272
- "identity": ">=0.116.0",
273
- "proxy-client": ">=0.30.1",
273
+ "identity": ">=0.118.0",
274
+ "proxy-client": ">=0.36.1",
274
275
  "store": ">=0.11.0",
275
- "passmanager": ">=0.17.0",
276
+ "passmanager": ">=0.18.4",
276
277
  "compat": ">=0.4.0",
277
278
  "opaque": ">=0.1.0",
278
- "roadmap": ">=0.4.13"
279
+ "roadmap": ">=0.4.26"
279
280
  }
280
281
  },
281
282
  "verifier": {
@@ -290,11 +291,11 @@ export const MANIFESTS = Object.freeze({
290
291
  "passmanager": {
291
292
  "repo": "imdotrino/dotrino-passmanager",
292
293
  "npm": "@dotrino/passmanager",
293
- "current": "0.17.0",
294
+ "current": "0.18.4",
294
295
  "protocol": 1,
295
296
  "requires": {
296
- "identity": ">=0.61.0",
297
- "proxy-client": ">=0.13.0"
297
+ "identity": ">=0.117.2",
298
+ "proxy-client": ">=0.36.1"
298
299
  }
299
300
  },
300
301
  "passmanager-android": {
@@ -303,7 +304,7 @@ export const MANIFESTS = Object.freeze({
303
304
  "protocol": 1,
304
305
  "requires": {
305
306
  "proxy": "1.1.0+",
306
- "vaultd": ">=0.125.0"
307
+ "vaultd": ">=0.160.2"
307
308
  }
308
309
  },
309
310
  "passmanager-ios": {
@@ -317,18 +318,19 @@ export const MANIFESTS = Object.freeze({
317
318
  },
318
319
  "passmanager-extension": {
319
320
  "repo": "imdotrino/dotrino-passmanager",
320
- "current": "0.49.1",
321
+ "current": "0.53.4",
321
322
  "protocol": 1,
322
323
  "requires": {
323
- "identity": ">=0.116.0",
324
- "proxy-client": ">=0.32.2",
325
- "vault": ">=0.86.0",
326
- "proxy": "1.1.0+"
324
+ "identity": ">=0.118.0",
325
+ "proxy-client": ">=0.36.1",
326
+ "vault": ">=0.88.0",
327
+ "proxy": "1.1.0+",
328
+ "vaultd": ">=0.160.2"
327
329
  }
328
330
  },
329
331
  "app": {
330
332
  "repo": "imdotrino/dotrino-app",
331
- "current": "0.4.21",
333
+ "current": "0.4.22",
332
334
  "protocol": 1,
333
335
  "requires": {
334
336
  "proxy": "1.4.7+",
@@ -338,17 +340,41 @@ export const MANIFESTS = Object.freeze({
338
340
  },
339
341
  "social-bot": {
340
342
  "repo": "imdotrino/dotrino-social-bot",
341
- "current": "0.2.1",
343
+ "current": "0.3.0",
342
344
  "protocol": 1,
343
345
  "requires": {
344
- "vault": ">=0.86.0",
345
- "identity": ">=0.116.0",
346
- "proxy-client": ">=0.23.1",
347
- "remote-agent": ">=0.13.0"
346
+ "vault": ">=0.87.0",
347
+ "identity": ">=0.117.2",
348
+ "proxy-client": ">=0.36.1",
349
+ "remote-agent": ">=0.20.1"
348
350
  }
349
351
  }
350
352
  },
351
353
  "broken": [
354
+ {
355
+ "product": "passmanager-extension",
356
+ "versions": "0.50.0",
357
+ "why": "la página que pide una passkey decía ella misma de qué sitio era (rpId y origen salían del mensaje, no del remitente): un sitio cualquiera podía pedir la firma de la passkey de otro sitio guardada en Dotrino",
358
+ "fix": "sube a la extensión 0.51.0 (se actualiza sola desde pass.dotrino.com/app)"
359
+ },
360
+ {
361
+ "product": "passmanager-extension",
362
+ "versions": "0.49.1",
363
+ "why": "la página que pide una passkey decía ella misma de qué sitio era (rpId y origen salían del mensaje, no del remitente): un sitio cualquiera podía pedir la firma de la passkey de otro sitio guardada en Dotrino",
364
+ "fix": "sube a la extensión 0.51.0 (se actualiza sola desde pass.dotrino.com/app)"
365
+ },
366
+ {
367
+ "product": "passmanager-extension",
368
+ "versions": "0.49.0",
369
+ "why": "la página que pide una passkey decía ella misma de qué sitio era (rpId y origen salían del mensaje, no del remitente): un sitio cualquiera podía pedir la firma de la passkey de otro sitio guardada en Dotrino",
370
+ "fix": "sube a la extensión 0.51.0 (se actualiza sola desde pass.dotrino.com/app)"
371
+ },
372
+ {
373
+ "product": "passmanager-extension",
374
+ "versions": "0.48.4",
375
+ "why": "la página que pide una passkey decía ella misma de qué sitio era (rpId y origen salían del mensaje, no del remitente): un sitio cualquiera podía pedir la firma de la passkey de otro sitio guardada en Dotrino",
376
+ "fix": "sube a la extensión 0.51.0 (se actualiza sola desde pass.dotrino.com/app)"
377
+ },
352
378
  {
353
379
  "product": "terminal-agent",
354
380
  "versions": "0.46.4",
@@ -802,6 +828,21 @@ export const MANIFESTS = Object.freeze({
802
828
  "versions": "1.6.0",
803
829
  "why": "pasa a base64 los mensajes binarios para cruzar la malla, y una parte de 768 KB así pasa de la trama de 1 MB del otro nodo: el enlace se cae en cada reconexión al reenviarla y todo lo que va detrás entre los dos nodos se queda atascado",
804
830
  "fix": "websocket-proxy 1.6.1"
831
+ },
832
+ {
833
+ "product": "proxy-client",
834
+ "versions": "0.36.0",
835
+ "why": "por token frena los bytes por lo recordado de la identidad (que puede ser de antes de que el otro se actualizara), y no solo por lo que dijo esa conexión en su saludo",
836
+ "fix": "@dotrino/proxy-client 0.36.1"
837
+ },
838
+ {
839
+ "product": "vaultd",
840
+ "versions": [
841
+ "0.160.0",
842
+ "0.160.1"
843
+ ],
844
+ "why": "la bóveda recordaba el sí de aprobación por aparato: después de aprobar un borrado, o de leer una contraseña, ese aparato podía borrar o pisar una passkey sin que se pidiera nada ni le llegara aviso a nadie",
845
+ "fix": "sube a @dotrino/vaultd 0.160.2"
805
846
  }
806
847
  ]
807
848
  }
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.86.1 (dotrino-vault/lib/src/{admin,b64,browser,core/approvals,core/blocked,core/browserDisk,core/commKey,core/disk,core/libVersion,core/machines,core/notices,core/notifiable,core/passwords,core/sealKey,core/sealer,core/sealers,core/secretsStore,core/sha256,core/store,core/subacta,core/threadStore,core/transport,core/vault,enroll,index,loginClient,passwordLogins,procContext,protocol,revocation,sealed}.js).
1
+ Copia vendorizada de @dotrino/vault@0.90.0 (dotrino-vault/lib/src/{admin,b64,browser,core/approvals,core/backup,core/blocked,core/browserDisk,core/commKey,core/disk,core/libVersion,core/machines,core/notices,core/notifiable,core/passwords,core/sealKey,core/sealer,core/sealers,core/secretsStore,core/sha256,core/store,core/subacta,core/threadStore,core/transport,core/vault,enroll,index,loginClient,passwordLogins,procContext,protocol,revocation,sealed}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  browser.js es la bóveda de esta pestaña: el MISMO núcleo que el binario (core/), con
4
4
  el disco en IndexedDB. Importa @dotrino/{compat,roadmap,passmanager,store/core} y
@@ -0,0 +1,244 @@
1
+ /**
2
+ * EL RESPALDO DE LA BÓVEDA: un archivo cifrado con una contraseña, con lo que hay en los
3
+ * cajones y en la bóveda de contraseñas. Diseño: `docs/respaldo.md`.
4
+ *
5
+ * QUÉ LLEVA Y QUÉ NO (dueño, 2026-10-16: «como las llaves son de dispositivo, solo se
6
+ * respalda el contenido de sobres y cajones»):
7
+ *
8
+ * · SÍ: los VALORES de cada cajón (`ns:<scope>`, `dev:<aparato>`, el perfil `@me`) y las
9
+ * entradas de la bóveda de contraseñas, abiertos y vueltos a cerrar con la contraseña
10
+ * del respaldo.
11
+ * · NO: ninguna llave. Ni la maestra, ni la de comunicación, ni las de los aparatos, ni
12
+ * el acta. Una llave es de su aparato; quien restaura en otra bóveda la reparte con sus
13
+ * propias llaves, a los aparatos de SU acta.
14
+ * · NO: el histórico. Un respaldo es lo vigente.
15
+ *
16
+ * Por qué los valores en claro (dentro del archivo cifrado) y no los sobres tal cual: los
17
+ * sobres van envueltos a los aparatos de ESTA cuenta y a su copia de recuperación. En otra
18
+ * bóveda no los abre nadie, y mover un cajón de una bóveda a otra es la mitad de lo que esto
19
+ * tiene que hacer.
20
+ *
21
+ * LAS DOS PUNTAS EXIGEN LA BÓVEDA ABIERTA. Exportar es leer —lo que guarda la frase—, e
22
+ * importar compara con lo que ya hay, que también es leer.
23
+ *
24
+ * CONFLICTOS. Importar NUNCA pisa en silencio: primero se hace un PLAN (`planImport`) que
25
+ * dice, valor por valor, qué es nuevo, qué es igual y qué choca. Si algo choca y quien
26
+ * importa no dijo qué hacer, no se escribe NADA y se devuelve la lista (`BACKUP_CONFLICTS`).
27
+ * Las dos respuestas posibles son `keep` (se queda lo de la bóveda) y `replace` (gana el
28
+ * respaldo), para todo de una vez o para cada choque.
29
+ *
30
+ * El cifrado del archivo es el de `@dotrino/passmanager` (PBKDF2-SHA256 600 000 + AES-GCM):
31
+ * corre igual en el navegador y en Node, así que lo que exporta una versión de la bóveda lo
32
+ * abre cualquiera de las otras (regla de las tres versiones).
33
+ */
34
+ import { deriveKeyFromPassword, makeSalt, makeVerifier, checkVerifier, sealValue, openValue, toBase64, fromBase64 } from '@dotrino/passmanager'
35
+
36
+ export const BACKUP_FORMAT = 'dotrino-vault-backup'
37
+ export const BACKUP_VERSION = 1
38
+ /** El nombre del cajón de las contraseñas en un respaldo. No choca con un `ns:`/`dev:`. */
39
+ export const PASSWORDS_DRAWER = 'passwords'
40
+ /** Mínimo de la contraseña del respaldo: el archivo se ataca fuera de línea, sin freno. */
41
+ export const BACKUP_PWD_MIN = 12
42
+ const KDF_ITERATIONS = 600_000
43
+ const AAD = 'dotrino-vault-backup/v1'
44
+
45
+ const coded = (message, code, extra = {}) => Object.assign(new Error(message), { code, ...extra })
46
+
47
+ // --- el archivo ----------------------------------------------------------------
48
+
49
+ /**
50
+ * CIERRA un respaldo con su contraseña. Devuelve el objeto que se escribe al archivo.
51
+ * Lo único en claro es el formato y la fecha: ni los nombres de los cajones salen.
52
+ */
53
+ export async function sealBackup (payload, password) {
54
+ if (typeof password !== 'string' || password.length < BACKUP_PWD_MIN) {
55
+ throw coded(`the backup password must be at least ${BACKUP_PWD_MIN} characters`, 'WEAK_PASSWORD')
56
+ }
57
+ const salt = makeSalt()
58
+ const key = await deriveKeyFromPassword(password, salt, KDF_ITERATIONS)
59
+ return {
60
+ format: BACKUP_FORMAT,
61
+ v: BACKUP_VERSION,
62
+ createdAt: payload.createdAt,
63
+ kdf: { name: 'PBKDF2-SHA256', iterations: KDF_ITERATIONS, salt: toBase64(salt) },
64
+ verifier: await makeVerifier(key),
65
+ box: await sealValue(key, JSON.stringify(payload), AAD)
66
+ }
67
+ }
68
+
69
+ /**
70
+ * ABRE un respaldo. Distingue «no es un respaldo», «esa contraseña no es» y «está roto»,
71
+ * porque se arreglan de formas distintas.
72
+ */
73
+ export async function openBackup (file, password) {
74
+ if (!file || file.format !== BACKUP_FORMAT) throw coded('this is not a vault backup file', 'NOT_A_BACKUP')
75
+ if (file.v !== BACKUP_VERSION) throw coded(`unknown backup version v${file.v}: update the vault`, 'BACKUP_VERSION')
76
+ if (file.kdf?.name !== 'PBKDF2-SHA256' || !file.kdf.salt || !file.kdf.iterations) {
77
+ throw coded('the backup file has no usable key derivation', 'BACKUP_CORRUPT')
78
+ }
79
+ const key = await deriveKeyFromPassword(String(password || ''), fromBase64(file.kdf.salt), file.kdf.iterations)
80
+ if (!(await checkVerifier(key, file.verifier))) throw coded('that password does not open this backup', 'WRONG_PASSWORD')
81
+ let payload
82
+ try { payload = JSON.parse(await openValue(key, file.box, AAD)) } catch (_) {
83
+ throw coded('the backup opens but its content cannot be read', 'BACKUP_CORRUPT')
84
+ }
85
+ if (!Array.isArray(payload?.drawers)) throw coded('the backup has no drawers', 'BACKUP_CORRUPT')
86
+ return payload
87
+ }
88
+
89
+ // --- qué cajones -----------------------------------------------------------------
90
+
91
+ /**
92
+ * ¿Entra este cajón? `selected` es la lista que pidió quien exporta o importa (`null` =
93
+ * todos). Vale el dueño entero (`ns:proxy`, `dev:<pub>`, `passwords`) y `@me` como atajo.
94
+ */
95
+ export function wants (selected, owner) {
96
+ if (!selected || !selected.length) return true
97
+ return selected.some((s) => s === owner || (s === '@me' && owner === 'ns:@me'))
98
+ }
99
+
100
+ /** Valida que todo lo pedido exista: pedir un cajón que no está no es «nada que hacer». */
101
+ export function assertSelected (selected, available) {
102
+ if (!selected || !selected.length) return
103
+ const missing = selected.filter((s) => !available.some((o) => wants([s], o)))
104
+ if (missing.length) throw coded(`no such drawer: ${missing.join(', ')}`, 'NO_SUCH_DRAWER', { missing })
105
+ }
106
+
107
+ // --- importar: el plan -----------------------------------------------------------
108
+
109
+ /** Lo que identifica un valor dentro de un plan: `<cajón destino> <clave>`. */
110
+ export const itemRef = (owner, key) => `${owner} ${key}`
111
+
112
+ /**
113
+ * EL PLAN DE IMPORTACIÓN. Puro: no lee ni escribe nada, recibe lo que hay.
114
+ *
115
+ * @param {object} payload el respaldo ya abierto
116
+ * @param {object} o
117
+ * `selected` qué cajones del respaldo (`null` = todos)
118
+ * `map` `{ <cajón del respaldo>: <cajón destino> }` para moverlo de nombre o de
119
+ * aparato. Sin entrada, va al mismo nombre.
120
+ * `current` `{ <cajón destino>: { KEY: {value, public} } | null }` lo que ya hay
121
+ * `devices` pubs de los aparatos del acta de destino: un `dev:` solo entra si su
122
+ * aparato existe aquí
123
+ * `passwords` `{ entries: [...] }` lo que ya hay en la bóveda de contraseñas, abierto
124
+ */
125
+ export function planImport (payload, { selected = null, map = {}, current = {}, devices = [], passwords = null } = {}) {
126
+ const available = payload.drawers.map((d) => d.owner)
127
+ assertSelected(selected, available)
128
+ const withDevice = new Set(devices)
129
+ const plan = { drawers: [], counts: { new: 0, same: 0, conflict: 0, skipped: 0 } }
130
+ for (const d of payload.drawers) {
131
+ if (!wants(selected, d.owner)) continue
132
+ const to = map[d.owner] || d.owner
133
+ if (d.owner === PASSWORDS_DRAWER) {
134
+ plan.drawers.push(planPasswords(d, passwords, plan.counts))
135
+ continue
136
+ }
137
+ const out = { from: d.owner, to, label: d.label || null, status: 'ok', items: [] }
138
+ if (to.startsWith('dev:') && !withDevice.has(to.slice(4))) {
139
+ // EL APARATO NO ESTÁ EN ESTA CUENTA. No se guarda «por si acaso»: un cajón de aparato
140
+ // sin aparato no lo lee nadie, y reaparecería solo si un día entra esa llave. Se dice,
141
+ // y se puede mover a otro aparato con `map`.
142
+ out.status = 'no-device'
143
+ plan.counts.skipped += Object.keys(d.vars || {}).length
144
+ plan.drawers.push(out)
145
+ continue
146
+ }
147
+ const have = current[to] || {}
148
+ for (const [key, v] of Object.entries(d.vars || {})) {
149
+ const existing = have[key]
150
+ const status = !existing ? 'new' : (existing.value === v.value ? 'same' : 'conflict')
151
+ plan.counts[status]++
152
+ out.items.push({ key, status, public: !!v.public, ...(existing ? { currentPublic: !!existing.public } : {}) })
153
+ }
154
+ plan.drawers.push(out)
155
+ }
156
+ return plan
157
+ }
158
+
159
+ /**
160
+ * Lo que dice que una entrada de contraseñas es «la misma» que otra: el mismo id, o los
161
+ * mismos sitios y el mismo usuario (una credencial por sitio y usuario).
162
+ */
163
+ const sitesKey = (e) => [...(e.sites || [])].map((s) => String(s).toLowerCase()).sort().join(' ')
164
+ const whoKey = (e) => `${sitesKey(e)}\u0000${String(e.username || '').toLowerCase()}\u0000${e.sites?.length ? '' : String(e.title || '')}`
165
+ /** Lo que se compara para decir «igual»: el contenido, sin fechas. */
166
+ export const entryContent = (e) => JSON.stringify({
167
+ type: e.type || 'login', title: e.title || '', sites: [...(e.sites || [])].sort(),
168
+ name: e.name || '', username: e.username || '', secret: e.secret || '', totp: e.totp || '',
169
+ notes: e.notes || '', fields: e.fields || '[]', webauthn: e.webauthn || null
170
+ })
171
+
172
+ function planPasswords (d, passwords, counts) {
173
+ const out = { from: PASSWORDS_DRAWER, to: PASSWORDS_DRAWER, status: 'ok', items: [] }
174
+ if (!passwords) {
175
+ // Sin bóveda de contraseñas en el destino no hay con quién comparar ni dónde guardar:
176
+ // se dice. No se inventa una llave de perfil a medias.
177
+ out.status = 'no-passwords'
178
+ counts.skipped += (d.entries || []).length
179
+ return out
180
+ }
181
+ const byId = new Map(passwords.entries.map((e) => [e.id, e]))
182
+ const byWho = new Map(passwords.entries.map((e) => [whoKey(e), e]))
183
+ for (const e of d.entries || []) {
184
+ const existing = byId.get(e.id) || byWho.get(whoKey(e)) || null
185
+ const status = !existing ? 'new' : (entryContent(existing) === entryContent(e) ? 'same' : 'conflict')
186
+ counts[status]++
187
+ out.items.push({ key: e.id, title: e.title || (e.sites || [])[0] || '', username: e.username || '', status, ...(existing ? { currentId: existing.id } : {}) })
188
+ }
189
+ return out
190
+ }
191
+
192
+ /**
193
+ * LA DECISIÓN DE CADA CHOQUE. `onConflict` vale para todos; `decisions` (por `itemRef`) manda
194
+ * sobre él. Si queda alguno sin decidir, se para con la lista entera: importar a medias y
195
+ * descubrir el resto después es peor que no importar.
196
+ */
197
+ export function resolvePlan (plan, { onConflict = null, decisions = {} } = {}) {
198
+ if (onConflict && onConflict !== 'keep' && onConflict !== 'replace') {
199
+ throw coded(`unknown conflict policy "${onConflict}" (use keep or replace)`, 'BAD_POLICY')
200
+ }
201
+ const pending = []
202
+ for (const d of plan.drawers) {
203
+ for (const it of d.items) {
204
+ if (it.status !== 'conflict') continue
205
+ const r = decisions[itemRef(d.to, it.key)] || onConflict
206
+ if (r !== 'keep' && r !== 'replace') { pending.push({ drawer: d.to, key: it.key, ...(it.title ? { title: it.title } : {}) }); continue }
207
+ it.resolution = r
208
+ }
209
+ }
210
+ if (pending.length) {
211
+ throw coded(`${pending.length} conflict(s) without a decision: nothing was imported`, 'BACKUP_CONFLICTS', { conflicts: pending, plan })
212
+ }
213
+ return plan
214
+ }
215
+
216
+ /**
217
+ * LO QUE HAY QUE ESCRIBIR, ya resuelto: `[{ to, key, value, public }]` para los cajones y
218
+ * `[{ entry, replaces }]` para las contraseñas. Lo `same` y lo `keep` no se tocan.
219
+ *
220
+ * La VISIBILIDAD: si lo que había era privado, se queda privado aunque el respaldo diga
221
+ * público. Hacer público un secreto no puede colarse en una importación (es la misma regla
222
+ * de `PrivateStaysPrivate`), y se anota para decirlo.
223
+ */
224
+ export function writesOf (plan, payload) {
225
+ const vars = []
226
+ const entries = []
227
+ const byOwner = new Map(payload.drawers.map((d) => [d.owner, d]))
228
+ for (const d of plan.drawers) {
229
+ if (d.status !== 'ok') continue
230
+ const src = byOwner.get(d.from)
231
+ for (const it of d.items) {
232
+ if (it.status === 'same' || (it.status === 'conflict' && it.resolution !== 'replace')) continue
233
+ if (d.from === PASSWORDS_DRAWER) {
234
+ const entry = src.entries.find((e) => e.id === it.key)
235
+ entries.push({ entry, replaces: it.status === 'conflict' ? it.currentId : null })
236
+ continue
237
+ }
238
+ const v = src.vars[it.key]
239
+ const keptPrivate = it.currentPublic === false && !!v.public
240
+ vars.push({ to: d.to, key: it.key, value: v.value, public: keptPrivate ? false : !!v.public, ...(keptPrivate ? { keptPrivate: true } : {}) })
241
+ }
242
+ }
243
+ return { vars, entries }
244
+ }
@@ -5,4 +5,4 @@
5
5
  * si no coincide con la del paquete: una versión anunciada que no es la que corre es peor
6
6
  * que no anunciar ninguna.
7
7
  */
8
- export const LIB_VERSION = '0.86.1'
8
+ export const LIB_VERSION = '0.90.0'