@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.
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
  }
@@ -235,15 +250,17 @@ function deviceRows (st, t) {
235
250
  }
236
251
  for (const d of devices) {
237
252
  const label = d.label || t.muted(i.noLabel)
238
- const extra = d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : ''
253
+ const vars = devVarsOf(st, d.sub).length
254
+ const extra = (d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : '') +
255
+ (vars ? t.muted(` vars:${vars}`) : '')
239
256
  // SIN ACCESO: está en el acta y no puede entrar. Es un aviso, no un adorno, así que va
240
257
  // en el color de aviso y en el sitio donde estaría su vencimiento.
241
- const estado = d.noAccess
258
+ const status = d.noAccess
242
259
  ? t.warn(i.deviceNoAccess)
243
260
  : d.isMaster
244
261
  ? t.muted(i.thisVault)
245
262
  : 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 })
263
+ rows.push({ text: ` ${t.bold(d.deviceId)} ${label} ${status}${extra}`, sel: true, meta: d })
247
264
  }
248
265
  const revoked = st.devices?.revoked || []
249
266
  if (revoked.length) {
@@ -256,11 +273,15 @@ function deviceRows (st, t) {
256
273
  /**
257
274
  * LA PREGUNTA DEL EMPAREJAMIENTO. La decisión es del vault (es quien lo inicia) y
258
275
  * 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).
276
+ * cuál entra el dispositivo. Se responde con las tres formas que existen —una
277
+ * cuenta que ya vive aquí, una nueva que se estrena para él, o un SERVICIO de la
278
+ * cuenta activa—; la cuarta («adoptar la que trae el aparato») necesita el
279
+ * protocolo de adopción y se muestra desactivada para no prometer lo que todavía
280
+ * no hace (docs/vinculacion-de-cuentas.md §5).
281
+ *
282
+ * Lo del servicio estaba SOLO en la línea de comandos (`pair --service <ns>`), y una
283
+ * máquina que sirve el proxy no se empareja de otra manera: sin esta opción, la TUI
284
+ * te dejaba a medio camino y había que salirse a la terminal a terminar el trabajo.
264
285
  */
265
286
  function pairModeRows (st, t) {
266
287
  const i = L(st)
@@ -272,6 +293,9 @@ function pairModeRows (st, t) {
272
293
  rows.push({ text: ` ${t.bold(i.pairModeNew)}`, sel: true, meta: { mode: 'new' } })
273
294
  rows.push({ text: t.muted(' ' + i.pairModeNewHint), sel: false })
274
295
  rows.push({ text: '', sel: false })
296
+ rows.push({ text: ` ${t.bold(i.pairModeService)}`, sel: true, meta: { mode: 'service' } })
297
+ rows.push({ text: t.muted(' ' + i.pairModeServiceHint), sel: false })
298
+ rows.push({ text: '', sel: false })
275
299
  rows.push({ text: ' ' + t.muted(i.pairModeAdopt), sel: false })
276
300
  rows.push({ text: t.muted(' (' + i.pairModeAdoptSoon + ')'), sel: false })
277
301
  return rows
@@ -282,25 +306,25 @@ function pairModeRows (st, t) {
282
306
  * una marca de si los tiene. El de administrar va aparte y avisado: es el único que deja
283
307
  * a ese aparato meter y sacar dispositivos sin venir aquí.
284
308
  */
285
- const CAPS_ORDEN = ['sign', 'store', 'read', 'admin']
309
+ const CAPS_ORDER = ['sign', 'store', 'read', 'admin']
286
310
 
287
311
  function capsRows (st, t) {
288
312
  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 }]
313
+ const target = st.capsFor
314
+ if (!target) return [{ text: t.muted(i.loading), sel: false }]
315
+ const member = (st.members || []).find((m) => m.pub === target.pub)
316
+ if (!member) return [{ text: t.muted(i.capsNoMember), sel: false }]
293
317
 
294
- const tiene = new Set(miembro.caps || [])
318
+ const has = new Set(member.caps || [])
295
319
  const rows = [
296
- { text: ' ' + t.bold(i.capsFor(objetivo.deviceId, miembro.label || '')), sel: false },
320
+ { text: ' ' + t.bold(i.capsFor(target.deviceId, member.label || '')), sel: false },
297
321
  { text: '', sel: false }
298
322
  ]
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 } })
323
+ for (const cap of CAPS_ORDER) {
324
+ const mark = has.has(cap) ? '[x]' : '[ ]'
325
+ const name = i.capName[cap]
326
+ const line = ` ${mark} ${cap === 'admin' ? t.bold(name) : name}`
327
+ rows.push({ text: line, sel: true, meta: { cap } })
304
328
  rows.push({ text: t.muted(' ' + i.capHint[cap]), sel: false })
305
329
  }
306
330
  rows.push({ text: '', sel: false })
@@ -308,21 +332,63 @@ function capsRows (st, t) {
308
332
  return rows
309
333
  }
310
334
 
335
+ /** Las variables por SCOPE: las que comparten todos los aparatos que sirven ese ns. */
311
336
  function secretRows (st, t) {
312
337
  const i = L(st)
313
- const ns = st.secrets || {}
338
+ const ns = st.secrets?.ns || {}
314
339
  const names = Object.keys(ns).sort()
315
340
  const rows = []
316
- if (!names.length) {
317
- rows.push({ text: t.muted(i.noScopes), sel: false })
318
- return rows
319
- }
341
+ // El puntero a la otra pantalla va SIEMPRE, con scopes y sin ellos: es la mitad de la
342
+ // función y quien la busca no tiene por qué adivinar que vive en Dispositivos.
343
+ const footer = [{ text: '', sel: false }, { text: t.muted(' ' + i.devVarsElsewhere), sel: false }]
344
+ if (!names.length) return [{ text: t.muted(i.noScopes), sel: false }, ...footer]
320
345
  for (const n of names) {
321
346
  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 } })
347
+ for (const k of sortByKey(ns[n])) {
348
+ rows.push({ text: varLine(k, t, i), sel: true, meta: { ns: n, key: k.key, public: k.public } })
324
349
  }
325
350
  }
351
+ return [...rows, ...footer]
352
+ }
353
+
354
+ /** Las claves guardadas para UN aparato (`pub`), o `[]`. Cada una es `{key, public}`. */
355
+ const devVarsOf = (st, pub) => (st.secrets?.dev || []).find((x) => x.pub === pub)?.keys || []
356
+
357
+ const sortByKey = (list) => (list || []).slice().sort((a, b) => a.key.localeCompare(b.key))
358
+
359
+ /**
360
+ * Una variable: su nombre y su valor. La PÚBLICA enseña el suyo —pública significa que ese
361
+ * valor puede salir de esta máquina, así que taparlo delante de su dueño, en la máquina
362
+ * donde vive, era lo único que la marca no quería decir— con el aviso de que viaja cuando
363
+ * la consola remota lo pide. La privada sigue tapada: no sale ni a esta pantalla.
364
+ */
365
+ const varLine = (v, t, i) => ` ${v.key} ` +
366
+ (v.public ? `${short(v.value)} ${t.warn(i.varPublic)}` : t.muted('••••••'))
367
+
368
+ /** Un valor largo no puede empujar la marca «pública» fuera de la pantalla. */
369
+ const short = (s) => {
370
+ const v = String(s ?? '')
371
+ return v.length > 40 ? v.slice(0, 39) + '…' : v
372
+ }
373
+
374
+ /**
375
+ * Las variables de UN aparato. Se entra desde Dispositivos con `e`, ya con el aparato
376
+ * elegido: por eso aquí no se vuelve a elegir, solo se agrega y se quita.
377
+ */
378
+ function devVarRows (st, t) {
379
+ const i = L(st)
380
+ const target = st.varsFor
381
+ if (!target) return [{ text: t.muted(i.loading), sel: false }]
382
+ const keys = sortByKey(devVarsOf(st, target.pub))
383
+ const rows = [
384
+ { text: ' ' + t.bold(i.devVarsFor(target.deviceId, target.label || '')), sel: false },
385
+ // Dato, no explicación: qué servicio es este aparato es lo que decide qué namespace
386
+ // lee, y por lo tanto a qué variables del scope le ganan estas.
387
+ { text: t.muted(' ' + i.devVarsService(target.cn)), sel: false },
388
+ { text: '', sel: false }
389
+ ]
390
+ if (!keys.length) rows.push({ text: t.muted(' ' + i.noDevVars), sel: false })
391
+ for (const k of keys) rows.push({ text: varLine(k, t, i), sel: true, meta: { key: k.key, public: k.public } })
326
392
  return rows
327
393
  }
328
394
 
@@ -341,27 +407,27 @@ function meRows (st, t) {
341
407
  if (!me) return [{ text: t.muted(i.noProfile), sel: false }, { text: '', sel: false }, { text: t.muted(i.noProfileHint), sel: false }]
342
408
 
343
409
  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
410
+ const field = (label, value, hidden) => rows.push({
411
+ text: ` ${t.muted(String(label).padEnd(12))} ${value}${hidden ? t.muted(i.hidden) : ''}`, sel: false
346
412
  })
347
413
  rows.push({ text: t.muted(i.profileUpdated(me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—')), sel: false })
348
414
  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
415
+ field(i.fieldName, me.nickname ? t.bold(me.nickname) : t.muted(i.noName))
416
+ field(i.fieldPhoto, me.avatar
351
417
  ? `${me.avatar.type || '?'} · ${(me.avatar.bytes / 1024).toFixed(1)} KB`
352
418
  : t.muted(i.no))
353
419
 
354
420
  const STD = [['nombres', i.fieldFirstName], ['apellidos', i.fieldLastName], ['email', i.fieldEmail],
355
421
  ['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)
422
+ const filled = STD.filter(([k]) => me[k])
423
+ if (filled.length) rows.push({ text: '', sel: false })
424
+ for (const [k, label] of filled) field(label, me[k], me[k + 'Visible'] === false)
359
425
 
360
- for (const [titulo, lista] of [[i.links, me.links], [i.otherData, me.fields]]) {
361
- if (!Array.isArray(lista) || !lista.length) continue
426
+ for (const [title, list] of [[i.links, me.links], [i.otherData, me.fields]]) {
427
+ if (!Array.isArray(list) || !list.length) continue
362
428
  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)
429
+ rows.push({ text: t.accent(' ▸ ' + title), sel: false })
430
+ for (const x of list) field(x.type || x.label || '', x.value, x.visible === false)
365
431
  }
366
432
  return rows
367
433
  }
@@ -381,28 +447,60 @@ async function guard (term, st, msg, fn) {
381
447
  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
448
  }
383
449
 
384
- async function refreshAll (term, st) {
385
- const r = await guard(term, st, L(st).loading, () => vc.snapshot(activeId(st)))
450
+ /**
451
+ * ¿La bóveda ACTIVA está cerrada? El candado es POR BÓVEDA, no del vault: que una esté
452
+ * cerrada no puede dejarte sin la lista ni sin poder entrar a otra.
453
+ */
454
+ const activeLocked = (st) => { const p = activeProfile(st); return !!(p?.protected && p.locked) }
455
+
456
+ // `api` es `vaultControl` — se recibe para poder probar ESTA función (la que se rompió)
457
+ // sin un daemon detrás, que es donde vive la regla de qué se pide y en qué orden.
458
+ async function refreshAll (term, st, api = vc) {
459
+ // LA LISTA DE BÓVEDAS VA PRIMERO Y APARTE. Antes esto pedía el volcado de la bóveda
460
+ // activa y, si esa era la cerrada, se salía sin llegar a guardar la lista: la TUI abría
461
+ // en blanco, sin bóvedas y con un error rojo. O sea que la contraseña de UNA bóveda te
462
+ // dejaba fuera del vault entero, que es exactamente lo que un candado por perfil no debe
463
+ // hacer.
464
+ const p = await guard(term, st, L(st).loadingVaults, () => api.listProfiles())
465
+ if (p.ok) st.profiles = p.v
466
+ // Cerrada: no se pide su contenido, y tampoco se enseña un error por mirarla desde
467
+ // fuera. Lo que hubiera cargado se suelta, para no dejar en pantalla lo de antes.
468
+ if (activeLocked(st)) {
469
+ st.devices = null; st.secrets = null; st.members = []; st.me = undefined
470
+ return
471
+ }
472
+ const r = await guard(term, st, L(st).loading, () => api.snapshot(activeId(st)))
386
473
  if (!r.ok) return
387
- const { devices, secrets, profiles, acta } = r.v
474
+ const { devices, secrets, profiles, record } = r.v
388
475
  if (profiles) st.profiles = profiles
389
- if (secrets) st.secrets = secrets.ns || {}
476
+ // Los DOS cajones de variables viajan juntos: `ns` (por scope) y `dev` (por aparato).
477
+ if (secrets) st.secrets = { ns: secrets.ns || {}, dev: Array.isArray(secrets.dev) ? secrets.dev : [] }
390
478
  // El ACTA entra en el volcado normal: es de donde sale la lista de dispositivos (ver
391
479
  // `mergeMembersAndCerts`). Antes solo se pedía al abrir la pantalla de permisos.
392
- if (acta) st.members = acta.members || []
480
+ if (record) st.members = record.members || []
393
481
  if (devices) {
394
482
  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 || [] }
483
+ st.devices = { issued: await Promise.all(issued.map(async (d) => ({ ...d, deviceId: d.sub ? await api.deviceIdOf(d.sub) : '????-????' }))), revoked: devices.revoked || [] }
396
484
  }
397
485
  }
398
486
 
487
+ /**
488
+ * Guarda un volcado de dispositivos EN LOS DOS SITIOS.
489
+ *
490
+ * La lista se pinta desde el ACTA (`st.members`) con los certificados pegados
491
+ * (`st.devices`), así que quedarse solo con la mitad deja la pantalla mintiendo: aprobar
492
+ * un aparato no lo hacía aparecer y quitarlo no lo hacía desaparecer, hasta que algo
493
+ * volviera a pedir el acta. Peor todavía en una lista: el aparato recién entrado no salía,
494
+ * así que la fila «la última» era otra y quitarla se llevaba por delante a quien no era.
495
+ */
496
+ function applyDump (st, v) {
497
+ st.devices = v
498
+ if (Array.isArray(v?.members)) st.members = v.members
499
+ }
500
+
399
501
  async function refreshDevices (term, st) {
400
502
  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
503
+ if (r.ok) applyDump(st, r.v)
406
504
  }
407
505
  async function refreshSecrets (term, st) {
408
506
  const r = await guard(term, st, L(st).loadingSecrets, () => vc.listSecrets(activeId(st)))
@@ -416,24 +514,66 @@ async function refreshMe (term, st) {
416
514
  const r = await guard(term, st, L(st).loadingProfile, () => vc.getMe(activeId(st)))
417
515
  st.me = r.ok ? r.v : null
418
516
  }
419
- async function refreshProfiles (term, st) {
420
- const r = await guard(term, st, L(st).loadingVaults, () => vc.listProfiles())
517
+ async function refreshProfiles (term, st, api = vc) {
518
+ const r = await guard(term, st, L(st).loadingVaults, () => api.listProfiles())
421
519
  if (r.ok) st.profiles = r.v
422
520
  }
423
521
 
424
- /** Asegura la bóveda desbloqueada antes de EDITARLA (rename/rm/password). */
425
- async function ensureUnlocked (term, st, p, thenFn) {
522
+ /**
523
+ * TECLEADA UNA VEZ, VALE PARA TODA LA SESIÓN de la TUI.
524
+ *
525
+ * El candado vive en la MEMORIA DEL DAEMON, así que cualquier cosa que se lleve ese
526
+ * estado —un `systemctl restart` al actualizar, un reinicio del servicio, una petición
527
+ * que se perdió— dejaba la bóveda cerrada otra vez EN MITAD de la sesión, y la TUI
528
+ * volvía a pedir la contraseña como si nunca se hubiera tecleado.
529
+ *
530
+ * Por eso la contraseña de lo que se abre aquí se guarda en `st.sessionPwd` (SOLO en
531
+ * memoria de este proceso) y se vuelve a usar en silencio para reabrir la misma bóveda.
532
+ * Se olvida con el candado (`k`), al quitar la contraseña y al salir de la TUI, que es
533
+ * exactamente donde el dueño dijo que tiene que volver a hacer falta.
534
+ */
535
+ // `api` es `vaultControl` — se recibe para poder probar esto sin un daemon detrás.
536
+ async function reunlockSilently (term, st, p, api = vc) {
537
+ const pwd = st.sessionPwd?.get(p.id)
538
+ if (!pwd) return false
539
+ const r = await guard(term, st, L(st).unlocking, () => api.unlockProfile(p.id, pwd))
540
+ // Ya no vale (se la cambiaron desde otro sitio, o el freno está esperando): se olvida
541
+ // y se vuelve al camino normal, que es preguntar diciendo por qué.
542
+ if (!r.ok) { st.sessionPwd.delete(p.id); return false }
543
+ await refreshProfiles(term, st, api)
544
+ return true
545
+ }
546
+
547
+ /**
548
+ * Pide la contraseña si hace falta y sigue. Lo que se abre aquí queda anotado en
549
+ * `st.unlockedHere` para volver a cerrarlo AL SALIR (ver `runTui`): la contraseña dura lo
550
+ * que dura la sesión, no hasta que alguien reinicie el servicio. Lo que ya estaba abierto
551
+ * antes de entrar no se toca — no lo abrió esta pantalla, no le toca cerrarlo.
552
+ */
553
+ async function ensureUnlocked (term, st, p, thenFn, reason = null, api = vc) {
426
554
  if (!p.protected || !p.locked) return thenFn()
555
+ // Cerrada, pero la contraseña ya se tecleó en esta sesión: se reabre sin molestar.
556
+ if (await reunlockSilently(term, st, p, api)) {
557
+ const fresh = (st.profiles?.profiles || []).find((x) => x.id === p.id) || p
558
+ return thenFn(fresh)
559
+ }
427
560
  const i = L(st)
428
561
  setInput(st, {
429
562
  label: i.passwordOf(p.name || p.id),
430
563
  mask: true,
431
- hint: i.passwordToEdit,
564
+ // Si la anterior fue rechazada, el motivo se queda AQUÍ, pegado al campo, en vez de
565
+ // irse en un aviso de cuatro segundos que se lleva el siguiente redibujado. Eso era lo
566
+ // que hacía que un rechazo pareciera «me la vuelve a pedir porque sí».
567
+ hint: reason || i.passwordToEdit,
432
568
  onSubmit: async (pwd) => {
433
569
  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)
570
+ const r = await guard(term, st, i.unlocking, () => api.unlockProfile(p.id, pwd))
571
+ // Rechazada: se vuelve a pedir en el acto, diciendo por qué. Cerrar el campo obligaba
572
+ // a adivinar qué había pasado y a empezar de nuevo.
573
+ if (!r.ok) return ensureUnlocked(term, st, p, thenFn, humanErr(r.e, st), api)
574
+ st.unlockedHere?.add(p.id)
575
+ st.sessionPwd?.set(p.id, pwd) // vale para toda la sesión (ver reunlockSilently)
576
+ await refreshProfiles(term, st, api)
437
577
  const fresh = (st.profiles.profiles || []).find((x) => x.id === p.id) || p
438
578
  await thenFn(fresh)
439
579
  },
@@ -445,7 +585,7 @@ async function ensureUnlocked (term, st, p, thenFn) {
445
585
 
446
586
  function moveSel (st, key, screen, count) {
447
587
  if (count <= 0) { st.sel[screen] = 0; return }
448
- // Clampa el índice guardado ANTES de aplicar el delta: si la lista encogió, la
588
+ // Clampa el índice guardado ANTES de apply el delta: si la lista encogió, la
449
589
  // primera flecha debe moverse desde la posición visible, no desde un índice viejo.
450
590
  st.sel[screen] = Math.max(0, Math.min(st.sel[screen], count - 1))
451
591
  if (key.name === 'up') st.sel[screen] = Math.max(0, st.sel[screen] - 1)
@@ -474,27 +614,38 @@ async function onKeyProfiles (term, st, key) {
474
614
  if (key.name === 'enter' && cur) {
475
615
  // Entrar a la bóveda: la activa (si no lo estaba ya) y pasa a sus pestañas
476
616
  // (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))
617
+ //
618
+ // Y si tiene candado, se pide la contraseña AQUÍ, antes de enseñar nada: dentro se ven
619
+ // los aparatos, las variables y tus datos, que es justo lo que la contraseña tapa.
620
+ await ensureUnlocked(term, st, cur, async (p = cur) => {
621
+ if (!p.current) {
622
+ const r = await guard(term, st, i.switchingVault, () => vc.useProfile(p.id))
623
+ if (!r.ok) return
624
+ flash(st, i.vaultNowActive(p.name || p.id))
625
+ }
626
+ // ENTRAR RECARGA TODO, SIEMPRE. Antes solo se recargaba al CAMBIAR de bóveda, y lo
627
+ // que traía era `refreshDevices`: aparatos y acta, no las variables. Así que entrar a
628
+ // la bóveda que ya estaba activa —el caso normal cuando estaba cerrada y acabas de
629
+ // teclear la contraseña, que es cuando la memoria está vacía a propósito— dejaba
630
+ // Scopes en blanco hasta que alguien pulsara F5. Un volcado trae las tres cosas.
481
631
  await refreshAll(term, st)
482
- }
483
- st.screen = 'devices'
484
- await refreshDevices(term, st)
632
+ st.screen = 'devices'
633
+ })
485
634
  } else if (ch === 'p' && cur) {
486
635
  // Emparejar SIN tener que entrar antes: `p` significa lo mismo aquí que en la
487
636
  // pestaña Dispositivos. Se activa la bóveda elegida (el QR sale de UNA, y las
488
637
  // acciones siguientes —aprobar, revocar— miran a la activa) y se abre la
489
638
  // 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'
639
+ await ensureUnlocked(term, st, cur, async (p = cur) => {
640
+ if (!p.current) {
641
+ const r = await guard(term, st, i.switchingVault, () => vc.useProfile(p.id))
642
+ if (!r.ok) return
643
+ await refreshAll(term, st)
644
+ }
645
+ st.sel.pairmode = 0
646
+ st.scroll.pairmode = { value: 0 }
647
+ st.screen = 'pairmode'
648
+ })
498
649
  } else if (ch === 'n') {
499
650
  setInput(st, {
500
651
  label: i.newVaultLabel,
@@ -546,7 +697,9 @@ async function onKeyProfiles (term, st, key) {
546
697
  st.input = null
547
698
  if (again !== pwd) { flash(st, i.passwordMismatch, 'danger'); return }
548
699
  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) }
700
+ // La nueva es la que vale para el resto de la sesión: guardar la vieja dejaría
701
+ // a la TUI reabriendo con una contraseña que ya no existe.
702
+ if (r.ok) { st.sessionPwd?.set(p.id, pwd); flash(st, i.passwordSaved); await refreshProfiles(term, st) }
550
703
  },
551
704
  onCancel: () => { st.input = null }
552
705
  })
@@ -557,7 +710,7 @@ async function onKeyProfiles (term, st, key) {
557
710
  if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
558
711
  await ensureUnlocked(term, st, cur, async (p = cur) => {
559
712
  const r = await guard(term, st, i.removingPassword, () => vc.removeProfilePassword(p.id))
560
- if (r.ok) { flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
713
+ if (r.ok) { st.sessionPwd?.delete(p.id); flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
561
714
  })
562
715
  } else if (ch === 'u' && cur) {
563
716
  if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
@@ -566,7 +719,10 @@ async function onKeyProfiles (term, st, key) {
566
719
  } else if (ch === 'k' && cur) { // locK (antes `l`, que ahora es el idioma)
567
720
  if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
568
721
  const r = await guard(term, st, i.lockingVault, () => vc.lockProfile(cur.id))
569
- if (r.ok) { flash(st, i.vaultLocked); await refreshProfiles(term, st) }
722
+ // Echar el candado a mano es DECIR que vuelva a hacer falta la contraseña: si la TUI
723
+ // se quedara con ella, la siguiente tecla la reabriría sola y el candado no cerraría
724
+ // nada.
725
+ if (r.ok) { st.sessionPwd?.delete(cur.id); st.unlockedHere?.delete(cur.id); flash(st, i.vaultLocked); await refreshProfiles(term, st) }
570
726
  }
571
727
  return true
572
728
  }
@@ -605,7 +761,7 @@ async function onKeyDevices (term, st, key) {
605
761
  st.confirm = null
606
762
  // Por `sub`: se le retiran TODOS los certificados, no solo el de esta fila.
607
763
  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 }
764
+ if (r.ok) { flash(st, i.deviceRevoked(cur.deviceId)); applyDump(st, r.v); st.sel.devices = 0 }
609
765
  },
610
766
  onNo: () => { st.confirm = null }
611
767
  })
@@ -617,11 +773,11 @@ async function onKeyDevices (term, st, key) {
617
773
  label: i.renameDeviceLabel(cur.deviceId),
618
774
  hint: i.renameDeviceHint,
619
775
  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)) }
776
+ onSubmit: async (raw) => {
777
+ const name = String(raw || '').trim()
778
+ if (!name) return
779
+ const r = await guard(term, st, i.renaming, () => vc.setDeviceLabel(cur.sub, name, activeId(st)))
780
+ if (r.ok) { applyDump(st, r.v); flash(st, i.deviceRenamed(name)) }
625
781
  }
626
782
  })
627
783
  } else if (ch === 'c' && cur?.sub) {
@@ -629,16 +785,31 @@ async function onKeyDevices (term, st, key) {
629
785
  st.sel.caps = 0
630
786
  await refreshMembers(term, st)
631
787
  st.screen = 'caps'
788
+ } else if (ch === 'e' && cur?.sub) {
789
+ // Variables de ESTE aparato. Solo un servicio las lee (es el único que pide su
790
+ // bundle), así que a un teléfono se le dice que no y por qué, en vez de dejarle
791
+ // guardar configuración que no va a leer nadie.
792
+ if (!cur.cn) { flash(st, i.devVarsOnlyServices, 'warn'); return true }
793
+ st.varsFor = { pub: cur.sub, deviceId: cur.deviceId, label: cur.label || '', cn: cur.cn }
794
+ st.sel.devvars = 0
795
+ await refreshSecrets(term, st)
796
+ st.screen = 'devvars'
632
797
  } else if (key.name === 'f5') {
633
798
  await refreshDevices(term, st)
634
799
  }
635
800
  return true
636
801
  }
637
802
 
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' }
803
+ /**
804
+ * Abre el emparejamiento contra `profile` y salta a la pantalla del QR. Con `service`,
805
+ * el QR es el de un SERVICIO de ese namespace (cert limitado a sus variables).
806
+ */
807
+ async function beginPairing (term, st, profile, service = null) {
808
+ const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile, ...(service ? { service } : {}) }))
809
+ // `service` se pega al estado porque el daemon no lo devuelve: la pantalla del QR
810
+ // tiene que poder decir qué se está entregando, que no es lo mismo un aparato tuyo
811
+ // que una máquina que solo va a leer la configuración del proxy.
812
+ if (r.ok) { st.pairing = { ...r.v, ...(service ? { service } : {}) }; st.pending = null; st.scroll.pairing = { value: 0 }; st.screen = 'pairing' }
642
813
  return r.ok
643
814
  }
644
815
 
@@ -659,27 +830,27 @@ async function onKeyCaps (term, st, key) {
659
830
  if (key.name === 'f5') { await refreshMembers(term, st); return true }
660
831
  if ((key.name !== 'enter' && ch !== ' ') || !cur) return true
661
832
 
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)
833
+ const member = (st.members || []).find((m) => m.pub === st.capsFor?.pub)
834
+ if (!member) return true
835
+ const caps = new Set(member.caps || [])
836
+ const giving = !caps.has(cur.cap)
837
+ if (giving) caps.add(cur.cap); else caps.delete(cur.cap)
667
838
 
668
- const aplicar = async () => {
669
- const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(miembro.pub, [...caps], activeId(st)))
839
+ const apply = async () => {
840
+ const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(member.pub, [...caps], activeId(st)))
670
841
  if (!r.ok) return
671
- st.devices = r.v
842
+ applyDump(st, r.v)
672
843
  await refreshMembers(term, st)
673
- flash(st, dando ? i.capGiven(i.capName[cur.cap]) : i.capTaken(i.capName[cur.cap]))
844
+ flash(st, giving ? i.capGiven(i.capName[cur.cap]) : i.capTaken(i.capName[cur.cap]))
674
845
  }
675
846
 
676
847
  // Administrar se PREGUNTA: es el permiso que deja a ese aparato admitir y expulsar
677
848
  // 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 })
849
+ if (cur.cap === 'admin' && giving) {
850
+ setConfirm(st, { text: i.confirmAdmin(st.capsFor.deviceId), onYes: apply })
680
851
  return true
681
852
  }
682
- await aplicar()
853
+ await apply()
683
854
  return true
684
855
  }
685
856
 
@@ -696,24 +867,43 @@ async function onKeyPairMode (term, st, key) {
696
867
 
697
868
  if (cur.mode === 'here') { await beginPairing(term, st, activeId(st)); return true }
698
869
 
870
+ // SERVICIO: entra a la cuenta activa, pero con un certificado que solo sirve para
871
+ // pedir las variables de SU namespace. El nombre del ns es el que luego pide el
872
+ // servicio al arrancar, así que se valida aquí con la misma regla que la CLI: un
873
+ // ns con mayúsculas o espacios se enrola igual y falla el día del despliegue.
874
+ if (cur.mode === 'service') {
875
+ setInput(st, {
876
+ label: i.serviceNsLabel,
877
+ hint: i.serviceNsHint,
878
+ onSubmit: async (raw) => {
879
+ st.input = null
880
+ const ns = String(raw || '').trim().toLowerCase()
881
+ if (!/^[a-z0-9-]{1,32}$/.test(ns)) { flash(st, i.serviceNsBad, 'danger'); return }
882
+ await beginPairing(term, st, activeId(st), ns)
883
+ },
884
+ onCancel: () => { st.input = null }
885
+ })
886
+ return true
887
+ }
888
+
699
889
  // Cuenta nueva: se crea aquí, se ACTIVA (así aprobar/rechazar y las listas miran
700
890
  // a la misma que el QR) y recién entonces se abre el emparejamiento contra ella.
701
891
  setInput(st, {
702
892
  label: i.newAccountLabel,
703
893
  hint: i.newAccountHint,
704
- onSubmit: async (name) => {
894
+ onSubmit: async (raw) => {
705
895
  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))
896
+ const name = raw.trim()
897
+ if (!name) { flash(st, i.nameEmpty, 'danger'); return }
898
+ const r = await guard(term, st, i.creatingVault, () => vc.addProfile(name))
709
899
  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))
900
+ const created = r.v?.id || (r.v?.profiles || []).find((p) => p.name === name)?.id
901
+ if (!created) { flash(st, i.errNoReply, 'danger'); return }
902
+ const u = await guard(term, st, i.switchingVault, () => vc.useProfile(created))
713
903
  if (!u.ok) return
714
904
  await refreshAll(term, st)
715
- flash(st, i.accountCreated(nombre))
716
- await beginPairing(term, st, nuevo)
905
+ flash(st, i.accountCreated(name))
906
+ await beginPairing(term, st, created)
717
907
  },
718
908
  onCancel: () => { st.input = null }
719
909
  })
@@ -729,7 +919,15 @@ function promptApprove (term, st) {
729
919
  st.input = null
730
920
  if (!code.trim()) { flash(st, i.codeMissing, 'danger'); return }
731
921
  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' }
922
+ if (r.ok) {
923
+ flash(st, i.deviceApproved)
924
+ st.pending = null
925
+ st.screen = 'devices'
926
+ // Sin lista (el volcado se perdió): se pide otra vez. El aparato ya está dentro;
927
+ // lo único que falta es la foto, y esa se vuelve a pedir sin drama.
928
+ if (r.v) applyDump(st, r.v)
929
+ else await refreshDevices(term, st)
930
+ }
733
931
  },
734
932
  onCancel: () => { st.input = null }
735
933
  })
@@ -792,6 +990,8 @@ async function onKeySecrets (term, st, key) {
792
990
 
793
991
  if (ch === 'n') {
794
992
  promptNewVariable(term, st)
993
+ } else if (ch === 'i') {
994
+ promptLoadScopeVars(term, st)
795
995
  } else if ((ch === 'x' || key.name === 'delete') && cur) {
796
996
  if (cur.key) {
797
997
  setConfirm(st, {
@@ -804,7 +1004,7 @@ async function onKeySecrets (term, st, key) {
804
1004
  onNo: () => { st.confirm = null }
805
1005
  })
806
1006
  } else {
807
- const count = (st.secrets?.[cur.ns] || []).length
1007
+ const count = (st.secrets?.ns?.[cur.ns] || []).length
808
1008
  setConfirm(st, {
809
1009
  text: i.removeScopeConfirm(cur.ns, count),
810
1010
  onYes: async () => {
@@ -815,38 +1015,198 @@ async function onKeySecrets (term, st, key) {
815
1015
  onNo: () => { st.confirm = null }
816
1016
  })
817
1017
  }
1018
+ } else if (ch === 't' && cur?.key) {
1019
+ await toggleVisibility(term, st, cur.public, () => vc.setSecretVisibility(cur.ns, cur.key, !cur.public, activeId(st)))
818
1020
  } else if (key.name === 'f5') {
819
1021
  await refreshSecrets(term, st)
820
1022
  }
821
1023
  return true
822
1024
  }
823
1025
 
1026
+ /**
1027
+ * Hacer pública una variable es dejar que su valor SALGA de esta máquina, así que se
1028
+ * pregunta; volverla privada no expone nada y se aplica directo.
1029
+ */
1030
+ async function toggleVisibility (term, st, wasPublic, apply) {
1031
+ const i = L(st)
1032
+ const run = async () => {
1033
+ const r = await guard(term, st, i.changingVisibility, apply)
1034
+ if (r.ok) { flash(st, wasPublic ? i.nowPrivate : i.nowPublic); st.secrets = r.v }
1035
+ }
1036
+ if (wasPublic) return run()
1037
+ setConfirm(st, { text: i.makePublicConfirm, onYes: run, onNo: () => { st.confirm = null } })
1038
+ }
1039
+
1040
+ /**
1041
+ * Teclas de las variables de UN aparato: agregar y quitar. Nada más — el aparato ya se
1042
+ * eligió en Dispositivos, y de ahí se vuelve con Esc.
1043
+ */
1044
+ async function onKeyDevVars (term, st, key) {
1045
+ const i = L(st)
1046
+ const rows = devVarRows(st, term.t)
1047
+ const sels = rows.filter((r) => r.sel).map((r) => r.meta)
1048
+ moveSel(st, key, 'devvars', sels.length)
1049
+ const cur = sels[Math.min(st.sel.devvars || 0, sels.length - 1)]
1050
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
1051
+ const target = st.varsFor
1052
+
1053
+ if (key.name === 'escape' || ch === 'b') {
1054
+ st.screen = 'devices'; st.varsFor = null
1055
+ await refreshDevices(term, st)
1056
+ return true
1057
+ }
1058
+ if (key.name === 'f5') { await refreshSecrets(term, st); return true }
1059
+ if (ch === 'n') { promptNewDeviceVariable(term, st); return true }
1060
+ if (ch === 'i' && target) { promptLoadVars(term, st, { pub: target.pub, where: target.deviceId }); return true }
1061
+ if (ch === 't' && cur && target) {
1062
+ await toggleVisibility(term, st, cur.public, () => vc.setDeviceSecretVisibility(target.pub, cur.key, !cur.public, activeId(st)))
1063
+ return true
1064
+ }
1065
+ if ((ch === 'x' || key.name === 'delete') && cur && target) {
1066
+ setConfirm(st, {
1067
+ text: i.removeDevVarConfirm(target.deviceId, cur.key),
1068
+ onYes: async () => {
1069
+ st.confirm = null
1070
+ const r = await guard(term, st, i.removingVar, () => vc.deleteDeviceSecret(target.pub, cur.key, activeId(st)))
1071
+ if (r.ok) { flash(st, i.varRemoved); st.secrets = r.v; st.sel.devvars = Math.max(0, st.sel.devvars - 1) }
1072
+ },
1073
+ onNo: () => { st.confirm = null }
1074
+ })
1075
+ }
1076
+ return true
1077
+ }
1078
+
1079
+ /**
1080
+ * Al crear una variable se PREGUNTA si su valor puede salir de esta máquina. Se pregunta
1081
+ * al crearla, y no después, porque es cuando quien la escribe sabe qué es: un puerto se
1082
+ * puede enseñar, una llave de producción no. La respuesta por defecto —Enter, o `n`— es
1083
+ * la privada.
1084
+ */
1085
+ function askVisibility (term, st, done) {
1086
+ const i = L(st)
1087
+ setConfirm(st, {
1088
+ text: i.newVarPublicAsk,
1089
+ onYes: () => { st.confirm = null; done(true) },
1090
+ onNo: () => { st.confirm = null; done(false) }
1091
+ })
1092
+ }
1093
+
1094
+ function promptNewDeviceVariable (term, st) {
1095
+ const i = L(st)
1096
+ const target = st.varsFor
1097
+ if (!target) return
1098
+ setInput(st, {
1099
+ label: i.keyLabel(target.deviceId),
1100
+ hint: i.keyHint,
1101
+ onSubmit: (key) => {
1102
+ const kv = key.trim()
1103
+ if (!KEY_RE.test(kv)) { flash(st, i.keyInvalid, 'danger'); return }
1104
+ st.input = null
1105
+ setInput(st, {
1106
+ label: i.valueLabel(target.deviceId, kv),
1107
+ mask: true,
1108
+ hint: i.valueHint,
1109
+ onSubmit: async (value) => {
1110
+ st.input = null
1111
+ if (!value) { flash(st, i.valueEmpty, 'danger'); return }
1112
+ askVisibility(term, st, async (isPublic) => {
1113
+ const r = await guard(term, st, i.savingVar, () => vc.setDeviceSecret(target.pub, kv, value, activeId(st), isPublic))
1114
+ if (r.ok) { flash(st, i.varSaved(target.deviceId, kv)); st.secrets = r.v }
1115
+ })
1116
+ },
1117
+ onCancel: () => { st.input = null }
1118
+ })
1119
+ },
1120
+ onCancel: () => { st.input = null }
1121
+ })
1122
+ }
1123
+
1124
+ /**
1125
+ * CARGAR VARIAS DE UNA VEZ (tecla `i`, de *import*, la misma palabra que en el CLI).
1126
+ *
1127
+ * Guardar las variables de un servicio una por una es, para la bóveda, un cambio de
1128
+ * configuración por variable: el servicio obedece el primero —sale y lo levanta su
1129
+ * supervisor— y arranca con media configuración mientras se teclea el resto. Cargarlas
1130
+ * juntas hace que se reinicie UNA vez, con todo puesto.
1131
+ *
1132
+ * Se acepta lo que se pueda escribir en una línea (`CLAVE=valor CLAVE2=valor2`) o la
1133
+ * RUTA de un `.env`, que es como suele llegar la configuración de un servicio.
1134
+ */
1135
+ function promptLoadVars (term, st, { ns = null, pub = null, where }) {
1136
+ const i = L(st)
1137
+ setInput(st, {
1138
+ label: i.loadLabel(where),
1139
+ hint: i.loadHint,
1140
+ onSubmit: async (raw) => {
1141
+ const text = raw.trim()
1142
+ st.input = null
1143
+ if (!text) return
1144
+ let content = text
1145
+ // Sin un `=` no es una lista de variables: es la ruta de un archivo.
1146
+ if (!text.includes('=')) {
1147
+ try { content = fs.readFileSync(text, 'utf8') } catch (_) { flash(st, i.loadNoFile(text), 'danger'); return }
1148
+ }
1149
+ const { items, errors } = parseEnvInput(content)
1150
+ // Un archivo con un problema no se carga a medias: se dice qué línea y no se
1151
+ // escribe nada. Media configuración aplicada es peor que ninguna.
1152
+ if (errors.length) { flash(st, i.loadNothing + ' ' + i.envErr[errors[0].code](errors[0]), 'danger'); return }
1153
+ askVisibility(term, st, async (isPublic) => {
1154
+ const withVisibility = items.map((it) => ({ ...it, public: isPublic }))
1155
+ const r = await guard(term, st, i.loadingVars, () => (pub
1156
+ ? vc.applyDeviceSecrets(pub, withVisibility, activeId(st))
1157
+ : vc.applySecrets(ns, withVisibility, activeId(st))))
1158
+ if (r.ok) { flash(st, i.loadedVars(items.length, where)); st.secrets = r.v }
1159
+ })
1160
+ },
1161
+ onCancel: () => { st.input = null }
1162
+ })
1163
+ }
1164
+
1165
+ /** Cargar varias en un SCOPE: primero cuál, luego el bloque. */
1166
+ function promptLoadScopeVars (term, st) {
1167
+ const i = L(st)
1168
+ const existing = Object.keys(st.secrets?.ns || {})
1169
+ setInput(st, {
1170
+ label: i.nsLabel,
1171
+ hint: existing.length ? i.nsHintExisting(existing.join(', ')) : i.nsHint,
1172
+ onSubmit: (ns) => {
1173
+ const nsName = ns.trim()
1174
+ if (!NS_RE.test(nsName)) { flash(st, i.nsInvalid, 'danger'); return }
1175
+ st.input = null
1176
+ promptLoadVars(term, st, { ns: nsName, where: nsName })
1177
+ },
1178
+ onCancel: () => { st.input = null }
1179
+ })
1180
+ }
1181
+
824
1182
  function promptNewVariable (term, st) {
825
1183
  const i = L(st)
826
- const existing = Object.keys(st.secrets || {})
1184
+ const existing = Object.keys(st.secrets?.ns || {})
827
1185
  setInput(st, {
828
1186
  label: i.nsLabel,
829
1187
  hint: existing.length ? i.nsHintExisting(existing.join(', ')) : i.nsHint,
830
1188
  onSubmit: (ns) => {
831
- const nsv = ns.trim()
832
- if (!NS_RE.test(nsv)) { flash(st, i.nsInvalid, 'danger'); promptNewVariable(term, st); return }
1189
+ const nsName = ns.trim()
1190
+ if (!NS_RE.test(nsName)) { flash(st, i.nsInvalid, 'danger'); promptNewVariable(term, st); return }
833
1191
  st.input = null
834
1192
  setInput(st, {
835
- label: i.keyLabel(nsv),
1193
+ label: i.keyLabel(nsName),
836
1194
  hint: i.keyHint,
837
1195
  onSubmit: (key) => {
838
1196
  const kv = key.trim()
839
1197
  if (!KEY_RE.test(kv)) { flash(st, i.keyInvalid, 'danger'); return }
840
1198
  st.input = null
841
1199
  setInput(st, {
842
- label: i.valueLabel(nsv, kv),
1200
+ label: i.valueLabel(nsName, kv),
843
1201
  mask: true,
844
1202
  hint: i.valueHint,
845
1203
  onSubmit: async (value) => {
846
1204
  st.input = null
847
1205
  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 }
1206
+ askVisibility(term, st, async (isPublic) => {
1207
+ const r = await guard(term, st, i.savingVar, () => vc.setSecret(nsName, kv, value, activeId(st), isPublic))
1208
+ if (r.ok) { flash(st, i.varSaved(nsName, kv)); st.secrets = r.v }
1209
+ })
850
1210
  },
851
1211
  onCancel: () => { st.input = null }
852
1212
  })
@@ -900,13 +1260,20 @@ const helpSegs = (i, screen, st = {}) => {
900
1260
  pairing: i.helpPairing,
901
1261
  pairmode: i.helpPairMode,
902
1262
  me: i.helpMe,
903
- caps: i.helpCaps
1263
+ caps: i.helpCaps,
1264
+ devvars: i.helpDevVars
904
1265
  }[screen] || []
905
1266
  if (typeof segs !== 'function') return segs
1267
+ // El aparato señalado ahora mismo: `e variables` solo tiene sentido en un servicio (es
1268
+ // el único que las lee), y las filas seleccionables de la lista son justo los aparatos.
1269
+ const devs = mergeMembersAndCerts(st.members, st.devices?.issued || [])
1270
+ const cur = devs[Math.min(st.sel?.devices || 0, devs.length - 1)]
906
1271
  return segs({
907
- pendiente: !!st.pending,
908
- hayAparatos: (st.devices?.issued || []).length > 0,
909
- haySecretos: Object.keys(st.secrets || {}).length > 0
1272
+ pending: !!st.pending,
1273
+ hasDevices: (st.devices?.issued || []).length > 0,
1274
+ isService: !!cur?.cn,
1275
+ hasSecrets: Object.keys(st.secrets?.ns || {}).length > 0,
1276
+ hasVars: devVarsOf(st, st.varsFor?.pub).length > 0
910
1277
  })
911
1278
  }
912
1279
 
@@ -914,7 +1281,8 @@ const title = (i, screen) => ({
914
1281
  profiles: i.titleProfiles,
915
1282
  pairing: i.titlePairing,
916
1283
  pairmode: i.titlePairMode,
917
- caps: i.titleCaps
1284
+ caps: i.titleCaps,
1285
+ devvars: i.titleDevVars
918
1286
  })[screen] || ''
919
1287
 
920
1288
  /** Barra de pestañas horizontal (Dispositivos | Scopes y variables) de la bóveda entrada. */
@@ -937,6 +1305,9 @@ function pairingBody (st, t, cols, height) {
937
1305
  const acct = info.profileName || ap?.name || info.profile || ap?.id || '—'
938
1306
  const left = Math.max(0, Math.round((info.expiresAt - Date.now()) / 60000))
939
1307
  lines.push(t.bold(i.pairAccount(acct, left)))
1308
+ // Y si lo que se entrega es un SERVICIO, se dice: el papel que sale de este QR no
1309
+ // firma ni ve el contenido, solo lee las variables de ese namespace.
1310
+ if (info.service) lines.push(t.warn(i.pairService(info.service)))
940
1311
  // QR: se dibuja siempre que quepa de ancho; si es más alto que la pantalla se
941
1312
  // puede hacer scroll hacia arriba/abajo para verlo completo.
942
1313
  let qr = ''
@@ -977,9 +1348,9 @@ function render (term, st) {
977
1348
 
978
1349
  const s = st.state
979
1350
  const up = st.daemonUp
980
- const ver = s?.version || 'dev'
1351
+ const version = s?.version || 'dev'
981
1352
  const daemonTxt = up ? i.daemonRunning : i.daemonStopped
982
- lines[0] = t.bar(`dotrino-vault ${ver} daemon: ${daemonTxt} ${vc.vaultDir()}`, cols)
1353
+ lines[0] = t.bar(`dotrino-vault ${version} daemon: ${daemonTxt} ${vc.vaultDir()}`, cols)
983
1354
 
984
1355
  const ap = activeProfile(st)
985
1356
  const apTxt = ap ? `${t.accent('●')} ${t.bold(ap.name || i.noName)} ${lockGlyph(ap)} ${t.muted('· ' + (ap.fingerprint || '—'))}` : t.muted('—')
@@ -1001,6 +1372,7 @@ function render (term, st) {
1001
1372
  else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
1002
1373
  else if (st.screen === 'me') body = renderList(meRows(st, t), -1, contentH, cols, t, scrollRef)
1003
1374
  else if (st.screen === 'caps') body = renderList(capsRows(st, t), st.sel.caps || 0, contentH, cols, t, scrollRef)
1375
+ else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
1004
1376
  else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
1005
1377
  else if (st.screen === 'pairing') {
1006
1378
  const pb = pairingBody(st, t, cols, contentH)
@@ -1086,7 +1458,13 @@ export async function runTui () {
1086
1458
  const st = {
1087
1459
  screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
1088
1460
  lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
1089
- sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0 },
1461
+ sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, devvars: 0 },
1462
+ // Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
1463
+ unlockedHere: new Set(),
1464
+ // Su contraseña, SOLO en memoria y SOLO mientras la TUI esté abierta: sirve para
1465
+ // reabrir sin volver a preguntar si el daemon pierde el estado (ver
1466
+ // `reunlockSilently`). Se olvida con `k`, al quitar la contraseña y al salir.
1467
+ sessionPwd: new Map(),
1090
1468
  scroll: {},
1091
1469
  profiles: null,
1092
1470
  devices: null,
@@ -1155,13 +1533,21 @@ export async function runTui () {
1155
1533
  else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
1156
1534
  else if (st.screen === 'me') running = await onKeyMe(term, st, key)
1157
1535
  else if (st.screen === 'caps') running = await onKeyCaps(term, st, key)
1536
+ else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
1158
1537
  else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
1159
1538
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
1160
1539
  }
1161
1540
  } finally {
1541
+ // AL SALIR SE VUELVE A CERRAR lo que se abrió aquí. Sin esto, teclear la contraseña una
1542
+ // vez dejaba la bóveda abierta para todo el que pasara por esta máquina hasta el
1543
+ // siguiente reinicio del servicio — un candado que solo se cierra reiniciando no es un
1544
+ // candado. (Si la TUI muere de un tirón —kill, ventana cerrada— no hay quien lo haga:
1545
+ // ahí el cierre lo pone el reinicio, como antes.)
1546
+ st.sessionPwd.clear()
1547
+ for (const id of st.unlockedHere) { try { await vc.lockProfile(id) } catch (_) {} }
1162
1548
  term.close()
1163
1549
  }
1164
1550
  }
1165
1551
 
1166
1552
  // 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 }
1553
+ export const __test = { render, activeLocked, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts }