@dotrino/vaultd 0.26.2 → 0.46.2

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/src/tui/app.js CHANGED
@@ -8,6 +8,13 @@
8
8
  * · Dispositivos (pares): ver · emparejar · aprobar/rechazar · revocar
9
9
  * · Scopes y variables (secretos): ver · agregar · quitar
10
10
  *
11
+ * LAS VARIABLES DE ENTORNO SE PONEN EN DOS SITIOS, y cada uno está donde se elige lo
12
+ * que las distingue: las del SCOPE, que comparten todos los aparatos del perfil que
13
+ * sirven ese namespace, en su pestaña; las de UN APARATO, que solo lee él y pisan a
14
+ * las del scope, dentro de Dispositivos (tecla `e`), que es donde ya elegiste el
15
+ * aparato. No se repiten en las dos pantallas a propósito: la de scopes enlaza a la
16
+ * otra en vez de duplicarla.
17
+ *
11
18
  * Cada "bóveda" es un PERFIL (maestra propia, dir propio, dispositivos y secretos
12
19
  * propios). Las acciones operan sobre la bóveda ACTIVA; para operar otra, cámbiala
13
20
  * en la pantalla de bóvedas.
@@ -22,7 +29,9 @@
22
29
  * en todas las pantallas: `p` es SIEMPRE emparejar (también en Bóvedas, sin tener
23
30
  * que entrar antes), el candado es `k` (la `l` es el idioma) y la contraseña `c`.
24
31
  */
32
+ import fs from 'node:fs'
25
33
  import { execFile } from 'node:child_process'
34
+ import { parseEnvInput } from '../../lib/src/envtext.js'
26
35
  import { createTerm, widthOf } from './term.js'
27
36
  import { qrToString } from '../qr.js'
28
37
  import { dict, otherLang, loadLang, saveLang } from './i18n.js'
@@ -46,13 +55,19 @@ const L = (st) => dict(st?.lang)
46
55
  */
47
56
  function humanErr (e, st) {
48
57
  const t = L(st)
58
+ // Con dato: «contraseña incorrecta (van 9 intentos)» y «espera 32 s» son lo que hace
59
+ // falta para saber qué está pasando; «error» a secas parece que la pantalla se colgó.
60
+ if (e?.code === 'WRONG_PASSWORD') return t.errWrongPassword(e.tries)
61
+ if (e?.code === 'TOO_MANY_TRIES') return t.errTooManyTries(e.waitSec)
49
62
  const byCode = {
50
63
  DAEMON_DOWN: t.errDaemonDown,
51
64
  NO_REPLY: t.errNoReply,
52
65
  NOT_APPLIED: t.errNotApplied,
53
66
  NOT_DELETED: t.errNotDeleted,
54
67
  PAIR_FAILED: t.errPairFailed,
55
- MASTER_WITH_MEMBERS: t.errMasterWithMembers
68
+ APPROVE_FAILED: t.errWrongCode,
69
+ MASTER_WITH_MEMBERS: t.errMasterWithMembers,
70
+ PROFILE_LOCKED: t.errProfileLocked
56
71
  }
57
72
  return byCode[e?.code] || e?.message || String(e)
58
73
  }
@@ -123,6 +138,13 @@ function activeProfile (st) {
123
138
  return list.find((p) => p.current) || list[0] || null
124
139
  }
125
140
  const activeId = (st) => activeProfile(st)?.id || undefined
141
+ /**
142
+ * La contraseña del perfil ACTIVO, que la TUI ya guarda para toda la sesión al
143
+ * desbloquear (ver `reunlockSilently`). Las operaciones que SELLAN una variable la
144
+ * necesitan: sin ella el daemon cae a la llave de la máquina, que no abre la copia
145
+ * maestra de un perfil con contraseña, y la escritura falla con «wrong password».
146
+ */
147
+ const activePwd = (st) => st.sessionPwd?.get(activeId(st)) || undefined
126
148
 
127
149
  function lockGlyph (p) {
128
150
  if (!p?.protected) return ''
@@ -235,15 +257,21 @@ function deviceRows (st, t) {
235
257
  }
236
258
  for (const d of devices) {
237
259
  const label = d.label || t.muted(i.noLabel)
238
- const extra = d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : ''
260
+ const vars = devVarsOf(st, d.sub).length
261
+ const debt = debtOf(st, d.sub)
262
+ const extra = (d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : '') +
263
+ (vars ? t.muted(` vars:${vars}`) : '') +
264
+ // EN DEUDA: en el acta y sin poder abrir lo suyo. Va en color de aviso al lado de
265
+ // sus variables, que es donde se mira cuando algo no arranca.
266
+ (debt ? t.warn(` ${i.deviceDebt(debt)}`) : '')
239
267
  // SIN ACCESO: está en el acta y no puede entrar. Es un aviso, no un adorno, así que va
240
268
  // en el color de aviso y en el sitio donde estaría su vencimiento.
241
- const estado = d.noAccess
269
+ const status = d.noAccess
242
270
  ? t.warn(i.deviceNoAccess)
243
271
  : d.isMaster
244
272
  ? t.muted(i.thisVault)
245
273
  : t.muted('scope:' + shortScope(d.scope)) + ' ' + t.muted('exp:' + fmtExp(d.exp))
246
- rows.push({ text: ` ${t.bold(d.deviceId)} ${label} ${estado}${extra}`, sel: true, meta: d })
274
+ rows.push({ text: ` ${t.bold(d.deviceId)} ${label} ${status}${extra}`, sel: true, meta: d })
247
275
  }
248
276
  const revoked = st.devices?.revoked || []
249
277
  if (revoked.length) {
@@ -256,11 +284,15 @@ function deviceRows (st, t) {
256
284
  /**
257
285
  * LA PREGUNTA DEL EMPAREJAMIENTO. La decisión es del vault (es quien lo inicia) y
258
286
  * este daemon puede tener varias cuentas: antes de mostrar el QR hay que decir a
259
- * cuál entra el dispositivo. Hoy se responde con las dos formas que existen —una
260
- * cuenta que ya vive aquí, o una nueva que se estrena para él—; la tercera
261
- * («adoptar la que trae el aparato») necesita el protocolo de adopción y se
262
- * muestra desactivada para no prometer lo que todavía no hace
263
- * (docs/vinculacion-de-cuentas.md §5).
287
+ * cuál entra el dispositivo. Se responde con las tres formas que existen —una
288
+ * cuenta que ya vive aquí, una nueva que se estrena para él, o un SERVICIO de la
289
+ * cuenta activa—; la cuarta («adoptar la que trae el aparato») necesita el
290
+ * protocolo de adopción y se muestra desactivada para no prometer lo que todavía
291
+ * no hace (docs/vinculacion-de-cuentas.md §5).
292
+ *
293
+ * Lo del servicio estaba SOLO en la línea de comandos (`pair --service <ns>`), y una
294
+ * máquina que sirve el proxy no se empareja de otra manera: sin esta opción, la TUI
295
+ * te dejaba a medio camino y había que salirse a la terminal a terminar el trabajo.
264
296
  */
265
297
  function pairModeRows (st, t) {
266
298
  const i = L(st)
@@ -272,6 +304,9 @@ function pairModeRows (st, t) {
272
304
  rows.push({ text: ` ${t.bold(i.pairModeNew)}`, sel: true, meta: { mode: 'new' } })
273
305
  rows.push({ text: t.muted(' ' + i.pairModeNewHint), sel: false })
274
306
  rows.push({ text: '', sel: false })
307
+ rows.push({ text: ` ${t.bold(i.pairModeService)}`, sel: true, meta: { mode: 'service' } })
308
+ rows.push({ text: t.muted(' ' + i.pairModeServiceHint), sel: false })
309
+ rows.push({ text: '', sel: false })
275
310
  rows.push({ text: ' ' + t.muted(i.pairModeAdopt), sel: false })
276
311
  rows.push({ text: t.muted(' (' + i.pairModeAdoptSoon + ')'), sel: false })
277
312
  return rows
@@ -282,25 +317,25 @@ function pairModeRows (st, t) {
282
317
  * una marca de si los tiene. El de administrar va aparte y avisado: es el único que deja
283
318
  * a ese aparato meter y sacar dispositivos sin venir aquí.
284
319
  */
285
- const CAPS_ORDEN = ['sign', 'store', 'read', 'admin']
320
+ const CAPS_ORDER = ['sign', 'store', 'read', 'admin']
286
321
 
287
322
  function capsRows (st, t) {
288
323
  const i = L(st)
289
- const objetivo = st.capsFor
290
- if (!objetivo) return [{ text: t.muted(i.loading), sel: false }]
291
- const miembro = (st.members || []).find((m) => m.pub === objetivo.pub)
292
- if (!miembro) return [{ text: t.muted(i.capsNoMember), sel: false }]
324
+ const target = st.capsFor
325
+ if (!target) return [{ text: t.muted(i.loading), sel: false }]
326
+ const member = (st.members || []).find((m) => m.pub === target.pub)
327
+ if (!member) return [{ text: t.muted(i.capsNoMember), sel: false }]
293
328
 
294
- const tiene = new Set(miembro.caps || [])
329
+ const has = new Set(member.caps || [])
295
330
  const rows = [
296
- { text: ' ' + t.bold(i.capsFor(objetivo.deviceId, miembro.label || '')), sel: false },
331
+ { text: ' ' + t.bold(i.capsFor(target.deviceId, member.label || '')), sel: false },
297
332
  { text: '', sel: false }
298
333
  ]
299
- for (const cap of CAPS_ORDEN) {
300
- const marca = tiene.has(cap) ? '[x]' : '[ ]'
301
- const nombre = i.capName[cap]
302
- const linea = ` ${marca} ${cap === 'admin' ? t.bold(nombre) : nombre}`
303
- rows.push({ text: linea, sel: true, meta: { cap } })
334
+ for (const cap of CAPS_ORDER) {
335
+ const mark = has.has(cap) ? '[x]' : '[ ]'
336
+ const name = i.capName[cap]
337
+ const line = ` ${mark} ${cap === 'admin' ? t.bold(name) : name}`
338
+ rows.push({ text: line, sel: true, meta: { cap } })
304
339
  rows.push({ text: t.muted(' ' + i.capHint[cap]), sel: false })
305
340
  }
306
341
  rows.push({ text: '', sel: false })
@@ -308,21 +343,90 @@ function capsRows (st, t) {
308
343
  return rows
309
344
  }
310
345
 
346
+ /** Las variables por SCOPE: las que comparten todos los aparatos que sirven ese ns. */
311
347
  function secretRows (st, t) {
312
348
  const i = L(st)
313
- const ns = st.secrets || {}
349
+ const ns = st.secrets?.ns || {}
314
350
  const names = Object.keys(ns).sort()
315
351
  const rows = []
316
- if (!names.length) {
317
- rows.push({ text: t.muted(i.noScopes), sel: false })
318
- return rows
352
+ // El puntero a la otra pantalla va SIEMPRE, con scopes y sin ellos: es la mitad de la
353
+ // función y quien la busca no tiene por qué adivinar que vive en Dispositivos.
354
+ const footer = [{ text: '', sel: false }, { text: t.muted(' ' + i.devVarsElsewhere), sel: false }]
355
+ // ARRIBA DEL TODO, antes que las variables: lo que está sin sellar significa que esos
356
+ // aparatos NO están leyendo su configuración ahora mismo, y que solo la contraseña lo
357
+ // arregla. Un aviso que hay que buscar no es un aviso.
358
+ const head = []
359
+ for (const [owner, info] of Object.entries(st.secrets?.pending || {})) {
360
+ const who = (info?.members || []).map((m) => deviceIdOf(st, m.pub) + ' (' + m.keys.join(', ') + ')').join(', ')
361
+ head.push({ text: t.warn(' ' + i.pendingSeal(owner, info?.kind, who)), sel: false })
319
362
  }
363
+ const p = activeProfile(st)
364
+ if (p && !p.protected) head.push({ text: t.warn(' ' + i.noPasswordWarn), sel: false })
365
+ if (head.length) head.push({ text: '', sel: false })
366
+ if (!names.length) return [...head, { text: t.muted(i.noScopes), sel: false }, ...footer]
367
+ rows.push(...head)
320
368
  for (const n of names) {
321
369
  rows.push({ text: t.accent(` ▸ ${n}`) + t.muted(i.scopeOf(n)), sel: true, meta: { ns: n, key: null } })
322
- for (const k of ns[n].slice().sort()) {
323
- rows.push({ text: ` ${k} ${t.muted('••••••')}`, sel: true, meta: { ns: n, key: k } })
370
+ for (const k of sortByKey(ns[n])) {
371
+ rows.push({ text: varLine(k, t, i), sel: true, meta: { ns: n, key: k.key, public: k.public } })
324
372
  }
325
373
  }
374
+ return [...rows, ...footer]
375
+ }
376
+
377
+ /** Las claves guardadas para UN aparato (`pub`), o `[]`. Cada una es `{key, public}`. */
378
+ const devVarsOf = (st, pub) => (st.secrets?.dev || []).find((x) => x.pub === pub)?.keys || []
379
+
380
+ /**
381
+ * Lo que ese aparato NO puede abrir (§8.11). Un servicio que entra después de escrita
382
+ * una variable no tiene envoltura de ella, y hasta que alguien se la reparta está en el
383
+ * acta sin poder arrancar del todo. Aquí se cuenta cuántas, que es lo que cabe en una
384
+ * fila; el detalle está en la consola.
385
+ */
386
+ /** El ID corto de un aparato a partir de su llave, tal como lo enseña la lista de Aparatos. */
387
+ const deviceIdOf = (st, pub) =>
388
+ (st.devices?.issued || []).find((d) => d.sub === pub)?.deviceId || (st.members || []).find((m) => m.pub === pub)?.id || pub.slice(0, 8)
389
+
390
+ const debtOf = (st, pub) => {
391
+ const d = (st.secrets?.incomplete || []).find((x) => x.pub === pub)
392
+ return d ? [...new Set(Object.values(d.owners || {}).flat())].length : 0
393
+ }
394
+
395
+ const sortByKey = (list) => (list || []).slice().sort((a, b) => a.key.localeCompare(b.key))
396
+
397
+ /**
398
+ * Una variable: su nombre y su valor. La PÚBLICA enseña el suyo —pública significa que ese
399
+ * valor puede salir de esta máquina, así que taparlo delante de su dueño, en la máquina
400
+ * donde vive, era lo único que la marca no quería decir— con el aviso de que viaja cuando
401
+ * la consola remota lo pide. La privada sigue tapada: no sale ni a esta pantalla.
402
+ */
403
+ const varLine = (v, t, i) => ` ${v.key} ` +
404
+ (v.public ? `${short(v.value)} ${t.warn(i.varPublic)}` : t.muted('••••••'))
405
+
406
+ /** Un valor largo no puede empujar la marca «pública» fuera de la pantalla. */
407
+ const short = (s) => {
408
+ const v = String(s ?? '')
409
+ return v.length > 40 ? v.slice(0, 39) + '…' : v
410
+ }
411
+
412
+ /**
413
+ * Las variables de UN aparato. Se entra desde Dispositivos con `e`, ya con el aparato
414
+ * elegido: por eso aquí no se vuelve a elegir, solo se agrega y se quita.
415
+ */
416
+ function devVarRows (st, t) {
417
+ const i = L(st)
418
+ const target = st.varsFor
419
+ if (!target) return [{ text: t.muted(i.loading), sel: false }]
420
+ const keys = sortByKey(devVarsOf(st, target.pub))
421
+ const rows = [
422
+ { text: ' ' + t.bold(i.devVarsFor(target.deviceId, target.label || '')), sel: false },
423
+ // Dato, no explicación: qué servicio es este aparato es lo que decide qué namespace
424
+ // lee, y por lo tanto a qué variables del scope le ganan estas.
425
+ { text: t.muted(' ' + i.devVarsService(target.cn)), sel: false },
426
+ { text: '', sel: false }
427
+ ]
428
+ if (!keys.length) rows.push({ text: t.muted(' ' + i.noDevVars), sel: false })
429
+ for (const k of keys) rows.push({ text: varLine(k, t, i), sel: true, meta: { key: k.key, public: k.public } })
326
430
  return rows
327
431
  }
328
432
 
@@ -341,27 +445,27 @@ function meRows (st, t) {
341
445
  if (!me) return [{ text: t.muted(i.noProfile), sel: false }, { text: '', sel: false }, { text: t.muted(i.noProfileHint), sel: false }]
342
446
 
343
447
  const rows = []
344
- const campo = (etiqueta, valor, oculto) => rows.push({
345
- text: ` ${t.muted(String(etiqueta).padEnd(12))} ${valor}${oculto ? t.muted(i.hidden) : ''}`, sel: false
448
+ const field = (label, value, hidden) => rows.push({
449
+ text: ` ${t.muted(String(label).padEnd(12))} ${value}${hidden ? t.muted(i.hidden) : ''}`, sel: false
346
450
  })
347
451
  rows.push({ text: t.muted(i.profileUpdated(me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—')), sel: false })
348
452
  rows.push({ text: '', sel: false })
349
- campo(i.fieldName, me.nickname ? t.bold(me.nickname) : t.muted(i.noName))
350
- campo(i.fieldPhoto, me.avatar
453
+ field(i.fieldName, me.nickname ? t.bold(me.nickname) : t.muted(i.noName))
454
+ field(i.fieldPhoto, me.avatar
351
455
  ? `${me.avatar.type || '?'} · ${(me.avatar.bytes / 1024).toFixed(1)} KB`
352
456
  : t.muted(i.no))
353
457
 
354
458
  const STD = [['nombres', i.fieldFirstName], ['apellidos', i.fieldLastName], ['email', i.fieldEmail],
355
459
  ['telefono', i.fieldPhone], ['direccion', i.fieldAddress]]
356
- const puestos = STD.filter(([k]) => me[k])
357
- if (puestos.length) rows.push({ text: '', sel: false })
358
- for (const [k, etiqueta] of puestos) campo(etiqueta, me[k], me[k + 'Visible'] === false)
460
+ const filled = STD.filter(([k]) => me[k])
461
+ if (filled.length) rows.push({ text: '', sel: false })
462
+ for (const [k, label] of filled) field(label, me[k], me[k + 'Visible'] === false)
359
463
 
360
- for (const [titulo, lista] of [[i.links, me.links], [i.otherData, me.fields]]) {
361
- if (!Array.isArray(lista) || !lista.length) continue
464
+ for (const [title, list] of [[i.links, me.links], [i.otherData, me.fields]]) {
465
+ if (!Array.isArray(list) || !list.length) continue
362
466
  rows.push({ text: '', sel: false })
363
- rows.push({ text: t.accent(' ▸ ' + titulo), sel: false })
364
- for (const x of lista) campo(x.type || x.label || '', x.value, x.visible === false)
467
+ rows.push({ text: t.accent(' ▸ ' + title), sel: false })
468
+ for (const x of list) field(x.type || x.label || '', x.value, x.visible === false)
365
469
  }
366
470
  return rows
367
471
  }
@@ -381,28 +485,60 @@ async function guard (term, st, msg, fn) {
381
485
  try { const v = await fn(); st.busy = null; return { ok: true, v } } catch (e) { st.busy = null; flash(st, humanErr(e, st), 'danger'); return { ok: false, e } }
382
486
  }
383
487
 
384
- async function refreshAll (term, st) {
385
- const r = await guard(term, st, L(st).loading, () => vc.snapshot(activeId(st)))
488
+ /**
489
+ * ¿La bóveda ACTIVA está cerrada? El candado es POR BÓVEDA, no del vault: que una esté
490
+ * cerrada no puede dejarte sin la lista ni sin poder entrar a otra.
491
+ */
492
+ const activeLocked = (st) => { const p = activeProfile(st); return !!(p?.protected && p.locked) }
493
+
494
+ // `api` es `vaultControl` — se recibe para poder probar ESTA función (la que se rompió)
495
+ // sin un daemon detrás, que es donde vive la regla de qué se pide y en qué orden.
496
+ async function refreshAll (term, st, api = vc) {
497
+ // LA LISTA DE BÓVEDAS VA PRIMERO Y APARTE. Antes esto pedía el volcado de la bóveda
498
+ // activa y, si esa era la cerrada, se salía sin llegar a guardar la lista: la TUI abría
499
+ // en blanco, sin bóvedas y con un error rojo. O sea que la contraseña de UNA bóveda te
500
+ // dejaba fuera del vault entero, que es exactamente lo que un candado por perfil no debe
501
+ // hacer.
502
+ const p = await guard(term, st, L(st).loadingVaults, () => api.listProfiles())
503
+ if (p.ok) st.profiles = p.v
504
+ // Cerrada: no se pide su contenido, y tampoco se enseña un error por mirarla desde
505
+ // fuera. Lo que hubiera cargado se suelta, para no dejar en pantalla lo de antes.
506
+ if (activeLocked(st)) {
507
+ st.devices = null; st.secrets = null; st.members = []; st.me = undefined
508
+ return
509
+ }
510
+ const r = await guard(term, st, L(st).loading, () => api.snapshot(activeId(st)))
386
511
  if (!r.ok) return
387
- const { devices, secrets, profiles, acta } = r.v
512
+ const { devices, secrets, profiles, record } = r.v
388
513
  if (profiles) st.profiles = profiles
389
- if (secrets) st.secrets = secrets.ns || {}
514
+ // Los DOS cajones de variables viajan juntos: `ns` (por scope) y `dev` (por aparato).
515
+ if (secrets) st.secrets = { ns: secrets.ns || {}, dev: Array.isArray(secrets.dev) ? secrets.dev : [] }
390
516
  // El ACTA entra en el volcado normal: es de donde sale la lista de dispositivos (ver
391
517
  // `mergeMembersAndCerts`). Antes solo se pedía al abrir la pantalla de permisos.
392
- if (acta) st.members = acta.members || []
518
+ if (record) st.members = record.members || []
393
519
  if (devices) {
394
520
  const issued = (devices.issued || devices.active || devices.delegations || [])
395
- st.devices = { issued: await Promise.all(issued.map(async (d) => ({ ...d, deviceId: d.sub ? await vc.deviceIdOf(d.sub) : '????-????' }))), revoked: devices.revoked || [] }
521
+ st.devices = { issued: await Promise.all(issued.map(async (d) => ({ ...d, deviceId: d.sub ? await api.deviceIdOf(d.sub) : '????-????' }))), revoked: devices.revoked || [] }
396
522
  }
397
523
  }
398
524
 
525
+ /**
526
+ * Guarda un volcado de dispositivos EN LOS DOS SITIOS.
527
+ *
528
+ * La lista se pinta desde el ACTA (`st.members`) con los certificados pegados
529
+ * (`st.devices`), así que quedarse solo con la mitad deja la pantalla mintiendo: aprobar
530
+ * un aparato no lo hacía aparecer y quitarlo no lo hacía desaparecer, hasta que algo
531
+ * volviera a pedir el acta. Peor todavía en una lista: el aparato recién entrado no salía,
532
+ * así que la fila «la última» era otra y quitarla se llevaba por delante a quien no era.
533
+ */
534
+ function applyDump (st, v) {
535
+ st.devices = v
536
+ if (Array.isArray(v?.members)) st.members = v.members
537
+ }
538
+
399
539
  async function refreshDevices (term, st) {
400
540
  const r = await guard(term, st, L(st).loadingDevices, () => vc.listDevices(activeId(st)))
401
- if (!r.ok) return
402
- st.devices = r.v
403
- // La lista se pinta desde el acta; traerla aparte dejaba la pantalla con los miembros de
404
- // hace dos operaciones (quitar uno no lo quitaba de la vista).
405
- if (Array.isArray(r.v.members)) st.members = r.v.members
541
+ if (r.ok) applyDump(st, r.v)
406
542
  }
407
543
  async function refreshSecrets (term, st) {
408
544
  const r = await guard(term, st, L(st).loadingSecrets, () => vc.listSecrets(activeId(st)))
@@ -416,24 +552,66 @@ async function refreshMe (term, st) {
416
552
  const r = await guard(term, st, L(st).loadingProfile, () => vc.getMe(activeId(st)))
417
553
  st.me = r.ok ? r.v : null
418
554
  }
419
- async function refreshProfiles (term, st) {
420
- const r = await guard(term, st, L(st).loadingVaults, () => vc.listProfiles())
555
+ async function refreshProfiles (term, st, api = vc) {
556
+ const r = await guard(term, st, L(st).loadingVaults, () => api.listProfiles())
421
557
  if (r.ok) st.profiles = r.v
422
558
  }
423
559
 
424
- /** Asegura la bóveda desbloqueada antes de EDITARLA (rename/rm/password). */
425
- async function ensureUnlocked (term, st, p, thenFn) {
560
+ /**
561
+ * TECLEADA UNA VEZ, VALE PARA TODA LA SESIÓN de la TUI.
562
+ *
563
+ * El candado vive en la MEMORIA DEL DAEMON, así que cualquier cosa que se lleve ese
564
+ * estado —un `systemctl restart` al actualizar, un reinicio del servicio, una petición
565
+ * que se perdió— dejaba la bóveda cerrada otra vez EN MITAD de la sesión, y la TUI
566
+ * volvía a pedir la contraseña como si nunca se hubiera tecleado.
567
+ *
568
+ * Por eso la contraseña de lo que se abre aquí se guarda en `st.sessionPwd` (SOLO en
569
+ * memoria de este proceso) y se vuelve a usar en silencio para reabrir la misma bóveda.
570
+ * Se olvida con el candado (`k`), al quitar la contraseña y al salir de la TUI, que es
571
+ * exactamente donde el dueño dijo que tiene que volver a hacer falta.
572
+ */
573
+ // `api` es `vaultControl` — se recibe para poder probar esto sin un daemon detrás.
574
+ async function reunlockSilently (term, st, p, api = vc) {
575
+ const pwd = st.sessionPwd?.get(p.id)
576
+ if (!pwd) return false
577
+ const r = await guard(term, st, L(st).unlocking, () => api.unlockProfile(p.id, pwd))
578
+ // Ya no vale (se la cambiaron desde otro sitio, o el freno está esperando): se olvida
579
+ // y se vuelve al camino normal, que es preguntar diciendo por qué.
580
+ if (!r.ok) { st.sessionPwd.delete(p.id); return false }
581
+ await refreshProfiles(term, st, api)
582
+ return true
583
+ }
584
+
585
+ /**
586
+ * Pide la contraseña si hace falta y sigue. Lo que se abre aquí queda anotado en
587
+ * `st.unlockedHere` para volver a cerrarlo AL SALIR (ver `runTui`): la contraseña dura lo
588
+ * que dura la sesión, no hasta que alguien reinicie el servicio. Lo que ya estaba abierto
589
+ * antes de entrar no se toca — no lo abrió esta pantalla, no le toca cerrarlo.
590
+ */
591
+ async function ensureUnlocked (term, st, p, thenFn, reason = null, api = vc) {
426
592
  if (!p.protected || !p.locked) return thenFn()
593
+ // Cerrada, pero la contraseña ya se tecleó en esta sesión: se reabre sin molestar.
594
+ if (await reunlockSilently(term, st, p, api)) {
595
+ const fresh = (st.profiles?.profiles || []).find((x) => x.id === p.id) || p
596
+ return thenFn(fresh)
597
+ }
427
598
  const i = L(st)
428
599
  setInput(st, {
429
600
  label: i.passwordOf(p.name || p.id),
430
601
  mask: true,
431
- hint: i.passwordToEdit,
602
+ // Si la anterior fue rechazada, el motivo se queda AQUÍ, pegado al campo, en vez de
603
+ // irse en un aviso de cuatro segundos que se lleva el siguiente redibujado. Eso era lo
604
+ // que hacía que un rechazo pareciera «me la vuelve a pedir porque sí».
605
+ hint: reason || i.passwordToEdit,
432
606
  onSubmit: async (pwd) => {
433
607
  st.input = null
434
- const r = await guard(term, st, i.unlocking, () => vc.unlockProfile(p.id, pwd))
435
- if (!r.ok) return
436
- await refreshProfiles(term, st)
608
+ const r = await guard(term, st, i.unlocking, () => api.unlockProfile(p.id, pwd))
609
+ // Rechazada: se vuelve a pedir en el acto, diciendo por qué. Cerrar el campo obligaba
610
+ // a adivinar qué había pasado y a empezar de nuevo.
611
+ if (!r.ok) return ensureUnlocked(term, st, p, thenFn, humanErr(r.e, st), api)
612
+ st.unlockedHere?.add(p.id)
613
+ st.sessionPwd?.set(p.id, pwd) // vale para toda la sesión (ver reunlockSilently)
614
+ await refreshProfiles(term, st, api)
437
615
  const fresh = (st.profiles.profiles || []).find((x) => x.id === p.id) || p
438
616
  await thenFn(fresh)
439
617
  },
@@ -445,7 +623,7 @@ async function ensureUnlocked (term, st, p, thenFn) {
445
623
 
446
624
  function moveSel (st, key, screen, count) {
447
625
  if (count <= 0) { st.sel[screen] = 0; return }
448
- // Clampa el índice guardado ANTES de aplicar el delta: si la lista encogió, la
626
+ // Clampa el índice guardado ANTES de apply el delta: si la lista encogió, la
449
627
  // primera flecha debe moverse desde la posición visible, no desde un índice viejo.
450
628
  st.sel[screen] = Math.max(0, Math.min(st.sel[screen], count - 1))
451
629
  if (key.name === 'up') st.sel[screen] = Math.max(0, st.sel[screen] - 1)
@@ -474,27 +652,38 @@ async function onKeyProfiles (term, st, key) {
474
652
  if (key.name === 'enter' && cur) {
475
653
  // Entrar a la bóveda: la activa (si no lo estaba ya) y pasa a sus pestañas
476
654
  // (Dispositivos/Scopes) — así siempre es explícito de qué bóveda son los ítems.
477
- if (!cur.current) {
478
- const r = await guard(term, st, i.switchingVault, () => vc.useProfile(cur.id))
479
- if (!r.ok) return true
480
- flash(st, i.vaultNowActive(cur.name || cur.id))
655
+ //
656
+ // Y si tiene candado, se pide la contraseña AQUÍ, antes de enseñar nada: dentro se ven
657
+ // los aparatos, las variables y tus datos, que es justo lo que la contraseña tapa.
658
+ await ensureUnlocked(term, st, cur, async (p = cur) => {
659
+ if (!p.current) {
660
+ const r = await guard(term, st, i.switchingVault, () => vc.useProfile(p.id))
661
+ if (!r.ok) return
662
+ flash(st, i.vaultNowActive(p.name || p.id))
663
+ }
664
+ // ENTRAR RECARGA TODO, SIEMPRE. Antes solo se recargaba al CAMBIAR de bóveda, y lo
665
+ // que traía era `refreshDevices`: aparatos y acta, no las variables. Así que entrar a
666
+ // la bóveda que ya estaba activa —el caso normal cuando estaba cerrada y acabas de
667
+ // teclear la contraseña, que es cuando la memoria está vacía a propósito— dejaba
668
+ // Scopes en blanco hasta que alguien pulsara F5. Un volcado trae las tres cosas.
481
669
  await refreshAll(term, st)
482
- }
483
- st.screen = 'devices'
484
- await refreshDevices(term, st)
670
+ st.screen = 'devices'
671
+ })
485
672
  } else if (ch === 'p' && cur) {
486
673
  // Emparejar SIN tener que entrar antes: `p` significa lo mismo aquí que en la
487
674
  // pestaña Dispositivos. Se activa la bóveda elegida (el QR sale de UNA, y las
488
675
  // acciones siguientes —aprobar, revocar— miran a la activa) y se abre la
489
676
  // pregunta de a qué cuenta entra el dispositivo.
490
- if (!cur.current) {
491
- const r = await guard(term, st, i.switchingVault, () => vc.useProfile(cur.id))
492
- if (!r.ok) return true
493
- await refreshAll(term, st)
494
- }
495
- st.sel.pairmode = 0
496
- st.scroll.pairmode = { value: 0 }
497
- st.screen = 'pairmode'
677
+ await ensureUnlocked(term, st, cur, async (p = cur) => {
678
+ if (!p.current) {
679
+ const r = await guard(term, st, i.switchingVault, () => vc.useProfile(p.id))
680
+ if (!r.ok) return
681
+ await refreshAll(term, st)
682
+ }
683
+ st.sel.pairmode = 0
684
+ st.scroll.pairmode = { value: 0 }
685
+ st.screen = 'pairmode'
686
+ })
498
687
  } else if (ch === 'n') {
499
688
  setInput(st, {
500
689
  label: i.newVaultLabel,
@@ -545,8 +734,10 @@ async function onKeyProfiles (term, st, key) {
545
734
  onSubmit: async (again) => {
546
735
  st.input = null
547
736
  if (again !== pwd) { flash(st, i.passwordMismatch, 'danger'); return }
548
- const r = await guard(term, st, i.savingPassword, () => vc.setProfilePassword(p.id, pwd))
549
- if (r.ok) { flash(st, i.passwordSaved); await refreshProfiles(term, st) }
737
+ const r = await guard(term, st, i.savingPassword, () => vc.setProfilePassword(p.id, pwd, st.sessionPwd?.get(p.id)))
738
+ // La nueva es la que vale para el resto de la sesión: guardar la vieja dejaría
739
+ // a la TUI reabriendo con una contraseña que ya no existe.
740
+ if (r.ok) { st.sessionPwd?.set(p.id, pwd); flash(st, i.passwordSaved); await refreshProfiles(term, st) }
550
741
  },
551
742
  onCancel: () => { st.input = null }
552
743
  })
@@ -556,8 +747,8 @@ async function onKeyProfiles (term, st, key) {
556
747
  } else if (ch === 'x' && cur) { // quitar contraseña
557
748
  if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
558
749
  await ensureUnlocked(term, st, cur, async (p = cur) => {
559
- const r = await guard(term, st, i.removingPassword, () => vc.removeProfilePassword(p.id))
560
- if (r.ok) { flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
750
+ const r = await guard(term, st, i.removingPassword, () => vc.removeProfilePassword(p.id, st.sessionPwd?.get(p.id)))
751
+ if (r.ok) { st.sessionPwd?.delete(p.id); flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
561
752
  })
562
753
  } else if (ch === 'u' && cur) {
563
754
  if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
@@ -566,7 +757,10 @@ async function onKeyProfiles (term, st, key) {
566
757
  } else if (ch === 'k' && cur) { // locK (antes `l`, que ahora es el idioma)
567
758
  if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
568
759
  const r = await guard(term, st, i.lockingVault, () => vc.lockProfile(cur.id))
569
- if (r.ok) { flash(st, i.vaultLocked); await refreshProfiles(term, st) }
760
+ // Echar el candado a mano es DECIR que vuelva a hacer falta la contraseña: si la TUI
761
+ // se quedara con ella, la siguiente tecla la reabriría sola y el candado no cerraría
762
+ // nada.
763
+ if (r.ok) { st.sessionPwd?.delete(cur.id); st.unlockedHere?.delete(cur.id); flash(st, i.vaultLocked); await refreshProfiles(term, st) }
570
764
  }
571
765
  return true
572
766
  }
@@ -605,7 +799,7 @@ async function onKeyDevices (term, st, key) {
605
799
  st.confirm = null
606
800
  // Por `sub`: se le retiran TODOS los certificados, no solo el de esta fila.
607
801
  const r = await guard(term, st, i.revoking, () => vc.revokeDevice({ sub: cur.sub, nonce: cur.nonce }, activeId(st)))
608
- if (r.ok) { flash(st, i.deviceRevoked(cur.deviceId)); st.devices = r.v; st.sel.devices = 0 }
802
+ if (r.ok) { flash(st, i.deviceRevoked(cur.deviceId)); applyDump(st, r.v); st.sel.devices = 0 }
609
803
  },
610
804
  onNo: () => { st.confirm = null }
611
805
  })
@@ -617,11 +811,11 @@ async function onKeyDevices (term, st, key) {
617
811
  label: i.renameDeviceLabel(cur.deviceId),
618
812
  hint: i.renameDeviceHint,
619
813
  value: cur.label || '',
620
- onSubmit: async (valor) => {
621
- const nombre = String(valor || '').trim()
622
- if (!nombre) return
623
- const r = await guard(term, st, i.renaming, () => vc.setDeviceLabel(cur.sub, nombre, activeId(st)))
624
- if (r.ok) { st.devices = r.v; flash(st, i.deviceRenamed(nombre)) }
814
+ onSubmit: async (raw) => {
815
+ const name = String(raw || '').trim()
816
+ if (!name) return
817
+ const r = await guard(term, st, i.renaming, () => vc.setDeviceLabel(cur.sub, name, activeId(st)))
818
+ if (r.ok) { applyDump(st, r.v); flash(st, i.deviceRenamed(name)) }
625
819
  }
626
820
  })
627
821
  } else if (ch === 'c' && cur?.sub) {
@@ -629,16 +823,44 @@ async function onKeyDevices (term, st, key) {
629
823
  st.sel.caps = 0
630
824
  await refreshMembers(term, st)
631
825
  st.screen = 'caps'
826
+ } else if (ch === 'e' && cur?.sub) {
827
+ // Variables de ESTE aparato. Solo un servicio las lee (es el único que pide su
828
+ // bundle), así que a un teléfono se le dice que no y por qué, en vez de dejarle
829
+ // guardar configuración que no va a leer nadie.
830
+ if (!cur.cn) { flash(st, i.devVarsOnlyServices, 'warn'); return true }
831
+ st.varsFor = { pub: cur.sub, deviceId: cur.deviceId, label: cur.label || '', cn: cur.cn }
832
+ st.sel.devvars = 0
833
+ await refreshSecrets(term, st)
834
+ st.screen = 'devvars'
632
835
  } else if (key.name === 'f5') {
633
836
  await refreshDevices(term, st)
634
837
  }
635
838
  return true
636
839
  }
637
840
 
638
- /** Abre el emparejamiento contra `profile` y salta a la pantalla del QR. */
639
- async function beginPairing (term, st, profile) {
640
- const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile }))
641
- if (r.ok) { st.pairing = r.v; st.pending = null; st.scroll.pairing = { value: 0 }; st.screen = 'pairing' }
841
+ /**
842
+ * Tira la cuenta que NACIÓ para un emparejamiento que no llegó a término y vuelve a la que
843
+ * estabas usando. Es una cuenta recién creada y vacía —el aparato nunca entró—, así que no
844
+ * hay nada dentro que perder; lo que molesta es que se queden acumulando.
845
+ */
846
+ async function descartarCuenta (term, st, id, volverA) {
847
+ const i = L(st)
848
+ const r = await guard(term, st, i.discardingAccount, () => vc.removeProfile(id))
849
+ if (volverA) await guard(term, st, i.switchingVault, () => vc.useProfile(volverA))
850
+ await refreshAll(term, st)
851
+ if (r.ok) flash(st, i.accountDiscarded)
852
+ }
853
+
854
+ /**
855
+ * Abre el emparejamiento contra `profile` y salta a la pantalla del QR. Con `service`,
856
+ * el QR es el de un SERVICIO de ese namespace (cert limitado a sus variables).
857
+ */
858
+ async function beginPairing (term, st, profile, service = null) {
859
+ const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile, ...(service ? { service } : {}) }))
860
+ // `service` se pega al estado porque el daemon no lo devuelve: la pantalla del QR
861
+ // tiene que poder decir qué se está entregando, que no es lo mismo un aparato tuyo
862
+ // que una máquina que solo va a leer la configuración del proxy.
863
+ if (r.ok) { st.pairing = { ...r.v, ...(service ? { service } : {}) }; st.pending = null; st.scroll.pairing = { value: 0 }; st.screen = 'pairing' }
642
864
  return r.ok
643
865
  }
644
866
 
@@ -659,27 +881,27 @@ async function onKeyCaps (term, st, key) {
659
881
  if (key.name === 'f5') { await refreshMembers(term, st); return true }
660
882
  if ((key.name !== 'enter' && ch !== ' ') || !cur) return true
661
883
 
662
- const miembro = (st.members || []).find((m) => m.pub === st.capsFor?.pub)
663
- if (!miembro) return true
664
- const caps = new Set(miembro.caps || [])
665
- const dando = !caps.has(cur.cap)
666
- if (dando) caps.add(cur.cap); else caps.delete(cur.cap)
884
+ const member = (st.members || []).find((m) => m.pub === st.capsFor?.pub)
885
+ if (!member) return true
886
+ const caps = new Set(member.caps || [])
887
+ const giving = !caps.has(cur.cap)
888
+ if (giving) caps.add(cur.cap); else caps.delete(cur.cap)
667
889
 
668
- const aplicar = async () => {
669
- const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(miembro.pub, [...caps], activeId(st)))
890
+ const apply = async () => {
891
+ const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(member.pub, [...caps], activeId(st)))
670
892
  if (!r.ok) return
671
- st.devices = r.v
893
+ applyDump(st, r.v)
672
894
  await refreshMembers(term, st)
673
- flash(st, dando ? i.capGiven(i.capName[cur.cap]) : i.capTaken(i.capName[cur.cap]))
895
+ flash(st, giving ? i.capGiven(i.capName[cur.cap]) : i.capTaken(i.capName[cur.cap]))
674
896
  }
675
897
 
676
898
  // Administrar se PREGUNTA: es el permiso que deja a ese aparato admitir y expulsar
677
899
  // dispositivos sin pasar por aquí. Los otros tres se marcan y ya.
678
- if (cur.cap === 'admin' && dando) {
679
- setConfirm(st, { text: i.confirmAdmin(st.capsFor.deviceId), onYes: aplicar })
900
+ if (cur.cap === 'admin' && giving) {
901
+ setConfirm(st, { text: i.confirmAdmin(st.capsFor.deviceId), onYes: apply })
680
902
  return true
681
903
  }
682
- await aplicar()
904
+ await apply()
683
905
  return true
684
906
  }
685
907
 
@@ -696,24 +918,48 @@ async function onKeyPairMode (term, st, key) {
696
918
 
697
919
  if (cur.mode === 'here') { await beginPairing(term, st, activeId(st)); return true }
698
920
 
921
+ // SERVICIO: entra a la cuenta activa, pero con un certificado que solo sirve para
922
+ // pedir las variables de SU namespace. El nombre del ns es el que luego pide el
923
+ // servicio al arrancar, así que se valida aquí con la misma regla que la CLI: un
924
+ // ns con mayúsculas o espacios se enrola igual y falla el día del despliegue.
925
+ if (cur.mode === 'service') {
926
+ setInput(st, {
927
+ label: i.serviceNsLabel,
928
+ hint: i.serviceNsHint,
929
+ onSubmit: async (raw) => {
930
+ st.input = null
931
+ const ns = String(raw || '').trim().toLowerCase()
932
+ if (!/^[a-z0-9-]{1,32}$/.test(ns)) { flash(st, i.serviceNsBad, 'danger'); return }
933
+ await beginPairing(term, st, activeId(st), ns)
934
+ },
935
+ onCancel: () => { st.input = null }
936
+ })
937
+ return true
938
+ }
939
+
699
940
  // Cuenta nueva: se crea aquí, se ACTIVA (así aprobar/rechazar y las listas miran
700
941
  // a la misma que el QR) y recién entonces se abre el emparejamiento contra ella.
701
942
  setInput(st, {
702
943
  label: i.newAccountLabel,
703
944
  hint: i.newAccountHint,
704
- onSubmit: async (name) => {
945
+ onSubmit: async (raw) => {
705
946
  st.input = null
706
- const nombre = name.trim()
707
- if (!nombre) { flash(st, i.nameEmpty, 'danger'); return }
708
- const r = await guard(term, st, i.creatingVault, () => vc.addProfile(nombre))
947
+ const name = raw.trim()
948
+ if (!name) { flash(st, i.nameEmpty, 'danger'); return }
949
+ const previa = activeId(st)
950
+ const r = await guard(term, st, i.creatingVault, () => vc.addProfile(name))
709
951
  if (!r.ok) return
710
- const nuevo = r.v?.id || (r.v?.profiles || []).find((p) => p.name === nombre)?.id
711
- if (!nuevo) { flash(st, i.errNoReply, 'danger'); return }
712
- const u = await guard(term, st, i.switchingVault, () => vc.useProfile(nuevo))
952
+ const created = r.v?.id || (r.v?.profiles || []).find((p) => p.name === name)?.id
953
+ if (!created) { flash(st, i.errNoReply, 'danger'); return }
954
+ const u = await guard(term, st, i.switchingVault, () => vc.useProfile(created))
713
955
  if (!u.ok) return
714
956
  await refreshAll(term, st)
715
- flash(st, i.accountCreated(nombre))
716
- await beginPairing(term, st, nuevo)
957
+ flash(st, i.accountCreated(name))
958
+ // Si el emparejamiento no llega a abrirse, la cuenta que se creó PARA él se va con
959
+ // él: si no, cada intento fallido dejaba una cuenta vacía —y encima activa— que
960
+ // luego había que ir a borrar a mano adivinando cuál era.
961
+ if (!await beginPairing(term, st, created)) await descartarCuenta(term, st, created, previa)
962
+ else st.pairing.born = { id: created, from: previa }
717
963
  },
718
964
  onCancel: () => { st.input = null }
719
965
  })
@@ -729,7 +975,18 @@ function promptApprove (term, st) {
729
975
  st.input = null
730
976
  if (!code.trim()) { flash(st, i.codeMissing, 'danger'); return }
731
977
  const r = await guard(term, st, i.approving, () => vc.approvePending(code.trim(), activeId(st)))
732
- if (r.ok) { flash(st, i.deviceApproved); st.devices = r.v; st.pending = null; st.screen = 'devices' }
978
+ if (r.ok) {
979
+ flash(st, i.deviceApproved)
980
+ st.pending = null
981
+ // El aparato entró: la cuenta que se creó para esto ya es de alguien, así que deja
982
+ // de estar en la lista de las que se descartan al salir.
983
+ st.pairing = null
984
+ st.screen = 'devices'
985
+ // Sin lista (el volcado se perdió): se pide otra vez. El aparato ya está dentro;
986
+ // lo único que falta es la foto, y esa se vuelve a pedir sin drama.
987
+ if (r.v) applyDump(st, r.v)
988
+ else await refreshDevices(term, st)
989
+ }
733
990
  },
734
991
  onCancel: () => { st.input = null }
735
992
  })
@@ -765,11 +1022,26 @@ async function onKeyPairing (term, st, key) {
765
1022
  return true
766
1023
  }
767
1024
  if (ch === 'r') { // restart: reiniciar el emparejamiento
1025
+ const born = st.pairing?.born
768
1026
  const r = await guard(term, st, i.restartingPairing, () => vc.startPairing({ profile: activeId(st) }))
769
- if (r.ok) { st.pairing = r.v; st.pending = null; st.scroll.pairing = { value: 0 } }
1027
+ if (r.ok) { st.pairing = { ...r.v, ...(born ? { born } : {}) }; st.pending = null; st.scroll.pairing = { value: 0 } }
770
1028
  return true
771
1029
  }
772
- if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; st.pairing = null; await refreshDevices(term, st) }
1030
+ if (key.name === 'escape' || ch === 'b') {
1031
+ // Te vas sin que nadie haya entrado, y la cuenta se creó PARA esto: se pregunta antes
1032
+ // de dejarla ahí. Es la otra mitad del mismo descuido — con «cuenta nueva» era fácil
1033
+ // acabar con tres cuentas vacías y ninguna forma de saber cuál era cuál.
1034
+ const born = st.pairing?.born
1035
+ if (born) {
1036
+ setConfirm(st, {
1037
+ text: i.confirmDiscardAccount,
1038
+ onYes: async () => { st.confirm = null; st.pairing = null; st.screen = 'devices'; await descartarCuenta(term, st, born.id, born.from) },
1039
+ onNo: async () => { st.confirm = null; st.pairing = null; st.screen = 'devices'; await refreshDevices(term, st) }
1040
+ })
1041
+ return true
1042
+ }
1043
+ st.screen = 'devices'; st.pairing = null; await refreshDevices(term, st)
1044
+ }
773
1045
  return true
774
1046
  }
775
1047
 
@@ -792,6 +1064,8 @@ async function onKeySecrets (term, st, key) {
792
1064
 
793
1065
  if (ch === 'n') {
794
1066
  promptNewVariable(term, st)
1067
+ } else if (ch === 'i') {
1068
+ promptLoadScopeVars(term, st)
795
1069
  } else if ((ch === 'x' || key.name === 'delete') && cur) {
796
1070
  if (cur.key) {
797
1071
  setConfirm(st, {
@@ -804,7 +1078,7 @@ async function onKeySecrets (term, st, key) {
804
1078
  onNo: () => { st.confirm = null }
805
1079
  })
806
1080
  } else {
807
- const count = (st.secrets?.[cur.ns] || []).length
1081
+ const count = (st.secrets?.ns?.[cur.ns] || []).length
808
1082
  setConfirm(st, {
809
1083
  text: i.removeScopeConfirm(cur.ns, count),
810
1084
  onYes: async () => {
@@ -815,38 +1089,234 @@ async function onKeySecrets (term, st, key) {
815
1089
  onNo: () => { st.confirm = null }
816
1090
  })
817
1091
  }
1092
+ } else if (ch === 'v' && cur?.key) {
1093
+ await revealValue(term, st, `ns:${cur.ns}`, cur.key, cur.public)
1094
+ } else if (ch === 't' && cur?.key) {
1095
+ await toggleVisibility(term, st, cur.public, () => vc.setSecretVisibility(cur.ns, cur.key, !cur.public, activeId(st), activePwd(st)))
818
1096
  } else if (key.name === 'f5') {
819
1097
  await refreshSecrets(term, st)
820
1098
  }
821
1099
  return true
822
1100
  }
823
1101
 
1102
+ /**
1103
+ * Hacer pública una variable es dejar que su valor SALGA de esta máquina, así que se
1104
+ * pregunta; volverla privada no expone nada y se aplica directo.
1105
+ */
1106
+ /**
1107
+ * VER el valor de una variable. Es lo único que la contraseña guarda desde v5, y en la
1108
+ * máquina de la bóveda no hay otro camino: la llave de este aparato vive en este mismo
1109
+ * disco, así que si abriera sin frase una copia del disco abriría todo.
1110
+ *
1111
+ * Si el perfil no tiene contraseña se abre con la llave de la máquina y no se pregunta
1112
+ * nada — pero se dice, para que no parezca una protección que no está puesta.
1113
+ */
1114
+ async function revealValue (term, st, owner, key, isPublic) {
1115
+ const i = L(st)
1116
+ const p = activeProfile(st)
1117
+ const mostrar = async (pwd) => {
1118
+ const r = await guard(term, st, i.revealing, () => vc.revealSecret(owner, key, activeId(st), pwd))
1119
+ if (r.ok) flash(st, i.revealed(key, r.v), 'ok')
1120
+ }
1121
+ if (isPublic) return mostrar(undefined) // una pública no está cerrada
1122
+ if (!p?.protected) { flash(st, i.revealNoPwd, 'warn'); return mostrar(undefined) }
1123
+ const guardada = activePwd(st)
1124
+ if (guardada) return mostrar(guardada)
1125
+ setInput(st, {
1126
+ label: i.revealAsk,
1127
+ hint: i.revealHint,
1128
+ mask: true,
1129
+ onSubmit: async (pwd) => {
1130
+ st.input = null
1131
+ if (!pwd) return
1132
+ await mostrar(pwd)
1133
+ },
1134
+ onCancel: () => { st.input = null }
1135
+ })
1136
+ }
1137
+
1138
+ async function toggleVisibility (term, st, wasPublic, apply) {
1139
+ const i = L(st)
1140
+ const run = async () => {
1141
+ const r = await guard(term, st, i.changingVisibility, apply)
1142
+ if (r.ok) { flash(st, wasPublic ? i.nowPrivate : i.nowPublic); st.secrets = r.v }
1143
+ }
1144
+ if (wasPublic) return run()
1145
+ setConfirm(st, { text: i.makePublicConfirm, onYes: run, onNo: () => { st.confirm = null } })
1146
+ }
1147
+
1148
+ /**
1149
+ * Teclas de las variables de UN aparato: agregar y quitar. Nada más — el aparato ya se
1150
+ * eligió en Dispositivos, y de ahí se vuelve con Esc.
1151
+ */
1152
+ async function onKeyDevVars (term, st, key) {
1153
+ const i = L(st)
1154
+ const rows = devVarRows(st, term.t)
1155
+ const sels = rows.filter((r) => r.sel).map((r) => r.meta)
1156
+ moveSel(st, key, 'devvars', sels.length)
1157
+ const cur = sels[Math.min(st.sel.devvars || 0, sels.length - 1)]
1158
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
1159
+ const target = st.varsFor
1160
+
1161
+ if (key.name === 'escape' || ch === 'b') {
1162
+ st.screen = 'devices'; st.varsFor = null
1163
+ await refreshDevices(term, st)
1164
+ return true
1165
+ }
1166
+ if (key.name === 'f5') { await refreshSecrets(term, st); return true }
1167
+ if (ch === 'n') { promptNewDeviceVariable(term, st); return true }
1168
+ if (ch === 'i' && target) { promptLoadVars(term, st, { pub: target.pub, where: target.deviceId }); return true }
1169
+ if (ch === 't' && cur && target) {
1170
+ await toggleVisibility(term, st, cur.public, () => vc.setDeviceSecretVisibility(target.pub, cur.key, !cur.public, activeId(st), activePwd(st)))
1171
+ } else if (ch === 'v' && cur?.key) {
1172
+ await revealValue(term, st, `dev:${target.pub}`, cur.key, cur.public)
1173
+ return true
1174
+ }
1175
+ if ((ch === 'x' || key.name === 'delete') && cur && target) {
1176
+ setConfirm(st, {
1177
+ text: i.removeDevVarConfirm(target.deviceId, cur.key),
1178
+ onYes: async () => {
1179
+ st.confirm = null
1180
+ const r = await guard(term, st, i.removingVar, () => vc.deleteDeviceSecret(target.pub, cur.key, activeId(st)))
1181
+ if (r.ok) { flash(st, i.varRemoved); st.secrets = r.v; st.sel.devvars = Math.max(0, st.sel.devvars - 1) }
1182
+ },
1183
+ onNo: () => { st.confirm = null }
1184
+ })
1185
+ }
1186
+ return true
1187
+ }
1188
+
1189
+ /**
1190
+ * Al crear una variable se PREGUNTA si su valor puede salir de esta máquina. Se pregunta
1191
+ * al crearla, y no después, porque es cuando quien la escribe sabe qué es: un puerto se
1192
+ * puede enseñar, una llave de producción no. La respuesta por defecto —Enter, o `n`— es
1193
+ * la privada.
1194
+ */
1195
+ function askVisibility (term, st, done) {
1196
+ const i = L(st)
1197
+ setConfirm(st, {
1198
+ text: i.newVarPublicAsk,
1199
+ onYes: () => { st.confirm = null; done(true) },
1200
+ onNo: () => { st.confirm = null; done(false) }
1201
+ })
1202
+ }
1203
+
1204
+ function promptNewDeviceVariable (term, st) {
1205
+ const i = L(st)
1206
+ const target = st.varsFor
1207
+ if (!target) return
1208
+ setInput(st, {
1209
+ label: i.keyLabel(target.deviceId),
1210
+ hint: i.keyHint,
1211
+ onSubmit: (key) => {
1212
+ const kv = key.trim()
1213
+ if (!KEY_RE.test(kv)) { flash(st, i.keyInvalid, 'danger'); return }
1214
+ st.input = null
1215
+ setInput(st, {
1216
+ label: i.valueLabel(target.deviceId, kv),
1217
+ mask: true,
1218
+ hint: i.valueHint,
1219
+ onSubmit: async (value) => {
1220
+ st.input = null
1221
+ if (!value) { flash(st, i.valueEmpty, 'danger'); return }
1222
+ askVisibility(term, st, async (isPublic) => {
1223
+ const r = await guard(term, st, i.savingVar, () => vc.setDeviceSecret(target.pub, kv, value, activeId(st), isPublic))
1224
+ if (r.ok) { flash(st, i.varSaved(target.deviceId, kv)); st.secrets = r.v }
1225
+ })
1226
+ },
1227
+ onCancel: () => { st.input = null }
1228
+ })
1229
+ },
1230
+ onCancel: () => { st.input = null }
1231
+ })
1232
+ }
1233
+
1234
+ /**
1235
+ * CARGAR VARIAS DE UNA VEZ (tecla `i`, de *import*, la misma palabra que en el CLI).
1236
+ *
1237
+ * Guardar las variables de un servicio una por una es, para la bóveda, un cambio de
1238
+ * configuración por variable: el servicio obedece el primero —sale y lo levanta su
1239
+ * supervisor— y arranca con media configuración mientras se teclea el resto. Cargarlas
1240
+ * juntas hace que se reinicie UNA vez, con todo puesto.
1241
+ *
1242
+ * Se acepta lo que se pueda escribir en una línea (`CLAVE=valor CLAVE2=valor2`) o la
1243
+ * RUTA de un `.env`, que es como suele llegar la configuración de un servicio.
1244
+ */
1245
+ function promptLoadVars (term, st, { ns = null, pub = null, where }) {
1246
+ const i = L(st)
1247
+ setInput(st, {
1248
+ label: i.loadLabel(where),
1249
+ hint: i.loadHint,
1250
+ onSubmit: async (raw) => {
1251
+ const text = raw.trim()
1252
+ st.input = null
1253
+ if (!text) return
1254
+ let content = text
1255
+ // Sin un `=` no es una lista de variables: es la ruta de un archivo.
1256
+ if (!text.includes('=')) {
1257
+ try { content = fs.readFileSync(text, 'utf8') } catch (_) { flash(st, i.loadNoFile(text), 'danger'); return }
1258
+ }
1259
+ const { items, errors } = parseEnvInput(content)
1260
+ // Un archivo con un problema no se carga a medias: se dice qué línea y no se
1261
+ // escribe nada. Media configuración aplicada es peor que ninguna.
1262
+ if (errors.length) { flash(st, i.loadNothing + ' ' + i.envErr[errors[0].code](errors[0]), 'danger'); return }
1263
+ askVisibility(term, st, async (isPublic) => {
1264
+ const withVisibility = items.map((it) => ({ ...it, public: isPublic }))
1265
+ const r = await guard(term, st, i.loadingVars, () => (pub
1266
+ ? vc.applyDeviceSecrets(pub, withVisibility, activeId(st))
1267
+ : vc.applySecrets(ns, withVisibility, activeId(st))))
1268
+ if (r.ok) { flash(st, i.loadedVars(items.length, where)); st.secrets = r.v }
1269
+ })
1270
+ },
1271
+ onCancel: () => { st.input = null }
1272
+ })
1273
+ }
1274
+
1275
+ /** Cargar varias en un SCOPE: primero cuál, luego el bloque. */
1276
+ function promptLoadScopeVars (term, st) {
1277
+ const i = L(st)
1278
+ const existing = Object.keys(st.secrets?.ns || {})
1279
+ setInput(st, {
1280
+ label: i.nsLabel,
1281
+ hint: existing.length ? i.nsHintExisting(existing.join(', ')) : i.nsHint,
1282
+ onSubmit: (ns) => {
1283
+ const nsName = ns.trim()
1284
+ if (!NS_RE.test(nsName)) { flash(st, i.nsInvalid, 'danger'); return }
1285
+ st.input = null
1286
+ promptLoadVars(term, st, { ns: nsName, where: nsName })
1287
+ },
1288
+ onCancel: () => { st.input = null }
1289
+ })
1290
+ }
1291
+
824
1292
  function promptNewVariable (term, st) {
825
1293
  const i = L(st)
826
- const existing = Object.keys(st.secrets || {})
1294
+ const existing = Object.keys(st.secrets?.ns || {})
827
1295
  setInput(st, {
828
1296
  label: i.nsLabel,
829
1297
  hint: existing.length ? i.nsHintExisting(existing.join(', ')) : i.nsHint,
830
1298
  onSubmit: (ns) => {
831
- const nsv = ns.trim()
832
- if (!NS_RE.test(nsv)) { flash(st, i.nsInvalid, 'danger'); promptNewVariable(term, st); return }
1299
+ const nsName = ns.trim()
1300
+ if (!NS_RE.test(nsName)) { flash(st, i.nsInvalid, 'danger'); promptNewVariable(term, st); return }
833
1301
  st.input = null
834
1302
  setInput(st, {
835
- label: i.keyLabel(nsv),
1303
+ label: i.keyLabel(nsName),
836
1304
  hint: i.keyHint,
837
1305
  onSubmit: (key) => {
838
1306
  const kv = key.trim()
839
1307
  if (!KEY_RE.test(kv)) { flash(st, i.keyInvalid, 'danger'); return }
840
1308
  st.input = null
841
1309
  setInput(st, {
842
- label: i.valueLabel(nsv, kv),
1310
+ label: i.valueLabel(nsName, kv),
843
1311
  mask: true,
844
1312
  hint: i.valueHint,
845
1313
  onSubmit: async (value) => {
846
1314
  st.input = null
847
1315
  if (!value) { flash(st, i.valueEmpty, 'danger'); return }
848
- const r = await guard(term, st, i.savingVar, () => vc.setSecret(nsv, kv, value, activeId(st)))
849
- if (r.ok) { flash(st, i.varSaved(nsv, kv)); st.secrets = r.v }
1316
+ askVisibility(term, st, async (isPublic) => {
1317
+ const r = await guard(term, st, i.savingVar, () => vc.setSecret(nsName, kv, value, activeId(st), isPublic))
1318
+ if (r.ok) { flash(st, i.varSaved(nsName, kv)); st.secrets = r.v }
1319
+ })
850
1320
  },
851
1321
  onCancel: () => { st.input = null }
852
1322
  })
@@ -900,13 +1370,20 @@ const helpSegs = (i, screen, st = {}) => {
900
1370
  pairing: i.helpPairing,
901
1371
  pairmode: i.helpPairMode,
902
1372
  me: i.helpMe,
903
- caps: i.helpCaps
1373
+ caps: i.helpCaps,
1374
+ devvars: i.helpDevVars
904
1375
  }[screen] || []
905
1376
  if (typeof segs !== 'function') return segs
1377
+ // El aparato señalado ahora mismo: `e variables` solo tiene sentido en un servicio (es
1378
+ // el único que las lee), y las filas seleccionables de la lista son justo los aparatos.
1379
+ const devs = mergeMembersAndCerts(st.members, st.devices?.issued || [])
1380
+ const cur = devs[Math.min(st.sel?.devices || 0, devs.length - 1)]
906
1381
  return segs({
907
- pendiente: !!st.pending,
908
- hayAparatos: (st.devices?.issued || []).length > 0,
909
- haySecretos: Object.keys(st.secrets || {}).length > 0
1382
+ pending: !!st.pending,
1383
+ hasDevices: (st.devices?.issued || []).length > 0,
1384
+ isService: !!cur?.cn,
1385
+ hasSecrets: Object.keys(st.secrets?.ns || {}).length > 0,
1386
+ hasVars: devVarsOf(st, st.varsFor?.pub).length > 0
910
1387
  })
911
1388
  }
912
1389
 
@@ -914,7 +1391,8 @@ const title = (i, screen) => ({
914
1391
  profiles: i.titleProfiles,
915
1392
  pairing: i.titlePairing,
916
1393
  pairmode: i.titlePairMode,
917
- caps: i.titleCaps
1394
+ caps: i.titleCaps,
1395
+ devvars: i.titleDevVars
918
1396
  })[screen] || ''
919
1397
 
920
1398
  /** Barra de pestañas horizontal (Dispositivos | Scopes y variables) de la bóveda entrada. */
@@ -937,6 +1415,9 @@ function pairingBody (st, t, cols, height) {
937
1415
  const acct = info.profileName || ap?.name || info.profile || ap?.id || '—'
938
1416
  const left = Math.max(0, Math.round((info.expiresAt - Date.now()) / 60000))
939
1417
  lines.push(t.bold(i.pairAccount(acct, left)))
1418
+ // Y si lo que se entrega es un SERVICIO, se dice: el papel que sale de este QR no
1419
+ // firma ni ve el contenido, solo lee las variables de ese namespace.
1420
+ if (info.service) lines.push(t.warn(i.pairService(info.service)))
940
1421
  // QR: se dibuja siempre que quepa de ancho; si es más alto que la pantalla se
941
1422
  // puede hacer scroll hacia arriba/abajo para verlo completo.
942
1423
  let qr = ''
@@ -977,9 +1458,9 @@ function render (term, st) {
977
1458
 
978
1459
  const s = st.state
979
1460
  const up = st.daemonUp
980
- const ver = s?.version || 'dev'
1461
+ const version = s?.version || 'dev'
981
1462
  const daemonTxt = up ? i.daemonRunning : i.daemonStopped
982
- lines[0] = t.bar(`dotrino-vault ${ver} daemon: ${daemonTxt} ${vc.vaultDir()}`, cols)
1463
+ lines[0] = t.bar(`dotrino-vault ${version} daemon: ${daemonTxt} ${vc.vaultDir()}`, cols)
983
1464
 
984
1465
  const ap = activeProfile(st)
985
1466
  const apTxt = ap ? `${t.accent('●')} ${t.bold(ap.name || i.noName)} ${lockGlyph(ap)} ${t.muted('· ' + (ap.fingerprint || '—'))}` : t.muted('—')
@@ -1001,6 +1482,7 @@ function render (term, st) {
1001
1482
  else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
1002
1483
  else if (st.screen === 'me') body = renderList(meRows(st, t), -1, contentH, cols, t, scrollRef)
1003
1484
  else if (st.screen === 'caps') body = renderList(capsRows(st, t), st.sel.caps || 0, contentH, cols, t, scrollRef)
1485
+ else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
1004
1486
  else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
1005
1487
  else if (st.screen === 'pairing') {
1006
1488
  const pb = pairingBody(st, t, cols, contentH)
@@ -1086,7 +1568,13 @@ export async function runTui () {
1086
1568
  const st = {
1087
1569
  screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
1088
1570
  lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
1089
- sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0 },
1571
+ sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, devvars: 0 },
1572
+ // Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
1573
+ unlockedHere: new Set(),
1574
+ // Su contraseña, SOLO en memoria y SOLO mientras la TUI esté abierta: sirve para
1575
+ // reabrir sin volver a preguntar si el daemon pierde el estado (ver
1576
+ // `reunlockSilently`). Se olvida con `k`, al quitar la contraseña y al salir.
1577
+ sessionPwd: new Map(),
1090
1578
  scroll: {},
1091
1579
  profiles: null,
1092
1580
  devices: null,
@@ -1155,13 +1643,21 @@ export async function runTui () {
1155
1643
  else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
1156
1644
  else if (st.screen === 'me') running = await onKeyMe(term, st, key)
1157
1645
  else if (st.screen === 'caps') running = await onKeyCaps(term, st, key)
1646
+ else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
1158
1647
  else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
1159
1648
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
1160
1649
  }
1161
1650
  } finally {
1651
+ // AL SALIR SE VUELVE A CERRAR lo que se abrió aquí. Sin esto, teclear la contraseña una
1652
+ // vez dejaba la bóveda abierta para todo el que pasara por esta máquina hasta el
1653
+ // siguiente reinicio del servicio — un candado que solo se cierra reiniciando no es un
1654
+ // candado. (Si la TUI muere de un tirón —kill, ventana cerrada— no hay quien lo haga:
1655
+ // ahí el cierre lo pone el reinicio, como antes.)
1656
+ st.sessionPwd.clear()
1657
+ for (const id of st.unlockedHere) { try { await vc.lockProfile(id) } catch (_) {} }
1162
1658
  term.close()
1163
1659
  }
1164
1660
  }
1165
1661
 
1166
1662
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
1167
- export const __test = { render, profileRows, deviceRows, secretRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts }
1663
+ export const __test = { render, activeLocked, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts }