@dotrino/remote-agent 0.15.0 → 0.15.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/remote-agent",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Middleware compartido del ecosistema Dotrino para apps de agente remoto cifrado punto a punto (handshake E2E, emparejamiento SAS con vault, revocación, descubrimiento). Lo consumen dotrino-terminal (PTY) y dotrino-ia (chat con IA).",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/src/agent.js CHANGED
@@ -26,6 +26,7 @@ import { sealersOf, memberCan, checkVaultReply } from '@dotrino/identity/acta'
26
26
  import { adoptRecord } from './record.js'
27
27
  import { installNodeGlobals } from '../node-globals.js'
28
28
  import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
29
+ import { makeCatchUp } from './catch-up.js'
29
30
  import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, REVOKE_REFRESH_MS } from '../protocol.js'
30
31
  import { loadLink, saveLink, dataDir } from './link.js'
31
32
 
@@ -259,6 +260,8 @@ export async function startRemoteAgent (opts = {}) {
259
260
  }
260
261
 
261
262
  const vaultTick = async () => { await refreshRevocations(); await renewCertIfNeeded() }
263
+ /** Trae el acta ahora (con freno) y dice si AVANZÓ: solo entonces merece juzgar otra vez. */
264
+ const catchUp = makeCatchUp({ refresh: vaultTick, seq: () => link.actaSeq })
262
265
  vaultTick()
263
266
  const revTimer = setInterval(vaultTick, REVOKE_REFRESH_MS); revTimer.unref?.()
264
267
 
@@ -280,8 +283,25 @@ export async function startRemoteAgent (opts = {}) {
280
283
  // Manda el acta, no una llave fija: con varias selladoras el papel de un peer puede
281
284
  // venir firmado por otra bóveda del mismo perfil. Sin acta no se atiende — no hay con
282
285
  // qué decidir, así que no se decide que sí.
283
- const chk = await verifyChain({ data, signature, cert, expectedScope: SIGN_SCOPE, ...contextoActa(), revoked: revokedSet })
284
- if (!chk.ok) return send(from, { type: ERROR, error: 'no autorizado: ' + chk.reason })
286
+ // SE JUZGA, Y SI NO PASA SE PREGUNTA A LA BÓVEDA ANTES DE DECIR QUE NO. El acta se trae cada
287
+ // `REVOKE_REFRESH_MS` (5 min), así que un aparato recién emparejado era un desconocido hasta
288
+ // el siguiente tic: «este perfil no reconoce a ese aparato» nada más emparejar (un iPhone, el
289
+ // 2026-10-07). Ahora, ante un rechazo, se trae el acta EN EL MOMENTO y se juzga otra vez, una
290
+ // sola — y solo si el acta avanzó. Con freno (`catchUp`): cualquiera puede mandar saludos, y
291
+ // no por eso se le pregunta a la bóveda cada vez.
292
+ const judge = async () => {
293
+ const chk = await verifyChain({ data, signature, cert, expectedScope: SIGN_SCOPE, ...contextoActa(), revoked: revokedSet })
294
+ if (!chk.ok) return { error: 'no autorizado: ' + chk.reason }
295
+ return { chk }
296
+ }
297
+ let verdict = await judge()
298
+ const known = (v) => {
299
+ const acta = actaDeConfianza()
300
+ return !!acta && (acta.members || []).some((m) => m?.pub === v.chk.device) && memberCan(acta, v.chk.device, 'sign')
301
+ }
302
+ if ((verdict.error || !known(verdict)) && await catchUp()) verdict = await judge()
303
+ if (verdict.error) return send(from, { type: ERROR, error: verdict.error })
304
+ const chk = verdict.chk
285
305
  // CERT ∩ ACTA. `verifyChain` comprueba QUIÉN EMITIÓ el papel; que el APARATO siga siendo
286
306
  // isMember y pueda firmar lo dice el acta, y hay que preguntárselo aparte. Sin esto, un
287
307
  // papel bien firmado por una selladora abría sesión aunque el acta no nombrara a ese
@@ -0,0 +1,29 @@
1
+ /**
2
+ * catch-up.js — ponerse al día con la bóveda ANTES de rechazar a alguien.
3
+ *
4
+ * El agente trae el acta cada pocos minutos. Entre un tic y el siguiente no sabe de un aparato
5
+ * recién emparejado, y lo rechazaba. `makeCatchUp` da una función que trae el acta en el momento
6
+ * y contesta si avanzó — con un freno, porque un saludo lo puede mandar cualquiera y no por eso se
7
+ * le pregunta a la bóveda cada vez.
8
+ *
9
+ * @param {{ refresh: () => Promise<void>, seq: () => (number|null|undefined), minGapMs?: number, now?: () => number }} o
10
+ * @returns {() => Promise<boolean>} true si el acta avanzó (y vale la pena juzgar otra vez).
11
+ */
12
+ export const CATCH_UP_GAP_MS = 10_000
13
+
14
+ export function makeCatchUp ({ refresh, seq, minGapMs = CATCH_UP_GAP_MS, now = Date.now }) {
15
+ let last = -Infinity
16
+ let running = null
17
+ return async () => {
18
+ // Uno a la vez: los saludos que lleguen mientras tanto esperan a ese mismo y usan su resultado.
19
+ if (running) return running
20
+ if (now() - last < minGapMs) return false
21
+ last = now()
22
+ const before = seq()
23
+ running = (async () => {
24
+ try { await refresh() } catch (_) { return false }
25
+ return seq() !== before
26
+ })().finally(() => { running = null })
27
+ return running
28
+ }
29
+ }