@dotrino/vaultd 0.146.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 +33 -7
- package/src/daemon.js +107 -18
- package/src/notices.js +68 -0
- package/src/tui/app.js +63 -4
- package/src/tui/i18n.js +32 -4
- package/src/updateApproval.js +110 -0
- package/src/vault.js +183 -9
package/lib/src/service.js
CHANGED
|
@@ -620,6 +620,119 @@ export async function fetchDigest ({ dir, ns, proxyUrl, masterPubkey, device, ce
|
|
|
620
620
|
} finally { client.close() }
|
|
621
621
|
}
|
|
622
622
|
|
|
623
|
+
/** Cuánto espera un aparato a que alguien apruebe su actualización (el pedido vive un día). */
|
|
624
|
+
export const UPDATE_APPROVAL_TIMEOUT_MS = 24 * 60 * 60 * 1000 + 10 * 1000
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* ¿PUEDO ACTUALIZARME? Lo pregunta un aparato a su bóveda ANTES de instalar una versión
|
|
628
|
+
* nueva, y SOLO si tiene encendido su propio interruptor de «pedir aprobación» (CONVENCIONES
|
|
629
|
+
* §15, dueño 2026-10-08: todo se actualiza solo; pedir aprobación es un ajuste de cada pieza,
|
|
630
|
+
* independiente del de la bóveda). Quien no lo tiene encendido no llama a esto.
|
|
631
|
+
*
|
|
632
|
+
* · nadie aprueba en la cuenta → `{ ok: true, asked: false }`, ya.
|
|
633
|
+
* · hay aprobadores → la bóveda contesta «pendiente», timbra a quien aprueba y el resultado
|
|
634
|
+
* llega por esta misma conexión cuando alguien decide: `{ ok, asked: true }`. El pedido
|
|
635
|
+
* dura un día; vencido es no.
|
|
636
|
+
*
|
|
637
|
+
* LANZA si la bóveda no contesta (`code: 'vault-no-reply'`) o si quedó «pendiente» y el
|
|
638
|
+
* resultado no llegó (`code: 'unanswered'`): quien llama decide qué hacer. Que nadie conteste
|
|
639
|
+
* NUNCA es un sí. Preguntar otra vez por lo mismo mientras el pedido vive no vuelve a timbrar;
|
|
640
|
+
* no repetir la pregunta por la misma versión es cosa de quien llama.
|
|
641
|
+
*
|
|
642
|
+
* @param {object} o
|
|
643
|
+
* @param {string} o.product nombre npm de lo que se actualiza, p. ej. `@dotrino/terminal-agent`
|
|
644
|
+
* @param {string} o.version la versión que se quiere instalar
|
|
645
|
+
* @param {string} [o.from] la que corre ahora
|
|
646
|
+
* @param {string} [o.dir] identidad guardada por `enrollService`; o, en su lugar, la
|
|
647
|
+
* identidad por parámetros (`proxyUrl`, `masterPubkey`, `device`, `cert`), como en los demás.
|
|
648
|
+
* @param {(p: { id: string, exp: number }) => void} [o.onPending]
|
|
649
|
+
* @returns {Promise<{ ok: boolean, asked: boolean }>}
|
|
650
|
+
*/
|
|
651
|
+
export async function askUpdateApproval ({ product, version, from = null, dir, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000, approvalTimeoutMs = UPDATE_APPROVAL_TIMEOUT_MS, onPending } = {}) {
|
|
652
|
+
const saved = dir ? readServiceIdentity(dir) : null
|
|
653
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
654
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
655
|
+
device = device || saved?.device
|
|
656
|
+
cert = cert || saved?.cert
|
|
657
|
+
if (typeof product !== 'string' || !product || typeof version !== 'string' || !version) throw new Error('askUpdateApproval: needs product and version')
|
|
658
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
659
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
660
|
+
try {
|
|
661
|
+
await identifyAsService(client, device)
|
|
662
|
+
const data = { op: 'update-ask', product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
|
|
663
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
664
|
+
const mine = (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'update-ask' && p.body.product === product && p.body.version === version)
|
|
665
|
+
const check = async (res) => {
|
|
666
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
667
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
668
|
+
if (typeof res.body.ts !== 'number' || Math.abs(Date.now() - res.body.ts) > FRESH_WINDOW_MS) throw new Error('stale update-ask reply')
|
|
669
|
+
return res.body
|
|
670
|
+
}
|
|
671
|
+
// UNA COLA, no dos esperas seguidas: el resultado puede llegar mientras todavía se
|
|
672
|
+
// comprueba el «pendiente», y una escucha puesta después lo perdería (y se esperaría un
|
|
673
|
+
// día por algo que ya se contestó).
|
|
674
|
+
const queue = []
|
|
675
|
+
let wake = null
|
|
676
|
+
const off = client.on('message', (_from, p) => { if (p && typeof p === 'object' && mine(p)) { queue.push(p); wake?.() } })
|
|
677
|
+
const next = (ms, code, message) => new Promise((resolve, reject) => {
|
|
678
|
+
if (queue.length) return resolve(queue.shift())
|
|
679
|
+
const t = setTimeout(() => { wake = null; reject(Object.assign(new Error(message), { code })) }, ms)
|
|
680
|
+
wake = () => { clearTimeout(t); wake = null; resolve(queue.shift()) }
|
|
681
|
+
})
|
|
682
|
+
let body
|
|
683
|
+
try {
|
|
684
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
685
|
+
body = await check(await next(timeoutMs, 'vault-no-reply', 'update-ask: the vault did not answer'))
|
|
686
|
+
if (body.pending === true) {
|
|
687
|
+
try { onPending?.({ id: body.id, exp: body.exp }) } catch (_) {}
|
|
688
|
+
const until = typeof body.exp === 'number' ? Math.max(5000, body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
689
|
+
do {
|
|
690
|
+
body = await check(await next(Math.min(until, approvalTimeoutMs), 'unanswered', 'update-ask: nobody answered the request in time'))
|
|
691
|
+
} while (body.pending === true)
|
|
692
|
+
}
|
|
693
|
+
} finally { off() }
|
|
694
|
+
if (typeof body.ok !== 'boolean') throw new Error('malformed update-ask reply')
|
|
695
|
+
return { ok: body.ok, asked: body.asked === true }
|
|
696
|
+
} finally { client.close() }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* ME ACTUALICÉ: un aparato se lo dice a su bóveda DESPUÉS de instalar, y ella se lo cuenta a
|
|
701
|
+
* quien aprueba en la cuenta (un aviso, y queda 24 h en la lista de avisos). Se llama solo
|
|
702
|
+
* si el aparato tiene encendido su interruptor de avisos de actualización.
|
|
703
|
+
*
|
|
704
|
+
* Quién es el aparato lo pone la bóveda desde el acta, no lo que él diga.
|
|
705
|
+
* LANZA si la bóveda no contesta (`code: 'vault-no-reply'`) o no pudo avisar.
|
|
706
|
+
*
|
|
707
|
+
* @param {object} o `product`, `version`, `from` y la conexión, como en `askUpdateApproval`
|
|
708
|
+
* @returns {Promise<{ ok: true }>}
|
|
709
|
+
*/
|
|
710
|
+
export async function reportUpdated ({ product, version, from = null, dir, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
|
|
711
|
+
const saved = dir ? readServiceIdentity(dir) : null
|
|
712
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
713
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
714
|
+
device = device || saved?.device
|
|
715
|
+
cert = cert || saved?.cert
|
|
716
|
+
if (typeof product !== 'string' || !product || typeof version !== 'string' || !version) throw new Error('reportUpdated: needs product and version')
|
|
717
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
718
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
719
|
+
try {
|
|
720
|
+
await identifyAsService(client, device)
|
|
721
|
+
const data = { op: 'update-done', product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
|
|
722
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
723
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'update-done' && p.body.product === product && p.body.version === version), timeoutMs)
|
|
724
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
725
|
+
const res = await pending.catch((e) => {
|
|
726
|
+
if (/timeout/.test(e.message)) throw Object.assign(new Error('update-done: the vault did not answer'), { code: 'vault-no-reply' })
|
|
727
|
+
throw e
|
|
728
|
+
})
|
|
729
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
730
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
731
|
+
if (res.body.ok !== true) throw new Error('malformed update-done reply')
|
|
732
|
+
return { ok: true }
|
|
733
|
+
} finally { client.close() }
|
|
734
|
+
}
|
|
735
|
+
|
|
623
736
|
/**
|
|
624
737
|
* Pide los secretos del ns al vault (una petición puntual; lanza si falla).
|
|
625
738
|
* Usa la identidad persistida por `enrollService` salvo que se pase explícita.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.147.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
|
@@ -79,6 +79,10 @@ const D = '\x1b[2m' // apagado: para el dato que acompaña sin competir con el n
|
|
|
79
79
|
* En español, como el resto de esta CLI: con el idioma del sistema salía «Jul 29, 2026»
|
|
80
80
|
* en medio de una frase en español, que es peor que cualquiera de los dos por separado.
|
|
81
81
|
*/
|
|
82
|
+
function fechaHora (ms) {
|
|
83
|
+
try { return new Date(ms).toLocaleString('es', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) }
|
|
84
|
+
catch (_) { return '' }
|
|
85
|
+
}
|
|
82
86
|
function fechaCorta (ms) {
|
|
83
87
|
try { return new Date(ms).toLocaleDateString('es', { year: 'numeric', month: 'short', day: 'numeric' }) }
|
|
84
88
|
catch (_) { return '' }
|
|
@@ -158,15 +162,22 @@ function cmdStatus () {
|
|
|
158
162
|
console.log(' %s⬆ hay %s publicada%s (esta es la %s) · tráela con: dotrino-vault update',
|
|
159
163
|
B, s.latest.version, Z, VERSION)
|
|
160
164
|
}
|
|
161
|
-
// …Y
|
|
162
|
-
//
|
|
165
|
+
// …Y SE PIDIÓ PERMISO. El pedido se hace UNA vez por versión y dura un día: mientras
|
|
166
|
+
// espera se dice hasta cuándo; si no se aprobó, que ya no se vuelve a pedir y cómo
|
|
167
|
+
// instalarla. Es un freno que desde fuera no se ve: la bóveda parece normal.
|
|
163
168
|
if (s.updateAsk?.version && isNewer(s.updateAsk.version, VERSION)) {
|
|
164
|
-
|
|
165
|
-
|
|
169
|
+
const u = s.updateAsk
|
|
170
|
+
if (u.result === 'pending') {
|
|
171
|
+
console.log(' se pidió permiso para instalar la %s el %s y está esperando respuesta hasta el %s · mira Pedidos en tu teléfono',
|
|
172
|
+
u.version, fechaHora(u.askedAt), fechaHora(u.askedAt + 24 * 60 * 60 * 1000))
|
|
173
|
+
} else {
|
|
174
|
+
console.log(' se pidió permiso para instalar la %s el %s y no se aprobó: no se vuelve a pedir · instálala con: dotrino-vault update',
|
|
175
|
+
u.version, fechaHora(u.askedAt))
|
|
176
|
+
}
|
|
166
177
|
// A QUIÉN SE LE PIDIÓ, y si dijo que le llegan los avisos: el que no lo ha dicho es el
|
|
167
178
|
// primer sitio donde mirar cuando nadie contesta.
|
|
168
179
|
for (const a of s.updateAsk.approvers || []) {
|
|
169
|
-
console.log(' %s %s · %s', a.id, a.label || '(sin nombre)', avisosTxt(a.notifiedAt))
|
|
180
|
+
console.log(' %s %s%s · %s', a.id, a.label || '(sin nombre)', a.profile ? ` [${a.profile}]` : '', avisosTxt(a.notifiedAt))
|
|
170
181
|
}
|
|
171
182
|
}
|
|
172
183
|
// CÓMO SE ACTUALIZA. Va siempre, también al día: es un ajuste y se mira aquí.
|
|
@@ -1813,6 +1824,18 @@ async function waitForService (version, { timeoutMs = 4 * 60_000 } = {}) {
|
|
|
1813
1824
|
return false
|
|
1814
1825
|
}
|
|
1815
1826
|
|
|
1827
|
+
/**
|
|
1828
|
+
* DEJA DICHO QUE SE INSTALÓ una versión nueva, para que el servicio se lo cuente a quien
|
|
1829
|
+
* aprueba cuando arranque con ella (`anunciarActualizacion` en daemon.js). Es el mismo
|
|
1830
|
+
* marcador que deja el servicio cuando se actualiza solo. Si no se puede escribir se dice:
|
|
1831
|
+
* la actualización está hecha, lo que se pierde es el aviso.
|
|
1832
|
+
*/
|
|
1833
|
+
function noteUpdated (to) {
|
|
1834
|
+
try { ipcWrite(path.join(dir, 'updated.json'), { v: 1, from: readState().version || VERSION, to, at: Date.now() }) } catch (e) {
|
|
1835
|
+
console.error('No se pudo dejar el aviso de actualización para tus aprobadores: %s', e.message)
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1816
1839
|
async function cmdUpdate (args = []) {
|
|
1817
1840
|
// PEDIR PERMISO PARA ACTUALIZARSE es un ajuste, apagado por defecto. Se guarda aquí y el
|
|
1818
1841
|
// servicio lo lee la próxima vez que mire si hay versión nueva: no hace falta reiniciarlo.
|
|
@@ -1823,7 +1846,7 @@ async function cmdUpdate (args = []) {
|
|
|
1823
1846
|
if (v === 'on' || v === 'off') writeSettings(dir, { updateApproval: v === 'on' })
|
|
1824
1847
|
else if (v !== undefined) { console.error('uso: dotrino-vault update --approval [on|off]'); process.exit(2) }
|
|
1825
1848
|
console.log(readSettings(dir).updateApproval
|
|
1826
|
-
? 'Esta bóveda pide aprobación antes de actualizarse (a los aparatos con «aprueba»).'
|
|
1849
|
+
? 'Esta bóveda pide aprobación antes de actualizarse (a los aparatos con «aprueba» de cualquiera de sus perfiles).'
|
|
1827
1850
|
: 'Esta bóveda se actualiza sola, sin pedir aprobación.')
|
|
1828
1851
|
return
|
|
1829
1852
|
}
|
|
@@ -1858,6 +1881,7 @@ async function cmdUpdate (args = []) {
|
|
|
1858
1881
|
}
|
|
1859
1882
|
console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
|
|
1860
1883
|
console.log('Instalada la %s.', res.version)
|
|
1884
|
+
noteUpdated(res.version)
|
|
1861
1885
|
if (!args.includes('--no-wait')) await waitForService(res.version)
|
|
1862
1886
|
return
|
|
1863
1887
|
}
|
|
@@ -1897,6 +1921,7 @@ async function cmdUpdate (args = []) {
|
|
|
1897
1921
|
B, file, r.version, Z)
|
|
1898
1922
|
return
|
|
1899
1923
|
}
|
|
1924
|
+
noteUpdated(r.version)
|
|
1900
1925
|
console.log('Listo. El servicio se reinicia solo; compruébalo con: dotrino-vault status')
|
|
1901
1926
|
}
|
|
1902
1927
|
|
|
@@ -2209,7 +2234,8 @@ function help () {
|
|
|
2209
2234
|
servicio la arranque · --check solo mira · --no-wait no espera
|
|
2210
2235
|
update --approval [on|off]
|
|
2211
2236
|
el servicio se actualiza solo; con «on» antes pide aprobación a los
|
|
2212
|
-
aparatos con «aprueba»
|
|
2237
|
+
aparatos con «aprueba» de cualquier perfil (vale para todos) · sin
|
|
2238
|
+
valor, dice cómo está
|
|
2213
2239
|
pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
|
|
2214
2240
|
pair --kms <config.json>
|
|
2215
2241
|
el sitio que se cree (--adopt o --new-account) NACE con su clave
|
package/src/daemon.js
CHANGED
|
@@ -28,10 +28,15 @@ import { watchBinary } from './selfupdate.js'
|
|
|
28
28
|
import { latestRelease, isNewer, CHECK_EVERY_MS, isUserInstall, installUserRelease } from './update.js'
|
|
29
29
|
import { VERSION } from './version.js'
|
|
30
30
|
import { readSettings } from './settings.js'
|
|
31
|
+
import { approversOfAll, firstYes, readAsked, writeAsked, clearAsked, shouldAsk, resultOfNo } from './updateApproval.js'
|
|
32
|
+
import { UPDATE_TTL_MS } from './approvals.js'
|
|
31
33
|
import crypto from 'node:crypto'
|
|
32
34
|
|
|
33
35
|
const readJsonSafe = (f) => ipcRead(f, null)
|
|
34
36
|
const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
|
|
37
|
+
/** Cada cuánto se reintenta contar la actualización, y cuántas veces (≈10 min). */
|
|
38
|
+
const ANNOUNCE_EVERY_MS = 5000
|
|
39
|
+
const ANNOUNCE_TRIES = 120
|
|
35
40
|
|
|
36
41
|
/**
|
|
37
42
|
* UNA sola bóveda por directorio de datos.
|
|
@@ -90,13 +95,18 @@ export async function runDaemon () {
|
|
|
90
95
|
|
|
91
96
|
// --- state.json ---
|
|
92
97
|
const stateFile = path.join(dir, 'state.json')
|
|
98
|
+
// Lo que deja quien instala una versión nueva, para que la nueva lo cuente al arrancar.
|
|
99
|
+
const updatedFile = path.join(dir, 'updated.json')
|
|
100
|
+
/** Los perfiles que están atendiendo ahora mismo, con su nombre para los mensajes. */
|
|
101
|
+
const perfilesEnMarcha = () => [...mgr.running.entries()].map(([id, vault]) => ({ id, vault, name: mgr.profiles.get(id)?.name || id }))
|
|
93
102
|
const daemonVersion = VERSION
|
|
94
103
|
/** `{ version, checkedAt }` de la última consulta que salió bien, o null si aún ninguna. */
|
|
95
104
|
let ultimaPublicada = null
|
|
96
105
|
/**
|
|
97
|
-
* EL PEDIDO DE ACTUALIZACIÓN
|
|
98
|
-
*
|
|
99
|
-
*
|
|
106
|
+
* EL PEDIDO DE ACTUALIZACIÓN: `{ version, askedAt, result, approvers }`, o null. `result`
|
|
107
|
+
* es `pending` mientras espera (un día) y `denied`/`expired` cuando no se aprobó — y
|
|
108
|
+
* entonces no se vuelve a pedir por esa versión. Va en la foto para que `status` y la TUI
|
|
109
|
+
* lo DIGAN: una bóveda que no se actualiza por esto se vería igual que una al día.
|
|
100
110
|
*/
|
|
101
111
|
let pedidoSinRespuesta = null
|
|
102
112
|
// Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
|
|
@@ -1131,11 +1141,16 @@ export async function runDaemon () {
|
|
|
1131
1141
|
console.log(`[vault] version ${r.version} is out, but the settings could not be read (${e?.message || e}) · not updating`)
|
|
1132
1142
|
return
|
|
1133
1143
|
}
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1144
|
+
// EN TODOS LOS PERFILES: el ajuste es de la bóveda, no de una cuenta (dueño, 2026-10-08).
|
|
1145
|
+
// Basta con que UNO tenga quien apruebe para que se pida, y se le pide a todos.
|
|
1146
|
+
//
|
|
1147
|
+
// Y si hay que pedir y no se puede saber a quién, tampoco se actualiza: una lista vacía
|
|
1148
|
+
// o a medias sería «nadie aprueba, adelante», el repliegue que abre la puerta justo
|
|
1149
|
+
// cuando algo se rompió.
|
|
1150
|
+
const enMarcha = perfilesEnMarcha()
|
|
1136
1151
|
let quienAprueba = []
|
|
1137
1152
|
if (pedirPermiso) {
|
|
1138
|
-
try { quienAprueba = await
|
|
1153
|
+
try { quienAprueba = await approversOfAll(enMarcha) } catch (e) {
|
|
1139
1154
|
console.log(`[vault] version ${r.version} is out, but the approvers could not be read (${e?.message || e}) · not updating`)
|
|
1140
1155
|
return
|
|
1141
1156
|
}
|
|
@@ -1151,26 +1166,55 @@ export async function runDaemon () {
|
|
|
1151
1166
|
if (actualizando) return
|
|
1152
1167
|
actualizando = true
|
|
1153
1168
|
try {
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1169
|
+
// (`aprobada`: ya dijeron que sí a esta versión y la instalación falló; no se vuelve a
|
|
1170
|
+
// preguntar por lo que ya se aprobó, se reintenta instalar.)
|
|
1171
|
+
if (quienAprueba.length && aprobada !== r.version) {
|
|
1172
|
+
// UNA VEZ POR VERSIÓN (dueño, 2026-10-08): el pedido dura un día y no se repite. Si
|
|
1173
|
+
// se denegó o venció, esa versión queda sin aprobar y se vuelve a preguntar solo
|
|
1174
|
+
// cuando salga una MÁS NUEVA. Lo preguntado se apunta al pedir y sobrevive a un
|
|
1175
|
+
// reinicio (`update-asked.json`); si no se puede leer, no se actualiza y se dice.
|
|
1176
|
+
let preguntado
|
|
1177
|
+
try { preguntado = readAsked(dir) } catch (e) {
|
|
1178
|
+
console.log(`[vault] version ${r.version} is out, but what was already asked could not be read (${e?.message || e}) · not updating`)
|
|
1179
|
+
return
|
|
1180
|
+
}
|
|
1181
|
+
const quienes = quienAprueba.map((a) => ({ profile: a.profile, id: a.id, label: a.label, notifiedAt: a.notifiedAt }))
|
|
1182
|
+
if (!shouldAsk(preguntado, r.version, isNewer)) {
|
|
1183
|
+
// Un «pendiente» que no es de este proceso es de antes de un reinicio: el pedido
|
|
1184
|
+
// murió con él, y nadie puede contestarlo ya.
|
|
1185
|
+
if (preguntado.result === 'pending') { preguntado = { ...preguntado, result: 'expired' }; writeAsked(dir, preguntado) }
|
|
1186
|
+
const nuevo = pedidoSinRespuesta?.version !== preguntado.version || pedidoSinRespuesta?.result !== preguntado.result
|
|
1187
|
+
pedidoSinRespuesta = { ...preguntado, approvers: quienes }
|
|
1188
|
+
writeState()
|
|
1189
|
+
if (nuevo) console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · approval for ${preguntado.version} was asked on ${new Date(preguntado.askedAt).toISOString()} and not given (${preguntado.result}) · not asking again until a newer version · install it with: dotrino-vault update`)
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1192
|
+
const askedAt = Date.now()
|
|
1193
|
+
writeAsked(dir, { version: r.version, askedAt, result: 'pending' })
|
|
1194
|
+
pedidoSinRespuesta = { version: r.version, askedAt, result: 'pending', approvers: quienes }
|
|
1195
|
+
writeState()
|
|
1196
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · asking ${quienAprueba.length} approver(s) across ${new Set(quienAprueba.map((a) => a.profileId)).size} profile(s), once · ${conAvisos} of them said notifications reach it`)
|
|
1197
|
+
// Un pedido por perfil que tenga quien apruebe, a la vez. El primer sí basta, y los
|
|
1198
|
+
// que queden vivos en los demás se retiran (`src/updateApproval.js`).
|
|
1199
|
+
const conAprobador = new Set(quienAprueba.map((a) => a.profileId))
|
|
1200
|
+
const si = await firstYes(enMarcha.filter((p) => conAprobador.has(p.id)).map((p) => p.vault.askUpdateApproval({ version: r.version })))
|
|
1157
1201
|
if (!si) {
|
|
1158
|
-
const
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
// A QUIÉN SE LE PIDIÓ, y si dijo que le llegan los avisos: es lo que hay que mirar
|
|
1162
|
-
// cuando nadie contesta.
|
|
1163
|
-
approvers: quienAprueba.map((a) => ({ id: a.id, label: a.label, notifiedAt: a.notifiedAt }))
|
|
1164
|
-
}
|
|
1202
|
+
const result = resultOfNo(askedAt, UPDATE_TTL_MS)
|
|
1203
|
+
writeAsked(dir, { version: r.version, askedAt, result })
|
|
1204
|
+
pedidoSinRespuesta = { version: r.version, askedAt, result, approvers: quienes }
|
|
1165
1205
|
writeState()
|
|
1166
|
-
console.log(`[vault] update to ${r.version}: not approved (
|
|
1206
|
+
console.log(`[vault] update to ${r.version}: not approved (${result}) · it will not ask again for this version · install it with: dotrino-vault update`)
|
|
1167
1207
|
return
|
|
1168
1208
|
}
|
|
1209
|
+
aprobada = r.version
|
|
1210
|
+
} else if (quienAprueba.length) {
|
|
1211
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · already approved: installing`)
|
|
1169
1212
|
} else if (pedirPermiso) {
|
|
1170
|
-
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · approval is on but no
|
|
1213
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · approval is on but no profile has an approver: updating on its own`)
|
|
1171
1214
|
} else {
|
|
1172
1215
|
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · updating on its own`)
|
|
1173
1216
|
}
|
|
1217
|
+
clearAsked(dir)
|
|
1174
1218
|
if (pedidoSinRespuesta) { pedidoSinRespuesta = null; writeState() }
|
|
1175
1219
|
const res = await installUserRelease(r, { log: (m) => console.log(m) })
|
|
1176
1220
|
if (!res.ok) {
|
|
@@ -1183,6 +1227,12 @@ export async function runDaemon () {
|
|
|
1183
1227
|
// minutos con el CLI y la TUI nuevos hablando con el daemon viejo; el dueño abrió la
|
|
1184
1228
|
// TUI justo ahí y no arrancó (2026-09-24). Solo bajo systemd, que es quien nos levanta:
|
|
1185
1229
|
// sin él, irnos sería quedarnos apagados.
|
|
1230
|
+
// DEJAR DICHO QUE SE INSTALÓ, para que la versión nueva se lo cuente a quien aprueba al
|
|
1231
|
+
// arrancar (`anunciarActualizacion`). Si no se puede escribir se dice: la actualización
|
|
1232
|
+
// está hecha igual, lo que se pierde es el aviso.
|
|
1233
|
+
try { ipcWrite(updatedFile, { v: 1, from: daemonVersion, to: r.version, at: Date.now() }) } catch (e) {
|
|
1234
|
+
console.log(`[vault] update: ${r.version} installed, but the note to tell the approvers could not be written: ${e.message}`)
|
|
1235
|
+
}
|
|
1186
1236
|
if (vigia) {
|
|
1187
1237
|
console.log(`[vault] update: ${r.version} installed · restarting now to run it`)
|
|
1188
1238
|
shutdown(`updated to ${r.version}`)
|
|
@@ -1194,6 +1244,45 @@ export async function runDaemon () {
|
|
|
1194
1244
|
} finally { actualizando = false }
|
|
1195
1245
|
}
|
|
1196
1246
|
let actualizando = false
|
|
1247
|
+
let aprobada = null
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* «ME ACTUALICÉ»: se lo cuenta a quien aprueba la versión NUEVA, ya corriendo (dueño,
|
|
1251
|
+
* 2026-10-08). Quien instaló —este daemon o `dotrino-vault update`— dejó `updated.json`;
|
|
1252
|
+
* si dice la versión que corre ahora, cada perfil avisa a sus aprobadores en cuanto está
|
|
1253
|
+
* identificado en el proxio, y el marcador se borra cuando salió en todos.
|
|
1254
|
+
*
|
|
1255
|
+
* No se borra lo que no se pudo leer ni lo que no salió: se dice y se reintenta. Un
|
|
1256
|
+
* marcador de una versión ya superada sí se quita: esa noticia caducó.
|
|
1257
|
+
*/
|
|
1258
|
+
const anunciarActualizacion = () => {
|
|
1259
|
+
if (!fs.existsSync(updatedFile)) return
|
|
1260
|
+
const m = ipcRead(updatedFile, null)
|
|
1261
|
+
if (!m || typeof m.to !== 'string') { console.log('[vault] update: updated.json exists but cannot be read · the approvers were not told'); return }
|
|
1262
|
+
if (m.to !== daemonVersion) {
|
|
1263
|
+
if (isNewer(daemonVersion, m.to)) rm(updatedFile)
|
|
1264
|
+
return // todavía no corre esa versión: se anuncia cuando arranque
|
|
1265
|
+
}
|
|
1266
|
+
const hechos = new Set()
|
|
1267
|
+
let intentos = 0
|
|
1268
|
+
const intento = async () => {
|
|
1269
|
+
intentos++
|
|
1270
|
+
let falta = false
|
|
1271
|
+
for (const p of perfilesEnMarcha()) {
|
|
1272
|
+
if (hechos.has(p.id)) continue
|
|
1273
|
+
if (!p.vault.isIdentified?.()) { falta = true; continue }
|
|
1274
|
+
try { await p.vault.announceUpdate({ version: m.to, from: typeof m.from === 'string' ? m.from : null }); hechos.add(p.id) } catch (e) {
|
|
1275
|
+
falta = true
|
|
1276
|
+
if (intentos === 1 || intentos === ANNOUNCE_TRIES) console.log(`[vault] update: profile ${p.name} could not tell its approvers yet: ${e?.message || e}`)
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
if (!falta) { rm(updatedFile); return }
|
|
1280
|
+
if (intentos >= ANNOUNCE_TRIES) { console.log('[vault] update: some approvers were not told · it will try again on the next start'); return }
|
|
1281
|
+
setTimeout(intento, ANNOUNCE_EVERY_MS).unref?.()
|
|
1282
|
+
}
|
|
1283
|
+
setTimeout(intento, ANNOUNCE_EVERY_MS).unref?.()
|
|
1284
|
+
}
|
|
1285
|
+
anunciarActualizacion()
|
|
1197
1286
|
mirarRelease()
|
|
1198
1287
|
const relojRelease = setInterval(mirarRelease, CHECK_EVERY_MS)
|
|
1199
1288
|
relojRelease.unref?.()
|
package/src/notices.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LO QUE LA BÓVEDA TIENE QUE CONTARLE A QUIEN APRUEBA, y que no es un pedido.
|
|
3
|
+
*
|
|
4
|
+
* Hoy es uno: «me actualicé» —la bóveda o uno de sus aparatos— (`ev: 'updated'`, dueño 2026-10-08: «las actualizaciones, que se
|
|
5
|
+
* envíe una notificación de que ocurrieron a los aprobadores»). Sale como aviso por el
|
|
6
|
+
* proxio, pero el timbre de una app nativa llega VACÍO —lo leen Google y Apple—, así que la
|
|
7
|
+
* app pregunta por su lista (`op: 'approvals'`) y lo lee de aquí, en `notices`.
|
|
8
|
+
*
|
|
9
|
+
* Caduca a las 24 h: es una noticia, no un registro (eso es la bitácora).
|
|
10
|
+
*
|
|
11
|
+
* Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer se LANZA: callar
|
|
12
|
+
* sería decir «no pasó nada» de algo que sí pasó.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import crypto from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
export const NOTICES_FILE = 'notices.json'
|
|
19
|
+
/** Cuánto se sigue contando un aviso. */
|
|
20
|
+
export const NOTICE_TTL_MS = 24 * 60 * 60 * 1000
|
|
21
|
+
|
|
22
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
|
|
26
|
+
*/
|
|
27
|
+
export function openNotices ({ dir, atRest, now = Date.now }) {
|
|
28
|
+
const file = path.join(dir, NOTICES_FILE)
|
|
29
|
+
let items = load()
|
|
30
|
+
|
|
31
|
+
function load () {
|
|
32
|
+
if (!fs.existsSync(file)) return []
|
|
33
|
+
try {
|
|
34
|
+
const d = JSON.parse(atRest.decrypt(fs.readFileSync(file, 'utf8')))
|
|
35
|
+
if (!d || !Array.isArray(d.items)) throw new Error('not a list')
|
|
36
|
+
return d.items.filter((n) => n && typeof n.id === 'string' && typeof n.ts === 'number')
|
|
37
|
+
} catch (e) { throw fail('notices-unreadable', `${NOTICES_FILE} cannot be read: ${e.message}`) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function save () {
|
|
41
|
+
const tmp = file + '.tmp'
|
|
42
|
+
fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items })), { mode: 0o600 })
|
|
43
|
+
fs.renameSync(tmp, file)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const alive = (n) => now() - n.ts < NOTICE_TTL_MS
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
/**
|
|
50
|
+
* Apunta que algo se actualizó. Devuelve el aviso tal como viaja. `product`: qué
|
|
51
|
+
* (`@dotrino/vaultd` es la propia bóveda); `deviceId`/`label`: el aparato, si fue uno.
|
|
52
|
+
*/
|
|
53
|
+
updated ({ version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null }) {
|
|
54
|
+
if (typeof version !== 'string' || !version) throw fail('bad-notice', 'updated: a version is required')
|
|
55
|
+
if (typeof product !== 'string' || !product) throw fail('bad-notice', 'updated: a product is required')
|
|
56
|
+
const n = {
|
|
57
|
+
id: crypto.randomBytes(8).toString('hex'), ev: 'updated', product, version, from: typeof from === 'string' ? from : null,
|
|
58
|
+
...(typeof deviceId === 'string' && deviceId ? { deviceId, label: typeof label === 'string' ? label : '' } : {}),
|
|
59
|
+
ts: now()
|
|
60
|
+
}
|
|
61
|
+
items = [...items.filter(alive), n]
|
|
62
|
+
save()
|
|
63
|
+
return n
|
|
64
|
+
},
|
|
65
|
+
/** Los avisos vivos, del más viejo al más nuevo. */
|
|
66
|
+
list () { return items.filter(alive).map((n) => ({ ...n })) }
|
|
67
|
+
}
|
|
68
|
+
}
|
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'
|
|
@@ -338,6 +339,50 @@ function doorRows (st, t) {
|
|
|
338
339
|
return rows
|
|
339
340
|
}
|
|
340
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
|
+
|
|
341
386
|
function deviceRows (st, t) {
|
|
342
387
|
const i = L(st)
|
|
343
388
|
const rows = []
|
|
@@ -1204,6 +1249,11 @@ async function onKeyProfiles (term, st, key) {
|
|
|
1204
1249
|
},
|
|
1205
1250
|
onCancel: () => { st.input = null }
|
|
1206
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'
|
|
1207
1257
|
} else if (ch === 'y' && cur) { // las llaves: con qué se abre la bóveda
|
|
1208
1258
|
await ensureUnlocked(term, st, cur, async (p = cur) => {
|
|
1209
1259
|
st.doorsFor = p.id
|
|
@@ -2308,6 +2358,7 @@ const helpSegs = (i, screen, st = {}) => {
|
|
|
2308
2358
|
pairing: i.helpPairing,
|
|
2309
2359
|
pairmode: i.helpPairMode,
|
|
2310
2360
|
doors: i.helpDoors,
|
|
2361
|
+
settings: i.helpSettings,
|
|
2311
2362
|
paircaps: i.helpPairCaps,
|
|
2312
2363
|
join: i.helpJoin,
|
|
2313
2364
|
me: i.helpMe,
|
|
@@ -2343,6 +2394,7 @@ const title = (i, screen) => ({
|
|
|
2343
2394
|
pairing: i.titlePairing,
|
|
2344
2395
|
pairmode: i.titlePairMode,
|
|
2345
2396
|
doors: i.titleDoors,
|
|
2397
|
+
settings: i.titleSettings,
|
|
2346
2398
|
paircaps: i.titlePairCaps,
|
|
2347
2399
|
join: i.titleJoin,
|
|
2348
2400
|
caps: i.titleCaps,
|
|
@@ -2443,10 +2495,15 @@ function render (term, st) {
|
|
|
2443
2495
|
// instalar, peor: el daemon se queda con el binario viejo, ya borrado, y hay dos copias
|
|
2444
2496
|
// en RAM). `status` ya lo avisa; aquí también, que es donde uno se queda mirando.
|
|
2445
2497
|
lines[2] = (up && s?.version && VERSION !== 'dev' && s.version !== VERSION) ? ' ' + t.warn(i.daemonStale(s.version, VERSION)) : ''
|
|
2446
|
-
// Una actualización que
|
|
2498
|
+
// Una actualización que pidió permiso (una vez por versión, dura un día): la bóveda parece
|
|
2447
2499
|
// normal y simplemente no se actualiza, así que se dice aquí.
|
|
2448
2500
|
if (!lines[2] && up && s?.updateAsk?.version) {
|
|
2449
|
-
|
|
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))))
|
|
2450
2507
|
}
|
|
2451
2508
|
// Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
|
|
2452
2509
|
// el resto muestra su título simple.
|
|
@@ -2468,6 +2525,7 @@ function render (term, st) {
|
|
|
2468
2525
|
else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
|
|
2469
2526
|
else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
|
|
2470
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)
|
|
2471
2529
|
else if (st.screen === 'paircaps') body = renderList(pairCapsRows(st, t), st.sel.paircaps || 0, contentH, cols, t, scrollRef)
|
|
2472
2530
|
else if (st.screen === 'pairing') {
|
|
2473
2531
|
const pb = pairingBody(st, t, cols, contentH)
|
|
@@ -2551,7 +2609,7 @@ export async function runTui () {
|
|
|
2551
2609
|
const st = {
|
|
2552
2610
|
screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
|
|
2553
2611
|
lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
|
|
2554
|
-
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 },
|
|
2555
2613
|
// Permisos tocados y sin guardar, por llave del aparato (ver `setCapsDraft`).
|
|
2556
2614
|
capsDrafts: {},
|
|
2557
2615
|
// Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
|
|
@@ -2658,6 +2716,7 @@ export async function runTui () {
|
|
|
2658
2716
|
else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
|
|
2659
2717
|
else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
|
|
2660
2718
|
else if (st.screen === 'doors') running = await onKeyDoors(term, st, key)
|
|
2719
|
+
else if (st.screen === 'settings') running = await onKeySettings(term, st, key)
|
|
2661
2720
|
else if (st.screen === 'paircaps') running = await onKeyPairCaps(term, st, key)
|
|
2662
2721
|
else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
|
|
2663
2722
|
else if (st.screen === 'join') running = await onKeyJoin(term, st, key)
|
|
@@ -2675,4 +2734,4 @@ export async function runTui () {
|
|
|
2675
2734
|
}
|
|
2676
2735
|
|
|
2677
2736
|
// Solo para pruebas headless (render sin terminal real). No usar en runtime.
|
|
2678
|
-
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',
|
|
@@ -416,7 +429,7 @@ const es = {
|
|
|
416
429
|
// INGLÉS: new/rename/delete/password/unlock/locK/pair/approve/revoke/refresh/
|
|
417
430
|
// language/quit); lo único que se traduce es la palabra que las explica.
|
|
418
431
|
// Segmentos, no una línea: si no caben en una, el render los reparte en varias.
|
|
419
|
-
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'],
|
|
420
433
|
// La barra dice lo que se PUEDE hacer AHORA, no todo lo que existe: aprobar/rechazar solo
|
|
421
434
|
// valen si hay alguien esperando, y renombrar/revocar solo si hay un aparato seleccionado.
|
|
422
435
|
// Anunciar teclas muertas confunde y además las quema para otros usos.
|
|
@@ -454,6 +467,7 @@ const es = {
|
|
|
454
467
|
helpPairing: ['a aprobar', 'x rechazar', 'r reiniciar', '↑↓ scroll', 'Esc atrás', 'l English'],
|
|
455
468
|
helpPairMode: ['↑↓', 'Enter elegir', 'Esc atrás', 'l English', 'q salir'],
|
|
456
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'],
|
|
457
471
|
helpJoin: ['Esc bóvedas', 'l English', 'q salir'],
|
|
458
472
|
|
|
459
473
|
// pantalla "daemon caído"
|
|
@@ -501,7 +515,8 @@ const en = {
|
|
|
501
515
|
|
|
502
516
|
daemonRunning: 'running',
|
|
503
517
|
daemonStopped: 'STOPPED',
|
|
504
|
-
|
|
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`,
|
|
505
520
|
daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
|
|
506
521
|
activeVault: 'Active vault: ',
|
|
507
522
|
tooSmall: 'Terminal too small',
|
|
@@ -624,6 +639,18 @@ const en = {
|
|
|
624
639
|
titlePairing: 'Pair a device',
|
|
625
640
|
titlePairMode: 'Pairing: which account does it join?',
|
|
626
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}`,
|
|
627
654
|
doorsIntro: (name) => `What opens “${name}”. Any of these opens it.`,
|
|
628
655
|
doorsNone: 'No lock: it opens by itself on this machine.',
|
|
629
656
|
doorPassword: 'password',
|
|
@@ -847,7 +874,7 @@ const en = {
|
|
|
847
874
|
helpInput: 'Enter confirm · Esc cancel · Ctrl-U clear',
|
|
848
875
|
helpConfirm: 'y confirm · n/Esc cancel',
|
|
849
876
|
|
|
850
|
-
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'],
|
|
851
878
|
helpDevices: ({ pending, hasDevices, isService, drafts, curBlocked } = {}) => [
|
|
852
879
|
'←→ tab', '↑↓', 'p pair',
|
|
853
880
|
...(drafts ? ['g save permissions'] : []),
|
|
@@ -879,6 +906,7 @@ const en = {
|
|
|
879
906
|
helpPairing: ['a approve', 'x reject', 'r restart', '↑↓ scroll', 'Esc back', 'l Español'],
|
|
880
907
|
helpPairMode: ['↑↓', 'Enter choose', 'Esc back', 'l Español', 'q quit'],
|
|
881
908
|
helpDoors: ['↑↓', 'Enter add', 'd remove', 'Esc back', 'l Español', 'q quit'],
|
|
909
|
+
helpSettings: ['Enter change', 'Esc back', 'l Español', 'q quit'],
|
|
882
910
|
helpJoin: ['Esc vaults', 'l Español', 'q quit'],
|
|
883
911
|
|
|
884
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
|
+
}
|
package/src/vault.js
CHANGED
|
@@ -31,6 +31,7 @@ import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER }
|
|
|
31
31
|
import { openSubacta } from './subacta.js'
|
|
32
32
|
import { openBlocked } from './blocked.js'
|
|
33
33
|
import { openNotifiable } from './notifiable.js'
|
|
34
|
+
import { openNotices } from './notices.js'
|
|
34
35
|
import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
|
|
35
36
|
import { VERSION } from './version.js'
|
|
36
37
|
import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
|
|
@@ -265,6 +266,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
265
266
|
const blocked = openBlocked({ dir, atRest: passwordsAtRest })
|
|
266
267
|
// Qué aprobadores dijeron que pueden recibir avisos (`src/notifiable.js`). No decide nada: se enseña.
|
|
267
268
|
const notifiable = openNotifiable({ dir, atRest: passwordsAtRest })
|
|
269
|
+
// Lo que hay que contarle a quien aprueba y no es un pedido (`src/notices.js`): hoy, «me actualicé».
|
|
270
|
+
const notices = openNotices({ dir, atRest: passwordsAtRest })
|
|
268
271
|
const readPasswordsFile = () => {
|
|
269
272
|
try { return JSON.parse(passwordsAtRest.decrypt(fs.readFileSync(passwordsFile, 'utf8'))) } catch (_) { return null }
|
|
270
273
|
}
|
|
@@ -1526,6 +1529,122 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1526
1529
|
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1527
1530
|
}
|
|
1528
1531
|
|
|
1532
|
+
/**
|
|
1533
|
+
* UN APARATO PREGUNTA SI PUEDE ACTUALIZARSE (`op: 'update-ask'`, dueño 2026-10-08).
|
|
1534
|
+
*
|
|
1535
|
+
* Pedir aprobación es un interruptor DE CADA AGENTE, independiente del de la bóveda
|
|
1536
|
+
* (`updateApproval` de `src/settings.js` vale solo para la propia bóveda): el agente solo
|
|
1537
|
+
* manda esto cuando lo tiene encendido. Así que aquí no se consulta ningún ajuste:
|
|
1538
|
+
*
|
|
1539
|
+
* · alguien aprueba en este perfil → un pedido `kind: 'update'` a nombre del aparato, que
|
|
1540
|
+
* dura un día; se contesta «pendiente» y el resultado sale cuando alguien decide, por
|
|
1541
|
+
* donde vino la pregunta. Vencido es no.
|
|
1542
|
+
* · nadie aprueba → sí, sin preguntar: esperar un sí que nadie puede dar sería no
|
|
1543
|
+
* actualizarse nunca.
|
|
1544
|
+
*
|
|
1545
|
+
* Lo puede preguntar cualquier miembro del acta: no pide nada que no sea suyo.
|
|
1546
|
+
*/
|
|
1547
|
+
const updateAsks = new Map() // device|product|version → { id, dest } del pedido vivo
|
|
1548
|
+
const updateDecisions = new Map() // device|product|version → { ok: true, exp }: lo ya aprobado
|
|
1549
|
+
const cut = (v, n) => (typeof v === 'string' && v && v.length <= n ? v : null)
|
|
1550
|
+
async function handleUpdateAsk (from, p) {
|
|
1551
|
+
const chk = await verifyChain({
|
|
1552
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1553
|
+
...(await contextoActa()), revoked: await revocationSet()
|
|
1554
|
+
})
|
|
1555
|
+
if (!chk.ok) return denyChain(from, chk, p, 'update-ask')
|
|
1556
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1557
|
+
if (!record || !(record.members || []).some((m) => m.pub === chk.device)) {
|
|
1558
|
+
audit('rejected', { what: 'update-ask', reason: record ? 'acta' : 'sin-acta' })
|
|
1559
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this device is not in the record' })
|
|
1560
|
+
}
|
|
1561
|
+
const product = cut(p.data?.product, 128)
|
|
1562
|
+
const version = cut(p.data?.version, 64)
|
|
1563
|
+
const fromVersion = cut(p.data?.from, 64)
|
|
1564
|
+
if (!product || !version) return reply(from, { type: MSG.ERROR, code: 'bad-request', error: 'update-ask: needs `product` and `version`' })
|
|
1565
|
+
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
1566
|
+
const say = async (dest, extra) => {
|
|
1567
|
+
const body = { op: 'update-ask', product, version, ...extra, ts: Date.now() }
|
|
1568
|
+
reply(dest, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const approvers = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).length
|
|
1572
|
+
if (!approvers) {
|
|
1573
|
+
audit('update-ask', { device: deviceId, product, version, asked: false })
|
|
1574
|
+
log(`[vault] update-ask: ${deviceId || '????-????'} may update ${product} to ${version} on its own (nobody approves in this profile)`)
|
|
1575
|
+
return say(from, { ok: true, asked: false })
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const key = `${chk.device}|${product}|${version}`
|
|
1579
|
+
const now = Date.now()
|
|
1580
|
+
for (const [k, d] of updateDecisions) if (d.exp <= now) updateDecisions.delete(k)
|
|
1581
|
+
// YA SE DECIDIÓ y quien preguntó no estaba para oírlo: se le dice lo decidido en vez de
|
|
1582
|
+
// volver a timbrar por lo mismo.
|
|
1583
|
+
const decided = updateDecisions.get(key)
|
|
1584
|
+
if (decided) return say(from, { ok: decided.ok, asked: true })
|
|
1585
|
+
// YA HAY UN PEDIDO VIVO por esto mismo (el agente se reinició y vuelve a preguntar): no se
|
|
1586
|
+
// timbra otra vez; la respuesta irá a quien pregunta ahora.
|
|
1587
|
+
const vivo = updateAsks.get(key)
|
|
1588
|
+
if (vivo && waiters.has(vivo.id)) {
|
|
1589
|
+
vivo.dest = from
|
|
1590
|
+
return say(from, { pending: true, id: vivo.id, exp: vivo.exp, asked: true })
|
|
1591
|
+
}
|
|
1592
|
+
const ask = { id: null, exp: null, dest: from }
|
|
1593
|
+
let pend
|
|
1594
|
+
try {
|
|
1595
|
+
pend = await awaitApproval({
|
|
1596
|
+
device: chk.device, ns: 'vault', kind: 'update', what: 'update',
|
|
1597
|
+
ctx: { product, version, from: fromVersion }, ttlMs: UPDATE_TTL_MS,
|
|
1598
|
+
onAnswer: async (ok) => {
|
|
1599
|
+
updateAsks.delete(key)
|
|
1600
|
+
// Solo se recuerda el SÍ. Un no puede ser un vencimiento (nadie lo vio), y
|
|
1601
|
+
// recordarlo dejaría al aparato un día sin poder volver a preguntar.
|
|
1602
|
+
if (ok === true) updateDecisions.set(key, { ok: true, exp: Date.now() + UPDATE_TTL_MS })
|
|
1603
|
+
await say(ask.dest, { ok: ok === true, asked: true, id: ask.id })
|
|
1604
|
+
}
|
|
1605
|
+
})
|
|
1606
|
+
} catch (e) {
|
|
1607
|
+
log(`[vault] update-ask: could not ask for approval: ${e.message}`)
|
|
1608
|
+
return reply(from, { type: MSG.ERROR, code: 'ask-failed', error: 'update-ask: could not ask for approval' })
|
|
1609
|
+
}
|
|
1610
|
+
ask.id = pend.id; ask.exp = pend.exp
|
|
1611
|
+
updateAsks.set(key, ask)
|
|
1612
|
+
return say(from, { pending: true, id: pend.id, exp: pend.exp, asked: true })
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/**
|
|
1616
|
+
* UN APARATO DICE QUE SE ACTUALIZÓ (`op: 'update-done'`): la bóveda se lo cuenta a quien
|
|
1617
|
+
* aprueba en este perfil, por el mismo camino que su propio «me actualicé». Si el agente
|
|
1618
|
+
* avisa o no lo decide él (su interruptor); aquí solo se reparte.
|
|
1619
|
+
*
|
|
1620
|
+
* QUIÉN ES lo dice el acta (id y nombre), no lo que el aparato cuente de sí mismo.
|
|
1621
|
+
*/
|
|
1622
|
+
async function handleUpdateDone (from, p) {
|
|
1623
|
+
const chk = await verifyChain({
|
|
1624
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1625
|
+
...(await contextoActa()), revoked: await revocationSet()
|
|
1626
|
+
})
|
|
1627
|
+
if (!chk.ok) return denyChain(from, chk, p, 'update-done')
|
|
1628
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1629
|
+
const member = (record?.members || []).find((m) => m.pub === chk.device)
|
|
1630
|
+
if (!member) {
|
|
1631
|
+
audit('rejected', { what: 'update-done', reason: record ? 'acta' : 'sin-acta' })
|
|
1632
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this device is not in the record' })
|
|
1633
|
+
}
|
|
1634
|
+
const product = cut(p.data?.product, 128)
|
|
1635
|
+
const version = cut(p.data?.version, 64)
|
|
1636
|
+
if (!product || !version) return reply(from, { type: MSG.ERROR, code: 'bad-request', error: 'update-done: needs `product` and `version`' })
|
|
1637
|
+
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
1638
|
+
try {
|
|
1639
|
+
await announceUpdate({ version, from: cut(p.data?.from, 64), product, deviceId, label: member.label || '' })
|
|
1640
|
+
} catch (e) {
|
|
1641
|
+
log(`[vault] update-done: ${deviceId || '????-????'} updated ${product} to ${version}, but the approvers could not be told: ${e.message}`)
|
|
1642
|
+
return reply(from, { type: MSG.ERROR, code: e.code || 'announce-failed', error: 'update-done: the approvers could not be told' })
|
|
1643
|
+
}
|
|
1644
|
+
const body = { op: 'update-done', ok: true, product, version, ts: Date.now() }
|
|
1645
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1529
1648
|
const storeNonces = new Map()
|
|
1530
1649
|
async function handleVarStore (from, p) {
|
|
1531
1650
|
const ns = p.data?.ns
|
|
@@ -1593,6 +1712,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1593
1712
|
if (p.data?.op === 'enckey') return handleEncKey(from, p)
|
|
1594
1713
|
if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
|
|
1595
1714
|
if (p.data?.op === 'digest') return handleDigest(from, p)
|
|
1715
|
+
if (p.data?.op === 'update-ask') return handleUpdateAsk(from, p)
|
|
1716
|
+
if (p.data?.op === 'update-done') return handleUpdateDone(from, p)
|
|
1596
1717
|
if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke', 'block', 'blocked'].includes(p.data?.op)) return handleApproval(from, p)
|
|
1597
1718
|
const ns = p.data?.ns
|
|
1598
1719
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
|
|
@@ -1772,7 +1893,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1772
1893
|
if (op === 'approvals' && typeof p.data?.notify === 'boolean') {
|
|
1773
1894
|
try { notifiable.declare(chk.device, p.data.notify) } catch (e) { log(`[vault] could not record whether ${by || '????-????'} can be notified: ${e.message}`) }
|
|
1774
1895
|
}
|
|
1775
|
-
|
|
1896
|
+
// `notices`: lo que no es un pedido y quien aprueba tiene que saber (la bóveda se
|
|
1897
|
+
// actualizó). El timbre de una app nativa llega vacío, así que lo lee de aquí.
|
|
1898
|
+
if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record), notices: notices.list() })
|
|
1776
1899
|
// LO QUE YA ESTÁ APROBADO SE VE Y SE CORTA DESDE DONDE SE APROBÓ. Una concesión que se
|
|
1777
1900
|
// renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
|
|
1778
1901
|
// el mismo sitio donde se dio, es un permiso invisible, y eso no lo damos por bueno.
|
|
@@ -1949,6 +2072,42 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1949
2072
|
} catch (e) { log('[vault] could not notify approvers:', e.message) }
|
|
1950
2073
|
}
|
|
1951
2074
|
|
|
2075
|
+
/**
|
|
2076
|
+
* «ME ACTUALICÉ», a todo el que aprueba (dueño, 2026-10-08). Vale con la aprobación
|
|
2077
|
+
* encendida o apagada: lo que se instala solo también se cuenta.
|
|
2078
|
+
*
|
|
2079
|
+
* Lo manda la versión NUEVA ya corriendo —el daemon llama aquí al arrancar, si dejó dicho
|
|
2080
|
+
* que instaló—, que es la prueba de que ocurrió. El aviso queda además en `notices`, de
|
|
2081
|
+
* donde lo lee una app nativa (su timbre llega vacío).
|
|
2082
|
+
*
|
|
2083
|
+
* También reparte el «me actualicé» de un APARATO (`op: 'update-done'`): entonces lleva su
|
|
2084
|
+
* `product`, `deviceId` y `label`.
|
|
2085
|
+
*
|
|
2086
|
+
* A diferencia de `notifyApprovers`, aquí el fallo SE LANZA: quien llama guarda el
|
|
2087
|
+
* marcador hasta que el aviso salió, y tragarlo sería borrarlo sin haber avisado.
|
|
2088
|
+
* @returns {Promise<{ approvers: number, sent: number, notice: object|null }>}
|
|
2089
|
+
*/
|
|
2090
|
+
async function announceUpdate ({ version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null }) {
|
|
2091
|
+
const record = await refreshActa()
|
|
2092
|
+
const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
|
|
2093
|
+
if (!who.length) return { approvers: 0, sent: 0, notice: null }
|
|
2094
|
+
// `deviceId`/`label`: solo cuando quien se actualizó es un APARATO (`update-done`). El
|
|
2095
|
+
// aviso de la propia bóveda no los lleva.
|
|
2096
|
+
const notice = notices.updated({ version, from, product, ...(deviceId ? { deviceId, label: label || '' } : {}) })
|
|
2097
|
+
const { id, ev, ts, ...rest } = notice
|
|
2098
|
+
const body = { ev, id, ...rest, ts }
|
|
2099
|
+
const seal = await sealOrFail(body)
|
|
2100
|
+
let sent = 0
|
|
2101
|
+
for (const pub of who) {
|
|
2102
|
+
try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, seal }, { app: 'vault' }); sent++ }
|
|
2103
|
+
catch (e) { log(`[vault] could not tell ${pub.slice(0, 24)}… about the update of ${product} to ${version}: ${e.message}`) }
|
|
2104
|
+
}
|
|
2105
|
+
audit('update.announced', { product, version, from: notice.from, device: deviceId, approvers: who.length, sent })
|
|
2106
|
+
log(`[vault] update: told ${sent} of ${who.length} approver(s) that ${deviceId ? `${deviceId} runs ${product}` : 'this vault now runs'} ${version}`)
|
|
2107
|
+
if (!sent) throw Object.assign(new Error('no approver could be told'), { code: 'announce-failed' })
|
|
2108
|
+
return { approvers: who.length, sent, notice }
|
|
2109
|
+
}
|
|
2110
|
+
|
|
1952
2111
|
/**
|
|
1953
2112
|
* UN INCIDENTE: un agente de la cuenta dice que OTRO aparato de la cuenta se equivocó de
|
|
1954
2113
|
* clave tres veces seguidas (la terminal, `dotrino-terminal lock`). No se decide aquí
|
|
@@ -4020,17 +4179,32 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
4020
4179
|
* PEDIR PERMISO PARA ACTUALIZARSE a `version` (la instala el daemon si se aprueba). Es
|
|
4021
4180
|
* un pedido como los demás —sale en Pedidos del teléfono, `kind: 'update'`—, pero dura
|
|
4022
4181
|
* un día (`UPDATE_TTL_MS`): no hay nadie esperando delante de una terminal.
|
|
4023
|
-
* @returns {Promise<boolean
|
|
4182
|
+
* @returns {{ answer: Promise<boolean>, withdraw: () => void }} `answer`: sí o no (vencido
|
|
4183
|
+
* o retirado es no). `withdraw` quita el pedido si sigue vivo.
|
|
4024
4184
|
*/
|
|
4025
|
-
askUpdateApproval:
|
|
4185
|
+
askUpdateApproval: ({ version }) => {
|
|
4026
4186
|
const yo = identity.me?.publickey || null
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4187
|
+
let pendId = null
|
|
4188
|
+
let withdrawn = false
|
|
4189
|
+
let settle
|
|
4190
|
+
const answer = new Promise((resolve) => { settle = resolve })
|
|
4191
|
+
// RETIRAR: otro perfil ya contestó por la máquina, o ya no hace falta. El pedido sale
|
|
4192
|
+
// de la lista de quien aprueba en vez de quedarse un día pidiendo algo ya decidido.
|
|
4193
|
+
const retirar = () => {
|
|
4194
|
+
if (!pendId || !waiters.delete(pendId)) return
|
|
4195
|
+
const pend = approvals.take(pendId)
|
|
4196
|
+
audit('update.withdrawn', { device: pend?.deviceId || null, ns: 'vault', id: pendId })
|
|
4197
|
+
log(`[vault] update: request ${pendId} withdrawn`)
|
|
4198
|
+
settle(false)
|
|
4199
|
+
}
|
|
4200
|
+
awaitApproval({
|
|
4201
|
+
device: yo, ns: 'vault', kind: 'update', what: 'update',
|
|
4202
|
+
ctx: { product: '@dotrino/vaultd', version, from: VERSION }, ttlMs: UPDATE_TTL_MS, onAnswer: settle
|
|
4203
|
+
}).then((pend) => { pendId = pend.id; if (withdrawn) retirar() })
|
|
4204
|
+
.catch((e) => { log(`[vault] update: could not ask for approval: ${e.message}`); settle(false) })
|
|
4205
|
+
return { answer, withdraw: () => { withdrawn = true; retirar() } }
|
|
4033
4206
|
},
|
|
4207
|
+
announceUpdate,
|
|
4034
4208
|
approvers: async () => {
|
|
4035
4209
|
const record = await refreshActa()
|
|
4036
4210
|
if (!record) return []
|