@dotrino/vaultd 0.18.0 → 0.19.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.18.0",
3
+ "version": "0.19.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": {
package/src/tui/app.js CHANGED
@@ -213,6 +213,37 @@ function pairModeRows (st, t) {
213
213
  return rows
214
214
  }
215
215
 
216
+ /**
217
+ * PERMISOS de un dispositivo. Los cuatro que existen, con lo que significan en cristiano y
218
+ * una marca de si los tiene. El de administrar va aparte y avisado: es el único que deja
219
+ * a ese aparato meter y sacar dispositivos sin venir aquí.
220
+ */
221
+ const CAPS_ORDEN = ['sign', 'store', 'read', 'admin']
222
+
223
+ function capsRows (st, t) {
224
+ const i = L(st)
225
+ const objetivo = st.capsFor
226
+ if (!objetivo) return [{ text: t.muted(i.loading), sel: false }]
227
+ const miembro = (st.members || []).find((m) => m.pub === objetivo.pub)
228
+ if (!miembro) return [{ text: t.muted(i.capsNoMember), sel: false }]
229
+
230
+ const tiene = new Set(miembro.caps || [])
231
+ const rows = [
232
+ { text: ' ' + t.bold(i.capsFor(objetivo.deviceId, miembro.label || '')), sel: false },
233
+ { text: '', sel: false }
234
+ ]
235
+ for (const cap of CAPS_ORDEN) {
236
+ const marca = tiene.has(cap) ? '[x]' : '[ ]'
237
+ const nombre = i.capName[cap]
238
+ const linea = ` ${marca} ${cap === 'admin' ? t.bold(nombre) : nombre}`
239
+ rows.push({ text: linea, sel: true, meta: { cap } })
240
+ rows.push({ text: t.muted(' ' + i.capHint[cap]), sel: false })
241
+ }
242
+ rows.push({ text: '', sel: false })
243
+ rows.push({ text: t.muted(' ' + i.capsApplyHint), sel: false })
244
+ return rows
245
+ }
246
+
216
247
  function secretRows (st, t) {
217
248
  const i = L(st)
218
249
  const ns = st.secrets || {}
@@ -306,6 +337,10 @@ async function refreshSecrets (term, st) {
306
337
  const r = await guard(term, st, L(st).loadingSecrets, () => vc.listSecrets(activeId(st)))
307
338
  if (r.ok) st.secrets = r.v
308
339
  }
340
+ async function refreshMembers (term, st) {
341
+ const r = await guard(term, st, L(st).loadingMembers, () => vc.listMembers(activeId(st)))
342
+ if (r.ok) st.members = r.v
343
+ }
309
344
  async function refreshMe (term, st) {
310
345
  const r = await guard(term, st, L(st).loadingProfile, () => vc.getMe(activeId(st)))
311
346
  st.me = r.ok ? r.v : null
@@ -498,9 +533,10 @@ async function onKeyDevices (term, st, key) {
498
533
  },
499
534
  onNo: () => { st.confirm = null }
500
535
  })
501
- } else if (ch === 'n' && cur) {
536
+ } else if (ch === 'r' && cur) {
502
537
  // Renombrar: el nombre lo trae el aparato al emparejarse (y si no le diste uno, entra
503
- // con TU apodo de ese momento), así que a la semana ya no dice nada.
538
+ // con TU apodo de ese momento), así que a la semana ya no dice nada. `r` es renombrar
539
+ // también en Bóvedas: una tecla, un significado.
504
540
  setInput(st, {
505
541
  label: i.renameDeviceLabel(cur.deviceId),
506
542
  hint: i.renameDeviceHint,
@@ -512,7 +548,12 @@ async function onKeyDevices (term, st, key) {
512
548
  if (r.ok) { st.devices = r.v; flash(st, i.deviceRenamed(nombre)) }
513
549
  }
514
550
  })
515
- } else if (ch === 'r') {
551
+ } else if (ch === 'c' && cur?.sub) {
552
+ st.capsFor = { pub: cur.sub, deviceId: cur.deviceId }
553
+ st.sel.caps = 0
554
+ await refreshMembers(term, st)
555
+ st.screen = 'caps'
556
+ } else if (key.name === 'f5') {
516
557
  await refreshDevices(term, st)
517
558
  }
518
559
  return true
@@ -525,6 +566,47 @@ async function beginPairing (term, st, profile) {
525
566
  return r.ok
526
567
  }
527
568
 
569
+ /**
570
+ * Enter marca o desmarca un permiso y lo aplica en el acto. Sin botón de «guardar»: cada
571
+ * cambio se sella en el acta y se avisa a los demás aparatos, así que acumularlos en
572
+ * pantalla solo serviría para que el acta y lo que ves dijeran cosas distintas.
573
+ */
574
+ async function onKeyCaps (term, st, key) {
575
+ const i = L(st)
576
+ const rows = capsRows(st, term.t)
577
+ const sels = rows.filter((r) => r.sel).map((r) => r.meta)
578
+ moveSel(st, key, 'caps', sels.length)
579
+ const cur = sels[Math.min(st.sel.caps || 0, sels.length - 1)]
580
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
581
+
582
+ if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; st.capsFor = null; return true }
583
+ if (key.name === 'f5') { await refreshMembers(term, st); return true }
584
+ if ((key.name !== 'enter' && ch !== ' ') || !cur) return true
585
+
586
+ const miembro = (st.members || []).find((m) => m.pub === st.capsFor?.pub)
587
+ if (!miembro) return true
588
+ const caps = new Set(miembro.caps || [])
589
+ const dando = !caps.has(cur.cap)
590
+ if (dando) caps.add(cur.cap); else caps.delete(cur.cap)
591
+
592
+ const aplicar = async () => {
593
+ const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(miembro.pub, [...caps], activeId(st)))
594
+ if (!r.ok) return
595
+ st.devices = r.v
596
+ await refreshMembers(term, st)
597
+ flash(st, dando ? i.capGiven(i.capName[cur.cap]) : i.capTaken(i.capName[cur.cap]))
598
+ }
599
+
600
+ // Administrar se PREGUNTA: es el permiso que deja a ese aparato admitir y expulsar
601
+ // dispositivos sin pasar por aquí. Los otros tres se marcan y ya.
602
+ if (cur.cap === 'admin' && dando) {
603
+ setConfirm(st, { text: i.confirmAdmin(st.capsFor.deviceId), onYes: aplicar })
604
+ return true
605
+ }
606
+ await aplicar()
607
+ return true
608
+ }
609
+
528
610
  async function onKeyPairMode (term, st, key) {
529
611
  const i = L(st)
530
612
  const rows = pairModeRows(st, term.t)
@@ -620,7 +702,7 @@ async function onKeyPairing (term, st, key) {
620
702
  * que usas, no en la máquina donde vive la bóveda.
621
703
  */
622
704
  async function onKeyMe (term, st, key) {
623
- if (key.name === 'char' && key.ch.toLowerCase() === 'r') await refreshMe(term, st)
705
+ if (key.name === 'f5') await refreshMe(term, st)
624
706
  return true
625
707
  }
626
708
 
@@ -657,7 +739,7 @@ async function onKeySecrets (term, st, key) {
657
739
  onNo: () => { st.confirm = null }
658
740
  })
659
741
  }
660
- } else if (ch === 'r') {
742
+ } else if (key.name === 'f5') {
661
743
  await refreshSecrets(term, st)
662
744
  }
663
745
  return true
@@ -741,7 +823,8 @@ const helpSegs = (i, screen, st = {}) => {
741
823
  secrets: i.helpSecrets,
742
824
  pairing: i.helpPairing,
743
825
  pairmode: i.helpPairMode,
744
- me: i.helpMe
826
+ me: i.helpMe,
827
+ caps: i.helpCaps
745
828
  }[screen] || []
746
829
  if (typeof segs !== 'function') return segs
747
830
  return segs({
@@ -754,7 +837,8 @@ const helpSegs = (i, screen, st = {}) => {
754
837
  const title = (i, screen) => ({
755
838
  profiles: i.titleProfiles,
756
839
  pairing: i.titlePairing,
757
- pairmode: i.titlePairMode
840
+ pairmode: i.titlePairMode,
841
+ caps: i.titleCaps
758
842
  })[screen] || ''
759
843
 
760
844
  /** Barra de pestañas horizontal (Dispositivos | Scopes y variables) de la bóveda entrada. */
@@ -840,6 +924,7 @@ function render (term, st) {
840
924
  else if (st.screen === 'devices') body = renderList(deviceRows(st, t), st.sel.devices, contentH, cols, t, scrollRef)
841
925
  else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
842
926
  else if (st.screen === 'me') body = renderList(meRows(st, t), -1, contentH, cols, t, scrollRef)
927
+ else if (st.screen === 'caps') body = renderList(capsRows(st, t), st.sel.caps || 0, contentH, cols, t, scrollRef)
843
928
  else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
844
929
  else if (st.screen === 'pairing') {
845
930
  const pb = pairingBody(st, t, cols, contentH)
@@ -993,6 +1078,7 @@ export async function runTui () {
993
1078
  else if (st.screen === 'devices') running = await onKeyDevices(term, st, key)
994
1079
  else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
995
1080
  else if (st.screen === 'me') running = await onKeyMe(term, st, key)
1081
+ else if (st.screen === 'caps') running = await onKeyCaps(term, st, key)
996
1082
  else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
997
1083
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
998
1084
  }
@@ -1002,4 +1088,4 @@ export async function runTui () {
1002
1088
  }
1003
1089
 
1004
1090
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
1005
- export const __test = { render, profileRows, deviceRows, secretRows, meRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang }
1091
+ export const __test = { render, profileRows, deviceRows, secretRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang }
package/src/tui/i18n.js CHANGED
@@ -35,11 +35,34 @@ const es = {
35
35
  tabDevices: 'Dispositivos',
36
36
  tabSecrets: 'Scopes y variables',
37
37
  tabMe: 'Perfil',
38
+ // PERMISOS de un dispositivo (§9.1: se dice el beneficio, no el scope del cert).
39
+ titleCaps: 'Permisos del dispositivo',
40
+ capsFor: (id, nombre) => `Permisos de ${id}${nombre ? ' · ' + nombre : ''}`,
41
+ capsNoMember: ' (este dispositivo ya no está en el acta)',
42
+ capsApplyHint: 'Enter marca o desmarca. Cada cambio se aplica y se avisa a tus otros aparatos.',
43
+ capName: {
44
+ sign: 'Firmar como tú',
45
+ store: 'Guardar tus datos',
46
+ read: 'Leer tus datos',
47
+ admin: 'Administrar el perfil'
48
+ },
49
+ capHint: {
50
+ sign: 'usar tu identidad en las apps del ecosistema',
51
+ store: 'escribir en tu bóveda (perfil, contenido, datos sensibles)',
52
+ read: 'ver lo que guardaste',
53
+ admin: 'conectar y quitar dispositivos desde ese aparato, sin venir aquí'
54
+ },
55
+ confirmAdmin: (id) => `¿Dejar que ${id} conecte y quite dispositivos sin venir aquí?`,
56
+ capGiven: (n) => `Concedido: ${n}`,
57
+ capTaken: (n) => `Quitado: ${n}`,
58
+ applyingCaps: 'Aplicando…',
59
+ loadingMembers: 'Cargando el acta…',
60
+ helpCaps: ['↑↓', 'Enter marcar', 'F5 refrescar', 'Esc dispositivos', 'l English', 'q salir'],
38
61
  // Perfil del usuario (lo que sincronizan los dispositivos). Solo lectura: se edita en
39
62
  // el aparato, no en la máquina donde vive la bóveda.
40
63
  loadingProfile: 'Cargando el perfil…',
41
64
  noProfile: ' (esta bóveda todavía no tiene perfil)',
42
- noProfileHint: ' Edita tu nombre o tu foto en un dispositivo emparejado y pulsa R.',
65
+ noProfileHint: ' Edita tu nombre o tu foto en un dispositivo emparejado y pulsa F5.',
43
66
  profileUpdated: (cuando) => ` actualizado ${cuando}`,
44
67
  fieldName: 'nombre',
45
68
  fieldPhoto: 'foto',
@@ -53,7 +76,7 @@ const es = {
53
76
  hidden: ' (oculto)',
54
77
  noName: '(sin nombre)',
55
78
  no: 'no',
56
- helpMe: ['←→ pestaña', 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'],
79
+ helpMe: ['←→ pestaña', 'F5 refrescar', 'Esc bóvedas', 'l English', 'q salir'],
57
80
  tabsHint: ' (←→ cambiar)',
58
81
  titleProfiles: 'Bóvedas',
59
82
  titlePairing: 'Emparejar un dispositivo',
@@ -181,8 +204,8 @@ const es = {
181
204
  helpDevices: ({ pendiente, hayAparatos } = {}) => [
182
205
  '←→ pestaña', '↑↓', 'p emparejar',
183
206
  ...(pendiente ? ['a aprobar', 'x rechazar'] : []),
184
- ...(hayAparatos ? ['n renombrar', 'v revocar'] : []),
185
- 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'
207
+ ...(hayAparatos ? ['r renombrar', 'c permisos', 'v revocar'] : []),
208
+ 'F5 refrescar', 'Esc bóvedas', 'l English', 'q salir'
186
209
  ],
187
210
  renameDeviceLabel: (id) => `¿Cómo quieres llamar a ${id}?`,
188
211
  renameDeviceHint: 'el nombre con el que lo reconoces (Esc cancela)',
@@ -191,7 +214,7 @@ const es = {
191
214
  helpSecrets: ({ haySecretos } = {}) => [
192
215
  '←→ pestaña', '↑↓', 'n nueva variable',
193
216
  ...(haySecretos ? ['x quitar (variable/scope)'] : []),
194
- 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'
217
+ 'F5 refrescar', 'Esc bóvedas', 'l English', 'q salir'
195
218
  ],
196
219
  helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', '↑↓ scroll', 'Esc atrás', 'l English'],
197
220
  helpPairMode: ['↑↓', 'Enter elegir', 'Esc atrás', 'l English', 'q salir'],
@@ -240,9 +263,31 @@ const en = {
240
263
  tabDevices: 'Devices',
241
264
  tabSecrets: 'Scopes & variables',
242
265
  tabMe: 'Profile',
266
+ titleCaps: 'Device permissions',
267
+ capsFor: (id, nombre) => `Permissions for ${id}${nombre ? ' · ' + nombre : ''}`,
268
+ capsNoMember: ' (this device is no longer in the record)',
269
+ capsApplyHint: 'Enter ticks or unticks. Each change applies and your other devices are told.',
270
+ capName: {
271
+ sign: 'Sign as you',
272
+ store: 'Save your data',
273
+ read: 'Read your data',
274
+ admin: 'Manage the profile'
275
+ },
276
+ capHint: {
277
+ sign: 'use your identity across the ecosystem apps',
278
+ store: 'write to your vault (profile, content, sensitive data)',
279
+ read: 'see what you saved',
280
+ admin: 'connect and remove devices from that device, without coming here'
281
+ },
282
+ confirmAdmin: (id) => `Let ${id} connect and remove devices without coming here?`,
283
+ capGiven: (n) => `Granted: ${n}`,
284
+ capTaken: (n) => `Removed: ${n}`,
285
+ applyingCaps: 'Applying…',
286
+ loadingMembers: 'Loading the record…',
287
+ helpCaps: ['↑↓', 'Enter tick', 'F5 refresh', 'Esc devices', 'l Español', 'q quit'],
243
288
  loadingProfile: 'Loading profile…',
244
289
  noProfile: ' (this vault has no profile yet)',
245
- noProfileHint: ' Edit your name or photo on a paired device and press R.',
290
+ noProfileHint: ' Edit your name or photo on a paired device and press F5.',
246
291
  profileUpdated: (cuando) => ` updated ${cuando}`,
247
292
  fieldName: 'name',
248
293
  fieldPhoto: 'photo',
@@ -256,7 +301,7 @@ const en = {
256
301
  hidden: ' (hidden)',
257
302
  noName: '(no name)',
258
303
  no: 'no',
259
- helpMe: ['←→ tab', 'r refresh', 'Esc vaults', 'l Español', 'q quit'],
304
+ helpMe: ['←→ tab', 'F5 refresh', 'Esc vaults', 'l Español', 'q quit'],
260
305
  tabsHint: ' (←→ switch)',
261
306
  titleProfiles: 'Vaults',
262
307
  titlePairing: 'Pair a device',
@@ -370,8 +415,8 @@ const en = {
370
415
  helpDevices: ({ pendiente, hayAparatos } = {}) => [
371
416
  '←→ tab', '↑↓', 'p pair',
372
417
  ...(pendiente ? ['a approve', 'x reject'] : []),
373
- ...(hayAparatos ? ['n rename', 'v revoke'] : []),
374
- 'r refresh', 'Esc vaults', 'l Español', 'q quit'
418
+ ...(hayAparatos ? ['r rename', 'c permissions', 'v revoke'] : []),
419
+ 'F5 refresh', 'Esc vaults', 'l Español', 'q quit'
375
420
  ],
376
421
  renameDeviceLabel: (id) => `What do you want to call ${id}?`,
377
422
  renameDeviceHint: 'the name you recognise it by (Esc cancels)',
@@ -380,7 +425,7 @@ const en = {
380
425
  helpSecrets: ({ haySecretos } = {}) => [
381
426
  '←→ tab', '↑↓', 'n new variable',
382
427
  ...(haySecretos ? ['x remove (variable/scope)'] : []),
383
- 'r refresh', 'Esc vaults', 'l Español', 'q quit'
428
+ 'F5 refresh', 'Esc vaults', 'l Español', 'q quit'
384
429
  ],
385
430
  helpPairing: ['a approve', 'x reject', 'r restart', '↑↓ scroll', 'Esc back', 'l Español'],
386
431
  helpPairMode: ['↑↓', 'Enter choose', 'Esc back', 'l Español', 'q quit'],
package/src/tui/term.js CHANGED
@@ -124,7 +124,13 @@ export function makeTheme () {
124
124
 
125
125
  // --------------------------------- teclado ----------------------------------
126
126
 
127
- function parseChunk (s, push) {
127
+ /**
128
+ * Decodifica un trozo de stdin en teclas. Exportado SOLO para poder probarlo: vivía
129
+ * encerrado en `createTerm`, así que las secuencias de escape (flechas, F5, supr) no las
130
+ * comprobaba nadie — y una tabla mal escrita ahí no falla, simplemente la tecla no hace
131
+ * nada, que es la avería más difícil de ver.
132
+ */
133
+ export function parseChunk (s, push) {
128
134
  let i = 0
129
135
  const arrow = { A: 'up', B: 'down', C: 'right', D: 'left', H: 'home', F: 'end' }
130
136
  while (i < s.length) {
@@ -138,7 +144,9 @@ function parseChunk (s, push) {
138
144
  let j = i + 2; let num = ''
139
145
  while (j < s.length && /[0-9;]/.test(s[j])) { num += s[j]; j++ }
140
146
  const fin = s[j]
141
- const seq = { 3: 'delete', 5: 'pageup', 6: 'pagedown', 1: 'home', 4: 'end' }
147
+ // F5 = refrescar. Es tecla de función y no consume un mnemónico: así `r` queda
148
+ // libre para renombrar, que es lo que significa en el resto de la TUI.
149
+ const seq = { 3: 'delete', 5: 'pageup', 6: 'pagedown', 1: 'home', 4: 'end', 15: 'f5' }
142
150
  if (fin === '~' && seq[num]) { push({ name: seq[num] }); i = j + 1; continue }
143
151
  i = (fin ? j + 1 : s.length); continue
144
152
  }
@@ -33,12 +33,14 @@ const F = {
33
33
  profilesList: 'profiles-list.json',
34
34
  secretsList: 'secrets-list.json',
35
35
  me: 'me.json',
36
+ acta: 'acta.json',
36
37
  // peticiones (las escribe el control; el daemon las consume y borra)
37
38
  pairReq: 'pair-request.json',
38
39
  approveReq: 'approve-request.json',
39
40
  rejectReq: 'reject-request.json',
40
41
  revokeReq: 'revoke-request.json',
41
42
  labelReq: 'label-request.json',
43
+ capsReq: 'caps-request.json',
42
44
  secretReq: 'secret-request.json',
43
45
  profileReq: 'profile-request.json',
44
46
  meReq: 'me-request.json',
@@ -212,6 +214,37 @@ export async function setDeviceLabel (pub, label, profile) {
212
214
  return listDevices(profile)
213
215
  }
214
216
 
217
+ /**
218
+ * El ACTA del perfil: quién es miembro y qué puede hacer cada uno. Es lo que manda para los
219
+ * permisos — la lista de dispositivos enseña el SCOPE DEL CERT, que es su reflejo y puede
220
+ * ir por detrás hasta que el aparato renueve.
221
+ */
222
+ export async function listMembers (profile) {
223
+ requireAlive()
224
+ rm(F.acta)
225
+ writeReq(F.dumpReq, {}, profile)
226
+ signalOrCleanup('SIGUSR2', [F.dumpReq])
227
+ const d = await waitFor(F.acta)
228
+ if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
229
+ return d.members || []
230
+ }
231
+
232
+ /**
233
+ * Cambia lo que PUEDE hacer un dispositivo. La lista es completa (no un delta): lo que no
234
+ * venga, se le quita.
235
+ *
236
+ * `admin` (administrar el perfil a distancia) se concede AQUÍ, en la máquina de la bóveda,
237
+ * y nunca al emparejar: así el QR que circula no puede otorgarla nunca, y darla es un
238
+ * gesto deliberado del dueño que queda escrito en el acta.
239
+ */
240
+ export async function setDeviceCaps (pub, caps, profile) {
241
+ requireAlive()
242
+ writeReq(F.capsReq, { pub, caps }, profile)
243
+ signalOrCleanup('SIGUSR2', [F.capsReq])
244
+ await sleep(600)
245
+ return listDevices(profile)
246
+ }
247
+
215
248
  /** Revoca un dispositivo por su `nonce` (le ordena autoborrarse) y revuelca. */
216
249
  export async function revokeDevice (nonce, profile) {
217
250
  requireAlive()