@dotrino/vaultd 0.144.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.144.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": {
@@ -25,7 +25,7 @@
25
25
  "@dotrino/identity": "^0.107.1",
26
26
  "@dotrino/opaque": "0.1.0",
27
27
  "@dotrino/passmanager": "0.17.0",
28
- "@dotrino/proxy-client": "0.30.0",
28
+ "@dotrino/proxy-client": "0.30.1",
29
29
  "@dotrino/roadmap": "0.3.19",
30
30
  "@dotrino/store": "0.11.0",
31
31
  "@dotrino/update": "0.2.2",
package/src/ctl.js CHANGED
@@ -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)
@@ -153,6 +158,24 @@ function cmdStatus () {
153
158
  console.log(' %s⬆ hay %s publicada%s (esta es la %s) · tráela con: dotrino-vault update',
154
159
  B, s.latest.version, Z, VERSION)
155
160
  }
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.
163
+ 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')
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)')
178
+ }
156
179
  const profiles = s.profiles || []
157
180
  if (profiles.length) {
158
181
  console.log(' perfiles : %d', profiles.length)
@@ -625,6 +648,9 @@ async function cmdMembers () {
625
648
  // Solo si LEE variables (`secrets`). La llave de comunicación de la bóveda entra con
626
649
  // `cn: 'vault'` y solo `firma`, y no tiene llave de cifrado a propósito: salía marcada
627
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)
628
654
  if (m.cn && !m.canSeal && m.caps.includes('secrets')) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
629
655
  // QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
630
656
  // incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
@@ -1788,6 +1814,19 @@ async function waitForService (version, { timeoutMs = 4 * 60_000 } = {}) {
1788
1814
  }
1789
1815
 
1790
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
+ }
1791
1830
  const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease } = await import('./update.js')
1792
1831
  const soloMirar = args.includes('--check')
1793
1832
 
@@ -2168,6 +2207,9 @@ function help () {
2168
2207
  status | info estado del servicio + fingerprint + el id de cada perfil
2169
2208
  update [--check] trae la versión nueva (verificando la firma) y espera a que el
2170
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á
2171
2213
  pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
2172
2214
  pair --kms <config.json>
2173
2215
  el sitio que se cree (--adopt o --new-account) NACE con su clave
@@ -2380,6 +2422,14 @@ async function cmdReplica (args) {
2380
2422
  process.exit(2)
2381
2423
  }
2382
2424
 
2425
+ /** «hace 3 días», «hace 5 horas»: para decir desde cuándo espera algo. */
2426
+ function hace (ms) {
2427
+ const h = Math.max(0, Math.floor((Date.now() - ms) / 3600000))
2428
+ if (h < 1) return 'hace menos de una hora'
2429
+ if (h < 48) return `hace ${h} ${h === 1 ? 'hora' : 'horas'}`
2430
+ return `hace ${Math.floor(h / 24)} días`
2431
+ }
2432
+
2383
2433
  export async function runCtl (argv) {
2384
2434
  const [cmd, ...rest] = takeProfileFlag(argv)
2385
2435
  // `--help` EN CUALQUIER COMANDO ENSEÑA LA AYUDA Y NO HACE NADA. Ningún subcomando lo miraba,
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)
@@ -92,9 +93,16 @@ export async function runDaemon () {
92
93
  const daemonVersion = VERSION
93
94
  /** `{ version, checkedAt }` de la última consulta que salió bien, o null si aún ninguna. */
94
95
  let ultimaPublicada = null
96
+ /**
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.
100
+ */
101
+ let pedidoSinRespuesta = null
95
102
  // Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
96
103
  // instalador y la web, que son anteriores al multi-perfil. La lista completa va
97
104
  // en `profiles`.
105
+ const ajusteActualizar = () => { try { return readSettings(dir).updateApproval } catch (_) { return null } }
98
106
  const writeState = () => {
99
107
  const cur = mgr.summary().find((p) => p.current) || {}
100
108
  ipcWrite(stateFile, {
@@ -107,6 +115,10 @@ export async function runDaemon () {
107
115
  // sin salir a la red: una pantalla de estado tiene que contestar al instante y
108
116
  // también sin conexión.
109
117
  ...(ultimaPublicada ? { latest: ultimaPublicada } : {}),
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(),
110
122
  current: mgr.currentId(), profiles: mgr.summary()
111
123
  })
112
124
  }
@@ -1096,19 +1108,39 @@ export async function runDaemon () {
1096
1108
  if (!isNewer(r.version, daemonVersion)) return
1097
1109
 
1098
1110
  /**
1099
- * 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).
1112
+ *
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.
1100
1118
  *
1101
- * La bóveda es la pieza sensible del ecosistema: quien le cuele una actualización se
1102
- * lleva la maestra, y eso no se rota — se pierde la cuenta. Por eso no se actualiza
1103
- * sola mientras haya un aparato con `aprueba` que pueda decidirlo, igual que ya pasa
1104
- * con las claves privadas.
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.
1105
1123
  *
1106
- * Y si NO hay aprobadores, esperar un sí que nadie puede dar sería condenarla a no
1107
- * actualizarse nunca. Entonces se actualiza sola, que es lo que hace el resto del
1108
- * ecosistema (§15).
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.
1109
1126
  */
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`)
1132
+ return
1133
+ }
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ó.
1110
1136
  let quienAprueba = []
1111
- try { quienAprueba = (await mgr.current()?.approvers?.()) || [] } catch (_) {}
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
1112
1144
 
1113
1145
  // INSTALADA COMO PAQUETE DEL SISTEMA (.deb en /usr/bin): el binario es de root y este
1114
1146
  // proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
@@ -1119,15 +1151,27 @@ export async function runDaemon () {
1119
1151
  if (actualizando) return
1120
1152
  actualizando = true
1121
1153
  try {
1122
- // PIDE PERMISO SOLO SI HAY A QUIÉN PEDÍRSELO (dueño, 2026-09-20); sin aprobadores
1123
- // no se necesita aprobación (dueño, 2026-09-24).
1124
1154
  if (quienAprueba.length) {
1125
- 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`)
1126
1156
  const si = await mgr.current().askUpdateApproval({ version: r.version })
1127
- if (!si) { console.log(`[vault] update to ${r.version}: not approved (denied or expired) · it will ask again`); return }
1157
+ 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
+ }
1165
+ 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`)
1167
+ return
1168
+ }
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`)
1128
1171
  } else {
1129
- 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`)
1130
1173
  }
1174
+ if (pedidoSinRespuesta) { pedidoSinRespuesta = null; writeState() }
1131
1175
  const res = await installUserRelease(r, { log: (m) => console.log(m) })
1132
1176
  if (!res.ok) {
1133
1177
  // Lo que NO cuadra no se instala, y se dice por qué: el archivo queda a mano.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * QUÉ APROBADORES PUEDEN RECIBIR UN AVISO: lo dice cada uno, y caduca.
3
+ *
4
+ * Un aparato con `approve` que no recibe avisos es un aprobador solo en el papel: el pedido
5
+ * no le suena y solo lo ve si abre la app por su cuenta.
6
+ *
7
+ * Así que el aprobador lo declara: al pedir su lista de pedidos (`op: 'approvals'`, que ya
8
+ * manda firmado cada vez que se abre) dice `notify: true` si tiene los avisos encendidos.
9
+ * Aquí se apunta CUÁNDO lo dijo por última vez, y vale `NOTIFIABLE_TTL_MS`: una app que se
10
+ * desinstala deja de renovarlo.
11
+ *
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).
18
+ *
19
+ * Y NO SE LE PREGUNTA AL PROXIO, que es quien guarda las suscripciones: es la pieza de la
20
+ * que el diseño no se fía, y creerle sería dejarle decidir cuándo no hace falta permiso.
21
+ *
22
+ * Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer NO es «nadie
23
+ * puede recibir avisos»: se lanza.
24
+ */
25
+ import fs from 'node:fs'
26
+ import path from 'node:path'
27
+
28
+ export const NOTIFIABLE_FILE = 'notifiable.json'
29
+ /** Cuánto vale un «puedo recibir avisos» sin renovarse. */
30
+ export const NOTIFIABLE_TTL_MS = 30 * 24 * 60 * 60 * 1000
31
+
32
+ const fail = (code, message) => Object.assign(new Error(message), { code })
33
+
34
+ /**
35
+ * @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
36
+ */
37
+ export function openNotifiable ({ dir, atRest, now = Date.now }) {
38
+ const file = path.join(dir, NOTIFIABLE_FILE)
39
+ /** pub → ms de la última vez que dijo que sí */
40
+ const items = load()
41
+
42
+ function load () {
43
+ let raw
44
+ try { raw = fs.readFileSync(file, 'utf8') } catch (e) {
45
+ if (e.code === 'ENOENT') return new Map()
46
+ throw fail('notifiable-unreadable', `cannot read ${NOTIFIABLE_FILE}: ${e.message}`)
47
+ }
48
+ let d
49
+ try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} cannot be opened: ${e.message}`) }
50
+ if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} has an unknown shape`)
51
+ return new Map(Object.entries(d.items))
52
+ }
53
+
54
+ function save () {
55
+ const tmp = file + '.tmp'
56
+ fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })), { mode: 0o600 })
57
+ fs.renameSync(tmp, file)
58
+ }
59
+
60
+ return {
61
+ /**
62
+ * Lo que acaba de decir un aprobador. `true` lo apunta (o lo renueva); `false` lo quita
63
+ * ya, sin esperar a que caduque. Cualquier otra cosa no dice nada y no cambia nada: un
64
+ * cliente viejo, que no manda el campo, no borra lo que dijo uno nuevo.
65
+ */
66
+ declare (pub, canNotify) {
67
+ if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'declare: a device key is required')
68
+ if (canNotify === true) { items.set(pub, now()); save() } else if (canNotify === false && items.delete(pub)) save()
69
+ },
70
+ /** Cuándo lo dijo por última vez, o `null` si nunca o si ya caducó. */
71
+ since (pub) {
72
+ const t = items.get(pub)
73
+ return typeof t === 'number' && now() - t < NOTIFIABLE_TTL_MS ? t : null
74
+ }
75
+ }
76
+ }
@@ -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)}`) : '')
@@ -2438,6 +2443,11 @@ function render (term, st) {
2438
2443
  // instalar, peor: el daemon se queda con el binario viejo, ya borrado, y hay dos copias
2439
2444
  // en RAM). `status` ya lo avisa; aquí también, que es donde uno se queda mirando.
2440
2445
  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
2447
+ // normal y simplemente no se actualiza, así que se dice aquí.
2448
+ 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)))
2450
+ }
2441
2451
  // Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
2442
2452
  // el resto muestra su título simple.
2443
2453
  lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + title(i, st.screen))
package/src/tui/i18n.js CHANGED
@@ -26,6 +26,7 @@ const es = {
26
26
  // encabezado / estado
27
27
  daemonRunning: 'corriendo',
28
28
  daemonStopped: 'DETENIDO',
29
+ updateUnanswered: (version, days) => `⬆ la ${version} espera tu aprobación desde hace ${days < 1 ? 'menos de un día' : days === 1 ? '1 día' : days + ' días'}: mira Pedidos en tu teléfono`,
29
30
  daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
30
31
  activeVault: 'Bóveda activa: ',
31
32
  noName: '(sin nombre)',
@@ -271,6 +272,8 @@ const es = {
271
272
  revokedCount: (n) => ` Revocados: ${n}`,
272
273
  startingPairing: 'Iniciando emparejamiento…',
273
274
  deviceSince: 'desde',
275
+ notifyYes: 'le llegan avisos',
276
+ notifyUnknown: 'no ha dicho si le llegan avisos',
274
277
  nameDeviceLabel: '¿Cómo se va a llamar este aparato?',
275
278
  nameDeviceHint: 'Enter para dejar que lo diga él (usará tu apodo, y así no se distinguen)',
276
279
  noPending: 'No hay ningún dispositivo pendiente',
@@ -498,6 +501,7 @@ const en = {
498
501
 
499
502
  daemonRunning: 'running',
500
503
  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`,
501
505
  daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
502
506
  activeVault: 'Active vault: ',
503
507
  tooSmall: 'Terminal too small',
@@ -717,6 +721,8 @@ const en = {
717
721
  revokedCount: (n) => ` Revoked: ${n}`,
718
722
  startingPairing: 'Starting pairing…',
719
723
  deviceSince: 'since',
724
+ notifyYes: 'gets notifications',
725
+ notifyUnknown: 'has not said if notifications reach it',
720
726
  nameDeviceLabel: 'What will this device be called?',
721
727
  nameDeviceHint: 'Enter to let it say (it will use your nickname, and then they all look alike)',
722
728
  noPending: 'No device is waiting',
package/src/vault.js CHANGED
@@ -30,6 +30,7 @@ import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './thr
30
30
  import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
31
31
  import { openSubacta } from './subacta.js'
32
32
  import { openBlocked } from './blocked.js'
33
+ import { openNotifiable } from './notifiable.js'
33
34
  import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
34
35
  import { VERSION } from './version.js'
35
36
  import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
@@ -262,6 +263,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
262
263
  const passwordsAtRest = atRestFor(dir)
263
264
  // Los aparatos BLOQUEADOS por un aprobador (`src/blocked.js`). Cifrado en reposo como lo demás.
264
265
  const blocked = openBlocked({ dir, atRest: passwordsAtRest })
266
+ // Qué aprobadores dijeron que pueden recibir avisos (`src/notifiable.js`). No decide nada: se enseña.
267
+ const notifiable = openNotifiable({ dir, atRest: passwordsAtRest })
265
268
  const readPasswordsFile = () => {
266
269
  try { return JSON.parse(passwordsAtRest.decrypt(fs.readFileSync(passwordsFile, 'utf8'))) } catch (_) { return null }
267
270
  }
@@ -1317,7 +1320,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1317
1320
  // que arrancamos», que es distinto de «no declara» — y se dice distinto.
1318
1321
  const devices = await Promise.all(issued.map(async (x) => ({
1319
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,
1320
- running: x.sub ? versionDe(x.sub) : null
1323
+ running: x.sub ? versionDe(x.sub) : null,
1324
+ notifiedAt: x.sub ? notifiable.since(x.sub) : null
1321
1325
  })))
1322
1326
  // LA SUBACTA VIAJA CON EL ACTA. Es la mitad de «se comparte entre todos los
1323
1327
  // dispositivos»: sin esto, una renuncia sin sellar solo la respeta esta máquina.
@@ -1763,6 +1767,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1763
1767
  body = { ...body, ts: Date.now() }
1764
1768
  reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body) })
1765
1769
  }
1770
+ // AL PEDIR SU LISTA, EL APROBADOR DICE SI LE LLEGAN LOS AVISOS (`notify`). Va dentro de lo
1771
+ // que ya firma, así que no hay mensaje nuevo ni nada que el proxio pueda inventar.
1772
+ if (op === 'approvals' && typeof p.data?.notify === 'boolean') {
1773
+ try { notifiable.declare(chk.device, p.data.notify) } catch (e) { log(`[vault] could not record whether ${by || '????-????'} can be notified: ${e.message}`) }
1774
+ }
1766
1775
  if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record) })
1767
1776
  // LO QUE YA ESTÁ APROBADO SE VE Y SE CORTA DESDE DONDE SE APROBÓ. Una concesión que se
1768
1777
  // renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
@@ -4027,7 +4036,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
4027
4036
  if (!record) return []
4028
4037
  return (record.members || [])
4029
4038
  .filter((m) => Acta.memberCan(record, m.pub, 'approve'))
4030
- .map((m) => ({ id: m.id, label: m.label || '', pub: m.pub }))
4039
+ // `notifiedAt`: cuándo dijo por última vez que le llegan los avisos (null = no lo ha
4040
+ // dicho, o caducó). No decide si se pide: se enseña junto al pedido sin respuesta.
4041
+ .map((m) => ({ id: m.id, label: m.label || '', pub: m.pub, notifiedAt: notifiable.since(m.pub) }))
4031
4042
  },
4032
4043
  // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
4033
4044
  // llave. Lo que no puede es listarla un aparato.
@@ -4166,6 +4177,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
4166
4177
  members: (r?.members || []).map((m) => ({
4167
4178
  ...m,
4168
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),
4169
4183
  renounced: subacta.forMember(m.pub).flatMap((e) => e.caps || [])
4170
4184
  }))
4171
4185
  }