@dotrino/vaultd 0.129.1 → 0.131.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.129.1",
3
+ "version": "0.131.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
@@ -520,7 +520,9 @@ async function cmdMe () {
520
520
  // El volcado es contenido del usuario: se lee y se BORRA, no se queda ahí suelto.
521
521
  try { fs.rmSync(meFile, { force: true }) } catch (_) {}
522
522
  if (!dump) { console.error('La bóveda no respondió. ¿Está corriendo? dotrino-vault status'); process.exit(1) }
523
- assertOpen(dump)
523
+ // Con el perfil CERRADO se enseña igual lo público (la bóveda ya se lo da a cualquiera);
524
+ // lo que no sale son ni los nombres de los datos privados. Se dice.
525
+ if (dump.locked && !dump.me) assertOpen(dump)
524
526
 
525
527
  const me = dump.me
526
528
  if (!me) {
@@ -530,6 +532,7 @@ async function cmdMe () {
530
532
  }
531
533
 
532
534
  const when = me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—'
535
+ if (dump.locked) console.log('\n(perfil bloqueado: solo lo público; ábrelo con dotrino-vault unlock para ver qué datos privados hay)')
533
536
  console.log('\n%sPerfil%s · actualizado %s\n', B, Z, when)
534
537
  console.log(' nombre : %s', me.nickname || '(sin nombre)')
535
538
  console.log(' foto : %s', me.avatar
@@ -546,6 +549,10 @@ async function cmdMe () {
546
549
  console.log(' %s: %s%s', label.padEnd(12), me[k], me[k + 'Visible'] === false ? ' (oculto)' : '')
547
550
  }
548
551
  }
552
+ // Lo PRIVADO viaja sellado y la bóveda no lo abre para enseñarlo: se dice qué hay.
553
+ if (Array.isArray(me.privateKeys) && me.privateKeys.length) {
554
+ console.log('\n privados : %s (sellados: se ven en tus aparatos, no aquí)', me.privateKeys.join(', '))
555
+ }
549
556
  for (const [title, list] of [['Enlaces', me.links], ['Otros datos', me.fields]]) {
550
557
  if (!Array.isArray(list) || !list.length) continue
551
558
  console.log('\n %s:', title)
@@ -1715,9 +1722,10 @@ async function cmdLogins (rest) {
1715
1722
  /**
1716
1723
  * `dotrino-vault update` — TRAER LA VERSIÓN NUEVA, cuando lo escribe una persona.
1717
1724
  *
1718
- * La mitad automática es MIRAR, y la hace el daemon una vez al día (`src/update.js`
1719
- * explica por qué la otra mitad no se automatiza: sería un canal para meter código en la
1720
- * máquina que guarda la maestra sin que nadie diga que sí).
1725
+ * Con la INSTALACIÓN DE USUARIO (`~/.local/share/dotrino/bin`) el daemon ya se actualiza
1726
+ * solo: pide permiso al teléfono si hay aprobadores y, si no los hay, no hace falta
1727
+ * (dueño, 2026-09-24). Esto es lo mismo a mano, sin esperar al aviso del día. Con el
1728
+ * `.deb` del sistema el binario es de root y aquí solo se verifica y se dice el `sudo`.
1721
1729
  *
1722
1730
  * Lo que se baja SE VERIFICA contra la atestación de sigstore antes de tocar el disco, y
1723
1731
  * si no se puede verificar NO SE INSTALA. Que la URL sea la correcta no prueba nada.
@@ -1726,7 +1734,7 @@ async function cmdLogins (rest) {
1726
1734
  * va para que systemd lo levante con el nuevo (`src/selfupdate.js`).
1727
1735
  */
1728
1736
  async function cmdUpdate (args = []) {
1729
- const { latestRelease, assetFor, download, verifyArtifact, isNewer } = await import('./update.js')
1737
+ const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease } = await import('./update.js')
1730
1738
  const soloMirar = args.includes('--check')
1731
1739
 
1732
1740
  process.stdout.write('Mirando qué hay publicado… ')
@@ -1745,6 +1753,21 @@ async function cmdUpdate (args = []) {
1745
1753
  console.log('%sHay %s y esta es la %s.%s', B, r.version, VERSION, Z)
1746
1754
  if (soloMirar) { console.log('Tráela con: dotrino-vault update'); return }
1747
1755
 
1756
+ // Instalación de usuario: se verifica y se instala aquí mismo, sin sudo.
1757
+ if (isUserInstall()) {
1758
+ process.stdout.write('Bajando y comprobando la firma… ')
1759
+ const res = await installUserRelease(r)
1760
+ if (!res.ok) {
1761
+ console.log('')
1762
+ console.error('%sNO se instala nada.%s %s', R, Z, res.reason)
1763
+ if (res.file) console.error('El archivo quedó en %s, sin tocar nada.', res.file)
1764
+ process.exit(1)
1765
+ }
1766
+ console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
1767
+ console.log('Instalada la %s. El servicio se reinicia solo; compruébalo con: dotrino-vault status', res.version)
1768
+ return
1769
+ }
1770
+
1748
1771
  const pick = assetFor(r.assets)
1749
1772
  if (!pick.ok) { console.error('%s', pick.reason); process.exit(1) }
1750
1773
 
package/src/daemon.js CHANGED
@@ -23,7 +23,7 @@ import { dataDir } from './paths.js'
23
23
  import { ipcRead, ipcWrite, migrateIpcDir } from './ipc.js'
24
24
  import { parseInvite } from '../lib/src/invite.js'
25
25
  import { watchBinary } from './selfupdate.js'
26
- import { latestRelease, isNewer, CHECK_EVERY_MS } from './update.js'
26
+ import { latestRelease, isNewer, CHECK_EVERY_MS, isUserInstall, installUserRelease } from './update.js'
27
27
  import { VERSION } from './version.js'
28
28
  import crypto from 'node:crypto'
29
29
 
@@ -342,6 +342,26 @@ export async function runDaemon () {
342
342
  const meReqFile = path.join(dir, 'me-request.json')
343
343
  const meFile = path.join(dir, 'me.json')
344
344
 
345
+ /**
346
+ * EL PERFIL PARA `dotrino-vault me` Y LA TUI, desde el cajón `@me` (donde escribe el aparato,
347
+ * dato a dato). Leía el `profileGet` del almacén viejo, al que ya no escribe nadie: un perfil
348
+ * nuevo salía SIEMPRE vacío aunque el aparato lo hubiera mandado (dueño, 2026-09-24).
349
+ *
350
+ * `conPrivados`: con el perfil CERRADO no se dicen ni los nombres de los datos privados —el
351
+ * candado esconde nombres igual que con las variables—. Lo público sí: la bóveda ya se lo
352
+ * da a cualquiera por el proxio (`profilePublic`), así que no revela nada.
353
+ */
354
+ function perfilParaMostrar (vault, { conPrivados }) {
355
+ const v = vault.profileView()
356
+ const lista = (s) => { if (typeof s !== 'string') return undefined; try { const a = JSON.parse(s); return Array.isArray(a) ? a : undefined } catch (_) { return undefined } }
357
+ const { avatar, links, fields, ...rest } = v.public
358
+ if (!Object.keys(v.public).length && !(conPrivados && v.private.length)) return null
359
+ return {
360
+ ...rest, avatar: avatarInfo(avatar), links: lista(links), fields: lista(fields),
361
+ ...(conPrivados ? { privateKeys: v.private } : {}), updatedAt: v.updatedAt
362
+ }
363
+ }
364
+
345
365
  /** Resumen de la foto de perfil: qué es y cuánto pesa, nunca los bytes. */
346
366
  function avatarInfo (avatar) {
347
367
  if (typeof avatar !== 'string' || !avatar) return null
@@ -366,7 +386,8 @@ export async function runDaemon () {
366
386
  const v = mgr.get(id)
367
387
  if (!v?.rekeySecrets) return
368
388
  const r = await v.rekeySecrets(vieja, nueva)
369
- if (r?.rekeyed) console.log('[vault] secrets master re-sealed (%d drawer(s))', r.drawers)
389
+ // Se re-sella UNA copia (la de recuperación, que abre el llavero), no cajón por cajón.
390
+ if (r?.rekeyed) console.log('[vault] secrets recovery copy re-sealed under the new key')
370
391
  }
371
392
 
372
393
  /**
@@ -808,7 +829,11 @@ export async function runDaemon () {
808
829
  ipcWrite(path.join(dir, 'acta.json'), { ...closed, members: [] })
809
830
  }
810
831
  rm(meReqFile)
811
- if (meReq) ipcWrite(meFile, { ...closed, req: meReq.id || null, me: null })
832
+ if (meReq) {
833
+ let me = null
834
+ try { me = perfilParaMostrar(t.vault, { conPrivados: false }) } catch (e) { console.error('[vault] could not dump the profile:', e.message) }
835
+ ipcWrite(meFile, { ...closed, req: meReq.id || null, me })
836
+ }
812
837
  return
813
838
  }
814
839
  if (dumpReq) {
@@ -852,9 +877,8 @@ export async function runDaemon () {
852
877
  rm(meReqFile)
853
878
  try {
854
879
  const tm = resolveTarget(meReq) || { id: mgr.currentId(), vault: mgr.current() }
855
- const { me } = tm.vault.threads.methods.profileGet()
856
- const { avatar, ...rest } = me || {}
857
- ipcWrite(meFile, { v: 1, at: Date.now(), req: meReq.id || null, profile: tm.id, me: me ? { ...rest, avatar: avatarInfo(avatar) } : null })
880
+ const me = perfilParaMostrar(tm.vault, { conPrivados: true })
881
+ ipcWrite(meFile, { v: 1, at: Date.now(), req: meReq.id || null, profile: tm.id, me })
858
882
  } catch (e) { console.error('[vault] could not dump the profile:', e.message) }
859
883
  }
860
884
  } catch (e) {
@@ -1025,11 +1049,37 @@ export async function runDaemon () {
1025
1049
  */
1026
1050
  let quienAprueba = []
1027
1051
  try { quienAprueba = (await mgr.current()?.approvers?.()) || [] } catch (_) {}
1028
- const comoLoDice = quienAprueba.length
1029
- ? `waiting for one of ${quienAprueba.length} approver(s) to say yes`
1030
- : 'no approver in the record, so this vault may update itself'
1031
- console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · ${comoLoDice} · dotrino-vault update`)
1052
+
1053
+ // INSTALADA COMO PAQUETE DEL SISTEMA (.deb en /usr/bin): el binario es de root y este
1054
+ // proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
1055
+ if (!isUserInstall()) {
1056
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · installed as a system package, so it cannot update itself: \`dotrino-vault update\` (needs sudo), or move to a user install with the tarball's install.sh`)
1057
+ return
1058
+ }
1059
+ if (actualizando) return
1060
+ actualizando = true
1061
+ try {
1062
+ // PIDE PERMISO SOLO SI HAY A QUIÉN PEDÍRSELO (dueño, 2026-09-20); sin aprobadores
1063
+ // no se necesita aprobación (dueño, 2026-09-24).
1064
+ if (quienAprueba.length) {
1065
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · asking one of ${quienAprueba.length} approver(s)`)
1066
+ const si = await mgr.current().askUpdateApproval({ version: r.version })
1067
+ if (!si) { console.log(`[vault] update to ${r.version}: not approved (denied or expired) · it will ask again`); return }
1068
+ } else {
1069
+ console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · no approver in the record: updating on its own`)
1070
+ }
1071
+ const res = await installUserRelease(r, { log: (m) => console.log(m) })
1072
+ if (!res.ok) {
1073
+ // Lo que NO cuadra no se instala, y se dice por qué: el archivo queda a mano.
1074
+ console.log(`[vault] update to ${r.version} NOT installed: ${res.reason}${res.file ? ` (file left at ${res.file})` : ''}`)
1075
+ return
1076
+ }
1077
+ console.log(`[vault] update: ${r.version} installed · the service restarts on its own`)
1078
+ } catch (e) {
1079
+ console.log(`[vault] update to ${r.version} failed: ${e.message}`)
1080
+ } finally { actualizando = false }
1032
1081
  }
1082
+ let actualizando = false
1033
1083
  mirarRelease()
1034
1084
  const relojRelease = setInterval(mirarRelease, CHECK_EVERY_MS)
1035
1085
  relojRelease.unref?.()
@@ -909,6 +909,24 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
909
909
  * Va firmado igual que lo demás: un dato público sin firma es un dato que cualquiera
910
910
  * puede inventar en tu nombre, que es peor que no tenerlo.
911
911
  */
912
+ profileView () {
913
+ // EL PERFIL TAL COMO LO TIENE LA BÓVEDA, para enseñárselo al dueño (`dotrino-vault me`,
914
+ // la TUI). Sale del cajón `@me`, que es donde escribe el aparato dato a dato. Lo
915
+ // público va en claro; de lo privado solo el NOMBRE del dato: viaja sellado y la
916
+ // bóveda no lo abre para enseñarlo (§8.1), igual que una variable privada.
917
+ const vars = data.ns[PROFILE_NS]?.vars || {}
918
+ const pub = {}
919
+ const priv = []
920
+ let at = 0
921
+ for (const [key, e] of Object.entries(vars)) {
922
+ if (!e) continue
923
+ at = Math.max(at, e.at || 0)
924
+ if (e.cls === 'public' && typeof e.pubv === 'string') pub[key] = e.pubv
925
+ else priv.push(key)
926
+ }
927
+ return { public: pub, private: priv.sort(), updatedAt: at || null }
928
+ },
929
+
912
930
  profilePublic () {
913
931
  const bag = data.ns[PROFILE_NS]
914
932
  if (!bag?.vars) return {}
package/src/tui/app.js CHANGED
@@ -620,6 +620,11 @@ function meRows (st, t) {
620
620
  if (filled.length) rows.push({ text: '', sel: false })
621
621
  for (const [k, label] of filled) field(label, me[k], me[k + 'Visible'] === false)
622
622
 
623
+ // Lo PRIVADO viaja sellado y la bóveda no lo abre: se dice qué hay, no qué dice.
624
+ if (Array.isArray(me.privateKeys) && me.privateKeys.length) {
625
+ rows.push({ text: '', sel: false })
626
+ field(i.privateData, me.privateKeys.join(', ') + t.muted(i.privateDataHint))
627
+ }
623
628
  for (const [title, list] of [[i.links, me.links], [i.otherData, me.fields]]) {
624
629
  if (!Array.isArray(list) || !list.length) continue
625
630
  rows.push({ text: '', sel: false })
package/src/tui/i18n.js CHANGED
@@ -148,6 +148,8 @@ const es = {
148
148
  fieldAddress: 'dirección',
149
149
  links: 'Enlaces',
150
150
  otherData: 'Otros datos',
151
+ privateData: 'Privados',
152
+ privateDataHint: ' (sellados: se ven en tus aparatos, no aquí)',
151
153
  hidden: ' (oculto)',
152
154
  no: 'no',
153
155
  helpMe: ['←→ pestaña', 'F5 refrescar', 'Esc bóvedas', 'l English', 'q salir'],
@@ -537,6 +539,8 @@ const en = {
537
539
  fieldAddress: 'address',
538
540
  links: 'Links',
539
541
  otherData: 'Other data',
542
+ privateData: 'Private',
543
+ privateDataHint: ' (sealed: they show on your devices, not here)',
540
544
  hidden: ' (hidden)',
541
545
  noName: '(no name)',
542
546
  no: 'no',
package/src/update.js CHANGED
@@ -10,8 +10,11 @@
10
10
  * mismo son dos sitios donde arreglar el mismo fallo, y el segundo siempre se olvida.
11
11
  */
12
12
  import fs from 'node:fs'
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+ import { execFileSync } from 'node:child_process'
13
16
  import { latestVersion, isNewer, CHECK_EVERY_MS } from '@dotrino/update'
14
- import { pickAsset, download, verifyArtifact as verify } from '@dotrino/update/fetch'
17
+ import { pickAsset, download, verifyArtifact as verify, fetchVerified } from '@dotrino/update/fetch'
15
18
  import { VERSION } from './version.js'
16
19
 
17
20
  export const REPO = 'imdotrino/dotrino-vault'
@@ -37,3 +40,56 @@ export function assetFor (assets, { platform = process.platform, arch = process.
37
40
 
38
41
  /** Verifica contra la atestación del release del vault. Sin `gh`, no se instala. */
39
42
  export const verifyArtifact = (file, opts = {}) => verify(file, { repo: REPO, ...opts })
43
+
44
+
45
+ /**
46
+ * INSTALACIÓN DE USUARIO: los binarios viven en `~/.local/share/dotrino/bin`, que es del
47
+ * usuario, y la bóveda se actualiza sin `sudo` (dueño, 2026-09-24).
48
+ *
49
+ * Es esa carpeta y no `~/.local/bin` por una razón concreta: la unidad de systemd lleva
50
+ * `ProtectHome=read-only` y solo deja escribir en `~/.local/share/dotrino`. Desde ahí el
51
+ * daemon puede reemplazar su propio binario; en `~/.local/bin` no podría.
52
+ *
53
+ * El precio, dicho: un proceso de ese mismo usuario también podría reemplazarlo, cosa que
54
+ * con el `.deb` en `/usr/bin` (de root) no. En el modelo de la bóveda la frontera ya es el
55
+ * usuario (memoria `dotrino-aprobacion-comando`), y el binario nuevo solo entra verificado.
56
+ */
57
+ export function userBinDir (home = os.homedir()) {
58
+ return path.join(home, '.local', 'share', 'dotrino', 'bin')
59
+ }
60
+
61
+ /** ¿Corre este proceso desde la instalación de usuario? Solo entonces puede actualizarse solo. */
62
+ export function isUserInstall (execPath = process.execPath, home = os.homedir()) {
63
+ return path.dirname(execPath) === userBinDir(home)
64
+ }
65
+
66
+ /**
67
+ * Baja el tarball del release, lo VERIFICA contra su atestación y cambia los dos binarios
68
+ * de forma atómica (se escribe al lado y se renombra encima: nunca queda uno a medias).
69
+ * `selfupdate.js` ve el binario nuevo y el servicio se reinicia solo.
70
+ *
71
+ * Si la verificación falla no se toca nada, y se devuelve por qué.
72
+ *
73
+ * @returns {Promise<{ok:true, version:string} | {ok:false, code?:string, reason:string, file?:string}>}
74
+ */
75
+ export async function installUserRelease (release, { binDir = userBinDir(), log = () => {}, fetchVerifiedImpl = fetchVerified } = {}) {
76
+ const pick = pickAsset(release.assets, [{ kind: 'tar', re: /-linux-x64\.tar\.gz$/ }])
77
+ if (!pick.ok) return pick
78
+ const got = await fetchVerifiedImpl(pick.asset, { repo: REPO, product: 'dotrino-vault', version: VERSION })
79
+ if (!got.ok) return got
80
+ log(`[vault] update: ${pick.asset.name} verified against its attestation`)
81
+ const work = path.dirname(got.file)
82
+ execFileSync('tar', ['xzf', got.file, '-C', work])
83
+ const src = fs.readdirSync(work).map((d) => path.join(work, d)).find((d) => fs.existsSync(path.join(d, 'dotrino-vaultd')))
84
+ if (!src) return { ok: false, code: 'BAD_TARBALL', reason: 'the tarball has no dotrino-vaultd inside' }
85
+ fs.mkdirSync(binDir, { recursive: true })
86
+ // El CLI primero y el daemon al final: cambiar el daemon es lo que dispara el reinicio.
87
+ for (const name of ['dotrino-vault', 'dotrino-vaultd']) {
88
+ const tmp = path.join(binDir, `.${name}.new`)
89
+ fs.copyFileSync(path.join(src, name), tmp)
90
+ fs.chmodSync(tmp, 0o755)
91
+ fs.renameSync(tmp, path.join(binDir, name))
92
+ }
93
+ try { fs.rmSync(work, { recursive: true, force: true }) } catch (_) {}
94
+ return { ok: true, version: release.version }
95
+ }
package/src/vault.js CHANGED
@@ -37,7 +37,7 @@ import { loadManifest, brokenOf } from '@dotrino/roadmap'
37
37
  // espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
38
38
  // llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
39
39
  // que lo recorriera hasta que la hubo (dotrino-test, smoke:demonio, 2026-08-30).
40
- import { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, GRANT_TTL_MS } from './approvals.js'
40
+ import { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, GRANT_TTL_MS, UPDATE_TTL_MS } from './approvals.js'
41
41
  import { readProcContext, sanitizeContext, commandFingerprint, commandLine } from '../lib/src/proc.js'
42
42
  import { makeContentKey, encryptWithCek, wrapForMember } from '@dotrino/identity/content'
43
43
  import { makeSealer } from './sealer.js'
@@ -167,11 +167,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
167
167
  * variables (la escritura se aplica al aprobar). Si nadie contesta, vence solo con un no:
168
168
  * nada se queda colgado para siempre.
169
169
  */
170
- async function awaitApproval ({ device, ns, kind, what, op = null, ctx = null, onAnswer }) {
170
+ async function awaitApproval ({ device, ns, kind, what, op = null, ctx = null, onAnswer, ttlMs = PENDING_TTL_MS }) {
171
171
  const deviceId = await deviceIdOf(device).catch(() => null)
172
172
  const record = await refreshActa()
173
173
  const label = (record?.members || []).find((m) => m.pub === device)?.label || ''
174
- const pend = approvals.request({ ns, device, deviceId, label, ek: '', ctx, kind })
174
+ const pend = approvals.request({ ns, device, deviceId, label, ek: '', ctx, kind, ttlMs })
175
175
  audit(`${what}.pending`, { device: deviceId, ns, id: pend.id, op })
176
176
  log(`[vault] ${what}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
177
177
  waiters.set(pend.id, onAnswer)
@@ -180,7 +180,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
180
180
  audit(`${what}.expired`, { device: deviceId, ns, id: pend.id })
181
181
  onAnswer(false)
182
182
  }
183
- }, PENDING_TTL_MS)
183
+ }, ttlMs)
184
184
  t.unref?.()
185
185
  await notifyApprovers(pend, record)
186
186
  return pend
@@ -1621,7 +1621,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1621
1621
  const resolver = waiters.get(id)
1622
1622
  waiters.delete(id)
1623
1623
  const ok = op === 'approve'
1624
- const what = pend.kind === 'write' ? 'vars' : 'passwords'
1624
+ const what = pend.kind === 'write' ? 'vars' : pend.kind === 'update' ? 'update' : 'passwords'
1625
1625
  audit(ok ? `${what}.approved` : `${what}.denied`, { device: pend.deviceId, ns: pend.ns, id, by })
1626
1626
  log(`[vault] ${what}: request ${pend.id} of ${pend.deviceId} ${ok ? 'approved' : 'DENIED'} by ${by}`)
1627
1627
  // Lo aprobado SE HACE AQUÍ (una escritura se aplica ahora), y si falla quien aprobó
@@ -3655,6 +3655,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3655
3655
  * aprobadores que se quedaría sin poder actualizarse nunca, esperando un sí que nadie
3656
3656
  * puede dar. Mismo sitio del que ya sale el «rang N approver(s)» de los secretos.
3657
3657
  */
3658
+ /**
3659
+ * PEDIR PERMISO PARA ACTUALIZARSE a `version` (la instala el daemon si se aprueba). Es
3660
+ * un pedido como los demás —sale en Pedidos del teléfono, `kind: 'update'`—, pero dura
3661
+ * un día (`UPDATE_TTL_MS`): no hay nadie esperando delante de una terminal.
3662
+ * @returns {Promise<boolean>} sí o no; vencido es no.
3663
+ */
3664
+ askUpdateApproval: async ({ version }) => {
3665
+ const yo = identity.me?.publickey || null
3666
+ return new Promise((resolve) => {
3667
+ awaitApproval({
3668
+ device: yo, ns: 'vault', kind: 'update', what: 'update',
3669
+ ctx: { version, from: VERSION }, ttlMs: UPDATE_TTL_MS, onAnswer: resolve
3670
+ }).catch((e) => { log(`[vault] update: could not ask for approval: ${e.message}`); resolve(false) })
3671
+ })
3672
+ },
3658
3673
  approvers: async () => {
3659
3674
  const record = await refreshActa()
3660
3675
  if (!record) return []
@@ -3703,6 +3718,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3703
3718
  return { ok: true, pub: found.pub, deviceId: found.deviceId }
3704
3719
  },
3705
3720
  listApprovals: () => approvals.list(),
3721
+ // El perfil desde el cajón `@me` (lo que empujan los aparatos): ver `profileView`.
3722
+ profileView: () => secrets.profileView(),
3706
3723
  // LAS CONCESIONES VIVAS, para verlas y para cortarlas. Se enseñan en la máquina del
3707
3724
  // dueño (TUI/CLI), donde el contexto ya es suyo y no hay nada que sellar.
3708
3725
  listGrants: () => grants.list(),
@@ -500,7 +500,8 @@ export async function getMe (profile) {
500
500
  const d = await waitFor(F.me, { req: id, since })
501
501
  rm(F.me)
502
502
  if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
503
- assertOpen(d)
503
+ // Con el perfil cerrado llega igual lo público (ver `perfilParaMostrar` en daemon.js).
504
+ if (d.locked && !d.me) assertOpen(d)
504
505
  return d.me || null
505
506
  }
506
507