@dotrino/identity 0.120.0 → 0.121.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.120.0",
3
+ "version": "0.121.1",
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",
@@ -0,0 +1,65 @@
1
+ /**
2
+ * QUIÉN PUEDE PEDIR QUÉ al iframe de identidad, por el origen que pone el navegador.
3
+ *
4
+ * El iframe habla con todo el ecosistema (`*.dotrino.com`, el espejo de GitHub y, en desarrollo,
5
+ * localhost y la red local). Eso está bien para firmar, cifrar o leer el perfil: es para lo que
6
+ * está. Pero hay pedidos que deciden QUIÉN ES de tu cuenta o se llevan la llave, y esos no los
7
+ * puede hacer cualquier página que se cargue en esos orígenes. Regla del dueño (2026-10-18):
8
+ * **ningún aparato entra a una cuenta sin invitación**, y una app cualquiera llamando a
9
+ * `admitMember` es justo eso.
10
+ *
11
+ * Esos pedidos solo los atiende la CONSOLA: `vault.dotrino.com` y `profile.dotrino.com`. Los
12
+ * orígenes locales valen únicamente si el propio iframe corre en local (desarrollo y pruebas):
13
+ * en producción, una página de localhost de tu máquina no administra tu cuenta.
14
+ */
15
+ const LOCAL = /^https?:\/\/(localhost|127\.0\.0\.1|192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})(:\d+)?$/
16
+ const CONSOLE = /^https:\/\/(vault|profile)\.dotrino\.com$/
17
+
18
+ /**
19
+ * Lo que solo pide la consola: entrar y salir del acta, cambiar permisos, la llave en crudo,
20
+ * borrar un perfil, la bóveda de esta pestaña (sus emparejamientos, sus altas con contraseña,
21
+ * sus respaldos) y la contraseña del perfil.
22
+ */
23
+ export const CONSOLE_ONLY = Object.freeze([
24
+ 'admitMember', 'removeMember', 'setCaps', 'setCapsMany', 'setLabel', 'setMemberEncPub', 'handoverMaster',
25
+ 'renounceCaps', 'absorbRenounce', 'adoptActa', 'adoptActaChain', 'joinProfile', 'rotateSealKey', 'setSealKeyProvider',
26
+ 'revokeDevice', 'vaultPair', 'vaultAdopt', 'vaultUnpair', 'prepareForAdoption', 'setSelfVault',
27
+ 'exportIdentity', 'importIdentity', 'deleteProfile', 'setProfilePassword', 'removeProfilePassword',
28
+ 'selfVaultPairing', 'selfVaultApprove', 'selfVaultLoginAdd',
29
+ 'selfVaultBackupDrawers', 'selfVaultBackupExport', 'selfVaultBackupPlan', 'selfVaultBackupImport'
30
+ ])
31
+
32
+ /** Además de la consola, quién más puede pedir uno de esos (su herramienta lo necesita). */
33
+ const ALSO = Object.freeze({
34
+ // El gestor de contraseñas vuelve a envolver sus llaves para los aparatos que entran.
35
+ rewrapFor: /^https:\/\/pass\.dotrino\.com$/
36
+ })
37
+
38
+ export function originPolicy ({ selfOrigin }) {
39
+ const selfIsLocal = LOCAL.test(String(selfOrigin || ''))
40
+ const isConsole = (origin) => CONSOLE.test(String(origin || '')) || (selfIsLocal && LOCAL.test(String(origin || '')))
41
+ const only = new Set(CONSOLE_ONLY)
42
+
43
+ /**
44
+ * Lanza `console-only` si `origin` no puede pedir `method` con estos parámetros. Lo demás pasa.
45
+ * `me` es la llave pública del perfil activo (para el certificado de uno mismo).
46
+ */
47
+ function check (method, params, origin, { me = null } = {}) {
48
+ if (isConsole(origin)) return
49
+ const deny = (why) => { throw Object.assign(new Error(`${method}: ${why}`), { code: 'console-only' }) }
50
+ if (only.has(method)) deny('only the vault console (vault.dotrino.com, profile.dotrino.com) can ask for this')
51
+ if (ALSO[method] && !ALSO[method].test(String(origin || ''))) deny('this origin cannot ask for this')
52
+ if (method === 'signDelegation') {
53
+ // Un certificado con poder sobre la bóveda (`vault:*`) es meter un aparato: eso es de la
54
+ // consola, con su invitación. Una app sí firma lo suyo (geo: los aparatos de here) y su
55
+ // propio certificado (`sub` = el perfil: el de terminal y el de ia).
56
+ const scopes = Array.isArray(params?.scope) ? params.scope : [params?.scope]
57
+ const vaultPower = scopes.some((s) => typeof s !== 'string' || s.startsWith('vault:'))
58
+ if (vaultPower && !(me && params?.sub === me)) deny('a certificate with vault powers is issued from the vault console, with an invitation')
59
+ }
60
+ }
61
+
62
+ return { check, isConsole }
63
+ }
64
+
65
+ export default { originPolicy, CONSOLE_ONLY }
package/vault/vault.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from './peerStore.js'
15
15
  import { createIdentityCore } from './core.js'
16
16
  import { keyLabel } from './capabilities.js'
17
+ import { originPolicy } from './originPolicy.js'
17
18
  import { withExternalKeys, appBridge } from './externalKeys.js'
18
19
  import { nativeBackends } from './nativeStore.js'
19
20
  import { useBackend as usePeerBackend } from './peerStore.js'
@@ -151,6 +152,7 @@ import { useBackend as usePeerBackend } from './peerStore.js'
151
152
  })
152
153
 
153
154
  const { handlers } = core
155
+ const policy = originPolicy({ selfOrigin: window.location.origin })
154
156
 
155
157
  // ---- Control de ORIGEN (crítico): la identidad solo habla con el ecosistema. ----
156
158
  // Sin esto, CUALQUIER web podía embeber este iframe y llamar `exportIdentity`
@@ -537,6 +539,9 @@ import { useBackend as usePeerBackend } from './peerStore.js'
537
539
  const handler = selfHandlers[method] || handlers[method]
538
540
  if (!handler) return reply({ error: `Unknown method: ${method}` })
539
541
  try {
542
+ // LO QUE DECIDE QUIÉN ES DE LA CUENTA solo lo pide la consola (`originPolicy.js`): una app
543
+ // del ecosistema firma y cifra, pero no mete aparatos ni se lleva la llave.
544
+ policy.check(method, params, event.origin, { me: (await handlers.getMe().catch(() => null))?.publickey || null })
540
545
  // EL ORIGEN LO PONE EL IFRAME, no quien llama: va pisado a propósito. Es el único
541
546
  // dato que la aplicación no puede falsificar —el navegador lo garantiza— y de él
542
547
  // depende a quién se le concedió qué.
@@ -1,3 +1,3 @@
1
- Copia vendorizada de @dotrino/passmanager@0.18.4 (dotrino-passmanager/lib/src/{crypto,fields,generate,import,index,match,model,pubkey,sealed/convert,sealed/device,sealed/entry,sealed/index,sealed/keys,sealed/recovery,sealed/store,session-cache,totp,transport/protocol,transport/proxy,transport/sealed,vault/approval,vault/errors,vault/guard,vault/interface,vault/local,vault/remote,vault/responder,vault/sealed-local,vault/sealed-responder,vault/sealed,webauthn}.js).
1
+ Copia vendorizada de @dotrino/passmanager@0.18.5 (dotrino-passmanager/lib/src/{crypto,fields,generate,import,index,match,model,pubkey,sealed/convert,sealed/device,sealed/entry,sealed/index,sealed/keys,sealed/recovery,sealed/store,session-cache,totp,transport/protocol,transport/proxy,transport/sealed,vault/approval,vault/errors,vault/guard,vault/interface,vault/local,vault/remote,vault/responder,vault/sealed-local,vault/sealed-responder,vault/sealed,webauthn}.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
  El sobre de las contraseñas, el mismo que usan el binario y la extensión. Importa @dotrino/identity/content.
@@ -65,7 +65,7 @@ export function generatePassword ({
65
65
  /** Frase de varias palabras, más fácil de dictar o teclear a mano. */
66
66
  export function generatePassphrase (words, { count = 4, separator = '-' } = {}) {
67
67
  if (!Array.isArray(words) || words.length < 16) {
68
- throw new Error('generatePassphrase: hace falta una lista de palabras')
68
+ throw new Error('generatePassphrase: a word list is required')
69
69
  }
70
70
  return Array.from({ length: Math.max(2, count) }, () => words[randomBelow(words.length)])
71
71
  .join(separator)
@@ -111,7 +111,7 @@ export function fromBitwardenCsv (text) {
111
111
  export function fromBitwardenJson (json) {
112
112
  const data = typeof json === 'string' ? JSON.parse(json) : json
113
113
  if (data?.encrypted) {
114
- const e = new Error('el export de Bitwarden está cifrado: expórtalo sin cifrar')
114
+ const e = new Error('the Bitwarden export is encrypted: export it unencrypted')
115
115
  e.code = 'encrypted-export'
116
116
  throw e
117
117
  }
@@ -136,7 +136,7 @@ export function fromBitwardenJson (json) {
136
136
  */
137
137
  export function importAuto (text) {
138
138
  const t = String(text || '').trim()
139
- if (!t) return { format: 'vacío', entries: [] }
139
+ if (!t) return { format: 'empty', entries: [] }
140
140
 
141
141
  if (t.startsWith('{')) return { format: 'bitwarden-json', entries: fromBitwardenJson(t) }
142
142
 
@@ -150,7 +150,7 @@ export function importAuto (text) {
150
150
  if (head.includes('name') && head.includes('url') && head.includes('password')) {
151
151
  return { format: 'chrome-csv', entries: fromChromeCsv(t) }
152
152
  }
153
- const e = new Error('no reconozco el formato del archivo')
153
+ const e = new Error('unrecognized file format')
154
154
  e.code = 'unknown-format'
155
155
  throw e
156
156
  }
@@ -13,7 +13,7 @@ export function base32Decode (input) {
13
13
  const out = []
14
14
  for (const ch of s) {
15
15
  const idx = B32.indexOf(ch)
16
- if (idx === -1) throw new Error('base32: carácter inválido')
16
+ if (idx === -1) throw new Error('base32: invalid character')
17
17
  value = (value << 5) | idx
18
18
  bits += 5
19
19
  if (bits >= 8) {
@@ -38,12 +38,12 @@ export function normalizeOtpauth (input, label = 'dotrino') {
38
38
  /** Parsea `otpauth://totp/Emisor:cuenta?secret=...&digits=6&period=30&algorithm=SHA1`. */
39
39
  export function parseOtpauth (uri) {
40
40
  const u = new URL(normalizeOtpauth(uri))
41
- if (u.protocol !== 'otpauth:') throw new Error('otpauth: esquema inválido')
41
+ if (u.protocol !== 'otpauth:') throw new Error('otpauth: invalid scheme')
42
42
  const type = u.hostname.toLowerCase()
43
- if (type !== 'totp' && type !== 'hotp') throw new Error('otpauth: tipo no soportado')
43
+ if (type !== 'totp' && type !== 'hotp') throw new Error('otpauth: unsupported type')
44
44
  const q = u.searchParams
45
45
  const secret = q.get('secret')
46
- if (!secret) throw new Error('otpauth: falta secret')
46
+ if (!secret) throw new Error('otpauth: missing secret')
47
47
 
48
48
  const label = decodeURIComponent(u.pathname.replace(/^\//, ''))
49
49
  const [maybeIssuer, account] = label.includes(':') ? label.split(/:(.+)/) : [null, label]
@@ -74,7 +74,7 @@ const HASHES = { SHA1: 'SHA-1', SHA256: 'SHA-256', SHA512: 'SHA-512' }
74
74
 
75
75
  async function hotp (secretBytes, counter, digits, algorithm) {
76
76
  const hash = HASHES[algorithm]
77
- if (!hash) throw new Error('otp: algoritmo no soportado: ' + algorithm)
77
+ if (!hash) throw new Error('otp: unsupported algorithm: ' + algorithm)
78
78
 
79
79
  const buf = new ArrayBuffer(8)
80
80
  const view = new DataView(buf)
@@ -77,7 +77,7 @@ export class ProxyTransport {
77
77
  this.pending.delete(rid)
78
78
  // No es "no hay nadie": es que nadie contestó. Suele ser el teléfono sin
79
79
  // que nadie lo mire, y el código lo distingue para poder decirlo bien.
80
- reject(new VaultError(CODES.APPROVAL_TIMEOUT, 'nadie respondió a tiempo'))
80
+ reject(new VaultError(CODES.APPROVAL_TIMEOUT, 'nobody replied in time'))
81
81
  }, this.timeoutMs)
82
82
 
83
83
  this.pending.set(rid, { resolve, reject, timer })
@@ -94,7 +94,7 @@ export class ProxyTransport {
94
94
  // «no tengo la llave del otro lado» y «se cayó la red» se vieran igual desde
95
95
  // arriba, y son cosas distintas: una se arregla enlazando, la otra esperando.
96
96
  if (e instanceof VaultError) return reject(e)
97
- reject(new VaultError(e?.code || CODES.UNREACHABLE, e?.message || 'no se pudo enviar'))
97
+ reject(new VaultError(e?.code || CODES.UNREACHABLE, e?.message || 'could not send'))
98
98
  })
99
99
  })
100
100
  }
@@ -103,7 +103,7 @@ export class ProxyTransport {
103
103
  close (code = CODES.UNREACHABLE) {
104
104
  for (const [rid, p] of this.pending) {
105
105
  clearTimeout(p.timer)
106
- p.reject(new VaultError(code, 'transporte cerrado'))
106
+ p.reject(new VaultError(code, 'transport closed'))
107
107
  this.pending.delete(rid)
108
108
  }
109
109
  }
@@ -64,7 +64,7 @@ export class GuardedVault {
64
64
 
65
65
  if (pedidasPrivadas.length) {
66
66
  const ok = await this.gate.allow({ op: 'get', payload: { id, keys: pedidasPrivadas } })
67
- if (!ok) throw new VaultError(CODES.NOT_APPROVED, 'no aprobado')
67
+ if (!ok) throw new VaultError(CODES.NOT_APPROVED, 'not approved')
68
68
  }
69
69
  return projectEntry(open, keys)
70
70
  }
@@ -38,7 +38,7 @@ export class LocalVault {
38
38
  }
39
39
 
40
40
  #requireKey () {
41
- if (!this.key) throw new VaultError(CODES.LOCKED, 'la bóveda está cerrada')
41
+ if (!this.key) throw new VaultError(CODES.LOCKED, 'the vault is locked')
42
42
  }
43
43
 
44
44
  async #all () {
@@ -73,13 +73,13 @@ export class LocalVault {
73
73
  async get (id) {
74
74
  this.#requireKey()
75
75
  const entry = (await this.#all()).find(e => e.id === id)
76
- if (!entry) throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
76
+ if (!entry) throw new VaultError(CODES.NOT_FOUND, 'there is no entry with that id')
77
77
  return openEntry(this.key, entry)
78
78
  }
79
79
 
80
80
  async put (plain) {
81
81
  this.#requireKey()
82
- if (this.readOnly) throw new VaultError(CODES.READ_ONLY, 'esta copia solo puede leer')
82
+ if (this.readOnly) throw new VaultError(CODES.READ_ONLY, 'this copy is read-only')
83
83
  const sealed = await sealEntry(this.key, plain)
84
84
  const all = await this.#all()
85
85
  const i = all.findIndex(e => e.id === sealed.id)
@@ -122,10 +122,10 @@ export class LocalVault {
122
122
  */
123
123
  async patch (id, changes = {}) {
124
124
  this.#requireKey()
125
- if (this.readOnly) throw new VaultError(CODES.READ_ONLY, 'esta copia solo puede leer')
125
+ if (this.readOnly) throw new VaultError(CODES.READ_ONLY, 'this copy is read-only')
126
126
  const all = await this.#all()
127
127
  const i = all.findIndex(e => e.id === id)
128
- if (i < 0) throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
128
+ if (i < 0) throw new VaultError(CODES.NOT_FOUND, 'there is no entry with that id')
129
129
 
130
130
  const base = await openEntry(this.key, all[i])
131
131
  // La fusión vive en `model.js` y la comparten esta bóveda y la sellada: no es un
@@ -199,10 +199,10 @@ export class LocalVault {
199
199
 
200
200
  async remove (id) {
201
201
  this.#requireKey()
202
- if (this.readOnly) throw new VaultError(CODES.READ_ONLY, 'esta copia solo puede leer')
202
+ if (this.readOnly) throw new VaultError(CODES.READ_ONLY, 'this copy is read-only')
203
203
  const all = await this.#all()
204
204
  const rest = all.filter(e => e.id !== id)
205
- if (rest.length === all.length) throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
205
+ if (rest.length === all.length) throw new VaultError(CODES.NOT_FOUND, 'there is no entry with that id')
206
206
  await this.store.set(KEY, rest)
207
207
  }
208
208
 
@@ -70,7 +70,7 @@ export class RemoteVault {
70
70
  }
71
71
 
72
72
  async list () {
73
- throw new VaultError(CODES.NO_KEY, 'una bóveda remota no lista: pide de a una')
73
+ throw new VaultError(CODES.NO_KEY, 'a remote vault does not list: ask one at a time')
74
74
  }
75
75
 
76
76
  async #request (op, payload) {
@@ -79,7 +79,7 @@ export class RemoteVault {
79
79
  } catch (e) {
80
80
  // El transporte puede fallar de muchas formas; hacia arriba solo hay códigos.
81
81
  if (e instanceof VaultError) throw e
82
- throw new VaultError(e?.code || CODES.UNREACHABLE, e?.message || 'no hay bóveda al otro lado')
82
+ throw new VaultError(e?.code || CODES.UNREACHABLE, e?.message || 'no vault on the other side')
83
83
  }
84
84
  }
85
85
  }
@@ -124,7 +124,7 @@ export class VaultResponder {
124
124
  // 1. ¿Es un aparato que conozco? Si no, ni se le contesta qué falló.
125
125
  if (!fromPubkey || !this.isAllowed(fromPubkey)) {
126
126
  this.onRequest({ ...record, outcome: 'denied' })
127
- return this.#send(fromPubkey, replyError(msg.rid, CODES.DENIED, 'aparato no autorizado'))
127
+ return this.#send(fromPubkey, replyError(msg.rid, CODES.DENIED, 'device not authorized'))
128
128
  }
129
129
 
130
130
  // 2. `list` no está en la lista, y esa ausencia es el diseño: si un aparato
@@ -132,11 +132,11 @@ export class VaultResponder {
132
132
  const esAdmin = ADMIN_OPS.includes(msg.op)
133
133
  if (!REMOTE_OPS.includes(msg.op) && !esAdmin) {
134
134
  this.onRequest({ ...record, outcome: 'denied' })
135
- return this.#send(fromPubkey, replyError(msg.rid, CODES.DENIED, 'operación no permitida en remoto'))
135
+ return this.#send(fromPubkey, replyError(msg.rid, CODES.DENIED, 'operation not allowed remotely'))
136
136
  }
137
137
  if (esAdmin && !this.admin) {
138
138
  this.onRequest({ ...record, outcome: 'denied' })
139
- return this.#send(fromPubkey, replyError(msg.rid, CODES.DENIED, 'esta bóveda no se administra en remoto'))
139
+ return this.#send(fromPubkey, replyError(msg.rid, CODES.DENIED, 'this vault is not managed remotely'))
140
140
  }
141
141
 
142
142
  try {
@@ -149,7 +149,7 @@ export class VaultResponder {
149
149
  const ok = await this.approve({ op: msg.op, payload, pubkey: fromPubkey, admin: true })
150
150
  if (!ok) {
151
151
  this.onRequest({ ...record, outcome: 'refused' })
152
- return this.#send(fromPubkey, replyError(msg.rid, CODES.NOT_APPROVED, 'no aprobado'))
152
+ return this.#send(fromPubkey, replyError(msg.rid, CODES.NOT_APPROVED, 'not approved'))
153
153
  }
154
154
  const result = msg.op === 'devices'
155
155
  ? await this.admin.devices()
@@ -162,7 +162,7 @@ export class VaultResponder {
162
162
  const ok = await this.gate.allow({ op: msg.op, payload, pubkey: fromPubkey })
163
163
  if (!ok) {
164
164
  this.onRequest({ ...record, outcome: 'refused' })
165
- return this.#send(fromPubkey, replyError(msg.rid, CODES.NOT_APPROVED, 'no aprobado'))
165
+ return this.#send(fromPubkey, replyError(msg.rid, CODES.NOT_APPROVED, 'not approved'))
166
166
  }
167
167
  }
168
168
 
@@ -144,10 +144,10 @@ export class SealedLocalVault {
144
144
  let got
145
145
  try { got = await this.sealed.get(id, { pub: this.readerPub, keys }) }
146
146
  catch (e) {
147
- if (e?.code === 'not-found') throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
147
+ if (e?.code === 'not-found') throw new VaultError(CODES.NOT_FOUND, 'there is no entry with that id')
148
148
  // «no me la envolvieron a mí» no es «no existe», y confundirlas manda a buscar el
149
149
  // problema al sitio equivocado.
150
- if (e?.code === 'not-yours') throw new VaultError(CODES.NOT_ALLOWED, 'esta punta no tiene llave para esa entrada')
150
+ if (e?.code === 'not-yours') throw new VaultError(CODES.NOT_ALLOWED, 'this end has no key for that entry')
151
151
  throw e
152
152
  }
153
153
  return openSealedEntry({ got, openSealed: this.openSealed })
@@ -64,12 +64,12 @@ export class SealedVault {
64
64
  forget () { this._keys = null; this._recipients = null }
65
65
 
66
66
  async #request (op, payload) {
67
- if (!SEALED_OPS.includes(op)) throw new VaultError(CODES.NOT_ALLOWED, 'operación desconocida: ' + op)
67
+ if (!SEALED_OPS.includes(op)) throw new VaultError(CODES.NOT_ALLOWED, 'unknown operation: ' + op)
68
68
  try {
69
69
  return await this.transport.request(op, payload)
70
70
  } catch (e) {
71
71
  if (e instanceof VaultError) throw e
72
- throw new VaultError(e?.code || CODES.UNREACHABLE, e?.message || 'no hay bóveda al otro lado')
72
+ throw new VaultError(e?.code || CODES.UNREACHABLE, e?.message || 'no vault on the other side')
73
73
  }
74
74
  }
75
75
 
@@ -194,7 +194,7 @@ export class SealedVault {
194
194
  async patch (id, changes = {}) {
195
195
  const [keys, recipients] = await Promise.all([this.keys(), this.recipients()])
196
196
  const actual = await this.#viewById(id)
197
- if (!actual) throw new VaultError(CODES.NOT_FOUND, 'no hay ninguna entrada con ese id')
197
+ if (!actual) throw new VaultError(CODES.NOT_FOUND, 'there is no entry with that id')
198
198
 
199
199
  const values = {}
200
200
  const drop = []
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.36.2 (dotrino-proxy-client/src/{binpack,canonical,client,encpub,errors,index,parts,sealing,signature,stats-log,stats,webrtc}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.38.2 (dotrino-proxy-client/src/{binpack,canonical,client,encpub,errors,index,parts,sealing,signature,stats-log,stats,webrtc}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  sealing.js resuelve @dotrino/identity/content de forma PEREZOSA (= ../../content.js
4
4
  por el import map): solo se carga si de verdad se sella algo.
@@ -270,9 +270,9 @@ export class WebSocketProxyClient {
270
270
 
271
271
  /** Corta en seco todo lo que esperaba respuesta, con su motivo. */
272
272
  _rejectAllPending (error) {
273
- const pendientes = [...this._pending.values()]
273
+ const pending = [...this._pending.values()]
274
274
  this._pending.clear()
275
- for (const entry of pendientes) {
275
+ for (const entry of pending) {
276
276
  clearTimeout(entry.timer)
277
277
  entry.reject(error)
278
278
  }
@@ -432,7 +432,11 @@ export class WebSocketProxyClient {
432
432
  async list (channelName) {
433
433
  const channel = await buildSignedChannel(channelName)
434
434
  const res = await this._request({ type: 'list', channel }, 'channel_list', 'channel')
435
- return res.tokens || []
435
+ const tokens = res.tokens || []
436
+ // Vacío pero con una bóveda despertándose detrás (websocket-proxy ≥ 1.7.0): quien lista
437
+ // puede esperar unos segundos en vez de concluir que no hay nadie.
438
+ if (res.waking) tokens.waking = true
439
+ return tokens
436
440
  }
437
441
 
438
442
  /** Alias for list() to ease migration from older clients. */
@@ -533,8 +537,8 @@ export class WebSocketProxyClient {
533
537
  // Y SE RESUELVEN TODAS ANTES DE MANDAR NADA. Enviar a los que se pudo y fallar por el
534
538
  // resto deja a la app creyendo que el mensaje salió, con la mitad de la sala sin él y
535
539
  // sin forma de saber cuál mitad.
536
- const llaves = await Promise.all(list.map(async (pk) => [pk, await this.encPubOf(pk)]))
537
- for (const [pk, encPub] of llaves) {
540
+ const keys = await Promise.all(list.map(async (pk) => [pk, await this.encPubOf(pk)]))
541
+ for (const [pk, encPub] of keys) {
538
542
  const sealPiece = await this._sealerFor(m.bin, encPub, pk)
539
543
  for (const piece of pieces) this._sendByPubkeyRaw([pk], await sealPiece(piece), opts)
540
544
  }
@@ -582,10 +586,10 @@ export class WebSocketProxyClient {
582
586
  if (m.bin && !direct) this._needBinaryProxy()
583
587
  const sealPiece = await this._sealerFor(m.bin, peerEncPub, peerPubkey)
584
588
  if (m.len <= size) {
585
- const sobre = await sealPiece(m.bin ? m.whole : payload)
586
- if (tokens.length === 1 && peerPubkey) { this._sendToTokenOrQueue(tokens[0], sobre, peerPubkey); return }
589
+ const envelope = await sealPiece(m.bin ? m.whole : payload)
590
+ if (tokens.length === 1 && peerPubkey) { this._sendToTokenOrQueue(tokens[0], envelope, peerPubkey); return }
587
591
  // Por `send`, no por `_sendRaw`: así sigue prefiriendo el canal directo si lo hay.
588
- this.send(tokens, sobre)
592
+ this.send(tokens, envelope)
589
593
  return
590
594
  }
591
595
  // Por el proxio en vivo hay tope (parts.js): lo que pasa de él solo sale por canal
@@ -601,9 +605,9 @@ export class WebSocketProxyClient {
601
605
  // mensaje entero cabe por ahí; si no, se para y se dice (`direct-lost`).
602
606
  const relayFallback = m.len <= relayMax
603
607
  for (const piece of (m.bin ? splitBinParts(m.whole, size) : splitParts(m.whole, size))) {
604
- const sobre = await sealPiece(piece)
605
- if (tokens.length === 1 && peerPubkey) await this._sendPiece(tokens[0], sobre, peerPubkey, queueFallback, relayFallback)
606
- else this.send(tokens, sobre)
608
+ const envelope = await sealPiece(piece)
609
+ if (tokens.length === 1 && peerPubkey) await this._sendPiece(tokens[0], envelope, peerPubkey, queueFallback, relayFallback)
610
+ else this.send(tokens, envelope)
607
611
  }
608
612
  }
609
613
 
@@ -632,13 +636,13 @@ export class WebSocketProxyClient {
632
636
  * archivo grande lo desborda y el navegador cierra el canal), o por el proxio en vivo con
633
637
  * una pausa corta entre partes, para no chocar con su límite de mensajes por segundo.
634
638
  */
635
- async _sendPiece (token, sobre, peerPubkey, queueFallback, relayFallback = true) {
636
- if (this._rtc && await this._rtc.sendDirect(token, this._wire(sobre))) return
639
+ async _sendPiece (token, envelope, peerPubkey, queueFallback, relayFallback = true) {
640
+ if (this._rtc && await this._rtc.sendDirect(token, this._wire(envelope))) return
637
641
  if (!relayFallback) {
638
642
  throw errorWithCode('the direct channel closed in the middle of a message too big for the proxy', 'direct-lost')
639
643
  }
640
- if (sobre instanceof Uint8Array) this._needBinaryProxy()
641
- this._sendToTokenOrQueue(token, sobre, peerPubkey, { queueFallback, skipDirect: true })
644
+ if (envelope instanceof Uint8Array) this._needBinaryProxy()
645
+ this._sendToTokenOrQueue(token, envelope, peerPubkey, { queueFallback, skipDirect: true })
642
646
  await new Promise((r) => setTimeout(r, 20))
643
647
  }
644
648
 
@@ -674,16 +678,16 @@ export class WebSocketProxyClient {
674
678
  * `token_gone` para que la app deje de usar ese token. No bloquea a nadie: se manda ya y
675
679
  * el arreglo, si hace falta, llega detrás.
676
680
  */
677
- _sendToTokenOrQueue (token, sobre, peerPubkey, { queueFallback = true, skipDirect = false } = {}) {
678
- if (this.requireSealed && !this._isSealed(sobre)) {
681
+ _sendToTokenOrQueue (token, envelope, peerPubkey, { queueFallback = true, skipDirect = false } = {}) {
682
+ if (this.requireSealed && !this._isSealed(envelope)) {
679
683
  throw errorWithCode('requireSealed: refusing to send a directed message in the clear — use sendSealedTo()', 'unsealed')
680
684
  }
681
- const messageStr = this._wire(sobre)
685
+ const messageStr = this._wire(envelope)
682
686
  if (!skipDirect && this._rtc && this._rtc.trySend(token, messageStr)) return
683
687
  const id = `msg_${this._nextId++}`
684
688
  const timer = setTimeout(() => this._tokenWatch.delete(id), 15000)
685
689
  timer.unref?.()
686
- this._tokenWatch.set(id, { token, sobre, peerPubkey, timer, queueFallback })
690
+ this._tokenWatch.set(id, { token, envelope, peerPubkey, timer, queueFallback })
687
691
  try {
688
692
  this._sendRaw({ to: [token], message: messageStr, id })
689
693
  } catch (e) {
@@ -707,7 +711,7 @@ export class WebSocketProxyClient {
707
711
  this._emit('error', { type: 'client', error: 'the peer left in the middle of a message too big to wait in the proxy', code: 'too-big-for-queue', peerPubkey: w.peerPubkey })
708
712
  return
709
713
  }
710
- try { this._sendByPubkeyRaw([w.peerPubkey], w.sobre) } catch (e) { this._emit('error', { type: 'client', error: e.message, code: e.code || null }) }
714
+ try { this._sendByPubkeyRaw([w.peerPubkey], w.envelope) } catch (e) { this._emit('error', { type: 'client', error: e.message, code: e.code || null }) }
711
715
  }
712
716
 
713
717
  // ---------- el saludo: de quién es este token ----------
@@ -896,16 +900,16 @@ export class WebSocketProxyClient {
896
900
  if (typeof publickey !== 'string' || !publickey) {
897
901
  throw errorWithCode('encPubOf: missing publickey', 'encpub-shape')
898
902
  }
899
- const cacheada = this._encPubs.get(publickey)
900
- if (cacheada) return cacheada
903
+ const cached = this._encPubs.get(publickey)
904
+ if (cached) return cached
901
905
  // Una pregunta en vuelo por llave: una sala de ocho manda ocho mensajes a la vez y
902
906
  // preguntaría ocho veces por lo mismo.
903
- const enVuelo = this._encPubInflight.get(publickey)
904
- if (enVuelo) return enVuelo
905
- const promesa = this._lookupEncPub(publickey)
907
+ const inflight = this._encPubInflight.get(publickey)
908
+ if (inflight) return inflight
909
+ const promise = this._lookupEncPub(publickey)
906
910
  .finally(() => this._encPubInflight.delete(publickey))
907
- this._encPubInflight.set(publickey, promesa)
908
- return promesa
911
+ this._encPubInflight.set(publickey, promise)
912
+ return promise
909
913
  }
910
914
 
911
915
  async _lookupEncPub (publickey) {
@@ -913,13 +917,13 @@ export class WebSocketProxyClient {
913
917
  // ya la tiene en la mano y firmada por el master: preguntársela a un servidor sería
914
918
  // dar la vuelta para llegar a lo que ya está aquí.
915
919
  if (this.encPubResolver) {
916
- const propia = await this.encPubResolver(publickey)
917
- if (propia) {
918
- if (!isEncPub(propia)) {
920
+ const own = await this.encPubResolver(publickey)
921
+ if (own) {
922
+ if (!isEncPub(own)) {
919
923
  throw errorWithCode('encPubResolver returned something that is not a P-256 public JWK', 'encpub-unverified')
920
924
  }
921
- this._encPubs.set(publickey, propia)
922
- return propia
925
+ this._encPubs.set(publickey, own)
926
+ return own
923
927
  }
924
928
  }
925
929
  // SI EL PROXIO DICE QUE NO SABE DE ESTO, se dice con su propio código en vez de
@@ -927,7 +931,7 @@ export class WebSocketProxyClient {
927
931
  // que esto existiera y no se sabe: se pregunta igual, y contesta la pregunta.
928
932
  if (Array.isArray(this.caps) && !this.caps.includes('encpub')) {
929
933
  throw errorWithCode(
930
- `este proxio (${this.url}) no sirve llaves de cifrado: hace falta websocket-proxy >= 1.1.0`,
934
+ `this proxy (${this.url}) does not serve encryption keys: it needs websocket-proxy >= 1.1.0`,
931
935
  'no-encpub-support')
932
936
  }
933
937
  const res = await this._request({ type: 'enc-lookup', publickeys: [publickey] }, 'enc-lookup')
@@ -1145,8 +1149,8 @@ export class WebSocketProxyClient {
1145
1149
  // conexión, y por eso este sobre no necesita otro. Lo que le faltaba era decir a quién
1146
1150
  // se lo estamos dando.
1147
1151
  const data = { op: 'identify', aud: this.audience, publickey, token: this.token, ts: Date.now() }
1148
- const firmado = await sign(data)
1149
- const signature = typeof firmado === 'string' ? firmado : firmado?.signature
1152
+ const signed = await sign(data)
1153
+ const signature = typeof signed === 'string' ? signed : signed?.signature
1150
1154
  // «No pude firmar» y «se cayó la red» son cosas distintas, y se distinguen por el
1151
1155
  // `code`: la bóveda usa esto para saber si su llave de comunicación firma todavía, y
1152
1156
  // tragarse un fallo de red como si fuera lo primero la mandaría al camino equivocado.
@@ -1229,7 +1233,7 @@ export class WebSocketProxyClient {
1229
1233
  * @returns {Promise<boolean>} true si TURN quedó activo.
1230
1234
  */
1231
1235
  async enableTurn ({ publicKey, sign } = {}) {
1232
- if (!this._rtc) throw new Error('WebRTC está deshabilitado en este cliente')
1236
+ if (!this._rtc) throw new Error('WebRTC is disabled in this client')
1233
1237
  if (this._turnTimer) {
1234
1238
  clearTimeout(this._turnTimer)
1235
1239
  this._turnTimer = null
@@ -1316,17 +1320,17 @@ export class WebSocketProxyClient {
1316
1320
  */
1317
1321
  async enablePush ({ publicKey, sign, vapidPublicKey, registration, swPath, swScope } = {}) {
1318
1322
  if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
1319
- throw new Error('Service Worker no soportado en este entorno')
1323
+ throw new Error('Service Worker is not supported in this environment')
1320
1324
  }
1321
1325
  if (typeof PushManager === 'undefined') {
1322
- throw new Error('Push API no soportada en este navegador')
1326
+ throw new Error('the Push API is not supported in this browser')
1323
1327
  }
1324
1328
  if (!publicKey || typeof sign !== 'function') {
1325
1329
  throw new Error('enablePush requires { publicKey, sign }')
1326
1330
  }
1327
1331
  if (!vapidPublicKey) {
1328
1332
  const cfg = await this.getPushConfig()
1329
- if (!cfg.enabled || !cfg.vapidPublicKey) throw new Error('El proxy no tiene Web Push habilitado')
1333
+ if (!cfg.enabled || !cfg.vapidPublicKey) throw new Error('the proxy does not have Web Push enabled')
1330
1334
  vapidPublicKey = cfg.vapidPublicKey
1331
1335
  }
1332
1336
  let reg
@@ -1367,6 +1371,29 @@ export class WebSocketProxyClient {
1367
1371
  return { kind, token }
1368
1372
  }
1369
1373
 
1374
+ /**
1375
+ * DESPIÉRTAME AQUÍ: el timbre de una bóveda que no vive conectada (la de una Lambda). Si
1376
+ * llega algo para la PERSONA de este acta y no hay nadie suyo conectado, el proxio guarda el
1377
+ * mensaje en la cola de siempre y hace un POST a `url`; la bóveda arranca, se identifica y
1378
+ * recibe lo encolado.
1379
+ *
1380
+ * Lo firma la llave que habla por la bóveda (su llave de comunicación), no la maestra, y el
1381
+ * `acta` es lo que prueba que esa llave es de la cuenta: el timbre se guarda para la cuenta
1382
+ * (`profileId`), que es a quien le escriben los servicios.
1383
+ *
1384
+ * `channel` (opcional): el canal donde la buscan quienes no saben su llave —quien entra con
1385
+ * usuario y contraseña—. Listarlo vacío también la despierta (`list()` devuelve `waking`).
1386
+ *
1387
+ * @param {{ publicKey:string, sign:Function, url:string, acta:object, channel?:string }} opts
1388
+ */
1389
+ async registerWake ({ publicKey, sign, url, acta, channel } = {}) {
1390
+ if (!publicKey || typeof sign !== 'function' || !url || !acta) throw errorWithCode('registerWake requires { publicKey, sign, url, acta }', 'bad-args')
1391
+ const data = { op: 'push-subscribe', publickey: publicKey, subscription: JSON.stringify({ kind: 'wake', url, ...(channel ? { channel } : {}) }), ts: Date.now() }
1392
+ const signature = await normalizeSignature(sign, data)
1393
+ const r = await this._request({ type: 'push-subscribe', data, signature, acta }, 'push-subscribed')
1394
+ return { kind: 'wake', url, profile: r?.publickey || null }
1395
+ }
1396
+
1370
1397
  /**
1371
1398
  * Desactivar Web Push: cancela la PushSubscription local y la borra del proxy.
1372
1399
  * @param {Object} opts
@@ -1540,8 +1567,8 @@ export class WebSocketProxyClient {
1540
1567
  // `_reconnectAttempts` vuelve a 0 al abrir, así que esto NO convierte un `connect()`
1541
1568
  // inicial fallido en un bucle de fondo: ahí vale 0 y la promesa se rechaza como
1542
1569
  // siempre.
1543
- const reintentando = this._reconnectAttempts > 0
1544
- if ((wasConnected || reintentando) && this.autoReconnect) {
1570
+ const retrying = this._reconnectAttempts > 0
1571
+ if ((wasConnected || retrying) && this.autoReconnect) {
1545
1572
  this._scheduleReconnect()
1546
1573
  }
1547
1574
  })
@@ -1776,14 +1803,14 @@ export class WebSocketProxyClient {
1776
1803
  this._countOut(frame, bytes)
1777
1804
  return
1778
1805
  }
1779
- const texto = JSON.stringify(frame)
1780
- this.ws.send(texto)
1781
- this._countOut(frame, texto)
1806
+ const text = JSON.stringify(frame)
1807
+ this.ws.send(text)
1808
+ this._countOut(frame, text)
1782
1809
  }
1783
1810
 
1784
1811
  /** Cuenta un frame que sale por el proxio, y a quién iba. */
1785
- _countOut (frame, texto) {
1786
- this._traffic.frame('out', utf8Length(texto))
1812
+ _countOut (frame, text) {
1813
+ this._traffic.frame('out', utf8Length(text))
1787
1814
  if (typeof frame.message !== 'string' && !(frame.message instanceof Uint8Array)) return
1788
1815
  const bytes = utf8Length(frame.message)
1789
1816
  for (const token of Array.isArray(frame.to) ? frame.to : []) {
@@ -1897,7 +1924,7 @@ export class WebSocketProxyClient {
1897
1924
  async function normalizeSignature (sign, data) {
1898
1925
  const out = await sign(data)
1899
1926
  const sig = typeof out === 'string' ? out : (out && out.signature)
1900
- if (!sig) throw new Error('sign() debe devolver una firma base64 (string o {signature})')
1927
+ if (!sig) throw new Error('sign() must return a base64 signature (a string or {signature})')
1901
1928
  return sig
1902
1929
  }
1903
1930
 
@@ -67,8 +67,8 @@ export async function buildEncPubStatement ({ publickey, encPub, sign } = /** @t
67
67
  if (!isEncPub(encPub)) throw errorWithCode('buildEncPubStatement: encPub is not a P-256 public JWK', 'encpub-shape')
68
68
  if (typeof sign !== 'function') throw errorWithCode('buildEncPubStatement: missing sign(data)', 'encpub-shape')
69
69
  const data = encPubBody({ publickey, encPub })
70
- const firmado = await sign(data)
71
- const signature = typeof firmado === 'string' ? firmado : firmado?.signature
70
+ const signed = await sign(data)
71
+ const signature = typeof signed === 'string' ? signed : signed?.signature
72
72
  // «No pude firmar» no es «se cayó la red», y aquí es donde se separan: quien llama
73
73
  // decide una cosa u otra según el `code`, nunca según la frase.
74
74
  if (typeof signature !== 'string') throw errorWithCode('buildEncPubStatement: sign() returned no signature', 'no-signature')
@@ -37,7 +37,7 @@ export function utf8Length (s) {
37
37
 
38
38
  export const PATHS = ['proxy', 'direct', 'turn', 'webrtc']
39
39
 
40
- const ceros = () => ({ proxy: 0, direct: 0, turn: 0, webrtc: 0 })
40
+ const zeros = () => ({ proxy: 0, direct: 0, turn: 0, webrtc: 0 })
41
41
 
42
42
  export class TrafficStats {
43
43
  constructor () {
@@ -64,7 +64,7 @@ export class TrafficStats {
64
64
  if (!key) return
65
65
  let p = this.peers.get(key)
66
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 }
67
+ p = { token: who.token || null, pubkey: who.pubkey || null, bytesIn: zeros(), bytesOut: zeros(), msgsIn: 0, msgsOut: 0, firstAt: Date.now(), lastAt: 0 }
68
68
  this.peers.set(key, p)
69
69
  }
70
70
  if (who.pubkey && !p.pubkey) p.pubkey = who.pubkey
@@ -104,8 +104,8 @@ export async function routeOf (pc) {
104
104
  const local = byId.get(pair.localCandidateId)
105
105
  const remote = byId.get(pair.remoteCandidateId)
106
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'
107
+ const typeOf = (c) => c && (c.candidateType || c.type)
108
+ return typeOf(local) === 'relay' || typeOf(remote) === 'relay' ? 'turn' : 'direct'
109
109
  }
110
110
 
111
111
  /**
@@ -121,7 +121,7 @@ function registry () {
121
121
  return globalThis[REGISTRY]
122
122
  }
123
123
 
124
- function avisar () {
124
+ function notify () {
125
125
  if (typeof globalThis.dispatchEvent === 'function' && typeof Event === 'function') {
126
126
  try { globalThis.dispatchEvent(new Event('dotrino-transports')) } catch (_) {}
127
127
  }
@@ -131,11 +131,11 @@ export function registerTransport (client) {
131
131
  const r = registry()
132
132
  if (r.has(client)) return
133
133
  r.add(client)
134
- avisar()
134
+ notify()
135
135
  }
136
136
 
137
137
  export function unregisterTransport (client) {
138
- if (registry().delete(client)) avisar()
138
+ if (registry().delete(client)) notify()
139
139
  }
140
140
 
141
141
  /** Los clientes registrados en esta página (o proceso). */
@@ -48,13 +48,13 @@ const RTC_TAG = '__cc_rtc__'
48
48
  * es el escalón 4, que siempre funciona.
49
49
  */
50
50
  let _PC = null
51
- let _PCBuscado = false
51
+ let _PCLookedUp = false
52
52
 
53
- export function setPeerConnection (impl) { _PC = impl; _PCBuscado = true }
53
+ export function setPeerConnection (impl) { _PC = impl; _PCLookedUp = true }
54
54
 
55
55
  export function resolvePeerConnection () {
56
- if (_PCBuscado) return _PC
57
- _PCBuscado = true
56
+ if (_PCLookedUp) return _PC
57
+ _PCLookedUp = true
58
58
  if (typeof globalThis.RTCPeerConnection === 'function') { _PC = globalThis.RTCPeerConnection; return _PC }
59
59
  return _PC
60
60
  }
@@ -64,15 +64,15 @@ export function resolvePeerConnection () {
64
64
  * llama una vez al arrancar, no en medio de una negociación.
65
65
  */
66
66
  export async function loadNodePeerConnection () {
67
- if (_PCBuscado && _PC) return _PC
68
- if (typeof globalThis.RTCPeerConnection === 'function') { _PC = globalThis.RTCPeerConnection; _PCBuscado = true; return _PC }
69
- for (const nombre of ['@dotrino/webrtc', 'werift']) {
67
+ if (_PCLookedUp && _PC) return _PC
68
+ if (typeof globalThis.RTCPeerConnection === 'function') { _PC = globalThis.RTCPeerConnection; _PCLookedUp = true; return _PC }
69
+ for (const name of ['@dotrino/webrtc', 'werift']) {
70
70
  try {
71
- const w = await import(nombre)
72
- if (typeof w?.RTCPeerConnection === 'function') { _PC = w.RTCPeerConnection; _PCBuscado = true; return _PC }
71
+ const w = await import(name)
72
+ if (typeof w?.RTCPeerConnection === 'function') { _PC = w.RTCPeerConnection; _PCLookedUp = true; return _PC }
73
73
  } catch (_) { /* no está: se prueba el siguiente, y si no, el proxio (escalón 4) */ }
74
74
  }
75
- _PCBuscado = true
75
+ _PCLookedUp = true
76
76
  return _PC
77
77
  }
78
78
 
@@ -1,3 +1,3 @@
1
- Copia vendorizada de @dotrino/roadmap@0.4.30 (dotrino-roadmap/src/{index,manifests}.js).
1
+ Copia vendorizada de @dotrino/roadmap@0.4.32 (dotrino-roadmap/src/{index,manifests}.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
  El registro de versiones rotas que viaja con cada versión. Importa @dotrino/compat/ranges.
@@ -80,10 +80,10 @@ export function meets (m, { product, peer, version }) {
80
80
  if (!productOf(m, product)) {
81
81
  return { ok: false, reason: `the registry does not know "${product}"` }
82
82
  }
83
- const rango = requirementFor(m, product, peer)
84
- if (rango == null) return { ok: true, reason: '' }
85
- if (satisfies(version, rango)) return { ok: true, reason: '' }
86
- return { ok: false, reason: `${product} needs ${peer} ${rango} and this one is ${version}` }
83
+ const range = requirementFor(m, product, peer)
84
+ if (range == null) return { ok: true, reason: '' }
85
+ if (satisfies(version, range)) return { ok: true, reason: '' }
86
+ return { ok: false, reason: `${product} needs ${peer} ${range} and this one is ${version}` }
87
87
  }
88
88
 
89
89
  /** La lista de rotas del registro, lista para pasarla a `check` de `@dotrino/compat`. */
@@ -8,7 +8,7 @@
8
8
  export const MANIFESTS = Object.freeze({
9
9
  "dotrino": {
10
10
  "manifest": "dotrino",
11
- "seq": 30,
11
+ "seq": 31,
12
12
  "issued": "2026-10-16",
13
13
  "note": "Registro comun de compatibilidad del ecosistema. Se edita a mano en este repo y se consulta con @dotrino/roadmap. Rangos: ver @dotrino/compat/ranges (x.y.z, >= > <= <, «a - b», «*», o una lista que es un O).",
14
14
  "products": {
@@ -22,16 +22,16 @@ export const MANIFESTS = Object.freeze({
22
22
  "env": {
23
23
  "repo": "imdotrino/dotrino-vault",
24
24
  "npm": "@dotrino/env",
25
- "current": "0.90.1",
25
+ "current": "0.91.1",
26
26
  "protocol": 1,
27
27
  "requires": {
28
- "vault": ">=0.90.1"
28
+ "vault": ">=0.91.1"
29
29
  }
30
30
  },
31
31
  "identity": {
32
32
  "repo": "imdotrino/dotrino-identity",
33
33
  "npm": "@dotrino/identity",
34
- "current": "0.119.0",
34
+ "current": "0.120.0",
35
35
  "protocol": 1,
36
36
  "requires": {
37
37
  "proxy-client": ">=0.13.1"
@@ -40,7 +40,7 @@ export const MANIFESTS = Object.freeze({
40
40
  "lobby": {
41
41
  "repo": "imdotrino/dotrino-lobby",
42
42
  "npm": "@dotrino/lobby",
43
- "current": "0.10.2",
43
+ "current": "0.10.4",
44
44
  "protocol": 2,
45
45
  "requires": {
46
46
  "proxy-client": ">=0.22.0",
@@ -85,7 +85,7 @@ export const MANIFESTS = Object.freeze({
85
85
  "notifications": {
86
86
  "repo": "imdotrino/dotrino-notifications",
87
87
  "npm": "@dotrino/notifications",
88
- "current": "0.6.0",
88
+ "current": "0.7.1",
89
89
  "protocol": 2,
90
90
  "requires": {
91
91
  "proxy-client": ">=0.20.0"
@@ -101,7 +101,7 @@ export const MANIFESTS = Object.freeze({
101
101
  "profile": {
102
102
  "repo": "imdotrino/dotrino-profile",
103
103
  "npm": "@dotrino/profile",
104
- "current": "0.20.1",
104
+ "current": "0.20.2",
105
105
  "protocol": 1,
106
106
  "requires": {
107
107
  "identity": ">=0.33.0"
@@ -109,7 +109,7 @@ export const MANIFESTS = Object.freeze({
109
109
  },
110
110
  "proxy": {
111
111
  "repo": "imdotrino/dotrino-proxy",
112
- "current": "1.6.4",
112
+ "current": "1.7.0",
113
113
  "protocol": 2,
114
114
  "requires": {
115
115
  "vault": ">=0.87.0",
@@ -121,7 +121,7 @@ export const MANIFESTS = Object.freeze({
121
121
  "proxy-client": {
122
122
  "repo": "imdotrino/dotrino-proxy-client",
123
123
  "npm": "@dotrino/proxy-client",
124
- "current": "0.36.1",
124
+ "current": "0.38.2",
125
125
  "protocol": 2,
126
126
  "requires": {
127
127
  "identity": ">=0.53.0",
@@ -131,10 +131,10 @@ export const MANIFESTS = Object.freeze({
131
131
  "remote-agent": {
132
132
  "repo": "imdotrino/dotrino-remote-agent",
133
133
  "npm": "@dotrino/remote-agent",
134
- "current": "0.20.1",
134
+ "current": "0.22.1",
135
135
  "protocol": 1,
136
136
  "requires": {
137
- "identity": ">=0.117.2",
137
+ "identity": ">=0.120.0",
138
138
  "vault": ">=0.85.0",
139
139
  "proxy-client": ">=0.36.1",
140
140
  "vaultd": ">=0.154.0"
@@ -163,7 +163,7 @@ export const MANIFESTS = Object.freeze({
163
163
  },
164
164
  "terminal-android": {
165
165
  "repo": "imdotrino/dotrino-terminal",
166
- "current": "0.13.2",
166
+ "current": "0.13.4",
167
167
  "protocol": 1,
168
168
  "requires": {
169
169
  "terminal-agent": ">=0.37.0"
@@ -193,11 +193,11 @@ export const MANIFESTS = Object.freeze({
193
193
  "content": {
194
194
  "repo": "imdotrino/dotrino-content",
195
195
  "npm": "@dotrino/content",
196
- "current": "0.6.0",
196
+ "current": "0.6.6",
197
197
  "protocol": 1,
198
198
  "requires": {
199
- "remote-agent": ">=0.20.1",
200
- "identity": ">=0.117.2",
199
+ "remote-agent": ">=0.22.0",
200
+ "identity": ">=0.120.0",
201
201
  "vault": ">=0.87.0",
202
202
  "proxy-client": ">=0.36.1"
203
203
  }
@@ -205,7 +205,7 @@ export const MANIFESTS = Object.freeze({
205
205
  "sealers": {
206
206
  "repo": "imdotrino/dotrino-sealers",
207
207
  "npm": "@dotrino/sealers",
208
- "current": "0.10.0",
208
+ "current": "0.10.1",
209
209
  "protocol": 1,
210
210
  "requires": {
211
211
  "remote-agent": ">=0.20.1",
@@ -217,7 +217,7 @@ export const MANIFESTS = Object.freeze({
217
217
  "roadmap": {
218
218
  "repo": "imdotrino/dotrino-roadmap",
219
219
  "npm": "@dotrino/roadmap",
220
- "current": "0.4.30",
220
+ "current": "0.4.32",
221
221
  "protocol": 1,
222
222
  "requires": {
223
223
  "compat": "0.4.0+"
@@ -226,7 +226,7 @@ export const MANIFESTS = Object.freeze({
226
226
  "store": {
227
227
  "repo": "imdotrino/dotrino-store",
228
228
  "npm": "@dotrino/store",
229
- "current": "0.12.3",
229
+ "current": "0.12.4",
230
230
  "protocol": 1,
231
231
  "requires": {
232
232
  "identity": ">=0.91.0",
@@ -243,7 +243,7 @@ export const MANIFESTS = Object.freeze({
243
243
  "topbar": {
244
244
  "repo": "imdotrino/dotrino-topbar",
245
245
  "npm": "@dotrino/topbar",
246
- "current": "0.16.1",
246
+ "current": "0.16.2",
247
247
  "protocol": 1,
248
248
  "requires": {
249
249
  "identity": ">=0.96.1",
@@ -253,11 +253,11 @@ export const MANIFESTS = Object.freeze({
253
253
  "vault": {
254
254
  "repo": "imdotrino/dotrino-vault",
255
255
  "npm": "@dotrino/vault",
256
- "current": "0.90.1",
256
+ "current": "0.91.1",
257
257
  "protocol": 1,
258
258
  "requires": {
259
259
  "vaultd": ">=0.105.0",
260
- "proxy-client": ">=0.36.1",
260
+ "proxy-client": ">=0.38.0",
261
261
  "identity": ">=0.118.0",
262
262
  "opaque": ">=0.1.0",
263
263
  "compat": ">=0.4.0",
@@ -269,11 +269,11 @@ export const MANIFESTS = Object.freeze({
269
269
  "vaultd": {
270
270
  "repo": "imdotrino/dotrino-vault",
271
271
  "npm": "@dotrino/vaultd",
272
- "current": "0.162.1",
272
+ "current": "0.163.4",
273
273
  "protocol": 1,
274
274
  "requires": {
275
275
  "identity": ">=0.118.0",
276
- "proxy-client": ">=0.36.1",
276
+ "proxy-client": ">=0.38.0",
277
277
  "store": ">=0.11.0",
278
278
  "passmanager": ">=0.18.4",
279
279
  "compat": ">=0.4.0",
@@ -284,7 +284,7 @@ export const MANIFESTS = Object.freeze({
284
284
  "verifier": {
285
285
  "repo": "imdotrino/dotrino-verifier",
286
286
  "npm": "@dotrino/verifier",
287
- "current": "0.3.0",
287
+ "current": "0.3.1",
288
288
  "protocol": 1,
289
289
  "requires": {
290
290
  "identity": "0.87.0+"
@@ -293,7 +293,7 @@ export const MANIFESTS = Object.freeze({
293
293
  "passmanager": {
294
294
  "repo": "imdotrino/dotrino-passmanager",
295
295
  "npm": "@dotrino/passmanager",
296
- "current": "0.18.4",
296
+ "current": "0.18.5",
297
297
  "protocol": 1,
298
298
  "requires": {
299
299
  "identity": ">=0.117.2",
@@ -340,6 +340,16 @@ export const MANIFESTS = Object.freeze({
340
340
  "identity": ">=0.110.1"
341
341
  }
342
342
  },
343
+ "app-ios": {
344
+ "repo": "imdotrino/dotrino-app",
345
+ "current": "0.4.13",
346
+ "protocol": 1,
347
+ "requires": {
348
+ "proxy": "1.4.7+",
349
+ "vaultd": ">=0.114.0",
350
+ "identity": ">=0.110.1"
351
+ }
352
+ },
343
353
  "social-bot": {
344
354
  "repo": "imdotrino/dotrino-social-bot",
345
355
  "current": "0.3.0",
@@ -845,6 +855,17 @@ export const MANIFESTS = Object.freeze({
845
855
  ],
846
856
  "why": "la bóveda recordaba el sí de aprobación por aparato: después de aprobar un borrado, o de leer una contraseña, ese aparato podía borrar o pisar una passkey sin que se pidiera nada ni le llegara aviso a nadie",
847
857
  "fix": "sube a @dotrino/vaultd 0.160.2"
858
+ },
859
+ {
860
+ "product": "content",
861
+ "versions": [
862
+ "0.6.1",
863
+ "0.6.2",
864
+ "0.6.3",
865
+ "0.6.4"
866
+ ],
867
+ "why": "el paquete no traía lib/cid.js, que lib/public.js importa: el node no arranca (ERR_MODULE_NOT_FOUND)",
868
+ "fix": "sube a @dotrino/content 0.6.6"
848
869
  }
849
870
  ]
850
871
  }
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.90.2 (dotrino-vault/lib/src/{admin,b64,browser,core/approvals,core/backup,core/blocked,core/browserDisk,core/commKey,core/disk,core/libVersion,core/machines,core/notices,core/notifiable,core/passwords,core/sealKey,core/sealer,core/sealers,core/secretsStore,core/sha256,core/store,core/subacta,core/threadStore,core/transport,core/vault,enroll,index,loginClient,passwordLogins,procContext,protocol,revocation,sealed}.js).
1
+ Copia vendorizada de @dotrino/vault@0.91.1 (dotrino-vault/lib/src/{admin,b64,browser,core/approvals,core/backup,core/blocked,core/browserDisk,core/commKey,core/disk,core/libVersion,core/machines,core/notices,core/notifiable,core/passwords,core/sealKey,core/sealer,core/sealers,core/secretsStore,core/sha256,core/store,core/subacta,core/threadStore,core/transport,core/vault,enroll,index,loginClient,passwordLogins,procContext,protocol,revocation,sealed}.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
  browser.js es la bóveda de esta pestaña: el MISMO núcleo que el binario (core/), con
4
4
  el disco en IndexedDB. Importa @dotrino/{compat,roadmap,passmanager,store/core} y
@@ -5,4 +5,4 @@
5
5
  * si no coincide con la del paquete: una versión anunciada que no es la que corre es peor
6
6
  * que no anunciar ninguna.
7
7
  */
8
- export const LIB_VERSION = '0.90.2'
8
+ export const LIB_VERSION = '0.91.1'
@@ -525,14 +525,14 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
525
525
  const [kind, k] = splitOwner(owner)
526
526
  const bag = kind === 'ns' ? data.ns : data.dev
527
527
  const g = (bag[k]?.keyring || []).find((x) => x.gen === gen)
528
- if (!g) throw new Error(`putWrap: no existe la generación ${gen} de ${owner}`)
528
+ if (!g) throw new Error(`putWrap: generation ${gen} of ${owner} does not exist`)
529
529
  if (!wrap?.epk || !wrap?.iv || !wrap?.ct) throw new Error('putWrap: envoltura mal formada')
530
530
  // SOLO AÑADE, NUNCA PISA. Quien re-envuelve es un servicio, y un servicio no
531
531
  // administra: si pudiera reemplazar una envoltura existente podría dejar sin leer
532
532
  // a otro miembro —o a quien administra— con una envoltura basura, y eso es
533
533
  // denegación de servicio disfrazada de reparto. Reemplazar es cosa de la bóveda,
534
534
  // por el camino de escribir (que estrena generación entera).
535
- if (g.wraps?.[memberPub]) throw new Error(`putWrap: ${memberPub.slice(0, 12)}… ya tiene envoltura de la generación ${gen}`)
535
+ if (g.wraps?.[memberPub]) throw new Error(`putWrap: ${memberPub.slice(0, 12)}… already has a wrap for generation ${gen}`)
536
536
  g.wraps = { ...(g.wraps || {}), [memberPub]: wrap }
537
537
  save()
538
538
  return true
@@ -36,7 +36,7 @@ export async function masterPubkeyOf (identity) {
36
36
  * @param {string} [opts.url] URL del proxy (default wss://proxy.dotrino.com).
37
37
  * @returns {Promise<{ client, token:string, identify():Promise<void> }>}
38
38
  */
39
- export async function createTransport ({ identity, url = DEFAULT_PROXY, commKey = null, log = () => {}, makeClient = null, announce = null, recheckMs = 60_000, patienceMs = 5 * 60_000, reannounceMs = 10 * 60_000 }) {
39
+ export async function createTransport ({ identity, url = DEFAULT_PROXY, commKey = null, log = () => {}, makeClient = null, announce = null, wakeUrl = null, recheckMs = 60_000, patienceMs = 5 * 60_000, reannounceMs = 10 * 60_000 }) {
40
40
  // Import dinámico DESPUÉS de instalar los globals que el paquete usa.
41
41
  // `WebSocketProxyClient` (la clase) y NO el helper `getWebSocketProxyClient`:
42
42
  // ese es un SINGLETON de proceso, y con multi-perfil el vault necesita una
@@ -182,6 +182,25 @@ export async function createTransport ({ identity, url = DEFAULT_PROXY, commKey
182
182
  }
183
183
  }
184
184
 
185
+ /**
186
+ * DESPIÉRTAME AQUÍ (una bóveda que no vive conectada, `lambda/`). Si la plataforma da una
187
+ * URL, se registra en el proxio con la llave de comunicación y el acta, para que llame a esa
188
+ * URL cuando llegue algo para la cuenta y no haya nadie suyo conectado. Va pegado a
189
+ * `identify` por lo mismo que el anuncio: el acta puede haber cambiado. Si falla, se dice:
190
+ * lo que se pierde es que la despierten, y eso no se puede callar.
191
+ */
192
+ const registerWake = async () => {
193
+ if (!wakeUrl || !identificado) return
194
+ const comm = commKey?.pub?.() || null
195
+ if (!comm || !actaVista) { log('[vault] cannot register the wake URL: no communication key in the record yet'); return }
196
+ try {
197
+ await client.registerWake({ publicKey: comm, sign: (d) => commKey.sign(d), url: wakeUrl, acta: actaVista, channel: announce || undefined })
198
+ log('[vault] wake URL registered on the proxy')
199
+ } catch (e) {
200
+ log(`[vault] could not register the wake URL (${e.code || ''} ${e.message}) — the proxy will not wake this vault`)
201
+ }
202
+ }
203
+
185
204
  let reintento = null
186
205
  let fallos = 0
187
206
  const identifyWithRetry = async () => {
@@ -189,6 +208,7 @@ export async function createTransport ({ identity, url = DEFAULT_PROXY, commKey
189
208
  try {
190
209
  await identify()
191
210
  await announceAccount()
211
+ await registerWake()
192
212
  if (fallos) log(`[vault] identified on the proxy after ${fallos + 1} attempt(s)`)
193
213
  fallos = 0
194
214
  } catch (e) {
@@ -80,6 +80,8 @@ const MAX_REPLY_BYTES = 768 * 1024
80
80
  * readProcContext(pid) lo que el sistema dice de un proceso; `null` si no se puede mirar
81
81
  * openLocalDesk({ name, onMessage, channels, maxBytes, log }) el atajo en la misma máquina
82
82
  * makeClient quién crea el cliente del proxio (opcional)
83
+ * wakeUrl la URL que despierta a esta bóveda si no vive conectada (opcional, `lambda/`)
84
+ * onActivity() llega un mensaje del proxio (opcional; la Lambda mide con esto su inactividad)
83
85
  * noticeGroupMs cuánto se agrupan los avisos de cambio
84
86
  * opaque el servidor de OPAQUE, si no vale el del paquete (opcional)
85
87
  * servePasswords `false`: el almacén de contraseñas lo atiende quien monta la bóveda,
@@ -492,7 +494,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
492
494
  // ninguna llave y solo sabe la dirección `nombre@AB12-CD34-EF56`— pueda encontrar a quien
493
495
  // la atiende. Lo construye la pieza compartida, porque quien lo busca es la extensión.
494
496
  const { client, identify: reidentificar, isIdentified } = await createTransport({
495
- identity, url: proxyUrl, commKey, log, announce: vaultChannel(fp), makeClient: platform.makeClient || null
497
+ identity, url: proxyUrl, commKey, log, announce: vaultChannel(fp), makeClient: platform.makeClient || null, wakeUrl: platform.wakeUrl || null
496
498
  })
497
499
 
498
500
  // El registro público de cadenas de selladores: deposita, si hay a dónde, los eslabones
@@ -2189,7 +2191,12 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
2189
2191
  reply(from, { type: MSG.INCIDENT_RESULT, id: pend.id, approvers, blocked: false })
2190
2192
  }
2191
2193
 
2192
- client.on('message', (from, payload) => onMessage(from, payload))
2194
+ client.on('message', (from, payload) => {
2195
+ // Quien monta la bóveda puede querer saber que hay trabajo (la Lambda se apaga cuando
2196
+ // deja de haberlo). Es un aviso, no decide nada.
2197
+ try { platform.onActivity?.() } catch (_) {}
2198
+ onMessage(from, payload)
2199
+ })
2193
2200
 
2194
2201
  // EL MOSTRADOR LOCAL, siempre que se pueda. Si no se puede abrir (un sistema sin sockets
2195
2202
  // de archivo, un directorio de solo lectura) se dice y se sigue: los servicios usan el
@@ -44,6 +44,8 @@ const err = (code, message, extra = {}) => Object.assign(new Error(message), { c
44
44
 
45
45
  /** Cuánto se espera a que una bóveda conteste antes de probar con la siguiente. */
46
46
  export const REPLY_TIMEOUT_MS = 15_000
47
+ /** Lo que se espera a una bóveda que se está despertando: arrancar en frío y conectarse. */
48
+ export const WAKE_WAIT_MS = 30_000
47
49
 
48
50
  /**
49
51
  * Escucha UNA respuesta de un token concreto. El `off` se suelta siempre —también al
@@ -98,7 +100,14 @@ export async function loginWithPassword ({ transport, address, password, label =
98
100
  if (typeof password !== 'string' || !password) throw err('no-password', 'loginWithPassword: password required')
99
101
  const { user, code, address: dir } = parseLoginAddress(address)
100
102
 
101
- const tokens = await transport.list(vaultChannel(code))
103
+ let tokens = await transport.list(vaultChannel(code))
104
+ // UNA BÓVEDA QUE DUERME (la de una Lambda): el proxio la está despertando y lo dice. Se
105
+ // espera a que se anuncie en vez de contestar que esa cuenta no existe.
106
+ const hasta = Date.now() + WAKE_WAIT_MS
107
+ while (!tokens.length && tokens.waking && Date.now() < hasta) {
108
+ await new Promise((resolve) => setTimeout(resolve, 2000))
109
+ tokens = await transport.list(vaultChannel(code))
110
+ }
102
111
  if (!tokens.length) {
103
112
  throw err('no-vault', 'no vault is answering for that address right now: turn yours on, or check the address')
104
113
  }
@@ -24,8 +24,8 @@
24
24
  * binario, `lib/src/index.js` en la pestaña—. Lo único que no es puro es `registerLogin`, al
25
25
  * final, porque hace falta la identidad que firma.
26
26
  */
27
- import { server as opaquePorDefecto, suiteId as suitePorDefecto } from '@dotrino/opaque'
28
- import { pubkeyId } from '@dotrino/identity/capabilities'
27
+ import { server as opaquePorDefecto, client as opaqueClientePorDefecto, suiteId as suitePorDefecto } from '@dotrino/opaque'
28
+ import { pubkeyId, makeDeviceKey, makeDeviceEncKey } from '@dotrino/identity/capabilities'
29
29
  import { DEVICE_CAPS, capScope } from '@dotrino/identity/acta'
30
30
  import { deviceIdOf } from './enroll.js'
31
31
  import { bytesToB64url, b64urlToBytes } from './b64.js'
@@ -448,6 +448,29 @@ export function createLoginDesk ({ load, save, now = () => Date.now(), opaque =
448
448
  * `caps` los PERMISOS del acta que lleva el aparato, cualquiera de `DEVICE_CAPS`;
449
449
  * por defecto firmar, leer y guardar
450
450
  */
451
+ /**
452
+ * DAR DE ALTA UN USUARIO, DEL LADO DE QUIEN TIENE LA CONTRASEÑA: el registro OPAQUE se
453
+ * termina aquí y las llaves del aparato nacen aquí y salen ya cerradas. La bóveda solo ve
454
+ * `begin`/`finish`, que es su mitad; quien llama decide por dónde llegan (el canal local de
455
+ * la CLI, o la propia bóveda en el arranque de una Lambda).
456
+ *
457
+ * @param {object} opts
458
+ * `begin(req)` → `{ response }` (`register-begin` de la bóveda)
459
+ * `finish(req)` → lo que devuelva (`register-finish` de la bóveda)
460
+ */
461
+ export async function createPasswordLogin ({ user, password, label, caps, begin, finish, opaque = opaqueClientePorDefecto } = {}) {
462
+ if (!isValidUser(user)) throw Object.assign(new Error('invalid user name'), { code: 'bad-user' })
463
+ if (typeof password !== 'string' || !password) throw Object.assign(new Error('a password is required'), { code: 'no-password' })
464
+ const start = opaque.registrationStart({ password })
465
+ const begun = await begin({ user, request: start.request })
466
+ // La bóveda contesta con su mitad; el registro se termina AQUÍ, con la contraseña.
467
+ const fin = opaque.registrationFinish({ state: start.state, response: begun.response, password })
468
+ const device = await makeDeviceKey({ label })
469
+ const enc = await makeDeviceEncKey()
470
+ const blob = await sealDeviceKeys(fin.exportKey, { sign: device.privateJwk, enc: enc.privateJwk })
471
+ return finish({ user, upload: fin.upload, pub: device.publickey, encPub: enc.publickey, label, blob, caps })
472
+ }
473
+
451
474
  export async function registerLogin ({
452
475
  identity, logins, user, upload, pub, encPub = null, label = '', blob,
453
476
  caps = ['sign', 'read', 'store'], scope, replace = false