@dotrino/vault 0.84.0 → 0.86.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/README.md CHANGED
@@ -1,42 +1,42 @@
1
1
  # @dotrino/vault
2
2
 
3
- Usa **este dispositivo (navegador) como bóveda/CA** del ecosistema Dotrino, sin un PC
4
- con el daemon. Es la contraparte browser del daemon `dotrino-vault`: atiende el mismo
5
- protocolo de enrolamiento endurecido por el proxy y firma certificados de delegación
6
- `D ← P` (donde `P` es la identidad de este dispositivo, `@dotrino/identity`).
3
+ La bóveda de Dotrino como librería. **El mismo núcleo que el binario `dotrino-vault`**
4
+ (`src/core/vault.js`) corre en el navegador: la pestaña de `vault.dotrino.com` y la extensión
5
+ del gestor de contraseñas son la misma bóveda que el demonio, con el disco en IndexedDB
6
+ (regla de las tres versiones, `docs/three-versions.md` del repo).
7
7
 
8
- Pensado para que **cualquier app** del ecosistema (no solo la terminal) pueda ofrecer
9
- "usar este dispositivo como bóveda".
8
+ Incluye además el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus
9
+ credenciales del vault en vez del `.env` (abajo).
10
10
 
11
- ## Uso
11
+ ## La bóveda en el navegador
12
12
 
13
13
  ```js
14
- import { Identity } from '@dotrino/identity'
15
- import { startDeviceVault } from '@dotrino/vault'
16
-
17
- const identity = await Identity.connect()
18
- const vault = await startDeviceVault(identity) // se conecta al proxy como P
19
-
20
- // 1) Abrir un emparejamiento y mostrar el QR/JSON al dispositivo a enrolar:
21
- const { qr } = vault.startPairing({ label: 'mi-agente' })
22
- // El dispositivo (p. ej. @dotrino/identity#enrollDevice) consume `qr`, GENERA un
23
- // código aleatorio y lo MUESTRA (no lo envía).
24
-
25
- // 2) Cuando el dispositivo pide acceso, aparece en la lista de pendientes:
26
- vault.onPendingChange(() => {
27
- for (const { deviceId } of vault.listPending()) {
28
- // Un humano LEE el código del dispositivo y lo TIPEA aquí:
29
- // await vault.approve(deviceId, codigoTipeado)
30
- }
31
- })
32
-
33
- // 3) Máquinas ya enroladas / revocar:
34
- const machines = await vault.listMachines() // [{ sub, deviceId, label, exp, nonce, scope }]
35
- // await vault.revoke(nonce)
36
-
37
- vault.close()
14
+ import { startBrowserVault } from '@dotrino/vault/browser'
15
+
16
+ // `core`: el núcleo de identidad ya abierto (`createIdentityCore` de @dotrino/identity),
17
+ // el del iframe de id.dotrino.com o el de la extensión.
18
+ const vault = await startBrowserVault({ core, onPendingChange: () => pintar(vault.listPending()) })
19
+
20
+ // Emparejar: abrir una invitación y aprobar TECLEANDO el código que muestra el aparato.
21
+ const { qr } = await vault.startPairing({ label: 'mi-agente' })
22
+ // await vault.approve(deviceId, codigoTipeado)
23
+
24
+ await vault.close()
38
25
  ```
39
26
 
27
+ Devuelve el mismo objeto que el núcleo del binario (cajones, aprobaciones, inicios de
28
+ sesión, bloqueados, revocar…) más `approve(deviceId, code)`, `reject`, `listMachines`,
29
+ `revokeDevice(sub)` y `close()`. La consola que corre en ese mismo navegador la administra
30
+ con el protocolo de la consola remota, por un camino local (`core.setSelfVault`).
31
+
32
+ Opciones: `proxyUrl`, `makeClient` (quién crea el cliente del proxio; obligatorio en un
33
+ contexto sin `import()`), `opaque` (el servidor de OPAQUE si el del paquete no carga),
34
+ `servePasswords: false` (las contraseñas las atiende quien monta la bóveda, con la política
35
+ del núcleo: `passwordsNeedApproval`, `approvePasswordRequest`), `legacyLogins`, `log`.
36
+
37
+ `startDeviceVault(identity, opts)` (el mostrador anterior, la mitad del protocolo) se
38
+ conserva solo para `dotrino-passmanager serve` y desaparecerá.
39
+
40
40
  ## Credenciales del vault en vez del `.env` (Node)
41
41
 
42
42
  La cara "dotenv" del paquete: **cualquier proyecto Node** jala sus credenciales del
@@ -315,7 +315,7 @@ quien llama. Lo preferible es instalar los servicios como usuario, que no necesi
315
315
 
316
316
  ## API
317
317
 
318
- `startDeviceVault(identity, { proxyUrl? }) → Promise<handle>`
318
+ `startDeviceVault(identity, { proxyUrl? }) → Promise<handle>` — el mostrador anterior, solo para `dotrino-passmanager serve`. Para una bóveda de navegador, `startBrowserVault` (arriba).
319
319
 
320
320
  - `startPairing({ scope?, ttlMs?, label?, mode?, account? }) → { qr, expiresInMs }`
321
321
  - `stopPairing(token)`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.84.0",
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'`).",
3
+ "version": "0.86.0",
4
+ "description": "La bóveda de Dotrino como librería: el MISMO núcleo que el binario corriendo en el navegador (`@dotrino/vault/browser`), el mostrador de emparejamiento y 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",
7
7
  "module": "src/index.js",
@@ -13,6 +13,9 @@
13
13
  ".": {
14
14
  "import": "./src/index.js"
15
15
  },
16
+ "./browser": {
17
+ "import": "./src/browser.js"
18
+ },
16
19
  "./service": {
17
20
  "import": "./src/service.js"
18
21
  },
@@ -59,8 +62,9 @@
59
62
  "pairing"
60
63
  ],
61
64
  "peerDependencies": {
62
- "@dotrino/identity": ">=0.111.0",
63
- "@dotrino/proxy-client": ">=0.23.1"
65
+ "@dotrino/identity": ">=0.116.0",
66
+ "@dotrino/proxy-client": ">=0.23.1",
67
+ "@dotrino/store": ">=0.11.0"
64
68
  },
65
69
  "license": "MIT",
66
70
  "repository": {
@@ -76,7 +80,15 @@
76
80
  "type-check": "tsc --noEmit"
77
81
  },
78
82
  "dependencies": {
83
+ "@dotrino/compat": "0.4.0",
79
84
  "@dotrino/opaque": "0.1.0",
85
+ "@dotrino/passmanager": "0.17.0",
86
+ "@dotrino/roadmap": "0.4.13",
80
87
  "@dotrino/update": "0.5.1"
88
+ },
89
+ "peerDependenciesMeta": {
90
+ "@dotrino/store": {
91
+ "optional": true
92
+ }
81
93
  }
82
94
  }
package/src/browser.js ADDED
@@ -0,0 +1,167 @@
1
+ /**
2
+ * LA BÓVEDA EN EL NAVEGADOR: la pestaña (`vault.dotrino.com`, dentro del iframe de
3
+ * identidad) y la extensión del gestor de contraseñas corren el MISMO núcleo que el
4
+ * binario (`./core/vault.js`). Lo único distinto es lo que pone la plataforma:
5
+ *
6
+ * · el disco es IndexedDB cifrado (`./core/browserDisk.js`), uno por perfil;
7
+ * · la identidad es la que ya tiene abierta la pestaña o la extensión;
8
+ * · no hay `/proc` ni socket de archivo: lo que declara quien pide se queda como
9
+ * declarado, y los servicios de la misma máquina llegan por el proxio.
10
+ *
11
+ * Y lo que sí suma: la consola que corre en este mismo navegador administra esta bóveda
12
+ * por un canal local (`core.setSelfVault`), con el MISMO protocolo y las mismas
13
+ * comprobaciones que contra el binario.
14
+ *
15
+ * Lo que el binario tiene y el navegador no, por su contexto: actualizarse solo (la pestaña
16
+ * se sirve siempre al día y la extensión la actualiza su tienda), las llaves de hardware
17
+ * como puerta del perfil y el candado que cifra la maestra con la contraseña. Ver
18
+ * `docs/three-versions.md`.
19
+ */
20
+ import { startVault } from './core/vault.js'
21
+ import { openBrowserDisk } from './core/browserDisk.js'
22
+ import { LIB_VERSION } from './core/libVersion.js'
23
+ import { IdentityFacade } from '@dotrino/identity/facade'
24
+ import { memberScopes } from '@dotrino/identity/acta'
25
+ import { deviceIdOf } from './enroll.js'
26
+
27
+ const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
28
+ /** Cada cuánto se rehace el papel con el que la consola local administra esta bóveda. */
29
+ const SELF_CERT_EVERY_MS = 60_000
30
+
31
+ /**
32
+ * @param {object} opts
33
+ * @param {object} opts.core el núcleo de identidad ya abierto (`createIdentityCore`)
34
+ * @param {string} [opts.proxyUrl]
35
+ * @param {Function} [opts.makeClient] quién crea el cliente del proxio. La extensión lo
36
+ * pasa siempre: un service worker no puede hacer `import()`.
37
+ * @param {Function} [opts.legacyLogins] los inicios de sesión que esta pestaña guardaba
38
+ * antes de correr el núcleo (`{ state, drop }` o `null`). Se traen UNA vez.
39
+ * @param {Function} [opts.onPendingChange] un aparato espera aprobación, o dejó de esperar
40
+ * @param {Function} [opts.onAdopted]
41
+ * @param {Function} [opts.log]
42
+ * @param {object} [opts.opaque] el servidor de OPAQUE, si el del paquete no se puede cargar aquí
43
+ * (la extensión: su CSP no deja WASM en la página de la bóveda).
44
+ * @param {boolean} [opts.servePasswords] `false` si las contraseñas las atiende quien monta
45
+ * la bóveda (la extensión, con su worker); decide con `passwordsNeedApproval`/`approvePasswordRequest`.
46
+ * @param {Function} [opts.openDisk] `(name) => disk`. Solo para las pruebas (Node no tiene
47
+ * IndexedDB); por defecto, `openBrowserDisk`.
48
+ */
49
+ export async function startBrowserVault ({ core, proxyUrl = DEFAULT_PROXY, makeClient = null, legacyLogins = null, onPendingChange = () => {}, onAdopted = () => {}, log = console.log, openDisk = openBrowserDisk, opaque = null, servePasswords = true } = {}) {
50
+ if (!core?.handlers) throw Object.assign(new Error('startBrowserVault: the identity core is required'), { code: 'no-identity' })
51
+ const identity = new IdentityFacade(core)
52
+ if (!identity.me?.publickey) throw Object.assign(new Error('no identity: create or unlock your profile before using this browser as a vault'), { code: 'no-identity' })
53
+ const { id: pid } = await identity.currentProfile()
54
+ if (!pid) throw Object.assign(new Error('no profile: the vault belongs to an account'), { code: 'no-profile' })
55
+
56
+ const disk = await openDisk(`dotrino-vault.${pid}`)
57
+
58
+ // LOS INICIOS DE SESIÓN QUE YA HABÍA. Antes de correr el núcleo, la pestaña guardaba los
59
+ // usuarios y contraseñas de la cuenta en su propio almacén. Se traen una vez —si el disco
60
+ // ya tiene los suyos, mandan esos— y después se borra lo viejo: dos copias acaban diciendo
61
+ // cosas distintas.
62
+ if (legacyLogins && !disk.exists('logins.json')) {
63
+ const old = await legacyLogins()
64
+ if (old?.state) {
65
+ disk.write('logins.json', disk.atRest.encrypt(JSON.stringify(old.state)))
66
+ await disk.flush()
67
+ try { await old.drop?.() } catch (_) {}
68
+ log('[vault] password logins moved into the vault storage')
69
+ }
70
+ }
71
+
72
+ const avisar = () => { try { onPendingChange() } catch (_) {} }
73
+ const vault = await startVault({
74
+ platform: {
75
+ disk,
76
+ product: 'vault',
77
+ version: LIB_VERSION,
78
+ openIdentity: async () => identity,
79
+ makeClient,
80
+ noticeGroupMs: 3000,
81
+ ...(opaque ? { opaque } : {}),
82
+ servePasswords
83
+ },
84
+ proxyUrl,
85
+ log,
86
+ isLocked: () => !!core.masterLocked,
87
+ // El navegador no tiene la contraseña del perfil con la que el binario cierra la copia
88
+ // de recuperación: se cierra con la llave de este almacén, y el núcleo lo dice.
89
+ hasPassword: () => false,
90
+ onEnrollChallenge: avisar,
91
+ onAdopted: (info) => { try { onAdopted(info) } catch (_) {} }
92
+ })
93
+
94
+ // EL PAPEL CON EL QUE LA CONSOLA DE ESTE NAVEGADOR ADMINISTRA ESTA BÓVEDA: la llave del
95
+ // perfil firmándose a sí misma con lo que el acta le deja hoy. Se rehace cada minuto
96
+ // porque el acta cambia (un permiso que te quitas, un sellado nuevo).
97
+ const master = identity.me.publickey
98
+ const selfCert = async () => {
99
+ const acta = (await identity.profileActa().catch(() => null))?.acta || null
100
+ if (!acta) return null
101
+ const scope = memberScopes(acta, master)
102
+ if (!scope.length) return null
103
+ return (await identity.signDelegation(master, scope, { label: 'self' })).cert
104
+ }
105
+ /**
106
+ * EL DUEÑO EN SU MÁQUINA ADMINISTRA. En el binario eso es la TUI, que no necesita ningún
107
+ * permiso del acta; aquí la consola habla el protocolo de la consola remota, que exige
108
+ * `admin`. Así que la llave de la cuenta se lo da a sí misma, una vez y a la vista en el
109
+ * acta: es verdad que este navegador administra la cuenta. `aprueba` NO, a propósito: con
110
+ * él los servicios pasarían a esperar el visto bueno de una pestaña que puede no estar.
111
+ */
112
+ const asegurarAdmin = async () => {
113
+ if (core.masterLocked) return
114
+ const acta = (await identity.profileActa().catch(() => null))?.acta || null
115
+ const yo = (acta?.members || []).find((m) => m.pub === master)
116
+ if (!yo || (yo.caps || []).includes('admin')) return
117
+ if (!(await identity.isMaster().catch(() => false))) return
118
+ await identity.setCaps(master, [...yo.caps, 'admin'])
119
+ log('[vault] this browser administers the account: admin added to its own key')
120
+ }
121
+ const open = (onReply) => vault.localChannel(onReply)
122
+ const presentar = async () => {
123
+ try {
124
+ await asegurarAdmin()
125
+ const cert = await selfCert()
126
+ core.setSelfVault?.(cert ? { cert, proxy: proxyUrl, open } : null)
127
+ } catch (e) { log('[vault] the console in this browser cannot administer this vault yet: ' + (e?.message || e)) }
128
+ }
129
+ await presentar()
130
+ const refresco = setInterval(presentar, SELF_CERT_EVERY_MS)
131
+
132
+ return {
133
+ ...vault,
134
+ iss: master,
135
+ proxy: proxyUrl,
136
+ disk,
137
+ /** Aprueba TIPEANDO el código que muestra el aparato (el núcleo comprueba el compromiso). */
138
+ approve: async (_deviceId, code) => { try { return await vault.approveDevice(code) } finally { avisar() } },
139
+ reject: (deviceId) => { try { return vault.rejectDevice(deviceId) } finally { avisar() } },
140
+ /**
141
+ * Las máquinas con papel de firma de esta cuenta (excluye los navegadores enrolados con
142
+ * etiqueta `cli`, que no atienden pedidos). Se queda el papel del acta más nueva.
143
+ */
144
+ async listMachines () {
145
+ const { issued } = await identity.listDelegations()
146
+ const bySub = new Map()
147
+ for (const x of (issued || [])) {
148
+ if (!x.sub || x.revokedAt) continue
149
+ if (!Array.isArray(x.scope) || !x.scope.includes('vault:sign')) continue
150
+ if (!x.label || x.label === 'cli' || x.label === 'self') continue
151
+ if (!bySub.has(x.sub) || (x.seq || 0) > (bySub.get(x.sub).seq || 0)) bySub.set(x.sub, x)
152
+ }
153
+ return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
154
+ },
155
+ revokeDevice: (sub) => vault.revokeDevice({ sub }),
156
+ revoke: (nonce) => vault.revokeDevice(nonce),
157
+ async close () {
158
+ clearInterval(refresco)
159
+ try { core.setSelfVault?.(null) } catch (_) {}
160
+ vault.close()
161
+ try { await disk.flush() } catch (e) { log('[vault] some changes could not be saved: ' + (e?.message || e)) }
162
+ disk.close()
163
+ }
164
+ }
165
+ }
166
+
167
+ export default { startBrowserVault }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * PEDIDOS DE APROBACIÓN Y CONCESIONES (puro: sin red ni disco).
3
+ *
4
+ * Un aparato marcado con `approval on` no recibe claves privadas solo por tener el cert: el
5
+ * vault apunta el pedido, avisa a los aparatos con `approve` (el teléfono), y solo su firma
6
+ * libera los secretos. Lo que nadie aprueba vence solo a los 5 min.
7
+ *
8
+ * Lo que se guarda de cada pedido es lo justo para contestar después: el cajón, quién pide,
9
+ * su llave efímera `ek` (a la que se sella la respuesta) y **qué comando está corriendo y
10
+ * desde dónde**. Nunca un valor.
11
+ *
12
+ * LA CONCESIÓN POR COMANDO (dueño, 2026-09-11). Antes no había ventana: cada petición era un
13
+ * pedido, y para un servicio bien hecho eso era una por arranque. Dejó de alcanzar en cuanto
14
+ * los pedidos empezaron a decir QUÉ comando piden: si el dueño ya dijo que sí a ese comando
15
+ * exacto desde esa carpeta exacta, volver a timbrarle por lo mismo es ruido. Así que aprobar
16
+ * concede **una hora, contada desde el último uso** — o sea que un servicio que sigue
17
+ * pidiendo la mantiene viva indefinidamente, que es lo que se pidió. Lo que la corta: que
18
+ * cambie el comando o la carpeta (otra huella, otro pedido), una hora entera sin usarla,
19
+ * revocarla a mano, o reiniciar la bóveda (viven en memoria y no se escriben en ningún
20
+ * sitio: un permiso que sobrevive a un reinicio es un permiso que nadie recuerda haber dado).
21
+ *
22
+ * SIN COMANDO NO HAY CONCESIÓN. Un pedido que no dice qué está corriendo —porque vino de
23
+ * otra máquina por el proxio, donde mandarlo en claro filtraría las rutas del dueño— no
24
+ * tiene huella con la que emparejarse, y entonces se pide aprobación cada vez. No se
25
+ * inventa una huella «vacía» que valdría para todo: eso sería exactamente un repliegue.
26
+ *
27
+ * Y PARA EL GESTOR DE CONTRASEÑAS ESO ES LO QUE SE QUIERE, no una carencia que arreglar
28
+ * (dueño, 2026-09-11: «para el gestor de contraseñas es perfecto»). La extensión es un
29
+ * navegador hablando por el proxio, no un proceso con línea de comandos, así que nunca va a
30
+ * traer huella — y así cada acceso a una credencial vuelve a pasar por tu mano, que es la
31
+ * protección por la que esto existe. No se le busque un sustituto del comando para que
32
+ * entre en la ventana.
33
+ */
34
+ import { commandFingerprint } from '../procContext.js'
35
+
36
+ const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, '0')).join('')
37
+
38
+ /** Separador de la llave compuesta. Es un byte que no puede aparecer en un cajón ni en una pubkey. */
39
+ const SEP = String.fromCharCode(0)
40
+
41
+ /**
42
+ * LO QUE DURA UN PEDIDO SIN CONTESTAR, y por qué son dos números y no uno.
43
+ *
44
+ * CINCO MINUTOS es lo correcto para un COMANDO: hay un proceso bloqueado esperando, y
45
+ * aprobar media hora más tarde entregaría claves a algo cuyo contexto ya cambió — la
46
+ * carpeta, el comando o la máquina pueden ser otros. Que caduque rápido es la protección.
47
+ *
48
+ * UNA ACTUALIZACIÓN no se parece en nada (dueño, 2026-09-20): no hay nadie esperando, y
49
+ * caducar no protege de nada — solo significa que mañana se vuelve a pedir y se vuelve a
50
+ * perder, porque mirar el teléfono puede tardar horas. Un pedido que nunca llega a tiempo
51
+ * es una función que no existe.
52
+ *
53
+ * Por eso el plazo es DEL PEDIDO y no de la mesa. Quien pide dice cuánto aguanta lo suyo.
54
+ */
55
+ export const PENDING_TTL_MS = 5 * 60 * 1000
56
+ /**
57
+ * Y el de una actualización: un día. Cuadra con que la bóveda mire una vez al día — pedir
58
+ * otra vez reemplaza el pedido anterior del mismo cajón y aparato, así que siempre hay
59
+ * exactamente uno vivo, en vez de una cola de avisos caducados.
60
+ */
61
+ export const UPDATE_TTL_MS = 24 * 60 * 60 * 1000
62
+ /** Lo que dura una concesión SIN USARSE. Cada uso la vuelve a poner en una hora. */
63
+ export const GRANT_TTL_MS = 60 * 60 * 1000
64
+
65
+ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS } = {}) {
66
+ /** id → { id, ns, device, deviceId, label, ek, ctx, ts, exp, from } */
67
+ const pending = new Map()
68
+ /**
69
+ * EL CONTEXTO VA EN LA LISTA, Y QUIEN LA SACA POR LA RED LO ENVUELVE.
70
+ *
71
+ * `ctx` es el comando y el path del dueño: por el mostrador local y en la consola de su
72
+ * propia máquina se enseña tal cual, pero al teléfono viaja por el proxio y ahí va dentro
73
+ * de un sobre (`handleApproval`). Si mañana aparece otro sitio que publique esta lista,
74
+ * esto es lo que hay que recordar.
75
+ */
76
+ // `kind` dice QUÉ se pide: `read` (llevarse las claves, lo de siempre) o `write` (guardar
77
+ // variables). La pantalla del teléfono no puede decir «pide tus claves» a una escritura.
78
+ const publicOf = (p) => ({ id: p.id, ns: p.ns, kind: p.kind, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp, ctx: p.ctx || null })
79
+
80
+ return {
81
+ /** Apunta un pedido. Uno por cajón y aparato: pedir otra vez reemplaza al anterior. */
82
+ /**
83
+ * `from` es POR DÓNDE ENTRÓ la pregunta. La respuesta llega más tarde —cuando alguien
84
+ * apruebe— y tiene que volver por el mismo sitio: desde que la bóveda atiende también
85
+ * por un socket local, mandarla siempre por el proxio la dejaba en el vacío.
86
+ */
87
+ request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null, ttlMs = null, kind = 'read' }) {
88
+ // Pedir las claves otra vez reemplaza al pedido anterior: es la misma pregunta. Una
89
+ // ESCRITURA no: cada una lleva su propio valor, y reemplazarla tiraría la anterior
90
+ // sin que nadie dijera que no.
91
+ if (kind !== 'write') for (const [id, p] of pending) if (p.kind !== 'write' && p.ns === ns && p.device === device) pending.delete(id)
92
+ const ts = now()
93
+ // `ttlMs` lo pone quien pide: una actualización aguanta un día y un comando cinco
94
+ // minutos, y la mesa no tiene por qué saber cuál es cuál (ver `PENDING_TTL_MS`).
95
+ const p = { id: rnd(), ns, kind, device, deviceId, label, ek, ctx, ts, exp: ts + (ttlMs || pendingTtlMs), from }
96
+ pending.set(p.id, p)
97
+ return publicOf(p)
98
+ },
99
+ /** Los pedidos vivos, sin la `ek` (no hace falta fuera de aquí). */
100
+ list () { this.sweep(); return [...pending.values()].map(publicOf) },
101
+ /** Saca un pedido para resolverlo (aprobar o denegar). `null` si no existe o venció. */
102
+ take (id) {
103
+ this.sweep()
104
+ const p = pending.get(id)
105
+ if (p) pending.delete(id)
106
+ return p || null
107
+ },
108
+ /** Tira lo vencido; devuelve los pedidos que vencieron, para anotarlos. */
109
+ sweep () {
110
+ const t = now(); const gone = []
111
+ for (const [id, p] of pending) if (p.exp <= t) { pending.delete(id); gone.push(publicOf(p)) }
112
+ return gone
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * LAS CONCESIONES VIVAS: «este comando, desde esta carpeta, ya está aprobado».
119
+ *
120
+ * La llave es cajón + aparato + huella del comando. Nada de esto se escribe en disco.
121
+ */
122
+ export function createGrants ({ now = Date.now, ttlMs = GRANT_TTL_MS } = {}) {
123
+ /** key → { id, ns, device, deviceId, fp, ctx, by, grantedAt, lastAt, exp, uses } */
124
+ const grants = new Map()
125
+ const keyOf = (ns, device, fp) => ns + SEP + device + SEP + fp
126
+ const publicOf = (g) => ({
127
+ id: g.id, ns: g.ns, deviceId: g.deviceId, fp: g.fp, ctx: g.ctx,
128
+ by: g.by, grantedAt: g.grantedAt, lastAt: g.lastAt, exp: g.exp, uses: g.uses
129
+ })
130
+
131
+ const api = {
132
+ /**
133
+ * Anota que este comando quedó aprobado. Devuelve la concesión, o `null` si el pedido no
134
+ * traía comando: sin huella no hay nada que recordar, y el próximo pedido volverá a
135
+ * timbrar. Que eso se diga en el log es cosa de quien llama.
136
+ */
137
+ grant ({ ns, device, deviceId = null, ctx = null, by = null }) {
138
+ const fp = commandFingerprint(ctx)
139
+ if (!fp) return null
140
+ const t = now()
141
+ const g = { id: rnd(), ns, device, deviceId, fp, ctx, by, grantedAt: t, lastAt: t, exp: t + ttlMs, uses: 0 }
142
+ grants.set(keyOf(ns, device, fp), g)
143
+ return publicOf(g)
144
+ },
145
+ /**
146
+ * ¿Está este comando ya aprobado? Si lo está, **la concesión se estira**: la hora cuenta
147
+ * desde el último uso, no desde que se dio. Devuelve la concesión (ya refrescada) o `null`.
148
+ */
149
+ allows ({ ns, device, ctx }) {
150
+ api.sweep()
151
+ const fp = commandFingerprint(ctx)
152
+ if (!fp) return null
153
+ const g = grants.get(keyOf(ns, device, fp))
154
+ if (!g) return null
155
+ const t = now()
156
+ g.lastAt = t
157
+ g.exp = t + ttlMs
158
+ g.uses += 1
159
+ return publicOf(g)
160
+ },
161
+ /** Las concesiones vivas. */
162
+ list () { api.sweep(); return [...grants.values()].map(publicOf) },
163
+ /** Corta una por su id. `true` si existía. */
164
+ revoke (id) {
165
+ for (const [k, g] of grants) if (g.id === id) { grants.delete(k); return true }
166
+ return false
167
+ },
168
+ /** Corta todas, o las de un cajón / un aparato. Devuelve cuántas se fueron. */
169
+ revokeAll ({ ns = null, device = null } = {}) {
170
+ let n = 0
171
+ for (const [k, g] of grants) {
172
+ if (ns && g.ns !== ns) continue
173
+ if (device && g.device !== device) continue
174
+ grants.delete(k); n++
175
+ }
176
+ return n
177
+ },
178
+ /** Tira lo vencido; devuelve lo que se fue, para anotarlo. */
179
+ sweep () {
180
+ const t = now(); const gone = []
181
+ for (const [k, g] of grants) if (g.exp <= t) { grants.delete(k); gone.push(publicOf(g)) }
182
+ return gone
183
+ }
184
+ }
185
+ return api
186
+ }
187
+
188
+ /**
189
+ * ¿SE LLEVÓ UNA LIMPIEZA AL ÚLTIMO QUE APROBABA? Devuelve los aprobadores que se fueron si
190
+ * antes había alguno y ahora no queda ninguno; si no, una lista vacía.
191
+ *
192
+ * Una cuenta SIN aprobadores puede ser legítima (todo desatendido, se actualiza sola), así
193
+ * que el aviso no es «no hay aprobadores»: es «LOS HABÍA y acabas de perder el último».
194
+ *
195
+ * @param {string[]} before llaves con `approve` antes
196
+ * @param {string[]} after llaves con `approve` después
197
+ * @param {string[]} removed llaves que salieron del acta
198
+ */
199
+ export function lastApproverGone (before, after, removed) {
200
+ if (!before.length || after.length) return []
201
+ return removed.filter((pub) => before.includes(pub))
202
+ }
203
+
204
+ export default { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, UPDATE_TTL_MS, GRANT_TTL_MS }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * LOS APARATOS BLOQUEADOS: la lista que un aprobador decide y solo la bóveda deshace.
3
+ *
4
+ * Un agente (la terminal, por ahora) reporta un INCIDENTE cuando un aparato de la cuenta se
5
+ * equivoca de clave tres veces seguidas. El aprobador lo ve en su teléfono y elige: bloquear
6
+ * o ignorar. Bloquear NO es revocar: el aparato sigue en el acta —no hace falta la maestra, y
7
+ * la bóveda vive cerrada—, pero la bóveda deja de atenderle y los agentes, que reciben esta
8
+ * lista con el acta, le cierran la puerta. Se deshace aquí (`dotrino-vault unblock <ID>`) o
9
+ * desde la consola de administración; un aprobador solo bloquea.
10
+ *
11
+ * Va en el disco cifrado en reposo como las contraseñas: es parte del mapa de la cuenta.
12
+ * Se carga UNA vez: un archivo que existe y no se puede leer NO es «nadie bloqueado» — se
13
+ * lanza, y la bóveda no arranca con una lista que no puede saber.
14
+ */
15
+
16
+ export const BLOCKED_FILE = 'blocked.json'
17
+
18
+ const fail = (code, message) => Object.assign(new Error(message), { code })
19
+
20
+ /**
21
+ * @param {{ disk: object, now?: () => number }} opts
22
+ */
23
+ export function openBlocked ({ disk, now = Date.now }) {
24
+ const file = BLOCKED_FILE
25
+ const atRest = disk.atRest
26
+ /** pub → { deviceId, label, since, by, reason } */
27
+ let items = load()
28
+
29
+ function load () {
30
+ let raw
31
+ try { raw = disk.read(file) } catch (e) {
32
+ throw fail('blocked-unreadable', `cannot read ${BLOCKED_FILE}: ${e.message}`)
33
+ }
34
+ if (raw == null) return new Map()
35
+ let d
36
+ try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('blocked-unreadable', `${BLOCKED_FILE} cannot be opened: ${e.message}`) }
37
+ if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('blocked-unreadable', `${BLOCKED_FILE} has an unknown shape`)
38
+ return new Map(Object.entries(d.items))
39
+ }
40
+
41
+ function save () {
42
+ disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })))
43
+ }
44
+
45
+ const publicOf = (pub, b) => ({ pub, deviceId: b.deviceId || null, label: b.label || '', since: b.since, by: b.by || null, reason: b.reason || null })
46
+
47
+ return {
48
+ has: (pub) => items.has(pub),
49
+ pubs: () => [...items.keys()],
50
+ list: () => [...items].map(([pub, b]) => publicOf(pub, b)),
51
+ /** Bloquea. Devuelve la entrada; si ya estaba, la que había (no se pisa el «desde cuándo»). */
52
+ block ({ pub, deviceId = null, label = '', by = null, reason = null }) {
53
+ if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'block: a device key is required')
54
+ if (items.has(pub)) return publicOf(pub, items.get(pub))
55
+ const b = { deviceId, label, since: now(), by, reason }
56
+ items.set(pub, b)
57
+ save()
58
+ return publicOf(pub, b)
59
+ },
60
+ /** Desbloquea. `true` si estaba. */
61
+ unblock (pub) {
62
+ if (!items.has(pub)) return false
63
+ items.delete(pub)
64
+ save()
65
+ return true
66
+ }
67
+ }
68
+ }