@dotrino/identity 0.14.0 → 0.16.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.14.0",
3
+ "version": "0.16.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
@@ -259,6 +259,23 @@ export class Identity {
259
259
  return this.on('vault', handler)
260
260
  }
261
261
 
262
+ // ----- multi-perfil por dispositivo -----
263
+ // Podés tener varios perfiles (identidades) en el mismo navegador, cada uno conectado o no
264
+ // a su propio vault. Crear/cambiar setea el perfil activo; la app RECARGA la página y toma
265
+ // el nuevo (no reactivo: las apps abiertas conservan el perfil con el que cargaron).
266
+ /** Lista de perfiles: [{ id, name, pubkey, current }]. */
267
+ async listProfiles () { return this._call('listProfiles') }
268
+ /** El perfil activo: { id, name, pubkey }. */
269
+ async currentProfile () { return this._call('currentProfile') }
270
+ /** Crea un perfil nuevo (identidad fresca) y lo deja activo. La app debe recargar. */
271
+ async createProfile (name) { return this._call('createProfile', { name }) }
272
+ /** Cambia el perfil activo. La app debe recargar la página. */
273
+ async switchProfile (id) { return this._call('switchProfile', { id }) }
274
+ /** Renombra un perfil (o el activo si no se pasa id). */
275
+ async renameProfile (id, name) { return this._call('renameProfile', { id, name }) }
276
+ /** Borra un perfil y sus datos (no el único). */
277
+ async deleteProfile (id) { return this._call('deleteProfile', { id }) }
278
+
262
279
  /**
263
280
  * Merge endorsements (signed ratings from third parties) about a subject
264
281
  * into the local peer book. Returns { merged, total }.
@@ -426,4 +443,4 @@ export class Identity {
426
443
 
427
444
  // Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), reutilizables
428
445
  // por apps/bridges sin cargar el iframe del vault.
429
- export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, makePairingCode, commitCode, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
446
+ export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, makePairingCode, commitCode, avatarSvg, avatarDataUri, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
package/src/node.js CHANGED
@@ -41,14 +41,19 @@ function fileKv (filePath) {
41
41
  function filePeers (filePath) {
42
42
  let peers = {}
43
43
  let markDirty = null
44
+ let pid = null // multi-perfil: el peer book se namespacea por perfil
45
+ const fileFor = () => pid ? path.join(path.dirname(filePath), `peers.${pid}.json`) : filePath
44
46
  const flush = () => {
45
- fs.mkdirSync(path.dirname(filePath), { recursive: true })
46
- fs.writeFileSync(filePath, JSON.stringify(peers))
47
+ const f = fileFor()
48
+ fs.mkdirSync(path.dirname(f), { recursive: true })
49
+ fs.writeFileSync(f, JSON.stringify(peers))
47
50
  }
48
51
  return {
52
+ setProfile (p) { pid = p || null },
49
53
  async initPeerStorage () {
54
+ const f = fileFor()
50
55
  try {
51
- if (fs.existsSync(filePath)) peers = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {}
56
+ peers = (fs.existsSync(f)) ? (JSON.parse(fs.readFileSync(f, 'utf8')) || {}) : {}
52
57
  } catch (_) { peers = {} }
53
58
  return peers
54
59
  },
@@ -144,6 +149,13 @@ export class Identity {
144
149
  listVaultDevices () { return this._h('listVaultDevices') }
145
150
  getVaultCert () { return this._h('getVaultCert') }
146
151
  onVault (handler) { return this.on('vault', handler) }
152
+ // Multi-perfil por dispositivo (crear/cambiar reinicializa con el nuevo perfil activo).
153
+ listProfiles () { return this._h('listProfiles') }
154
+ currentProfile () { return this._h('currentProfile') }
155
+ createProfile (name) { return this._h('createProfile', { name }) }
156
+ switchProfile (id) { return this._h('switchProfile', { id }) }
157
+ renameProfile (id, name) { return this._h('renameProfile', { id, name }) }
158
+ deleteProfile (id) { return this._h('deleteProfile', { id }) }
147
159
  mergeEndorsements (subject, endorsements, askerPubkey) {
148
160
  return this._h('mergeEndorsements', { subject, endorsements, askerPubkey })
149
161
  }
@@ -186,4 +198,4 @@ export default Identity
186
198
 
187
199
  // Helpers de capacidad SIN clave maestra (lado dispositivo + verificación), para que
188
200
  // un bridge/bot Node pueda crear su clave, firmar acciones y verificar cadenas D←P.
189
- export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, makePairingCode, commitCode, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
201
+ export { makeDeviceKey, signWithDevice, verifyDelegation, verifyChain, pubkeyId, deriveSAS, verifyDeviceSig, makePairingCode, commitCode, avatarSvg, avatarDataUri, MAX_DELEGATION_MS, DEFAULT_DELEGATION_MS } from '../vault/capabilities.js'
@@ -99,6 +99,50 @@ export async function commitCode ({ code, dpub, sn }) {
99
99
  return [...new Uint8Array(h)].map((x) => x.toString(16).padStart(2, '0')).join('')
100
100
  }
101
101
 
102
+ /**
103
+ * Avatar generado (identicon de Dotrino): SVG DETERMINISTA a partir de una semilla
104
+ * (típicamente el pubkey del perfil) → cada perfil/identidad nace con imagen, sin que el
105
+ * usuario tenga que subir nada. Síncrono (hash FNV-1a + xorshift, sin necesidades de
106
+ * seguridad: es solo decorativo) → usable directo en plantillas. Rejilla 5×5 simétrica
107
+ * sobre una "moneda" redondeada, con color derivado del hash.
108
+ */
109
+ function _hashSeed (s) {
110
+ let h = 2166136261 >>> 0
111
+ for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) }
112
+ const bytes = []
113
+ let x = (h ^ 0x9e3779b9) >>> 0
114
+ for (let i = 0; i < 16; i++) { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; x >>>= 0; bytes.push(x & 0xff) }
115
+ return { h: h >>> 0, bytes }
116
+ }
117
+ export function avatarSvg (seed, { size = 80 } = {}) {
118
+ const { h, bytes } = _hashSeed(String(seed || 'dotrino'))
119
+ const hue = h % 360
120
+ const hue2 = (hue + 40) % 360
121
+ const fg = `hsl(${hue} 62% 46%)`
122
+ const bg1 = `hsl(${hue} 48% 95%)`
123
+ const bg2 = `hsl(${hue2} 48% 90%)`
124
+ const cells = 5
125
+ const unit = size / cells
126
+ let rects = ''
127
+ for (let col = 0; col < 3; col++) {
128
+ for (let row = 0; row < cells; row++) {
129
+ if (!(bytes[col * cells + row] & 1)) continue
130
+ for (const c of (col === 2 ? [2] : [col, cells - 1 - col])) {
131
+ rects += `<rect x="${(c * unit).toFixed(2)}" y="${(row * unit).toFixed(2)}" width="${unit.toFixed(2)}" height="${unit.toFixed(2)}"/>`
132
+ }
133
+ }
134
+ }
135
+ const id = 'g' + (h % 100000)
136
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">` +
137
+ `<defs><linearGradient id="${id}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${bg1}"/><stop offset="1" stop-color="${bg2}"/></linearGradient></defs>` +
138
+ `<rect width="${size}" height="${size}" rx="${(size * 0.5).toFixed(2)}" fill="url(#${id})"/>` +
139
+ `<g fill="${fg}" transform="translate(${(size * 0.12).toFixed(2)} ${(size * 0.12).toFixed(2)}) scale(0.76)">${rects}</g></svg>`
140
+ }
141
+ /** El avatar como data-URI listo para `<img src>` o `background-image`. */
142
+ export function avatarDataUri (seed, opts) {
143
+ return 'data:image/svg+xml,' + encodeURIComponent(avatarSvg(seed, opts))
144
+ }
145
+
102
146
  /** Cuerpo canónico del certificado (lo que se firma): el cert SIN la firma. */
103
147
  export function delegationBody (cert) {
104
148
  return { v: cert.v, iss: cert.iss, sub: cert.sub, scope: cert.scope, iat: cert.iat, exp: cert.exp, nonce: cert.nonce }
package/vault/core.js CHANGED
@@ -29,6 +29,10 @@ export const DELEGATIONS_STORAGE = 'dotrino.identity.delegations' // caps emit
29
29
  export const REVOCATIONS_STORAGE = 'dotrino.identity.revocations' // nonces revocados
30
30
  export const VAULT_DEVICE_STORAGE = 'dotrino.identity.vault.device' // sub-clave D de ESTE dispositivo (custodia en el iframe)
31
31
  export const VAULT_CERT_STORAGE = 'dotrino.identity.vault.cert' // { cert, master, proxy, deviceId, pairedAt }
32
+ // Multi-perfil por dispositivo: lista de perfiles + el activo. Cada perfil tiene su propio
33
+ // namespace `dotrino.identity.p.<id>.<suffix>` para TODAS las claves de arriba (keypair, me, etc.).
34
+ export const PROFILES_STORAGE = 'dotrino.identity.profiles' // [{ id, name, pubkey }]
35
+ export const CURRENT_STORAGE = 'dotrino.identity.current' // id del perfil activo
32
36
 
33
37
  const NONCE_TTL_MS = 5 * 60 * 1000
34
38
 
@@ -104,11 +108,27 @@ async function verifyBytes (publicJwkStr, bytes, signatureBase64) {
104
108
  * @returns {Promise<{ handlers:Object, get me():Object, sync:Object|null,
105
109
  * onSyncStatus(fn):void }>}
106
110
  */
107
- export async function createIdentityCore ({ kv, peers, makeSync = null }) {
111
+ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null }) {
108
112
  const {
109
113
  initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
110
114
  } = peers
111
115
 
116
+ // ----- multi-perfil: kv SCOPEADO por el perfil activo -----
117
+ // Todas las claves `dotrino.identity.*` (keypair, me, nonces, delegations, vault.*) se
118
+ // namespacean transparentemente bajo `dotrino.identity.p.<currentPid>.*`. Las dos claves
119
+ // globales (lista de perfiles + activo) usan el kv crudo. Cambiar de perfil = setear el
120
+ // activo; la app recarga la página y re-inicializa con el nuevo (no reactivo, por diseño).
121
+ let currentPid = null
122
+ const _scoped = (k) => (!currentPid || k === PROFILES_STORAGE || k === CURRENT_STORAGE)
123
+ ? k : k.replace(/^dotrino\.identity\./, `dotrino.identity.p.${currentPid}.`)
124
+ const kv = {
125
+ getItem: (k) => rawKv.getItem(_scoped(k)),
126
+ setItem: (k, v) => rawKv.setItem(_scoped(k), v),
127
+ removeItem: (k) => rawKv.removeItem(_scoped(k))
128
+ }
129
+ const loadProfiles = () => { try { return JSON.parse(rawKv.getItem(PROFILES_STORAGE) || '[]') || [] } catch { return [] } }
130
+ const saveProfiles = (list) => rawKv.setItem(PROFILES_STORAGE, JSON.stringify(list))
131
+
112
132
  // ----- keypair loaders (kv-backed) -----
113
133
 
114
134
  async function loadOrCreateKeypair () {
@@ -175,6 +195,16 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
175
195
  // Canal de eventos 'vault' (p.ej. el SAS a comparar durante el emparejamiento).
176
196
  const vaultListeners = new Set()
177
197
  const emitVault = (p) => { for (const fn of vaultListeners) { try { fn(p) } catch (_) {} } }
198
+ // Si el vault RECHAZA por cert revocado, este dispositivo perdió el acceso: limpiamos el
199
+ // cert local (ya no sirve) y emitimos 'revoked' → @dotrino/store borra SOLO el store de
200
+ // ESTE perfil (los demás perfiles quedan intactos). Cualquier otro error se propaga igual.
201
+ const handleVaultError = (e) => {
202
+ if (e && /\brevoked\b/.test(e.message || '')) {
203
+ try { kv.removeItem(VAULT_CERT_STORAGE); kv.removeItem(VAULT_DEVICE_STORAGE) } catch (_) {}
204
+ emitVault({ phase: 'revoked' })
205
+ }
206
+ throw e
207
+ }
178
208
  const loadVaultCert = () => { try { return JSON.parse(kv.getItem(VAULT_CERT_STORAGE) || 'null') } catch (_) { return null } }
179
209
  const loadVaultDevice = () => { try { return JSON.parse(kv.getItem(VAULT_DEVICE_STORAGE) || 'null') } catch (_) { return null } }
180
210
 
@@ -511,6 +541,54 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
511
541
  }
512
542
  },
513
543
 
544
+ // ----- perfiles (multi-perfil por dispositivo) -----
545
+ // Cambiar/crear setea el perfil activo; la app RECARGA la página y re-inicializa con él
546
+ // (no reactivo, por diseño). Las apps abiertas conservan el perfil con el que cargaron.
547
+ async listProfiles () {
548
+ return loadProfiles().map((p) => ({ id: p.id, name: p.name || '', pubkey: p.pubkey || null, current: p.id === currentPid }))
549
+ },
550
+ async currentProfile () {
551
+ const e = loadProfiles().find((p) => p.id === currentPid) || {}
552
+ return { id: currentPid, name: e.name || me?.nickname || '', pubkey: publickeyJwkStr }
553
+ },
554
+ async createProfile ({ name } = {}) {
555
+ const pid = 'p' + crypto.randomUUID().slice(0, 8)
556
+ currentPid = pid
557
+ rawKv.setItem(CURRENT_STORAGE, pid)
558
+ await peers.setProfile?.(pid)
559
+ await initPeerStorage()
560
+ keypair = await loadOrCreateKeypair(); publickeyJwkStr = JSON.stringify(keypair.publicJwk)
561
+ encKeypair = await loadOrCreateEncKeypair(); encPublickeyJwkStr = JSON.stringify(encKeypair.publicJwk)
562
+ me = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, nickname: String(name || '').slice(0, 40) }
563
+ saveMe(me)
564
+ const list = loadProfiles(); list.push({ id: pid, name: me.nickname, pubkey: publickeyJwkStr }); saveProfiles(list)
565
+ return { id: pid, name: me.nickname, pubkey: publickeyJwkStr }
566
+ },
567
+ async switchProfile ({ id } = {}) {
568
+ if (!loadProfiles().find((p) => p.id === id)) throw new Error('perfil no existe')
569
+ rawKv.setItem(CURRENT_STORAGE, id) // la app recarga la página → re-init con el nuevo perfil
570
+ return { id }
571
+ },
572
+ async renameProfile ({ id, name } = {}) {
573
+ const list = loadProfiles(); const e = list.find((p) => p.id === (id || currentPid))
574
+ if (!e) throw new Error('perfil no existe')
575
+ e.name = String(name || '').slice(0, 40); saveProfiles(list)
576
+ if (e.id === currentPid) { me = { ...(me || {}), nickname: e.name }; saveMe(me) }
577
+ return { id: e.id, name: e.name }
578
+ },
579
+ async deleteProfile ({ id } = {}) {
580
+ let list = loadProfiles()
581
+ if (list.length <= 1) throw new Error('no se puede borrar el único perfil')
582
+ if (!list.find((p) => p.id === id)) throw new Error('perfil no existe')
583
+ list = list.filter((p) => p.id !== id); saveProfiles(list)
584
+ // Borrado directo del namespace del perfil (incluye su store del vault si lo tuviera).
585
+ for (const s of ['keypair', 'enc-keypair', 'me', 'nonces', 'delegations', 'revocations', 'vault.device', 'vault.cert']) {
586
+ rawKv.removeItem(`dotrino.identity.p.${id}.${s}`)
587
+ }
588
+ if (currentPid === id) { currentPid = list[0].id; rawKv.setItem(CURRENT_STORAGE, currentPid) }
589
+ return { ok: true, current: currentPid }
590
+ },
591
+
514
592
  // ----- emparejar ESTE dispositivo con el vault del usuario (Fase 1) -----
515
593
  // Genera D aquí dentro (su privada NUNCA sale de la identidad), hace el enroll
516
594
  // endurecido por el proxy y guarda el cert. NO cambia signData todavía (Fase 2).
@@ -545,7 +623,8 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
545
623
  async vaultSign ({ payload }) {
546
624
  const v = loadVaultCert(); const device = loadVaultDevice()
547
625
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
548
- return remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload })
626
+ try { return await remoteSign({ master: v.master, proxy: v.proxy, device, cert: v.cert, payload }) }
627
+ catch (e) { return handleVaultError(e) }
549
628
  },
550
629
 
551
630
  // Store DELEGADO: lee/escribe el store de hilos+aperturas EN tu vault (con el cert).
@@ -553,14 +632,16 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
553
632
  async vaultStore ({ method, args }) {
554
633
  const v = loadVaultCert(); const device = loadVaultDevice()
555
634
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
556
- return remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method, args })
635
+ try { return await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method, args }) }
636
+ catch (e) { return handleVaultError(e) }
557
637
  },
558
638
 
559
639
  // Lista (solo lectura) de dispositivos enrolados en tu vault.
560
640
  async listVaultDevices () {
561
641
  const v = loadVaultCert(); const device = loadVaultDevice()
562
642
  if (!v?.cert || !device) throw new Error('este dispositivo no está emparejado con un vault')
563
- return remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert })
643
+ try { return await remoteDevices({ master: v.master, proxy: v.proxy, device, cert: v.cert }) }
644
+ catch (e) { return handleVaultError(e) }
564
645
  },
565
646
 
566
647
  // El cert de delegación de este dispositivo (para presentarlo al proxy en `identify`
@@ -671,6 +752,24 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
671
752
 
672
753
  // ----- bootstrap -----
673
754
 
755
+ // Perfil activo (multi-perfil por dispositivo). Sin migración (ecosistema nuevo, no importa
756
+ // perder data): si no hay perfiles, se crea uno fresco. A partir de acá `kv` está scopeado a
757
+ // `currentPid` y `peers` apunta al peer book de ese perfil.
758
+ {
759
+ let profiles = loadProfiles()
760
+ currentPid = rawKv.getItem(CURRENT_STORAGE)
761
+ if (!profiles.length) {
762
+ currentPid = 'p' + crypto.randomUUID().slice(0, 8)
763
+ profiles = [{ id: currentPid, name: '', pubkey: null }]
764
+ saveProfiles(profiles)
765
+ rawKv.setItem(CURRENT_STORAGE, currentPid)
766
+ } else if (!currentPid || !profiles.find((p) => p.id === currentPid)) {
767
+ currentPid = profiles[0].id
768
+ rawKv.setItem(CURRENT_STORAGE, currentPid)
769
+ }
770
+ }
771
+ await peers.setProfile?.(currentPid)
772
+
674
773
  keypair = await loadOrCreateKeypair()
675
774
  publickeyJwkStr = JSON.stringify(keypair.publicJwk)
676
775
  encKeypair = await loadOrCreateEncKeypair()
@@ -690,6 +789,14 @@ export async function createIdentityCore ({ kv, peers, makeSync = null }) {
690
789
  kv.setItem(ME_STORAGE, JSON.stringify(me))
691
790
  }
692
791
 
792
+ // Registrar el pubkey (y nombre) del perfil activo en su meta → para avatar/listado sin abrir cada perfil.
793
+ {
794
+ const list = loadProfiles(); const e = list.find((p) => p.id === currentPid)
795
+ if (e && (e.pubkey !== publickeyJwkStr || (!e.name && me?.nickname))) {
796
+ e.pubkey = publickeyJwkStr; if (!e.name && me?.nickname) e.name = me.nickname; saveProfiles(list)
797
+ }
798
+ }
799
+
693
800
  if (typeof makeSync === 'function') {
694
801
  sync = makeSync({
695
802
  fileName: 'dotrino-identity-backup.json',
@@ -31,10 +31,15 @@ let _fallback = false
31
31
  let _idb = null
32
32
  let _writeChain = Promise.resolve()
33
33
  let _markDirty = null
34
+ let _pid = null
34
35
 
35
36
  /** Registra el callback que marca el estado como "sucio" para el sync. */
36
37
  export function onDirty (fn) { _markDirty = fn }
37
38
 
39
+ /** Multi-perfil: namespacea el peer book por perfil. El core lo llama antes de initPeerStorage. */
40
+ export function setProfile (pid) { _pid = pid || null }
41
+ function peersKey () { return _pid ? `peers.${_pid}.v1` : IDB_PEERS_KEY }
42
+
38
43
  function openIdb () {
39
44
  return new Promise((resolve, reject) => {
40
45
  let req
@@ -74,43 +79,28 @@ export async function initPeerStorage () {
74
79
  catch (_) { /* best-effort */ }
75
80
  try {
76
81
  _idb = await openIdb()
77
- const stored = await idbGet(_idb, IDB_PEERS_KEY)
78
- const storedPeers = (stored && typeof stored === 'object') ? stored : {}
79
- const migratedFlag = await idbGet(_idb, IDB_MIGRATED_KEY)
80
- if (migratedFlag) {
81
- // Ya reconciliado: IndexedDB es la fuente de verdad (ignora el LS viejo).
82
- _peers = storedPeers
83
- } else {
84
- // Primera corrida (o reintento tras el bug que escribía {}): unimos el
85
- // peer book del localStorage viejo con lo que haya en IndexedDB —unión,
86
- // IndexedDB gana en conflictos— para recuperar contactos que un bug previo
87
- // pudo enmascarar. Nunca borra. Marcamos el flag para no rehacerlo (así no
88
- // se "resucitan" contactos que el usuario borre más adelante).
89
- const local = readLocalPeers()
90
- _peers = { ...local, ...storedPeers }
91
- const recovered = Object.keys(local).filter(k => !(k in storedPeers)).length
92
- await idbPut(_idb, IDB_PEERS_KEY, _peers)
93
- await idbPut(_idb, IDB_MIGRATED_KEY, true)
94
- if (recovered) console.log(`[cc-identity] ${recovered} peer(s) recuperados del localStorage viejo → IndexedDB`)
95
- }
82
+ const stored = await idbGet(_idb, peersKey()) // peer book DEL perfil activo (namespaceado)
83
+ _peers = (stored && typeof stored === 'object') ? stored : {}
96
84
  } catch (e) {
97
85
  console.warn('[cc-identity] IndexedDB no disponible, uso localStorage:', e?.message)
98
86
  _fallback = true
99
87
  _idb = null
100
- _peers = readLocalPeers()
88
+ try { const raw = localStorage.getItem(peersKey()); _peers = raw ? (JSON.parse(raw) || {}) : {} }
89
+ catch (_) { _peers = {} }
101
90
  }
102
91
  return _peers
103
92
  }
104
93
 
105
94
  function persistPeers () {
95
+ const key = peersKey()
106
96
  if (_fallback || !_idb) {
107
- try { localStorage.setItem(PEERS_STORAGE, JSON.stringify(_peers)) }
97
+ try { localStorage.setItem(key, JSON.stringify(_peers)) }
108
98
  catch (e) { console.warn('[cc-identity] persist (ls) falló:', e?.message) }
109
99
  return _writeChain
110
100
  }
111
101
  const snapshot = _peers
112
102
  _writeChain = _writeChain
113
- .then(() => idbPut(_idb, IDB_PEERS_KEY, snapshot))
103
+ .then(() => idbPut(_idb, key, snapshot))
114
104
  .catch(e => console.warn('[cc-identity] persist (idb) falló:', e?.message))
115
105
  return _writeChain
116
106
  }
@@ -143,5 +133,5 @@ export function upsertPeer (publickey, patch) {
143
133
  // Sólo para tests: resetea el estado del módulo (y cierra la conexión IDB).
144
134
  export function _resetForTest () {
145
135
  try { if (_idb && _idb.close) _idb.close() } catch (_) {}
146
- _peers = {}; _fallback = false; _idb = null; _writeChain = Promise.resolve(); _markDirty = null
136
+ _peers = {}; _fallback = false; _idb = null; _writeChain = Promise.resolve(); _markDirty = null; _pid = null
147
137
  }