@dotrino/vaultd 0.145.0 → 0.147.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,119 @@ 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 async function reportUpdated ({ product, version, from = null, dir, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
711
+ const saved = dir ? readServiceIdentity(dir) : null
712
+ proxyUrl = proxyUrl || saved?.proxy
713
+ masterPubkey = masterPubkey || saved?.iss
714
+ device = device || saved?.device
715
+ cert = cert || saved?.cert
716
+ if (typeof product !== 'string' || !product || typeof version !== 'string' || !version) throw new Error('reportUpdated: needs product and version')
717
+ if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
718
+ const client = await freshClient(proxyUrl, 20000, masterPubkey)
719
+ try {
720
+ await identifyAsService(client, device)
721
+ const data = { op: 'update-done', product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
722
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
723
+ const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'update-done' && p.body.product === product && p.body.version === version), timeoutMs)
724
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
725
+ const res = await pending.catch((e) => {
726
+ if (/timeout/.test(e.message)) throw Object.assign(new Error('update-done: the vault did not answer'), { code: 'vault-no-reply' })
727
+ throw e
728
+ })
729
+ if (res.type === MSG.ERROR) throw vaultError(res)
730
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
731
+ if (res.body.ok !== true) throw new Error('malformed update-done reply')
732
+ return { ok: true }
733
+ } finally { client.close() }
734
+ }
735
+
623
736
  /**
624
737
  * Pide los secretos del ns al vault (una petición puntual; lanza si falla).
625
738
  * 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.145.0",
3
+ "version": "0.147.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 '' }
@@ -131,6 +135,11 @@ function deviceIdOf (sub) {
131
135
  return pubkeyId(sub).then((id) => id.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
132
136
  }
133
137
 
138
+ /** Lo que dijo un aprobador sobre sus avisos, para enseñarlo. No decide nada. */
139
+ function avisosTxt (notifiedAt) {
140
+ return notifiedAt ? `dijo que le llegan los avisos (${fechaCorta(notifiedAt)})` : 'no ha dicho que le lleguen los avisos'
141
+ }
142
+
134
143
  function cmdStatus () {
135
144
  const s = readState()
136
145
  const up = alive(s.pid)
@@ -153,11 +162,30 @@ function cmdStatus () {
153
162
  console.log(' %s⬆ hay %s publicada%s (esta es la %s) · tráela con: dotrino-vault update',
154
163
  B, s.latest.version, Z, VERSION)
155
164
  }
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.
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.
158
168
  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')
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
+ }
177
+ // A QUIÉN SE LE PIDIÓ, y si dijo que le llegan los avisos: el que no lo ha dicho es el
178
+ // primer sitio donde mirar cuando nadie contesta.
179
+ for (const a of s.updateAsk.approvers || []) {
180
+ console.log(' %s %s%s · %s', a.id, a.label || '(sin nombre)', a.profile ? ` [${a.profile}]` : '', avisosTxt(a.notifiedAt))
181
+ }
182
+ }
183
+ // CÓMO SE ACTUALIZA. Va siempre, también al día: es un ajuste y se mira aquí.
184
+ if (up && 'updateApproval' in s) {
185
+ console.log(' actualizar : %s', s.updateApproval === null
186
+ ? `${R}no se pudo leer el ajuste: no se actualiza${Z} · ponlo de nuevo con: dotrino-vault update --approval on|off`
187
+ : s.updateApproval ? 'pide aprobación (dotrino-vault update --approval off para que lo haga sola)'
188
+ : 'sola, sin pedir aprobación (dotrino-vault update --approval on para que pida)')
161
189
  }
162
190
  const profiles = s.profiles || []
163
191
  if (profiles.length) {
@@ -631,6 +659,9 @@ async function cmdMembers () {
631
659
  // Solo si LEE variables (`secrets`). La llave de comunicación de la bóveda entra con
632
660
  // `cn: 'vault'` y solo `firma`, y no tiene llave de cifrado a propósito: salía marcada
633
661
  // en rojo «NO puede leer sus variables» cuando no tiene ninguna que leer.
662
+ // SI LE LLEGAN LOS AVISOS, en quien aprueba. No cambia a quién se le pide —se le pide a
663
+ // todos—: es para ver a cuál no le suena el pedido.
664
+ if (m.caps.includes('approve') && 'notifiedAt' in m) console.log(' %s%s%s', D, avisosTxt(m.notifiedAt), Z)
634
665
  if (m.cn && !m.canSeal && m.caps.includes('secrets')) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
635
666
  // QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
636
667
  // incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
@@ -1793,7 +1824,32 @@ async function waitForService (version, { timeoutMs = 4 * 60_000 } = {}) {
1793
1824
  return false
1794
1825
  }
1795
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
+
1796
1839
  async function cmdUpdate (args = []) {
1840
+ // PEDIR PERMISO PARA ACTUALIZARSE es un ajuste, apagado por defecto. Se guarda aquí y el
1841
+ // servicio lo lee la próxima vez que mire si hay versión nueva: no hace falta reiniciarlo.
1842
+ const iAp = args.indexOf('--approval')
1843
+ if (iAp !== -1) {
1844
+ const { readSettings, writeSettings } = await import('./settings.js')
1845
+ const v = args[iAp + 1]
1846
+ if (v === 'on' || v === 'off') writeSettings(dir, { updateApproval: v === 'on' })
1847
+ else if (v !== undefined) { console.error('uso: dotrino-vault update --approval [on|off]'); process.exit(2) }
1848
+ console.log(readSettings(dir).updateApproval
1849
+ ? 'Esta bóveda pide aprobación antes de actualizarse (a los aparatos con «aprueba» de cualquiera de sus perfiles).'
1850
+ : 'Esta bóveda se actualiza sola, sin pedir aprobación.')
1851
+ return
1852
+ }
1797
1853
  const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease } = await import('./update.js')
1798
1854
  const soloMirar = args.includes('--check')
1799
1855
 
@@ -1825,6 +1881,7 @@ async function cmdUpdate (args = []) {
1825
1881
  }
1826
1882
  console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
1827
1883
  console.log('Instalada la %s.', res.version)
1884
+ noteUpdated(res.version)
1828
1885
  if (!args.includes('--no-wait')) await waitForService(res.version)
1829
1886
  return
1830
1887
  }
@@ -1864,6 +1921,7 @@ async function cmdUpdate (args = []) {
1864
1921
  B, file, r.version, Z)
1865
1922
  return
1866
1923
  }
1924
+ noteUpdated(r.version)
1867
1925
  console.log('Listo. El servicio se reinicia solo; compruébalo con: dotrino-vault status')
1868
1926
  }
1869
1927
 
@@ -2174,6 +2232,10 @@ function help () {
2174
2232
  status | info estado del servicio + fingerprint + el id de cada perfil
2175
2233
  update [--check] trae la versión nueva (verificando la firma) y espera a que el
2176
2234
  servicio la arranque · --check solo mira · --no-wait no espera
2235
+ update --approval [on|off]
2236
+ el servicio se actualiza solo; con «on» antes pide aprobación a los
2237
+ aparatos con «aprueba» de cualquier perfil (vale para todos) · sin
2238
+ valor, dice cómo está
2177
2239
  pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
2178
2240
  pair --kms <config.json>
2179
2241
  el sitio que se cree (--adopt o --new-account) NACE con su clave
package/src/daemon.js CHANGED
@@ -27,10 +27,16 @@ import { capScope } from '@dotrino/identity/acta'
27
27
  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
+ import { readSettings } from './settings.js'
31
+ import { approversOfAll, firstYes, readAsked, writeAsked, clearAsked, shouldAsk, resultOfNo } from './updateApproval.js'
32
+ import { UPDATE_TTL_MS } from './approvals.js'
30
33
  import crypto from 'node:crypto'
31
34
 
32
35
  const readJsonSafe = (f) => ipcRead(f, null)
33
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
34
40
 
35
41
  /**
36
42
  * UNA sola bóveda por directorio de datos.
@@ -89,18 +95,24 @@ export async function runDaemon () {
89
95
 
90
96
  // --- state.json ---
91
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 }))
92
102
  const daemonVersion = VERSION
93
103
  /** `{ version, checkedAt }` de la última consulta que salió bien, o null si aún ninguna. */
94
104
  let ultimaPublicada = null
95
105
  /**
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.
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.
99
110
  */
100
111
  let pedidoSinRespuesta = null
101
112
  // Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
102
113
  // instalador y la web, que son anteriores al multi-perfil. La lista completa va
103
114
  // en `profiles`.
115
+ const ajusteActualizar = () => { try { return readSettings(dir).updateApproval } catch (_) { return null } }
104
116
  const writeState = () => {
105
117
  const cur = mgr.summary().find((p) => p.current) || {}
106
118
  ipcWrite(stateFile, {
@@ -114,6 +126,9 @@ export async function runDaemon () {
114
126
  // también sin conexión.
115
127
  ...(ultimaPublicada ? { latest: ultimaPublicada } : {}),
116
128
  ...(pedidoSinRespuesta ? { updateAsk: pedidoSinRespuesta } : {}),
129
+ // Si para actualizarse pide permiso (`src/settings.js`). `null` = el ajuste no se
130
+ // pudo leer, que `status` dice distinto de «no».
131
+ updateApproval: ajusteActualizar(),
117
132
  current: mgr.currentId(), profiles: mgr.summary()
118
133
  })
119
134
  }
@@ -1103,35 +1118,44 @@ export async function runDaemon () {
1103
1118
  if (!isNewer(r.version, daemonVersion)) return
1104
1119
 
1105
1120
  /**
1106
- * PIDE PERMISO SOLO SI HAY A QUIÉN PEDÍRSELO (dueño, 2026-09-20).
1121
+ * SE ACTUALIZA SOLA. PEDIR PERMISO ES UN AJUSTE (dueño, 2026-10-08).
1107
1122
  *
1108
- * La bóveda es la pieza sensible del ecosistema: quien le cuele una actualización se
1109
- * lleva la maestra, y eso no se rota — se pierde la cuenta. Por eso no se actualiza
1110
- * sola mientras haya un aparato con `aprueba` que pueda decidirlo, igual que ya pasa
1111
- * con las claves privadas.
1123
+ * Por defecto no se le pregunta a nadie: lo que se baja se verifica contra la
1124
+ * atestación del release antes de tocar el disco, y eso no depende de que alguien
1125
+ * conteste. Antes se pedía permiso según quién hubiera en el acta y según si decía que
1126
+ * le llegan los avisos; eran condiciones que explicar y que se desalineaban, y una
1127
+ * bóveda podía quedarse atrás sin que nadie lo viera.
1112
1128
  *
1113
- * Y si NO hay aprobadores, esperar un sí que nadie puede dar sería condenarla a no
1114
- * actualizarse nunca. Entonces se actualiza sola, que es lo que hace el resto del
1115
- * ecosistema (§15).
1129
+ * Quien quiera decidir cada actualización lo enciende: `dotrino-vault update --approval
1130
+ * on` (`src/settings.js`). Entonces se le pide a TODO el que tenga `aprueba`, reciba
1131
+ * avisos o no, y sin un sí no se instala. Si en el acta no aprueba nadie, se actualiza
1132
+ * igual: esperar un sí que nadie puede dar sería no actualizarse nunca.
1133
+ *
1134
+ * Lo que cada aprobador dijo de sus avisos (`notifiedAt`, `src/notifiable.js`) no
1135
+ * decide nada: se enseña junto al pedido sin respuesta, para ver a cuál no le suena.
1116
1136
  */
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`)
1137
+ // SI EL AJUSTE NO SE PUEDE LEER, NO SE ACTUALIZA: el valor por defecto es no preguntar,
1138
+ // así que suponerlo sería saltarse la aprobación que el dueño pidió.
1139
+ let pedirPermiso
1140
+ try { pedirPermiso = readSettings(dir).updateApproval } catch (e) {
1141
+ console.log(`[vault] version ${r.version} is out, but the settings could not be read (${e?.message || e}) · not updating`)
1123
1142
  return
1124
1143
  }
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)
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()
1151
+ let quienAprueba = []
1152
+ if (pedirPermiso) {
1153
+ try { quienAprueba = await approversOfAll(enMarcha) } catch (e) {
1154
+ console.log(`[vault] version ${r.version} is out, but the approvers could not be read (${e?.message || e}) · not updating`)
1155
+ return
1156
+ }
1157
+ }
1158
+ const conAvisos = quienAprueba.filter((a) => a.notifiedAt).length
1135
1159
 
1136
1160
  // INSTALADA COMO PAQUETE DEL SISTEMA (.deb en /usr/bin): el binario es de root y este
1137
1161
  // proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
@@ -1142,23 +1166,55 @@ export async function runDaemon () {
1142
1166
  if (actualizando) return
1143
1167
  actualizando = true
1144
1168
  try {
1145
- // PIDE PERMISO SOLO SI HAY A QUIÉN PEDÍRSELO (dueño, 2026-09-20); sin aprobadores
1146
- // no se necesita aprobación (dueño, 2026-09-24).
1147
- if (quienAprueba.length) {
1148
- console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · asking one of ${quienAprueba.length} approver(s)`)
1149
- const si = await mgr.current().askUpdateApproval({ version: r.version })
1169
+ // (`aprobada`: ya dijeron que sí a esta versión y la instalación falló; no se vuelve a
1170
+ // preguntar por lo que ya se aprobó, se reintenta instalar.)
1171
+ if (quienAprueba.length && aprobada !== r.version) {
1172
+ // UNA VEZ POR VERSIÓN (dueño, 2026-10-08): el pedido dura un día y no se repite. Si
1173
+ // se denegó o venció, esa versión queda sin aprobar y se vuelve a preguntar solo
1174
+ // cuando salga una MÁS NUEVA. Lo preguntado se apunta al pedir y sobrevive a un
1175
+ // reinicio (`update-asked.json`); si no se puede leer, no se actualiza y se dice.
1176
+ let preguntado
1177
+ try { preguntado = readAsked(dir) } catch (e) {
1178
+ console.log(`[vault] version ${r.version} is out, but what was already asked could not be read (${e?.message || e}) · not updating`)
1179
+ return
1180
+ }
1181
+ const quienes = quienAprueba.map((a) => ({ profile: a.profile, id: a.id, label: a.label, notifiedAt: a.notifiedAt }))
1182
+ if (!shouldAsk(preguntado, r.version, isNewer)) {
1183
+ // Un «pendiente» que no es de este proceso es de antes de un reinicio: el pedido
1184
+ // murió con él, y nadie puede contestarlo ya.
1185
+ if (preguntado.result === 'pending') { preguntado = { ...preguntado, result: 'expired' }; writeAsked(dir, preguntado) }
1186
+ const nuevo = pedidoSinRespuesta?.version !== preguntado.version || pedidoSinRespuesta?.result !== preguntado.result
1187
+ pedidoSinRespuesta = { ...preguntado, approvers: quienes }
1188
+ writeState()
1189
+ 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`)
1190
+ return
1191
+ }
1192
+ const askedAt = Date.now()
1193
+ writeAsked(dir, { version: r.version, askedAt, result: 'pending' })
1194
+ pedidoSinRespuesta = { version: r.version, askedAt, result: 'pending', approvers: quienes }
1195
+ writeState()
1196
+ 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`)
1197
+ // Un pedido por perfil que tenga quien apruebe, a la vez. El primer sí basta, y los
1198
+ // que queden vivos en los demás se retiran (`src/updateApproval.js`).
1199
+ const conAprobador = new Set(quienAprueba.map((a) => a.profileId))
1200
+ const si = await firstYes(enMarcha.filter((p) => conAprobador.has(p.id)).map((p) => p.vault.askUpdateApproval({ version: r.version })))
1150
1201
  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 }
1202
+ const result = resultOfNo(askedAt, UPDATE_TTL_MS)
1203
+ writeAsked(dir, { version: r.version, askedAt, result })
1204
+ pedidoSinRespuesta = { version: r.version, askedAt, result, approvers: quienes }
1153
1205
  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`)
1206
+ console.log(`[vault] update to ${r.version}: not approved (${result}) · it will not ask again for this version · install it with: dotrino-vault update`)
1155
1207
  return
1156
1208
  }
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`)
1209
+ aprobada = r.version
1210
+ } else if (quienAprueba.length) {
1211
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · already approved: installing`)
1212
+ } else if (pedirPermiso) {
1213
+ 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`)
1159
1214
  } else {
1160
- console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · no approver in the record: updating on its own`)
1215
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · updating on its own`)
1161
1216
  }
1217
+ clearAsked(dir)
1162
1218
  if (pedidoSinRespuesta) { pedidoSinRespuesta = null; writeState() }
1163
1219
  const res = await installUserRelease(r, { log: (m) => console.log(m) })
1164
1220
  if (!res.ok) {
@@ -1171,6 +1227,12 @@ export async function runDaemon () {
1171
1227
  // minutos con el CLI y la TUI nuevos hablando con el daemon viejo; el dueño abrió la
1172
1228
  // TUI justo ahí y no arrancó (2026-09-24). Solo bajo systemd, que es quien nos levanta:
1173
1229
  // sin él, irnos sería quedarnos apagados.
1230
+ // DEJAR DICHO QUE SE INSTALÓ, para que la versión nueva se lo cuente a quien aprueba al
1231
+ // arrancar (`anunciarActualizacion`). Si no se puede escribir se dice: la actualización
1232
+ // está hecha igual, lo que se pierde es el aviso.
1233
+ try { ipcWrite(updatedFile, { v: 1, from: daemonVersion, to: r.version, at: Date.now() }) } catch (e) {
1234
+ console.log(`[vault] update: ${r.version} installed, but the note to tell the approvers could not be written: ${e.message}`)
1235
+ }
1174
1236
  if (vigia) {
1175
1237
  console.log(`[vault] update: ${r.version} installed · restarting now to run it`)
1176
1238
  shutdown(`updated to ${r.version}`)
@@ -1182,6 +1244,45 @@ export async function runDaemon () {
1182
1244
  } finally { actualizando = false }
1183
1245
  }
1184
1246
  let actualizando = false
1247
+ let aprobada = null
1248
+
1249
+ /**
1250
+ * «ME ACTUALICÉ»: se lo cuenta a quien aprueba la versión NUEVA, ya corriendo (dueño,
1251
+ * 2026-10-08). Quien instaló —este daemon o `dotrino-vault update`— dejó `updated.json`;
1252
+ * si dice la versión que corre ahora, cada perfil avisa a sus aprobadores en cuanto está
1253
+ * identificado en el proxio, y el marcador se borra cuando salió en todos.
1254
+ *
1255
+ * No se borra lo que no se pudo leer ni lo que no salió: se dice y se reintenta. Un
1256
+ * marcador de una versión ya superada sí se quita: esa noticia caducó.
1257
+ */
1258
+ const anunciarActualizacion = () => {
1259
+ if (!fs.existsSync(updatedFile)) return
1260
+ const m = ipcRead(updatedFile, null)
1261
+ if (!m || typeof m.to !== 'string') { console.log('[vault] update: updated.json exists but cannot be read · the approvers were not told'); return }
1262
+ if (m.to !== daemonVersion) {
1263
+ if (isNewer(daemonVersion, m.to)) rm(updatedFile)
1264
+ return // todavía no corre esa versión: se anuncia cuando arranque
1265
+ }
1266
+ const hechos = new Set()
1267
+ let intentos = 0
1268
+ const intento = async () => {
1269
+ intentos++
1270
+ let falta = false
1271
+ for (const p of perfilesEnMarcha()) {
1272
+ if (hechos.has(p.id)) continue
1273
+ if (!p.vault.isIdentified?.()) { falta = true; continue }
1274
+ try { await p.vault.announceUpdate({ version: m.to, from: typeof m.from === 'string' ? m.from : null }); hechos.add(p.id) } catch (e) {
1275
+ falta = true
1276
+ if (intentos === 1 || intentos === ANNOUNCE_TRIES) console.log(`[vault] update: profile ${p.name} could not tell its approvers yet: ${e?.message || e}`)
1277
+ }
1278
+ }
1279
+ if (!falta) { rm(updatedFile); return }
1280
+ if (intentos >= ANNOUNCE_TRIES) { console.log('[vault] update: some approvers were not told · it will try again on the next start'); return }
1281
+ setTimeout(intento, ANNOUNCE_EVERY_MS).unref?.()
1282
+ }
1283
+ setTimeout(intento, ANNOUNCE_EVERY_MS).unref?.()
1284
+ }
1285
+ anunciarActualizacion()
1185
1286
  mirarRelease()
1186
1287
  const relojRelease = setInterval(mirarRelease, CHECK_EVERY_MS)
1187
1288
  relojRelease.unref?.()
package/src/notices.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * LO QUE LA BÓVEDA TIENE QUE CONTARLE A QUIEN APRUEBA, y que no es un pedido.
3
+ *
4
+ * Hoy es uno: «me actualicé» —la bóveda o uno de sus aparatos— (`ev: 'updated'`, dueño 2026-10-08: «las actualizaciones, que se
5
+ * envíe una notificación de que ocurrieron a los aprobadores»). Sale como aviso por el
6
+ * proxio, pero el timbre de una app nativa llega VACÍO —lo leen Google y Apple—, así que la
7
+ * app pregunta por su lista (`op: 'approvals'`) y lo lee de aquí, en `notices`.
8
+ *
9
+ * Caduca a las 24 h: es una noticia, no un registro (eso es la bitácora).
10
+ *
11
+ * Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer se LANZA: callar
12
+ * sería decir «no pasó nada» de algo que sí pasó.
13
+ */
14
+ import fs from 'node:fs'
15
+ import path from 'node:path'
16
+ import crypto from 'node:crypto'
17
+
18
+ export const NOTICES_FILE = 'notices.json'
19
+ /** Cuánto se sigue contando un aviso. */
20
+ export const NOTICE_TTL_MS = 24 * 60 * 60 * 1000
21
+
22
+ const fail = (code, message) => Object.assign(new Error(message), { code })
23
+
24
+ /**
25
+ * @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
26
+ */
27
+ export function openNotices ({ dir, atRest, now = Date.now }) {
28
+ const file = path.join(dir, NOTICES_FILE)
29
+ let items = load()
30
+
31
+ function load () {
32
+ if (!fs.existsSync(file)) return []
33
+ try {
34
+ const d = JSON.parse(atRest.decrypt(fs.readFileSync(file, 'utf8')))
35
+ if (!d || !Array.isArray(d.items)) throw new Error('not a list')
36
+ return d.items.filter((n) => n && typeof n.id === 'string' && typeof n.ts === 'number')
37
+ } catch (e) { throw fail('notices-unreadable', `${NOTICES_FILE} cannot be read: ${e.message}`) }
38
+ }
39
+
40
+ function save () {
41
+ const tmp = file + '.tmp'
42
+ fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items })), { mode: 0o600 })
43
+ fs.renameSync(tmp, file)
44
+ }
45
+
46
+ const alive = (n) => now() - n.ts < NOTICE_TTL_MS
47
+
48
+ return {
49
+ /**
50
+ * Apunta que algo se actualizó. Devuelve el aviso tal como viaja. `product`: qué
51
+ * (`@dotrino/vaultd` es la propia bóveda); `deviceId`/`label`: el aparato, si fue uno.
52
+ */
53
+ updated ({ version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null }) {
54
+ if (typeof version !== 'string' || !version) throw fail('bad-notice', 'updated: a version is required')
55
+ if (typeof product !== 'string' || !product) throw fail('bad-notice', 'updated: a product is required')
56
+ const n = {
57
+ id: crypto.randomBytes(8).toString('hex'), ev: 'updated', product, version, from: typeof from === 'string' ? from : null,
58
+ ...(typeof deviceId === 'string' && deviceId ? { deviceId, label: typeof label === 'string' ? label : '' } : {}),
59
+ ts: now()
60
+ }
61
+ items = [...items.filter(alive), n]
62
+ save()
63
+ return n
64
+ },
65
+ /** Los avisos vivos, del más viejo al más nuevo. */
66
+ list () { return items.filter(alive).map((n) => ({ ...n })) }
67
+ }
68
+ }
package/src/notifiable.js CHANGED
@@ -2,24 +2,25 @@
2
2
  * QUÉ APROBADORES PUEDEN RECIBIR UN AVISO: lo dice cada uno, y caduca.
3
3
  *
4
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).
5
+ * no le suena y solo lo ve si abre la app por su cuenta.
8
6
  *
9
7
  * Así que el aprobador lo declara: al pedir su lista de pedidos (`op: 'approvals'`, que ya
10
8
  * manda firmado cada vez que se abre) dice `notify: true` si tiene los avisos encendidos.
11
9
  * 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.
10
+ * desinstala deja de renovarlo.
13
11
  *
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.
12
+ * NO DECIDE NADA: SE ENSEÑA (dueño, 2026-10-08). Un día decidió si la bóveda pedía permiso
13
+ * para actualizarse —sin nadie que lo hubiera dicho, se actualizaba sola— y se quitó: era
14
+ * una condición más, y el silencio de un aprobador abría la puerta. Quién aprueba lo dice
15
+ * el acta y nada más; los avisos salen a todos los que tienen `approve`, lo hayan dicho o
16
+ * no. Esto sirve para VER a cuál no le está sonando el pedido (`members`, `status`, la
17
+ * consola).
17
18
  *
18
19
  * Y NO SE LE PREGUNTA AL PROXIO, que es quien guarda las suscripciones: es la pieza de la
19
20
  * que el diseño no se fía, y creerle sería dejarle decidir cuándo no hace falta permiso.
20
21
  *
21
22
  * 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
+ * puede recibir avisos»: se lanza.
23
24
  */
24
25
  import fs from 'node:fs'
25
26
  import path from 'node:path'