@dotrino/vaultd 0.146.0 → 0.148.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.
@@ -620,6 +620,173 @@ export async function fetchDigest ({ dir, ns, proxyUrl, masterPubkey, device, ce
620
620
  } finally { client.close() }
621
621
  }
622
622
 
623
+ /** Cuánto espera un aparato a que alguien apruebe su actualización (el pedido vive un día). */
624
+ export const UPDATE_APPROVAL_TIMEOUT_MS = 24 * 60 * 60 * 1000 + 10 * 1000
625
+
626
+ /**
627
+ * ¿PUEDO ACTUALIZARME? Lo pregunta un aparato a su bóveda ANTES de instalar una versión
628
+ * nueva, y SOLO si tiene encendido su propio interruptor de «pedir aprobación» (CONVENCIONES
629
+ * §15, dueño 2026-10-08: todo se actualiza solo; pedir aprobación es un ajuste de cada pieza,
630
+ * independiente del de la bóveda). Quien no lo tiene encendido no llama a esto.
631
+ *
632
+ * · nadie aprueba en la cuenta → `{ ok: true, asked: false }`, ya.
633
+ * · hay aprobadores → la bóveda contesta «pendiente», timbra a quien aprueba y el resultado
634
+ * llega por esta misma conexión cuando alguien decide: `{ ok, asked: true }`. El pedido
635
+ * dura un día; vencido es no.
636
+ *
637
+ * LANZA si la bóveda no contesta (`code: 'vault-no-reply'`) o si quedó «pendiente» y el
638
+ * resultado no llegó (`code: 'unanswered'`): quien llama decide qué hacer. Que nadie conteste
639
+ * NUNCA es un sí. Preguntar otra vez por lo mismo mientras el pedido vive no vuelve a timbrar;
640
+ * no repetir la pregunta por la misma versión es cosa de quien llama.
641
+ *
642
+ * @param {object} o
643
+ * @param {string} o.product nombre npm de lo que se actualiza, p. ej. `@dotrino/terminal-agent`
644
+ * @param {string} o.version la versión que se quiere instalar
645
+ * @param {string} [o.from] la que corre ahora
646
+ * @param {string} [o.dir] identidad guardada por `enrollService`; o, en su lugar, la
647
+ * identidad por parámetros (`proxyUrl`, `masterPubkey`, `device`, `cert`), como en los demás.
648
+ * @param {(p: { id: string, exp: number }) => void} [o.onPending]
649
+ * @returns {Promise<{ ok: boolean, asked: boolean }>}
650
+ */
651
+ export async function askUpdateApproval ({ product, version, from = null, dir, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000, approvalTimeoutMs = UPDATE_APPROVAL_TIMEOUT_MS, onPending } = {}) {
652
+ const saved = dir ? readServiceIdentity(dir) : null
653
+ proxyUrl = proxyUrl || saved?.proxy
654
+ masterPubkey = masterPubkey || saved?.iss
655
+ device = device || saved?.device
656
+ cert = cert || saved?.cert
657
+ if (typeof product !== 'string' || !product || typeof version !== 'string' || !version) throw new Error('askUpdateApproval: needs product and version')
658
+ if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
659
+ const client = await freshClient(proxyUrl, 20000, masterPubkey)
660
+ try {
661
+ await identifyAsService(client, device)
662
+ const data = { op: 'update-ask', product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
663
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
664
+ const mine = (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'update-ask' && p.body.product === product && p.body.version === version)
665
+ const check = async (res) => {
666
+ if (res.type === MSG.ERROR) throw vaultError(res)
667
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
668
+ if (typeof res.body.ts !== 'number' || Math.abs(Date.now() - res.body.ts) > FRESH_WINDOW_MS) throw new Error('stale update-ask reply')
669
+ return res.body
670
+ }
671
+ // UNA COLA, no dos esperas seguidas: el resultado puede llegar mientras todavía se
672
+ // comprueba el «pendiente», y una escucha puesta después lo perdería (y se esperaría un
673
+ // día por algo que ya se contestó).
674
+ const queue = []
675
+ let wake = null
676
+ const off = client.on('message', (_from, p) => { if (p && typeof p === 'object' && mine(p)) { queue.push(p); wake?.() } })
677
+ const next = (ms, code, message) => new Promise((resolve, reject) => {
678
+ if (queue.length) return resolve(queue.shift())
679
+ const t = setTimeout(() => { wake = null; reject(Object.assign(new Error(message), { code })) }, ms)
680
+ wake = () => { clearTimeout(t); wake = null; resolve(queue.shift()) }
681
+ })
682
+ let body
683
+ try {
684
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
685
+ body = await check(await next(timeoutMs, 'vault-no-reply', 'update-ask: the vault did not answer'))
686
+ if (body.pending === true) {
687
+ try { onPending?.({ id: body.id, exp: body.exp }) } catch (_) {}
688
+ const until = typeof body.exp === 'number' ? Math.max(5000, body.exp - Date.now() + 5000) : approvalTimeoutMs
689
+ do {
690
+ body = await check(await next(Math.min(until, approvalTimeoutMs), 'unanswered', 'update-ask: nobody answered the request in time'))
691
+ } while (body.pending === true)
692
+ }
693
+ } finally { off() }
694
+ if (typeof body.ok !== 'boolean') throw new Error('malformed update-ask reply')
695
+ return { ok: body.ok, asked: body.asked === true }
696
+ } finally { client.close() }
697
+ }
698
+
699
+ /**
700
+ * ME ACTUALICÉ: un aparato se lo dice a su bóveda DESPUÉS de instalar, y ella se lo cuenta a
701
+ * quien aprueba en la cuenta (un aviso, y queda 24 h en la lista de avisos). Se llama solo
702
+ * si el aparato tiene encendido su interruptor de avisos de actualización.
703
+ *
704
+ * Quién es el aparato lo pone la bóveda desde el acta, no lo que él diga.
705
+ * LANZA si la bóveda no contesta (`code: 'vault-no-reply'`) o no pudo avisar.
706
+ *
707
+ * @param {object} o `product`, `version`, `from` y la conexión, como en `askUpdateApproval`
708
+ * @returns {Promise<{ ok: true }>}
709
+ */
710
+ export function reportUpdated (o = {}) { return reportToVault('update-done', o) }
711
+
712
+ /**
713
+ * HAY VERSIÓN NUEVA Y NO PUEDO INSTALARLA SOLO: necesita permisos de administrador (el
714
+ * servicio está instalado para todo el sistema, no como usuario). El aparato se lo dice a su
715
+ * bóveda y ella avisa a quien aprueba, que es quien puede ir a esa máquina e instalarla a
716
+ * mano (dueño, 2026-10-08). No instala nada ni pide permiso: solo avisa.
717
+ *
718
+ * Mismos parámetros y errores que `reportUpdated`. Avisar una sola vez por versión es cosa
719
+ * de quien llama.
720
+ * @returns {Promise<{ ok: true }>}
721
+ */
722
+ export function reportUpdateNeedsRoot (o = {}) { return reportToVault('update-needs-root', o) }
723
+
724
+ /**
725
+ * LOS TRES GANCHOS DE ACTUALIZACIÓN, YA ARMADOS para pasárselos a `watchSelfUpdateNpm` de
726
+ * `@dotrino/update/npm`: una pieza, para que cada agente no lo escriba por su cuenta. No
727
+ * depende de ese paquete: solo devuelve funciones con la forma que él espera.
728
+ *
729
+ * · `mayUpdate({ version, from })` → pregunta a la bóveda y devuelve si se puede. Si la
730
+ * bóveda NO CONTESTA, lanza: para quien llama eso es «no pude preguntar» y se reintenta.
731
+ * Si el pedido VENCE sin respuesta devuelve `false`: vencido es no, y no se repite.
732
+ * · `onUpdated({ version, from })` → le dice a la bóveda que se actualizó. Deja pasar el
733
+ * error, para que quien llama conserve su marcador y lo reintente.
734
+ * · `onNeedsRoot({ version, from })` → le dice que hay versión nueva y necesita permisos de
735
+ * administrador. Igual.
736
+ *
737
+ * @param {object} o
738
+ * @param {string} o.product nombre npm de la pieza
739
+ * @param {(m: string) => void} [o.log]
740
+ * …y la conexión como en los demás: `dir`, o `proxyUrl` + `masterPubkey` + `device` + `cert`.
741
+ * @returns {{ mayUpdate: (u: { version: string, from?: string }) => Promise<boolean>,
742
+ * onUpdated: (u: { version: string, from?: string }) => Promise<void>,
743
+ * onNeedsRoot: (u: { version: string, from?: string }) => Promise<void> }}
744
+ */
745
+ export function vaultUpdateHooks ({ product, log = () => {}, dir, proxyUrl, masterPubkey, device, cert, _ask = askUpdateApproval, _done = reportUpdated, _root = reportUpdateNeedsRoot } = {}) {
746
+ if (typeof product !== 'string' || !product) throw new Error('vaultUpdateHooks: needs product')
747
+ const conn = { dir, proxyUrl, masterPubkey, device, cert }
748
+ return {
749
+ async mayUpdate ({ version, from = null } = {}) {
750
+ let r
751
+ try { r = await _ask({ ...conn, product, version, from, onPending: () => log(`[update] ${product} ${version}: waiting for approval on your vault`) }) } catch (e) {
752
+ if (e?.code === 'unanswered') { log(`[update] ${product} ${version}: nobody answered in time, so it is a no`); return false }
753
+ throw e
754
+ }
755
+ log(`[update] ${product} ${version}: ${r.ok ? (r.asked ? 'approved' : 'no approval needed') : 'not approved'}`)
756
+ return r.ok === true
757
+ },
758
+ async onUpdated ({ version, from = null } = {}) { await _done({ ...conn, product, version, from }) },
759
+ async onNeedsRoot ({ version, from = null } = {}) { await _root({ ...conn, product, version, from }) }
760
+ }
761
+ }
762
+
763
+ /** Lo común de los dos avisos: firmar, mandar, y comprobar quién contesta. */
764
+ async function reportToVault (op, { product, version, from = null, dir, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
765
+ const saved = dir ? readServiceIdentity(dir) : null
766
+ proxyUrl = proxyUrl || saved?.proxy
767
+ masterPubkey = masterPubkey || saved?.iss
768
+ device = device || saved?.device
769
+ cert = cert || saved?.cert
770
+ if (typeof product !== 'string' || !product || typeof version !== 'string' || !version) throw new Error(`${op}: needs product and version`)
771
+ if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
772
+ const client = await freshClient(proxyUrl, 20000, masterPubkey)
773
+ try {
774
+ await identifyAsService(client, device)
775
+ const data = { op, product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
776
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
777
+ const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === op && p.body.product === product && p.body.version === version), timeoutMs)
778
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
779
+ const res = await pending.catch((e) => {
780
+ if (/timeout/.test(e.message)) throw Object.assign(new Error(`${op}: the vault did not answer`), { code: 'vault-no-reply' })
781
+ throw e
782
+ })
783
+ if (res.type === MSG.ERROR) throw vaultError(res)
784
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
785
+ if (res.body.ok !== true) throw new Error(`malformed ${op} reply`)
786
+ return { ok: true }
787
+ } finally { client.close() }
788
+ }
789
+
623
790
  /**
624
791
  * Pide los secretos del ns al vault (una petición puntual; lanza si falla).
625
792
  * Usa la identidad persistida por `enrollService` salvo que se pase explícita.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.146.0",
3
+ "version": "0.148.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": {
package/src/ctl.js CHANGED
@@ -79,6 +79,10 @@ const D = '\x1b[2m' // apagado: para el dato que acompaña sin competir con el n
79
79
  * En español, como el resto de esta CLI: con el idioma del sistema salía «Jul 29, 2026»
80
80
  * en medio de una frase en español, que es peor que cualquiera de los dos por separado.
81
81
  */
82
+ function fechaHora (ms) {
83
+ try { return new Date(ms).toLocaleString('es', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) }
84
+ catch (_) { return '' }
85
+ }
82
86
  function fechaCorta (ms) {
83
87
  try { return new Date(ms).toLocaleDateString('es', { year: 'numeric', month: 'short', day: 'numeric' }) }
84
88
  catch (_) { return '' }
@@ -158,15 +162,22 @@ function cmdStatus () {
158
162
  console.log(' %s⬆ hay %s publicada%s (esta es la %s) · tráela con: dotrino-vault update',
159
163
  B, s.latest.version, Z, VERSION)
160
164
  }
161
- // …Y LLEVA DÍAS PIDIENDO PERMISO SIN RESPUESTA. Es un bloqueo que desde fuera no se ve:
162
- // la bóveda parece normal y simplemente no se actualiza.
165
+ // …Y SE PIDIÓ PERMISO. El pedido se hace UNA vez por versión y dura un día: mientras
166
+ // espera se dice hasta cuándo; si no se aprobó, que ya no se vuelve a pedir y cómo
167
+ // instalarla. Es un freno que desde fuera no se ve: la bóveda parece normal.
163
168
  if (s.updateAsk?.version && isNewer(s.updateAsk.version, VERSION)) {
164
- console.log(' pidió permiso para instalarla %s (%d %s) y nadie lo ha aprobado · mira Pedidos en tu teléfono',
165
- hace(s.updateAsk.since), s.updateAsk.asks, s.updateAsk.asks === 1 ? 'vez' : 'veces')
169
+ const u = s.updateAsk
170
+ if (u.result === 'pending') {
171
+ console.log(' se pidió permiso para instalar la %s el %s y está esperando respuesta hasta el %s · mira Pedidos en tu teléfono',
172
+ u.version, fechaHora(u.askedAt), fechaHora(u.askedAt + 24 * 60 * 60 * 1000))
173
+ } else {
174
+ console.log(' se pidió permiso para instalar la %s el %s y no se aprobó: no se vuelve a pedir · instálala con: dotrino-vault update',
175
+ u.version, fechaHora(u.askedAt))
176
+ }
166
177
  // A QUIÉN SE LE PIDIÓ, y si dijo que le llegan los avisos: el que no lo ha dicho es el
167
178
  // primer sitio donde mirar cuando nadie contesta.
168
179
  for (const a of s.updateAsk.approvers || []) {
169
- console.log(' %s %s · %s', a.id, a.label || '(sin nombre)', avisosTxt(a.notifiedAt))
180
+ console.log(' %s %s%s · %s', a.id, a.label || '(sin nombre)', a.profile ? ` [${a.profile}]` : '', avisosTxt(a.notifiedAt))
170
181
  }
171
182
  }
172
183
  // CÓMO SE ACTUALIZA. Va siempre, también al día: es un ajuste y se mira aquí.
@@ -1813,6 +1824,18 @@ async function waitForService (version, { timeoutMs = 4 * 60_000 } = {}) {
1813
1824
  return false
1814
1825
  }
1815
1826
 
1827
+ /**
1828
+ * DEJA DICHO QUE SE INSTALÓ una versión nueva, para que el servicio se lo cuente a quien
1829
+ * aprueba cuando arranque con ella (`anunciarActualizacion` en daemon.js). Es el mismo
1830
+ * marcador que deja el servicio cuando se actualiza solo. Si no se puede escribir se dice:
1831
+ * la actualización está hecha, lo que se pierde es el aviso.
1832
+ */
1833
+ function noteUpdated (to) {
1834
+ try { ipcWrite(path.join(dir, 'updated.json'), { v: 1, from: readState().version || VERSION, to, at: Date.now() }) } catch (e) {
1835
+ console.error('No se pudo dejar el aviso de actualización para tus aprobadores: %s', e.message)
1836
+ }
1837
+ }
1838
+
1816
1839
  async function cmdUpdate (args = []) {
1817
1840
  // PEDIR PERMISO PARA ACTUALIZARSE es un ajuste, apagado por defecto. Se guarda aquí y el
1818
1841
  // servicio lo lee la próxima vez que mire si hay versión nueva: no hace falta reiniciarlo.
@@ -1823,7 +1846,7 @@ async function cmdUpdate (args = []) {
1823
1846
  if (v === 'on' || v === 'off') writeSettings(dir, { updateApproval: v === 'on' })
1824
1847
  else if (v !== undefined) { console.error('uso: dotrino-vault update --approval [on|off]'); process.exit(2) }
1825
1848
  console.log(readSettings(dir).updateApproval
1826
- ? 'Esta bóveda pide aprobación antes de actualizarse (a los aparatos con «aprueba»).'
1849
+ ? 'Esta bóveda pide aprobación antes de actualizarse (a los aparatos con «aprueba» de cualquiera de sus perfiles).'
1827
1850
  : 'Esta bóveda se actualiza sola, sin pedir aprobación.')
1828
1851
  return
1829
1852
  }
@@ -1858,6 +1881,7 @@ async function cmdUpdate (args = []) {
1858
1881
  }
1859
1882
  console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
1860
1883
  console.log('Instalada la %s.', res.version)
1884
+ noteUpdated(res.version)
1861
1885
  if (!args.includes('--no-wait')) await waitForService(res.version)
1862
1886
  return
1863
1887
  }
@@ -1897,6 +1921,7 @@ async function cmdUpdate (args = []) {
1897
1921
  B, file, r.version, Z)
1898
1922
  return
1899
1923
  }
1924
+ noteUpdated(r.version)
1900
1925
  console.log('Listo. El servicio se reinicia solo; compruébalo con: dotrino-vault status')
1901
1926
  }
1902
1927
 
@@ -2209,7 +2234,8 @@ function help () {
2209
2234
  servicio la arranque · --check solo mira · --no-wait no espera
2210
2235
  update --approval [on|off]
2211
2236
  el servicio se actualiza solo; con «on» antes pide aprobación a los
2212
- aparatos con «aprueba» · sin valor, dice cómo está
2237
+ aparatos con «aprueba» de cualquier perfil (vale para todos) · sin
2238
+ valor, dice cómo está
2213
2239
  pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
2214
2240
  pair --kms <config.json>
2215
2241
  el sitio que se cree (--adopt o --new-account) NACE con su clave
package/src/daemon.js CHANGED
@@ -28,10 +28,15 @@ import { watchBinary } from './selfupdate.js'
28
28
  import { latestRelease, isNewer, CHECK_EVERY_MS, isUserInstall, installUserRelease } from './update.js'
29
29
  import { VERSION } from './version.js'
30
30
  import { readSettings } from './settings.js'
31
+ import { approversOfAll, firstYes, readAsked, writeAsked, clearAsked, shouldAsk, resultOfNo, readRootTold, writeRootTold, shouldTellRoot } from './updateApproval.js'
32
+ import { UPDATE_TTL_MS } from './approvals.js'
31
33
  import crypto from 'node:crypto'
32
34
 
33
35
  const readJsonSafe = (f) => ipcRead(f, null)
34
36
  const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
37
+ /** Cada cuánto se reintenta contar la actualización, y cuántas veces (≈10 min). */
38
+ const ANNOUNCE_EVERY_MS = 5000
39
+ const ANNOUNCE_TRIES = 120
35
40
 
36
41
  /**
37
42
  * UNA sola bóveda por directorio de datos.
@@ -90,13 +95,18 @@ export async function runDaemon () {
90
95
 
91
96
  // --- state.json ---
92
97
  const stateFile = path.join(dir, 'state.json')
98
+ // Lo que deja quien instala una versión nueva, para que la nueva lo cuente al arrancar.
99
+ const updatedFile = path.join(dir, 'updated.json')
100
+ /** Los perfiles que están atendiendo ahora mismo, con su nombre para los mensajes. */
101
+ const perfilesEnMarcha = () => [...mgr.running.entries()].map(([id, vault]) => ({ id, vault, name: mgr.profiles.get(id)?.name || id }))
93
102
  const daemonVersion = VERSION
94
103
  /** `{ version, checkedAt }` de la última consulta que salió bien, o null si aún ninguna. */
95
104
  let ultimaPublicada = null
96
105
  /**
97
- * EL PEDIDO DE ACTUALIZACIÓN QUE NADIE HA CONTESTADO: `{ version, since, asks }`, o null.
98
- * Va en la foto para que `status` y la TUI lo DIGAN. Sin esto, una bóveda que lleva días
99
- * pidiendo permiso y sin respuesta se veía igual que una al día.
106
+ * EL PEDIDO DE ACTUALIZACIÓN: `{ version, askedAt, result, approvers }`, o null. `result`
107
+ * es `pending` mientras espera (un día) y `denied`/`expired` cuando no se aprobó — y
108
+ * entonces no se vuelve a pedir por esa versión. Va en la foto para que `status` y la TUI
109
+ * lo DIGAN: una bóveda que no se actualiza por esto se vería igual que una al día.
100
110
  */
101
111
  let pedidoSinRespuesta = null
102
112
  // Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
@@ -1131,11 +1141,16 @@ export async function runDaemon () {
1131
1141
  console.log(`[vault] version ${r.version} is out, but the settings could not be read (${e?.message || e}) · not updating`)
1132
1142
  return
1133
1143
  }
1134
- // Y si hay que pedir y no se puede saber a quién, tampoco: lista vacía sería «nadie
1135
- // aprueba, adelante», el repliegue que abre la puerta justo cuando algo se rompió.
1144
+ // EN TODOS LOS PERFILES: el ajuste es de la bóveda, no de una cuenta (dueño, 2026-10-08).
1145
+ // Basta con que UNO tenga quien apruebe para que se pida, y se le pide a todos.
1146
+ //
1147
+ // Y si hay que pedir y no se puede saber a quién, tampoco se actualiza: una lista vacía
1148
+ // o a medias sería «nadie aprueba, adelante», el repliegue que abre la puerta justo
1149
+ // cuando algo se rompió.
1150
+ const enMarcha = perfilesEnMarcha()
1136
1151
  let quienAprueba = []
1137
1152
  if (pedirPermiso) {
1138
- try { quienAprueba = await mgr.current().approvers() } catch (e) {
1153
+ try { quienAprueba = await approversOfAll(enMarcha) } catch (e) {
1139
1154
  console.log(`[vault] version ${r.version} is out, but the approvers could not be read (${e?.message || e}) · not updating`)
1140
1155
  return
1141
1156
  }
@@ -1146,31 +1161,79 @@ export async function runDaemon () {
1146
1161
  // proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
1147
1162
  if (!isUserInstall()) {
1148
1163
  console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · installed as a system package, so it cannot update itself: \`dotrino-vault update\` (needs sudo), or move to a user install with the tarball's install.sh`)
1164
+ // …Y SE LE DICE A QUIEN APRUEBA, en todos los perfiles (dueño, 2026-10-08: «si se
1165
+ // requiere root por algún motivo, simplemente se notifica al aprobador que hay
1166
+ // actualización y necesita root»). El log no lo lee nadie; el teléfono sí. UNA vez por
1167
+ // versión, apuntado para que un reinicio no lo repita (`update-root-told.json`).
1168
+ let dicho
1169
+ try { dicho = readRootTold(dir) } catch (e) {
1170
+ console.log(`[vault] update: whether the approvers were already told could not be read (${e?.message || e}) · not telling them again`)
1171
+ return
1172
+ }
1173
+ if (shouldTellRoot(dicho, r.version, isNewer) && avisandoRoot !== r.version) {
1174
+ avisandoRoot = r.version
1175
+ contarATodos({ ev: 'update-needs-root', version: r.version, from: daemonVersion }, ({ todos, alguno }) => {
1176
+ avisandoRoot = null
1177
+ // Con que salió en algún perfil ya está dicho: repetirlo mañana a los que sí lo
1178
+ // oyeron por uno que no pudo sería timbrar de más.
1179
+ if (todos || alguno) { try { writeRootTold(dir, { version: r.version, at: Date.now() }) } catch (e) { console.log(`[vault] update: could not note that the approvers were told: ${e.message}`) } }
1180
+ else console.log(`[vault] update: the approvers could not be told that ${r.version} needs administrator rights · it will try again on the next check`)
1181
+ })
1182
+ }
1149
1183
  return
1150
1184
  }
1151
1185
  if (actualizando) return
1152
1186
  actualizando = true
1153
1187
  try {
1154
- if (quienAprueba.length) {
1155
- console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · asking one of ${quienAprueba.length} approver(s) · ${conAvisos} of them said notifications reach it`)
1156
- const si = await mgr.current().askUpdateApproval({ version: r.version })
1188
+ // (`aprobada`: ya dijeron que sí a esta versión y la instalación falló; no se vuelve a
1189
+ // preguntar por lo que ya se aprobó, se reintenta instalar.)
1190
+ if (quienAprueba.length && aprobada !== r.version) {
1191
+ // UNA VEZ POR VERSIÓN (dueño, 2026-10-08): el pedido dura un día y no se repite. Si
1192
+ // se denegó o venció, esa versión queda sin aprobar y se vuelve a preguntar solo
1193
+ // cuando salga una MÁS NUEVA. Lo preguntado se apunta al pedir y sobrevive a un
1194
+ // reinicio (`update-asked.json`); si no se puede leer, no se actualiza y se dice.
1195
+ let preguntado
1196
+ try { preguntado = readAsked(dir) } catch (e) {
1197
+ console.log(`[vault] version ${r.version} is out, but what was already asked could not be read (${e?.message || e}) · not updating`)
1198
+ return
1199
+ }
1200
+ const quienes = quienAprueba.map((a) => ({ profile: a.profile, id: a.id, label: a.label, notifiedAt: a.notifiedAt }))
1201
+ if (!shouldAsk(preguntado, r.version, isNewer)) {
1202
+ // Un «pendiente» que no es de este proceso es de antes de un reinicio: el pedido
1203
+ // murió con él, y nadie puede contestarlo ya.
1204
+ if (preguntado.result === 'pending') { preguntado = { ...preguntado, result: 'expired' }; writeAsked(dir, preguntado) }
1205
+ const nuevo = pedidoSinRespuesta?.version !== preguntado.version || pedidoSinRespuesta?.result !== preguntado.result
1206
+ pedidoSinRespuesta = { ...preguntado, approvers: quienes }
1207
+ writeState()
1208
+ if (nuevo) console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · approval for ${preguntado.version} was asked on ${new Date(preguntado.askedAt).toISOString()} and not given (${preguntado.result}) · not asking again until a newer version · install it with: dotrino-vault update`)
1209
+ return
1210
+ }
1211
+ const askedAt = Date.now()
1212
+ writeAsked(dir, { version: r.version, askedAt, result: 'pending' })
1213
+ pedidoSinRespuesta = { version: r.version, askedAt, result: 'pending', approvers: quienes }
1214
+ writeState()
1215
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · asking ${quienAprueba.length} approver(s) across ${new Set(quienAprueba.map((a) => a.profileId)).size} profile(s), once · ${conAvisos} of them said notifications reach it`)
1216
+ // Un pedido por perfil que tenga quien apruebe, a la vez. El primer sí basta, y los
1217
+ // que queden vivos en los demás se retiran (`src/updateApproval.js`).
1218
+ const conAprobador = new Set(quienAprueba.map((a) => a.profileId))
1219
+ const si = await firstYes(enMarcha.filter((p) => conAprobador.has(p.id)).map((p) => p.vault.askUpdateApproval({ version: r.version })))
1157
1220
  if (!si) {
1158
- const antes = pedidoSinRespuesta?.version === r.version ? pedidoSinRespuesta : null
1159
- pedidoSinRespuesta = {
1160
- version: r.version, since: antes?.since || Date.now(), asks: (antes?.asks || 0) + 1,
1161
- // A QUIÉN SE LE PIDIÓ, y si dijo que le llegan los avisos: es lo que hay que mirar
1162
- // cuando nadie contesta.
1163
- approvers: quienAprueba.map((a) => ({ id: a.id, label: a.label, notifiedAt: a.notifiedAt }))
1164
- }
1221
+ const result = resultOfNo(askedAt, UPDATE_TTL_MS)
1222
+ writeAsked(dir, { version: r.version, askedAt, result })
1223
+ pedidoSinRespuesta = { version: r.version, askedAt, result, approvers: quienes }
1165
1224
  writeState()
1166
- 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`)
1225
+ console.log(`[vault] update to ${r.version}: not approved (${result}) · it will not ask again for this version · install it with: dotrino-vault update`)
1167
1226
  return
1168
1227
  }
1228
+ aprobada = r.version
1229
+ } else if (quienAprueba.length) {
1230
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · already approved: installing`)
1169
1231
  } else if (pedirPermiso) {
1170
- console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · approval is on but no approver in the record: updating on its own`)
1232
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · approval is on but no profile has an approver: updating on its own`)
1171
1233
  } else {
1172
1234
  console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · updating on its own`)
1173
1235
  }
1236
+ clearAsked(dir)
1174
1237
  if (pedidoSinRespuesta) { pedidoSinRespuesta = null; writeState() }
1175
1238
  const res = await installUserRelease(r, { log: (m) => console.log(m) })
1176
1239
  if (!res.ok) {
@@ -1183,6 +1246,12 @@ export async function runDaemon () {
1183
1246
  // minutos con el CLI y la TUI nuevos hablando con el daemon viejo; el dueño abrió la
1184
1247
  // TUI justo ahí y no arrancó (2026-09-24). Solo bajo systemd, que es quien nos levanta:
1185
1248
  // sin él, irnos sería quedarnos apagados.
1249
+ // DEJAR DICHO QUE SE INSTALÓ, para que la versión nueva se lo cuente a quien aprueba al
1250
+ // arrancar (`anunciarActualizacion`). Si no se puede escribir se dice: la actualización
1251
+ // está hecha igual, lo que se pierde es el aviso.
1252
+ try { ipcWrite(updatedFile, { v: 1, from: daemonVersion, to: r.version, at: Date.now() }) } catch (e) {
1253
+ console.log(`[vault] update: ${r.version} installed, but the note to tell the approvers could not be written: ${e.message}`)
1254
+ }
1186
1255
  if (vigia) {
1187
1256
  console.log(`[vault] update: ${r.version} installed · restarting now to run it`)
1188
1257
  shutdown(`updated to ${r.version}`)
@@ -1194,6 +1263,58 @@ export async function runDaemon () {
1194
1263
  } finally { actualizando = false }
1195
1264
  }
1196
1265
  let actualizando = false
1266
+ let aprobada = null
1267
+ let avisandoRoot = null
1268
+
1269
+ /**
1270
+ * «ME ACTUALICÉ»: se lo cuenta a quien aprueba la versión NUEVA, ya corriendo (dueño,
1271
+ * 2026-10-08). Quien instaló —este daemon o `dotrino-vault update`— dejó `updated.json`;
1272
+ * si dice la versión que corre ahora, cada perfil avisa a sus aprobadores en cuanto está
1273
+ * identificado en el proxio, y el marcador se borra cuando salió en todos.
1274
+ *
1275
+ * No se borra lo que no se pudo leer ni lo que no salió: se dice y se reintenta. Un
1276
+ * marcador de una versión ya superada sí se quita: esa noticia caducó.
1277
+ */
1278
+ const anunciarActualizacion = () => {
1279
+ if (!fs.existsSync(updatedFile)) return
1280
+ const m = ipcRead(updatedFile, null)
1281
+ if (!m || typeof m.to !== 'string') { console.log('[vault] update: updated.json exists but cannot be read · the approvers were not told'); return }
1282
+ if (m.to !== daemonVersion) {
1283
+ if (isNewer(daemonVersion, m.to)) rm(updatedFile)
1284
+ return // todavía no corre esa versión: se anuncia cuando arranque
1285
+ }
1286
+ contarATodos({ version: m.to, from: typeof m.from === 'string' ? m.from : null }, ({ todos }) => {
1287
+ if (todos) rm(updatedFile)
1288
+ else console.log('[vault] update: some approvers were not told · it will try again on the next start')
1289
+ })
1290
+ }
1291
+
1292
+ /**
1293
+ * Cuenta algo a quien aprueba en TODOS los perfiles en marcha (`announceUpdate`), cada uno
1294
+ * en cuanto está identificado en el proxio: reintenta cada pocos segundos hasta que salió
1295
+ * en todos o se agotan los intentos. `alTerminar({ todos, alguno })`.
1296
+ */
1297
+ function contarATodos (aviso, alTerminar) {
1298
+ const hechos = new Set()
1299
+ let intentos = 0
1300
+ const intento = async () => {
1301
+ intentos++
1302
+ let falta = false
1303
+ for (const p of perfilesEnMarcha()) {
1304
+ if (hechos.has(p.id)) continue
1305
+ if (!p.vault.isIdentified?.()) { falta = true; continue }
1306
+ try { await p.vault.announceUpdate(aviso); hechos.add(p.id) } catch (e) {
1307
+ falta = true
1308
+ if (intentos === 1 || intentos === ANNOUNCE_TRIES) console.log(`[vault] update: profile ${p.name} could not tell its approvers yet: ${e?.message || e}`)
1309
+ }
1310
+ }
1311
+ if (!falta) return alTerminar({ todos: true, alguno: hechos.size > 0 })
1312
+ if (intentos >= ANNOUNCE_TRIES) return alTerminar({ todos: false, alguno: hechos.size > 0 })
1313
+ setTimeout(intento, ANNOUNCE_EVERY_MS).unref?.()
1314
+ }
1315
+ setTimeout(intento, ANNOUNCE_EVERY_MS).unref?.()
1316
+ }
1317
+ anunciarActualizacion()
1197
1318
  mirarRelease()
1198
1319
  const relojRelease = setInterval(mirarRelease, CHECK_EVERY_MS)
1199
1320
  relojRelease.unref?.()
package/src/notices.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * LO QUE LA BÓVEDA TIENE QUE CONTARLE A QUIEN APRUEBA, y que no es un pedido.
3
+ *
4
+ * Son dos, de la bóveda o de uno de sus aparatos: «me actualicé» y «hay versión nueva y
5
+ * necesita permisos de administrador: instálala a mano» (`update-needs-root`). (`ev: 'updated'`, dueño 2026-10-08: «las actualizaciones, que se
6
+ * envíe una notificación de que ocurrieron a los aprobadores»). Sale como aviso por el
7
+ * proxio, pero el timbre de una app nativa llega VACÍO —lo leen Google y Apple—, así que la
8
+ * app pregunta por su lista (`op: 'approvals'`) y lo lee de aquí, en `notices`.
9
+ *
10
+ * Caduca a las 24 h: es una noticia, no un registro (eso es la bitácora).
11
+ *
12
+ * Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer se LANZA: callar
13
+ * sería decir «no pasó nada» de algo que sí pasó.
14
+ */
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import crypto from 'node:crypto'
18
+
19
+ export const NOTICES_FILE = 'notices.json'
20
+ /** Cuánto se sigue contando un aviso. */
21
+ export const NOTICE_TTL_MS = 24 * 60 * 60 * 1000
22
+ /** Los avisos que existen. */
23
+ export const NOTICE_EVS = ['updated', 'update-needs-root']
24
+
25
+ const fail = (code, message) => Object.assign(new Error(message), { code })
26
+
27
+ /**
28
+ * @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
29
+ */
30
+ export function openNotices ({ dir, atRest, now = Date.now }) {
31
+ const file = path.join(dir, NOTICES_FILE)
32
+ let items = load()
33
+
34
+ function load () {
35
+ if (!fs.existsSync(file)) return []
36
+ try {
37
+ const d = JSON.parse(atRest.decrypt(fs.readFileSync(file, 'utf8')))
38
+ if (!d || !Array.isArray(d.items)) throw new Error('not a list')
39
+ return d.items.filter((n) => n && typeof n.id === 'string' && typeof n.ts === 'number')
40
+ } catch (e) { throw fail('notices-unreadable', `${NOTICES_FILE} cannot be read: ${e.message}`) }
41
+ }
42
+
43
+ function save () {
44
+ const tmp = file + '.tmp'
45
+ fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items })), { mode: 0o600 })
46
+ fs.renameSync(tmp, file)
47
+ }
48
+
49
+ const alive = (n) => now() - n.ts < NOTICE_TTL_MS
50
+
51
+ return {
52
+ /**
53
+ * Apunta un aviso y lo devuelve tal como viaja. `ev`: `updated` (se actualizó) o
54
+ * `update-needs-root` (hay versión nueva y hay que instalarla a mano, con permisos de
55
+ * administrador). `product`: qué (`@dotrino/vaultd` es la propia bóveda);
56
+ * `deviceId`/`label`: el aparato, si fue uno.
57
+ */
58
+ add (ev, { version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null }) {
59
+ if (!NOTICE_EVS.includes(ev)) throw fail('bad-notice', `unknown notice: ${ev}`)
60
+ if (typeof version !== 'string' || !version) throw fail('bad-notice', `${ev}: a version is required`)
61
+ if (typeof product !== 'string' || !product) throw fail('bad-notice', `${ev}: a product is required`)
62
+ const n = {
63
+ id: crypto.randomBytes(8).toString('hex'), ev, product, version, from: typeof from === 'string' ? from : null,
64
+ ...(typeof deviceId === 'string' && deviceId ? { deviceId, label: typeof label === 'string' ? label : '' } : {}),
65
+ ts: now()
66
+ }
67
+ items = [...items.filter(alive), n]
68
+ save()
69
+ return n
70
+ },
71
+ updated (o) { return this.add('updated', o) },
72
+ /** Los avisos vivos, del más viejo al más nuevo. */
73
+ list () { return items.filter(alive).map((n) => ({ ...n })) }
74
+ }
75
+ }
package/src/tui/app.js CHANGED
@@ -36,6 +36,7 @@ import { createTerm, widthOf } from './term.js'
36
36
  import { qrToString } from '../qr.js'
37
37
  import { dict, otherLang, loadLang, saveLang } from './i18n.js'
38
38
  import * as vc from '../vaultControl.js'
39
+ import { readSettings, writeSettings } from '../settings.js'
39
40
  import * as hw from '../hwkeys.js'
40
41
  import { VERSION } from '../version.js'
41
42
  import { DEVICE_CAPS } from '@dotrino/identity/acta'
@@ -338,6 +339,50 @@ function doorRows (st, t) {
338
339
  return rows
339
340
  }
340
341
 
342
+ /**
343
+ * AJUSTES DE LA BÓVEDA (no de un perfil). Cada regla se lee como una frase con su estado, y
344
+ * Enter la cambia. Hoy hay una: pedir aprobación para actualizarse.
345
+ *
346
+ * `st.settings` es lo leído al entrar: `{ updateApproval }`, o `{ error }` si el ajuste no se
347
+ * pudo leer — que se DICE, no se pinta como apagado: apagado es «se actualiza sola».
348
+ */
349
+ const settingsDir = (st) => st.settingsDir || vc.vaultDir()
350
+ function loadSettings (st) {
351
+ try { st.settings = readSettings(settingsDir(st)) } catch (e) { st.settings = { error: e?.message || String(e) } }
352
+ }
353
+ function settingsRows (st, t) {
354
+ const i = L(st)
355
+ const cfg = st.settings
356
+ if (!cfg) return [{ text: t.muted(' —'), sel: false }]
357
+ const rows = []
358
+ if (cfg.error) {
359
+ rows.push({ text: ' ' + t.warn(i.settingsUnreadable), sel: false })
360
+ rows.push({ text: t.muted(' ' + i.settingsUnreadableHint), sel: false })
361
+ rows.push({ text: '', sel: false })
362
+ }
363
+ const frase = cfg.error ? i.settingUpdateUnknown : cfg.updateApproval ? i.settingUpdateAsks : i.settingUpdateAlone
364
+ rows.push({ text: ` ${t.bold(frase)} ${t.accent(i.settingChange)}`, sel: true, meta: { setting: 'updateApproval' } })
365
+ rows.push({ text: t.muted(' ' + i.settingUpdateHint1), sel: false })
366
+ rows.push({ text: t.muted(' ' + i.settingUpdateHint2), sel: false })
367
+ return rows
368
+ }
369
+ async function onKeySettings (term, st, key) {
370
+ const i = L(st)
371
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
372
+ if (key.name === 'escape' || ch === 'b') { st.screen = 'profiles'; return true }
373
+ if (key.name === 'enter' || (key.name === 'char' && key.ch === ' ')) {
374
+ // Con el ajuste ilegible, Enter lo ESCRIBE de nuevo (encendido: es lo prudente cuando
375
+ // no se sabe qué había); así la pantalla también es la salida del atasco.
376
+ const next = st.settings?.error ? true : !st.settings?.updateApproval
377
+ try {
378
+ writeSettings(settingsDir(st), { updateApproval: next })
379
+ loadSettings(st)
380
+ flash(st, next ? i.settingUpdateNowAsks : i.settingUpdateNowAlone)
381
+ } catch (e) { flash(st, i.settingSaveFailed(e?.message || String(e)), 'danger') }
382
+ }
383
+ return true
384
+ }
385
+
341
386
  function deviceRows (st, t) {
342
387
  const i = L(st)
343
388
  const rows = []
@@ -1204,6 +1249,11 @@ async function onKeyProfiles (term, st, key) {
1204
1249
  },
1205
1250
  onCancel: () => { st.input = null }
1206
1251
  }))
1252
+ } else if (ch === 's') { // ajustes de la bóveda (no de un perfil: no hace falta elegir uno)
1253
+ loadSettings(st)
1254
+ st.sel.settings = 0
1255
+ st.scroll.settings = { value: 0 }
1256
+ st.screen = 'settings'
1207
1257
  } else if (ch === 'y' && cur) { // las llaves: con qué se abre la bóveda
1208
1258
  await ensureUnlocked(term, st, cur, async (p = cur) => {
1209
1259
  st.doorsFor = p.id
@@ -2308,6 +2358,7 @@ const helpSegs = (i, screen, st = {}) => {
2308
2358
  pairing: i.helpPairing,
2309
2359
  pairmode: i.helpPairMode,
2310
2360
  doors: i.helpDoors,
2361
+ settings: i.helpSettings,
2311
2362
  paircaps: i.helpPairCaps,
2312
2363
  join: i.helpJoin,
2313
2364
  me: i.helpMe,
@@ -2343,6 +2394,7 @@ const title = (i, screen) => ({
2343
2394
  pairing: i.titlePairing,
2344
2395
  pairmode: i.titlePairMode,
2345
2396
  doors: i.titleDoors,
2397
+ settings: i.titleSettings,
2346
2398
  paircaps: i.titlePairCaps,
2347
2399
  join: i.titleJoin,
2348
2400
  caps: i.titleCaps,
@@ -2443,10 +2495,15 @@ function render (term, st) {
2443
2495
  // instalar, peor: el daemon se queda con el binario viejo, ya borrado, y hay dos copias
2444
2496
  // en RAM). `status` ya lo avisa; aquí también, que es donde uno se queda mirando.
2445
2497
  lines[2] = (up && s?.version && VERSION !== 'dev' && s.version !== VERSION) ? ' ' + t.warn(i.daemonStale(s.version, VERSION)) : ''
2446
- // Una actualización que lleva tiempo pidiendo permiso sin respuesta: la bóveda parece
2498
+ // Una actualización que pidió permiso (una vez por versión, dura un día): la bóveda parece
2447
2499
  // normal y simplemente no se actualiza, así que se dice aquí.
2448
2500
  if (!lines[2] && up && s?.updateAsk?.version) {
2449
- lines[2] = ' ' + t.warn(i.updateUnanswered(s.updateAsk.version, Math.floor((Date.now() - s.updateAsk.since) / 86400000)))
2501
+ const u = s.updateAsk
2502
+ const dia = (ms) => new Date(ms).toISOString().slice(0, 16).replace('T', ' ')
2503
+ // Esperando es un estado normal (gris); no aprobada es lo que hay que ver (aviso).
2504
+ lines[2] = ' ' + (u.result === 'pending'
2505
+ ? t.muted(i.updateWaiting(u.version, dia(u.askedAt + 24 * 60 * 60 * 1000)))
2506
+ : t.warn(i.updateNotApproved(u.version, dia(u.askedAt))))
2450
2507
  }
2451
2508
  // Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
2452
2509
  // el resto muestra su título simple.
@@ -2468,6 +2525,7 @@ function render (term, st) {
2468
2525
  else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
2469
2526
  else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
2470
2527
  else if (st.screen === 'doors') body = renderList(doorRows(st, t), st.sel.doors || 0, contentH, cols, t, scrollRef)
2528
+ else if (st.screen === 'settings') body = renderList(settingsRows(st, t), st.sel.settings || 0, contentH, cols, t, scrollRef)
2471
2529
  else if (st.screen === 'paircaps') body = renderList(pairCapsRows(st, t), st.sel.paircaps || 0, contentH, cols, t, scrollRef)
2472
2530
  else if (st.screen === 'pairing') {
2473
2531
  const pb = pairingBody(st, t, cols, contentH)
@@ -2551,7 +2609,7 @@ export async function runTui () {
2551
2609
  const st = {
2552
2610
  screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
2553
2611
  lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
2554
- sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, paircaps: 0, devvars: 0, logins: 0, doors: 0 },
2612
+ sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, paircaps: 0, devvars: 0, logins: 0, doors: 0, settings: 0 },
2555
2613
  // Permisos tocados y sin guardar, por llave del aparato (ver `setCapsDraft`).
2556
2614
  capsDrafts: {},
2557
2615
  // Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
@@ -2658,6 +2716,7 @@ export async function runTui () {
2658
2716
  else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
2659
2717
  else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
2660
2718
  else if (st.screen === 'doors') running = await onKeyDoors(term, st, key)
2719
+ else if (st.screen === 'settings') running = await onKeySettings(term, st, key)
2661
2720
  else if (st.screen === 'paircaps') running = await onKeyPairCaps(term, st, key)
2662
2721
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
2663
2722
  else if (st.screen === 'join') running = await onKeyJoin(term, st, key)
@@ -2675,4 +2734,4 @@ export async function runTui () {
2675
2734
  }
2676
2735
 
2677
2736
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
2678
- export const __test = { render, doorRows, onKeyDoors, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
2737
+ export const __test = { render, settingsRows, onKeySettings, doorRows, onKeyDoors, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
package/src/tui/i18n.js CHANGED
@@ -26,7 +26,8 @@ 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
+ updateWaiting: (version, until) => `⬆ la ${version} espera tu aprobación hasta ${until} UTC: mira Pedidos en tu teléfono`,
30
+ updateNotApproved: (version, when) => `⬆ se pidió permiso para la ${version} el ${when} UTC y no se aprobó: no se vuelve a pedir · dotrino-vault update`,
30
31
  daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
31
32
  activeVault: 'Bóveda activa: ',
32
33
  noName: '(sin nombre)',
@@ -169,6 +170,18 @@ const es = {
169
170
  titlePairing: 'Emparejar un dispositivo',
170
171
  titlePairMode: 'Emparejar: ¿a qué cuenta entra?',
171
172
  titleDoors: 'Con qué se abre la bóveda',
173
+ titleSettings: 'Ajustes de esta bóveda',
174
+ settingUpdateAlone: 'Esta bóveda se actualiza sola, sin pedir aprobación.',
175
+ settingUpdateAsks: 'Esta bóveda pide aprobación antes de actualizarse.',
176
+ settingUpdateUnknown: 'No se sabe si esta bóveda pide aprobación para actualizarse.',
177
+ settingChange: 'Enter cambiar',
178
+ settingUpdateHint1: 'Vale para todos los perfiles: se pide a los aparatos con «aprueba» de cualquiera de ellos, una vez por versión.',
179
+ settingUpdateHint2: 'Si ningún perfil tiene un aparato con «aprueba», la bóveda se actualiza sola.',
180
+ settingsUnreadable: 'El ajuste no se pudo leer: mientras siga así, la bóveda no se actualiza.',
181
+ settingsUnreadableHint: 'Pulsa Enter para volver a guardarlo (queda pidiendo aprobación; Enter otra vez lo cambia).',
182
+ settingUpdateNowAsks: 'Hecho: pide aprobación antes de actualizarse.',
183
+ settingUpdateNowAlone: 'Hecho: se actualiza sola.',
184
+ settingSaveFailed: (m) => `No se pudo guardar el ajuste: ${m}`,
172
185
  doorsIntro: (name) => `Con qué se abre «${name}». Abre cualquiera de estas.`,
173
186
  doorsNone: 'Sin candado: se abre sola en esta máquina.',
174
187
  doorPassword: 'contraseña',
@@ -416,7 +429,7 @@ const es = {
416
429
  // INGLÉS: new/rename/delete/password/unlock/locK/pair/approve/revoke/refresh/
417
430
  // language/quit); lo único que se traduce es la palabra que las explica.
418
431
  // Segmentos, no una línea: si no caben en una, el render los reparte en varias.
419
- helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'j unirse', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'y llaves', 'u desbloq', 'k bloquear', 'l English', 'q salir'],
432
+ helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'j unirse', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'y llaves', 'u desbloq', 'k bloquear', 's ajustes', 'l English', 'q salir'],
420
433
  // La barra dice lo que se PUEDE hacer AHORA, no todo lo que existe: aprobar/rechazar solo
421
434
  // valen si hay alguien esperando, y renombrar/revocar solo si hay un aparato seleccionado.
422
435
  // Anunciar teclas muertas confunde y además las quema para otros usos.
@@ -454,6 +467,7 @@ const es = {
454
467
  helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', '↑↓ scroll', 'Esc atrás', 'l English'],
455
468
  helpPairMode: ['↑↓', 'Enter elegir', 'Esc atrás', 'l English', 'q salir'],
456
469
  helpDoors: ['↑↓', 'Enter añadir', 'd quitar', 'Esc atrás', 'l English', 'q salir'],
470
+ helpSettings: ['Enter cambiar', 'Esc atrás', 'l English', 'q salir'],
457
471
  helpJoin: ['Esc bóvedas', 'l English', 'q salir'],
458
472
 
459
473
  // pantalla "daemon caído"
@@ -501,7 +515,8 @@ const en = {
501
515
 
502
516
  daemonRunning: 'running',
503
517
  daemonStopped: 'STOPPED',
504
- 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`,
518
+ updateWaiting: (version, until) => `⬆ ${version} is waiting for your approval until ${until} UTC: check Requests on your phone`,
519
+ updateNotApproved: (version, when) => `⬆ approval for ${version} was asked on ${when} UTC and not given: it will not ask again · dotrino-vault update`,
505
520
  daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
506
521
  activeVault: 'Active vault: ',
507
522
  tooSmall: 'Terminal too small',
@@ -624,6 +639,18 @@ const en = {
624
639
  titlePairing: 'Pair a device',
625
640
  titlePairMode: 'Pairing: which account does it join?',
626
641
  titleDoors: 'What opens this vault',
642
+ titleSettings: 'Settings of this vault',
643
+ settingUpdateAlone: 'This vault updates on its own, without asking for approval.',
644
+ settingUpdateAsks: 'This vault asks for approval before updating.',
645
+ settingUpdateUnknown: 'It is not known whether this vault asks for approval to update.',
646
+ settingChange: 'Enter change',
647
+ settingUpdateHint1: 'It applies to every profile: devices with “approve” in any of them are asked, once per version.',
648
+ settingUpdateHint2: 'If no profile has a device with “approve”, the vault updates on its own.',
649
+ settingsUnreadable: 'The setting could not be read: while it stays like this, the vault does not update.',
650
+ settingsUnreadableHint: 'Press Enter to save it again (it will ask for approval; Enter again changes it).',
651
+ settingUpdateNowAsks: 'Done: it asks for approval before updating.',
652
+ settingUpdateNowAlone: 'Done: it updates on its own.',
653
+ settingSaveFailed: (m) => `The setting could not be saved: ${m}`,
627
654
  doorsIntro: (name) => `What opens “${name}”. Any of these opens it.`,
628
655
  doorsNone: 'No lock: it opens by itself on this machine.',
629
656
  doorPassword: 'password',
@@ -847,7 +874,7 @@ const en = {
847
874
  helpInput: 'Enter confirm · Esc cancel · Ctrl-U clear',
848
875
  helpConfirm: 'y confirm · n/Esc cancel',
849
876
 
850
- helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'y keys', 'u unlock', 'k lock', 'l Español', 'q quit'],
877
+ helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'y keys', 'u unlock', 'k lock', 's settings', 'l Español', 'q quit'],
851
878
  helpDevices: ({ pending, hasDevices, isService, drafts, curBlocked } = {}) => [
852
879
  '←→ tab', '↑↓', 'p pair',
853
880
  ...(drafts ? ['g save permissions'] : []),
@@ -879,6 +906,7 @@ const en = {
879
906
  helpPairing: ['a approve', 'x reject', 'r restart', '↑↓ scroll', 'Esc back', 'l Español'],
880
907
  helpPairMode: ['↑↓', 'Enter choose', 'Esc back', 'l Español', 'q quit'],
881
908
  helpDoors: ['↑↓', 'Enter add', 'd remove', 'Esc back', 'l Español', 'q quit'],
909
+ helpSettings: ['Enter change', 'Esc back', 'l Español', 'q quit'],
882
910
  helpJoin: ['Esc vaults', 'l Español', 'q quit'],
883
911
 
884
912
  downTitle: 'The vault daemon is not running.',
@@ -0,0 +1,139 @@
1
+ /**
2
+ * PEDIR PERMISO PARA ACTUALIZARSE, EN TODOS LOS PERFILES A LA VEZ.
3
+ *
4
+ * El ajuste es de la BÓVEDA, no de un perfil (dueño, 2026-10-08): «si se activa funciona si
5
+ * cualquier perfil tiene aprobador, afecta a todos los perfiles y se envía notificación a
6
+ * todos los perfiles». El binario es uno solo, así que quien aprueba en cualquiera de las
7
+ * cuentas que viven aquí decide por la máquina.
8
+ *
9
+ * Sin estado y sin red: recibe los perfiles en marcha y lo que cada uno sabe hacer, para
10
+ * poder probarse sin daemon (`test/actualizar-todos-los-perfiles.test.mjs`).
11
+ */
12
+ import fs from 'node:fs'
13
+ import path from 'node:path'
14
+ import { ipcRead, ipcWrite } from './ipc.js'
15
+
16
+ /**
17
+ * Quién aprueba, en todos los perfiles. SI UNO NO SE PUEDE LEER, SE LANZA: una lista a medias
18
+ * sería decidir con menos aprobadores de los que hay, que es el repliegue que abre la puerta
19
+ * justo cuando algo se rompió.
20
+ * @param {{ id: string, name?: string, vault: { approvers: () => Promise<any[]> } }[]} profiles
21
+ * @returns {Promise<{ profile: string, profileId: string, id: string, label: string, pub: string, notifiedAt: number|null }[]>}
22
+ */
23
+ export async function approversOfAll (profiles) {
24
+ const out = []
25
+ for (const p of profiles) {
26
+ let list
27
+ try { list = await p.vault.approvers() } catch (e) {
28
+ throw Object.assign(new Error(`profile ${p.name || p.id}: ${e?.message || e}`), { code: 'approvers-unreadable' })
29
+ }
30
+ for (const a of list) out.push({ ...a, profile: p.name || p.id, profileId: p.id })
31
+ }
32
+ return out
33
+ }
34
+
35
+ /**
36
+ * Pide en cada perfil y espera: EL PRIMER «SÍ» BASTA; es «no» cuando todos dijeron que no o
37
+ * vencieron. Al resolverse se RETIRAN los pedidos que quedaron vivos en los demás, para que
38
+ * no se queden un día en la lista de quien aprueba pidiendo algo que ya se decidió.
39
+ * @param {{ answer: Promise<boolean>, withdraw: () => void }[]} asks
40
+ * @returns {Promise<boolean>}
41
+ */
42
+ export function firstYes (asks) {
43
+ if (!asks.length) return Promise.resolve(false)
44
+ return new Promise((resolve) => {
45
+ let left = asks.length
46
+ let done = false
47
+ const finish = (ok, winner) => {
48
+ if (done) return
49
+ done = true
50
+ for (const a of asks) { if (a !== winner) { try { a.withdraw() } catch (_) { /* retirar es limpieza: no cambia la respuesta */ } } }
51
+ resolve(ok)
52
+ }
53
+ for (const a of asks) {
54
+ Promise.resolve(a.answer).then((ok) => {
55
+ if (ok === true) return finish(true, a)
56
+ if (--left === 0) finish(false, null)
57
+ }, () => { if (--left === 0) finish(false, null) })
58
+ }
59
+ })
60
+ }
61
+
62
+ /**
63
+ * EL PEDIDO SE HACE UNA VEZ POR VERSIÓN (dueño, 2026-10-08): «dura un día, pero se hace una
64
+ * sola vez; no se reintenta al siguiente día; se asume negado si no se hizo en 24 horas; se
65
+ * dispara nuevamente en la siguiente actualización».
66
+ *
67
+ * Así que se apunta QUÉ versión se preguntó, AL PEDIR, y sobrevive a un reinicio: una bóveda
68
+ * que se reinicia con el pedido a medias no vuelve a timbrar por lo mismo. Va por el canal
69
+ * local (cifrado en reposo), junto al resto del estado del daemon.
70
+ *
71
+ * UN ARCHIVO QUE EXISTE Y NO SE PUEDE LEER NO ES «NUNCA SE PREGUNTÓ» —eso volvería a pedir— ni
72
+ * tampoco «ya se aprobó»: se lanza, y quien llama no actualiza y lo dice.
73
+ */
74
+
75
+ export const ASKED_FILE = 'update-asked.json'
76
+ const RESULTS = ['pending', 'denied', 'expired']
77
+ const fail = (code, message) => Object.assign(new Error(message), { code })
78
+
79
+ /** @returns {{ version: string, askedAt: number, result: 'pending'|'denied'|'expired' } | null} */
80
+ export function readAsked (dir) {
81
+ const file = path.join(dir, ASKED_FILE)
82
+ if (!fs.existsSync(file)) return null
83
+ const d = ipcRead(file, null)
84
+ if (!d || typeof d.version !== 'string' || !d.version || typeof d.askedAt !== 'number' || !RESULTS.includes(d.result)) {
85
+ throw fail('asked-unreadable', `${ASKED_FILE} exists but cannot be read`)
86
+ }
87
+ return { version: d.version, askedAt: d.askedAt, result: d.result }
88
+ }
89
+
90
+ export function writeAsked (dir, { version, askedAt, result }) {
91
+ if (typeof version !== 'string' || !version || typeof askedAt !== 'number' || !RESULTS.includes(result)) throw fail('bad-asked', 'writeAsked: version, askedAt and a known result are required')
92
+ ipcWrite(path.join(dir, ASKED_FILE), { v: 1, version, askedAt, result })
93
+ }
94
+
95
+ /** Ya se instaló (o ya no hace falta preguntar): se olvida lo preguntado. */
96
+ export function clearAsked (dir) { fs.rmSync(path.join(dir, ASKED_FILE), { force: true }) }
97
+
98
+ /**
99
+ * ¿Hay que pedir permiso por `version`? Solo si nunca se preguntó, o si es MÁS NUEVA que la
100
+ * que se preguntó. La misma (o una anterior) ya tuvo su pedido: no se repite.
101
+ * @param {(a: string, b: string) => boolean} isNewer `isNewer(a, b)`: a es más nueva que b
102
+ */
103
+ export function shouldAsk (asked, version, isNewer) {
104
+ return !asked || isNewer(version, asked.version)
105
+ }
106
+
107
+ /** Un «no» que llega cuando el pedido ya cumplió su día es un vencimiento; antes, alguien lo denegó. */
108
+ export function resultOfNo (askedAt, ttlMs, now = Date.now()) {
109
+ return now - askedAt >= ttlMs - 60_000 ? 'expired' : 'denied'
110
+ }
111
+
112
+ /**
113
+ * «NECESITO PERMISOS DE ADMINISTRADOR» SE DICE UNA VEZ POR VERSIÓN. Una bóveda instalada como
114
+ * paquete del sistema no puede cambiarse el binario: avisa a quien aprueba y espera a que
115
+ * alguien la instale a mano. Sin apuntarlo, lo repetiría en cada pasada y en cada reinicio.
116
+ *
117
+ * Mismas reglas que lo preguntado: sobrevive al reinicio, y un archivo que existe y no se
118
+ * puede leer se LANZA (ni «ya se dijo» ni «dilo otra vez»).
119
+ */
120
+ export const ROOT_TOLD_FILE = 'update-root-told.json'
121
+
122
+ /** @returns {{ version: string, at: number } | null} */
123
+ export function readRootTold (dir) {
124
+ const file = path.join(dir, ROOT_TOLD_FILE)
125
+ if (!fs.existsSync(file)) return null
126
+ const d = ipcRead(file, null)
127
+ if (!d || typeof d.version !== 'string' || !d.version || typeof d.at !== 'number') throw fail('root-told-unreadable', `${ROOT_TOLD_FILE} exists but cannot be read`)
128
+ return { version: d.version, at: d.at }
129
+ }
130
+
131
+ export function writeRootTold (dir, { version, at }) {
132
+ if (typeof version !== 'string' || !version || typeof at !== 'number') throw fail('bad-root-told', 'writeRootTold: version and at are required')
133
+ ipcWrite(path.join(dir, ROOT_TOLD_FILE), { v: 1, version, at })
134
+ }
135
+
136
+ /** ¿Hay que avisar por `version`? Solo si nunca se avisó, o si es MÁS NUEVA que la avisada. */
137
+ export function shouldTellRoot (told, version, isNewer) {
138
+ return !told || isNewer(version, told.version)
139
+ }
package/src/vault.js CHANGED
@@ -31,6 +31,7 @@ import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER }
31
31
  import { openSubacta } from './subacta.js'
32
32
  import { openBlocked } from './blocked.js'
33
33
  import { openNotifiable } from './notifiable.js'
34
+ import { openNotices } from './notices.js'
34
35
  import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
35
36
  import { VERSION } from './version.js'
36
37
  import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
@@ -265,6 +266,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
265
266
  const blocked = openBlocked({ dir, atRest: passwordsAtRest })
266
267
  // Qué aprobadores dijeron que pueden recibir avisos (`src/notifiable.js`). No decide nada: se enseña.
267
268
  const notifiable = openNotifiable({ dir, atRest: passwordsAtRest })
269
+ // Lo que hay que contarle a quien aprueba y no es un pedido (`src/notices.js`): hoy, «me actualicé».
270
+ const notices = openNotices({ dir, atRest: passwordsAtRest })
268
271
  const readPasswordsFile = () => {
269
272
  try { return JSON.parse(passwordsAtRest.decrypt(fs.readFileSync(passwordsFile, 'utf8'))) } catch (_) { return null }
270
273
  }
@@ -1526,6 +1529,127 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1526
1529
  reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1527
1530
  }
1528
1531
 
1532
+ /**
1533
+ * UN APARATO PREGUNTA SI PUEDE ACTUALIZARSE (`op: 'update-ask'`, dueño 2026-10-08).
1534
+ *
1535
+ * Pedir aprobación es un interruptor DE CADA AGENTE, independiente del de la bóveda
1536
+ * (`updateApproval` de `src/settings.js` vale solo para la propia bóveda): el agente solo
1537
+ * manda esto cuando lo tiene encendido. Así que aquí no se consulta ningún ajuste:
1538
+ *
1539
+ * · alguien aprueba en este perfil → un pedido `kind: 'update'` a nombre del aparato, que
1540
+ * dura un día; se contesta «pendiente» y el resultado sale cuando alguien decide, por
1541
+ * donde vino la pregunta. Vencido es no.
1542
+ * · nadie aprueba → sí, sin preguntar: esperar un sí que nadie puede dar sería no
1543
+ * actualizarse nunca.
1544
+ *
1545
+ * Lo puede preguntar cualquier miembro del acta: no pide nada que no sea suyo.
1546
+ */
1547
+ const updateAsks = new Map() // device|product|version → { id, dest } del pedido vivo
1548
+ const updateDecisions = new Map() // device|product|version → { ok: true, exp }: lo ya aprobado
1549
+ const cut = (v, n) => (typeof v === 'string' && v && v.length <= n ? v : null)
1550
+ async function handleUpdateAsk (from, p) {
1551
+ const chk = await verifyChain({
1552
+ data: p.data, signature: p.signature, cert: p.cert,
1553
+ ...(await contextoActa()), revoked: await revocationSet()
1554
+ })
1555
+ if (!chk.ok) return denyChain(from, chk, p, 'update-ask')
1556
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1557
+ if (!record || !(record.members || []).some((m) => m.pub === chk.device)) {
1558
+ audit('rejected', { what: 'update-ask', reason: record ? 'acta' : 'sin-acta' })
1559
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this device is not in the record' })
1560
+ }
1561
+ const product = cut(p.data?.product, 128)
1562
+ const version = cut(p.data?.version, 64)
1563
+ const fromVersion = cut(p.data?.from, 64)
1564
+ if (!product || !version) return reply(from, { type: MSG.ERROR, code: 'bad-request', error: 'update-ask: needs `product` and `version`' })
1565
+ const deviceId = await deviceIdOf(chk.device).catch(() => null)
1566
+ const say = async (dest, extra) => {
1567
+ const body = { op: 'update-ask', product, version, ...extra, ts: Date.now() }
1568
+ reply(dest, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1569
+ }
1570
+
1571
+ const approvers = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).length
1572
+ if (!approvers) {
1573
+ audit('update-ask', { device: deviceId, product, version, asked: false })
1574
+ log(`[vault] update-ask: ${deviceId || '????-????'} may update ${product} to ${version} on its own (nobody approves in this profile)`)
1575
+ return say(from, { ok: true, asked: false })
1576
+ }
1577
+
1578
+ const key = `${chk.device}|${product}|${version}`
1579
+ const now = Date.now()
1580
+ for (const [k, d] of updateDecisions) if (d.exp <= now) updateDecisions.delete(k)
1581
+ // YA SE DECIDIÓ y quien preguntó no estaba para oírlo: se le dice lo decidido en vez de
1582
+ // volver a timbrar por lo mismo.
1583
+ const decided = updateDecisions.get(key)
1584
+ if (decided) return say(from, { ok: decided.ok, asked: true })
1585
+ // YA HAY UN PEDIDO VIVO por esto mismo (el agente se reinició y vuelve a preguntar): no se
1586
+ // timbra otra vez; la respuesta irá a quien pregunta ahora.
1587
+ const vivo = updateAsks.get(key)
1588
+ if (vivo && waiters.has(vivo.id)) {
1589
+ vivo.dest = from
1590
+ return say(from, { pending: true, id: vivo.id, exp: vivo.exp, asked: true })
1591
+ }
1592
+ const ask = { id: null, exp: null, dest: from }
1593
+ let pend
1594
+ try {
1595
+ pend = await awaitApproval({
1596
+ device: chk.device, ns: 'vault', kind: 'update', what: 'update',
1597
+ ctx: { product, version, from: fromVersion }, ttlMs: UPDATE_TTL_MS,
1598
+ onAnswer: async (ok) => {
1599
+ updateAsks.delete(key)
1600
+ // Solo se recuerda el SÍ. Un no puede ser un vencimiento (nadie lo vio), y
1601
+ // recordarlo dejaría al aparato un día sin poder volver a preguntar.
1602
+ if (ok === true) updateDecisions.set(key, { ok: true, exp: Date.now() + UPDATE_TTL_MS })
1603
+ await say(ask.dest, { ok: ok === true, asked: true, id: ask.id })
1604
+ }
1605
+ })
1606
+ } catch (e) {
1607
+ log(`[vault] update-ask: could not ask for approval: ${e.message}`)
1608
+ return reply(from, { type: MSG.ERROR, code: 'ask-failed', error: 'update-ask: could not ask for approval' })
1609
+ }
1610
+ ask.id = pend.id; ask.exp = pend.exp
1611
+ updateAsks.set(key, ask)
1612
+ return say(from, { pending: true, id: pend.id, exp: pend.exp, asked: true })
1613
+ }
1614
+
1615
+ /**
1616
+ * UN APARATO DICE QUE SE ACTUALIZÓ (`op: 'update-done'`): la bóveda se lo cuenta a quien
1617
+ * aprueba en este perfil, por el mismo camino que su propio «me actualicé». Si el agente
1618
+ * avisa o no lo decide él (su interruptor); aquí solo se reparte.
1619
+ *
1620
+ * Y su hermano `op: 'update-needs-root'`: el aparato vio una versión nueva y no puede
1621
+ * instalarla solo porque necesita permisos de administrador. Mismo reparto, otro `ev`.
1622
+ *
1623
+ * QUIÉN ES lo dice el acta (id y nombre), no lo que el aparato cuente de sí mismo.
1624
+ */
1625
+ async function handleUpdateDone (from, p) {
1626
+ const op = p.data.op
1627
+ const ev = op === 'update-needs-root' ? 'update-needs-root' : 'updated'
1628
+ const chk = await verifyChain({
1629
+ data: p.data, signature: p.signature, cert: p.cert,
1630
+ ...(await contextoActa()), revoked: await revocationSet()
1631
+ })
1632
+ if (!chk.ok) return denyChain(from, chk, p, op)
1633
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1634
+ const member = (record?.members || []).find((m) => m.pub === chk.device)
1635
+ if (!member) {
1636
+ audit('rejected', { what: op, reason: record ? 'acta' : 'sin-acta' })
1637
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this device is not in the record' })
1638
+ }
1639
+ const product = cut(p.data?.product, 128)
1640
+ const version = cut(p.data?.version, 64)
1641
+ if (!product || !version) return reply(from, { type: MSG.ERROR, code: 'bad-request', error: `${op}: needs \`product\` and \`version\`` })
1642
+ const deviceId = await deviceIdOf(chk.device).catch(() => null)
1643
+ try {
1644
+ await announceUpdate({ ev, version, from: cut(p.data?.from, 64), product, deviceId, label: member.label || '' })
1645
+ } catch (e) {
1646
+ log(`[vault] ${op}: ${deviceId || '????-????'} reported ${product} ${version}, but the approvers could not be told: ${e.message}`)
1647
+ return reply(from, { type: MSG.ERROR, code: e.code || 'announce-failed', error: `${op}: the approvers could not be told` })
1648
+ }
1649
+ const body = { op, ok: true, product, version, ts: Date.now() }
1650
+ reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1651
+ }
1652
+
1529
1653
  const storeNonces = new Map()
1530
1654
  async function handleVarStore (from, p) {
1531
1655
  const ns = p.data?.ns
@@ -1593,6 +1717,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1593
1717
  if (p.data?.op === 'enckey') return handleEncKey(from, p)
1594
1718
  if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
1595
1719
  if (p.data?.op === 'digest') return handleDigest(from, p)
1720
+ if (p.data?.op === 'update-ask') return handleUpdateAsk(from, p)
1721
+ if (p.data?.op === 'update-done' || p.data?.op === 'update-needs-root') return handleUpdateDone(from, p)
1596
1722
  if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke', 'block', 'blocked'].includes(p.data?.op)) return handleApproval(from, p)
1597
1723
  const ns = p.data?.ns
1598
1724
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
@@ -1772,7 +1898,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1772
1898
  if (op === 'approvals' && typeof p.data?.notify === 'boolean') {
1773
1899
  try { notifiable.declare(chk.device, p.data.notify) } catch (e) { log(`[vault] could not record whether ${by || '????-????'} can be notified: ${e.message}`) }
1774
1900
  }
1775
- if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record) })
1901
+ // `notices`: lo que no es un pedido y quien aprueba tiene que saber (la bóveda se
1902
+ // actualizó). El timbre de una app nativa llega vacío, así que lo lee de aquí.
1903
+ if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record), notices: notices.list() })
1776
1904
  // LO QUE YA ESTÁ APROBADO SE VE Y SE CORTA DESDE DONDE SE APROBÓ. Una concesión que se
1777
1905
  // renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
1778
1906
  // el mismo sitio donde se dio, es un permiso invisible, y eso no lo damos por bueno.
@@ -1949,6 +2077,46 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1949
2077
  } catch (e) { log('[vault] could not notify approvers:', e.message) }
1950
2078
  }
1951
2079
 
2080
+ /**
2081
+ * «ME ACTUALICÉ», a todo el que aprueba (dueño, 2026-10-08). Vale con la aprobación
2082
+ * encendida o apagada: lo que se instala solo también se cuenta.
2083
+ *
2084
+ * Lo manda la versión NUEVA ya corriendo —el daemon llama aquí al arrancar, si dejó dicho
2085
+ * que instaló—, que es la prueba de que ocurrió. El aviso queda además en `notices`, de
2086
+ * donde lo lee una app nativa (su timbre llega vacío).
2087
+ *
2088
+ * También reparte el «me actualicé» de un APARATO (`op: 'update-done'`): entonces lleva su
2089
+ * `product`, `deviceId` y `label`.
2090
+ *
2091
+ * Y con `ev: 'update-needs-root'` cuenta lo contrario: hay versión nueva y NO se instaló
2092
+ * sola porque necesita permisos de administrador (dueño, 2026-10-08: «si se requiere root
2093
+ * por algún motivo, simplemente se notifica al aprobador»). Mismo camino, otro `ev`.
2094
+ *
2095
+ * A diferencia de `notifyApprovers`, aquí el fallo SE LANZA: quien llama guarda el
2096
+ * marcador hasta que el aviso salió, y tragarlo sería borrarlo sin haber avisado.
2097
+ * @returns {Promise<{ approvers: number, sent: number, notice: object|null }>}
2098
+ */
2099
+ async function announceUpdate ({ version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null, ev = 'updated' }) {
2100
+ const record = await refreshActa()
2101
+ const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
2102
+ if (!who.length) return { approvers: 0, sent: 0, notice: null }
2103
+ // `deviceId`/`label`: solo cuando quien se actualizó es un APARATO (`update-done`). El
2104
+ // aviso de la propia bóveda no los lleva.
2105
+ const notice = notices.add(ev, { version, from, product, ...(deviceId ? { deviceId, label: label || '' } : {}) })
2106
+ const { id, ev: _ev, ts, ...rest } = notice
2107
+ const body = { ev, id, ...rest, ts }
2108
+ const seal = await sealOrFail(body)
2109
+ let sent = 0
2110
+ for (const pub of who) {
2111
+ try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, seal }, { app: 'vault' }); sent++ }
2112
+ catch (e) { log(`[vault] could not tell ${pub.slice(0, 24)}… about the update of ${product} to ${version}: ${e.message}`) }
2113
+ }
2114
+ audit(ev === 'updated' ? 'update.announced' : 'update.needs-root', { product, version, from: notice.from, device: deviceId, approvers: who.length, sent })
2115
+ log(`[vault] update: told ${sent} of ${who.length} approver(s) that ${deviceId ? `${deviceId} (${product})` : 'this vault'} ${ev === 'updated' ? 'now runs' : 'needs administrator rights to install'} ${version}`)
2116
+ if (!sent) throw Object.assign(new Error('no approver could be told'), { code: 'announce-failed' })
2117
+ return { approvers: who.length, sent, notice }
2118
+ }
2119
+
1952
2120
  /**
1953
2121
  * UN INCIDENTE: un agente de la cuenta dice que OTRO aparato de la cuenta se equivocó de
1954
2122
  * clave tres veces seguidas (la terminal, `dotrino-terminal lock`). No se decide aquí
@@ -4020,17 +4188,32 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
4020
4188
  * PEDIR PERMISO PARA ACTUALIZARSE a `version` (la instala el daemon si se aprueba). Es
4021
4189
  * un pedido como los demás —sale en Pedidos del teléfono, `kind: 'update'`—, pero dura
4022
4190
  * un día (`UPDATE_TTL_MS`): no hay nadie esperando delante de una terminal.
4023
- * @returns {Promise<boolean>} sí o no; vencido es no.
4191
+ * @returns {{ answer: Promise<boolean>, withdraw: () => void }} `answer`: sí o no (vencido
4192
+ * o retirado es no). `withdraw` quita el pedido si sigue vivo.
4024
4193
  */
4025
- askUpdateApproval: async ({ version }) => {
4194
+ askUpdateApproval: ({ version }) => {
4026
4195
  const yo = identity.me?.publickey || null
4027
- return new Promise((resolve) => {
4028
- awaitApproval({
4029
- device: yo, ns: 'vault', kind: 'update', what: 'update',
4030
- ctx: { version, from: VERSION }, ttlMs: UPDATE_TTL_MS, onAnswer: resolve
4031
- }).catch((e) => { log(`[vault] update: could not ask for approval: ${e.message}`); resolve(false) })
4032
- })
4196
+ let pendId = null
4197
+ let withdrawn = false
4198
+ let settle
4199
+ const answer = new Promise((resolve) => { settle = resolve })
4200
+ // RETIRAR: otro perfil ya contestó por la máquina, o ya no hace falta. El pedido sale
4201
+ // de la lista de quien aprueba en vez de quedarse un día pidiendo algo ya decidido.
4202
+ const retirar = () => {
4203
+ if (!pendId || !waiters.delete(pendId)) return
4204
+ const pend = approvals.take(pendId)
4205
+ audit('update.withdrawn', { device: pend?.deviceId || null, ns: 'vault', id: pendId })
4206
+ log(`[vault] update: request ${pendId} withdrawn`)
4207
+ settle(false)
4208
+ }
4209
+ awaitApproval({
4210
+ device: yo, ns: 'vault', kind: 'update', what: 'update',
4211
+ ctx: { product: '@dotrino/vaultd', version, from: VERSION }, ttlMs: UPDATE_TTL_MS, onAnswer: settle
4212
+ }).then((pend) => { pendId = pend.id; if (withdrawn) retirar() })
4213
+ .catch((e) => { log(`[vault] update: could not ask for approval: ${e.message}`); settle(false) })
4214
+ return { answer, withdraw: () => { withdrawn = true; retirar() } }
4033
4215
  },
4216
+ announceUpdate,
4034
4217
  approvers: async () => {
4035
4218
  const record = await refreshActa()
4036
4219
  if (!record) return []