@dotrino/identity 0.121.1 → 0.121.3

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.121.1",
3
+ "version": "0.121.3",
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/vault/remote.js CHANGED
@@ -109,13 +109,27 @@ async function redeemCode (client, code) {
109
109
  return r.instance
110
110
  }
111
111
 
112
+ /**
113
+ * ¿LO DICE MI BÓVEDA, DE ESTA SESIÓN? Lo que va a la cuenta lo reciben todas sus bóvedas y
114
+ * cualquiera que lo vea pasar puede contestar. Un reto o un error solo cuentan firmados con la
115
+ * maestra de la invitación (`iss`) y con el `sn` de esta sesión (2026-10-18: sin esto, un
116
+ * `vault.error` de cualquiera tumbaba el alta). Mismo contrato que `fromMyVault` en
117
+ * `@dotrino/vault/service`.
118
+ */
119
+ async function fromMyVault (p, qr) {
120
+ const b = p?.signed
121
+ if (!p?.vsig || !b || typeof b !== 'object' || !qr?.iss) return false
122
+ if (b.iss !== qr.iss || b.sn !== qr.sn || b.type !== p.type) return false
123
+ try { return await verifyDeviceSig({ publickey: qr.iss, data: b, signature: p.vsig }) } catch (_) { return false }
124
+ }
125
+
112
126
  /** «¿Quién eres?» del QR corto: devuelve `{ iss, proxy, acct, m }` de la bóveda. */
113
127
  async function askVault (client, qr) {
114
128
  const target = await redeemCode(client, qr.conn)
115
129
  return new Promise((resolve, reject) => {
116
130
  const off = client.on('message', (_from, p) => {
131
+ // Un error aquí no se puede verificar (aún no se sabe quién es la bóveda): no cuenta.
117
132
  if (p?.type === MSG.HELLO_OK) { done(); verifyHello(p, qr.sn).then((b) => resolve({ iss: b.iss, proxy: b.proxy || qr.proxy, acct: b.acct || '', m: b.m || qr.m }), reject) }
118
- else if (p?.type === MSG.ERROR) { done(); reject(new Error(p.error)) }
119
133
  })
120
134
  const t = setTimeout(() => { done(); reject(new Error('the vault did not answer: that code may have expired')) }, 15000)
121
135
  const done = () => { off(); clearTimeout(t) }
@@ -179,8 +193,9 @@ export async function enrollDevice ({ qr, device, onChallenge, label = '', conti
179
193
 
180
194
  const enrolled = new Promise((resolve, reject) => {
181
195
  let sellando = false
182
- const off = client.on('message', (_from, p) => {
196
+ const off = client.on('message', async (_from, p) => {
183
197
  if (!p || typeof p !== 'object') return
198
+ if ((p.type === MSG.ENROLL_CHALLENGE || p.type === MSG.ERROR) && !await fromMyVault(p, qr)) return
184
199
  if (p.type === MSG.ENROLL_CHALLENGE) { try { onChallenge?.({ deviceId, code }) } catch (_) {} }
185
200
  // El vault ECHA el código que tipeaste; aceptamos SOLO si coincide con el que generamos.
186
201
  // (Un código distinto = un vault que no lo conoce → lo ignoramos y seguimos esperando.)
@@ -1,3 +1,3 @@
1
- Copia vendorizada de @dotrino/compat@0.4.0 (dotrino-compat/src/{advisory,index,ranges}.js).
1
+ Copia vendorizada de @dotrino/compat@0.4.1 (dotrino-compat/src/{advisory,index,ranges}.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
  Qué versión es cada pieza y si pueden hablar (CONVENCIONES §14).
@@ -105,14 +105,14 @@ export async function verifyAdvisory ({ advisory, publickey }) {
105
105
  * Devuelve el aviso vigente (el nuevo o el que ya tenía) y por qué.
106
106
  */
107
107
  export async function adoptAdvisory ({ current, incoming, publickey }) {
108
- if (!isAdvisory(incoming)) return { advisory: current || null, adopted: false, reason: 'no-es-un-aviso' }
108
+ if (!isAdvisory(incoming)) return { advisory: current || null, adopted: false, reason: 'not-an-advisory' }
109
109
  if (current && incoming.seq <= current.seq) {
110
- return { advisory: current, adopted: false, reason: 'no-es-mas-nuevo' }
110
+ return { advisory: current, adopted: false, reason: 'not-newer' }
111
111
  }
112
112
  if (!(await verifyAdvisory({ advisory: incoming, publickey }))) {
113
- return { advisory: current || null, adopted: false, reason: 'firma-invalida' }
113
+ return { advisory: current || null, adopted: false, reason: 'invalid-signature' }
114
114
  }
115
- return { advisory: incoming, adopted: true, reason: current ? 'mas-nuevo' : 'primero' }
115
+ return { advisory: incoming, adopted: true, reason: current ? 'newer' : 'first' }
116
116
  }
117
117
 
118
118
  /**
@@ -1,3 +1,3 @@
1
- Copia vendorizada de @dotrino/roadmap@0.4.32 (dotrino-roadmap/src/{index,manifests}.js).
1
+ Copia vendorizada de @dotrino/roadmap@0.4.33 (dotrino-roadmap/src/{index,manifests}.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
  El registro de versiones rotas que viaja con cada versión. Importa @dotrino/compat/ranges.
@@ -22,16 +22,16 @@ export const MANIFESTS = Object.freeze({
22
22
  "env": {
23
23
  "repo": "imdotrino/dotrino-vault",
24
24
  "npm": "@dotrino/env",
25
- "current": "0.91.1",
25
+ "current": "0.92.0",
26
26
  "protocol": 1,
27
27
  "requires": {
28
- "vault": ">=0.91.1"
28
+ "vault": ">=0.92.0"
29
29
  }
30
30
  },
31
31
  "identity": {
32
32
  "repo": "imdotrino/dotrino-identity",
33
33
  "npm": "@dotrino/identity",
34
- "current": "0.120.0",
34
+ "current": "0.121.2",
35
35
  "protocol": 1,
36
36
  "requires": {
37
37
  "proxy-client": ">=0.13.1"
@@ -253,7 +253,7 @@ export const MANIFESTS = Object.freeze({
253
253
  "vault": {
254
254
  "repo": "imdotrino/dotrino-vault",
255
255
  "npm": "@dotrino/vault",
256
- "current": "0.91.1",
256
+ "current": "0.92.0",
257
257
  "protocol": 1,
258
258
  "requires": {
259
259
  "vaultd": ">=0.105.0",
@@ -269,7 +269,7 @@ export const MANIFESTS = Object.freeze({
269
269
  "vaultd": {
270
270
  "repo": "imdotrino/dotrino-vault",
271
271
  "npm": "@dotrino/vaultd",
272
- "current": "0.163.4",
272
+ "current": "0.164.1",
273
273
  "protocol": 1,
274
274
  "requires": {
275
275
  "identity": ">=0.118.0",
@@ -360,6 +360,17 @@ export const MANIFESTS = Object.freeze({
360
360
  "proxy-client": ">=0.36.1",
361
361
  "remote-agent": ">=0.20.1"
362
362
  }
363
+ },
364
+ "accounts": {
365
+ "repo": "imdotrino/dotrino-accounts",
366
+ "npm": null,
367
+ "current": "0.1.0",
368
+ "protocol": 1,
369
+ "requires": {
370
+ "identity": ">=0.116.0",
371
+ "remote-agent": ">=0.19.0",
372
+ "vault": ">=0.92.0"
373
+ }
363
374
  }
364
375
  },
365
376
  "broken": [
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.91.1 (dotrino-vault/lib/src/{admin,b64,browser,core/approvals,core/backup,core/blocked,core/browserDisk,core/commKey,core/disk,core/libVersion,core/machines,core/notices,core/notifiable,core/passwords,core/sealKey,core/sealer,core/sealers,core/secretsStore,core/sha256,core/store,core/subacta,core/threadStore,core/transport,core/vault,enroll,index,loginClient,passwordLogins,procContext,protocol,revocation,sealed}.js).
1
+ Copia vendorizada de @dotrino/vault@0.92.0 (dotrino-vault/lib/src/{admin,b64,browser,core/approvals,core/backup,core/blocked,core/browserDisk,core/commKey,core/disk,core/libVersion,core/machines,core/notices,core/notifiable,core/passwords,core/sealKey,core/sealer,core/sealers,core/secretsStore,core/sha256,core/store,core/subacta,core/threadStore,core/transport,core/vault,enroll,index,loginClient,passwordLogins,procContext,protocol,revocation,sealed}.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
  browser.js es la bóveda de esta pestaña: el MISMO núcleo que el binario (core/), con
4
4
  el disco en IndexedDB. Importa @dotrino/{compat,roadmap,passmanager,store/core} y
@@ -5,4 +5,4 @@
5
5
  * si no coincide con la del paquete: una versión anunciada que no es la que corre es peor
6
6
  * que no anunciar ninguna.
7
7
  */
8
- export const LIB_VERSION = '0.91.1'
8
+ export const LIB_VERSION = '0.92.0'
@@ -143,6 +143,23 @@ export function createEnrollDesk ({
143
143
 
144
144
  const fire = (fn, arg) => { try { fn(arg) } catch (_) {} }
145
145
  const reply = (to, obj) => { try { send(to, obj) } catch (e) { log('[vault] could not reply:', e.message) } }
146
+ /**
147
+ * LO QUE LA BÓVEDA LE DICE AL APARATO MIENTRAS SE EMPAREJA VA FIRMADO CON LA MAESTRA, y
148
+ * con el `sn` de la sesión dentro. Lo que va a la cuenta lo reciben todas sus bóvedas y
149
+ * cualquiera que lo vea pasar puede contestar; sin firma, un `vault.error` de cualquiera
150
+ * tumbaba el alta (el iPhone, haciendo de bóveda, lo hacía sin querer, 2026-10-18). El
151
+ * aparato solo obedece lo que verifica contra la `iss` de su invitación (`fromMyVault`).
152
+ */
153
+ // Lo firmado va en un bloque aparte (`signed`), como en el hello: quien envía estampa lo suyo
154
+ // en el sobre (versión, protocolo) y la firma tiene que cubrir exactamente lo que se verifica.
155
+ // Los campos de arriba (`error`, `deviceId`) son los de siempre, para quien los lee.
156
+ const tell = async (to, obj, sn) => {
157
+ const signed = { ...obj, sn: sn || null, iss, ts: Date.now() }
158
+ let signature
159
+ try { ({ signature } = await identity.signData(signed)) } catch (e) { log('[vault] could not sign the reply:', e.message); return }
160
+ reply(to, { ...obj, signed, vsig: signature })
161
+ }
162
+ const fail = (to, error, sn) => tell(to, { type: MSG_ERROR, error }, sn)
146
163
  const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
147
164
 
148
165
  /**
@@ -182,7 +199,9 @@ export function createEnrollDesk ({
182
199
  if (!caps.length && !cn) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
183
200
  caps = [...new Set(caps)]
184
201
  }
185
- pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
202
+ // Uno a la vez: una sesión nueva supersede a la anterior. Las que se quitan se recuerdan
203
+ // como MÍAS: a un aparato que llegue con una de esas sí se le contesta que ya no vale.
204
+ for (const k of pending.keys()) forget(k)
186
205
  const acct = String(account || '').slice(0, 40)
187
206
  // INVITACIÓN CORTA: si sabemos cómo alcanzarnos, el QR lleva solo eso y el
188
207
  // nonce de la sesión. La llave, el proxy y el nombre de la cuenta los pide el
@@ -206,7 +225,21 @@ export function createEnrollDesk ({
206
225
  return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
207
226
  }
208
227
 
209
- function stopPairing (token) { pending.delete(token) }
228
+ function stopPairing (token) { forget(token) }
229
+
230
+ /**
231
+ * Las sesiones que ESTA bóveda abrió y ya no valen (superadas, paradas o gastadas), por
232
+ * su token y su `sn`. Distingue «la abrí yo y ya no vale» (se contesta con el error) de
233
+ * «no sé nada de esa invitación» (se calla: la abrió otra bóveda de la cuenta).
234
+ */
235
+ const gone = new Set()
236
+ function forget (key) {
237
+ const pend = pending.get(key)
238
+ pending.delete(key)
239
+ if (!pend) return
240
+ gone.add(pend.token); if (pend.sn) gone.add(pend.sn)
241
+ while (gone.size > 200) gone.delete(gone.values().next().value)
242
+ }
210
243
 
211
244
  function listPending () {
212
245
  return [...pending.values()]
@@ -229,9 +262,18 @@ export function createEnrollDesk ({
229
262
  */
230
263
  async function handleHello (from, p) {
231
264
  const pend = pending.get(String(p?.sn || ''))
265
+ // UNA INVITACIÓN QUE NO ES MÍA NO SE CONTESTA. Una cuenta tiene varias bóvedas a la vez
266
+ // (la del VPS, la pestaña, el teléfono que hace de bóveda), y lo que va a la cuenta les
267
+ // llega a todas. Si la que no abrió el emparejamiento contesta «no hay sesión», ese
268
+ // error le gana a la respuesta buena y el aparato se queda fuera (2026-10-18: el iPhone
269
+ // tumbaba cada alta de un servicio). Se anota y se calla: la que la abrió, contesta.
270
+ if (!pend && !gone.has(String(p?.sn || ''))) {
271
+ audit('ignored', { what: 'hello', reason: 'not-mine' })
272
+ return { ignored: true }
273
+ }
232
274
  if (!pend || Date.now() > pend.exp) {
233
275
  audit('rejected', { what: 'hello', reason: 'sin-sesion' })
234
- return reply(from, { type: MSG_ERROR, error: 'no pairing session open for that code' })
276
+ return fail(from, 'no pairing session open for that code', String(p?.sn || ''))
235
277
  }
236
278
  // La respuesta va FIRMADA por la maestra y el `sn` va dentro de lo firmado. Eso ata
237
279
  // la respuesta a ESTA sesión: no se puede reutilizar la de otro emparejamiento ni la
@@ -250,42 +292,48 @@ export function createEnrollDesk ({
250
292
  async function handleEnroll (from, p) {
251
293
  const d = p?.data
252
294
  if (!d || typeof d.dpub !== 'string' || typeof p.signature !== 'string') {
253
- return reply(from, { type: MSG_ERROR, error: 'invalid enroll' })
295
+ return fail(from, 'invalid enroll', p?.data?.sn)
254
296
  }
255
297
  const pend = pending.get(d.token)
298
+ // Lo mismo que en el hello: una sesión que esta bóveda no abrió no es suya para
299
+ // rechazarla. Solo contesta por las que conoce (gastada o vencida).
300
+ if (!pend && !gone.has(String(d.token || ''))) {
301
+ audit('ignored', { what: 'enroll', reason: 'not-mine' })
302
+ return { ignored: true }
303
+ }
256
304
  if (!pend || pend.state === 'DONE' || Date.now() > pend.exp) {
257
- return reply(from, { type: MSG_ERROR, error: 'invalid or expired pairing token' })
305
+ return fail(from, 'invalid or expired pairing token', d.sn)
258
306
  }
259
- if (d.sn !== pend.sn) return reply(from, { type: MSG_ERROR, error: 'invalid session' })
307
+ if (d.sn !== pend.sn) return fail(from, 'invalid session', d.sn)
260
308
  // V7 · la INTENCIÓN viaja firmada y tiene que coincidir con el modo con el que ESTA
261
309
  // bóveda abrió el emparejamiento. Es lo que garantiza que lo que pasa es lo que el
262
310
  // humano vio anunciado en las dos pantallas, y no algo que se decidió a mitad de camino.
263
311
  const intent = d.intent || 'join'
264
312
  if (intent !== 'join' && intent !== 'adopt') {
265
- return reply(from, { type: MSG_ERROR, error: 'unknown intent: ' + intent })
313
+ return fail(from, 'unknown intent: ' + intent, d.sn)
266
314
  }
267
315
  if (intent !== (pend.mode || 'join')) {
268
316
  audit('rejected', { what: 'enroll', reason: 'intent-mismatch' })
269
- return reply(from, { type: MSG_ERROR, error: `this pairing was opened for "${pend.mode || 'join'}" and the device asked for "${intent}"` })
317
+ return fail(from, `this pairing was opened for "${pend.mode || 'join'}" and the device asked for "${intent}"`, d.sn)
270
318
  }
271
319
  if (!isFresh(d)) {
272
320
  audit('rejected', { what: 'enroll', reason: 'stale' })
273
- return reply(from, { type: MSG_ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
321
+ return fail(from, 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)', d.sn)
274
322
  }
275
323
  // PRUEBA DE POSESIÓN: la firma de `data` debe verificar contra `dpub`.
276
324
  if (!(await verifyDeviceSig({ publickey: d.dpub, data: d, signature: p.signature }))) {
277
325
  audit('rejected', { what: 'enroll', reason: 'bad-device-signature' })
278
- return reply(from, { type: MSG_ERROR, error: 'invalid device signature' })
326
+ return fail(from, 'invalid device signature', d.sn)
279
327
  }
280
328
  // El COMPROMISO del código es obligatorio: sin él no se puede comprobar al aprobar
281
329
  // y volveríamos a emitir certs a ciegas. Un cliente viejo cae acá con un mensaje claro.
282
330
  if (typeof d.commit !== 'string' || !/^[0-9a-f]{64}$/.test(d.commit)) {
283
331
  audit('rejected', { what: 'enroll', reason: 'no-commit' })
284
- return reply(from, { type: MSG_ERROR, error: 'this device speaks an old pairing version (no code commitment). Update it and try again.' })
332
+ return fail(from, 'this device speaks an old pairing version (no code commitment). Update it and try again.', d.sn)
285
333
  }
286
334
  // Un solo dispositivo a la vez esperando su código (así aprobar no es ambiguo).
287
335
  if (pend.state === 'PENDING_CONFIRM' && pend.dpub && pend.dpub !== d.dpub) {
288
- return reply(from, { type: MSG_ERROR, error: 'another device is already using this pairing session' })
336
+ return fail(from, 'another device is already using this pairing session', d.sn)
289
337
  }
290
338
 
291
339
  const deviceId = await deviceIdOf(d.dpub)
@@ -300,7 +348,7 @@ export function createEnrollDesk ({
300
348
  // persona sí puede entrar sin ella: no lee variables de servicio.
301
349
  if (typeof d.encPub === 'string') pend.encPub = d.encPub
302
350
  if (!pend.encPub && scopeToCn(pend.scope)) {
303
- return reply(from, { type: MSG_ERROR, error: 'a service must send its encryption key (update @dotrino/vault on the service)' })
351
+ return fail(from, 'a service must send its encryption key (update @dotrino/vault on the service)', d.sn)
304
352
  }
305
353
  // Certificado de continuidad (opcional): lo firma la identidad que se une, con su
306
354
  // propia llave. Se comprueba aquí y se guarda con el miembro al aprobar.
@@ -322,7 +370,7 @@ export function createEnrollDesk ({
322
370
  // llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
323
371
  if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
324
372
 
325
- reply(from, { type: MSG_ENROLL_CHALLENGE, deviceId })
373
+ await tell(from, { type: MSG_ENROLL_CHALLENGE, deviceId }, pend.sn)
326
374
  fire(onChallenge, { deviceId, scope: pend.scope, label: pend.label || '', machine: pend.machine || null, mode: pend.mode || 'join' })
327
375
  fire(onPendingChange)
328
376
  return { deviceId }
@@ -410,8 +458,8 @@ export function createEnrollDesk ({
410
458
  log('[vault] could not revoke the undelivered certificate %s: %s', cert.nonce, re.message)
411
459
  }
412
460
  audit('rejected', { what: 'approve', device: pend.deviceId, reason: 'admit-failed' })
413
- reply(pend.from, { type: MSG_ERROR, error: 'the vault could not add this device to the account: pairing failed, try again' })
414
- pending.delete(pend.token)
461
+ await fail(pend.from, 'the vault could not add this device to the account: pairing failed, try again', pend.sn)
462
+ forget(pend.token)
415
463
  fire(onPendingChange)
416
464
  log('[vault] could not add %s to the record, no certificate was delivered: %s', pend.deviceId, e.message)
417
465
  throw Object.assign(
@@ -427,7 +475,7 @@ export function createEnrollDesk ({
427
475
  // El acta viaja con el cert: el dispositivo ya sabe de quién es el perfil al que entra.
428
476
  reply(pend.from, { type: MSG_ENROLLED, code, cert, iss, acta: record })
429
477
  pend.state = 'DONE'
430
- pending.delete(pend.token)
478
+ forget(pend.token)
431
479
  fire(onPendingChange)
432
480
  log(`[vault] device approved: ${pend.deviceId}`)
433
481
  return { ok: true, deviceId: pend.deviceId, cert }
@@ -451,22 +499,22 @@ export function createEnrollDesk ({
451
499
  async function handleActaSealed (from, p) {
452
500
  const record = p?.acta
453
501
  const pend = [...pending.values()].find((x) => x.state === 'AWAITING_ACTA' && (x.from === from || x.dpub))
454
- if (!pend) return reply(from, { type: MSG_ERROR, error: 'no adoption awaiting a record' })
455
- if (!record || typeof record !== 'object') return reply(from, { type: MSG_ERROR, error: 'record missing or unreadable' })
502
+ if (!pend) return fail(from, 'no adoption awaiting a record', pend?.sn)
503
+ if (!record || typeof record !== 'object') return fail(from, 'record missing or unreadable', pend?.sn)
456
504
  // Ya no hay campo `sealer`: se le pregunta al PERMISO. Es la misma comprobación —«¿me
457
505
  // nombra a mí la que me mandan?»— dicha en el idioma nuevo, y con varios selladores la
458
506
  // respuesta puede ser que sí para más de uno, que es lo correcto.
459
507
  if (!canSeal(record, iss)) {
460
508
  audit('rejected', { what: 'adopt', reason: 'not-sealer' })
461
- return reply(from, { type: MSG_ERROR, error: 'that record does not let this vault seal it' })
509
+ return fail(from, 'that record does not let this vault seal it', pend?.sn)
462
510
  }
463
511
  if (record.sealedBy !== pend.dpub) {
464
512
  audit('rejected', { what: 'adopt', reason: 'sealed-by-other' })
465
- return reply(from, { type: MSG_ERROR, error: 'that record was not sealed by the device of this pairing' })
513
+ return fail(from, 'that record was not sealed by the device of this pairing', pend?.sn)
466
514
  }
467
515
  if (pend.profileId && record.profileId !== pend.profileId) {
468
516
  audit('rejected', { what: 'adopt', reason: 'other-profile' })
469
- return reply(from, { type: MSG_ERROR, error: 'that record belongs to an account other than the one the device announced' })
517
+ return fail(from, 'that record belongs to an account other than the one the device announced', pend?.sn)
470
518
  }
471
519
 
472
520
  try {
@@ -478,26 +526,26 @@ export function createEnrollDesk ({
478
526
  const mine = (await identity.profileActa?.())?.acta || record
479
527
  reply(pend.from, { type: MSG_ACTA_ADOPTED, code: p.code, acta: mine })
480
528
  pend.state = 'DONE'
481
- pending.delete(pend.token)
529
+ forget(pend.token)
482
530
  fire(onPendingChange)
483
531
  fire(onAdopted, { deviceId: pend.deviceId, profileId: record.profileId, seq: mine.seq })
484
532
  log('[vault] account adopted from device %s (profile %s)', pend.deviceId, record.profileId?.slice(0, 12))
485
533
  return { ok: true, adopted: true, profileId: record.profileId, seq: mine.seq }
486
534
  } catch (e) {
487
535
  log('[vault] could not adopt the account: %s', e.message)
488
- reply(pend.from, { type: MSG_ERROR, error: 'the vault could not adopt the account: ' + e.message })
536
+ fail(pend.from, 'the vault could not adopt the account: ' + e.message, pend.sn)
489
537
  return { ok: false, error: e.message }
490
538
  }
491
539
  }
492
540
 
493
541
  /** Rechaza un enrolamiento pendiente. */
494
- function reject (deviceId) {
542
+ async function reject (deviceId) {
495
543
  const pend = deviceId
496
544
  ? findPending(deviceId)
497
545
  : [...pending.values()].find((p) => p.state === 'PENDING_CONFIRM')
498
546
  if (!pend) return { ok: false }
499
- reply(pend.from, { type: MSG_ERROR, error: 'pairing rejected' })
500
- pending.delete(pend.token)
547
+ await fail(pend.from, 'pairing rejected', pend.sn)
548
+ forget(pend.token)
501
549
  audit('reject', { device: pend.deviceId })
502
550
  fire(onPendingChange)
503
551
  log('[vault] dispositivo rechazado: %s', pend.deviceId)