@dotrino/vaultd 0.114.0 → 0.115.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.114.0",
3
+ "version": "0.115.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -26,6 +26,7 @@
26
26
  "@dotrino/passmanager": "^0.10.1",
27
27
  "@dotrino/proxy-client": "0.18.2",
28
28
  "@dotrino/roadmap": "0.2.0",
29
+ "@dotrino/store": "0.11.0",
29
30
  "ws": "^8.18.0"
30
31
  },
31
32
  "license": "MIT",
package/src/approvals.js CHANGED
@@ -23,6 +23,13 @@
23
23
  * otra máquina por el proxio, donde mandarlo en claro filtraría las rutas del dueño— no
24
24
  * tiene huella con la que emparejarse, y entonces se pide aprobación cada vez. No se
25
25
  * inventa una huella «vacía» que valdría para todo: eso sería exactamente un repliegue.
26
+ *
27
+ * Y PARA EL GESTOR DE CONTRASEÑAS ESO ES LO QUE SE QUIERE, no una carencia que arreglar
28
+ * (dueño, 2026-09-11: «para el gestor de contraseñas es perfecto»). La extensión es un
29
+ * navegador hablando por el proxio, no un proceso con línea de comandos, así que nunca va a
30
+ * traer huella — y así cada acceso a una credencial vuelve a pasar por tu mano, que es la
31
+ * protección por la que esto existe. No se le busque un sustituto del comando para que
32
+ * entre en la ventana.
26
33
  */
27
34
  import { commandFingerprint } from '../lib/src/proc.js'
28
35
 
@@ -14,10 +14,14 @@
14
14
  * que el ecosistema promete que no queda en claro en ningún disco.
15
15
  */
16
16
  import path from 'node:path'
17
+ import * as core from '@dotrino/store/core'
17
18
  import { readJson, writeJson } from './paths.js'
18
19
  import { atRestFor } from './atrest.js'
19
20
 
20
- const MAX_PER_THREAD = 1000
21
+ // El MISMO tope que acepta la página del almacén. Era 1000: una app que guarda más en un
22
+ // hilo (los compradores de facturero, por ejemplo) perdía aquí lo más viejo sin decir nada,
23
+ // y el navegador lo volvía a subir en cada sincronización.
24
+ const MAX_PER_THREAD = core.MAX_PER_THREAD_LIMIT
21
25
 
22
26
  // DATOS SENSIBLES (F4): topes para que un dispositivo con `vault:store` no pueda
23
27
  // llenar el disco de la bóveda. Son generosos para el uso real (unas contraseñas,
@@ -33,24 +37,31 @@ export function openThreadStore (dir) {
33
37
  if (!data.threads) data.threads = {}
34
38
  if (!data.opens) data.opens = {}
35
39
  if (!data.secure) data.secure = {}
40
+ // Lápidas de lo borrado (`@dotrino/store/core`): sin ellas un borrado vuelve desde el
41
+ // primer aparato que todavía tenga la entrada.
42
+ if (!data.tombs) data.tombs = {}
43
+ core.pruneTombs(data.tombs, Date.now())
36
44
  const save = () => writeJson(file, data, atRest)
37
45
  save() // reescribe al abrir: cifra lo que venía en claro
38
- const trim = (arr) => { if (arr.length > MAX_PER_THREAD) arr.splice(0, arr.length - MAX_PER_THREAD) }
46
+ const budget = (n) => Math.min(Math.max(Number(n) || core.PAGE_BYTES, 1024), core.PAGE_BYTES)
47
+ const needKeys = (keys) => {
48
+ if (!Array.isArray(keys) || !keys.every((k) => typeof k === 'string')) throw new Error('keys must be a list of thread keys')
49
+ return keys
50
+ }
51
+ const needMap = (value, what) => {
52
+ if (value == null) return {}
53
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error(`${what} must be an object`)
54
+ return value
55
+ }
39
56
 
40
57
  const methods = {
41
58
  appendMessage ({ threadKey, entry }) {
42
- if (!threadKey || typeof threadKey !== 'string') throw new Error('threadKey required')
43
- if (!entry || typeof entry !== 'object') throw new Error('entry required')
44
- if (!entry.id) entry.id = crypto.randomUUID()
45
- if (!entry.ts) entry.ts = Date.now()
46
- const arr = data.threads[threadKey] || (data.threads[threadKey] = [])
47
- const i = arr.findIndex((e) => e.id === entry.id)
48
- if (i >= 0) arr[i] = { ...arr[i], ...entry }; else arr.push(entry)
49
- trim(arr); save(); return entry
59
+ core.writeEntry(data.threads, data.tombs, threadKey, entry, { max: MAX_PER_THREAD, now: Date.now(), newId: () => crypto.randomUUID() })
60
+ save(); return entry
50
61
  },
51
62
  listThread ({ threadKey, limit, before }) {
52
- if (!threadKey) return []
53
- let arr = data.threads[threadKey] || []
63
+ if (!core.validKey(threadKey) || !Object.hasOwn(data.threads, threadKey)) return []
64
+ let arr = data.threads[threadKey]
54
65
  if (typeof before === 'number') arr = arr.filter((e) => (e.ts || 0) < before)
55
66
  if (typeof limit === 'number' && limit > 0) arr = arr.slice(-limit)
56
67
  return arr
@@ -62,14 +73,14 @@ export function openThreadStore (dir) {
62
73
  return out
63
74
  },
64
75
  removeThread ({ threadKey }) {
65
- const removed = data.threads[threadKey]?.length || 0
66
- delete data.threads[threadKey]; save(); return { removed }
76
+ const removed = core.removeWholeThread(data.threads, data.tombs, threadKey, Date.now())
77
+ if (removed) save()
78
+ return { removed }
67
79
  },
68
80
  removeMessage ({ threadKey, id }) {
69
- const arr = data.threads[threadKey] || []; const before = arr.length
70
- data.threads[threadKey] = arr.filter((e) => e.id !== id)
71
- if (data.threads[threadKey].length === 0) delete data.threads[threadKey]
72
- save(); return { removed: before - (data.threads[threadKey]?.length || 0) }
81
+ const removed = core.removeEntry(data.threads, data.tombs, threadKey, id, Date.now())
82
+ if (removed) save()
83
+ return { removed }
73
84
  },
74
85
  recordOpen ({ appId }) {
75
86
  if (!appId || typeof appId !== 'string') throw new Error('appId required')
@@ -79,17 +90,38 @@ export function openThreadStore (dir) {
79
90
  },
80
91
  getOpens () { return { ...data.opens } },
81
92
  clearOpens () { data.opens = {}; save(); return { ok: true } },
93
+ /** Mezcla el contador de un aparato (el mayor de cada lado) y devuelve el resultado. */
94
+ mergeOpens ({ opens }) {
95
+ if (core.mergeOpens(data.opens, needMap(opens, 'opens'))) save()
96
+ return { ...data.opens }
97
+ },
82
98
  exportThreads () { return { threads: data.threads } },
83
- importThreads ({ threads, mode = 'merge' }) {
99
+ // ----- SINCRONIZAR POR PARTES (@dotrino/store ≥ 0.11) -----
100
+ // `exportThreads`/`importThreads` a secas mueven TODO en un mensaje, y el proxio corta
101
+ // los frames en 1 MB: con unos cientos de KB de datos la sincronización dejaba de caber.
102
+ // Con esto el aparato pregunta qué hilos difieren (una huella por hilo), baja su índice
103
+ // (id y ts) y pide solo las entradas que le faltan, todo por páginas con tope de bytes.
104
+ // Las reglas —huella, mezcla, lápidas— son las de `@dotrino/store/core`, las mismas que
105
+ // aplica el navegador: si cada lado llevara las suyas, las huellas no coincidirían nunca.
106
+ getThreadDigests ({ keys } = {}) {
107
+ return core.digestsOf(data.threads, keys == null ? undefined : needKeys(keys))
108
+ },
109
+ getThreadIndexes ({ keys, cursor, maxBytes }) {
110
+ return core.indexPage(data.threads, data.tombs, needKeys(keys), cursor || null, budget(maxBytes))
111
+ },
112
+ getEntries ({ refs, maxBytes }) {
113
+ return core.entriesPage(data.threads, needMap(refs, 'refs'), budget(maxBytes))
114
+ },
115
+ importThreads ({ threads, tombs, mode = 'merge' }) {
84
116
  if (!threads || typeof threads !== 'object') throw new Error('threads required')
85
117
  if (mode === 'replace') { data.threads = threads; save(); return { mode, count: Object.keys(threads).length } }
86
- for (const [k, arr] of Object.entries(threads)) {
87
- const cur = data.threads[k] || (data.threads[k] = [])
88
- const byId = new Map(cur.map((e) => [e.id, e]))
89
- for (const e of arr) { if (!e?.id) continue; const pr = byId.get(e.id); if (!pr || (e.ts || 0) > (pr.ts || 0)) byId.set(e.id, e) }
90
- data.threads[k] = Array.from(byId.values()).sort((a, b) => (a.ts || 0) - (b.ts || 0)); trim(data.threads[k])
91
- }
92
- save(); return { mode, count: Object.keys(data.threads).length }
118
+ if (mode !== 'merge' && mode !== 'upsert') throw new Error(`unknown import mode: ${mode}`)
119
+ const now = Date.now()
120
+ const buried = core.applyTombs(data.threads, data.tombs, needMap(tombs, 'tombs'), now)
121
+ const merged = core.mergeEntries(data.threads, data.tombs, threads, { mode, max: MAX_PER_THREAD })
122
+ core.pruneTombs(data.tombs, now)
123
+ if (buried.tombsChanged || buried.changed.size || merged.size) save()
124
+ return { mode, count: Object.keys(data.threads).length, changed: [...new Set([...buried.changed, ...merged])] }
93
125
  },
94
126
  // ----- PERFIL del usuario (me): el vault es la copia AUTORITATIVA -----
95
127
  // Cada dispositivo emparejado lo empuja al editarlo y lo jala al arrancar →
@@ -182,7 +214,8 @@ export function openThreadStore (dir) {
182
214
  * dispositivo al que solo le diste «leer» no lee tus contraseñas.
183
215
  */
184
216
  export const STORE_READ_METHODS = new Set([
185
- 'listThread', 'listThreadKeys', 'getThreadSummaries', 'getOpens', 'exportThreads', 'getStats', 'profileGet'
217
+ 'listThread', 'listThreadKeys', 'getThreadSummaries', 'getOpens', 'exportThreads', 'getStats', 'profileGet',
218
+ 'getThreadDigests', 'getThreadIndexes', 'getEntries'
186
219
  ])
187
220
 
188
221
  /**
package/src/vault.js CHANGED
@@ -936,7 +936,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
936
936
  // `method: 'toString'` (o cualquier miembro heredado de Object) pasaba el filtro y
937
937
  // se llamaba como si fuera del store.
938
938
  if (!d || typeof d.method !== 'string' || !Object.hasOwn(threads.methods, d.method)) {
939
- return reply(from, { type: MSG.ERROR, error: 'store: invalid method' })
939
+ // Con `code`: un aparato más nuevo que esta bóveda pide métodos que aquí no existen, y
940
+ // tiene que poder decir «actualiza la bóveda» en vez de enseñar la frase.
941
+ return reply(from, { type: MSG.ERROR, error: 'store: invalid method', code: 'store-unknown-method' })
940
942
  }
941
943
  if (!isFresh(d)) return staleReply(from)
942
944
  // EL PERFIL VA POR OTRO CAMINO, y por eso se atiende antes que nada