@dotrino/identity 0.114.1 → 0.116.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.
Files changed (81) hide show
  1. package/package.json +7 -1
  2. package/src/index.js +9 -1
  3. package/src/node.js +4 -180
  4. package/vault/acta.js +5 -1
  5. package/vault/core.js +95 -15
  6. package/vault/facade.js +200 -0
  7. package/vault/index.html +10 -0
  8. package/vault/machine.js +95 -0
  9. package/vault/remote.js +54 -9
  10. package/vault/vault.js +29 -58
  11. package/vault/vendor/compat/VERSION.txt +3 -0
  12. package/vault/vendor/compat/advisory.js +141 -0
  13. package/vault/vendor/compat/index.js +188 -0
  14. package/vault/vendor/compat/ranges.js +93 -0
  15. package/vault/vendor/opaque/VERSION.txt +1 -1
  16. package/vault/vendor/passmanager/VERSION.txt +3 -0
  17. package/vault/vendor/passmanager/crypto.js +167 -0
  18. package/vault/vendor/passmanager/fields.js +183 -0
  19. package/vault/vendor/passmanager/generate.js +74 -0
  20. package/vault/vendor/passmanager/import.js +156 -0
  21. package/vault/vendor/passmanager/index.js +27 -0
  22. package/vault/vendor/passmanager/match.js +137 -0
  23. package/vault/vendor/passmanager/model.js +332 -0
  24. package/vault/vendor/passmanager/pubkey.js +33 -0
  25. package/vault/vendor/passmanager/sealed/convert.js +72 -0
  26. package/vault/vendor/passmanager/sealed/device.js +211 -0
  27. package/vault/vendor/passmanager/sealed/entry.js +209 -0
  28. package/vault/vendor/passmanager/sealed/index.js +20 -0
  29. package/vault/vendor/passmanager/sealed/keys.js +120 -0
  30. package/vault/vendor/passmanager/sealed/recovery.js +136 -0
  31. package/vault/vendor/passmanager/sealed/store.js +508 -0
  32. package/vault/vendor/passmanager/session-cache.js +60 -0
  33. package/vault/vendor/passmanager/totp.js +111 -0
  34. package/vault/vendor/passmanager/transport/protocol.js +83 -0
  35. package/vault/vendor/passmanager/transport/proxy.js +112 -0
  36. package/vault/vendor/passmanager/transport/sealed.js +74 -0
  37. package/vault/vendor/passmanager/vault/approval.js +63 -0
  38. package/vault/vendor/passmanager/vault/errors.js +34 -0
  39. package/vault/vendor/passmanager/vault/guard.js +114 -0
  40. package/vault/vendor/passmanager/vault/interface.js +34 -0
  41. package/vault/vendor/passmanager/vault/local.js +213 -0
  42. package/vault/vendor/passmanager/vault/remote.js +85 -0
  43. package/vault/vendor/passmanager/vault/responder.js +192 -0
  44. package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
  45. package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
  46. package/vault/vendor/passmanager/vault/sealed.js +362 -0
  47. package/vault/vendor/passmanager/webauthn.js +221 -0
  48. package/vault/vendor/proxy-client/VERSION.txt +1 -1
  49. package/vault/vendor/proxy-client/client.js +36 -0
  50. package/vault/vendor/roadmap/VERSION.txt +3 -0
  51. package/vault/vendor/roadmap/index.js +108 -0
  52. package/vault/vendor/roadmap/manifests.js +797 -0
  53. package/vault/vendor/store/VERSION.txt +3 -0
  54. package/vault/vendor/store/core.js +309 -0
  55. package/vault/vendor/vault/VERSION.txt +4 -1
  56. package/vault/vendor/vault/admin.js +437 -0
  57. package/vault/vendor/vault/browser.js +167 -0
  58. package/vault/vendor/vault/core/approvals.js +204 -0
  59. package/vault/vendor/vault/core/blocked.js +68 -0
  60. package/vault/vendor/vault/core/browserDisk.js +109 -0
  61. package/vault/vendor/vault/core/commKey.js +68 -0
  62. package/vault/vendor/vault/core/disk.js +65 -0
  63. package/vault/vendor/vault/core/libVersion.js +8 -0
  64. package/vault/vendor/vault/core/machines.js +66 -0
  65. package/vault/vendor/vault/core/notices.js +71 -0
  66. package/vault/vendor/vault/core/notifiable.js +73 -0
  67. package/vault/vendor/vault/core/passwords.js +125 -0
  68. package/vault/vendor/vault/core/sealKey.js +77 -0
  69. package/vault/vendor/vault/core/sealer.js +172 -0
  70. package/vault/vendor/vault/core/sealers.js +159 -0
  71. package/vault/vendor/vault/core/secretsStore.js +1326 -0
  72. package/vault/vendor/vault/core/sha256.js +69 -0
  73. package/vault/vendor/vault/core/store.js +68 -0
  74. package/vault/vendor/vault/core/subacta.js +101 -0
  75. package/vault/vendor/vault/core/threadStore.js +273 -0
  76. package/vault/vendor/vault/core/transport.js +270 -0
  77. package/vault/vendor/vault/core/vault.js +4414 -0
  78. package/vault/vendor/vault/enroll.js +8 -4
  79. package/vault/vendor/vault/procContext.js +97 -0
  80. package/vault/vendor/vault/revocation.js +67 -0
  81. package/vault/vendor/vault/sealed.js +84 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * SHA-256 SÍNCRONO, en JavaScript puro.
3
+ *
4
+ * El navegador solo trae `crypto.subtle.digest`, que es asíncrono, y la bitácora de la
5
+ * bóveda encadena cada línea con el hash de la anterior en el mismo paso en que la escribe:
6
+ * volverlo asíncrono dejaría dos entradas apuntando a la misma anterior. Así el binario y
7
+ * el navegador encadenan exactamente igual, y lo comprueba la prueba contra `node:crypto`.
8
+ */
9
+ const K = new Uint32Array([
10
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
11
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
12
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
13
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
14
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
15
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
16
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
17
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
18
+ ])
19
+
20
+ /** @param {Uint8Array} data */
21
+ export function sha256Bytes (data) {
22
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19])
23
+ const len = data.length
24
+ const total = ((len + 9 + 63) >> 6) << 6
25
+ const buf = new Uint8Array(total)
26
+ buf.set(data)
27
+ buf[len] = 0x80
28
+ const bits = len * 8
29
+ const view = new DataView(buf.buffer)
30
+ view.setUint32(total - 8, Math.floor(bits / 0x100000000))
31
+ view.setUint32(total - 4, bits >>> 0)
32
+ const w = new Uint32Array(64)
33
+ for (let off = 0; off < total; off += 64) {
34
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4)
35
+ for (let i = 16; i < 64; i++) {
36
+ const a = w[i - 15], b = w[i - 2]
37
+ const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3)
38
+ const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10)
39
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0
40
+ }
41
+ let [a, b, c, d, e, f, g, hh] = h
42
+ for (let i = 0; i < 64; i++) {
43
+ const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7))
44
+ const ch = (e & f) ^ (~e & g)
45
+ const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0
46
+ const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10))
47
+ const maj = (a & b) ^ (a & c) ^ (b & c)
48
+ const t2 = (S0 + maj) >>> 0
49
+ hh = g; g = f; f = e; e = (d + t1) >>> 0
50
+ d = c; c = b; b = a; a = (t1 + t2) >>> 0
51
+ }
52
+ h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0
53
+ h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0; h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + hh) >>> 0
54
+ }
55
+ const out = new Uint8Array(32)
56
+ const ov = new DataView(out.buffer)
57
+ for (let i = 0; i < 8; i++) ov.setUint32(i * 4, h[i])
58
+ return out
59
+ }
60
+
61
+ /** El hash de un texto (UTF-8), en hexadecimal. Lo mismo que `createHash('sha256').update(text, 'utf8').digest('hex')`. */
62
+ export function sha256Hex (text) {
63
+ const bytes = typeof text === 'string' ? new TextEncoder().encode(text) : text
64
+ let s = ''
65
+ for (const b of sha256Bytes(bytes)) s += b.toString(16).padStart(2, '0')
66
+ return s
67
+ }
68
+
69
+ export default { sha256Bytes, sha256Hex }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Store del árbol de contenidos del vault (`vault.json`). **Cifrado en reposo**
3
+ * con la misma clave ligada a la máquina que la identidad (`atrest.js`): el
4
+ * contenido del usuario no es menos sensible que la maestra. NO guarda
5
+ * identidad/dispositivos/certs: de eso se encarga
6
+ * `@dotrino/identity` dentro del mismo dir (keypair, contactos, delegaciones,
7
+ * revocaciones). Aquí vive solo lo del usuario: el árbol y los settings.
8
+ */
9
+ import { readJson, writeJson } from './disk.js'
10
+
11
+ const SCHEMA_VERSION = 1
12
+
13
+ function newTree () {
14
+ return { id: 'root', name: '', type: 'folder', children: [] }
15
+ }
16
+
17
+ function findNode (node, id) {
18
+ if (!node) return null
19
+ if (node.id === id) return node
20
+ if (Array.isArray(node.children)) {
21
+ for (const c of node.children) {
22
+ const r = findNode(c, id)
23
+ if (r) return r
24
+ }
25
+ }
26
+ return null
27
+ }
28
+
29
+ export function openStore (disk) {
30
+ const file = 'vault.json'
31
+ let data = readJson(disk, file, null)
32
+ if (!data || data.schemaVersion !== SCHEMA_VERSION) {
33
+ data = { schemaVersion: SCHEMA_VERSION, tree: newTree(), settings: {} }
34
+ }
35
+ // Se reescribe SIEMPRE al abrir: así un archivo de una instalación anterior
36
+ // (en claro) queda cifrado sin pedirle nada al usuario.
37
+ writeJson(disk, file, data)
38
+ const save = () => writeJson(disk, file, data)
39
+
40
+ return {
41
+ get raw () { return data },
42
+ getTree () { return data.tree },
43
+ getNode (id) { return findNode(data.tree, id || 'root') },
44
+ addNode (parentId, node) {
45
+ const parent = findNode(data.tree, parentId || 'root')
46
+ if (!parent) throw new Error('parent node not found: ' + parentId)
47
+ if (!Array.isArray(parent.children)) parent.children = []
48
+ parent.children.push(node)
49
+ save()
50
+ return node
51
+ },
52
+ removeNode (id) {
53
+ const remove = (node) => {
54
+ if (!Array.isArray(node.children)) return false
55
+ const i = node.children.findIndex((c) => c.id === id)
56
+ if (i >= 0) { node.children.splice(i, 1); return true }
57
+ return node.children.some(remove)
58
+ }
59
+ const ok = remove(data.tree)
60
+ if (ok) save()
61
+ return ok
62
+ },
63
+ getSetting (k) { return data.settings[k] },
64
+ setSetting (k, v) { if (v === undefined) delete data.settings[k]; else data.settings[k] = v; save() },
65
+ /** Todos los ajustes (copia). Para quien necesita buscar por prefijo. */
66
+ listSettings () { return { ...data.settings } }
67
+ }
68
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * subacta.js — LO DECIDIDO QUE TODAVÍA NO SELLÓ EL ACTA.
3
+ *
4
+ * Idea del dueño (2026-09-04): *«una subacta efímera que se comparte entre todos los
5
+ * dispositivos y puede tener decisiones a futuro que se aceptan cuando el acta se abre»*.
6
+ *
7
+ * EL AGUJERO QUE TAPA. Sellar el acta es de la maestra, y con el perfil cerrado la maestra
8
+ * no está. Así que una renuncia —«me quito estos permisos»— no surtía NINGÚN efecto hasta
9
+ * que alguien iba a la máquina y tecleaba la contraseña. Justo el caso que la renuncia
10
+ * existe para cubrir: te roban el teléfono, renuncias desde él, y el ladrón sigue siendo
11
+ * admin hasta que llegues. El pilar ya tenía la pieza (`extraRenounces`) y no la pasaba
12
+ * nadie.
13
+ *
14
+ * LA REGLA DURA, y es la que sostiene todo lo demás:
15
+ *
16
+ * LA SUBACTA SOLO PUEDE QUITAR, NUNCA DAR.
17
+ *
18
+ * De ahí salen las tres propiedades que la hacen segura sin maestra:
19
+ *
20
+ * · **Se autentica sola.** Cada entrada la firma EL MIEMBRO AL QUE AFECTA. No hace falta
21
+ * sellador, así que no hay una segunda autoridad al lado del acta.
22
+ * · **Una entrada falsa o repetida cuesta disponibilidad, no autoridad.** Lo peor que
23
+ * consigue quien cuele una es quitarle permisos a alguien: molesto y reversible.
24
+ * · **Nadie puede quitarle nada a otro.** Si valiera la firma de cualquiera, un servicio
25
+ * con `secrets` en un VPS alquilado podría dejar sin `admin` a tu teléfono.
26
+ *
27
+ * Y por eso el catálogo de operaciones es una LISTA BLANCA de una sola: `renounce`. La
28
+ * lista es la regla — si algún día entra otra, tiene que quitar y venir firmada por su
29
+ * sujeto, y se escribe aquí a propósito, no se cuela.
30
+ *
31
+ * JUNTAR DOS ES UNIR. Dos entradas nunca se contradicen porque las dos quitan, así que no
32
+ * hay conflictos ni hace falta un `seq`. Y **solo salen al absorberse** en el acta: nunca
33
+ * por reloj, o un aparato apagado perdería justo la que lo protege.
34
+ */
35
+ import { verifyRenounce } from '@dotrino/identity/acta'
36
+ import { readJson, writeJson } from './disk.js'
37
+
38
+ /** Las operaciones que caben. La lista ES la regla: todas quitan, todas las firma su sujeto. */
39
+ export const OPS = Object.freeze(['renounce'])
40
+
41
+ /** Identidad de una entrada, para no guardar dos veces la misma. */
42
+ const idOf = (e) => `${e.op}|${e.member}|${[...(e.caps || [])].sort().join(',')}|${e.ts}`
43
+
44
+ /**
45
+ * ¿Es una entrada que se puede aceptar? Comprueba la FORMA y la FIRMA; que quite es
46
+ * estructural (el catálogo) y no una comprobación aparte.
47
+ */
48
+ export async function isAcceptable (e) {
49
+ if (!e || !OPS.includes(e.op)) return false
50
+ if (e.op === 'renounce') return verifyRenounce(e).catch(() => false)
51
+ return false
52
+ }
53
+
54
+ /**
55
+ * La subacta de un perfil. Vive en su directorio y va cifrada en reposo como todo lo
56
+ * demás: sus entradas dicen qué aparatos se quedaron sin qué, y eso es material de la
57
+ * cuenta aunque no sea un secreto.
58
+ */
59
+ export function openSubacta (disk) {
60
+ const file = 'subacta.json'
61
+ let data = readJson(disk, file, null) || { v: 1, entries: [] }
62
+ const guardar = () => writeJson(disk, file, data)
63
+
64
+ return {
65
+ /** Las entradas vigentes, tal cual se le pasan a `memberCan` y compañía. */
66
+ entries () { return [...data.entries] },
67
+ get count () { return data.entries.length },
68
+
69
+ /** Las de UN miembro. Es lo que se mira al decidir sobre él. */
70
+ forMember (pub) { return data.entries.filter((e) => e.member === pub) },
71
+
72
+ /**
73
+ * Acepta una entrada. Devuelve `{ok, reason}` — y un `false` aquí no es un fallo del
74
+ * que la manda: puede ser que ya la tuviéramos, que es el caso normal cuando dos
75
+ * aparatos se cuentan lo mismo.
76
+ */
77
+ async add (e) {
78
+ if (!(await isAcceptable(e))) return { ok: false, reason: 'no-aceptable' }
79
+ const id = idOf(e)
80
+ if (data.entries.some((x) => idOf(x) === id)) return { ok: false, reason: 'ya-estaba' }
81
+ data.entries = [...data.entries, e]
82
+ guardar()
83
+ return { ok: true, reason: 'nueva' }
84
+ },
85
+
86
+ /**
87
+ * SACA TODO para absorberlo en el acta, y vacía. Se llama con la bóveda ABIERTA, que es
88
+ * la única que puede sellar. Vaciar aquí y fallar el sellado perdería la entrada, así
89
+ * que quien llama tiene que absorber primero y vaciar después (`clear`).
90
+ */
91
+ drain () { return [...data.entries] },
92
+
93
+ /** Vacía lo ya absorbido. Se le pasa lo que se absorbió, no «todo»: mientras se sella
94
+ * puede haber entrado una nueva, y tirarla sería perder una renuncia sin aplicar. */
95
+ clear (absorbidas) {
96
+ const ids = new Set(absorbidas.map(idOf))
97
+ data.entries = data.entries.filter((e) => !ids.has(idOf(e)))
98
+ guardar()
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Sub-store de HILOS + APERTURAS del vault (Fase 3: store centralizado).
3
+ *
4
+ * Espeja el modelo de datos de `@dotrino/store` (store.dotrino.com):
5
+ * threads: { [threadKey]: Entry[] } (Entry tiene `id` + `ts`, opaco para el store)
6
+ * opens: { [appId]: { count, ts } } (contador de "recientes" del hub)
7
+ * para que un dispositivo emparejado pueda guardar su contenido EN el vault del
8
+ * usuario (su propio servidor) en vez de —o además de— el IndexedDB del navegador.
9
+ *
10
+ * File-backed (`threads.json`), síncrono y simple (sin cuota/IndexedDB). Es el
11
+ * backend autoritativo; el navegador usa su IndexedDB como caché y sincroniza.
12
+ * **Cifrado en reposo** con la clave ligada a la máquina (`atrest.js`): aquí
13
+ * vive el contenido de las apps y el perfil del usuario, que es exactamente lo
14
+ * que el ecosistema promete que no queda en claro en ningún disco.
15
+ */
16
+ import * as core from '@dotrino/store/core'
17
+ import { resealStale } from '@dotrino/identity/content'
18
+ import { readJson, writeJson } from './disk.js'
19
+
20
+ // El MISMO tope que acepta la página del almacén. Era 1000: una app que guarda más en un
21
+ // hilo (los compradores de facturero, por ejemplo) perdía aquí lo más viejo sin decir nada,
22
+ // y el navegador lo volvía a subir en cada sincronización.
23
+ const MAX_PER_THREAD = core.MAX_PER_THREAD_LIMIT
24
+
25
+ // DATOS SENSIBLES (F4): topes para que un dispositivo con `vault:store` no pueda
26
+ // llenar el disco de la bóveda. Son generosos para el uso real (unas contraseñas,
27
+ // notas, un documento corto) y ridículos para un abuso.
28
+ // LO QUE SE VUELVE A CERRAR AL ROTAR NO SE BORRA: la versión anterior de cada sobre se
29
+ // guarda como respaldo AL MENOS UN AÑO (política del dueño, 2026-09-30). No se sirve por
30
+ // ningún método; solo está por si volver a cerrar sale mal.
31
+ export const RESEALED_KEEP_MS = 366 * 24 * 60 * 60 * 1000
32
+
33
+ const MAX_SECURE_ITEMS = 2000
34
+ const MAX_SECURE_BLOB = 64 * 1024 // por campo sellado (meta y valor)
35
+
36
+ export function openThreadStore (disk) {
37
+ const file = 'threads.json'
38
+ let data = readJson(disk, file, null)
39
+ if (!data || typeof data !== 'object') data = { v: 1, threads: {}, opens: {} }
40
+ if (!data.threads) data.threads = {}
41
+ if (!data.opens) data.opens = {}
42
+ if (!data.secure) data.secure = {}
43
+ // Lápidas de lo borrado (`@dotrino/store/core`): sin ellas un borrado vuelve desde el
44
+ // primer aparato que todavía tenga la entrada.
45
+ if (!data.tombs) data.tombs = {}
46
+ core.pruneTombs(data.tombs, Date.now())
47
+ const save = () => writeJson(disk, file, data)
48
+ save() // reescribe al abrir: cifra lo que venía en claro
49
+ const budget = (n) => Math.min(Math.max(Number(n) || core.PAGE_BYTES, 1024), core.PAGE_BYTES)
50
+ const needKeys = (keys) => {
51
+ if (!Array.isArray(keys) || !keys.every((k) => typeof k === 'string')) throw new Error('keys must be a list of thread keys')
52
+ return keys
53
+ }
54
+ const needMap = (value, what) => {
55
+ if (value == null) return {}
56
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error(`${what} must be an object`)
57
+ return value
58
+ }
59
+
60
+ const methods = {
61
+ appendMessage ({ threadKey, entry }) {
62
+ core.writeEntry(data.threads, data.tombs, threadKey, entry, { max: MAX_PER_THREAD, now: Date.now(), newId: () => crypto.randomUUID() })
63
+ save(); return entry
64
+ },
65
+ listThread ({ threadKey, limit, before }) {
66
+ if (!core.validKey(threadKey) || !Object.hasOwn(data.threads, threadKey)) return []
67
+ let arr = data.threads[threadKey]
68
+ if (typeof before === 'number') arr = arr.filter((e) => (e.ts || 0) < before)
69
+ if (typeof limit === 'number' && limit > 0) arr = arr.slice(-limit)
70
+ return arr
71
+ },
72
+ listThreadKeys () { return Object.keys(data.threads) },
73
+ getThreadSummaries () {
74
+ const out = {}
75
+ for (const [k, arr] of Object.entries(data.threads)) out[k] = { lastEntry: arr.length ? arr[arr.length - 1] : null, count: arr.length }
76
+ return out
77
+ },
78
+ removeThread ({ threadKey }) {
79
+ const removed = core.removeWholeThread(data.threads, data.tombs, threadKey, Date.now())
80
+ if (removed) save()
81
+ return { removed }
82
+ },
83
+ removeMessage ({ threadKey, id }) {
84
+ const removed = core.removeEntry(data.threads, data.tombs, threadKey, id, Date.now())
85
+ if (removed) save()
86
+ return { removed }
87
+ },
88
+ recordOpen ({ appId }) {
89
+ if (!appId || typeof appId !== 'string') throw new Error('appId required')
90
+ const prev = data.opens[appId]
91
+ data.opens[appId] = { count: (prev?.count || 0) + 1, ts: Date.now() }
92
+ save(); return data.opens[appId]
93
+ },
94
+ getOpens () { return { ...data.opens } },
95
+ clearOpens () { data.opens = {}; save(); return { ok: true } },
96
+ /** Mezcla el contador de un aparato (el mayor de cada lado) y devuelve el resultado. */
97
+ mergeOpens ({ opens }) {
98
+ if (core.mergeOpens(data.opens, needMap(opens, 'opens'))) save()
99
+ return { ...data.opens }
100
+ },
101
+ exportThreads () { return { threads: data.threads } },
102
+ // ----- SINCRONIZAR POR PARTES (@dotrino/store ≥ 0.11) -----
103
+ // `exportThreads`/`importThreads` a secas mueven TODO en un mensaje, y el proxio corta
104
+ // los frames en 1 MB: con unos cientos de KB de datos la sincronización dejaba de caber.
105
+ // Con esto el aparato pregunta qué hilos difieren (una huella por hilo), baja su índice
106
+ // (id y ts) y pide solo las entradas que le faltan, todo por páginas con tope de bytes.
107
+ // Las reglas —huella, mezcla, lápidas— son las de `@dotrino/store/core`, las mismas que
108
+ // aplica el navegador: si cada lado llevara las suyas, las huellas no coincidirían nunca.
109
+ getThreadDigests ({ keys } = {}) {
110
+ return core.digestsOf(data.threads, keys == null ? undefined : needKeys(keys))
111
+ },
112
+ getThreadIndexes ({ keys, cursor, maxBytes }) {
113
+ return core.indexPage(data.threads, data.tombs, needKeys(keys), cursor || null, budget(maxBytes))
114
+ },
115
+ getEntries ({ refs, maxBytes }) {
116
+ return core.entriesPage(data.threads, needMap(refs, 'refs'), budget(maxBytes))
117
+ },
118
+ importThreads ({ threads, tombs, mode = 'merge' }) {
119
+ if (!threads || typeof threads !== 'object') throw new Error('threads required')
120
+ if (mode === 'replace') { data.threads = threads; save(); return { mode, count: Object.keys(threads).length } }
121
+ if (mode !== 'merge' && mode !== 'upsert') throw new Error(`unknown import mode: ${mode}`)
122
+ const now = Date.now()
123
+ const buried = core.applyTombs(data.threads, data.tombs, needMap(tombs, 'tombs'), now)
124
+ const merged = core.mergeEntries(data.threads, data.tombs, threads, { mode, max: MAX_PER_THREAD })
125
+ core.pruneTombs(data.tombs, now)
126
+ if (buried.tombsChanged || buried.changed.size || merged.size) save()
127
+ return { mode, count: Object.keys(data.threads).length, changed: [...new Set([...buried.changed, ...merged])] }
128
+ },
129
+ // ----- PERFIL del usuario (me): el vault es la copia AUTORITATIVA -----
130
+ // Cada dispositivo emparejado lo empuja al editarlo y lo jala al arrancar →
131
+ // el mismo perfil (apodo/avatar/datos) en todos los dispositivos.
132
+ profileSet ({ me }) {
133
+ if (!me || typeof me !== 'object') throw new Error('me required')
134
+ // nunca guardar llaves de dispositivo (son por-dispositivo)
135
+ const { publickey, encryptionPubkey, ...content } = me
136
+ data.profile = { ...content, updatedAt: content.updatedAt || Date.now() }
137
+ save(); return { ok: true, updatedAt: data.profile.updatedAt }
138
+ },
139
+ profileGet () { return { me: data.profile || null } },
140
+
141
+ // LOS DATOS DEL PERFIL EN SOBRES (`docs/datos-del-perfil.md`). Están aquí solo para
142
+ // que el filtro de métodos los reconozca: los atiende `handleProfile` en `vault.js`,
143
+ // antes de llegar a este store, porque su puerta es otra —`firma` y sin candado—.
144
+ // Si alguna vez alguien los llamara por este camino, contestan que no en vez de
145
+ // hacer algo a medias.
146
+ profilePut () { throw new Error('profilePut is handled before the store: see handleProfile') },
147
+ profileBundle () { throw new Error('profileBundle is handled before the store: see handleProfile') },
148
+ profilePublic () { throw new Error('profilePublic is handled before the store: see handleProfile') },
149
+ profileRecipients () { throw new Error('profileRecipients is handled before the store: see handleProfile') },
150
+
151
+ // ----- DATOS SENSIBLES del usuario (F4, docs/consola-remota.md §6) -----
152
+ //
153
+ // Contraseñas, notas, documentos: van al contenido del perfil, cifrados con la
154
+ // CEK de la cuenta y accesibles con `vault:store` — el mismo camino que hilos y
155
+ // perfil. NO tocan `secrets.json`: ese es el cajón de los SERVICIOS (proxy, geo),
156
+ // acotado por CN y con clave ligada a la máquina. Mismo nombre coloquial, distinto
157
+ // dueño.
158
+ //
159
+ // La bóveda guarda DOS SOBRES OPACOS por ficha y no abre ninguno:
160
+ // `meta` — lo que hace falta para pintar la lista (nombre, tipo, carpeta)
161
+ // `enc` — el valor en sí, que solo viaja cuando abres la ficha
162
+ // Los sella el dispositivo con la clave de contenido (`identity.sealContent`). Que
163
+ // sean dos y no uno es lo que permite listar sin bajar todas las contraseñas, y que
164
+ // el nombre («Banco») tampoco quede legible aquí.
165
+ //
166
+ // Alcance: esto es el ALMACÉN. Una app de contraseñas con generador y
167
+ // autocompletado es otra cosa y no vive aquí.
168
+ 'secure.list' () {
169
+ return Object.values(data.secure)
170
+ .map(({ enc, ...rest }) => rest) // el valor NO viaja al listar
171
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
172
+ },
173
+ 'secure.get' ({ id }) {
174
+ if (!id || typeof id !== 'string') throw new Error('id required')
175
+ return data.secure[id] || null
176
+ },
177
+ 'secure.put' ({ id, meta, enc }) {
178
+ if (typeof enc !== 'string' || !enc) throw new Error('enc required (sealed value)')
179
+ if (meta != null && typeof meta !== 'string') throw new Error('meta must be a sealed string')
180
+ // Se comprueba el TAMAÑO, nunca el contenido: son sobres cerrados.
181
+ if (enc.length > MAX_SECURE_BLOB || (meta || '').length > MAX_SECURE_BLOB) throw new Error('secure: item too large')
182
+ const prev = id ? data.secure[id] : null
183
+ if (!prev && Object.keys(data.secure).length >= MAX_SECURE_ITEMS) throw new Error('secure: too many items')
184
+ const rec = {
185
+ id: prev?.id || id || crypto.randomUUID(),
186
+ ts: prev?.ts || Date.now(),
187
+ updatedAt: Date.now(),
188
+ meta: meta ?? prev?.meta ?? null,
189
+ enc
190
+ }
191
+ data.secure[rec.id] = rec
192
+ save()
193
+ return { id: rec.id, updatedAt: rec.updatedAt }
194
+ },
195
+ 'secure.del' ({ id }) {
196
+ if (!id || typeof id !== 'string') throw new Error('id required')
197
+ const had = !!data.secure[id]
198
+ delete data.secure[id]
199
+ if (had) save()
200
+ return { removed: had ? 1 : 0 }
201
+ },
202
+
203
+ getStats () {
204
+ const threads = {}
205
+ for (const [k, arr] of Object.entries(data.threads)) threads[k] = { count: arr.length }
206
+ return { threadCount: Object.keys(data.threads).length, threads, opensCount: Object.keys(data.opens).length, secureCount: Object.keys(data.secure).length }
207
+ }
208
+ }
209
+ /**
210
+ * VUELVE A CERRAR CON LA LLAVE VIGENTE todo sobre de la cuenta (`t: 'dotrino-cek'`) de
211
+ * una generación anterior que haya en lo guardado. Lo llama la BÓVEDA al abrirse y
212
+ * después de quitar un aparato —que es cuando la llave rota—; NO está en `methods`, así
213
+ * que ningún aparato puede dispararlo.
214
+ *
215
+ * Lo que sirve la bóveda va así siempre con la generación vigente: un aparato que entra
216
+ * después, que solo recibe esa, abre todo; y la vieja deja de abrir nada porque ya no se
217
+ * sirve nada cerrado con ella (dueño, 2026-09-30). Cada entrada que cambia estrena `ts`,
218
+ * para que los aparatos la bajen en su siguiente sincronización.
219
+ *
220
+ * También MIGRA los sobres de la cuenta anteriores a la marca (`{ gen, iv, ct }` a secas,
221
+ * identity < 0.107): si abren con la llave de la cuenta se vuelven a cerrar marcados; si no,
222
+ * no eran de la cuenta y se cuentan en `skipped` sin tocarlos.
223
+ *
224
+ * La versión anterior NO se tira: queda en `resealed` con la fecha, por si algo sale mal
225
+ * (lo pidió el dueño: «podría retenerlos… pero no los envía»), y se guarda AL MENOS un
226
+ * año (`RESEALED_KEEP_MS`). No se sirve por ningún método.
227
+ *
228
+ * @param {{ gen: number, reseal: (env: object) => Promise<object> }} o
229
+ * @returns {Promise<{ changed: number, entries: number, skipped: number }>}
230
+ */
231
+ async function resealStaleEnvelopes ({ gen, reseal }) {
232
+ let changed = 0; let entries = 0; let skipped = 0
233
+ const now = Date.now()
234
+ if (!data.resealed) data.resealed = []
235
+ // Solo sale lo que ya cumplió su año de respaldo.
236
+ const antes = data.resealed.length
237
+ data.resealed = data.resealed.filter((x) => now - (x.at || 0) < RESEALED_KEEP_MS)
238
+ const podados = antes - data.resealed.length
239
+ for (const [key, arr] of Object.entries(data.threads)) {
240
+ for (let i = 0; i < arr.length; i++) {
241
+ const r = await resealStale(arr[i], { gen, reseal })
242
+ skipped += r.skipped || 0
243
+ if (!r.changed) continue
244
+ data.resealed.push({ threadKey: key, at: now, gen, before: arr[i] })
245
+ arr[i] = { ...r.value, ts: Math.max(now, (arr[i].ts || 0) + 1) }
246
+ changed += r.changed; entries++
247
+ }
248
+ }
249
+ if (entries || podados) save()
250
+ return { changed, entries, skipped }
251
+ }
252
+
253
+ return { methods, raw: () => data, resealStaleEnvelopes }
254
+ }
255
+
256
+ /**
257
+ * Métodos del store que son de SOLO LECTURA (para decidir el scope necesario).
258
+ *
259
+ * `secure.list`/`secure.get` NO están aquí a propósito, aunque sean lecturas: los datos
260
+ * sensibles piden `vault:store` (doc §6), que es MÁS estricto que `vault:read`. Un
261
+ * dispositivo al que solo le diste «leer» no lee tus contraseñas.
262
+ */
263
+ export const STORE_READ_METHODS = new Set([
264
+ 'listThread', 'listThreadKeys', 'getThreadSummaries', 'getOpens', 'exportThreads', 'getStats', 'profileGet',
265
+ 'getThreadDigests', 'getThreadIndexes', 'getEntries'
266
+ ])
267
+
268
+ /**
269
+ * Métodos que EDITAN el perfil del usuario (quién es: apodo, avatar, campos).
270
+ * Son los únicos que el candado por contraseña bloquea (`vault.js`): el resto del
271
+ * store —contenido de las apps— sigue disponible con el perfil bloqueado.
272
+ */
273
+ export const PROFILE_EDIT_METHODS = new Set(['profileSet'])