@dotrino/vaultd 0.126.0 → 0.128.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/lib/src/admin.js CHANGED
@@ -284,6 +284,12 @@ export function createAdminDesk ({
284
284
  by
285
285
  })
286
286
  const keys = result?.keys || []
287
+ // EN ESPERA DE APROBACIÓN: todavía no cambió nada, así que no hay de qué avisar. El
288
+ // aviso sale cuando se aprueba y se escribe de verdad.
289
+ if (result?.pending) {
290
+ audit('admin.var.pending', { by, ns: toScope ? data.ns : null, id: result.pending, count: data.items.length })
291
+ return { ok: true, result }
292
+ }
287
293
  audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, keys })
288
294
  await notify('vars', { by, keys, ns: toScope ? data.ns : null })
289
295
  return { ok: true, result: result || { ok: true } }
@@ -308,6 +314,10 @@ export function createAdminDesk ({
308
314
  caller: chk.device,
309
315
  by
310
316
  })
317
+ if (result?.pending) {
318
+ audit('admin.var.pending', { by, ns: toScope ? data.ns : null, id: result.pending, count: 1 })
319
+ return { ok: true, result }
320
+ }
311
321
  audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, key: data.key })
312
322
  // Cambiar la configuración de un servicio a distancia no puede ser invisible: es
313
323
  // la contrapartida de delegar (F3 de docs/consola-remota.md).
@@ -313,7 +313,7 @@ export async function sayHello (client, qr) {
313
313
  const hello = await new Promise((resolve, reject) => {
314
314
  const off = client.on('message', (_f, p) => {
315
315
  if (p?.type === MSG.HELLO_OK) { finish(); verifyHello(p, qr.sn).then(resolve, reject) }
316
- else if (p?.type === MSG.ERROR) { finish(); reject(new Error(p.error)) }
316
+ else if (p?.type === MSG.ERROR) { finish(); reject(vaultError(p)) }
317
317
  })
318
318
  const t = setTimeout(() => { finish(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
319
319
  const finish = () => { off(); clearTimeout(t) }
@@ -387,7 +387,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
387
387
  if (p.type === MSG.ENROLL_CHALLENGE) {
388
388
  const show = onCode || (({ deviceId, code }) => console.log(`[vault] device ${deviceId} · approve it on the vault: dotrino-vault approve ${code}`))
389
389
  show({ deviceId, code })
390
- } else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
390
+ } else if (p.type === MSG.ENROLLED) { cleanup(); resolve(p) } else if (p.type === MSG.ERROR) { cleanup(); reject(vaultError(p)) }
391
391
  })
392
392
  const t = setTimeout(() => { cleanup(); reject(new Error('timeout waiting for approval on the vault')) }, approveTimeoutMs)
393
393
  const cleanup = () => { off(); clearTimeout(t) }
@@ -507,7 +507,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
507
507
  const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
508
508
  client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
509
509
  const res = await pending
510
- if (res.type === MSG.ERROR) throw new Error(res.error)
510
+ if (res.type === MSG.ERROR) throw vaultError(res)
511
511
  return { encPub, created, ok: true }
512
512
  } finally { client.close() }
513
513
  }
@@ -656,7 +656,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
656
656
  try { res = await pending; break } catch (e) { ultimo = e }
657
657
  }
658
658
  if (!res) throw ultimo || new Error('nobody answered: neither the vault nor any replica')
659
- if (res.type === MSG.ERROR) throw new Error(res.error)
659
+ if (res.type === MSG.ERROR) throw vaultError(res)
660
660
 
661
661
  // CAJÓN CON APROBACIÓN: la bóveda contesta «pendiente» (firmado) y la respuesta de
662
662
  // verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
@@ -668,7 +668,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
668
668
  const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
669
669
  res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
670
670
  .catch((e) => { throw new Error(/timeout/.test(e.message) ? 'approval: nobody approved the request in time' : e.message) })
671
- if (res.type === MSG.ERROR) throw new Error(res.error)
671
+ if (res.type === MSG.ERROR) throw vaultError(res)
672
672
  }
673
673
 
674
674
  // AUTENTICIDAD: la firma NO es de la maestra. El acta dice qué llave vale para esto
@@ -1244,9 +1244,23 @@ function fingerprintOf (secrets) {
1244
1244
  */
1245
1245
  const FINAL_CODES = new Set([
1246
1246
  'no-wrapping', // falta la envoltura, y solo la fabrica el dueño abriendo la bóveda
1247
- 'plaintext-var' // el valor está en claro en la bóveda: hay que volver a escribirlo
1247
+ 'plaintext-var', // el valor está en claro en la bóveda: hay que volver a escribirlo
1248
+ // NADIE EN EL ACTA PUEDE APROBAR: reintentar no lo arregla, y callarlo es lo peor. Pasó el
1249
+ // 2026-09-24: la limpieza de aparatos muertos se llevó al único teléfono con `aprueba`, y
1250
+ // `dotrino-env` se quedó reintentando cada minuto sin decir nada mientras la bóveda
1251
+ // contestaba exactamente qué había que hacer.
1252
+ 'no-approver'
1248
1253
  ])
1249
1254
 
1255
+ /**
1256
+ * Un `vault.error` como Error CON SU `code`. Antes se construía con el texto a secas, y el
1257
+ * código que la bóveda manda para distinguir los casos se perdía justo aquí — así que
1258
+ * `isFinal` no podía reconocer ninguno por `code` y todo se reintentaba.
1259
+ */
1260
+ function vaultError (p) {
1261
+ return Object.assign(new Error(p?.error || 'vault error'), { code: p?.code || null })
1262
+ }
1263
+
1250
1264
  export function isFinal (e) {
1251
1265
  if (FINAL_CODES.has(e?.code)) return true
1252
1266
  return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.126.0",
3
+ "version": "0.128.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.101.0",
26
26
  "@dotrino/opaque": "0.1.0",
27
27
  "@dotrino/passmanager": "0.17.0",
28
- "@dotrino/proxy-client": "0.23.1",
28
+ "@dotrino/proxy-client": "0.24.0",
29
29
  "@dotrino/roadmap": "0.3.7",
30
30
  "@dotrino/store": "0.11.0",
31
31
  "@dotrino/update": "0.1.1",
package/src/approvals.js CHANGED
@@ -73,7 +73,9 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
73
73
  * de un sobre (`handleApproval`). Si mañana aparece otro sitio que publique esta lista,
74
74
  * esto es lo que hay que recordar.
75
75
  */
76
- const publicOf = (p) => ({ id: p.id, ns: p.ns, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp, ctx: p.ctx || null })
76
+ // `kind` dice QUÉ se pide: `read` (llevarse las claves, lo de siempre) o `write` (guardar
77
+ // variables). La pantalla del teléfono no puede decir «pide tus claves» a una escritura.
78
+ const publicOf = (p) => ({ id: p.id, ns: p.ns, kind: p.kind, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp, ctx: p.ctx || null })
77
79
 
78
80
  return {
79
81
  /** Apunta un pedido. Uno por cajón y aparato: pedir otra vez reemplaza al anterior. */
@@ -82,12 +84,15 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
82
84
  * apruebe— y tiene que volver por el mismo sitio: desde que la bóveda atiende también
83
85
  * por un socket local, mandarla siempre por el proxio la dejaba en el vacío.
84
86
  */
85
- request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null, ttlMs = null }) {
86
- for (const [id, p] of pending) if (p.ns === ns && p.device === device) pending.delete(id)
87
+ request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null, ttlMs = null, kind = 'read' }) {
88
+ // Pedir las claves otra vez reemplaza al pedido anterior: es la misma pregunta. Una
89
+ // ESCRITURA no: cada una lleva su propio valor, y reemplazarla tiraría la anterior
90
+ // sin que nadie dijera que no.
91
+ if (kind !== 'write') for (const [id, p] of pending) if (p.kind !== 'write' && p.ns === ns && p.device === device) pending.delete(id)
87
92
  const ts = now()
88
93
  // `ttlMs` lo pone quien pide: una actualización aguanta un día y un comando cinco
89
94
  // minutos, y la mesa no tiene por qué saber cuál es cuál (ver `PENDING_TTL_MS`).
90
- const p = { id: rnd(), ns, device, deviceId, label, ek, ctx, ts, exp: ts + (ttlMs || pendingTtlMs), from }
95
+ const p = { id: rnd(), ns, kind, device, deviceId, label, ek, ctx, ts, exp: ts + (ttlMs || pendingTtlMs), from }
91
96
  pending.set(p.id, p)
92
97
  return publicOf(p)
93
98
  },
@@ -180,4 +185,20 @@ export function createGrants ({ now = Date.now, ttlMs = GRANT_TTL_MS } = {}) {
180
185
  return api
181
186
  }
182
187
 
183
- export default { createApprovals, createGrants, PENDING_TTL_MS, UPDATE_TTL_MS, GRANT_TTL_MS }
188
+ /**
189
+ * ¿SE LLEVÓ UNA LIMPIEZA AL ÚLTIMO QUE APROBABA? Devuelve los aprobadores que se fueron si
190
+ * antes había alguno y ahora no queda ninguno; si no, una lista vacía.
191
+ *
192
+ * Una cuenta SIN aprobadores puede ser legítima (todo desatendido, se actualiza sola), así
193
+ * que el aviso no es «no hay aprobadores»: es «LOS HABÍA y acabas de perder el último».
194
+ *
195
+ * @param {string[]} before llaves con `approve` antes
196
+ * @param {string[]} after llaves con `approve` después
197
+ * @param {string[]} removed llaves que salieron del acta
198
+ */
199
+ export function lastApproverGone (before, after, removed) {
200
+ if (!before.length || after.length) return []
201
+ return removed.filter((pub) => before.includes(pub))
202
+ }
203
+
204
+ export default { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, UPDATE_TTL_MS, GRANT_TTL_MS }
package/src/vault.js CHANGED
@@ -37,7 +37,7 @@ import { loadManifest, brokenOf } from '@dotrino/roadmap'
37
37
  // espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
38
38
  // llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
39
39
  // que lo recorriera hasta que la hubo (dotrino-test, smoke:demonio, 2026-08-30).
40
- import { createApprovals, createGrants, PENDING_TTL_MS, GRANT_TTL_MS } from './approvals.js'
40
+ import { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, GRANT_TTL_MS } from './approvals.js'
41
41
  import { readProcContext, sanitizeContext, commandFingerprint, commandLine } from '../lib/src/proc.js'
42
42
  import { makeContentKey, encryptWithCek, wrapForMember } from '@dotrino/identity/content'
43
43
  import { makeSealer } from './sealer.js'
@@ -155,6 +155,31 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
155
155
  return !Acta.memberCan(record, pub, 'unattended')
156
156
  }
157
157
 
158
+ /**
159
+ * APUNTA UN PEDIDO QUE NO ENTREGA UN CAJÓN, avisa a quien aprueba y deja la respuesta en
160
+ * `onAnswer(sí|no)`. Lo usan la bóveda de contraseñas (un aparato espera su sí) y guardar
161
+ * variables (la escritura se aplica al aprobar). Si nadie contesta, vence solo con un no:
162
+ * nada se queda colgado para siempre.
163
+ */
164
+ async function awaitApproval ({ device, ns, kind, what, op = null, ctx = null, onAnswer }) {
165
+ const deviceId = await deviceIdOf(device).catch(() => null)
166
+ const record = await refreshActa()
167
+ const label = (record?.members || []).find((m) => m.pub === device)?.label || ''
168
+ const pend = approvals.request({ ns, device, deviceId, label, ek: '', ctx, kind })
169
+ audit(`${what}.pending`, { device: deviceId, ns, id: pend.id, op })
170
+ log(`[vault] ${what}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
171
+ waiters.set(pend.id, onAnswer)
172
+ const t = setTimeout(() => {
173
+ if (waiters.delete(pend.id)) {
174
+ audit(`${what}.expired`, { device: deviceId, ns, id: pend.id })
175
+ onAnswer(false)
176
+ }
177
+ }, PENDING_TTL_MS)
178
+ t.unref?.()
179
+ await notifyApprovers(pend, record)
180
+ return pend
181
+ }
182
+
158
183
  /**
159
184
  * QUÉ COMANDO ESTÁ PIDIENDO LAS CLAVES, Y DESDE QUÉ CARPETA — medido, no creído.
160
185
  *
@@ -1595,16 +1620,23 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1595
1620
  const id = typeof p.data?.id === 'string' ? p.data.id : ''
1596
1621
  const pend = approvals.take(id)
1597
1622
  if (!pend) return reply(from, { type: MSG.ERROR, error: 'approval: unknown or expired request' })
1598
- // PEDIDOS QUE NO SON DE UN CAJÓN (hoy: la bóveda de contraseñas). Se resuelve ANTES
1599
- // de tocar `resultFor`, que asume un cajón y una `ek`: aquí no hay nada que sellar,
1600
- // solo una promesa esperando un sí o un no.
1623
+ // PEDIDOS QUE NO ENTREGAN UN CAJÓN (la bóveda de contraseñas, guardar variables). Se
1624
+ // resuelven ANTES de tocar `resultFor`, que asume un cajón y una `ek`: aquí no hay nada
1625
+ // que sellar, solo una promesa esperando un sí o un no.
1601
1626
  if (waiters.has(id)) {
1602
1627
  const resolver = waiters.get(id)
1603
1628
  waiters.delete(id)
1604
1629
  const ok = op === 'approve'
1605
- audit(ok ? 'passwords.approved' : 'passwords.denied', { device: pend.deviceId, id, by })
1606
- log(`[vault] passwords: request of ${pend.deviceId} ${ok ? 'approved' : 'DENIED'} by ${by}`)
1607
- resolver(ok)
1630
+ const what = pend.kind === 'write' ? 'vars' : 'passwords'
1631
+ audit(ok ? `${what}.approved` : `${what}.denied`, { device: pend.deviceId, ns: pend.ns, id, by })
1632
+ log(`[vault] ${what}: request ${pend.id} of ${pend.deviceId} ${ok ? 'approved' : 'DENIED'} by ${by}`)
1633
+ // Lo aprobado SE HACE AQUÍ (una escritura se aplica ahora), y si falla quien aprobó
1634
+ // tiene que enterarse: decirle «hecho» a algo que no se guardó es el peor resultado.
1635
+ try { await resolver(ok) } catch (e) {
1636
+ log(`[vault] ${what}: ${pend.id} was approved but could not be applied: ${e.message}`)
1637
+ audit(`${what}.apply-failed`, { id, reason: e.message })
1638
+ return reply(from, { type: MSG.ERROR, error: `approval: approved, but it could not be applied: ${e.message}` })
1639
+ }
1608
1640
  return answer({ op: `${op}.result`, id, ok: true })
1609
1641
  }
1610
1642
  if (op === 'deny') {
@@ -1917,24 +1949,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1917
1949
  // El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
1918
1950
  // Es el mismo camino que ya recorren los cajones de secretos.
1919
1951
  approve: async ({ pubkey, op }) => {
1920
- const deviceId = await deviceIdOf(pubkey).catch(() => null)
1921
- const label = (actaCache?.members || []).find((m) => m.pub === pubkey)?.label || ''
1922
- const pend = approvals.request({ ns: 'passwords', device: pubkey, deviceId, label, ek: '' })
1923
- audit('passwords.pending', { device: deviceId, id: pend.id, op })
1924
- log(`[vault] passwords: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
1925
- const espera = new Promise((resolve) => {
1926
- waiters.set(pend.id, resolve)
1927
- // Si nadie contesta, vence solo: la promesa no se queda colgada para siempre
1928
- // y el aparato recibe un no en vez de esperar sin fin.
1929
- const t = setTimeout(() => {
1930
- if (waiters.delete(pend.id)) {
1931
- audit('passwords.expired', { device: deviceId, id: pend.id })
1932
- resolve(false)
1933
- }
1934
- }, PENDING_TTL_MS)
1935
- t.unref?.()
1936
- })
1937
- await notifyApprovers(pend, actaCache)
1952
+ let resolve
1953
+ const espera = new Promise((r) => { resolve = r })
1954
+ await awaitApproval({ device: pubkey, ns: 'passwords', kind: 'read', what: 'passwords', op, onAnswer: resolve })
1938
1955
  return espera
1939
1956
  },
1940
1957
  audit,
@@ -2111,7 +2128,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2111
2128
  // El fallo NO se traga. Que un aviso no salga es exactamente lo que deja a dos
2112
2129
  // bóvedas con actas distintas sin que nadie se entere, y encontrarlo sin una línea
2113
2130
  // de log cuesta días (la última vez, tres).
2114
- try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, signature, acta }) }
2131
+ // CALLADO (`quiet`): se encola igual, pero no toca el timbre push. Un aviso de que
2132
+ // el perfil cambió puede esperar a que el aparato abra solo; timbrar por él hacía
2133
+ // sonar el teléfono con «alguien pide tus claves» sin ningún pedido detrás.
2134
+ // El timbre es para los pedidos (`notifyApprovers`), que sí esperan una mano.
2135
+ try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, signature, acta }, { quiet: true }) }
2115
2136
  catch (e) { log(`[vault] could not notify ${pub.slice(0, 24)}… of "${ev}": ${e.message}`) }
2116
2137
  }
2117
2138
  for (const m of miembros) avisar(m.pub)
@@ -2300,11 +2321,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2300
2321
  // PÚBLICA Y PRIVADA VAN IGUAL (dueño, 2026-09-02: «la única diferencia es si se
2301
2322
  // despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
2302
2323
  // marca solo decide si se entrega sin aprobación.
2324
+ const apply = async () => {
2325
+ await checkAuthor(owner, key, sealed, caller)
2326
+ await checkRecipients(owner, sealed.wraps, !!isPublic)
2327
+ await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
2328
+ audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
2329
+ await settleDebts(owner)
2330
+ }
2331
+ // Comprobado ANTES de dejarlo en espera: un sobre malo falla al guardarlo, no cinco
2332
+ // minutos después en la pantalla de quien aprueba. `apply` lo repite al aprobar.
2303
2333
  await checkAuthor(owner, key, sealed, caller)
2304
2334
  await checkRecipients(owner, sealed.wraps, !!isPublic)
2305
- await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
2306
- audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
2307
- await settleDebts(owner)
2335
+ const pending = await holdWrite({ caller, ns, pub, keys: [key], apply })
2336
+ if (pending) return { ok: true, key, pending }
2337
+ await apply()
2308
2338
  return { ok: true, key }
2309
2339
  }
2310
2340
  // NO HAY OTRO CAMINO (dueño, 2026-09-02: «no dejes caminos viejos ni fallbacks; no hay
@@ -2345,16 +2375,58 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2345
2375
  }
2346
2376
  // Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
2347
2377
  // configuración que nadie quiso, y el servicio se reinicia con ella.
2348
- for (const it of items) {
2349
- await secrets.putSealed(owner, it.key, it.sealed, { by: who, public: !!it.public })
2350
- keys.push(it.key)
2378
+ const apply = async () => {
2379
+ for (const it of items) {
2380
+ await checkAuthor(owner, it.key, it.sealed, caller)
2381
+ await checkRecipients(owner, it.sealed.wraps, !!it.public)
2382
+ }
2383
+ for (const it of items) {
2384
+ await secrets.putSealed(owner, it.key, it.sealed, { by: who, public: !!it.public })
2385
+ keys.push(it.key)
2386
+ }
2387
+ audit('secret.setMany', { ns: ns || null, keys }); scheduleNotice(ns)
2388
+ await settleDebts(owner)
2351
2389
  }
2352
- audit('secret.setMany', { ns: ns || null, keys }); scheduleNotice(ns)
2353
- await settleDebts(owner)
2390
+ const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply })
2391
+ if (pending) return { ok: true, keys: [], pending }
2392
+ await apply()
2354
2393
  return { ok: true, keys }
2355
2394
  }
2356
2395
  }
2357
2396
 
2397
+ /**
2398
+ * GUARDAR VARIABLES TAMBIÉN PIDE PERMISO (dueño, 2026-09-23: «si el admin no es
2399
+ * unattended, sí debería pedir permiso para almacenar variables»).
2400
+ *
2401
+ * Es el mismo permiso que ya decide si un aparato se lleva las claves solo: quien tiene
2402
+ * `unattended` escribe sin preguntar; quien no, deja la escritura en espera y el teléfono
2403
+ * decide. Un permiso, una regla, para leer y para escribir.
2404
+ *
2405
+ * No se espera DENTRO de la llamada: la consola corta a los 20 s y aprobar puede tardar
2406
+ * cinco minutos. Se contesta «pendiente» en el acto, el sobre se queda en memoria (va
2407
+ * sellado, la bóveda no lo abre) y se escribe al aprobar. Denegado o vencido, se tira.
2408
+ *
2409
+ * Devuelve el id del pedido si la escritura quedó en espera, o `null` si se puede hacer ya.
2410
+ */
2411
+ async function holdWrite ({ caller, ns, pub, keys, apply }) {
2412
+ if (!needsApproval(caller, await refreshActa())) return null
2413
+ // Quien llama ya comprobó el sobre; `apply` lo vuelve a comprobar al aprobar, porque el
2414
+ // acta pudo cambiar en medio.
2415
+ const target = ns || await deviceIdOf(pub).catch(() => null) || '?'
2416
+ const pend = await awaitApproval({
2417
+ device: caller, ns: target, kind: 'write', what: 'vars',
2418
+ // LOS NOMBRES van en el contexto, que viaja SELLADO al que aprueba: el proxio no
2419
+ // tiene por qué aprender cómo se llama la configuración de un servicio.
2420
+ ctx: { keys },
2421
+ onAnswer: async (ok) => {
2422
+ if (!ok) return
2423
+ await apply()
2424
+ await notifyMembers('vars', { by: await deviceIdOf(caller).catch(() => null), keys, ns: ns || null })
2425
+ }
2426
+ })
2427
+ return pend.id
2428
+ }
2429
+
2358
2430
  const admin = createAdminDesk({
2359
2431
  desk,
2360
2432
  deviceIdOf,
@@ -3545,12 +3617,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3545
3617
  // Si falla no se arrastra —abrir no depende de esto—, pero se dice.
3546
3618
  if (r?.locked === false) {
3547
3619
  try {
3620
+ const antes = await refreshActa()
3621
+ const aprobaban = (antes?.members || []).filter((m) => Acta.memberCan(antes, m.pub, 'approve')).map((m) => m.pub)
3548
3622
  const { removed = [], seq } = await identity.pruneExpiredDevices?.() || {}
3549
3623
  for (const d of removed) {
3550
3624
  const id = await deviceIdOf(d.pub).catch(() => null)
3551
3625
  audit('pruned', { device: id, label: d.label || '', reason: 'expired', seq })
3552
3626
  log(`[vault] unlock: removed ${id || '????-????'} ${d.label ? `(${d.label}) ` : ''}from the record: its certificate expired and cannot be renewed · record #${seq}`)
3553
3627
  }
3628
+ // ¿SE LLEVÓ AL ÚLTIMO QUE APROBABA? Entonces se dice ALTO. Quitarlo es correcto —con
3629
+ // el papel muerto no podía firmar ninguna aprobación, estuviera o no en el acta—,
3630
+ // pero desde ese momento todo pedido de un aparato sin `unattended` se rechaza, y
3631
+ // el 2026-09-22 eso pasó en silencio: el teléfono se fue y nadie lo supo en dos días.
3632
+ const despues = await refreshActa()
3633
+ const quedan = (despues?.members || []).filter((m) => Acta.memberCan(despues, m.pub, 'approve')).map((m) => m.pub)
3634
+ const idosPub = lastApproverGone(aprobaban, quedan, removed.map((d) => d.pub))
3635
+ const idos = removed.filter((d) => idosPub.includes(d.pub))
3636
+ if (idos.length) {
3637
+ const quienes = (await Promise.all(idos.map(async (d) => `${await deviceIdOf(d.pub).catch(() => '????-????')}${d.label ? ` (${d.label})` : ''}`))).join(', ')
3638
+ audit('no-approver', { removed: idos.length, seq })
3639
+ log(`[vault] WARNING: nobody in the record can approve any more — ${quienes} was the last. Requests from devices without \`unattended\` will be refused until you enrol a device and grant it \`dotrino-vault caps <ID> +aprueba\``)
3640
+ }
3554
3641
  } catch (e) { log(`[vault] unlock: could not remove expired devices: ${e.message}`) }
3555
3642
  }
3556
3643
  return { locked: r?.locked !== false }