@dotrino/vaultd 0.116.0 → 0.117.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 +6 -2
- package/package.json +1 -1
- package/src/ctl.js +5 -2
- package/src/daemon.js +68 -43
- package/src/tui/app.js +176 -20
- package/src/tui/i18n.js +42 -3
- package/src/vaultControl.js +50 -1
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),
|
|
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 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.117.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
|
@@ -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
|
-
|
|
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
|
-
|
|
516
|
-
//
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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 =
|
|
1246
|
-
const pb = pairingBody(st, term.t,
|
|
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
|
|
1781
|
-
const
|
|
1782
|
-
const
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
1850
|
-
|
|
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' &&
|
|
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
|
|
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` : ''}`,
|
package/src/vaultControl.js
CHANGED
|
@@ -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
|
+
}
|