@nyx5/nyx5 0.6.0 → 0.8.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 (64) hide show
  1. package/README.md +50 -7
  2. package/bin/nyx5.js +81 -2
  3. package/demo/edge-local.mjs +41 -0
  4. package/demo/x402-pagar.mjs +64 -0
  5. package/demo/x402-testnet.mjs +80 -0
  6. package/docs/ARQUITECTURA.md +354 -0
  7. package/docs/SPEC.md +414 -15
  8. package/examples/ci/nyx5-ejecutor.yml +82 -0
  9. package/examples/frameworks/README.md +55 -0
  10. package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
  11. package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
  12. package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
  13. package/examples/frameworks/claude-agent-sdk.mjs +48 -0
  14. package/examples/frameworks/crewai_nyx5.py +72 -0
  15. package/examples/frameworks/http.md +117 -0
  16. package/examples/frameworks/langgraph_nyx5.py +90 -0
  17. package/examples/frameworks/nyx5_http.py +255 -0
  18. package/examples/frameworks/openai-agents.mjs +41 -0
  19. package/examples/hola-mundo.mjs +4 -4
  20. package/migrations/0006_puente_remoto.sql +19 -0
  21. package/migrations/0007_indice_reputacion.sql +31 -0
  22. package/migrations/0008_notaria.sql +18 -0
  23. package/migrations/0009_contratos_estado.sql +4 -0
  24. package/package.json +4 -2
  25. package/server.json +2 -2
  26. package/src/correo/acciones.js +216 -0
  27. package/src/correo/adjuntos.js +185 -0
  28. package/src/correo/agente.js +370 -22
  29. package/src/correo/asistente.js +599 -0
  30. package/src/correo/ci.js +262 -0
  31. package/src/correo/cobro.js +164 -0
  32. package/src/correo/estafeta.js +1548 -58
  33. package/src/correo/ideas.js +154 -0
  34. package/src/correo/indice.js +178 -0
  35. package/src/correo/politica.js +280 -2
  36. package/src/correo/resolver.js +35 -11
  37. package/src/libro/contratos.js +116 -7
  38. package/src/libro/estado.js +150 -0
  39. package/src/libro/informe.js +147 -3
  40. package/src/libro/libro.js +83 -12
  41. package/src/libro/notaria.js +74 -0
  42. package/src/libro/verifica.js +239 -36
  43. package/src/nucleo/almacen-d1.js +143 -14
  44. package/src/nucleo/almacen.js +137 -13
  45. package/src/nucleo/boveda.js +37 -0
  46. package/src/nucleo/crypto.js +55 -12
  47. package/src/nucleo/keccak.js +118 -0
  48. package/src/nucleo/objetos.js +78 -0
  49. package/src/nucleo/secp256k1.js +228 -0
  50. package/src/plataformas/app-html.js +1 -1
  51. package/src/plataformas/home-html.js +1 -1
  52. package/src/plataformas/iconos.js +6 -0
  53. package/src/plataformas/node.js +28 -4
  54. package/src/plataformas/spec-html.js +1 -1
  55. package/src/plataformas/terms-html.js +2 -0
  56. package/src/plataformas/worker.js +52 -7
  57. package/src/puentes/ejecutor.js +118 -0
  58. package/src/puentes/herramientas.js +315 -0
  59. package/src/puentes/mcp-remoto.js +71 -0
  60. package/src/puentes/mcp.js +5 -99
  61. package/src/puentes/oauth.js +361 -0
  62. package/src/puentes/x402-pagador.js +248 -0
  63. package/src/puentes/x402.js +266 -0
  64. package/src/version.js +4 -0
@@ -3,8 +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 { 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';
7
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';
8
12
 
9
13
  const iso = (t = Date.now()) => new Date(t).toISOString();
10
14
 
@@ -15,7 +19,9 @@ export class Agent {
15
19
  this.keys = keys;
16
20
  this.estafeta = estafeta.replace(/\/$/, '');
17
21
  this.fetch = (...a) => fetchImpl(...a); // envuelto: workerd exige fetch con this=globalThis
18
- this.resolver = resolver || new Resolver({ hosts: { [domain]: { url: this.estafeta }, ...hosts }, fetchImpl });
22
+ // El cliente firma ante su propia casa cuando una tarjeta no aparece sin autenticar (NX-202):
23
+ // así un contacto de un agente secreto lo resuelve, y un extraño ve lo mismo que si no existiera.
24
+ this.resolver = resolver || new Resolver({ hosts: { [domain]: { url: this.estafeta }, ...hosts }, fetchImpl, auth: { domain, url: this.estafeta, header: (method, path) => this._auth(method, path) } });
19
25
  }
20
26
 
21
27
  static create(address, estafeta, opts = {}) { return new Agent({ address, estafeta, keys: generateKeys(), ...opts }); }
@@ -30,12 +36,12 @@ export class Agent {
30
36
  const token = b64u(canonical(claims));
31
37
  return `Nyx5 ${token}.${signBytes(canonical(claims), keys)}`;
32
38
  }
33
- async _call(method, path, body, { admin, noAuth, authKeys } = {}) {
39
+ async _call(method, path, body, { admin, noAuth, authKeys, timeoutMs = 10_000 } = {}) {
34
40
  const headers = { 'content-type': 'application/json' };
35
41
  if (admin) headers.authorization = `Bearer ${admin}`; else if (!noAuth) headers.authorization = this._auth(method, path.split('?')[0], authKeys);
36
42
  const res = await this.fetch(`${this.estafeta}${path}`, {
37
43
  method, headers,
38
- body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10_000),
44
+ body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs),
39
45
  });
40
46
  const json = await res.json().catch(() => ({}));
41
47
  if (!res.ok) throw Object.assign(new Error(json.reason || `HTTP ${res.status}`), { status: res.status, body: json });
@@ -45,10 +51,12 @@ export class Agent {
45
51
  // ---------- registro ----------
46
52
  // Tres caminos: adminToken (la casa inscribe), invite (código de la casa) o abierto si la casa lo permite.
47
53
  // Sin adminToken y sin estar registrado, el cuerpo va firmado con la propia clave (prueba de posesión).
48
- async register({ adminToken, invite, capabilities, inbox, webhook, notify_email, valid_until, source } = {}) {
54
+ async register({ adminToken, invite, capabilities, inbox, wallet, wallets, webhook, notify_email, valid_until, source, visibility, profile } = {}) {
49
55
  // `source` es atribución de distribución: viaja al alta, se registra en el evento `join`
50
56
  // y NO entra en la tarjeta. Nadie puede leer de dónde vino un agente mirando su tarjeta.
51
- const body = { local: this.local, sig: this.keys.sig, enc: this.keys.enc, capabilities, inbox, webhook, notify_email, valid_until, source };
57
+ // `wallet` es la dirección a la que este agente quiere que le paguen en dinero real. Es
58
+ // PÚBLICA y va en la tarjeta: la casa no la controla ni puede mover nada de ella.
59
+ const body = { local: this.local, sig: this.keys.sig, enc: this.keys.enc, capabilities, inbox, wallet, wallets, webhook, notify_email, valid_until, source, visibility, profile };
52
60
  if (adminToken) this.card = await this._call('POST', '/agents', body, { admin: adminToken });
53
61
  else if (this.card) this.card = await this._call('POST', '/agents', body);
54
62
  else this.card = await this._call('POST', '/agents', signObject({ ...body, invite: invite || undefined, ts: iso() }, this.keys), { noAuth: true });
@@ -76,27 +84,81 @@ export class Agent {
76
84
  }
77
85
 
78
86
  // ---------- envío ----------
79
- async send({ to, type = 'message', body, media, encrypt = true, thread, inReplyTo, expires, deliverAfter, attachments, extensions, receipt }) {
87
+ async send({ to, type = 'message', body, media, encrypt = true, thread, inReplyTo, expires, deliverAfter, attachments, extensions, receipt, project, role, summary }) {
80
88
  const recipients = Array.isArray(to) ? to : [to];
81
89
  const id = uuid();
90
+ // Proyecto y rol viajan como extensión firmada: el buzón del otro lado filtra por proyecto.
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
+ }
105
+ // Un sobre que responde a otro hereda su hilo. Defecto real (12-sep-2026): las respuestas iban con
106
+ // in_reply_to y thread null, y la conversación quedaba como mensajes sueltos; el historial firmado
107
+ // es el producto, y sin hilo no es historial. La casa no puede rellenarlo: el sobre va firmado.
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
+ }));
82
116
  const base = {
83
117
  nyx5: '1', id, from: this.address, to: recipients, created: iso(),
84
118
  expires: expires ?? null, deliver_after: deliverAfter ?? undefined, thread: thread ?? null, in_reply_to: inReplyTo ?? null, type,
85
- attachments, extensions, receipt,
119
+ extensions, receipt,
86
120
  };
87
121
  const content = { media: media || (typeof body === 'string' ? 'text/plain' : 'application/json'), body };
88
122
 
89
123
  // Tarjetas de los destinatarios: para cifrar (clave enc) y para saber si exigen proof-of-work.
90
- const cards = await Promise.all(recipients.map((r) => this.resolver.agentCard(r).catch((e) => ({ address: r, _error: e.message }))));
124
+ // La tarjeta de un grupo cambia cada vez que entra o sale alguien: se pide fresca, siempre.
125
+ // Defecto real de la primera prueba: con la tarjeta en caché, el miembro nuevo recibía un sobre
126
+ // que no podía abrir.
127
+ for (const r of recipients) { try { if (parseAddress(r).local.startsWith('g.')) this.resolver.invalidate(`agent:${r}`); } catch { /* dirección inválida: la resolución lo dirá */ } }
128
+ const cards = await Promise.all(recipients.map((r) => this.resolver.agentCard(r, { onBehalfOf: this.address }).catch((e) => ({ address: r, _error: e.message }))));
91
129
  const missing = cards.filter((c) => c._error);
92
130
  if (missing.length) throw new Error(`could not resolve: ${missing.map((c) => `${c.address} (${c._error})`).join(', ')}`);
93
131
 
132
+ // Un grupo no tiene llave: se cifra para cada uno de sus miembros, que sí la publican. La casa
133
+ // reparte el sobre tal cual y nunca puede abrirlo.
134
+ const lectores = [];
135
+ for (const c of cards) {
136
+ if (!c.group) { lectores.push({ address: c.address, enc: c.enc }); continue; }
137
+ for (const dir of c.group.members) {
138
+ if (dir === this.address) continue;
139
+ const mc = await this.resolver.agentCard(dir, { onBehalfOf: this.address }).catch(() => null);
140
+ lectores.push({ address: dir, enc: mc?.enc || null });
141
+ }
142
+ }
94
143
  let env;
95
- if (encrypt && cards.every((c) => c.enc)) {
96
- env = { ...base, encrypted: encryptContent(content, cards.map((c) => ({ address: c.address, enc: c.enc })), aad(base)) };
144
+ if (encrypt && lectores.every((l) => l.enc)) {
145
+ // El remitente también recibe una copia de la llave del contenido. No es un destinatario más:
146
+ // `to` no cambia y la AAD tampoco. Sin esto, lo que uno mismo mandó cifrado es ilegible para
147
+ // uno mismo, y el historial de una conversación queda con la mitad de los mensajes en blanco.
148
+ if (this.keys.enc && !lectores.some((l) => l.address === this.address)) lectores.push({ address: this.address, enc: this.keys.enc });
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)) };
97
153
  } else {
98
154
  if (encrypt === 'required') throw new Error('a recipient does not publish an encryption key');
99
- 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);
100
162
  }
101
163
  const powBits = Math.max(0, ...cards.map((c) => (c.inbox?.policy === 'pow' ? c.inbox.pow_bits ?? 16 : 0)));
102
164
  if (powBits) env.pow = mintPow(id, powBits);
@@ -108,7 +170,9 @@ export class Agent {
108
170
 
109
171
  const signed = signObject(env, this.keys);
110
172
  const r = await this._call('POST', '/outbound', signed);
111
- return { id, envelope: signed, jobs: r.jobs };
173
+ // `encrypted` dice lo que de verdad pasó: un destinatario sin llave de cifrado recibe en claro,
174
+ // y quien manda tiene que poder saberlo en vez de creer que se cifró.
175
+ return { id, envelope: signed, jobs: r.jobs, encrypted: !!env.encrypted };
112
176
  }
113
177
  // Un mensaje a tu yo futuro: llega a tu propio buzón en la fecha indicada, cifrado (solo tú lo abres).
114
178
  // La cola ya lo sostiene; esto le da a un agente memoria operativa entre sesiones.
@@ -122,6 +186,87 @@ export class Agent {
122
186
  try { return await this._call('POST', '/email/out', { to, subject, body }); }
123
187
  catch (e) { if (e.status === 503 && e.body?.pending) return e.body; throw e; }
124
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
+
260
+ // El hilo del sobre al que se responde: el suyo si lo tenía, si no su propio id. Se busca en la
261
+ // conversación con el destinatario (lo recibido y lo enviado); si la casa no lo conoce, el id.
262
+ async _hiloDe(inReplyTo, con) {
263
+ try {
264
+ const msgs = await this.conversation(con, { limit: 200 });
265
+ const m = msgs.find((x) => (x.envelope || x).id === inReplyTo);
266
+ if (m) return (m.envelope || m).thread || inReplyTo;
267
+ } catch { /* sin historial disponible: el id basta como raíz del hilo */ }
268
+ return inReplyTo;
269
+ }
125
270
  reply(envelope, body, opts = {}) {
126
271
  return this.send({ to: envelope.from, thread: envelope.thread || envelope.id, inReplyTo: envelope.id, type: opts.type || 'result', body, ...opts });
127
272
  }
@@ -143,11 +288,146 @@ export class Agent {
143
288
 
144
289
  // ---------- Libro: cotizaciones y contratos ----------
145
290
  // Una cotización es un documento firmado por el vendedor; viaja dentro de un sobre (cifrado) al comprador.
146
- async quote({ to, contract = 'spot', price, concept, terms, expires, arbiter, house, referrer }) {
147
- const q = Libro.buildQuote({ seller: this.address, buyer: to, house: house || parseAddress(to).domain, contract, price, concept, terms, expires, arbiter, referrer }, this.keys);
148
- const sent = await this.send({ to, type: 'message', media: MEDIA.cotizacion, body: q, expires: expires ?? null });
291
+ // Con `service` (NX-301) el precio, el contrato y el concepto se leen de la propia ficha publicada
292
+ // si no vienen; lo que venga explícito viaja igual y la casa lo cruza con la ficha al aceptar.
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 }) {
295
+ if (service != null) {
296
+ const s = await this._servicioPropio(service);
297
+ price ??= s.price.tokens; contract ??= s.contract; concept ??= s.name;
298
+ }
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);
300
+ const sent = await this.send({ to, type: 'message', media: MEDIA.cotizacion, body: q, expires: expires ?? null, thread, inReplyTo });
149
301
  return { quote: q, ...sent };
150
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
+
151
431
  // Operación genérica: sobre firmado, sin cifrar, a libro@<casa>. La respuesta llega como recibo.
152
432
  libroOp(house, body, opts = {}) {
153
433
  return this.send({ to: `libro@${house}`, type: 'task', media: MEDIA.op, body, encrypt: false, ...opts });
@@ -156,6 +436,7 @@ export class Agent {
156
436
  deliver(house, contract, { evidence_sha256, note } = {}) { return this.libroOp(house, { op: 'deliver', contract, evidence_sha256, note }); }
157
437
  release(house, contract) { return this.libroOp(house, { op: 'release', contract }); }
158
438
  refund(house, contract, note) { return this.libroOp(house, { op: 'refund', contract, note }); }
439
+ reclaim(house, contract) { return this.libroOp(house, { op: 'reclaim', contract }); }
159
440
  bond(house, { amount, claim, verifier, beneficiary, arbiter, evidence_sha256, expires, vouchee }) { return this.libroOp(house, { op: 'bond', amount, claim, verifier, beneficiary, arbiter, evidence_sha256, expires, vouchee }); }
160
441
  // Avalar a un desconocido para que entre a un buzón con lista blanca: una fianza en la casa del
161
442
  // receptor, con el receptor como verificador y beneficiario. Si la presentación es basura, la ejecuta.
@@ -166,9 +447,19 @@ export class Agent {
166
447
  mandate(house, { grantee, cap, scope, expires, parent }) { return this.libroOp(house, { op: 'mandate', grantee, cap, scope, expires, parent }); }
167
448
  charge(house, { mandate, amount, concept }) { return this.libroOp(house, { op: 'charge', mandate, amount, concept }); }
168
449
  revoke(house, mandate) { return this.libroOp(house, { op: 'revoke', mandate }); }
450
+ pay(house, { to, amount, concept }) { return this.libroOp(house, { op: 'pay', to, amount, concept }); }
451
+ // Notaría (NX-601): la casa sella el hash de un documento con fecha y firma; el sello llega como
452
+ // recibo. Gratis; el mismo hash sellado dos veces por el mismo agente devuelve el mismo sello.
453
+ notarize(house, { sha256, name, media, note }) { return this.libroOp(house, { op: 'notarize', sha256, name, media, note }); }
169
454
 
170
455
  // Lecturas directas (sin pasar por correo) en la casa indicada; por defecto, la propia estafeta.
171
456
  async balance(house) { return this._callAt(house, 'GET', `/libro/cuenta/${encodeURIComponent(this.address)}`); }
457
+ // Estado de cuenta por rango (NX-501), lectura directa firmada: opening + in − out = closing.
458
+ // `since`/`until` ISO-8601 ([since, until)); `limit` acota cuántos asientos, los más recientes.
459
+ async statement(house, { since, until, limit } = {}) {
460
+ const q = new URLSearchParams(Object.entries({ desde: since, hasta: until, limit }).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]));
461
+ return this._callAt(house, 'GET', `/libro/estado${q.size ? `?${q}` : ''}`);
462
+ }
172
463
  async contract(house, id) { return this._callAt(house, 'GET', `/libro/contrato/${encodeURIComponent(id)}`); }
173
464
  // Historial público de cualquier agente (por defecto, el propio): la reputación es el libro.
174
465
  async historial(address = this.address) {
@@ -179,6 +470,20 @@ export class Agent {
179
470
  if (!res.ok) throw Object.assign(new Error(json.reason || `HTTP ${res.status}`), { status: res.status });
180
471
  return json;
181
472
  }
473
+ // Sellos PÚBLICOS de un hash en una casa (sin cuenta): null si no hay ninguno. Cada sello se
474
+ // verifica contra la tarjeta del dominio; si la casa sirviera uno que no firmó, se rechaza entero.
475
+ async notarized(sha256, house = this.domain) {
476
+ const dc = await this.resolver.domainCard(house);
477
+ const res = await this.fetch(`${dc._estafeta}/notaria/${encodeURIComponent(String(sha256).toLowerCase())}`, { signal: AbortSignal.timeout(10_000) });
478
+ if (res.status === 404) return null;
479
+ const json = await res.json().catch(() => ({}));
480
+ if (!res.ok) throw Object.assign(new Error(json.reason || `HTTP ${res.status}`), { status: res.status });
481
+ const kids = (dc.keys || []).map((k) => k.sig);
482
+ for (const s of json.seals || []) {
483
+ if (!kids.includes(s.signature?.kid) || !verifyObject(s, s.signature.kid)) throw new Error(`seal ${s.id} is not signed by ${house}`);
484
+ }
485
+ return json;
486
+ }
182
487
  async _callAt(house, method, path) {
183
488
  if (!house || house === this.domain) return this._call(method, path);
184
489
  const dc = await this.resolver.domainCard(house);
@@ -197,8 +502,10 @@ export class Agent {
197
502
 
198
503
  // Búsqueda en un índice federado (urn:nyx5:ext:indice): por casa que lo opera o URL directa.
199
504
  // El índice es una pista: cada tarjeta se re-verifica por la cadena normal al usarla.
200
- async search(index, { q, capability, accepts, house, limit, offset } = {}) {
201
- const params = new URLSearchParams(Object.entries({ q, capability, accepts, house, limit, offset }).filter(([, v]) => v != null));
505
+ // Filtros: q, tag, lang, capability, accepts, house, price_max, min_score, limit; y `cursor`
506
+ // (el next_cursor de la página anterior). No hay offset: el índice lo rechaza con 400.
507
+ async search(index, { q, tag, lang, capability, accepts, house, price_max, min_score, limit, cursor } = {}) {
508
+ const params = new URLSearchParams(Object.entries({ q, tag, lang, capability, accepts, house, price_max, min_score, limit, cursor }).filter(([, v]) => v != null));
202
509
  const base = index.startsWith('http') ? index.replace(/\/$/, '') : (await this.resolver.domainCard(index))._estafeta;
203
510
  const res = await this.fetch(`${base}/index/agents?${params}`, { signal: AbortSignal.timeout(10_000) });
204
511
  const json = await res.json().catch(() => ({}));
@@ -211,6 +518,41 @@ export class Agent {
211
518
  async ack(ids) { return (await this._call('POST', `/mailbox/${this.local}/ack`, { ids: Array.isArray(ids) ? ids : [ids] })).acked; }
212
519
  async outbox() { return (await this._call('GET', `/outbox/${this.local}`)).sent; }
213
520
 
521
+ // ---------- conversación y tiempo real ----------
522
+ // El historial vive en la casa, no en la sesión: desde otro dispositivo se retoma igual.
523
+ async conversation(withAddress, { limit = 30, project = null } = {}) {
524
+ const q = new URLSearchParams({ with: String(withAddress), limit: String(limit), ...(project ? { project } : {}) });
525
+ return (await this._call('GET', `/conversations/${this.local}?${q}`)).messages;
526
+ }
527
+ async conversations({ project = null } = {}) { const q = project ? `?${new URLSearchParams({ project })}` : ''; return (await this._call('GET', `/conversations/${this.local}${q}`)).conversations; }
528
+ // Ficha pública: la propia (o la de un delegado propio) se declara; la de cualquiera se lee de su tarjeta.
529
+ setProfile(profile, { of = null } = {}) { const l = of ? parseAddress(of).local : this.local; return this._call('POST', `/agents/${encodeURIComponent(l)}/profile`, { profile }); }
530
+ async profile(address) { this.resolver.invalidate(`agent:${String(address).toLowerCase()}`); const c = await this.resolver.agentCard(address, { onBehalfOf: this.address }); return { address: c.address, profile: c.profile || null, capabilities: c.capabilities || {} }; }
531
+ // «Visto por última vez» de una dirección de una casa (null si el dueño no lo activó).
532
+ async presence(address) {
533
+ const { local, domain } = parseAddress(address);
534
+ const r = await this._callAt(domain, 'GET', `/agents/${encodeURIComponent(local)}/presence`).catch(() => null);
535
+ return r?.last_seen || null;
536
+ }
537
+ // ---------- grupos ----------
538
+ // `g` acepta la dirección completa (g.equipo@casa) o el nombre (equipo). Los miembros son de la casa.
539
+ static _localDeGrupo(g) { const s = String(g || '').toLowerCase(); const l = s.includes('@') ? parseAddress(s).local : s; return l.startsWith('g.') ? l : `g.${l}`; }
540
+ createGroup(name, { members = [], post = 'members' } = {}) { return this._call('POST', '/groups', { name: String(name).replace(/^g\./, ''), members, post }); }
541
+ group(g) { return this._call('GET', `/groups/${encodeURIComponent(Agent._localDeGrupo(g))}/members`); }
542
+ editGroup(g, { add = [], remove = [], admins = [] } = {}) { return this._call('POST', `/groups/${encodeURIComponent(Agent._localDeGrupo(g))}/members`, { add, remove, admins }); }
543
+ leaveGroup(g) { return this.editGroup(g, { remove: [this.address] }); }
544
+ // Espera el próximo sobre pendiente que cumpla el filtro. No sondea desde aquí: la casa responde
545
+ // apenas llega. Devuelve null si se acabó el tiempo sin nada.
546
+ async wait({ from, thread, since, project, seconds = 25 } = {}) {
547
+ const q = new URLSearchParams(Object.entries({ from, thread, since, project, timeout: String(seconds) }).filter(([, v]) => v != null && v !== ''));
548
+ return (await this._call('GET', `/mailbox/${this.local}/wait?${q}`, undefined, { timeoutMs: (Number(seconds) + 15) * 1000 })).message;
549
+ }
550
+ // Quién soy y qué puedo hacer: la tarjeta certificada, resumida.
551
+ async whoami() {
552
+ const c = await this.resolver.agentCard(this.address);
553
+ return { address: c.address, delegated_by: c.delegation?.by || null, valid_until: c.valid_until || null, scope: c.delegation?.scope || null, custody: c.custody || null, inbox: c.inbox || null, encryption: !!c.enc };
554
+ }
555
+
214
556
  // Verifica la cadena de confianza del remitente y descifra si corresponde.
215
557
  async open(envelope) {
216
558
  // Correo entrante por el puente (urn:nyx5:ext:email): sin firma, en claro, marcado como NO
@@ -219,13 +561,19 @@ export class Agent {
219
561
  if (em && !envelope.signature) {
220
562
  return { id: envelope.id, from: em.from, to: envelope.to, type: envelope.type, created: envelope.created, verified: false, via: 'email', subject: em.subject || null, content: envelope.content };
221
563
  }
222
- const card = await this.resolver.agentCardForKid(envelope.from, envelope.signature?.kid);
564
+ const card = await this.resolver.agentCardForKid(envelope.from, envelope.signature?.kid, { onBehalfOf: this.address });
223
565
  const verified = Resolver.acceptedKids(card).includes(envelope.signature?.kid) && verifyObject(envelope, envelope.signature.kid);
224
566
  if (!verified) throw new Error(`invalid signature on envelope ${envelope.id} from ${envelope.from}`);
225
567
  if (envelope.expires && Date.parse(envelope.expires) < Date.now()) throw new Error(`sobre vencido: ${envelope.id}`);
226
- if (!envelope.encrypted && !envelope.to.includes(this.address)) throw new Error(`envelope ${envelope.id} is not addressed to ${this.address}`);
227
- const content = envelope.encrypted ? decryptContent(envelope.encrypted, this.address, this.keys, aad(envelope)) : envelope.content;
228
- 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, sender: card, content };
568
+ // Un sobre a un grupo llega con la dirección del grupo, no la del miembro que lo abre.
569
+ const aGrupo = envelope.to.some((t) => { try { return parseAddress(t).local.startsWith('g.'); } catch { return false; } });
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}`);
571
+ const content = envelope.encrypted ? await decryptContent(envelope.encrypted, this.address, this.keys, aad(envelope)) : envelope.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 };
229
577
  }
230
578
 
231
579
  // Espera hasta que llegue un sobre que cumpla el filtro (útil para pruebas y flujos síncronos).