@dotrino/vaultd 0.134.0 → 0.135.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/env.js +6 -2
- package/lib/src/service.js +73 -23
- package/package.json +1 -1
- package/src/vault.js +52 -2
package/lib/src/env.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import fs from 'node:fs'
|
|
22
22
|
import os from 'node:os'
|
|
23
23
|
import path from 'node:path'
|
|
24
|
-
import { fetchSecrets, waitForSecrets, readServiceIdentity, watchSecretsChanges } from './service.js'
|
|
24
|
+
import { fetchSecrets, waitForSecrets, readServiceIdentity, watchSecretsChanges, lastDigestOf } from './service.js'
|
|
25
25
|
import { isValidSecretsNs } from './protocol.js'
|
|
26
26
|
|
|
27
27
|
/** Raíz donde viven las identidades de servicio de esta máquina/usuario. */
|
|
@@ -276,10 +276,14 @@ export async function watchEnv ({ ns, vault, dir, applied = lastApplied ?? undef
|
|
|
276
276
|
process.exit(reason === 'revoked' ? 1 : exitCode)
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
// La HUELLA de lo que este proceso puso a correr (la trajo el cajón al pedirlo). Con ella la
|
|
280
|
+
// primera comparación ya detecta un cambio ocurrido entre pedir y escuchar, sin volver a
|
|
281
|
+
// pedir las claves — que a un servicio con aprobación le hacía sonar el teléfono al dueño.
|
|
282
|
+
const pub = readServiceIdentity(dir)?.device?.publickey
|
|
279
283
|
return watchSecretsChanges({
|
|
280
284
|
dir,
|
|
281
285
|
ns,
|
|
282
|
-
applied,
|
|
286
|
+
digest: applied !== undefined && pub ? lastDigestOf(ns, pub) : null,
|
|
283
287
|
log: say,
|
|
284
288
|
onChange: ({ ts, via }) => (onUpdate ? onUpdate({ ns, ts, reason: 'changed', via }) : exitNow('changed')),
|
|
285
289
|
// Un cert revocado sale con código de FALLO a propósito: si el supervisor lo
|
package/lib/src/service.js
CHANGED
|
@@ -581,6 +581,45 @@ export async function storeVars ({ dir, ns, proxyUrl, masterPubkey, device, cert
|
|
|
581
581
|
} finally { client.close() }
|
|
582
582
|
}
|
|
583
583
|
|
|
584
|
+
/** La huella del último cajón recibido, por `ns` + aparato (ver `fetchDigest`). */
|
|
585
|
+
const lastDigests = new Map()
|
|
586
|
+
const digestKey = (ns, pub) => ns + String.fromCharCode(0) + pub
|
|
587
|
+
/** La huella del cajón que este proceso recibió por última vez, o `null`. */
|
|
588
|
+
export function lastDigestOf (ns, publickey) { return lastDigests.get(digestKey(ns, publickey)) ?? null }
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* ¿CAMBIÓ LA CONFIGURACIÓN? Pregunta la HUELLA del cajón —nombres, visibilidad y generación,
|
|
592
|
+
* ningún valor— y por eso no pide aprobación (dueño, 2026-09-30: «revisa que no se pidan
|
|
593
|
+
* aprobaciones sin motivo»). Es lo que usa `watchSecretsChanges` al reconectarse; antes
|
|
594
|
+
* pedía el cajón entero y a un servicio con aprobación le sonaba el teléfono al dueño en
|
|
595
|
+
* cada despliegue del proxio.
|
|
596
|
+
* @returns {Promise<string>} la huella
|
|
597
|
+
*/
|
|
598
|
+
export async function fetchDigest ({ dir, ns, proxyUrl, masterPubkey, device, cert, timeoutMs = 20000 } = {}) {
|
|
599
|
+
const saved = dir ? readServiceIdentity(dir) : null
|
|
600
|
+
ns = ns || saved?.ns
|
|
601
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
602
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
603
|
+
device = device || saved?.device
|
|
604
|
+
cert = cert || saved?.cert
|
|
605
|
+
if (!isValidSecretsNs(ns)) throw new Error('invalid ns')
|
|
606
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
607
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
608
|
+
try {
|
|
609
|
+
await identifyAsService(client, device)
|
|
610
|
+
const data = { op: 'digest', ns, publickey: device.publickey, ts: Date.now() }
|
|
611
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
612
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.digest'), timeoutMs)
|
|
613
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
614
|
+
const res = await pending
|
|
615
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
616
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
617
|
+
if (res.body.ns !== ns || typeof res.body.digest !== 'string') throw new Error('malformed digest reply')
|
|
618
|
+
if (typeof res.body.ts !== 'number' || Math.abs(Date.now() - res.body.ts) > FRESH_WINDOW_MS) throw new Error('stale digest reply')
|
|
619
|
+
return res.body.digest
|
|
620
|
+
} finally { client.close() }
|
|
621
|
+
}
|
|
622
|
+
|
|
584
623
|
/**
|
|
585
624
|
* Pide los secretos del ns al vault (una petición puntual; lanza si falla).
|
|
586
625
|
* Usa la identidad persistida por `enrollService` salvo que se pase explícita.
|
|
@@ -736,7 +775,10 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
736
775
|
try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
|
|
737
776
|
const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
738
777
|
res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
|
|
739
|
-
.catch((e) => {
|
|
778
|
+
.catch((e) => {
|
|
779
|
+
if (/timeout/.test(e.message)) throw Object.assign(new Error('approval: nobody approved the request in time'), { code: 'unanswered' })
|
|
780
|
+
throw e
|
|
781
|
+
})
|
|
740
782
|
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
741
783
|
}
|
|
742
784
|
|
|
@@ -755,6 +797,9 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
755
797
|
const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
|
|
756
798
|
await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
757
799
|
anotarActa(payload?.acta)
|
|
800
|
+
// La huella de lo que se acaba de recibir: con ella se compara después (`fetchDigest`)
|
|
801
|
+
// sin volver a pedir claves. Ya comprobada: va dentro del cuerpo firmado.
|
|
802
|
+
if (typeof body.digest === 'string') lastDigests.set(digestKey(ns, device.publickey), body.digest)
|
|
758
803
|
|
|
759
804
|
// DOS CAPAS DE SOBRE, y hacen cosas distintas:
|
|
760
805
|
// · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
|
|
@@ -1010,11 +1055,10 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
|
|
|
1010
1055
|
* `via` dice por dónde se enteró: `notice` (llegó el aviso) o `reconcile` (nadie
|
|
1011
1056
|
* avisó y la comparación al reconectar encontró otra configuración).
|
|
1012
1057
|
* @param {(info:{nonce:string})=>void} [opts.onRevoked] Cert revocado: apagar YA.
|
|
1013
|
-
* @param {
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
* limita a tomar la referencia.
|
|
1058
|
+
* @param {string} [opts.digest] La huella del cajón EN USO (`lastDigestOf(ns, pub)` tras
|
|
1059
|
+
* pedirlo). Con ella se detecta ya en la primera conexión un cambio ocurrido entre que el
|
|
1060
|
+
* agente pidió su configuración y logró ponerse a escuchar; sin ella la primera
|
|
1061
|
+
* comparación solo toma la referencia.
|
|
1018
1062
|
* @param {number} [opts.graceMs=30000] No obedecer avisos durante los primeros N ms.
|
|
1019
1063
|
* La comparación también lo respeta, pero **aplazándose** (el aviso sí se descarta):
|
|
1020
1064
|
* es lo que impide que un fallo sistemático se convierta en un ciclo de reinicios,
|
|
@@ -1030,7 +1074,7 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
|
|
|
1030
1074
|
* encontró un cambio.
|
|
1031
1075
|
*/
|
|
1032
1076
|
export async function watchSecretsChanges ({
|
|
1033
|
-
dir, ns, onChange, onRevoked,
|
|
1077
|
+
dir, ns, onChange, onRevoked, digest = null, graceMs = 30000, minIntervalMs = 60000, jitterMs = 5000,
|
|
1034
1078
|
reconcileMinMs = 30000, log = () => {}
|
|
1035
1079
|
} = {}) {
|
|
1036
1080
|
const saved = dir ? readServiceIdentity(dir) : null
|
|
@@ -1048,7 +1092,10 @@ export async function watchSecretsChanges ({
|
|
|
1048
1092
|
let retryTimer = null
|
|
1049
1093
|
// Huella de la configuración EN USO. Comparar huellas y no valores es lo que permite
|
|
1050
1094
|
// decir «esto no es lo que está corriendo» sin volver a manejar los secretos.
|
|
1051
|
-
|
|
1095
|
+
// La referencia es la HUELLA del cajón que está EN USO (la bóveda la manda con cada entrega:
|
|
1096
|
+
// `lastDigestOf`). La pasa quien sabe qué aplicó; sin ella, la primera comparación solo la
|
|
1097
|
+
// toma. Antes era el bundle entero (`applied`), y compararlo obligaba a volver a pedirlo.
|
|
1098
|
+
let fingerprint = typeof digest === 'string' ? digest : null
|
|
1052
1099
|
let firstConnection = true
|
|
1053
1100
|
let lastReconcile = 0
|
|
1054
1101
|
let reconciling = false
|
|
@@ -1206,9 +1253,11 @@ export async function watchSecretsChanges ({
|
|
|
1206
1253
|
return false
|
|
1207
1254
|
}
|
|
1208
1255
|
reconciling = true
|
|
1209
|
-
let
|
|
1256
|
+
let current = null
|
|
1210
1257
|
try {
|
|
1211
|
-
|
|
1258
|
+
// LA HUELLA, NO EL CAJÓN: preguntar si cambió no es pedir las claves, y no puede
|
|
1259
|
+
// hacer sonar el teléfono de nadie.
|
|
1260
|
+
current = await fetchDigest({ dir, ns })
|
|
1212
1261
|
} catch (e) {
|
|
1213
1262
|
// El cert revocado mientras estaba incomunicado: el `REVOKED` firmado se perdió
|
|
1214
1263
|
// igual que el aviso, y esta es la única otra forma de enterarse. Lo demás (la
|
|
@@ -1225,8 +1274,7 @@ export async function watchSecretsChanges ({
|
|
|
1225
1274
|
reconciling = false
|
|
1226
1275
|
lastReconcile = Date.now()
|
|
1227
1276
|
}
|
|
1228
|
-
|
|
1229
|
-
if (fingerprint === null) { fingerprint = current; return false } // primera vez: solo tomar referencia
|
|
1277
|
+
if (fingerprint === null) { fingerprint = current; return false } // sin referencia: solo tomarla
|
|
1230
1278
|
if (current === fingerprint) return false
|
|
1231
1279
|
fingerprint = current
|
|
1232
1280
|
lastObeyed = Date.now()
|
|
@@ -1279,16 +1327,6 @@ export async function watchSecretsChanges ({
|
|
|
1279
1327
|
}
|
|
1280
1328
|
}
|
|
1281
1329
|
|
|
1282
|
-
/**
|
|
1283
|
-
* Huella de un bundle. Las claves van ORDENADAS: el bundle se arma mezclando el cajón
|
|
1284
|
-
* del scope con el del aparato, así que el mismo contenido puede llegar en otro orden y
|
|
1285
|
-
* un cambio de orden no es un cambio de configuración.
|
|
1286
|
-
*/
|
|
1287
|
-
function fingerprintOf (secrets) {
|
|
1288
|
-
const pairs = Object.entries(secrets || {}).map(([k, v]) => [k, String(v)]).sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
1289
|
-
return createHash('sha256').update(JSON.stringify(pairs)).digest('hex')
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
1330
|
/**
|
|
1293
1331
|
* Bucle de arranque de un servicio: pide los secretos y, si el vault no está
|
|
1294
1332
|
* disponible, REINTENTA para siempre (con backoff hasta `maxRetryMs`). El
|
|
@@ -1352,6 +1390,12 @@ export function isFinal (e) {
|
|
|
1352
1390
|
* @param {number} approvalTimeoutMs lo que dura un pedido de aprobación
|
|
1353
1391
|
* @param {number} now
|
|
1354
1392
|
*/
|
|
1393
|
+
/** Cuánto esperar para volver a pedir tras `n` pedidos sin contestar: 0, 5, 10, 20… min, hasta 6 h. */
|
|
1394
|
+
export function unansweredDelay (n) {
|
|
1395
|
+
if (!n) return 0
|
|
1396
|
+
return Math.min(6 * 60 * 60 * 1000, 5 * 60 * 1000 * 2 ** (n - 1))
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1355
1399
|
export function retryDelay (backoffMs, rangAt, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, now = Date.now()) {
|
|
1356
1400
|
if (!rangAt) return backoffMs
|
|
1357
1401
|
return Math.max(backoffMs, approvalTimeoutMs - (now - rangAt))
|
|
@@ -1360,13 +1404,19 @@ export function retryDelay (backoffMs, rangAt, approvalTimeoutMs = APPROVAL_TIME
|
|
|
1360
1404
|
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, onRetry, onPending, onCert, publicOnly = false } = {}) {
|
|
1361
1405
|
let backoff = retryMs
|
|
1362
1406
|
let rangAt = 0 // cuándo timbró ESTE intento; 0 = no hubo aprobación de por medio
|
|
1407
|
+
let unanswered = 0
|
|
1363
1408
|
const notePending = (info) => { rangAt = Date.now(); onPending?.(info) }
|
|
1364
1409
|
for (;;) {
|
|
1365
1410
|
try {
|
|
1366
1411
|
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, approvalTimeoutMs, onPending: notePending, onCert, publicOnly })
|
|
1367
1412
|
} catch (e) {
|
|
1368
1413
|
if (isFinal(e)) throw e
|
|
1369
|
-
|
|
1414
|
+
// UN PEDIDO QUE NADIE CONTESTÓ NO SE REPITE CADA CINCO MINUTOS (dueño, 2026-09-30:
|
|
1415
|
+
// «que no se pidan aprobaciones sin motivo»). Volver a pedir en cuanto vence hacía sonar
|
|
1416
|
+
// el teléfono toda la noche por un servicio que arrancó de madrugada: el dueño ya lo vio
|
|
1417
|
+
// y no lo aprobó. Se espacia —5, 10, 20, 40 min… hasta 6 h— y se sigue esperando.
|
|
1418
|
+
if (e?.code === 'unanswered') unanswered++
|
|
1419
|
+
const wait = Math.max(retryDelay(backoff, rangAt, approvalTimeoutMs), unansweredDelay(unanswered))
|
|
1370
1420
|
rangAt = 0
|
|
1371
1421
|
try { onRetry?.(e, wait) } catch (_) {}
|
|
1372
1422
|
await new Promise((r) => setTimeout(r, wait))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.135.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/vault.js
CHANGED
|
@@ -1447,6 +1447,50 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1447
1447
|
* Dos operaciones: `store.recipients` (para quién se sella: solo públicas, nada que ocultar)
|
|
1448
1448
|
* y `store` (los sobres ya hechos; la bóveda no los abre).
|
|
1449
1449
|
*/
|
|
1450
|
+
/**
|
|
1451
|
+
* LA HUELLA DE UN CAJÓN, tal como lo recibiría este aparato: nombres y GENERACIÓN de cada
|
|
1452
|
+
* variable, sin un solo valor. La visibilidad NO entra: taparla no cambia lo que el servicio
|
|
1453
|
+
* lee (y no sube la generación ni avisa). La generación la sube el almacén al
|
|
1454
|
+
* escribir, así que la huella cambia cuando cambia la configuración y no cuando la
|
|
1455
|
+
* bóveda solo rehace envolturas al abrirse. (Una rotación de llaves —al quitar un
|
|
1456
|
+
* aparato— sí la sube: el servicio se reinicia una vez, que es raro y no hace daño.)
|
|
1457
|
+
*/
|
|
1458
|
+
function drawerDigest (ns, devicePub) {
|
|
1459
|
+
const b = secrets.bundleFor(ns, devicePub)
|
|
1460
|
+
const filas = Object.entries(b.entries || {})
|
|
1461
|
+
.map(([k, e]) => [k, b.legacy ? nodeCrypto.createHash('sha256').update(String(e.v)).digest('hex') : (e.gen ?? null)])
|
|
1462
|
+
.sort((a, c) => (a[0] < c[0] ? -1 : 1))
|
|
1463
|
+
return nodeCrypto.createHash('sha256').update(JSON.stringify(filas)).digest('hex')
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* ¿CAMBIÓ MI CONFIGURACIÓN? Sin llevarse nada (dueño, 2026-09-30: «revisa que no se pidan
|
|
1468
|
+
* aprobaciones sin motivo»).
|
|
1469
|
+
*
|
|
1470
|
+
* El servicio lo pregunta al reconectarse, por si se perdió un aviso. Antes lo preguntaba
|
|
1471
|
+
* pidiendo el cajón ENTERO —privadas incluidas— para sacarle una huella, y eso es un
|
|
1472
|
+
* pedido de claves: a un servicio que pide aprobación le sonaba el teléfono al dueño en
|
|
1473
|
+
* cada despliegue del proxio, para aprobar algo que no entregaba nada que el servicio no
|
|
1474
|
+
* tuviera ya. La huella no revela nada, así que no se aprueba: quien pregunta tiene que
|
|
1475
|
+
* ser igualmente el servicio de ese cajón (cert + CN del acta).
|
|
1476
|
+
*/
|
|
1477
|
+
async function handleDigest (from, p) {
|
|
1478
|
+
const ns = p.data?.ns
|
|
1479
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'digest: invalid namespace' })
|
|
1480
|
+
const chk = await verifyChain({
|
|
1481
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1482
|
+
expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
|
|
1483
|
+
})
|
|
1484
|
+
if (!chk.ok) return denyChain(from, chk, p, 'digest')
|
|
1485
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1486
|
+
if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
1487
|
+
audit('rejected', { what: 'digest', ns, reason: record ? 'cn' : 'sin-acta' })
|
|
1488
|
+
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
1489
|
+
}
|
|
1490
|
+
const body = { op: 'secrets.digest', ns, digest: drawerDigest(ns, chk.device), ts: Date.now() }
|
|
1491
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1450
1494
|
const storeNonces = new Map()
|
|
1451
1495
|
async function handleVarStore (from, p) {
|
|
1452
1496
|
const ns = p.data?.ns
|
|
@@ -1513,6 +1557,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1513
1557
|
// del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
|
|
1514
1558
|
if (p.data?.op === 'enckey') return handleEncKey(from, p)
|
|
1515
1559
|
if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
|
|
1560
|
+
if (p.data?.op === 'digest') return handleDigest(from, p)
|
|
1516
1561
|
if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke'].includes(p.data?.op)) return handleApproval(from, p)
|
|
1517
1562
|
const ns = p.data?.ns
|
|
1518
1563
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
|
|
@@ -1636,7 +1681,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1636
1681
|
}
|
|
1637
1682
|
}
|
|
1638
1683
|
const enc = await seal({ ek, payload })
|
|
1639
|
-
|
|
1684
|
+
// La huella va con cada entrega: es la referencia con la que el servicio compara después
|
|
1685
|
+
// (`digest`) sin volver a pedir claves.
|
|
1686
|
+
const body = { op: 'secrets.result', ns, enc, digest: drawerDigest(ns, devicePub), ts: Date.now() }
|
|
1640
1687
|
// LA MAESTRA NO FIRMA ESTO. Su trabajo es sellar el acta y reenvolver sobres; servir
|
|
1641
1688
|
// no es suyo. Quien firma es la LLAVE DE SELLADO que el acta nombra (`sealPub`), la
|
|
1642
1689
|
// misma que ya firma cada sobre — y por eso esto se puede servir con el perfil
|
|
@@ -1814,7 +1861,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1814
1861
|
*/
|
|
1815
1862
|
async function notifyApprovers (pend, record) {
|
|
1816
1863
|
try {
|
|
1817
|
-
|
|
1864
|
+
// `kind` dice QUÉ se pide (llevarse claves, guardar variables, actualizar…): es lo que la
|
|
1865
|
+
// app necesita para que el aviso diga el porqué sin tener que abrir la lista. Los
|
|
1866
|
+
// detalles (comando, carpeta, nombres) NO van aquí: van sellados en la lista.
|
|
1867
|
+
const body = { ev: 'approval', id: pend.id, ns: pend.ns, kind: pend.kind || 'read', deviceId: pend.deviceId, label: pend.label, exp: pend.exp, ts: Date.now() }
|
|
1818
1868
|
const seal = await sealOrFail(body)
|
|
1819
1869
|
const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
|
|
1820
1870
|
if (!who.length) log(`[vault] ${pend.ns}: nobody can approve (grant it with: dotrino-vault caps <ID> +aprueba)`)
|