@dotrino/vault 0.80.0 → 0.82.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.
Files changed (3) hide show
  1. package/README.md +49 -0
  2. package/package.json +1 -1
  3. package/src/service.js +167 -0
package/README.md CHANGED
@@ -254,6 +254,55 @@ firmada por la llave del servicio + cert, respuesta **sellada** (ECDH efímero +
254
254
  el proxy no ve los valores) y **firmada por la maestra**, verificada contra la `iss`
255
255
  pineada en el enrolamiento.
256
256
 
257
+ ### Actualizarse: preguntar y avisar (`@dotrino/vault/service`)
258
+
259
+ Un aparato enrolado se actualiza solo (CONVENCIONES §15). Si tiene encendido **su propio**
260
+ interruptor de «pedir aprobación», le pregunta a su bóveda antes de instalar; si tiene
261
+ encendido el de avisos, se lo cuenta después. Son interruptores de cada pieza, independientes
262
+ del de la bóveda (`dotrino-vault update --approval`, que vale solo para ella).
263
+
264
+ ```js
265
+ import { askUpdateApproval, reportUpdated } from '@dotrino/vault/service'
266
+
267
+ // Antes de instalar. Con `dir` (identidad de enrollService) o con
268
+ // { proxyUrl, masterPubkey, device, cert }, como el resto de helpers.
269
+ const r = await askUpdateApproval({ product: '@dotrino/terminal-agent', version: '1.3.0', from: '1.2.9', dir })
270
+ // → { ok: true, asked: false } nadie aprueba en la cuenta: adelante
271
+ // → { ok: true, asked: true } alguien lo aprobó
272
+ // → { ok: false, asked: true } lo denegaron, o pasó un día sin respuesta
273
+ if (r.ok) { /* instalar */ }
274
+
275
+ // Después de instalar: la bóveda avisa a quien aprueba, y dice qué aparato fue.
276
+ await reportUpdated({ product: '@dotrino/terminal-agent', version: '1.3.0', from: '1.2.9', dir }) // → { ok: true }
277
+ ```
278
+
279
+ - El pedido dura **un día**. Mientras vive, volver a preguntar por lo mismo no vuelve a
280
+ timbrar. No repetir la pregunta por una versión ya denegada es cosa de quien llama.
281
+ - `askUpdateApproval` **lanza** si la bóveda no contesta (`code: 'vault-no-reply'`) o si quedó
282
+ pendiente y el resultado no llegó (`code: 'unanswered'`). Que nadie conteste nunca es un sí.
283
+ - `reportUpdated` lanza con `code: 'vault-no-reply'` si la bóveda no contesta.
284
+
285
+ **La receta completa**, para un agente que usa `watchSelfUpdateNpm` de `@dotrino/update/npm`:
286
+ `vaultUpdateHooks` devuelve los tres ganchos ya armados (preguntar, avisar que se actualizó,
287
+ avisar que necesita permisos de administrador).
288
+
289
+ ```js
290
+ import { vaultUpdateHooks } from '@dotrino/vault/service'
291
+ watchSelfUpdateNpm({ name: '@dotrino/terminal-agent', /* … */ ...vaultUpdateHooks({ product: '@dotrino/terminal-agent', dir, log }) })
292
+ ```
293
+
294
+ **Si el servicio no puede instalarse solo** porque necesita permisos de administrador (está
295
+ instalado para todo el sistema y no como usuario), no instala ni pregunta: avisa.
296
+
297
+ ```js
298
+ import { reportUpdateNeedsRoot } from '@dotrino/vault/service'
299
+ await reportUpdateNeedsRoot({ product: '@dotrino/terminal-agent', version: '1.3.0', from: '1.2.9', dir }) // → { ok: true }
300
+ ```
301
+
302
+ La bóveda se lo cuenta a quien aprueba («tiene una actualización pendiente: instálala a mano
303
+ en esa máquina»). Mismos errores que `reportUpdated`; avisar una vez por versión es cosa de
304
+ quien llama. Lo preferible es instalar los servicios como usuario, que no necesita nada de esto.
305
+
257
306
  ## Modelo de aprobación (seguro por diseño)
258
307
 
259
308
  - El **dispositivo** que se enrola genera un **código aleatorio** (`makePairingCode`) y
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.80.0",
3
+ "version": "0.82.0",
4
4
  "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/service.js CHANGED
@@ -620,6 +620,173 @@ 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 function reportUpdated (o = {}) { return reportToVault('update-done', o) }
711
+
712
+ /**
713
+ * HAY VERSIÓN NUEVA Y NO PUEDO INSTALARLA SOLO: necesita permisos de administrador (el
714
+ * servicio está instalado para todo el sistema, no como usuario). El aparato se lo dice a su
715
+ * bóveda y ella avisa a quien aprueba, que es quien puede ir a esa máquina e instalarla a
716
+ * mano (dueño, 2026-10-08). No instala nada ni pide permiso: solo avisa.
717
+ *
718
+ * Mismos parámetros y errores que `reportUpdated`. Avisar una sola vez por versión es cosa
719
+ * de quien llama.
720
+ * @returns {Promise<{ ok: true }>}
721
+ */
722
+ export function reportUpdateNeedsRoot (o = {}) { return reportToVault('update-needs-root', o) }
723
+
724
+ /**
725
+ * LOS TRES GANCHOS DE ACTUALIZACIÓN, YA ARMADOS para pasárselos a `watchSelfUpdateNpm` de
726
+ * `@dotrino/update/npm`: una pieza, para que cada agente no lo escriba por su cuenta. No
727
+ * depende de ese paquete: solo devuelve funciones con la forma que él espera.
728
+ *
729
+ * · `mayUpdate({ version, from })` → pregunta a la bóveda y devuelve si se puede. Si la
730
+ * bóveda NO CONTESTA, lanza: para quien llama eso es «no pude preguntar» y se reintenta.
731
+ * Si el pedido VENCE sin respuesta devuelve `false`: vencido es no, y no se repite.
732
+ * · `onUpdated({ version, from })` → le dice a la bóveda que se actualizó. Deja pasar el
733
+ * error, para que quien llama conserve su marcador y lo reintente.
734
+ * · `onNeedsRoot({ version, from })` → le dice que hay versión nueva y necesita permisos de
735
+ * administrador. Igual.
736
+ *
737
+ * @param {object} o
738
+ * @param {string} o.product nombre npm de la pieza
739
+ * @param {(m: string) => void} [o.log]
740
+ * …y la conexión como en los demás: `dir`, o `proxyUrl` + `masterPubkey` + `device` + `cert`.
741
+ * @returns {{ mayUpdate: (u: { version: string, from?: string }) => Promise<boolean>,
742
+ * onUpdated: (u: { version: string, from?: string }) => Promise<void>,
743
+ * onNeedsRoot: (u: { version: string, from?: string }) => Promise<void> }}
744
+ */
745
+ export function vaultUpdateHooks ({ product, log = () => {}, dir, proxyUrl, masterPubkey, device, cert, _ask = askUpdateApproval, _done = reportUpdated, _root = reportUpdateNeedsRoot } = {}) {
746
+ if (typeof product !== 'string' || !product) throw new Error('vaultUpdateHooks: needs product')
747
+ const conn = { dir, proxyUrl, masterPubkey, device, cert }
748
+ return {
749
+ async mayUpdate ({ version, from = null } = {}) {
750
+ let r
751
+ try { r = await _ask({ ...conn, product, version, from, onPending: () => log(`[update] ${product} ${version}: waiting for approval on your vault`) }) } catch (e) {
752
+ if (e?.code === 'unanswered') { log(`[update] ${product} ${version}: nobody answered in time, so it is a no`); return false }
753
+ throw e
754
+ }
755
+ log(`[update] ${product} ${version}: ${r.ok ? (r.asked ? 'approved' : 'no approval needed') : 'not approved'}`)
756
+ return r.ok === true
757
+ },
758
+ async onUpdated ({ version, from = null } = {}) { await _done({ ...conn, product, version, from }) },
759
+ async onNeedsRoot ({ version, from = null } = {}) { await _root({ ...conn, product, version, from }) }
760
+ }
761
+ }
762
+
763
+ /** Lo común de los dos avisos: firmar, mandar, y comprobar quién contesta. */
764
+ async function reportToVault (op, { product, version, from = null, dir, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
765
+ const saved = dir ? readServiceIdentity(dir) : null
766
+ proxyUrl = proxyUrl || saved?.proxy
767
+ masterPubkey = masterPubkey || saved?.iss
768
+ device = device || saved?.device
769
+ cert = cert || saved?.cert
770
+ if (typeof product !== 'string' || !product || typeof version !== 'string' || !version) throw new Error(`${op}: needs product and version`)
771
+ if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
772
+ const client = await freshClient(proxyUrl, 20000, masterPubkey)
773
+ try {
774
+ await identifyAsService(client, device)
775
+ const data = { op, product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
776
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
777
+ const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === op && p.body.product === product && p.body.version === version), timeoutMs)
778
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
779
+ const res = await pending.catch((e) => {
780
+ if (/timeout/.test(e.message)) throw Object.assign(new Error(`${op}: the vault did not answer`), { code: 'vault-no-reply' })
781
+ throw e
782
+ })
783
+ if (res.type === MSG.ERROR) throw vaultError(res)
784
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
785
+ if (res.body.ok !== true) throw new Error(`malformed ${op} reply`)
786
+ return { ok: true }
787
+ } finally { client.close() }
788
+ }
789
+
623
790
  /**
624
791
  * Pide los secretos del ns al vault (una petición puntual; lanza si falla).
625
792
  * Usa la identidad persistida por `enrollService` salvo que se pase explícita.