@dotrino/vaultd 0.143.0 → 0.145.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.
@@ -59,6 +59,16 @@ try {
59
59
  throw e
60
60
  }
61
61
 
62
+ // POR DÓNDE VA EL TRÁFICO, AL LOG. La bóveda tiene el canal directo encendido para los
63
+ // aparatos del acta, y sin esto no había forma de ver si lo usan o si todo sigue dando la
64
+ // vuelta por el proxio. Una vez por PROCESO y no por perfil: `logStats` lee todos los
65
+ // clientes que haya (uno por perfil abierto). Escribe con `console.log` en el momento, así
66
+ // que con `--tui` cae en el mismo búfer que el resto y no pinta encima de la pantalla.
67
+ {
68
+ const { logStats } = await import('@dotrino/proxy-client')
69
+ logStats({ label: 'vault' })
70
+ }
71
+
62
72
  // Atajo de dev: --pair imprime el QR directo en stdout (en producción se usa el CLI).
63
73
  // Empareja contra el perfil ACTIVO.
64
74
  if (process.argv.includes('--pair')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.143.0",
3
+ "version": "0.145.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": {
@@ -25,7 +25,7 @@
25
25
  "@dotrino/identity": "^0.107.1",
26
26
  "@dotrino/opaque": "0.1.0",
27
27
  "@dotrino/passmanager": "0.17.0",
28
- "@dotrino/proxy-client": "0.27.1",
28
+ "@dotrino/proxy-client": "0.30.1",
29
29
  "@dotrino/roadmap": "0.3.19",
30
30
  "@dotrino/store": "0.11.0",
31
31
  "@dotrino/update": "0.2.2",
package/src/ctl.js CHANGED
@@ -153,6 +153,12 @@ function cmdStatus () {
153
153
  console.log(' %s⬆ hay %s publicada%s (esta es la %s) · tráela con: dotrino-vault update',
154
154
  B, s.latest.version, Z, VERSION)
155
155
  }
156
+ // …Y LLEVA DÍAS PIDIENDO PERMISO SIN RESPUESTA. Es un bloqueo que desde fuera no se ve:
157
+ // la bóveda parece normal y simplemente no se actualiza.
158
+ if (s.updateAsk?.version && isNewer(s.updateAsk.version, VERSION)) {
159
+ console.log(' pidió permiso para instalarla %s (%d %s) y nadie lo ha aprobado · mira Pedidos en tu teléfono',
160
+ hace(s.updateAsk.since), s.updateAsk.asks, s.updateAsk.asks === 1 ? 'vez' : 'veces')
161
+ }
156
162
  const profiles = s.profiles || []
157
163
  if (profiles.length) {
158
164
  console.log(' perfiles : %d', profiles.length)
@@ -2380,6 +2386,14 @@ async function cmdReplica (args) {
2380
2386
  process.exit(2)
2381
2387
  }
2382
2388
 
2389
+ /** «hace 3 días», «hace 5 horas»: para decir desde cuándo espera algo. */
2390
+ function hace (ms) {
2391
+ const h = Math.max(0, Math.floor((Date.now() - ms) / 3600000))
2392
+ if (h < 1) return 'hace menos de una hora'
2393
+ if (h < 48) return `hace ${h} ${h === 1 ? 'hora' : 'horas'}`
2394
+ return `hace ${Math.floor(h / 24)} días`
2395
+ }
2396
+
2383
2397
  export async function runCtl (argv) {
2384
2398
  const [cmd, ...rest] = takeProfileFlag(argv)
2385
2399
  // `--help` EN CUALQUIER COMANDO ENSEÑA LA AYUDA Y NO HACE NADA. Ningún subcomando lo miraba,
package/src/daemon.js CHANGED
@@ -92,6 +92,12 @@ export async function runDaemon () {
92
92
  const daemonVersion = VERSION
93
93
  /** `{ version, checkedAt }` de la última consulta que salió bien, o null si aún ninguna. */
94
94
  let ultimaPublicada = null
95
+ /**
96
+ * EL PEDIDO DE ACTUALIZACIÓN QUE NADIE HA CONTESTADO: `{ version, since, asks }`, o null.
97
+ * Va en la foto para que `status` y la TUI lo DIGAN. Sin esto, una bóveda que lleva días
98
+ * pidiendo permiso y sin respuesta se veía igual que una al día.
99
+ */
100
+ let pedidoSinRespuesta = null
95
101
  // Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
96
102
  // instalador y la web, que son anteriores al multi-perfil. La lista completa va
97
103
  // en `profiles`.
@@ -107,6 +113,7 @@ export async function runDaemon () {
107
113
  // sin salir a la red: una pantalla de estado tiene que contestar al instante y
108
114
  // también sin conexión.
109
115
  ...(ultimaPublicada ? { latest: ultimaPublicada } : {}),
116
+ ...(pedidoSinRespuesta ? { updateAsk: pedidoSinRespuesta } : {}),
110
117
  current: mgr.currentId(), profiles: mgr.summary()
111
118
  })
112
119
  }
@@ -1107,8 +1114,24 @@ export async function runDaemon () {
1107
1114
  * actualizarse nunca. Entonces se actualiza sola, que es lo que hace el resto del
1108
1115
  * ecosistema (§15).
1109
1116
  */
1110
- let quienAprueba = []
1111
- try { quienAprueba = (await mgr.current()?.approvers?.()) || [] } catch (_) {}
1117
+ // SI NO SE PUEDE SABER QUIÉN APRUEBA, NO SE ACTUALIZA. Antes un fallo aquí dejaba la
1118
+ // lista vacía, y lista vacía es «nadie aprueba, adelante»: el repliegue que abre la
1119
+ // puerta justo cuando algo se rompió. Se dice y se vuelve a mirar en la próxima pasada.
1120
+ let enElActa
1121
+ try { enElActa = await mgr.current().approvers() } catch (e) {
1122
+ console.log(`[vault] version ${r.version} is out, but the approvers could not be read (${e?.message || e}) · not updating`)
1123
+ return
1124
+ }
1125
+ /**
1126
+ * Y SOLO CUENTA EL APROBADOR QUE PUEDE ENTERARSE (dueño, 2026-10-07). Uno que no recibe
1127
+ * avisos no ve el pedido si no abre la app por su cuenta: la bóveda pedía cada día y se
1128
+ * quedaba atrás para siempre, que es el mismo bloqueo de «sin aprobadores» un paso
1129
+ * después. Lo dice el propio aparato, firmado, y caduca (`src/notifiable.js`).
1130
+ *
1131
+ * Esto vale SOLO para actualizarse. Lo que se instala se verifica igual contra la
1132
+ * atestación del release, se haya pedido permiso o no.
1133
+ */
1134
+ const quienAprueba = enElActa.filter((a) => a.notifiedAt)
1112
1135
 
1113
1136
  // INSTALADA COMO PAQUETE DEL SISTEMA (.deb en /usr/bin): el binario es de root y este
1114
1137
  // proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
@@ -1124,10 +1147,19 @@ export async function runDaemon () {
1124
1147
  if (quienAprueba.length) {
1125
1148
  console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · asking one of ${quienAprueba.length} approver(s)`)
1126
1149
  const si = await mgr.current().askUpdateApproval({ version: r.version })
1127
- if (!si) { console.log(`[vault] update to ${r.version}: not approved (denied or expired) · it will ask again`); return }
1150
+ if (!si) {
1151
+ const antes = pedidoSinRespuesta?.version === r.version ? pedidoSinRespuesta : null
1152
+ pedidoSinRespuesta = { version: r.version, since: antes?.since || Date.now(), asks: (antes?.asks || 0) + 1 }
1153
+ writeState()
1154
+ console.log(`[vault] update to ${r.version}: not approved (denied or expired) · asked ${pedidoSinRespuesta.asks} time(s) since ${new Date(pedidoSinRespuesta.since).toISOString()} · it will ask again`)
1155
+ return
1156
+ }
1157
+ } else if (enElActa.length) {
1158
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · ${enElActa.length} approver(s) in the record but none receives notifications: updating on its own`)
1128
1159
  } else {
1129
1160
  console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · no approver in the record: updating on its own`)
1130
1161
  }
1162
+ if (pedidoSinRespuesta) { pedidoSinRespuesta = null; writeState() }
1131
1163
  const res = await installUserRelease(r, { log: (m) => console.log(m) })
1132
1164
  if (!res.ok) {
1133
1165
  // Lo que NO cuadra no se instala, y se dice por qué: el archivo queda a mano.
@@ -0,0 +1,75 @@
1
+ /**
2
+ * QUÉ APROBADORES PUEDEN RECIBIR UN AVISO: lo dice cada uno, y caduca.
3
+ *
4
+ * Un aparato con `approve` que no recibe avisos es un aprobador solo en el papel: el pedido
5
+ * no le suena y solo lo ve si abre la app por su cuenta. Para la ACTUALIZACIÓN eso era un
6
+ * bloqueo mudo — la bóveda pedía cada día, nadie se enteraba y se quedaba atrás para siempre
7
+ * (dueño, 2026-10-07).
8
+ *
9
+ * Así que el aprobador lo declara: al pedir su lista de pedidos (`op: 'approvals'`, que ya
10
+ * manda firmado cada vez que se abre) dice `notify: true` si tiene los avisos encendidos.
11
+ * Aquí se apunta CUÁNDO lo dijo por última vez, y vale `NOTIFIABLE_TTL_MS`: una app que se
12
+ * desinstala deja de renovarlo y sale sola de la cuenta.
13
+ *
14
+ * SOLO DECIDE LA ACTUALIZACIÓN. Las claves, las contraseñas y guardar variables siguen
15
+ * pidiendo aprobación mientras haya un aparato con `approve` en el acta, pueda o no recibir
16
+ * avisos: ahí «nadie se entera» se resuelve esperando, no entregando.
17
+ *
18
+ * Y NO SE LE PREGUNTA AL PROXIO, que es quien guarda las suscripciones: es la pieza de la
19
+ * que el diseño no se fía, y creerle sería dejarle decidir cuándo no hace falta permiso.
20
+ *
21
+ * Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer NO es «nadie
22
+ * puede recibir avisos» (eso saltaría la aprobación): se lanza.
23
+ */
24
+ import fs from 'node:fs'
25
+ import path from 'node:path'
26
+
27
+ export const NOTIFIABLE_FILE = 'notifiable.json'
28
+ /** Cuánto vale un «puedo recibir avisos» sin renovarse. */
29
+ export const NOTIFIABLE_TTL_MS = 30 * 24 * 60 * 60 * 1000
30
+
31
+ const fail = (code, message) => Object.assign(new Error(message), { code })
32
+
33
+ /**
34
+ * @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
35
+ */
36
+ export function openNotifiable ({ dir, atRest, now = Date.now }) {
37
+ const file = path.join(dir, NOTIFIABLE_FILE)
38
+ /** pub → ms de la última vez que dijo que sí */
39
+ const items = load()
40
+
41
+ function load () {
42
+ let raw
43
+ try { raw = fs.readFileSync(file, 'utf8') } catch (e) {
44
+ if (e.code === 'ENOENT') return new Map()
45
+ throw fail('notifiable-unreadable', `cannot read ${NOTIFIABLE_FILE}: ${e.message}`)
46
+ }
47
+ let d
48
+ try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} cannot be opened: ${e.message}`) }
49
+ if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} has an unknown shape`)
50
+ return new Map(Object.entries(d.items))
51
+ }
52
+
53
+ function save () {
54
+ const tmp = file + '.tmp'
55
+ fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })), { mode: 0o600 })
56
+ fs.renameSync(tmp, file)
57
+ }
58
+
59
+ return {
60
+ /**
61
+ * Lo que acaba de decir un aprobador. `true` lo apunta (o lo renueva); `false` lo quita
62
+ * ya, sin esperar a que caduque. Cualquier otra cosa no dice nada y no cambia nada: un
63
+ * cliente viejo, que no manda el campo, no borra lo que dijo uno nuevo.
64
+ */
65
+ declare (pub, canNotify) {
66
+ if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'declare: a device key is required')
67
+ if (canNotify === true) { items.set(pub, now()); save() } else if (canNotify === false && items.delete(pub)) save()
68
+ },
69
+ /** Cuándo lo dijo por última vez, o `null` si nunca o si ya caducó. */
70
+ since (pub) {
71
+ const t = items.get(pub)
72
+ return typeof t === 'number' && now() - t < NOTIFIABLE_TTL_MS ? t : null
73
+ }
74
+ }
75
+ }
package/src/tui/app.js CHANGED
@@ -2438,6 +2438,11 @@ function render (term, st) {
2438
2438
  // instalar, peor: el daemon se queda con el binario viejo, ya borrado, y hay dos copias
2439
2439
  // en RAM). `status` ya lo avisa; aquí también, que es donde uno se queda mirando.
2440
2440
  lines[2] = (up && s?.version && VERSION !== 'dev' && s.version !== VERSION) ? ' ' + t.warn(i.daemonStale(s.version, VERSION)) : ''
2441
+ // Una actualización que lleva tiempo pidiendo permiso sin respuesta: la bóveda parece
2442
+ // normal y simplemente no se actualiza, así que se dice aquí.
2443
+ if (!lines[2] && up && s?.updateAsk?.version) {
2444
+ lines[2] = ' ' + t.warn(i.updateUnanswered(s.updateAsk.version, Math.floor((Date.now() - s.updateAsk.since) / 86400000)))
2445
+ }
2441
2446
  // Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
2442
2447
  // el resto muestra su título simple.
2443
2448
  lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + title(i, st.screen))
package/src/tui/i18n.js CHANGED
@@ -26,6 +26,7 @@ const es = {
26
26
  // encabezado / estado
27
27
  daemonRunning: 'corriendo',
28
28
  daemonStopped: 'DETENIDO',
29
+ updateUnanswered: (version, days) => `⬆ la ${version} espera tu aprobación desde hace ${days < 1 ? 'menos de un día' : days === 1 ? '1 día' : days + ' días'}: mira Pedidos en tu teléfono`,
29
30
  daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
30
31
  activeVault: 'Bóveda activa: ',
31
32
  noName: '(sin nombre)',
@@ -498,6 +499,7 @@ const en = {
498
499
 
499
500
  daemonRunning: 'running',
500
501
  daemonStopped: 'STOPPED',
502
+ updateUnanswered: (version, days) => `⬆ ${version} has been waiting for your approval for ${days < 1 ? 'less than a day' : days === 1 ? '1 day' : days + ' days'}: check Requests on your phone`,
501
503
  daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
502
504
  activeVault: 'Active vault: ',
503
505
  tooSmall: 'Terminal too small',
package/src/vault.js CHANGED
@@ -30,6 +30,7 @@ import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './thr
30
30
  import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
31
31
  import { openSubacta } from './subacta.js'
32
32
  import { openBlocked } from './blocked.js'
33
+ import { openNotifiable } from './notifiable.js'
33
34
  import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
34
35
  import { VERSION } from './version.js'
35
36
  import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
@@ -262,6 +263,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
262
263
  const passwordsAtRest = atRestFor(dir)
263
264
  // Los aparatos BLOQUEADOS por un aprobador (`src/blocked.js`). Cifrado en reposo como lo demás.
264
265
  const blocked = openBlocked({ dir, atRest: passwordsAtRest })
266
+ // Qué aprobadores dijeron que pueden recibir avisos (`src/notifiable.js`): solo lo mira la actualización.
267
+ const notifiable = openNotifiable({ dir, atRest: passwordsAtRest })
265
268
  const readPasswordsFile = () => {
266
269
  try { return JSON.parse(passwordsAtRest.decrypt(fs.readFileSync(passwordsFile, 'utf8'))) } catch (_) { return null }
267
270
  }
@@ -1763,6 +1766,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1763
1766
  body = { ...body, ts: Date.now() }
1764
1767
  reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body) })
1765
1768
  }
1769
+ // AL PEDIR SU LISTA, EL APROBADOR DICE SI LE LLEGAN LOS AVISOS (`notify`). Va dentro de lo
1770
+ // que ya firma, así que no hay mensaje nuevo ni nada que el proxio pueda inventar.
1771
+ if (op === 'approvals' && typeof p.data?.notify === 'boolean') {
1772
+ try { notifiable.declare(chk.device, p.data.notify) } catch (e) { log(`[vault] could not record whether ${by || '????-????'} can be notified: ${e.message}`) }
1773
+ }
1766
1774
  if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record) })
1767
1775
  // LO QUE YA ESTÁ APROBADO SE VE Y SE CORTA DESDE DONDE SE APROBÓ. Una concesión que se
1768
1776
  // renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
@@ -4027,7 +4035,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
4027
4035
  if (!record) return []
4028
4036
  return (record.members || [])
4029
4037
  .filter((m) => Acta.memberCan(record, m.pub, 'approve'))
4030
- .map((m) => ({ id: m.id, label: m.label || '', pub: m.pub }))
4038
+ // `notifiedAt`: cuándo dijo por última vez que le llegan los avisos (null = no lo ha
4039
+ // dicho, o caducó). Solo lo usa la actualización para saber si hay a quién pedirle.
4040
+ .map((m) => ({ id: m.id, label: m.label || '', pub: m.pub, notifiedAt: notifiable.since(m.pub) }))
4031
4041
  },
4032
4042
  // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
4033
4043
  // llave. Lo que no puede es listarla un aparato.