@dotrino/remote-agent 0.22.3 → 0.22.4

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.22.3",
3
+ "version": "0.22.4",
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/protocol.js CHANGED
@@ -33,6 +33,9 @@ export const VMSG = {
33
33
  ERROR: 'vault.error'
34
34
  }
35
35
 
36
+ /** El proxio por defecto, cuando el enlace no trae uno. Única definición del paquete. */
37
+ export const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
38
+
36
39
  /** Scope del cert de dispositivo que autoriza a hablar con el agente. */
37
40
  export const SIGN_SCOPE = 'vault:sign'
38
41
 
package/src/agent.js CHANGED
@@ -27,8 +27,8 @@ import { adoptRecord } from './record.js'
27
27
  import { installNodeGlobals } from '../node-globals.js'
28
28
  import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
29
29
  import { makeCatchUp } from './catch-up.js'
30
- import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, SESSION_GONE_MS, REVOKE_REFRESH_MS } from '../protocol.js'
31
- import { loadLink, saveLink, dataDir } from './link.js'
30
+ import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, SESSION_GONE_MS, REVOKE_REFRESH_MS, DEFAULT_PROXY } from '../protocol.js'
31
+ import { loadLink, saveLink, dataDir, renewalReason, storeRenewedCert } from './link.js'
32
32
 
33
33
  /** Sesión cifrada con un dispositivo cliente. La app recibe esto en `onSession`. */
34
34
  export class AgentSession {
@@ -142,7 +142,7 @@ export async function startRemoteAgent (opts = {}) {
142
142
  const sessions = new Map()
143
143
  const hasSessionFrom = (token) => { for (const s of sessions.values()) if (s.from === token) return true; return false }
144
144
 
145
- const proxyUrl = opts.proxyUrl || process.env.PROXY_URL || link.proxy || 'wss://proxy.dotrino.com'
145
+ const proxyUrl = opts.proxyUrl || process.env.PROXY_URL || link.proxy || DEFAULT_PROXY
146
146
  // `client` inyectado: solo para las pruebas (transporte de mentira). En producción se
147
147
  // levanta el del ecosistema — no hay otro transporte.
148
148
  const client = opts.client || await (async () => {
@@ -264,16 +264,8 @@ export async function startRemoteAgent (opts = {}) {
264
264
  * que una segunda bóveda pueda emitir; lo que se fija es el perfil.
265
265
  */
266
266
  async function renewCertIfNeeded () {
267
- // DOS MOTIVOS PARA PEDIR PAPEL NUEVO:
268
- // · el nuestro es del MODELO VIEJO (lleva `exp` y no `seq`). Ese hay que cambiarlo sí
269
- // o sí: se acepta por el repliegue de migración, pero muere en su fecha y con él la
270
- // máquina. Sin esta rama la migración no terminaba nunca — el disparador comparaba
271
- // `seq` y un papel viejo no tiene, así que nunca saltaba.
272
- // · o el acta que hemos visto va por delante del papel: el dueño cambió permisos.
273
- const legacy = typeof link.cert?.seq !== 'number'
274
- const seenSeq = link.actaSeq
275
- const behind = typeof seenSeq === 'number' && typeof link.cert?.seq === 'number' && seenSeq > link.cert.seq
276
- if (!legacy && !behind) return
267
+ // La regla de cuándo toca (papel del modelo viejo, o acta por delante) es UNA, en link.js.
268
+ if (!renewalReason(link)) return
277
269
  try {
278
270
  const res = await vaultRpc(VMSG.RENEW, VMSG.RENEWED, { op: 'renew', publickey: myPub, ts: Date.now() })
279
271
  const cert = res?.cert
@@ -282,9 +274,7 @@ export async function startRemoteAgent (opts = {}) {
282
274
  // `acta.profileId === master`, que con una segunda bóveda nunca es verdad.
283
275
  const chk = await checkVaultReply({ acta, cert, vault: master, sub: myPub, scope: SIGN_SCOPE })
284
276
  if (!chk.ok) throw new Error('invalid renewed cert: ' + chk.reason)
285
- link.cert = cert
286
- link.actaSeq = acta.seq
287
- saveLink(dir, link)
277
+ await storeRenewedCert({ dir, link, cert, acta })
288
278
  audit('cert-renew', { seq: cert.seq })
289
279
  if (!opts.quiet) console.log(`[remote-agent] cert renewed against record #${cert.seq}`)
290
280
  } catch (e) {
@@ -1,3 +1,5 @@
1
+ import { DEFAULT_PROXY } from '../protocol.js'
2
+
1
3
  /**
2
4
  * El enlace de un NAVEGADOR a su bóveda, sacado del pilar de identidad (iframe
3
5
  * id.dotrino.com): es lo que necesita `RemoteAgentClient` para hablar con un agente.
@@ -23,7 +25,7 @@ export async function linkFromIdentity (id) {
23
25
  id,
24
26
  cert: v.cert,
25
27
  iss: status.master,
26
- proxy: status.proxy || 'wss://proxy.dotrino.com',
28
+ proxy: status.proxy || DEFAULT_PROXY,
27
29
  deviceId: status.deviceId,
28
30
  scope: status.scope || []
29
31
  }
package/src/client.js CHANGED
@@ -23,7 +23,7 @@
23
23
  import { verifyChain } from '@dotrino/identity/capabilities'
24
24
  import { sealersOf } from '@dotrino/identity/acta'
25
25
  import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
26
- import { HS, ACK, DATA, PING, PONG, ERROR } from '../protocol.js'
26
+ import { HS, ACK, DATA, PING, PONG, ERROR, DEFAULT_PROXY } from '../protocol.js'
27
27
 
28
28
  export class RemoteAgentClient {
29
29
  /**
@@ -37,7 +37,7 @@ export class RemoteAgentClient {
37
37
  constructor (link, { agentPubkey, proxyUrl } = {}) {
38
38
  this.link = link // { id, cert, iss, proxy, mode? }
39
39
  this.agentPubkey = agentPubkey // pubkey del agente destino
40
- this.proxyUrl = proxyUrl || link.proxy || 'wss://proxy.dotrino.com'
40
+ this.proxyUrl = proxyUrl || link.proxy || DEFAULT_PROXY
41
41
  this.client = null
42
42
  this.key = null
43
43
  this.sid = null
package/src/link.js CHANGED
@@ -21,6 +21,7 @@ import { requestDevices, requestRenew } from '@dotrino/identity/vault/remote.js'
21
21
  import { installNodeGlobals } from '../node-globals.js'
22
22
  import { adoptRecord } from './record.js'
23
23
  import { verifySealerChain } from '@dotrino/identity/acta'
24
+ import { DEFAULT_PROXY, RENEW_BEFORE_MS } from '../protocol.js'
24
25
 
25
26
  export function dataDir (name = 'dotrino-remote-agent') {
26
27
  if (process.env.DOTRINO_REMOTE_AGENT_DIR) return process.env.DOTRINO_REMOTE_AGENT_DIR
@@ -113,7 +114,7 @@ export function identityFromLink (link, { dir = dataDir() } = {}) {
113
114
  if (!link?.device?.privateJwk || !link?.cert || !link?.iss) throw new Error('invalid link: device/cert/iss required')
114
115
  installNodeGlobals(dir)
115
116
  const device = link.device
116
- const proxy = link.proxy || 'wss://proxy.dotrino.com'
117
+ const proxy = link.proxy || DEFAULT_PROXY
117
118
  return {
118
119
  me: { publickey: device.publickey },
119
120
  async signData (data) {
@@ -146,8 +147,43 @@ export function identityFromLink (link, { dir = dataDir() } = {}) {
146
147
  }
147
148
  }
148
149
 
149
- /** Con menos de esto de vida se pide cert nuevo (7 días; el cert dura 30). */
150
- export const RENEW_BEFORE_MS = 7 * 24 * 60 * 60 * 1000
150
+ // Se reexporta por compatibilidad del subpath '/link'; la definición vive en protocol.js.
151
+ export { RENEW_BEFORE_MS }
152
+
153
+ /**
154
+ * POR QUÉ TOCA PEDIR PAPEL NUEVO, o `null` si no toca. Es la única regla de renovación:
155
+ * la usan `renewLink` (agentes que corren una vez) y el tic de `startRemoteAgent`.
156
+ * · `'legacy'`: el papel es del MODELO VIEJO (lleva `exp` y no `seq`). Se cambia sí o sí:
157
+ * se acepta por el repliegue de migración, pero muere en su fecha y con él la máquina.
158
+ * · `'behind'`: el acta que hemos visto va por delante del papel (el dueño cambió permisos).
159
+ * Nunca por calendario: el papel no vence.
160
+ *
161
+ * @param {object} link
162
+ * @returns {'legacy'|'behind'|null}
163
+ */
164
+ export function renewalReason (link) {
165
+ if (typeof link?.cert?.seq !== 'number') return 'legacy'
166
+ if (typeof link.actaSeq === 'number' && link.actaSeq > link.cert.seq) return 'behind'
167
+ return null
168
+ }
169
+
170
+ /**
171
+ * GUARDA un papel renovado (ya comprobado por quien lo pidió) y lo persiste. SE GUARDA ANTES
172
+ * DE DAR NADA POR BUENO: emitir un papel RETIRA el anterior, así que si esto no se persiste la
173
+ * máquina se queda usando uno revocado y fuera para siempre.
174
+ *
175
+ * El acta que viaja con el papel se adopta SOLO si se puede comprobar (`record.js`): es la que
176
+ * decide quién abre sesión con este agente. El papel sí se guarda siempre.
177
+ */
178
+ export async function storeRenewedCert ({ dir, link, cert, acta }) {
179
+ link.cert = cert
180
+ link.actaSeq = acta.seq
181
+ if ((await adoptRecord({ link, candidate: acta, me: link.device.publickey })).adopt) {
182
+ link.acta = acta
183
+ link.actaTrusted = true
184
+ }
185
+ saveLink(dir, link)
186
+ }
151
187
 
152
188
  /**
153
189
  * RENUEVA el cert del enlace si vence pronto y lo persiste. Es lo que hace el agente
@@ -167,31 +203,22 @@ export async function renewLink (link, { dir = dataDir(), force = false } = {})
167
203
  // EL PAPEL DEL MODELO VIEJO SE CAMBIA SÍ O SÍ. Lleva `exp` y no `seq`; se acepta por el
168
204
  // repliegue de migración pero muere en su fecha, y con él la máquina. Los demás se
169
205
  // renuevan cuando el acta va por delante — nunca por calendario, que el papel no vence.
170
- const legacy = typeof link.cert.seq !== 'number'
171
- if (legacy && typeof link.cert.exp === 'number' && link.cert.exp <= Date.now()) {
206
+ const why = renewalReason(link)
207
+ if (why === 'legacy' && typeof link.cert.exp === 'number' && link.cert.exp <= Date.now()) {
172
208
  return { renewed: false, reason: 'expired: enroll again' }
173
209
  }
174
- const behind = typeof link.actaSeq === 'number' && typeof link.cert.seq === 'number' && link.actaSeq > link.cert.seq
175
- if (!force && !legacy && !behind) return { renewed: false, seq: link.cert.seq, reason: 'not due' }
210
+ if (!force && !why) return { renewed: false, seq: link.cert.seq, reason: 'not due' }
176
211
  installNodeGlobals(dir)
177
212
  try {
178
213
  // `requestRenew` ya JUZGA lo que devuelve (`checkVaultReply` del pilar: acta bien
179
214
  // firmada, esta bóveda puede sellarla, el papel lo firmó ella y es para esta llave) y
180
215
  // lanza si no cuadra. Aquí se comparaba `acta.profileId` con `link.iss`, que solo vale
181
216
  // en una cuenta que nació en esa bóveda: con una segunda bóveda no renovaba nunca.
182
- const { cert, acta } = await requestRenew({ master: link.iss, proxy: link.proxy || 'wss://proxy.dotrino.com', device: link.device, cert: link.cert })
217
+ const { cert, acta } = await requestRenew({ master: link.iss, proxy: link.proxy || DEFAULT_PROXY, device: link.device, cert: link.cert })
183
218
  // SE GUARDA ANTES DE DAR NADA POR BUENO. Emitir un papel RETIRA el anterior, así que si
184
219
  // esto no se persiste la máquina se queda usando uno revocado y fuera para siempre. Le
185
220
  // pasó al registro de selladores en la migración, y por eso está dicho aquí.
186
- link.cert = cert
187
- link.actaSeq = acta.seq
188
- // El acta que viaja con el papel se guarda SOLO si se puede comprobar (`record.js`): es
189
- // la que decide quién abre sesión con este agente. El papel sí se guarda siempre.
190
- if ((await adoptRecord({ link, candidate: acta, me: link.device.publickey })).adopt) {
191
- link.acta = acta
192
- link.actaTrusted = true
193
- }
194
- saveLink(dir, link)
221
+ await storeRenewedCert({ dir, link, cert, acta })
195
222
  return { renewed: true, seq: cert.seq }
196
223
  } catch (e) {
197
224
  return { renewed: false, seq: link.cert.seq ?? null, reason: e.message }
@@ -211,7 +238,7 @@ export async function refreshChain (link, { dir = dataDir() } = {}) {
211
238
  if (!link?.device?.privateJwk || !link?.cert || !link?.iss) return { ok: false, reason: 'invalid link' }
212
239
  installNodeGlobals(dir)
213
240
  try {
214
- const res = await requestDevices({ master: link.iss, proxy: link.proxy || 'wss://proxy.dotrino.com', device: link.device, cert: link.cert })
241
+ const res = await requestDevices({ master: link.iss, proxy: link.proxy || DEFAULT_PROXY, device: link.device, cert: link.cert })
215
242
  const chain = res.sealerChain
216
243
  if (!Array.isArray(chain) || !chain.length) return { ok: false, reason: 'the vault sent no sealer chain (vaultd < 0.154.0?)' }
217
244
  const expected = link.sealerChain?.[0]?.profileId ?? link.acta?.profileId ?? null
@@ -232,7 +259,7 @@ export async function refreshChain (link, { dir = dataDir() } = {}) {
232
259
 
233
260
  /** El enlace como lo esperan `ContentClient.connect({ link })` y `RemoteAgentClient`. */
234
261
  export function clientLink (link, opts) {
235
- return { id: identityFromLink(link, opts), cert: link.cert, iss: link.iss, proxy: link.proxy || 'wss://proxy.dotrino.com' }
262
+ return { id: identityFromLink(link, opts), cert: link.cert, iss: link.iss, proxy: link.proxy || DEFAULT_PROXY }
236
263
  }
237
264
 
238
- export default { dataDir, loadLink, saveLink, parseQr, enroll, identityFromLink, clientLink, renewLink, refreshChain }
265
+ export default { dataDir, loadLink, saveLink, parseQr, enroll, identityFromLink, clientLink, renewLink, renewalReason, storeRenewedCert, refreshChain }