@dotrino/identity 0.108.0 → 0.110.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.108.0",
3
+ "version": "0.110.0",
4
4
  "description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.d.ts CHANGED
@@ -187,9 +187,9 @@ export class Identity {
187
187
  * abierto: viaja sellado a la llave de cifrado de este aparato y se descifra aquí dentro.
188
188
  * `ctxError` si no se pudo abrir; sin ninguno de los dos, el pedido no dijo qué corría.
189
189
  */
190
- vaultApprovals (op: 'approvals' | 'approve' | 'deny', args?: { id?: string; profile?: string }): Promise<any>
190
+ vaultApprovals (op: 'approvals' | 'approve' | 'deny', args?: { id?: string; profile?: string; notify?: boolean }): Promise<any>
191
191
  /** Los pedidos de TODAS las cuentas de este dispositivo que aprueban, sin cambiar la activa. */
192
- vaultApprovalsAll (): Promise<Array<{ profile: string; name: string; current: boolean; items: ApprovalRequest[]; error?: string }>>
192
+ vaultApprovalsAll (args?: { notify?: boolean }): Promise<Array<{ profile: string; name: string; current: boolean; items: ApprovalRequest[]; error?: string }>>
193
193
  canApproveVault (): Promise<boolean>
194
194
  getVaultCert (): Promise<any>
195
195
  onVault (handler: (payload: any) => void): () => void
package/src/index.js CHANGED
@@ -476,6 +476,10 @@ export class Identity {
476
476
  * PEDIDOS DE APROBACIÓN de la bóveda (cajones con `approval`): `op` = `approvals` ·
477
477
  * `approve` · `deny` (estos dos con `{ id }`). Requiere `vault:approve` en el cert.
478
478
  *
479
+ * `args.notify` (solo con `approvals`): `true`/`false` según a este aparato le lleguen los
480
+ * avisos de pedidos. La bóveda lo apunta, y para actualizarse solo pide permiso si hay un
481
+ * aprobador que pueda enterarse. No pasarlo no cambia lo ya dicho.
482
+ *
479
483
  * `args.profile` apunta a OTRA cuenta de este dispositivo (la que diga
480
484
  * `vaultApprovalsAll`); sin él es la activa.
481
485
  */
@@ -494,8 +498,8 @@ export class Identity {
494
498
  *
495
499
  * Aprobar uno de otra cuenta: `vaultApprovals('approve', { id, profile })`.
496
500
  */
497
- async vaultApprovalsAll () {
498
- return this._call('vaultApprovalsAll', {}, 30000)
501
+ async vaultApprovalsAll (args) {
502
+ return this._call('vaultApprovalsAll', args || {}, 30000)
499
503
  }
500
504
 
501
505
  /** Registra el token de push de la app nativa (FCM/APNs) bajo la llave de este aparato. */
package/src/node.js CHANGED
@@ -263,7 +263,7 @@ export class Identity {
263
263
  getVaultCert () { return this._h('getVaultCert') }
264
264
  vaultApprovals (op, args) { return this._h('vaultApprovals', { op, ...(args || {}) }) }
265
265
  /** Los pedidos de TODAS las cuentas que aprueban, sin cambiar la activa. */
266
- vaultApprovalsAll () { return this._h('vaultApprovalsAll') }
266
+ vaultApprovalsAll (args) { return this._h('vaultApprovalsAll', args || {}) }
267
267
  onVault (handler) { return this.on('vault', handler) }
268
268
  // Multi-perfil por dispositivo (crear/cambiar reinicializa con el nuevo perfil activo).
269
269
  listProfiles () { return this._h('listProfiles') }
package/vault/core.js CHANGED
@@ -1121,9 +1121,22 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
1121
1121
  // se hace al generar una propia — quién puede hacer qué con esta llave lo decide esta
1122
1122
  // casa, no una etiqueta que viajó dentro del paquete.
1123
1123
  const sinTopes = (jwk) => { const { key_ops: _o, ...resto } = jwk || {}; return resto }
1124
- await adoptJwkPair('sign', KEY_STORAGE, sinTopes(entrada.keys.sign), JSON.parse(entrada.publickey))
1125
- if (entrada.keys.enc && entrada.encPublickey) {
1126
- await adoptJwkPair('enc', ENC_KEY_STORAGE, sinTopes(entrada.keys.enc), JSON.parse(entrada.encPublickey))
1124
+ if (keyStore?.importPair) {
1125
+ // DENTRO DE UNA APP NATIVA: la llave que entrega la bóveda la guarda la app (cifrada
1126
+ // con una llave del chip) y firma ella. Van las dos mitades juntas, bajo un mismo id.
1127
+ if (!entrada.keys.enc || !entrada.encPublickey) {
1128
+ throw Object.assign(new Error('this login has no encryption key: the phone needs both'), { code: 'native-import-needs-enc' })
1129
+ }
1130
+ await keyStore.importPair({
1131
+ sign: { name: _scoped(KEY_STORAGE), privateJwk: sinTopes(entrada.keys.sign), publickey: entrada.publickey },
1132
+ enc: { name: _scoped(ENC_KEY_STORAGE), privateJwk: sinTopes(entrada.keys.enc), publickey: entrada.encPublickey }
1133
+ })
1134
+ kv.removeItem(KEY_STORAGE); kv.removeItem(ENC_KEY_STORAGE)
1135
+ } else {
1136
+ await adoptJwkPair('sign', KEY_STORAGE, sinTopes(entrada.keys.sign), JSON.parse(entrada.publickey))
1137
+ if (entrada.keys.enc && entrada.encPublickey) {
1138
+ await adoptJwkPair('enc', ENC_KEY_STORAGE, sinTopes(entrada.keys.enc), JSON.parse(entrada.encPublickey))
1139
+ }
1127
1140
  }
1128
1141
  saveActa(entrada.acta)
1129
1142
  kv.setItem(ACTA_HISTORY_STORAGE, '[]')
@@ -1147,6 +1160,18 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
1147
1160
  list.push({ id: pid, name: entrada.user, pubkey: publickeyJwkStr })
1148
1161
  saveProfiles(list)
1149
1162
  emitVault({ phase: 'login', address: entrada.address, volatile: !remember })
1163
+ // En una app nativa, la cuenta pasa también a su pantalla de Pedidos, con el mismo papel
1164
+ // (como al emparejar). Si la app no la guarda, se dice: la entrada en sí ya está hecha.
1165
+ if (keypair?.privateKey?.external) {
1166
+ keypair.privateKey.paired({
1167
+ cert: entrada.cert, vault: entrada.iss, proxy: proxy || DEFAULT_PROXY,
1168
+ deviceId: await deviceIdDe(entrada.publickey),
1169
+ name: entrada.acta?.name || entrada.address, profileId: entrada.acta?.profileId || null
1170
+ }).then(
1171
+ () => emitVault({ phase: 'native-account', ok: true }),
1172
+ (e) => { console.warn('[identity] the phone did not save the account: ' + e.message); emitVault({ phase: 'native-account', ok: false, code: e.code || 'native-error', reason: e.message }) }
1173
+ )
1174
+ }
1150
1175
  return { id: pid, name: entrada.user, address: entrada.address, user: entrada.user, sid: entrada.sid, volatile: !remember }
1151
1176
  } catch (e) {
1152
1177
  // NADA DE MEDIAS CUENTAS: si algo falla, no se queda una identidad a medio adoptar en
@@ -3057,12 +3082,12 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
3057
3082
  * `profile` apunta a OTRA cuenta de este mismo dispositivo (ver `vaultApprovalsAll`):
3058
3083
  * sin él es la activa, como siempre.
3059
3084
  */
3060
- async vaultApprovals ({ op, id, profile = null } = {}) {
3085
+ async vaultApprovals ({ op, id, profile = null, notify } = {}) {
3061
3086
  if (profile && profile !== currentPid) return approvalsDeOtroPerfil(profile, { op, id })
3062
3087
  const v = loadVaultCert(); const device = loadVaultDevice()
3063
3088
  if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
3064
3089
  maybeRenewVaultCert()
3065
- const ask = (cert) => remoteApproval({ master: v.master, proxy: v.proxy, device, cert, op, id, onRevoked: wipeVaultLink })
3090
+ const ask = (cert) => remoteApproval({ master: v.master, proxy: v.proxy, device, cert, op, id, notify, onRevoked: wipeVaultLink })
3066
3091
  try {
3067
3092
  let r
3068
3093
  try { r = await ask(v.cert) } catch (e) {
@@ -3104,7 +3129,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
3104
3129
  * @returns {Promise<Array<{ profile, name, items, error? }>>} una entrada por cuenta que
3105
3130
  * aprueba — con sus pedidos, o con el motivo por el que no se pudo preguntar.
3106
3131
  */
3107
- async vaultApprovalsAll () {
3132
+ async vaultApprovalsAll ({ notify } = {}) {
3108
3133
  // La activa sí se pone al día: es la única en la que esta pantalla puede escribir.
3109
3134
  try { maybeRenewVaultCert() } catch (_) {}
3110
3135
  const perfiles = loadProfiles()
@@ -3119,7 +3144,9 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
3119
3144
  }
3120
3145
  if (!device) return { ...base, items: [], error: 'no-key' }
3121
3146
  try {
3122
- const r = await remoteApproval({ master: v.master, proxy: v.proxy, device, cert: v.cert, op: 'approvals' })
3147
+ // `notify` habla de la cuenta ACTIVA: los avisos se suscriben bajo SU llave. De las
3148
+ // demás no se dice nada, que no es lo mismo que decir que no.
3149
+ const r = await remoteApproval({ master: v.master, proxy: v.proxy, device, cert: v.cert, op: 'approvals', ...(p.id === currentPid ? { notify } : {}) })
3123
3150
  const items = Array.isArray(r?.items) ? await abrirContextos(r.items, p.id) : []
3124
3151
  return { ...base, items }
3125
3152
  } catch (e) {
@@ -78,6 +78,21 @@ export function withExternalKeys (keyStore, bridge) {
78
78
  const other = sib ? await keyStore.get(sib) : null
79
79
  if (rec?.external && !(other?.external === rec.external)) await bridge.call('remove', { kid: rec.external })
80
80
  },
81
+ /**
82
+ * UNA LLAVE QUE YA EXISTE (entrar con usuario y contraseña): la bóveda la entrega y no se
83
+ * puede meter en el chip, así que la app la guarda cifrada con una llave del chip y firma
84
+ * ella. Desde aquí es una llave externa más: la página no se queda con la privada.
85
+ * `sign` y `enc`: `{ name, privateJwk, publickey }`, con la pública TAL CUAL la escribe el
86
+ * acta (es la cadena con la que esa llave es miembro).
87
+ */
88
+ async importPair ({ sign, enc }) {
89
+ const k = await bridge.call('import', {
90
+ sign: sign.privateJwk, enc: enc.privateJwk, publickey: sign.publickey, encPub: enc.publickey
91
+ })
92
+ if (!k?.kid) throw Object.assign(new Error('the phone did not keep the key'), { code: 'native-no-key' })
93
+ await keyStore.set(sign.name, { external: k.kid, kind: 'sign', publicJwk: JSON.parse(sign.publickey) })
94
+ await keyStore.set(enc.name, { external: k.kid, kind: 'enc', publicJwk: JSON.parse(enc.publickey) })
95
+ },
81
96
  async create (kind, name) {
82
97
  // Las dos mitades del perfil van bajo la MISMA llave del chip: si la otra ya nació
83
98
  // allí, se reutiliza; si no, se crea una nueva.
@@ -28,8 +28,9 @@ function plainKeyRecord (rec) {
28
28
  if (rec && typeof rec.external === 'string' && rec.publicJwk && (rec.kind === 'sign' || rec.kind === 'enc')) {
29
29
  return { external: rec.external, kind: rec.kind, publicJwk: rec.publicJwk }
30
30
  }
31
- // Una llave de SOFTWARE (adoptar un perfil, entrar con contraseña) no cabe en el chip, y
32
- // guardarla a medias sería perder la cuenta sin decirlo. Se para aquí y se dice.
31
+ // Una llave de SOFTWARE no se guarda aquí: guardarla a medias sería perder la cuenta sin
32
+ // decirlo. La de entrar con contraseña no llega a este punto: la guarda la app, cifrada con
33
+ // una llave del chip (`importPair` de `externalKeys.js`), y aquí queda su registro externo.
33
34
  throw Object.assign(new Error('this app keeps keys in the phone chip only: a software key cannot be stored here'), { code: 'native-no-import' })
34
35
  }
35
36
 
package/vault/remote.js CHANGED
@@ -452,11 +452,16 @@ export async function requestAdmin ({ master, proxy, device, cert, op, onRevoked
452
452
  * `vault:approve` — que, como `admin`, no se recibe al emparejar: se concede a mano.
453
453
  * La bóveda contesta un cuerpo firmado por la maestra (`{ op, items | ok }`).
454
454
  */
455
- export async function requestApproval ({ master, proxy, device, cert, op, id, onRevoked } = {}) {
455
+ export async function requestApproval ({ master, proxy, device, cert, op, id, notify, onRevoked } = {}) {
456
+ // `notify`: al pedir la lista, este aparato dice si le llegan los avisos. La bóveda lo
457
+ // apunta y es lo que mira para saber si hay a quién pedirle permiso antes de actualizarse.
458
+ // Solo va si quien llama lo SABE (true o false): no decir nada no cambia lo ya dicho.
459
+ const data = id ? { op, id } : { op }
460
+ if (op === 'approvals' && typeof notify === 'boolean') data.notify = notify
456
461
  const res = await vaultRpc({
457
462
  master, proxy, device, cert, onRevoked,
458
463
  sendType: MSG.SECRETS, okType: MSG.SECRETS_RESULT,
459
- data: id ? { op, id } : { op }
464
+ data
460
465
  })
461
466
  return res.body
462
467
  }
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.28.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc,stats}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.30.4 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc,stats,stats-log}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  sealing.js resuelve @dotrino/identity/content de forma PEREZOSA (= ../../content.js
4
4
  por el import map): solo se carga si de verdad se sella algo.
@@ -545,6 +545,27 @@ export class WebSocketProxyClient {
545
545
  this.send(tokens, sobre)
546
546
  }
547
547
 
548
+ /**
549
+ * Mandar POR TOKEN algo que la app YA CIFRÓ por su cuenta (un canal de sesión propio,
550
+ * como el de `@dotrino/remote-agent`), con la misma red que `sendSealedTo`: sube al
551
+ * canal directo si lo hay, y si el token murió el mismo mensaje sale por `peerPubkey`
552
+ * y se avisa con `token_gone`.
553
+ *
554
+ * Existe porque `sendByPubkey` NUNCA sube de escalón —la cola es del proxio— y `send`
555
+ * a secas pierde en silencio lo que va a un token que ya no existe. Con `requireSealed`
556
+ * se niega igual que los demás: esto no abre una puerta para ir en claro.
557
+ *
558
+ * @param {string} token
559
+ * @param {any} payload
560
+ * @param {{ peerPubkey:string }} opts
561
+ */
562
+ sendToOrQueue (token, payload, { peerPubkey } = /** @type {any} */ ({})) {
563
+ if (!token || !peerPubkey) {
564
+ throw errorCon('sendToOrQueue: needs the token and whose it is (peerPubkey)', 'no-peer-identity')
565
+ }
566
+ this._sendToTokenOrQueue(token, payload, peerPubkey)
567
+ }
568
+
548
569
  /**
549
570
  * UN TOKEN MUERTO NO SE TRAGA EL MENSAJE. Un token es una conexión, y cuando la otra punta
550
571
  * reinicia la app su token deja de existir; la app que lo tenía apuntado sigue mandando ahí
@@ -11,6 +11,7 @@ export {
11
11
  } from './encpub.js'
12
12
 
13
13
  export { listTransports, routeOf } from './stats.js'
14
+ export { logStats, formatStats } from './stats-log.js'
14
15
 
15
16
  import { WebSocketProxyClient } from './client.js'
16
17
 
@@ -0,0 +1,107 @@
1
+ /**
2
+ * LAS ESTADÍSTICAS DE RED, AL LOG. Para lo que no tiene pantalla: un daemon, un agente, un
3
+ * servicio bajo pm2.
4
+ *
5
+ * En el navegador las enseña el topbar (su modal de red lee el registro de clientes). Un
6
+ * proceso de Node no tiene dónde, así que «¿esto va por WebRTC o sigue dando la vuelta por
7
+ * el proxio?» solo se contestaba con una prueba en contenedores. Esto escribe lo mismo que
8
+ * pinta el modal, una línea cada tanto, con el `log` que el servicio ya usa.
9
+ *
10
+ * Lee el MISMO registro que el topbar (`listTransports`), así que el servicio no le pasa
11
+ * sus clientes: los que haya en el proceso, salen.
12
+ *
13
+ * Tres cosas a propósito:
14
+ * - Solo escribe si algo cambió. Un servicio quieto no llena el log de líneas iguales.
15
+ * - Los contadores son ACUMULADOS desde `since`: dos líneas se restan, y una sola ya dice
16
+ * si hubo tráfico directo alguna vez.
17
+ * - De la otra punta solo sale el TOKEN, que es efímero. La pubkey no: con quién habla
18
+ * una máquina es justo lo que no se deja escrito en un disco.
19
+ */
20
+ import { listTransports, PATHS } from './stats.js'
21
+
22
+ const sum = (peers, dir) => {
23
+ const t = { proxy: 0, direct: 0, turn: 0, webrtc: 0 }
24
+ for (const p of peers) for (const k of PATHS) t[k] += p[dir][k]
25
+ return t
26
+ }
27
+
28
+ const pairs = (prefix, o) => PATHS.map((k) => `${prefix}.${k}=${o[k]}`).join(' ')
29
+
30
+ /**
31
+ * Las líneas de UN cliente, a partir de lo que devuelve `client.stats()`.
32
+ * La primera es el resumen; después, una por conexión.
33
+ *
34
+ * @param {object} s lo que devuelve `client.stats()` (`TransportStats` en los tipos)
35
+ * @param {{ label?: string }} [opts]
36
+ * @returns {string[]}
37
+ */
38
+ export function formatStats (s, opts = {}) {
39
+ const tag = opts.label ? `[net] ${opts.label}` : '[net]'
40
+ // QUIÉN CONTESTÓ Y QUIÉN NO. Un sondeo (un ping a cada aparato del acta) deja una entrada
41
+ // por destinatario aunque nadie responda: esas no son conexiones y van resumidas
42
+ // (`unanswered`), igual que en el informe del topbar y de las apps nativas.
43
+ // `unknown`: quien nos escribió, nunca recibió respuesta y no sabemos su llave (otro aparato
44
+ // sondeando qué máquinas están encendidas). Tampoco es una conexión.
45
+ const isUnknown = (p) => p.msgsIn > 0 && !(p.msgsOut > 0) && !p.pubkey
46
+ const unknown = s.peers.filter(isUnknown)
47
+ const talking = s.peers.filter((p) => p.msgsIn > 0 && !isUnknown(p))
48
+ const silent = s.peers.filter((p) => !(p.msgsIn > 0))
49
+ const routes = {}
50
+ for (const p of talking) routes[p.route] = (routes[p.route] || 0) + 1
51
+ const routeList = Object.keys(routes).sort().map((r) => `${r}=${routes[r]}`).join(',') || 'none'
52
+ const lines = [
53
+ `${tag} url=${s.url} connected=${s.connected ? 'yes' : 'no'} webrtc=${s.webrtc ? 'on' : 'off'}` +
54
+ ` ws.in=${s.proxy.bytesIn} ws.out=${s.proxy.bytesOut}` +
55
+ ` ${pairs('in', sum(s.peers, 'bytesIn'))} ${pairs('out', sum(s.peers, 'bytesOut'))}` +
56
+ ` peers=${talking.length} routes=${routeList}` +
57
+ (silent.length ? ` unanswered=${silent.length} unanswered.out=${silent.reduce((n, p) => n + PATHS.reduce((m, k) => m + p.bytesOut[k], 0), 0)}` : '') +
58
+ (unknown.length ? ` unknown=${unknown.length} unknown.in=${unknown.reduce((n, p) => n + PATHS.reduce((m, k) => m + p.bytesIn[k], 0), 0)}` : '')
59
+ ]
60
+ for (const p of talking) {
61
+ lines.push(
62
+ `${tag} peer=${p.token || 'by-key'} route=${p.route}` +
63
+ ` ${pairs('in', p.bytesIn)} ${pairs('out', p.bytesOut)} msgs.in=${p.msgsIn} msgs.out=${p.msgsOut}`
64
+ )
65
+ }
66
+ return lines
67
+ }
68
+
69
+ /**
70
+ * Escribe las estadísticas de todos los clientes del proceso cada `everyMs`, si cambiaron.
71
+ *
72
+ * @param {{ log?: (line: string) => void, everyMs?: number, label?: string }} [opts]
73
+ * @returns {{ stop: () => Promise<void>, flush: () => Promise<void> }}
74
+ * `flush` escribe ya (si cambió algo); `stop` escribe una última vez y para.
75
+ */
76
+ export function logStats (opts = {}) {
77
+ const log = opts.log || ((line) => console.log(line))
78
+ const everyMs = opts.everyMs || 5 * 60 * 1000
79
+ /** cliente → lo último que se escribió de él */
80
+ const last = new WeakMap()
81
+
82
+ const flush = async () => {
83
+ for (const client of listTransports()) {
84
+ if (typeof client.stats !== 'function') continue
85
+ let lines
86
+ try {
87
+ lines = formatStats(await client.stats(), opts)
88
+ } catch (e) {
89
+ // No poder leer NO es «no hubo tráfico»: se dice, para que no parezca silencio.
90
+ lines = [`${opts.label ? `[net] ${opts.label}` : '[net]'} could not read stats: ${e?.message || e}`]
91
+ }
92
+ const text = lines.join('\n')
93
+ if (last.get(client) === text) continue
94
+ last.set(client, text)
95
+ for (const line of lines) log(line)
96
+ }
97
+ }
98
+
99
+ const timer = setInterval(() => { flush() }, everyMs)
100
+ // El log no puede ser lo que mantiene vivo un proceso que ya terminó.
101
+ if (typeof timer.unref === 'function') timer.unref()
102
+
103
+ return {
104
+ flush,
105
+ stop: async () => { clearInterval(timer); await flush() }
106
+ }
107
+ }
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.79.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
1
+ Copia vendorizada de @dotrino/vault@0.82.1 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  index.js importa ./enroll.js y ./protocol.js (relativos, van en esta misma copia),
4
4
  @dotrino/identity/{capabilities,acta} (= ../../{capabilities,acta}.js) y
@@ -103,6 +103,13 @@ export const MSG = Object.freeze({
103
103
  // una forma de echar a alguien de su cuenta.
104
104
  LOGIN_CLOSE: 'vault.login.close', // aparato → vault: { data:{op,user,sid,publickey,ts}, signature }
105
105
  LOGIN_CLOSED: 'vault.login.closed', // vault → aparato: { ok }
106
+ // INCIDENTE: un agente de la cuenta (la terminal) avisa de que OTRO aparato de la cuenta
107
+ // se equivocó de clave tres veces seguidas. La bóveda lo apunta como un pedido más
108
+ // (`kind: 'incident'`), timbra a quien aprueba, y el aprobador bloquea o ignora. Bloquear
109
+ // no toca el acta: es una lista de la bóveda (`blocked.json`) que viaja a los agentes con
110
+ // `vault.devices.result` (`blocked`) y se deshace con `dotrino-vault unblock <ID>`.
111
+ INCIDENT: 'vault.incident', // agente → vault: { data:{op:'incident',kind,about,tries,publickey,ts}, signature, cert }
112
+ INCIDENT_RESULT: 'vault.incident.result', // vault → agente: { id, approvers, blocked }
106
113
  ERROR: 'vault.error' // vault → dispositivo: { error }
107
114
  })
108
115