@dotrino/identity 0.16.0 → 0.17.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.16.0",
3
+ "version": "0.17.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
@@ -306,6 +306,22 @@ export class Identity {
306
306
  return result
307
307
  }
308
308
 
309
+ /**
310
+ * Actualiza tu PERFIL (merge): `{ nickname?, avatar?, avatarVisible?, links?, fields? }`.
311
+ * `avatar` = data-URI 250×250 (o null para quitarla); `links`/`fields` = arrays con `visible`
312
+ * por ítem (oculto = no se comparte). No pisa lo que no mandes.
313
+ */
314
+ async updateMe (patch) {
315
+ const result = await this._call('updateMe', { patch })
316
+ if (result?.me) this._me = result.me
317
+ return result
318
+ }
319
+
320
+ /** Tu `me` completo (incluye ocultos). */
321
+ async getMe () { return this._call('getMe') }
322
+ /** Subconjunto PÚBLICO de tu perfil (solo lo visible) — para compartir/publicar. */
323
+ async publicMe () { return this._call('publicMe') }
324
+
309
325
  /** Pubkey ECDH (JWK string) propio para encripción. */
310
326
  async getEncryptionPubkey () {
311
327
  return this._call('getEncryptionPubkey')
package/src/node.js CHANGED
@@ -50,6 +50,16 @@ function filePeers (filePath) {
50
50
  }
51
51
  return {
52
52
  setProfile (p) { pid = p || null },
53
+ adoptLegacy (newPid) {
54
+ // Migración: copia el peers.json viejo (sin namespace) al perfil newPid.
55
+ try {
56
+ if (!fs.existsSync(filePath)) return
57
+ const legacy = JSON.parse(fs.readFileSync(filePath, 'utf8'))
58
+ if (legacy && typeof legacy === 'object' && Object.keys(legacy).length) {
59
+ fs.writeFileSync(path.join(path.dirname(filePath), `peers.${newPid}.json`), JSON.stringify(legacy))
60
+ }
61
+ } catch (_) { /* sin peers viejos */ }
62
+ },
53
63
  async initPeerStorage () {
54
64
  const f = fileFor()
55
65
  try {
@@ -165,6 +175,9 @@ export class Identity {
165
175
  const result = await this._h('setMyNickname', { nickname })
166
176
  return result
167
177
  }
178
+ async updateMe (patch) { return this._h('updateMe', { patch }) }
179
+ getMe () { return this._h('getMe') }
180
+ publicMe () { return this._h('publicMe') }
168
181
  getEncryptionPubkey () { return this._h('getEncryptionPubkey') }
169
182
  encrypt (recipients, plaintext) { return this._h('encrypt', { recipients, plaintext }) }
170
183
  decrypt (senderEncryptionPubkey, myToken, envelope) {
package/vault/core.js CHANGED
@@ -108,6 +108,33 @@ async function verifyBytes (publicJwkStr, bytes, signatureBase64) {
108
108
  * @returns {Promise<{ handlers:Object, get me():Object, sync:Object|null,
109
109
  * onSyncStatus(fn):void }>}
110
110
  */
111
+ // Sanea un patch de perfil (avatar/links/fields/nickname). Cada link/field lleva `visible`
112
+ // (oculto = no se comparte). Caps de tamaño para no inflar el `me`. Los ids los pone la UI.
113
+ function sanitizeProfilePatch (patch = {}) {
114
+ const out = {}
115
+ if (typeof patch.nickname === 'string') out.nickname = patch.nickname.slice(0, 40)
116
+ if (patch.avatar === null) out.avatar = null
117
+ else if (typeof patch.avatar === 'string') out.avatar = patch.avatar.slice(0, 120000) // ~90KB: data-URI 250x250
118
+ if (typeof patch.avatarVisible === 'boolean') out.avatarVisible = patch.avatarVisible
119
+ if (Array.isArray(patch.links)) {
120
+ out.links = patch.links.slice(0, 12).map((l) => ({
121
+ id: String(l?.id || '').slice(0, 24),
122
+ type: String(l?.type || 'web').slice(0, 16),
123
+ value: String(l?.value || '').slice(0, 200),
124
+ visible: l?.visible !== false
125
+ })).filter((l) => l.value)
126
+ }
127
+ if (Array.isArray(patch.fields)) {
128
+ out.fields = patch.fields.slice(0, 20).map((f) => ({
129
+ id: String(f?.id || '').slice(0, 24),
130
+ label: String(f?.label || '').slice(0, 40),
131
+ value: String(f?.value || '').slice(0, 280),
132
+ visible: f?.visible !== false
133
+ })).filter((f) => f.label || f.value)
134
+ }
135
+ return out
136
+ }
137
+
111
138
  export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null }) {
112
139
  const {
113
140
  initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
@@ -354,6 +381,20 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
354
381
 
355
382
  // ----- handlers (idénticos a la versión iframe) -----
356
383
 
384
+ // Merge de un patch de perfil en `me` (preserva lo demás), saneado. Refleja el nombre en la
385
+ // meta del perfil (para el switcher). Devuelve el `me` resultante.
386
+ function applyMeUpdate (patch) {
387
+ const clean = sanitizeProfilePatch(patch || {})
388
+ me = { ...(me || {}), ...clean, publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr }
389
+ if (clean.avatar === null) delete me.avatar
390
+ saveMe(me)
391
+ if (typeof clean.nickname === 'string') {
392
+ const list = loadProfiles(); const e = list.find((p) => p.id === currentPid)
393
+ if (e && e.name !== clean.nickname) { e.name = clean.nickname; saveProfiles(list) }
394
+ }
395
+ return me
396
+ }
397
+
357
398
  const handlers = {
358
399
  async makeChallenge () {
359
400
  const nonce = crypto.randomUUID()
@@ -656,9 +697,24 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
656
697
  },
657
698
 
658
699
  async setMyNickname ({ nickname }) {
659
- const next = { publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr, nickname: String(nickname || '').slice(0, 40) }
660
- saveMe(next)
661
- return { me: next }
700
+ return { me: applyMeUpdate({ nickname }) }
701
+ },
702
+
703
+ // Perfil completo (avatar 250x250, links de redes, datos), cada ítem con `visible`
704
+ // (oculto = no se comparte). Merge: no pisa lo que no venga en el patch.
705
+ async updateMe ({ patch } = {}) {
706
+ return { me: applyMeUpdate(patch || {}) }
707
+ },
708
+ async getMe () { return me },
709
+ // Subconjunto PÚBLICO del perfil (solo lo marcado visible) — para compartir/publicar.
710
+ async publicMe () {
711
+ const m = me || {}
712
+ const out = { publickey: m.publickey, encryptionPubkey: m.encryptionPubkey }
713
+ if (m.nickname) out.nickname = m.nickname
714
+ if (m.avatar && m.avatarVisible !== false) out.avatar = m.avatar
715
+ if (Array.isArray(m.links)) { const v = m.links.filter((l) => l.visible !== false).map(({ visible, ...r }) => r); if (v.length) out.links = v }
716
+ if (Array.isArray(m.fields)) { const v = m.fields.filter((f) => f.visible !== false).map(({ visible, ...r }) => r); if (v.length) out.fields = v }
717
+ return out
662
718
  },
663
719
 
664
720
  async getEncryptionPubkey () { return encPublickeyJwkStr },
@@ -752,17 +808,27 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null })
752
808
 
753
809
  // ----- bootstrap -----
754
810
 
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.
811
+ // Perfil activo (multi-perfil por dispositivo). Si no hay perfiles, se crea el primero; si
812
+ // existe una identidad ÚNICA vieja (pre-multi-perfil, claves sin namespace), se ADOPTA como
813
+ // "Perfil 1" sin pérdida. A partir de acá `kv` está scopeado a `currentPid`.
758
814
  {
759
815
  let profiles = loadProfiles()
760
816
  currentPid = rawKv.getItem(CURRENT_STORAGE)
761
817
  if (!profiles.length) {
762
- currentPid = 'p' + crypto.randomUUID().slice(0, 8)
763
- profiles = [{ id: currentPid, name: '', pubkey: null }]
818
+ const pid = 'p' + crypto.randomUUID().slice(0, 8)
819
+ // Migración: adoptar la identidad única vieja (si la hay) copiando sus claves al namespace de pid.
820
+ const legacy = rawKv.getItem(KEY_STORAGE)
821
+ if (legacy) {
822
+ for (const s of ['keypair', 'enc-keypair', 'me', 'nonces', 'delegations', 'revocations', 'vault.device', 'vault.cert']) {
823
+ const v = rawKv.getItem('dotrino.identity.' + s)
824
+ if (v != null) rawKv.setItem(`dotrino.identity.p.${pid}.${s}`, v)
825
+ }
826
+ try { await peers.adoptLegacy?.(pid) } catch (_) { /* peers viejos opcionales */ }
827
+ }
828
+ currentPid = pid
829
+ profiles = [{ id: pid, name: '', pubkey: null }]
764
830
  saveProfiles(profiles)
765
- rawKv.setItem(CURRENT_STORAGE, currentPid)
831
+ rawKv.setItem(CURRENT_STORAGE, pid)
766
832
  } else if (!currentPid || !profiles.find((p) => p.id === currentPid)) {
767
833
  currentPid = profiles[0].id
768
834
  rawKv.setItem(CURRENT_STORAGE, currentPid)
@@ -40,6 +40,20 @@ export function onDirty (fn) { _markDirty = fn }
40
40
  export function setProfile (pid) { _pid = pid || null }
41
41
  function peersKey () { return _pid ? `peers.${_pid}.v1` : IDB_PEERS_KEY }
42
42
 
43
+ /** Migración: copia el peer book VIEJO (pre-multi-perfil, sin namespace) al perfil `pid`. */
44
+ export async function adoptLegacy (pid) {
45
+ try {
46
+ _idb = _idb || await openIdb()
47
+ let legacy = await idbGet(_idb, IDB_PEERS_KEY) // 'peers.v1' (viejo)
48
+ if (!legacy || typeof legacy !== 'object' || !Object.keys(legacy).length) {
49
+ try { const raw = localStorage.getItem(PEERS_STORAGE); legacy = raw ? JSON.parse(raw) : null } catch (_) { legacy = null }
50
+ }
51
+ if (legacy && typeof legacy === 'object' && Object.keys(legacy).length) {
52
+ await idbPut(_idb, `peers.${pid}.v1`, legacy)
53
+ }
54
+ } catch (_) { /* sin peers viejos que adoptar */ }
55
+ }
56
+
43
57
  function openIdb () {
44
58
  return new Promise((resolve, reject) => {
45
59
  let req