@dotrino/identity 0.117.2 → 0.119.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.117.2",
3
+ "version": "0.119.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.js CHANGED
@@ -601,6 +601,20 @@ export class Identity {
601
601
  /** Cambia la contraseña: hace falta la vieja, y lo que estuviera abierto se cierra. */
602
602
  async selfVaultLoginPasswd (opts) { return this._call('selfVaultLoginPasswd', opts || {}, 60000) }
603
603
 
604
+ // ----- el RESPALDO, cuando la bóveda es ESTA pestaña (solo desde la consola) -----
605
+ //
606
+ // Bóveda abierta y origen de la consola (`vault.dotrino.com`): cualquier otro recibe
607
+ // `code: 'not-the-console'`. El archivo vuelve YA CIFRADO con la contraseña dada.
608
+
609
+ /** Los cajones que hay: `[{ owner, count, device? }]`. */
610
+ async selfVaultBackupDrawers () { return this._call('selfVaultBackupDrawers', {}, 30000) }
611
+ /** Exporta todo (`drawers: null`) o esos cajones. Devuelve `{ file, summary }`. */
612
+ async selfVaultBackupExport (opts) { return this._call('selfVaultBackupExport', opts || {}, 120000) }
613
+ /** El plan de importar `file` (qué es nuevo, igual o choca). No escribe nada. */
614
+ async selfVaultBackupPlan (opts) { return this._call('selfVaultBackupPlan', opts || {}, 120000) }
615
+ /** Importa. Con un choque sin decidir lanza `BACKUP_CONFLICTS` y no escribe nada. */
616
+ async selfVaultBackupImport (opts) { return this._call('selfVaultBackupImport', opts || {}, 120000) }
617
+
604
618
  /** Cierra un inicio de sesión abierto (sin `sid`, todos los de ese usuario). */
605
619
  async selfVaultLoginClose (opts) { return this._call('selfVaultLoginClose', opts || {}) }
606
620
 
package/vault/acta.js CHANGED
@@ -105,7 +105,7 @@ const conCampoSellador = (acta) => Number(acta?.v) < V_SIN_CAMPO_SELLADOR
105
105
  * el rol de master, que no se delega. Así un dispositivo con `admin` robado hace daño
106
106
  * acotado y **reversible** (se le revoca), en vez de poder dejarte fuera de tu cuenta.
107
107
  */
108
- export const CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'passkeys', 'sealer', 'unattended', 'replica'])
108
+ export const CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica'])
109
109
 
110
110
  /** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
111
111
  /**
@@ -119,14 +119,12 @@ export const CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve',
119
119
  * pedirle algo a la bóveda es exactamente lo que decide el acta — tener dos registros
120
120
  * de lo mismo obliga a acordarse de los dos al quitar un aparato.
121
121
  *
122
- * `passkeys` es ABRIR LA PRIVADA DE UNA PASSKEY. Una passkey no se parece a una
123
- * contraseña: es una llave privada que firma el reto de un sitio, y si se copia sirve
124
- * **hasta que la borres en cada sitio** donde la registraste — no hay nada que «cambiar».
125
- * Por eso su envoltura solo se le hace a quien tenga `passwords` **y** esto
126
- * (`dotrino-passmanager/docs/sealed-passwords.md` §2.8).
127
- *
128
- * Sin `passwords` no significa nada: la passkey vive en una entrada de contraseñas. Y una
129
- * SESIÓN no lo lleva nunca, como `passwords` (`session.js`).
122
+ * Y `passwords` cubre TAMBIÉN LAS PASSKEYS (dueño, 2026-10-16: «realmente deberían ser uno
123
+ * solo»). Hasta la 0.117 había un `passkeys` aparte para abrir la privada de una passkey;
124
+ * se fusionó en este. Un acta vieja que todavía lo nombre sigue valiendo: un permiso
125
+ * desconocido se ignora (ver `checkShape`), y al siguiente cambio del acta desaparece.
126
+ * Lo que distingue una passkey es la POLÍTICA de la bóveda, no un permiso: añadirla es
127
+ * directo, reemplazarla o borrarla pide aprobación (`@dotrino/passmanager`).
130
128
  *
131
129
  * `replica` es REPARTIR, NO DECIDIR. Un replicador no tiene maestra: guarda el acta y los
132
130
  * sobres —que ya vienen sellados a su destinatario, así que tampoco puede abrirlos— y los
@@ -151,7 +149,7 @@ export const CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve',
151
149
  * de la cuenta, se ve en la pantalla de permisos como los demás, y se quita quitándolo —
152
150
  * sin acordarse de un segundo registro escondido.
153
151
  */
154
- export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'passkeys', 'sealer', 'unattended', 'replica'])
152
+ export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'sealer', 'unattended', 'replica'])
155
153
 
156
154
  /**
157
155
  * Lo que recibe un dispositivo recién emparejado. `admin` **no está**: no se
package/vault/index.html CHANGED
@@ -168,6 +168,7 @@
168
168
  "@dotrino/identity/machine": "./machine.js",
169
169
  "@dotrino/vault/password-logins": "./vendor/vault/passwordLogins.js",
170
170
  "@dotrino/vault/login-client": "./vendor/vault/loginClient.js",
171
+ "@dotrino/vault/backup": "./vendor/vault/core/backup.js",
171
172
  "@dotrino/opaque": "./vendor/opaque/src/index.js",
172
173
  "@dotrino/identity/capabilities": "./capabilities.js",
173
174
  "@dotrino/identity/acta": "./acta.js",
package/vault/session.js CHANGED
@@ -57,7 +57,7 @@ export const SESSION_SCOPES = Object.freeze(['id:whoami', 'vault:store'])
57
57
  * blanca a propósito: si mañana alguien añade un alcance a `SESSION_SCOPES` sin pensarlo,
58
58
  * esto sigue cortando lo que no puede pasar.
59
59
  */
60
- export const SESSION_FORBIDDEN = Object.freeze(['secrets', 'admin', 'approve', 'sealer', 'passwords', 'passkeys', 'unattended', 'replica'])
60
+ export const SESSION_FORBIDDEN = Object.freeze(['secrets', 'admin', 'approve', 'sealer', 'passwords', 'unattended', 'replica'])
61
61
 
62
62
  /** Qué capacidad del acta hace falta para conceder cada alcance de sesión. */
63
63
  const SCOPE_NEEDS = Object.freeze({ 'id:whoami': null, 'vault:store': 'store' })
package/vault/vault.js CHANGED
@@ -469,6 +469,45 @@ import { useBackend as usePeerBackend } from './peerStore.js'
469
469
  }
470
470
  }
471
471
  return { ...r, deviceId: fila?.deviceId || null }
472
+ },
473
+
474
+ // ----- EL RESPALDO (`dotrino-vault/docs/respaldo.md`), cuando la bóveda es ESTA pestaña -----
475
+ //
476
+ // Exportar e importar, con la bóveda abierta (lo comprueba el núcleo: `VAULT_LOCKED`).
477
+ // Y SOLO DESDE LA CONSOLA: este iframe atiende a cualquier `*.dotrino.com`, y quien exporta
478
+ // elige la contraseña del respaldo — o sea, puede leerlo todo. Un fallo en cualquiera de
479
+ // las apps no puede convertirse en sacar los secretos de la cuenta.
480
+ // El archivo se cierra AQUÍ: a la consola le llega ya cifrado.
481
+ selfVaultBackupDrawers: async ({ __origin } = {}) => {
482
+ soloConsola(__origin)
483
+ return pidaDaemon().backupDrawers()
484
+ },
485
+ selfVaultBackupExport: async ({ drawers = null, password, __origin } = {}) => {
486
+ soloConsola(__origin)
487
+ const { sealBackup } = await import('@dotrino/vault/backup')
488
+ const { payload, summary } = await pidaDaemon().exportBackup({ drawers })
489
+ return { file: await sealBackup(payload, password), summary }
490
+ },
491
+ selfVaultBackupPlan: async ({ file, password, drawers = null, map = {}, __origin } = {}) => {
492
+ soloConsola(__origin)
493
+ const { openBackup } = await import('@dotrino/vault/backup')
494
+ return pidaDaemon().planBackupImport(await openBackup(file, password), { drawers, map })
495
+ },
496
+ selfVaultBackupImport: async ({ file, password, drawers = null, map = {}, onConflict = null, decisions = {}, __origin } = {}) => {
497
+ soloConsola(__origin)
498
+ const { openBackup } = await import('@dotrino/vault/backup')
499
+ return pidaDaemon().importBackup(await openBackup(file, password), { drawers, map, onConflict, decisions })
500
+ }
501
+ }
502
+
503
+ /**
504
+ * ¿ES LA CONSOLA? `vault.dotrino.com`, o un origen de desarrollo (localhost / LAN privada),
505
+ * que es donde se prueba. Lo demás del ecosistema no puede pedir un respaldo.
506
+ */
507
+ const CONSOLE_ORIGIN = /^(https:\/\/vault\.dotrino\.com|https?:\/\/(localhost|127\.0\.0\.1|192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})(:\d+)?)$/
508
+ function soloConsola (origin) {
509
+ if (!CONSOLE_ORIGIN.test(String(origin || ''))) {
510
+ throw Object.assign(new Error('only the vault console (vault.dotrino.com) can back up or restore'), { code: 'not-the-console' })
472
511
  }
473
512
  }
474
513
 
@@ -1,3 +1,3 @@
1
- Copia vendorizada de @dotrino/passmanager@0.17.0 (dotrino-passmanager/lib/src/{crypto,fields,generate,import,index,match,model,pubkey,sealed/convert,sealed/device,sealed/entry,sealed/index,sealed/keys,sealed/recovery,sealed/store,session-cache,totp,transport/protocol,transport/proxy,transport/sealed,vault/approval,vault/errors,vault/guard,vault/interface,vault/local,vault/remote,vault/responder,vault/sealed-local,vault/sealed-responder,vault/sealed,webauthn}.js).
1
+ Copia vendorizada de @dotrino/passmanager@0.18.4 (dotrino-passmanager/lib/src/{crypto,fields,generate,import,index,match,model,pubkey,sealed/convert,sealed/device,sealed/entry,sealed/index,sealed/keys,sealed/recovery,sealed/store,session-cache,totp,transport/protocol,transport/proxy,transport/sealed,vault/approval,vault/errors,vault/guard,vault/interface,vault/local,vault/remote,vault/responder,vault/sealed-local,vault/sealed-responder,vault/sealed,webauthn}.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 sobre de las contraseñas, el mismo que usan el binario y la extensión. Importa @dotrino/identity/content.
@@ -324,6 +324,10 @@ export function mergePatch (base, changes = {}) {
324
324
  ...(changes.totp !== undefined ? { totp: changes.totp } : {}),
325
325
  ...(changes.notes !== undefined ? { notes: changes.notes } : {}),
326
326
  ...vaciados,
327
+ // El contador de una passkey sube en cada firma: cambiarlo no toca la llave.
328
+ ...(Number.isInteger(changes.signCount) && base.webauthn
329
+ ? { webauthn: { ...base.webauthn, signCount: changes.signCount } }
330
+ : {}),
327
331
  sites,
328
332
  fields: quedan,
329
333
  id: base.id,
@@ -78,7 +78,7 @@ export async function sealEntry ({ plain, main, passkeys = null, keys }) {
78
78
  if (e.webauthn.privateKey) {
79
79
  if (!passkeys?.cek || typeof passkeys.gen !== 'number') {
80
80
  throw Object.assign(
81
- new Error('this entry carries a passkey: it needs its own generation, wrapped only to devices with `passkeys`'),
81
+ new Error('this entry carries a passkey: it needs its own generation'),
82
82
  { code: 'passkeys-generation-missing' })
83
83
  }
84
84
  fields[PASSKEY_FIELD] = await sealV(passkeys.cek, passkeys.gen, e.webauthn.privateKey)
@@ -38,8 +38,9 @@ export class SealedStore {
38
38
  * @param {object} store `{ get(k), set(k, v) }`
39
39
  * @param {object} opts
40
40
  * `recipients(kind)` → `[{ pub, encPub }]` quién DEBE tener envoltura. `kind` es
41
- * `'main'` (los aparatos con `passwords`) o `'passkeys'` (los que además tienen
42
- * `passkeys`). Lo pone quien conoce el acta: esta pieza no la lee.
41
+ * `'main'` o `'passkeys'` (la generación aparte de la privada de una passkey). Desde
42
+ * 2026-10-16 las dos son los aparatos con `passwords`: el permiso `passkeys` se fusionó
43
+ * en ese. Lo pone quien conoce el acta: esta pieza no la lee.
43
44
  * `verifyAuthor({ body, author })` → ¿la firmó quien dice, y puede escribir? Lo
44
45
  * contesta quien tiene el acta. **No es opcional**: sin ella, cualquiera que llegue
45
46
  * al almacén podría dejar una entrada a nombre de otro, y la bóveda no puede mirar
@@ -97,13 +98,12 @@ export class SealedStore {
97
98
  'the wraps must include the recovery one (#recovery), or this entry could never be handed out again')
98
99
  }
99
100
  const deben = (await this.recipients(kind)).filter((m) => m?.encPub).map((m) => m.pub)
100
- // NADIE A QUIEN ENVOLVÉRSELA NO ES «CERO DESTINATARIOS», ES UN ERROR. Guardar una
101
- // passkey que ningún aparato puede abrir la deja viva solo para la frase del perfil: no
102
- // se pierde, pero no sirve, y el usuario se entera el día que la necesita. Se dice
103
- // ahora, con un código que explica qué falta.
104
- if (kind === 'passkeys' && !deben.length) {
101
+ // Una passkey no se guarda para NADIE: sin aparatos que puedan abrirla y sin copia de
102
+ // recuperación, se perdería entera. Con la copia basta: quien reciba el permiso después
103
+ // la recibe al re-repartir (`rewrapAll`).
104
+ if (kind === 'passkeys' && !deben.length && !wraps[RECOVERY]) {
105
105
  throw new SealedError('no-passkeys-device',
106
- 'no device in the account record can open passkeys: grant `passkeys` to one (`dotrino-vault caps <ID> +passkeys`) before saving it')
106
+ 'nobody could ever open this passkey: no device has `passwords` and the account has no recovery copy')
107
107
  }
108
108
  const hay = Object.keys(wraps).filter((k) => k !== RECOVERY)
109
109
  if (!same(deben, hay)) {
@@ -127,7 +127,7 @@ export class SealedStore {
127
127
  // reparte una llave que nadie va a usar.
128
128
  if (llevaPasskey && !tienePasskeys) {
129
129
  throw new SealedError('passkeys-generation-missing',
130
- 'this entry carries a passkey: it needs its own wraps, only for devices with `passkeys`')
130
+ 'this entry carries a passkey: it needs its own wraps (its own generation)')
131
131
  }
132
132
  if (!llevaPasskey && tienePasskeys) {
133
133
  throw new SealedError('passkeys-generation-unused', 'there is no passkey in this entry to wrap')
@@ -225,7 +225,7 @@ export class SealedStore {
225
225
  if (passkeys) await this.#checkWraps('passkeys', passkeys.wraps)
226
226
  if (fields[PASSKEY_FIELD] && !passkeys) {
227
227
  throw new SealedError('passkeys-generation-missing',
228
- 'that patch carries a passkey: it needs its own wraps, only for devices with `passkeys`')
228
+ 'that patch carries a passkey: it needs its own wraps (its own generation)')
229
229
  }
230
230
 
231
231
  const s = await this.#state()
@@ -343,6 +343,12 @@ export class SealedStore {
343
343
  return s.entries.find((e) => e.id === id)?.priv || []
344
344
  }
345
345
 
346
+ /** ¿Esa entrada lleva una passkey? La bóveda lo sabe sin abrir nada: por su huella `wa`. */
347
+ async hasPasskey (id) {
348
+ const s = await this.#state()
349
+ return !!s.entries.find((e) => e.id === id)?.wa
350
+ }
351
+
346
352
  async remove (id) {
347
353
  const s = await this.#state()
348
354
  const antes = s.entries.length
@@ -11,6 +11,12 @@
11
11
  // · una NEGATIVA no queda recordada como aprobación: la siguiente vuelve a preguntar
12
12
  // · dos peticiones a la vez producen UN aviso, no dos
13
13
  // · lo aprobado vive en memoria y muere con la bóveda: no se guarda en ninguna parte
14
+ // · y además CADUCA: una hora deslizante, como dotrino-env (dueño, 2026-10-16). Cada uso
15
+ // la renueva; una hora sin usarla y se vuelve a preguntar. Antes duraba hasta reiniciar
16
+ // la bóveda, o sea días, y un sí de la mañana servía para todo lo de la semana.
17
+
18
+ /** Cuánto vale un sí sin usarse: una hora, deslizante. */
19
+ export const GRANT_TTL_MS = 60 * 60 * 1000
14
20
 
15
21
  export class ApprovalGate {
16
22
  /**
@@ -22,27 +28,49 @@ export class ApprovalGate {
22
28
  * aprobación es del aparato y no de cada credencial.
23
29
  * `remember` si un sí vale para los siguientes. En `false` la puerta sigue
24
30
  * juntando las simultáneas, pero cada petición nueva vuelve a preguntar.
31
+ * `ttlMs` cuánto vale un sí sin usarse (deslizante: cada uso lo renueva). Una hora.
32
+ * `now` el reloj, para las pruebas.
25
33
  */
26
- constructor ({ ask, scope, remember = true } = {}) {
34
+ constructor ({ ask, scope, remember = true, ttlMs = GRANT_TTL_MS, now = Date.now } = {}) {
27
35
  this.ask = ask || (async () => false)
28
36
  this.scope = scope || (() => null)
29
37
  this.remember = remember !== false
30
- this._granted = new Set()
38
+ this.ttlMs = ttlMs
39
+ this.now = now
40
+ this._granted = new Map() // llave → cuándo se usó por última vez
31
41
  this._asking = new Map()
32
42
  }
33
43
 
34
- /** ¿Pasa? Pregunta si hace falta, y recuerda el sí si le toca recordarlo. */
44
+ /** ¿Sigue vivo el sí de esa llave? Si sí, se renueva (deslizante); si caducó, se olvida. */
45
+ #fresh (key) {
46
+ const last = this._granted.get(key)
47
+ if (last == null) return false
48
+ if (this.now() - last > this.ttlMs) { this._granted.delete(key); return false }
49
+ this._granted.set(key, this.now())
50
+ return true
51
+ }
52
+
53
+ /**
54
+ * ¿Pasa? Pregunta si hace falta, y recuerda el sí si le toca recordarlo.
55
+ *
56
+ * `req.remember === false`: ESTA petición pregunta siempre, y su sí no vale para las
57
+ * siguientes. Es la de lo que no se deshace (borrar o pisar una passkey): que el sí a
58
+ * leer, o a borrar otra cosa, la dejara pasar es borrar sin que nadie lo vea
59
+ * (pasó el 2026-10-16: un borrado aprobado dejó pasar el siguiente sin aviso).
60
+ */
35
61
  async allow (req = {}) {
36
62
  const key = this.scope(req)
37
- if (this.remember && key != null && this._granted.has(key)) return true
63
+ const remember = this.remember && req.remember !== false
64
+ if (remember && key != null && this.#fresh(key)) return true
38
65
 
39
66
  // Dos pestañas abriendo el mismo sitio no deben hacer sonar el teléfono dos veces.
40
- const junta = key == null ? `${req.op || ''}:${req.payload?.id || ''}` : String(key)
67
+ // Lo que pregunta siempre se junta solo con la MISMA petición, no con todo el aparato.
68
+ const junta = key == null || !remember ? `${key ?? ''}:${req.op || ''}:${req.payload?.id || ''}` : String(key)
41
69
  if (this._asking.has(junta)) return this._asking.get(junta)
42
70
 
43
71
  const p = Promise.resolve(this.ask(req))
44
72
  .then(ok => {
45
- if (ok && this.remember && key != null) this._granted.add(key)
73
+ if (ok && remember && key != null) this._granted.set(key, this.now())
46
74
  return !!ok
47
75
  })
48
76
  .finally(() => this._asking.delete(junta))
@@ -52,7 +80,8 @@ export class ApprovalGate {
52
80
  }
53
81
 
54
82
  granted (key) {
55
- return this._granted.has(key)
83
+ const last = this._granted.get(key)
84
+ return last != null && this.now() - last <= this.ttlMs
56
85
  }
57
86
 
58
87
  /** Retirar lo aprobado sin apagar la bóveda. Sin argumento, todo. */
@@ -107,7 +107,11 @@ export class SealedResponder {
107
107
  // `allow`, no `ask`: es el que RECUERDA el sí y junta las simultáneas. `ask` es la
108
108
  // función cruda de preguntar, y usarla directamente hacía sonar el teléfono en cada
109
109
  // petición — que es como se enseña a aprobar sin mirar.
110
- const ok = await this.gate.allow({ op, payload, pubkey })
110
+ //
111
+ // Salvo borrar o pisar una passkey: eso no se deshace, así que pregunta CADA vez y
112
+ // ningún sí anterior del aparato lo cubre.
113
+ const remember = !(await this.replacesPasskey(op, payload))
114
+ const ok = await this.gate.allow({ op, payload, pubkey, remember })
111
115
  if (!ok) { anota('rejected'); return this.#send(from, pubkey, replyError(rid, CODES.NOT_APPROVED, 'not approved on the trusted device')) }
112
116
  }
113
117
  const result = await this.#run(op, payload, pubkey)
@@ -119,8 +123,25 @@ export class SealedResponder {
119
123
  }
120
124
  }
121
125
 
126
+ /**
127
+ * ¿CAMBIA O QUITA UNA PASSKEY QUE YA ESTÁ? Añadir es directo y no pregunta; reemplazarla o
128
+ * borrarla, sí — como las variables de `dotrino-env` (dueño, 2026-10-16). Una passkey
129
+ * borrada o pisada deja a alguien fuera de un sitio, y no hay forma de deshacerlo.
130
+ *
131
+ * Subir el contador al entrar NO es reemplazar: cambia la vista, no la llave.
132
+ */
133
+ async replacesPasskey (op, payload) {
134
+ if (op === 'pm2.remove') return this.store.hasPasskey(payload?.id)
135
+ if (op === 'pm2.put') return this.store.hasPasskey(payload?.entry?.id)
136
+ if (op === 'pm2.patch') {
137
+ return !!payload?.fields?.[PASSKEY_FIELD] || (payload?.drop || []).includes(PASSKEY_FIELD)
138
+ }
139
+ return false
140
+ }
141
+
122
142
  async #needsApproval (op, payload, pubkey) {
123
- return await this.needsApproval(pubkey) && await this.wantsPrivate(op, payload)
143
+ if (!await this.needsApproval(pubkey)) return false
144
+ return await this.wantsPrivate(op, payload) || await this.replacesPasskey(op, payload)
124
145
  }
125
146
 
126
147
  async #run (op, payload = {}, pubkey) {
@@ -262,7 +262,11 @@ export class SealedVault {
262
262
  fields: actual.fieldKeys.some((k) => !FIXED.includes(k)),
263
263
  webauthn: actual.hasWebauthn
264
264
  },
265
- ...(actual.webauthn ? { webauthn: actual.webauthn } : {})
265
+ // El contador de la passkey sube en cada firma y vive en la vista: cambiarlo no toca
266
+ // la llave, así que no es reemplazarla (no pide aprobación).
267
+ ...(actual.webauthn
268
+ ? { webauthn: Number.isInteger(changes.signCount) ? { ...actual.webauthn, signCount: changes.signCount } : actual.webauthn }
269
+ : {})
266
270
  }
267
271
  const idx = sites ? await Promise.all(sites.map((s) => siteIndex(keys.kidx, s))) : null
268
272
  const parche = await buildSealedPatch({
@@ -1,3 +1,3 @@
1
- Copia vendorizada de @dotrino/roadmap@0.4.18 (dotrino-roadmap/src/{index,manifests}.js).
1
+ Copia vendorizada de @dotrino/roadmap@0.4.29 (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.