@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.
- package/README.md +50 -7
- package/bin/nyx5.js +81 -2
- package/demo/edge-local.mjs +41 -0
- package/demo/x402-pagar.mjs +64 -0
- package/demo/x402-testnet.mjs +80 -0
- package/docs/ARQUITECTURA.md +354 -0
- package/docs/SPEC.md +414 -15
- package/examples/ci/nyx5-ejecutor.yml +82 -0
- package/examples/frameworks/README.md +55 -0
- package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
- package/examples/frameworks/claude-agent-sdk.mjs +48 -0
- package/examples/frameworks/crewai_nyx5.py +72 -0
- package/examples/frameworks/http.md +117 -0
- package/examples/frameworks/langgraph_nyx5.py +90 -0
- package/examples/frameworks/nyx5_http.py +255 -0
- package/examples/frameworks/openai-agents.mjs +41 -0
- package/examples/hola-mundo.mjs +4 -4
- package/migrations/0006_puente_remoto.sql +19 -0
- package/migrations/0007_indice_reputacion.sql +31 -0
- package/migrations/0008_notaria.sql +18 -0
- package/migrations/0009_contratos_estado.sql +4 -0
- package/package.json +4 -2
- package/server.json +2 -2
- package/src/correo/acciones.js +216 -0
- package/src/correo/adjuntos.js +185 -0
- package/src/correo/agente.js +370 -22
- package/src/correo/asistente.js +599 -0
- package/src/correo/ci.js +262 -0
- package/src/correo/cobro.js +164 -0
- package/src/correo/estafeta.js +1548 -58
- package/src/correo/ideas.js +154 -0
- package/src/correo/indice.js +178 -0
- package/src/correo/politica.js +280 -2
- package/src/correo/resolver.js +35 -11
- package/src/libro/contratos.js +116 -7
- package/src/libro/estado.js +150 -0
- package/src/libro/informe.js +147 -3
- package/src/libro/libro.js +83 -12
- package/src/libro/notaria.js +74 -0
- package/src/libro/verifica.js +239 -36
- package/src/nucleo/almacen-d1.js +143 -14
- package/src/nucleo/almacen.js +137 -13
- package/src/nucleo/boveda.js +37 -0
- package/src/nucleo/crypto.js +55 -12
- package/src/nucleo/keccak.js +118 -0
- package/src/nucleo/objetos.js +78 -0
- package/src/nucleo/secp256k1.js +228 -0
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +1 -1
- package/src/plataformas/iconos.js +6 -0
- package/src/plataformas/node.js +28 -4
- package/src/plataformas/spec-html.js +1 -1
- package/src/plataformas/terms-html.js +2 -0
- package/src/plataformas/worker.js +52 -7
- package/src/puentes/ejecutor.js +118 -0
- package/src/puentes/herramientas.js +315 -0
- package/src/puentes/mcp-remoto.js +71 -0
- package/src/puentes/mcp.js +5 -99
- package/src/puentes/oauth.js +361 -0
- package/src/puentes/x402-pagador.js +248 -0
- package/src/puentes/x402.js +266 -0
- package/src/version.js +4 -0
package/src/correo/agente.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
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).
|