@dotrino/vaultd 0.14.0 → 0.16.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.14.0",
3
+ "version": "0.16.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/ctl.js CHANGED
@@ -257,18 +257,14 @@ function cmdReject (deviceId) {
257
257
  * Distinto de `members` (quién es del perfil) y de `profile` (los perfiles del PC): esto
258
258
  * es el CONTENIDO, no la identidad.
259
259
  *
260
- * La foto no se imprime es un data-URI de hasta ~90 KB— sino que se resume;
261
- * `me --foto <archivo>` la escribe en disco para poder mirarla.
260
+ * De la foto solo se dice que la hay, de qué tipo y cuánto pesa: es un data-URI de hasta
261
+ * ~90 KB y una terminal no es sitio para volcarlo.
262
262
  */
263
- async function cmdMe (args = []) {
264
- const i = args.findIndex((a) => a === '--foto' || a === '--photo')
265
- const avatarPath = i >= 0 ? args[i + 1] : null
266
- if (i >= 0 && !avatarPath) { console.error('uso: dotrino-vault me --foto <archivo>'); process.exit(2) }
267
-
263
+ async function cmdMe () {
268
264
  const s = requireDaemon()
269
265
  const meFile = path.join(dir, 'me.json')
270
266
  try { fs.rmSync(meFile, { force: true }) } catch (_) {}
271
- writeReq('me-request.json', { ...(avatarPath ? { avatarPath: path.resolve(avatarPath) } : {}) })
267
+ writeReq('me-request.json', {})
272
268
  avisar(s.pid, 'SIGUSR2')
273
269
  let dump = null
274
270
  for (let n = 0; n < 50; n++) { await sleep(100); const d = readJson(meFile, null); if (d?.at) { dump = d; break } }
@@ -306,8 +302,6 @@ async function cmdMe (args = []) {
306
302
  for (const x of lista) console.log(' %s %s%s', (x.type || x.label || '').padEnd(12), x.value, x.visible === false ? ' (oculto)' : '')
307
303
  }
308
304
 
309
- if (dump.avatarGuardada) console.log('\n Foto escrita en: %s', dump.avatarGuardada)
310
- else if (me.avatar) console.log('\n Para verla: dotrino-vault me --foto ~/perfil.png')
311
305
  console.log('')
312
306
  }
313
307
 
@@ -691,7 +685,7 @@ export async function runCtl (argv) {
691
685
  case 'approve': return cmdApprove(rest[0])
692
686
  case 'reject': return cmdReject(rest[0])
693
687
  case 'devices': return cmdDevices()
694
- case 'me': return cmdMe(rest)
688
+ case 'me': return cmdMe()
695
689
  case 'members': return cmdMembers()
696
690
  case 'caps': return cmdCaps(rest)
697
691
  case 'revoke': return cmdRevoke(rest[0])
package/src/daemon.js CHANGED
@@ -257,7 +257,7 @@ export async function runDaemon () {
257
257
  // Solo se vuelca cuando se PIDE, no en cada señal: es contenido del usuario y no tiene
258
258
  // por qué quedar escrito en un archivo suelto cada vez que alguien mira los miembros.
259
259
  // La FOTO no entra en el volcado (son hasta ~90 KB de data-URI que nadie va a leer en
260
- // una terminal): se resume, y si la quieres, `--foto <archivo>` la escribe donde digas.
260
+ // una terminal): solo se dice que la hay, de qué tipo y cuánto pesa.
261
261
  const meReq = readJsonSafe(meReqFile)
262
262
  if (meReq) {
263
263
  rm(meReqFile)
@@ -265,15 +265,7 @@ export async function runDaemon () {
265
265
  const tm = resolveTarget(meReq) || { id: mgr.currentId(), vault: mgr.current() }
266
266
  const { me } = tm.vault.threads.methods.profileGet()
267
267
  const { avatar, ...resto } = me || {}
268
- let guardada = null
269
- if (meReq.avatarPath && typeof avatar === 'string') {
270
- const m = /^data:([^;,]+)?(?:;base64)?,(.*)$/s.exec(avatar)
271
- if (m) {
272
- fs.writeFileSync(meReq.avatarPath, Buffer.from(m[2], 'base64'), { mode: 0o600 })
273
- guardada = meReq.avatarPath
274
- }
275
- }
276
- writeJson(meFile, { v: 1, at: Date.now(), profile: tm.id, me: me ? { ...resto, avatar: avatarInfo(avatar) } : null, avatarGuardada: guardada })
268
+ writeJson(meFile, { v: 1, at: Date.now(), profile: tm.id, me: me ? { ...resto, avatar: avatarInfo(avatar) } : null })
277
269
  } catch (e) { console.error('[vault] could not dump the profile:', e.message) }
278
270
  }
279
271
  } catch (e) {
package/src/tui/app.js CHANGED
@@ -231,6 +231,46 @@ function secretRows (st, t) {
231
231
  return rows
232
232
  }
233
233
 
234
+ /**
235
+ * PERFIL del usuario: nombre, foto y datos, tal como los tiene la bóveda. Es lo que se
236
+ * edita en un dispositivo emparejado y se sincroniza aquí; esta pantalla sirve para
237
+ * comprobar que llegó.
238
+ *
239
+ * Solo lectura A PROPÓSITO: el perfil se edita donde lo usas (el aparato), no en el
240
+ * servidor donde vive la bóveda. Aquí se mira.
241
+ */
242
+ function meRows (st, t) {
243
+ const i = L(st)
244
+ const me = st.me
245
+ if (me === undefined) return [{ text: t.muted(i.loading), sel: false }]
246
+ if (!me) return [{ text: t.muted(i.noProfile), sel: false }, { text: '', sel: false }, { text: t.muted(i.noProfileHint), sel: false }]
247
+
248
+ const rows = []
249
+ const campo = (etiqueta, valor, oculto) => rows.push({
250
+ text: ` ${t.muted(String(etiqueta).padEnd(12))} ${valor}${oculto ? t.muted(i.hidden) : ''}`, sel: false
251
+ })
252
+ rows.push({ text: t.muted(i.profileUpdated(me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—')), sel: false })
253
+ rows.push({ text: '', sel: false })
254
+ campo(i.fieldName, me.nickname ? t.bold(me.nickname) : t.muted(i.noName))
255
+ campo(i.fieldPhoto, me.avatar
256
+ ? `${me.avatar.type || '?'} · ${(me.avatar.bytes / 1024).toFixed(1)} KB`
257
+ : t.muted(i.no))
258
+
259
+ const STD = [['nombres', i.fieldFirstName], ['apellidos', i.fieldLastName], ['email', i.fieldEmail],
260
+ ['telefono', i.fieldPhone], ['direccion', i.fieldAddress]]
261
+ const puestos = STD.filter(([k]) => me[k])
262
+ if (puestos.length) rows.push({ text: '', sel: false })
263
+ for (const [k, etiqueta] of puestos) campo(etiqueta, me[k], me[k + 'Visible'] === false)
264
+
265
+ for (const [titulo, lista] of [[i.links, me.links], [i.otherData, me.fields]]) {
266
+ if (!Array.isArray(lista) || !lista.length) continue
267
+ rows.push({ text: '', sel: false })
268
+ rows.push({ text: t.accent(' ▸ ' + titulo), sel: false })
269
+ for (const x of lista) campo(x.type || x.label || '', x.value, x.visible === false)
270
+ }
271
+ return rows
272
+ }
273
+
234
274
  // --------------------------------- entrada ---------------------------------
235
275
 
236
276
  function setInput (st, opts) {
@@ -266,6 +306,10 @@ async function refreshSecrets (term, st) {
266
306
  const r = await guard(term, st, L(st).loadingSecrets, () => vc.listSecrets(activeId(st)))
267
307
  if (r.ok) st.secrets = r.v
268
308
  }
309
+ async function refreshMe (term, st) {
310
+ const r = await guard(term, st, L(st).loadingProfile, () => vc.getMe(activeId(st)))
311
+ st.me = r.ok ? r.v : null
312
+ }
269
313
  async function refreshProfiles (term, st) {
270
314
  const r = await guard(term, st, L(st).loadingVaults, () => vc.listProfiles())
271
315
  if (r.ok) st.profiles = r.v
@@ -557,6 +601,15 @@ async function onKeyPairing (term, st, key) {
557
601
  return true
558
602
  }
559
603
 
604
+ /**
605
+ * Teclas del PERFIL: solo refrescar. Nada de editar — el perfil se edita en el dispositivo
606
+ * que usas, no en la máquina donde vive la bóveda.
607
+ */
608
+ async function onKeyMe (term, st, key) {
609
+ if (key.name === 'char' && key.ch.toLowerCase() === 'r') await refreshMe(term, st)
610
+ return true
611
+ }
612
+
560
613
  async function onKeySecrets (term, st, key) {
561
614
  const i = L(st)
562
615
  const rows = secretRows(st, term.t)
@@ -659,15 +712,16 @@ async function onConfirmKey (st, key) {
659
712
 
660
713
  // Pestañas INTERNAS de una bóveda ya elegida: se cambian con ←→. La lista de
661
714
  // bóvedas (profiles) es el nivel de arriba (se entra con Enter, no es una pestaña).
662
- const INNER_TABS = ['devices', 'secrets']
663
- const tabLabel = (i, k) => (k === 'devices' ? i.tabDevices : i.tabSecrets)
715
+ const INNER_TABS = ['devices', 'secrets', 'me']
716
+ const tabLabel = (i, k) => ({ devices: i.tabDevices, secrets: i.tabSecrets, me: i.tabMe })[k]
664
717
 
665
718
  const helpSegs = (i, screen) => ({
666
719
  profiles: i.helpProfiles,
667
720
  devices: i.helpDevices,
668
721
  secrets: i.helpSecrets,
669
722
  pairing: i.helpPairing,
670
- pairmode: i.helpPairMode
723
+ pairmode: i.helpPairMode,
724
+ me: i.helpMe
671
725
  })[screen] || []
672
726
 
673
727
  const title = (i, screen) => ({
@@ -758,6 +812,7 @@ function render (term, st) {
758
812
  if (st.screen === 'profiles') body = renderList(profileRows(st, t), st.sel.profiles, contentH, cols, t, scrollRef)
759
813
  else if (st.screen === 'devices') body = renderList(deviceRows(st, t), st.sel.devices, contentH, cols, t, scrollRef)
760
814
  else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
815
+ else if (st.screen === 'me') body = renderList(meRows(st, t), -1, contentH, cols, t, scrollRef)
761
816
  else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
762
817
  else if (st.screen === 'pairing') {
763
818
  const pb = pairingBody(st, t, cols, contentH)
@@ -896,6 +951,9 @@ export async function runTui () {
896
951
  if ((key.name === 'left' || key.name === 'right') && INNER_TABS.includes(st.screen)) {
897
952
  const n = INNER_TABS.indexOf(st.screen)
898
953
  st.screen = INNER_TABS[(n + (key.name === 'right' ? 1 : -1) + INNER_TABS.length) % INNER_TABS.length]
954
+ // El perfil se pide al ENTRAR en su pestaña, no al arrancar: es contenido del
955
+ // usuario y no hay por qué sacarlo del cifrado si nadie lo está mirando.
956
+ if (st.screen === 'me' && st.me === undefined) await refreshMe(term, st)
899
957
  continue
900
958
  }
901
959
  // Esc/'b' desde una pestaña vuelve a la lista de bóvedas (salir de la bóveda
@@ -907,6 +965,7 @@ export async function runTui () {
907
965
  if (st.screen === 'profiles') running = await onKeyProfiles(term, st, key)
908
966
  else if (st.screen === 'devices') running = await onKeyDevices(term, st, key)
909
967
  else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
968
+ else if (st.screen === 'me') running = await onKeyMe(term, st, key)
910
969
  else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
911
970
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
912
971
  }
@@ -916,4 +975,4 @@ export async function runTui () {
916
975
  }
917
976
 
918
977
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
919
- export const __test = { render, profileRows, deviceRows, secretRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang }
978
+ export const __test = { render, profileRows, deviceRows, secretRows, meRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang }
package/src/tui/i18n.js CHANGED
@@ -34,6 +34,26 @@ const es = {
34
34
  // pestañas y títulos
35
35
  tabDevices: 'Dispositivos',
36
36
  tabSecrets: 'Scopes y variables',
37
+ tabMe: 'Perfil',
38
+ // Perfil del usuario (lo que sincronizan los dispositivos). Solo lectura: se edita en
39
+ // el aparato, no en la máquina donde vive la bóveda.
40
+ loadingProfile: 'Cargando el perfil…',
41
+ noProfile: ' (esta bóveda todavía no tiene perfil)',
42
+ noProfileHint: ' Edita tu nombre o tu foto en un dispositivo emparejado y pulsa R.',
43
+ profileUpdated: (cuando) => ` actualizado ${cuando}`,
44
+ fieldName: 'nombre',
45
+ fieldPhoto: 'foto',
46
+ fieldFirstName: 'nombres',
47
+ fieldLastName: 'apellidos',
48
+ fieldEmail: 'correo',
49
+ fieldPhone: 'teléfono',
50
+ fieldAddress: 'dirección',
51
+ links: 'Enlaces',
52
+ otherData: 'Otros datos',
53
+ hidden: ' (oculto)',
54
+ noName: '(sin nombre)',
55
+ no: 'no',
56
+ helpMe: ['←→ pestaña', 'r refrescar', 'Esc bóvedas', 'l English', 'q salir'],
37
57
  tabsHint: ' (←→ cambiar)',
38
58
  titleProfiles: 'Bóvedas',
39
59
  titlePairing: 'Emparejar un dispositivo',
@@ -203,6 +223,24 @@ const en = {
203
223
 
204
224
  tabDevices: 'Devices',
205
225
  tabSecrets: 'Scopes & variables',
226
+ tabMe: 'Profile',
227
+ loadingProfile: 'Loading profile…',
228
+ noProfile: ' (this vault has no profile yet)',
229
+ noProfileHint: ' Edit your name or photo on a paired device and press R.',
230
+ profileUpdated: (cuando) => ` updated ${cuando}`,
231
+ fieldName: 'name',
232
+ fieldPhoto: 'photo',
233
+ fieldFirstName: 'first name',
234
+ fieldLastName: 'last name',
235
+ fieldEmail: 'email',
236
+ fieldPhone: 'phone',
237
+ fieldAddress: 'address',
238
+ links: 'Links',
239
+ otherData: 'Other data',
240
+ hidden: ' (hidden)',
241
+ noName: '(no name)',
242
+ no: 'no',
243
+ helpMe: ['←→ tab', 'r refresh', 'Esc vaults', 'l Español', 'q quit'],
206
244
  tabsHint: ' (←→ switch)',
207
245
  titleProfiles: 'Vaults',
208
246
  titlePairing: 'Pair a device',
@@ -32,6 +32,7 @@ const F = {
32
32
  devices: 'devices.json',
33
33
  profilesList: 'profiles-list.json',
34
34
  secretsList: 'secrets-list.json',
35
+ me: 'me.json',
35
36
  // peticiones (las escribe el control; el daemon las consume y borra)
36
37
  pairReq: 'pair-request.json',
37
38
  approveReq: 'approve-request.json',
@@ -39,6 +40,7 @@ const F = {
39
40
  revokeReq: 'revoke-request.json',
40
41
  secretReq: 'secret-request.json',
41
42
  profileReq: 'profile-request.json',
43
+ meReq: 'me-request.json',
42
44
  dumpReq: 'dump-request.json'
43
45
  }
44
46
 
@@ -206,6 +208,33 @@ export async function revokeDevice (nonce, profile) {
206
208
  return listDevices(profile)
207
209
  }
208
210
 
211
+ // ---------------------------------------------------------------------------
212
+ // Perfil del usuario (lo que sincronizan los dispositivos)
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /**
216
+ * El PERFIL del usuario tal como lo tiene la bóveda: nombre, foto y datos. Es lo que se
217
+ * edita en cualquier dispositivo emparejado y se sincroniza aquí, así que sirve para
218
+ * comprobar que lo que cambiaste en el aparato llegó.
219
+ *
220
+ * No es lo mismo que `listProfiles` (las cuentas de ESTE PC) ni que el acta (quién es del
221
+ * perfil): esto es el CONTENIDO. La foto llega resumida (tipo y tamaño), no en bytes:
222
+ * nadie va a mirar un data-URI de 90 KB en una terminal.
223
+ *
224
+ * El volcado es contenido del usuario, así que se lee y se BORRA en el acto.
225
+ */
226
+ export async function getMe (profile) {
227
+ requireAlive()
228
+ rm(F.me)
229
+ writeReq(F.meReq, {}, profile)
230
+ signalOrCleanup('SIGUSR2', [F.meReq])
231
+ const d = await waitFor(F.me)
232
+ rm(F.me)
233
+ if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
234
+ return d.me || null
235
+ }
236
+
237
+
209
238
  // ---------------------------------------------------------------------------
210
239
  // Secretos: scopes (namespaces) y variables (claves)
211
240
  // ---------------------------------------------------------------------------