@dotrino/vaultd 0.116.0 → 0.118.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/README.md CHANGED
@@ -353,7 +353,9 @@ dispositivos/variables que estás viendo:
353
353
  1. **Bóvedas** es la pantalla de entrada: lista tus perfiles (`↑↓` mover, `Enter`
354
354
  **entrar** a uno — lo activa si no lo estaba). Ahí también **conectas un
355
355
  dispositivo** con `p` (sin entrar: activa la bóveda elegida y abre la pregunta de
356
- a qué cuenta entra), creas una bóveda nueva, renombras, borras y pones/quitas/usas
356
+ a qué cuenta entra), **metes esta bóveda en la cuenta de otra** con `j` (pegas la
357
+ invitación que enseña la otra y tecleas allí el código que sale aquí: lo mismo que
358
+ `dotrino-vault join`), creas una bóveda nueva, renombras, borras y pones/quitas/usas
357
359
  la contraseña (candado).
358
360
  2. Al entrar caes en sus **pestañas horizontales**, que cambias con `←→`:
359
361
  - **Dispositivos (pares):** verlos, **emparejar** uno nuevo, **aprobar** con el
@@ -391,7 +393,8 @@ se recuerda en `prefs.json` del dir de datos, y al arrancar se decide en este or
391
393
  sistema (`LC_ALL`, `LC_MESSAGES`, `LANGUAGE` o `LANG`) → español.
392
394
 
393
395
  **Las teclas NO cambian con el idioma**: son mnemónicos en **inglés** y valen igual en
394
- español (solo se traduce la palabra que las explica en la barra de ayuda).
396
+ español (solo se traduce la palabra que las explica en la barra de ayuda). Si la barra no
397
+ cabe en una línea, se reparte en varias; nunca se esconde una tecla.
395
398
 
396
399
  | Tecla | Acción | Dónde |
397
400
  |---|---|---|
@@ -400,6 +403,7 @@ español (solo se traduce la palabra que las explica en la barra de ayuda).
400
403
  | `r` | rename (Bóvedas) · refresh (Dispositivos/Scopes) · restart (Emparejar) | — |
401
404
  | `d` | delete — borrar la bóveda | Bóvedas |
402
405
  | `p` | **pair — conectar un dispositivo** (desde Bóvedas entra directo, sin `Enter`) | Bóvedas · Dispositivos |
406
+ | `j` | join — entrar en la cuenta de otra bóveda (pegando su invitación) | Bóvedas |
403
407
  | `c` | change password — poner/cambiar la contraseña | Bóvedas |
404
408
  | `x` | quitar: contraseña · dispositivo pendiente · variable/scope | Bóvedas · Dispositivos · Scopes · Emparejar |
405
409
  | `u` / `k` | unlock / locK — candado de la bóveda | Bóvedas |
@@ -22,10 +22,10 @@ import fs from 'node:fs'
22
22
  import path from 'node:path'
23
23
  import { createHash } from 'node:crypto'
24
24
  import {
25
- makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
25
+ makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice,
26
26
  verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
27
27
  import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
28
- import { verifyActa, sealKeyAt, sealersOf, memberCan } from '@dotrino/identity/acta'
28
+ import { verifyActa, sealKeyAt, memberCan, checkVaultReply } from '@dotrino/identity/acta'
29
29
  import { MSG, secretsScope, isValidSecretsNs, VAULT_PROTOCOL, VAULT_SPEAKS } from './protocol.js'
30
30
  import { PKG_VERSION } from './version.js'
31
31
  import { makeEphemeralKey, openSealed } from './sealed.js'
@@ -400,18 +400,15 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
400
400
  // por reloj, dice el `seq` del acta con el que se emitió, y quien lo emitió tiene que
401
401
  // ser SELLADORA de ese acta. Sin acta no se puede comprobar nada de eso, y entrar a
402
402
  // ciegas en un perfil es justo lo que no se hace.
403
- const acta = res.acta
404
- if (!acta) throw new Error('the vault did not send its record: cannot check who signed this cert')
405
- // Y el acta tiene que ser la DEL PERFIL DEL QR. Antes esto se comprobaba mirando que el
406
- // cert lo firmara `qr.iss`; con varias selladoras el emisor puede ser otra llave del
407
- // mismo perfil, así que lo que se fija es el perfil, no la llave.
408
- if (acta.profileId !== qr.iss) throw new Error('the record is from a profile other than the one in the QR')
409
- const v = await verifyDelegation({
410
- cert: res.cert, expectedSub: device.publickey,
411
- actaSeq: acta.seq, sealers: sealersOf(acta),
412
- ...(expectedScope ? { expectedScope } : {})
403
+ //
404
+ // Se juzga contra la llave de LA BÓVEDA del QR, con la regla del pilar. Antes se exigía
405
+ // `acta.profileId === qr.iss`, que solo es verdad en una cuenta que nació en esa bóveda:
406
+ // un servicio no podía emparejarse con una segunda bóveda ni con una que adoptó la cuenta.
407
+ const chk = await checkVaultReply({
408
+ acta: res.acta, cert: res.cert, vault: qr.iss, sub: device.publickey,
409
+ scope: expectedScope || null, justSealed: true
413
410
  })
414
- if (!v.ok) throw new Error('invalid cert: ' + v.reason)
411
+ if (!chk.ok) throw new Error('the vault reply does not check out: ' + chk.reason)
415
412
 
416
413
  return { device, enc: enc ? { publickey: enc.encPublickey, privateJwk: enc.encPrivateJwk } : null, cert: res.cert, iss: qr.iss, proxy: qr.proxy || 'wss://proxy.dotrino.com' }
417
414
  } finally { client.close() }
@@ -599,7 +596,13 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
599
596
  const pending = waitForMsg(client, (p) => p.type === MSG.RENEWED || p.type === MSG.ERROR, 15000)
600
597
  client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
601
598
  const res = await pending
602
- if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
599
+ // SE VERIFICA ANTES DE GUARDARLO. Aquí solo se miraba que fuera para esta llave: un
600
+ // papel cualquiera, sin firma que valga, sustituía al bueno y el servicio se quedaba
601
+ // fuera hasta el siguiente arranque. Misma regla que al enrolarse, sin `justSealed`:
602
+ // la última acta pudo sellarla otra selladora de la cuenta.
603
+ const ok = res.type === MSG.RENEWED &&
604
+ (await checkVaultReply({ acta: res.acta, cert: res.cert, vault: masterPubkey, sub: device.publickey })).ok
605
+ if (ok) {
603
606
  cert = res.cert
604
607
  recordar({ cert })
605
608
  // QUIEN NO PASA `dir` GUARDA ÉL. Un servicio que lleva su identidad en su propio
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.116.0",
3
+ "version": "0.118.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": {
@@ -22,10 +22,10 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@dotrino/compat": "0.4.0",
25
- "@dotrino/identity": "0.81.0",
25
+ "@dotrino/identity": "0.92.0",
26
26
  "@dotrino/passmanager": "^0.10.1",
27
27
  "@dotrino/proxy-client": "0.18.2",
28
- "@dotrino/roadmap": "0.2.0",
28
+ "@dotrino/roadmap": "0.3.3",
29
29
  "@dotrino/store": "0.11.0",
30
30
  "ws": "^8.18.0"
31
31
  },
package/src/client.js CHANGED
@@ -11,19 +11,10 @@
11
11
  * estrictamente (firmado por la maestra que vio en el QR, y para SU clave) antes de
12
12
  * guardarlo. La maestra nunca sale del vault.
13
13
  */
14
- import { makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
14
+ import { makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
15
15
  import { myContentKey, encryptWithCek, decryptWithKeyring } from '@dotrino/identity/content'
16
- import { sealersOf } from '@dotrino/identity/acta'
17
-
18
- /**
19
- * Con qué se juzga un papel: el ACTA que la bóveda manda junto a él. Sustituye a comparar
20
- * `cert.iss` con la maestra — con varias selladoras el emisor puede ser otra llave del mismo
21
- * perfil, así que lo que se fija es el PERFIL, no la llave.
22
- */
23
- function contexto (acta) {
24
- if (!acta) throw new Error('the vault did not send its record: cannot check who signed this cert')
25
- return { actaSeq: acta.seq, sealers: sealersOf(acta) }
26
- }
16
+ // Con qué se juzga lo que devuelve la bóveda (papel + acta): la regla del pilar, una sola.
17
+ import { checkVaultReply } from '@dotrino/identity/acta'
27
18
  import { installNodeGlobals } from './node-globals.js'
28
19
  import { MSG } from './protocol.js'
29
20
 
@@ -102,11 +93,11 @@ export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeout
102
93
  client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
103
94
  const res = await enrolled
104
95
 
105
- // VALIDACIÓN ESTRICTA antes de persistir (cierra inyección de cert / sustitución de maestra).
106
- if (res.acta?.profileId !== qr.iss) throw new Error('the record is from a profile other than the one you saw (possible malicious proxy)')
107
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...contexto(res.acta) })
108
- if (!v.ok) throw new Error('invalid cert: ' + v.reason)
109
- if (res.cert.sub !== device.publickey) throw new Error('cert issued for a different device')
96
+ // VALIDACIÓN ESTRICTA antes de persistir (cierra inyección de cert / sustitución de maestra),
97
+ // contra la llave de la bóveda del QR y con la regla del pilar: vale también para una
98
+ // segunda bóveda o una que adoptó la cuenta, donde esa llave no es el `profileId`.
99
+ const chk = await checkVaultReply({ acta: res.acta, cert: res.cert, vault: qr.iss, sub: device.publickey, justSealed: true })
100
+ if (!chk.ok) throw new Error('the vault reply does not check out: ' + chk.reason)
110
101
  // OJO: devolvemos qr.iss (la maestra que el usuario VIO), NO res.iss.
111
102
  return { device: { ...device, encPublickey: enc.encPublickey, encPrivateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, acta: res.acta }
112
103
  } finally { client.close() }
@@ -168,11 +159,10 @@ export async function requestRenew ({ masterPubkey, proxyUrl, device, cert, dir
168
159
  client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
169
160
  const res = await pending
170
161
  if (res.type === MSG.ERROR) throw new Error(res.error)
171
- // Se valida antes de devolverlo, igual que en el enrolamiento: un cert que no está
172
- // firmado por la maestra que ya conocemos, o que no es para esta llave, no se guarda.
173
- if (res.acta?.profileId !== masterPubkey) throw new Error('the record is from a profile other than the pinned one')
174
- const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...contexto(res.acta) })
175
- if (!v.ok) throw new Error('invalid cert: ' + v.reason)
162
+ // Se valida antes de devolverlo, igual que en el enrolamiento: un cert que no firmó la
163
+ // bóveda que ya conocemos, o que no es para esta llave, no se guarda.
164
+ const chk = await checkVaultReply({ acta: res.acta, cert: res.cert, vault: masterPubkey, sub: device.publickey })
165
+ if (!chk.ok) throw new Error('invalid renewed cert: ' + chk.reason)
176
166
  return { cert: res.cert }
177
167
  } finally { client.close() }
178
168
  }
package/src/ctl.js CHANGED
@@ -453,7 +453,10 @@ async function cmdJoin (rest) {
453
453
  const s = requireDaemon()
454
454
  const res = path.join(dir, 'join.json')
455
455
  try { fs.rmSync(res, { force: true }) } catch (_) {}
456
- writeReq('join-request.json', { qr, label: 'bóveda', ...(name ? { name } : {}), ...(kek ? { kek } : {}) })
456
+ // El `id` vuelve en cada respuesta: el daemon ya no se bloquea mientras espera, así que
457
+ // otra consola puede lanzar su propio `join` y su respuesta cae en el mismo archivo.
458
+ const reqId = crypto.randomUUID()
459
+ writeReq('join-request.json', { id: reqId, qr, label: 'bóveda', ...(name ? { name } : {}), ...(kek ? { kek } : {}) })
457
460
  sendSignal(s.pid, 'SIGUSR2')
458
461
 
459
462
  console.log('Entrando en la cuenta de la otra bóveda…')
@@ -461,7 +464,7 @@ async function cmdJoin (rest) {
461
464
  for (let i = 0; i < 900; i++) { // hasta 3 min: hay un humano tipeando al otro lado
462
465
  await sleep(200)
463
466
  const d = ipcRead(res, null)
464
- if (!d) continue
467
+ if (!d || ('req' in d && d.req !== reqId)) continue
465
468
  if (d.code && d.code !== visto) {
466
469
  visto = d.code
467
470
  console.log('\n Tipea este código en la OTRA bóveda: dotrino-vault approve %s\n', d.code)
package/src/daemon.js CHANGED
@@ -256,6 +256,58 @@ export async function runDaemon () {
256
256
  // es el papel contrario — aquí no se invita a nadie, se acepta una invitación ajena.
257
257
  const joinReqFile = path.join(dir, 'join-request.json')
258
258
  const joinResFile = path.join(dir, 'join.json')
259
+ /** Hay un `join` esperando su código. Uno a la vez: el perfil que nace es para ESE intento. */
260
+ let joining = false
261
+
262
+ async function runJoin (join, answer) {
263
+ // LA CUENTA AJENA VA EN UN PERFIL DEL GESTOR, no en una cuenta interna de la
264
+ // identidad. Antes esto usaba `enrollDevice(…, { join: 'new' })`, que crea una
265
+ // cuenta más DENTRO de la identidad de un perfil que ya existía —y el gestor no se
266
+ // enteraba—: no había instancia de bóveda para ella, nadie se identificaba en el
267
+ // proxio con esa llave, y el aviso de la otra bóveda —con el acta donde acababa de
268
+ // conceder `sella`— no llegaba a ninguna parte. Se unía y no servía para nada.
269
+ //
270
+ // Un perfil nace vacío y `adopt: true`, que es la marca que deja a `joinProfile`
271
+ // cambiar su acta recién nacida por la que traiga la otra bóveda; sin ella, unirse
272
+ // sería pisar una cuenta con datos y se rechaza, que es lo correcto por defecto.
273
+ let nacido = null
274
+ try {
275
+ const p = await mgr.add(join.name || 'cuenta de la otra bóveda', { adopt: true, kek: join.kek || null })
276
+ nacido = p.id
277
+ const id = mgr.get(p.id)?.identity
278
+ if (!id) throw new Error('no identity for the new profile')
279
+ const off = id.onVault?.((e) => {
280
+ if (e?.phase === 'challenge' && e.code) {
281
+ console.log('[vault] type this code in the other vault: %s', e.code)
282
+ answer({ code: e.code, state: 'waiting', profile: p.id })
283
+ }
284
+ })
285
+ // `'current'` y no `'new'`: el perfil que se acaba de crear ES el sitio, y su
286
+ // llave recién hecha es la que entra en el acta de la otra bóveda. Crear ahí
287
+ // dentro otra cuenta más sería el bug de arriba otra vez, un nivel más abajo.
288
+ // `seq` es el del acta con la que la otra bóveda emitió el papel (`pairWithVault`
289
+ // de @dotrino/identity). Antes se leía `r.acta.seq`, que no existe: el aviso decía
290
+ // siempre «acta #?».
291
+ let r
292
+ try { r = await id.enrollDevice(join.qr, { label: join.label || 'bóveda', join: 'current' }) } finally { off?.() }
293
+ // LA CUENTA RECIÉN ADOPTADA PASA A SER LA ACTIVA, y no es un capricho: es la
294
+ // razón por la que se hizo el `join`. Sin esto, el `Perfil 1` vacío que nace en
295
+ // el primer arranque sigue siendo el destino por defecto y todo lo que hagas
296
+ // después —emparejar un aparato, aprobarlo— entra en la cuenta equivocada sin
297
+ // decir nada. En un contenedor que arranca para respaldar una cuenta, ese perfil
298
+ // vacío no es más que un accidente del primer arranque.
299
+ try { mgr.profiles.setCurrent(p.id) } catch (_) {}
300
+ console.log('[vault] joined the account of the other vault (record #%s) as profile %s (now the active one)', r.seq, p.id)
301
+ answer({ state: 'done', seq: r.seq, profile: p.id })
302
+ writeState()
303
+ } catch (e) {
304
+ console.error('[vault] could not join:', e.message)
305
+ // El perfil nació para esto y está vacío: si el intento no llegó a término se va
306
+ // con él. Si no, cada reintento dejaba una cuenta fantasma en el conmutador.
307
+ if (nacido) { try { await mgr.remove(nacido) } catch (_) {} }
308
+ answer({ state: 'error', error: e.message })
309
+ }
310
+ }
259
311
  const secretsListFile = path.join(dir, 'secrets-list.json')
260
312
  /**
261
313
  * Por qué falló la última orden de variables. La consola pide el cambio y el volcado
@@ -512,49 +564,22 @@ export async function runDaemon () {
512
564
  const join = readJsonSafe(joinReqFile)
513
565
  if (join?.qr) {
514
566
  rm(joinReqFile)
515
- rm(joinResFile)
516
- // LA CUENTA AJENA VA EN UN PERFIL DEL GESTOR, no en una cuenta interna de la
517
- // identidad. Antes esto usaba `enrollDevice(…, { join: 'new' })`, que crea una
518
- // cuenta más DENTRO de la identidad de un perfil que ya existía —y el gestor no se
519
- // enteraba—: no había instancia de bóveda para ella, nadie se identificaba en el
520
- // proxio con esa llave, y el aviso de la otra bóveda —con el acta donde acababa de
521
- // conceder `sella`— no llegaba a ninguna parte. Se unía y no servía para nada.
522
- //
523
- // Un perfil nace vacío y `adopt: true`, que es la marca que deja a `joinProfile`
524
- // cambiar su acta recién nacida por la que traiga la otra bóveda; sin ella, unirse
525
- // sería pisar una cuenta con datos y se rechaza, que es lo correcto por defecto.
526
- let nacido = null
527
- try {
528
- const p = await mgr.add(join.name || 'cuenta de la otra bóveda', { adopt: true, kek: join.kek || null })
529
- nacido = p.id
530
- const id = mgr.get(p.id)?.identity
531
- if (!id) throw new Error('no identity for the new profile')
532
- const off = id.onVault?.((e) => {
533
- if (e?.phase === 'challenge' && e.code) {
534
- console.log('[vault] type this code in the other vault: %s', e.code)
535
- ipcWrite(joinResFile, { at: Date.now(), code: e.code, state: 'waiting', profile: p.id })
536
- }
537
- })
538
- // `'current'` y no `'new'`: el perfil que se acaba de crear ES el sitio, y su
539
- // llave recién hecha es la que entra en el acta de la otra bóveda. Crear ahí
540
- // dentro otra cuenta más sería el bug de arriba otra vez, un nivel más abajo.
541
- const r = await id.enrollDevice(join.qr, { label: join.label || 'bóveda', join: 'current' })
542
- off?.()
543
- // LA CUENTA RECIÉN ADOPTADA PASA A SER LA ACTIVA, y no es un capricho: es la
544
- // razón por la que se hizo el `join`. Sin esto, el `Perfil 1` vacío que nace en
545
- // el primer arranque sigue siendo el destino por defecto y todo lo que hagas
546
- // después —emparejar un aparato, aprobarlo— entra en la cuenta equivocada sin
547
- // decir nada. En un contenedor que arranca para respaldar una cuenta, ese perfil
548
- // vacío no es más que un accidente del primer arranque.
549
- try { mgr.profiles.setCurrent(p.id) } catch (_) {}
550
- console.log('[vault] joined the account of the other vault (record #%s) as profile %s (now the active one)', r?.acta?.seq ?? '?', p.id)
551
- ipcWrite(joinResFile, { at: Date.now(), state: 'done', seq: r?.acta?.seq ?? null, profile: p.id })
552
- } catch (e) {
553
- console.error('[vault] could not join:', e.message)
554
- // El perfil nació para esto y está vacío: si el intento no llegó a término se va
555
- // con él. Si no, cada reintento dejaba una cuenta fantasma en el conmutador.
556
- if (nacido) { try { await mgr.remove(nacido) } catch (_) {} }
557
- ipcWrite(joinResFile, { at: Date.now(), state: 'error', error: e.message })
567
+ // Cada respuesta lleva el `id` de la petición, para que quien espera no tome por
568
+ // suya la de otro intento (la TUI y la CLI leen el mismo archivo).
569
+ const answer = (extra) => ipcWrite(joinResFile, { at: Date.now(), req: join.id || null, ...extra })
570
+ if (joining) {
571
+ console.error('[vault] join rejected: another join is still waiting for its code')
572
+ answer({ state: 'error', errorCode: 'JOIN_BUSY', error: 'another join is still waiting for its code' })
573
+ } else {
574
+ rm(joinResFile)
575
+ joining = true
576
+ // NO SE ESPERA AQUÍ. Unirse aguarda a que una persona teclee el código en la otra
577
+ // bóveda —hasta 3 minutos—, y `serve()` atiende las peticiones de una en una: con
578
+ // un `await` en este punto el daemon no contestaba NADA durante ese rato. La CLI
579
+ // no lo notaba porque se quedaba quieta esperando; la TUI sí, porque sigue pidiendo
580
+ // cosas (la lista de bóvedas, el «sigo aquí» del candado) y cada una acababa en
581
+ // «el daemon no respondió».
582
+ runJoin(join, answer).finally(() => { joining = false })
558
583
  }
559
584
  }
560
585
  // Secretos: `secret set/rm` (por SCOPE) y `secret device set/rm` (por APARATO),
package/src/tui/app.js CHANGED
@@ -24,7 +24,7 @@
24
24
  *
25
25
  * LAS TECLAS NO CAMBIAN CON EL IDIOMA: son mnemónicos en INGLÉS y son las mismas
26
26
  * en español (lo que se traduce es la palabra que las explica en la barra de
27
- * ayuda). new · rename · delete · password · unlock · locK · pair · approve ·
27
+ * ayuda). new · rename · delete · password · unlock · locK · pair · join · approve ·
28
28
  * reject · reVoke · refresh · back · language · quit. Una tecla significa LO MISMO
29
29
  * en todas las pantallas: `p` es SIEMPRE emparejar (también en Bóvedas, sin tener
30
30
  * que entrar antes), el candado es `k` (la `l` es el idioma) y la contraseña `c`.
@@ -67,6 +67,8 @@ function humanErr (e, st) {
67
67
  NOT_APPLIED: t.errNotApplied,
68
68
  NOT_DELETED: t.errNotDeleted,
69
69
  PAIR_FAILED: t.errPairFailed,
70
+ INVITE_INVALID: t.errInviteInvalid,
71
+ JOIN_BUSY: t.errJoinBusy,
70
72
  APPROVE_FAILED: t.errWrongCode,
71
73
  MASTER_WITH_MEMBERS: t.errMasterWithMembers,
72
74
  PROFILE_LOCKED: t.errProfileLocked
@@ -230,6 +232,41 @@ function fitHelp (segs, cols) {
230
232
  return join([...head, '…', ...tail])
231
233
  }
232
234
 
235
+ /**
236
+ * La barra de ayuda en VARIAS líneas cuando no cabe en una. Recortar del medio escondía
237
+ * justo las teclas de la pantalla —en 80 columnas, Bóvedas perdía la mitad—, y una tecla
238
+ * que no se ve es una tecla que no existe para quien no la sepa de memoria.
239
+ *
240
+ * Se reparte por segmentos enteros (una tecla nunca se parte en dos líneas). `maxLines`
241
+ * es lo que deja el alto del terminal: si ni así caben, la última línea vuelve a recortar
242
+ * del medio con `fitHelp`, que conserva el idioma y la salida.
243
+ */
244
+ function wrapHelp (segs, cols, maxLines = Infinity) {
245
+ const sep = ' · '
246
+ const lines = []
247
+ let cur = []
248
+ for (const seg of segs) {
249
+ if (cur.length && widthOf([...cur, seg].join(sep)) + 1 > cols) { lines.push(cur); cur = [seg] } else cur.push(seg)
250
+ }
251
+ if (cur.length) lines.push(cur)
252
+ if (!lines.length) return ['']
253
+ if (lines.length <= maxLines) return lines.map((l) => l.join(sep))
254
+ const keep = Math.max(0, maxLines - 1)
255
+ return [...lines.slice(0, keep).map((l) => l.join(sep)), fitHelp(lines.slice(keep).flat(), cols)]
256
+ }
257
+
258
+ /** Texto corrido partido por palabras para que quepa en `width` columnas. */
259
+ function wrapWords (text, width) {
260
+ const out = []
261
+ let line = ''
262
+ for (const word of String(text).split(/\s+/).filter(Boolean)) {
263
+ const cand = line ? line + ' ' + word : word
264
+ if (line && widthOf(cand) > width) { out.push(line); line = word } else line = cand
265
+ }
266
+ if (line) out.push(line)
267
+ return out
268
+ }
269
+
233
270
  // --------------------------------- pantallas -------------------------------
234
271
 
235
272
  function profileRows (st, t) {
@@ -852,6 +889,8 @@ async function onKeyProfiles (term, st, key) {
852
889
  st.scroll.pairmode = { value: 0 }
853
890
  st.screen = 'pairmode'
854
891
  })
892
+ } else if (ch === 'j') {
893
+ promptJoin(term, st)
855
894
  } else if (ch === 'n') {
856
895
  setInput(st, {
857
896
  label: i.newVaultLabel,
@@ -1231,6 +1270,104 @@ function promptApprove (term, st) {
1231
1270
  })
1232
1271
  }
1233
1272
 
1273
+ /**
1274
+ * `j`: ESTA bóveda entra en la cuenta de otra (multivault). Es el papel contrario a `p`:
1275
+ * aquí no se invita a nadie, se acepta la invitación que enseña la otra bóveda.
1276
+ */
1277
+ function promptJoin (term, st, api = vc) {
1278
+ const i = L(st)
1279
+ setInput(st, {
1280
+ label: i.joinInviteLabel,
1281
+ hint: i.joinInviteHint,
1282
+ onSubmit: (text) => {
1283
+ st.input = null
1284
+ const invite = text.trim()
1285
+ // Se mira ANTES de preguntar el nombre: pedirte cómo llamar a la cuenta y decir
1286
+ // después que la invitación no vale es hacerte teclear para nada.
1287
+ if (!api.joinInvite(invite)) { flash(st, i.errInviteInvalid, 'danger'); return }
1288
+ setInput(st, {
1289
+ label: i.joinNameLabel,
1290
+ hint: i.joinNameHint,
1291
+ onSubmit: async (name) => {
1292
+ st.input = null
1293
+ const r = await guard(term, st, i.startingJoin, () => api.startJoin({ invite, name: name.trim().slice(0, 60) || null }))
1294
+ if (!r.ok) return
1295
+ st.join = { id: r.v, startedAt: Date.now(), state: 'waiting', code: null }
1296
+ st.scroll.join = { value: 0 }
1297
+ st.screen = 'join'
1298
+ },
1299
+ onCancel: () => { st.input = null }
1300
+ })
1301
+ },
1302
+ onCancel: () => { st.input = null }
1303
+ })
1304
+ }
1305
+
1306
+ /** Lo que la bóveda espera a que la otra apruebe (`approveTimeoutMs` de @dotrino/identity). */
1307
+ const JOIN_APPROVE_MS = 180_000
1308
+ /** Y lo que espera la pantalla antes de darlo por perdido: el daemon contesta antes. */
1309
+ const JOIN_GIVE_UP_MS = JOIN_APPROVE_MS + 20_000
1310
+
1311
+ async function onKeyJoin (term, st, key, api = vc) {
1312
+ const j = st.join
1313
+ if (!j) { st.screen = 'profiles'; return true }
1314
+ if (key.name === 'tick') {
1315
+ if (j.state !== 'waiting') return true
1316
+ const d = api.joinStatus(j.id)
1317
+ if (d?.state === 'waiting' && d.code) j.code = d.code
1318
+ else if (d?.state === 'done') {
1319
+ Object.assign(j, { state: 'done', seq: d.seq, profile: d.profile })
1320
+ // La cuenta nueva es ya la activa (lo decide el daemon): la lista tiene que decirlo,
1321
+ // y la selección se pone encima para que Enter entre en ELLA.
1322
+ await refreshAll(term, st, api)
1323
+ const idx = (st.profiles?.profiles || []).findIndex((p) => p.id === d.profile)
1324
+ if (idx >= 0) st.sel.profiles = idx
1325
+ } else if (d?.state === 'error') {
1326
+ Object.assign(j, { state: 'error', error: d.errorCode ? humanErr({ code: d.errorCode }, st) : d.error })
1327
+ } else if (Date.now() - j.startedAt > JOIN_GIVE_UP_MS) {
1328
+ Object.assign(j, { state: 'error', error: L(st).joinNoReply })
1329
+ }
1330
+ return true
1331
+ }
1332
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
1333
+ if (key.name === 'escape' || ch === 'b' || (key.name === 'enter' && j.state !== 'waiting')) {
1334
+ // Salir no cancela: la bóveda sigue esperando el código en segundo plano (no hay forma
1335
+ // de abortar un enrolamiento a medias). Se dice, para que nadie crea que lo paró.
1336
+ if (j.state === 'waiting') flash(st, L(st).joinInBackground, 'warn')
1337
+ st.join = null
1338
+ st.screen = 'profiles'
1339
+ await refreshProfiles(term, st, api)
1340
+ }
1341
+ return true
1342
+ }
1343
+
1344
+ function joinBody (st, t, cols) {
1345
+ const i = L(st)
1346
+ const j = st.join
1347
+ if (!j) return []
1348
+ const width = Math.max(10, cols - 2)
1349
+ const lines = []
1350
+ if (j.state === 'done') {
1351
+ for (const l of wrapWords(i.joinDone(j.seq ?? '?'), width)) lines.push(t.ok(l))
1352
+ lines.push('')
1353
+ for (const l of wrapWords(i.joinNextPassword, width)) lines.push(t.warn(l))
1354
+ } else if (j.state === 'error') {
1355
+ for (const l of wrapWords(i.joinFailed(j.error || '?'), width)) lines.push(t.danger(l))
1356
+ } else if (!j.code) {
1357
+ lines.push(t.muted(i.joinWaiting))
1358
+ } else {
1359
+ lines.push(t.bold(i.joinCodeIntro))
1360
+ lines.push('')
1361
+ lines.push(' ' + t.accent(t.bold(j.code)))
1362
+ lines.push('')
1363
+ for (const l of wrapWords(i.joinCodeWhere(j.code), width)) lines.push(t.muted(l))
1364
+ lines.push('')
1365
+ const left = Math.max(0, Math.round((j.startedAt + JOIN_APPROVE_MS - Date.now()) / 1000))
1366
+ lines.push(t.muted(i.joinLeft(left)))
1367
+ }
1368
+ return lines
1369
+ }
1370
+
1234
1371
  async function onKeyPairing (term, st, key) {
1235
1372
  const i = L(st)
1236
1373
  const ch = key.name === 'char' ? key.ch.toLowerCase() : null
@@ -1241,9 +1378,9 @@ async function onKeyPairing (term, st, key) {
1241
1378
  }
1242
1379
  // Scroll vertical para ver el QR completo cuando no cabe en la pantalla.
1243
1380
  if (['up', 'down', 'pageup', 'pagedown', 'home', 'end'].includes(key.name)) {
1244
- const { rows } = term.size()
1245
- const contentH = Math.max(1, rows - 7)
1246
- const pb = pairingBody(st, term.t, term.size().cols, contentH)
1381
+ const { cols, rows } = term.size()
1382
+ const contentH = contentHeight(st, cols, rows)
1383
+ const pb = pairingBody(st, term.t, cols, contentH)
1247
1384
  const maxScroll = Math.max(0, pb.length - contentH)
1248
1385
  const scroll = st.scroll.pairing || (st.scroll.pairing = { value: 0 })
1249
1386
  if (key.name === 'up') scroll.value = Math.max(0, scroll.value - 1)
@@ -1672,6 +1809,7 @@ const helpSegs = (i, screen, st = {}) => {
1672
1809
  secrets: i.helpSecrets,
1673
1810
  pairing: i.helpPairing,
1674
1811
  pairmode: i.helpPairMode,
1812
+ join: i.helpJoin,
1675
1813
  me: i.helpMe,
1676
1814
  caps: i.helpCaps,
1677
1815
  devvars: i.helpDevVars
@@ -1694,6 +1832,7 @@ const title = (i, screen) => ({
1694
1832
  profiles: i.titleProfiles,
1695
1833
  pairing: i.titlePairing,
1696
1834
  pairmode: i.titlePairMode,
1835
+ join: i.titleJoin,
1697
1836
  caps: i.titleCaps,
1698
1837
  devvars: i.titleDevVars
1699
1838
  })[screen] || ''
@@ -1747,6 +1886,24 @@ function pairingBody (st, t, cols, height) {
1747
1886
  return lines
1748
1887
  }
1749
1888
 
1889
+ /** Filas fijas de arriba: cabecera, bóveda activa, aviso de versión, título y un hueco. */
1890
+ const TOP = 5
1891
+ /** Filas de contenido que deja libres como mínimo una ayuda larga. */
1892
+ const MIN_CONTENT = 3
1893
+
1894
+ /** Las líneas de la barra de ayuda de la pantalla actual, ya repartidas al ancho. */
1895
+ function helpLines (st, i, cols, rows) {
1896
+ if (st.input) return [i.helpInput]
1897
+ if (st.confirm) return [i.helpConfirm]
1898
+ const maxLines = Math.max(1, rows - TOP - 1 - MIN_CONTENT)
1899
+ return wrapHelp(helpSegs(i, st.screen, st), cols, maxLines)
1900
+ }
1901
+
1902
+ /** Alto del contenido: lo que queda entre la cabecera, la línea de estado y la ayuda. */
1903
+ function contentHeight (st, cols, rows, help = helpLines(st, L(st), cols, rows)) {
1904
+ return Math.max(1, rows - TOP - 1 - help.length)
1905
+ }
1906
+
1750
1907
  function render (term, st) {
1751
1908
  const t = term.t
1752
1909
  const i = L(st)
@@ -1777,9 +1934,9 @@ function render (term, st) {
1777
1934
  lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + title(i, st.screen))
1778
1935
  lines[4] = ''
1779
1936
 
1780
- const top = 5
1781
- const bottom = 2 // status + help
1782
- const contentH = Math.max(1, rows - top - bottom)
1937
+ const help = helpLines(st, i, cols, rows)
1938
+ const contentH = contentHeight(st, cols, rows, help)
1939
+ const top = TOP
1783
1940
  const scrollRef = st.scroll[st.screen] || (st.scroll[st.screen] = { value: 0 })
1784
1941
 
1785
1942
  let body = []
@@ -1793,11 +1950,11 @@ function render (term, st) {
1793
1950
  else if (st.screen === 'pairing') {
1794
1951
  const pb = pairingBody(st, t, cols, contentH)
1795
1952
  body = scrollBody(pb, contentH, scrollRef)
1796
- }
1953
+ } else if (st.screen === 'join') body = scrollBody(joinBody(st, t, cols), contentH, scrollRef)
1797
1954
  for (let n = 0; n < contentH; n++) lines[top + n] = body[n] ?? ''
1798
1955
 
1799
- // línea de estado: input / confirm / flash / busy
1800
- const statusRow = rows - 2
1956
+ // línea de estado: input / confirm / flash / busy, justo encima de la ayuda
1957
+ const statusRow = rows - 1 - help.length
1801
1958
  if (st.busy) lines[statusRow] = ' ' + t.accent('⏳ ' + st.busy)
1802
1959
  else if (st.input) {
1803
1960
  const inp = st.input
@@ -1812,11 +1969,8 @@ function render (term, st) {
1812
1969
  lines[statusRow] = ' ' + style((kind === 'danger' ? '✗ ' : kind === 'warn' ? '! ' : '✓ ') + st.flash.text)
1813
1970
  } else lines[statusRow] = ''
1814
1971
 
1815
- // barra de ayuda
1816
- let help = fitHelp(helpSegs(i, st.screen, st), cols)
1817
- if (st.input) help = i.helpInput
1818
- else if (st.confirm) help = i.helpConfirm
1819
- lines[rows - 1] = t.bar(help, cols)
1972
+ // barra de ayuda (una o varias líneas, pegadas abajo)
1973
+ help.forEach((h, n) => { lines[rows - help.length + n] = t.bar(h, cols) })
1820
1974
 
1821
1975
  term.render(lines)
1822
1976
  }
@@ -1846,8 +2000,9 @@ async function daemonDownScreen (term, st) {
1846
2000
  ]
1847
2001
  if (st.flash) content.push('', (st.flash.kind === 'danger' ? t.danger : t.warn)(st.flash.text))
1848
2002
  lines[0] = t.bar(i.downHeader, cols)
1849
- for (let n = 0; n < content.length && 2 + n < rows - 1; n++) lines[2 + n] = ' ' + content[n]
1850
- lines[rows - 1] = t.bar(fitHelp(i.downHelp, cols), cols)
2003
+ const help = wrapHelp(i.downHelp, cols, Math.max(1, rows - 4))
2004
+ for (let n = 0; n < content.length && 2 + n < rows - help.length; n++) lines[2 + n] = ' ' + content[n]
2005
+ help.forEach((h, n) => { lines[rows - help.length + n] = t.bar(h, cols) })
1851
2006
  term.render(lines)
1852
2007
 
1853
2008
  const key = await term.readKey()
@@ -1916,7 +2071,7 @@ export async function runTui () {
1916
2071
  if (st.flash && Date.now() - st.flash.at > 4000) st.flash = null
1917
2072
  render(term, st)
1918
2073
 
1919
- const base = (st.screen === 'pairing' || (st.screen === 'devices' && !st.input && !st.confirm)) ? 800 : 0
2074
+ const base = (st.screen === 'pairing' || st.screen === 'join' || (st.screen === 'devices' && !st.input && !st.confirm)) ? 800 : 0
1920
2075
  // Despertar a tiempo del bloqueo automático, además del sondeo de la pantalla.
1921
2076
  const wake = autoLockWakeIn(st)
1922
2077
  const tick = base && wake ? Math.min(base, wake) : (base || wake)
@@ -1926,7 +2081,7 @@ export async function runTui () {
1926
2081
  // Un despertar del bloqueo automático no es una tecla: ya se atendió arriba, y las
1927
2082
  // pantallas que NO esperan `tick` (todas menos aparatos y emparejamiento) no tienen
1928
2083
  // por qué verlo.
1929
- if (key.name === 'tick' && st.screen !== 'pairing' && st.screen !== 'devices') continue
2084
+ if (key.name === 'tick' && !['pairing', 'join', 'devices'].includes(st.screen)) continue
1930
2085
  // input/confirm se AWAITan: serializa las ops contra el daemon (ver onInputKey).
1931
2086
  // Ctrl-C dentro de un modal lo CANCELA (no sale); fuera de un modal, sale.
1932
2087
  if (st.input) { await onInputKey(st, key); continue }
@@ -1968,6 +2123,7 @@ export async function runTui () {
1968
2123
  else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
1969
2124
  else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
1970
2125
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
2126
+ else if (st.screen === 'join') running = await onKeyJoin(term, st, key)
1971
2127
  }
1972
2128
  } finally {
1973
2129
  // AL SALIR SE VUELVE A CERRAR lo que se abrió aquí. Sin esto, teclear la contraseña una
@@ -1982,4 +2138,4 @@ export async function runTui () {
1982
2138
  }
1983
2139
 
1984
2140
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
1985
- export const __test = { render, onKeySecrets, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
2141
+ export const __test = { render, onKeySecrets, activeLocked, autoLockedIds, autoLockWakeIn, forgetAutoLocked, autoLockMin, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, onKeyCaps, pairModeRows, pairingBody, scrollBody, fitHelp, wrapHelp, wrapWords, joinBody, onKeyJoin, promptJoin, onKeyProfiles, onInputKey, toggleLang, mergeMembersAndCerts, seguirAqui, resetToque: () => { ultimoToque = 0 } }
package/src/tui/i18n.js CHANGED
@@ -113,6 +113,7 @@ const es = {
113
113
  titleProfiles: 'Bóvedas',
114
114
  titlePairing: 'Emparejar un dispositivo',
115
115
  titlePairMode: 'Emparejar: ¿a qué cuenta entra?',
116
+ titleJoin: 'Entrar en la cuenta de otra bóveda',
116
117
 
117
118
  // bóvedas (perfiles)
118
119
  noPassword: 'sin clave',
@@ -285,6 +286,22 @@ const es = {
285
286
  pairWaiting: 'Esperando a que el dispositivo se conecte…',
286
287
  pairQrTooNarrow: (cols, need) => `Agranda el terminal (el QR necesita ${need} cols; hay ${cols}).`,
287
288
 
289
+ // unirse a la cuenta de OTRA bóveda: el papel contrario a emparejar
290
+ joinInviteLabel: 'Invitación de la otra bóveda',
291
+ joinInviteHint: 'pega el código o la URL que enseña la otra al pulsar p (Esc cancela)',
292
+ joinNameLabel: '¿Cómo se llamará aquí esa cuenta?',
293
+ joinNameHint: 'el nombre con el que la verás en esta lista; vacío = «cuenta de la otra bóveda»',
294
+ startingJoin: 'Pidiendo entrar en la cuenta…',
295
+ joinWaiting: 'Conectando con la otra bóveda…',
296
+ joinCodeIntro: 'Teclea este código en la OTRA bóveda:',
297
+ joinCodeWhere: (code) => `allí: pulsa a en la pantalla del QR, o ejecuta dotrino-vault approve ${code}`,
298
+ joinLeft: (s) => `Esperando la aprobación · quedan ~${s} s`,
299
+ joinDone: (seq) => `Listo: esta bóveda ya es miembro de esa cuenta (acta #${seq}). Ahora es la bóveda activa.`,
300
+ joinNextPassword: 'Si la otra bóveda le va a dar «sella», ponle antes contraseña a esta cuenta (c en Bóvedas): sin ella, cualquiera con acceso a esta máquina podría firmar el acta en tu nombre.',
301
+ joinFailed: (err) => `No se pudo entrar: ${err}`,
302
+ joinNoReply: 'La bóveda no contestó a tiempo. ¿Se aprobó el código en la otra?',
303
+ joinInBackground: 'La espera sigue: si la otra bóveda aprueba, la cuenta aparecerá en esta lista.',
304
+
288
305
  // confirmación / entrada
289
306
  confirmKeys: ' (s / N)',
290
307
  helpInput: 'Enter confirmar · Esc cancelar · Ctrl-U limpiar',
@@ -293,8 +310,8 @@ const es = {
293
310
  // Barras de ayuda. Las TECLAS son las mismas en los dos idiomas (mnemónico
294
311
  // INGLÉS: new/rename/delete/password/unlock/locK/pair/approve/revoke/refresh/
295
312
  // language/quit); lo único que se traduce es la palabra que las explica.
296
- // Segmentos, no una línea: el render recorta del medio si no caben.
297
- helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'u desbloq', 'k bloquear', 'l English', 'q salir'],
313
+ // Segmentos, no una línea: si no caben en una, el render los reparte en varias.
314
+ helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'j unirse', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'u desbloq', 'k bloquear', 'l English', 'q salir'],
298
315
  // La barra dice lo que se PUEDE hacer AHORA, no todo lo que existe: aprobar/rechazar solo
299
316
  // valen si hay alguien esperando, y renombrar/revocar solo si hay un aparato seleccionado.
300
317
  // Anunciar teclas muertas confunde y además las quema para otros usos.
@@ -330,6 +347,7 @@ const es = {
330
347
  revealNoPwd: 'Este perfil no tiene contraseña: se abre con la llave de esta máquina.',
331
348
  helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', '↑↓ scroll', 'Esc atrás', 'l English'],
332
349
  helpPairMode: ['↑↓', 'Enter elegir', 'Esc atrás', 'l English', 'q salir'],
350
+ helpJoin: ['Esc bóvedas', 'l English', 'q salir'],
333
351
 
334
352
  // pantalla "daemon caído"
335
353
  downTitle: 'El daemon del vault no está corriendo.',
@@ -354,6 +372,8 @@ const es = {
354
372
  errNotApplied: 'El daemon no aplicó el cambio (revisa los logs del servicio).',
355
373
  errNotDeleted: 'El daemon no borró la variable (revisa los logs del servicio).',
356
374
  errPairFailed: 'El daemon no inició el emparejamiento.',
375
+ errInviteInvalid: 'Esa invitación no se entiende: pega el código o la URL completos que enseña la otra bóveda.',
376
+ errJoinBusy: 'Ya hay otra entrada en curso esperando su código: termina esa primero.',
357
377
  // El código NO es la contraseña de la bóveda: son los seis dígitos que el aparato
358
378
  // enseña en su pantalla. Si no coincide, la bóveda no firma nada y el aparato sigue
359
379
  // esperando ahí, así que se puede volver a intentar con A.
@@ -444,6 +464,7 @@ const en = {
444
464
  titleProfiles: 'Vaults',
445
465
  titlePairing: 'Pair a device',
446
466
  titlePairMode: 'Pairing: which account does it join?',
467
+ titleJoin: 'Join the account of another vault',
447
468
 
448
469
  noPassword: 'no password',
449
470
  noPasswordWarn: 'This profile has no password: a copy of this disk opens the private variables.',
@@ -599,11 +620,26 @@ const en = {
599
620
  pairWaiting: 'Waiting for the device to connect…',
600
621
  pairQrTooNarrow: (cols, need) => `Widen the terminal (the QR needs ${need} cols; you have ${cols}).`,
601
622
 
623
+ joinInviteLabel: 'Invitation from the other vault',
624
+ joinInviteHint: 'paste the code or the URL the other one shows when you press p (Esc cancels)',
625
+ joinNameLabel: 'What should that account be called here?',
626
+ joinNameHint: 'the name you will see in this list; empty = “account of the other vault”',
627
+ startingJoin: 'Asking to join the account…',
628
+ joinWaiting: 'Connecting to the other vault…',
629
+ joinCodeIntro: 'Type this code in the OTHER vault:',
630
+ joinCodeWhere: (code) => `there: press a on the QR screen, or run dotrino-vault approve ${code}`,
631
+ joinLeft: (s) => `Waiting for approval · ~${s} s left`,
632
+ joinDone: (seq) => `Done: this vault is now a member of that account (record #${seq}). It is now the active vault.`,
633
+ joinNextPassword: 'If the other vault is going to grant it “sealer”, set a password on this account first (c in Vaults): without one, anyone with access to this machine could sign the record as you.',
634
+ joinFailed: (err) => `Could not join: ${err}`,
635
+ joinNoReply: 'The vault did not answer in time. Was the code approved in the other one?',
636
+ joinInBackground: 'Still waiting: if the other vault approves, the account will show up in this list.',
637
+
602
638
  confirmKeys: ' (y / N)',
603
639
  helpInput: 'Enter confirm · Esc cancel · Ctrl-U clear',
604
640
  helpConfirm: 'y confirm · n/Esc cancel',
605
641
 
606
- helpProfiles: ['↑↓', 'Enter open', 'p pair', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'u unlock', 'k lock', 'l Español', 'q quit'],
642
+ helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'u unlock', 'k lock', 'l Español', 'q quit'],
607
643
  helpDevices: ({ pending, hasDevices, isService } = {}) => [
608
644
  '←→ tab', '↑↓', 'p pair',
609
645
  ...(pending ? ['a approve', 'x reject'] : []),
@@ -633,6 +669,7 @@ const en = {
633
669
  revealNoPwd: 'This profile has no password: it opens with this machine key.',
634
670
  helpPairing: ['a approve', 'x reject', 'r restart', '↑↓ scroll', 'Esc back', 'l Español'],
635
671
  helpPairMode: ['↑↓', 'Enter choose', 'Esc back', 'l Español', 'q quit'],
672
+ helpJoin: ['Esc vaults', 'l Español', 'q quit'],
636
673
 
637
674
  downTitle: 'The vault daemon is not running.',
638
675
  downBody1: 'The TUI gives orders to the daemon (the keeper of your key). Without it',
@@ -655,6 +692,8 @@ const en = {
655
692
  errNotApplied: 'The daemon did not apply the change (check the service logs).',
656
693
  errNotDeleted: 'The daemon did not delete the variable (check the service logs).',
657
694
  errPairFailed: 'The daemon did not start the pairing.',
695
+ errInviteInvalid: 'That invitation cannot be read: paste the whole code or URL the other vault shows.',
696
+ errJoinBusy: 'Another join is already waiting for its code: finish that one first.',
658
697
  errWrongCode: 'The code does not match the one shown by the device: nobody got in. Check it and press A again.',
659
698
  errProfileLocked: 'Vault locked: open it with its password',
660
699
  errWrongPassword: (n) => `Wrong password${n ? ` — ${n} failed attempts so far` : ''}`,
@@ -22,7 +22,7 @@ import { pubkeyId } from '@dotrino/identity/capabilities'
22
22
  import { dataDir } from './paths.js'
23
23
  // El canal local va cifrado en reposo (ver `ipc.js`).
24
24
  import { ipcRead, ipcWrite } from './ipc.js'
25
- import { encodeInvite, inviteUrl } from '../lib/src/invite.js'
25
+ import { encodeInvite, inviteUrl, parseInvite } from '../lib/src/invite.js'
26
26
 
27
27
  const dir = dataDir()
28
28
 
@@ -37,8 +37,10 @@ const F = {
37
37
  me: 'me.json',
38
38
  acta: 'acta.json',
39
39
  approve: 'approve.json',
40
+ join: 'join.json',
40
41
  // peticiones (las escribe el control; el daemon las consume y borra)
41
42
  pairReq: 'pair-request.json',
43
+ joinReq: 'join-request.json',
42
44
  approveReq: 'approve-request.json',
43
45
  rejectReq: 'reject-request.json',
44
46
  revokeReq: 'revoke-request.json',
@@ -697,3 +699,50 @@ export async function rejectPending (deviceId, profile) {
697
699
  signalOrCleanup('SIGUSR2', [F.rejectReq])
698
700
  await sleep(200)
699
701
  }
702
+
703
+ // ---------------------------------------------------------------------------
704
+ // Unirse a la cuenta de otra bóveda (multivault)
705
+ // ---------------------------------------------------------------------------
706
+
707
+ /**
708
+ * La invitación de otra bóveda, lista para `startJoin`, o null si no se entiende. Vale el
709
+ * código suelto y la URL del QR: es lo que enseña la otra al pulsar `p`.
710
+ *
711
+ * Lo que hace falta de verdad es el nonce de la sesión (`sn`) y una forma de alcanzar a la
712
+ * otra bóveda: la cita del proxio (`conn`) o su llave (`iss`). Misma regla que `join` en
713
+ * la CLI.
714
+ */
715
+ export function joinInvite (text) {
716
+ const qr = parseInvite(String(text || '').trim())
717
+ return qr?.sn && (qr.conn || qr.iss) ? qr : null
718
+ }
719
+
720
+ /**
721
+ * Pide al daemon que ESTA bóveda entre en la cuenta de la otra. Devuelve el `id` de la
722
+ * petición; el progreso se lee con `joinStatus(id)`.
723
+ *
724
+ * No espera al final a propósito: entre medias una persona tiene que teclear en la otra
725
+ * bóveda el código que sale aquí, y eso son minutos.
726
+ *
727
+ * @param {{ invite?: string, name?: string|null }} [opts]
728
+ */
729
+ export async function startJoin ({ invite, name } = {}) {
730
+ const qr = joinInvite(invite)
731
+ if (!qr) throw coded('invalid invitation', 'INVITE_INVALID')
732
+ requireAlive()
733
+ rm(F.join)
734
+ const id = await writeReq(F.joinReq, { qr, label: 'bóveda', ...(name ? { name } : {}) })
735
+ signalOrCleanup('SIGUSR2', [F.joinReq])
736
+ return id
737
+ }
738
+
739
+ /**
740
+ * Por dónde va el `join` con ese `id`: `{ state: 'waiting', code }`, `{ state: 'done',
741
+ * seq, profile }` o `{ state: 'error', error, errorCode? }`. null si aún no hay nada suyo.
742
+ */
743
+ export function joinStatus (id) {
744
+ const d = read(F.join, null)
745
+ if (!d) return null
746
+ if ('req' in d && d.req !== id) return null
747
+ return d
748
+ }