@dotrino/vaultd 0.147.0 → 0.148.1

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.
@@ -707,28 +707,82 @@ export async function askUpdateApproval ({ product, version, from = null, dir, p
707
707
  * @param {object} o `product`, `version`, `from` y la conexión, como en `askUpdateApproval`
708
708
  * @returns {Promise<{ ok: true }>}
709
709
  */
710
- export async function reportUpdated ({ product, version, from = null, dir, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
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 } = {}) {
711
765
  const saved = dir ? readServiceIdentity(dir) : null
712
766
  proxyUrl = proxyUrl || saved?.proxy
713
767
  masterPubkey = masterPubkey || saved?.iss
714
768
  device = device || saved?.device
715
769
  cert = cert || saved?.cert
716
- if (typeof product !== 'string' || !product || typeof version !== 'string' || !version) throw new Error('reportUpdated: needs product and version')
770
+ if (typeof product !== 'string' || !product || typeof version !== 'string' || !version) throw new Error(`${op}: needs product and version`)
717
771
  if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
718
772
  const client = await freshClient(proxyUrl, 20000, masterPubkey)
719
773
  try {
720
774
  await identifyAsService(client, device)
721
- const data = { op: 'update-done', product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
775
+ const data = { op, product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
722
776
  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)
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)
724
778
  client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
725
779
  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' })
780
+ if (/timeout/.test(e.message)) throw Object.assign(new Error(`${op}: the vault did not answer`), { code: 'vault-no-reply' })
727
781
  throw e
728
782
  })
729
783
  if (res.type === MSG.ERROR) throw vaultError(res)
730
784
  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')
785
+ if (res.body.ok !== true) throw new Error(`malformed ${op} reply`)
732
786
  return { ok: true }
733
787
  } finally { client.close() }
734
788
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.147.0",
3
+ "version": "0.148.1",
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": {
@@ -28,7 +28,7 @@
28
28
  "@dotrino/proxy-client": "0.30.1",
29
29
  "@dotrino/roadmap": "0.3.19",
30
30
  "@dotrino/store": "0.11.0",
31
- "@dotrino/update": "0.2.2",
31
+ "@dotrino/update": "0.5.1",
32
32
  "ws": "^8.18.0"
33
33
  },
34
34
  "license": "MIT",
package/src/daemon.js CHANGED
@@ -28,7 +28,7 @@ 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 } from './updateApproval.js'
31
+ import { approversOfAll, firstYes, readAsked, writeAsked, clearAsked, shouldAsk, resultOfNo, readRootTold, writeRootTold, shouldTellRoot } from './updateApproval.js'
32
32
  import { UPDATE_TTL_MS } from './approvals.js'
33
33
  import crypto from 'node:crypto'
34
34
 
@@ -1161,6 +1161,25 @@ export async function runDaemon () {
1161
1161
  // proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
1162
1162
  if (!isUserInstall()) {
1163
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
+ }
1164
1183
  return
1165
1184
  }
1166
1185
  if (actualizando) return
@@ -1245,6 +1264,7 @@ export async function runDaemon () {
1245
1264
  }
1246
1265
  let actualizando = false
1247
1266
  let aprobada = null
1267
+ let avisandoRoot = null
1248
1268
 
1249
1269
  /**
1250
1270
  * «ME ACTUALICÉ»: se lo cuenta a quien aprueba la versión NUEVA, ya corriendo (dueño,
@@ -1263,6 +1283,18 @@ export async function runDaemon () {
1263
1283
  if (isNewer(daemonVersion, m.to)) rm(updatedFile)
1264
1284
  return // todavía no corre esa versión: se anuncia cuando arranque
1265
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) {
1266
1298
  const hechos = new Set()
1267
1299
  let intentos = 0
1268
1300
  const intento = async () => {
@@ -1271,13 +1303,13 @@ export async function runDaemon () {
1271
1303
  for (const p of perfilesEnMarcha()) {
1272
1304
  if (hechos.has(p.id)) continue
1273
1305
  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) {
1306
+ try { await p.vault.announceUpdate(aviso); hechos.add(p.id) } catch (e) {
1275
1307
  falta = true
1276
1308
  if (intentos === 1 || intentos === ANNOUNCE_TRIES) console.log(`[vault] update: profile ${p.name} could not tell its approvers yet: ${e?.message || e}`)
1277
1309
  }
1278
1310
  }
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 }
1311
+ if (!falta) return alTerminar({ todos: true, alguno: hechos.size > 0 })
1312
+ if (intentos >= ANNOUNCE_TRIES) return alTerminar({ todos: false, alguno: hechos.size > 0 })
1281
1313
  setTimeout(intento, ANNOUNCE_EVERY_MS).unref?.()
1282
1314
  }
1283
1315
  setTimeout(intento, ANNOUNCE_EVERY_MS).unref?.()
package/src/notices.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * LO QUE LA BÓVEDA TIENE QUE CONTARLE A QUIEN APRUEBA, y que no es un pedido.
3
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
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
5
6
  * envíe una notificación de que ocurrieron a los aprobadores»). Sale como aviso por el
6
7
  * proxio, pero el timbre de una app nativa llega VACÍO —lo leen Google y Apple—, así que la
7
8
  * app pregunta por su lista (`op: 'approvals'`) y lo lee de aquí, en `notices`.
@@ -18,6 +19,8 @@ import crypto from 'node:crypto'
18
19
  export const NOTICES_FILE = 'notices.json'
19
20
  /** Cuánto se sigue contando un aviso. */
20
21
  export const NOTICE_TTL_MS = 24 * 60 * 60 * 1000
22
+ /** Los avisos que existen. */
23
+ export const NOTICE_EVS = ['updated', 'update-needs-root']
21
24
 
22
25
  const fail = (code, message) => Object.assign(new Error(message), { code })
23
26
 
@@ -47,14 +50,17 @@ export function openNotices ({ dir, atRest, now = Date.now }) {
47
50
 
48
51
  return {
49
52
  /**
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.
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.
52
57
  */
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')
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`)
56
62
  const n = {
57
- id: crypto.randomBytes(8).toString('hex'), ev: 'updated', product, version, from: typeof from === 'string' ? from : null,
63
+ id: crypto.randomBytes(8).toString('hex'), ev, product, version, from: typeof from === 'string' ? from : null,
58
64
  ...(typeof deviceId === 'string' && deviceId ? { deviceId, label: typeof label === 'string' ? label : '' } : {}),
59
65
  ts: now()
60
66
  }
@@ -62,6 +68,7 @@ export function openNotices ({ dir, atRest, now = Date.now }) {
62
68
  save()
63
69
  return n
64
70
  },
71
+ updated (o) { return this.add('updated', o) },
65
72
  /** Los avisos vivos, del más viejo al más nuevo. */
66
73
  list () { return items.filter(alive).map((n) => ({ ...n })) }
67
74
  }
@@ -108,3 +108,32 @@ export function shouldAsk (asked, version, isNewer) {
108
108
  export function resultOfNo (askedAt, ttlMs, now = Date.now()) {
109
109
  return now - askedAt >= ttlMs - 60_000 ? 'expired' : 'denied'
110
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
@@ -1617,31 +1617,36 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1617
1617
  * aprueba en este perfil, por el mismo camino que su propio «me actualicé». Si el agente
1618
1618
  * avisa o no lo decide él (su interruptor); aquí solo se reparte.
1619
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
+ *
1620
1623
  * QUIÉN ES lo dice el acta (id y nombre), no lo que el aparato cuente de sí mismo.
1621
1624
  */
1622
1625
  async function handleUpdateDone (from, p) {
1626
+ const op = p.data.op
1627
+ const ev = op === 'update-needs-root' ? 'update-needs-root' : 'updated'
1623
1628
  const chk = await verifyChain({
1624
1629
  data: p.data, signature: p.signature, cert: p.cert,
1625
1630
  ...(await contextoActa()), revoked: await revocationSet()
1626
1631
  })
1627
- if (!chk.ok) return denyChain(from, chk, p, 'update-done')
1632
+ if (!chk.ok) return denyChain(from, chk, p, op)
1628
1633
  const record = (await identity.profileActa?.().catch(() => null))?.acta
1629
1634
  const member = (record?.members || []).find((m) => m.pub === chk.device)
1630
1635
  if (!member) {
1631
- audit('rejected', { what: 'update-done', reason: record ? 'acta' : 'sin-acta' })
1636
+ audit('rejected', { what: op, reason: record ? 'acta' : 'sin-acta' })
1632
1637
  return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this device is not in the record' })
1633
1638
  }
1634
1639
  const product = cut(p.data?.product, 128)
1635
1640
  const version = cut(p.data?.version, 64)
1636
- if (!product || !version) return reply(from, { type: MSG.ERROR, code: 'bad-request', error: 'update-done: needs `product` and `version`' })
1641
+ if (!product || !version) return reply(from, { type: MSG.ERROR, code: 'bad-request', error: `${op}: needs \`product\` and \`version\`` })
1637
1642
  const deviceId = await deviceIdOf(chk.device).catch(() => null)
1638
1643
  try {
1639
- await announceUpdate({ version, from: cut(p.data?.from, 64), product, deviceId, label: member.label || '' })
1644
+ await announceUpdate({ ev, version, from: cut(p.data?.from, 64), product, deviceId, label: member.label || '' })
1640
1645
  } catch (e) {
1641
- log(`[vault] update-done: ${deviceId || '????-????'} updated ${product} to ${version}, but the approvers could not be told: ${e.message}`)
1642
- return reply(from, { type: MSG.ERROR, code: e.code || 'announce-failed', error: 'update-done: the approvers could not be told' })
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` })
1643
1648
  }
1644
- const body = { op: 'update-done', ok: true, product, version, ts: Date.now() }
1649
+ const body = { op, ok: true, product, version, ts: Date.now() }
1645
1650
  reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1646
1651
  }
1647
1652
 
@@ -1713,7 +1718,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1713
1718
  if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
1714
1719
  if (p.data?.op === 'digest') return handleDigest(from, p)
1715
1720
  if (p.data?.op === 'update-ask') return handleUpdateAsk(from, p)
1716
- if (p.data?.op === 'update-done') return handleUpdateDone(from, p)
1721
+ if (p.data?.op === 'update-done' || p.data?.op === 'update-needs-root') return handleUpdateDone(from, p)
1717
1722
  if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke', 'block', 'blocked'].includes(p.data?.op)) return handleApproval(from, p)
1718
1723
  const ns = p.data?.ns
1719
1724
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
@@ -2083,18 +2088,22 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2083
2088
  * También reparte el «me actualicé» de un APARATO (`op: 'update-done'`): entonces lleva su
2084
2089
  * `product`, `deviceId` y `label`.
2085
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
+ *
2086
2095
  * A diferencia de `notifyApprovers`, aquí el fallo SE LANZA: quien llama guarda el
2087
2096
  * marcador hasta que el aviso salió, y tragarlo sería borrarlo sin haber avisado.
2088
2097
  * @returns {Promise<{ approvers: number, sent: number, notice: object|null }>}
2089
2098
  */
2090
- async function announceUpdate ({ version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null }) {
2099
+ async function announceUpdate ({ version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null, ev = 'updated' }) {
2091
2100
  const record = await refreshActa()
2092
2101
  const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
2093
2102
  if (!who.length) return { approvers: 0, sent: 0, notice: null }
2094
2103
  // `deviceId`/`label`: solo cuando quien se actualizó es un APARATO (`update-done`). El
2095
2104
  // aviso de la propia bóveda no los lleva.
2096
- const notice = notices.updated({ version, from, product, ...(deviceId ? { deviceId, label: label || '' } : {}) })
2097
- const { id, ev, ts, ...rest } = notice
2105
+ const notice = notices.add(ev, { version, from, product, ...(deviceId ? { deviceId, label: label || '' } : {}) })
2106
+ const { id, ev: _ev, ts, ...rest } = notice
2098
2107
  const body = { ev, id, ...rest, ts }
2099
2108
  const seal = await sealOrFail(body)
2100
2109
  let sent = 0
@@ -2102,8 +2111,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2102
2111
  try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, seal }, { app: 'vault' }); sent++ }
2103
2112
  catch (e) { log(`[vault] could not tell ${pub.slice(0, 24)}… about the update of ${product} to ${version}: ${e.message}`) }
2104
2113
  }
2105
- audit('update.announced', { product, version, from: notice.from, device: deviceId, approvers: who.length, sent })
2106
- log(`[vault] update: told ${sent} of ${who.length} approver(s) that ${deviceId ? `${deviceId} runs ${product}` : 'this vault now runs'} ${version}`)
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}`)
2107
2116
  if (!sent) throw Object.assign(new Error('no approver could be told'), { code: 'announce-failed' })
2108
2117
  return { approvers: who.length, sent, notice }
2109
2118
  }