@dotrino/vaultd 0.79.0 → 0.80.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.79.0",
3
+ "version": "0.80.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -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
@@ -347,20 +347,31 @@ function capsRows (st, t) {
347
347
  const member = (st.members || []).find((m) => m.pub === target.pub)
348
348
  if (!member) return [{ text: t.muted(i.capsNoMember), sel: false }]
349
349
 
350
- const has = new Set(member.caps || [])
350
+ // EL BORRADOR manda sobre lo que hay en el acta: es lo que estás a punto de dejar.
351
+ const real = new Set(member.caps || [])
352
+ const has = st.capsDraft?.pub === member.pub ? new Set(st.capsDraft.caps) : real
353
+
351
354
  const rows = [
352
355
  { text: ' ' + t.bold(i.capsFor(target.deviceId, member.label || '')), sel: false },
353
356
  { text: '', sel: false }
354
357
  ]
358
+ let tocados = 0
355
359
  for (const cap of CAPS_ORDER) {
356
360
  const mark = has.has(cap) ? '[x]' : '[ ]'
357
361
  const name = i.capName[cap]
358
- const line = ` ${mark} ${cap === 'admin' ? t.bold(name) : name}`
362
+ // LO QUE CAMBIA SE SEÑALA. Sin esto el borrador es indistinguible del acta y no sabes
363
+ // qué vas a firmar — que es justo lo que hace peligroso acumular cambios.
364
+ const cambia = has.has(cap) !== real.has(cap)
365
+ if (cambia) tocados++
366
+ const line = ` ${mark}${cambia ? t.bold('*') : ' '} ${cap === 'admin' ? t.bold(name) : name}`
359
367
  rows.push({ text: line, sel: true, meta: { cap } })
360
368
  rows.push({ text: t.muted(' ' + i.capHint[cap]), sel: false })
361
369
  }
362
370
  rows.push({ text: '', sel: false })
363
- rows.push({ text: t.muted(' ' + i.capsApplyHint), sel: false })
371
+ rows.push({
372
+ text: tocados ? ' ' + t.bold(i.capsPending(tocados)) : t.muted(' ' + i.capsApplyHint),
373
+ sel: false
374
+ })
364
375
  return rows
365
376
  }
366
377
 
@@ -929,6 +940,8 @@ async function onKeyDevices (term, st, key) {
929
940
  })
930
941
  } else if (ch === 'c' && cur?.sub) {
931
942
  st.capsFor = { pub: cur.sub, deviceId: cur.deviceId }
943
+ // Borrador limpio al entrar: arrastrar el de otro aparato sería firmar lo que no viste.
944
+ st.capsDraft = null
932
945
  st.sel.caps = 0
933
946
  await refreshMembers(term, st)
934
947
  st.screen = 'caps'
@@ -1003,9 +1016,19 @@ async function beginPairing (term, st, profile, service = null, label = null) {
1003
1016
  }
1004
1017
 
1005
1018
  /**
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.
1019
+ * UN BORRADOR QUE SE GUARDA O SE DESCARTA (dueño, 2026-09-01).
1020
+ *
1021
+ * Antes cada Enter sellaba un acta. Cambiar cuatro permisos eran cuatro actas, cuatro
1022
+ * avisos a todos los aparatos y cuatro renovaciones de certificado — absurdo, y encima
1023
+ * dejaba la cuenta pasando por estados intermedios que nadie quiso (un aparato con
1024
+ * `admin` pero todavía sin `read`, por ejemplo).
1025
+ *
1026
+ * Ahora Enter mueve un borrador LOCAL —nada firmado, nada avisado— y `G` lo aplica de
1027
+ * golpe. `applyChanges` ya tomaba una LISTA de cambios y producía UNA acta: el borrador es
1028
+ * esa lista, sin firmar, hasta que confirmas.
1029
+ *
1030
+ * Lo que se cambia va marcado con `*` y contado abajo: un borrador que no se distingue del
1031
+ * acta es peor que no tenerlo, porque firmas sin saber qué.
1009
1032
  */
1010
1033
  async function onKeyCaps (term, st, key) {
1011
1034
  const i = L(st)
@@ -1015,31 +1038,51 @@ async function onKeyCaps (term, st, key) {
1015
1038
  const cur = sels[Math.min(st.sel.caps || 0, sels.length - 1)]
1016
1039
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
1017
1040
 
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
1041
  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]))
1042
+ if (!member) {
1043
+ if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; st.capsFor = null }
1044
+ return true
1034
1045
  }
1046
+ const real = new Set(member.caps || [])
1047
+ if (st.capsDraft?.pub !== member.pub) st.capsDraft = { pub: member.pub, caps: [...real] }
1048
+ const draft = new Set(st.capsDraft.caps)
1049
+ const sucio = [...new Set([...draft, ...real])].some((c) => draft.has(c) !== real.has(c))
1035
1050
 
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 })
1051
+ const salir = () => { st.screen = 'devices'; st.capsFor = null; st.capsDraft = null }
1052
+ if (key.name === 'escape' || ch === 'b') {
1053
+ // SALIR CON CAMBIOS SIN GUARDAR SE PREGUNTA. Tirarlos callando es perder trabajo sin
1054
+ // decirlo, y es justo lo que un borrador no debe hacer.
1055
+ if (sucio) setConfirm(st, { text: i.capsDiscard, onYes: async () => { salir() } })
1056
+ else salir()
1040
1057
  return true
1041
1058
  }
1042
- await apply()
1059
+ if (key.name === 'f5') { st.capsDraft = null; await refreshMembers(term, st); return true }
1060
+
1061
+ // GUARDAR: UNA sola acta con todo lo que hayas tocado.
1062
+ if (ch === 'g') {
1063
+ if (!sucio) { flash(st, i.capsNothing); return true }
1064
+ const cuantos = [...new Set([...draft, ...real])].filter((c) => draft.has(c) !== real.has(c)).length
1065
+ const guardar = async () => {
1066
+ const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(member.pub, [...draft], activeId(st)))
1067
+ if (!r.ok) return
1068
+ applyDump(st, r.v)
1069
+ st.capsDraft = null
1070
+ await refreshMembers(term, st)
1071
+ flash(st, i.capsSaved(cuantos))
1072
+ }
1073
+ // Administrar se PREGUNTA, y se pregunta AL GUARDAR: es cuando pasa a ser verdad.
1074
+ if (draft.has('admin') && !real.has('admin')) {
1075
+ setConfirm(st, { text: i.confirmAdmin(st.capsFor.deviceId), onYes: guardar })
1076
+ return true
1077
+ }
1078
+ await guardar()
1079
+ return true
1080
+ }
1081
+
1082
+ if ((key.name !== 'enter' && ch !== ' ') || !cur) return true
1083
+ // Enter solo mueve el BORRADOR: no firma, no avisa, no se puede equivocar caro.
1084
+ if (draft.has(cur.cap)) draft.delete(cur.cap); else draft.add(cur.cap)
1085
+ st.capsDraft = { pub: member.pub, caps: [...draft] }
1043
1086
  return true
1044
1087
  }
1045
1088
 
@@ -1819,4 +1862,4 @@ export async function runTui () {
1819
1862
  }
1820
1863
 
1821
1864
  // 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 } }
1865
+ 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,7 +40,11 @@ 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: {
45
49
  sign: 'Firmar como tú',
46
50
  store: 'Guardar tus datos',
@@ -70,7 +74,7 @@ const es = {
70
74
  capTaken: (n) => `Quitado: ${n}`,
71
75
  applyingCaps: 'Aplicando…',
72
76
  loadingMembers: 'Cargando el acta…',
73
- helpCaps: ['↑↓', 'Enter marcar', 'F5 refrescar', 'Esc dispositivos', 'l English', 'q salir'],
77
+ helpCaps: ['↑↓', 'Enter marcar', 'G guardar', 'F5 refrescar', 'Esc dispositivos', 'l English', 'q salir'],
74
78
  // Perfil del usuario (lo que sincronizan los dispositivos). Solo lectura: se edita en
75
79
  // el aparato, no en la máquina donde vive la bóveda.
76
80
  loadingProfile: 'Cargando el perfil…',
@@ -352,7 +356,11 @@ const en = {
352
356
  titleCaps: 'Device permissions',
353
357
  capsFor: (id, name) => `Permissions for ${id}${name ? ' · ' + name : ''}`,
354
358
  capsNoMember: ' (this device is no longer in the record)',
355
- capsApplyHint: 'Enter ticks or unticks. Each change applies and your other devices are told.',
359
+ capsApplyHint: 'Enter ticks or unticks · G saves · Esc leaves',
360
+ capsPending: (n) => `${n} unsaved change(s) (*) · G to save them in ONE record · Esc to discard`,
361
+ capsSaved: (n) => `${n} change(s) saved in a single record`,
362
+ capsNothing: 'You have not changed anything',
363
+ capsDiscard: 'Discard the unsaved changes?',
356
364
  capName: {
357
365
  sign: 'Sign as you',
358
366
  store: 'Save your data',
@@ -378,7 +386,7 @@ const en = {
378
386
  capTaken: (n) => `Removed: ${n}`,
379
387
  applyingCaps: 'Applying…',
380
388
  loadingMembers: 'Loading the record…',
381
- helpCaps: ['↑↓', 'Enter tick', 'F5 refresh', 'Esc devices', 'l Español', 'q quit'],
389
+ helpCaps: ['↑↓', 'Enter tick', 'G save', 'F5 refresh', 'Esc devices', 'l Español', 'q quit'],
382
390
  loadingProfile: 'Loading profile…',
383
391
  noProfile: ' (this vault has no profile yet)',
384
392
  noProfileHint: ' Edit your name or photo on a paired device and press F5.',