@dotrino/vaultd 0.144.0 → 0.146.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/ctl.js +50 -0
- package/src/daemon.js +58 -14
- package/src/notifiable.js +76 -0
- package/src/settings.js +37 -0
- package/src/tui/app.js +11 -1
- package/src/tui/i18n.js +6 -0
- package/src/vault.js +16 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.146.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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@dotrino/identity": "^0.107.1",
|
|
26
26
|
"@dotrino/opaque": "0.1.0",
|
|
27
27
|
"@dotrino/passmanager": "0.17.0",
|
|
28
|
-
"@dotrino/proxy-client": "0.30.
|
|
28
|
+
"@dotrino/proxy-client": "0.30.1",
|
|
29
29
|
"@dotrino/roadmap": "0.3.19",
|
|
30
30
|
"@dotrino/store": "0.11.0",
|
|
31
31
|
"@dotrino/update": "0.2.2",
|
package/src/ctl.js
CHANGED
|
@@ -131,6 +131,11 @@ function deviceIdOf (sub) {
|
|
|
131
131
|
return pubkeyId(sub).then((id) => id.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/** Lo que dijo un aprobador sobre sus avisos, para enseñarlo. No decide nada. */
|
|
135
|
+
function avisosTxt (notifiedAt) {
|
|
136
|
+
return notifiedAt ? `dijo que le llegan los avisos (${fechaCorta(notifiedAt)})` : 'no ha dicho que le lleguen los avisos'
|
|
137
|
+
}
|
|
138
|
+
|
|
134
139
|
function cmdStatus () {
|
|
135
140
|
const s = readState()
|
|
136
141
|
const up = alive(s.pid)
|
|
@@ -153,6 +158,24 @@ function cmdStatus () {
|
|
|
153
158
|
console.log(' %s⬆ hay %s publicada%s (esta es la %s) · tráela con: dotrino-vault update',
|
|
154
159
|
B, s.latest.version, Z, VERSION)
|
|
155
160
|
}
|
|
161
|
+
// …Y LLEVA DÍAS PIDIENDO PERMISO SIN RESPUESTA. Es un bloqueo que desde fuera no se ve:
|
|
162
|
+
// la bóveda parece normal y simplemente no se actualiza.
|
|
163
|
+
if (s.updateAsk?.version && isNewer(s.updateAsk.version, VERSION)) {
|
|
164
|
+
console.log(' pidió permiso para instalarla %s (%d %s) y nadie lo ha aprobado · mira Pedidos en tu teléfono',
|
|
165
|
+
hace(s.updateAsk.since), s.updateAsk.asks, s.updateAsk.asks === 1 ? 'vez' : 'veces')
|
|
166
|
+
// A QUIÉN SE LE PIDIÓ, y si dijo que le llegan los avisos: el que no lo ha dicho es el
|
|
167
|
+
// primer sitio donde mirar cuando nadie contesta.
|
|
168
|
+
for (const a of s.updateAsk.approvers || []) {
|
|
169
|
+
console.log(' %s %s · %s', a.id, a.label || '(sin nombre)', avisosTxt(a.notifiedAt))
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// CÓMO SE ACTUALIZA. Va siempre, también al día: es un ajuste y se mira aquí.
|
|
173
|
+
if (up && 'updateApproval' in s) {
|
|
174
|
+
console.log(' actualizar : %s', s.updateApproval === null
|
|
175
|
+
? `${R}no se pudo leer el ajuste: no se actualiza${Z} · ponlo de nuevo con: dotrino-vault update --approval on|off`
|
|
176
|
+
: s.updateApproval ? 'pide aprobación (dotrino-vault update --approval off para que lo haga sola)'
|
|
177
|
+
: 'sola, sin pedir aprobación (dotrino-vault update --approval on para que pida)')
|
|
178
|
+
}
|
|
156
179
|
const profiles = s.profiles || []
|
|
157
180
|
if (profiles.length) {
|
|
158
181
|
console.log(' perfiles : %d', profiles.length)
|
|
@@ -625,6 +648,9 @@ async function cmdMembers () {
|
|
|
625
648
|
// Solo si LEE variables (`secrets`). La llave de comunicación de la bóveda entra con
|
|
626
649
|
// `cn: 'vault'` y solo `firma`, y no tiene llave de cifrado a propósito: salía marcada
|
|
627
650
|
// en rojo «NO puede leer sus variables» cuando no tiene ninguna que leer.
|
|
651
|
+
// SI LE LLEGAN LOS AVISOS, en quien aprueba. No cambia a quién se le pide —se le pide a
|
|
652
|
+
// todos—: es para ver a cuál no le suena el pedido.
|
|
653
|
+
if (m.caps.includes('approve') && 'notifiedAt' in m) console.log(' %s%s%s', D, avisosTxt(m.notifiedAt), Z)
|
|
628
654
|
if (m.cn && !m.canSeal && m.caps.includes('secrets')) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
629
655
|
// QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
|
|
630
656
|
// incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
|
|
@@ -1788,6 +1814,19 @@ async function waitForService (version, { timeoutMs = 4 * 60_000 } = {}) {
|
|
|
1788
1814
|
}
|
|
1789
1815
|
|
|
1790
1816
|
async function cmdUpdate (args = []) {
|
|
1817
|
+
// PEDIR PERMISO PARA ACTUALIZARSE es un ajuste, apagado por defecto. Se guarda aquí y el
|
|
1818
|
+
// servicio lo lee la próxima vez que mire si hay versión nueva: no hace falta reiniciarlo.
|
|
1819
|
+
const iAp = args.indexOf('--approval')
|
|
1820
|
+
if (iAp !== -1) {
|
|
1821
|
+
const { readSettings, writeSettings } = await import('./settings.js')
|
|
1822
|
+
const v = args[iAp + 1]
|
|
1823
|
+
if (v === 'on' || v === 'off') writeSettings(dir, { updateApproval: v === 'on' })
|
|
1824
|
+
else if (v !== undefined) { console.error('uso: dotrino-vault update --approval [on|off]'); process.exit(2) }
|
|
1825
|
+
console.log(readSettings(dir).updateApproval
|
|
1826
|
+
? 'Esta bóveda pide aprobación antes de actualizarse (a los aparatos con «aprueba»).'
|
|
1827
|
+
: 'Esta bóveda se actualiza sola, sin pedir aprobación.')
|
|
1828
|
+
return
|
|
1829
|
+
}
|
|
1791
1830
|
const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease } = await import('./update.js')
|
|
1792
1831
|
const soloMirar = args.includes('--check')
|
|
1793
1832
|
|
|
@@ -2168,6 +2207,9 @@ function help () {
|
|
|
2168
2207
|
status | info estado del servicio + fingerprint + el id de cada perfil
|
|
2169
2208
|
update [--check] trae la versión nueva (verificando la firma) y espera a que el
|
|
2170
2209
|
servicio la arranque · --check solo mira · --no-wait no espera
|
|
2210
|
+
update --approval [on|off]
|
|
2211
|
+
el servicio se actualiza solo; con «on» antes pide aprobación a los
|
|
2212
|
+
aparatos con «aprueba» · sin valor, dice cómo está
|
|
2171
2213
|
pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
|
|
2172
2214
|
pair --kms <config.json>
|
|
2173
2215
|
el sitio que se cree (--adopt o --new-account) NACE con su clave
|
|
@@ -2380,6 +2422,14 @@ async function cmdReplica (args) {
|
|
|
2380
2422
|
process.exit(2)
|
|
2381
2423
|
}
|
|
2382
2424
|
|
|
2425
|
+
/** «hace 3 días», «hace 5 horas»: para decir desde cuándo espera algo. */
|
|
2426
|
+
function hace (ms) {
|
|
2427
|
+
const h = Math.max(0, Math.floor((Date.now() - ms) / 3600000))
|
|
2428
|
+
if (h < 1) return 'hace menos de una hora'
|
|
2429
|
+
if (h < 48) return `hace ${h} ${h === 1 ? 'hora' : 'horas'}`
|
|
2430
|
+
return `hace ${Math.floor(h / 24)} días`
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2383
2433
|
export async function runCtl (argv) {
|
|
2384
2434
|
const [cmd, ...rest] = takeProfileFlag(argv)
|
|
2385
2435
|
// `--help` EN CUALQUIER COMANDO ENSEÑA LA AYUDA Y NO HACE NADA. Ningún subcomando lo miraba,
|
package/src/daemon.js
CHANGED
|
@@ -27,6 +27,7 @@ import { capScope } from '@dotrino/identity/acta'
|
|
|
27
27
|
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
|
+
import { readSettings } from './settings.js'
|
|
30
31
|
import crypto from 'node:crypto'
|
|
31
32
|
|
|
32
33
|
const readJsonSafe = (f) => ipcRead(f, null)
|
|
@@ -92,9 +93,16 @@ export async function runDaemon () {
|
|
|
92
93
|
const daemonVersion = VERSION
|
|
93
94
|
/** `{ version, checkedAt }` de la última consulta que salió bien, o null si aún ninguna. */
|
|
94
95
|
let ultimaPublicada = null
|
|
96
|
+
/**
|
|
97
|
+
* EL PEDIDO DE ACTUALIZACIÓN QUE NADIE HA CONTESTADO: `{ version, since, asks }`, o null.
|
|
98
|
+
* Va en la foto para que `status` y la TUI lo DIGAN. Sin esto, una bóveda que lleva días
|
|
99
|
+
* pidiendo permiso y sin respuesta se veía igual que una al día.
|
|
100
|
+
*/
|
|
101
|
+
let pedidoSinRespuesta = null
|
|
95
102
|
// Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
|
|
96
103
|
// instalador y la web, que son anteriores al multi-perfil. La lista completa va
|
|
97
104
|
// en `profiles`.
|
|
105
|
+
const ajusteActualizar = () => { try { return readSettings(dir).updateApproval } catch (_) { return null } }
|
|
98
106
|
const writeState = () => {
|
|
99
107
|
const cur = mgr.summary().find((p) => p.current) || {}
|
|
100
108
|
ipcWrite(stateFile, {
|
|
@@ -107,6 +115,10 @@ export async function runDaemon () {
|
|
|
107
115
|
// sin salir a la red: una pantalla de estado tiene que contestar al instante y
|
|
108
116
|
// también sin conexión.
|
|
109
117
|
...(ultimaPublicada ? { latest: ultimaPublicada } : {}),
|
|
118
|
+
...(pedidoSinRespuesta ? { updateAsk: pedidoSinRespuesta } : {}),
|
|
119
|
+
// Si para actualizarse pide permiso (`src/settings.js`). `null` = el ajuste no se
|
|
120
|
+
// pudo leer, que `status` dice distinto de «no».
|
|
121
|
+
updateApproval: ajusteActualizar(),
|
|
110
122
|
current: mgr.currentId(), profiles: mgr.summary()
|
|
111
123
|
})
|
|
112
124
|
}
|
|
@@ -1096,19 +1108,39 @@ export async function runDaemon () {
|
|
|
1096
1108
|
if (!isNewer(r.version, daemonVersion)) return
|
|
1097
1109
|
|
|
1098
1110
|
/**
|
|
1099
|
-
*
|
|
1111
|
+
* SE ACTUALIZA SOLA. PEDIR PERMISO ES UN AJUSTE (dueño, 2026-10-08).
|
|
1112
|
+
*
|
|
1113
|
+
* Por defecto no se le pregunta a nadie: lo que se baja se verifica contra la
|
|
1114
|
+
* atestación del release antes de tocar el disco, y eso no depende de que alguien
|
|
1115
|
+
* conteste. Antes se pedía permiso según quién hubiera en el acta y según si decía que
|
|
1116
|
+
* le llegan los avisos; eran condiciones que explicar y que se desalineaban, y una
|
|
1117
|
+
* bóveda podía quedarse atrás sin que nadie lo viera.
|
|
1100
1118
|
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1119
|
+
* Quien quiera decidir cada actualización lo enciende: `dotrino-vault update --approval
|
|
1120
|
+
* on` (`src/settings.js`). Entonces se le pide a TODO el que tenga `aprueba`, reciba
|
|
1121
|
+
* avisos o no, y sin un sí no se instala. Si en el acta no aprueba nadie, se actualiza
|
|
1122
|
+
* igual: esperar un sí que nadie puede dar sería no actualizarse nunca.
|
|
1105
1123
|
*
|
|
1106
|
-
*
|
|
1107
|
-
*
|
|
1108
|
-
* ecosistema (§15).
|
|
1124
|
+
* Lo que cada aprobador dijo de sus avisos (`notifiedAt`, `src/notifiable.js`) no
|
|
1125
|
+
* decide nada: se enseña junto al pedido sin respuesta, para ver a cuál no le suena.
|
|
1109
1126
|
*/
|
|
1127
|
+
// SI EL AJUSTE NO SE PUEDE LEER, NO SE ACTUALIZA: el valor por defecto es no preguntar,
|
|
1128
|
+
// así que suponerlo sería saltarse la aprobación que el dueño pidió.
|
|
1129
|
+
let pedirPermiso
|
|
1130
|
+
try { pedirPermiso = readSettings(dir).updateApproval } catch (e) {
|
|
1131
|
+
console.log(`[vault] version ${r.version} is out, but the settings could not be read (${e?.message || e}) · not updating`)
|
|
1132
|
+
return
|
|
1133
|
+
}
|
|
1134
|
+
// Y si hay que pedir y no se puede saber a quién, tampoco: lista vacía sería «nadie
|
|
1135
|
+
// aprueba, adelante», el repliegue que abre la puerta justo cuando algo se rompió.
|
|
1110
1136
|
let quienAprueba = []
|
|
1111
|
-
|
|
1137
|
+
if (pedirPermiso) {
|
|
1138
|
+
try { quienAprueba = await mgr.current().approvers() } catch (e) {
|
|
1139
|
+
console.log(`[vault] version ${r.version} is out, but the approvers could not be read (${e?.message || e}) · not updating`)
|
|
1140
|
+
return
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
const conAvisos = quienAprueba.filter((a) => a.notifiedAt).length
|
|
1112
1144
|
|
|
1113
1145
|
// INSTALADA COMO PAQUETE DEL SISTEMA (.deb en /usr/bin): el binario es de root y este
|
|
1114
1146
|
// proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
|
|
@@ -1119,15 +1151,27 @@ export async function runDaemon () {
|
|
|
1119
1151
|
if (actualizando) return
|
|
1120
1152
|
actualizando = true
|
|
1121
1153
|
try {
|
|
1122
|
-
// PIDE PERMISO SOLO SI HAY A QUIÉN PEDÍRSELO (dueño, 2026-09-20); sin aprobadores
|
|
1123
|
-
// no se necesita aprobación (dueño, 2026-09-24).
|
|
1124
1154
|
if (quienAprueba.length) {
|
|
1125
|
-
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · asking one of ${quienAprueba.length} approver(s)`)
|
|
1155
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · asking one of ${quienAprueba.length} approver(s) · ${conAvisos} of them said notifications reach it`)
|
|
1126
1156
|
const si = await mgr.current().askUpdateApproval({ version: r.version })
|
|
1127
|
-
if (!si) {
|
|
1157
|
+
if (!si) {
|
|
1158
|
+
const antes = pedidoSinRespuesta?.version === r.version ? pedidoSinRespuesta : null
|
|
1159
|
+
pedidoSinRespuesta = {
|
|
1160
|
+
version: r.version, since: antes?.since || Date.now(), asks: (antes?.asks || 0) + 1,
|
|
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
|
+
}
|
|
1165
|
+
writeState()
|
|
1166
|
+
console.log(`[vault] update to ${r.version}: not approved (denied or expired) · asked ${pedidoSinRespuesta.asks} time(s) since ${new Date(pedidoSinRespuesta.since).toISOString()} · it will ask again`)
|
|
1167
|
+
return
|
|
1168
|
+
}
|
|
1169
|
+
} else if (pedirPermiso) {
|
|
1170
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · approval is on but no approver in the record: updating on its own`)
|
|
1128
1171
|
} else {
|
|
1129
|
-
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) ·
|
|
1172
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · updating on its own`)
|
|
1130
1173
|
}
|
|
1174
|
+
if (pedidoSinRespuesta) { pedidoSinRespuesta = null; writeState() }
|
|
1131
1175
|
const res = await installUserRelease(r, { log: (m) => console.log(m) })
|
|
1132
1176
|
if (!res.ok) {
|
|
1133
1177
|
// Lo que NO cuadra no se instala, y se dice por qué: el archivo queda a mano.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUÉ APROBADORES PUEDEN RECIBIR UN AVISO: lo dice cada uno, y caduca.
|
|
3
|
+
*
|
|
4
|
+
* Un aparato con `approve` que no recibe avisos es un aprobador solo en el papel: el pedido
|
|
5
|
+
* no le suena y solo lo ve si abre la app por su cuenta.
|
|
6
|
+
*
|
|
7
|
+
* Así que el aprobador lo declara: al pedir su lista de pedidos (`op: 'approvals'`, que ya
|
|
8
|
+
* manda firmado cada vez que se abre) dice `notify: true` si tiene los avisos encendidos.
|
|
9
|
+
* Aquí se apunta CUÁNDO lo dijo por última vez, y vale `NOTIFIABLE_TTL_MS`: una app que se
|
|
10
|
+
* desinstala deja de renovarlo.
|
|
11
|
+
*
|
|
12
|
+
* NO DECIDE NADA: SE ENSEÑA (dueño, 2026-10-08). Un día decidió si la bóveda pedía permiso
|
|
13
|
+
* para actualizarse —sin nadie que lo hubiera dicho, se actualizaba sola— y se quitó: era
|
|
14
|
+
* una condición más, y el silencio de un aprobador abría la puerta. Quién aprueba lo dice
|
|
15
|
+
* el acta y nada más; los avisos salen a todos los que tienen `approve`, lo hayan dicho o
|
|
16
|
+
* no. Esto sirve para VER a cuál no le está sonando el pedido (`members`, `status`, la
|
|
17
|
+
* consola).
|
|
18
|
+
*
|
|
19
|
+
* Y NO SE LE PREGUNTA AL PROXIO, que es quien guarda las suscripciones: es la pieza de la
|
|
20
|
+
* que el diseño no se fía, y creerle sería dejarle decidir cuándo no hace falta permiso.
|
|
21
|
+
*
|
|
22
|
+
* Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer NO es «nadie
|
|
23
|
+
* puede recibir avisos»: se lanza.
|
|
24
|
+
*/
|
|
25
|
+
import fs from 'node:fs'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
|
|
28
|
+
export const NOTIFIABLE_FILE = 'notifiable.json'
|
|
29
|
+
/** Cuánto vale un «puedo recibir avisos» sin renovarse. */
|
|
30
|
+
export const NOTIFIABLE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
31
|
+
|
|
32
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
|
|
36
|
+
*/
|
|
37
|
+
export function openNotifiable ({ dir, atRest, now = Date.now }) {
|
|
38
|
+
const file = path.join(dir, NOTIFIABLE_FILE)
|
|
39
|
+
/** pub → ms de la última vez que dijo que sí */
|
|
40
|
+
const items = load()
|
|
41
|
+
|
|
42
|
+
function load () {
|
|
43
|
+
let raw
|
|
44
|
+
try { raw = fs.readFileSync(file, 'utf8') } catch (e) {
|
|
45
|
+
if (e.code === 'ENOENT') return new Map()
|
|
46
|
+
throw fail('notifiable-unreadable', `cannot read ${NOTIFIABLE_FILE}: ${e.message}`)
|
|
47
|
+
}
|
|
48
|
+
let d
|
|
49
|
+
try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} cannot be opened: ${e.message}`) }
|
|
50
|
+
if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} has an unknown shape`)
|
|
51
|
+
return new Map(Object.entries(d.items))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function save () {
|
|
55
|
+
const tmp = file + '.tmp'
|
|
56
|
+
fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })), { mode: 0o600 })
|
|
57
|
+
fs.renameSync(tmp, file)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
/**
|
|
62
|
+
* Lo que acaba de decir un aprobador. `true` lo apunta (o lo renueva); `false` lo quita
|
|
63
|
+
* ya, sin esperar a que caduque. Cualquier otra cosa no dice nada y no cambia nada: un
|
|
64
|
+
* cliente viejo, que no manda el campo, no borra lo que dijo uno nuevo.
|
|
65
|
+
*/
|
|
66
|
+
declare (pub, canNotify) {
|
|
67
|
+
if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'declare: a device key is required')
|
|
68
|
+
if (canNotify === true) { items.set(pub, now()); save() } else if (canNotify === false && items.delete(pub)) save()
|
|
69
|
+
},
|
|
70
|
+
/** Cuándo lo dijo por última vez, o `null` si nunca o si ya caducó. */
|
|
71
|
+
since (pub) {
|
|
72
|
+
const t = items.get(pub)
|
|
73
|
+
return typeof t === 'number' && now() - t < NOTIFIABLE_TTL_MS ? t : null
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
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
|
@@ -155,7 +155,9 @@ function mergeMembersAndCerts (members, issued) {
|
|
|
155
155
|
noAccess: !cert && !m.isMaster && !m.cn,
|
|
156
156
|
// QUÉ CORRE ESE APARATO (CONVENCIONES §14). Lo apunta la bóveda cuando le habla, así
|
|
157
157
|
// que aquí solo hay que dejarlo pasar — que es justo lo que le faltaba a `addedAt`.
|
|
158
|
-
running: m.running || null
|
|
158
|
+
running: m.running || null,
|
|
159
|
+
// SI LE LLEGAN LOS AVISOS, solo en quien aprueba (`undefined` = no aprueba, no se pinta).
|
|
160
|
+
notifiedAt: (m.caps || []).includes('approve') && 'notifiedAt' in m ? m.notifiedAt : undefined
|
|
159
161
|
}
|
|
160
162
|
})
|
|
161
163
|
}
|
|
@@ -386,6 +388,9 @@ function deviceRows (st, t) {
|
|
|
386
388
|
const extra = (d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : '') +
|
|
387
389
|
(vars ? t.muted(` vars:${vars}`) : '') +
|
|
388
390
|
corre +
|
|
391
|
+
// En quien aprueba: si dijo que le llegan los avisos. No cambia a quién se le pide
|
|
392
|
+
// (a todos); deja ver a cuál no le suena el pedido. Es un dato, va en gris.
|
|
393
|
+
(d.notifiedAt === undefined ? '' : t.muted(` ${d.notifiedAt ? i.notifyYes : i.notifyUnknown}`)) +
|
|
389
394
|
// EN DEUDA: en el acta y sin poder abrir lo suyo. Va en color de aviso al lado de
|
|
390
395
|
// sus variables, que es donde se mira cuando algo no arranca.
|
|
391
396
|
(debt ? t.warn(` ${i.deviceDebt(debt)}`) : '')
|
|
@@ -2438,6 +2443,11 @@ function render (term, st) {
|
|
|
2438
2443
|
// instalar, peor: el daemon se queda con el binario viejo, ya borrado, y hay dos copias
|
|
2439
2444
|
// en RAM). `status` ya lo avisa; aquí también, que es donde uno se queda mirando.
|
|
2440
2445
|
lines[2] = (up && s?.version && VERSION !== 'dev' && s.version !== VERSION) ? ' ' + t.warn(i.daemonStale(s.version, VERSION)) : ''
|
|
2446
|
+
// Una actualización que lleva tiempo pidiendo permiso sin respuesta: la bóveda parece
|
|
2447
|
+
// normal y simplemente no se actualiza, así que se dice aquí.
|
|
2448
|
+
if (!lines[2] && up && s?.updateAsk?.version) {
|
|
2449
|
+
lines[2] = ' ' + t.warn(i.updateUnanswered(s.updateAsk.version, Math.floor((Date.now() - s.updateAsk.since) / 86400000)))
|
|
2450
|
+
}
|
|
2441
2451
|
// Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
|
|
2442
2452
|
// el resto muestra su título simple.
|
|
2443
2453
|
lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + title(i, st.screen))
|
package/src/tui/i18n.js
CHANGED
|
@@ -26,6 +26,7 @@ const es = {
|
|
|
26
26
|
// encabezado / estado
|
|
27
27
|
daemonRunning: 'corriendo',
|
|
28
28
|
daemonStopped: 'DETENIDO',
|
|
29
|
+
updateUnanswered: (version, days) => `⬆ la ${version} espera tu aprobación desde hace ${days < 1 ? 'menos de un día' : days === 1 ? '1 día' : days + ' días'}: mira Pedidos en tu teléfono`,
|
|
29
30
|
daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
|
|
30
31
|
activeVault: 'Bóveda activa: ',
|
|
31
32
|
noName: '(sin nombre)',
|
|
@@ -271,6 +272,8 @@ const es = {
|
|
|
271
272
|
revokedCount: (n) => ` Revocados: ${n}`,
|
|
272
273
|
startingPairing: 'Iniciando emparejamiento…',
|
|
273
274
|
deviceSince: 'desde',
|
|
275
|
+
notifyYes: 'le llegan avisos',
|
|
276
|
+
notifyUnknown: 'no ha dicho si le llegan avisos',
|
|
274
277
|
nameDeviceLabel: '¿Cómo se va a llamar este aparato?',
|
|
275
278
|
nameDeviceHint: 'Enter para dejar que lo diga él (usará tu apodo, y así no se distinguen)',
|
|
276
279
|
noPending: 'No hay ningún dispositivo pendiente',
|
|
@@ -498,6 +501,7 @@ const en = {
|
|
|
498
501
|
|
|
499
502
|
daemonRunning: 'running',
|
|
500
503
|
daemonStopped: 'STOPPED',
|
|
504
|
+
updateUnanswered: (version, days) => `⬆ ${version} has been waiting for your approval for ${days < 1 ? 'less than a day' : days === 1 ? '1 day' : days + ' days'}: check Requests on your phone`,
|
|
501
505
|
daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
|
|
502
506
|
activeVault: 'Active vault: ',
|
|
503
507
|
tooSmall: 'Terminal too small',
|
|
@@ -717,6 +721,8 @@ const en = {
|
|
|
717
721
|
revokedCount: (n) => ` Revoked: ${n}`,
|
|
718
722
|
startingPairing: 'Starting pairing…',
|
|
719
723
|
deviceSince: 'since',
|
|
724
|
+
notifyYes: 'gets notifications',
|
|
725
|
+
notifyUnknown: 'has not said if notifications reach it',
|
|
720
726
|
nameDeviceLabel: 'What will this device be called?',
|
|
721
727
|
nameDeviceHint: 'Enter to let it say (it will use your nickname, and then they all look alike)',
|
|
722
728
|
noPending: 'No device is waiting',
|
package/src/vault.js
CHANGED
|
@@ -30,6 +30,7 @@ import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './thr
|
|
|
30
30
|
import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
|
|
31
31
|
import { openSubacta } from './subacta.js'
|
|
32
32
|
import { openBlocked } from './blocked.js'
|
|
33
|
+
import { openNotifiable } from './notifiable.js'
|
|
33
34
|
import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
|
|
34
35
|
import { VERSION } from './version.js'
|
|
35
36
|
import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
|
|
@@ -262,6 +263,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
262
263
|
const passwordsAtRest = atRestFor(dir)
|
|
263
264
|
// Los aparatos BLOQUEADOS por un aprobador (`src/blocked.js`). Cifrado en reposo como lo demás.
|
|
264
265
|
const blocked = openBlocked({ dir, atRest: passwordsAtRest })
|
|
266
|
+
// Qué aprobadores dijeron que pueden recibir avisos (`src/notifiable.js`). No decide nada: se enseña.
|
|
267
|
+
const notifiable = openNotifiable({ dir, atRest: passwordsAtRest })
|
|
265
268
|
const readPasswordsFile = () => {
|
|
266
269
|
try { return JSON.parse(passwordsAtRest.decrypt(fs.readFileSync(passwordsFile, 'utf8'))) } catch (_) { return null }
|
|
267
270
|
}
|
|
@@ -1317,7 +1320,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1317
1320
|
// que arrancamos», que es distinto de «no declara» — y se dice distinto.
|
|
1318
1321
|
const devices = await Promise.all(issued.map(async (x) => ({
|
|
1319
1322
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce,
|
|
1320
|
-
running: x.sub ? versionDe(x.sub) : null
|
|
1323
|
+
running: x.sub ? versionDe(x.sub) : null,
|
|
1324
|
+
notifiedAt: x.sub ? notifiable.since(x.sub) : null
|
|
1321
1325
|
})))
|
|
1322
1326
|
// LA SUBACTA VIAJA CON EL ACTA. Es la mitad de «se comparte entre todos los
|
|
1323
1327
|
// dispositivos»: sin esto, una renuncia sin sellar solo la respeta esta máquina.
|
|
@@ -1763,6 +1767,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1763
1767
|
body = { ...body, ts: Date.now() }
|
|
1764
1768
|
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body) })
|
|
1765
1769
|
}
|
|
1770
|
+
// AL PEDIR SU LISTA, EL APROBADOR DICE SI LE LLEGAN LOS AVISOS (`notify`). Va dentro de lo
|
|
1771
|
+
// que ya firma, así que no hay mensaje nuevo ni nada que el proxio pueda inventar.
|
|
1772
|
+
if (op === 'approvals' && typeof p.data?.notify === 'boolean') {
|
|
1773
|
+
try { notifiable.declare(chk.device, p.data.notify) } catch (e) { log(`[vault] could not record whether ${by || '????-????'} can be notified: ${e.message}`) }
|
|
1774
|
+
}
|
|
1766
1775
|
if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record) })
|
|
1767
1776
|
// LO QUE YA ESTÁ APROBADO SE VE Y SE CORTA DESDE DONDE SE APROBÓ. Una concesión que se
|
|
1768
1777
|
// renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
|
|
@@ -4027,7 +4036,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
4027
4036
|
if (!record) return []
|
|
4028
4037
|
return (record.members || [])
|
|
4029
4038
|
.filter((m) => Acta.memberCan(record, m.pub, 'approve'))
|
|
4030
|
-
|
|
4039
|
+
// `notifiedAt`: cuándo dijo por última vez que le llegan los avisos (null = no lo ha
|
|
4040
|
+
// dicho, o caducó). No decide si se pide: se enseña junto al pedido sin respuesta.
|
|
4041
|
+
.map((m) => ({ id: m.id, label: m.label || '', pub: m.pub, notifiedAt: notifiable.since(m.pub) }))
|
|
4031
4042
|
},
|
|
4032
4043
|
// La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
|
|
4033
4044
|
// llave. Lo que no puede es listarla un aparato.
|
|
@@ -4166,6 +4177,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
4166
4177
|
members: (r?.members || []).map((m) => ({
|
|
4167
4178
|
...m,
|
|
4168
4179
|
running: versionDe(m.pub),
|
|
4180
|
+
// Cuándo dijo que le llegan los avisos (null = no lo ha dicho, o caducó). Solo
|
|
4181
|
+
// tiene sentido en quien aprueba, y es un dato para enseñar: no decide nada.
|
|
4182
|
+
notifiedAt: notifiable.since(m.pub),
|
|
4169
4183
|
renounced: subacta.forMember(m.pub).flatMap((e) => e.caps || [])
|
|
4170
4184
|
}))
|
|
4171
4185
|
}
|