@dotrino/identity 0.107.1 → 0.108.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.1",
3
+ "version": "0.108.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>
@@ -188,6 +193,18 @@ export class Identity {
188
193
  canApproveVault (): Promise<boolean>
189
194
  getVaultCert (): Promise<any>
190
195
  onVault (handler: (payload: any) => void): () => void
196
+ /** El perfil ACTIVO de este aparato (el que usan las apps; `@dotrino/store` se ata a él). */
197
+ currentProfile (): Promise<{ id: string; name?: string; pubkey?: string } | null>
198
+ /** Los perfiles de este aparato. */
199
+ listProfiles (): Promise<Array<{ id: string; name?: string; pubkey?: string; current?: boolean; avatar?: string }>>
200
+ /** Cambia el perfil activo (la app recarga: no es reactivo). */
201
+ switchProfile (id: string): Promise<any>
202
+ /** Cifra con la llave de la CUENTA: el sobre lleva la marca `t: 'dotrino-cek'` y lo abren todos los aparatos del acta. */
203
+ sealContent (plaintext: string): Promise<{ t: 'dotrino-cek'; gen: number; iv: string; ct: string }>
204
+ /** Abre un sobre de la cuenta (cualquier generación que este aparato tenga). */
205
+ openContent (envelope: { gen: number; iv: string; ct: string; t?: string }): Promise<string>
206
+ /** Mi copia de la llave de la cuenta vigente, o null si todavía no me la han envuelto. */
207
+ contentKey (): Promise<{ gen: number; cek: string } | null>
191
208
  // self-vault (este dispositivo ES el vault, daemon dentro del iframe)
192
209
  selfVaultStatus (): Promise<{ enabled: boolean; running: boolean }>
193
210
  setSelfVault (enabled: boolean): Promise<{ ok: true; enabled: boolean }>
package/src/index.js CHANGED
@@ -139,6 +139,7 @@ export class Identity {
139
139
  const err = new Error(msg.error)
140
140
  if (msg.code) err.code = msg.code
141
141
  if (msg.detail) err.detail = msg.detail
142
+ if (msg.reason) err.reason = msg.reason
142
143
  pending.reject(err)
143
144
  }
144
145
  else pending.resolve(msg.result)
@@ -208,6 +209,11 @@ export class Identity {
208
209
  return this._call('getPeer', { publickey })
209
210
  }
210
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
+
211
217
  async setNickname (publickey, nickname) {
212
218
  return this._call('setNickname', { publickey, nickname })
213
219
  }
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 }) }
@@ -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 -----
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
@@ -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>
package/vault/vault.js CHANGED
@@ -540,7 +540,10 @@ import { useBackend as usePeerBackend } from './peerStore.js'
540
540
  reply({
541
541
  error: e?.message || String(e),
542
542
  ...(e?.code ? { code: e.code } : {}),
543
- ...(e?.detail ? { detail: e.detail } : {})
543
+ ...(e?.detail ? { detail: e.detail } : {}),
544
+ // El MOTIVO de un rechazo de la bóveda (`expired`, `revoked`, `acta`…): sin él, «la
545
+ // bóveda te rechaza» no dice si hay que volver a enlazar o esperar.
546
+ ...(e?.reason ? { reason: e.reason } : {})
544
547
  })
545
548
  }
546
549
  })
@@ -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.28.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc,stats}.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) {
@@ -765,9 +807,15 @@ export class WebSocketProxyClient {
765
807
  }
766
808
  if (opts.ephemeral) msg.ephemeral = true
767
809
  if (opts.quiet) msg.quiet = true
810
+ // A QUÉ APP va (ruteo): el proxio timbra y entrega solo a esa app de ese aparato.
811
+ const app = checkApp(opts.app)
812
+ if (app) msg.app = app
768
813
  this._sendRaw(msg)
769
814
  }
770
815
 
816
+ /** `{ app }` para lo firmado (suscribirse al timbre), o nada si esta conexión no dijo cuál. */
817
+ _appField () { return this.app ? { app: this.app } : {} }
818
+
771
819
  /**
772
820
  * Pedir una CITA: el código corto que una persona lee, dicta o escanea para
773
821
  * emparejarse con esta conexión.
@@ -858,6 +906,7 @@ export class WebSocketProxyClient {
858
906
  identify ({ data, signature, cert, acta, sign }) {
859
907
  if (!data || !signature) throw new Error('identify requires {data, signature}')
860
908
  const msg = { type: 'identify', data, signature }
909
+ if (this.app) msg.app = this.app
861
910
  if (cert) msg.cert = cert // "una identidad": el proxy bindea este token también bajo tu maestra M
862
911
  // Acta de perfil: el proxy la verifica (va firmada) y bindea también el `profileId`, así
863
912
  // escribirle a la PERSONA llega a cualquiera de sus dispositivos. Ver acta-de-perfil.md.
@@ -1019,7 +1068,7 @@ export class WebSocketProxyClient {
1019
1068
  })
1020
1069
  }
1021
1070
  const subJson = typeof sub.toJSON === 'function' ? sub.toJSON() : sub
1022
- const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify(subJson), ts: Date.now() }
1071
+ const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify(subJson), ts: Date.now(), ...this._appField() }
1023
1072
  const signature = await normalizeSignature(sign, data)
1024
1073
  await this._request({ type: 'push-subscribe', data, signature }, 'push-subscribed')
1025
1074
  return sub
@@ -1033,7 +1082,7 @@ export class WebSocketProxyClient {
1033
1082
  */
1034
1083
  async registerPushToken ({ publicKey, sign, token, kind = 'fcm' } = {}) {
1035
1084
  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() }
1085
+ const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify({ kind, token }), ts: Date.now(), ...this._appField() }
1037
1086
  const signature = await normalizeSignature(sign, data)
1038
1087
  await this._request({ type: 'push-subscribe', data, signature }, 'push-subscribed')
1039
1088
  return { kind, token }
@@ -1058,7 +1107,7 @@ export class WebSocketProxyClient {
1058
1107
  } catch (_) { /* best-effort local */ }
1059
1108
  }
1060
1109
  if (publicKey && typeof sign === 'function') {
1061
- const data = { op: 'push-unsubscribe', publickey: publicKey, ts: Date.now() }
1110
+ const data = { op: 'push-unsubscribe', publickey: publicKey, ts: Date.now(), ...this._appField() }
1062
1111
  const signature = await normalizeSignature(sign, data)
1063
1112
  await this._request({ type: 'push-unsubscribe', data, signature }, 'push-unsubscribed')
1064
1113
  }
@@ -1145,6 +1194,7 @@ export class WebSocketProxyClient {
1145
1194
  // ---------- internals ----------
1146
1195
 
1147
1196
  _open () {
1197
+ registerTransport(this)
1148
1198
  const ws = new WebSocket(this.url)
1149
1199
  this.ws = ws
1150
1200
  // `ws !== this.ws` ⇒ es un socket que ya abandonamos (p.ej. por heartbeat
@@ -1159,6 +1209,7 @@ export class WebSocketProxyClient {
1159
1209
  ws.addEventListener('message', (ev) => {
1160
1210
  if (ws !== this.ws) return
1161
1211
  this._noteActivity() // cualquier frame entrante prueba que está vivo
1212
+ this._traffic.frame('in', utf8Length(ev.data))
1162
1213
  this._handleFrame(ev.data)
1163
1214
  })
1164
1215
  ws.addEventListener('error', (err) => {
@@ -1224,7 +1275,7 @@ export class WebSocketProxyClient {
1224
1275
  _heartbeatTick () {
1225
1276
  if (!this._connected || !this.ws) return
1226
1277
  if (this._hbDeadTimer) return // ya hay un ping en vuelo esperando respuesta
1227
- try { this.ws.send(JSON.stringify({ type: 'ping' })) }
1278
+ try { const ping = JSON.stringify({ type: 'ping' }); this.ws.send(ping); this._traffic.frame('out', ping.length) }
1228
1279
  catch (_) { this._onHeartbeatDead(); return }
1229
1280
  this._hbDeadTimer = setTimeout(() => this._onHeartbeatDead(), this.heartbeatTimeout)
1230
1281
  }
@@ -1301,6 +1352,7 @@ export class WebSocketProxyClient {
1301
1352
  if (typeof message === 'string') {
1302
1353
  try { parsed = JSON.parse(message) } catch (_) { parsed = null }
1303
1354
  }
1355
+ this._traffic.peer('in', 'proxy', { token: from || null, pubkey: from_publickey || this._tokenPubkeys.get(from) || null }, utf8Length(message))
1304
1356
  if (this._rtc && parsed && parsed.t === RTC_TAG) {
1305
1357
  this._rtc.handleIncoming(from, parsed)
1306
1358
  break
@@ -1395,7 +1447,65 @@ export class WebSocketProxyClient {
1395
1447
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1396
1448
  throw errorCon('WebSocket not connected', 'NOT_CONNECTED')
1397
1449
  }
1398
- this.ws.send(JSON.stringify(frame))
1450
+ const texto = JSON.stringify(frame)
1451
+ this.ws.send(texto)
1452
+ this._countOut(frame, texto)
1453
+ }
1454
+
1455
+ /** Cuenta un frame que sale por el proxio, y a quién iba. */
1456
+ _countOut (frame, texto) {
1457
+ this._traffic.frame('out', utf8Length(texto))
1458
+ if (typeof frame.message !== 'string') return
1459
+ const bytes = utf8Length(frame.message)
1460
+ for (const token of Array.isArray(frame.to) ? frame.to : []) {
1461
+ this._traffic.peer('out', 'proxy', { token, pubkey: this._tokenPubkeys.get(token) || null }, bytes)
1462
+ }
1463
+ for (const pubkey of Array.isArray(frame.to_publickey) ? frame.to_publickey : []) {
1464
+ this._traffic.peer('out', 'proxy', { pubkey }, bytes)
1465
+ }
1466
+ }
1467
+
1468
+ /**
1469
+ * ESTADÍSTICAS DE RED: cuánto entró y salió, por conexión, y por qué camino.
1470
+ *
1471
+ * `route` es por dónde va AHORA cada conexión: `proxy`, `connecting` (por el proxio
1472
+ * mientras se negocia WebRTC), `direct` o `turn` (WebRTC, sin o con relevo), `webrtc`
1473
+ * (canal abierto sin poder saber cuál) o `failed` (WebRTC no salió: sigue por el proxio).
1474
+ * Los bytes van separados por el camino por el que pasaron de verdad.
1475
+ *
1476
+ * Los bytes son de payload (UTF-8), no del cable: las cabeceras de TLS/DTLS no se ven
1477
+ * desde aquí.
1478
+ */
1479
+ async stats () {
1480
+ const t = this._traffic
1481
+ const routes = this._rtc ? await this._rtc.describe() : new Map()
1482
+ const peers = [...t.peers.values()].map((p) => {
1483
+ const pubkey = p.pubkey || (p.token && this._tokenPubkeys.get(p.token)) || null
1484
+ const rtc = p.token ? routes.get(p.token) : null
1485
+ return {
1486
+ token: p.token,
1487
+ pubkey,
1488
+ route: rtc && rtc !== 'failed' && rtc !== 'connecting' ? rtc : (rtc || 'proxy'),
1489
+ bytesIn: { ...p.bytesIn },
1490
+ bytesOut: { ...p.bytesOut },
1491
+ msgsIn: p.msgsIn,
1492
+ msgsOut: p.msgsOut,
1493
+ firstAt: p.firstAt,
1494
+ lastAt: p.lastAt
1495
+ }
1496
+ }).sort((a, b) => b.lastAt - a.lastAt)
1497
+ return {
1498
+ url: this.url,
1499
+ app: this.app,
1500
+ node: this.node || null,
1501
+ token: this.token,
1502
+ publickey: this.myPublickey,
1503
+ connected: this._connected,
1504
+ webrtc: !!this._rtc,
1505
+ since: t.since,
1506
+ proxy: { ...t.proxy },
1507
+ peers
1508
+ }
1399
1509
  }
1400
1510
 
1401
1511
  _request (frame, expectedType, channelKey) {
@@ -10,6 +10,8 @@ export {
10
10
  buildEncPubStatement, readEncPubStatement,
11
11
  } from './encpub.js'
12
12
 
13
+ export { listTransports, routeOf } from './stats.js'
14
+
13
15
  import { WebSocketProxyClient } from './client.js'
14
16
 
15
17
  let _singleton = null
@@ -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
  }