@dotrino/vaultd 0.26.2 → 0.38.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.
@@ -34,6 +34,7 @@ const F = {
34
34
  secretsList: 'secrets-list.json',
35
35
  me: 'me.json',
36
36
  acta: 'acta.json',
37
+ approve: 'approve.json',
37
38
  // peticiones (las escribe el control; el daemon las consume y borra)
38
39
  pairReq: 'pair-request.json',
39
40
  approveReq: 'approve-request.json',
@@ -91,14 +92,41 @@ function requireAlive () {
91
92
  if (!s || !pidAlive(s.pid)) throw new DaemonDownError()
92
93
  }
93
94
 
94
- function writeReq (name, obj, profile) {
95
- const body = { ...obj, at: Date.now() }
96
- if (profile) body.profile = profile
95
+ /** Contador de peticiones de ESTE proceso: junto al pid, identifica cada una. */
96
+ let reqSeq = 0
97
+
98
+ /**
99
+ * Escribe una petición y devuelve su `id`.
100
+ *
101
+ * ESPERA A QUE EL DAEMON SE HAYA LLEVADO LA ANTERIOR. Cada tipo de petición es UN
102
+ * archivo con nombre fijo, así que escribir la siguiente antes de que se consuma la
103
+ * primera la BORRABA sin que nadie se enterara. Es exactamente lo que le pasaba al
104
+ * candado: la TUI manda `unlock` y, pegado, el `list` para repintar; si el daemon
105
+ * estaba ocupado, el `list` se comía el `unlock` —contraseña incluida— y la bóveda
106
+ * seguía cerrada sin un solo error por ninguna parte. Se vuelve a preguntar la
107
+ * contraseña, y otra vez lo mismo.
108
+ */
109
+ async function writeReq (name, obj, profile) {
97
110
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
98
- fs.writeFileSync(p(name), JSON.stringify(body), { mode: 0o600 })
111
+ // Hasta 3 s por la anterior; pasado eso, se pisa (el daemon no la va a atender ya, y
112
+ // dejar a quien pide esperando para siempre es peor).
113
+ for (let i = 0; i < 30 && fs.existsSync(p(name)); i++) await sleep(100)
114
+ const id = `${process.pid}-${++reqSeq}`
115
+ const body = { ...obj, id, at: Date.now() }
116
+ if (profile) body.profile = profile
117
+ // ATÓMICO (escribir aparte y renombrar), y no por manía: el daemon vigila esta carpeta
118
+ // con `fs.watch`, que avisa al CREAR el archivo — no al terminar de escribirlo. Escrito
119
+ // en el sitio, el daemon podía leerlo a medias, y como una petición sin `id` no es de
120
+ // nadie, se perdía: quien la había pedido esperaba seis segundos y leía «el daemon no
121
+ // respondió», con el daemon sano. `rename` es atómico dentro del mismo sistema de
122
+ // archivos, así que el vigilante o no ve nada, o ve la petición ENTERA.
123
+ const tmp = p(name) + '.tmp'
124
+ fs.writeFileSync(tmp, JSON.stringify(body), { mode: 0o600 })
125
+ fs.renameSync(tmp, p(name))
99
126
  // `mode` de writeFileSync solo aplica al CREAR: re-chmod por si el archivo ya
100
127
  // existía con permisos más laxos (defensa en profundidad; el dir ya es 0700).
101
128
  try { fs.chmodSync(p(name), 0o600) } catch (_) {}
129
+ return id
102
130
  }
103
131
 
104
132
  /**
@@ -126,12 +154,39 @@ function signalOrCleanup (sig, reqFiles) {
126
154
  try { signal(sig) } catch (e) { for (const f of reqFiles) rm(f); throw e }
127
155
  }
128
156
 
129
- /** Espera a que reaparezca un archivo de respuesta (con `.at`) tras borrarlo. */
130
- async function waitFor (name, { tries = 60, interval = 100 } = {}) {
157
+ /**
158
+ * EL CANDADO, del lado de quien pregunta. El daemon contesta los volcados de un perfil
159
+ * bloqueado con `locked: true` y sin contenido; aquí eso se convierte en un error con
160
+ * código para que la CLI y la TUI digan lo mismo: hay que desbloquearlo, y no es que la
161
+ * bóveda esté vacía o rota.
162
+ */
163
+ function assertOpen (d) {
164
+ if (d?.locked) throw coded('profile locked: unlock it with your password (dotrino-vault unlock)', 'PROFILE_LOCKED')
165
+ return d
166
+ }
167
+
168
+ /**
169
+ * Espera a que reaparezca un archivo de respuesta (con `.at`) tras borrarlo.
170
+ *
171
+ * Con `req`, solo vale LA RESPUESTA A ESA PETICIÓN (el daemon devuelve el `id` en
172
+ * `req`). El daemon vuelca la lista de perfiles también por su cuenta —cada repaso, y
173
+ * al atender cualquier otra cosa—, así que sin esto se podía tomar por respuesta un
174
+ * volcado de hace un instante, escrito ANTES de que la petición se atendiera: el
175
+ * `unlock` contestaba con la foto de cuando aún estaba cerrada.
176
+ *
177
+ * Un daemon viejo no devuelve `req`: entonces vale cualquier volcado posterior a la
178
+ * petición (que es lo que se hacía siempre, y sigue siendo mejor que nada).
179
+ */
180
+ async function waitFor (name, { tries = 60, interval = 100, req = null, since = 0 } = {}) {
131
181
  for (let i = 0; i < tries; i++) {
132
182
  await sleep(interval)
133
183
  const d = read(name, null)
134
- if (d && d.at) return d
184
+ if (!d || !d.at || d.at < since) continue
185
+ if (!req || d.req === req) return d
186
+ // Un daemon viejo NO trae el campo. Que venga con `null` significa lo contrario: el
187
+ // daemon sabe marcarlas y este volcado no contesta a nadie (es uno de los suyos), así
188
+ // que se sigue esperando el que sí lleva nuestro id.
189
+ if (!('req' in d)) return d
135
190
  }
136
191
  return null
137
192
  }
@@ -151,11 +206,14 @@ async function profileOp (op, { profile, name, password } = {}) {
151
206
  const extra = {}
152
207
  if (name != null) extra.name = name
153
208
  if (password != null) extra.password = password
154
- writeReq(F.profileReq, { op, ...extra }, profile)
209
+ const since = Date.now()
210
+ const id = await writeReq(F.profileReq, { op, ...extra }, profile)
155
211
  signalOrCleanup('SIGUSR2', [F.profileReq])
156
- const d = await waitFor(F.profilesList)
212
+ const d = await waitFor(F.profilesList, { req: id, since })
157
213
  if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
158
- if (d.error) throw coded(d.error, d.code) // p.ej. MASTER_WITH_MEMBERS (freno D12)
214
+ // p.ej. MASTER_WITH_MEMBERS (freno D12) o WRONG_PASSWORD. Los datos del error viajan
215
+ // pegados (cuánto hay que esperar, cuántos intentos van): quien lo pinta los necesita.
216
+ if (d.error) throw Object.assign(coded(d.error, d.code), { waitSec: d.waitSec, tries: d.tries })
159
217
  return d // { profiles:[{id,name,protected,locked,current,fingerprint,iss,createdAt}], current, done? }
160
218
  }
161
219
 
@@ -179,17 +237,30 @@ export const removeProfilePassword = (profile) => profileOp('password-rm', { pro
179
237
  */
180
238
  export async function snapshot (profile) {
181
239
  requireAlive()
182
- rm(F.devices); rm(F.secretsList); rm(F.profilesList); rm(F.acta)
183
- writeReq(F.dumpReq, {}, profile)
240
+ rm(F.devices); rm(F.secretsList); rm(F.acta)
241
+ const since = Date.now()
242
+ const id = await writeReq(F.dumpReq, {}, profile)
184
243
  signalOrCleanup('SIGUSR2', [F.dumpReq])
185
244
  // El ACTA entra en el volcado normal: es la lista de dispositivos de verdad, y las
186
245
  // delegaciones son su reflejo. Sin ella, un miembro sin certificados (revocado a medias,
187
246
  // o con el papel caducado) no salía en ninguna pantalla del PC — invisible y, por lo
188
247
  // tanto, imposible de quitar desde aquí.
189
- const [devices, secrets, profiles, acta] = await Promise.all([
190
- waitFor(F.devices), waitFor(F.secretsList), waitFor(F.profilesList), waitFor(F.acta)
248
+ const [devices, secrets, record] = await Promise.all([
249
+ waitFor(F.devices, { req: id, since }), waitFor(F.secretsList, { req: id, since }),
250
+ waitFor(F.acta, { req: id, since })
191
251
  ])
192
- return { devices, secrets, profiles, acta }
252
+ // El volcado de variables lleva el valor de las PÚBLICAS, así que se borra en cuanto se
253
+ // tiene: en la memoria de quien lo pidió, no esperando en el disco a que copien la
254
+ // carpeta (ahí es donde el cifrado en reposo dejaría de servir de nada).
255
+ rm(F.secretsList)
256
+ assertOpen(devices); assertOpen(secrets); assertOpen(record)
257
+ // LA LISTA DE BÓVEDAS NO SE ESPERA AQUÍ. `profiles-list.json` es la respuesta a una
258
+ // petición de PERFIL (`dumpProfiles`), y el daemon dejó de volcarlo por su cuenta —
259
+ // volcarlo sin que nadie preguntara se llevaba por delante las respuestas de verdad.
260
+ // Seguir esperándolo aquí era esperar seis segundos, los de rendirse, a un archivo que
261
+ // ya no iba a llegar: CADA refresco de la TUI costaba eso, con el daemon contestando lo
262
+ // suyo en 100 ms. Quien necesita la lista llama a `listProfiles()`, que sí la pide.
263
+ return { devices, secrets, record, profiles: read(F.profilesList, null) }
193
264
  }
194
265
 
195
266
  /**
@@ -197,7 +268,7 @@ export async function snapshot (profile) {
197
268
  * `issued` viene de identity.listDelegations(); el deviceId se deriva del `sub`.
198
269
  */
199
270
  export async function listDevices (profile) {
200
- const { devices, acta } = await snapshot(profile)
271
+ const { devices, record } = await snapshot(profile)
201
272
  if (!devices) throw coded('the daemon did not reply', 'NO_REPLY')
202
273
  const issued = devices.issued || devices.active || devices.delegations || []
203
274
  const revoked = devices.revoked || []
@@ -208,9 +279,9 @@ export async function listDevices (profile) {
208
279
  // certificados son su reflejo. Quien pinte la lista los necesita a la vez, o acaba
209
280
  // enseñando solo a los que tienen papel — y el que hay que quitar es justo el que no.
210
281
  return {
211
- issued: agruparPorAparato(withIds, revoked),
282
+ issued: groupCertsByDevice(withIds, revoked),
212
283
  revoked,
213
- members: acta?.members || [],
284
+ members: record?.members || [],
214
285
  profile: devices.profile || null
215
286
  }
216
287
  }
@@ -224,20 +295,20 @@ export async function listDevices (profile) {
224
295
  * como «enrolados». Se agrupa por llave, se queda el más nuevo y se guardan TODOS sus
225
296
  * nonces, que es lo que hace falta para retirarlo entero.
226
297
  */
227
- export function agruparPorAparato (lista, revoked = []) {
228
- const fuera = new Set(revoked.map((r) => r?.nonce || r))
229
- const porLlave = new Map()
230
- for (const d of lista) {
231
- if (d.revokedAt || fuera.has(d.nonce)) continue
232
- const clave = d.sub || d.nonce
233
- const y = porLlave.get(clave)
234
- if (!y) porLlave.set(clave, { ...d, nonces: [d.nonce] })
298
+ export function groupCertsByDevice (list, revoked = []) {
299
+ const out = new Set(revoked.map((r) => r?.nonce || r))
300
+ const byKey = new Map()
301
+ for (const d of list) {
302
+ if (d.revokedAt || out.has(d.nonce)) continue
303
+ const key = d.sub || d.nonce
304
+ const y = byKey.get(key)
305
+ if (!y) byKey.set(key, { ...d, nonces: [d.nonce] })
235
306
  else {
236
307
  y.nonces.push(d.nonce)
237
308
  if ((d.exp || 0) > (y.exp || 0)) Object.assign(y, { ...d, nonces: y.nonces })
238
309
  }
239
310
  }
240
- return [...porLlave.values()]
311
+ return [...byKey.values()]
241
312
  }
242
313
 
243
314
  /**
@@ -246,7 +317,7 @@ export function agruparPorAparato (lista, revoked = []) {
246
317
  */
247
318
  export async function setDeviceLabel (pub, label, profile) {
248
319
  requireAlive()
249
- writeReq(F.labelReq, { pub, label }, profile)
320
+ await writeReq(F.labelReq, { pub, label }, profile)
250
321
  signalOrCleanup('SIGUSR2', [F.labelReq])
251
322
  await sleep(400)
252
323
  return listDevices(profile)
@@ -260,10 +331,12 @@ export async function setDeviceLabel (pub, label, profile) {
260
331
  export async function listMembers (profile) {
261
332
  requireAlive()
262
333
  rm(F.acta)
263
- writeReq(F.dumpReq, {}, profile)
334
+ const since = Date.now()
335
+ const id = await writeReq(F.dumpReq, {}, profile)
264
336
  signalOrCleanup('SIGUSR2', [F.dumpReq])
265
- const d = await waitFor(F.acta)
337
+ const d = await waitFor(F.acta, { req: id, since })
266
338
  if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
339
+ assertOpen(d)
267
340
  return d.members || []
268
341
  }
269
342
 
@@ -277,7 +350,7 @@ export async function listMembers (profile) {
277
350
  */
278
351
  export async function setDeviceCaps (pub, caps, profile) {
279
352
  requireAlive()
280
- writeReq(F.capsReq, { pub, caps }, profile)
353
+ await writeReq(F.capsReq, { pub, caps }, profile)
281
354
  signalOrCleanup('SIGUSR2', [F.capsReq])
282
355
  await sleep(600)
283
356
  return listDevices(profile)
@@ -291,7 +364,7 @@ export async function setDeviceCaps (pub, caps, profile) {
291
364
  export async function revokeDevice (target, profile) {
292
365
  requireAlive()
293
366
  const req = typeof target === 'string' ? { nonce: target } : { sub: target?.sub, nonce: target?.nonce }
294
- writeReq(F.revokeReq, req, profile)
367
+ await writeReq(F.revokeReq, req, profile)
295
368
  signalOrCleanup('SIGUSR2', [F.revokeReq])
296
369
  await sleep(300)
297
370
  return listDevices(profile)
@@ -315,59 +388,160 @@ export async function revokeDevice (target, profile) {
315
388
  export async function getMe (profile) {
316
389
  requireAlive()
317
390
  rm(F.me)
318
- writeReq(F.meReq, {}, profile)
391
+ const since = Date.now()
392
+ const id = await writeReq(F.meReq, {}, profile)
319
393
  signalOrCleanup('SIGUSR2', [F.meReq])
320
- const d = await waitFor(F.me)
394
+ const d = await waitFor(F.me, { req: id, since })
321
395
  rm(F.me)
322
396
  if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
397
+ assertOpen(d)
323
398
  return d.me || null
324
399
  }
325
400
 
326
401
 
327
402
  // ---------------------------------------------------------------------------
328
- // Secretos: scopes (namespaces) y variables (claves)
403
+ // Variables de entorno: DOS CAJONES
404
+ //
405
+ // · por SCOPE (`ns`) — las comparten todos los aparatos del perfil que sirven
406
+ // ese namespace.
407
+ // · por APARATO (`dev`) — solo las lee ese aparato, y PISAN a las del scope.
408
+ //
409
+ // Las dos listas viajan juntas en el mismo volcado (`secrets-list.json`), así que
410
+ // todas estas funciones devuelven lo mismo: `{ ns, dev }`. Nunca hay valores en
411
+ // ninguna de las dos: el daemon no los expone.
329
412
  // ---------------------------------------------------------------------------
330
413
 
331
- /** Scopes→[claves] del perfil (NUNCA los valores; el daemon no los expone). */
414
+ /**
415
+ * Da forma al volcado: `{ ns: {<scope>: [{key, public}]}, dev: [{pub,id,label,cn,keys,orphan}] }`.
416
+ * `public` dice si el VALOR puede salir de la máquina de la bóveda hacia la consola remota;
417
+ * el valor en sí no está aquí ni en ningún volcado.
418
+ */
419
+ const shapeSecrets = (d) => ({ ns: d?.ns || {}, dev: Array.isArray(d?.dev) ? d.dev : [] })
420
+
421
+ /** Los dos cajones del perfil (NUNCA los valores). */
332
422
  export async function listSecrets (profile) {
333
423
  const { secrets } = await snapshot(profile)
334
424
  if (!secrets) throw coded('the daemon did not reply', 'NO_REPLY')
335
- return secrets.ns || {}
425
+ return shapeSecrets(secrets)
336
426
  }
337
427
 
338
- /** Guarda/actualiza una variable. ns: [a-z0-9-]{1,32}. clave: [A-Z0-9_]{1,64}. */
339
- export async function setSecret (ns, key, value, profile) {
428
+ /**
429
+ * Manda una orden de secreto y espera el volcado, comprobando que se aplicó de verdad:
430
+ * el daemon puede rechazarla (clave inválida, aparato que no es un servicio) y quedarse
431
+ * callado, y un «guardado» que no guardó nada es la peor forma de fallar en esto.
432
+ */
433
+ async function secretOp (req, profile, check) {
340
434
  requireAlive() // el VALOR es secreto: no escribirlo si el daemon está caído
341
435
  rm(F.secretsList)
342
- writeReq(F.secretReq, { op: 'set', ns, key, value }, profile)
343
- writeReq(F.dumpReq, {}, profile)
436
+ await writeReq(F.secretReq, req, profile)
437
+ await writeReq(F.dumpReq, {}, profile)
344
438
  signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
345
439
  const d = await waitFor(F.secretsList)
440
+ rm(F.secretsList) // lleva el valor de las públicas: no se queda en el disco (ver `snapshot`)
346
441
  if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
347
- if (!(d.ns?.[ns] || []).includes(key)) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
348
- return d.ns
442
+ assertOpen(d)
443
+ const out = shapeSecrets(d)
444
+ check(out)
445
+ return out
349
446
  }
350
447
 
351
- /** Borra una variable. Si era la última del scope, el scope desaparece. */
352
- export async function deleteSecret (ns, key, profile) {
353
- requireAlive()
354
- rm(F.secretsList)
355
- writeReq(F.secretReq, { op: 'rm', ns, key }, profile)
356
- writeReq(F.dumpReq, {}, profile)
357
- signalOrCleanup('SIGUSR2', [F.secretReq, F.dumpReq])
358
- const d = await waitFor(F.secretsList)
359
- if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
360
- if ((d.ns?.[ns] || []).includes(key)) throw coded('the daemon did not delete the variable (check the service logs)', 'NOT_DELETED')
361
- return d.ns
448
+ const keysOf = (out, pub) => (out.dev.find((x) => x.pub === pub)?.keys) || []
449
+ /** ¿Está esa clave en la lista? Las listas traen `{key, public}`, nunca el valor. */
450
+ const has = (list, key) => (list || []).some((x) => x.key === key)
451
+ const visibilityOf = (list, key) => !!(list || []).find((x) => x.key === key)?.public
452
+
453
+ /**
454
+ * Guarda/actualiza una variable de SCOPE. ns: [a-z0-9-]{1,32}. clave: [A-Z0-9_]{1,64}.
455
+ * `isPublic` opcional: sin decir nada conserva la visibilidad que ya tenía (y una nueva
456
+ * nace privada, o sea que su valor no sale de la máquina de la bóveda).
457
+ */
458
+ export function setSecret (ns, key, value, profile, isPublic) {
459
+ return secretOp({ op: 'set', ns, key, value, ...(isPublic === undefined ? {} : { public: !!isPublic }) }, profile, (out) => {
460
+ if (!has(out.ns[ns], key)) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
461
+ })
462
+ }
463
+
464
+ /** Borra una variable de SCOPE. Si era la última, el scope desaparece. */
465
+ export function deleteSecret (ns, key, profile) {
466
+ return secretOp({ op: 'rm', ns, key }, profile, (out) => {
467
+ if (has(out.ns[ns], key)) throw coded('the daemon did not delete the variable (check the service logs)', 'NOT_DELETED')
468
+ })
469
+ }
470
+
471
+ /**
472
+ * Cambia SOLO quién puede ver el valor: `public` deja que la consola remota lo vea,
473
+ * `private` lo encierra en esta máquina. No toca el valor (ni hace falta conocerlo).
474
+ */
475
+ export function setSecretVisibility (ns, key, isPublic, profile) {
476
+ return secretOp({ op: 'vis', ns, key, public: !!isPublic }, profile, (out) => {
477
+ if (visibilityOf(out.ns[ns], key) !== !!isPublic) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
478
+ })
479
+ }
480
+
481
+ export function setDeviceSecretVisibility (pub, key, isPublic, profile) {
482
+ return secretOp({ op: 'dev-vis', pub, key, public: !!isPublic }, profile, (out) => {
483
+ if (visibilityOf(keysOf(out, pub), key) !== !!isPublic) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
484
+ })
485
+ }
486
+
487
+ /**
488
+ * CARGA VARIAS DE UNA VEZ: un solo viaje al daemon, un solo guardado y —lo que de
489
+ * verdad importa— UN solo aviso a los servicios de ese scope.
490
+ *
491
+ * De una en una, cada variable es un cambio de configuración: el servicio obedece el
492
+ * primero, sale, lo levanta su supervisor y arranca con lo que hubiera puesto en ese
493
+ * momento, mientras quien administra sigue escribiendo el resto. Por eso cargar
494
+ * configuración tiene que llegar entera hasta abajo, y no romperse en N órdenes.
495
+ *
496
+ * @param {Array<{op:'set'|'rm', key:string, value?:string, public?:boolean}>} items
497
+ */
498
+ export function applySecrets (ns, items, profile) {
499
+ return secretOp({ op: 'batch', ns, items }, profile, (out) => {
500
+ const missing = items.find((it) => (it.op === 'rm' ? has(out.ns[ns], it.key) : !has(out.ns[ns], it.key)))
501
+ if (missing) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
502
+ })
503
+ }
504
+
505
+ /** Lo mismo para el cajón de UN aparato. */
506
+ export function applyDeviceSecrets (pub, items, profile) {
507
+ return secretOp({ op: 'batch', pub, items }, profile, (out) => {
508
+ const missing = items.find((it) => (it.op === 'rm' ? has(keysOf(out, pub), it.key) : !has(keysOf(out, pub), it.key)))
509
+ if (missing) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
510
+ })
362
511
  }
363
512
 
364
- /** Borra un scope entero (todas sus variables, una por una). */
513
+ /** Borra un scope entero: todas sus variables juntas, para no avisar una vez por variable. */
365
514
  export async function deleteScope (ns, profile) {
366
- const all = await listSecrets(profile)
367
- const keys = all[ns] || []
368
- let ns2 = all
369
- for (const k of keys) ns2 = await deleteSecret(ns, k, profile)
370
- return ns2
515
+ const out = await listSecrets(profile)
516
+ const keys = (out.ns[ns] || []).map(({ key }) => ({ op: 'rm', key }))
517
+ if (!keys.length) return out
518
+ return applySecrets(ns, keys, profile)
519
+ }
520
+
521
+ /**
522
+ * Guarda/actualiza una variable de UN APARATO, identificado por su llave (`pub`, la
523
+ * misma que trae el acta). Solo la lee ese aparato, y le gana a la del scope con el
524
+ * mismo nombre.
525
+ */
526
+ export function setDeviceSecret (pub, key, value, profile, isPublic) {
527
+ return secretOp({ op: 'dev-set', pub, key, value, ...(isPublic === undefined ? {} : { public: !!isPublic }) }, profile, (out) => {
528
+ if (!has(keysOf(out, pub), key)) throw coded('the daemon did not apply the change (check the service logs)', 'NOT_APPLIED')
529
+ })
530
+ }
531
+
532
+ /** Borra una variable de un aparato. */
533
+ export function deleteDeviceSecret (pub, key, profile) {
534
+ return secretOp({ op: 'dev-rm', pub, key }, profile, (out) => {
535
+ if (has(keysOf(out, pub), key)) throw coded('the daemon did not delete the variable (check the service logs)', 'NOT_DELETED')
536
+ })
537
+ }
538
+
539
+ /** Borra TODAS las variables de un aparato, juntas (mismo motivo que `deleteScope`). */
540
+ export async function deleteDeviceVars (pub, profile) {
541
+ const out = await listSecrets(profile)
542
+ const keys = keysOf(out, pub).map(({ key }) => ({ op: 'rm', key }))
543
+ if (!keys.length) return out
544
+ return applyDeviceSecrets(pub, keys, profile)
371
545
  }
372
546
 
373
547
  // ---------------------------------------------------------------------------
@@ -397,11 +571,12 @@ export function pairUrl (qr) {
397
571
  export async function startPairing ({ profile, service } = {}) {
398
572
  requireAlive()
399
573
  rm(F.pair); rm(F.pending)
400
- writeReq(F.pairReq, service ? { service } : {}, profile)
574
+ await writeReq(F.pairReq, service ? { service } : {}, profile)
401
575
  signalOrCleanup('SIGUSR1', [F.pairReq])
402
576
  for (let i = 0; i < 50; i++) {
403
577
  await sleep(100)
404
578
  const pr = read(F.pair, null)
579
+ assertOpen(pr)
405
580
  if (pr?.expiresAt > Date.now()) {
406
581
  const { url, payload, code } = pairUrl(pr.qr)
407
582
  // `profile`/`profileName`: DE QUÉ CUENTA del vault sale este QR. El vault
@@ -426,16 +601,30 @@ export function pendingEnroll () {
426
601
  */
427
602
  export async function approvePending (code, profile) {
428
603
  requireAlive()
429
- writeReq(F.approveReq, { code: String(code) }, profile)
604
+ rm(F.approve)
605
+ const since = Date.now()
606
+ const id = await writeReq(F.approveReq, { code: String(code) }, profile)
430
607
  signalOrCleanup('SIGUSR2', [F.approveReq])
608
+ // SE ESPERA LA RESPUESTA. Antes se daba por hecho: con el código equivocado —que la
609
+ // bóveda rechaza sin firmar nada— la pantalla decía «Dispositivo aprobado» igual, se
610
+ // olvidaba del pendiente y no había forma de reintentar desde aquí.
611
+ const r = await waitFor(F.approve, { req: id, since, tries: 40 })
612
+ // Sin respuesta: un daemon anterior a esto. Se sigue como antes en vez de inventar un
613
+ // error (el emparejamiento pudo salir bien).
614
+ if (r && r.ok === false) throw coded(r.error || 'the code does not match', r.code || 'APPROVE_FAILED')
431
615
  await sleep(400)
432
- return listDevices(profile)
616
+ // LA LISTA ES UN EXTRA, NO PARTE DE APROBAR. Si el volcado no llega, el aparato ya
617
+ // entró igual: el certificado se firmó y el acta se selló antes de esto. Dejar que ese
618
+ // fallo suba convertía un emparejamiento correcto en «el daemon no respondió», que es
619
+ // lo contrario de lo que pasó — y el dueño se queda sin saber si repetirlo o no.
620
+ // Se devuelve null y quien llama refresca por su cuenta.
621
+ try { return await listDevices(profile) } catch (_) { return null }
433
622
  }
434
623
 
435
624
  /** Rechaza el dispositivo pendiente. */
436
625
  export async function rejectPending (deviceId, profile) {
437
626
  requireAlive()
438
- writeReq(F.rejectReq, { deviceId }, profile)
627
+ await writeReq(F.rejectReq, { deviceId }, profile)
439
628
  signalOrCleanup('SIGUSR2', [F.rejectReq])
440
629
  await sleep(200)
441
630
  }