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