@dotrino/vaultd 0.145.0 → 0.147.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/lib/src/service.js +113 -0
- package/package.json +1 -1
- package/src/ctl.js +66 -4
- package/src/daemon.js +139 -38
- package/src/notices.js +68 -0
- package/src/notifiable.js +9 -8
- package/src/settings.js +37 -0
- package/src/tui/app.js +69 -5
- package/src/tui/i18n.js +36 -4
- package/src/updateApproval.js +110 -0
- package/src/vault.js +190 -12
package/src/settings.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AJUSTES DE ESTA BÓVEDA: lo que el dueño elige distinto del comportamiento por defecto.
|
|
3
|
+
*
|
|
4
|
+
* Hoy es uno: `updateApproval`. Por defecto la bóveda se actualiza sola (lo que baja se
|
|
5
|
+
* verifica contra la atestación del release, se pida permiso o no); con `updateApproval:
|
|
6
|
+
* true` antes le pide permiso a los aparatos con `approve` (dueño, 2026-10-08).
|
|
7
|
+
*
|
|
8
|
+
* Va por el canal local (`ipc.js`): lo escribe la CLI (`dotrino-vault update --approval
|
|
9
|
+
* on|off`) y lo lee el daemon cada vez que mira si hay versión nueva, sin señal ni reinicio.
|
|
10
|
+
*
|
|
11
|
+
* UN ARCHIVO QUE EXISTE Y NO SE PUEDE LEER NO ES «EL VALOR POR DEFECTO»: el por defecto es
|
|
12
|
+
* actualizarse sin preguntar, así que tragarse el fallo sería saltarse la aprobación que el
|
|
13
|
+
* dueño pidió justo cuando algo se rompió. Se lanza.
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { ipcRead, ipcWrite } from './ipc.js'
|
|
18
|
+
|
|
19
|
+
export const SETTINGS_FILE = 'settings.json'
|
|
20
|
+
|
|
21
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
22
|
+
|
|
23
|
+
/** @returns {{ updateApproval: boolean }} */
|
|
24
|
+
export function readSettings (dir) {
|
|
25
|
+
const file = path.join(dir, SETTINGS_FILE)
|
|
26
|
+
if (!fs.existsSync(file)) return { updateApproval: false }
|
|
27
|
+
const raw = ipcRead(file, null)
|
|
28
|
+
if (!raw || typeof raw !== 'object' || typeof raw.updateApproval !== 'boolean') {
|
|
29
|
+
throw fail('settings-unreadable', `${SETTINGS_FILE} exists but cannot be read`)
|
|
30
|
+
}
|
|
31
|
+
return { updateApproval: raw.updateApproval }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writeSettings (dir, { updateApproval }) {
|
|
35
|
+
if (typeof updateApproval !== 'boolean') throw fail('bad-setting', 'updateApproval must be true or false')
|
|
36
|
+
ipcWrite(path.join(dir, SETTINGS_FILE), { v: 1, updateApproval })
|
|
37
|
+
}
|
package/src/tui/app.js
CHANGED
|
@@ -36,6 +36,7 @@ import { createTerm, widthOf } from './term.js'
|
|
|
36
36
|
import { qrToString } from '../qr.js'
|
|
37
37
|
import { dict, otherLang, loadLang, saveLang } from './i18n.js'
|
|
38
38
|
import * as vc from '../vaultControl.js'
|
|
39
|
+
import { readSettings, writeSettings } from '../settings.js'
|
|
39
40
|
import * as hw from '../hwkeys.js'
|
|
40
41
|
import { VERSION } from '../version.js'
|
|
41
42
|
import { DEVICE_CAPS } from '@dotrino/identity/acta'
|
|
@@ -155,7 +156,9 @@ function mergeMembersAndCerts (members, issued) {
|
|
|
155
156
|
noAccess: !cert && !m.isMaster && !m.cn,
|
|
156
157
|
// QUÉ CORRE ESE APARATO (CONVENCIONES §14). Lo apunta la bóveda cuando le habla, así
|
|
157
158
|
// que aquí solo hay que dejarlo pasar — que es justo lo que le faltaba a `addedAt`.
|
|
158
|
-
running: m.running || null
|
|
159
|
+
running: m.running || null,
|
|
160
|
+
// SI LE LLEGAN LOS AVISOS, solo en quien aprueba (`undefined` = no aprueba, no se pinta).
|
|
161
|
+
notifiedAt: (m.caps || []).includes('approve') && 'notifiedAt' in m ? m.notifiedAt : undefined
|
|
159
162
|
}
|
|
160
163
|
})
|
|
161
164
|
}
|
|
@@ -336,6 +339,50 @@ function doorRows (st, t) {
|
|
|
336
339
|
return rows
|
|
337
340
|
}
|
|
338
341
|
|
|
342
|
+
/**
|
|
343
|
+
* AJUSTES DE LA BÓVEDA (no de un perfil). Cada regla se lee como una frase con su estado, y
|
|
344
|
+
* Enter la cambia. Hoy hay una: pedir aprobación para actualizarse.
|
|
345
|
+
*
|
|
346
|
+
* `st.settings` es lo leído al entrar: `{ updateApproval }`, o `{ error }` si el ajuste no se
|
|
347
|
+
* pudo leer — que se DICE, no se pinta como apagado: apagado es «se actualiza sola».
|
|
348
|
+
*/
|
|
349
|
+
const settingsDir = (st) => st.settingsDir || vc.vaultDir()
|
|
350
|
+
function loadSettings (st) {
|
|
351
|
+
try { st.settings = readSettings(settingsDir(st)) } catch (e) { st.settings = { error: e?.message || String(e) } }
|
|
352
|
+
}
|
|
353
|
+
function settingsRows (st, t) {
|
|
354
|
+
const i = L(st)
|
|
355
|
+
const cfg = st.settings
|
|
356
|
+
if (!cfg) return [{ text: t.muted(' —'), sel: false }]
|
|
357
|
+
const rows = []
|
|
358
|
+
if (cfg.error) {
|
|
359
|
+
rows.push({ text: ' ' + t.warn(i.settingsUnreadable), sel: false })
|
|
360
|
+
rows.push({ text: t.muted(' ' + i.settingsUnreadableHint), sel: false })
|
|
361
|
+
rows.push({ text: '', sel: false })
|
|
362
|
+
}
|
|
363
|
+
const frase = cfg.error ? i.settingUpdateUnknown : cfg.updateApproval ? i.settingUpdateAsks : i.settingUpdateAlone
|
|
364
|
+
rows.push({ text: ` ${t.bold(frase)} ${t.accent(i.settingChange)}`, sel: true, meta: { setting: 'updateApproval' } })
|
|
365
|
+
rows.push({ text: t.muted(' ' + i.settingUpdateHint1), sel: false })
|
|
366
|
+
rows.push({ text: t.muted(' ' + i.settingUpdateHint2), sel: false })
|
|
367
|
+
return rows
|
|
368
|
+
}
|
|
369
|
+
async function onKeySettings (term, st, key) {
|
|
370
|
+
const i = L(st)
|
|
371
|
+
const ch = key.name === 'char' ? key.ch.toLowerCase() : null
|
|
372
|
+
if (key.name === 'escape' || ch === 'b') { st.screen = 'profiles'; return true }
|
|
373
|
+
if (key.name === 'enter' || (key.name === 'char' && key.ch === ' ')) {
|
|
374
|
+
// Con el ajuste ilegible, Enter lo ESCRIBE de nuevo (encendido: es lo prudente cuando
|
|
375
|
+
// no se sabe qué había); así la pantalla también es la salida del atasco.
|
|
376
|
+
const next = st.settings?.error ? true : !st.settings?.updateApproval
|
|
377
|
+
try {
|
|
378
|
+
writeSettings(settingsDir(st), { updateApproval: next })
|
|
379
|
+
loadSettings(st)
|
|
380
|
+
flash(st, next ? i.settingUpdateNowAsks : i.settingUpdateNowAlone)
|
|
381
|
+
} catch (e) { flash(st, i.settingSaveFailed(e?.message || String(e)), 'danger') }
|
|
382
|
+
}
|
|
383
|
+
return true
|
|
384
|
+
}
|
|
385
|
+
|
|
339
386
|
function deviceRows (st, t) {
|
|
340
387
|
const i = L(st)
|
|
341
388
|
const rows = []
|
|
@@ -386,6 +433,9 @@ function deviceRows (st, t) {
|
|
|
386
433
|
const extra = (d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : '') +
|
|
387
434
|
(vars ? t.muted(` vars:${vars}`) : '') +
|
|
388
435
|
corre +
|
|
436
|
+
// En quien aprueba: si dijo que le llegan los avisos. No cambia a quién se le pide
|
|
437
|
+
// (a todos); deja ver a cuál no le suena el pedido. Es un dato, va en gris.
|
|
438
|
+
(d.notifiedAt === undefined ? '' : t.muted(` ${d.notifiedAt ? i.notifyYes : i.notifyUnknown}`)) +
|
|
389
439
|
// EN DEUDA: en el acta y sin poder abrir lo suyo. Va en color de aviso al lado de
|
|
390
440
|
// sus variables, que es donde se mira cuando algo no arranca.
|
|
391
441
|
(debt ? t.warn(` ${i.deviceDebt(debt)}`) : '')
|
|
@@ -1199,6 +1249,11 @@ async function onKeyProfiles (term, st, key) {
|
|
|
1199
1249
|
},
|
|
1200
1250
|
onCancel: () => { st.input = null }
|
|
1201
1251
|
}))
|
|
1252
|
+
} else if (ch === 's') { // ajustes de la bóveda (no de un perfil: no hace falta elegir uno)
|
|
1253
|
+
loadSettings(st)
|
|
1254
|
+
st.sel.settings = 0
|
|
1255
|
+
st.scroll.settings = { value: 0 }
|
|
1256
|
+
st.screen = 'settings'
|
|
1202
1257
|
} else if (ch === 'y' && cur) { // las llaves: con qué se abre la bóveda
|
|
1203
1258
|
await ensureUnlocked(term, st, cur, async (p = cur) => {
|
|
1204
1259
|
st.doorsFor = p.id
|
|
@@ -2303,6 +2358,7 @@ const helpSegs = (i, screen, st = {}) => {
|
|
|
2303
2358
|
pairing: i.helpPairing,
|
|
2304
2359
|
pairmode: i.helpPairMode,
|
|
2305
2360
|
doors: i.helpDoors,
|
|
2361
|
+
settings: i.helpSettings,
|
|
2306
2362
|
paircaps: i.helpPairCaps,
|
|
2307
2363
|
join: i.helpJoin,
|
|
2308
2364
|
me: i.helpMe,
|
|
@@ -2338,6 +2394,7 @@ const title = (i, screen) => ({
|
|
|
2338
2394
|
pairing: i.titlePairing,
|
|
2339
2395
|
pairmode: i.titlePairMode,
|
|
2340
2396
|
doors: i.titleDoors,
|
|
2397
|
+
settings: i.titleSettings,
|
|
2341
2398
|
paircaps: i.titlePairCaps,
|
|
2342
2399
|
join: i.titleJoin,
|
|
2343
2400
|
caps: i.titleCaps,
|
|
@@ -2438,10 +2495,15 @@ function render (term, st) {
|
|
|
2438
2495
|
// instalar, peor: el daemon se queda con el binario viejo, ya borrado, y hay dos copias
|
|
2439
2496
|
// en RAM). `status` ya lo avisa; aquí también, que es donde uno se queda mirando.
|
|
2440
2497
|
lines[2] = (up && s?.version && VERSION !== 'dev' && s.version !== VERSION) ? ' ' + t.warn(i.daemonStale(s.version, VERSION)) : ''
|
|
2441
|
-
// Una actualización que
|
|
2498
|
+
// Una actualización que pidió permiso (una vez por versión, dura un día): la bóveda parece
|
|
2442
2499
|
// normal y simplemente no se actualiza, así que se dice aquí.
|
|
2443
2500
|
if (!lines[2] && up && s?.updateAsk?.version) {
|
|
2444
|
-
|
|
2501
|
+
const u = s.updateAsk
|
|
2502
|
+
const dia = (ms) => new Date(ms).toISOString().slice(0, 16).replace('T', ' ')
|
|
2503
|
+
// Esperando es un estado normal (gris); no aprobada es lo que hay que ver (aviso).
|
|
2504
|
+
lines[2] = ' ' + (u.result === 'pending'
|
|
2505
|
+
? t.muted(i.updateWaiting(u.version, dia(u.askedAt + 24 * 60 * 60 * 1000)))
|
|
2506
|
+
: t.warn(i.updateNotApproved(u.version, dia(u.askedAt))))
|
|
2445
2507
|
}
|
|
2446
2508
|
// Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
|
|
2447
2509
|
// el resto muestra su título simple.
|
|
@@ -2463,6 +2525,7 @@ function render (term, st) {
|
|
|
2463
2525
|
else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
|
|
2464
2526
|
else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
|
|
2465
2527
|
else if (st.screen === 'doors') body = renderList(doorRows(st, t), st.sel.doors || 0, contentH, cols, t, scrollRef)
|
|
2528
|
+
else if (st.screen === 'settings') body = renderList(settingsRows(st, t), st.sel.settings || 0, contentH, cols, t, scrollRef)
|
|
2466
2529
|
else if (st.screen === 'paircaps') body = renderList(pairCapsRows(st, t), st.sel.paircaps || 0, contentH, cols, t, scrollRef)
|
|
2467
2530
|
else if (st.screen === 'pairing') {
|
|
2468
2531
|
const pb = pairingBody(st, t, cols, contentH)
|
|
@@ -2546,7 +2609,7 @@ export async function runTui () {
|
|
|
2546
2609
|
const st = {
|
|
2547
2610
|
screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
|
|
2548
2611
|
lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
|
|
2549
|
-
sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, paircaps: 0, devvars: 0, logins: 0, doors: 0 },
|
|
2612
|
+
sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, paircaps: 0, devvars: 0, logins: 0, doors: 0, settings: 0 },
|
|
2550
2613
|
// Permisos tocados y sin guardar, por llave del aparato (ver `setCapsDraft`).
|
|
2551
2614
|
capsDrafts: {},
|
|
2552
2615
|
// Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
|
|
@@ -2653,6 +2716,7 @@ export async function runTui () {
|
|
|
2653
2716
|
else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
|
|
2654
2717
|
else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
|
|
2655
2718
|
else if (st.screen === 'doors') running = await onKeyDoors(term, st, key)
|
|
2719
|
+
else if (st.screen === 'settings') running = await onKeySettings(term, st, key)
|
|
2656
2720
|
else if (st.screen === 'paircaps') running = await onKeyPairCaps(term, st, key)
|
|
2657
2721
|
else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
|
|
2658
2722
|
else if (st.screen === 'join') running = await onKeyJoin(term, st, key)
|
|
@@ -2670,4 +2734,4 @@ export async function runTui () {
|
|
|
2670
2734
|
}
|
|
2671
2735
|
|
|
2672
2736
|
// Solo para pruebas headless (render sin terminal real). No usar en runtime.
|
|
2673
|
-
export const __test = { render, doorRows, onKeyDoors, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, 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 } }
|
|
2737
|
+
export const __test = { render, settingsRows, onKeySettings, doorRows, onKeyDoors, pairCapsRows, onKeyPairCaps, onKeyDevices, draftChanges, saveCapsDrafts, onKeySecrets, loginRows, onKeyLogins, refreshLogins, 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
|
@@ -26,7 +26,8 @@ const es = {
|
|
|
26
26
|
// encabezado / estado
|
|
27
27
|
daemonRunning: 'corriendo',
|
|
28
28
|
daemonStopped: 'DETENIDO',
|
|
29
|
-
|
|
29
|
+
updateWaiting: (version, until) => `⬆ la ${version} espera tu aprobación hasta ${until} UTC: mira Pedidos en tu teléfono`,
|
|
30
|
+
updateNotApproved: (version, when) => `⬆ se pidió permiso para la ${version} el ${when} UTC y no se aprobó: no se vuelve a pedir · dotrino-vault update`,
|
|
30
31
|
daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
|
|
31
32
|
activeVault: 'Bóveda activa: ',
|
|
32
33
|
noName: '(sin nombre)',
|
|
@@ -169,6 +170,18 @@ const es = {
|
|
|
169
170
|
titlePairing: 'Emparejar un dispositivo',
|
|
170
171
|
titlePairMode: 'Emparejar: ¿a qué cuenta entra?',
|
|
171
172
|
titleDoors: 'Con qué se abre la bóveda',
|
|
173
|
+
titleSettings: 'Ajustes de esta bóveda',
|
|
174
|
+
settingUpdateAlone: 'Esta bóveda se actualiza sola, sin pedir aprobación.',
|
|
175
|
+
settingUpdateAsks: 'Esta bóveda pide aprobación antes de actualizarse.',
|
|
176
|
+
settingUpdateUnknown: 'No se sabe si esta bóveda pide aprobación para actualizarse.',
|
|
177
|
+
settingChange: 'Enter cambiar',
|
|
178
|
+
settingUpdateHint1: 'Vale para todos los perfiles: se pide a los aparatos con «aprueba» de cualquiera de ellos, una vez por versión.',
|
|
179
|
+
settingUpdateHint2: 'Si ningún perfil tiene un aparato con «aprueba», la bóveda se actualiza sola.',
|
|
180
|
+
settingsUnreadable: 'El ajuste no se pudo leer: mientras siga así, la bóveda no se actualiza.',
|
|
181
|
+
settingsUnreadableHint: 'Pulsa Enter para volver a guardarlo (queda pidiendo aprobación; Enter otra vez lo cambia).',
|
|
182
|
+
settingUpdateNowAsks: 'Hecho: pide aprobación antes de actualizarse.',
|
|
183
|
+
settingUpdateNowAlone: 'Hecho: se actualiza sola.',
|
|
184
|
+
settingSaveFailed: (m) => `No se pudo guardar el ajuste: ${m}`,
|
|
172
185
|
doorsIntro: (name) => `Con qué se abre «${name}». Abre cualquiera de estas.`,
|
|
173
186
|
doorsNone: 'Sin candado: se abre sola en esta máquina.',
|
|
174
187
|
doorPassword: 'contraseña',
|
|
@@ -272,6 +285,8 @@ const es = {
|
|
|
272
285
|
revokedCount: (n) => ` Revocados: ${n}`,
|
|
273
286
|
startingPairing: 'Iniciando emparejamiento…',
|
|
274
287
|
deviceSince: 'desde',
|
|
288
|
+
notifyYes: 'le llegan avisos',
|
|
289
|
+
notifyUnknown: 'no ha dicho si le llegan avisos',
|
|
275
290
|
nameDeviceLabel: '¿Cómo se va a llamar este aparato?',
|
|
276
291
|
nameDeviceHint: 'Enter para dejar que lo diga él (usará tu apodo, y así no se distinguen)',
|
|
277
292
|
noPending: 'No hay ningún dispositivo pendiente',
|
|
@@ -414,7 +429,7 @@ const es = {
|
|
|
414
429
|
// INGLÉS: new/rename/delete/password/unlock/locK/pair/approve/revoke/refresh/
|
|
415
430
|
// language/quit); lo único que se traduce es la palabra que las explica.
|
|
416
431
|
// Segmentos, no una línea: si no caben en una, el render los reparte en varias.
|
|
417
|
-
helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'j unirse', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'y llaves', 'u desbloq', 'k bloquear', 'l English', 'q salir'],
|
|
432
|
+
helpProfiles: ['↑↓', 'Enter entrar', 'p emparejar', 'j unirse', 'n nueva', 'r renombrar', 'd borrar', 'c clave', 'x quitar-clave', 'y llaves', 'u desbloq', 'k bloquear', 's ajustes', 'l English', 'q salir'],
|
|
418
433
|
// La barra dice lo que se PUEDE hacer AHORA, no todo lo que existe: aprobar/rechazar solo
|
|
419
434
|
// valen si hay alguien esperando, y renombrar/revocar solo si hay un aparato seleccionado.
|
|
420
435
|
// Anunciar teclas muertas confunde y además las quema para otros usos.
|
|
@@ -452,6 +467,7 @@ const es = {
|
|
|
452
467
|
helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', '↑↓ scroll', 'Esc atrás', 'l English'],
|
|
453
468
|
helpPairMode: ['↑↓', 'Enter elegir', 'Esc atrás', 'l English', 'q salir'],
|
|
454
469
|
helpDoors: ['↑↓', 'Enter añadir', 'd quitar', 'Esc atrás', 'l English', 'q salir'],
|
|
470
|
+
helpSettings: ['Enter cambiar', 'Esc atrás', 'l English', 'q salir'],
|
|
455
471
|
helpJoin: ['Esc bóvedas', 'l English', 'q salir'],
|
|
456
472
|
|
|
457
473
|
// pantalla "daemon caído"
|
|
@@ -499,7 +515,8 @@ const en = {
|
|
|
499
515
|
|
|
500
516
|
daemonRunning: 'running',
|
|
501
517
|
daemonStopped: 'STOPPED',
|
|
502
|
-
|
|
518
|
+
updateWaiting: (version, until) => `⬆ ${version} is waiting for your approval until ${until} UTC: check Requests on your phone`,
|
|
519
|
+
updateNotApproved: (version, when) => `⬆ approval for ${version} was asked on ${when} UTC and not given: it will not ask again · dotrino-vault update`,
|
|
503
520
|
daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
|
|
504
521
|
activeVault: 'Active vault: ',
|
|
505
522
|
tooSmall: 'Terminal too small',
|
|
@@ -622,6 +639,18 @@ const en = {
|
|
|
622
639
|
titlePairing: 'Pair a device',
|
|
623
640
|
titlePairMode: 'Pairing: which account does it join?',
|
|
624
641
|
titleDoors: 'What opens this vault',
|
|
642
|
+
titleSettings: 'Settings of this vault',
|
|
643
|
+
settingUpdateAlone: 'This vault updates on its own, without asking for approval.',
|
|
644
|
+
settingUpdateAsks: 'This vault asks for approval before updating.',
|
|
645
|
+
settingUpdateUnknown: 'It is not known whether this vault asks for approval to update.',
|
|
646
|
+
settingChange: 'Enter change',
|
|
647
|
+
settingUpdateHint1: 'It applies to every profile: devices with “approve” in any of them are asked, once per version.',
|
|
648
|
+
settingUpdateHint2: 'If no profile has a device with “approve”, the vault updates on its own.',
|
|
649
|
+
settingsUnreadable: 'The setting could not be read: while it stays like this, the vault does not update.',
|
|
650
|
+
settingsUnreadableHint: 'Press Enter to save it again (it will ask for approval; Enter again changes it).',
|
|
651
|
+
settingUpdateNowAsks: 'Done: it asks for approval before updating.',
|
|
652
|
+
settingUpdateNowAlone: 'Done: it updates on its own.',
|
|
653
|
+
settingSaveFailed: (m) => `The setting could not be saved: ${m}`,
|
|
625
654
|
doorsIntro: (name) => `What opens “${name}”. Any of these opens it.`,
|
|
626
655
|
doorsNone: 'No lock: it opens by itself on this machine.',
|
|
627
656
|
doorPassword: 'password',
|
|
@@ -719,6 +748,8 @@ const en = {
|
|
|
719
748
|
revokedCount: (n) => ` Revoked: ${n}`,
|
|
720
749
|
startingPairing: 'Starting pairing…',
|
|
721
750
|
deviceSince: 'since',
|
|
751
|
+
notifyYes: 'gets notifications',
|
|
752
|
+
notifyUnknown: 'has not said if notifications reach it',
|
|
722
753
|
nameDeviceLabel: 'What will this device be called?',
|
|
723
754
|
nameDeviceHint: 'Enter to let it say (it will use your nickname, and then they all look alike)',
|
|
724
755
|
noPending: 'No device is waiting',
|
|
@@ -843,7 +874,7 @@ const en = {
|
|
|
843
874
|
helpInput: 'Enter confirm · Esc cancel · Ctrl-U clear',
|
|
844
875
|
helpConfirm: 'y confirm · n/Esc cancel',
|
|
845
876
|
|
|
846
|
-
helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'y keys', 'u unlock', 'k lock', 'l Español', 'q quit'],
|
|
877
|
+
helpProfiles: ['↑↓', 'Enter open', 'p pair', 'j join', 'n new', 'r rename', 'd delete', 'c password', 'x drop-password', 'y keys', 'u unlock', 'k lock', 's settings', 'l Español', 'q quit'],
|
|
847
878
|
helpDevices: ({ pending, hasDevices, isService, drafts, curBlocked } = {}) => [
|
|
848
879
|
'←→ tab', '↑↓', 'p pair',
|
|
849
880
|
...(drafts ? ['g save permissions'] : []),
|
|
@@ -875,6 +906,7 @@ const en = {
|
|
|
875
906
|
helpPairing: ['a approve', 'x reject', 'r restart', '↑↓ scroll', 'Esc back', 'l Español'],
|
|
876
907
|
helpPairMode: ['↑↓', 'Enter choose', 'Esc back', 'l Español', 'q quit'],
|
|
877
908
|
helpDoors: ['↑↓', 'Enter add', 'd remove', 'Esc back', 'l Español', 'q quit'],
|
|
909
|
+
helpSettings: ['Enter change', 'Esc back', 'l Español', 'q quit'],
|
|
878
910
|
helpJoin: ['Esc vaults', 'l Español', 'q quit'],
|
|
879
911
|
|
|
880
912
|
downTitle: 'The vault daemon is not running.',
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PEDIR PERMISO PARA ACTUALIZARSE, EN TODOS LOS PERFILES A LA VEZ.
|
|
3
|
+
*
|
|
4
|
+
* El ajuste es de la BÓVEDA, no de un perfil (dueño, 2026-10-08): «si se activa funciona si
|
|
5
|
+
* cualquier perfil tiene aprobador, afecta a todos los perfiles y se envía notificación a
|
|
6
|
+
* todos los perfiles». El binario es uno solo, así que quien aprueba en cualquiera de las
|
|
7
|
+
* cuentas que viven aquí decide por la máquina.
|
|
8
|
+
*
|
|
9
|
+
* Sin estado y sin red: recibe los perfiles en marcha y lo que cada uno sabe hacer, para
|
|
10
|
+
* poder probarse sin daemon (`test/actualizar-todos-los-perfiles.test.mjs`).
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { ipcRead, ipcWrite } from './ipc.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Quién aprueba, en todos los perfiles. SI UNO NO SE PUEDE LEER, SE LANZA: una lista a medias
|
|
18
|
+
* sería decidir con menos aprobadores de los que hay, que es el repliegue que abre la puerta
|
|
19
|
+
* justo cuando algo se rompió.
|
|
20
|
+
* @param {{ id: string, name?: string, vault: { approvers: () => Promise<any[]> } }[]} profiles
|
|
21
|
+
* @returns {Promise<{ profile: string, profileId: string, id: string, label: string, pub: string, notifiedAt: number|null }[]>}
|
|
22
|
+
*/
|
|
23
|
+
export async function approversOfAll (profiles) {
|
|
24
|
+
const out = []
|
|
25
|
+
for (const p of profiles) {
|
|
26
|
+
let list
|
|
27
|
+
try { list = await p.vault.approvers() } catch (e) {
|
|
28
|
+
throw Object.assign(new Error(`profile ${p.name || p.id}: ${e?.message || e}`), { code: 'approvers-unreadable' })
|
|
29
|
+
}
|
|
30
|
+
for (const a of list) out.push({ ...a, profile: p.name || p.id, profileId: p.id })
|
|
31
|
+
}
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Pide en cada perfil y espera: EL PRIMER «SÍ» BASTA; es «no» cuando todos dijeron que no o
|
|
37
|
+
* vencieron. Al resolverse se RETIRAN los pedidos que quedaron vivos en los demás, para que
|
|
38
|
+
* no se queden un día en la lista de quien aprueba pidiendo algo que ya se decidió.
|
|
39
|
+
* @param {{ answer: Promise<boolean>, withdraw: () => void }[]} asks
|
|
40
|
+
* @returns {Promise<boolean>}
|
|
41
|
+
*/
|
|
42
|
+
export function firstYes (asks) {
|
|
43
|
+
if (!asks.length) return Promise.resolve(false)
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
let left = asks.length
|
|
46
|
+
let done = false
|
|
47
|
+
const finish = (ok, winner) => {
|
|
48
|
+
if (done) return
|
|
49
|
+
done = true
|
|
50
|
+
for (const a of asks) { if (a !== winner) { try { a.withdraw() } catch (_) { /* retirar es limpieza: no cambia la respuesta */ } } }
|
|
51
|
+
resolve(ok)
|
|
52
|
+
}
|
|
53
|
+
for (const a of asks) {
|
|
54
|
+
Promise.resolve(a.answer).then((ok) => {
|
|
55
|
+
if (ok === true) return finish(true, a)
|
|
56
|
+
if (--left === 0) finish(false, null)
|
|
57
|
+
}, () => { if (--left === 0) finish(false, null) })
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* EL PEDIDO SE HACE UNA VEZ POR VERSIÓN (dueño, 2026-10-08): «dura un día, pero se hace una
|
|
64
|
+
* sola vez; no se reintenta al siguiente día; se asume negado si no se hizo en 24 horas; se
|
|
65
|
+
* dispara nuevamente en la siguiente actualización».
|
|
66
|
+
*
|
|
67
|
+
* Así que se apunta QUÉ versión se preguntó, AL PEDIR, y sobrevive a un reinicio: una bóveda
|
|
68
|
+
* que se reinicia con el pedido a medias no vuelve a timbrar por lo mismo. Va por el canal
|
|
69
|
+
* local (cifrado en reposo), junto al resto del estado del daemon.
|
|
70
|
+
*
|
|
71
|
+
* UN ARCHIVO QUE EXISTE Y NO SE PUEDE LEER NO ES «NUNCA SE PREGUNTÓ» —eso volvería a pedir— ni
|
|
72
|
+
* tampoco «ya se aprobó»: se lanza, y quien llama no actualiza y lo dice.
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
export const ASKED_FILE = 'update-asked.json'
|
|
76
|
+
const RESULTS = ['pending', 'denied', 'expired']
|
|
77
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
78
|
+
|
|
79
|
+
/** @returns {{ version: string, askedAt: number, result: 'pending'|'denied'|'expired' } | null} */
|
|
80
|
+
export function readAsked (dir) {
|
|
81
|
+
const file = path.join(dir, ASKED_FILE)
|
|
82
|
+
if (!fs.existsSync(file)) return null
|
|
83
|
+
const d = ipcRead(file, null)
|
|
84
|
+
if (!d || typeof d.version !== 'string' || !d.version || typeof d.askedAt !== 'number' || !RESULTS.includes(d.result)) {
|
|
85
|
+
throw fail('asked-unreadable', `${ASKED_FILE} exists but cannot be read`)
|
|
86
|
+
}
|
|
87
|
+
return { version: d.version, askedAt: d.askedAt, result: d.result }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function writeAsked (dir, { version, askedAt, result }) {
|
|
91
|
+
if (typeof version !== 'string' || !version || typeof askedAt !== 'number' || !RESULTS.includes(result)) throw fail('bad-asked', 'writeAsked: version, askedAt and a known result are required')
|
|
92
|
+
ipcWrite(path.join(dir, ASKED_FILE), { v: 1, version, askedAt, result })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Ya se instaló (o ya no hace falta preguntar): se olvida lo preguntado. */
|
|
96
|
+
export function clearAsked (dir) { fs.rmSync(path.join(dir, ASKED_FILE), { force: true }) }
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* ¿Hay que pedir permiso por `version`? Solo si nunca se preguntó, o si es MÁS NUEVA que la
|
|
100
|
+
* que se preguntó. La misma (o una anterior) ya tuvo su pedido: no se repite.
|
|
101
|
+
* @param {(a: string, b: string) => boolean} isNewer `isNewer(a, b)`: a es más nueva que b
|
|
102
|
+
*/
|
|
103
|
+
export function shouldAsk (asked, version, isNewer) {
|
|
104
|
+
return !asked || isNewer(version, asked.version)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Un «no» que llega cuando el pedido ya cumplió su día es un vencimiento; antes, alguien lo denegó. */
|
|
108
|
+
export function resultOfNo (askedAt, ttlMs, now = Date.now()) {
|
|
109
|
+
return now - askedAt >= ttlMs - 60_000 ? 'expired' : 'denied'
|
|
110
|
+
}
|