@nyx5/nyx5 0.7.0 → 0.9.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.
Files changed (53) hide show
  1. package/README.md +33 -5
  2. package/agents.md +24 -7
  3. package/bin/nyx5.js +153 -6
  4. package/demo/x402-pagar.mjs +64 -0
  5. package/docs/ARQUITECTURA.md +352 -0
  6. package/docs/HOUSE.es.md +237 -0
  7. package/docs/HOUSE.md +234 -0
  8. package/docs/SPEC.md +337 -6
  9. package/examples/ci/nyx5-ejecutor.yml +82 -0
  10. package/examples/frameworks/README.md +55 -0
  11. package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
  12. package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
  13. package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
  14. package/examples/frameworks/claude-agent-sdk.mjs +48 -0
  15. package/examples/frameworks/crewai_nyx5.py +72 -0
  16. package/examples/frameworks/http.md +117 -0
  17. package/examples/frameworks/langgraph_nyx5.py +90 -0
  18. package/examples/frameworks/nyx5_http.py +255 -0
  19. package/examples/frameworks/openai-agents.mjs +41 -0
  20. package/examples/hola-mundo.mjs +4 -4
  21. package/migrations/0009_contratos_estado.sql +4 -0
  22. package/package.json +6 -2
  23. package/server.json +2 -2
  24. package/src/correo/acciones.js +216 -0
  25. package/src/correo/adjuntos.js +185 -0
  26. package/src/correo/agente.js +247 -13
  27. package/src/correo/asistente.js +502 -77
  28. package/src/correo/ci.js +262 -0
  29. package/src/correo/cobro.js +164 -0
  30. package/src/correo/estafeta.js +558 -59
  31. package/src/correo/ideas.js +154 -0
  32. package/src/correo/identidad.js +81 -0
  33. package/src/correo/politica.js +92 -3
  34. package/src/correo/unirse.js +14 -4
  35. package/src/libro/libro.js +6 -2
  36. package/src/libro/verifica.js +239 -36
  37. package/src/nucleo/almacen-d1.js +24 -5
  38. package/src/nucleo/almacen.js +31 -4
  39. package/src/nucleo/crypto.js +36 -0
  40. package/src/nucleo/keccak.js +118 -0
  41. package/src/nucleo/objetos.js +78 -0
  42. package/src/nucleo/secp256k1.js +228 -0
  43. package/src/plataformas/app-html.js +1 -1
  44. package/src/plataformas/home-html.js +1 -1
  45. package/src/plataformas/node.js +18 -1
  46. package/src/plataformas/spec-html.js +2 -2
  47. package/src/plataformas/terms-html.js +2 -0
  48. package/src/plataformas/worker.js +21 -1
  49. package/src/puentes/ejecutor.js +118 -0
  50. package/src/puentes/herramientas.js +137 -12
  51. package/src/puentes/mcp.js +4 -2
  52. package/src/puentes/x402-pagador.js +248 -0
  53. package/src/version.js +1 -1
@@ -3,9 +3,12 @@
3
3
  // Las operaciones del Libro son sobres firmados a libro@<casa>; las respuestas vuelven como recibos al buzón.
4
4
 
5
5
  import { Resolver, parseAddress } from './resolver.js';
6
- import { EXT_PROYECTO, proyectoDe, rolDe, nombreDeProyecto } from './politica.js';
7
- import { generateKeys, signObject, verifyObject, signBytes, canonical, b64u, uuid, encryptContent, decryptContent, mintPow, sha256hex } from '../nucleo/crypto.js';
6
+ import { EXT_PROYECTO, EXT_RESUMEN, RESUMEN_MAX, ADJUNTOS, proyectoDe, rolDe, resumenDe, nombreDeProyecto, limpio, validarAdjuntos } from './politica.js';
7
+ import { generateKeys, signObject, verifyObject, signBytes, canonical, b64u, uuid, encryptContent, decryptContent, encryptBlob, decryptBlob, generateBlobKey, mintPow, sha256hex } from '../nucleo/crypto.js';
8
+ import { aadDe as aadBlob } from './adjuntos.js';
8
9
  import { Libro, MEDIA } from '../libro/libro.js';
10
+ import { pruebaDeAceptacion } from '../libro/verifica.js';
11
+ import { MEDIA_COBRO, MEDIA_COBRO_CONFIRMACION, cobroValido, confirmacionValida, extensionDeCobro, razonParaNoCobrar } from './cobro.js';
9
12
 
10
13
  const iso = (t = Date.now()) => new Date(t).toISOString();
11
14
 
@@ -81,19 +84,39 @@ export class Agent {
81
84
  }
82
85
 
83
86
  // ---------- envío ----------
84
- async send({ to, type = 'message', body, media, encrypt = true, thread, inReplyTo, expires, deliverAfter, attachments, extensions, receipt, project, role }) {
87
+ async send({ to, type = 'message', body, media, encrypt = true, thread, inReplyTo, expires, deliverAfter, attachments, extensions, receipt, project, role, summary }) {
85
88
  const recipients = Array.isArray(to) ? to : [to];
86
89
  const id = uuid();
87
90
  // Proyecto y rol viajan como extensión firmada: el buzón del otro lado filtra por proyecto.
88
- if (project || role) extensions = { ...(extensions || {}), [EXT_PROYECTO]: { ...(project ? { project: nombreDeProyecto(project) } : {}), ...(role ? { role: String(role).trim().slice(0, 40) } : {}) } };
91
+ // Un proyecto que no normaliza a un nombre (sólo invisibles, o `__proto__`/`constructor`) se
92
+ // rechaza aquí, con nombre: antes viajaba como `project: null` y el remitente creía haberlo etiquetado.
93
+ const proyecto = project ? nombreDeProyecto(project) : null;
94
+ if (project && !proyecto) throw new Error(`invalid project name: ${JSON.stringify(String(project).slice(0, 40))} (empty after normalization, or a reserved word)`);
95
+ if (proyecto || role) extensions = { ...(extensions || {}), [EXT_PROYECTO]: { ...(proyecto ? { project: proyecto } : {}), ...(role ? { role: String(role).trim().slice(0, 40) } : {}) } };
96
+ // El resumen EN CLARO: una línea que la casa puede leer y poner en el asunto del aviso por
97
+ // correo, porque el cuerpo va cifrado y nadie más que el destinatario lo abre. Lo decide quien
98
+ // escribe, no la casa. Se rechaza aquí, con nombre, si no queda nada después de limpiarlo: que
99
+ // viaje vacío sería creer que etiquetaste y no haberlo hecho (el mismo defecto que `project`).
100
+ if (summary != null) {
101
+ const r = limpio(String(summary), RESUMEN_MAX);
102
+ if (!r) throw new Error('invalid summary: empty after cleaning (it must be one readable line)');
103
+ extensions = { ...(extensions || {}), [EXT_RESUMEN]: { text: r } };
104
+ }
89
105
  // Un sobre que responde a otro hereda su hilo. Defecto real (12-sep-2026): las respuestas iban con
90
106
  // in_reply_to y thread null, y la conversación quedaba como mensajes sueltos; el historial firmado
91
107
  // es el producto, y sin hilo no es historial. La casa no puede rellenarlo: el sobre va firmado.
92
108
  if (inReplyTo && thread == null) thread = await this._hiloDe(inReplyTo, recipients[0]);
109
+ // Adjuntos (NX-413): lo que llega aquí son los descriptores que devolvió `subirAdjunto`, o sea
110
+ // archivos YA cifrados y YA guardados en el almacén de esta casa. `url` no se firma: se deriva
111
+ // de la casa del remitente al bajar, así que una url en el sobre sería una url que alguien
112
+ // puede cambiar por otra.
113
+ const adj = attachments == null ? null : (Array.isArray(attachments) ? attachments : [attachments]).map((a) => ({
114
+ blob: String(a?.blob || '').toLowerCase(), bytes: a?.bytes, name: a?.name, media: a?.media, sha256: a?.sha256, key: a?.key,
115
+ }));
93
116
  const base = {
94
117
  nyx5: '1', id, from: this.address, to: recipients, created: iso(),
95
118
  expires: expires ?? null, deliver_after: deliverAfter ?? undefined, thread: thread ?? null, in_reply_to: inReplyTo ?? null, type,
96
- attachments, extensions, receipt,
119
+ extensions, receipt,
97
120
  };
98
121
  const content = { media: media || (typeof body === 'string' ? 'text/plain' : 'application/json'), body };
99
122
 
@@ -123,10 +146,19 @@ export class Agent {
123
146
  // `to` no cambia y la AAD tampoco. Sin esto, lo que uno mismo mandó cifrado es ilegible para
124
147
  // uno mismo, y el historial de una conversación queda con la mitad de los mensajes en blanco.
125
148
  if (this.keys.enc && !lectores.some((l) => l.address === this.address)) lectores.push({ address: this.address, enc: this.keys.enc });
126
- env = { ...base, encrypted: await encryptContent(content, lectores, aad(base)) };
149
+ // La ficha COMPLETA de cada adjunto —nombre, tipo, hash del claro y la llave— va DENTRO del
150
+ // bloque cifrado. Afuera sólo queda `blob` y `bytes`: lo que la casa necesita para servir el
151
+ // objeto y cobrar su cupo, y nada de lo que diga qué hay dentro.
152
+ env = { ...base, attachments: adj ? adj.map((a) => ({ blob: a.blob, bytes: a.bytes })) : undefined, encrypted: await encryptContent(adj ? { ...content, attachments: adj } : content, lectores, aad(base)) };
127
153
  } else {
128
154
  if (encrypt === 'required') throw new Error('a recipient does not publish an encryption key');
129
- env = { ...base, content };
155
+ // Sin cifrar, la ficha viaja a la vista y la LLAVE también: el objeto sigue cifrado en el
156
+ // almacén, pero la casa puede abrirlo. Se permite y se dice; `nyx5_attach` lo advierte.
157
+ env = { ...base, attachments: adj || undefined, content };
158
+ }
159
+ if (adj) {
160
+ const malo = validarAdjuntos(env.attachments, { cifrado: !!env.encrypted });
161
+ if (malo) throw new Error(malo);
130
162
  }
131
163
  const powBits = Math.max(0, ...cards.map((c) => (c.inbox?.policy === 'pow' ? c.inbox.pow_bits ?? 16 : 0)));
132
164
  if (powBits) env.pow = mintPow(id, powBits);
@@ -154,6 +186,77 @@ export class Agent {
154
186
  try { return await this._call('POST', '/email/out', { to, subject, body }); }
155
187
  catch (e) { if (e.status === 503 && e.body?.pending) return e.body; throw e; }
156
188
  }
189
+ // ---------- adjuntos (NX-413): archivos de verdad en el sobre ----------
190
+ // Sube un archivo CIFRADO al almacén de objetos de su propia casa y devuelve el descriptor que
191
+ // `send({ attachments: [...] })` mete dentro del bloque cifrado. La llave se genera aquí, es
192
+ // distinta para cada adjunto, y nunca sale de este descriptor: la casa aloja bytes que no puede
193
+ // abrir (invariante 8). El sha256 es del CLARO, para que el receptor compruebe que descifró lo
194
+ // correcto; el del cifrado lo guarda la casa para su propia integridad.
195
+ async subirAdjunto(datos, { name, media = 'application/octet-stream', to = [] } = {}) {
196
+ const buf = Buffer.from(datos);
197
+ if (!buf.length) throw new Error('an attachment needs bytes');
198
+ const nombre = limpio(String(name ?? ''), ADJUNTOS.nombreMax);
199
+ if (!nombre) throw new Error('an attachment needs a name (it travels inside the encrypted block)');
200
+ // El tope se comprueba ANTES de cifrar y de subir: no se gastan 11 MB de red para que la casa
201
+ // diga no, y el motivo es el mismo que daría ella.
202
+ if (buf.length > ADJUNTOS.bytesPorAdjunto) throw Object.assign(new Error(`attachment of ${buf.length} bytes exceeds the maximum ${ADJUNTOS.bytesPorAdjunto} (${Math.round(ADJUNTOS.bytesPorAdjunto / 1024 / 1024)} MB) per attachment`), { status: 413 });
203
+ const destinos = (Array.isArray(to) ? to : [to]).filter(Boolean).map((t) => { const { local, domain } = parseAddress(t); return `${local}@${domain}`; });
204
+ const blob = uuid();
205
+ const key = generateBlobKey();
206
+ const objeto = encryptBlob(buf, key, aadBlob(blob, this.address));
207
+ const ruta = `/blob/${blob}`;
208
+ const q = destinos.length ? `?to=${encodeURIComponent(destinos.join(','))}` : '';
209
+ const res = await this.fetch(`${this.estafeta}${ruta}${q}`, {
210
+ method: 'PUT', body: objeto, signal: AbortSignal.timeout(120_000),
211
+ headers: { 'content-type': 'application/octet-stream', authorization: this._auth('PUT', ruta) },
212
+ });
213
+ const j = await res.json().catch(() => ({}));
214
+ if (!res.ok) throw Object.assign(new Error(j.reason || `HTTP ${res.status}`), { status: res.status, body: j });
215
+ return { blob, bytes: buf.length, name: nombre, media, sha256: sha256hex(buf), key, url: j.url };
216
+ }
217
+ // Baja un adjunto de la casa del REMITENTE (la url se deriva, no se lee del sobre), lo descifra y
218
+ // comprueba el hash del claro. Si el hash no cuadra, el error dice CUÁL adjunto: con varios en un
219
+ // sobre, «el hash no cuadra» sin nombre no se puede accionar.
220
+ async bajarAdjunto(att, { from = this.address } = {}) {
221
+ const quien = att?.name || att?.blob || '(unnamed)';
222
+ if (!att?.blob) throw new Error('an attachment reference needs blob');
223
+ if (!att?.key) throw new Error(`attachment ${quien}: no key. In an encrypted envelope the key travels INSIDE the encrypted block: open the envelope first and pass the attachment it gives you`);
224
+ const { domain } = parseAddress(from);
225
+ const base = domain === this.domain ? this.estafeta : (await this.resolver.domainCard(domain))._estafeta;
226
+ const ruta = `/blob/${String(att.blob).toLowerCase()}`;
227
+ const res = await this.fetch(`${base}${ruta}`, { headers: { authorization: this._auth('GET', ruta, this.keys, base) }, signal: AbortSignal.timeout(120_000) });
228
+ if (!res.ok) {
229
+ const j = await res.json().catch(() => ({}));
230
+ throw Object.assign(new Error(j.reason || `HTTP ${res.status}`), { status: res.status, body: j });
231
+ }
232
+ const objeto = Buffer.from(await res.arrayBuffer());
233
+ let claro;
234
+ try { claro = decryptBlob(objeto, att.key, aadBlob(att.blob, from)); }
235
+ catch { throw new Error(`attachment ${quien}: could not be decrypted (wrong key, or it was not sent by ${from}: the AAD ties the object to its sender)`); }
236
+ if (att.sha256 && sha256hex(claro) !== String(att.sha256).toLowerCase()) {
237
+ throw new Error(`attachment ${quien}: the sha256 of the plaintext does not match what the envelope declares (expected ${String(att.sha256).toLowerCase()}, got ${sha256hex(claro)})`);
238
+ }
239
+ return claro;
240
+ }
241
+ // El sobre con adjuntos que está en el buzón (o en la conversación, si ya se confirmó), abierto.
242
+ async conAdjuntos(id, from = null) {
243
+ let m = (await this.inbox({ limit: 200 })).find((x) => x.envelope?.id === id);
244
+ if (!m && from) m = (await this.conversation(from, { limit: 200 })).find((x) => x.envelope?.id === id);
245
+ if (!m) return null;
246
+ const o = await this.open(m.envelope);
247
+ if (!o.attachments?.length) throw new Error(`envelope ${id} carries no attachments`);
248
+ return o;
249
+ }
250
+ // Lo borra antes de que se cumpla la retención. Sólo quien lo subió: la copia es del remitente.
251
+ async borrarAdjunto(blob, { house = this.domain } = {}) {
252
+ const base = house === this.domain ? this.estafeta : (await this.resolver.domainCard(house))._estafeta;
253
+ const ruta = `/blob/${String(blob).toLowerCase()}`;
254
+ const res = await this.fetch(`${base}${ruta}`, { method: 'DELETE', headers: { authorization: this._auth('DELETE', ruta, this.keys, base) }, signal: AbortSignal.timeout(30_000) });
255
+ const j = await res.json().catch(() => ({}));
256
+ if (!res.ok) throw Object.assign(new Error(j.reason || `HTTP ${res.status}`), { status: res.status, body: j });
257
+ return j;
258
+ }
259
+
157
260
  // El hilo del sobre al que se responde: el suyo si lo tenía, si no su propio id. Se busca en la
158
261
  // conversación con el destinatario (lo recibido y lo enviado); si la casa no lo conoce, el id.
159
262
  async _hiloDe(inReplyTo, con) {
@@ -187,17 +290,144 @@ export class Agent {
187
290
  // Una cotización es un documento firmado por el vendedor; viaja dentro de un sobre (cifrado) al comprador.
188
291
  // Con `service` (NX-301) el precio, el contrato y el concepto se leen de la propia ficha publicada
189
292
  // si no vienen; lo que venga explícito viaja igual y la casa lo cruza con la ficha al aceptar.
190
- async quote({ to, contract, price, concept, terms, expires, arbiter, house, referrer, service }) {
293
+ // `thread`/`inReplyTo`: cuando la cotización contesta un pedido (NX-305) viaja en su hilo.
294
+ async quote({ to, contract, price, concept, terms, expires, arbiter, house, referrer, service, thread, inReplyTo }) {
191
295
  if (service != null) {
192
- this.resolver.invalidate(`agent:${this.address}`);
193
- const s = ((await this.resolver.agentCard(this.address)).profile?.services || []).find((x) => x.id === service);
194
- if (!s) throw new Error(`service "${service}" is not published in your profile; publish it with setProfile first`);
296
+ const s = await this._servicioPropio(service);
195
297
  price ??= s.price.tokens; contract ??= s.contract; concept ??= s.name;
196
298
  }
197
299
  const q = Libro.buildQuote({ seller: this.address, buyer: to, house: house || parseAddress(to).domain, contract: contract ?? 'spot', price, concept, terms, expires, arbiter, referrer, service }, this.keys);
198
- const sent = await this.send({ to, type: 'message', media: MEDIA.cotizacion, body: q, expires: expires ?? null });
300
+ const sent = await this.send({ to, type: 'message', media: MEDIA.cotizacion, body: q, expires: expires ?? null, thread, inReplyTo });
199
301
  return { quote: q, ...sent };
200
302
  }
303
+ async _servicioPropio(service) {
304
+ this.resolver.invalidate(`agent:${this.address}`);
305
+ const s = ((await this.resolver.agentCard(this.address)).profile?.services || []).find((x) => x.id === service);
306
+ if (!s) throw new Error(`service "${service}" is not published in your profile; publish it with setProfile first`);
307
+ return s;
308
+ }
309
+
310
+ // ---------- NX-305: contratar desde el catálogo en un paso ----------
311
+ // El comprador PIDE (sobre `task` con media pedido: { service, input, note }); el vendedor
312
+ // contesta con la cotización de su propia ficha TAL CUAL; el comprador la acepta si coincide.
313
+ // Lado vendedor: la cotización que responde a un pedido, armada desde la ficha publicada. El
314
+ // precio y el contrato son los de la ficha (la casa los cruza al aceptar); la prueba sale de
315
+ // `acceptance` + el `input` del pedido; el árbitro es verifica@ de la casa del contrato.
316
+ // `pedido` es el sobre ya abierto (lo que devuelve `open`).
317
+ async quoteFromCatalog(pedido, { service = null } = {}) {
318
+ if (pedido?.content?.media !== MEDIA.pedido) throw new Error(`not a service request: media is ${pedido?.content?.media || '(none)'}, expected ${MEDIA.pedido}`);
319
+ const b = pedido.content.body || {};
320
+ if (typeof b.service !== 'string' || !b.service) throw new Error('the request does not name a service');
321
+ if (service != null && service !== b.service) throw new Error(`the request asks for "${b.service}", not "${service}"`);
322
+ const s = await this._servicioPropio(b.service);
323
+ const input = b.input && typeof b.input === 'object' && !Array.isArray(b.input) ? b.input : {};
324
+ const house = parseAddress(pedido.from).domain;
325
+ // Sin prueba publicada no hay árbitro: un escrow queda como pago diferido (SPEC §16, `expire`).
326
+ const verify = s.acceptance ? pruebaDeAceptacion(s.acceptance, input) : null;
327
+ const terms = { input, ...(s.acceptance ? { acceptance: s.acceptance.template, verify } : {}), ...(typeof b.note === 'string' && b.note ? { note: b.note.slice(0, 500) } : {}) };
328
+ return this.quote({ to: pedido.from, service: s.id, terms, arbiter: verify ? `verifica@${house}` : undefined, house, thread: pedido.thread || pedido.id, inReplyTo: pedido.id });
329
+ }
330
+ // Un pedido recibido, por id: en la bandeja o, si ya se confirmó, en la conversación con `from`.
331
+ async pedido(id, from = null) {
332
+ let m = (await this.inbox({ limit: 200 })).find((x) => x.envelope?.id === id);
333
+ if (!m && from) m = (await this.conversation(from, { limit: 200 })).find((x) => x.dir === 'in' && x.envelope?.id === id);
334
+ if (!m) return null;
335
+ const o = await this.open(m.envelope);
336
+ if (o.content?.media !== MEDIA.pedido) throw new Error(`envelope ${id} is not a service request (media ${o.content?.media || '(none)'})`);
337
+ return o;
338
+ }
339
+ // Lado comprador. Manda el pedido y, con `autoAccept`, espera la cotización hasta `wait`
340
+ // segundos y la acepta SOLO si es la del catálogo que este cliente leyó: mismo servicio, mismo
341
+ // vendedor, precio y contrato publicados, precio <= maxPrice, el mismo input, y con prueba
342
+ // publicada, verifica@ de árbitro y la prueba del `kind` publicado. Cualquier diferencia se
343
+ // devuelve nombrada y NO se acepta. Lo que impide siquiera pedir (no vende, servicio
344
+ // inexistente, precio sobre el tope) se lanza como error.
345
+ async hire({ agent, service, input = {}, note, autoAccept = true, maxPrice = null, wait = 25 } = {}) {
346
+ if (typeof agent !== 'string' || typeof service !== 'string' || !service) throw new Error('hire needs agent (address) and service (id)');
347
+ if (input == null) input = {};
348
+ if (typeof input !== 'object' || Array.isArray(input)) throw new Error('input must be an object');
349
+ this.resolver.invalidate(`agent:${String(agent).toLowerCase()}`);
350
+ const card = await this.resolver.agentCard(agent, { onBehalfOf: this.address });
351
+ if (card.delegation?.scope?.messages_only) throw new Error(`${card.address} does not sell by itself: it is a messages-only address (a connected Claude) and cannot quote; write to it, or to its owner ${card.delegation.by}`);
352
+ const publicados = card.profile?.services || [];
353
+ const s = publicados.find((x) => x.id === service);
354
+ if (!s) throw new Error(publicados.length ? `${card.address} does not publish service "${service}"; it publishes: ${publicados.map((x) => x.id).join(', ')}` : `${card.address} publishes no services; write to it instead`);
355
+ if (maxPrice != null && s.price.tokens > maxPrice) throw new Error(`service "${service}" is published at ${s.price.tokens} tok; your max_price is ${maxPrice}. Nothing was requested`);
356
+ const pedido = await this.send({ to: card.address, type: 'task', media: MEDIA.pedido, body: { service, input, ...(note ? { note: String(note) } : {}) }, encrypt: true });
357
+ const base = { request: pedido.id, agent: card.address, service, published: { price: s.price.tokens, contract: s.contract, acceptance: s.acceptance || null } };
358
+ if (!autoAccept) return { ...base, accepted: false, status: 'requested', note: 'the quote will arrive in your mailbox; accept it yourself' };
359
+ const m = await this.wait({ from: card.address, thread: pedido.id, seconds: Math.max(1, Math.min(Number(wait) || 25, 90)) });
360
+ if (!m) return { ...base, accepted: false, status: 'no_quote', reason: `no quote arrived within ${wait} s; the request waits in ${card.address}'s mailbox and the quote, if any, will land in yours` };
361
+ const o = await this.open(m.envelope);
362
+ if (o.content?.media !== MEDIA.cotizacion) { await this.ack(m.envelope.id).catch(() => {}); return { ...base, accepted: false, status: 'declined', reason: 'the seller answered without a quote', reply: o.content }; }
363
+ const q = o.content.body || {};
364
+ const rechazo = (reason) => ({ ...base, accepted: false, status: 'rejected', reason, quote: q });
365
+ const cotizado = await (async () => {
366
+ if (q.seller !== card.address) return `the quote is signed by ${q.seller}, not by ${card.address}`;
367
+ if (q.buyer !== this.address) return `the quote names ${q.buyer} as buyer, not you`;
368
+ if (q.service !== service) return `the quote is for service ${JSON.stringify(q.service ?? null)}, you asked for "${service}"`;
369
+ if (q.price !== s.price.tokens || q.contract !== s.contract) return `service ${service} is published at ${s.price.tokens} tok as ${s.contract}; the quote says ${q.price} as ${q.contract}`;
370
+ if (maxPrice != null && q.price > maxPrice) return `the quote is ${q.price} tok, above your max_price of ${maxPrice}`;
371
+ if (canonical(q.terms?.input ?? {}) !== canonical(input)) return 'the quote does not carry the input you sent';
372
+ if (s.acceptance) {
373
+ const v = q.terms?.verify;
374
+ if (q.arbiter !== `verifica@${q.house}`) return `the service publishes a ${s.acceptance.kind} test but the quote names ${q.arbiter || 'no'} arbiter instead of verifica@${q.house}`;
375
+ if (!v || v.type !== s.acceptance.kind) return `the service publishes a ${s.acceptance.kind} test; the quote carries ${v?.type || 'none'}`;
376
+ // La prueba ENTERA tiene que ser la que sale de la ficha y de este input (revisión del 14-sep:
377
+ // un vendedor cotizaba con el tipo correcto y su propia URL siempre-200, y cobraba sin tocar el trabajo).
378
+ let esperada; try { esperada = pruebaDeAceptacion(s.acceptance, input); } catch (e) { return `the published test cannot be derived from your input: ${e.message}`; }
379
+ if (canonical(v) !== canonical(esperada)) return `the quote's test differs from the published one applied to your input: expected ${JSON.stringify(esperada)}, got ${JSON.stringify(v)}`;
380
+ }
381
+ return null;
382
+ })();
383
+ await this.ack(m.envelope.id).catch(() => {});
384
+ if (cotizado) return rechazo(cotizado);
385
+ const enviada = await this.accept(q);
386
+ const r = await this.awaitReceipt(enviada.id, { timeoutMs: 15_000 });
387
+ if (r.from !== `libro@${q.house}`) return { ...base, accepted: false, status: 'rejected', reason: `the house rejected the acceptance: ${r.receipt?.reason || 'no reason given'}`, quote: q };
388
+ const c = r.receipt.contract;
389
+ return { ...base, accepted: true, status: 'hired', quote_id: q.id, contract: c, price: c.amount, verification: c.arbiter && c.terms?.verify ? { arbiter: c.arbiter, verify: c.terms.verify, when: 'after the seller delivers (nyx5_libro op=deliver), verifica@ runs the test and releases or refunds' } : null };
390
+ }
391
+ // ---------- NX-502: pedido de pago por transferencia (dinero real, fuera del Libro) ----------
392
+ // Manda a UNA persona un cobro cifrado (monto, moneda, nombre, RUT, banco, cuenta, referencia).
393
+ // Se valida antes de firmar (RUT módulo 11, monto por moneda); el sobre va cifrado o no va; y
394
+ // nunca a una dirección cuya llave guarda la casa (razonParaNoCobrar). Lo que la casa ve es la
395
+ // extensión en claro { kind, request_id, currency }: con eso anota `payment_requested`, sin monto.
396
+ async paymentRequest({ to, thread, ...datos } = {}) {
397
+ if (typeof to !== 'string' || !to) throw new Error('paymentRequest needs to: the address of who pays');
398
+ const cobro = cobroValido({ ...datos, request_id: datos.request_id ?? uuid() });
399
+ this.resolver.invalidate(`agent:${String(to).toLowerCase()}`);
400
+ const card = await this.resolver.agentCard(to, { onBehalfOf: this.address });
401
+ const razon = razonParaNoCobrar(card);
402
+ if (razon) throw new Error(razon);
403
+ const r = await this.send({ to: card.address, type: 'message', media: MEDIA_COBRO, body: cobro, encrypt: 'required', thread, extensions: extensionDeCobro('request', cobro) });
404
+ return { id: r.id, request_id: cobro.request_id, to: card.address, currency: cobro.currency, encrypted: r.encrypted, jobs: r.jobs };
405
+ }
406
+ // Un cobro recibido, por id de sobre: en la bandeja o, si ya se confirmó, en la conversación con `from`.
407
+ async cobro(id, from = null) {
408
+ let m = (await this.inbox({ limit: 200 })).find((x) => x.envelope?.id === id);
409
+ if (!m && from) m = (await this.conversation(from, { limit: 200 })).find((x) => x.dir === 'in' && x.envelope?.id === id);
410
+ if (!m) return null;
411
+ const o = await this.open(m.envelope);
412
+ if (!o.encrypted) throw new Error(`envelope ${id} arrived in the clear: a payment request only counts encrypted`);
413
+ if (o.content?.media !== MEDIA_COBRO) throw new Error(`envelope ${id} is not a payment request (media ${o.content?.media || '(none)'})`);
414
+ return o;
415
+ }
416
+ // Quien pagó desde su banco le contesta a quien pidió, en el hilo del pedido, con la referencia
417
+ // bancaria. Cifrado o nada, y con la misma regla de destino que el pedido. `pedido` es el sobre
418
+ // abierto (lo que devuelve `cobro`). Nyx5 no comprueba el pago: lleva la palabra del pagador.
419
+ async paymentConfirm(pedido, { bank_reference } = {}) {
420
+ if (pedido?.content?.media !== MEDIA_COBRO) throw new Error(`not a payment request: media is ${pedido?.content?.media || '(none)'}, expected ${MEDIA_COBRO}`);
421
+ if (!pedido.to.includes(this.address)) throw new Error(`payment request ${pedido.id} was not addressed to ${this.address}`);
422
+ const confirmacion = confirmacionValida({ request_id: pedido.content.body?.request_id, bank_reference });
423
+ const card = await this.resolver.agentCard(pedido.from, { onBehalfOf: this.address });
424
+ const razon = razonParaNoCobrar(card);
425
+ if (razon) throw new Error(razon);
426
+ const currency = pedido.content.body?.currency;
427
+ const r = await this.send({ to: card.address, type: 'result', media: MEDIA_COBRO_CONFIRMACION, body: confirmacion, encrypt: 'required', thread: pedido.thread || pedido.id, inReplyTo: pedido.id, extensions: extensionDeCobro('confirmation', { request_id: confirmacion.request_id, currency }) });
428
+ return { id: r.id, request_id: confirmacion.request_id, to: card.address, in_reply_to: pedido.id, encrypted: r.encrypted, jobs: r.jobs };
429
+ }
430
+
201
431
  // Operación genérica: sobre firmado, sin cifrar, a libro@<casa>. La respuesta llega como recibo.
202
432
  libroOp(house, body, opts = {}) {
203
433
  return this.send({ to: `libro@${house}`, type: 'task', media: MEDIA.op, body, encrypt: false, ...opts });
@@ -339,7 +569,11 @@ export class Agent {
339
569
  const aGrupo = envelope.to.some((t) => { try { return parseAddress(t).local.startsWith('g.'); } catch { return false; } });
340
570
  if (!envelope.encrypted && !aGrupo && !envelope.to.includes(this.address) && envelope.from !== this.address) throw new Error(`envelope ${envelope.id} is not addressed to ${this.address}`);
341
571
  const content = envelope.encrypted ? await decryptContent(envelope.encrypted, this.address, this.keys, aad(envelope)) : envelope.content;
342
- return { id: envelope.id, from: envelope.from, to: envelope.to, type: envelope.type, thread: envelope.thread, in_reply_to: envelope.in_reply_to, created: envelope.created, encrypted: !!envelope.encrypted, project: proyectoDe(envelope), role: rolDe(envelope), sender: card, content };
572
+ // Los adjuntos de un sobre cifrado están DENTRO del contenido (con nombre, tipo y llave); los
573
+ // de un sobre en claro, afuera. Se devuelven en el mismo sitio en los dos casos para que quien
574
+ // lee no tenga que saber cuál era: lo que recibe es lo que `bajarAdjunto` necesita.
575
+ const adj = (envelope.encrypted ? content?.attachments : envelope.attachments) || null;
576
+ return { id: envelope.id, from: envelope.from, to: envelope.to, type: envelope.type, thread: envelope.thread, in_reply_to: envelope.in_reply_to, created: envelope.created, encrypted: !!envelope.encrypted, project: proyectoDe(envelope), role: rolDe(envelope), summary: resumenDe(envelope), attachments: adj, sender: card, content };
343
577
  }
344
578
 
345
579
  // Espera hasta que llegue un sobre que cumpla el filtro (útil para pruebas y flujos síncronos).