@dotrino/identity 0.104.1 → 0.105.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.104.1",
3
+ "version": "0.105.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
@@ -348,6 +348,8 @@ export class Identity {
348
348
 
349
349
  /** Cambia las capacidades de un miembro (solo el master). */
350
350
  async setCaps (pub, caps) { return this._call('setCaps', { pub, caps }) }
351
+ /** Cambia las capacidades de VARIOS miembros en una sola acta: `[{ pub, caps }]`. */
352
+ async setCapsMany (changes) { return this._call('setCapsMany', { changes }) }
351
353
  async setLabel (pub, label) { return this._call('setLabel', { pub, label }) }
352
354
 
353
355
  /** Expulsa a un miembro (solo el master; al master no se le puede expulsar). */
package/src/node.js CHANGED
@@ -228,6 +228,7 @@ export class Identity {
228
228
  /** Registra la llave de cifrado de un miembro ya admitido (evita re-enrolarlo). */
229
229
  setMemberEncPub (args) { return this._h('setMemberEncPub', args) }
230
230
  setCaps (pub, caps) { return this._h('setCaps', { pub, caps }) }
231
+ setCapsMany (changes) { return this._h('setCapsMany', { changes }) }
231
232
  setLabel (pub, label) { return this._h('setLabel', { pub, label }) }
232
233
  removeMember (pub) { return this._h('removeMember', { pub }) }
233
234
  handoverMaster (to, member = null) { return this._h('handoverMaster', { to, member }) }
package/vault/core.js CHANGED
@@ -973,19 +973,6 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
973
973
  keyring: loadActa()?.keyring, myPub: publickeyJwkStr, myEncPrivateKey: encKeypair.privateKey
974
974
  })
975
975
 
976
- /** Envuelve la clave vigente para un miembro recién admitido (no hace falta rotar). */
977
- async function wrapForNewMember (pub) {
978
- const acta = loadActa()
979
- const gen = (acta?.keyring || []).at(-1)
980
- const m = acta?.members.find((x) => x.pub === pub)
981
- if (!gen || !m?.encPub) return false
982
- const mine = await myCek()
983
- if (!mine) return false
984
- const wrap = await Content.wrapForMember({ cek: mine.cek, memberEncPub: m.encPub })
985
- await sealChanges([{ op: 'wrap', gen: mine.gen, pub, wrap }])
986
- return true
987
- }
988
-
989
976
  /**
990
977
  * QUITAR UN APARATO, entero y de una vez.
991
978
  *
@@ -2571,12 +2558,21 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
2571
2558
  // `cn`: si viene, este miembro es un SERVICIO y su único permiso es abrir el cajón de
2572
2559
  // secretos de ESE nombre — no ve nada más del usuario. Sin `cn` es un dispositivo.
2573
2560
  const finales = caps || (cn ? ['secrets'] : ['store', 'read'])
2574
- const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, cn, caps: finales, cert, continuity } }])
2575
2561
  // Que entre al perfil incluye poder LEER lo que ya hay: se le envuelve la clave
2576
2562
  // vigente (no hace falta rotar; rotar es para cuando alguien SALE).
2577
- let wrapped = false
2578
- try { wrapped = await wrapForNewMember(pub) } catch (_) {}
2579
- return { ok: true, seq: acta.seq, wrapped }
2563
+ //
2564
+ // EN LA MISMA ACTA que la admisión (dueño, 2026-09-26: nada de actas inútiles). Antes
2565
+ // se sellaba una para admitir y otra para envolver, y cada acta es un aviso a todos
2566
+ // los aparatos. La envoltura solo necesita la llave de cifrado del que entra, que ya
2567
+ // viene aquí. Si no se puede envolver (sin clave propia, sin `encPub`), entra igual y
2568
+ // `wrapped` lo dice — lo mismo que antes.
2569
+ let wrap = null
2570
+ try {
2571
+ const mine = encPub ? await myCek() : null
2572
+ if (mine) wrap = { op: 'wrap', gen: mine.gen, pub, wrap: await Content.wrapForMember({ cek: mine.cek, memberEncPub: encPub }) }
2573
+ } catch (_) { wrap = null }
2574
+ const acta = await sealChanges([{ op: 'admit', member: { pub, encPub, label, cn, caps: finales, cert, continuity } }, ...(wrap ? [wrap] : [])])
2575
+ return { ok: true, seq: acta.seq, wrapped: !!wrap }
2580
2576
  },
2581
2577
 
2582
2578
  async setCaps ({ pub, caps } = {}) {
@@ -2584,6 +2580,18 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
2584
2580
  return { ok: true, seq: acta.seq }
2585
2581
  },
2586
2582
 
2583
+ /**
2584
+ * LOS PERMISOS DE VARIOS APARATOS EN UNA SOLA ACTA (dueño, 2026-09-26). Editar tres
2585
+ * aparatos eran tres actas y tres avisos; ahora el borrador entero se sella de golpe.
2586
+ * `changes`: `[{ pub, caps }]`, cada `caps` la lista COMPLETA de ese aparato.
2587
+ * @param {{ changes?: Array<{ pub: string, caps: string[] }> }} [args]
2588
+ */
2589
+ async setCapsMany ({ changes } = {}) {
2590
+ if (!Array.isArray(changes) || !changes.length) throw new Error('setCapsMany: no changes')
2591
+ const acta = await sealChanges(changes.map(({ pub, caps }) => ({ op: 'caps', pub, caps })))
2592
+ return { ok: true, seq: acta.seq }
2593
+ },
2594
+
2587
2595
  /**
2588
2596
  * RENOMBRA un miembro (el nombre con el que lo reconoces). Se escribe en el acta y
2589
2597
  * TAMBIÉN en la delegación: son dos registros distintos —el acta dice quién es del
@@ -79,83 +79,3 @@ export async function nativeBackends (bridge) {
79
79
  return { kv, keyStore, peers, flush: () => chain }
80
80
  }
81
81
 
82
- const PROFILES = 'dotrino.identity.profiles'
83
- const CURRENT = 'dotrino.identity.current'
84
- const scopedKey = (pid, k) => `dotrino.identity.p.${pid}.${k}`
85
-
86
- /**
87
- * LOS PERFILES QUE SE QUEDARON EN LA PÁGINA pasan a la app (dueño, 2026-09-26: «todas las
88
- * pestañas usan el mismo perfil, pero NO es el de Dotrino que está en Pedidos»).
89
- *
90
- * Antes de `nativeStore` cada página guardaba la identidad aparte, así que la cuenta que se
91
- * emparejó en `vault.dotrino.com/d` quedó en el almacén de ESA página: su llave está en el
92
- * chip (Pedidos la usa), pero su acta, su papel y su nombre no llegaron a la app, y la app
93
- * estrenó un perfil vacío. Esto la trae.
94
- *
95
- * Solo se trae un perfil cuya llave VIVE en el chip (`open` contesta): uno de software no
96
- * cabe, y uno cuya llave ya se borró no sirve para nada. Lo que la app ya tiene no se pisa.
97
- * Y si el perfil activo de la app no está emparejado y el traído sí, pasa a ser el activo:
98
- * el vacío se inventó justo porque faltaba este.
99
- *
100
- * `page`: lo que hay en el almacén de ESTA página — `kv` (entradas del localStorage),
101
- * `keys` (registros del IndexedDB de llaves) y `peers` (libros de contactos).
102
- */
103
- export async function adoptPageProfiles (native, bridge, page) {
104
- const mine = JSON.parse(native.kv.getItem(PROFILES) || '[]')
105
- const theirs = JSON.parse(page.kv.get(PROFILES) || '[]')
106
- const adopted = []
107
- for (const p of theirs) {
108
- if (!p?.id || mine.some((m) => m.id === p.id)) continue
109
- const sign = page.keys.get(scopedKey(p.id, 'keypair'))
110
- const enc = page.keys.get(scopedKey(p.id, 'enc-keypair'))
111
- if (!sign?.external) continue
112
- try { await bridge.call('open', { kid: sign.external }) } catch (_) { continue }
113
- const prefix = `dotrino.identity.p.${p.id}.`
114
- for (const [k, v] of page.kv) if (k.startsWith(prefix)) native.kv.setItem(k, v)
115
- await native.keyStore.set(scopedKey(p.id, 'keypair'), sign)
116
- if (enc?.external) await native.keyStore.set(scopedKey(p.id, 'enc-keypair'), enc)
117
- const book = page.peers.get(`peers.${p.id}.v1`)
118
- if (book) await native.peers.put(`peers.${p.id}.v1`, book)
119
- mine.push(p)
120
- adopted.push(p.id)
121
- }
122
- if (!adopted.length) return adopted
123
- native.kv.setItem(PROFILES, JSON.stringify(mine))
124
- const cur = native.kv.getItem(CURRENT)
125
- const paired = (pid) => !!native.kv.getItem(scopedKey(pid, 'vault.cert'))
126
- if (!cur || !paired(cur)) {
127
- const next = adopted.find(paired)
128
- if (next) native.kv.setItem(CURRENT, next)
129
- }
130
- console.info(`[cc-identity] adopted ${adopted.length} profile(s) from this page's storage into the app: ${adopted.join(', ')}`)
131
- return adopted
132
- }
133
-
134
- /** Todo lo que la identidad guardó en el almacén de ESTA página (localStorage + sus dos IndexedDB). */
135
- export async function readPageStorage () {
136
- const kv = new Map()
137
- for (let i = 0; i < localStorage.length; i++) {
138
- const k = localStorage.key(i)
139
- if (k && k.startsWith('dotrino.identity.')) kv.set(k, localStorage.getItem(k))
140
- }
141
- return { kv, keys: await idbAll('dotrino-identity-keys', 'keys'), peers: await idbAll('cc-identity', 'kv') }
142
- }
143
-
144
- function idbAll (dbName, store) {
145
- return new Promise((resolve, reject) => {
146
- const req = indexedDB.open(dbName)
147
- req.onupgradeneeded = () => { if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store) }
148
- req.onerror = () => reject(req.error)
149
- req.onsuccess = () => {
150
- const db = req.result
151
- if (!db.objectStoreNames.contains(store)) { db.close(); resolve(new Map()); return }
152
- const out = new Map()
153
- const cur = db.transaction(store, 'readonly').objectStore(store).openCursor()
154
- cur.onsuccess = () => {
155
- const c = cur.result
156
- if (c) { out.set(String(c.key), c.value); c.continue() } else { db.close(); resolve(out) }
157
- }
158
- cur.onerror = () => reject(cur.error)
159
- }
160
- })
161
- }
package/vault/vault.js CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  import { createIdentityCore } from './core.js'
16
16
  import { pubkeyId } from './capabilities.js'
17
17
  import { withExternalKeys, appBridge } from './externalKeys.js'
18
- import { nativeBackends, adoptPageProfiles, readPageStorage } from './nativeStore.js'
18
+ import { nativeBackends } from './nativeStore.js'
19
19
  import { useBackend as usePeerBackend } from './peerStore.js'
20
20
 
21
21
  ;(async () => {
@@ -25,14 +25,7 @@ import { useBackend as usePeerBackend } from './peerStore.js'
25
25
  // Y si la app GUARDA la identidad (`storage`, la de iOS): todo vive allí, uno para todas
26
26
  // las páginas, porque WebKit parte el almacén de este iframe por página (`./nativeStore.js`).
27
27
  const native = bridge && idKeys.storage === true ? await nativeBackends(bridge) : null
28
- if (native) {
29
- usePeerBackend(native.peers)
30
- // Lo que se emparejó cuando la identidad aún vivía en la página, a la app. Si no se puede
31
- // leer el almacén de la página se dice (con su error) y se sigue: la identidad de la app
32
- // funciona igual, solo que sin ese perfil.
33
- try { await adoptPageProfiles(native, bridge, await readPageStorage()) }
34
- catch (e) { console.error('[cc-identity] could not bring this page\'s profiles into the app:', e?.message || e) }
35
- }
28
+ if (native) usePeerBackend(native.peers)
36
29
 
37
30
  // kv estilo localStorage (síncrono) para me, nonces, delegaciones, certs.
38
31
  const kv = native ? native.kv : {
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.73.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
1
+ Copia vendorizada de @dotrino/vault@0.74.0 (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
@@ -30,7 +30,7 @@
30
30
  * `docs/pairing-protocol.md`).
31
31
  */
32
32
  import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
33
- import { verifyContinuity, canSeal } from '@dotrino/identity/acta'
33
+ import { verifyContinuity, canSeal, DEVICE_CAPS } from '@dotrino/identity/acta'
34
34
 
35
35
  /** Un token de emparejamiento vale 5 min. */
36
36
  export const PAIRING_TTL_MS = 5 * 60 * 1000
@@ -159,8 +159,24 @@ export function createEnrollDesk ({
159
159
  * · `account` → cómo se llama la cuenta de la bóveda, para nombrarla en el
160
160
  * aviso. Es ORIENTATIVO (un nombre que puso su dueño); la
161
161
  * identidad de verdad de la cuenta es `iss`.
162
+ * · `caps` → LOS PERMISOS con los que entra al acta, elegidos por quien
163
+ * empareja (dueño, 2026-09-26: «al emparejar debería escoger los
164
+ * permisos, caso contrario estoy obligado a hacer dos actas»). Sin
165
+ * esto el aparato entraba con lo que da el scope y cualquier otro
166
+ * permiso —`admin`, `approve`…— costaba una segunda acta. Se quedan
167
+ * AQUÍ, en la sesión de la bóveda: NO viajan en el QR, así que la
168
+ * invitación que circula no concede nada; lo concede quien teclea
169
+ * el código, igual que hacía `caps` después.
162
170
  */
163
- async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '' } = {}) {
171
+ async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '', caps = null } = {}) {
172
+ if (caps != null) {
173
+ if (!Array.isArray(caps)) throw Object.assign(new Error('caps must be a list'), { code: 'bad-caps' })
174
+ const cn = scopeToCn(scope)
175
+ const bad = caps.filter((c) => !(DEVICE_CAPS.includes(c) || (c === 'secrets' && cn)))
176
+ if (bad.length) throw Object.assign(new Error('unknown permission(s): ' + bad.join(', ')), { code: 'bad-caps' })
177
+ if (!caps.length) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
178
+ caps = [...new Set(caps)]
179
+ }
164
180
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
165
181
  const acct = String(account || '').slice(0, 40)
166
182
  // INVITACIÓN CORTA: si sabemos cómo alcanzarnos, el QR lleva solo eso y el
@@ -176,12 +192,12 @@ export function createEnrollDesk ({
176
192
  const conn = typeof connToken === 'function' ? await connToken() : connToken
177
193
  if (conn) {
178
194
  const sn = randToken(8)
179
- pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
195
+ pending.set(sn, { token: sn, exp: Date.now() + PAIRING_TTL_MS, scope, caps, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
180
196
  return { token: sn, qr: { v: 2, conn, sn, m: mode, proxy }, expiresInMs: PAIRING_TTL_MS }
181
197
  }
182
198
  const token = randToken(PAIR_TOKEN_BYTES)
183
199
  const sn = randToken(PAIR_TOKEN_BYTES)
184
- pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
200
+ pending.set(token, { token, exp: Date.now() + PAIRING_TTL_MS, scope, caps, ttlMs, label, sn, mode, account: acct, state: 'AWAITING_ENROLL' })
185
201
  return { token, qr: { v: 2, iss, proxy, token, sn, m: mode, ...(acct ? { acct } : {}) }, expiresInMs: PAIRING_TTL_MS }
186
202
  }
187
203
 
@@ -364,8 +380,10 @@ export function createEnrollDesk ({
364
380
  // PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
365
381
  // cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
366
382
  // con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
383
+ // Si quien emparejó ELIGIÓ los permisos (`startPairing({ caps })`), entran esos y en
384
+ // esta misma acta; si no, los que da el scope.
367
385
  const cn = scopeToCn(pend.scope)
368
- const caps = [...new Set([...scopeToCaps(pend.scope), ...(cn ? ['secrets'] : [])])]
386
+ const caps = [...new Set([...(pend.caps || scopeToCaps(pend.scope)), ...(cn ? ['secrets'] : [])])]
369
387
  if (!caps.length) {
370
388
  throw Object.assign(new Error('the pairing scope grants no permission: no certificate was issued'), { code: 'empty-scope' })
371
389
  }