@dotrino/identity 0.107.2 → 0.109.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/README.md CHANGED
@@ -19,6 +19,24 @@ Identidad de usuario y rating de peers compartidos entre las apps de Dotrino. Fu
19
19
 
20
20
  Como todas las apps cargan el vault desde el mismo origin, comparten el mismo `localStorage` aunque ellas estén en orígenes distintos. **Las claves privadas nunca salen del vault** — las apps reciben firmas (de la ECDSA) y plaintext descifrado (de la ECDH) pero nunca las llaves.
21
21
 
22
+ ## Dónde vive la identidad: web, Android, iOS
23
+
24
+ Uso para el usuario: [El navegador y las apps instaladas](https://wiki.dotrino.com/empezar/navegador-y-apps/) (wiki). Portada: [id.dotrino.com](https://id.dotrino.com/).
25
+
26
+ «Identidad Dotrino» es un **concepto** con tres implementaciones. Solo en Android es una app:
27
+
28
+ | Plataforma | Dónde están las llaves y los perfiles | Quién los comparte |
29
+ |---|---|---|
30
+ | **Navegador** | este paquete: el iframe `id.dotrino.com` (localStorage + IndexedDB de ese navegador, llaves WebCrypto no extraíbles) | todas las apps abiertas **en ese navegador** |
31
+ | **Android nativo** | la app **`com.dotrino.identity`** (Keystore), servicio con permiso de nivel firma | las apps nativas firmadas con la misma llave de Play |
32
+ | **iOS nativo** | **no hay app**: llavero `P7G853375S.com.dotrino.shared` + App Group `group.com.dotrino`, dentro de cada app | las apps del equipo `P7G853375S` |
33
+
34
+ **El navegador y lo nativo NO se ven, en ninguna dirección.** Chrome no puede enlazar el servicio de `com.dotrino.identity` (exige la firma de Play) y Safari no entra en los grupos del equipo; ninguna app nativa puede leer el almacén de un navegador. El WebView de una app nativa tampoco es el navegador: su iframe `id.dotrino.com` guarda en lo nativo por el puente (`window.DotrinoIdentityKeys`, `nativeStore.js`), no en Chrome ni en Safari.
35
+
36
+ Para la cuenta son **dos aparatos distintos del acta** (el navegador y el conjunto de apps nativas del teléfono). Se unen como cualquier par de aparatos: emparejando con la bóveda («Adoptar un perfil», `vault.dotrino.com/d`) o entrando con dirección y contraseña. No existe ni se construye un puente para copiar perfiles de un lado a otro. Detalle nativo en `dotrino-native/docs/DISENO.md` §2.
37
+
38
+ `id.dotrino.com/` tiene dos papeles: dentro de un iframe es la bóveda (carga `vault.js`); abierta directo es la portada (no carga `vault.js`, para no tomar el candado del modo «este dispositivo es bóveda»).
39
+
22
40
  ## Instalación
23
41
 
24
42
  ```bash
@@ -84,6 +102,7 @@ Inicializa el iframe y resuelve cuando el vault está listo.
84
102
 
85
103
  - `id.getPeer(publickey)`
86
104
  - `id.setNickname(publickey, nickname)`
105
+ - `id.setBlocked(publickey, blocked)` — **bloquear** (0.108): privado, no se firma ni se publica; viaja con tus contactos (`peer.blocked`) a tu bóveda y tus aparatos. Cada app que recibe mensajes lo respeta.
87
106
  - `id.setRating(publickey, rating, notes?)` — produce un envelope firmado y lo guarda como `peer.myRating` (rating 0–5).
88
107
  - `id.mergeEndorsements(subject, [signedRatings], askerPubkey?)` — para web-of-trust: valida firmas, dedupea por `(ratedBy, subject)`, cap 50.
89
108
  - `id.getRatingsForSubject(subject)` → `{ mine, endorsements }` para responder a un `RATING_QUERY`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.107.2",
3
+ "version": "0.109.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
@@ -65,6 +65,9 @@ export interface PeerInfo {
65
65
  queryStats?: QueryStats
66
66
  firstSeen?: number
67
67
  lastSeen?: number
68
+ /** You blocked them (private, never published). */
69
+ blocked?: boolean
70
+ changedAt?: number
68
71
  }
69
72
 
70
73
  export interface Challenge {
@@ -106,6 +109,8 @@ export class Identity {
106
109
  verifyResponse (response: ChallengeResponse): Promise<VerifyResult>
107
110
  getPeer (publickey: string): Promise<PeerInfo | null>
108
111
  setNickname (publickey: string, nickname: string): Promise<PeerInfo>
112
+ /** Private block: not signed, not published; travels with your contacts. */
113
+ setBlocked (publickey: string, blocked: boolean): Promise<PeerInfo>
109
114
  setRating (publickey: string, rating: number, notes?: string): Promise<PeerInfo>
110
115
  listPeers (): Promise<PeerInfo[]>
111
116
  forgetPeer (publickey: string): Promise<void>
@@ -182,9 +187,9 @@ export class Identity {
182
187
  * abierto: viaja sellado a la llave de cifrado de este aparato y se descifra aquí dentro.
183
188
  * `ctxError` si no se pudo abrir; sin ninguno de los dos, el pedido no dijo qué corría.
184
189
  */
185
- vaultApprovals (op: 'approvals' | 'approve' | 'deny', args?: { id?: string; profile?: string }): Promise<any>
190
+ vaultApprovals (op: 'approvals' | 'approve' | 'deny', args?: { id?: string; profile?: string; notify?: boolean }): Promise<any>
186
191
  /** Los pedidos de TODAS las cuentas de este dispositivo que aprueban, sin cambiar la activa. */
187
- vaultApprovalsAll (): Promise<Array<{ profile: string; name: string; current: boolean; items: ApprovalRequest[]; error?: string }>>
192
+ vaultApprovalsAll (args?: { notify?: boolean }): Promise<Array<{ profile: string; name: string; current: boolean; items: ApprovalRequest[]; error?: string }>>
188
193
  canApproveVault (): Promise<boolean>
189
194
  getVaultCert (): Promise<any>
190
195
  onVault (handler: (payload: any) => void): () => void
package/src/index.js CHANGED
@@ -209,6 +209,11 @@ export class Identity {
209
209
  return this._call('getPeer', { publickey })
210
210
  }
211
211
 
212
+ /** Bloquear (o desbloquear) a alguien: privado, viaja con tus contactos y no se publica. */
213
+ async setBlocked (publickey, blocked) {
214
+ return this._call('setBlocked', { publickey, blocked })
215
+ }
216
+
212
217
  async setNickname (publickey, nickname) {
213
218
  return this._call('setNickname', { publickey, nickname })
214
219
  }
@@ -471,6 +476,10 @@ export class Identity {
471
476
  * PEDIDOS DE APROBACIÓN de la bóveda (cajones con `approval`): `op` = `approvals` ·
472
477
  * `approve` · `deny` (estos dos con `{ id }`). Requiere `vault:approve` en el cert.
473
478
  *
479
+ * `args.notify` (solo con `approvals`): `true`/`false` según a este aparato le lleguen los
480
+ * avisos de pedidos. La bóveda lo apunta, y para actualizarse solo pide permiso si hay un
481
+ * aprobador que pueda enterarse. No pasarlo no cambia lo ya dicho.
482
+ *
474
483
  * `args.profile` apunta a OTRA cuenta de este dispositivo (la que diga
475
484
  * `vaultApprovalsAll`); sin él es la activa.
476
485
  */
@@ -489,8 +498,8 @@ export class Identity {
489
498
  *
490
499
  * Aprobar uno de otra cuenta: `vaultApprovals('approve', { id, profile })`.
491
500
  */
492
- async vaultApprovalsAll () {
493
- return this._call('vaultApprovalsAll', {}, 30000)
501
+ async vaultApprovalsAll (args) {
502
+ return this._call('vaultApprovalsAll', args || {}, 30000)
494
503
  }
495
504
 
496
505
  /** Registra el token de push de la app nativa (FCM/APNs) bajo la llave de este aparato. */
package/src/node.js CHANGED
@@ -166,6 +166,7 @@ export class Identity {
166
166
  }
167
167
  getPeer (publickey) { return this._h('getPeer', { publickey }) }
168
168
  setNickname (publickey, nickname) { return this._h('setNickname', { publickey, nickname }) }
169
+ setBlocked (publickey, blocked) { return this._h('setBlocked', { publickey, blocked }) }
169
170
  setRating (publickey, rating, notes) { return this._h('setRating', { publickey, rating, notes }) }
170
171
  listPeers () { return this._h('listPeers') }
171
172
  forgetPeer (publickey) { return this._h('forgetPeer', { publickey }) }
@@ -262,7 +263,7 @@ export class Identity {
262
263
  getVaultCert () { return this._h('getVaultCert') }
263
264
  vaultApprovals (op, args) { return this._h('vaultApprovals', { op, ...(args || {}) }) }
264
265
  /** Los pedidos de TODAS las cuentas que aprueban, sin cambiar la activa. */
265
- vaultApprovalsAll () { return this._h('vaultApprovalsAll') }
266
+ vaultApprovalsAll (args) { return this._h('vaultApprovalsAll', args || {}) }
266
267
  onVault (handler) { return this.on('vault', handler) }
267
268
  // Multi-perfil por dispositivo (crear/cambiar reinicializa con el nuevo perfil activo).
268
269
  listProfiles () { return this._h('listProfiles') }
@@ -27,27 +27,12 @@ const ECDSA = { name: 'ECDSA', namedCurve: 'P-256' }
27
27
  const ECDH = { name: 'ECDH', namedCurve: 'P-256' }
28
28
  const SIGN = { name: 'ECDSA', hash: { name: 'SHA-256' } }
29
29
 
30
- /**
31
- * HASTA CUÁNDO SE ACEPTA UN PAPEL DEL MODELO VIEJO — **2026-10-01**, y se quita ese día.
32
- *
33
- * Los certificados de antes de 0.73 llevaban `exp` y no `seq`. Al pasar la bóveda al modelo
34
- * del acta, TODOS los aparatos ya enrolados se quedaban fuera de golpe (`shape`) y sin forma
35
- * de volver: el mostrador de renovación los rechazaba por lo mismo. Diez máquinas —los dos
36
- * proxios, los nodes de contenido, el bot— habrían necesitado re-enrolarse a mano, con TTY
37
- * en los dos lados.
38
- *
39
- * Así que se aceptan, con dos frenos y ninguna concesión de fondo:
40
- * · hasta su PROPIO vencimiento, que sigue valiendo (el más lejano es 2026-09-30);
41
- * · y en ningún caso pasado este tope.
42
- * Además el mostrador los cruza con el acta igual que a los nuevos, así que un papel viejo
43
- * no autoriza nada que el acta de hoy no autorice.
44
- *
45
- * Se apaga solo por partida doble: ninguna bóveda 0.73+ puede EMITIR uno de estos
46
- * (`signDelegationWith` solo escribe `seq`), así que cuando el último caduque esta rama es
47
- * inalcanzable. **Y aun así se borra en la fecha**, porque un repliegue sin fecha no es una
48
- * migración: es un agujero (`CLAUDE.md`).
30
+ /*
31
+ * EL MODELO VIEJO YA NO ENTRA. Los papeles de antes de 0.73 llevaban `exp` y no `seq`; se
32
+ * aceptaron hasta el 2026-10-01 por un repliegue de migración declarado y con fecha, y esa
33
+ * fecha pasó: se quitó (2026-10-05). Un papel sin `seq` se rechaza con `legacy-cert-retirado`,
34
+ * el mismo código que daba el repliegue al caducar, así que quien lo comprueba no cambia.
49
35
  */
50
- export const LEGACY_CERTS_UNTIL = Date.parse('2026-10-01T00:00:00Z')
51
36
 
52
37
  const enc = (s) => new TextEncoder().encode(s)
53
38
 
@@ -252,9 +237,8 @@ export async function verifyDelegation ({ cert, expectedScope, expectedSub, acta
252
237
  const { v, iss, sub, scope, iat, seq, nonce, sig } = cert
253
238
  if (v !== 1 || typeof iss !== 'string' || typeof sub !== 'string' || typeof sig !== 'string') return { ok: false, reason: 'shape' }
254
239
  if (typeof iat !== 'number' || (typeof scope !== 'string' && !Array.isArray(scope))) return { ok: false, reason: 'shape' }
255
- // ¿ES UN PAPEL DEL MODELO VIEJO? Llevaba `exp` y no `seq` (ver `LEGACY_CERTS_UNTIL`).
256
- const legado = typeof seq !== 'number'
257
- if (legado && typeof cert.exp !== 'number') return { ok: false, reason: 'shape' }
240
+ // UN PAPEL DEL MODELO VIEJO (con `exp`, sin `seq`) ya no vale: el repliegue se retiró.
241
+ if (typeof seq !== 'number') return { ok: false, reason: typeof cert.exp === 'number' ? 'legacy-cert-retirado' : 'shape' }
258
242
  if (!(await rawVerify(iss, enc(canonicalStringify(delegationBody(cert))), sig))) return { ok: false, reason: 'bad-signature' }
259
243
  // EL ACTA MANDA, Y NO HAY RELOJ (dueño, 2026-08-31). El papel no vence: dice «una
260
244
  // selladora de este perfil, mirando el acta nº `seq`, avaló esta llave». Lo que puede
@@ -280,19 +264,7 @@ export async function verifyDelegation ({ cert, expectedScope, expectedSub, acta
280
264
  // estaba apagado: vuelve, su papel ya no vale, y renovarlo exige una selladora ABIERTA.
281
265
  // O sea que un cambio de acta te obligaría a abrir la bóveda para que tus aparatos
282
266
  // volvieran — justo lo que se acaba de quitar de en medio.
283
- if (!legado && seq > actaSeq) return { ok: false, reason: 'acta-vieja' }
284
- // REPLIEGUE DE MIGRACIÓN, declarado y con fecha (ver `LEGACY_CERTS_UNTIL`). Un papel del
285
- // modelo viejo se sigue aceptando, pero SOLO hasta su propio vencimiento: no se le regala
286
- // ni un día. No hay nada que decidir sobre el acta que nombra, porque no nombra ninguna.
287
- //
288
- // Lo que NO se relaja, y es lo que hace que esto no sea un agujero: quien lo emitió tiene
289
- // que poder sellar (abajo), y el mostrador cruza igual con el acta (`memberCanScope`). Un
290
- // papel viejo no autoriza nada que el acta de hoy no autorice; lo único que compra es
291
- // seguir hablando mientras su máquina se actualiza.
292
- if (legado) {
293
- if (Date.now() > cert.exp) return { ok: false, reason: 'expired' }
294
- if (Date.now() > LEGACY_CERTS_UNTIL) return { ok: false, reason: 'legacy-cert-retirado' }
295
- }
267
+ if (seq > actaSeq) return { ok: false, reason: 'acta-vieja' }
296
268
  // Y QUIEN LO EMITIÓ TIENE QUE PODER SELLAR. No se compara contra «la maestra»: cualquiera
297
269
  // que el acta nombre sellador emite papeles válidos — si no, la segunda bóveda podría
298
270
  // invalidar todos los certificados al sellar y luego no poder dar los nuevos.
@@ -304,7 +276,7 @@ export async function verifyDelegation ({ cert, expectedScope, expectedSub, acta
304
276
  : (revoked instanceof Set ? revoked.has(nonce) : !!revoked[nonce])
305
277
  if (isRev) return { ok: false, reason: 'revoked' }
306
278
  }
307
- return { ok: true, iss, sub, scope, iat, seq: legado ? null : seq, nonce, legacy: legado || undefined }
279
+ return { ok: true, iss, sub, scope, iat, seq, nonce }
308
280
  }
309
281
 
310
282
  /**
package/vault/core.js CHANGED
@@ -18,7 +18,7 @@
18
18
  * vault, compartida por todos los runtimes.
19
19
  */
20
20
 
21
- import { signDelegationWith, LEGACY_CERTS_UNTIL } from './capabilities.js'
21
+ import { signDelegationWith } from './capabilities.js'
22
22
  import * as Acta from './acta.js'
23
23
  import * as Content from './content.js'
24
24
  import { syncPeers, stampOf } from './peerSync.js'
@@ -1015,14 +1015,13 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
1015
1015
  * el vault y hay un aparato expirado debe quitarlo del acta en una nueva acta»*).
1016
1016
  *
1017
1017
  * Muerto es un aparato cuyo papel no tiene vuelta atrás: todos los certificados que ESTA
1018
- * bóveda le dio son del modelo viejo (sin `seq`) y ya no valen — vencidos, o pasado
1019
- * `LEGACY_CERTS_UNTIL`, que los retira a todos. Renovar tampoco lo salva: la renovación
1018
+ * bóveda le dio son del modelo viejo (sin `seq`), que ya no vale desde que se retiró su
1019
+ * repliegue (2026-10-01). Renovar tampoco lo salva: la renovación
1020
1020
  * viaja firmada con ese mismo papel, y la bóveda lo rechaza (`unauthorized: expired`).
1021
1021
  * Hasta ahora se quedaban en el acta para siempre, como miembros que nadie podía usar.
1022
1022
  *
1023
1023
  * Lo que NO se toca, porque no hay datos para juzgarlo:
1024
1024
  * · un miembro sin certificados de esta bóveda (se los pudo dar otra);
1025
- * · un papel viejo sin `exp` antes del corte;
1026
1025
  * · esta misma llave.
1027
1026
  *
1028
1027
  * Todo sale en UNA acta: las bajas y la clave de contenido nueva van en el mismo sello,
@@ -1041,8 +1040,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
1041
1040
  if (!porSub.has(d.sub)) porSub.set(d.sub, [])
1042
1041
  porSub.get(d.sub).push(d)
1043
1042
  }
1044
- const muerto = (d) => typeof d.seq !== 'number' &&
1045
- (now > LEGACY_CERTS_UNTIL || (typeof d.exp === 'number' && now > d.exp))
1043
+ const muerto = (d) => typeof d.seq !== 'number'
1046
1044
  const muertos = [...porSub]
1047
1045
  .filter(([sub, ds]) => sub !== publickeyJwkStr && ds.every(muerto))
1048
1046
  .map(([sub, ds]) => ({ pub: sub, label: ds[0]?.label || '' }))
@@ -1383,13 +1381,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
1383
1381
  // él se va la última razón por la que la maestra tenía que estar disponible sin nadie
1384
1382
  // delante. Renovar pasa a ocurrir justo cuando ya hay una selladora abierta, porque
1385
1383
  // cambiar el acta ES tenerla abierta.
1386
- // Y LA MIGRACIÓN: un papel del modelo viejo (sin `seq`) que todavía vale se cambia por
1387
- // uno nuevo. Sin esto moría en su fecha aunque el aparato se usara a diario, y ya no
1388
- // tenía arreglo — el teléfono que aprueba se quedó así el 2026-09-22. Caduca sola: a
1389
- // partir de `LEGACY_CERTS_UNTIL` no queda ningún papel viejo que valga.
1390
- const legadoVivo = typeof v.cert.seq !== 'number' && typeof v.cert.exp === 'number' &&
1391
- now < v.cert.exp && now < LEGACY_CERTS_UNTIL
1392
- if (!legadoVivo && !certDesfasadoDelActa()) return
1384
+ if (!certDesfasadoDelActa()) return
1393
1385
  if (now - renewLastTry < RENEW_RETRY_MS) return
1394
1386
  renovarCert().catch(() => {}) // best-effort: el cert vigente sigue sirviendo mientras tanto
1395
1387
  } catch (_) {}
@@ -1461,6 +1453,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
1461
1453
  if (b.contactNotes !== undefined) merged.contactNotes = b.contactNotes
1462
1454
  if (b.encryptionPubkey) merged.encryptionPubkey = b.encryptionPubkey
1463
1455
  if (typeof b.rating === 'number') merged.rating = b.rating
1456
+ if (typeof b.blocked === 'boolean') merged.blocked = b.blocked
1464
1457
  }
1465
1458
  merged.firstSeen = Math.min(a.firstSeen || aSeen || Date.now(), b.firstSeen || bSeen || Date.now())
1466
1459
  merged.lastSeen = Math.max(aSeen, bSeen)
@@ -1984,6 +1977,18 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
1984
1977
  return p[publickey] || null
1985
1978
  },
1986
1979
 
1980
+ /**
1981
+ * BLOQUEAR a alguien (dueño, 2026-10-05): un indicador PRIVADO, aparte de la calificación.
1982
+ * No se firma ni se publica —no es una opinión sobre esa persona, es tu decisión de no
1983
+ * recibirla—, viaja con el libro de contactos a tu bóveda y a tus aparatos, y lo respeta
1984
+ * cada app que recibe mensajes (el messenger, el primero). Desbloquear deja `false` y no
1985
+ * borra el campo: así el cambio también llega a los demás aparatos.
1986
+ */
1987
+ async setBlocked ({ publickey, blocked }) {
1988
+ if (!publickey) throw new Error('publickey required')
1989
+ return upsertPeer(publickey, { blocked: !!blocked, changedAt: Date.now() })
1990
+ },
1991
+
1987
1992
  async setNickname ({ publickey, nickname }) {
1988
1993
  return upsertPeer(publickey, { nickname: String(nickname || '').slice(0, 40) })
1989
1994
  },
@@ -2396,14 +2401,17 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
2396
2401
  if (e.id === currentPid) { me = { ...(me || {}), nickname: e.name }; saveMe(me) }
2397
2402
  return { id: e.id, name: e.name }
2398
2403
  },
2404
+ /**
2405
+ * BORRAR UNA CUENTA, también la activa y también la única (dueño, 2026-10-05): borrar tu
2406
+ * cuenta desde la app es un derecho del usuario, y la App Store lo exige (norma 5.1.1(v)).
2407
+ * La alerta es de la INTERFAZ, que pregunta antes. Si era la activa, `current` dice cuál
2408
+ * queda, y la app tiene que RECARGAR; si no queda ninguna, el arranque estrena una nueva
2409
+ * y vacía (ver `purgeProfile`).
2410
+ */
2399
2411
  async deleteProfile ({ id } = {}) {
2400
- const list = loadProfiles()
2401
- // El freno es de la INTERFAZ: el botón «Borrar» de la página de perfiles no puede
2402
- // dejarte sin ninguna de un clic. La expulsión no pasa por aquí (ver `purgeProfile`):
2403
- // ahí sí se va la última, porque no es un descuido sino que te echaron.
2404
- if (list.length <= 1) throw new Error('cannot delete the only profile')
2405
- if (!list.find((p) => p.id === id)) throw new Error('profile does not exist')
2406
- return purgeProfile(id)
2412
+ if (!loadProfiles().find((p) => p.id === id)) throw new Error('profile does not exist')
2413
+ const wasCurrent = id === currentPid
2414
+ return { ...(await purgeProfile(id)), wasCurrent }
2407
2415
  },
2408
2416
 
2409
2417
  // ----- ENTRAR CON USUARIO Y CONTRASEÑA -----
@@ -3049,12 +3057,12 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
3049
3057
  * `profile` apunta a OTRA cuenta de este mismo dispositivo (ver `vaultApprovalsAll`):
3050
3058
  * sin él es la activa, como siempre.
3051
3059
  */
3052
- async vaultApprovals ({ op, id, profile = null } = {}) {
3060
+ async vaultApprovals ({ op, id, profile = null, notify } = {}) {
3053
3061
  if (profile && profile !== currentPid) return approvalsDeOtroPerfil(profile, { op, id })
3054
3062
  const v = loadVaultCert(); const device = loadVaultDevice()
3055
3063
  if (!v?.cert || !device) throw new Error('this device is not paired with a vault')
3056
3064
  maybeRenewVaultCert()
3057
- const ask = (cert) => remoteApproval({ master: v.master, proxy: v.proxy, device, cert, op, id, onRevoked: wipeVaultLink })
3065
+ const ask = (cert) => remoteApproval({ master: v.master, proxy: v.proxy, device, cert, op, id, notify, onRevoked: wipeVaultLink })
3058
3066
  try {
3059
3067
  let r
3060
3068
  try { r = await ask(v.cert) } catch (e) {
@@ -3096,7 +3104,7 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
3096
3104
  * @returns {Promise<Array<{ profile, name, items, error? }>>} una entrada por cuenta que
3097
3105
  * aprueba — con sus pedidos, o con el motivo por el que no se pudo preguntar.
3098
3106
  */
3099
- async vaultApprovalsAll () {
3107
+ async vaultApprovalsAll ({ notify } = {}) {
3100
3108
  // La activa sí se pone al día: es la única en la que esta pantalla puede escribir.
3101
3109
  try { maybeRenewVaultCert() } catch (_) {}
3102
3110
  const perfiles = loadProfiles()
@@ -3111,7 +3119,9 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
3111
3119
  }
3112
3120
  if (!device) return { ...base, items: [], error: 'no-key' }
3113
3121
  try {
3114
- const r = await remoteApproval({ master: v.master, proxy: v.proxy, device, cert: v.cert, op: 'approvals' })
3122
+ // `notify` habla de la cuenta ACTIVA: los avisos se suscriben bajo SU llave. De las
3123
+ // demás no se dice nada, que no es lo mismo que decir que no.
3124
+ const r = await remoteApproval({ master: v.master, proxy: v.proxy, device, cert: v.cert, op: 'approvals', ...(p.id === currentPid ? { notify } : {}) })
3115
3125
  const items = Array.isArray(r?.items) ? await abrirContextos(r.items, p.id) : []
3116
3126
  return { ...base, items }
3117
3127
  } catch (e) {
Binary file
package/vault/index.html CHANGED
@@ -1,24 +1,155 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="es">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
- <title>Dotrino Identity Vault</title>
6
- <meta name="robots" content="noindex,nofollow">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
6
+ <!-- Esta página tiene DOS papeles según cómo se abra:
7
+ · en un iframe (lo normal): es la bóveda de identidad de todas las apps. Carga
8
+ vault.js y no enseña nada.
9
+ · abierta directo: es la portada que explica qué es Identidad Dotrino. NO carga
10
+ vault.js (una pestaña de portada no debe tomar el candado del modo «este
11
+ dispositivo es bóveda» ni atender a nadie), y carga el topbar y GoatCounter, que
12
+ dentro del iframe sobrarían (contarían cada arranque de cada app). -->
13
+ <script>if (window.top !== window) document.documentElement.classList.add('embedded')</script>
14
+ <title>Identidad Dotrino — tu llave y tus perfiles, en tu aparato</title>
15
+ <meta name="description" content="La identidad de Dotrino: una llave y tus perfiles que viven en tu aparato y comparten todas las apps de Dotrino. Cómo funciona en el navegador, en Android y en iPhone.">
16
+ <link rel="canonical" href="https://id.dotrino.com/">
17
+ <link rel="icon" type="image/png" href="icon-192.png">
18
+ <link rel="apple-touch-icon" href="icon-192.png">
19
+ <meta name="theme-color" content="#0a3570">
20
+ <meta property="og:type" content="website">
21
+ <meta property="og:locale" content="es_ES">
22
+ <meta property="og:locale:alternate" content="en_US">
23
+ <meta property="og:site_name" content="Dotrino">
24
+ <meta property="og:title" content="Identidad Dotrino">
25
+ <meta property="og:description" content="Tu llave y tus perfiles viven en tu aparato y los comparten todas las apps de Dotrino. En el navegador, en Android y en iPhone.">
26
+ <meta property="og:url" content="https://id.dotrino.com/">
27
+ <meta property="og:image" content="https://id.dotrino.com/og.jpg">
28
+ <meta property="og:image:width" content="1200">
29
+ <meta property="og:image:height" content="630">
30
+ <meta name="twitter:card" content="summary_large_image">
31
+ <meta name="twitter:title" content="Identidad Dotrino">
32
+ <meta name="twitter:description" content="Tu llave y tus perfiles viven en tu aparato y los comparten todas las apps de Dotrino.">
33
+ <meta name="twitter:image" content="https://id.dotrino.com/og.jpg">
34
+ <script type="application/ld+json">
35
+ {
36
+ "@context": "https://schema.org",
37
+ "@type": "WebApplication",
38
+ "name": "Identidad Dotrino",
39
+ "url": "https://id.dotrino.com/",
40
+ "description": "La identidad compartida de las apps de Dotrino: una llave y tus perfiles que viven en tu aparato.",
41
+ "applicationCategory": "SecurityApplication",
42
+ "operatingSystem": "Web, Android, iOS",
43
+ "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
44
+ "publisher": { "@type": "Organization", "name": "Dotrino", "url": "https://dotrino.com/" }
45
+ }
46
+ </script>
7
47
  <style>
8
- body { font-family: system-ui, sans-serif; padding: 1rem; color: #444; background: #fafafa; }
9
- code { background: #eee; padding: 2px 6px; border-radius: 3px; }
48
+ :root {
49
+ --brand: #0a3570;
50
+ --accent: #00658c;
51
+ --bg: #f6f8fb;
52
+ --panel: #ffffff;
53
+ --ink: #14213d;
54
+ --muted: #5a6478;
55
+ --line: #dfe5ee;
56
+ --warn-bg: #fff7e6;
57
+ --warn-line: #f0c36d;
58
+ --dotrino-topbar-bg: rgba(255, 255, 255, .92);
59
+ --dotrino-topbar-border: var(--line);
60
+ --dotrino-topbar-text: var(--ink);
61
+ --dotrino-topbar-muted: var(--muted);
62
+ --dotrino-topbar-accent: var(--accent);
63
+ }
64
+ * { box-sizing: border-box; }
65
+ html, body { margin: 0; padding: 0; }
66
+ body {
67
+ background: var(--bg); color: var(--ink);
68
+ font: 16px/1.6 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
69
+ -webkit-text-size-adjust: 100%;
70
+ }
71
+ html.embedded body { background: transparent; }
72
+ html.embedded #home { display: none; }
73
+ a { color: var(--accent); }
74
+ .wrap { max-width: 860px; margin: 0 auto; padding: 28px 20px 80px; }
75
+ h1 { color: var(--brand); font-size: clamp(30px, 6vw, 42px); line-height: 1.15; margin: 8px 0 10px; }
76
+ h2 { color: var(--brand); font-size: 22px; margin: 40px 0 12px; }
77
+ h3 { margin: 0 0 6px; font-size: 18px; }
78
+ .lead { font-size: 19px; color: var(--muted); max-width: 640px; }
79
+ .cards { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
80
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 18px; }
81
+ .card p { margin: 6px 0; }
82
+ .tag { display: inline-block; font-size: 12px; font-weight: 600; color: var(--accent); text-transform: uppercase; letter-spacing: .04em; }
83
+ .warn { background: var(--warn-bg); border: 1px solid var(--warn-line); border-radius: 14px; padding: 18px 20px; }
84
+ .warn p { margin: 8px 0; }
85
+ ul { padding-left: 20px; }
86
+ footer { margin-top: 48px; color: var(--muted); font-size: 14px; }
87
+ html[lang="en"] [data-lang="es"] { display: none; }
88
+ html:not([lang="en"]) [data-lang="en"] { display: none; }
10
89
  </style>
11
90
  </head>
12
91
  <body>
13
- <h1>Dotrino — Identity Vault</h1>
14
- <p>This page is the cross-app storage for Dotrino identities.
15
- It is meant to be embedded as an invisible iframe by the apps.</p>
16
- <p>Stored in <code>localStorage</code> on this origin:</p>
17
- <ul>
18
- <li>Your ECDSA P-256 keypair (private key never leaves this page).</li>
19
- <li>Nicknames and ratings of peers you've met.</li>
20
- </ul>
21
- <p>Source: <a href="https://github.com/imdotrino/dotrino-identity" target="_blank" rel="noopener">github.com/imdotrino/dotrino-identity</a></p>
92
+ <div id="home">
93
+ <dotrino-topbar profile
94
+ brand="Identidad"
95
+ icon="icon-192.png"
96
+ no-back
97
+ support-repo="imdotrino/dotrino-identity"
98
+ support-discord="https://discord.gg/D648uq7cth"></dotrino-topbar>
99
+
100
+ <main class="wrap">
101
+ <h1>Identidad Dotrino</h1>
102
+ <p class="lead" data-lang="es">Tu llave y tus perfiles. Viven en tu aparato, no se registran en ningún lado y los comparten todas las apps de Dotrino.</p>
103
+ <p class="lead" data-lang="en">Your key and your profiles. They live on your device, aren't registered anywhere, and every Dotrino app shares them.</p>
104
+
105
+ <h2 data-lang="es">Dónde vive, según dónde uses Dotrino</h2>
106
+ <h2 data-lang="en">Where it lives, depending on where you use Dotrino</h2>
107
+ <div class="cards">
108
+ <section class="card">
109
+ <span class="tag" data-lang="es">Navegador</span><span class="tag" data-lang="en">Browser</span>
110
+ <h3 data-lang="es">Se crea sola</h3><h3 data-lang="en">It creates itself</h3>
111
+ <p data-lang="es">La primera vez que abres una app de Dotrino, tu navegador crea tu llave. Todas las apps que abras <b>en ese navegador</b> la comparten.</p>
112
+ <p data-lang="en">The first time you open a Dotrino app, your browser creates your key. Every app you open <b>in that browser</b> shares it.</p>
113
+ </section>
114
+ <section class="card">
115
+ <span class="tag">Android</span>
116
+ <h3 data-lang="es">Una app: Identidad Dotrino</h3><h3 data-lang="en">An app: Dotrino Identity</h3>
117
+ <p data-lang="es">Las apps de Dotrino instaladas desde Google Play guardan tus llaves y perfiles en la app <b>Identidad Dotrino</b>. No se abre ni hace nada sola: está ahí para que las demás la compartan. Las apps que la necesitan te avisan si falta.</p>
118
+ <p data-lang="en">Dotrino apps installed from Google Play keep your keys and profiles in the <b>Dotrino Identity</b> app. You don't open it and it does nothing on its own: it's there so the others can share it. The apps that need it tell you if it's missing.</p>
119
+ <p><a href="https://play.google.com/apps/internaltest/4699800053552003281" rel="noopener"><span data-lang="es">Identidad Dotrino en Google Play (beta)</span><span data-lang="en">Dotrino Identity on Google Play (beta)</span></a></p>
120
+ </section>
121
+ <section class="card">
122
+ <span class="tag" data-lang="es">iPhone y iPad</span><span class="tag" data-lang="en">iPhone and iPad</span>
123
+ <h3 data-lang="es">No es una app, pero existe</h3><h3 data-lang="en">Not an app, but it's there</h3>
124
+ <p data-lang="es">En iPhone <b>no hay app de Identidad Dotrino, ni hace falta</b>. Apple deja que las apps de un mismo fabricante compartan un espacio guardado, y ahí cada app de Dotrino guarda tus perfiles para las demás. No hay nada que instalar.</p>
125
+ <p data-lang="en">On iPhone <b>there is no Dotrino Identity app, and none is needed</b>. Apple lets apps from the same maker share a saved space, and each Dotrino app keeps your profiles there for the others. There's nothing to install.</p>
126
+ </section>
127
+ </div>
128
+
129
+ <h2 data-lang="es">El navegador y las apps instaladas no se ven</h2>
130
+ <h2 data-lang="en">The browser and installed apps don't see each other</h2>
131
+ <div class="warn">
132
+ <p data-lang="es"><b>Los perfiles de tu navegador no aparecen en las apps instaladas desde la tienda, y los de las apps instaladas no aparecen en el navegador.</b> El teléfono no deja que uno mire dentro del otro. Pasa igual en Android y en iPhone.</p>
133
+ <p data-lang="en"><b>Your browser's profiles don't show up in apps installed from the store, and the installed apps' profiles don't show up in the browser.</b> The phone doesn't let one look inside the other. It's the same on Android and iPhone.</p>
134
+ <p data-lang="es">Para usar el mismo perfil en los dos, se conectan a tu bóveda como dos aparatos de tu cuenta, o se inicia sesión con tu dirección y tu contraseña. Si no haces nada, cada lado sigue con su propia cuenta y todo funciona igual.</p>
135
+ <p data-lang="en">To use the same profile in both, connect them to your vault as two devices of your account, or sign in with your address and password. If you do nothing, each side keeps its own account and everything works the same.</p>
136
+ <p><a data-lang="es" href="https://wiki.dotrino.com/empezar/navegador-y-apps/">Cómo usar el mismo perfil en los dos</a><a data-lang="en" href="https://wiki.dotrino.com/en/empezar/navegador-y-apps/">How to use the same profile in both</a></p>
137
+ </div>
138
+
139
+ <h2 data-lang="es">Más</h2>
140
+ <h2 data-lang="en">More</h2>
141
+ <ul>
142
+ <li><a href="https://profile.dotrino.com/"><span data-lang="es">Administrar tus perfiles</span><span data-lang="en">Manage your profiles</span></a></li>
143
+ <li><a data-lang="es" href="https://wiki.dotrino.com/empezar/identidad/">Tu identidad y tus perfiles</a><a data-lang="en" href="https://wiki.dotrino.com/en/empezar/identidad/">Your identity and your profiles</a></li>
144
+ <li><a data-lang="es" href="https://wiki.dotrino.com/empezar/otros-dispositivos/">Usar tu cuenta en otros dispositivos</a><a data-lang="en" href="https://wiki.dotrino.com/en/empezar/otros-dispositivos/">Use your account on other devices</a></li>
145
+ </ul>
146
+
147
+ <footer>
148
+ <p>MIT · <a href="https://github.com/imdotrino/dotrino-identity">imdotrino/dotrino-identity</a> · <span data-lang="es">parte de</span><span data-lang="en">part of</span> <a href="https://dotrino.com/">Dotrino</a></p>
149
+ </footer>
150
+ </main>
151
+ </div>
152
+
22
153
  <!-- Transporte self-hosted (vendorizado): el emparejamiento de dispositivos importa
23
154
  @dotrino/proxy-client de forma perezosa; este import map lo resuelve sin CDN. -->
24
155
  <script type="importmap">
@@ -33,6 +164,21 @@
33
164
  "@dotrino/identity/content": "./content.js"
34
165
  } }
35
166
  </script>
36
- <script type="module" src="./vault.js"></script>
167
+ <script type="module">
168
+ if (window.top !== window) {
169
+ import('./vault.js')
170
+ } else {
171
+ // Portada. Analítica cookieless autohosteada (GoatCounter); no cuenta en localhost/LAN.
172
+ window.goatcounter = { path: (p) => location.hostname + p }
173
+ const goat = document.createElement('script')
174
+ goat.async = true
175
+ goat.dataset.goatcounter = 'https://goat.dotrino.com/count'
176
+ goat.src = 'https://goat.dotrino.com/count.js'
177
+ document.head.appendChild(goat)
178
+ // Por CDN el topbar va SIEMPRE con `+esm` (CONVENCIONES §5). Pone el idioma en
179
+ // <html lang>, que es de lo que cuelgan los bloques [data-lang].
180
+ import('https://cdn.jsdelivr.net/npm/@dotrino/topbar@0.12/+esm')
181
+ }
182
+ </script>
37
183
  </body>
38
184
  </html>
package/vault/og.jpg ADDED
Binary file
package/vault/remote.js CHANGED
@@ -452,11 +452,16 @@ export async function requestAdmin ({ master, proxy, device, cert, op, onRevoked
452
452
  * `vault:approve` — que, como `admin`, no se recibe al emparejar: se concede a mano.
453
453
  * La bóveda contesta un cuerpo firmado por la maestra (`{ op, items | ok }`).
454
454
  */
455
- export async function requestApproval ({ master, proxy, device, cert, op, id, onRevoked } = {}) {
455
+ export async function requestApproval ({ master, proxy, device, cert, op, id, notify, onRevoked } = {}) {
456
+ // `notify`: al pedir la lista, este aparato dice si le llegan los avisos. La bóveda lo
457
+ // apunta y es lo que mira para saber si hay a quién pedirle permiso antes de actualizarse.
458
+ // Solo va si quien llama lo SABE (true o false): no decir nada no cambia lo ya dicho.
459
+ const data = id ? { op, id } : { op }
460
+ if (op === 'approvals' && typeof notify === 'boolean') data.notify = notify
456
461
  const res = await vaultRpc({
457
462
  master, proxy, device, cert, onRevoked,
458
463
  sendType: MSG.SECRETS, okType: MSG.SECRETS_RESULT,
459
- data: id ? { op, id } : { op }
464
+ data
460
465
  })
461
466
  return res.body
462
467
  }
@@ -0,0 +1,3 @@
1
+ User-agent: *
2
+ Allow: /
3
+ Sitemap: https://id.dotrino.com/sitemap.xml
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
+ <url><loc>https://id.dotrino.com/</loc></url>
4
+ </urlset>
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.26.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.30.1 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc,stats,stats-log}.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.
@@ -2,6 +2,7 @@ import { buildSignedChannel, getPublicKeyJwk, signData, samePubkey } from './sig
2
2
  import { seal, open, isSealed } from './sealing.js'
3
3
  import { buildEncPubStatement, readEncPubStatement, isEncPub } from './encpub.js'
4
4
  import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS, loadNodePeerConnection, resolvePeerConnection } from './webrtc.js'
5
+ import { TrafficStats, utf8Length, registerTransport, unregisterTransport } from './stats.js'
5
6
 
6
7
  /**
7
8
  * Error con un `code` estable.
@@ -46,6 +47,20 @@ function errorCon (mensaje, code) {
46
47
  * - 'reconnecting' (attempt, max)
47
48
  * - 'reconnect_failed' (attempts)
48
49
  */
50
+ const APP_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
51
+
52
+ /**
53
+ * Valida un nombre de app (`messenger`, `vault`…). Lo que no tiene forma LANZA: un nombre mal
54
+ * escrito haría que el timbre no sonara nunca en esa app, y eso no se ve.
55
+ */
56
+ function checkApp (app) {
57
+ if (app == null) return null
58
+ if (typeof app !== 'string' || !APP_RE.test(app)) {
59
+ throw Object.assign(new Error(`app: "${app}" is not a valid app name (a-z, 0-9, -; up to 32)`), { code: 'bad-app' })
60
+ }
61
+ return app
62
+ }
63
+
49
64
  export class WebSocketProxyClient {
50
65
  constructor (options = {}) {
51
66
  this.url = options.url || 'wss://proxy.dotrino.com'
@@ -69,6 +84,18 @@ export class WebSocketProxyClient {
69
84
  this.requireSealed = options.requireSealed === true
70
85
  this.myEncPrivateKey = options.myEncPrivateKey || null
71
86
 
87
+ /**
88
+ * QUÉ APP ES ESTA (websocket-proxy ≥ 1.4.0). En un teléfono varias apps hablan con la
89
+ * MISMA llave (el perfil del teléfono), y el proxio necesita saber a cuál despertar y a
90
+ * cuál entregarle la cola: sin esto, la última en suscribirse se llevaba todos los
91
+ * timbres y la primera en conectarse, los mensajes de todas. Es ruteo, no contenido.
92
+ *
93
+ * Se dice al identificarse (solo baja lo suyo de la cola), al suscribirse al timbre (solo
94
+ * suena con lo suyo) y quien le escribe lo marca con `sendByPubkey(…, { app })`.
95
+ * Sin `app`, como antes: recibe todo.
96
+ */
97
+ this.app = checkApp(options.app)
98
+
72
99
  /**
73
100
  * MI llave de cifrado, la pública. Con ella puesta, `identify` anuncia al proxio
74
101
  * —firmado— que esta identidad se abre por aquí, y cualquiera que sepa mi pubkey puede
@@ -154,6 +181,7 @@ export class WebSocketProxyClient {
154
181
  this._pending = new Map() // messageId -> { resolve, reject, timer }
155
182
  this._nextId = 1
156
183
  this._tokenWatch = new Map() // messageId -> { token, sobre, peerPubkey, timer }
184
+ this._traffic = new TrafficStats()
157
185
 
158
186
  this._rtc = this.enableWebRTC ? new WebRTCManager({
159
187
  getSelfToken: () => this.token,
@@ -166,6 +194,8 @@ export class WebSocketProxyClient {
166
194
  if (event === 'webrtc_close') this._rtcTried?.delete(args[0])
167
195
  this._emit(event, ...args)
168
196
  },
197
+ count: (dir, token, bytes, route) =>
198
+ this._traffic.peer(dir, route, { token, pubkey: this._tokenPubkeys.get(token) || null }, bytes),
169
199
  config: this.iceServers ? { iceServers: this.iceServers } : null
170
200
  }) : null
171
201
  // QUIÉN PUEDE HACERTE NEGOCIAR UN CANAL DIRECTO. Sin política, cualquiera que sepa
@@ -203,6 +233,7 @@ export class WebSocketProxyClient {
203
233
 
204
234
  close () {
205
235
  this.autoReconnect = false
236
+ unregisterTransport(this)
206
237
  if (this._reconnectTimer) {
207
238
  clearTimeout(this._reconnectTimer)
208
239
  this._reconnectTimer = null
@@ -257,6 +288,17 @@ export class WebSocketProxyClient {
257
288
  // cualquier otro módulo de la app la desactivara sin querer, y no hay ningún
258
289
  // motivo legítimo para hacerlo a mitad de una sesión.
259
290
  if (options.requireSealed === true) this.requireSealed = true
291
+
292
+ // La APP también, por la misma razón (el singleton): si se perdiera, esta app ni sonaría
293
+ // con lo suyo ni se llevaría solo su cola. Se fija una vez; otra distinta después es que
294
+ // dos apps comparten el cliente de la página, y eso es un error, no algo que resolver aquí.
295
+ if (options.app != null) {
296
+ const app = checkApp(options.app)
297
+ if (this.app && this.app !== app) {
298
+ throw Object.assign(new Error(`app: this client is already "${this.app}", not "${app}"`), { code: 'app-conflict' })
299
+ }
300
+ this.app = app
301
+ }
260
302
  }
261
303
 
262
304
  on (event, handler) {
@@ -503,6 +545,27 @@ export class WebSocketProxyClient {
503
545
  this.send(tokens, sobre)
504
546
  }
505
547
 
548
+ /**
549
+ * Mandar POR TOKEN algo que la app YA CIFRÓ por su cuenta (un canal de sesión propio,
550
+ * como el de `@dotrino/remote-agent`), con la misma red que `sendSealedTo`: sube al
551
+ * canal directo si lo hay, y si el token murió el mismo mensaje sale por `peerPubkey`
552
+ * y se avisa con `token_gone`.
553
+ *
554
+ * Existe porque `sendByPubkey` NUNCA sube de escalón —la cola es del proxio— y `send`
555
+ * a secas pierde en silencio lo que va a un token que ya no existe. Con `requireSealed`
556
+ * se niega igual que los demás: esto no abre una puerta para ir en claro.
557
+ *
558
+ * @param {string} token
559
+ * @param {any} payload
560
+ * @param {{ peerPubkey:string }} opts
561
+ */
562
+ sendToOrQueue (token, payload, { peerPubkey } = /** @type {any} */ ({})) {
563
+ if (!token || !peerPubkey) {
564
+ throw errorCon('sendToOrQueue: needs the token and whose it is (peerPubkey)', 'no-peer-identity')
565
+ }
566
+ this._sendToTokenOrQueue(token, payload, peerPubkey)
567
+ }
568
+
506
569
  /**
507
570
  * UN TOKEN MUERTO NO SE TRAGA EL MENSAJE. Un token es una conexión, y cuando la otra punta
508
571
  * reinicia la app su token deja de existir; la app que lo tenía apuntado sigue mandando ahí
@@ -765,9 +828,15 @@ export class WebSocketProxyClient {
765
828
  }
766
829
  if (opts.ephemeral) msg.ephemeral = true
767
830
  if (opts.quiet) msg.quiet = true
831
+ // A QUÉ APP va (ruteo): el proxio timbra y entrega solo a esa app de ese aparato.
832
+ const app = checkApp(opts.app)
833
+ if (app) msg.app = app
768
834
  this._sendRaw(msg)
769
835
  }
770
836
 
837
+ /** `{ app }` para lo firmado (suscribirse al timbre), o nada si esta conexión no dijo cuál. */
838
+ _appField () { return this.app ? { app: this.app } : {} }
839
+
771
840
  /**
772
841
  * Pedir una CITA: el código corto que una persona lee, dicta o escanea para
773
842
  * emparejarse con esta conexión.
@@ -858,6 +927,7 @@ export class WebSocketProxyClient {
858
927
  identify ({ data, signature, cert, acta, sign }) {
859
928
  if (!data || !signature) throw new Error('identify requires {data, signature}')
860
929
  const msg = { type: 'identify', data, signature }
930
+ if (this.app) msg.app = this.app
861
931
  if (cert) msg.cert = cert // "una identidad": el proxy bindea este token también bajo tu maestra M
862
932
  // Acta de perfil: el proxy la verifica (va firmada) y bindea también el `profileId`, así
863
933
  // escribirle a la PERSONA llega a cualquiera de sus dispositivos. Ver acta-de-perfil.md.
@@ -1019,7 +1089,7 @@ export class WebSocketProxyClient {
1019
1089
  })
1020
1090
  }
1021
1091
  const subJson = typeof sub.toJSON === 'function' ? sub.toJSON() : sub
1022
- const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify(subJson), ts: Date.now() }
1092
+ const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify(subJson), ts: Date.now(), ...this._appField() }
1023
1093
  const signature = await normalizeSignature(sign, data)
1024
1094
  await this._request({ type: 'push-subscribe', data, signature }, 'push-subscribed')
1025
1095
  return sub
@@ -1033,7 +1103,7 @@ export class WebSocketProxyClient {
1033
1103
  */
1034
1104
  async registerPushToken ({ publicKey, sign, token, kind = 'fcm' } = {}) {
1035
1105
  if (!publicKey || typeof sign !== 'function' || !token) throw new Error('registerPushToken requires { publicKey, sign, token }')
1036
- const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify({ kind, token }), ts: Date.now() }
1106
+ const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify({ kind, token }), ts: Date.now(), ...this._appField() }
1037
1107
  const signature = await normalizeSignature(sign, data)
1038
1108
  await this._request({ type: 'push-subscribe', data, signature }, 'push-subscribed')
1039
1109
  return { kind, token }
@@ -1058,7 +1128,7 @@ export class WebSocketProxyClient {
1058
1128
  } catch (_) { /* best-effort local */ }
1059
1129
  }
1060
1130
  if (publicKey && typeof sign === 'function') {
1061
- const data = { op: 'push-unsubscribe', publickey: publicKey, ts: Date.now() }
1131
+ const data = { op: 'push-unsubscribe', publickey: publicKey, ts: Date.now(), ...this._appField() }
1062
1132
  const signature = await normalizeSignature(sign, data)
1063
1133
  await this._request({ type: 'push-unsubscribe', data, signature }, 'push-unsubscribed')
1064
1134
  }
@@ -1145,6 +1215,7 @@ export class WebSocketProxyClient {
1145
1215
  // ---------- internals ----------
1146
1216
 
1147
1217
  _open () {
1218
+ registerTransport(this)
1148
1219
  const ws = new WebSocket(this.url)
1149
1220
  this.ws = ws
1150
1221
  // `ws !== this.ws` ⇒ es un socket que ya abandonamos (p.ej. por heartbeat
@@ -1159,6 +1230,7 @@ export class WebSocketProxyClient {
1159
1230
  ws.addEventListener('message', (ev) => {
1160
1231
  if (ws !== this.ws) return
1161
1232
  this._noteActivity() // cualquier frame entrante prueba que está vivo
1233
+ this._traffic.frame('in', utf8Length(ev.data))
1162
1234
  this._handleFrame(ev.data)
1163
1235
  })
1164
1236
  ws.addEventListener('error', (err) => {
@@ -1224,7 +1296,7 @@ export class WebSocketProxyClient {
1224
1296
  _heartbeatTick () {
1225
1297
  if (!this._connected || !this.ws) return
1226
1298
  if (this._hbDeadTimer) return // ya hay un ping en vuelo esperando respuesta
1227
- try { this.ws.send(JSON.stringify({ type: 'ping' })) }
1299
+ try { const ping = JSON.stringify({ type: 'ping' }); this.ws.send(ping); this._traffic.frame('out', ping.length) }
1228
1300
  catch (_) { this._onHeartbeatDead(); return }
1229
1301
  this._hbDeadTimer = setTimeout(() => this._onHeartbeatDead(), this.heartbeatTimeout)
1230
1302
  }
@@ -1301,6 +1373,7 @@ export class WebSocketProxyClient {
1301
1373
  if (typeof message === 'string') {
1302
1374
  try { parsed = JSON.parse(message) } catch (_) { parsed = null }
1303
1375
  }
1376
+ this._traffic.peer('in', 'proxy', { token: from || null, pubkey: from_publickey || this._tokenPubkeys.get(from) || null }, utf8Length(message))
1304
1377
  if (this._rtc && parsed && parsed.t === RTC_TAG) {
1305
1378
  this._rtc.handleIncoming(from, parsed)
1306
1379
  break
@@ -1395,7 +1468,65 @@ export class WebSocketProxyClient {
1395
1468
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1396
1469
  throw errorCon('WebSocket not connected', 'NOT_CONNECTED')
1397
1470
  }
1398
- this.ws.send(JSON.stringify(frame))
1471
+ const texto = JSON.stringify(frame)
1472
+ this.ws.send(texto)
1473
+ this._countOut(frame, texto)
1474
+ }
1475
+
1476
+ /** Cuenta un frame que sale por el proxio, y a quién iba. */
1477
+ _countOut (frame, texto) {
1478
+ this._traffic.frame('out', utf8Length(texto))
1479
+ if (typeof frame.message !== 'string') return
1480
+ const bytes = utf8Length(frame.message)
1481
+ for (const token of Array.isArray(frame.to) ? frame.to : []) {
1482
+ this._traffic.peer('out', 'proxy', { token, pubkey: this._tokenPubkeys.get(token) || null }, bytes)
1483
+ }
1484
+ for (const pubkey of Array.isArray(frame.to_publickey) ? frame.to_publickey : []) {
1485
+ this._traffic.peer('out', 'proxy', { pubkey }, bytes)
1486
+ }
1487
+ }
1488
+
1489
+ /**
1490
+ * ESTADÍSTICAS DE RED: cuánto entró y salió, por conexión, y por qué camino.
1491
+ *
1492
+ * `route` es por dónde va AHORA cada conexión: `proxy`, `connecting` (por el proxio
1493
+ * mientras se negocia WebRTC), `direct` o `turn` (WebRTC, sin o con relevo), `webrtc`
1494
+ * (canal abierto sin poder saber cuál) o `failed` (WebRTC no salió: sigue por el proxio).
1495
+ * Los bytes van separados por el camino por el que pasaron de verdad.
1496
+ *
1497
+ * Los bytes son de payload (UTF-8), no del cable: las cabeceras de TLS/DTLS no se ven
1498
+ * desde aquí.
1499
+ */
1500
+ async stats () {
1501
+ const t = this._traffic
1502
+ const routes = this._rtc ? await this._rtc.describe() : new Map()
1503
+ const peers = [...t.peers.values()].map((p) => {
1504
+ const pubkey = p.pubkey || (p.token && this._tokenPubkeys.get(p.token)) || null
1505
+ const rtc = p.token ? routes.get(p.token) : null
1506
+ return {
1507
+ token: p.token,
1508
+ pubkey,
1509
+ route: rtc && rtc !== 'failed' && rtc !== 'connecting' ? rtc : (rtc || 'proxy'),
1510
+ bytesIn: { ...p.bytesIn },
1511
+ bytesOut: { ...p.bytesOut },
1512
+ msgsIn: p.msgsIn,
1513
+ msgsOut: p.msgsOut,
1514
+ firstAt: p.firstAt,
1515
+ lastAt: p.lastAt
1516
+ }
1517
+ }).sort((a, b) => b.lastAt - a.lastAt)
1518
+ return {
1519
+ url: this.url,
1520
+ app: this.app,
1521
+ node: this.node || null,
1522
+ token: this.token,
1523
+ publickey: this.myPublickey,
1524
+ connected: this._connected,
1525
+ webrtc: !!this._rtc,
1526
+ since: t.since,
1527
+ proxy: { ...t.proxy },
1528
+ peers
1529
+ }
1399
1530
  }
1400
1531
 
1401
1532
  _request (frame, expectedType, channelKey) {
@@ -10,6 +10,9 @@ export {
10
10
  buildEncPubStatement, readEncPubStatement,
11
11
  } from './encpub.js'
12
12
 
13
+ export { listTransports, routeOf } from './stats.js'
14
+ export { logStats, formatStats } from './stats-log.js'
15
+
13
16
  import { WebSocketProxyClient } from './client.js'
14
17
 
15
18
  let _singleton = null
@@ -0,0 +1,96 @@
1
+ /**
2
+ * LAS ESTADÍSTICAS DE RED, AL LOG. Para lo que no tiene pantalla: un daemon, un agente, un
3
+ * servicio bajo pm2.
4
+ *
5
+ * En el navegador las enseña el topbar (su modal de red lee el registro de clientes). Un
6
+ * proceso de Node no tiene dónde, así que «¿esto va por WebRTC o sigue dando la vuelta por
7
+ * el proxio?» solo se contestaba con una prueba en contenedores. Esto escribe lo mismo que
8
+ * pinta el modal, una línea cada tanto, con el `log` que el servicio ya usa.
9
+ *
10
+ * Lee el MISMO registro que el topbar (`listTransports`), así que el servicio no le pasa
11
+ * sus clientes: los que haya en el proceso, salen.
12
+ *
13
+ * Tres cosas a propósito:
14
+ * - Solo escribe si algo cambió. Un servicio quieto no llena el log de líneas iguales.
15
+ * - Los contadores son ACUMULADOS desde `since`: dos líneas se restan, y una sola ya dice
16
+ * si hubo tráfico directo alguna vez.
17
+ * - De la otra punta solo sale el TOKEN, que es efímero. La pubkey no: con quién habla
18
+ * una máquina es justo lo que no se deja escrito en un disco.
19
+ */
20
+ import { listTransports, PATHS } from './stats.js'
21
+
22
+ const sum = (peers, dir) => {
23
+ const t = { proxy: 0, direct: 0, turn: 0, webrtc: 0 }
24
+ for (const p of peers) for (const k of PATHS) t[k] += p[dir][k]
25
+ return t
26
+ }
27
+
28
+ const pairs = (prefix, o) => PATHS.map((k) => `${prefix}.${k}=${o[k]}`).join(' ')
29
+
30
+ /**
31
+ * Las líneas de UN cliente, a partir de lo que devuelve `client.stats()`.
32
+ * La primera es el resumen; después, una por conexión.
33
+ *
34
+ * @param {object} s lo que devuelve `client.stats()` (`TransportStats` en los tipos)
35
+ * @param {{ label?: string }} [opts]
36
+ * @returns {string[]}
37
+ */
38
+ export function formatStats (s, opts = {}) {
39
+ const tag = opts.label ? `[net] ${opts.label}` : '[net]'
40
+ const routes = {}
41
+ for (const p of s.peers) routes[p.route] = (routes[p.route] || 0) + 1
42
+ const routeList = Object.keys(routes).sort().map((r) => `${r}=${routes[r]}`).join(',') || 'none'
43
+ const lines = [
44
+ `${tag} url=${s.url} connected=${s.connected ? 'yes' : 'no'} webrtc=${s.webrtc ? 'on' : 'off'}` +
45
+ ` ws.in=${s.proxy.bytesIn} ws.out=${s.proxy.bytesOut}` +
46
+ ` ${pairs('in', sum(s.peers, 'bytesIn'))} ${pairs('out', sum(s.peers, 'bytesOut'))}` +
47
+ ` peers=${s.peers.length} routes=${routeList}`
48
+ ]
49
+ for (const p of s.peers) {
50
+ lines.push(
51
+ `${tag} peer=${p.token || 'by-key'} route=${p.route}` +
52
+ ` ${pairs('in', p.bytesIn)} ${pairs('out', p.bytesOut)} msgs.in=${p.msgsIn} msgs.out=${p.msgsOut}`
53
+ )
54
+ }
55
+ return lines
56
+ }
57
+
58
+ /**
59
+ * Escribe las estadísticas de todos los clientes del proceso cada `everyMs`, si cambiaron.
60
+ *
61
+ * @param {{ log?: (line: string) => void, everyMs?: number, label?: string }} [opts]
62
+ * @returns {{ stop: () => Promise<void>, flush: () => Promise<void> }}
63
+ * `flush` escribe ya (si cambió algo); `stop` escribe una última vez y para.
64
+ */
65
+ export function logStats (opts = {}) {
66
+ const log = opts.log || ((line) => console.log(line))
67
+ const everyMs = opts.everyMs || 5 * 60 * 1000
68
+ /** cliente → lo último que se escribió de él */
69
+ const last = new WeakMap()
70
+
71
+ const flush = async () => {
72
+ for (const client of listTransports()) {
73
+ if (typeof client.stats !== 'function') continue
74
+ let lines
75
+ try {
76
+ lines = formatStats(await client.stats(), opts)
77
+ } catch (e) {
78
+ // No poder leer NO es «no hubo tráfico»: se dice, para que no parezca silencio.
79
+ lines = [`${opts.label ? `[net] ${opts.label}` : '[net]'} could not read stats: ${e?.message || e}`]
80
+ }
81
+ const text = lines.join('\n')
82
+ if (last.get(client) === text) continue
83
+ last.set(client, text)
84
+ for (const line of lines) log(line)
85
+ }
86
+ }
87
+
88
+ const timer = setInterval(() => { flush() }, everyMs)
89
+ // El log no puede ser lo que mantiene vivo un proceso que ya terminó.
90
+ if (typeof timer.unref === 'function') timer.unref()
91
+
92
+ return {
93
+ flush,
94
+ stop: async () => { clearInterval(timer); await flush() }
95
+ }
96
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * CUÁNTO TRÁFICO Y POR DÓNDE. Contadores del transporte, por conexión.
3
+ *
4
+ * Existe para poder VER la regla del camino más directo (CLAUDE.md, «el transporte SIEMPRE
5
+ * prefiere el camino más directo»): sin números, «el segundo mensaje ya no toca el proxio»
6
+ * solo se podía comprobar con una prueba en contenedores. Con esto lo enseña cualquier app
7
+ * (el topbar tiene un modal que lo pinta).
8
+ *
9
+ * Se cuentan los BYTES DEL PAYLOAD que pasa por este cliente (UTF-8), no los del cable:
10
+ * las cabeceras de TCP, TLS, DTLS o SCTP no las ve JavaScript. Para el proxio se cuenta el
11
+ * frame entero; por conexión, lo que iba dirigido a ella.
12
+ *
13
+ * Los caminos:
14
+ * proxy por el proxio (incluye la señalización de WebRTC y el saludo)
15
+ * direct WebRTC sin relevo
16
+ * turn WebRTC por un relevo TURN
17
+ * webrtc WebRTC sin saber todavía cuál de los dos (getStats no contestó)
18
+ */
19
+
20
+ /** Longitud en bytes UTF-8 de un string, sin reservar un buffer por mensaje. */
21
+ export function utf8Length (s) {
22
+ if (typeof s !== 'string') {
23
+ if (s && typeof s.byteLength === 'number') return s.byteLength
24
+ if (s && typeof s.size === 'number') return s.size
25
+ return 0
26
+ }
27
+ let n = 0
28
+ for (let i = 0; i < s.length; i++) {
29
+ const c = s.charCodeAt(i)
30
+ if (c < 0x80) n += 1
31
+ else if (c < 0x800) n += 2
32
+ else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) { n += 4; i++ } // par sustituto
33
+ else n += 3
34
+ }
35
+ return n
36
+ }
37
+
38
+ export const PATHS = ['proxy', 'direct', 'turn', 'webrtc']
39
+
40
+ const ceros = () => ({ proxy: 0, direct: 0, turn: 0, webrtc: 0 })
41
+
42
+ export class TrafficStats {
43
+ constructor () {
44
+ this.since = Date.now()
45
+ this.proxy = { bytesIn: 0, bytesOut: 0, framesIn: 0, framesOut: 0 }
46
+ /** clave (`t:<token>` | `k:<pubkey>`) → contadores de esa conexión */
47
+ this.peers = new Map()
48
+ }
49
+
50
+ /** Un frame entero del WebSocket del proxio. */
51
+ frame (dir, bytes) {
52
+ if (dir === 'in') { this.proxy.bytesIn += bytes; this.proxy.framesIn++ } else { this.proxy.bytesOut += bytes; this.proxy.framesOut++ }
53
+ }
54
+
55
+ /**
56
+ * Un mensaje dirigido a (o venido de) una conexión.
57
+ * @param {'in'|'out'} dir
58
+ * @param {'proxy'|'direct'|'turn'|'webrtc'} path
59
+ * @param {{ token?: string|null, pubkey?: string|null }} who
60
+ * @param {number} bytes
61
+ */
62
+ peer (dir, path, who, bytes) {
63
+ const key = who.token ? `t:${who.token}` : who.pubkey ? `k:${who.pubkey}` : null
64
+ if (!key) return
65
+ let p = this.peers.get(key)
66
+ if (!p) {
67
+ p = { token: who.token || null, pubkey: who.pubkey || null, bytesIn: ceros(), bytesOut: ceros(), msgsIn: 0, msgsOut: 0, firstAt: Date.now(), lastAt: 0 }
68
+ this.peers.set(key, p)
69
+ }
70
+ if (who.pubkey && !p.pubkey) p.pubkey = who.pubkey
71
+ if (dir === 'in') { p.bytesIn[path] += bytes; p.msgsIn++ } else { p.bytesOut[path] += bytes; p.msgsOut++ }
72
+ p.lastAt = Date.now()
73
+ }
74
+ }
75
+
76
+ /**
77
+ * POR DÓNDE VA UN CANAL ABIERTO: `direct` o `turn`, según el par de candidatos que ICE
78
+ * eligió. Si cualquiera de las dos puntas es `relay`, hay un TURN en medio.
79
+ *
80
+ * `null` si no se puede saber (la implementación no tiene getStats, o aún no hay par
81
+ * elegido). No se adivina: «no lo sé» se enseña como tal.
82
+ *
83
+ * @param {any} pc RTCPeerConnection
84
+ * @returns {Promise<'direct'|'turn'|null>}
85
+ */
86
+ export async function routeOf (pc) {
87
+ if (!pc || typeof pc.getStats !== 'function') return null
88
+ let report
89
+ try { report = await pc.getStats() } catch (_) { return null }
90
+ if (!report || typeof report.forEach !== 'function') return null
91
+ const byId = new Map()
92
+ report.forEach((s) => { if (s && s.id) byId.set(s.id, s) })
93
+ let pair = null
94
+ // El par vigente lo dice el transporte; si no, el que está nominado y funcionando.
95
+ for (const s of byId.values()) {
96
+ if (s.type === 'transport' && s.selectedCandidatePairId) { pair = byId.get(s.selectedCandidatePairId) || null; break }
97
+ }
98
+ if (!pair) {
99
+ for (const s of byId.values()) {
100
+ if (s.type === 'candidate-pair' && (s.selected || (s.nominated && s.state === 'succeeded'))) { pair = s; break }
101
+ }
102
+ }
103
+ if (!pair) return null
104
+ const local = byId.get(pair.localCandidateId)
105
+ const remote = byId.get(pair.remoteCandidateId)
106
+ if (!local && !remote) return null
107
+ const tipo = (c) => c && (c.candidateType || c.type)
108
+ return tipo(local) === 'relay' || tipo(remote) === 'relay' ? 'turn' : 'direct'
109
+ }
110
+
111
+ /**
112
+ * LOS CLIENTES VIVOS DE ESTA PÁGINA, para quien quiera enseñarlos sin que la app lo
113
+ * cablee (el topbar). Va en `globalThis` con un Symbol.for, no en una variable del módulo:
114
+ * una app puede llevar dos copias de este paquete (la suya y la de otro pilar), y las dos
115
+ * tienen que verse.
116
+ */
117
+ const REGISTRY = Symbol.for('dotrino.transports')
118
+
119
+ function registry () {
120
+ if (!globalThis[REGISTRY]) globalThis[REGISTRY] = new Set()
121
+ return globalThis[REGISTRY]
122
+ }
123
+
124
+ function avisar () {
125
+ if (typeof globalThis.dispatchEvent === 'function' && typeof Event === 'function') {
126
+ try { globalThis.dispatchEvent(new Event('dotrino-transports')) } catch (_) {}
127
+ }
128
+ }
129
+
130
+ export function registerTransport (client) {
131
+ const r = registry()
132
+ if (r.has(client)) return
133
+ r.add(client)
134
+ avisar()
135
+ }
136
+
137
+ export function unregisterTransport (client) {
138
+ if (registry().delete(client)) avisar()
139
+ }
140
+
141
+ /** Los clientes registrados en esta página (o proceso). */
142
+ export function listTransports () {
143
+ return [...registry()]
144
+ }
@@ -12,6 +12,8 @@
12
12
  * the "polite" one (rolls back on collision).
13
13
  */
14
14
 
15
+ import { routeOf, utf8Length } from './stats.js'
16
+
15
17
  export const DEFAULT_ICE_SERVERS = [
16
18
  { urls: 'stun:stun.l.google.com:19302' },
17
19
  { urls: 'stun:stun1.l.google.com:19302' },
@@ -82,6 +84,8 @@ export class WebRTCManager {
82
84
  * @param {(from: string, parsed: any, meta: any) => void} opts.deliverMessage forwards
83
85
  * an incoming P2P payload as if it had arrived via the proxy
84
86
  * @param {(event: string, ...args: any[]) => void} opts.emit
87
+ * @param {(dir: 'in'|'out', token: string, bytes: number, route: 'direct'|'turn'|'webrtc') => void} [opts.count]
88
+ * cuenta el tráfico que va por el canal (ver stats.js)
85
89
  * @param {{iceServers?: any[]}} [opts.config]
86
90
  */
87
91
  constructor (opts) {
@@ -90,6 +94,7 @@ export class WebRTCManager {
90
94
  this.signalSend = opts.signalSend
91
95
  this.deliverMessage = opts.deliverMessage
92
96
  this.emit = opts.emit
97
+ this.count = typeof opts.count === 'function' ? opts.count : null
93
98
  this.iceServers = (opts.config && opts.config.iceServers) || DEFAULT_ICE_SERVERS
94
99
  this.peers = new Map() // remoteToken -> PeerState
95
100
  }
@@ -128,6 +133,7 @@ export class WebRTCManager {
128
133
  if (peer.dc && peer.dc.readyState === 'open') {
129
134
  try {
130
135
  peer.dc.send(payloadString)
136
+ this.count?.('out', to, utf8Length(payloadString), peer.route || 'webrtc')
131
137
  return true
132
138
  } catch (_) {
133
139
  return false
@@ -168,6 +174,27 @@ export class WebRTCManager {
168
174
  if (Array.isArray(list) && list.length) this.iceServers = list
169
175
  }
170
176
 
177
+ /** Pregunta a ICE por dónde va el canal y lo apunta en `peer.route`. */
178
+ async probeRoute (peer) {
179
+ const r = await routeOf(peer.pc)
180
+ if (r) peer.route = r
181
+ return peer.route || null
182
+ }
183
+
184
+ /**
185
+ * El estado de cada canal, para las estadísticas: `connecting` mientras se negocia,
186
+ * `direct`/`turn`/`webrtc` con el canal abierto, `failed` si no salió.
187
+ */
188
+ async describe () {
189
+ const out = new Map()
190
+ for (const peer of this.peers.values()) {
191
+ const open = !!(peer.dc && peer.dc.readyState === 'open')
192
+ if (open) { try { await this.probeRoute(peer) } catch (_) {} }
193
+ out.set(peer.remote, open ? (peer.route || 'webrtc') : peer.failed ? 'failed' : peer.negotiating ? 'connecting' : null)
194
+ }
195
+ return out
196
+ }
197
+
171
198
  isOpen (to) {
172
199
  const p = this.peers.get(to)
173
200
  return !!(p && p.dc && p.dc.readyState === 'open')
@@ -253,6 +280,8 @@ export class WebRTCManager {
253
280
  _attachDC (peer, dc) {
254
281
  peer.dc = dc
255
282
  dc.onopen = () => {
283
+ // Por dónde salió: directo o por TURN. Por detrás, sin retrasar a nadie.
284
+ this.probeRoute(peer).catch(() => {})
256
285
  this.emit('webrtc_open', peer.remote)
257
286
  const waiters = peer.openWaiters
258
287
  peer.openWaiters = []
@@ -267,6 +296,7 @@ export class WebRTCManager {
267
296
  dc.onmessage = (ev) => {
268
297
  let parsed = null
269
298
  const raw = ev.data
299
+ this.count?.('in', peer.remote, utf8Length(raw), peer.route || 'webrtc')
270
300
  if (typeof raw === 'string') {
271
301
  try { parsed = JSON.parse(raw) } catch (_) { parsed = null }
272
302
  }
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.79.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
1
+ Copia vendorizada de @dotrino/vault@0.80.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
@@ -103,6 +103,13 @@ export const MSG = Object.freeze({
103
103
  // una forma de echar a alguien de su cuenta.
104
104
  LOGIN_CLOSE: 'vault.login.close', // aparato → vault: { data:{op,user,sid,publickey,ts}, signature }
105
105
  LOGIN_CLOSED: 'vault.login.closed', // vault → aparato: { ok }
106
+ // INCIDENTE: un agente de la cuenta (la terminal) avisa de que OTRO aparato de la cuenta
107
+ // se equivocó de clave tres veces seguidas. La bóveda lo apunta como un pedido más
108
+ // (`kind: 'incident'`), timbra a quien aprueba, y el aprobador bloquea o ignora. Bloquear
109
+ // no toca el acta: es una lista de la bóveda (`blocked.json`) que viaja a los agentes con
110
+ // `vault.devices.result` (`blocked`) y se deshace con `dotrino-vault unblock <ID>`.
111
+ INCIDENT: 'vault.incident', // agente → vault: { data:{op:'incident',kind,about,tries,publickey,ts}, signature, cert }
112
+ INCIDENT_RESULT: 'vault.incident.result', // vault → agente: { id, approvers, blocked }
106
113
  ERROR: 'vault.error' // vault → dispositivo: { error }
107
114
  })
108
115