@dotrino/vaultd 0.145.0 → 0.146.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.145.0",
3
+ "version": "0.146.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
@@ -131,6 +131,11 @@ function deviceIdOf (sub) {
131
131
  return pubkeyId(sub).then((id) => id.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
132
132
  }
133
133
 
134
+ /** Lo que dijo un aprobador sobre sus avisos, para enseñarlo. No decide nada. */
135
+ function avisosTxt (notifiedAt) {
136
+ return notifiedAt ? `dijo que le llegan los avisos (${fechaCorta(notifiedAt)})` : 'no ha dicho que le lleguen los avisos'
137
+ }
138
+
134
139
  function cmdStatus () {
135
140
  const s = readState()
136
141
  const up = alive(s.pid)
@@ -158,6 +163,18 @@ function cmdStatus () {
158
163
  if (s.updateAsk?.version && isNewer(s.updateAsk.version, VERSION)) {
159
164
  console.log(' pidió permiso para instalarla %s (%d %s) y nadie lo ha aprobado · mira Pedidos en tu teléfono',
160
165
  hace(s.updateAsk.since), s.updateAsk.asks, s.updateAsk.asks === 1 ? 'vez' : 'veces')
166
+ // A QUIÉN SE LE PIDIÓ, y si dijo que le llegan los avisos: el que no lo ha dicho es el
167
+ // primer sitio donde mirar cuando nadie contesta.
168
+ for (const a of s.updateAsk.approvers || []) {
169
+ console.log(' %s %s · %s', a.id, a.label || '(sin nombre)', avisosTxt(a.notifiedAt))
170
+ }
171
+ }
172
+ // CÓMO SE ACTUALIZA. Va siempre, también al día: es un ajuste y se mira aquí.
173
+ if (up && 'updateApproval' in s) {
174
+ console.log(' actualizar : %s', s.updateApproval === null
175
+ ? `${R}no se pudo leer el ajuste: no se actualiza${Z} · ponlo de nuevo con: dotrino-vault update --approval on|off`
176
+ : s.updateApproval ? 'pide aprobación (dotrino-vault update --approval off para que lo haga sola)'
177
+ : 'sola, sin pedir aprobación (dotrino-vault update --approval on para que pida)')
161
178
  }
162
179
  const profiles = s.profiles || []
163
180
  if (profiles.length) {
@@ -631,6 +648,9 @@ async function cmdMembers () {
631
648
  // Solo si LEE variables (`secrets`). La llave de comunicación de la bóveda entra con
632
649
  // `cn: 'vault'` y solo `firma`, y no tiene llave de cifrado a propósito: salía marcada
633
650
  // en rojo «NO puede leer sus variables» cuando no tiene ninguna que leer.
651
+ // SI LE LLEGAN LOS AVISOS, en quien aprueba. No cambia a quién se le pide —se le pide a
652
+ // todos—: es para ver a cuál no le suena el pedido.
653
+ if (m.caps.includes('approve') && 'notifiedAt' in m) console.log(' %s%s%s', D, avisosTxt(m.notifiedAt), Z)
634
654
  if (m.cn && !m.canSeal && m.caps.includes('secrets')) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
635
655
  // QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
636
656
  // incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
@@ -1794,6 +1814,19 @@ async function waitForService (version, { timeoutMs = 4 * 60_000 } = {}) {
1794
1814
  }
1795
1815
 
1796
1816
  async function cmdUpdate (args = []) {
1817
+ // PEDIR PERMISO PARA ACTUALIZARSE es un ajuste, apagado por defecto. Se guarda aquí y el
1818
+ // servicio lo lee la próxima vez que mire si hay versión nueva: no hace falta reiniciarlo.
1819
+ const iAp = args.indexOf('--approval')
1820
+ if (iAp !== -1) {
1821
+ const { readSettings, writeSettings } = await import('./settings.js')
1822
+ const v = args[iAp + 1]
1823
+ if (v === 'on' || v === 'off') writeSettings(dir, { updateApproval: v === 'on' })
1824
+ else if (v !== undefined) { console.error('uso: dotrino-vault update --approval [on|off]'); process.exit(2) }
1825
+ console.log(readSettings(dir).updateApproval
1826
+ ? 'Esta bóveda pide aprobación antes de actualizarse (a los aparatos con «aprueba»).'
1827
+ : 'Esta bóveda se actualiza sola, sin pedir aprobación.')
1828
+ return
1829
+ }
1797
1830
  const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease } = await import('./update.js')
1798
1831
  const soloMirar = args.includes('--check')
1799
1832
 
@@ -2174,6 +2207,9 @@ function help () {
2174
2207
  status | info estado del servicio + fingerprint + el id de cada perfil
2175
2208
  update [--check] trae la versión nueva (verificando la firma) y espera a que el
2176
2209
  servicio la arranque · --check solo mira · --no-wait no espera
2210
+ update --approval [on|off]
2211
+ el servicio se actualiza solo; con «on» antes pide aprobación a los
2212
+ aparatos con «aprueba» · sin valor, dice cómo está
2177
2213
  pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
2178
2214
  pair --kms <config.json>
2179
2215
  el sitio que se cree (--adopt o --new-account) NACE con su clave
package/src/daemon.js CHANGED
@@ -27,6 +27,7 @@ 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'
30
31
  import crypto from 'node:crypto'
31
32
 
32
33
  const readJsonSafe = (f) => ipcRead(f, null)
@@ -101,6 +102,7 @@ export async function runDaemon () {
101
102
  // Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
102
103
  // instalador y la web, que son anteriores al multi-perfil. La lista completa va
103
104
  // en `profiles`.
105
+ const ajusteActualizar = () => { try { return readSettings(dir).updateApproval } catch (_) { return null } }
104
106
  const writeState = () => {
105
107
  const cur = mgr.summary().find((p) => p.current) || {}
106
108
  ipcWrite(stateFile, {
@@ -114,6 +116,9 @@ export async function runDaemon () {
114
116
  // también sin conexión.
115
117
  ...(ultimaPublicada ? { latest: ultimaPublicada } : {}),
116
118
  ...(pedidoSinRespuesta ? { updateAsk: pedidoSinRespuesta } : {}),
119
+ // Si para actualizarse pide permiso (`src/settings.js`). `null` = el ajuste no se
120
+ // pudo leer, que `status` dice distinto de «no».
121
+ updateApproval: ajusteActualizar(),
117
122
  current: mgr.currentId(), profiles: mgr.summary()
118
123
  })
119
124
  }
@@ -1103,35 +1108,39 @@ export async function runDaemon () {
1103
1108
  if (!isNewer(r.version, daemonVersion)) return
1104
1109
 
1105
1110
  /**
1106
- * PIDE PERMISO SOLO SI HAY A QUIÉN PEDÍRSELO (dueño, 2026-09-20).
1111
+ * SE ACTUALIZA SOLA. PEDIR PERMISO ES UN AJUSTE (dueño, 2026-10-08).
1107
1112
  *
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.
1113
+ * Por defecto no se le pregunta a nadie: lo que se baja se verifica contra la
1114
+ * atestación del release antes de tocar el disco, y eso no depende de que alguien
1115
+ * conteste. Antes se pedía permiso según quién hubiera en el acta y según si decía que
1116
+ * le llegan los avisos; eran condiciones que explicar y que se desalineaban, y una
1117
+ * bóveda podía quedarse atrás sin que nadie lo viera.
1112
1118
  *
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).
1119
+ * Quien quiera decidir cada actualización lo enciende: `dotrino-vault update --approval
1120
+ * on` (`src/settings.js`). Entonces se le pide a TODO el que tenga `aprueba`, reciba
1121
+ * avisos o no, y sin un sí no se instala. Si en el acta no aprueba nadie, se actualiza
1122
+ * igual: esperar un sí que nadie puede dar sería no actualizarse nunca.
1123
+ *
1124
+ * Lo que cada aprobador dijo de sus avisos (`notifiedAt`, `src/notifiable.js`) no
1125
+ * decide nada: se enseña junto al pedido sin respuesta, para ver a cuál no le suena.
1116
1126
  */
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`)
1127
+ // SI EL AJUSTE NO SE PUEDE LEER, NO SE ACTUALIZA: el valor por defecto es no preguntar,
1128
+ // así que suponerlo sería saltarse la aprobación que el dueño pidió.
1129
+ let pedirPermiso
1130
+ try { pedirPermiso = readSettings(dir).updateApproval } catch (e) {
1131
+ console.log(`[vault] version ${r.version} is out, but the settings could not be read (${e?.message || e}) · not updating`)
1123
1132
  return
1124
1133
  }
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)
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ó.
1136
+ let quienAprueba = []
1137
+ if (pedirPermiso) {
1138
+ try { quienAprueba = await mgr.current().approvers() } catch (e) {
1139
+ console.log(`[vault] version ${r.version} is out, but the approvers could not be read (${e?.message || e}) · not updating`)
1140
+ return
1141
+ }
1142
+ }
1143
+ const conAvisos = quienAprueba.filter((a) => a.notifiedAt).length
1135
1144
 
1136
1145
  // INSTALADA COMO PAQUETE DEL SISTEMA (.deb en /usr/bin): el binario es de root y este
1137
1146
  // proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
@@ -1142,22 +1151,25 @@ export async function runDaemon () {
1142
1151
  if (actualizando) return
1143
1152
  actualizando = true
1144
1153
  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
1154
  if (quienAprueba.length) {
1148
- console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · asking one of ${quienAprueba.length} approver(s)`)
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`)
1149
1156
  const si = await mgr.current().askUpdateApproval({ version: r.version })
1150
1157
  if (!si) {
1151
1158
  const antes = pedidoSinRespuesta?.version === r.version ? pedidoSinRespuesta : null
1152
- pedidoSinRespuesta = { version: r.version, since: antes?.since || Date.now(), asks: (antes?.asks || 0) + 1 }
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
+ }
1153
1165
  writeState()
1154
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`)
1155
1167
  return
1156
1168
  }
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`)
1169
+ } 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`)
1159
1171
  } else {
1160
- console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · no approver in the record: updating on its own`)
1172
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · updating on its own`)
1161
1173
  }
1162
1174
  if (pedidoSinRespuesta) { pedidoSinRespuesta = null; writeState() }
1163
1175
  const res = await installUserRelease(r, { log: (m) => console.log(m) })
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'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * AJUSTES DE ESTA BÓVEDA: lo que el dueño elige distinto del comportamiento por defecto.
3
+ *
4
+ * Hoy es uno: `updateApproval`. Por defecto la bóveda se actualiza sola (lo que baja se
5
+ * verifica contra la atestación del release, se pida permiso o no); con `updateApproval:
6
+ * true` antes le pide permiso a los aparatos con `approve` (dueño, 2026-10-08).
7
+ *
8
+ * Va por el canal local (`ipc.js`): lo escribe la CLI (`dotrino-vault update --approval
9
+ * on|off`) y lo lee el daemon cada vez que mira si hay versión nueva, sin señal ni reinicio.
10
+ *
11
+ * UN ARCHIVO QUE EXISTE Y NO SE PUEDE LEER NO ES «EL VALOR POR DEFECTO»: el por defecto es
12
+ * actualizarse sin preguntar, así que tragarse el fallo sería saltarse la aprobación que el
13
+ * dueño pidió justo cuando algo se rompió. Se lanza.
14
+ */
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import { ipcRead, ipcWrite } from './ipc.js'
18
+
19
+ export const SETTINGS_FILE = 'settings.json'
20
+
21
+ const fail = (code, message) => Object.assign(new Error(message), { code })
22
+
23
+ /** @returns {{ updateApproval: boolean }} */
24
+ export function readSettings (dir) {
25
+ const file = path.join(dir, SETTINGS_FILE)
26
+ if (!fs.existsSync(file)) return { updateApproval: false }
27
+ const raw = ipcRead(file, null)
28
+ if (!raw || typeof raw !== 'object' || typeof raw.updateApproval !== 'boolean') {
29
+ throw fail('settings-unreadable', `${SETTINGS_FILE} exists but cannot be read`)
30
+ }
31
+ return { updateApproval: raw.updateApproval }
32
+ }
33
+
34
+ export function writeSettings (dir, { updateApproval }) {
35
+ if (typeof updateApproval !== 'boolean') throw fail('bad-setting', 'updateApproval must be true or false')
36
+ ipcWrite(path.join(dir, SETTINGS_FILE), { v: 1, updateApproval })
37
+ }
package/src/tui/app.js CHANGED
@@ -155,7 +155,9 @@ function mergeMembersAndCerts (members, issued) {
155
155
  noAccess: !cert && !m.isMaster && !m.cn,
156
156
  // QUÉ CORRE ESE APARATO (CONVENCIONES §14). Lo apunta la bóveda cuando le habla, así
157
157
  // que aquí solo hay que dejarlo pasar — que es justo lo que le faltaba a `addedAt`.
158
- running: m.running || null
158
+ running: m.running || null,
159
+ // SI LE LLEGAN LOS AVISOS, solo en quien aprueba (`undefined` = no aprueba, no se pinta).
160
+ notifiedAt: (m.caps || []).includes('approve') && 'notifiedAt' in m ? m.notifiedAt : undefined
159
161
  }
160
162
  })
161
163
  }
@@ -386,6 +388,9 @@ function deviceRows (st, t) {
386
388
  const extra = (d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : '') +
387
389
  (vars ? t.muted(` vars:${vars}`) : '') +
388
390
  corre +
391
+ // En quien aprueba: si dijo que le llegan los avisos. No cambia a quién se le pide
392
+ // (a todos); deja ver a cuál no le suena el pedido. Es un dato, va en gris.
393
+ (d.notifiedAt === undefined ? '' : t.muted(` ${d.notifiedAt ? i.notifyYes : i.notifyUnknown}`)) +
389
394
  // EN DEUDA: en el acta y sin poder abrir lo suyo. Va en color de aviso al lado de
390
395
  // sus variables, que es donde se mira cuando algo no arranca.
391
396
  (debt ? t.warn(` ${i.deviceDebt(debt)}`) : '')
package/src/tui/i18n.js CHANGED
@@ -272,6 +272,8 @@ const es = {
272
272
  revokedCount: (n) => ` Revocados: ${n}`,
273
273
  startingPairing: 'Iniciando emparejamiento…',
274
274
  deviceSince: 'desde',
275
+ notifyYes: 'le llegan avisos',
276
+ notifyUnknown: 'no ha dicho si le llegan avisos',
275
277
  nameDeviceLabel: '¿Cómo se va a llamar este aparato?',
276
278
  nameDeviceHint: 'Enter para dejar que lo diga él (usará tu apodo, y así no se distinguen)',
277
279
  noPending: 'No hay ningún dispositivo pendiente',
@@ -719,6 +721,8 @@ const en = {
719
721
  revokedCount: (n) => ` Revoked: ${n}`,
720
722
  startingPairing: 'Starting pairing…',
721
723
  deviceSince: 'since',
724
+ notifyYes: 'gets notifications',
725
+ notifyUnknown: 'has not said if notifications reach it',
722
726
  nameDeviceLabel: 'What will this device be called?',
723
727
  nameDeviceHint: 'Enter to let it say (it will use your nickname, and then they all look alike)',
724
728
  noPending: 'No device is waiting',
package/src/vault.js CHANGED
@@ -263,7 +263,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
263
263
  const passwordsAtRest = atRestFor(dir)
264
264
  // Los aparatos BLOQUEADOS por un aprobador (`src/blocked.js`). Cifrado en reposo como lo demás.
265
265
  const blocked = openBlocked({ dir, atRest: passwordsAtRest })
266
- // Qué aprobadores dijeron que pueden recibir avisos (`src/notifiable.js`): solo lo mira la actualización.
266
+ // Qué aprobadores dijeron que pueden recibir avisos (`src/notifiable.js`). No decide nada: se enseña.
267
267
  const notifiable = openNotifiable({ dir, atRest: passwordsAtRest })
268
268
  const readPasswordsFile = () => {
269
269
  try { return JSON.parse(passwordsAtRest.decrypt(fs.readFileSync(passwordsFile, 'utf8'))) } catch (_) { return null }
@@ -1320,7 +1320,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1320
1320
  // que arrancamos», que es distinto de «no declara» — y se dice distinto.
1321
1321
  const devices = await Promise.all(issued.map(async (x) => ({
1322
1322
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce,
1323
- running: x.sub ? versionDe(x.sub) : null
1323
+ running: x.sub ? versionDe(x.sub) : null,
1324
+ notifiedAt: x.sub ? notifiable.since(x.sub) : null
1324
1325
  })))
1325
1326
  // LA SUBACTA VIAJA CON EL ACTA. Es la mitad de «se comparte entre todos los
1326
1327
  // dispositivos»: sin esto, una renuncia sin sellar solo la respeta esta máquina.
@@ -4036,7 +4037,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
4036
4037
  return (record.members || [])
4037
4038
  .filter((m) => Acta.memberCan(record, m.pub, 'approve'))
4038
4039
  // `notifiedAt`: cuándo dijo por última vez que le llegan los avisos (null = no lo ha
4039
- // dicho, o caducó). Solo lo usa la actualización para saber si hay a quién pedirle.
4040
+ // dicho, o caducó). No decide si se pide: se enseña junto al pedido sin respuesta.
4040
4041
  .map((m) => ({ id: m.id, label: m.label || '', pub: m.pub, notifiedAt: notifiable.since(m.pub) }))
4041
4042
  },
4042
4043
  // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
@@ -4176,6 +4177,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
4176
4177
  members: (r?.members || []).map((m) => ({
4177
4178
  ...m,
4178
4179
  running: versionDe(m.pub),
4180
+ // Cuándo dijo que le llegan los avisos (null = no lo ha dicho, o caducó). Solo
4181
+ // tiene sentido en quien aprueba, y es un dato para enseñar: no decide nada.
4182
+ notifiedAt: notifiable.since(m.pub),
4179
4183
  renounced: subacta.forMember(m.pub).flatMap((e) => e.caps || [])
4180
4184
  }))
4181
4185
  }