@dotrino/identity 0.110.1 → 0.111.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.110.1",
3
+ "version": "0.111.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/vault/acta.js CHANGED
@@ -94,8 +94,8 @@ const conCampoSellador = (acta) => Number(acta?.v) < V_SIN_CAMPO_SELLADOR
94
94
  * traspaso); el permiso dice **quién puede firmar el acta**. Admitir sigue sin ser un
95
95
  * permiso: se admite sellando.
96
96
  *
97
- * `secrets` es distinta de las otras: solo la pueden tener los miembros con **CN**
98
- * (los servicios), y lo que abre es únicamente el cajón de SU nombre. Ver `cn` abajo.
97
+ * El cajón de un servicio NO está en la lista: lo abre su **CN**, no un permiso. Ver
98
+ * `cnScope` abajo.
99
99
  *
100
100
  * `admin` es la rendija que se le abre a la consola remota (`dotrino-vault/docs/
101
101
  * consola-remota.md`): deja **admitir y expulsar** miembros a distancia, y nada más.
@@ -103,7 +103,7 @@ const conCampoSellador = (acta) => Number(acta?.v) < V_SIN_CAMPO_SELLADOR
103
103
  * el rol de master, que no se delega. Así un dispositivo con `admin` robado hace daño
104
104
  * acotado y **reversible** (se le revoca), en vez de poder dejarte fuera de tu cuenta.
105
105
  */
106
- export const CAPS = Object.freeze(['sign', 'store', 'read', 'secrets', 'admin', 'approve', 'passwords', 'passkeys', 'sealer', 'unattended', 'replica'])
106
+ export const CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'passkeys', 'sealer', 'unattended', 'replica'])
107
107
 
108
108
  /** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
109
109
  /**
@@ -158,32 +158,57 @@ export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'app
158
158
  */
159
159
  export const PAIRED_CAPS = Object.freeze(['sign', 'store', 'read'])
160
160
 
161
- /** Capacidades de un SERVICIO (con CN): solo su propio cajón de secretos. */
162
- export const SERVICE_CAPS = Object.freeze(['secrets'])
163
-
164
161
  /**
165
- * Qué capacidades puede llevar un miembro según tenga cajón (CN) o no. Con CN: su cajón
166
- * y, si se le conceden, las de aparato. Sin CN: solo las de aparato (no hay cajón que abrir).
162
+ * EL CAJÓN NO ES UN PERMISO: LO ABRE EL CN (dueño, 2026-10-13).
163
+ *
164
+ * Antes existía la capacidad `secrets`, y abrir el cajón exigía las dos cosas: llevar el
165
+ * `cn` de ese cajón Y tener `secrets`. La segunda no decidía nada —el alta se la ponía sola
166
+ * a todo miembro con `cn`— y en la pantalla de permisos salía como una casilla más al lado
167
+ * de las de verdad. Un servicio abre el cajón de SU nombre porque el acta dice que es ese
168
+ * servicio; para cortárselo se le bloquea o se le quita.
169
+ *
170
+ * Las actas ya selladas que llevan la palabra se siguen leyendo: es una capacidad que este
171
+ * lector no conoce, así que no cuenta (ver `checkShape`).
167
172
  */
168
- export const allowedCaps = (cn) => (cn ? [...SERVICE_CAPS, ...DEVICE_CAPS] : [...DEVICE_CAPS])
173
+
174
+ /** Qué capacidades puede llevar un miembro: las mismas con cajón (CN) que sin él. */
175
+ export const allowedCaps = () => [...DEVICE_CAPS]
169
176
 
170
177
  /** Un CN válido: minúsculas, números y guiones (igual que el namespace de secretos). */
171
178
  export const isValidCn = (cn) => typeof cn === 'string' && /^[a-z0-9-]{1,32}$/.test(cn)
172
179
 
173
180
  /**
174
- * Scope del cert que corresponde a cada capacidad. `secrets` necesita el CN para
175
- * completarse: el miembro `proxy` obtiene `vault:secrets:proxy` y nada más — no existe un
176
- * scope de secretos «de todos».
181
+ * Scope del cert que corresponde a cada capacidad. El del cajón no sale de aquí: lo da el
182
+ * CN (`cnScope`).
177
183
  */
178
- export function capScope (cap, cn = null) {
184
+ export function capScope (cap) {
179
185
  // SALE DE `CAP_SCOPE`, no de una cadena de `if` escrita a mano. Esto era lo segundo: la
180
186
  // lista de arriba y esta se escribían por separado, así que un permiso nuevo entraba en
181
187
  // una y no en la otra y se quedaba sin scope en silencio. Ya pasó con `sealer`,
182
188
  // `unattended` y `secrets`; con `replica` se cortó aquí.
183
- if (cap === 'secrets') return isValidCn(cn) ? 'vault:secrets:' + cn : null
184
189
  return CAP_SCOPE[cap] || null
185
190
  }
186
191
 
192
+ /**
193
+ * EL NOMBRE DE LA PROPIA BÓVEDA NO ES UN SERVICIO, Y NO TIENE CAJÓN.
194
+ *
195
+ * La llave de comunicación de la bóveda entra en el acta con este `cn` para hablar por la
196
+ * bóveda y no firmar por la persona. Es la que firma con el perfil CERRADO, así que un sobre
197
+ * dirigido a ella sería leer secretos sin abrir la bóveda. Mientras existió la capacidad
198
+ * `secrets` lo impedía no tenerla; ahora que el cajón lo abre el `cn`, se dice aquí y por
199
+ * su nombre: este `cn` no abre ninguno, tampoco uno que se llame igual.
200
+ */
201
+ export const VAULT_CN = 'vault'
202
+
203
+ /** ¿Es el `cn` de un servicio con cajón? Válido y distinto del de la propia bóveda. */
204
+ const hasDrawer = (cn) => isValidCn(cn) && cn !== VAULT_CN
205
+
206
+ /**
207
+ * El scope del cajón de un servicio: el miembro `proxy` obtiene `vault:secrets:proxy` y nada
208
+ * más — no existe un scope de secretos «de todos».
209
+ */
210
+ export const cnScope = (cn) => (hasDrawer(cn) ? 'vault:secrets:' + cn : null)
211
+
187
212
  /** Compat: el mapa directo, para las capacidades de dispositivo. */
188
213
  export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read', admin: 'vault:admin', approve: 'vault:approve', passwords: 'vault:passwords', sealer: 'vault:sealer', replica: 'vault:replica' })
189
214
 
@@ -479,11 +504,7 @@ export function checkShape (acta) {
479
504
  // El CN es la frontera: un SERVICIO solo puede abrir su propio cajón, y un
480
505
  // DISPOSITIVO no tiene cajón que abrir. Que no se pueda escribir un acta que
481
506
  // mezcle las dos cosas es lo que hace que el límite sea real y no una costumbre.
482
- if (m.cn != null) {
483
- if (!isValidCn(m.cn)) return 'cn-invalido'
484
- } else if (m.caps.includes('secrets')) {
485
- return 'secretos-sin-cn'
486
- }
507
+ if (m.cn != null && !isValidCn(m.cn)) return 'cn-invalido'
487
508
  // La llave de cifrado: por ahora se valida la FORMA y solo si viene. NO se exige
488
509
  // todavía, aunque el destino sea exigírsela a quien recibe secretos.
489
510
  //
@@ -777,8 +798,8 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
777
798
  cn,
778
799
  // PERMISOS, no tipos (2026-08-22): el CN dice qué cajón abre; no recorta lo demás.
779
800
  // Un miembro con CN puede llevar además las capacidades de aparato (un bot que
780
- // firma como aparato del acta Y lee su cajón). Sin CN, `secrets` no significa nada.
781
- caps: cleanCaps(m.caps).filter((c) => allowedCaps(cn).includes(c)),
801
+ // firma como aparato del acta Y lee su cajón).
802
+ caps: cleanCaps(m.caps),
782
803
  addedAt: now,
783
804
  cert: m.cert || null,
784
805
  // Puente con la identidad que este miembro traía de antes (ver makeContinuity).
@@ -803,8 +824,8 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
803
824
  const m = find(ch.pub)
804
825
  if (!m) throw new Error('caps: that member is not in the record')
805
826
  // El CN no cambia por aquí (quitar el cajón es sacar al miembro y volver a admitirlo,
806
- // que es un gesto visible); las capacidades sí, dentro de lo que su CN permite.
807
- m.caps = cleanCaps(ch.caps).filter((c) => allowedCaps(m.cn).includes(c))
827
+ // que es un gesto visible); las capacidades sí.
828
+ m.caps = cleanCaps(ch.caps)
808
829
  // CONCEDER LIMPIA LA RENUNCIA. `effectiveCaps` resta lo renunciado, así que sin
809
830
  // esto una renuncia sellada era IRREVERSIBLE: el master le devolvía el permiso en
810
831
  // `caps` y la resta seguía dejándolo fuera, para siempre. La interfaz promete
@@ -1066,15 +1087,14 @@ export function memberCanScope (acta, pub, scope, extraRenounces = []) {
1066
1087
 
1067
1088
  export function memberCanReadSecrets (acta, pub, ns, extraRenounces = []) {
1068
1089
  const m = (acta?.members || []).find((x) => x.pub === pub)
1069
- if (!m || !m.cn) return false
1070
- return m.cn === ns && effectiveCaps(acta, pub, extraRenounces).includes('secrets')
1090
+ return !!m && hasDrawer(m.cn) && m.cn === ns
1071
1091
  }
1072
1092
 
1073
1093
  /** Los scopes de cert que le corresponden a un miembro según el acta. */
1074
1094
  export function memberScopes (acta, pub, extraRenounces = []) {
1075
1095
  const m = (acta?.members || []).find((x) => x.pub === pub)
1076
1096
  if (!m) return []
1077
- return effectiveCaps(acta, pub, extraRenounces).map((c) => capScope(c, m.cn)).filter(Boolean)
1097
+ return [cnScope(m.cn), ...effectiveCaps(acta, pub, extraRenounces).map((c) => capScope(c))].filter(Boolean)
1078
1098
  }
1079
1099
 
1080
1100
  /** ¿Es un servicio (tiene CN) o un dispositivo del usuario? */
@@ -1276,6 +1296,6 @@ export default {
1276
1296
  sealActa, verifyActa, checkVaultReply, applyChanges, makeRenounce, verifyRenounce,
1277
1297
  makeContinuity, verifyContinuity,
1278
1298
  cardBody, makeProfileCard, verifyProfileCard, canAdoptCard, sealersOf, canSeal,
1279
- effectiveCaps, memberCan, memberCanSign, memberCanScope, memberCanReadSecrets, memberScopes, isService, capScope, isValidCn, canAdopt,
1299
+ effectiveCaps, memberCan, memberCanSign, memberCanScope, memberCanReadSecrets, memberScopes, isService, capScope, cnScope, VAULT_CN, isValidCn, canAdopt,
1280
1300
  samePubkey, memberEncPub
1281
1301
  }
package/vault/core.js CHANGED
@@ -2596,9 +2596,9 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
2596
2596
  * `continuity`: si esa identidad ya existía por su cuenta, su puente firmado (F3).
2597
2597
  */
2598
2598
  async admitMember ({ pub, encPub = null, label = '', cn = null, caps = null, cert = null, continuity = null } = {}) {
2599
- // `cn`: si viene, este miembro es un SERVICIO y su único permiso es abrir el cajón de
2600
- // secretos de ESE nombre — no ve nada más del usuario. Sin `cn` es un dispositivo.
2601
- const finales = caps || (cn ? ['secrets'] : ['store', 'read'])
2599
+ // `cn`: si viene, este miembro es un SERVICIO: abre el cajón de secretos de ESE nombre
2600
+ // por llevarlo, sin permiso aparte, y no ve nada más del usuario. Sin `cn` es un dispositivo.
2601
+ const finales = caps || (cn ? [] : ['store', 'read'])
2602
2602
  // Que entre al perfil incluye poder LEER lo que ya hay: se le envuelve la clave
2603
2603
  // vigente (no hace falta rotar; rotar es para cuando alguien SALE).
2604
2604
  //
package/vault/vault.js CHANGED
@@ -531,8 +531,14 @@ import { useBackend as usePeerBackend } from './peerStore.js'
531
531
  // dato que la aplicación no puede falsificar —el navegador lo garantiza— y de él
532
532
  // depende a quién se le concedió qué.
533
533
  const result = await handler({ ...(params || {}), __origin: event.origin })
534
+ // DENTRO DE LA APP, lo guardado viaja a la app por una cola (`nativeStore.js`). Quien
535
+ // llama suele NAVEGAR en cuanto recibe la respuesta (entrar con contraseña vuelve a
536
+ // donde estaba; cambiar de perfil recarga), y eso destruye este iframe con la cola a
537
+ // medias: la cuenta quedaba en Pedidos y no en la lista de perfiles (2026-10-12).
538
+ if (native) await native.flush()
534
539
  reply({ result })
535
540
  } catch (e) {
541
+ if (native) await native.flush()
536
542
  // `code` (y su `detail`) CRUZAN. Sin ellos, al otro lado solo llegaba la frase, y una
537
543
  // app que quiere reaccionar a un rechazo concreto —«esa bóveda ya está en otra cuenta
538
544
  // de este aparato»— no tenía más remedio que emparejarla por su texto: se traduce o se
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.82.1 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
1
+ Copia vendorizada de @dotrino/vault@0.83.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, DEVICE_CAPS } from '@dotrino/identity/acta'
33
+ import { verifyContinuity, canSeal, DEVICE_CAPS, VAULT_CN } 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
@@ -169,12 +169,16 @@ export function createEnrollDesk ({
169
169
  * el código, igual que hacía `caps` después.
170
170
  */
171
171
  async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '', caps = null } = {}) {
172
+ // El nombre de la propia bóveda no es un servicio: su llave firma con el perfil cerrado
173
+ // y no puede abrir ningún cajón. Un servicio que entrara con ese nombre tampoco podría.
174
+ if (scopeToCn(scope) === VAULT_CN) throw Object.assign(new Error(`"${VAULT_CN}" is the vault's own name: a service cannot use it`), { code: 'reserved-cn' })
172
175
  if (caps != null) {
173
176
  if (!Array.isArray(caps)) throw Object.assign(new Error('caps must be a list'), { code: 'bad-caps' })
174
177
  const cn = scopeToCn(scope)
175
- const bad = caps.filter((c) => !(DEVICE_CAPS.includes(c) || (c === 'secrets' && cn)))
178
+ const bad = caps.filter((c) => !DEVICE_CAPS.includes(c))
176
179
  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' })
180
+ // Un servicio puede entrar sin ningún permiso: su cajón lo abre el CN.
181
+ if (!caps.length && !cn) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
178
182
  caps = [...new Set(caps)]
179
183
  }
180
184
  pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
@@ -378,13 +382,14 @@ export function createEnrollDesk ({
378
382
  throw Object.assign(new Error('this vault cannot add devices to the account record: no certificate was issued'), { code: 'admit-unavailable' })
379
383
  }
380
384
  // PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
381
- // cajón (`secrets:<ns>`) suma `secrets` y fija el CN; no borra lo demás — un bot
382
- // con `sign,secrets:eco` firma como aparato del acta Y lee solo su cajón.
385
+ // cajón (`secrets:<ns>`) fija el CN, que es lo que lo abre —no hay permiso aparte
386
+ // (dueño, 2026-10-13)—, y no borra lo demás: un bot con `sign,secrets:eco` firma como
387
+ // aparato del acta Y lee solo su cajón.
383
388
  // Si quien emparejó ELIGIÓ los permisos (`startPairing({ caps })`), entran esos y en
384
389
  // esta misma acta; si no, los que da el scope.
385
390
  const cn = scopeToCn(pend.scope)
386
- const caps = [...new Set([...(pend.caps || scopeToCaps(pend.scope)), ...(cn ? ['secrets'] : [])])]
387
- if (!caps.length) {
391
+ const caps = [...new Set(pend.caps || scopeToCaps(pend.scope))]
392
+ if (!caps.length && !cn) {
388
393
  throw Object.assign(new Error('the pairing scope grants no permission: no certificate was issued'), { code: 'empty-scope' })
389
394
  }
390
395