@dotrino/vaultd 0.13.0 → 0.15.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.13.0",
3
+ "version": "0.15.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
@@ -249,6 +249,68 @@ function cmdReject (deviceId) {
249
249
  console.log('Rechazado %s.', deviceId)
250
250
  }
251
251
 
252
+ /**
253
+ * `dotrino-vault me` — el PERFIL del usuario tal como lo tiene la bóveda: apodo, foto y
254
+ * datos. Es lo que editas en cualquier dispositivo emparejado y se sincroniza aquí, así
255
+ * que sirve para comprobar que lo que cambiaste en el aparato llegó de verdad.
256
+ *
257
+ * Distinto de `members` (quién es del perfil) y de `profile` (los perfiles del PC): esto
258
+ * es el CONTENIDO, no la identidad.
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.
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
+
268
+ const s = requireDaemon()
269
+ const meFile = path.join(dir, 'me.json')
270
+ try { fs.rmSync(meFile, { force: true }) } catch (_) {}
271
+ writeReq('me-request.json', { ...(avatarPath ? { avatarPath: path.resolve(avatarPath) } : {}) })
272
+ avisar(s.pid, 'SIGUSR2')
273
+ let dump = null
274
+ for (let n = 0; n < 50; n++) { await sleep(100); const d = readJson(meFile, null); if (d?.at) { dump = d; break } }
275
+ // El volcado es contenido del usuario: se lee y se BORRA, no se queda ahí suelto.
276
+ try { fs.rmSync(meFile, { force: true }) } catch (_) {}
277
+ if (!dump) { console.error('La bóveda no respondió. ¿Está corriendo? dotrino-vault status'); process.exit(1) }
278
+
279
+ const me = dump.me
280
+ if (!me) {
281
+ console.log('\nTodavía no hay perfil en esta bóveda.')
282
+ console.log('Edita tu nombre o tu foto en un dispositivo emparejado y vuelve a mirar.\n')
283
+ return
284
+ }
285
+
286
+ const cuando = me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—'
287
+ console.log('\n%sPerfil%s · actualizado %s\n', B, Z, cuando)
288
+ console.log(' nombre : %s', me.nickname || '(sin nombre)')
289
+ console.log(' foto : %s', me.avatar
290
+ ? `sí · ${me.avatar.type || 'desconocido'} · ${(me.avatar.bytes / 1024).toFixed(1)} KB`
291
+ : 'no')
292
+
293
+ // Los campos estándar. `visible` es del usuario: teléfono y dirección nacen ocultos.
294
+ const STD = [['nombres', 'nombres'], ['apellidos', 'apellidos'], ['email', 'correo'],
295
+ ['telefono', 'teléfono'], ['direccion', 'dirección']]
296
+ const puestos = STD.filter(([k]) => me[k])
297
+ if (puestos.length) {
298
+ console.log('')
299
+ for (const [k, etiqueta] of puestos) {
300
+ console.log(' %s: %s%s', etiqueta.padEnd(12), me[k], me[k + 'Visible'] === false ? ' (oculto)' : '')
301
+ }
302
+ }
303
+ for (const [titulo, lista] of [['Enlaces', me.links], ['Otros datos', me.fields]]) {
304
+ if (!Array.isArray(lista) || !lista.length) continue
305
+ console.log('\n %s:', titulo)
306
+ for (const x of lista) console.log(' %s %s%s', (x.type || x.label || '').padEnd(12), x.value, x.visible === false ? ' (oculto)' : '')
307
+ }
308
+
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
+ console.log('')
312
+ }
313
+
252
314
  /**
253
315
  * `dotrino-vault members` — el ACTA del perfil: qué llaves son tuyas y qué puede hacer cada
254
316
  * una. Es la misma información que muestra la consola de vault.dotrino.com.
@@ -584,6 +646,7 @@ function help () {
584
646
  approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
585
647
  reject <deviceId> rechaza un dispositivo pendiente
586
648
  devices lista dispositivos enrolados / revocados
649
+ me tu perfil (nombre, foto, datos) tal como lo tiene la bóveda
587
650
  members el acta del perfil: quién es tuyo y qué puede hacer
588
651
  caps <ID> ±permiso cambia permisos (+firma -guarda +administra …)
589
652
  revoke <nonce> revoca un dispositivo (le ordena autoborrarse)
@@ -628,6 +691,7 @@ export async function runCtl (argv) {
628
691
  case 'approve': return cmdApprove(rest[0])
629
692
  case 'reject': return cmdReject(rest[0])
630
693
  case 'devices': return cmdDevices()
694
+ case 'me': return cmdMe(rest)
631
695
  case 'members': return cmdMembers()
632
696
  case 'caps': return cmdCaps(rest)
633
697
  case 'revoke': return cmdRevoke(rest[0])
package/src/daemon.js CHANGED
@@ -154,6 +154,16 @@ export async function runDaemon () {
154
154
  const secretsListFile = path.join(dir, 'secrets-list.json')
155
155
  const profileReqFile = path.join(dir, 'profile-request.json')
156
156
  const dumpReqFile = path.join(dir, 'dump-request.json')
157
+ const meReqFile = path.join(dir, 'me-request.json')
158
+ const meFile = path.join(dir, 'me.json')
159
+
160
+ /** Resumen de la foto de perfil: qué es y cuánto pesa, nunca los bytes. */
161
+ function avatarInfo (avatar) {
162
+ if (typeof avatar !== 'string' || !avatar) return null
163
+ const m = /^data:([^;,]+)?(?:;base64)?,(.*)$/s.exec(avatar)
164
+ if (!m) return { type: null, bytes: avatar.length }
165
+ return { type: m[1] || 'desconocido', bytes: Math.floor(m[2].length * 3 / 4) }
166
+ }
157
167
 
158
168
  /**
159
169
  * Órdenes de perfil (crear/renombrar/borrar/activar) y del candado
@@ -242,6 +252,30 @@ export async function runDaemon () {
242
252
  writeJson(devFile, { v: 1, at: Date.now(), profile: t.id, ...(await t.vault.listDevices()) })
243
253
  // Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
244
254
  try { writeJson(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), profile: t.id, ...(await t.vault.profileMembers()) }) } catch (_) {}
255
+
256
+ // PERFIL del usuario (apodo, foto, datos) tal como lo tiene la bóveda: `dotrino-vault me`.
257
+ // Solo se vuelca cuando se PIDE, no en cada señal: es contenido del usuario y no tiene
258
+ // por qué quedar escrito en un archivo suelto cada vez que alguien mira los miembros.
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.
261
+ const meReq = readJsonSafe(meReqFile)
262
+ if (meReq) {
263
+ rm(meReqFile)
264
+ try {
265
+ const tm = resolveTarget(meReq) || { id: mgr.currentId(), vault: mgr.current() }
266
+ const { me } = tm.vault.threads.methods.profileGet()
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 })
277
+ } catch (e) { console.error('[vault] could not dump the profile:', e.message) }
278
+ }
245
279
  } catch (e) {
246
280
  console.error('[vault] error handling a control signal:', e.message)
247
281
  }
package/src/tui/app.js CHANGED
@@ -231,6 +231,47 @@ 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
+ if (me.avatar) { rows.push({ text: '', sel: false }); rows.push({ text: t.muted(i.savePhotoHint), sel: false }) }
272
+ return rows
273
+ }
274
+
234
275
  // --------------------------------- entrada ---------------------------------
235
276
 
236
277
  function setInput (st, opts) {
@@ -266,6 +307,10 @@ async function refreshSecrets (term, st) {
266
307
  const r = await guard(term, st, L(st).loadingSecrets, () => vc.listSecrets(activeId(st)))
267
308
  if (r.ok) st.secrets = r.v
268
309
  }
310
+ async function refreshMe (term, st) {
311
+ const r = await guard(term, st, L(st).loadingProfile, () => vc.getMe(activeId(st)))
312
+ st.me = r.ok ? r.v : null
313
+ }
269
314
  async function refreshProfiles (term, st) {
270
315
  const r = await guard(term, st, L(st).loadingVaults, () => vc.listProfiles())
271
316
  if (r.ok) st.profiles = r.v
@@ -557,6 +602,31 @@ async function onKeyPairing (term, st, key) {
557
602
  return true
558
603
  }
559
604
 
605
+ /**
606
+ * Teclas del PERFIL: refrescar y guardar la foto. Nada de editar — el perfil se edita en
607
+ * el dispositivo que usas, no en la máquina donde vive la bóveda.
608
+ */
609
+ async function onKeyMe (term, st, key) {
610
+ const i = L(st)
611
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
612
+ if (ch === 'r') {
613
+ await refreshMe(term, st)
614
+ } else if (ch === 'f' && st.me?.avatar) {
615
+ setInput(st, {
616
+ label: i.savePhotoLabel,
617
+ hint: i.savePhotoHintInput,
618
+ value: 'perfil.png',
619
+ onSubmit: async (valor) => {
620
+ const destino = String(valor || '').trim()
621
+ if (!destino) return
622
+ const r = await guard(term, st, i.savingPhoto, () => vc.saveAvatar(destino, activeId(st)))
623
+ if (r.ok && r.v) flash(st, i.photoSaved(r.v))
624
+ }
625
+ })
626
+ }
627
+ return true
628
+ }
629
+
560
630
  async function onKeySecrets (term, st, key) {
561
631
  const i = L(st)
562
632
  const rows = secretRows(st, term.t)
@@ -659,15 +729,16 @@ async function onConfirmKey (st, key) {
659
729
 
660
730
  // Pestañas INTERNAS de una bóveda ya elegida: se cambian con ←→. La lista de
661
731
  // 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)
732
+ const INNER_TABS = ['devices', 'secrets', 'me']
733
+ const tabLabel = (i, k) => ({ devices: i.tabDevices, secrets: i.tabSecrets, me: i.tabMe })[k]
664
734
 
665
735
  const helpSegs = (i, screen) => ({
666
736
  profiles: i.helpProfiles,
667
737
  devices: i.helpDevices,
668
738
  secrets: i.helpSecrets,
669
739
  pairing: i.helpPairing,
670
- pairmode: i.helpPairMode
740
+ pairmode: i.helpPairMode,
741
+ me: i.helpMe
671
742
  })[screen] || []
672
743
 
673
744
  const title = (i, screen) => ({
@@ -758,6 +829,7 @@ function render (term, st) {
758
829
  if (st.screen === 'profiles') body = renderList(profileRows(st, t), st.sel.profiles, contentH, cols, t, scrollRef)
759
830
  else if (st.screen === 'devices') body = renderList(deviceRows(st, t), st.sel.devices, contentH, cols, t, scrollRef)
760
831
  else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
832
+ else if (st.screen === 'me') body = renderList(meRows(st, t), -1, contentH, cols, t, scrollRef)
761
833
  else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
762
834
  else if (st.screen === 'pairing') {
763
835
  const pb = pairingBody(st, t, cols, contentH)
@@ -896,6 +968,9 @@ export async function runTui () {
896
968
  if ((key.name === 'left' || key.name === 'right') && INNER_TABS.includes(st.screen)) {
897
969
  const n = INNER_TABS.indexOf(st.screen)
898
970
  st.screen = INNER_TABS[(n + (key.name === 'right' ? 1 : -1) + INNER_TABS.length) % INNER_TABS.length]
971
+ // El perfil se pide al ENTRAR en su pestaña, no al arrancar: es contenido del
972
+ // usuario y no hay por qué sacarlo del cifrado si nadie lo está mirando.
973
+ if (st.screen === 'me' && st.me === undefined) await refreshMe(term, st)
899
974
  continue
900
975
  }
901
976
  // Esc/'b' desde una pestaña vuelve a la lista de bóvedas (salir de la bóveda
@@ -907,6 +982,7 @@ export async function runTui () {
907
982
  if (st.screen === 'profiles') running = await onKeyProfiles(term, st, key)
908
983
  else if (st.screen === 'devices') running = await onKeyDevices(term, st, key)
909
984
  else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
985
+ else if (st.screen === 'me') running = await onKeyMe(term, st, key)
910
986
  else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
911
987
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
912
988
  }
@@ -916,4 +992,4 @@ export async function runTui () {
916
992
  }
917
993
 
918
994
  // 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 }
995
+ export const __test = { render, profileRows, deviceRows, secretRows, meRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang }
package/src/tui/i18n.js CHANGED
@@ -34,6 +34,31 @@ 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
+ savePhotoHint: ' Pulsa F para guardar la foto y poder verla.',
57
+ savePhotoLabel: '¿Dónde guardo la foto?',
58
+ savePhotoHintInput: 'ruta del archivo (Esc cancela)',
59
+ savingPhoto: 'Guardando la foto…',
60
+ photoSaved: (ruta) => `Foto guardada en ${ruta}`,
61
+ helpMe: ['←→ pestaña', 'r refrescar', 'f guardar foto', 'Esc bóvedas', 'l English', 'q salir'],
37
62
  tabsHint: ' (←→ cambiar)',
38
63
  titleProfiles: 'Bóvedas',
39
64
  titlePairing: 'Emparejar un dispositivo',
@@ -203,6 +228,29 @@ const en = {
203
228
 
204
229
  tabDevices: 'Devices',
205
230
  tabSecrets: 'Scopes & variables',
231
+ tabMe: 'Profile',
232
+ loadingProfile: 'Loading profile…',
233
+ noProfile: ' (this vault has no profile yet)',
234
+ noProfileHint: ' Edit your name or photo on a paired device and press R.',
235
+ profileUpdated: (cuando) => ` updated ${cuando}`,
236
+ fieldName: 'name',
237
+ fieldPhoto: 'photo',
238
+ fieldFirstName: 'first name',
239
+ fieldLastName: 'last name',
240
+ fieldEmail: 'email',
241
+ fieldPhone: 'phone',
242
+ fieldAddress: 'address',
243
+ links: 'Links',
244
+ otherData: 'Other data',
245
+ hidden: ' (hidden)',
246
+ noName: '(no name)',
247
+ no: 'no',
248
+ savePhotoHint: ' Press F to save the photo so you can look at it.',
249
+ savePhotoLabel: 'Where should I save the photo?',
250
+ savePhotoHintInput: 'file path (Esc cancels)',
251
+ savingPhoto: 'Saving the photo…',
252
+ photoSaved: (ruta) => `Photo saved to ${ruta}`,
253
+ helpMe: ['←→ tab', 'r refresh', 'f save photo', 'Esc vaults', 'l Español', 'q quit'],
206
254
  tabsHint: ' (←→ switch)',
207
255
  titleProfiles: 'Vaults',
208
256
  titlePairing: 'Pair a device',
package/src/vault.js CHANGED
@@ -200,6 +200,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
200
200
  args = JSON.parse(await identity.openContent(d.enc))
201
201
  }
202
202
  const result = await threads.methods[d.method](args)
203
+ // Que un aparato ESCRIBA en tu bóveda queda anotado. Antes solo se auditaba el
204
+ // rechazo, así que la bitácora contaba quién entró pero no qué hizo después.
205
+ // Solo la operación y el aparato: nunca el contenido (`activity` es un registro
206
+ // de seguridad, no una copia de lo que guardas).
207
+ if (!STORE_READ_METHODS.has(d.method)) {
208
+ audit('store', { device: await deviceIdOf(chk.device), method: d.method })
209
+ }
203
210
  if (cek) {
204
211
  const enc = await identity.sealContent(JSON.stringify(result ?? null))
205
212
  return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: { __enc: enc } })
@@ -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,44 @@ 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
+ /** Escribe la foto de perfil en `destino` (para poder mirarla) y devuelve la ruta. */
238
+ export async function saveAvatar (destino, profile) {
239
+ requireAlive()
240
+ rm(F.me)
241
+ writeReq(F.meReq, { avatarPath: destino }, profile)
242
+ signalOrCleanup('SIGUSR2', [F.meReq])
243
+ const d = await waitFor(F.me)
244
+ rm(F.me)
245
+ if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
246
+ return d.avatarGuardada || null
247
+ }
248
+
209
249
  // ---------------------------------------------------------------------------
210
250
  // Secretos: scopes (namespaces) y variables (claves)
211
251
  // ---------------------------------------------------------------------------