@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 +19 -0
- package/package.json +1 -1
- package/src/index.d.ts +7 -2
- package/src/index.js +11 -2
- package/src/node.js +2 -1
- package/vault/capabilities.js +9 -37
- package/vault/core.js +34 -24
- package/vault/icon-192.png +0 -0
- package/vault/index.html +161 -15
- package/vault/og.jpg +0 -0
- package/vault/remote.js +7 -2
- package/vault/robots.txt +3 -0
- package/vault/sitemap.xml +4 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +136 -5
- package/vault/vendor/proxy-client/index.js +3 -0
- package/vault/vendor/proxy-client/stats-log.js +96 -0
- package/vault/vendor/proxy-client/stats.js +144 -0
- package/vault/vendor/proxy-client/webrtc.js +30 -0
- package/vault/vendor/vault/VERSION.txt +1 -1
- package/vault/vendor/vault/protocol.js +7 -0
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
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') }
|
package/vault/capabilities.js
CHANGED
|
@@ -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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
//
|
|
256
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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`)
|
|
1019
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
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
|
-
|
|
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="
|
|
2
|
+
<html lang="es">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
|
-
<
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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"
|
|
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
|
|
464
|
+
data
|
|
460
465
|
})
|
|
461
466
|
return res.body
|
|
462
467
|
}
|
package/vault/robots.txt
ADDED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Copia vendorizada de @dotrino/proxy-client@0.
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
|