@dotrino/vaultd 0.79.0 → 0.80.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.79.0",
3
+ "version": "0.80.1",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "dependencies": {
23
- "@dotrino/identity": "0.75.0",
23
+ "@dotrino/identity": "0.76.0",
24
24
  "@dotrino/passmanager": "^0.9.0",
25
25
  "@dotrino/proxy-client": "^0.13.1",
26
26
  "ws": "^8.18.0"
package/src/ctl.js CHANGED
@@ -546,7 +546,11 @@ async function cmdMembers () {
546
546
  const CAP = {
547
547
  sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves',
548
548
  admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}`,
549
- sealer: `${B}sella el acta${Z}`, passwords: `${B}pide contraseñas${Z}`
549
+ sealer: `${B}sella el acta${Z}`, passwords: `${B}pide contraseñas${Z}`,
550
+ // Resaltado como los otros cuatro: dice si esa máquina se lleva tus claves privadas
551
+ // sola o si te lo pregunta. Sin traducción salía el identificador en crudo
552
+ // (`unattended`) en una lista que lee un humano.
553
+ unattended: `${B}recibe claves sin aprobación${Z}`
550
554
  }
551
555
  // El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
552
556
  // pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
package/src/tui/app.js CHANGED
@@ -340,6 +340,19 @@ function pairModeRows (st, t) {
340
340
  */
341
341
  const CAPS_ORDER = ['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended']
342
342
 
343
+ /**
344
+ * Los permisos que se le pueden dar A ESTE miembro, en orden.
345
+ *
346
+ * `secrets` solo existe para un SERVICIO (los que tienen `cn`), y por eso faltaba: la
347
+ * lista era fija y no cabía algo que depende de quién sea. Pero para un servicio es EL
348
+ * permiso —el que decide si abre su cajón— y no verlo aquí hacía creer que no existe,
349
+ * igual que pasaba con `unattended`. Se enseña primero, que es su importancia real.
350
+ *
351
+ * A un aparato SIN `cn` no se le ofrece: no hay cajón que abrir, el acta lo filtra
352
+ * (`allowedCaps`) y enseñar una casilla que no hace nada es peor que no enseñarla.
353
+ */
354
+ const capsForMember = (member) => (member?.cn ? ['secrets', ...CAPS_ORDER] : CAPS_ORDER)
355
+
343
356
  function capsRows (st, t) {
344
357
  const i = L(st)
345
358
  const target = st.capsFor
@@ -347,20 +360,34 @@ function capsRows (st, t) {
347
360
  const member = (st.members || []).find((m) => m.pub === target.pub)
348
361
  if (!member) return [{ text: t.muted(i.capsNoMember), sel: false }]
349
362
 
350
- const has = new Set(member.caps || [])
363
+ // EL BORRADOR manda sobre lo que hay en el acta: es lo que estás a punto de dejar.
364
+ const real = new Set(member.caps || [])
365
+ const has = st.capsDraft?.pub === member.pub ? new Set(st.capsDraft.caps) : real
366
+
351
367
  const rows = [
352
368
  { text: ' ' + t.bold(i.capsFor(target.deviceId, member.label || '')), sel: false },
353
369
  { text: '', sel: false }
354
370
  ]
355
- for (const cap of CAPS_ORDER) {
371
+ let tocados = 0
372
+ for (const cap of capsForMember(member)) {
356
373
  const mark = has.has(cap) ? '[x]' : '[ ]'
357
- const name = i.capName[cap]
358
- const line = ` ${mark} ${cap === 'admin' ? t.bold(name) : name}`
374
+ // El nombre de `secrets` lleva el cajón dentro: «las claves de proxy» dice qué abre,
375
+ // «lee sus claves» te deja preguntándote cuáles.
376
+ const name = typeof i.capName[cap] === 'function' ? i.capName[cap](member.cn) : i.capName[cap]
377
+ // LO QUE CAMBIA SE SEÑALA. Sin esto el borrador es indistinguible del acta y no sabes
378
+ // qué vas a firmar — que es justo lo que hace peligroso acumular cambios.
379
+ const cambia = has.has(cap) !== real.has(cap)
380
+ if (cambia) tocados++
381
+ const line = ` ${mark}${cambia ? t.bold('*') : ' '} ${cap === 'admin' ? t.bold(name) : name}`
359
382
  rows.push({ text: line, sel: true, meta: { cap } })
360
- rows.push({ text: t.muted(' ' + i.capHint[cap]), sel: false })
383
+ const hint = typeof i.capHint[cap] === 'function' ? i.capHint[cap](member.cn) : i.capHint[cap]
384
+ rows.push({ text: t.muted(' ' + hint), sel: false })
361
385
  }
362
386
  rows.push({ text: '', sel: false })
363
- rows.push({ text: t.muted(' ' + i.capsApplyHint), sel: false })
387
+ rows.push({
388
+ text: tocados ? ' ' + t.bold(i.capsPending(tocados)) : t.muted(' ' + i.capsApplyHint),
389
+ sel: false
390
+ })
364
391
  return rows
365
392
  }
366
393
 
@@ -929,6 +956,8 @@ async function onKeyDevices (term, st, key) {
929
956
  })
930
957
  } else if (ch === 'c' && cur?.sub) {
931
958
  st.capsFor = { pub: cur.sub, deviceId: cur.deviceId }
959
+ // Borrador limpio al entrar: arrastrar el de otro aparato sería firmar lo que no viste.
960
+ st.capsDraft = null
932
961
  st.sel.caps = 0
933
962
  await refreshMembers(term, st)
934
963
  st.screen = 'caps'
@@ -1003,9 +1032,19 @@ async function beginPairing (term, st, profile, service = null, label = null) {
1003
1032
  }
1004
1033
 
1005
1034
  /**
1006
- * Enter marca o desmarca un permiso y lo aplica en el acto. Sin botón de «guardar»: cada
1007
- * cambio se sella en el acta y se avisa a los demás aparatos, así que acumularlos en
1008
- * pantalla solo serviría para que el acta y lo que ves dijeran cosas distintas.
1035
+ * UN BORRADOR QUE SE GUARDA O SE DESCARTA (dueño, 2026-09-01).
1036
+ *
1037
+ * Antes cada Enter sellaba un acta. Cambiar cuatro permisos eran cuatro actas, cuatro
1038
+ * avisos a todos los aparatos y cuatro renovaciones de certificado — absurdo, y encima
1039
+ * dejaba la cuenta pasando por estados intermedios que nadie quiso (un aparato con
1040
+ * `admin` pero todavía sin `read`, por ejemplo).
1041
+ *
1042
+ * Ahora Enter mueve un borrador LOCAL —nada firmado, nada avisado— y `G` lo aplica de
1043
+ * golpe. `applyChanges` ya tomaba una LISTA de cambios y producía UNA acta: el borrador es
1044
+ * esa lista, sin firmar, hasta que confirmas.
1045
+ *
1046
+ * Lo que se cambia va marcado con `*` y contado abajo: un borrador que no se distingue del
1047
+ * acta es peor que no tenerlo, porque firmas sin saber qué.
1009
1048
  */
1010
1049
  async function onKeyCaps (term, st, key) {
1011
1050
  const i = L(st)
@@ -1015,31 +1054,51 @@ async function onKeyCaps (term, st, key) {
1015
1054
  const cur = sels[Math.min(st.sel.caps || 0, sels.length - 1)]
1016
1055
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
1017
1056
 
1018
- if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; st.capsFor = null; return true }
1019
- if (key.name === 'f5') { await refreshMembers(term, st); return true }
1020
- if ((key.name !== 'enter' && ch !== ' ') || !cur) return true
1021
-
1022
1057
  const member = (st.members || []).find((m) => m.pub === st.capsFor?.pub)
1023
- if (!member) return true
1024
- const caps = new Set(member.caps || [])
1025
- const giving = !caps.has(cur.cap)
1026
- if (giving) caps.add(cur.cap); else caps.delete(cur.cap)
1027
-
1028
- const apply = async () => {
1029
- const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(member.pub, [...caps], activeId(st)))
1030
- if (!r.ok) return
1031
- applyDump(st, r.v)
1032
- await refreshMembers(term, st)
1033
- flash(st, giving ? i.capGiven(i.capName[cur.cap]) : i.capTaken(i.capName[cur.cap]))
1058
+ if (!member) {
1059
+ if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; st.capsFor = null }
1060
+ return true
1034
1061
  }
1062
+ const real = new Set(member.caps || [])
1063
+ if (st.capsDraft?.pub !== member.pub) st.capsDraft = { pub: member.pub, caps: [...real] }
1064
+ const draft = new Set(st.capsDraft.caps)
1065
+ const sucio = [...new Set([...draft, ...real])].some((c) => draft.has(c) !== real.has(c))
1035
1066
 
1036
- // Administrar se PREGUNTA: es el permiso que deja a ese aparato admitir y expulsar
1037
- // dispositivos sin pasar por aquí. Los otros tres se marcan y ya.
1038
- if (cur.cap === 'admin' && giving) {
1039
- setConfirm(st, { text: i.confirmAdmin(st.capsFor.deviceId), onYes: apply })
1067
+ const salir = () => { st.screen = 'devices'; st.capsFor = null; st.capsDraft = null }
1068
+ if (key.name === 'escape' || ch === 'b') {
1069
+ // SALIR CON CAMBIOS SIN GUARDAR SE PREGUNTA. Tirarlos callando es perder trabajo sin
1070
+ // decirlo, y es justo lo que un borrador no debe hacer.
1071
+ if (sucio) setConfirm(st, { text: i.capsDiscard, onYes: async () => { salir() } })
1072
+ else salir()
1040
1073
  return true
1041
1074
  }
1042
- await apply()
1075
+ if (key.name === 'f5') { st.capsDraft = null; await refreshMembers(term, st); return true }
1076
+
1077
+ // GUARDAR: UNA sola acta con todo lo que hayas tocado.
1078
+ if (ch === 'g') {
1079
+ if (!sucio) { flash(st, i.capsNothing); return true }
1080
+ const cuantos = [...new Set([...draft, ...real])].filter((c) => draft.has(c) !== real.has(c)).length
1081
+ const guardar = async () => {
1082
+ const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(member.pub, [...draft], activeId(st)))
1083
+ if (!r.ok) return
1084
+ applyDump(st, r.v)
1085
+ st.capsDraft = null
1086
+ await refreshMembers(term, st)
1087
+ flash(st, i.capsSaved(cuantos))
1088
+ }
1089
+ // Administrar se PREGUNTA, y se pregunta AL GUARDAR: es cuando pasa a ser verdad.
1090
+ if (draft.has('admin') && !real.has('admin')) {
1091
+ setConfirm(st, { text: i.confirmAdmin(st.capsFor.deviceId), onYes: guardar })
1092
+ return true
1093
+ }
1094
+ await guardar()
1095
+ return true
1096
+ }
1097
+
1098
+ if ((key.name !== 'enter' && ch !== ' ') || !cur) return true
1099
+ // Enter solo mueve el BORRADOR: no firma, no avisa, no se puede equivocar caro.
1100
+ if (draft.has(cur.cap)) draft.delete(cur.cap); else draft.add(cur.cap)
1101
+ st.capsDraft = { pub: member.pub, caps: [...draft] }
1043
1102
  return true
1044
1103
  }
1045
1104
 
@@ -1819,4 +1878,4 @@ export async function runTui () {
1819
1878
  }
1820
1879
 
1821
1880
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
1822
- export const __test = { render, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
1881
+ export const __test = { render, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
package/src/tui/i18n.js CHANGED
@@ -40,8 +40,15 @@ const es = {
40
40
  titleCaps: 'Permisos del dispositivo',
41
41
  capsFor: (id, name) => `Permisos de ${id}${name ? ' · ' + name : ''}`,
42
42
  capsNoMember: ' (este dispositivo ya no está en el acta)',
43
- capsApplyHint: 'Enter marca o desmarca. Cada cambio se aplica y se avisa a tus otros aparatos.',
43
+ capsApplyHint: 'Enter marca o desmarca · G guarda los cambios · Esc sale',
44
+ capsPending: (n) => `${n} cambio(s) sin guardar (*) · G para guardarlos en UNA sola acta · Esc para descartarlos`,
45
+ capsSaved: (n) => `${n} cambio(s) guardados en una sola acta`,
46
+ capsNothing: 'No has cambiado nada',
47
+ capsDiscard: '¿Descartar los cambios sin guardar?',
44
48
  capName: {
49
+ // Lleva el cajón dentro: «las claves de proxy» dice QUÉ abre; «lee sus claves» te deja
50
+ // preguntándote cuáles. Solo se le ofrece a un servicio (los que tienen `cn`).
51
+ secrets: (cn) => `Leer las claves de «${cn}»`,
45
52
  sign: 'Firmar como tú',
46
53
  store: 'Guardar tus datos',
47
54
  read: 'Leer tus datos',
@@ -52,6 +59,7 @@ const es = {
52
59
  unattended: 'Recibir claves sin aprobación'
53
60
  },
54
61
  capHint: {
62
+ secrets: (cn) => `abrir SU cajón y ninguno más: las variables de «${cn}»`,
55
63
  sign: 'usar tu identidad en las apps del ecosistema',
56
64
  store: 'escribir en tu bóveda (perfil, contenido, datos sensibles)',
57
65
  read: 'ver lo que guardaste',
@@ -70,7 +78,7 @@ const es = {
70
78
  capTaken: (n) => `Quitado: ${n}`,
71
79
  applyingCaps: 'Aplicando…',
72
80
  loadingMembers: 'Cargando el acta…',
73
- helpCaps: ['↑↓', 'Enter marcar', 'F5 refrescar', 'Esc dispositivos', 'l English', 'q salir'],
81
+ helpCaps: ['↑↓', 'Enter marcar', 'G guardar', 'F5 refrescar', 'Esc dispositivos', 'l English', 'q salir'],
74
82
  // Perfil del usuario (lo que sincronizan los dispositivos). Solo lectura: se edita en
75
83
  // el aparato, no en la máquina donde vive la bóveda.
76
84
  loadingProfile: 'Cargando el perfil…',
@@ -352,8 +360,13 @@ const en = {
352
360
  titleCaps: 'Device permissions',
353
361
  capsFor: (id, name) => `Permissions for ${id}${name ? ' · ' + name : ''}`,
354
362
  capsNoMember: ' (this device is no longer in the record)',
355
- capsApplyHint: 'Enter ticks or unticks. Each change applies and your other devices are told.',
363
+ capsApplyHint: 'Enter ticks or unticks · G saves · Esc leaves',
364
+ capsPending: (n) => `${n} unsaved change(s) (*) · G to save them in ONE record · Esc to discard`,
365
+ capsSaved: (n) => `${n} change(s) saved in a single record`,
366
+ capsNothing: 'You have not changed anything',
367
+ capsDiscard: 'Discard the unsaved changes?',
356
368
  capName: {
369
+ secrets: (cn) => `Read the «${cn}» keys`,
357
370
  sign: 'Sign as you',
358
371
  store: 'Save your data',
359
372
  read: 'Read your data',
@@ -378,7 +391,7 @@ const en = {
378
391
  capTaken: (n) => `Removed: ${n}`,
379
392
  applyingCaps: 'Applying…',
380
393
  loadingMembers: 'Loading the record…',
381
- helpCaps: ['↑↓', 'Enter tick', 'F5 refresh', 'Esc devices', 'l Español', 'q quit'],
394
+ helpCaps: ['↑↓', 'Enter tick', 'G save', 'F5 refresh', 'Esc devices', 'l Español', 'q quit'],
382
395
  loadingProfile: 'Loading profile…',
383
396
  noProfile: ' (this vault has no profile yet)',
384
397
  noProfileHint: ' Edit your name or photo on a paired device and press F5.',