@nyx5/nyx5 0.5.0 → 0.7.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.
@@ -20,22 +20,45 @@
20
20
 
21
21
  import { FileStore } from '../nucleo/almacen.js';
22
22
  import { Resolver, parseAddress } from './resolver.js';
23
- import { validateEnvelope, applyInboxPolicy, RateLimiter } from './politica.js';
23
+ import { validateEnvelope, applyInboxPolicy, applyEmailPolicy, RateLimiter, RateLimiterDurable, proyectoDe, nombreDeProyecto, validarPerfil, usdSinBilletera } from './politica.js';
24
24
  import { generateSigningKeys, signObject, verifyObject, signBytes, verifyBytes, canonical, uuid, unb64u, sha256hex } from '../nucleo/crypto.js';
25
25
  import { Libro, MEDIA, LibroError } from '../libro/libro.js';
26
26
  import { veredicto, pruebasDe, pruebasDisponibles } from '../libro/verifica.js';
27
27
  import { contratoPublico, ACP } from '../libro/contratos.js';
28
+ import { estadoDeCuenta, csvDe, nombreCsv } from '../libro/estado.js';
29
+ import { selloPublico } from '../libro/notaria.js';
28
30
  import { Tareas } from '../libro/tareas.js';
29
- import { datosInforme, informeHtml } from '../libro/informe.js';
31
+ import { datosInforme, datosEmbudo, informeHtml } from '../libro/informe.js';
30
32
  import { APP_HTML } from '../plataformas/app-html.js';
31
33
  import { SPEC_HTML, LLMS_TXT } from '../plataformas/spec-html.js';
32
34
  import { HOME_HTML } from '../plataformas/home-html.js';
33
35
  import { OG_PNG_B64 } from '../plataformas/og-png.js';
34
36
  import { inboundEnvelope, outboundPayload, isEmailAddress } from '../puentes/email.js';
37
+ import * as x402 from '../puentes/x402.js';
38
+ import * as oauth from '../puentes/oauth.js';
39
+ import { atenderMcp } from '../puentes/mcp-remoto.js';
40
+ import { abrirBoveda } from '../nucleo/boveda.js';
41
+ import { ICONOS } from '../plataformas/iconos.js';
42
+ import { Agent } from './agente.js';
43
+ import { atenderAsistentes, PRECIOS } from './asistente.js';
44
+ import { validarFiltros, puntajeDe, precioMinimo, FILTROS } from './indice.js';
35
45
 
36
46
  const now = () => Date.now();
37
47
  const iso = (t = now()) => new Date(t).toISOString();
38
48
  const RETRYABLE = new Set([408, 421, 425, 429, 500, 502, 503, 504]);
49
+ // CORS sólo donde lo necesita un cliente MCP que corre en un navegador (el MCP Inspector, por
50
+ // ejemplo): descubrimiento, registro, token y /mcp. El resto de la casa sigue siendo mismo-origen.
51
+ const CORS = {
52
+ 'access-control-allow-origin': '*',
53
+ 'access-control-allow-methods': 'GET, POST, OPTIONS',
54
+ 'access-control-allow-headers': 'authorization, content-type, mcp-protocol-version, mcp-session-id, last-event-id',
55
+ 'access-control-expose-headers': 'www-authenticate, mcp-session-id',
56
+ 'access-control-max-age': '600',
57
+ };
58
+ const rutaCors = (p) => p.startsWith('/.well-known/oauth-') || p === '/oauth/register' || p === '/oauth/token' || p === '/mcp' || oauth.RUTA_INVITACION.test(p);
59
+ // La app se instala en la pantalla de inicio: en iPhone es lo que evita que Safari borre la llave
60
+ // del usuario a los siete días sin abrirla.
61
+ const MANIFIESTO = { name: 'Nyx5', short_name: 'Nyx5', start_url: '/app', scope: '/', display: 'standalone', background_color: '#FFFFFF', theme_color: '#12A594', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }] };
39
62
 
40
63
  export class Estafeta {
41
64
  constructor({
@@ -43,7 +66,7 @@ export class Estafeta {
43
66
  port = 4000, host = '127.0.0.1', publicUrl,
44
67
  hosts = {}, fetchImpl = globalThis.fetch,
45
68
  policy = {}, retry = {}, workerIntervalMs = 1000, libro = {},
46
- index = {}, email = {}, verifica = {}, eventos = true, tareas = {}, terms = null,
69
+ index = {}, email = {}, verifica = {}, eventos = true, tareas = {}, terms = null, remoto = {}, asistente = {},
47
70
  extensions = null,
48
71
  log = (...a) => console.log(`[estafeta ${domain}]`, ...a),
49
72
  }) {
@@ -54,9 +77,24 @@ export class Estafeta {
54
77
  this.authHost = new URL(this.publicUrl).host;
55
78
  this.adminToken = adminToken;
56
79
  this.fetch = (...a) => fetchImpl(...a); // envuelto: workerd exige fetch con this=globalThis
80
+ // fetch que resuelve la propia casa EN PROCESO. Un Worker no puede pedirse su propia URL
81
+ // pública (522): el agente de un subagente delegado (el conector remoto) habla por aquí con su
82
+ // estafeta, pasando por las mismas rutas, la misma firma y la misma política que cualquiera.
83
+ this.fetchPropio = async (url, init = {}) => {
84
+ const u = new URL(String(url));
85
+ if (u.host !== this.authHost) return this.fetch(url, init);
86
+ const headers = {};
87
+ new Headers(init.headers || {}).forEach((v, k) => { headers[k] = v; });
88
+ let body = null;
89
+ if (init.body != null) { try { body = JSON.parse(init.body); } catch { body = null; } }
90
+ const out = await this.handleRequest({ method: String(init.method || 'GET').toUpperCase(), path: u.pathname, query: u.searchParams, headers, body, ip: 'local' });
91
+ if (out.pending) out.pending.catch(() => {});
92
+ const texto = out.contentType ? String(out.body ?? '') : JSON.stringify(out.body);
93
+ return new Response(out.status === 204 || out.status === 304 ? null : texto, { status: out.status, headers: { 'content-type': out.contentType || 'application/json' } });
94
+ };
57
95
  this.log = log;
58
96
  // registration: 'admin' (solo la casa inscribe) | 'invite' (código emitido por la casa) | 'open' (cualquiera, con prueba de posesión de clave)
59
- this.policy = { inbound: 'verified', max_bytes: 1_048_576, rate_per_minute: 120, registration: 'admin', registrations_per_minute: 10, ...policy };
97
+ this.policy = { inbound: 'verified', max_bytes: 1_048_576, rate_per_minute: 120, registration: 'admin', registrations_per_minute: 10, min_name_length: 4, ...policy };
60
98
  this.retry = { baseMs: 1000, maxMs: 60_000, giveUpMs: 3 * 24 * 3600 * 1000, ...retry };
61
99
  this.workerIntervalMs = workerIntervalMs;
62
100
  this.index = { enabled: false, crawlMinutes: 15, maxHouses: 500, ...index };
@@ -64,12 +102,14 @@ export class Estafeta {
64
102
  // el cron hace en una pasada para no comerse el minuto entero verificando.
65
103
  this.verifica = { enabled: true, maxPorTick: 10, timeoutMs: 10_000, ...verifica };
66
104
  this.eventos = eventos !== false;
105
+ this._primeros = new Set(); // raíces cuyo `first_message` esta instancia ya vio (ver _primerMensaje)
67
106
  this.terms = terms || null; // HTML de los términos; sin esto, /terms no existe
68
107
  // Trabajo sembrado: la casa es el primer comprador. Sin catálogo, apagado.
69
108
  this.tareas = new Tareas(tareas);
70
109
  // Puente de correo: entrada siempre disponible si la casa la enciende; salida solo si hay proveedor.
71
110
  // `email.provider` es una función async(payload) (ver src/puentes/email.js); sin ella, la salida queda pendiente.
72
- this.email = { footer: email.footer === true, enabled: !!(email.enabled || email.provider), provider: email.provider || null };
111
+ // `senders`: las únicas direcciones que pueden escribirle a una persona por correo. Vacío = cerrado.
112
+ this.email = { footer: email.footer === true, enabled: !!(email.enabled || email.provider), provider: email.provider || null, senders: new Set((email.senders || []).map((s) => String(s).trim().toLowerCase()).filter(Boolean)) };
73
113
  this.extensions = extensions || [
74
114
  'urn:nyx5:ext:mcp', 'urn:nyx5:ext:a2a', 'urn:nyx5:ext:libro',
75
115
  ...(this.index.enabled ? ['urn:nyx5:ext:indice'] : []),
@@ -79,8 +119,21 @@ export class Estafeta {
79
119
  this.hostsOverride = hosts;
80
120
 
81
121
  this.store = store || new FileStore(dataDir);
82
- this.rate = new RateLimiter({ perMinute: this.policy.rate_per_minute });
83
- this.regRate = new RateLimiter({ perMinute: this.policy.registrations_per_minute });
122
+ // Límites de tasa durables (NX-901): cuentan en el almacén, así que valen para toda la casa y
123
+ // no por isolate. Un almacén sin contador (uno ajeno, mínimo) cae al de memoria.
124
+ const limitador = (perMinute, ns) => (this.store.kvIncrement ? new RateLimiterDurable({ store: this.store, perMinute, ns, log: (m) => this.log(m) }) : new RateLimiter({ perMinute }));
125
+ this.rate = limitador(this.policy.rate_per_minute, 'tasa');
126
+ this.regRate = limitador(this.policy.registrations_per_minute, 'tasa-alta');
127
+ // Conector MCP remoto (src/puentes/oauth.js + mcp-remoto.js). Existe sólo si la casa lo enciende
128
+ // Y tiene llave de bóveda: sin un lugar cifrado donde guardar la llave de un subagente, no hay
129
+ // conector. La llave de la bóveda no se guarda en `this.remoto`: sólo la bóveda la conoce.
130
+ const { vaultKey, ...remotoSinLlave } = remoto;
131
+ this.boveda = abrirBoveda(vaultKey || null);
132
+ this.remoto = { dias: 30, accesoS: 3600, refrescoMs: 30 * 24 * 3600 * 1000, ...remotoSinLlave, enabled: !!(remoto.enabled && this.boveda) };
133
+ this.remotoRate = limitador(240, 'tasa-remoto');
134
+ // Asistentes (src/correo/asistente.js): existen sólo si la casa tiene una clave de la API de
135
+ // Anthropic. La clave no se guarda aparte: sólo el asistente la usa, al llamar.
136
+ this.asistente = asistente.apiKey ? { apiKey: asistente.apiKey, fetch: (...a) => (asistente.fetchImpl || this.fetch)(...a) } : null;
84
137
  this._ready = null;
85
138
  this._domainCardCache = null; // { value, until }
86
139
  this._pushes = []; // avisos por webhook en vuelo (los espera flushPushes)
@@ -102,7 +155,7 @@ export class Estafeta {
102
155
  // Resolución local de la propia casa: sin esto, verificar a un vendedor de casa exige un
103
156
  // fetch del Worker a su propio dominio público, que Cloudflare corta (522) y deja la
104
157
  // operación reintentando para siempre.
105
- self: { domain: this.domain, estafeta: this.publicUrl, domainCard: () => this.domainCard(), agentCard: (local) => this.agentCard(local) },
158
+ self: { domain: this.domain, estafeta: this.publicUrl, domainCard: () => this.domainCard(), agentCard: (local) => this.agentCard(local), firmarPara: (addr, dom) => this._firmarPara(addr, dom) },
106
159
  });
107
160
  this.libro = new Libro({ domain: this.domain, store: this.store, keys: this.keys, resolver: this.resolver, log: this.log, ...this.libroOpts });
108
161
  await this._ensureSystemAgents();
@@ -125,7 +178,11 @@ export class Estafeta {
125
178
  // Agentes de sistema, firman con la clave del dominio:
126
179
  // postmaster@ -> avisos de entrega y rebotes libro@ -> operaciones y recibos del Libro
127
180
  if (!await this.store.getAgent('postmaster')) await this.registerAgent({ local: 'postmaster', sig: this.keys.sig, capabilities: { accepts: [] }, inbox: { policy: 'allowlist', allowlist: [] } });
128
- if (!await this.store.getAgent('libro')) await this.registerAgent({ local: 'libro', sig: this.keys.sig, capabilities: { accepts: [MEDIA.op], libro: { fee_bps: this.libro.feeBps, ops: this.libro.ops } }, inbox: { policy: 'open' } });
181
+ // La tarjeta de libro@ anuncia las operaciones y el fee: se re-certifica cuando cambian (en
182
+ // producción decía las ops de hace una semana: ni pay, ni reclaim, ni expire).
183
+ const libroRec = await this.store.getAgent('libro');
184
+ const libroCaps = { accepts: [MEDIA.op], libro: { fee_bps: this.libro.feeBps, ops: this.libro.ops } };
185
+ if (!libroRec || canonical(libroRec.capabilities?.libro || {}) !== canonical(libroCaps.libro)) await this.registerAgent({ local: 'libro', sig: this.keys.sig, capabilities: libroCaps, inbox: { policy: 'open' } });
129
186
  // verifica@ — el evaluador de referencia de la casa. Pruebas deterministas y nada más: un
130
187
  // verificador que se equivoca castiga a un inocente. Su tarjeta declara cuáles puede correr
131
188
  // en ESTE runtime (en el edge no hay shell) y se REESCRIBE si eso cambia: al añadir una
@@ -153,7 +210,38 @@ export class Estafeta {
153
210
  try { await this.store.putEvent({ id: uuid(), name, ts: iso(), actor: actor || null, data }); }
154
211
  catch (e) { this.log(`evento ${name} no registrado: ${e.message}`); }
155
212
  }
213
+ // La fuente de atribución (`source`) es texto ajeno: se recorta y se sanea antes de entrar a
214
+ // un evento. Una sola definición para el join, la invitación y el enlace abierto.
215
+ static fuenteLimpia(x) { return typeof x === 'string' ? (x.slice(0, 64).replace(/[^\w.:@/-]/g, '') || null) : null; }
216
+ // Embudo (NX-801): `first_message` es la primera vez que una dirección raíz, o su Claude
217
+ // conectado `claude.<raíz>`, le escribe a OTRA dirección (no a sí misma, no a libro@). Se
218
+ // recuerda en el almacén por raíz (ns `primer_mensaje`, sin vencimiento) y en memoria por
219
+ // instancia: cuesta a lo sumo una escritura condicional por raíz, nunca una lectura de todos
220
+ // los eventos por sobre. Otros delegados no cuentan: un asistente que contesta solo no es el
221
+ // humano escribiendo. Un fallo aquí jamás detiene el envío.
222
+ async _primerMensaje(submitter, env) {
223
+ if (!this.eventos || !this.store.kvPutIfAbsent) return;
224
+ let raiz = submitter.address, via = 'root';
225
+ const del = submitter.record.delegation;
226
+ if (del) {
227
+ const padre = parseAddress(del.by);
228
+ if (padre.domain !== this.domain || submitter.local !== `claude.${padre.local}`) return;
229
+ raiz = del.by; via = 'claude';
230
+ }
231
+ if (this._primeros.has(raiz)) return;
232
+ const propias = new Set([env.from, raiz]);
233
+ if (!env.to.some((t) => !propias.has(t) && !String(t).startsWith('libro@'))) return;
234
+ try {
235
+ if (await this.store.kvPutIfAbsent('primer_mensaje', raiz, { ts: iso(), via })) await this._evento('first_message', raiz, { via });
236
+ this._primeros.add(raiz);
237
+ } catch (e) { this.log(`first_message ${raiz} no registrado: ${e.message}`); }
238
+ }
156
239
  isSystem(local) { return ['postmaster', 'libro', 'verifica', 'tareas'].includes(local); }
240
+ // ¿Podría alguien registrarse HOY con este nombre? Mismas reglas que registerAgent, sin registrar nada.
241
+ async nombreDisponible(local) {
242
+ if (!local || Estafeta.RESERVED.has(local) || local.length < (this.policy.min_name_length || 0)) return false;
243
+ return !(await this.store.getAgent(local));
244
+ }
157
245
  static RESERVED = new Set(['postmaster', 'libro', 'verifica', 'tareas', 'casa', 'admin', 'root', 'abuse', 'security', 'hostmaster', 'noreply', 'no-reply', 'support', 'estafeta', 'nyx5', 'indice']);
158
246
 
159
247
  // ---------- servicio de registro ----------
@@ -183,7 +271,7 @@ export class Estafeta {
183
271
  // Opt-in: nadie aparece en el directorio (ni, por lo tanto, en el índice federado que lo rastrea)
184
272
  // sin haberlo pedido con `capabilities.listed: true`. El default es no figurar. El lookup directo
185
273
  // por dirección (`/agents/:local`) sigue disponible: no listar no es esconder a quien ya te conoce.
186
- let out = cards.filter((c) => c.capabilities?.listed === true);
274
+ let out = cards.filter((c) => c.visibility !== 'secret' && (c.visibility === 'public' || c.capabilities?.listed === true));
187
275
  if (capability) out = out.filter((c) => c.capabilities?.[capability]);
188
276
  if (accepts) out = out.filter((c) => c.capabilities?.accepts?.includes(accepts));
189
277
  if (q) { const needle = String(q).toLowerCase(); out = out.filter((c) => c.address.includes(needle) || JSON.stringify(c.capabilities).toLowerCase().includes(needle)); }
@@ -223,11 +311,30 @@ export class Estafeta {
223
311
  }
224
312
 
225
313
  // ---------- agentes ----------
226
- async registerAgent({ local, sig, enc = null, capabilities = {}, inbox = { policy: 'open' }, webhook = null, notify_email = null, valid_until = null, delegation = null, welcome = null }) {
314
+ static VISIBILIDADES = ['public', 'private', 'secret'];
315
+ async registerAgent({ local, sig, enc = null, capabilities = {}, inbox = { policy: 'open' }, wallet = null, wallets = null, webhook = null, notify_email = null, valid_until = null, delegation = null, welcome = null, custody = null, group = null, profile = undefined, visibility = undefined }) {
316
+ // Visibilidad (NX-202): public = en el directorio; private = existe, lo encuentra quien sabe la
317
+ // dirección (lo de siempre; es el defecto); secret = a quien no está en su lista se le responde
318
+ // exactamente lo que a un nombre inexistente. Se conserva entre re-certificaciones.
319
+ if (visibility !== undefined && visibility !== null && !Estafeta.VISIBILIDADES.includes(visibility)) throw Object.assign(new Error(`visibility must be one of ${Estafeta.VISIBILIDADES.join(', ')}`), { status: 400 });
320
+ // La ficha viaja validada o no viaja: una clave ajena o un link que no es https la rechazan.
321
+ let perfil;
322
+ if (profile !== undefined) { const v = validarPerfil(profile); if (v.error) throw Object.assign(new Error(v.error), { status: 400 }); perfil = v.perfil ? { ...v.perfil, updated: iso() } : null; }
323
+ // Grupos (13-sep-2026): g.<nombre> es una dirección de grupo. La firma la casa, no tiene llave
324
+ // de cifrado (los miembros publican las suyas) y sólo nace por crearGrupo. Nadie registra un
325
+ // nombre g.* por la puerta normal, así que un grupo nunca puede ser suplantado por un agente.
326
+ if (String(local).startsWith('g.') && !group) throw Object.assign(new Error('names starting with g. are groups: create one with POST /groups'), { status: 409 });
327
+ if (group && !String(local).startsWith('g.')) throw Object.assign(new Error('a group name starts with g.'), { status: 400 });
227
328
  local = String(local).toLowerCase();
228
329
  const address = `${local}@${this.domain}`;
229
330
  parseAddress(address);
230
331
  if (Estafeta.RESERVED.has(local) && !this.isSystem(local)) throw Object.assign(new Error(`name reserved by the protocol: ${local}`), { status: 409 });
332
+ // Nombres de 1 a 3 caracteres: reservados en una casa de registro abierto. Son lo primero
333
+ // que alguien acapara para revender o para suplantar (a@casa se confunde con cualquiera), y
334
+ // un agente que llega no necesita un nombre corto: necesita uno suyo. Los de sistema pasan.
335
+ if (local.length <= this.policy.min_name_length - 1 && !this.isSystem(local) && !delegation) {
336
+ throw Object.assign(new Error(`names shorter than ${this.policy.min_name_length} characters are reserved in this house`), { status: 409 });
337
+ }
231
338
  if (delegation) {
232
339
  // Tarjeta delegada: el agente padre firma { by, address, sig, scope, valid_until }; el dominio la certifica igual.
233
340
  const { local: parentLocal, domain: parentDomain } = parseAddress(delegation.by);
@@ -250,19 +357,45 @@ export class Estafeta {
250
357
  else if (!hs[campo].every((x) => ps[campo].includes(x))) throw Object.assign(new Error(`a subagent cannot widen ${campo} beyond its parent`), { status: 403 });
251
358
  }
252
359
  }
360
+ // Sólo-mensajes no se hereda reescribiendo la delegación (quedaría con una firma que ya no
361
+ // cubre lo que dice): se exige. Un hijo de un subagente de sólo mensajes, también lo es.
362
+ if (ps.messages_only && hs.messages_only !== true) throw Object.assign(new Error('a subagent of a messages-only address must itself be messages-only'), { status: 403 });
253
363
  delegation = { ...delegation, scope: hs };
254
364
  }
255
365
  valid_until = valid_until || delegation.valid_until || null;
256
366
  }
257
367
  const prev = await this.store.getAgent(local);
368
+ // `<algo>.<agente existente>` es la forma de un subagente: sin la delegación de ese agente no se
369
+ // registra (revisión del 13-sep: `evil.alicia` parecía delegado de alicia sin serlo).
370
+ if (!prev && !delegation && !group && local.includes('.')) {
371
+ const padre = local.slice(local.indexOf('.') + 1);
372
+ if (await this.store.getAgent(padre)) throw Object.assign(new Error(`names of the form <name>.${padre} are subagents of ${padre}@${this.domain}: they need its delegation`), { status: 409 });
373
+ }
258
374
  const previous = [];
259
- if (prev && prev.sig !== sig) previous.push({ sig: prev.sig, until: iso(now() + 7 * 24 * 3600 * 1000) }, ...(prev.previous || []));
375
+ // Una llave revocada no vuelve por la ventana de gracia: si su dueño la revocó fue porque no
376
+ // debía seguir firmando, y los siete días de `previous` la resucitarían.
377
+ if (prev && prev.sig !== sig && !prev.revoked) previous.push({ sig: prev.sig, until: iso(now() + 7 * 24 * 3600 * 1000) }, ...(prev.previous || []));
378
+ // La billetera es PÚBLICA y va en la tarjeta: es sólo la dirección a la que se le cobra. La
379
+ // casa no la controla ni puede mover nada de ella, igual que no controla la llave del agente.
380
+ let billeteras; try { billeteras = x402.validarBilleteras(wallets ?? wallet ?? prev?.wallets ?? prev?.wallet ?? null); }
381
+ catch (e) { throw Object.assign(new Error(e.message), { status: 400 }); }
382
+ // Un servicio con precio en dólares sin billetera a la que cobrarlo es una promesa gratis (NX-301).
383
+ { const e = usdSinBilletera(profile === undefined ? prev?.profile : perfil, billeteras); if (e) throw Object.assign(new Error(e), { status: 400 }); }
260
384
  const card = signObject({
261
385
  nyx5: '1', address, sig, enc,
262
386
  capabilities: { accepts: ['text/plain', 'application/json'], ...capabilities },
263
387
  inbox: { policy: 'open', ...inbox },
388
+ ...(billeteras ? { wallets: billeteras } : {}),
264
389
  valid_from: iso(), valid_until, previous: previous.slice(0, 3),
265
390
  delegation: delegation || undefined,
391
+ group: group || undefined,
392
+ // La ficha se conserva entre re-certificaciones salvo que esta llamada la traiga (o la borre con null).
393
+ profile: profile === undefined ? (prev?.profile || undefined) : (perfil || undefined),
394
+ visibility: visibility === undefined ? (prev?.visibility || undefined) : (visibility || undefined),
395
+ // Quién guarda las llaves de esta dirección. Sólo lo pone la casa (el conector remoto) y se
396
+ // publica: quien le escribe a una dirección cuya llave guarda la casa tiene derecho a saber
397
+ // que la casa puede leer lo que le llega. Se conserva mientras la llave no cambie.
398
+ custody: custody || (prev && prev.sig === sig ? prev.custody : undefined) || undefined,
266
399
  }, this.keys, 'certification');
267
400
  // Alta atómica cuando el nombre es nuevo: dos altas concurrentes del mismo nombre no pueden
268
401
  // pisarse la clave (el perdedor cree tener el nombre y su correo iría al otro), ni cobrar el
@@ -277,12 +410,177 @@ export class Estafeta {
277
410
  await this.store.putAgent(local, { ...card, webhook, notify_email });
278
411
  }
279
412
  const gift = welcome ?? this.libro.welcome;
280
- if (creado && !prev && !this.isSystem(local) && !delegation && gift > 0) await this.libro.topup(address, gift, 'regalo de bienvenida', { agent: address });
413
+ if (creado && !prev && !this.isSystem(local) && !delegation && !group && gift > 0) await this.libro.topup(address, gift, 'regalo de bienvenida', { agent: address });
281
414
  return card;
282
415
  }
416
+
417
+ // ---------- acuse de lectura y presencia (13-sep-2026), los dos opt-in en la tarjeta ----------
418
+ async _acusesDeLectura(who, ids) {
419
+ const historial = await this.store.listMailHistory(who.local, { limit: 1000 });
420
+ for (const id of ids) {
421
+ const m = historial.find((x) => x.envelope?.id === id);
422
+ const e = m?.envelope;
423
+ if (!e || e.type === 'receipt') continue;
424
+ let fromLocal = ''; try { fromLocal = parseAddress(e.from).local; } catch { continue; }
425
+ if (e.from === who.address || this.isSystem(fromLocal)) continue;
426
+ await this._systemSend('postmaster', [e.from], { type: 'receipt', in_reply_to: e.id, thread: e.thread || e.id, content: { media: 'application/nyx5.recibo+json', body: { read_of: e.id, read_by: who.address, read_at: iso() } } });
427
+ }
428
+ }
429
+ // Presencia: «visto por última vez», redondeado a la hora, sólo si la tarjeta declara presence:true.
430
+ // Un dato por hora y por dirección, en memoria por isolate: cuesta una escritura por hora, no una
431
+ // por petición. Sin opt-in no se anota nada: presencia sin consentimiento sería vigilancia.
432
+ _presencia = new Map();
433
+ async _anotarPresencia(who) {
434
+ if (who.record?.capabilities?.presence !== true) return;
435
+ const hora = iso().slice(0, 13) + ':00:00.000Z';
436
+ if (this._presencia.get(who.local) === hora) return;
437
+ this._presencia.set(who.local, hora);
438
+ await this.store.kvPut('presencia', who.local, { last_seen: hora }, now() + 90 * 86_400_000);
439
+ }
440
+ async presenciaDe(local) {
441
+ const rec = await this.store.getAgent(local);
442
+ if (!rec || rec.revoked || rec.capabilities?.presence !== true) return null;
443
+ return (await this.store.kvGet('presencia', local))?.last_seen || null;
444
+ }
445
+
446
+ // ---------- grupos: una dirección que reparte el mismo sobre a cada miembro ----------
447
+ // Nació el 13-sep-2026 del pedido de Nicholas de conversar de a varios (una sala de proyecto con
448
+ // su Claude, el de Basti y el agente de Sigo). Primera versión: miembros de esta casa. Un sobre
449
+ // firmado lleva la dirección del grupo, y otra casa no sabría a quién entregarlo sin reescribirlo.
450
+ static NOMBRE_GRUPO = /^[a-z0-9][a-z0-9._-]{2,40}$/;
451
+ static MAX_MIEMBROS = 50;
452
+ // ¿El buzón de `rec` acepta un mensaje normal de `de`? Sólo abierto o lista blanca cuentan: una
453
+ // estampilla o una prueba de trabajo no se pagan por cada miembro, y un `intro` o un aval no abren
454
+ // un grupo. Es la regla de consentimiento de los grupos (revisión adversarial del 13-sep-2026: sin
455
+ // ella, cualquiera metía a cualquiera en un grupo y le saltaba la lista blanca, incluso al
456
+ // asistente de Sigo, gastándole presupuesto).
457
+ _aceptaDe(rec, de) {
458
+ const inbox = rec?.inbox || { policy: 'open' };
459
+ if (inbox.policy === 'open') return true;
460
+ if (inbox.policy !== 'allowlist') return false;
461
+ let dominio = ''; try { dominio = parseAddress(de).domain; } catch { return false; }
462
+ return (inbox.allowlist || []).some((x) => x === de || x === dominio);
463
+ }
464
+ // `nuevos` son los que entran ahora: cada uno tiene que aceptar ya a quien lo agrega.
465
+ async _miembrosValidos(direcciones, { agregadoPor = null, nuevos = null } = {}) {
466
+ const lista = [];
467
+ for (const raw of direcciones) {
468
+ const dir = String(raw || '').toLowerCase();
469
+ let p; try { p = parseAddress(dir); } catch { return { error: `not an address: ${dir}` }; }
470
+ if (p.domain !== this.domain) return { error: `${dir} is not in this house: groups hold members of ${this.domain} only, for now` };
471
+ const rec = await this.store.getAgent(p.local);
472
+ if (!rec || rec.revoked || (agregadoPor && !await this._visibleA(rec, agregadoPor))) return { error: `${dir} does not exist or is revoked` };
473
+ if (rec.group || this.isSystem(p.local)) return { error: `${dir} cannot be a member (it is a group or a system address)` };
474
+ if (agregadoPor && dir !== agregadoPor && (!nuevos || nuevos.has(dir)) && !this._aceptaDe(rec, agregadoPor)) return { error: `${dir} does not accept messages from you: only someone who already lists you as a contact can be added to a group` };
475
+ if (!lista.includes(dir)) lista.push(dir);
476
+ }
477
+ if (lista.length > Estafeta.MAX_MIEMBROS) return { error: `a group holds at most ${Estafeta.MAX_MIEMBROS} members` };
478
+ return { lista };
479
+ }
480
+ _tarjetaDeGrupo(local, group) {
481
+ return this.registerAgent({
482
+ local, sig: this.keys.sig, group,
483
+ capabilities: { accepts: ['text/plain', 'application/json'], group: true },
484
+ // Sólo quien puede publicar entra al buzón: la política del buzón es la primera puerta y el
485
+ // reparto vuelve a comprobarlo (un `intro` o un aval no abren un grupo).
486
+ inbox: { policy: 'allowlist', allowlist: group.post === 'admins' ? group.admins : group.members },
487
+ });
488
+ }
489
+ async crearGrupo(who, b) {
490
+ const nombre = String(b.name || '').toLowerCase();
491
+ if (!Estafeta.NOMBRE_GRUPO.test(nombre)) return { status: 400, body: { reason: 'a group name has 3 to 41 characters: letters, digits, . _ -' } };
492
+ const local = `g.${nombre}`;
493
+ if (await this.store.getAgent(local)) return { status: 409, body: { reason: `${local}@${this.domain} already exists` } };
494
+ if (!await this.remotoRate.allow(`grupo:${who.address}`)) return { status: 429, body: { reason: 'too many groups; try again in a minute' }, headers: this._retryAfter() };
495
+ const v = await this._miembrosValidos([who.address, ...(Array.isArray(b.members) ? b.members : [])], { agregadoPor: who.address });
496
+ if (v.error) return { status: 400, body: { reason: v.error } };
497
+ const group = { admins: [who.address], members: v.lista, post: b.post === 'admins' ? 'admins' : 'members', max: Estafeta.MAX_MIEMBROS, created: iso() };
498
+ const card = await this._tarjetaDeGrupo(local, group);
499
+ await this._evento('group_created', who.address, { group: card.address, members: group.members.length });
500
+ return { status: 201, body: { address: card.address, group } };
501
+ }
502
+ async miembrosGrupo(who, local) {
503
+ const rec = await this.store.getAgent(local);
504
+ if (!rec?.group || rec.revoked) return { status: 404, body: { reason: 'no such group' } };
505
+ if (!rec.group.members.includes(who.address)) return { status: 403, body: { reason: 'only members see who is in a group' } };
506
+ return { status: 200, body: { address: `${local}@${this.domain}`, group: rec.group } };
507
+ }
508
+ async editarGrupo(who, local, b) {
509
+ const rec = await this.store.getAgent(local);
510
+ if (!rec?.group || rec.revoked) return { status: 404, body: { reason: 'no such group' } };
511
+ const g = rec.group;
512
+ const admin = g.admins.includes(who.address);
513
+ const add = Array.isArray(b.add) ? b.add.map((x) => String(x).toLowerCase()) : [];
514
+ const remove = Array.isArray(b.remove) ? b.remove.map((x) => String(x).toLowerCase()) : [];
515
+ const admins = Array.isArray(b.admins) ? b.admins.map((x) => String(x).toLowerCase()) : [];
516
+ // Un miembro sólo puede irse; agregar, quitar a otros y nombrar admins es de los admins.
517
+ if (!admin && (add.length || admins.length || remove.some((x) => x !== who.address))) return { status: 403, body: { reason: 'only an admin changes the members of a group; a member can only leave' } };
518
+ if (!admin && !g.members.includes(who.address)) return { status: 403, body: { reason: 'not a member of this group' } };
519
+ const v = await this._miembrosValidos([...g.members.filter((m) => !remove.includes(m)), ...add], { agregadoPor: who.address, nuevos: new Set(add.filter((a) => !g.members.includes(a))) });
520
+ if (v.error) return { status: 400, body: { reason: v.error } };
521
+ const nuevosAdmins = [...new Set([...g.admins.filter((a) => v.lista.includes(a)), ...admins.filter((a) => v.lista.includes(a))])];
522
+ if (!nuevosAdmins.length) return { status: 409, body: { reason: 'a group needs at least one admin: name another admin before leaving' } };
523
+ const group = { ...g, members: v.lista, admins: nuevosAdmins, updated: iso() };
524
+ const card = await this._tarjetaDeGrupo(local, group);
525
+ await this._evento('group_changed', who.address, { group: card.address, members: group.members.length });
526
+ return { status: 200, body: { address: card.address, group } };
527
+ }
283
528
  // El nombre local viaja como clave al almacenamiento: se valida SIEMPRE aquí, no se confía en
284
529
  // que el store lo sanee. Sin esto, `GET /agents/..%2Fdomain` leía el archivo del dominio y
285
530
  // devolvía la clave PRIVADA de la casa (compromiso total).
531
+ _retryAfter() { return { 'retry-after': String(this.rate.retryAfter()) }; }
532
+
533
+ // ---------- visibilidad (NX-202): a quién se le confirma que un agente secreto existe ----------
534
+ // Lo ve: él mismo, quien lo delegó, sus propios delegados, y quien está en su lista (dirección o
535
+ // dominio). Nadie más: ni autenticado, ni la app, ni otra casa que no diga para quién pregunta.
536
+ // «Sus delegados» se comprueba leyendo la tarjeta del que pregunta: el sufijo del nombre no basta
537
+ // (revisión del 13-sep: `evil.alicia` registrado por un extraño veía a alicia).
538
+ async _visibleA(rec, quien) {
539
+ if (!rec || rec.visibility !== 'secret') return true;
540
+ if (!quien) return false;
541
+ quien = String(quien).toLowerCase();
542
+ if (quien === rec.address || quien === rec.delegation?.by) return true;
543
+ let q; try { q = parseAddress(quien); } catch { q = null; }
544
+ if (q && q.domain === this.domain) {
545
+ const del = await this.store.getAgent(q.local);
546
+ if (del && !del.revoked && del.delegation?.by === rec.address) return true;
547
+ }
548
+ const dominio = q ? q.domain : (quien.includes('@') ? quien.slice(quien.lastIndexOf('@') + 1) : '');
549
+ return (rec.inbox?.allowlist || []).some((x) => String(x).toLowerCase() === quien || String(x).toLowerCase() === dominio);
550
+ }
551
+ // Si el que selló (NX-601) puede nombrarse en público: uno de esta casa se consulta en vivo
552
+ // (su visibilidad puede haber cambiado); uno de otra casa, por lo que se supo al sellar.
553
+ async _declaranteVisible(doc) {
554
+ let p; try { p = parseAddress(doc.by); } catch { return false; }
555
+ if (p.domain !== this.domain) return !doc.secret;
556
+ return this._visibleA(await this.store.getAgent(p.local), null);
557
+ }
558
+ // Quién pregunta por una tarjeta: un agente de esta casa autenticado, o una casa ajena que firma
559
+ // «lo pido para bob@su-casa» con su llave de dominio. Se evalúa SIEMPRE, exista o no lo que se
560
+ // pide, y antes de tocar el almacén (revisión del 13-sep: evaluarlo sólo cuando había tarjeta
561
+ // convertía un 401, o la latencia del fetch a la otra casa, en un oráculo de existencia). Un
562
+ // token inválido cuenta como nadie, no como error. Una petición que trae credencial cuesta (una
563
+ // firma o una tarjeta de dominio ajena): se limita por IP igual que /resolve.
564
+ async _quienPregunta(rx, path) {
565
+ const auth = rx.headers.authorization?.startsWith('Nyx5 ');
566
+ const h = rx.headers['x-nyx5-for'];
567
+ if (!auth && !h) return null;
568
+ if (!await this.rate.allow(`resolve:${rx.ip || 'x'}`)) throw Object.assign(new Error('too many requests'), { status: 429, headers: this._retryAfter() });
569
+ if (auth) { try { return (await this._authenticate(rx, path)).address; } catch { return null; } }
570
+ const r = Object.fromEntries(String(h).replace(/^nyx51\s*/, '').split(';').map((p) => p.trim().split('=').map((x) => x.trim())).filter((p) => p[0]));
571
+ try {
572
+ const { domain } = parseAddress(r.for || '');
573
+ if (domain !== r.domain || domain === this.domain) return null;
574
+ if (Math.abs(now() - Date.parse(r.ts || 0)) > 300_000) return null;
575
+ const dc = await this.resolver.domainCard(domain);
576
+ if (!dc.keys.some((k) => k.sig === r.kid)) return null;
577
+ return verifyBytes(`for:${r.for}:${this.domain}:${r.ts}`, r.sig, r.kid) ? String(r.for).toLowerCase() : null;
578
+ } catch { return null; }
579
+ }
580
+ _firmarPara(addr, dom) {
581
+ const ts = iso();
582
+ return `nyx51 domain=${this.domain}; for=${addr}; ts=${ts}; kid=${this.keys.sig}; sig=${signBytes(`for:${addr}:${dom}:${ts}`, this.keys)}`;
583
+ }
286
584
  static LOCAL = /^[a-z0-9][a-z0-9._-]{0,63}$/;
287
585
  static validLocal(local) { return typeof local === 'string' && Estafeta.LOCAL.test(local); }
288
586
  async agentCard(local) {
@@ -293,6 +591,289 @@ export class Estafeta {
293
591
  return card;
294
592
  }
295
593
 
594
+ // ---------- conector remoto: la bóveda y el agente en proceso ----------
595
+ async llavesDeBoveda(local) {
596
+ if (!this.boveda || !this.store.kvGet) return null;
597
+ const v = await this.store.kvGet('boveda', local);
598
+ if (!v) return null;
599
+ try { return this.boveda.abrir(v.sellado, local); }
600
+ catch (e) { this.log(`bóveda: no abre la llave de ${local}: ${e.message}`); return null; }
601
+ }
602
+ // El agente de un subagente delegado, armado en este proceso con la llave de la bóveda. Firma y
603
+ // pasa por /outbound como cualquiera: no hay atajo que se salte la política ni el alcance.
604
+ async agenteDeBoveda(local) {
605
+ await this.init();
606
+ const keys = await this.llavesDeBoveda(local);
607
+ if (!keys) return null;
608
+ return new Agent({ address: `${local}@${this.domain}`, keys, estafeta: this.publicUrl, resolver: this.resolver, fetchImpl: this.fetchPropio });
609
+ }
610
+
611
+ // ---------- tiempo real e historial ----------
612
+ // Espera el primer sobre PENDIENTE que cumpla el filtro, preguntando cada segundo. Pendiente y no
613
+ // "nuevo": si la respuesta llegó entre que uno mandó y empezó a esperar, se entrega igual.
614
+ async esperarCorreo(local, { from = null, thread = null, since = null, project = null, timeoutMs = 25_000, everyMs = 1000 } = {}) {
615
+ const hasta = now() + Math.max(0, timeoutMs);
616
+ const cumple = (m) => {
617
+ const e = m.envelope || {};
618
+ const de = e.extensions?.['urn:nyx5:ext:email']?.from || e.from;
619
+ return (!from || de === from) && (!thread || e.thread === thread || e.id === thread) && (!project || proyectoDe(e) === project);
620
+ };
621
+ for (;;) {
622
+ const lista = this.store.listMailSince ? await this.store.listMailSince(local, since) : (await this.store.listMail(local)).filter((m) => !since || String(m.received) > since);
623
+ const m = lista.find(cumple);
624
+ if (m) return m;
625
+ if (now() >= hasta) return null;
626
+ await new Promise((r) => setTimeout(r, Math.min(everyMs, Math.max(1, hasta - now()))));
627
+ }
628
+ }
629
+ // La conversación con una dirección (o, sin dirección, la lista de conversaciones): lo recibido,
630
+ // incluido lo confirmado, y lo enviado, que ahora queda en la bandeja con su sobre.
631
+ async conversacion(local, { con = null, limit = 50, project = null } = {}) {
632
+ const propia = `${local}@${this.domain}`;
633
+ const delProyecto = (x) => !project || proyectoDe(x.envelope) === project;
634
+ const recibidos = (await this.store.listMailHistory(local, { limit: 1000 })).map((m) => ({ id: m.envelope.id, dir: 'in', at: m.received, acked: !!m.acked, envelope: m.envelope }));
635
+ const enviados = [];
636
+ const vistos = new Set();
637
+ for (const e of await this.store.listOutbox(local)) {
638
+ if (!e.envelope || vistos.has(e.envelope.id)) continue;
639
+ vistos.add(e.envelope.id);
640
+ // Lo que uno se manda a sí mismo (un recordatorio) ya aparece como recibido: una sola vez.
641
+ if (e.envelope.to.includes(propia)) continue;
642
+ enviados.push({ id: e.envelope.id, dir: 'out', at: e.envelope.created, status: e.status, envelope: e.envelope });
643
+ }
644
+ // Lo enviado sabe si fue leído: el acuse de lectura llegó como recibo de postmaster@ con read_of.
645
+ // Sólo vale si lo firmó el postmaster de la casa de quien leyó (revisión del 13-sep-2026: antes
646
+ // cualquier sobre `receipt` con read_of marcaba leído lo que fuera) y si ese lector era
647
+ // destinatario del sobre, o miembro de su casa cuando el sobre iba a un grupo.
648
+ const leidos = new Map();
649
+ for (const r of recibidos) {
650
+ const b = r.envelope?.content?.body;
651
+ if (r.envelope?.type !== 'receipt' || typeof b?.read_of !== 'string' || typeof b?.read_by !== 'string') continue;
652
+ let lector; try { lector = parseAddress(b.read_by); } catch { continue; }
653
+ if (r.envelope.from !== `postmaster@${lector.domain}`) continue;
654
+ leidos.set(b.read_of, { by: b.read_by, at: b.read_at });
655
+ }
656
+ for (const e of enviados) {
657
+ const l = leidos.get(e.id); if (!l) continue;
658
+ const destinos = e.envelope.to.map((t) => String(t).toLowerCase());
659
+ const lectorDom = parseAddress(l.by).domain;
660
+ if (destinos.includes(l.by) || destinos.some((t) => t.startsWith('g.') && parseAddress(t).domain === lectorDom)) e.read = l;
661
+ }
662
+ const todos = [...recibidos, ...enviados].filter(delProyecto);
663
+ // Un mensaje de grupo se conversa con el GRUPO, no con quien lo escribió.
664
+ const grupo = (x) => x.envelope.to.find((t) => { try { const p = parseAddress(t); return p.domain === this.domain && p.local.startsWith('g.'); } catch { return false; } });
665
+ const contraparte = (x) => grupo(x) || (x.dir === 'in' ? (x.envelope.extensions?.['urn:nyx5:ext:email']?.from || x.envelope.from) : (x.envelope.to.find((t) => t !== propia) || x.envelope.to[0]));
666
+ const orden = (a, b) => String(a.at).localeCompare(String(b.at));
667
+ if (con) return todos.filter((x) => contraparte(x) === con || (x.dir === 'out' && x.envelope.to.includes(con))).sort(orden).slice(-limit);
668
+ const mapa = new Map();
669
+ for (const x of todos) {
670
+ const c = contraparte(x);
671
+ const r = mapa.get(c) || { with: c, count: 0, pending: 0, last_at: '', last_dir: null, last_id: null };
672
+ r.count++;
673
+ if (x.dir === 'in' && !x.acked) r.pending++;
674
+ if (String(x.at) > r.last_at) { r.last_at = String(x.at); r.last_dir = x.dir; r.last_id = x.id; }
675
+ mapa.set(c, r);
676
+ }
677
+ return [...mapa.values()].sort((a, b) => b.last_at.localeCompare(a.last_at));
678
+ }
679
+ // Los subagentes que un dueño delegó (sus Claude conectados), con lo necesario para revocarlos.
680
+ async delegados(local) {
681
+ const padre = `${local}@${this.domain}`;
682
+ const out = [];
683
+ for (const l of await this.store.listAgents()) {
684
+ if (!l.endsWith(`.${local}`)) continue;
685
+ const r = await this.store.getAgent(l);
686
+ if (r?.delegation?.by !== padre) continue;
687
+ out.push({ address: r.address, sig: r.sig, enc: r.enc || null, since: r.valid_from || null, valid_until: r.valid_until || null, revoked: r.revoked || null, custody: r.custody || null, scope: r.delegation.scope || null, inbox: r.inbox || null });
688
+ }
689
+ return out;
690
+ }
691
+ // Revocar es definitivo para esa llave: la tarjeta vence ahora (el resolver deja de aceptarla),
692
+ // la llave sale de la bóveda y ningún token vuelve a servir, porque cada uno se valida contra esto.
693
+ async revocarDelegado(subLocal, por) {
694
+ const rec = await this.store.getAgent(subLocal);
695
+ if (!rec?.delegation) throw Object.assign(new Error('no such subagent'), { status: 404 });
696
+ const { certification: _c, webhook, notify_email, ...cuerpo } = rec;
697
+ const card = signObject({ ...cuerpo, valid_until: iso(), revoked: { at: iso(), by: por } }, this.keys, 'certification');
698
+ await this.store.putAgent(subLocal, { ...card, webhook, notify_email });
699
+ await this.store.kvDelete?.('boveda', subLocal);
700
+ await this.store.kvDelete?.('pendiente', subLocal);
701
+ await this._evento('delegation_revoked', rec.address, { by: por });
702
+ return card;
703
+ }
704
+ // ---------- invitaciones de contacto ----------
705
+ // Quien invita (su llave raíz, o la casa en su nombre) genera un link. El invitado conecta su
706
+ // Claude con la URL que trae el link, la pantalla llega prellenada, y los dos Claude quedan como
707
+ // contactos: cada uno en la lista del otro. Un solo uso, siete días.
708
+ async crearInvitacion(rx) {
709
+ const b = rx.body || {};
710
+ let inviter;
711
+ if ((rx.headers.authorization || '') === `Bearer ${this.adminToken}`) {
712
+ inviter = String(b.inviter || '').toLowerCase();
713
+ let local, domain;
714
+ try { ({ local, domain } = parseAddress(inviter)); } catch { return { status: 400, body: { reason: 'inviter must be an address' } }; }
715
+ const rec = domain === this.domain ? await this.store.getAgent(local) : null;
716
+ if (!rec || rec.delegation) return { status: 404, body: { reason: 'the inviter must be an own (not delegated) address of this house' } };
717
+ } else {
718
+ const who = await this._authenticate(rx, rx.path);
719
+ if (who.record.delegation) return { status: 403, body: { reason: 'a delegated address cannot invite; use your own address' } };
720
+ inviter = who.address;
721
+ }
722
+ if (!await this.remotoRate.allow(`invitar:${inviter}`)) return { status: 429, body: { reason: 'too many invitations; try again in a minute' }, headers: this._retryAfter() };
723
+ const suClaude = await this.store.getAgent(`claude.${parseAddress(inviter).local}`);
724
+ const viva = suClaude && !suClaude.revoked && suClaude.delegation?.by === inviter && (!suClaude.valid_until || Date.parse(suClaude.valid_until) > now());
725
+ const hint = typeof b.name === 'string' ? b.name.toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 30) : '';
726
+ // Contactos extra: sólo direcciones PROPIAS de quien invita (sus delegados vigentes). Así Nicholas
727
+ // invita a Basti y lo deja conectado con su agente de Sigo, no sólo consigo mismo.
728
+ const contactos = [];
729
+ for (const c of (Array.isArray(b.contacts) ? b.contacts : []).slice(0, 10)) {
730
+ const dir = String(c || '').toLowerCase();
731
+ let pc; try { pc = parseAddress(dir); } catch { return { status: 400, body: { reason: `not an address: ${dir}` } }; }
732
+ const rc = pc.domain === this.domain ? await this.store.getAgent(pc.local) : null;
733
+ if (!rc || rc.revoked || rc.delegation?.by !== inviter) return { status: 400, body: { reason: `contacts must be your own delegated addresses: ${dir}` } };
734
+ contactos.push(dir);
735
+ }
736
+ const code = uuid().replace(/-/g, '');
737
+ const greet = typeof b.greet === 'string' && [inviter, ...contactos].includes(b.greet.toLowerCase()) ? b.greet.toLowerCase() : null;
738
+ // `source`: por qué canal se va a mandar el enlace (whatsapp, correo...). Atribución del embudo;
739
+ // nunca entra a una tarjeta. Al abrir el enlace, `?source=` puede precisarla.
740
+ const inv = { code, inviter, inviter_claude: viva ? suClaude.address : null, contacts: contactos, greet, name_hint: hint || null, source: Estafeta.fuenteLimpia(b.source), created: iso(), expires: iso(now() + 7 * 86_400_000) };
741
+ await this.store.kvPut('invitacion', code, inv, now() + 7 * 86_400_000);
742
+ await this._evento('invitation_created', inviter, { with_claude: !!inv.inviter_claude });
743
+ return { status: 201, body: { link: `${this.publicUrl}/i/${code}`, connector_url: `${this.publicUrl}/mcp/i/${code}`, inviter, inviter_claude: inv.inviter_claude, contacts: contactos, expires: inv.expires } };
744
+ }
745
+ async _paginaInvitacion(code, fuente = null, ip = null) {
746
+ const inv = await this.store.kvGet('invitacion', code);
747
+ const datos = inv && !inv.used_by
748
+ ? { code, inviter: inv.inviter, inviter_claude: inv.inviter_claude, contacts: inv.contacts || [], greet: inv.greet || null, name_hint: inv.name_hint, connector_url: `${this.publicUrl}/mcp/i/${code}` }
749
+ : { error: inv ? 'used' : 'unknown' };
750
+ // Embudo, etapa 1: el enlace se abrió (una invitación viva). Cuenta visitas, no personas: una
751
+ // vista previa de WhatsApp o un bot también abren. Actor = quien invitó; el invitado aún no existe.
752
+ // Una visita por código, canal, IP y hora: la revisión del 14-sep inundó el embudo con 80 GET en
753
+ // 43 ms. Otro canal (`?source=`) sí cuenta: es otra puerta, no la misma vista previa repetida.
754
+ const canal = Estafeta.fuenteLimpia(fuente) || inv?.source || '';
755
+ if (!datos.error && await this.store.kvPutIfAbsent?.('open_invite', `${code}:${canal}:${ip || 'x'}:${iso().slice(0, 13)}`, 1, now() + 2 * 3600_000)) await this._evento('open_invite', inv.inviter, { code, source: Estafeta.fuenteLimpia(fuente) || inv.source || null });
756
+ const html = APP_HTML.replace('<!--OAUTH-->', `<script>window.NYX5_INVITE=${JSON.stringify(datos).replace(/</g, '\\u003c')}</script>`);
757
+ return { status: inv ? 200 : 404, contentType: 'text/html; charset=utf-8', body: html };
758
+ }
759
+ // ---------- asistentes: alta, conocimiento, config, pausa y estado ----------
760
+ // Qué se puede configurar y con qué límites. Un modelo sin precio conocido se rechaza: el tope
761
+ // mensual se calcula con ese precio (PRECIOS en src/correo/asistente.js), y sin él mentiría.
762
+ _configAsistente(c = {}, base = {}) {
763
+ const model = c.model ?? base.model ?? 'claude-opus-5';
764
+ if (!PRECIOS[model]) return { error: `unknown model ${model}; this house knows the price of: ${Object.keys(PRECIOS).join(', ')}` };
765
+ const effort = c.effort ?? base.effort ?? 'medium';
766
+ if (!['low', 'medium', 'high', 'xhigh', 'max'].includes(effort)) return { error: 'effort must be low, medium, high, xhigh or max' };
767
+ const max_tokens = Number(c.max_tokens ?? base.max_tokens ?? 8000);
768
+ if (!Number.isInteger(max_tokens) || max_tokens < 256 || max_tokens > 64000) return { error: 'max_tokens must be an integer between 256 and 64000' };
769
+ const budget_usd = Number(c.budget_usd ?? base.budget_usd ?? 30);
770
+ if (!(budget_usd > 0 && budget_usd <= 1000)) return { error: 'budget_usd must be above 0 and at most 1000' };
771
+ // seal: cada respuesta termina con su sha256, para sellarla en la notaría (qa@, NX-606).
772
+ const seal = c.seal ?? base.seal ?? false;
773
+ if (typeof seal !== 'boolean') return { error: 'seal must be true or false' };
774
+ return { model, effort, max_tokens, budget_usd, seal };
775
+ }
776
+ async _adminAsistente(rx, local, accion) {
777
+ const b = rx.body || {};
778
+ const indice = async () => (await this.store.kvGet('asistente', '_indice')) || [];
779
+ if (rx.method === 'POST' && !local && !accion) {
780
+ if (!this.boveda) return { status: 503, body: { reason: 'this house has no vault key' } };
781
+ const l = String(b.local || '').toLowerCase();
782
+ const rec = Estafeta.validLocal(l) ? await this.store.getAgent(l) : null;
783
+ if (!rec?.delegation || rec.revoked) return { status: 404, body: { reason: 'an assistant must be an existing delegated address' } };
784
+ if (rec.delegation.scope?.messages_only !== true) return { status: 400, body: { reason: 'an assistant must be messages-only: it answers, it does not move money' } };
785
+ const k = b.keys || {};
786
+ if (k.sig !== rec.sig || !k.sigPriv || !k.enc || !k.encPriv || k.enc !== rec.enc) return { status: 400, body: { reason: 'keys do not match the card of that address' } };
787
+ const v = this._configAsistente(b.config || {});
788
+ if (v.error) return { status: 400, body: { reason: v.error } };
789
+ await this.store.kvPut('boveda', l, { sellado: this.boveda.sellar({ sig: k.sig, sigPriv: k.sigPriv, enc: k.enc, encPriv: k.encPriv }, l), root: rec.delegation.by, since: iso() });
790
+ // La tarjeta declara que la casa guarda su llave, igual que la de un Claude conectado.
791
+ const { certification: _c, webhook, notify_email, ...cuerpo } = rec;
792
+ const card = signObject({ ...cuerpo, custody: { keys: 'house', via: 'assistant', since: iso() } }, this.keys, 'certification');
793
+ await this.store.putAgent(l, { ...card, webhook, notify_email });
794
+ const c = b.config || {};
795
+ const cfg = { local: l, owner: rec.delegation.by, ...v, persona: String(c.persona || ''), enabled: true, created: iso() };
796
+ await this.store.kvPut('asistente', l, cfg);
797
+ const i = await indice();
798
+ if (!i.includes(l)) await this.store.kvPut('asistente', '_indice', [...i, l]);
799
+ await this._evento('assistant_created', card.address, { budget_usd: cfg.budget_usd });
800
+ return { status: 201, body: { address: card.address, custody: card.custody, config: { ...cfg, persona: `${cfg.persona.length} chars` } } };
801
+ }
802
+ const cfg = local ? await this.store.kvGet('asistente', local) : null;
803
+ if (!cfg) return { status: 404, body: { reason: 'no such assistant' } };
804
+ if (rx.method === 'PUT' && accion === 'knowledge') {
805
+ const texto = typeof b.texto === 'string' ? b.texto : '';
806
+ if (!texto || Buffer.byteLength(texto) > 1_500_000) return { status: 400, body: { reason: 'knowledge must be text up to 1.5 MB' } };
807
+ await this.store.kvPut('asistente-conocimiento', local, { texto, updated: iso() });
808
+ return { status: 200, body: { bytes: Buffer.byteLength(texto), updated: iso() } };
809
+ }
810
+ // Cambiar modelo, esfuerzo, tope o persona sin volver a dar de alta (11-sep-2026: Nicholas pidió
811
+ // un modelo más barato para el agente de Sigo). Lo que no se manda, queda como estaba.
812
+ if (rx.method === 'PUT' && accion === 'config') {
813
+ const v = this._configAsistente(b, cfg);
814
+ if (v.error) return { status: 400, body: { reason: v.error } };
815
+ const nueva = { ...cfg, ...v, ...(typeof b.persona === 'string' ? { persona: b.persona } : {}), updated: iso() };
816
+ await this.store.kvPut('asistente', local, nueva);
817
+ return { status: 200, body: { model: nueva.model, effort: nueva.effort, max_tokens: nueva.max_tokens, budget_usd: nueva.budget_usd, seal: nueva.seal === true } };
818
+ }
819
+ if (rx.method === 'POST' && (accion === 'pause' || accion === 'resume')) {
820
+ await this.store.kvPut('asistente', local, { ...cfg, enabled: accion === 'resume' });
821
+ return { status: 200, body: { enabled: accion === 'resume' } };
822
+ }
823
+ if (rx.method === 'GET' && !accion) {
824
+ const mes = new Date().toISOString().slice(0, 7);
825
+ const gasto = (await this.store.kvGet('asistente-gasto', `${local}:${mes}`)) || { usd: 0, llamadas: 0 };
826
+ const con = await this.store.kvGet('asistente-conocimiento', local);
827
+ return { status: 200, body: { address: `${local}@${this.domain}`, enabled: cfg.enabled, model: cfg.model, effort: cfg.effort, budget_usd: cfg.budget_usd, month: mes, spent_usd: Math.round(gasto.usd * 10000) / 10000, calls: gasto.llamadas, knowledge_bytes: con ? Buffer.byteLength(con.texto) : 0, knowledge_updated: con?.updated || null, api_key: !!this.asistente, pending: (await this.store.listMail(local)).length } };
828
+ }
829
+ return { status: 405, body: { reason: 'method not allowed here' } };
830
+ }
831
+
832
+ // Suma direcciones a la lista de quién puede escribirle a una dirección (la re-certifica la casa).
833
+ // Sólo agrega, y sólo si esa dirección ya filtra por lista: una abierta sigue abierta.
834
+ async agregarContactos(local, direcciones) {
835
+ const rec = await this.store.getAgent(local);
836
+ if (!rec || rec.revoked || rec.inbox?.policy !== 'allowlist') return null;
837
+ const lista = [...new Set([...(rec.inbox.allowlist || []), ...direcciones])];
838
+ const { certification: _c, webhook, notify_email, ...cuerpo } = rec;
839
+ const card = signObject({ ...cuerpo, inbox: { ...rec.inbox, allowlist: lista } }, this.keys, 'certification');
840
+ await this.store.putAgent(local, { ...card, webhook, notify_email });
841
+ return card;
842
+ }
843
+ // Conecta dos direcciones de esta casa en los DOS sentidos. Nació el 11-sep-2026: Nicholas pidió
844
+ // que su Claude del teléfono y el de Basti quedaran como contactos sin que Basti volviera a
845
+ // conectar el suyo (el plan gratis de Claude admite un solo conector). Sólo la casa lo hace, y
846
+ // revisa los dos lados ANTES de escribir: nunca queda una conexión en un solo sentido.
847
+ async conectarContactos(a, b) {
848
+ const dirs = [a, b].map((x) => String(x || '').toLowerCase());
849
+ let pa, pb; try { pa = parseAddress(dirs[0]); pb = parseAddress(dirs[1]); } catch { return { status: 400, body: { reason: 'two addresses of this house are needed' } }; }
850
+ if (pa.domain !== this.domain || pb.domain !== this.domain) return { status: 400, body: { reason: `both addresses must belong to ${this.domain}` } };
851
+ if (dirs[0] === dirs[1]) return { status: 400, body: { reason: 'an address is already its own contact' } };
852
+ for (const l of [pa.local, pb.local]) {
853
+ const rec = await this.store.getAgent(l);
854
+ if (!rec || rec.revoked || rec.inbox?.policy !== 'allowlist') return { status: 409, body: { reason: `${l}@${this.domain} does not exist, is revoked, or does not filter by list` } };
855
+ }
856
+ await this.agregarContactos(pa.local, [dirs[1]]);
857
+ await this.agregarContactos(pb.local, [dirs[0]]);
858
+ await this._evento('contacts_connected', dirs[0], { with: dirs[1] });
859
+ return { status: 200, body: { connected: dirs } };
860
+ }
861
+
862
+ // Rutas del conector que un cliente MCP pide con CORS: descubrimiento, registro, token y /mcp.
863
+ async _rutaRemota(rx) {
864
+ const p = rx.path;
865
+ if (rx.method === 'GET' && p.startsWith('/.well-known/oauth-protected-resource')) {
866
+ const ruta = p.slice('/.well-known/oauth-protected-resource'.length) || '/mcp';
867
+ if (ruta === '/mcp' || oauth.RUTA_INVITACION.test(ruta)) return { status: 200, body: oauth.metadatosRecurso(this, ruta) };
868
+ return null;
869
+ }
870
+ if (rx.method === 'GET' && p === '/.well-known/oauth-authorization-server') return { status: 200, body: oauth.metadatosServidor(this) };
871
+ if (rx.method === 'POST' && p === '/oauth/register') return oauth.registrar(this, rx.body, rx.ip);
872
+ if (rx.method === 'POST' && p === '/oauth/token') return oauth.token(this, rx.body);
873
+ if (p === '/mcp' || oauth.RUTA_INVITACION.test(p)) return atenderMcp(this, rx);
874
+ return null;
875
+ }
876
+
296
877
  // ---------- autenticación de agentes propios ----------
297
878
  // Authorization: Nyx5 <b64u(canonical({address,ts,nonce,method,path,host}))>.<firma Ed25519>
298
879
  // `host` amarra el token a ESTA estafeta: el mismo header no sirve contra otra casa.
@@ -313,23 +894,43 @@ export class Estafeta {
313
894
  if (!allowForeign) throw Object.assign(new Error('the agent does not belong to this domain'), { status: 401 });
314
895
  try { rec = await this.resolver.agentCard(claims.address); } catch (e) { throw Object.assign(new Error(`foreign agent could not be verified: ${e.message}`), { status: 401 }); }
315
896
  }
897
+ if (rec.revoked) throw Object.assign(new Error('this address was revoked by its owner'), { status: 401 });
898
+ if (rec.valid_until && Date.parse(rec.valid_until) <= now()) throw Object.assign(new Error('this address expired'), { status: 401 });
316
899
  if (Math.abs(now() - Date.parse(claims.ts)) > 300_000) throw Object.assign(new Error('token expired (5 minute window)'), { status: 401 });
317
900
  if (claims.method !== rx.method || claims.path !== path) throw Object.assign(new Error('token does not match this request'), { status: 401 });
318
901
  if (claims.host !== this.authHost) throw Object.assign(new Error(`token issued for another house (host ${claims.host || 'missing'}, expected ${this.authHost})`), { status: 401 });
319
902
  if (!verifyBytes(canonical(claims), m[2], rec.sig)) throw Object.assign(new Error('invalid token signature'), { status: 401 });
320
903
  if (!await this.store.useNonce(`${claims.address}:${claims.nonce}`, now())) throw Object.assign(new Error('nonce reutilizado'), { status: 401 });
321
- return { local, address: claims.address, record: rec };
904
+ const who = { local, address: claims.address, record: rec };
905
+ if (domain === this.domain) this._anotarPresencia(who).catch((e) => this.log(`presencia: ${e.message}`));
906
+ return who;
322
907
  }
323
908
 
324
909
  // ---------- salida: el agente entrega un sobre a su estafeta ----------
910
+ // Los límites de un delegado (types, to_domains, sólo mensajes) valen por DONDE entre el sobre.
911
+ // Defecto real (11-sep-2026, revisión antes de publicar el botón de tokens): sólo se aplicaban en
912
+ // /outbound, y un subagente de sólo mensajes pagaba entregando su sobre firmado directo a /inbound,
913
+ // que es público porque por ahí entra la federación. Ahora los aplican las dos puertas y el Libro.
914
+ _limiteDeAlcance(env, scope) {
915
+ if (!scope) return null;
916
+ if (scope.types?.length && !scope.types.includes(env.type)) return `agente delegado: solo puede enviar type ${scope.types.join('|')}`;
917
+ if (scope.to_domains?.length && !env.to.every((t) => scope.to_domains.includes(parseAddress(t).domain))) return `agente delegado: solo puede escribir a ${scope.to_domains.join(', ')}`;
918
+ // Sólo mensajes: la dirección que guarda la casa para el Claude de un teléfono no toca el Libro
919
+ // ni paga estampillas. Aunque el puente no le ofrezca esas herramientas, la casa lo niega igual.
920
+ if (scope.messages_only) {
921
+ if (!['message', 'result', 'receipt'].includes(env.type)) return 'this is a messages-only address: it can send message, result or receipt';
922
+ if (env.to.some((t) => parseAddress(t).local === 'libro')) return 'this is a messages-only address: it cannot operate the ledger';
923
+ if (env.stamp) return 'this is a messages-only address: it cannot pay stamps';
924
+ }
925
+ return null;
926
+ }
325
927
  async outbound(env, submitter) {
326
928
  const v = validateEnvelope(env, { maxBytes: this.policy.max_bytes });
327
929
  if (!v.ok) return v;
328
930
  if (env.from !== submitter.address) return { ok: false, code: 403, reason: 'from does not match the authenticated agent' };
329
931
  if (env.signature.kid !== submitter.record.sig || !verifyObject(env, submitter.record.sig)) return { ok: false, code: 403, reason: 'invalid envelope signature' };
330
- const scope = submitter.record.delegation?.scope;
331
- if (scope?.types?.length && !scope.types.includes(env.type)) return { ok: false, code: 403, reason: `agente delegado: solo puede enviar type ${scope.types.join('|')}` };
332
- if (scope?.to_domains?.length && !env.to.every((t) => scope.to_domains.includes(parseAddress(t).domain))) return { ok: false, code: 403, reason: `agente delegado: solo puede escribir a ${scope.to_domains.join(', ')}` };
932
+ const fuera = this._limiteDeAlcance(env, submitter.record.delegation?.scope);
933
+ if (fuera) return { ok: false, code: 403, reason: fuera };
333
934
 
334
935
  const byDomain = new Map();
335
936
  for (const to of env.to) { const { domain } = parseAddress(to); byDomain.set(domain, [...(byDomain.get(domain) || []), to]); }
@@ -343,22 +944,34 @@ export class Estafeta {
343
944
  await this._outbox(job);
344
945
  jobs.push({ job: job.id, domain, to });
345
946
  }
947
+ await this._primerMensaje(submitter, env);
346
948
  return { ok: true, code: 202, id: env.id, jobs };
347
949
  }
348
950
  async _outbox(job, extra = {}) {
349
- await this.store.putOutbox(job.from_local, { job: job.id, id: job.envelope.id, domain: job.domain, to: job.to, status: job.status, attempts: job.attempts, next_attempt: job.next_attempt, updated: iso(), log: job.log, ...extra });
951
+ // El sobre viaja con el estado: la bandeja guardaba sólo cómo iba el envío, y así lo que uno
952
+ // mandó desaparecía de su propio historial en cuanto se entregaba.
953
+ await this.store.putOutbox(job.from_local, { job: job.id, id: job.envelope.id, domain: job.domain, to: job.to, status: job.status, attempts: job.attempts, next_attempt: job.next_attempt, updated: iso(), log: job.log, envelope: job.envelope, ...extra });
350
954
  }
351
955
 
352
956
  // ---------- trabajador de entrega (store-and-forward) ----------
353
957
  // El reclamo es exclusivo (claimDueJobs): dos ticks concurrentes (cron solapado, multi-isolate)
354
958
  // no toman el mismo trabajo. Un trabajo reclamado y no resuelto vuelve a ser reclamable al minuto.
355
- async tick() {
959
+ // `programado`: el reloj de la casa (cron en el edge, intervalo en Node). Un tick disparado por una
960
+ // petición (kick) NO atiende asistentes: después de responder, el edge sólo da 30 segundos.
961
+ async tick({ programado = true } = {}) {
356
962
  await this.init();
357
963
  const due = await this.store.claimDueJobs(now(), 20);
358
964
  for (const job of due) await this._deliver(job);
359
965
  await this.store.pruneNonces?.(now() - 600_000);
966
+ // La purga de lo vencido es limpieza: si falla (una casa cuya base aún no tiene la tabla 0006),
967
+ // no puede tumbar el ciclo que entrega el correo de todos.
968
+ try { await this.store.kvPurge?.(now()); } catch (e) { this.log(`kv: no se pudo purgar lo vencido: ${e.message}`); }
360
969
  if (this.index.enabled) await this._indexCrawlIfDue();
361
970
  if (this.verifica.enabled) await this._verificarPendientes();
971
+ // Sólo desde el reloj programado: recorre la tabla de contratos, y el tick que sigue a cada
972
+ // petición no tiene por qué pagarlo.
973
+ if (programado) await this._liberarVencidos();
974
+ if (programado) { try { await atenderAsistentes(this); } catch (e) { this.log(`asistentes: ${e.message}`); } }
362
975
  await this.flushPushes();
363
976
  }
364
977
  async _deliver(job) {
@@ -466,6 +1079,32 @@ export class Estafeta {
466
1079
  }
467
1080
  }
468
1081
 
1082
+ // ----- escrow que vence (NX-503): entregado, con plazo vencido y ventana de revisión cerrada -----
1083
+ // La casa se lo dice a sí misma como sobre firmado de libro@ a libro@ y entra por `inbound`,
1084
+ // igual que verifica@ (invariante 2): nada toca el Libro por dentro. La op `expire` vuelve a
1085
+ // comprobar las fechas; aquí sólo se elige a quién mirar.
1086
+ async _liberarVencidos() {
1087
+ let contratos;
1088
+ try { contratos = await this.store.libroListContracts(); } catch { return; }
1089
+ const ahora = now();
1090
+ const vencidos = contratos.filter((c) => {
1091
+ if (c.kind !== 'escrow' || c.state !== 'delivered') return false;
1092
+ const plazo = Date.parse(c.terms?.deadline || ''); if (Number.isNaN(plazo)) return false;
1093
+ const entregado = Date.parse(c.history?.filter((h) => h.op === 'deliver').at(-1)?.at || '') || 0;
1094
+ return ahora >= Math.max(plazo, entregado) + this.libro.reviewWindowMs;
1095
+ });
1096
+ for (const c of vencidos.slice(0, 10)) {
1097
+ const env = signObject({
1098
+ nyx5: '1', id: uuid(), from: `libro@${this.domain}`, to: [`libro@${this.domain}`],
1099
+ created: iso(), expires: null, thread: c.id, in_reply_to: null, type: 'task',
1100
+ content: { media: MEDIA.op, body: { op: 'expire', contract: c.id } },
1101
+ }, this.keys);
1102
+ const r = await this.inbound(env);
1103
+ if (!r.ok) this.log(`expire ${c.id}: la casa rechazó su propia liquidación (${r.code}): ${r.reason}`);
1104
+ else { this.log(`expire ${c.id}: liberado al vendedor por ventana vencida`); await this._evento('escrow_expired', `libro@${this.domain}`, { contract: c.id, amount: c.amount }); }
1105
+ }
1106
+ }
1107
+
469
1108
  // Sobres emitidos por los agentes de sistema (postmaster@, libro@), firmados con la clave del dominio.
470
1109
  // Destinatarios locales: directo al buzón. Remotos: por la cola, como cualquier envío.
471
1110
  async _systemSend(fromLocal, to, { type = 'receipt', content, thread = null, in_reply_to = null, deliverAfter = null }) {
@@ -531,7 +1170,10 @@ export class Estafeta {
531
1170
  if (op === 'accept' && c) return this._evento('first_quote', c.buyer, { contract: c.id, kind: c.kind, amount: c.amount, seller: c.seller });
532
1171
  if (op === 'release' && c?.kind === 'escrow') return this._evento('escrow_released', c.seller, { contract: c.id, amount: c.amount, by: env.from, arbitrado: env.from === `verifica@${this.domain}` });
533
1172
  if (op === 'refund' && c) return this._evento('escrow_refunded', c.buyer, { contract: c.id, amount: c.amount, by: env.from });
1173
+ if (op === 'reclaim' && c) return this._evento('escrow_refunded', c.buyer, { contract: c.id, amount: c.amount, by: env.from, reclaimed: true });
1174
+ if (op === 'expire' && c) return this._evento('escrow_released', c.seller, { contract: c.id, amount: c.amount, by: env.from, expired: true });
534
1175
  if (op === 'forfeit' && c) return this._evento('bond_forfeited', c.seller, { contract: c.id, amount: c.amount, by: env.from, vouchee: c.vouchee || null });
1176
+ if (op === 'notarize' && result?.seal && !result.existing) return this._evento('notarized', env.from, { seal: result.seal.id, sha256: result.seal.sha256 });
535
1177
  }
536
1178
 
537
1179
  // Avisos del postmaster al remitente (rebotes y acuses de entrega). Llevan el hash del sobre original.
@@ -562,10 +1204,15 @@ export class Estafeta {
562
1204
 
563
1205
  // Cadena de confianza: dominio emisor -> agente emisor -> firma del sobre
564
1206
  let senderCard;
565
- try { senderCard = await this.resolver.agentCardForKid(env.from, env.signature.kid); }
1207
+ // Se pide «para» el primer destinatario de esta casa: si el remitente es secreto en la suya, su
1208
+ // casa sólo entrega la tarjeta a quien él tiene en su lista (NX-202).
1209
+ const paraQuien = pendientes.map((t) => t.toLowerCase()).find((t) => { try { return parseAddress(t).domain === this.domain; } catch { return false; } }) || null;
1210
+ try { senderCard = await this.resolver.agentCardForKid(env.from, env.signature.kid, { onBehalfOf: paraQuien }); }
566
1211
  catch (e) { return { ok: false, code: e.permanent ? 403 : 421, reason: `could not verify the sender: ${e.message}` }; }
567
1212
  const validKids = Resolver.acceptedKids(senderCard);
568
1213
  if (!validKids.includes(env.signature.kid) || !verifyObject(env, env.signature.kid)) return { ok: false, code: 403, reason: 'the envelope signature does not match the sender' };
1214
+ const fuera = this._limiteDeAlcance(env, senderCard.delegation?.scope);
1215
+ if (fuera) return { ok: false, code: 403, reason: fuera };
569
1216
 
570
1217
  // Firma de relay (segunda capa: la estafeta emisora también firma, análogo a SPF/DKIM)
571
1218
  const { domain: fromDomain } = parseAddress(env.from);
@@ -575,17 +1222,26 @@ export class Estafeta {
575
1222
  relayVerified = r.domain === fromDomain && senderCard._domain.keys.some((k) => k.sig === r.kid) && verifyBytes(`relay:${env.id}:${this.domain}`, r.sig, r.kid);
576
1223
  }
577
1224
  if (this.policy.require_relay && !relayVerified) return { ok: false, code: 403, reason: 'this domain requires a valid relay signature' };
578
- if (!this.rate.allow(fromDomain)) return { ok: false, code: 429, reason: 'rate limit for the sending domain' };
1225
+ // La clave del límite: cada agente de ESTA casa cuenta por su cuenta (uno solo no frena a los
1226
+ // demás); los de otra casa cuentan por dominio, que es lo único verificado de ellos aquí.
1227
+ const claveTasa = fromDomain === this.domain ? env.from : fromDomain;
1228
+ if (!await this.rate.allow(claveTasa)) return { ok: false, code: 429, reason: fromDomain === this.domain ? 'rate limit for this sender' : 'rate limit for the sending domain' };
579
1229
 
580
1230
  const accepted = [], rejected = [], results = {};
581
1231
  const mails = [], libroBundles = [];
582
1232
  const recibosPendientes = [];
583
1233
  const avisosPendientes = [];
584
1234
  let stampUsed = false;
1235
+ // Sobre x402 de esta entrega. Un buzón con estampilla ES un recurso de pago: si falta la
1236
+ // estampilla se responde 402 anunciando el precio, y si se cobró se responde con el recibo de
1237
+ // liquidación. No es un camino paralelo para mover tokens: el pago lo hizo el Libro igual.
1238
+ let x402req = null, x402pago = null;
585
1239
  for (const to of pendientes) {
586
1240
  const { local } = parseAddress(to);
587
1241
  const rec = await this.store.getAgent(local);
588
- if (!rec) { rejected.push({ to, code: 404, reason: 'no such agent' }); continue; }
1242
+ // Un secreto le contesta a quien no está en su lista lo mismo que un inexistente: ni el
1243
+ // rebote del postmaster confirma que la dirección existe.
1244
+ if (!rec || !await this._visibleA(rec, env.from)) { rejected.push({ to, code: 404, reason: 'no such agent' }); continue; }
589
1245
  const p = applyInboxPolicy(env, rec, senderCard._domain);
590
1246
  if (!p.ok) { rejected.push({ to, ...p, ok: undefined }); continue; }
591
1247
  try {
@@ -605,11 +1261,33 @@ export class Estafeta {
605
1261
  if (!r.ok) { rejected.push({ to, code: r.code, reason: r.reason }); continue; }
606
1262
  for (const rc of r.recibos || []) recibosPendientes.push(rc);
607
1263
  results[to] = r.result;
1264
+ } else if (rec.group) {
1265
+ // Grupo: la casa NO descifra. Deja el MISMO sobre firmado en el buzón de cada miembro (menos
1266
+ // el remitente, que lo tiene en su bandeja de salida) y en el del grupo, que es su historial.
1267
+ // La membresía se comprueba aquí otra vez: la política del buzón deja pasar un `intro` o un
1268
+ // aval, y eso no puede abrir un grupo.
1269
+ const posters = rec.group.post === 'admins' ? rec.group.admins : rec.group.members;
1270
+ if (!posters.includes(env.from)) { rejected.push({ to, code: 403, reason: 'only members post to this group' }); continue; }
1271
+ const meta = { from_verified: true, relay_verified: relayVerified, sender_kid: env.signature.kid, group: to };
1272
+ // Cada miembro recibe sólo si SU buzón acepta a quien escribe: el grupo no es una puerta
1273
+ // trasera a una lista blanca. Y cada entrega extra cuenta en el límite de tasa del remitente:
1274
+ // un grupo de 50 no multiplica por 50 lo que un dominio puede mandar por minuto.
1275
+ let omitidos = 0;
1276
+ for (const dir of rec.group.members) {
1277
+ if (dir === env.from) continue;
1278
+ const recM = await this.store.getAgent(parseAddress(dir).local);
1279
+ if (!recM || recM.revoked || !this._aceptaDe(recM, env.from)) { omitidos++; continue; }
1280
+ if (mails.length && !await this.rate.allow(claveTasa)) { rejected.push({ to, code: 429, reason: 'rate limit for the sender (group delivery)' }); break; }
1281
+ mails.push({ local: parseAddress(dir).local, envelope: env, meta });
1282
+ }
1283
+ mails.push({ local, envelope: env, meta });
1284
+ results[to] = { members: rec.group.members.length, skipped: omitidos };
608
1285
  } else if (p.stamp) {
609
1286
  // Una estampilla paga UN buzón: el sobre declara un monto, no un monto por destinatario.
610
1287
  if (stampUsed) { rejected.push({ to, code: 402, reason: 'the envelope stamp was already spent on another recipient' }); continue; }
611
1288
  const { asiento, bundle } = await this.libro.stamp(env, to, p.stamp.price);
612
1289
  stampUsed = true;
1290
+ x402pago = { to, price: p.stamp.price, asiento: asiento.id, payer: env.from };
613
1291
  libroBundles.push(bundle);
614
1292
  mails.push({ local, envelope: env, meta: { from_verified: true, relay_verified: relayVerified, sender_kid: env.signature.kid, stamp: asiento.id } });
615
1293
  } else if (p.vouch) {
@@ -631,7 +1309,10 @@ export class Estafeta {
631
1309
  mails.push({ local, envelope: env, meta: { from_verified: true, relay_verified: relayVerified, sender_kid: env.signature.kid } });
632
1310
  }
633
1311
  } catch (e) {
634
- if (e instanceof LibroError) { rejected.push({ to, code: e.code, reason: e.message }); continue; }
1312
+ if (e instanceof LibroError) {
1313
+ if (e.code === 402 && p.stamp && !x402req) x402req = { to, price: p.stamp.price };
1314
+ rejected.push({ to, code: e.code, reason: e.message }); continue;
1315
+ }
635
1316
  throw e;
636
1317
  }
637
1318
  accepted.push(to);
@@ -652,8 +1333,33 @@ export class Estafeta {
652
1333
  // deadline, una fianza que vence). No desaparecen mudos: llegan a las partes en la fecha.
653
1334
  for (const av of avisosPendientes) await this._systemSend('libro', av.to, { thread: av.thread || null, content: { media: MEDIA.recibo, body: av.body }, deliverAfter: av.deliver_after });
654
1335
 
655
- if (!union.length) return { ok: false, code: rejected[0].code, reason: rejected[0].reason, rejected, accepted: [] };
656
- return { ok: true, code: 202, accepted: union, rejected, results, ...(seen ? { duplicate: true } : {}) };
1336
+ if (!union.length) return { ok: false, code: rejected[0].code, reason: rejected[0].reason, rejected, accepted: [], ...(x402req ? { x402: { required: x402req } } : {}) };
1337
+ return { ok: true, code: 202, accepted: union, rejected, results, ...(seen ? { duplicate: true } : {}), ...(x402pago ? { x402: { settled: x402pago } } : x402req ? { x402: { required: x402req } } : {}) };
1338
+ }
1339
+
1340
+ // Traduce el resultado de `inbound` al cable de x402. Dos casos y nada más:
1341
+ // se cobró estampilla -> PAYMENT-RESPONSE con el id del asiento como `transaction`
1342
+ // faltaba la estampilla -> PAYMENT-REQUIRED con el precio del buzón
1343
+ // El id del asiento no es adorno: cualquiera puede pedirlo al Libro, que es lo que un explorador
1344
+ // hace con un hash de transacción. Si no hay nada que decir, no se pone ninguna cabecera.
1345
+ _cabecerasX402(r) {
1346
+ try {
1347
+ if (r.x402?.settled) {
1348
+ const s = r.x402.settled;
1349
+ return { headers: { 'payment-response': x402.cabeceraLiquidacion(x402.liquidacion({ transaction: s.asiento, payer: s.payer, amount: s.price })) } };
1350
+ }
1351
+ if (r.x402?.required) {
1352
+ const q = r.x402.required;
1353
+ const { local } = parseAddress(q.to);
1354
+ return { headers: { 'payment-required': x402.cabeceraRequerido(x402.requisitos({
1355
+ url: `https://${this.domain}/x402/inbox/${encodeURIComponent(local)}`, amount: q.price, payTo: q.to, flow: 'upfront',
1356
+ description: `Delivery of one signed envelope into the mailbox of ${q.to}`,
1357
+ serviceName: this.domain, tags: ['nyx5', 'mailbox'],
1358
+ error: `delivery into ${q.to} costs ${q.price} tok; the envelope carried no valid stamp`,
1359
+ })) } };
1360
+ }
1361
+ } catch (e) { this.log(`x402: no se pudo armar la cabecera: ${e.message}`); }
1362
+ return {};
657
1363
  }
658
1364
 
659
1365
  // Los avisos por webhook se acumulan para que el ciclo (y ctx.waitUntil en el edge) los espere:
@@ -734,6 +1440,10 @@ export class Estafeta {
734
1440
  offset += batch.length;
735
1441
  if (batch.length < 200 || offset >= page.total) break;
736
1442
  }
1443
+ // Reputación y precio (NX-302): cada tarjeta lleva su puntaje arbitrado, leído del historial
1444
+ // público de SU casa (§21). La propia casa se lee local; las ajenas por HTTP, de a pocas.
1445
+ h.reputacion = await this._indexReputacion(cards, dc, propia);
1446
+ for (const c of cards) c._price_min = precioMinimo(c);
737
1447
  await this.store.indexReplaceAgents(h.domain, cards);
738
1448
  h.last_ok = iso(); h.fails = 0; h.agents = cards.length;
739
1449
  await this.store.indexPutHouse(h);
@@ -743,6 +1453,33 @@ export class Estafeta {
743
1453
  throw e;
744
1454
  }
745
1455
  }
1456
+ // Puntaje de cada tarjeta a partir del historial de su casa. Devuelve el DENOMINADOR: cuántas
1457
+ // se pidieron, cuántas quedaron con puntaje, cuántas sin historial, cuántas de una casa que no
1458
+ // distingue arbitrados (versión anterior de Nyx5) y cuántas fallaron al pedirse. Una falla no
1459
+ // tumba el rastreo: esa tarjeta entra sin puntaje (al final del orden), y el número queda escrito.
1460
+ async _indexReputacion(cards, dc, propia) {
1461
+ const cuenta = { pedidas: cards.length, con_puntaje: 0, sin_historial: 0, sin_arbitrados: 0, fallidas: 0 };
1462
+ const LOTE = 4;
1463
+ for (let i = 0; i < cards.length; i += LOTE) {
1464
+ await Promise.all(cards.slice(i, i + LOTE).map(async (c) => {
1465
+ c._score = null; c._jobs_done = null;
1466
+ let hist;
1467
+ try {
1468
+ if (propia) hist = await this.libro.historial(c.address);
1469
+ else {
1470
+ const local = String(c.address).slice(0, String(c.address).lastIndexOf('@'));
1471
+ const res = await this.fetch(`${dc._estafeta}/agents/${encodeURIComponent(local)}/historial`, { signal: AbortSignal.timeout(10_000) });
1472
+ if (!res.ok) throw new Error(`historial -> ${res.status}`);
1473
+ hist = await res.json();
1474
+ }
1475
+ } catch (e) { cuenta.fallidas += 1; this.log(`índice: sin historial de ${c.address}: ${e.message}`); return; }
1476
+ const r = puntajeDe(hist);
1477
+ c._score = r.score; c._jobs_done = r.jobs_done;
1478
+ if (r.motivo) cuenta[r.motivo] += 1; else cuenta.con_puntaje += 1;
1479
+ }));
1480
+ }
1481
+ return cuenta;
1482
+ }
746
1483
  // ---------- puente de correo (urn:nyx5:ext:email) ----------
747
1484
  // ENTRADA: un email real entra al buzón del destinatario como sobre SIN FIRMA, marcado
748
1485
  // from_verified:false y via:'email'. No pasa por /inbound ni finge estar firmado (invariante 1).
@@ -753,7 +1490,17 @@ export class Estafeta {
753
1490
  try { ({ local, domain: dom } = parseAddress(to)); } catch { return { ok: false, code: 400, reason: 'invalid recipient' }; }
754
1491
  if (dom !== this.domain) return { ok: false, code: 400, reason: `the email is for ${dom}, not ${this.domain}` };
755
1492
  const rec = await this.store.getAgent(local);
756
- if (!rec) return { ok: false, code: 404, reason: 'no such agent' };
1493
+ if (!rec || !await this._visibleA(rec, from)) return { ok: false, code: 404, reason: 'no such agent' };
1494
+ // La política del buzón manda también aquí. Antes no: la puerta del correo se saltaba la
1495
+ // estampilla, la lista blanca y la prueba de trabajo. El adaptador del edge convierte este
1496
+ // rechazo en un rechazo SMTP, así que el remitente recibe un rebote de su propio proveedor y
1497
+ // la casa no manda correo a una dirección que no verificó (nada de backscatter).
1498
+ const pol = applyEmailPolicy(rec, from);
1499
+ if (!pol.ok) return { ok: false, code: pol.code, reason: pol.reason };
1500
+ // Y un buzón abierto tampoco es un embudo infinito: se limita por dominio del remitente.
1501
+ if (!await this.rate.allow(`email:${String(from).slice(String(from).lastIndexOf('@') + 1).toLowerCase()}`)) {
1502
+ return { ok: false, code: 429, reason: 'rate limit for the sending domain' };
1503
+ }
757
1504
  const env = inboundEnvelope({ from, to: `${local}@${this.domain}`, subject, text, messageId });
758
1505
  // dedupe por id (message-id del correo o uuid) igual que un sobre normal
759
1506
  const nuevo = await this.store.markSeenIfNew(env.id, { from: `email:${from}`, accepted: [to] });
@@ -765,6 +1512,10 @@ export class Estafeta {
765
1512
  // SALIDA: un agente le escribe a una dirección de correo. Con proveedor, se envía (Reply-To = el
766
1513
  // agente, para que la respuesta vuelva por ENTRADA). Sin proveedor, queda pendiente: no se inventa canal.
767
1514
  async emailOut({ fromAgent, to, subject, text }) {
1515
+ // Cerrado por defecto (11-sep-2026, revisión antes de que Nicholas se fuera un mes): con el
1516
+ // registro abierto, "cualquier agente le escribe a cualquier correo" era un relé de spam con
1517
+ // nuestra cuenta de envío y nuestro dominio. Sólo escriben las direcciones que la casa autoriza.
1518
+ if (!this.email.senders.has(String(fromAgent || '').toLowerCase())) return { ok: false, code: 403, reason: 'outbound email is closed in this house: only addresses the house authorizes can write to people by email' };
768
1519
  if (!isEmailAddress(to)) return { ok: false, code: 400, reason: 'invalid email destination' };
769
1520
  // El pie viaja solo si la casa lo enciende (`email.footer`). Apagado por defecto: el texto
770
1521
  // que un tercero recibe es decisión del operador de la casa, no del código.
@@ -774,9 +1525,12 @@ export class Estafeta {
774
1525
  catch (e) { return { ok: false, code: 502, reason: `the email provider failed: ${e.message}` }; }
775
1526
  }
776
1527
 
1528
+ // `params` crudos (strings de URL o argumentos de la herramienta): se validan aquí, así el
1529
+ // método y la ruta rechazan lo mismo (400 cursor/filtro inválido, 410 recorrido caduco).
777
1530
  async indexSearch(params) {
778
- const out = await this.store.indexSearch(params);
1531
+ const out = await this.store.indexSearch(validarFiltros(params));
779
1532
  // Respuesta firmada por la casa del índice: otro índice (u otra casa) puede ingerirla verificada.
1533
+ // `total` es lo que cumple los filtros hoy; `next_cursor` es null en la última página.
780
1534
  return signObject({ nyx5: '1', index: this.domain, issued: iso(), ...out }, this.keys);
781
1535
  }
782
1536
 
@@ -785,9 +1539,83 @@ export class Estafeta {
785
1539
  async handleRequest(rx) {
786
1540
  await this.init();
787
1541
  const path = rx.path;
788
- const send = (status, body) => ({ status, body });
1542
+ const send = (status, body, headers = null) => (headers ? { status, body, headers } : { status, body });
1543
+ // Un 429 dice cuándo volver: Retry-After en segundos hasta la ventana siguiente.
1544
+ const tarde = (body) => send(429, body, this._retryAfter());
1545
+ let m;
789
1546
  try {
790
1547
  if (rx.method === 'GET' && path === '/health') return send(200, { ok: true, domain: this.domain, agents: (await this.store.listAgents()).length, queue: (await this.store.listQueue()).length });
1548
+ // ----- Conector MCP remoto: OAuth + /mcp -----
1549
+ if (this.remoto.enabled && rutaCors(path)) {
1550
+ if (rx.method === 'OPTIONS') return { status: 204, headers: CORS, contentType: 'text/plain', body: '' };
1551
+ const r = await this._rutaRemota(rx);
1552
+ if (r) return { ...r, headers: { ...CORS, ...(r.headers || {}) } };
1553
+ }
1554
+ if (this.remoto.enabled && rx.method === 'GET' && path === '/oauth/authorize') return oauth.paginaAutorizar(this, rx.query, APP_HTML);
1555
+ if (this.remoto.enabled && rx.method === 'POST' && path === '/oauth/prepare') return oauth.preparar(this, rx);
1556
+ if (this.remoto.enabled && rx.method === 'POST' && path === '/oauth/approve') return oauth.aprobar(this, rx);
1557
+ // ----- Invitaciones de contacto: un link que se manda por WhatsApp -----
1558
+ if (this.remoto.enabled && rx.method === 'POST' && path === '/contact-invites') return this.crearInvitacion(rx);
1559
+ if (this.remoto.enabled && rx.method === 'GET' && (m = /^\/i\/([A-Za-z0-9_-]{16,64})$/.exec(path))) return this._paginaInvitacion(m[1], rx.query.get('source'), rx.ip);
1560
+ // ----- Asistentes (sólo la casa los configura; el dueño los pide) -----
1561
+ if ((m = /^\/admin\/assistants(?:\/([^/]+))?(?:\/(knowledge|config|pause|resume))?$/.exec(path))) {
1562
+ if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house configures assistants' });
1563
+ return this._adminAsistente(rx, m[1] ? decodeURIComponent(m[1]).toLowerCase() : null, m[2] || null);
1564
+ }
1565
+ // ----- Ficha pública: la edita el dueño de la dirección o el dueño de su delegación -----
1566
+ if (rx.method === 'POST' && (m = /^\/agents\/([^/]+)\/profile$/.exec(path))) {
1567
+ const who = await this._authenticate(rx, path);
1568
+ const l = decodeURIComponent(m[1]).toLowerCase();
1569
+ const rec = Estafeta.validLocal(l) ? await this.store.getAgent(l) : null;
1570
+ if (!rec || rec.revoked) return send(404, { reason: 'no such agent' });
1571
+ // Una dirección de sólo mensajes no edita ficha alguna, ni la suya: lo que se publica en
1572
+ // nombre del dueño lo escribe el dueño (revisión del 13-sep-2026).
1573
+ if (who.record.delegation?.scope?.messages_only) return send(403, { reason: 'a messages-only address cannot edit a profile' });
1574
+ if (who.local !== l && who.address !== rec.delegation?.by) return send(403, { reason: 'only the owner of an address (or of its delegation) edits its profile' });
1575
+ const v = validarPerfil(rx.body?.profile === undefined ? null : rx.body.profile);
1576
+ if (v.error) return send(400, { reason: v.error });
1577
+ // El validador no ve la tarjeta: el precio en dólares se cruza aquí con la billetera declarada.
1578
+ { const e = usdSinBilletera(v.perfil, rec.wallets || (rec.wallet ? [rec.wallet] : [])); if (e) return send(400, { reason: e }); }
1579
+ // Re-certificar sin tocar nada más: mismas llaves, misma delegación, mismo buzón.
1580
+ const { certification: _c, webhook, notify_email, ...cuerpo } = rec;
1581
+ const card = signObject({ ...cuerpo, profile: v.perfil ? { ...v.perfil, updated: iso() } : undefined }, this.keys, 'certification');
1582
+ await this.store.putAgent(l, { ...card, webhook, notify_email });
1583
+ this.resolver.invalidate(`agent:${l}@${this.domain}`);
1584
+ return send(200, { address: card.address, profile: card.profile || null });
1585
+ }
1586
+ // ----- Presencia: «visto por última vez», sólo con opt-in del dueño -----
1587
+ if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)\/presence$/.exec(path))) {
1588
+ if (!await this.rate.allow(`resolve:${rx.ip || 'x'}`)) return tarde({ reason: 'too many requests' });
1589
+ const l = decodeURIComponent(m[1]).toLowerCase();
1590
+ if (!Estafeta.validLocal(l)) return send(400, { reason: 'invalid agent name' });
1591
+ // Un inexistente contesta «sin presencia»; un secreto, a quien no lo ve, exactamente lo mismo.
1592
+ const quien = await this._quienPregunta(rx, path);
1593
+ const visible = await this._visibleA(await this.store.getAgent(l), quien);
1594
+ return send(200, { address: `${l}@${this.domain}`, last_seen: visible ? await this.presenciaDe(l) : null });
1595
+ }
1596
+ // ----- Grupos: crear, ver miembros, agregar, quitar, irse -----
1597
+ if (rx.method === 'POST' && path === '/groups') {
1598
+ const who = await this._authenticate(rx, path);
1599
+ const r = await this.crearGrupo(who, rx.body || {});
1600
+ return send(r.status, r.body);
1601
+ }
1602
+ if ((m = /^\/groups\/([^/]+)\/members$/.exec(path))) {
1603
+ const who = await this._authenticate(rx, path);
1604
+ const l = decodeURIComponent(m[1]).toLowerCase();
1605
+ if (!Estafeta.validLocal(l)) return send(400, { reason: 'invalid group name' });
1606
+ const r = rx.method === 'GET' ? await this.miembrosGrupo(who, l) : rx.method === 'POST' ? await this.editarGrupo(who, l, rx.body || {}) : { status: 405, body: { reason: 'method not allowed here' } };
1607
+ return send(r.status, r.body);
1608
+ }
1609
+ // ----- Contactos: la casa conecta dos direcciones suyas en los dos sentidos -----
1610
+ if (rx.method === 'POST' && path === '/admin/contacts') {
1611
+ if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house connects contacts' });
1612
+ const between = Array.isArray(rx.body?.between) ? rx.body.between : [];
1613
+ const r = await this.conectarContactos(between[0], between[1]);
1614
+ return send(r.status, r.body);
1615
+ }
1616
+ // ----- La app en la pantalla de inicio -----
1617
+ if (rx.method === 'GET' && path === '/manifest.webmanifest') return { status: 200, contentType: 'application/manifest+json', body: JSON.stringify(MANIFIESTO) };
1618
+ if (rx.method === 'GET' && (m = /^\/(?:icon-(180|192|512)|apple-touch-icon)\.png$/.exec(path))) return { status: 200, contentType: 'image/png', body: Buffer.from(ICONOS[m[1] || 180], 'base64') };
791
1619
  // Cliente web para personas: se sirve desde la propia casa (mismo origen, sin CORS).
792
1620
  // El HTML genera las llaves en el navegador del usuario; la casa nunca las ve.
793
1621
  // La portada muestra prueba de vida REAL, leída del libro en el momento: cuántos agentes
@@ -852,19 +1680,86 @@ export class Estafeta {
852
1680
  body: `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${paginas.map((u) => ` <url><loc>https://${this.domain}${u}</loc><lastmod>${hoy}</lastmod></url>`).join('\n')}\n</urlset>\n` };
853
1681
  }
854
1682
  if (rx.method === 'GET' && path === '/llms.txt') return { status: 200, body: LLMS_TXT, contentType: 'text/plain; charset=utf-8' };
1683
+ // ---------- x402: el estándar de "402 Payment Required" para agentes ----------
1684
+ // La casa es su propio facilitador: verifica y liquida contra su propio Libro. El spec lo
1685
+ // permite ("or host the endpoints themselves"), y así no hay un tercero en el camino del
1686
+ // dinero. Ver docs/interop/x402.md, que dice también qué NO reclamamos.
1687
+ if (rx.method === 'GET' && (path === '/x402/supported' || path === '/x402/supported/')) {
1688
+ const card = await this.domainCard();
1689
+ return send(200, x402.soportado(this.domain, (card.keys || []).map((k) => k.sig).filter(Boolean)));
1690
+ }
1691
+ // El recurso pagable que esta casa ya tenía: entregar en un buzón con estampilla. Un cliente
1692
+ // x402 hace GET aquí y lee el precio antes de componer nada. Un buzón gratis contesta 200 y
1693
+ // lo dice: "no hay nada que pagar" es una respuesta, no un error.
1694
+ const mX402 = rx.method === 'GET' ? /^\/x402\/inbox\/([^/]+)$/.exec(path) : null;
1695
+ if (mX402) {
1696
+ const local = decodeURIComponent(mX402[1]).toLowerCase();
1697
+ const quien = await this._quienPregunta(rx, path);
1698
+ const rec = await this.store.getAgent(local);
1699
+ if (!rec || !await this._visibleA(rec, quien)) return send(404, { reason: 'no such agent' });
1700
+ const precio = rec.inbox?.policy === 'stamp' ? (rec.inbox.price ?? 1) : 0;
1701
+ const url = `https://${this.domain}/x402/inbox/${encodeURIComponent(local)}`;
1702
+ if (!precio) return send(200, { x402Version: x402.X402_VERSION, free: true, resource: { url }, reason: `${local}@${this.domain} does not charge for delivery` });
1703
+ const pr = x402.requisitos({
1704
+ url, amount: precio, payTo: `${local}@${this.domain}`, flow: 'upfront',
1705
+ description: `Delivery of one signed envelope into the mailbox of ${local}@${this.domain}`,
1706
+ serviceName: this.domain, tags: ['nyx5', 'mailbox'],
1707
+ error: `delivery into this mailbox costs ${precio} tok; send the envelope to POST /inbound with a stamp field`,
1708
+ });
1709
+ // Segunda forma de pago: dólares de verdad, si el agente declaró a dónde cobrarlos y a qué
1710
+ // precio. NO convertimos tokens a dólares: inventar un tipo de cambio sería justo la cifra
1711
+ // sin respaldo que este protocolo existe para encarecer. El precio en dólares lo pone el
1712
+ // agente o no hay opción en dólares.
1713
+ // Una entrada por cada red que el agente declaró Y que sabemos liquidar. El estándar dice
1714
+ // que `accepts` es una lista y que el cliente elige, así que ofrecer una sola red sería
1715
+ // desperdiciar el mecanismo: quien sólo puede pagar en una cadena encuentra la suya.
1716
+ const ws = rec.wallets || (rec.wallet ? [rec.wallet] : []);
1717
+ if (ws.length && Number.isInteger(rec.inbox?.price_usd) && rec.inbox.price_usd > 0) {
1718
+ for (const w of ws) {
1719
+ const t = x402.TOKEN_USD[w?.network];
1720
+ if (!t || !w?.address) continue;
1721
+ pr.accepts.push(...x402.requisitosEvm({
1722
+ url, amount: rec.inbox.price_usd, payTo: w.address, network: w.network,
1723
+ asset: t.asset, tokenName: t.name, tokenVersion: t.version,
1724
+ }).accepts);
1725
+ }
1726
+ }
1727
+ // Catalogable: el facilitador que vea este 402 puede publicarlo en su directorio, que es
1728
+ // donde un agente busca. Se declara lo que se vende de verdad, no una promesa más grande.
1729
+ pr.extensions = { bazaar: x402.bazaarBuzon({ url, direccion: `${local}@${this.domain}` }) };
1730
+ return { status: 402, body: pr, headers: { 'payment-required': x402.cabeceraRequerido(pr) } };
1731
+ }
855
1732
  if (rx.method === 'GET' && path === '/.well-known/nyx5.json') return send(200, await this.domainCard());
856
- let m;
857
1733
  // Reputación = una consulta al libro. Es PÚBLICA a propósito: sirve justamente para que
858
1734
  // un desconocido decida antes de contratar, igual que la tarjeta. No expone contenido ni
859
1735
  // contrapartes: solo cuántas entregas, cuántas fianzas y cuántos tokens se movieron.
860
1736
  if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)\/historial$/.exec(path))) {
861
1737
  const local = decodeURIComponent(m[1]).toLowerCase();
862
- if (!(await this.store.getAgent(local))) return send(404, { reason: 'no such agent' });
1738
+ const quien = await this._quienPregunta(rx, path);
1739
+ const rec = Estafeta.validLocal(local) ? await this.store.getAgent(local) : null;
1740
+ if (!rec || !await this._visibleA(rec, quien)) return send(404, { reason: 'no such agent' });
863
1741
  return send(200, await this.libro.historial(`${local}@${this.domain}`));
864
1742
  }
1743
+ // ----- Notaría (NX-601): verificación PÚBLICA de sellos, sin cuenta. Se limita por IP como
1744
+ // /resolve. Un hash sin sellos y un id inexistente contestan el mismo 404. El nombre del
1745
+ // declarante se muestra sólo si «nadie» lo vería (_visibleA con quien = null).
1746
+ if (rx.method === 'GET' && (m = /^\/notaria\/(?:sello\/([^/]+)|([0-9a-fA-F]{64}))$/.exec(path))) {
1747
+ if (!await this.rate.allow(`notaria:${rx.ip || 'x'}`)) return tarde({ reason: 'too many requests' });
1748
+ let idSello = null; if (m[1]) { try { idSello = decodeURIComponent(m[1]); } catch { return send(404, { reason: 'no such seal' }); } }
1749
+ const docs = idSello ? [await this.store.notariaGet(idSello)].filter(Boolean) : await this.store.notariaList(m[2].toLowerCase());
1750
+ if (!docs.length) return send(404, { reason: 'no such seal' });
1751
+ const seals = [];
1752
+ for (const d of docs) seals.push(selloPublico(d, await this._declaranteVisible(d)));
1753
+ return m[1] ? send(200, seals[0]) : send(200, { sha256: m[2].toLowerCase(), house: this.domain, seals });
1754
+ }
865
1755
  if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)$/.exec(path))) {
1756
+ // Un secreto responde a quien no lo ve EXACTAMENTE lo que un inexistente: mismo cuerpo,
1757
+ // mismas cabeceras. Lo cuida test/visibilidad.test.js comparando los dos byte a byte.
1758
+ // Quién pregunta se resuelve ANTES de mirar si existe: el orden es parte de la igualdad.
1759
+ const quien = await this._quienPregunta(rx, path);
866
1760
  const card = await this.agentCard(decodeURIComponent(m[1]).toLowerCase());
867
- return card ? send(200, card) : send(404, { reason: 'no such agent' });
1761
+ if (!card || !await this._visibleA(card, quien)) return send(404, { reason: 'no such agent' });
1762
+ return send(200, card);
868
1763
  }
869
1764
  if (rx.method === 'GET' && path === '/agents') {
870
1765
  const p = Object.fromEntries(rx.query);
@@ -874,7 +1769,8 @@ export class Estafeta {
874
1769
  const body = rx.body || {};
875
1770
  const local = String(body.local || '').toLowerCase();
876
1771
  if (!Estafeta.validLocal(local)) return send(400, { reason: 'invalid agent name' });
877
- const exists = !!(await this.store.getAgent(local));
1772
+ const previo = await this.store.getAgent(local);
1773
+ const exists = !!previo;
878
1774
  const isAdmin = (rx.headers.authorization || '') === `Bearer ${this.adminToken}`;
879
1775
  let ok = isAdmin, via = 'admin';
880
1776
  if (!ok && rx.headers.authorization?.startsWith('Nyx5 ')) {
@@ -884,21 +1780,26 @@ export class Estafeta {
884
1780
  }
885
1781
  if (!ok) {
886
1782
  // Auto-registro: el cuerpo viene firmado por la clave que se inscribe (prueba de posesión).
887
- if (exists) return send(409, { reason: 'that name is taken; only its owner or the house can update it' });
1783
+ // «Nombre tomado» también confirma existencia: un secreto recorre el MISMO camino que un
1784
+ // nombre que no existe (firma, fecha, tasa, invitación) y recién al final recibe el 409 de
1785
+ // un nombre reservado por el protocolo, que es donde lo recibiría un reservado de verdad.
1786
+ const secreto = exists && previo.visibility === 'secret';
1787
+ if (exists && !secreto) return send(409, { reason: 'that name is taken; only its owner or the house can update it' });
888
1788
  if (!body.signature || body.signature.kid !== body.sig || !verifyObject(body, body.sig)) return send(401, { reason: 'to self-register, sign the body with the same sig key you are enrolling (proof of possession)' });
889
1789
  if (Math.abs(now() - Date.parse(body.ts || 0)) > 300_000) return send(401, { reason: 'the signed request needs a ts (ISO) within 5 minutes' });
890
- if (!this.regRate.allow(rx.ip || 'x')) return send(429, { reason: 'too many registrations from this address' });
1790
+ if (!await this.regRate.allow(rx.ip || 'x')) return tarde({ reason: 'too many registrations from this address' });
891
1791
  if (this.policy.registration === 'open') via = 'open';
892
1792
  else if (this.policy.registration === 'invite') { const inv = await this._consumeInvite(body.invite); via = `invite:${inv.code}`; if (inv.welcome != null) body._welcome = inv.welcome; }
893
1793
  else return send(403, { reason: `this house does not accept self-registration (registration=${this.policy.registration}); ask for an invitation` });
1794
+ if (secreto) return send(409, { reason: `name reserved by the protocol: ${local}` });
894
1795
  }
895
1796
  // `source` es atribución y NO entra en la tarjeta: se descarta aquí y viaja al evento.
896
- const { signature: _s, ts: _t, invite: _i, _welcome, source: _src, ...clean } = body;
1797
+ // `custody` y `revoked` los pone sólo la casa: nadie se declara custodiado ni des-revocado.
1798
+ const { signature: _s, ts: _t, invite: _i, _welcome, source: _src, custody: _cu, revoked: _rv, ...clean } = body;
897
1799
  const card = await this.registerAgent({ ...clean, welcome: _welcome });
898
1800
  this.log(`registro ${card.address} via ${via}`);
899
1801
  if (!card.delegation) {
900
- const fuente = typeof _src === 'string' ? _src.slice(0, 64).replace(/[^\w.:@/-]/g, '') : null;
901
- await this._evento('join', card.address, { via, listed: card.capabilities?.listed === true, source: fuente || null });
1802
+ await this._evento('join', card.address, { via, listed: card.capabilities?.listed === true, source: Estafeta.fuenteLimpia(_src) });
902
1803
  }
903
1804
  return send(201, { ...card, registered_via: via });
904
1805
  }
@@ -927,7 +1828,7 @@ export class Estafeta {
927
1828
  const r = await this.inbound(rx.body, rx.headers['x-nyx5-relay']);
928
1829
  // pending: los avisos por webhook que nacieron aquí. El adaptador los pasa a waitUntil;
929
1830
  // sin eso el runtime cancela el fetch al cerrar la respuesta y el aviso nunca sale.
930
- return { ...send(r.code || 400, r), kick: true, pending: this.flushPushes() };
1831
+ return { ...send(r.code || 400, r, r.code === 429 ? this._retryAfter() : null), ...this._cabecerasX402(r), kick: true, pending: this.flushPushes() };
931
1832
  }
932
1833
  if (rx.method === 'GET' && (m = /^\/mailbox\/([^/]+)$/.exec(path))) {
933
1834
  const who = await this._authenticate(rx, path);
@@ -943,9 +1844,91 @@ export class Estafeta {
943
1844
  const { ids = [] } = rx.body || {};
944
1845
  const acked = [];
945
1846
  for (const id of ids) if (await this.store.ackMail(who.local, id)) acked.push(id);
1847
+ // Acuse de lectura (13-sep-2026), opt-in del que lee: «entregado» no decía si alguien leyó.
1848
+ // Sale de la casa, que da fe de la hora en que ese buzón confirmó; sólo la primera vez.
1849
+ if (acked.length && who.record.capabilities?.read_receipts === true) await this._acusesDeLectura(who, acked);
946
1850
  return send(200, { acked });
947
1851
  }
1852
+ // ----- Tiempo real, historial, conectores y tarjetas ajenas -----
1853
+ if (rx.method === 'GET' && (m = /^\/mailbox\/([^/]+)\/wait$/.exec(path))) {
1854
+ const who = await this._authenticate(rx, path);
1855
+ if (who.local !== decodeURIComponent(m[1]).toLowerCase()) return send(403, { reason: 'not your mailbox' });
1856
+ const q = Object.fromEntries(rx.query);
1857
+ const segundos = Math.max(0, Math.min(Number(q.timeout ?? 25) || 0, 90));
1858
+ const msg = await this.esperarCorreo(who.local, { from: q.from || null, thread: q.thread || null, since: q.since || null, project: q.project ? nombreDeProyecto(q.project) : null, timeoutMs: segundos * 1000 });
1859
+ return send(200, { message: msg });
1860
+ }
1861
+ if (rx.method === 'GET' && (m = /^\/conversations\/([^/]+)$/.exec(path))) {
1862
+ const who = await this._authenticate(rx, path);
1863
+ if (who.local !== decodeURIComponent(m[1]).toLowerCase()) return send(403, { reason: 'not your conversations' });
1864
+ const con = rx.query.get('with');
1865
+ const limit = Math.max(1, Math.min(Number(rx.query.get('limit') || 50) || 50, 500));
1866
+ const project = rx.query.get("project") ? nombreDeProyecto(rx.query.get("project")) : null;
1867
+ if (con) return send(200, { with: con.toLowerCase(), messages: await this.conversacion(who.local, { con: con.toLowerCase(), limit, project }) });
1868
+ return send(200, { conversations: await this.conversacion(who.local, { project }) });
1869
+ }
1870
+ if (rx.method === 'GET' && (m = /^\/delegations\/([^/]+)$/.exec(path))) {
1871
+ const who = await this._authenticate(rx, path);
1872
+ if (who.local !== decodeURIComponent(m[1]).toLowerCase()) return send(403, { reason: 'not your delegations' });
1873
+ return send(200, { delegations: await this.delegados(who.local) });
1874
+ }
1875
+ if (rx.method === 'POST' && (m = /^\/agents\/([^/]+)\/revoke$/.exec(path))) {
1876
+ const sub = decodeURIComponent(m[1]).toLowerCase();
1877
+ if (!Estafeta.validLocal(sub)) return send(400, { reason: 'invalid agent name' });
1878
+ const rec = await this.store.getAgent(sub);
1879
+ if (!rec?.delegation) return send(404, { reason: 'no such subagent' });
1880
+ let por;
1881
+ if ((rx.headers.authorization || '') === `Bearer ${this.adminToken}`) por = `casa@${this.domain}`;
1882
+ else {
1883
+ const who = await this._authenticate(rx, path);
1884
+ if (who.address !== rec.delegation.by && who.local !== sub) return send(403, { reason: 'only the owner who delegated it can revoke it' });
1885
+ por = who.address;
1886
+ }
1887
+ if (rec.revoked) return send(200, { address: rec.address, revoked: rec.revoked, already: true });
1888
+ const card = await this.revocarDelegado(sub, por);
1889
+ return send(200, { address: card.address, revoked: card.revoked });
1890
+ }
1891
+ // La tarjeta verificada de CUALQUIER dirección, resuelta por la casa. La app del navegador no
1892
+ // puede pedirle la tarjeta a otra casa (CSP y CORS lo impiden, y está bien), y sin la llave de
1893
+ // cifrado del destinatario tendría que mandar en claro.
1894
+ if (rx.method === 'GET' && (m = /^\/resolve\/([^/]+)$/.exec(path))) {
1895
+ if (!await this.rate.allow(`resolve:${rx.ip || 'x'}`)) return tarde({ reason: 'too many requests' });
1896
+ let addr; try { addr = decodeURIComponent(m[1]).toLowerCase(); parseAddress(addr); } catch { return send(400, { reason: 'invalid address' }); }
1897
+ // Para quién se resuelve: el agente de esta casa que firma la petición, si alguno. A una
1898
+ // dirección secreta de esta casa se le aplica la misma regla que en /agents/<l>; a una de
1899
+ // otra casa, la casa pregunta «para» ese agente y la otra decide.
1900
+ const quien = await this._quienPregunta(rx, path);
1901
+ try {
1902
+ if (parseAddress(addr).domain === this.domain) {
1903
+ const rec = Estafeta.validLocal(parseAddress(addr).local) ? await this.store.getAgent(parseAddress(addr).local) : null;
1904
+ if (!rec || !await this._visibleA(rec, quien)) return send(404, { reason: 'no such agent' });
1905
+ }
1906
+ const { _estafeta, _domain, delegation, ...card } = await this.resolver.agentCard(addr, { onBehalfOf: quien });
1907
+ const { _parent, ...d } = delegation || {};
1908
+ // La presencia va FUERA de la tarjeta firmada (no altera la certificación) y sólo si es de
1909
+ // esta casa y el dueño la activó.
1910
+ const p = parseAddress(addr);
1911
+ const last_seen = p.domain === this.domain ? await this.presenciaDe(p.local) : null;
1912
+ return send(200, { ...card, ...(delegation ? { delegation: d } : {}), ...(last_seen ? { presence: { last_seen } } : {}) });
1913
+ } catch (e) { return send(e.permanent ? 404 : 502, { reason: e.message }); }
1914
+ }
948
1915
  // ----- Libro (lecturas directas; las operaciones van por correo a libro@) -----
1916
+ // Estado de cuenta por rango (NX-501): ?desde&hasta ISO-8601, ?formato=json|csv, ?limit.
1917
+ // La misma firma que /libro/cuenta; el dueño sólo ve su cuenta (?account= ajena → 403).
1918
+ if (rx.method === 'GET' && path === '/libro/estado') {
1919
+ const who = await this._authenticate(rx, path, { allowForeign: true });
1920
+ const p = Object.fromEntries(rx.query || []);
1921
+ const cuenta = String(p.account || who.address).toLowerCase();
1922
+ if (cuenta !== who.address) return send(403, { reason: 'not your account' });
1923
+ const formato = p.formato || 'json';
1924
+ if (!['json', 'csv'].includes(formato)) return send(400, { reason: 'formato must be json or csv' });
1925
+ const estado = await estadoDeCuenta(this.libro, cuenta, { since: p.desde, until: p.hasta, limit: p.limit, max: 1000 });
1926
+ // Diario y saldos que no cuadran es la primera señal de un libro corrompido: se anota y se
1927
+ // registra como evento, no se devuelve en silencio dentro del JSON.
1928
+ if (estado.reconciled === false) { this.log(`libro: el diario y los saldos de ${cuenta} divergen (${estado.closing_balance} vs ${estado.ledger_balance})`); await this._evento('ledger_divergence', cuenta, { closing_balance: estado.closing_balance, ledger_balance: estado.ledger_balance }); }
1929
+ if (formato === 'json') return send(200, estado);
1930
+ return { status: 200, contentType: 'text/csv; charset=utf-8', headers: { 'content-disposition': `attachment; filename="${nombreCsv(estado)}"` }, body: csvDe(estado) };
1931
+ }
949
1932
  if (rx.method === 'GET' && (m = /^\/libro\/cuenta\/([^/]+)$/.exec(path))) {
950
1933
  const who = await this._authenticate(rx, path, { allowForeign: true });
951
1934
  const address = decodeURIComponent(m[1]).toLowerCase();
@@ -983,6 +1966,21 @@ export class Estafeta {
983
1966
  mostrador: desk, arbitro: arbiter, tareas: publicadas, _deprecated: ['mostrador', 'arbitro', 'tareas'],
984
1967
  });
985
1968
  }
1969
+ // El embudo (NX-801), en PRIVADO: lleva direcciones y la fecha de cada etapa por persona,
1970
+ // así que nunca va en /report, que es público y promete no nombrar a nadie. Misma llave
1971
+ // que /eventos. Se calcula al pedirlo; si el almacén no responde, se dice, no se inventa.
1972
+ if (rx.method === 'GET' && (path === '/informe' || path === '/informe.json')) {
1973
+ if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house reads its funnel' });
1974
+ try {
1975
+ const dias = Math.min(90, Math.max(1, Number(rx.query.get('days') || 7) || 7));
1976
+ const [d, embudo] = await Promise.all([datosInforme(this, { dias }), datosEmbudo(this)]);
1977
+ if (path === '/informe.json') return send(200, { ...d, embudo });
1978
+ return { status: 200, contentType: 'text/html; charset=utf-8', body: informeHtml(this.domain, d, { embudo }) };
1979
+ } catch (e) {
1980
+ this.log(`embudo no disponible: ${e.message}`);
1981
+ return send(503, { reason: 'the ledger could not be read right now' });
1982
+ }
1983
+ }
986
1984
  if (rx.method === 'GET' && path === '/eventos') {
987
1985
  if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house reads its events' });
988
1986
  const p = Object.fromEntries(rx.query);
@@ -1002,7 +2000,7 @@ export class Estafeta {
1002
2000
  }
1003
2001
  // ----- Índice federado -----
1004
2002
  if (this.index.enabled && rx.method === 'POST' && path === '/index/houses') {
1005
- if (!this.rate.allow(`index:${rx.ip || 'x'}`)) return send(429, { reason: 'demasiadas solicitudes' });
2003
+ if (!await this.rate.allow(`index:${rx.ip || 'x'}`)) return tarde({ reason: 'too many requests' });
1006
2004
  const h = await this.indexAddHouse((rx.body || {}).domain);
1007
2005
  return send(201, h);
1008
2006
  }
@@ -1011,17 +2009,16 @@ export class Estafeta {
1011
2009
  return send(200, { total: houses.length, houses });
1012
2010
  }
1013
2011
  if (this.index.enabled && rx.method === 'GET' && path === '/index/agents') {
2012
+ // Paginación por cursor opaco, nunca por offset; los filtros se validan en indexSearch.
1014
2013
  const p = Object.fromEntries(rx.query);
1015
- const limit = Number.isInteger(Number(p.limit)) && Number(p.limit) > 0 ? Math.min(Number(p.limit), 200) : 50;
1016
- const offset = Number.isInteger(Number(p.offset)) && Number(p.offset) >= 0 ? Number(p.offset) : 0;
1017
- return send(200, await this.indexSearch({ q: p.q, capability: p.capability, accepts: p.accepts, house: p.house, limit, offset }));
2014
+ return send(200, await this.indexSearch({ ...Object.fromEntries(FILTROS.map((k) => [k, p[k]])), offset: p.offset }));
1018
2015
  }
1019
2016
  return send(404, { reason: 'unknown route' });
1020
2017
  } catch (e) {
1021
2018
  // Falla cerrado y con código HTTP válido: e.code puede ser un string del sistema ('ENOENT').
1022
2019
  const status = Number.isInteger(e.status) ? e.status : (Number.isInteger(e.code) ? e.code : 500);
1023
2020
  if (status >= 500) this.log(`error ${rx.method} ${path}: ${e.message}`);
1024
- return send(status, { reason: e.message });
2021
+ return send(status, { reason: e.message }, e.headers || null);
1025
2022
  }
1026
2023
  }
1027
2024