@dotrino/identity 0.79.0 → 0.81.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/LICENSE CHANGED
File without changes
package/README.md CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.79.0",
3
+ "version": "0.81.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.d.ts CHANGED
File without changes
package/src/index.js CHANGED
@@ -571,6 +571,14 @@ export class Identity {
571
571
  async getMe () { return this._call('getMe') }
572
572
  /** Subconjunto PÚBLICO de tu perfil (solo lo visible) — para compartir/publicar. */
573
573
  async publicMe () { return this._call('publicMe') }
574
+ /**
575
+ * CÓMO FUE EL ÚLTIMO EMPUJÓN del perfil a la bóveda: `{ ok, at, error }`.
576
+ *
577
+ * El núcleo lo lleva desde la fase 3 y aquí no estaba, así que una app no podía
578
+ * preguntarlo — que es justo para lo que se guardó: poder decir «esto no se guardó» en
579
+ * vez de enseñar tan tranquila un perfil que solo vive en este aparato.
580
+ */
581
+ async profilePushState () { return this._call('profilePushState') }
574
582
 
575
583
  /** Pubkey ECDH (JWK string) propio para encripción. */
576
584
  async getEncryptionPubkey () {
package/src/node.js CHANGED
@@ -269,6 +269,8 @@ export class Identity {
269
269
  async updateMe (patch) { return this._h('updateMe', { patch }) }
270
270
  getMe () { return this._h('getMe') }
271
271
  publicMe () { return this._h('publicMe') }
272
+ /** Cómo fue el último empujón del perfil a la bóveda: `{ ok, at, error }`. */
273
+ profilePushState () { return this._h('profilePushState') }
272
274
  getEncryptionPubkey () { return this._h('getEncryptionPubkey') }
273
275
  encrypt (recipients, plaintext) { return this._h('encrypt', { recipients, plaintext }) }
274
276
  decrypt (senderEncryptionPubkey, myToken, envelope) {
package/vault/CNAME CHANGED
File without changes
package/vault/acta.js CHANGED
@@ -968,8 +968,8 @@ export function effectiveCaps (acta, pub, extraRenounces = []) {
968
968
  * `ns` es opcional y es para lo que va atado a un cajón: un servicio (miembro con `cn`)
969
969
  * solo firma dentro del suyo. Sin `ns`, se pregunta por la capacidad a secas.
970
970
  */
971
- export function memberCanSign (acta, pub, ns = null) {
972
- if (!memberCan(acta, pub, 'sign')) return false
971
+ export function memberCanSign (acta, pub, ns = null, extraRenounces = []) {
972
+ if (!memberCan(acta, pub, 'sign', extraRenounces)) return false
973
973
  const m = (acta?.members || []).find((x) => x.pub === pub)
974
974
  // SIN `cn` = un aparato TUYO: firma como tú, y no hay nada que preguntarle a nadie.
975
975
  if (!m?.cn) return true
@@ -996,19 +996,19 @@ export function memberCanSign (acta, pub, ns = null) {
996
996
  * Devuelve `false` ante un scope que no reconoce: si aparece uno nuevo, lo que toca es
997
997
  * añadirlo aquí, no que se cuele por no estar en la lista.
998
998
  */
999
- export function memberCanScope (acta, pub, scope) {
999
+ export function memberCanScope (acta, pub, scope, extraRenounces = []) {
1000
1000
  if (!acta || typeof scope !== 'string') return false
1001
1001
  const secretos = /^vault:secrets:([a-z0-9-]{1,32})$/.exec(scope)
1002
- if (secretos) return memberCanReadSecrets(acta, pub, secretos[1])
1003
- if (scope === 'vault:sign') return memberCanSign(acta, pub)
1002
+ if (secretos) return memberCanReadSecrets(acta, pub, secretos[1], extraRenounces)
1003
+ if (scope === 'vault:sign') return memberCanSign(acta, pub, null, extraRenounces)
1004
1004
  const cap = Object.keys(CAP_SCOPE).find((c) => CAP_SCOPE[c] === scope)
1005
- return cap ? memberCan(acta, pub, cap) : false
1005
+ return cap ? memberCan(acta, pub, cap, extraRenounces) : false
1006
1006
  }
1007
1007
 
1008
- export function memberCanReadSecrets (acta, pub, ns) {
1008
+ export function memberCanReadSecrets (acta, pub, ns, extraRenounces = []) {
1009
1009
  const m = (acta?.members || []).find((x) => x.pub === pub)
1010
1010
  if (!m || !m.cn) return false
1011
- return m.cn === ns && effectiveCaps(acta, pub).includes('secrets')
1011
+ return m.cn === ns && effectiveCaps(acta, pub, extraRenounces).includes('secrets')
1012
1012
  }
1013
1013
 
1014
1014
  /** Los scopes de cert que le corresponden a un miembro según el acta. */
package/vault/avatar.js CHANGED
File without changes
File without changes
package/vault/content.js CHANGED
File without changes
package/vault/core.js CHANGED
@@ -124,6 +124,30 @@ const STD_FIELD_CAPS = [
124
124
  // (los demás campos estándar se comparten salvo que su flag sea false).
125
125
  const STD_FIELDS_SENSITIVE = new Set(['telefono', 'direccion'])
126
126
 
127
+ /**
128
+ * QUÉ CLASE ES CADA DATO DEL PERFIL: `public` o `private`
129
+ * (`dotrino-vault/docs/datos-del-perfil.md` §2).
130
+ *
131
+ * La regla no es nueva —es la que ya decidía `publicMe()`: lo que marcaste visible es lo
132
+ * que ve quien pregunta desde fuera—. Lo que cambia es que ahora decide **cómo se guarda**:
133
+ * en claro o en sobre. Por eso vive aquí, exportada y a nivel de módulo, en vez de escondida
134
+ * dentro de una función: es política y hay que poder mirarla y probarla.
135
+ *
136
+ * Sensibles (teléfono, dirección) OCULTOS salvo que su marca diga que sí, explícitamente.
137
+ * El resto se comparte salvo que digas que no.
138
+ */
139
+ export function profileFieldClasses (m = {}) {
140
+ const out = {}
141
+ const clase = (esPublico) => (esPublico ? 'public' : 'private')
142
+ if (m.nickname) out.nickname = clase(true)
143
+ if (m.avatar) out.avatar = clase(m.avatarVisible !== false)
144
+ for (const [k] of STD_FIELD_CAPS) {
145
+ if (!m[k]) continue
146
+ out[k] = clase(STD_FIELDS_SENSITIVE.has(k) ? (m[k + 'Visible'] === true) : (m[k + 'Visible'] !== false))
147
+ }
148
+ return out
149
+ }
150
+
127
151
  // Sanea un patch de perfil (avatar/links/fields/nickname + campos estándar). Cada link/field
128
152
  // lleva `visible` (oculto = no se comparte). Caps de tamaño para no inflar el `me`. Los ids los pone la UI.
129
153
  function sanitizeProfilePatch (patch = {}) {
@@ -1099,15 +1123,118 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1099
1123
  // store). Al editar aquí se EMPUJA; al arrancar se JALA y gana el más nuevo
1100
1124
  // (updatedAt). Las llaves (publickey/encryptionPubkey) son POR dispositivo y
1101
1125
  // nunca se sincronizan. Todo best-effort: sin vault encendido no molesta.
1126
+ /**
1127
+ * QUÉ ES PÚBLICO Y QUÉ ES PRIVADO, en un solo sitio.
1128
+ *
1129
+ * Es la misma regla que ya decidía `publicMe()`: lo que marcaste visible es lo que ve
1130
+ * quien pregunta desde fuera. Lo que cambia es que ahora esa marca decide **cómo se
1131
+ * guarda** —en claro o en sobre— y no solo qué se enseña (`docs/datos-del-perfil.md` §2).
1132
+ *
1133
+ * @returns {Array<{key:string, value:string, cls:'public'|'private'}>}
1134
+ */
1135
+ function profileFields (m) {
1136
+ const out = []
1137
+ const add = (key, value, esPublico) => {
1138
+ if (typeof value !== 'string' || !value) return
1139
+ out.push({ key, value, cls: esPublico ? 'public' : 'private' })
1140
+ }
1141
+ // La clase la decide `profileFieldClasses`, que está exportada y probada: tener la
1142
+ // regla en dos sitios es como acaban divergiendo lo que se enseña y lo que se guarda.
1143
+ const clases = profileFieldClasses(m)
1144
+ for (const [k, cls] of Object.entries(clases)) add(k, m[k], cls === 'public')
1145
+ // Enlaces y campos libres viajan como UN dato cada lista: son arrays y partirlos por
1146
+ // elemento haría que reordenarlos pareciera media docena de cambios.
1147
+ const visibles = (arr) => (arr || []).filter((x) => x.visible !== false).map(({ visible, ...r }) => r)
1148
+ const ocultos = (arr) => (arr || []).filter((x) => x.visible === false)
1149
+ if (Array.isArray(m.links)) {
1150
+ const v = visibles(m.links); if (v.length) add('links', JSON.stringify(v), true)
1151
+ const o = ocultos(m.links); if (o.length) add('links_private', JSON.stringify(o), false)
1152
+ }
1153
+ if (Array.isArray(m.fields)) {
1154
+ const v = visibles(m.fields); if (v.length) add('fields', JSON.stringify(v), true)
1155
+ const o = ocultos(m.fields); if (o.length) add('fields_private', JSON.stringify(o), false)
1156
+ }
1157
+ return out
1158
+ }
1159
+
1160
+ /**
1161
+ * EMPUJAR EL PERFIL, DATO A DATO Y EN SOBRES (`docs/datos-del-perfil.md`).
1162
+ *
1163
+ * Antes se mandaba el `me` entero por `profileSet`, y eso exigía la bóveda ABIERTA —era
1164
+ * ella quien decidía guardarlo, porque lo veía en claro—. Ahora cada dato viaja como un
1165
+ * sobre que la bóveda no puede leer ni fabricar, así que aceptarlo no es decisión suya y
1166
+ * el candado deja de estorbar. Los públicos van en claro porque no hay a quién sellarlos.
1167
+ *
1168
+ * Solo se manda LO QUE CAMBIÓ: cada escritura estrena generación, y reescribir un dato
1169
+ * que no cambió llenaría el llavero y el histórico de ruido.
1170
+ */
1171
+ async function pushProfileFields (v, device) {
1172
+ const campos = profileFields(me || {})
1173
+ const pendientes = campos.filter((c) => lastPushedFields[c.key] !== c.cls + '\u0000' + c.value)
1174
+ if (!pendientes.length) return
1175
+
1176
+ const privados = pendientes.filter((c) => c.cls === 'private')
1177
+ let destinatarios = null
1178
+ if (privados.length) {
1179
+ destinatarios = await remoteStore({
1180
+ master: v.master, proxy: v.proxy, device, cert: v.cert,
1181
+ method: 'profileRecipients', args: {}, onRevoked: wipeVaultLink
1182
+ })
1183
+ if (!destinatarios?.recoveryPub) {
1184
+ throw new Error('the vault did not say who to seal the profile for')
1185
+ }
1186
+ }
1187
+
1188
+ for (const c of pendientes) {
1189
+ const args = { key: c.key, cls: c.cls }
1190
+ if (c.cls === 'public') {
1191
+ args.value = c.value
1192
+ } else {
1193
+ // Envolver solo necesita PÚBLICAS: por eso esto se puede hacer aquí, en el
1194
+ // navegador, sin que ninguna privada ande suelta.
1195
+ const cek = await Content.makeContentKey()
1196
+ const e = await Content.encryptWithCek({ cek, gen: 0, plaintext: c.value })
1197
+ const wraps = { '#recovery': await Content.wrapForMember({ cek, memberEncPub: destinatarios.recoveryPub }) }
1198
+ for (const m of destinatarios.members || []) {
1199
+ if (m.encPub) wraps[m.pub] = await Content.wrapForMember({ cek, memberEncPub: m.encPub })
1200
+ }
1201
+ args.sobre = { e, wraps }
1202
+ }
1203
+ await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profilePut', args, onRevoked: wipeVaultLink })
1204
+ lastPushedFields[c.key] = c.cls + '\u0000' + c.value
1205
+ }
1206
+ }
1207
+
1208
+ /** Lo último que se consiguió empujar de cada dato, para no reescribir lo que no cambió. */
1209
+ const lastPushedFields = Object.create(null)
1210
+
1102
1211
  let profilePushTimer = null
1212
+ /**
1213
+ * CÓMO FUE EL ÚLTIMO EMPUJÓN. Se guarda para que la UI pueda decir «esto no se guardó»
1214
+ * en vez de enseñar un perfil que solo existe en este aparato. Sin esto, el usuario ve
1215
+ * su cambio en pantalla y cree que está hecho.
1216
+ */
1217
+ // `ok: null` = TODAVÍA NO SE HA EMPUJADO NADA. Nacía en `true`, así que «nunca se
1218
+ // intentó» y «salió bien» se veían igual — un valor por defecto que dice que sí, en la
1219
+ // función que existe justamente para no tragarse el fallo. Quien pregunte tiene que
1220
+ // poder distinguir las tres cosas, y por eso son tres valores y no dos.
1221
+ let lastProfilePush = { ok: null, at: 0, error: null }
1103
1222
  function pushProfileToVault () {
1104
1223
  const v = loadVaultCert(); const device = loadVaultDevice()
1105
1224
  if (!v?.cert || !device) return
1106
1225
  clearTimeout(profilePushTimer)
1107
1226
  profilePushTimer = setTimeout(() => {
1108
- const { publickey, encryptionPubkey, ...content } = me || {}
1109
- remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileSet', args: { me: content }, onRevoked: wipeVaultLink })
1110
- .catch(() => {}) // el vault puede estar apagado; se reintenta en la próxima edición
1227
+ pushProfileFields(v, device)
1228
+ .then(() => { lastProfilePush = { ok: true, at: Date.now(), error: null } })
1229
+ .catch((e) => {
1230
+ // EL FALLO SE VE. Aquí había un `.catch(() => {})` con un comentario que decía
1231
+ // «se reintenta en la próxima edición», y era falso de dos maneras: la próxima
1232
+ // edición se encontraba la bóveda cerrada otra vez, y mientras tanto el aparato
1233
+ // se quedaba con datos que nadie más tenía. Es exactamente lo que produjo el
1234
+ // «edito y no funciona, y cada dispositivo ve algo distinto».
1235
+ lastProfilePush = { ok: false, at: Date.now(), error: e?.message || String(e) }
1236
+ try { console.warn('[identity] the profile change did NOT reach the vault:', lastProfilePush.error) } catch (_) {}
1237
+ })
1111
1238
  }, 800) // debounce: ediciones seguidas = un solo push
1112
1239
  }
1113
1240
  /**
@@ -1152,6 +1279,41 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1152
1279
  * quedaba enseñando un perfil del que ya no era, para siempre, sin que nadie pulsara
1153
1280
  * nada porque no había nada que pulsar.
1154
1281
  */
1282
+ /**
1283
+ * COMPONE EL `me` CON LO QUE SE PUDO ABRIR. Devuelve si cambió algo, para no avisar de
1284
+ * una sincronización que no movió nada.
1285
+ *
1286
+ * `links`/`fields` viajan como UN dato cada lista (y su gemelo privado), así que aquí se
1287
+ * vuelven a juntar con su marca de visibilidad — que es de dónde salió la separación.
1288
+ */
1289
+ function applyPulledProfile (content) {
1290
+ const antes = JSON.stringify(me || {})
1291
+ const next = { ...(me || {}) }
1292
+ const lista = (json, visible) => {
1293
+ try { return (JSON.parse(json) || []).map((x) => ({ ...x, visible })) } catch (_) { return [] }
1294
+ }
1295
+ for (const [k, v] of Object.entries(content)) {
1296
+ if (k === 'links' || k === 'fields' || k === 'links_private' || k === 'fields_private') continue
1297
+ next[k] = v
1298
+ }
1299
+ if (content.links != null || content.links_private != null) {
1300
+ next.links = [...lista(content.links, true), ...lista(content.links_private, false)]
1301
+ }
1302
+ if (content.fields != null || content.fields_private != null) {
1303
+ next.fields = [...lista(content.fields, true), ...lista(content.fields_private, false)]
1304
+ }
1305
+ next.publickey = publickeyJwkStr
1306
+ next.encryptionPubkey = encPublickeyJwkStr
1307
+ if (JSON.stringify(next) === antes) return false
1308
+ me = next
1309
+ saveMe(me)
1310
+ if (typeof next.nickname === 'string') {
1311
+ const list = loadProfiles(); const e = list.find((p) => p.id === currentPid)
1312
+ if (e && e.name !== next.nickname) { e.name = next.nickname; saveProfiles(list) }
1313
+ }
1314
+ return true
1315
+ }
1316
+
1155
1317
  async function pullProfileFromVault () {
1156
1318
  try {
1157
1319
  const v = loadVaultCert(); const device = loadVaultDevice()
@@ -1161,23 +1323,31 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
1161
1323
  // lo habían echado. La bóveda contesta «vencido» y no pasa nada; y si además ya no
1162
1324
  // está en el acta, contesta con el aviso firmado y aquí se le borra la cuenta.
1163
1325
  if (!v?.cert || !device) return
1164
- const res = await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileGet', args: {}, onRevoked: wipeVaultLink })
1165
- const remoteMe = res?.me
1166
- if (!remoteMe) {
1167
- // el vault aún no tiene perfil: sembrar con el local (si tiene contenido)
1326
+ // EL PAQUETE LO ARMA ESTE APARATO (dueño, 2026-09-03). La bóveda entrega los sobres
1327
+ // que tiene; aquí se abren los que nos tocan y se compone el perfil.
1328
+ const b = await remoteStore({ master: v.master, proxy: v.proxy, device, cert: v.cert, method: 'profileBundle', args: {}, onRevoked: wipeVaultLink })
1329
+ const entries = b?.entries || {}
1330
+ if (!Object.keys(entries).length) {
1331
+ // La bóveda aún no tiene perfil: sembrar con el local (si tiene contenido).
1168
1332
  if (me?.nickname || me?.avatar) pushProfileToVault()
1169
1333
  return
1170
1334
  }
1171
- if ((remoteMe.updatedAt || 0) > (me?.updatedAt || 0)) {
1172
- const { publickey, encryptionPubkey, ...content } = remoteMe
1173
- me = { ...(me || {}), ...content, publickey: publickeyJwkStr, encryptionPubkey: encPublickeyJwkStr }
1174
- saveMe(me)
1175
- if (typeof content.nickname === 'string') {
1176
- const list = loadProfiles(); const e = list.find((p) => p.id === currentPid)
1177
- if (e && e.name !== content.nickname) { e.name = content.nickname; saveProfiles(list) }
1335
+ const keyring = (b.wraps || []).map((w) => ({ gen: w.gen, wraps: { [publickeyJwkStr]: w.wrap } }))
1336
+ const content = {}
1337
+ for (const [key, e] of Object.entries(entries)) {
1338
+ try {
1339
+ if (e.cls === 'public') { content[key] = e.pubv; continue }
1340
+ content[key] = await Content.decryptWithKeyring({
1341
+ envelope: e.e, keyring, myPub: publickeyJwkStr, myEncPrivateKey: encKeypair.privateKey
1342
+ })
1343
+ } catch (_) {
1344
+ // SIN ENVOLTURA NO SE INVENTA NADA. Un aparato que entró después de escribirse un
1345
+ // dato no tiene su llave hasta que el dueño abra la bóveda; dejarlo fuera es
1346
+ // correcto, y poner un valor por defecto sería fabricar un perfil falso.
1178
1347
  }
1179
- emitVault({ phase: 'profile-sync', updatedAt: remoteMe.updatedAt })
1180
1348
  }
1349
+ if (!applyPulledProfile(content)) return
1350
+ emitVault({ phase: 'profile-sync' })
1181
1351
  } catch (_) { /* vault apagado: el perfil local sigue mandando */ }
1182
1352
  }
1183
1353
 
@@ -2275,6 +2445,13 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
2275
2445
  return { me: applyMeUpdate(patch || {}) }
2276
2446
  },
2277
2447
  async getMe () { return me },
2448
+ /**
2449
+ * CÓMO FUE EL ÚLTIMO EMPUJÓN del perfil a la bóveda. Existe para que la interfaz pueda
2450
+ * decir «esto no se guardó» en vez de enseñar tan tranquila un perfil que solo vive en
2451
+ * este aparato — que es lo que pasaba cuando el fallo se tragaba.
2452
+ * `{ ok, at, error }`.
2453
+ */
2454
+ async profilePushState () { return lastProfilePush },
2278
2455
  // Subconjunto PÚBLICO del perfil (solo lo marcado visible) — para compartir/publicar.
2279
2456
  async publicMe () {
2280
2457
  const m = me || {}
package/vault/index.html CHANGED
File without changes
package/vault/keyid.js CHANGED
File without changes
File without changes
package/vault/remote.js CHANGED
File without changes
package/vault/sync.js CHANGED
File without changes
package/vault/vault.js CHANGED
File without changes
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.13.1 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,webrtc}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.17.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,webrtc}.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
  sealing.js resuelve @dotrino/identity/content de forma PEREZOSA (= ../../content.js
4
4
  por el import map): solo se carga si de verdad se sella algo.
File without changes
@@ -1,6 +1,6 @@
1
1
  import { buildSignedChannel, getPublicKeyJwk, signData } from './signature.js'
2
2
  import { seal, open, isSealed } from './sealing.js'
3
- import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS } from './webrtc.js'
3
+ import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS, loadNodePeerConnection, resolvePeerConnection } from './webrtc.js'
4
4
 
5
5
  /**
6
6
  * Error con un `code` estable.
@@ -96,15 +96,39 @@ export class WebSocketProxyClient {
96
96
  getSelfToken: () => this.token,
97
97
  signalSend: (to, payload) => this._proxySendOne(to, payload),
98
98
  deliverMessage: (from, parsed, meta) => this._deliver(from, parsed, meta),
99
- emit: (event, ...args) => this._emit(event, ...args),
99
+ emit: (event, ...args) => {
100
+ // UN CANAL QUE SE CAE SE VUELVE A INTENTAR. Sin esto, el «un intento y no más» de
101
+ // `_upgradeDirect` sería para siempre: una desconexión momentánea condenaría a ese
102
+ // destinatario a ir por el proxio el resto de la vida del proceso.
103
+ if (event === 'webrtc_close') this._rtcTried?.delete(args[0])
104
+ this._emit(event, ...args)
105
+ },
100
106
  config: this.iceServers ? { iceServers: this.iceServers } : null
101
107
  }) : null
108
+ // QUIÉN PUEDE HACERTE NEGOCIAR UN CANAL DIRECTO. Sin política, cualquiera que sepa
109
+ // alcanzarte por el proxio — y negociar arranca DTLS/ICE/SCTP, o sea código que parsea
110
+ // red no confiable. En un navegador se vive con ello; en un proceso que guarda llaves,
111
+ // no. Quien monta el cliente lo acota (`acceptDirectFrom`).
112
+ if (this._rtc && typeof options.acceptDirectFrom === 'function') {
113
+ this._rtc.acceptFrom = options.acceptDirectFrom
114
+ }
102
115
  }
103
116
 
104
117
  // ---------- public API ----------
105
118
 
106
119
  get isConnected () { return this._connected }
107
120
 
121
+ /**
122
+ * WEBRTC EN NODE, si hay con qué. Se busca UNA vez al conectar y no en medio de una
123
+ * negociación: importar un paquete es asíncrono y hacerlo tarde metería una espera justo
124
+ * donde no puede haberla. Si no hay implementación, no pasa nada — se sigue por el
125
+ * proxio, que es el escalón que siempre funciona.
126
+ */
127
+ async _prepareWebRTC () {
128
+ if (!this._rtc || resolvePeerConnection()) return
129
+ await loadNodePeerConnection()
130
+ }
131
+
108
132
  connect () {
109
133
  return new Promise((resolve, reject) => {
110
134
  if (this._connected) return resolve(this.token)
@@ -198,6 +222,41 @@ export class WebSocketProxyClient {
198
222
  }
199
223
  if (proxyTokens.length) {
200
224
  this._sendRaw({ to: proxyTokens, message: messageStr })
225
+ // Y SE INTENTA IR DIRECTO PARA LA PRÓXIMA, sin esperar a nadie.
226
+ this._upgradeDirect(proxyTokens)
227
+ }
228
+ }
229
+
230
+ /**
231
+ * SIEMPRE EL CAMINO MÁS DIRECTO (dueño, 2026-09-03), pero sin pagar por ello.
232
+ *
233
+ * Hablarle a alguien por primera vez sale por el proxio, que es lo que hay AHORA. En
234
+ * paralelo se abre un canal directo, y desde el segundo mensaje `trySend` ya lo prefiere
235
+ * solo — eso no hay que cambiarlo, ya estaba.
236
+ *
237
+ * **No se espera a la negociación.** Bloquear el primer mensaje hasta tener canal directo
238
+ * haría más lento justo el arranque, que es lo que se quiere arreglar: se manda por donde
239
+ * se pueda y la conexión mejora por debajo.
240
+ *
241
+ * Un intento por destinatario y no más: si no salió, casi siempre es que no se puede
242
+ * (dos NAT que no se dejan, un navegador sin permisos) y reintentar en cada mensaje sería
243
+ * quemar CPU y señalización para nada. Si el canal se cae, `webrtc_close` lo desapunta y
244
+ * el siguiente mensaje vuelve a intentarlo.
245
+ */
246
+ _upgradeDirect (tokens) {
247
+ if (!this._rtc) return
248
+ this._rtcTried = this._rtcTried || new Set()
249
+ for (const t of tokens) {
250
+ if (this._rtcTried.has(t) || this._rtc.isOpen(t)) continue
251
+ this._rtcTried.add(t)
252
+ // La implementación se busca AQUÍ, justo antes de negociar, y no al conectar: esto ya
253
+ // corre desatendido, así que la espera no se la come nadie. Lanzarlo desde `connect`
254
+ // metía un microtask de más y descuadraba los tiempos de otras cosas — lo cazaron
255
+ // las pruebas del protocolo, que fallaban solo al correr todas juntas.
256
+ Promise.resolve()
257
+ .then(() => this._prepareWebRTC())
258
+ .then(() => this._rtc.connect(t))
259
+ .catch(() => {}) // no poder ir directo no es un fallo: es el caso normal en internet
201
260
  }
202
261
  }
203
262
 
@@ -415,14 +474,31 @@ export class WebSocketProxyClient {
415
474
  * (típicamente por el identity vault). Devuelve la respuesta del proxy con
416
475
  * `queued_delivered` (mensajes offline despachados al instante).
417
476
  */
418
- identify ({ data, signature, cert, acta }) {
477
+ /**
478
+ * @param {(data:any)=>Promise<any>} [opts.sign] Con qué firmar. Solo se usa para encender
479
+ * TURN, y por eso es opcional: quien no lo pase se queda como estaba.
480
+ *
481
+ * TURN NO ES UN CANAL APARTE: es lo que WebRTC usa cuando no consigue ir directo. Pero
482
+ * un canal por TURN sigue siendo mejor que el proxio, y no por velocidad — **el relevo
483
+ * no puede leer lo que reenvía** (va cifrado extremo a extremo) y el proxio sí. Por eso
484
+ * el orden es: aquí mismo, directo, por TURN, y el proxio el último (dueño, 2026-09-03).
485
+ *
486
+ * Se enciende SOLO y por detrás: encenderlo pide credenciales al proxio, y hacer
487
+ * esperar a `identify` por eso retrasaría todo lo que viene después para ganar algo que
488
+ * solo hace falta cuando se negocie el primer canal.
489
+ */
490
+ identify ({ data, signature, cert, acta, sign }) {
419
491
  if (!data || !signature) throw new Error('identify requires {data, signature}')
420
492
  const msg = { type: 'identify', data, signature }
421
493
  if (cert) msg.cert = cert // "una identidad": el proxy bindea este token también bajo tu maestra M
422
494
  // Acta de perfil: el proxy la verifica (va firmada) y bindea también el `profileId`, así
423
495
  // escribirle a la PERSONA llega a cualquiera de sus dispositivos. Ver acta-de-perfil.md.
424
496
  if (acta) msg.acta = acta
425
- return this._request(msg, 'identified')
497
+ const done = this._request(msg, 'identified')
498
+ if (this._rtc && typeof sign === 'function' && data.publickey) {
499
+ done.then(() => this.enableTurn({ publicKey: data.publickey, sign })).catch(() => {})
500
+ }
501
+ return done
426
502
  }
427
503
 
428
504
  /**
File without changes
File without changes
File without changes
@@ -20,6 +20,60 @@ export const DEFAULT_ICE_SERVERS = [
20
20
 
21
21
  const RTC_TAG = '__cc_rtc__'
22
22
 
23
+ /**
24
+ * DE DÓNDE SALE `RTCPeerConnection`, y por qué esto existe.
25
+ *
26
+ * En un navegador es nativo. En Node no hay ninguno, y por eso el ecosistema tenía WebRTC
27
+ * apagado a mano en todas partes: dos máquinas Node se hablaban por el proxio aunque
28
+ * estuvieran en la misma red, que es lo contrario de la regla («siempre el camino más
29
+ * directo», CLAUDE.md).
30
+ *
31
+ * Se resuelve en este orden:
32
+ *
33
+ * 1. el del entorno (navegador, o un Node que algún día lo traiga);
34
+ * 2. el que le inyecten (`setPeerConnection`), para no atarse a un paquete concreto;
35
+ * 3. **`@dotrino/webrtc`**, si está instalado — y si no, **`werift`**, del que aquél es
36
+ * una poda. Los dos son WebRTC en JavaScript puro, sin binario nativo, y eso no es una
37
+ * preferencia estética: la bóveda se distribuye como un ejecutable único (SEA) y un
38
+ * `.node` no entra ahí. Se cargan PEREZOSO y ninguno es dependencia de este paquete:
39
+ * quien lo quiera en Node lo instala.
40
+ *
41
+ * La poda va primero porque es la mitad de paquetes y sin la pila de audio y vídeo,
42
+ * que un canal de datos no toca. Se sigue aceptando el de arriba para no obligar a
43
+ * nadie a cambiar, y porque son el mismo código.
44
+ *
45
+ * Si no hay ninguno, WebRTC queda apagado y se sigue por el proxio. Eso no es un fallo:
46
+ * es el escalón 4, que siempre funciona.
47
+ */
48
+ let _PC = null
49
+ let _PCBuscado = false
50
+
51
+ export function setPeerConnection (impl) { _PC = impl; _PCBuscado = true }
52
+
53
+ export function resolvePeerConnection () {
54
+ if (_PCBuscado) return _PC
55
+ _PCBuscado = true
56
+ if (typeof globalThis.RTCPeerConnection === 'function') { _PC = globalThis.RTCPeerConnection; return _PC }
57
+ return _PC
58
+ }
59
+
60
+ /**
61
+ * Busca una implementación para Node. Es ASÍNCRONO —importar un paquete lo es— así que se
62
+ * llama una vez al arrancar, no en medio de una negociación.
63
+ */
64
+ export async function loadNodePeerConnection () {
65
+ if (_PCBuscado && _PC) return _PC
66
+ if (typeof globalThis.RTCPeerConnection === 'function') { _PC = globalThis.RTCPeerConnection; _PCBuscado = true; return _PC }
67
+ for (const nombre of ['@dotrino/webrtc', 'werift']) {
68
+ try {
69
+ const w = await import(nombre)
70
+ if (typeof w?.RTCPeerConnection === 'function') { _PC = w.RTCPeerConnection; _PCBuscado = true; return _PC }
71
+ } catch (_) { /* no está: se prueba el siguiente, y si no, el proxio (escalón 4) */ }
72
+ }
73
+ _PCBuscado = true
74
+ return _PC
75
+ }
76
+
23
77
  export class WebRTCManager {
24
78
  /**
25
79
  * @param {object} opts
@@ -31,6 +85,7 @@ export class WebRTCManager {
31
85
  * @param {{iceServers?: any[]}} [opts.config]
32
86
  */
33
87
  constructor (opts) {
88
+ this.acceptFrom = null // ver `handleIncoming`: lo pone quien monta el cliente
34
89
  this.getSelfToken = opts.getSelfToken
35
90
  this.signalSend = opts.signalSend
36
91
  this.deliverMessage = opts.deliverMessage
@@ -43,8 +98,19 @@ export class WebRTCManager {
43
98
  * True if this is a control envelope and was consumed.
44
99
  * Otherwise the caller should keep delivering it normally.
45
100
  */
101
+ /**
102
+ * QUIÉN PUEDE HACERTE NEGOCIAR. Antes: cualquiera que supiera alcanzarte por el proxio.
103
+ *
104
+ * Aceptar una señal arranca DTLS, ICE y SCTP — código que parsea red no confiable— así
105
+ * que quien decide si eso corre no puede ser el que llama a la puerta. En un navegador
106
+ * eso ya era así y se vivía con ello; en la bóveda es el proceso que tiene la maestra.
107
+ *
108
+ * `acceptFrom` lo decide quien monta el cliente: la bóveda solo acepta a MIEMBROS DE SU
109
+ * ACTA. Sin política se mantiene lo de antes, para no romper a quien ya dependía de ello.
110
+ */
46
111
  handleIncoming (from, parsed) {
47
112
  if (!parsed || typeof parsed !== 'object' || parsed.t !== RTC_TAG) return false
113
+ if (this.acceptFrom && !this.acceptFrom(from)) return false
48
114
  const peer = this._ensurePeer(from)
49
115
  this._handleSignal(peer, parsed).catch((e) => {
50
116
  this.emit('error', { type: 'webrtc_signal', error: e, peer: from })
@@ -135,7 +201,9 @@ export class WebRTCManager {
135
201
  }
136
202
 
137
203
  _createPC (peer) {
138
- const pc = new RTCPeerConnection({ iceServers: this.iceServers })
204
+ const PC = resolvePeerConnection()
205
+ if (!PC) throw new Error('no WebRTC here: install `werift` for Node, or run this in a browser')
206
+ const pc = new PC({ iceServers: this.iceServers })
139
207
  peer.pc = pc
140
208
  peer.polite = this._isPolite(peer.remote)
141
209
 
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.53.0 (dotrino-vault/lib/src/{index,enroll,protocol}.js).
1
+ Copia vendorizada de @dotrino/vault@0.61.0 (dotrino-vault/lib/src/{index,enroll,protocol}.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
@@ -52,7 +52,18 @@ export const MSG_REVOKED = 'vault.revoked'
52
52
  export const MSG_ERROR = 'vault.error'
53
53
 
54
54
  /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
55
- const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin', 'vault:passwords': 'passwords' }
55
+ const SCOPE_TO_CAP = {
56
+ 'vault:sign': 'sign',
57
+ 'vault:store': 'store',
58
+ 'vault:read': 'read',
59
+ 'vault:admin': 'admin',
60
+ 'vault:passwords': 'passwords',
61
+ // `replica` SÍ se empareja, al revés que `sealer` y `admin`. La razón es la misma que
62
+ // hace estrecho al permiso: un replicador reparte sobres que no puede abrir y no cambia
63
+ // nada. Y se despliega sin teclado —un contenedor, una máquina ajena—, que es justo
64
+ // donde obligar a un segundo paso a mano es el paso que nadie da.
65
+ 'vault:replica': 'replica'
66
+ }
56
67
  export const scopeToCaps = (scope) =>
57
68
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
58
69
 
@@ -84,7 +84,10 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
84
84
  const client = injectedClient || await (async () => {
85
85
  const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
86
86
  const c = new WebSocketProxyClient({
87
- url: proxy, enableWebRTC: false, autoReconnect: true,
87
+ // WEBRTC SOLO DONDE EXISTE (ver `lib/src/service.js`): en Node no hay
88
+ // `RTCPeerConnection` y encenderlo reventaría al negociar; en un navegador es nativo y es
89
+ // el camino directo que hay que preferir. Se mira, en vez de apagarlo para siempre.
90
+ url: proxy, enableWebRTC: typeof globalThis.RTCPeerConnection === 'function', autoReconnect: true,
88
91
  maxReconnectAttempts: 100000, reconnectDelay: 4000
89
92
  })
90
93
  await c.connect()
@@ -128,3 +128,18 @@ export const isValidSecretsNs = (ns) => typeof ns === 'string' && /^[a-z0-9-]{1,
128
128
  * remota y el lector de `.env`, y tres copias de una regla son tres reglas.
129
129
  */
130
130
  export const isValidVarKey = (key) => typeof key === 'string' && /^[A-Z0-9_]{1,64}$/.test(key)
131
+
132
+ /**
133
+ * LA VERSIÓN DEL CABLE (CONVENCIONES §14, `@dotrino/compat`).
134
+ *
135
+ * Sube **solo cuando cambia el protocolo**, no con cada release: es lo que decide *si dos
136
+ * piezas pueden hablar*. La `version` del paquete decide otra cosa —si esa build concreta
137
+ * está rota— y por eso hacen falta las dos.
138
+ *
139
+ * Empieza en 1 hoy, con todo lo anterior sin declarar nada: a quien calla se le atiende
140
+ * hasta que cierre la ventana de migración del pilar, y después no.
141
+ */
142
+ export const VAULT_PROTOCOL = 1
143
+
144
+ /** Los protocolos que esta build entiende. Al añadir el 2, aquí van `[1, 2]`. */
145
+ export const VAULT_SPEAKS = [1]