@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/estafeta.js
CHANGED
|
@@ -19,31 +19,65 @@
|
|
|
19
19
|
// body, ip } -> { status, body }. El almacenamiento es async (FileStore local, D1Store en el edge).
|
|
20
20
|
|
|
21
21
|
import { FileStore } from '../nucleo/almacen.js';
|
|
22
|
+
import { ObjetosDisco } from '../nucleo/objetos.js';
|
|
22
23
|
import { Resolver, parseAddress } from './resolver.js';
|
|
23
|
-
import { validateEnvelope, applyInboxPolicy, RateLimiter } from './politica.js';
|
|
24
|
+
import { validateEnvelope, applyInboxPolicy, applyEmailPolicy, RateLimiter, RateLimiterDurable, proyectoDe, resumenDe, nombreDeProyecto, validarPerfil, usdSinBilletera } from './politica.js';
|
|
24
25
|
import { generateSigningKeys, signObject, verifyObject, signBytes, verifyBytes, canonical, uuid, unb64u, sha256hex } from '../nucleo/crypto.js';
|
|
25
26
|
import { Libro, MEDIA, LibroError } from '../libro/libro.js';
|
|
26
27
|
import { veredicto, pruebasDe, pruebasDisponibles } from '../libro/verifica.js';
|
|
27
28
|
import { contratoPublico, ACP } from '../libro/contratos.js';
|
|
29
|
+
import { estadoDeCuenta, csvDe, nombreCsv } from '../libro/estado.js';
|
|
30
|
+
import { selloPublico } from '../libro/notaria.js';
|
|
28
31
|
import { Tareas } from '../libro/tareas.js';
|
|
29
|
-
import { datosInforme, informeHtml } from '../libro/informe.js';
|
|
32
|
+
import { datosInforme, datosEmbudo, informeHtml } from '../libro/informe.js';
|
|
30
33
|
import { APP_HTML } from '../plataformas/app-html.js';
|
|
31
34
|
import { SPEC_HTML, LLMS_TXT } from '../plataformas/spec-html.js';
|
|
32
35
|
import { HOME_HTML } from '../plataformas/home-html.js';
|
|
33
36
|
import { OG_PNG_B64 } from '../plataformas/og-png.js';
|
|
34
37
|
import { inboundEnvelope, outboundPayload, isEmailAddress } from '../puentes/email.js';
|
|
38
|
+
import * as x402 from '../puentes/x402.js';
|
|
39
|
+
import * as oauth from '../puentes/oauth.js';
|
|
40
|
+
import { atenderMcp } from '../puentes/mcp-remoto.js';
|
|
41
|
+
import { abrirBoveda } from '../nucleo/boveda.js';
|
|
42
|
+
import { ICONOS } from '../plataformas/iconos.js';
|
|
43
|
+
import { Agent } from './agente.js';
|
|
44
|
+
import { eventoDeCobro } from './cobro.js';
|
|
45
|
+
import { atenderAsistentes, gastoDe, PRECIOS, MEDIA_GATE } from './asistente.js';
|
|
46
|
+
import { atenderIdeas, paginaDeIdeas, puertaIdeas, LOCAL as IDEAS_LOCAL, VIA as IDEAS_VIA } from './ideas.js';
|
|
47
|
+
import { subirAdjunto, bajarAdjunto, borrarAdjunto, limpiarAdjuntos, capacidadAdjuntos } from './adjuntos.js';
|
|
48
|
+
import { atenderEjecutores, despachar as despacharCi, despachable, validarConfig as validarCi, urlDe as urlCi, PROVEEDORES as CI_PROVEEDORES, INTENTOS as CI_INTENTOS, CUPO_DESPACHOS_DIA } from './ci.js';
|
|
49
|
+
import { validarFiltros, puntajeDe, precioMinimo, FILTROS } from './indice.js';
|
|
35
50
|
|
|
36
51
|
const now = () => Date.now();
|
|
37
52
|
const iso = (t = now()) => new Date(t).toISOString();
|
|
38
53
|
const RETRYABLE = new Set([408, 421, 425, 429, 500, 502, 503, 504]);
|
|
54
|
+
// CORS sólo donde lo necesita un cliente MCP que corre en un navegador (el MCP Inspector, por
|
|
55
|
+
// ejemplo): descubrimiento, registro, token y /mcp. El resto de la casa sigue siendo mismo-origen.
|
|
56
|
+
const CORS = {
|
|
57
|
+
'access-control-allow-origin': '*',
|
|
58
|
+
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
|
59
|
+
'access-control-allow-headers': 'authorization, content-type, mcp-protocol-version, mcp-session-id, last-event-id',
|
|
60
|
+
'access-control-expose-headers': 'www-authenticate, mcp-session-id',
|
|
61
|
+
'access-control-max-age': '600',
|
|
62
|
+
};
|
|
63
|
+
const rutaCors = (p) => p.startsWith('/.well-known/oauth-') || p === '/oauth/register' || p === '/oauth/token' || p === '/mcp' || oauth.RUTA_INVITACION.test(p);
|
|
64
|
+
// La app se instala en la pantalla de inicio: en iPhone es lo que evita que Safari borre la llave
|
|
65
|
+
// del usuario a los siete días sin abrirla.
|
|
66
|
+
const MANIFIESTO = { name: 'Nyx5', short_name: 'Nyx5', start_url: '/app', scope: '/', display: 'standalone', background_color: '#FFFFFF', theme_color: '#12A594', icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }] };
|
|
67
|
+
|
|
68
|
+
// Direcciones por petición en GET /agents/historial?addresses=… (NX-905). El rastreo del índice
|
|
69
|
+
// pide lotes de este tamaño y una casa que no tenga la ruta (versión anterior) contesta 404.
|
|
70
|
+
export const HISTORIAL_LOTE_MAX = 50;
|
|
71
|
+
// Proyectos que la lista de conversaciones devuelve por contacto (14-sep-2026): los de último uso.
|
|
72
|
+
export const PROYECTOS_POR_CONTACTO = 20;
|
|
39
73
|
|
|
40
74
|
export class Estafeta {
|
|
41
75
|
constructor({
|
|
42
76
|
domain, dataDir, store, adminToken,
|
|
43
77
|
port = 4000, host = '127.0.0.1', publicUrl,
|
|
44
78
|
hosts = {}, fetchImpl = globalThis.fetch,
|
|
45
|
-
policy = {}, retry = {}, workerIntervalMs = 1000, libro = {},
|
|
46
|
-
index = {}, email = {}, verifica = {}, eventos = true, tareas = {}, terms = null,
|
|
79
|
+
policy = {}, retry = {}, workerIntervalMs = 1000, libro = {}, objetos, sinObjetos = false,
|
|
80
|
+
index = {}, email = {}, verifica = {}, eventos = true, tareas = {}, terms = null, remoto = {}, asistente = {}, ideas = {},
|
|
47
81
|
extensions = null,
|
|
48
82
|
log = (...a) => console.log(`[estafeta ${domain}]`, ...a),
|
|
49
83
|
}) {
|
|
@@ -54,6 +88,21 @@ export class Estafeta {
|
|
|
54
88
|
this.authHost = new URL(this.publicUrl).host;
|
|
55
89
|
this.adminToken = adminToken;
|
|
56
90
|
this.fetch = (...a) => fetchImpl(...a); // envuelto: workerd exige fetch con this=globalThis
|
|
91
|
+
// fetch que resuelve la propia casa EN PROCESO. Un Worker no puede pedirse su propia URL
|
|
92
|
+
// pública (522): el agente de un subagente delegado (el conector remoto) habla por aquí con su
|
|
93
|
+
// estafeta, pasando por las mismas rutas, la misma firma y la misma política que cualquiera.
|
|
94
|
+
this.fetchPropio = async (url, init = {}) => {
|
|
95
|
+
const u = new URL(String(url));
|
|
96
|
+
if (u.host !== this.authHost) return this.fetch(url, init);
|
|
97
|
+
const headers = {};
|
|
98
|
+
new Headers(init.headers || {}).forEach((v, k) => { headers[k] = v; });
|
|
99
|
+
let body = null;
|
|
100
|
+
if (init.body != null) { try { body = JSON.parse(init.body); } catch { body = null; } }
|
|
101
|
+
const out = await this.handleRequest({ method: String(init.method || 'GET').toUpperCase(), path: u.pathname, query: u.searchParams, headers, body, ip: 'local' });
|
|
102
|
+
if (out.pending) out.pending.catch(() => {});
|
|
103
|
+
const texto = out.contentType ? String(out.body ?? '') : JSON.stringify(out.body);
|
|
104
|
+
return new Response(out.status === 204 || out.status === 304 ? null : texto, { status: out.status, headers: { 'content-type': out.contentType || 'application/json' } });
|
|
105
|
+
};
|
|
57
106
|
this.log = log;
|
|
58
107
|
// registration: 'admin' (solo la casa inscribe) | 'invite' (código emitido por la casa) | 'open' (cualquiera, con prueba de posesión de clave)
|
|
59
108
|
this.policy = { inbound: 'verified', max_bytes: 1_048_576, rate_per_minute: 120, registration: 'admin', registrations_per_minute: 10, min_name_length: 4, ...policy };
|
|
@@ -64,12 +113,14 @@ export class Estafeta {
|
|
|
64
113
|
// el cron hace en una pasada para no comerse el minuto entero verificando.
|
|
65
114
|
this.verifica = { enabled: true, maxPorTick: 10, timeoutMs: 10_000, ...verifica };
|
|
66
115
|
this.eventos = eventos !== false;
|
|
116
|
+
this._primeros = new Set(); // raíces cuyo `first_message` esta instancia ya vio (ver _primerMensaje)
|
|
67
117
|
this.terms = terms || null; // HTML de los términos; sin esto, /terms no existe
|
|
68
118
|
// Trabajo sembrado: la casa es el primer comprador. Sin catálogo, apagado.
|
|
69
119
|
this.tareas = new Tareas(tareas);
|
|
70
120
|
// Puente de correo: entrada siempre disponible si la casa la enciende; salida solo si hay proveedor.
|
|
71
121
|
// `email.provider` es una función async(payload) (ver src/puentes/email.js); sin ella, la salida queda pendiente.
|
|
72
|
-
|
|
122
|
+
// `senders`: las únicas direcciones que pueden escribirle a una persona por correo. Vacío = cerrado.
|
|
123
|
+
this.email = { footer: email.footer === true, enabled: !!(email.enabled || email.provider), provider: email.provider || null, senders: new Set((email.senders || []).map((s) => String(s).trim().toLowerCase()).filter(Boolean)) };
|
|
73
124
|
this.extensions = extensions || [
|
|
74
125
|
'urn:nyx5:ext:mcp', 'urn:nyx5:ext:a2a', 'urn:nyx5:ext:libro',
|
|
75
126
|
...(this.index.enabled ? ['urn:nyx5:ext:indice'] : []),
|
|
@@ -79,8 +130,30 @@ export class Estafeta {
|
|
|
79
130
|
this.hostsOverride = hosts;
|
|
80
131
|
|
|
81
132
|
this.store = store || new FileStore(dataDir);
|
|
82
|
-
|
|
83
|
-
|
|
133
|
+
// Almacén de OBJETOS: dónde vive un adjunto cifrado (NX-413). Una casa de archivos lo tiene
|
|
134
|
+
// solo; una casa del edge lo tiene si su Worker le pasó el binding R2. Sin almacén, la ruta
|
|
135
|
+
// /blob contesta 503 y lo DICE: aceptar un archivo que no se va a guardar es peor que no
|
|
136
|
+
// tenerlo, porque el remitente cree que viajó. `sinObjetos` es para probar justamente eso.
|
|
137
|
+
this.objetos = objetos || (dataDir && !sinObjetos ? new ObjetosDisco(`${dataDir}/blobs`) : null);
|
|
138
|
+
// Límites de tasa durables (NX-901): cuentan en el almacén, así que valen para toda la casa y
|
|
139
|
+
// no por isolate. Un almacén sin contador (uno ajeno, mínimo) cae al de memoria.
|
|
140
|
+
const limitador = (perMinute, ns) => (this.store.kvIncrement ? new RateLimiterDurable({ store: this.store, perMinute, ns, log: (m) => this.log(m) }) : new RateLimiter({ perMinute }));
|
|
141
|
+
this.rate = limitador(this.policy.rate_per_minute, 'tasa');
|
|
142
|
+
this.regRate = limitador(this.policy.registrations_per_minute, 'tasa-alta');
|
|
143
|
+
// Conector MCP remoto (src/puentes/oauth.js + mcp-remoto.js). Existe sólo si la casa lo enciende
|
|
144
|
+
// Y tiene llave de bóveda: sin un lugar cifrado donde guardar la llave de un subagente, no hay
|
|
145
|
+
// conector. La llave de la bóveda no se guarda en `this.remoto`: sólo la bóveda la conoce.
|
|
146
|
+
const { vaultKey, ...remotoSinLlave } = remoto;
|
|
147
|
+
this.boveda = abrirBoveda(vaultKey || null);
|
|
148
|
+
this.remoto = { dias: 30, accesoS: 3600, refrescoMs: 30 * 24 * 3600 * 1000, ...remotoSinLlave, enabled: !!(remoto.enabled && this.boveda) };
|
|
149
|
+
this.remotoRate = limitador(240, 'tasa-remoto');
|
|
150
|
+
// Asistentes (src/correo/asistente.js): existen sólo si la casa tiene una clave de la API de
|
|
151
|
+
// Anthropic. La clave no se guarda aparte: sólo el asistente la usa, al llamar.
|
|
152
|
+
this.asistente = asistente.apiKey ? { apiKey: asistente.apiKey, fetch: (...a) => (asistente.fetchImpl || this.fetch)(...a) } : null;
|
|
153
|
+
// ideas@ (src/correo/ideas.js): el buzón que registra y confirma, nunca ejecuta. Se enciende por
|
|
154
|
+
// casa (NYX5_IDEAS=on) y exige la bóveda: su llave vive ahí, como la de un asistente de sistema.
|
|
155
|
+
// `cupo` sólo para pruebas (no sale de ninguna variable de entorno): el límite es de código.
|
|
156
|
+
this.ideas = { enabled: !!(ideas.enabled && this.boveda), cupo: ideas.cupo || undefined };
|
|
84
157
|
this._ready = null;
|
|
85
158
|
this._domainCardCache = null; // { value, until }
|
|
86
159
|
this._pushes = []; // avisos por webhook en vuelo (los espera flushPushes)
|
|
@@ -102,7 +175,7 @@ export class Estafeta {
|
|
|
102
175
|
// Resolución local de la propia casa: sin esto, verificar a un vendedor de casa exige un
|
|
103
176
|
// fetch del Worker a su propio dominio público, que Cloudflare corta (522) y deja la
|
|
104
177
|
// operación reintentando para siempre.
|
|
105
|
-
self: { domain: this.domain, estafeta: this.publicUrl, domainCard: () => this.domainCard(), agentCard: (local) => this.agentCard(local) },
|
|
178
|
+
self: { domain: this.domain, estafeta: this.publicUrl, domainCard: () => this.domainCard(), agentCard: (local) => this.agentCard(local), firmarPara: (addr, dom) => this._firmarPara(addr, dom) },
|
|
106
179
|
});
|
|
107
180
|
this.libro = new Libro({ domain: this.domain, store: this.store, keys: this.keys, resolver: this.resolver, log: this.log, ...this.libroOpts });
|
|
108
181
|
await this._ensureSystemAgents();
|
|
@@ -125,7 +198,11 @@ export class Estafeta {
|
|
|
125
198
|
// Agentes de sistema, firman con la clave del dominio:
|
|
126
199
|
// postmaster@ -> avisos de entrega y rebotes libro@ -> operaciones y recibos del Libro
|
|
127
200
|
if (!await this.store.getAgent('postmaster')) await this.registerAgent({ local: 'postmaster', sig: this.keys.sig, capabilities: { accepts: [] }, inbox: { policy: 'allowlist', allowlist: [] } });
|
|
128
|
-
|
|
201
|
+
// La tarjeta de libro@ anuncia las operaciones y el fee: se re-certifica cuando cambian (en
|
|
202
|
+
// producción decía las ops de hace una semana: ni pay, ni reclaim, ni expire).
|
|
203
|
+
const libroRec = await this.store.getAgent('libro');
|
|
204
|
+
const libroCaps = { accepts: [MEDIA.op], libro: { fee_bps: this.libro.feeBps, ops: this.libro.ops } };
|
|
205
|
+
if (!libroRec || canonical(libroRec.capabilities?.libro || {}) !== canonical(libroCaps.libro)) await this.registerAgent({ local: 'libro', sig: this.keys.sig, capabilities: libroCaps, inbox: { policy: 'open' } });
|
|
129
206
|
// verifica@ — el evaluador de referencia de la casa. Pruebas deterministas y nada más: un
|
|
130
207
|
// verificador que se equivoca castiga a un inocente. Su tarjeta declara cuáles puede correr
|
|
131
208
|
// en ESTE runtime (en el edge no hay shell) y se REESCRIBE si eso cambia: al añadir una
|
|
@@ -153,8 +230,46 @@ export class Estafeta {
|
|
|
153
230
|
try { await this.store.putEvent({ id: uuid(), name, ts: iso(), actor: actor || null, data }); }
|
|
154
231
|
catch (e) { this.log(`evento ${name} no registrado: ${e.message}`); }
|
|
155
232
|
}
|
|
233
|
+
// La fuente de atribución (`source`) es texto ajeno: se recorta y se sanea antes de entrar a
|
|
234
|
+
// un evento. Una sola definición para el join, la invitación y el enlace abierto.
|
|
235
|
+
static fuenteLimpia(x) { return typeof x === 'string' ? (x.slice(0, 64).replace(/[^\w.:@/-]/g, '') || null) : null; }
|
|
236
|
+
// Embudo (NX-801): `first_message` es la primera vez que una dirección raíz, o su Claude
|
|
237
|
+
// conectado `claude.<raíz>`, le escribe a OTRA dirección (no a sí misma, no a libro@). Se
|
|
238
|
+
// recuerda en el almacén por raíz (ns `primer_mensaje`, sin vencimiento) y en memoria por
|
|
239
|
+
// instancia: cuesta a lo sumo una escritura condicional por raíz, nunca una lectura de todos
|
|
240
|
+
// los eventos por sobre. Otros delegados no cuentan: un asistente que contesta solo no es el
|
|
241
|
+
// humano escribiendo. Un fallo aquí jamás detiene el envío.
|
|
242
|
+
async _primerMensaje(submitter, env) {
|
|
243
|
+
if (!this.eventos || !this.store.kvPutIfAbsent) return;
|
|
244
|
+
let raiz = submitter.address, via = 'root';
|
|
245
|
+
const del = submitter.record.delegation;
|
|
246
|
+
if (del) {
|
|
247
|
+
const padre = parseAddress(del.by);
|
|
248
|
+
if (padre.domain !== this.domain || submitter.local !== `claude.${padre.local}`) return;
|
|
249
|
+
raiz = del.by; via = 'claude';
|
|
250
|
+
}
|
|
251
|
+
if (this._primeros.has(raiz)) return;
|
|
252
|
+
const propias = new Set([env.from, raiz]);
|
|
253
|
+
if (!env.to.some((t) => !propias.has(t) && !String(t).startsWith('libro@'))) return;
|
|
254
|
+
try {
|
|
255
|
+
if (await this.store.kvPutIfAbsent('primer_mensaje', raiz, { ts: iso(), via })) await this._evento('first_message', raiz, { via });
|
|
256
|
+
this._primeros.add(raiz);
|
|
257
|
+
} catch (e) { this.log(`first_message ${raiz} no registrado: ${e.message}`); }
|
|
258
|
+
}
|
|
259
|
+
// `eventoDeCobro` copia campo por campo: aunque el remitente meta `amount` en la extensión, no entra.
|
|
260
|
+
async _eventoDeCobro(env, local) {
|
|
261
|
+
const ev = eventoDeCobro(env);
|
|
262
|
+
if (ev) await this._evento(ev.name, env.from, { ...ev.data, to: `${local}@${this.domain}` });
|
|
263
|
+
}
|
|
156
264
|
isSystem(local) { return ['postmaster', 'libro', 'verifica', 'tareas'].includes(local); }
|
|
157
|
-
|
|
265
|
+
// ¿Podría alguien registrarse HOY con este nombre? Mismas reglas que registerAgent, sin registrar nada.
|
|
266
|
+
async nombreDisponible(local) {
|
|
267
|
+
if (!local || Estafeta.RESERVED.has(local) || local.length < (this.policy.min_name_length || 0)) return false;
|
|
268
|
+
return !(await this.store.getAgent(local));
|
|
269
|
+
}
|
|
270
|
+
// `qa` es de la casa (NX-606): sólo un asistente de sistema puede vivir ahí, nunca un registro.
|
|
271
|
+
// `ideas` igual: sólo el buzón de vacaciones (POST /admin/ideas) puede ocuparlo.
|
|
272
|
+
static RESERVED = new Set(['postmaster', 'libro', 'verifica', 'tareas', 'casa', 'admin', 'root', 'abuse', 'security', 'hostmaster', 'noreply', 'no-reply', 'support', 'estafeta', 'nyx5', 'indice', 'historial', 'qa', 'ideas']);
|
|
158
273
|
|
|
159
274
|
// ---------- servicio de registro ----------
|
|
160
275
|
// Invitaciones: la casa emite códigos con usos y vencimiento; un agente los presenta al inscribirse.
|
|
@@ -183,7 +298,7 @@ export class Estafeta {
|
|
|
183
298
|
// Opt-in: nadie aparece en el directorio (ni, por lo tanto, en el índice federado que lo rastrea)
|
|
184
299
|
// sin haberlo pedido con `capabilities.listed: true`. El default es no figurar. El lookup directo
|
|
185
300
|
// por dirección (`/agents/:local`) sigue disponible: no listar no es esconder a quien ya te conoce.
|
|
186
|
-
let out = cards.filter((c) => c.capabilities?.listed === true);
|
|
301
|
+
let out = cards.filter((c) => c.visibility !== 'secret' && (c.visibility === 'public' || c.capabilities?.listed === true));
|
|
187
302
|
if (capability) out = out.filter((c) => c.capabilities?.[capability]);
|
|
188
303
|
if (accepts) out = out.filter((c) => c.capabilities?.accepts?.includes(accepts));
|
|
189
304
|
if (q) { const needle = String(q).toLowerCase(); out = out.filter((c) => c.address.includes(needle) || JSON.stringify(c.capabilities).toLowerCase().includes(needle)); }
|
|
@@ -202,6 +317,10 @@ export class Estafeta {
|
|
|
202
317
|
nyx5: '1', domain: this.domain, estafeta: this.publicUrl,
|
|
203
318
|
keys: rec.keys.map((k) => ({ sig: k.sig, created: k.created })),
|
|
204
319
|
policy: { inbound: this.policy.inbound, max_bytes: this.policy.max_bytes, registration: this.policy.registration, ...(this.policy.outbound ? { outbound: this.policy.outbound } : {}) },
|
|
320
|
+
// Los cupos de los adjuntos, SÓLO si esta casa los aloja de verdad (NX-413). Una casa sin
|
|
321
|
+
// almacén de objetos no los anuncia: anunciar un límite es prometer que hay dónde guardar, y
|
|
322
|
+
// esa promesa sin nada detrás es justamente el defecto que este ítem vino a cerrar.
|
|
323
|
+
...(this.objetos ? { attachments: capacidadAdjuntos() } : {}),
|
|
205
324
|
extensions: this.extensions,
|
|
206
325
|
};
|
|
207
326
|
const guardada = rec.card;
|
|
@@ -223,15 +342,31 @@ export class Estafeta {
|
|
|
223
342
|
}
|
|
224
343
|
|
|
225
344
|
// ---------- agentes ----------
|
|
226
|
-
|
|
345
|
+
static VISIBILIDADES = ['public', 'private', 'secret'];
|
|
346
|
+
// `system`: una dirección de sistema con llaves propias (un asistente de la casa, NX-606). Sólo
|
|
347
|
+
// la pone `_adminAsistente` (ruta de la casa): salta la lista de reservados y el largo mínimo,
|
|
348
|
+
// como verifica@ y tareas@, y no recibe regalo de bienvenida.
|
|
349
|
+
async registerAgent({ local, sig, enc = null, capabilities = {}, inbox = { policy: 'open' }, wallet = null, wallets = null, webhook = null, notify_email = null, valid_until = null, delegation = null, welcome = null, custody = null, group = null, profile = undefined, visibility = undefined, system = false }) {
|
|
350
|
+
// Visibilidad (NX-202): public = en el directorio; private = existe, lo encuentra quien sabe la
|
|
351
|
+
// dirección (lo de siempre; es el defecto); secret = a quien no está en su lista se le responde
|
|
352
|
+
// exactamente lo que a un nombre inexistente. Se conserva entre re-certificaciones.
|
|
353
|
+
if (visibility !== undefined && visibility !== null && !Estafeta.VISIBILIDADES.includes(visibility)) throw Object.assign(new Error(`visibility must be one of ${Estafeta.VISIBILIDADES.join(', ')}`), { status: 400 });
|
|
354
|
+
// La ficha viaja validada o no viaja: una clave ajena o un link que no es https la rechazan.
|
|
355
|
+
let perfil;
|
|
356
|
+
if (profile !== undefined) { const v = validarPerfil(profile); if (v.error) throw Object.assign(new Error(v.error), { status: 400 }); perfil = v.perfil ? { ...v.perfil, updated: iso() } : null; }
|
|
357
|
+
// Grupos (13-sep-2026): g.<nombre> es una dirección de grupo. La firma la casa, no tiene llave
|
|
358
|
+
// de cifrado (los miembros publican las suyas) y sólo nace por crearGrupo. Nadie registra un
|
|
359
|
+
// nombre g.* por la puerta normal, así que un grupo nunca puede ser suplantado por un agente.
|
|
360
|
+
if (String(local).startsWith('g.') && !group) throw Object.assign(new Error('names starting with g. are groups: create one with POST /groups'), { status: 409 });
|
|
361
|
+
if (group && !String(local).startsWith('g.')) throw Object.assign(new Error('a group name starts with g.'), { status: 400 });
|
|
227
362
|
local = String(local).toLowerCase();
|
|
228
363
|
const address = `${local}@${this.domain}`;
|
|
229
364
|
parseAddress(address);
|
|
230
|
-
if (Estafeta.RESERVED.has(local) && !this.isSystem(local)) throw Object.assign(new Error(`name reserved by the protocol: ${local}`), { status: 409 });
|
|
365
|
+
if (Estafeta.RESERVED.has(local) && !this.isSystem(local) && !system) throw Object.assign(new Error(`name reserved by the protocol: ${local}`), { status: 409 });
|
|
231
366
|
// Nombres de 1 a 3 caracteres: reservados en una casa de registro abierto. Son lo primero
|
|
232
367
|
// que alguien acapara para revender o para suplantar (a@casa se confunde con cualquiera), y
|
|
233
368
|
// un agente que llega no necesita un nombre corto: necesita uno suyo. Los de sistema pasan.
|
|
234
|
-
if (local.length <= this.policy.min_name_length - 1 && !this.isSystem(local) && !delegation) {
|
|
369
|
+
if (local.length <= this.policy.min_name_length - 1 && !this.isSystem(local) && !delegation && !system) {
|
|
235
370
|
throw Object.assign(new Error(`names shorter than ${this.policy.min_name_length} characters are reserved in this house`), { status: 409 });
|
|
236
371
|
}
|
|
237
372
|
if (delegation) {
|
|
@@ -256,19 +391,45 @@ export class Estafeta {
|
|
|
256
391
|
else if (!hs[campo].every((x) => ps[campo].includes(x))) throw Object.assign(new Error(`a subagent cannot widen ${campo} beyond its parent`), { status: 403 });
|
|
257
392
|
}
|
|
258
393
|
}
|
|
394
|
+
// Sólo-mensajes no se hereda reescribiendo la delegación (quedaría con una firma que ya no
|
|
395
|
+
// cubre lo que dice): se exige. Un hijo de un subagente de sólo mensajes, también lo es.
|
|
396
|
+
if (ps.messages_only && hs.messages_only !== true) throw Object.assign(new Error('a subagent of a messages-only address must itself be messages-only'), { status: 403 });
|
|
259
397
|
delegation = { ...delegation, scope: hs };
|
|
260
398
|
}
|
|
261
399
|
valid_until = valid_until || delegation.valid_until || null;
|
|
262
400
|
}
|
|
263
401
|
const prev = await this.store.getAgent(local);
|
|
402
|
+
// `<algo>.<agente existente>` es la forma de un subagente: sin la delegación de ese agente no se
|
|
403
|
+
// registra (revisión del 13-sep: `evil.alicia` parecía delegado de alicia sin serlo).
|
|
404
|
+
if (!prev && !delegation && !group && local.includes('.')) {
|
|
405
|
+
const padre = local.slice(local.indexOf('.') + 1);
|
|
406
|
+
if (await this.store.getAgent(padre)) throw Object.assign(new Error(`names of the form <name>.${padre} are subagents of ${padre}@${this.domain}: they need its delegation`), { status: 409 });
|
|
407
|
+
}
|
|
264
408
|
const previous = [];
|
|
265
|
-
|
|
409
|
+
// Una llave revocada no vuelve por la ventana de gracia: si su dueño la revocó fue porque no
|
|
410
|
+
// debía seguir firmando, y los siete días de `previous` la resucitarían.
|
|
411
|
+
if (prev && prev.sig !== sig && !prev.revoked) previous.push({ sig: prev.sig, until: iso(now() + 7 * 24 * 3600 * 1000) }, ...(prev.previous || []));
|
|
412
|
+
// La billetera es PÚBLICA y va en la tarjeta: es sólo la dirección a la que se le cobra. La
|
|
413
|
+
// casa no la controla ni puede mover nada de ella, igual que no controla la llave del agente.
|
|
414
|
+
let billeteras; try { billeteras = x402.validarBilleteras(wallets ?? wallet ?? prev?.wallets ?? prev?.wallet ?? null); }
|
|
415
|
+
catch (e) { throw Object.assign(new Error(e.message), { status: 400 }); }
|
|
416
|
+
// Un servicio con precio en dólares sin billetera a la que cobrarlo es una promesa gratis (NX-301).
|
|
417
|
+
{ const e = usdSinBilletera(profile === undefined ? prev?.profile : perfil, billeteras); if (e) throw Object.assign(new Error(e), { status: 400 }); }
|
|
266
418
|
const card = signObject({
|
|
267
419
|
nyx5: '1', address, sig, enc,
|
|
268
420
|
capabilities: { accepts: ['text/plain', 'application/json'], ...capabilities },
|
|
269
421
|
inbox: { policy: 'open', ...inbox },
|
|
422
|
+
...(billeteras ? { wallets: billeteras } : {}),
|
|
270
423
|
valid_from: iso(), valid_until, previous: previous.slice(0, 3),
|
|
271
424
|
delegation: delegation || undefined,
|
|
425
|
+
group: group || undefined,
|
|
426
|
+
// La ficha se conserva entre re-certificaciones salvo que esta llamada la traiga (o la borre con null).
|
|
427
|
+
profile: profile === undefined ? (prev?.profile || undefined) : (perfil || undefined),
|
|
428
|
+
visibility: visibility === undefined ? (prev?.visibility || undefined) : (visibility || undefined),
|
|
429
|
+
// Quién guarda las llaves de esta dirección. Sólo lo pone la casa (el conector remoto) y se
|
|
430
|
+
// publica: quien le escribe a una dirección cuya llave guarda la casa tiene derecho a saber
|
|
431
|
+
// que la casa puede leer lo que le llega. Se conserva mientras la llave no cambie.
|
|
432
|
+
custody: custody || (prev && prev.sig === sig ? prev.custody : undefined) || undefined,
|
|
272
433
|
}, this.keys, 'certification');
|
|
273
434
|
// Alta atómica cuando el nombre es nuevo: dos altas concurrentes del mismo nombre no pueden
|
|
274
435
|
// pisarse la clave (el perdedor cree tener el nombre y su correo iría al otro), ni cobrar el
|
|
@@ -283,12 +444,177 @@ export class Estafeta {
|
|
|
283
444
|
await this.store.putAgent(local, { ...card, webhook, notify_email });
|
|
284
445
|
}
|
|
285
446
|
const gift = welcome ?? this.libro.welcome;
|
|
286
|
-
if (creado && !prev && !this.isSystem(local) && !delegation && gift > 0) await this.libro.topup(address, gift, 'regalo de bienvenida', { agent: address });
|
|
447
|
+
if (creado && !prev && !this.isSystem(local) && !delegation && !group && !system && gift > 0) await this.libro.topup(address, gift, 'regalo de bienvenida', { agent: address });
|
|
287
448
|
return card;
|
|
288
449
|
}
|
|
450
|
+
|
|
451
|
+
// ---------- acuse de lectura y presencia (13-sep-2026), los dos opt-in en la tarjeta ----------
|
|
452
|
+
async _acusesDeLectura(who, ids) {
|
|
453
|
+
const historial = await this.store.listMailHistory(who.local, { limit: 1000 });
|
|
454
|
+
for (const id of ids) {
|
|
455
|
+
const m = historial.find((x) => x.envelope?.id === id);
|
|
456
|
+
const e = m?.envelope;
|
|
457
|
+
if (!e || e.type === 'receipt') continue;
|
|
458
|
+
let fromLocal = ''; try { fromLocal = parseAddress(e.from).local; } catch { continue; }
|
|
459
|
+
if (e.from === who.address || this.isSystem(fromLocal)) continue;
|
|
460
|
+
await this._systemSend('postmaster', [e.from], { type: 'receipt', in_reply_to: e.id, thread: e.thread || e.id, content: { media: 'application/nyx5.recibo+json', body: { read_of: e.id, read_by: who.address, read_at: iso() } } });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// Presencia: «visto por última vez», redondeado a la hora, sólo si la tarjeta declara presence:true.
|
|
464
|
+
// Un dato por hora y por dirección, en memoria por isolate: cuesta una escritura por hora, no una
|
|
465
|
+
// por petición. Sin opt-in no se anota nada: presencia sin consentimiento sería vigilancia.
|
|
466
|
+
_presencia = new Map();
|
|
467
|
+
async _anotarPresencia(who) {
|
|
468
|
+
if (who.record?.capabilities?.presence !== true) return;
|
|
469
|
+
const hora = iso().slice(0, 13) + ':00:00.000Z';
|
|
470
|
+
if (this._presencia.get(who.local) === hora) return;
|
|
471
|
+
this._presencia.set(who.local, hora);
|
|
472
|
+
await this.store.kvPut('presencia', who.local, { last_seen: hora }, now() + 90 * 86_400_000);
|
|
473
|
+
}
|
|
474
|
+
async presenciaDe(local) {
|
|
475
|
+
const rec = await this.store.getAgent(local);
|
|
476
|
+
if (!rec || rec.revoked || rec.capabilities?.presence !== true) return null;
|
|
477
|
+
return (await this.store.kvGet('presencia', local))?.last_seen || null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ---------- grupos: una dirección que reparte el mismo sobre a cada miembro ----------
|
|
481
|
+
// Nació el 13-sep-2026 del pedido de Nicholas de conversar de a varios (una sala de proyecto con
|
|
482
|
+
// su Claude, el de Basti y el agente de Sigo). Primera versión: miembros de esta casa. Un sobre
|
|
483
|
+
// firmado lleva la dirección del grupo, y otra casa no sabría a quién entregarlo sin reescribirlo.
|
|
484
|
+
static NOMBRE_GRUPO = /^[a-z0-9][a-z0-9._-]{2,40}$/;
|
|
485
|
+
static MAX_MIEMBROS = 50;
|
|
486
|
+
// ¿El buzón de `rec` acepta un mensaje normal de `de`? Sólo abierto o lista blanca cuentan: una
|
|
487
|
+
// estampilla o una prueba de trabajo no se pagan por cada miembro, y un `intro` o un aval no abren
|
|
488
|
+
// un grupo. Es la regla de consentimiento de los grupos (revisión adversarial del 13-sep-2026: sin
|
|
489
|
+
// ella, cualquiera metía a cualquiera en un grupo y le saltaba la lista blanca, incluso al
|
|
490
|
+
// asistente de Sigo, gastándole presupuesto).
|
|
491
|
+
_aceptaDe(rec, de) {
|
|
492
|
+
const inbox = rec?.inbox || { policy: 'open' };
|
|
493
|
+
if (inbox.policy === 'open') return true;
|
|
494
|
+
if (inbox.policy !== 'allowlist') return false;
|
|
495
|
+
let dominio = ''; try { dominio = parseAddress(de).domain; } catch { return false; }
|
|
496
|
+
return (inbox.allowlist || []).some((x) => x === de || x === dominio);
|
|
497
|
+
}
|
|
498
|
+
// `nuevos` son los que entran ahora: cada uno tiene que aceptar ya a quien lo agrega.
|
|
499
|
+
async _miembrosValidos(direcciones, { agregadoPor = null, nuevos = null } = {}) {
|
|
500
|
+
const lista = [];
|
|
501
|
+
for (const raw of direcciones) {
|
|
502
|
+
const dir = String(raw || '').toLowerCase();
|
|
503
|
+
let p; try { p = parseAddress(dir); } catch { return { error: `not an address: ${dir}` }; }
|
|
504
|
+
if (p.domain !== this.domain) return { error: `${dir} is not in this house: groups hold members of ${this.domain} only, for now` };
|
|
505
|
+
const rec = await this.store.getAgent(p.local);
|
|
506
|
+
if (!rec || rec.revoked || (agregadoPor && !await this._visibleA(rec, agregadoPor))) return { error: `${dir} does not exist or is revoked` };
|
|
507
|
+
if (rec.group || this.isSystem(p.local)) return { error: `${dir} cannot be a member (it is a group or a system address)` };
|
|
508
|
+
if (agregadoPor && dir !== agregadoPor && (!nuevos || nuevos.has(dir)) && !this._aceptaDe(rec, agregadoPor)) return { error: `${dir} does not accept messages from you: only someone who already lists you as a contact can be added to a group` };
|
|
509
|
+
if (!lista.includes(dir)) lista.push(dir);
|
|
510
|
+
}
|
|
511
|
+
if (lista.length > Estafeta.MAX_MIEMBROS) return { error: `a group holds at most ${Estafeta.MAX_MIEMBROS} members` };
|
|
512
|
+
return { lista };
|
|
513
|
+
}
|
|
514
|
+
_tarjetaDeGrupo(local, group) {
|
|
515
|
+
return this.registerAgent({
|
|
516
|
+
local, sig: this.keys.sig, group,
|
|
517
|
+
capabilities: { accepts: ['text/plain', 'application/json'], group: true },
|
|
518
|
+
// Sólo quien puede publicar entra al buzón: la política del buzón es la primera puerta y el
|
|
519
|
+
// reparto vuelve a comprobarlo (un `intro` o un aval no abren un grupo).
|
|
520
|
+
inbox: { policy: 'allowlist', allowlist: group.post === 'admins' ? group.admins : group.members },
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
async crearGrupo(who, b) {
|
|
524
|
+
const nombre = String(b.name || '').toLowerCase();
|
|
525
|
+
if (!Estafeta.NOMBRE_GRUPO.test(nombre)) return { status: 400, body: { reason: 'a group name has 3 to 41 characters: letters, digits, . _ -' } };
|
|
526
|
+
const local = `g.${nombre}`;
|
|
527
|
+
if (await this.store.getAgent(local)) return { status: 409, body: { reason: `${local}@${this.domain} already exists` } };
|
|
528
|
+
if (!await this.remotoRate.allow(`grupo:${who.address}`)) return { status: 429, body: { reason: 'too many groups; try again in a minute' }, headers: this._retryAfter() };
|
|
529
|
+
const v = await this._miembrosValidos([who.address, ...(Array.isArray(b.members) ? b.members : [])], { agregadoPor: who.address });
|
|
530
|
+
if (v.error) return { status: 400, body: { reason: v.error } };
|
|
531
|
+
const group = { admins: [who.address], members: v.lista, post: b.post === 'admins' ? 'admins' : 'members', max: Estafeta.MAX_MIEMBROS, created: iso() };
|
|
532
|
+
const card = await this._tarjetaDeGrupo(local, group);
|
|
533
|
+
await this._evento('group_created', who.address, { group: card.address, members: group.members.length });
|
|
534
|
+
return { status: 201, body: { address: card.address, group } };
|
|
535
|
+
}
|
|
536
|
+
async miembrosGrupo(who, local) {
|
|
537
|
+
const rec = await this.store.getAgent(local);
|
|
538
|
+
if (!rec?.group || rec.revoked) return { status: 404, body: { reason: 'no such group' } };
|
|
539
|
+
if (!rec.group.members.includes(who.address)) return { status: 403, body: { reason: 'only members see who is in a group' } };
|
|
540
|
+
return { status: 200, body: { address: `${local}@${this.domain}`, group: rec.group } };
|
|
541
|
+
}
|
|
542
|
+
async editarGrupo(who, local, b) {
|
|
543
|
+
const rec = await this.store.getAgent(local);
|
|
544
|
+
if (!rec?.group || rec.revoked) return { status: 404, body: { reason: 'no such group' } };
|
|
545
|
+
const g = rec.group;
|
|
546
|
+
const admin = g.admins.includes(who.address);
|
|
547
|
+
const add = Array.isArray(b.add) ? b.add.map((x) => String(x).toLowerCase()) : [];
|
|
548
|
+
const remove = Array.isArray(b.remove) ? b.remove.map((x) => String(x).toLowerCase()) : [];
|
|
549
|
+
const admins = Array.isArray(b.admins) ? b.admins.map((x) => String(x).toLowerCase()) : [];
|
|
550
|
+
// Un miembro sólo puede irse; agregar, quitar a otros y nombrar admins es de los admins.
|
|
551
|
+
if (!admin && (add.length || admins.length || remove.some((x) => x !== who.address))) return { status: 403, body: { reason: 'only an admin changes the members of a group; a member can only leave' } };
|
|
552
|
+
if (!admin && !g.members.includes(who.address)) return { status: 403, body: { reason: 'not a member of this group' } };
|
|
553
|
+
const v = await this._miembrosValidos([...g.members.filter((m) => !remove.includes(m)), ...add], { agregadoPor: who.address, nuevos: new Set(add.filter((a) => !g.members.includes(a))) });
|
|
554
|
+
if (v.error) return { status: 400, body: { reason: v.error } };
|
|
555
|
+
const nuevosAdmins = [...new Set([...g.admins.filter((a) => v.lista.includes(a)), ...admins.filter((a) => v.lista.includes(a))])];
|
|
556
|
+
if (!nuevosAdmins.length) return { status: 409, body: { reason: 'a group needs at least one admin: name another admin before leaving' } };
|
|
557
|
+
const group = { ...g, members: v.lista, admins: nuevosAdmins, updated: iso() };
|
|
558
|
+
const card = await this._tarjetaDeGrupo(local, group);
|
|
559
|
+
await this._evento('group_changed', who.address, { group: card.address, members: group.members.length });
|
|
560
|
+
return { status: 200, body: { address: card.address, group } };
|
|
561
|
+
}
|
|
289
562
|
// El nombre local viaja como clave al almacenamiento: se valida SIEMPRE aquí, no se confía en
|
|
290
563
|
// que el store lo sanee. Sin esto, `GET /agents/..%2Fdomain` leía el archivo del dominio y
|
|
291
564
|
// devolvía la clave PRIVADA de la casa (compromiso total).
|
|
565
|
+
_retryAfter() { return { 'retry-after': String(this.rate.retryAfter()) }; }
|
|
566
|
+
|
|
567
|
+
// ---------- visibilidad (NX-202): a quién se le confirma que un agente secreto existe ----------
|
|
568
|
+
// Lo ve: él mismo, quien lo delegó, sus propios delegados, y quien está en su lista (dirección o
|
|
569
|
+
// dominio). Nadie más: ni autenticado, ni la app, ni otra casa que no diga para quién pregunta.
|
|
570
|
+
// «Sus delegados» se comprueba leyendo la tarjeta del que pregunta: el sufijo del nombre no basta
|
|
571
|
+
// (revisión del 13-sep: `evil.alicia` registrado por un extraño veía a alicia).
|
|
572
|
+
async _visibleA(rec, quien) {
|
|
573
|
+
if (!rec || rec.visibility !== 'secret') return true;
|
|
574
|
+
if (!quien) return false;
|
|
575
|
+
quien = String(quien).toLowerCase();
|
|
576
|
+
if (quien === rec.address || quien === rec.delegation?.by) return true;
|
|
577
|
+
let q; try { q = parseAddress(quien); } catch { q = null; }
|
|
578
|
+
if (q && q.domain === this.domain) {
|
|
579
|
+
const del = await this.store.getAgent(q.local);
|
|
580
|
+
if (del && !del.revoked && del.delegation?.by === rec.address) return true;
|
|
581
|
+
}
|
|
582
|
+
const dominio = q ? q.domain : (quien.includes('@') ? quien.slice(quien.lastIndexOf('@') + 1) : '');
|
|
583
|
+
return (rec.inbox?.allowlist || []).some((x) => String(x).toLowerCase() === quien || String(x).toLowerCase() === dominio);
|
|
584
|
+
}
|
|
585
|
+
// Si el que selló (NX-601) puede nombrarse en público: uno de esta casa se consulta en vivo
|
|
586
|
+
// (su visibilidad puede haber cambiado); uno de otra casa, por lo que se supo al sellar.
|
|
587
|
+
async _declaranteVisible(doc) {
|
|
588
|
+
let p; try { p = parseAddress(doc.by); } catch { return false; }
|
|
589
|
+
if (p.domain !== this.domain) return !doc.secret;
|
|
590
|
+
return this._visibleA(await this.store.getAgent(p.local), null);
|
|
591
|
+
}
|
|
592
|
+
// Quién pregunta por una tarjeta: un agente de esta casa autenticado, o una casa ajena que firma
|
|
593
|
+
// «lo pido para bob@su-casa» con su llave de dominio. Se evalúa SIEMPRE, exista o no lo que se
|
|
594
|
+
// pide, y antes de tocar el almacén (revisión del 13-sep: evaluarlo sólo cuando había tarjeta
|
|
595
|
+
// convertía un 401, o la latencia del fetch a la otra casa, en un oráculo de existencia). Un
|
|
596
|
+
// token inválido cuenta como nadie, no como error. Una petición que trae credencial cuesta (una
|
|
597
|
+
// firma o una tarjeta de dominio ajena): se limita por IP igual que /resolve.
|
|
598
|
+
async _quienPregunta(rx, path) {
|
|
599
|
+
const auth = rx.headers.authorization?.startsWith('Nyx5 ');
|
|
600
|
+
const h = rx.headers['x-nyx5-for'];
|
|
601
|
+
if (!auth && !h) return null;
|
|
602
|
+
if (!await this.rate.allow(`resolve:${rx.ip || 'x'}`)) throw Object.assign(new Error('too many requests'), { status: 429, headers: this._retryAfter() });
|
|
603
|
+
if (auth) { try { return (await this._authenticate(rx, path)).address; } catch { return null; } }
|
|
604
|
+
const r = Object.fromEntries(String(h).replace(/^nyx51\s*/, '').split(';').map((p) => p.trim().split('=').map((x) => x.trim())).filter((p) => p[0]));
|
|
605
|
+
try {
|
|
606
|
+
const { domain } = parseAddress(r.for || '');
|
|
607
|
+
if (domain !== r.domain || domain === this.domain) return null;
|
|
608
|
+
if (Math.abs(now() - Date.parse(r.ts || 0)) > 300_000) return null;
|
|
609
|
+
const dc = await this.resolver.domainCard(domain);
|
|
610
|
+
if (!dc.keys.some((k) => k.sig === r.kid)) return null;
|
|
611
|
+
return verifyBytes(`for:${r.for}:${this.domain}:${r.ts}`, r.sig, r.kid) ? String(r.for).toLowerCase() : null;
|
|
612
|
+
} catch { return null; }
|
|
613
|
+
}
|
|
614
|
+
_firmarPara(addr, dom) {
|
|
615
|
+
const ts = iso();
|
|
616
|
+
return `nyx51 domain=${this.domain}; for=${addr}; ts=${ts}; kid=${this.keys.sig}; sig=${signBytes(`for:${addr}:${dom}:${ts}`, this.keys)}`;
|
|
617
|
+
}
|
|
292
618
|
static LOCAL = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
293
619
|
static validLocal(local) { return typeof local === 'string' && Estafeta.LOCAL.test(local); }
|
|
294
620
|
async agentCard(local) {
|
|
@@ -299,6 +625,602 @@ export class Estafeta {
|
|
|
299
625
|
return card;
|
|
300
626
|
}
|
|
301
627
|
|
|
628
|
+
// ---------- conector remoto: la bóveda y el agente en proceso ----------
|
|
629
|
+
async llavesDeBoveda(local) {
|
|
630
|
+
if (!this.boveda || !this.store.kvGet) return null;
|
|
631
|
+
const v = await this.store.kvGet('boveda', local);
|
|
632
|
+
if (!v) return null;
|
|
633
|
+
try { return this.boveda.abrir(v.sellado, local); }
|
|
634
|
+
catch (e) { this.log(`bóveda: no abre la llave de ${local}: ${e.message}`); return null; }
|
|
635
|
+
}
|
|
636
|
+
// El agente de un subagente delegado, armado en este proceso con la llave de la bóveda. Firma y
|
|
637
|
+
// pasa por /outbound como cualquiera: no hay atajo que se salte la política ni el alcance.
|
|
638
|
+
async agenteDeBoveda(local) {
|
|
639
|
+
await this.init();
|
|
640
|
+
const keys = await this.llavesDeBoveda(local);
|
|
641
|
+
if (!keys) return null;
|
|
642
|
+
return new Agent({ address: `${local}@${this.domain}`, keys, estafeta: this.publicUrl, resolver: this.resolver, fetchImpl: this.fetchPropio });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ---------- tiempo real e historial ----------
|
|
646
|
+
// Espera el primer sobre PENDIENTE que cumpla el filtro, preguntando cada segundo. Pendiente y no
|
|
647
|
+
// "nuevo": si la respuesta llegó entre que uno mandó y empezó a esperar, se entrega igual.
|
|
648
|
+
async esperarCorreo(local, { from = null, thread = null, since = null, project = null, timeoutMs = 25_000, everyMs = 1000 } = {}) {
|
|
649
|
+
const hasta = now() + Math.max(0, timeoutMs);
|
|
650
|
+
const cumple = (m) => {
|
|
651
|
+
const e = m.envelope || {};
|
|
652
|
+
const de = e.extensions?.['urn:nyx5:ext:email']?.from || e.from;
|
|
653
|
+
return (!from || de === from) && (!thread || e.thread === thread || e.id === thread) && (!project || proyectoDe(e) === project);
|
|
654
|
+
};
|
|
655
|
+
for (;;) {
|
|
656
|
+
const lista = this.store.listMailSince ? await this.store.listMailSince(local, since) : (await this.store.listMail(local)).filter((m) => !since || String(m.received) > since);
|
|
657
|
+
const m = lista.find(cumple);
|
|
658
|
+
if (m) return m;
|
|
659
|
+
if (now() >= hasta) return null;
|
|
660
|
+
await new Promise((r) => setTimeout(r, Math.min(everyMs, Math.max(1, hasta - now()))));
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// La conversación con una dirección (o, sin dirección, la lista de conversaciones): lo recibido,
|
|
664
|
+
// incluido lo confirmado, y lo enviado, que ahora queda en la bandeja con su sobre.
|
|
665
|
+
async conversacion(local, { con = null, limit = 50, project = null } = {}) {
|
|
666
|
+
const propia = `${local}@${this.domain}`;
|
|
667
|
+
const delProyecto = (x) => !project || proyectoDe(x.envelope) === project;
|
|
668
|
+
const recibidos = (await this.store.listMailHistory(local, { limit: 1000 })).map((m) => ({ id: m.envelope.id, dir: 'in', at: m.received, acked: !!m.acked, envelope: m.envelope }));
|
|
669
|
+
const enviados = [];
|
|
670
|
+
const vistos = new Set();
|
|
671
|
+
for (const e of await this.store.listOutbox(local)) {
|
|
672
|
+
if (!e.envelope || vistos.has(e.envelope.id)) continue;
|
|
673
|
+
vistos.add(e.envelope.id);
|
|
674
|
+
// Lo que uno se manda a sí mismo (un recordatorio) ya aparece como recibido: una sola vez.
|
|
675
|
+
if (e.envelope.to.includes(propia)) continue;
|
|
676
|
+
enviados.push({ id: e.envelope.id, dir: 'out', at: e.envelope.created, status: e.status, envelope: e.envelope });
|
|
677
|
+
}
|
|
678
|
+
// Lo enviado sabe si fue leído: el acuse de lectura llegó como recibo de postmaster@ con read_of.
|
|
679
|
+
// Sólo vale si lo firmó el postmaster de la casa de quien leyó (revisión del 13-sep-2026: antes
|
|
680
|
+
// cualquier sobre `receipt` con read_of marcaba leído lo que fuera) y si ese lector era
|
|
681
|
+
// destinatario del sobre, o miembro de su casa cuando el sobre iba a un grupo.
|
|
682
|
+
const leidos = new Map();
|
|
683
|
+
for (const r of recibidos) {
|
|
684
|
+
const b = r.envelope?.content?.body;
|
|
685
|
+
if (r.envelope?.type !== 'receipt' || typeof b?.read_of !== 'string' || typeof b?.read_by !== 'string') continue;
|
|
686
|
+
let lector; try { lector = parseAddress(b.read_by); } catch { continue; }
|
|
687
|
+
if (r.envelope.from !== `postmaster@${lector.domain}`) continue;
|
|
688
|
+
leidos.set(b.read_of, { by: b.read_by, at: b.read_at });
|
|
689
|
+
}
|
|
690
|
+
for (const e of enviados) {
|
|
691
|
+
const l = leidos.get(e.id); if (!l) continue;
|
|
692
|
+
const destinos = e.envelope.to.map((t) => String(t).toLowerCase());
|
|
693
|
+
const lectorDom = parseAddress(l.by).domain;
|
|
694
|
+
if (destinos.includes(l.by) || destinos.some((t) => t.startsWith('g.') && parseAddress(t).domain === lectorDom)) e.read = l;
|
|
695
|
+
}
|
|
696
|
+
const todos = [...recibidos, ...enviados].filter(delProyecto);
|
|
697
|
+
// Un mensaje de grupo se conversa con el GRUPO, no con quien lo escribió.
|
|
698
|
+
const grupo = (x) => x.envelope.to.find((t) => { try { const p = parseAddress(t); return p.domain === this.domain && p.local.startsWith('g.'); } catch { return false; } });
|
|
699
|
+
const contraparte = (x) => grupo(x) || (x.dir === 'in' ? (x.envelope.extensions?.['urn:nyx5:ext:email']?.from || x.envelope.from) : (x.envelope.to.find((t) => t !== propia) || x.envelope.to[0]));
|
|
700
|
+
const orden = (a, b) => String(a.at).localeCompare(String(b.at));
|
|
701
|
+
if (con) return todos.filter((x) => contraparte(x) === con || (x.dir === 'out' && x.envelope.to.includes(con))).sort(orden).slice(-limit);
|
|
702
|
+
// La lista trae, por contacto, los proyectos vistos (14-sep-2026, decisión de Nicholas): la app
|
|
703
|
+
// filtra por proyecto sin leer cada hilo. Sale de la MISMA pasada, no de otra consulta: el
|
|
704
|
+
// proyecto real es `proyectoDe` (NFKC, sin invisibles, minúsculas), que SQLite no sabe calcular
|
|
705
|
+
// (su `lower()` es sólo ASCII y no hay NFKC), y el contacto tampoco es una columna (grupo,
|
|
706
|
+
// pasarela de correo o primer destinatario). Con `?project=` todo se calcula sobre ese proyecto.
|
|
707
|
+
const mapa = new Map();
|
|
708
|
+
for (const x of todos) {
|
|
709
|
+
const c = contraparte(x);
|
|
710
|
+
const r = mapa.get(c) || { with: c, count: 0, pending: 0, last_at: '', last_dir: null, last_id: null, proyectos: new Map(), pendientes: new Map() };
|
|
711
|
+
r.count++;
|
|
712
|
+
const pendiente = x.dir === 'in' && !x.acked;
|
|
713
|
+
if (pendiente) r.pending++;
|
|
714
|
+
if (String(x.at) > r.last_at) { r.last_at = String(x.at); r.last_dir = x.dir; r.last_id = x.id; }
|
|
715
|
+
const p = proyectoDe(x.envelope);
|
|
716
|
+
if (p) {
|
|
717
|
+
// Sin proyecto no hay entrada: no se inventa un «(none)».
|
|
718
|
+
if (String(x.at) > (r.proyectos.get(p) || '')) r.proyectos.set(p, String(x.at));
|
|
719
|
+
if (pendiente) r.pendientes.set(p, (r.pendientes.get(p) || 0) + 1);
|
|
720
|
+
}
|
|
721
|
+
mapa.set(c, r);
|
|
722
|
+
}
|
|
723
|
+
return [...mapa.values()].sort((a, b) => b.last_at.localeCompare(a.last_at)).map(({ proyectos, pendientes, ...r }) => {
|
|
724
|
+
// Por último uso, a lo sumo 20: un contacto que etiquetó 10.000 proyectos distintos no
|
|
725
|
+
// devuelve 10.000 nombres. `pending_by_project` cubre SÓLO esos 20 (revisión del 14-sep-2026:
|
|
726
|
+
// traía una clave por proyecto pendiente, 1.000 con la ventana del historial, y el lector no
|
|
727
|
+
// podía cruzarlas con `projects`); lo pendiente bajo los demás va sumado en `pending_other`.
|
|
728
|
+
// Ninguna de las dos claves aparece en cero. `Object.fromEntries` crea propiedades propias:
|
|
729
|
+
// aunque llegara un nombre como `__proto__`, no pisaría el prototipo.
|
|
730
|
+
r.projects = [...proyectos.entries()].sort((a, b) => b[1].localeCompare(a[1])).slice(0, PROYECTOS_POR_CONTACTO).map(([p]) => p);
|
|
731
|
+
const listados = new Set(r.projects);
|
|
732
|
+
const porProyecto = [...pendientes].filter(([p]) => listados.has(p));
|
|
733
|
+
const otros = [...pendientes].filter(([p]) => !listados.has(p)).reduce((s, [, n]) => s + n, 0);
|
|
734
|
+
if (porProyecto.length) r.pending_by_project = Object.fromEntries(porProyecto);
|
|
735
|
+
if (otros) r.pending_other = otros;
|
|
736
|
+
return r;
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
// El `?project=` de una ruta: null si no viene (o viene vacío: sin filtro), el nombre normalizado
|
|
740
|
+
// como al enviar, o `false` si viene algo que no es un nombre (sólo invisibles, `%00`, una palabra
|
|
741
|
+
// reservada). Revisión del 14-sep-2026: `?project=%00` devolvía la lista ENTERA como si no hubiera
|
|
742
|
+
// filtro, y quien pidió un proyecto se llevaba todo. Se evalúa después de autenticar: no es oráculo.
|
|
743
|
+
_proyectoPedido(query) {
|
|
744
|
+
const crudo = query.get('project');
|
|
745
|
+
if (!crudo) return null;
|
|
746
|
+
return nombreDeProyecto(crudo) ?? false;
|
|
747
|
+
}
|
|
748
|
+
// Los subagentes que un dueño delegó (sus Claude conectados), con lo necesario para revocarlos.
|
|
749
|
+
async delegados(local) {
|
|
750
|
+
const padre = `${local}@${this.domain}`;
|
|
751
|
+
const out = [];
|
|
752
|
+
for (const l of await this.store.listAgents()) {
|
|
753
|
+
if (!l.endsWith(`.${local}`)) continue;
|
|
754
|
+
const r = await this.store.getAgent(l);
|
|
755
|
+
if (r?.delegation?.by !== padre) continue;
|
|
756
|
+
out.push({ address: r.address, sig: r.sig, enc: r.enc || null, since: r.valid_from || null, valid_until: r.valid_until || null, revoked: r.revoked || null, custody: r.custody || null, scope: r.delegation.scope || null, inbox: r.inbox || null });
|
|
757
|
+
}
|
|
758
|
+
return out;
|
|
759
|
+
}
|
|
760
|
+
// Revocar es definitivo para esa llave: la tarjeta vence ahora (el resolver deja de aceptarla),
|
|
761
|
+
// la llave sale de la bóveda y ningún token vuelve a servir, porque cada uno se valida contra esto.
|
|
762
|
+
async revocarDelegado(subLocal, por) {
|
|
763
|
+
const rec = await this.store.getAgent(subLocal);
|
|
764
|
+
if (!rec?.delegation) throw Object.assign(new Error('no such subagent'), { status: 404 });
|
|
765
|
+
const { certification: _c, webhook, notify_email, ...cuerpo } = rec;
|
|
766
|
+
const card = signObject({ ...cuerpo, valid_until: iso(), revoked: { at: iso(), by: por } }, this.keys, 'certification');
|
|
767
|
+
await this.store.putAgent(subLocal, { ...card, webhook, notify_email });
|
|
768
|
+
await this.store.kvDelete?.('boveda', subLocal);
|
|
769
|
+
await this.store.kvDelete?.('pendiente', subLocal);
|
|
770
|
+
await this._evento('delegation_revoked', rec.address, { by: por });
|
|
771
|
+
return card;
|
|
772
|
+
}
|
|
773
|
+
// ---------- invitaciones de contacto ----------
|
|
774
|
+
// Quien invita (su llave raíz, o la casa en su nombre) genera un link. El invitado conecta su
|
|
775
|
+
// Claude con la URL que trae el link, la pantalla llega prellenada, y los dos Claude quedan como
|
|
776
|
+
// contactos: cada uno en la lista del otro. Un solo uso, siete días.
|
|
777
|
+
async crearInvitacion(rx) {
|
|
778
|
+
const b = rx.body || {};
|
|
779
|
+
let inviter;
|
|
780
|
+
if ((rx.headers.authorization || '') === `Bearer ${this.adminToken}`) {
|
|
781
|
+
inviter = String(b.inviter || '').toLowerCase();
|
|
782
|
+
let local, domain;
|
|
783
|
+
try { ({ local, domain } = parseAddress(inviter)); } catch { return { status: 400, body: { reason: 'inviter must be an address' } }; }
|
|
784
|
+
const rec = domain === this.domain ? await this.store.getAgent(local) : null;
|
|
785
|
+
if (!rec || rec.delegation) return { status: 404, body: { reason: 'the inviter must be an own (not delegated) address of this house' } };
|
|
786
|
+
} else {
|
|
787
|
+
const who = await this._authenticate(rx, rx.path);
|
|
788
|
+
if (who.record.delegation) return { status: 403, body: { reason: 'a delegated address cannot invite; use your own address' } };
|
|
789
|
+
inviter = who.address;
|
|
790
|
+
}
|
|
791
|
+
if (!await this.remotoRate.allow(`invitar:${inviter}`)) return { status: 429, body: { reason: 'too many invitations; try again in a minute' }, headers: this._retryAfter() };
|
|
792
|
+
const suClaude = await this.store.getAgent(`claude.${parseAddress(inviter).local}`);
|
|
793
|
+
const viva = suClaude && !suClaude.revoked && suClaude.delegation?.by === inviter && (!suClaude.valid_until || Date.parse(suClaude.valid_until) > now());
|
|
794
|
+
const hint = typeof b.name === 'string' ? b.name.toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 30) : '';
|
|
795
|
+
// Contactos extra: sólo direcciones PROPIAS de quien invita (sus delegados vigentes). Así Nicholas
|
|
796
|
+
// invita a Basti y lo deja conectado con su agente de Sigo, no sólo consigo mismo.
|
|
797
|
+
const contactos = [];
|
|
798
|
+
for (const c of (Array.isArray(b.contacts) ? b.contacts : []).slice(0, 10)) {
|
|
799
|
+
const dir = String(c || '').toLowerCase();
|
|
800
|
+
let pc; try { pc = parseAddress(dir); } catch { return { status: 400, body: { reason: `not an address: ${dir}` } }; }
|
|
801
|
+
const rc = pc.domain === this.domain ? await this.store.getAgent(pc.local) : null;
|
|
802
|
+
if (!rc || rc.revoked || rc.delegation?.by !== inviter) return { status: 400, body: { reason: `contacts must be your own delegated addresses: ${dir}` } };
|
|
803
|
+
contactos.push(dir);
|
|
804
|
+
}
|
|
805
|
+
const code = uuid().replace(/-/g, '');
|
|
806
|
+
const greet = typeof b.greet === 'string' && [inviter, ...contactos].includes(b.greet.toLowerCase()) ? b.greet.toLowerCase() : null;
|
|
807
|
+
// `source`: por qué canal se va a mandar el enlace (whatsapp, correo...). Atribución del embudo;
|
|
808
|
+
// nunca entra a una tarjeta. Al abrir el enlace, `?source=` puede precisarla.
|
|
809
|
+
const inv = { code, inviter, inviter_claude: viva ? suClaude.address : null, contacts: contactos, greet, name_hint: hint || null, source: Estafeta.fuenteLimpia(b.source), created: iso(), expires: iso(now() + 7 * 86_400_000) };
|
|
810
|
+
await this.store.kvPut('invitacion', code, inv, now() + 7 * 86_400_000);
|
|
811
|
+
await this._evento('invitation_created', inviter, { with_claude: !!inv.inviter_claude });
|
|
812
|
+
return { status: 201, body: { link: `${this.publicUrl}/i/${code}`, connector_url: `${this.publicUrl}/mcp/i/${code}`, inviter, inviter_claude: inv.inviter_claude, contacts: contactos, expires: inv.expires } };
|
|
813
|
+
}
|
|
814
|
+
async _paginaInvitacion(code, fuente = null, ip = null) {
|
|
815
|
+
const inv = await this.store.kvGet('invitacion', code);
|
|
816
|
+
const datos = inv && !inv.used_by
|
|
817
|
+
? { code, inviter: inv.inviter, inviter_claude: inv.inviter_claude, contacts: inv.contacts || [], greet: inv.greet || null, name_hint: inv.name_hint, connector_url: `${this.publicUrl}/mcp/i/${code}` }
|
|
818
|
+
: { error: inv ? 'used' : 'unknown' };
|
|
819
|
+
// Embudo, etapa 1: el enlace se abrió (una invitación viva). Cuenta visitas, no personas: una
|
|
820
|
+
// vista previa de WhatsApp o un bot también abren. Actor = quien invitó; el invitado aún no existe.
|
|
821
|
+
// Una visita por código, canal, IP y hora: la revisión del 14-sep inundó el embudo con 80 GET en
|
|
822
|
+
// 43 ms. Otro canal (`?source=`) sí cuenta: es otra puerta, no la misma vista previa repetida.
|
|
823
|
+
const canal = Estafeta.fuenteLimpia(fuente) || inv?.source || '';
|
|
824
|
+
if (!datos.error && await this.store.kvPutIfAbsent?.('open_invite', `${code}:${canal}:${ip || 'x'}:${iso().slice(0, 13)}`, 1, now() + 2 * 3600_000)) await this._evento('open_invite', inv.inviter, { code, source: Estafeta.fuenteLimpia(fuente) || inv.source || null });
|
|
825
|
+
const html = APP_HTML.replace('<!--OAUTH-->', `<script>window.NYX5_INVITE=${JSON.stringify(datos).replace(/</g, '\\u003c')}</script>`);
|
|
826
|
+
return { status: inv ? 200 : 404, contentType: 'text/html; charset=utf-8', body: html };
|
|
827
|
+
}
|
|
828
|
+
// ---------- asistentes: alta, conocimiento, config, pausa y estado ----------
|
|
829
|
+
// Qué se puede configurar y con qué límites. Un modelo sin precio conocido se rechaza: el tope
|
|
830
|
+
// mensual se calcula con ese precio (PRECIOS en src/correo/asistente.js), y sin él mentiría.
|
|
831
|
+
_configAsistente(c = {}, base = {}) {
|
|
832
|
+
const model = c.model ?? base.model ?? 'claude-opus-5';
|
|
833
|
+
if (!PRECIOS[model]) return { error: `unknown model ${model}; this house knows the price of: ${Object.keys(PRECIOS).join(', ')}` };
|
|
834
|
+
const effort = c.effort ?? base.effort ?? 'medium';
|
|
835
|
+
if (!['low', 'medium', 'high', 'xhigh', 'max'].includes(effort)) return { error: 'effort must be low, medium, high, xhigh or max' };
|
|
836
|
+
const max_tokens = Number(c.max_tokens ?? base.max_tokens ?? 8000);
|
|
837
|
+
if (!Number.isInteger(max_tokens) || max_tokens < 256 || max_tokens > 64000) return { error: 'max_tokens must be an integer between 256 and 64000' };
|
|
838
|
+
const budget_usd = Number(c.budget_usd ?? base.budget_usd ?? 30);
|
|
839
|
+
if (!(budget_usd > 0 && budget_usd <= 1000)) return { error: 'budget_usd must be above 0 and at most 1000' };
|
|
840
|
+
// seal: cada respuesta termina con su sha256, para sellarla en la notaría (qa@, NX-606).
|
|
841
|
+
const seal = c.seal ?? base.seal ?? false;
|
|
842
|
+
if (typeof seal !== 'boolean') return { error: 'seal must be true or false' };
|
|
843
|
+
const free_for = c.free_for ?? base.free_for ?? [];
|
|
844
|
+
if (!Array.isArray(free_for) || free_for.length > 10 || !free_for.every((a) => { try { parseAddress(a); return true; } catch { return false; } })) return { error: 'free_for must be a list of up to 10 addresses that do not pay' };
|
|
845
|
+
// Cobro por crédito (NX-606 fase 1): tokens por respuesta de Spec, por veredicto de Gate y por
|
|
846
|
+
// abstención de Gate. 0 = gratis (el asistente de Sigo). Decisión de Nicholas (14-sep-2026):
|
|
847
|
+
// Spec 400, Gate 400 si dictamina y 200 si se abstiene.
|
|
848
|
+
const entero = (k, def, max = 1_000_000) => { const v = Number(c[k] ?? base[k] ?? def); return Number.isInteger(v) && v >= 0 && v <= max ? v : null; };
|
|
849
|
+
const price_tokens = entero('price_tokens', 0);
|
|
850
|
+
if (price_tokens === null) return { error: 'price_tokens must be an integer between 0 and 1000000' };
|
|
851
|
+
const gate_price_tokens = entero('gate_price_tokens', price_tokens);
|
|
852
|
+
if (gate_price_tokens === null) return { error: 'gate_price_tokens must be an integer between 0 and 1000000' };
|
|
853
|
+
const gate_abstain_tokens = entero('gate_abstain_tokens', Math.floor(gate_price_tokens / 2));
|
|
854
|
+
if (gate_abstain_tokens === null || gate_abstain_tokens > gate_price_tokens) return { error: 'gate_abstain_tokens must be an integer between 0 and gate_price_tokens' };
|
|
855
|
+
const gate = c.gate ?? base.gate ?? false;
|
|
856
|
+
if (typeof gate !== 'boolean') return { error: 'gate must be true or false' };
|
|
857
|
+
const persona_gate = c.persona_gate ?? base.persona_gate ?? '';
|
|
858
|
+
if (typeof persona_gate !== 'string' || persona_gate.length > 20_000) return { error: 'persona_gate must be a string of at most 20000 characters' };
|
|
859
|
+
// `tools` (26-oct-2026): las manos del agente vivo (src/correo/acciones.js), APAGADAS por
|
|
860
|
+
// defecto. Un cerebro no recibe manos por existir: hay que decirlo, y la lista es cerrada.
|
|
861
|
+
const tools = c.tools ?? base.tools ?? false;
|
|
862
|
+
if (typeof tools !== 'boolean') return { error: 'tools must be true or false' };
|
|
863
|
+
return { model, effort, max_tokens, budget_usd, seal, price_tokens, gate_price_tokens, gate_abstain_tokens, gate, persona_gate, free_for, tools };
|
|
864
|
+
}
|
|
865
|
+
// Alta de un asistente de SISTEMA (NX-606): `<local>@<casa>` con llaves propias guardadas en la
|
|
866
|
+
// bóveda, tarjeta certificada por la casa, buzón abierto y cuenta en el Libro (recibe `pay`).
|
|
867
|
+
// A diferencia del modo delegado, no cuelga de nadie: su dueño es quien diga `config.owner`.
|
|
868
|
+
async _altaAsistenteSistema(b) {
|
|
869
|
+
const l = String(b.local || '').toLowerCase();
|
|
870
|
+
if (!Estafeta.validLocal(l) || l.startsWith('g.')) return { status: 400, body: { reason: 'local must be a valid address name' } };
|
|
871
|
+
if (this.isSystem(l)) return { status: 409, body: { reason: `${l}@ is a protocol address, not an assistant` } };
|
|
872
|
+
if (await this.store.getAgent(l)) return { status: 409, body: { reason: `${l}@${this.domain} already exists` } };
|
|
873
|
+
const k = b.keys || {};
|
|
874
|
+
if (![k.sig, k.sigPriv, k.enc, k.encPriv].every((x) => typeof x === 'string' && x)) return { status: 400, body: { reason: 'keys must bring sig, sigPriv, enc and encPriv' } };
|
|
875
|
+
// Prueba de posesión (invariante 9): la llave privada firma y la pública verifica.
|
|
876
|
+
let posee = false; try { posee = verifyObject(signObject({ nyx5: '1', prueba: l }, k), k.sig); } catch { posee = false; }
|
|
877
|
+
if (!posee) return { status: 400, body: { reason: 'sigPriv does not match sig' } };
|
|
878
|
+
const c = b.config || {};
|
|
879
|
+
const v = this._configAsistente(c);
|
|
880
|
+
if (v.error) return { status: 400, body: { reason: v.error } };
|
|
881
|
+
let owner = null;
|
|
882
|
+
if (c.owner != null) {
|
|
883
|
+
try { const p = parseAddress(String(c.owner)); owner = `${p.local}@${p.domain}`; } catch { return { status: 400, body: { reason: 'owner must be an address' } }; }
|
|
884
|
+
}
|
|
885
|
+
const since = iso();
|
|
886
|
+
let card;
|
|
887
|
+
try {
|
|
888
|
+
card = await this.registerAgent({ local: l, sig: k.sig, enc: k.enc, system: true, welcome: 0, inbox: { policy: 'open' },
|
|
889
|
+
capabilities: { accepts: ['text/plain', 'application/json', ...(v.gate ? [MEDIA_GATE] : [])], listed: true, assistant: { spec_tokens: v.price_tokens, ...(v.gate ? { gate_tokens: v.gate_price_tokens, gate_abstain_tokens: v.gate_abstain_tokens } : {}) } },
|
|
890
|
+
custody: { keys: 'house', via: 'assistant', since } });
|
|
891
|
+
} catch (e) { return { status: e.status || 400, body: { reason: e.message } }; }
|
|
892
|
+
await this.store.kvPut('boveda', l, { sellado: this.boveda.sellar({ sig: k.sig, sigPriv: k.sigPriv, enc: k.enc, encPriv: k.encPriv }, l), root: null, since });
|
|
893
|
+
const cfg = { local: l, owner, system: true, ...v, persona: String(c.persona || ''), enabled: true, created: since };
|
|
894
|
+
await this.store.kvPut('asistente', l, cfg);
|
|
895
|
+
const i = (await this.store.kvGet('asistente', '_indice')) || [];
|
|
896
|
+
if (!i.includes(l)) await this.store.kvPut('asistente', '_indice', [...i, l]);
|
|
897
|
+
await this._evento('assistant_created', card.address, { budget_usd: cfg.budget_usd, system: true, price_tokens: cfg.price_tokens });
|
|
898
|
+
return { status: 201, body: { address: card.address, custody: card.custody, system: true, config: { ...cfg, persona: `${cfg.persona.length} chars`, persona_gate: `${cfg.persona_gate.length} chars` } } };
|
|
899
|
+
}
|
|
900
|
+
// ADOPCIÓN (26-oct-2026): un cerebro para una dirección que YA EXISTE. Nació de la prioridad de
|
|
901
|
+
// Nicholas — «que los agentes y sus funciones persistan y funcionen sin mi máquina» —: rosetta@,
|
|
902
|
+
// norte@ o sigo@ ya viven, tienen historial y tarjeta; lo que les falta es poder pensar desde la
|
|
903
|
+
// casa. No se crea nada: se toma la dirección viva, su tarjeta pasa a declarar que desde ahora la
|
|
904
|
+
// casa guarda su llave (la excepción declarada al invariante 8, visible para quien le escriba), y
|
|
905
|
+
// queda APAGADA. Nadie se despierta solo por haber sido adoptado: hay que encenderlo a mano.
|
|
906
|
+
async _adoptarAsistente(b) {
|
|
907
|
+
const l = String(b.local || '').toLowerCase();
|
|
908
|
+
if (!Estafeta.validLocal(l)) return { status: 400, body: { reason: 'local must be a valid address name' } };
|
|
909
|
+
if (this.isSystem(l)) return { status: 409, body: { reason: `${l}@ is a protocol address, not an assistant` } };
|
|
910
|
+
const rec = await this.store.getAgent(l);
|
|
911
|
+
if (!rec || rec.revoked) return { status: 404, body: { reason: `${l}@${this.domain} does not exist: adoption is for an address that is already alive` } };
|
|
912
|
+
if (l.startsWith('g.') || rec.group) return { status: 409, body: { reason: `${l}@${this.domain} is a group, not an agent` } };
|
|
913
|
+
// Un DELEGADO no se adopta: su llave la controla su dueño y la casa no se la toma por detrás.
|
|
914
|
+
// El modo delegado existe y pide la llave al dueño (POST sin `adopt`); aquí se nombra a quién pedírsela.
|
|
915
|
+
if (rec.delegation) return { status: 409, body: { reason: `${l}@${this.domain} is a delegated address of ${rec.delegation.by}: its owner registers it as a delegated assistant, or adopt the root address instead` } };
|
|
916
|
+
// Ya bajo custodia de la casa por OTRA vía (el buzón de ideas, un Claude conectado): no se pisa.
|
|
917
|
+
if (rec.custody?.keys === 'house' && rec.custody.via !== 'assistant') return { status: 409, body: { reason: `${l}@${this.domain} is already under house custody via ${rec.custody.via}: that brain is not replaced from here` } };
|
|
918
|
+
if (await this.store.kvGet('asistente', l)) return { status: 409, body: { reason: `${l}@${this.domain} already has an assistant brain: change it with PUT /admin/assistants/${l}/config, or remove it with DELETE /admin/assistants/${l}` } };
|
|
919
|
+
const k = b.keys || {};
|
|
920
|
+
if (k.sig !== rec.sig || k.enc !== rec.enc || !k.sigPriv || !k.encPriv) return { status: 400, body: { reason: 'keys do not match the card of that address' } };
|
|
921
|
+
// Prueba de posesión (invariante 9): la privada firma y la pública de la tarjeta verifica.
|
|
922
|
+
let posee = false; try { posee = verifyObject(signObject({ nyx5: '1', prueba: l }, k), k.sig); } catch { posee = false; }
|
|
923
|
+
if (!posee) return { status: 400, body: { reason: 'sigPriv does not match sig' } };
|
|
924
|
+
const c = b.config || {};
|
|
925
|
+
const v = this._configAsistente(c);
|
|
926
|
+
if (v.error) return { status: 400, body: { reason: v.error } };
|
|
927
|
+
let owner = null;
|
|
928
|
+
if (c.owner != null) {
|
|
929
|
+
try { const p = parseAddress(String(c.owner)); owner = `${p.local}@${p.domain}`; } catch { return { status: 400, body: { reason: 'owner must be an address' } }; }
|
|
930
|
+
}
|
|
931
|
+
const since = iso();
|
|
932
|
+
const { certification: _c, webhook, notify_email, ...cuerpo } = rec;
|
|
933
|
+
const card = signObject({ ...cuerpo, custody: { keys: 'house', via: 'assistant', since } }, this.keys, 'certification');
|
|
934
|
+
await this.store.putAgent(l, { ...card, webhook, notify_email });
|
|
935
|
+
this.resolver.invalidate(`agent:${l}@${this.domain}`);
|
|
936
|
+
await this.store.kvPut('boveda', l, { sellado: this.boveda.sellar({ sig: k.sig, sigPriv: k.sigPriv, enc: k.enc, encPriv: k.encPriv }, l), root: null, since });
|
|
937
|
+
const cfg = { local: l, owner, adopted: true, ...v, persona: String(c.persona || ''), enabled: false, created: since };
|
|
938
|
+
await this.store.kvPut('asistente', l, cfg);
|
|
939
|
+
const i = (await this.store.kvGet('asistente', '_indice')) || [];
|
|
940
|
+
if (!i.includes(l)) await this.store.kvPut('asistente', '_indice', [...i, l]);
|
|
941
|
+
await this._evento('assistant_adopted', card.address, { budget_usd: cfg.budget_usd, tools: cfg.tools === true });
|
|
942
|
+
return { status: 201, body: { address: card.address, custody: card.custody, adopted: true, enabled: false, config: { ...cfg, persona: `${cfg.persona.length} chars`, persona_gate: `${cfg.persona_gate.length} chars` } } };
|
|
943
|
+
}
|
|
944
|
+
// Quitar el cerebro: la dirección sigue VIVA y queda sin cerebro. La llave sale de la bóveda, así
|
|
945
|
+
// que la casa deja de poder firmar y descifrar en su nombre, y la tarjeta lo dice.
|
|
946
|
+
async _quitarAsistente(local, cfg) {
|
|
947
|
+
const rec = await this.store.getAgent(local);
|
|
948
|
+
let custody = null;
|
|
949
|
+
if (rec && rec.custody?.via === 'assistant') {
|
|
950
|
+
const { certification: _c, webhook, notify_email, custody: _cu, ...cuerpo } = rec;
|
|
951
|
+
custody = { keys: 'self', via: null, ended: iso(), was: 'assistant' };
|
|
952
|
+
const card = signObject({ ...cuerpo, custody }, this.keys, 'certification');
|
|
953
|
+
await this.store.putAgent(local, { ...card, webhook, notify_email });
|
|
954
|
+
this.resolver.invalidate(`agent:${local}@${this.domain}`);
|
|
955
|
+
}
|
|
956
|
+
await this.store.kvDelete?.('boveda', local);
|
|
957
|
+
await this.store.kvDelete?.('asistente', local);
|
|
958
|
+
await this.store.kvDelete?.('asistente-conocimiento', local);
|
|
959
|
+
const i = (await this.store.kvGet('asistente', '_indice')) || [];
|
|
960
|
+
await this.store.kvPut('asistente', '_indice', i.filter((x) => x !== local));
|
|
961
|
+
await this._evento('assistant_removed', `${local}@${this.domain}`, { adopted: cfg?.adopted === true });
|
|
962
|
+
return { status: 200, body: { address: `${local}@${this.domain}`, removed: true, alive: !!(rec && !rec.revoked), custody } };
|
|
963
|
+
}
|
|
964
|
+
// Alta del buzón de vacaciones ideas@ (src/correo/ideas.js): dirección raíz de la casa con llaves
|
|
965
|
+
// propias en la bóveda, tarjeta `custody.via = ideas`, buzón por lista (el dueño y quien él diga) y
|
|
966
|
+
// sin cuenta que valga nada (nace sin regalo y nunca opera el Libro). Con el buzón ya dado de alta,
|
|
967
|
+
// un cuerpo sin `keys` sólo cambia la lista: la llave no se reemplaza por esta puerta.
|
|
968
|
+
async _altaBuzonIdeas(b) {
|
|
969
|
+
const l = IDEAS_LOCAL;
|
|
970
|
+
// La confirmación tiene que poder VOLVER: un Claude conectado sólo acepta a su dueño (lista), y
|
|
971
|
+
// la confirmación de ideas@ le rebotaría en silencio. A cada dirección de la lista que sea de
|
|
972
|
+
// esta casa y filtre por lista se le agrega ideas@ (lo mismo que hace /admin/contacts).
|
|
973
|
+
const abrirVuelta = async (allow) => {
|
|
974
|
+
const propia = `${l}@${this.domain}`;
|
|
975
|
+
for (const dir of allow) {
|
|
976
|
+
const p = parseAddress(dir);
|
|
977
|
+
if (p.domain !== this.domain || p.local === l) continue;
|
|
978
|
+
const rec = await this.store.getAgent(p.local);
|
|
979
|
+
if (rec?.inbox?.policy === 'allowlist' && !(rec.inbox.allowlist || []).includes(propia)) { await this.agregarContactos(p.local, [propia]); this.resolver.invalidate(`agent:${dir}`); }
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
let owner;
|
|
983
|
+
try { const p = parseAddress(String(b.owner || '')); owner = `${p.local}@${p.domain}`; } catch { return { status: 400, body: { reason: 'owner must be an address' } }; }
|
|
984
|
+
const allow = [];
|
|
985
|
+
for (const x of Array.isArray(b.allow) ? b.allow : []) {
|
|
986
|
+
let dir; try { const p = parseAddress(String(x)); dir = `${p.local}@${p.domain}`; } catch { return { status: 400, body: { reason: `allow: ${String(x).slice(0, 80)} is not an address` } }; }
|
|
987
|
+
if (!allow.includes(dir)) allow.push(dir);
|
|
988
|
+
}
|
|
989
|
+
if (!allow.includes(owner)) allow.unshift(owner);
|
|
990
|
+
if (allow.length > 20) return { status: 400, body: { reason: 'allow: at most 20 addresses' } };
|
|
991
|
+
const previo = await this.store.getAgent(l);
|
|
992
|
+
const since = iso();
|
|
993
|
+
if (previo) {
|
|
994
|
+
if (previo.custody?.via !== IDEAS_VIA) return { status: 409, body: { reason: `${l}@${this.domain} already exists and is not the ideas mailbox` } };
|
|
995
|
+
if (b.keys) return { status: 409, body: { reason: `${l}@${this.domain} already exists; send only owner and allow to change its list` } };
|
|
996
|
+
const { certification: _c, webhook, notify_email, ...cuerpo } = previo;
|
|
997
|
+
const card = signObject({ ...cuerpo, inbox: { policy: 'allowlist', allowlist: allow } }, this.keys, 'certification');
|
|
998
|
+
await this.store.putAgent(l, { ...card, webhook, notify_email });
|
|
999
|
+
this.resolver.invalidate(`agent:${l}@${this.domain}`);
|
|
1000
|
+
await this.store.kvPut('ideas', '_config', { owner, allow, updated: since });
|
|
1001
|
+
await abrirVuelta(allow);
|
|
1002
|
+
return { status: 200, body: { address: card.address, owner, allow } };
|
|
1003
|
+
}
|
|
1004
|
+
const k = b.keys || {};
|
|
1005
|
+
if (![k.sig, k.sigPriv, k.enc, k.encPriv].every((x) => typeof x === 'string' && x)) return { status: 400, body: { reason: 'keys must bring sig, sigPriv, enc and encPriv' } };
|
|
1006
|
+
let posee = false; try { posee = verifyObject(signObject({ nyx5: '1', prueba: l }, k), k.sig); } catch { posee = false; }
|
|
1007
|
+
if (!posee) return { status: 400, body: { reason: 'sigPriv does not match sig' } };
|
|
1008
|
+
let card;
|
|
1009
|
+
try {
|
|
1010
|
+
card = await this.registerAgent({ local: l, sig: k.sig, enc: k.enc, system: true, welcome: 0, inbox: { policy: 'allowlist', allowlist: allow },
|
|
1011
|
+
capabilities: { accepts: ['text/plain', 'application/json'], listed: false }, custody: { keys: 'house', via: IDEAS_VIA, since } });
|
|
1012
|
+
} catch (e) { return { status: e.status || 400, body: { reason: e.message } }; }
|
|
1013
|
+
await this.store.kvPut('boveda', l, { sellado: this.boveda.sellar({ sig: k.sig, sigPriv: k.sigPriv, enc: k.enc, encPriv: k.encPriv }, l), root: null, since });
|
|
1014
|
+
await this.store.kvPut('ideas', '_config', { owner, allow, created: since });
|
|
1015
|
+
await abrirVuelta(allow);
|
|
1016
|
+
await this._evento('ideas_created', card.address, { owner, allow: allow.length });
|
|
1017
|
+
return { status: 201, body: { address: card.address, custody: card.custody, owner, allow } };
|
|
1018
|
+
}
|
|
1019
|
+
async _adminAsistente(rx, local, accion) {
|
|
1020
|
+
const b = rx.body || {};
|
|
1021
|
+
const indice = async () => (await this.store.kvGet('asistente', '_indice')) || [];
|
|
1022
|
+
if (rx.method === 'POST' && !local && !accion) {
|
|
1023
|
+
if (!this.boveda) return { status: 503, body: { reason: 'this house has no vault key' } };
|
|
1024
|
+
// El recíproco del guard de /admin/executors: un sobre lo contesta UNO. Si esta dirección ya
|
|
1025
|
+
// trabaja en CI, darle cerebro la haría contestar dos veces el mismo encargo.
|
|
1026
|
+
const conEjecutor = String(b.local || '').toLowerCase();
|
|
1027
|
+
if (conEjecutor && await this.store.kvGet('ci', conEjecutor)) {
|
|
1028
|
+
return { status: 409, body: { reason: `${conEjecutor}@${this.domain} already has an executor in CI: an address answers with a brain or with an executor, not both. Remove it with DELETE /admin/executors/${conEjecutor}` } };
|
|
1029
|
+
}
|
|
1030
|
+
if (b.adopt === true) return this._adoptarAsistente(b);
|
|
1031
|
+
if (b.system === true) return this._altaAsistenteSistema(b);
|
|
1032
|
+
const l = String(b.local || '').toLowerCase();
|
|
1033
|
+
const rec = Estafeta.validLocal(l) ? await this.store.getAgent(l) : null;
|
|
1034
|
+
if (!rec?.delegation || rec.revoked) return { status: 404, body: { reason: 'an assistant must be an existing delegated address' } };
|
|
1035
|
+
if (rec.delegation.scope?.messages_only !== true) return { status: 400, body: { reason: 'an assistant must be messages-only: it answers, it does not move money' } };
|
|
1036
|
+
const k = b.keys || {};
|
|
1037
|
+
if (k.sig !== rec.sig || !k.sigPriv || !k.enc || !k.encPriv || k.enc !== rec.enc) return { status: 400, body: { reason: 'keys do not match the card of that address' } };
|
|
1038
|
+
const v = this._configAsistente(b.config || {});
|
|
1039
|
+
if (v.error) return { status: 400, body: { reason: v.error } };
|
|
1040
|
+
await this.store.kvPut('boveda', l, { sellado: this.boveda.sellar({ sig: k.sig, sigPriv: k.sigPriv, enc: k.enc, encPriv: k.encPriv }, l), root: rec.delegation.by, since: iso() });
|
|
1041
|
+
// La tarjeta declara que la casa guarda su llave, igual que la de un Claude conectado.
|
|
1042
|
+
const { certification: _c, webhook, notify_email, ...cuerpo } = rec;
|
|
1043
|
+
const card = signObject({ ...cuerpo, custody: { keys: 'house', via: 'assistant', since: iso() } }, this.keys, 'certification');
|
|
1044
|
+
await this.store.putAgent(l, { ...card, webhook, notify_email });
|
|
1045
|
+
const c = b.config || {};
|
|
1046
|
+
const cfg = { local: l, owner: rec.delegation.by, ...v, persona: String(c.persona || ''), enabled: true, created: iso() };
|
|
1047
|
+
await this.store.kvPut('asistente', l, cfg);
|
|
1048
|
+
const i = await indice();
|
|
1049
|
+
if (!i.includes(l)) await this.store.kvPut('asistente', '_indice', [...i, l]);
|
|
1050
|
+
await this._evento('assistant_created', card.address, { budget_usd: cfg.budget_usd });
|
|
1051
|
+
return { status: 201, body: { address: card.address, custody: card.custody, config: { ...cfg, persona: `${cfg.persona.length} chars` } } };
|
|
1052
|
+
}
|
|
1053
|
+
const cfg = local ? await this.store.kvGet('asistente', local) : null;
|
|
1054
|
+
if (!cfg) return { status: 404, body: { reason: 'no such assistant' } };
|
|
1055
|
+
if (rx.method === 'PUT' && accion === 'knowledge') {
|
|
1056
|
+
const texto = typeof b.texto === 'string' ? b.texto : '';
|
|
1057
|
+
if (!texto || Buffer.byteLength(texto) > 1_500_000) return { status: 400, body: { reason: 'knowledge must be text up to 1.5 MB' } };
|
|
1058
|
+
await this.store.kvPut('asistente-conocimiento', local, { texto, updated: iso() });
|
|
1059
|
+
return { status: 200, body: { bytes: Buffer.byteLength(texto), updated: iso() } };
|
|
1060
|
+
}
|
|
1061
|
+
// Cambiar modelo, esfuerzo, tope o persona sin volver a dar de alta (11-sep-2026: Nicholas pidió
|
|
1062
|
+
// un modelo más barato para el agente de Sigo). Lo que no se manda, queda como estaba.
|
|
1063
|
+
if (rx.method === 'PUT' && accion === 'config') {
|
|
1064
|
+
const v = this._configAsistente(b, cfg);
|
|
1065
|
+
if (v.error) return { status: 400, body: { reason: v.error } };
|
|
1066
|
+
if (b.enabled != null && typeof b.enabled !== 'boolean') return { status: 400, body: { reason: 'enabled must be true or false' } };
|
|
1067
|
+
// `enabled` por aquí es lo que ENCIENDE un cerebro adoptado: nace apagado a propósito.
|
|
1068
|
+
const nueva = { ...cfg, ...v, ...(typeof b.persona === 'string' ? { persona: b.persona } : {}), ...(typeof b.enabled === 'boolean' ? { enabled: b.enabled } : {}), updated: iso() };
|
|
1069
|
+
await this.store.kvPut('asistente', local, nueva);
|
|
1070
|
+
return { status: 200, body: { enabled: nueva.enabled === true, model: nueva.model, effort: nueva.effort, max_tokens: nueva.max_tokens, budget_usd: nueva.budget_usd, seal: nueva.seal === true, tools: nueva.tools === true, price_tokens: nueva.price_tokens, gate: nueva.gate === true, gate_price_tokens: nueva.gate_price_tokens, gate_abstain_tokens: nueva.gate_abstain_tokens, free_for: nueva.free_for || [] } };
|
|
1071
|
+
}
|
|
1072
|
+
if (rx.method === 'DELETE' && !accion) return this._quitarAsistente(local, cfg);
|
|
1073
|
+
if (rx.method === 'POST' && (accion === 'pause' || accion === 'resume')) {
|
|
1074
|
+
await this.store.kvPut('asistente', local, { ...cfg, enabled: accion === 'resume' });
|
|
1075
|
+
return { status: 200, body: { enabled: accion === 'resume' } };
|
|
1076
|
+
}
|
|
1077
|
+
if (rx.method === 'GET' && !accion) {
|
|
1078
|
+
const mes = new Date().toISOString().slice(0, 7);
|
|
1079
|
+
const gasto = await gastoDe(this.store, local, mes);
|
|
1080
|
+
const con = await this.store.kvGet('asistente-conocimiento', local);
|
|
1081
|
+
return { status: 200, body: { address: `${local}@${this.domain}`, enabled: cfg.enabled, system: cfg.system === true, adopted: cfg.adopted === true, tools: cfg.tools === true, owner: cfg.owner || null, free_for: cfg.free_for || [], model: cfg.model, effort: cfg.effort, budget_usd: cfg.budget_usd, price_tokens: cfg.price_tokens ?? 0, gate: cfg.gate === true, gate_price_tokens: cfg.gate_price_tokens ?? 0, gate_abstain_tokens: cfg.gate_abstain_tokens ?? 0, month: mes, spent_usd: Math.round(gasto.usd * 10000) / 10000, calls: gasto.llamadas, knowledge_bytes: con ? Buffer.byteLength(con.texto) : 0, knowledge_updated: con?.updated || null, api_key: !!this.asistente, pending: (await this.store.listMail(local)).length } };
|
|
1082
|
+
}
|
|
1083
|
+
return { status: 405, body: { reason: 'method not allowed here' } };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ----- ejecutores: la dirección que trabaja sobre archivos (NX-412) -----
|
|
1087
|
+
// Lo que la casa guarda es el TOKEN de CI y a dónde dispararlo. El trabajo lo hace el ejecutor,
|
|
1088
|
+
// con la llave del rol en los secrets de su repositorio: aquí no se guarda ninguna llave de agente.
|
|
1089
|
+
// El token es de SÓLO ESCRITURA (como los secrets de Cloudflare): se rota, nunca se lee de vuelta.
|
|
1090
|
+
async _adminEjecutor(rx, local, accion) {
|
|
1091
|
+
const b = rx.body || {};
|
|
1092
|
+
const indice = async () => (await this.store.kvGet('ci', '_indice')) || [];
|
|
1093
|
+
const publico = (c) => ({
|
|
1094
|
+
address: `${c.local}@${this.domain}`, provider: c.provider, repo: c.repo, event_type: c.event_type,
|
|
1095
|
+
owner: c.owner, cupo_dia: c.cupo_dia, enabled: c.enabled === true, url: urlCi(c),
|
|
1096
|
+
token: c.token_last4 ? `…${c.token_last4}` : null, created: c.created, updated: c.updated || null,
|
|
1097
|
+
});
|
|
1098
|
+
if (rx.method === 'GET' && !local) {
|
|
1099
|
+
const todos = [];
|
|
1100
|
+
for (const l of await indice()) { const c = await this.store.kvGet('ci', l); if (c) todos.push(publico(c)); }
|
|
1101
|
+
return { status: 200, body: { executors: todos, providers: Object.keys(CI_PROVEEDORES), attempts: CI_INTENTOS, default_quota: CUPO_DESPACHOS_DIA } };
|
|
1102
|
+
}
|
|
1103
|
+
if (rx.method === 'POST' && !local && !accion) {
|
|
1104
|
+
if (!this.boveda) return { status: 503, body: { reason: 'this house has no vault key' } };
|
|
1105
|
+
const l = String(b.local || '').toLowerCase();
|
|
1106
|
+
const rec = Estafeta.validLocal(l) ? await this.store.getAgent(l) : null;
|
|
1107
|
+
if (!rec || rec.revoked) return { status: 404, body: { reason: 'an executor must be an existing address of this house' } };
|
|
1108
|
+
// Un sobre lo contesta UNO: un cerebro en la casa o un ejecutor afuera. Los dos juntos
|
|
1109
|
+
// contestarían el mismo encargo dos veces, y el segundo no sabría que el primero ya respondió.
|
|
1110
|
+
if (await this.store.kvGet('asistente', l)) return { status: 409, body: { reason: `${l}@${this.domain} already has a brain in this house: an address answers with a brain or with an executor, not both. Remove it with DELETE /admin/assistants/${l}` } };
|
|
1111
|
+
if (await this.store.kvGet('ci', l)) return { status: 409, body: { reason: `${l}@${this.domain} already has an executor: change it with PUT /admin/executors/${l}, or remove it with DELETE /admin/executors/${l}` } };
|
|
1112
|
+
const { local: _l, ...campos } = b;
|
|
1113
|
+
const v = validarCi(campos);
|
|
1114
|
+
if (v.error) return { status: 400, body: { reason: v.error } };
|
|
1115
|
+
const token = String(b.token);
|
|
1116
|
+
await this.store.kvPut('boveda', `ci:${l}`, { sellado: this.boveda.sellar({ token }, `ci:${l}`), since: iso() });
|
|
1117
|
+
const cfg = { local: l, ...v, token_last4: token.slice(-4), enabled: b.enabled === true, created: iso() };
|
|
1118
|
+
await this.store.kvPut('ci', l, cfg);
|
|
1119
|
+
const i = await indice();
|
|
1120
|
+
if (!i.includes(l)) await this.store.kvPut('ci', '_indice', [...i, l]);
|
|
1121
|
+
this._ciIdx = null;
|
|
1122
|
+
await this._evento('executor_created', `${l}@${this.domain}`, { provider: cfg.provider, repo: cfg.repo });
|
|
1123
|
+
return { status: 201, body: publico(cfg) };
|
|
1124
|
+
}
|
|
1125
|
+
const cfg = local ? await this.store.kvGet('ci', local) : null;
|
|
1126
|
+
if (!cfg) return { status: 404, body: { reason: 'no such executor' } };
|
|
1127
|
+
if (rx.method === 'GET' && !accion) {
|
|
1128
|
+
const pendientes = await this.store.listMail(local);
|
|
1129
|
+
const rec = await this.store.getAgent(local);
|
|
1130
|
+
const sobres = [];
|
|
1131
|
+
for (const m of pendientes) {
|
|
1132
|
+
if (!despachable(this, local, rec, m)) continue;
|
|
1133
|
+
const d = await this.store.kvGet('ci-despacho', `${local}:${m.envelope.id}`);
|
|
1134
|
+
sobres.push({ envelope: m.envelope.id, from: m.envelope.from, type: m.envelope.type, state: d?.estado || 'nuevo', attempts: Number(d?.intentos || 0), last: d?.ultimo || null, reason: d?.razon || null });
|
|
1135
|
+
}
|
|
1136
|
+
const usado = Number(await this.store.kvGet('ci-cupo', `${local}:${iso().slice(0, 10)}`)) || 0;
|
|
1137
|
+
return { status: 200, body: { ...publico(cfg), pending: pendientes.length, dispatchable: sobres, used_today: usado } };
|
|
1138
|
+
}
|
|
1139
|
+
if (rx.method === 'PUT' && !accion) {
|
|
1140
|
+
const { local: _l2, ...campos } = b;
|
|
1141
|
+
const v = validarCi(campos, cfg);
|
|
1142
|
+
if (v.error) return { status: 400, body: { reason: v.error } };
|
|
1143
|
+
if (b.token != null) {
|
|
1144
|
+
if (!this.boveda) return { status: 503, body: { reason: 'this house has no vault key' } };
|
|
1145
|
+
await this.store.kvPut('boveda', `ci:${local}`, { sellado: this.boveda.sellar({ token: String(b.token) }, `ci:${local}`), since: iso() });
|
|
1146
|
+
}
|
|
1147
|
+
const nueva = { ...cfg, ...v, ...(b.token != null ? { token_last4: String(b.token).slice(-4) } : {}), ...(typeof b.enabled === 'boolean' ? { enabled: b.enabled } : {}), updated: iso() };
|
|
1148
|
+
await this.store.kvPut('ci', local, nueva);
|
|
1149
|
+
return { status: 200, body: publico(nueva) };
|
|
1150
|
+
}
|
|
1151
|
+
if (rx.method === 'DELETE' && !accion) {
|
|
1152
|
+
await this.store.kvDelete('ci', local);
|
|
1153
|
+
await this.store.kvDelete('boveda', `ci:${local}`);
|
|
1154
|
+
await this.store.kvPut('ci', '_indice', (await indice()).filter((x) => x !== local));
|
|
1155
|
+
this._ciIdx = null;
|
|
1156
|
+
await this._evento('executor_removed', `${local}@${this.domain}`, {});
|
|
1157
|
+
return { status: 200, body: { removed: `${local}@${this.domain}` } };
|
|
1158
|
+
}
|
|
1159
|
+
// Volver a intentar a mano lo que agotó sus intentos: es la salida que nombra el aviso al dueño.
|
|
1160
|
+
// Borra el estado del sobre, no el cupo del día: el freno de plata no se levanta por aquí.
|
|
1161
|
+
if (rx.method === 'POST' && accion === 'dispatch') {
|
|
1162
|
+
const rec = await this.store.getAgent(local);
|
|
1163
|
+
const hechos = [];
|
|
1164
|
+
for (const m of await this.store.listMail(local)) {
|
|
1165
|
+
if (!despachable(this, local, rec, m)) continue;
|
|
1166
|
+
const d = await this.store.kvGet('ci-despacho', `${local}:${m.envelope.id}`);
|
|
1167
|
+
if (d?.estado === 'ok') continue;
|
|
1168
|
+
for (let i = 0; i < CI_INTENTOS; i++) await this.store.kvDelete('ci-turno', `${local}:${m.envelope.id}:${i}`);
|
|
1169
|
+
await this.store.kvDelete('ci-despacho', `${local}:${m.envelope.id}`);
|
|
1170
|
+
await this.store.kvDelete('ci-aviso', `${local}:${m.envelope.id}`);
|
|
1171
|
+
const r = await despacharCi(this, local, m.envelope, { cfg });
|
|
1172
|
+
hechos.push({ envelope: m.envelope.id, ok: r.ok === true, status: r.status || null, reason: r.reason || r.skipped || null });
|
|
1173
|
+
}
|
|
1174
|
+
return { status: 200, body: { dispatched: hechos } };
|
|
1175
|
+
}
|
|
1176
|
+
return { status: 405, body: { reason: 'method not allowed here' } };
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Suma direcciones a la lista de quién puede escribirle a una dirección (la re-certifica la casa).
|
|
1180
|
+
// Sólo agrega, y sólo si esa dirección ya filtra por lista: una abierta sigue abierta.
|
|
1181
|
+
async agregarContactos(local, direcciones) {
|
|
1182
|
+
const rec = await this.store.getAgent(local);
|
|
1183
|
+
if (!rec || rec.revoked || rec.inbox?.policy !== 'allowlist') return null;
|
|
1184
|
+
const lista = [...new Set([...(rec.inbox.allowlist || []), ...direcciones])];
|
|
1185
|
+
const { certification: _c, webhook, notify_email, ...cuerpo } = rec;
|
|
1186
|
+
const card = signObject({ ...cuerpo, inbox: { ...rec.inbox, allowlist: lista } }, this.keys, 'certification');
|
|
1187
|
+
await this.store.putAgent(local, { ...card, webhook, notify_email });
|
|
1188
|
+
return card;
|
|
1189
|
+
}
|
|
1190
|
+
// Conecta dos direcciones de esta casa en los DOS sentidos. Nació el 11-sep-2026: Nicholas pidió
|
|
1191
|
+
// que su Claude del teléfono y el de Basti quedaran como contactos sin que Basti volviera a
|
|
1192
|
+
// conectar el suyo (el plan gratis de Claude admite un solo conector). Sólo la casa lo hace, y
|
|
1193
|
+
// revisa los dos lados ANTES de escribir: nunca queda una conexión en un solo sentido.
|
|
1194
|
+
async conectarContactos(a, b) {
|
|
1195
|
+
const dirs = [a, b].map((x) => String(x || '').toLowerCase());
|
|
1196
|
+
let pa, pb; try { pa = parseAddress(dirs[0]); pb = parseAddress(dirs[1]); } catch { return { status: 400, body: { reason: 'two addresses of this house are needed' } }; }
|
|
1197
|
+
if (pa.domain !== this.domain || pb.domain !== this.domain) return { status: 400, body: { reason: `both addresses must belong to ${this.domain}` } };
|
|
1198
|
+
if (dirs[0] === dirs[1]) return { status: 400, body: { reason: 'an address is already its own contact' } };
|
|
1199
|
+
for (const l of [pa.local, pb.local]) {
|
|
1200
|
+
const rec = await this.store.getAgent(l);
|
|
1201
|
+
if (!rec || rec.revoked || rec.inbox?.policy !== 'allowlist') return { status: 409, body: { reason: `${l}@${this.domain} does not exist, is revoked, or does not filter by list` } };
|
|
1202
|
+
}
|
|
1203
|
+
await this.agregarContactos(pa.local, [dirs[1]]);
|
|
1204
|
+
await this.agregarContactos(pb.local, [dirs[0]]);
|
|
1205
|
+
await this._evento('contacts_connected', dirs[0], { with: dirs[1] });
|
|
1206
|
+
return { status: 200, body: { connected: dirs } };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Rutas del conector que un cliente MCP pide con CORS: descubrimiento, registro, token y /mcp.
|
|
1210
|
+
async _rutaRemota(rx) {
|
|
1211
|
+
const p = rx.path;
|
|
1212
|
+
if (rx.method === 'GET' && p.startsWith('/.well-known/oauth-protected-resource')) {
|
|
1213
|
+
const ruta = p.slice('/.well-known/oauth-protected-resource'.length) || '/mcp';
|
|
1214
|
+
if (ruta === '/mcp' || oauth.RUTA_INVITACION.test(ruta)) return { status: 200, body: oauth.metadatosRecurso(this, ruta) };
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
if (rx.method === 'GET' && p === '/.well-known/oauth-authorization-server') return { status: 200, body: oauth.metadatosServidor(this) };
|
|
1218
|
+
if (rx.method === 'POST' && p === '/oauth/register') return oauth.registrar(this, rx.body, rx.ip);
|
|
1219
|
+
if (rx.method === 'POST' && p === '/oauth/token') return oauth.token(this, rx.body);
|
|
1220
|
+
if (p === '/mcp' || oauth.RUTA_INVITACION.test(p)) return atenderMcp(this, rx);
|
|
1221
|
+
return null;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
302
1224
|
// ---------- autenticación de agentes propios ----------
|
|
303
1225
|
// Authorization: Nyx5 <b64u(canonical({address,ts,nonce,method,path,host}))>.<firma Ed25519>
|
|
304
1226
|
// `host` amarra el token a ESTA estafeta: el mismo header no sirve contra otra casa.
|
|
@@ -319,23 +1241,43 @@ export class Estafeta {
|
|
|
319
1241
|
if (!allowForeign) throw Object.assign(new Error('the agent does not belong to this domain'), { status: 401 });
|
|
320
1242
|
try { rec = await this.resolver.agentCard(claims.address); } catch (e) { throw Object.assign(new Error(`foreign agent could not be verified: ${e.message}`), { status: 401 }); }
|
|
321
1243
|
}
|
|
1244
|
+
if (rec.revoked) throw Object.assign(new Error('this address was revoked by its owner'), { status: 401 });
|
|
1245
|
+
if (rec.valid_until && Date.parse(rec.valid_until) <= now()) throw Object.assign(new Error('this address expired'), { status: 401 });
|
|
322
1246
|
if (Math.abs(now() - Date.parse(claims.ts)) > 300_000) throw Object.assign(new Error('token expired (5 minute window)'), { status: 401 });
|
|
323
1247
|
if (claims.method !== rx.method || claims.path !== path) throw Object.assign(new Error('token does not match this request'), { status: 401 });
|
|
324
1248
|
if (claims.host !== this.authHost) throw Object.assign(new Error(`token issued for another house (host ${claims.host || 'missing'}, expected ${this.authHost})`), { status: 401 });
|
|
325
1249
|
if (!verifyBytes(canonical(claims), m[2], rec.sig)) throw Object.assign(new Error('invalid token signature'), { status: 401 });
|
|
326
1250
|
if (!await this.store.useNonce(`${claims.address}:${claims.nonce}`, now())) throw Object.assign(new Error('nonce reutilizado'), { status: 401 });
|
|
327
|
-
|
|
1251
|
+
const who = { local, address: claims.address, record: rec };
|
|
1252
|
+
if (domain === this.domain) this._anotarPresencia(who).catch((e) => this.log(`presencia: ${e.message}`));
|
|
1253
|
+
return who;
|
|
328
1254
|
}
|
|
329
1255
|
|
|
330
1256
|
// ---------- salida: el agente entrega un sobre a su estafeta ----------
|
|
1257
|
+
// Los límites de un delegado (types, to_domains, sólo mensajes) valen por DONDE entre el sobre.
|
|
1258
|
+
// Defecto real (11-sep-2026, revisión antes de publicar el botón de tokens): sólo se aplicaban en
|
|
1259
|
+
// /outbound, y un subagente de sólo mensajes pagaba entregando su sobre firmado directo a /inbound,
|
|
1260
|
+
// que es público porque por ahí entra la federación. Ahora los aplican las dos puertas y el Libro.
|
|
1261
|
+
_limiteDeAlcance(env, scope) {
|
|
1262
|
+
if (!scope) return null;
|
|
1263
|
+
if (scope.types?.length && !scope.types.includes(env.type)) return `agente delegado: solo puede enviar type ${scope.types.join('|')}`;
|
|
1264
|
+
if (scope.to_domains?.length && !env.to.every((t) => scope.to_domains.includes(parseAddress(t).domain))) return `agente delegado: solo puede escribir a ${scope.to_domains.join(', ')}`;
|
|
1265
|
+
// Sólo mensajes: la dirección que guarda la casa para el Claude de un teléfono no toca el Libro
|
|
1266
|
+
// ni paga estampillas. Aunque el puente no le ofrezca esas herramientas, la casa lo niega igual.
|
|
1267
|
+
if (scope.messages_only) {
|
|
1268
|
+
if (!['message', 'result', 'receipt'].includes(env.type)) return 'this is a messages-only address: it can send message, result or receipt';
|
|
1269
|
+
if (env.to.some((t) => parseAddress(t).local === 'libro')) return 'this is a messages-only address: it cannot operate the ledger';
|
|
1270
|
+
if (env.stamp) return 'this is a messages-only address: it cannot pay stamps';
|
|
1271
|
+
}
|
|
1272
|
+
return null;
|
|
1273
|
+
}
|
|
331
1274
|
async outbound(env, submitter) {
|
|
332
1275
|
const v = validateEnvelope(env, { maxBytes: this.policy.max_bytes });
|
|
333
1276
|
if (!v.ok) return v;
|
|
334
1277
|
if (env.from !== submitter.address) return { ok: false, code: 403, reason: 'from does not match the authenticated agent' };
|
|
335
1278
|
if (env.signature.kid !== submitter.record.sig || !verifyObject(env, submitter.record.sig)) return { ok: false, code: 403, reason: 'invalid envelope signature' };
|
|
336
|
-
const
|
|
337
|
-
if (
|
|
338
|
-
if (scope?.to_domains?.length && !env.to.every((t) => scope.to_domains.includes(parseAddress(t).domain))) return { ok: false, code: 403, reason: `agente delegado: solo puede escribir a ${scope.to_domains.join(', ')}` };
|
|
1279
|
+
const fuera = this._limiteDeAlcance(env, submitter.record.delegation?.scope);
|
|
1280
|
+
if (fuera) return { ok: false, code: 403, reason: fuera };
|
|
339
1281
|
|
|
340
1282
|
const byDomain = new Map();
|
|
341
1283
|
for (const to of env.to) { const { domain } = parseAddress(to); byDomain.set(domain, [...(byDomain.get(domain) || []), to]); }
|
|
@@ -349,22 +1291,39 @@ export class Estafeta {
|
|
|
349
1291
|
await this._outbox(job);
|
|
350
1292
|
jobs.push({ job: job.id, domain, to });
|
|
351
1293
|
}
|
|
1294
|
+
await this._primerMensaje(submitter, env);
|
|
352
1295
|
return { ok: true, code: 202, id: env.id, jobs };
|
|
353
1296
|
}
|
|
354
1297
|
async _outbox(job, extra = {}) {
|
|
355
|
-
|
|
1298
|
+
// El sobre viaja con el estado: la bandeja guardaba sólo cómo iba el envío, y así lo que uno
|
|
1299
|
+
// mandó desaparecía de su propio historial en cuanto se entregaba.
|
|
1300
|
+
await this.store.putOutbox(job.from_local, { job: job.id, id: job.envelope.id, domain: job.domain, to: job.to, status: job.status, attempts: job.attempts, next_attempt: job.next_attempt, updated: iso(), log: job.log, envelope: job.envelope, ...extra });
|
|
356
1301
|
}
|
|
357
1302
|
|
|
358
1303
|
// ---------- trabajador de entrega (store-and-forward) ----------
|
|
359
1304
|
// El reclamo es exclusivo (claimDueJobs): dos ticks concurrentes (cron solapado, multi-isolate)
|
|
360
1305
|
// no toman el mismo trabajo. Un trabajo reclamado y no resuelto vuelve a ser reclamable al minuto.
|
|
361
|
-
|
|
1306
|
+
// `programado`: el reloj de la casa (cron en el edge, intervalo en Node). Un tick disparado por una
|
|
1307
|
+
// petición (kick) NO atiende asistentes: después de responder, el edge sólo da 30 segundos.
|
|
1308
|
+
async tick({ programado = true } = {}) {
|
|
362
1309
|
await this.init();
|
|
363
1310
|
const due = await this.store.claimDueJobs(now(), 20);
|
|
364
1311
|
for (const job of due) await this._deliver(job);
|
|
365
1312
|
await this.store.pruneNonces?.(now() - 600_000);
|
|
1313
|
+
// La purga de lo vencido es limpieza: si falla (una casa cuya base aún no tiene la tabla 0006),
|
|
1314
|
+
// no puede tumbar el ciclo que entrega el correo de todos.
|
|
1315
|
+
try { await this.store.kvPurge?.(now()); } catch (e) { this.log(`kv: no se pudo purgar lo vencido: ${e.message}`); }
|
|
366
1316
|
if (this.index.enabled) await this._indexCrawlIfDue();
|
|
367
1317
|
if (this.verifica.enabled) await this._verificarPendientes();
|
|
1318
|
+
// Sólo desde el reloj programado: recorre la tabla de contratos, y el tick que sigue a cada
|
|
1319
|
+
// petición no tiene por qué pagarlo.
|
|
1320
|
+
if (programado) await this._liberarVencidos();
|
|
1321
|
+
if (programado) { try { await atenderAsistentes(this); } catch (e) { this.log(`asistentes: ${e.message}`); } }
|
|
1322
|
+
if (programado && this.ideas.enabled) { try { await atenderIdeas(this); } catch (e) { this.log(`ideas: ${e.message}`); } }
|
|
1323
|
+
if (programado) { try { await atenderEjecutores(this); } catch (e) { this.log(`ejecutores: ${e.message}`); } }
|
|
1324
|
+
// Retención de los adjuntos (NX-413): 30 días, o 7 desde que el último destinatario lo bajó.
|
|
1325
|
+
// Es limpieza, como la purga del kv: si falla no puede tumbar la entrega del correo de todos.
|
|
1326
|
+
if (programado && this.objetos) { try { await limpiarAdjuntos(this, { nowMs: now() }); } catch (e) { this.log(`adjuntos: ${e.message}`); } }
|
|
368
1327
|
await this.flushPushes();
|
|
369
1328
|
}
|
|
370
1329
|
async _deliver(job) {
|
|
@@ -444,13 +1403,13 @@ export class Estafeta {
|
|
|
444
1403
|
async _verificarPendientes() {
|
|
445
1404
|
const arbitro = `verifica@${this.domain}`;
|
|
446
1405
|
let contratos;
|
|
447
|
-
try { contratos = await this.store.libroListContracts(); } catch { return; }
|
|
1406
|
+
try { contratos = await this.store.libroListContracts({ state: ['held', 'delivered'] }); } catch { return; }
|
|
448
1407
|
const candidatos = contratos.filter((c) => c.kind === 'escrow' && ['held', 'delivered'].includes(c.state) && c.arbiter === arbitro && pruebasDe(c));
|
|
449
1408
|
for (const c of candidatos.slice(0, this.verifica.maxPorTick)) {
|
|
450
1409
|
// Solo se verifica lo que ya se declaró entregado, salvo que el contrato pida verificar
|
|
451
1410
|
// desde el arranque (terms.verify_on: 'accept'), útil para un endpoint que ya debía estar en pie.
|
|
452
1411
|
if (c.state === 'held' && (c.terms?.verify_on || 'deliver') !== 'accept') continue;
|
|
453
|
-
const v = await veredicto(pruebasDe(c), { fetchImpl: this.fetch, timeoutMs: this.verifica.timeoutMs, entregado: c.evidence ?? null, entregadoSha256: c.evidence_sha256 ?? null });
|
|
1412
|
+
const v = await veredicto(pruebasDe(c), { fetchImpl: this.fetch, timeoutMs: this.verifica.timeoutMs, privados: this.verifica.privados === true, entregado: c.evidence ?? null, entregadoSha256: c.evidence_sha256 ?? null });
|
|
454
1413
|
if (v.indeciso) { this.log(`verifica ${c.id}: sin veredicto (${v.razon})`); continue; }
|
|
455
1414
|
// La decisión viaja como sobre firmado a libro@ y entra por `inbound`, la MISMA puerta
|
|
456
1415
|
// que usa cualquier agente (invariante 2). No se toca el Libro por dentro: si la firma
|
|
@@ -472,6 +1431,32 @@ export class Estafeta {
|
|
|
472
1431
|
}
|
|
473
1432
|
}
|
|
474
1433
|
|
|
1434
|
+
// ----- escrow que vence (NX-503): entregado, con plazo vencido y ventana de revisión cerrada -----
|
|
1435
|
+
// La casa se lo dice a sí misma como sobre firmado de libro@ a libro@ y entra por `inbound`,
|
|
1436
|
+
// igual que verifica@ (invariante 2): nada toca el Libro por dentro. La op `expire` vuelve a
|
|
1437
|
+
// comprobar las fechas; aquí sólo se elige a quién mirar.
|
|
1438
|
+
async _liberarVencidos() {
|
|
1439
|
+
let contratos;
|
|
1440
|
+
try { contratos = await this.store.libroListContracts({ state: 'delivered' }); } catch { return; }
|
|
1441
|
+
const ahora = now();
|
|
1442
|
+
const vencidos = contratos.filter((c) => {
|
|
1443
|
+
if (c.kind !== 'escrow' || c.state !== 'delivered') return false;
|
|
1444
|
+
const plazo = Date.parse(c.terms?.deadline || ''); if (Number.isNaN(plazo)) return false;
|
|
1445
|
+
const entregado = Date.parse(c.history?.filter((h) => h.op === 'deliver').at(-1)?.at || '') || 0;
|
|
1446
|
+
return ahora >= Math.max(plazo, entregado) + this.libro.reviewWindowMs;
|
|
1447
|
+
});
|
|
1448
|
+
for (const c of vencidos.slice(0, 10)) {
|
|
1449
|
+
const env = signObject({
|
|
1450
|
+
nyx5: '1', id: uuid(), from: `libro@${this.domain}`, to: [`libro@${this.domain}`],
|
|
1451
|
+
created: iso(), expires: null, thread: c.id, in_reply_to: null, type: 'task',
|
|
1452
|
+
content: { media: MEDIA.op, body: { op: 'expire', contract: c.id } },
|
|
1453
|
+
}, this.keys);
|
|
1454
|
+
const r = await this.inbound(env);
|
|
1455
|
+
if (!r.ok) this.log(`expire ${c.id}: la casa rechazó su propia liquidación (${r.code}): ${r.reason}`);
|
|
1456
|
+
else { this.log(`expire ${c.id}: liberado al vendedor por ventana vencida`); await this._evento('escrow_expired', `libro@${this.domain}`, { contract: c.id, amount: c.amount }); }
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
475
1460
|
// Sobres emitidos por los agentes de sistema (postmaster@, libro@), firmados con la clave del dominio.
|
|
476
1461
|
// Destinatarios locales: directo al buzón. Remotos: por la cola, como cualquier envío.
|
|
477
1462
|
async _systemSend(fromLocal, to, { type = 'receipt', content, thread = null, in_reply_to = null, deliverAfter = null }) {
|
|
@@ -537,7 +1522,10 @@ export class Estafeta {
|
|
|
537
1522
|
if (op === 'accept' && c) return this._evento('first_quote', c.buyer, { contract: c.id, kind: c.kind, amount: c.amount, seller: c.seller });
|
|
538
1523
|
if (op === 'release' && c?.kind === 'escrow') return this._evento('escrow_released', c.seller, { contract: c.id, amount: c.amount, by: env.from, arbitrado: env.from === `verifica@${this.domain}` });
|
|
539
1524
|
if (op === 'refund' && c) return this._evento('escrow_refunded', c.buyer, { contract: c.id, amount: c.amount, by: env.from });
|
|
1525
|
+
if (op === 'reclaim' && c) return this._evento('escrow_refunded', c.buyer, { contract: c.id, amount: c.amount, by: env.from, reclaimed: true });
|
|
1526
|
+
if (op === 'expire' && c) return this._evento('escrow_released', c.seller, { contract: c.id, amount: c.amount, by: env.from, expired: true });
|
|
540
1527
|
if (op === 'forfeit' && c) return this._evento('bond_forfeited', c.seller, { contract: c.id, amount: c.amount, by: env.from, vouchee: c.vouchee || null });
|
|
1528
|
+
if (op === 'notarize' && result?.seal && !result.existing) return this._evento('notarized', env.from, { seal: result.seal.id, sha256: result.seal.sha256 });
|
|
541
1529
|
}
|
|
542
1530
|
|
|
543
1531
|
// Avisos del postmaster al remitente (rebotes y acuses de entrega). Llevan el hash del sobre original.
|
|
@@ -568,10 +1556,15 @@ export class Estafeta {
|
|
|
568
1556
|
|
|
569
1557
|
// Cadena de confianza: dominio emisor -> agente emisor -> firma del sobre
|
|
570
1558
|
let senderCard;
|
|
571
|
-
|
|
1559
|
+
// Se pide «para» el primer destinatario de esta casa: si el remitente es secreto en la suya, su
|
|
1560
|
+
// casa sólo entrega la tarjeta a quien él tiene en su lista (NX-202).
|
|
1561
|
+
const paraQuien = pendientes.map((t) => t.toLowerCase()).find((t) => { try { return parseAddress(t).domain === this.domain; } catch { return false; } }) || null;
|
|
1562
|
+
try { senderCard = await this.resolver.agentCardForKid(env.from, env.signature.kid, { onBehalfOf: paraQuien }); }
|
|
572
1563
|
catch (e) { return { ok: false, code: e.permanent ? 403 : 421, reason: `could not verify the sender: ${e.message}` }; }
|
|
573
1564
|
const validKids = Resolver.acceptedKids(senderCard);
|
|
574
1565
|
if (!validKids.includes(env.signature.kid) || !verifyObject(env, env.signature.kid)) return { ok: false, code: 403, reason: 'the envelope signature does not match the sender' };
|
|
1566
|
+
const fuera = this._limiteDeAlcance(env, senderCard.delegation?.scope);
|
|
1567
|
+
if (fuera) return { ok: false, code: 403, reason: fuera };
|
|
575
1568
|
|
|
576
1569
|
// Firma de relay (segunda capa: la estafeta emisora también firma, análogo a SPF/DKIM)
|
|
577
1570
|
const { domain: fromDomain } = parseAddress(env.from);
|
|
@@ -581,19 +1574,34 @@ export class Estafeta {
|
|
|
581
1574
|
relayVerified = r.domain === fromDomain && senderCard._domain.keys.some((k) => k.sig === r.kid) && verifyBytes(`relay:${env.id}:${this.domain}`, r.sig, r.kid);
|
|
582
1575
|
}
|
|
583
1576
|
if (this.policy.require_relay && !relayVerified) return { ok: false, code: 403, reason: 'this domain requires a valid relay signature' };
|
|
584
|
-
|
|
1577
|
+
// La clave del límite: cada agente de ESTA casa cuenta por su cuenta (uno solo no frena a los
|
|
1578
|
+
// demás); los de otra casa cuentan por dominio, que es lo único verificado de ellos aquí.
|
|
1579
|
+
const claveTasa = fromDomain === this.domain ? env.from : fromDomain;
|
|
1580
|
+
if (!await this.rate.allow(claveTasa)) return { ok: false, code: 429, reason: fromDomain === this.domain ? 'rate limit for this sender' : 'rate limit for the sending domain' };
|
|
585
1581
|
|
|
586
1582
|
const accepted = [], rejected = [], results = {};
|
|
587
1583
|
const mails = [], libroBundles = [];
|
|
588
1584
|
const recibosPendientes = [];
|
|
589
1585
|
const avisosPendientes = [];
|
|
590
1586
|
let stampUsed = false;
|
|
1587
|
+
// Sobre x402 de esta entrega. Un buzón con estampilla ES un recurso de pago: si falta la
|
|
1588
|
+
// estampilla se responde 402 anunciando el precio, y si se cobró se responde con el recibo de
|
|
1589
|
+
// liquidación. No es un camino paralelo para mover tokens: el pago lo hizo el Libro igual.
|
|
1590
|
+
let x402req = null, x402pago = null;
|
|
591
1591
|
for (const to of pendientes) {
|
|
592
1592
|
const { local } = parseAddress(to);
|
|
593
1593
|
const rec = await this.store.getAgent(local);
|
|
594
|
-
|
|
1594
|
+
// Un secreto le contesta a quien no está en su lista lo mismo que un inexistente: ni el
|
|
1595
|
+
// rebote del postmaster confirma que la dirección existe.
|
|
1596
|
+
if (!rec || !await this._visibleA(rec, env.from)) { rejected.push({ to, code: 404, reason: 'no such agent' }); continue; }
|
|
595
1597
|
const p = applyInboxPolicy(env, rec, senderCard._domain);
|
|
596
1598
|
if (!p.ok) { rejected.push({ to, ...p, ok: undefined }); continue; }
|
|
1599
|
+
// ideas@ tiene su propia puerta (src/correo/ideas.js): sólo su lista, con cupo diario. Ni el
|
|
1600
|
+
// intro ni el aval de la política general entran: nadie los leería, y el buzón no se borra.
|
|
1601
|
+
if (local === IDEAS_LOCAL && this.ideas.enabled && rec.custody?.via === IDEAS_VIA) {
|
|
1602
|
+
const veto = await puertaIdeas(this, env, rec, this.ideas.cupo ? { cupo: this.ideas.cupo } : {});
|
|
1603
|
+
if (veto) { rejected.push({ to, ...veto }); continue; }
|
|
1604
|
+
}
|
|
597
1605
|
try {
|
|
598
1606
|
if (local === 'libro') {
|
|
599
1607
|
// Operación del Libro: se ejecuta (idempotente por id de sobre), no se almacena;
|
|
@@ -611,11 +1619,33 @@ export class Estafeta {
|
|
|
611
1619
|
if (!r.ok) { rejected.push({ to, code: r.code, reason: r.reason }); continue; }
|
|
612
1620
|
for (const rc of r.recibos || []) recibosPendientes.push(rc);
|
|
613
1621
|
results[to] = r.result;
|
|
1622
|
+
} else if (rec.group) {
|
|
1623
|
+
// Grupo: la casa NO descifra. Deja el MISMO sobre firmado en el buzón de cada miembro (menos
|
|
1624
|
+
// el remitente, que lo tiene en su bandeja de salida) y en el del grupo, que es su historial.
|
|
1625
|
+
// La membresía se comprueba aquí otra vez: la política del buzón deja pasar un `intro` o un
|
|
1626
|
+
// aval, y eso no puede abrir un grupo.
|
|
1627
|
+
const posters = rec.group.post === 'admins' ? rec.group.admins : rec.group.members;
|
|
1628
|
+
if (!posters.includes(env.from)) { rejected.push({ to, code: 403, reason: 'only members post to this group' }); continue; }
|
|
1629
|
+
const meta = { from_verified: true, relay_verified: relayVerified, sender_kid: env.signature.kid, group: to };
|
|
1630
|
+
// Cada miembro recibe sólo si SU buzón acepta a quien escribe: el grupo no es una puerta
|
|
1631
|
+
// trasera a una lista blanca. Y cada entrega extra cuenta en el límite de tasa del remitente:
|
|
1632
|
+
// un grupo de 50 no multiplica por 50 lo que un dominio puede mandar por minuto.
|
|
1633
|
+
let omitidos = 0;
|
|
1634
|
+
for (const dir of rec.group.members) {
|
|
1635
|
+
if (dir === env.from) continue;
|
|
1636
|
+
const recM = await this.store.getAgent(parseAddress(dir).local);
|
|
1637
|
+
if (!recM || recM.revoked || !this._aceptaDe(recM, env.from)) { omitidos++; continue; }
|
|
1638
|
+
if (mails.length && !await this.rate.allow(claveTasa)) { rejected.push({ to, code: 429, reason: 'rate limit for the sender (group delivery)' }); break; }
|
|
1639
|
+
mails.push({ local: parseAddress(dir).local, envelope: env, meta });
|
|
1640
|
+
}
|
|
1641
|
+
mails.push({ local, envelope: env, meta });
|
|
1642
|
+
results[to] = { members: rec.group.members.length, skipped: omitidos };
|
|
614
1643
|
} else if (p.stamp) {
|
|
615
1644
|
// Una estampilla paga UN buzón: el sobre declara un monto, no un monto por destinatario.
|
|
616
1645
|
if (stampUsed) { rejected.push({ to, code: 402, reason: 'the envelope stamp was already spent on another recipient' }); continue; }
|
|
617
1646
|
const { asiento, bundle } = await this.libro.stamp(env, to, p.stamp.price);
|
|
618
1647
|
stampUsed = true;
|
|
1648
|
+
x402pago = { to, price: p.stamp.price, asiento: asiento.id, payer: env.from };
|
|
619
1649
|
libroBundles.push(bundle);
|
|
620
1650
|
mails.push({ local, envelope: env, meta: { from_verified: true, relay_verified: relayVerified, sender_kid: env.signature.kid, stamp: asiento.id } });
|
|
621
1651
|
} else if (p.vouch) {
|
|
@@ -637,7 +1667,10 @@ export class Estafeta {
|
|
|
637
1667
|
mails.push({ local, envelope: env, meta: { from_verified: true, relay_verified: relayVerified, sender_kid: env.signature.kid } });
|
|
638
1668
|
}
|
|
639
1669
|
} catch (e) {
|
|
640
|
-
if (e instanceof LibroError) {
|
|
1670
|
+
if (e instanceof LibroError) {
|
|
1671
|
+
if (e.code === 402 && p.stamp && !x402req) x402req = { to, price: p.stamp.price };
|
|
1672
|
+
rejected.push({ to, code: e.code, reason: e.message }); continue;
|
|
1673
|
+
}
|
|
641
1674
|
throw e;
|
|
642
1675
|
}
|
|
643
1676
|
accepted.push(to);
|
|
@@ -652,14 +1685,43 @@ export class Estafeta {
|
|
|
652
1685
|
mails, libro: libroBundles,
|
|
653
1686
|
});
|
|
654
1687
|
for (const m of mails) { this._push(m.local, env); this._notifyEmail(m.local, env); }
|
|
1688
|
+
// NX-502: un pedido de pago o su confirmación declaran EN CLARO { kind, request_id, currency }
|
|
1689
|
+
// (extensión firmada); el contenido con el monto y la cuenta va cifrado y la casa no lo ve.
|
|
1690
|
+
// Se anota como evento, nunca como asiento: es dinero real fuera del Libro.
|
|
1691
|
+
if (mails.length) await this._eventoDeCobro(env, mails[0].local);
|
|
655
1692
|
}
|
|
656
1693
|
for (const rc of recibosPendientes) await this._systemSend('libro', rc.to, { in_reply_to: env.id, thread: rc.thread || env.thread || null, content: { media: MEDIA.recibo, body: rc.body } });
|
|
657
1694
|
// Avisos de plazo: sobres del Libro programados para el futuro (un escrow que llega a su
|
|
658
1695
|
// deadline, una fianza que vence). No desaparecen mudos: llegan a las partes en la fecha.
|
|
659
1696
|
for (const av of avisosPendientes) await this._systemSend('libro', av.to, { thread: av.thread || null, content: { media: MEDIA.recibo, body: av.body }, deliverAfter: av.deliver_after });
|
|
660
1697
|
|
|
661
|
-
if (!union.length) return { ok: false, code: rejected[0].code, reason: rejected[0].reason, rejected, accepted: [] };
|
|
662
|
-
return { ok: true, code: 202, accepted: union, rejected, results, ...(seen ? { duplicate: true } : {}) };
|
|
1698
|
+
if (!union.length) return { ok: false, code: rejected[0].code, reason: rejected[0].reason, rejected, accepted: [], ...(x402req ? { x402: { required: x402req } } : {}) };
|
|
1699
|
+
return { ok: true, code: 202, accepted: union, rejected, results, ...(seen ? { duplicate: true } : {}), ...(x402pago ? { x402: { settled: x402pago } } : x402req ? { x402: { required: x402req } } : {}) };
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// Traduce el resultado de `inbound` al cable de x402. Dos casos y nada más:
|
|
1703
|
+
// se cobró estampilla -> PAYMENT-RESPONSE con el id del asiento como `transaction`
|
|
1704
|
+
// faltaba la estampilla -> PAYMENT-REQUIRED con el precio del buzón
|
|
1705
|
+
// El id del asiento no es adorno: cualquiera puede pedirlo al Libro, que es lo que un explorador
|
|
1706
|
+
// hace con un hash de transacción. Si no hay nada que decir, no se pone ninguna cabecera.
|
|
1707
|
+
_cabecerasX402(r) {
|
|
1708
|
+
try {
|
|
1709
|
+
if (r.x402?.settled) {
|
|
1710
|
+
const s = r.x402.settled;
|
|
1711
|
+
return { headers: { 'payment-response': x402.cabeceraLiquidacion(x402.liquidacion({ transaction: s.asiento, payer: s.payer, amount: s.price })) } };
|
|
1712
|
+
}
|
|
1713
|
+
if (r.x402?.required) {
|
|
1714
|
+
const q = r.x402.required;
|
|
1715
|
+
const { local } = parseAddress(q.to);
|
|
1716
|
+
return { headers: { 'payment-required': x402.cabeceraRequerido(x402.requisitos({
|
|
1717
|
+
url: `https://${this.domain}/x402/inbox/${encodeURIComponent(local)}`, amount: q.price, payTo: q.to, flow: 'upfront',
|
|
1718
|
+
description: `Delivery of one signed envelope into the mailbox of ${q.to}`,
|
|
1719
|
+
serviceName: this.domain, tags: ['nyx5', 'mailbox'],
|
|
1720
|
+
error: `delivery into ${q.to} costs ${q.price} tok; the envelope carried no valid stamp`,
|
|
1721
|
+
})) } };
|
|
1722
|
+
}
|
|
1723
|
+
} catch (e) { this.log(`x402: no se pudo armar la cabecera: ${e.message}`); }
|
|
1724
|
+
return {};
|
|
663
1725
|
}
|
|
664
1726
|
|
|
665
1727
|
// Los avisos por webhook se acumulan para que el ciclo (y ctx.waitUntil en el edge) los espere:
|
|
@@ -672,6 +1734,36 @@ export class Estafeta {
|
|
|
672
1734
|
return this.fetch(rec.webhook, { method: 'POST', headers: { 'content-type': 'application/json', 'x-nyx5-push': `domain=${this.domain}; kid=${this.keys.sig}; sig=${sig}` }, body, signal: AbortSignal.timeout(5000) });
|
|
673
1735
|
}).catch((e) => this.log(`webhook ${local} falló: ${e.message} / ${e.cause?.message}`));
|
|
674
1736
|
this._pushes.push(pendiente);
|
|
1737
|
+
this._ci(local, env);
|
|
1738
|
+
return pendiente;
|
|
1739
|
+
}
|
|
1740
|
+
// Aviso al EJECUTOR (NX-412): si esta dirección trabaja sobre archivos, la casa no hace el
|
|
1741
|
+
// trabajo — dispara el CI del proyecto y el ejecutor contesta firmando con la llave del rol. El
|
|
1742
|
+
// primer intento sale aquí, al entregar; el reloj reintenta lo que se perdió (`atenderEjecutores`).
|
|
1743
|
+
// Se acumula en `_pushes` por lo mismo que el webhook: una promesa suelta la cancela el runtime.
|
|
1744
|
+
// El índice se cachea en la instancia: `_ci` corre en CADA entrega a CUALQUIER dirección, y sin
|
|
1745
|
+
// esto era una lectura de la base por sobre y por destinatario para algo que casi ninguna
|
|
1746
|
+
// dirección usa. Una casa sin ejecutores paga una lectura por minuto y por isolate. Precio
|
|
1747
|
+
// declarado: un ejecutor recién creado puede tardar hasta un minuto en despachar al entregar; el
|
|
1748
|
+
// reloj, que lee el índice fresco, lo atrapa igual.
|
|
1749
|
+
async _ciIndice() {
|
|
1750
|
+
if (this._ciIdx && this._ciIdx.until > now()) return this._ciIdx.lista;
|
|
1751
|
+
const lista = (await this.store.kvGet('ci', '_indice')) || [];
|
|
1752
|
+
this._ciIdx = { lista, until: now() + 60_000 };
|
|
1753
|
+
return lista;
|
|
1754
|
+
}
|
|
1755
|
+
_ci(local, env) {
|
|
1756
|
+
if (!this.store.kvGet) return null;
|
|
1757
|
+
const pendiente = this._ciIndice().then(async (idx) => {
|
|
1758
|
+
if (!idx.includes(local)) return;
|
|
1759
|
+
const cfg = await this.store.kvGet('ci', local);
|
|
1760
|
+
if (!cfg?.enabled) return;
|
|
1761
|
+
const rec = await this.store.getAgent(local);
|
|
1762
|
+
if (!rec || rec.revoked) return;
|
|
1763
|
+
if (!despachable(this, local, rec, { envelope: env, from_verified: !!env.signature })) return;
|
|
1764
|
+
await despacharCi(this, local, env, { cfg });
|
|
1765
|
+
}).catch((e) => this.log(`ci ${local} falló: ${e.message}`));
|
|
1766
|
+
this._pushes.push(pendiente);
|
|
675
1767
|
return pendiente;
|
|
676
1768
|
}
|
|
677
1769
|
// Aviso por email: si el agente registró `notify_email` y la casa tiene proveedor de salida, le
|
|
@@ -686,9 +1778,23 @@ export class Estafeta {
|
|
|
686
1778
|
const emailOrig = env.extensions?.['urn:nyx5:ext:email']?.from;
|
|
687
1779
|
if (emailOrig && emailOrig === rec.notify_email) return; // no te avises de tu propio correo
|
|
688
1780
|
const quien = emailOrig || env.from;
|
|
1781
|
+
// Texto aprobado por Nicholas el 14-sep-2026: remitente, proyecto y hora (lo único que la casa
|
|
1782
|
+
// sabe: el contenido va cifrado), y dónde leerlo según el aparato. Sin avisos de recibos.
|
|
1783
|
+
const proyecto = proyectoDe(env);
|
|
1784
|
+
const cuando = new Date(env.created || Date.now()).toUTCString().replace(/:\d\d GMT$/, ' UTC');
|
|
1785
|
+
// El resumen EN CLARO que puso el remitente (29-oct-2026). Sin él, el aviso sólo podía decir
|
|
1786
|
+
// QUIÉN escribió: el cuerpo va cifrado y la casa no lo lee. Con él, el asunto dice de qué se
|
|
1787
|
+
// trata y se puede decidir desde el teléfono sin abrir nada. Va tal cual lo limpió `resumenDe`
|
|
1788
|
+
// (una línea, 140 caracteres, sin controles ni invisibles): el asunto de un correo no se
|
|
1789
|
+
// parte en dos. La casa NO lo inventa ni lo resume: si el remitente no puso línea, no hay.
|
|
1790
|
+
const resumen = resumenDe(env);
|
|
689
1791
|
await this.emailOut({ fromAgent: `${local}@${this.domain}`, to: rec.notify_email,
|
|
690
|
-
subject:
|
|
691
|
-
|
|
1792
|
+
subject: resumen
|
|
1793
|
+
? `${quien}${proyecto ? ` · ${proyecto}` : ''}: ${resumen}`
|
|
1794
|
+
: `${quien} wrote to you on Nyx5${proyecto ? ` (project ${proyecto})` : ''}`,
|
|
1795
|
+
text: resumen
|
|
1796
|
+
? `${quien} wrote to ${local}@${this.domain}${proyecto ? ` · project: ${proyecto}` : ''} · ${cuando}. They summarised it as: ${resumen} — that line comes from the sender, in the clear; the house did not write it and cannot check it. The content itself is encrypted and only your key opens it. On your Mac: https://${this.domain}/app. From your phone, ask your Claude for your mailbox (replies to what your phone Claude sends arrive there, not here).`
|
|
1797
|
+
: `${quien} wrote to ${local}@${this.domain}${proyecto ? ` · project: ${proyecto}` : ''} · ${cuando}. The content is encrypted and only your key opens it. On your Mac: https://${this.domain}/app. From your phone, ask your Claude for your mailbox (replies to what your phone Claude sends arrive there, not here).` });
|
|
692
1798
|
}).catch((e) => this.log(`notify_email ${local} falló: ${e.message}`));
|
|
693
1799
|
this._pushes.push(pendiente);
|
|
694
1800
|
return pendiente;
|
|
@@ -740,6 +1846,10 @@ export class Estafeta {
|
|
|
740
1846
|
offset += batch.length;
|
|
741
1847
|
if (batch.length < 200 || offset >= page.total) break;
|
|
742
1848
|
}
|
|
1849
|
+
// Reputación y precio (NX-302): cada tarjeta lleva su puntaje arbitrado, leído del historial
|
|
1850
|
+
// público de SU casa (§21). La propia casa se lee local; las ajenas por HTTP, de a pocas.
|
|
1851
|
+
h.reputacion = await this._indexReputacion(cards, dc, propia);
|
|
1852
|
+
for (const c of cards) c._price_min = precioMinimo(c);
|
|
743
1853
|
await this.store.indexReplaceAgents(h.domain, cards);
|
|
744
1854
|
h.last_ok = iso(); h.fails = 0; h.agents = cards.length;
|
|
745
1855
|
await this.store.indexPutHouse(h);
|
|
@@ -749,6 +1859,54 @@ export class Estafeta {
|
|
|
749
1859
|
throw e;
|
|
750
1860
|
}
|
|
751
1861
|
}
|
|
1862
|
+
// Puntaje de cada tarjeta a partir del historial de su casa. Devuelve el DENOMINADOR: cuántas
|
|
1863
|
+
// se pidieron, cuántas quedaron con puntaje, cuántas sin historial, cuántas de una casa que no
|
|
1864
|
+
// distingue arbitrados (versión anterior de Nyx5) y cuántas fallaron al pedirse. Una falla no
|
|
1865
|
+
// tumba el rastreo: esa tarjeta entra sin puntaje (al final del orden), y el número queda escrito.
|
|
1866
|
+
async _indexReputacion(cards, dc, propia) {
|
|
1867
|
+
const cuenta = { pedidas: cards.length, con_puntaje: 0, sin_historial: 0, sin_arbitrados: 0, fallidas: 0 };
|
|
1868
|
+
const anotar = (c, hist) => { const r = puntajeDe(hist); c._score = r.score; c._jobs_done = r.jobs_done; if (r.motivo) cuenta[r.motivo] += 1; else cuenta.con_puntaje += 1; };
|
|
1869
|
+
const localDe = (c) => String(c.address).slice(0, String(c.address).lastIndexOf('@'));
|
|
1870
|
+
const fallo = (c, e) => { cuenta.fallidas += 1; this.log(`índice: sin historial de ${c.address}: ${e.message}`); };
|
|
1871
|
+
for (const c of cards) { c._score = null; c._jobs_done = null; }
|
|
1872
|
+
if (propia) {
|
|
1873
|
+
// Local, y los contratos se leen una vez para todas las tarjetas.
|
|
1874
|
+
const contratos = await this.store.libroListContracts();
|
|
1875
|
+
for (const c of cards) { try { anotar(c, await this.libro.historial(c.address, { contratos })); } catch (e) { fallo(c, e); } }
|
|
1876
|
+
return cuenta;
|
|
1877
|
+
}
|
|
1878
|
+
// Casa ajena: en lotes por GET /agents/historial?addresses=… (NX-905), una subpetición por cada
|
|
1879
|
+
// HISTORIAL_LOTE_MAX tarjetas en vez de una por tarjeta. Una casa de una versión anterior no
|
|
1880
|
+
// tiene la ruta (404): desde ahí se pide de a una, como antes, de a pocas a la vez.
|
|
1881
|
+
let enLote = true;
|
|
1882
|
+
for (let i = 0; i < cards.length; i += HISTORIAL_LOTE_MAX) {
|
|
1883
|
+
const lote = cards.slice(i, i + HISTORIAL_LOTE_MAX);
|
|
1884
|
+
if (enLote) {
|
|
1885
|
+
let respuesta = null;
|
|
1886
|
+
try {
|
|
1887
|
+
const res = await this.fetch(`${dc._estafeta}/agents/historial?addresses=${encodeURIComponent(lote.map(localDe).join(','))}`, { signal: AbortSignal.timeout(10_000) });
|
|
1888
|
+
if (res.status === 404) enLote = false;
|
|
1889
|
+
else if (!res.ok) throw new Error(`historial en lote -> ${res.status}`);
|
|
1890
|
+
else respuesta = await res.json();
|
|
1891
|
+
} catch (e) { this.log(`índice: el lote de historiales de ${dc.domain || ''} falló (${e.message}); se pide de a uno`); }
|
|
1892
|
+
if (respuesta) {
|
|
1893
|
+
for (const c of lote) { const h = respuesta.historiales?.[localDe(c)]; if (h) anotar(c, h); else fallo(c, new Error('missing from the batch reply')); }
|
|
1894
|
+
continue;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
const POCAS = 4;
|
|
1898
|
+
for (let j = 0; j < lote.length; j += POCAS) {
|
|
1899
|
+
await Promise.all(lote.slice(j, j + POCAS).map(async (c) => {
|
|
1900
|
+
try {
|
|
1901
|
+
const res = await this.fetch(`${dc._estafeta}/agents/${encodeURIComponent(localDe(c))}/historial`, { signal: AbortSignal.timeout(10_000) });
|
|
1902
|
+
if (!res.ok) throw new Error(`historial -> ${res.status}`);
|
|
1903
|
+
anotar(c, await res.json());
|
|
1904
|
+
} catch (e) { fallo(c, e); }
|
|
1905
|
+
}));
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
return cuenta;
|
|
1909
|
+
}
|
|
752
1910
|
// ---------- puente de correo (urn:nyx5:ext:email) ----------
|
|
753
1911
|
// ENTRADA: un email real entra al buzón del destinatario como sobre SIN FIRMA, marcado
|
|
754
1912
|
// from_verified:false y via:'email'. No pasa por /inbound ni finge estar firmado (invariante 1).
|
|
@@ -759,7 +1917,21 @@ export class Estafeta {
|
|
|
759
1917
|
try { ({ local, domain: dom } = parseAddress(to)); } catch { return { ok: false, code: 400, reason: 'invalid recipient' }; }
|
|
760
1918
|
if (dom !== this.domain) return { ok: false, code: 400, reason: `the email is for ${dom}, not ${this.domain}` };
|
|
761
1919
|
const rec = await this.store.getAgent(local);
|
|
762
|
-
if (!rec) return { ok: false, code: 404, reason: 'no such agent' };
|
|
1920
|
+
if (!rec || !await this._visibleA(rec, from)) return { ok: false, code: 404, reason: 'no such agent' };
|
|
1921
|
+
// La política del buzón manda también aquí. Antes no: la puerta del correo se saltaba la
|
|
1922
|
+
// estampilla, la lista blanca y la prueba de trabajo. El adaptador del edge convierte este
|
|
1923
|
+
// rechazo en un rechazo SMTP, así que el remitente recibe un rebote de su propio proveedor y
|
|
1924
|
+
// la casa no manda correo a una dirección que no verificó (nada de backscatter).
|
|
1925
|
+
const pol = applyEmailPolicy(rec, from);
|
|
1926
|
+
if (!pol.ok) return { ok: false, code: pol.code, reason: pol.reason };
|
|
1927
|
+
// Un correo nunca es una idea (no va firmado): se rechaza en la puerta, y el remitente recibe el
|
|
1928
|
+
// rebote de su propio proveedor. Antes entraba con `From:` de la lista y el tick lo cerraba en
|
|
1929
|
+
// silencio: quien escribía desde su correo creía que había quedado anotado.
|
|
1930
|
+
if (this.ideas.enabled && local === IDEAS_LOCAL && rec.custody?.via === IDEAS_VIA) return { ok: false, code: 403, reason: 'the ideas mailbox records only signed envelopes; email is not recorded' };
|
|
1931
|
+
// Y un buzón abierto tampoco es un embudo infinito: se limita por dominio del remitente.
|
|
1932
|
+
if (!await this.rate.allow(`email:${String(from).slice(String(from).lastIndexOf('@') + 1).toLowerCase()}`)) {
|
|
1933
|
+
return { ok: false, code: 429, reason: 'rate limit for the sending domain' };
|
|
1934
|
+
}
|
|
763
1935
|
const env = inboundEnvelope({ from, to: `${local}@${this.domain}`, subject, text, messageId });
|
|
764
1936
|
// dedupe por id (message-id del correo o uuid) igual que un sobre normal
|
|
765
1937
|
const nuevo = await this.store.markSeenIfNew(env.id, { from: `email:${from}`, accepted: [to] });
|
|
@@ -771,6 +1943,10 @@ export class Estafeta {
|
|
|
771
1943
|
// SALIDA: un agente le escribe a una dirección de correo. Con proveedor, se envía (Reply-To = el
|
|
772
1944
|
// agente, para que la respuesta vuelva por ENTRADA). Sin proveedor, queda pendiente: no se inventa canal.
|
|
773
1945
|
async emailOut({ fromAgent, to, subject, text }) {
|
|
1946
|
+
// Cerrado por defecto (11-sep-2026, revisión antes de que Nicholas se fuera un mes): con el
|
|
1947
|
+
// registro abierto, "cualquier agente le escribe a cualquier correo" era un relé de spam con
|
|
1948
|
+
// nuestra cuenta de envío y nuestro dominio. Sólo escriben las direcciones que la casa autoriza.
|
|
1949
|
+
if (!this.email.senders.has(String(fromAgent || '').toLowerCase())) return { ok: false, code: 403, reason: 'outbound email is closed in this house: only addresses the house authorizes can write to people by email' };
|
|
774
1950
|
if (!isEmailAddress(to)) return { ok: false, code: 400, reason: 'invalid email destination' };
|
|
775
1951
|
// El pie viaja solo si la casa lo enciende (`email.footer`). Apagado por defecto: el texto
|
|
776
1952
|
// que un tercero recibe es decisión del operador de la casa, no del código.
|
|
@@ -780,9 +1956,12 @@ export class Estafeta {
|
|
|
780
1956
|
catch (e) { return { ok: false, code: 502, reason: `the email provider failed: ${e.message}` }; }
|
|
781
1957
|
}
|
|
782
1958
|
|
|
1959
|
+
// `params` crudos (strings de URL o argumentos de la herramienta): se validan aquí, así el
|
|
1960
|
+
// método y la ruta rechazan lo mismo (400 cursor/filtro inválido, 410 recorrido caduco).
|
|
783
1961
|
async indexSearch(params) {
|
|
784
|
-
const out = await this.store.indexSearch(params);
|
|
1962
|
+
const out = await this.store.indexSearch(validarFiltros(params));
|
|
785
1963
|
// Respuesta firmada por la casa del índice: otro índice (u otra casa) puede ingerirla verificada.
|
|
1964
|
+
// `total` es lo que cumple los filtros hoy; `next_cursor` es null en la última página.
|
|
786
1965
|
return signObject({ nyx5: '1', index: this.domain, issued: iso(), ...out }, this.keys);
|
|
787
1966
|
}
|
|
788
1967
|
|
|
@@ -791,9 +1970,111 @@ export class Estafeta {
|
|
|
791
1970
|
async handleRequest(rx) {
|
|
792
1971
|
await this.init();
|
|
793
1972
|
const path = rx.path;
|
|
794
|
-
const send = (status, body) => ({ status, body });
|
|
1973
|
+
const send = (status, body, headers = null) => (headers ? { status, body, headers } : { status, body });
|
|
1974
|
+
// Un 429 dice cuándo volver: Retry-After en segundos hasta la ventana siguiente.
|
|
1975
|
+
const tarde = (body) => send(429, body, this._retryAfter());
|
|
1976
|
+
// Un segmento que no se puede decodificar (`%E0%A4%A`) se usa TAL CUAL: nunca es un nombre
|
|
1977
|
+
// válido, así que cae al mismo 404/400 que un inexistente. Antes decodeURIComponent lanzaba
|
|
1978
|
+
// URIError y la ruta contestaba 500 (revisión adversarial del 14-sep).
|
|
1979
|
+
const dec = (s) => { try { return decodeURIComponent(s); } catch { return s; } };
|
|
1980
|
+
let m;
|
|
795
1981
|
try {
|
|
796
1982
|
if (rx.method === 'GET' && path === '/health') return send(200, { ok: true, domain: this.domain, agents: (await this.store.listAgents()).length, queue: (await this.store.listQueue()).length });
|
|
1983
|
+
// ----- Conector MCP remoto: OAuth + /mcp -----
|
|
1984
|
+
if (this.remoto.enabled && rutaCors(path)) {
|
|
1985
|
+
if (rx.method === 'OPTIONS') return { status: 204, headers: CORS, contentType: 'text/plain', body: '' };
|
|
1986
|
+
const r = await this._rutaRemota(rx);
|
|
1987
|
+
if (r) return { ...r, headers: { ...CORS, ...(r.headers || {}) } };
|
|
1988
|
+
}
|
|
1989
|
+
if (this.remoto.enabled && rx.method === 'GET' && path === '/oauth/authorize') return oauth.paginaAutorizar(this, rx.query, APP_HTML);
|
|
1990
|
+
if (this.remoto.enabled && rx.method === 'POST' && path === '/oauth/prepare') return oauth.preparar(this, rx);
|
|
1991
|
+
if (this.remoto.enabled && rx.method === 'POST' && path === '/oauth/approve') return oauth.aprobar(this, rx);
|
|
1992
|
+
// ----- Invitaciones de contacto: un link que se manda por WhatsApp -----
|
|
1993
|
+
if (this.remoto.enabled && rx.method === 'POST' && path === '/contact-invites') return this.crearInvitacion(rx);
|
|
1994
|
+
if (this.remoto.enabled && rx.method === 'GET' && (m = /^\/i\/([A-Za-z0-9_-]{16,64})$/.exec(path))) return this._paginaInvitacion(m[1], rx.query.get('source'), rx.ip);
|
|
1995
|
+
// ----- Asistentes (sólo la casa los configura; el dueño los pide) -----
|
|
1996
|
+
if ((m = /^\/admin\/executors(?:\/([^/]+))?(?:\/(dispatch))?$/.exec(path))) {
|
|
1997
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house configures executors' });
|
|
1998
|
+
return this._adminEjecutor(rx, m[1] ? dec(m[1]).toLowerCase() : null, m[2] || null);
|
|
1999
|
+
}
|
|
2000
|
+
if ((m = /^\/admin\/assistants(?:\/([^/]+))?(?:\/(knowledge|config|pause|resume))?$/.exec(path))) {
|
|
2001
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house configures assistants' });
|
|
2002
|
+
return this._adminAsistente(rx, m[1] ? dec(m[1]).toLowerCase() : null, m[2] || null);
|
|
2003
|
+
}
|
|
2004
|
+
// ----- ideas@: alta por la casa; el registro lo lee su dueño (o la casa) -----
|
|
2005
|
+
if (this.ideas.enabled && rx.method === 'POST' && path === '/admin/ideas') {
|
|
2006
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house configures the ideas mailbox' });
|
|
2007
|
+
const r = await this._altaBuzonIdeas(rx.body || {});
|
|
2008
|
+
return send(r.status, r.body);
|
|
2009
|
+
}
|
|
2010
|
+
if (this.ideas.enabled && rx.method === 'GET' && path === '/ideas') {
|
|
2011
|
+
// Sin nombre en la ruta no hay oráculo de existencia: quien no es el dueño recibe 403, y
|
|
2012
|
+
// quien no firma, 401, exista o no el buzón. La lista no dice quién más está en ella.
|
|
2013
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) {
|
|
2014
|
+
const who = await this._authenticate(rx, path);
|
|
2015
|
+
const cfg = await this.store.kvGet('ideas', '_config');
|
|
2016
|
+
if (!cfg || who.address !== cfg.owner) return send(403, { reason: 'only the owner of the ideas mailbox reads it' });
|
|
2017
|
+
}
|
|
2018
|
+
// Paginado: `?after=<n>` sigue desde ese número; `next` dice desde dónde sigue la página
|
|
2019
|
+
// siguiente (antes la lista se cortaba en 1.000 en silencio y `total` decía 1.000).
|
|
2020
|
+
const after = rx.query.get('after');
|
|
2021
|
+
if (after != null && !/^\d{1,9}$/.test(after)) return send(400, { reason: 'after must be a non-negative integer' });
|
|
2022
|
+
return send(200, { address: `${IDEAS_LOCAL}@${this.domain}`, ...(await paginaDeIdeas(this, { after: Number(after) || 0 })) });
|
|
2023
|
+
}
|
|
2024
|
+
// ----- Ficha pública: la edita el dueño de la dirección o el dueño de su delegación -----
|
|
2025
|
+
if (rx.method === 'POST' && (m = /^\/agents\/([^/]+)\/profile$/.exec(path))) {
|
|
2026
|
+
const who = await this._authenticate(rx, path);
|
|
2027
|
+
const l = dec(m[1]).toLowerCase();
|
|
2028
|
+
const rec = Estafeta.validLocal(l) ? await this.store.getAgent(l) : null;
|
|
2029
|
+
if (!rec || rec.revoked) return send(404, { reason: 'no such agent' });
|
|
2030
|
+
// Una dirección de sólo mensajes no edita ficha alguna, ni la suya: lo que se publica en
|
|
2031
|
+
// nombre del dueño lo escribe el dueño (revisión del 13-sep-2026).
|
|
2032
|
+
if (who.record.delegation?.scope?.messages_only) return send(403, { reason: 'a messages-only address cannot edit a profile' });
|
|
2033
|
+
if (who.local !== l && who.address !== rec.delegation?.by) return send(403, { reason: 'only the owner of an address (or of its delegation) edits its profile' });
|
|
2034
|
+
const v = validarPerfil(rx.body?.profile === undefined ? null : rx.body.profile);
|
|
2035
|
+
if (v.error) return send(400, { reason: v.error });
|
|
2036
|
+
// El validador no ve la tarjeta: el precio en dólares se cruza aquí con la billetera declarada.
|
|
2037
|
+
{ const e = usdSinBilletera(v.perfil, rec.wallets || (rec.wallet ? [rec.wallet] : [])); if (e) return send(400, { reason: e }); }
|
|
2038
|
+
// Re-certificar sin tocar nada más: mismas llaves, misma delegación, mismo buzón.
|
|
2039
|
+
const { certification: _c, webhook, notify_email, ...cuerpo } = rec;
|
|
2040
|
+
const card = signObject({ ...cuerpo, profile: v.perfil ? { ...v.perfil, updated: iso() } : undefined }, this.keys, 'certification');
|
|
2041
|
+
await this.store.putAgent(l, { ...card, webhook, notify_email });
|
|
2042
|
+
this.resolver.invalidate(`agent:${l}@${this.domain}`);
|
|
2043
|
+
return send(200, { address: card.address, profile: card.profile || null });
|
|
2044
|
+
}
|
|
2045
|
+
// ----- Presencia: «visto por última vez», sólo con opt-in del dueño -----
|
|
2046
|
+
if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)\/presence$/.exec(path))) {
|
|
2047
|
+
if (!await this.rate.allow(`resolve:${rx.ip || 'x'}`)) return tarde({ reason: 'too many requests' });
|
|
2048
|
+
const l = dec(m[1]).toLowerCase();
|
|
2049
|
+
if (!Estafeta.validLocal(l)) return send(400, { reason: 'invalid agent name' });
|
|
2050
|
+
// Un inexistente contesta «sin presencia»; un secreto, a quien no lo ve, exactamente lo mismo.
|
|
2051
|
+
const quien = await this._quienPregunta(rx, path);
|
|
2052
|
+
const visible = await this._visibleA(await this.store.getAgent(l), quien);
|
|
2053
|
+
return send(200, { address: `${l}@${this.domain}`, last_seen: visible ? await this.presenciaDe(l) : null });
|
|
2054
|
+
}
|
|
2055
|
+
// ----- Grupos: crear, ver miembros, agregar, quitar, irse -----
|
|
2056
|
+
if (rx.method === 'POST' && path === '/groups') {
|
|
2057
|
+
const who = await this._authenticate(rx, path);
|
|
2058
|
+
const r = await this.crearGrupo(who, rx.body || {});
|
|
2059
|
+
return send(r.status, r.body);
|
|
2060
|
+
}
|
|
2061
|
+
if ((m = /^\/groups\/([^/]+)\/members$/.exec(path))) {
|
|
2062
|
+
const who = await this._authenticate(rx, path);
|
|
2063
|
+
const l = dec(m[1]).toLowerCase();
|
|
2064
|
+
if (!Estafeta.validLocal(l)) return send(400, { reason: 'invalid group name' });
|
|
2065
|
+
const r = rx.method === 'GET' ? await this.miembrosGrupo(who, l) : rx.method === 'POST' ? await this.editarGrupo(who, l, rx.body || {}) : { status: 405, body: { reason: 'method not allowed here' } };
|
|
2066
|
+
return send(r.status, r.body);
|
|
2067
|
+
}
|
|
2068
|
+
// ----- Contactos: la casa conecta dos direcciones suyas en los dos sentidos -----
|
|
2069
|
+
if (rx.method === 'POST' && path === '/admin/contacts') {
|
|
2070
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house connects contacts' });
|
|
2071
|
+
const between = Array.isArray(rx.body?.between) ? rx.body.between : [];
|
|
2072
|
+
const r = await this.conectarContactos(between[0], between[1]);
|
|
2073
|
+
return send(r.status, r.body);
|
|
2074
|
+
}
|
|
2075
|
+
// ----- La app en la pantalla de inicio -----
|
|
2076
|
+
if (rx.method === 'GET' && path === '/manifest.webmanifest') return { status: 200, contentType: 'application/manifest+json', body: JSON.stringify(MANIFIESTO) };
|
|
2077
|
+
if (rx.method === 'GET' && (m = /^\/(?:icon-(180|192|512)|apple-touch-icon)\.png$/.exec(path))) return { status: 200, contentType: 'image/png', body: Buffer.from(ICONOS[m[1] || 180], 'base64') };
|
|
797
2078
|
// Cliente web para personas: se sirve desde la propia casa (mismo origen, sin CORS).
|
|
798
2079
|
// El HTML genera las llaves en el navegador del usuario; la casa nunca las ve.
|
|
799
2080
|
// La portada muestra prueba de vida REAL, leída del libro en el momento: cuántos agentes
|
|
@@ -852,25 +2133,114 @@ export class Estafeta {
|
|
|
852
2133
|
return { status: 200, contentType: 'text/plain; charset=utf-8', body: `User-agent: *\nAllow: /\n# Los buzones y el Libro exigen firma; no hay nada que rastrear ahí.\nDisallow: /mailbox/\nDisallow: /libro/\nDisallow: /outbox/\nSitemap: https://${this.domain}/sitemap.xml\n` };
|
|
853
2134
|
}
|
|
854
2135
|
if (rx.method === 'GET' && path === '/sitemap.xml') {
|
|
855
|
-
const paginas = ['/', '/spec', '/app', '/report', '/llms.txt'].concat(this.tareas.enabled ? ['/tareas'] : []);
|
|
2136
|
+
const paginas = ['/', '/spec', '/app', '/report', '/llms.txt'].concat(this.tareas.enabled ? ['/tareas'] : []).concat(this.terms ? ['/terms'] : []);
|
|
856
2137
|
const hoy = iso().slice(0, 10);
|
|
857
2138
|
return { status: 200, contentType: 'application/xml; charset=utf-8',
|
|
858
2139
|
body: `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${paginas.map((u) => ` <url><loc>https://${this.domain}${u}</loc><lastmod>${hoy}</lastmod></url>`).join('\n')}\n</urlset>\n` };
|
|
859
2140
|
}
|
|
860
2141
|
if (rx.method === 'GET' && path === '/llms.txt') return { status: 200, body: LLMS_TXT, contentType: 'text/plain; charset=utf-8' };
|
|
2142
|
+
// ---------- x402: el estándar de "402 Payment Required" para agentes ----------
|
|
2143
|
+
// La casa es su propio facilitador: verifica y liquida contra su propio Libro. El spec lo
|
|
2144
|
+
// permite ("or host the endpoints themselves"), y así no hay un tercero en el camino del
|
|
2145
|
+
// dinero. Ver docs/interop/x402.md, que dice también qué NO reclamamos.
|
|
2146
|
+
if (rx.method === 'GET' && (path === '/x402/supported' || path === '/x402/supported/')) {
|
|
2147
|
+
const card = await this.domainCard();
|
|
2148
|
+
return send(200, x402.soportado(this.domain, (card.keys || []).map((k) => k.sig).filter(Boolean)));
|
|
2149
|
+
}
|
|
2150
|
+
// El recurso pagable que esta casa ya tenía: entregar en un buzón con estampilla. Un cliente
|
|
2151
|
+
// x402 hace GET aquí y lee el precio antes de componer nada. Un buzón gratis contesta 200 y
|
|
2152
|
+
// lo dice: "no hay nada que pagar" es una respuesta, no un error.
|
|
2153
|
+
const mX402 = rx.method === 'GET' ? /^\/x402\/inbox\/([^/]+)$/.exec(path) : null;
|
|
2154
|
+
if (mX402) {
|
|
2155
|
+
const local = dec(mX402[1]).toLowerCase();
|
|
2156
|
+
const quien = await this._quienPregunta(rx, path);
|
|
2157
|
+
const rec = await this.store.getAgent(local);
|
|
2158
|
+
if (!rec || !await this._visibleA(rec, quien)) return send(404, { reason: 'no such agent' });
|
|
2159
|
+
const precio = rec.inbox?.policy === 'stamp' ? (rec.inbox.price ?? 1) : 0;
|
|
2160
|
+
const url = `https://${this.domain}/x402/inbox/${encodeURIComponent(local)}`;
|
|
2161
|
+
if (!precio) return send(200, { x402Version: x402.X402_VERSION, free: true, resource: { url }, reason: `${local}@${this.domain} does not charge for delivery` });
|
|
2162
|
+
const pr = x402.requisitos({
|
|
2163
|
+
url, amount: precio, payTo: `${local}@${this.domain}`, flow: 'upfront',
|
|
2164
|
+
description: `Delivery of one signed envelope into the mailbox of ${local}@${this.domain}`,
|
|
2165
|
+
serviceName: this.domain, tags: ['nyx5', 'mailbox'],
|
|
2166
|
+
error: `delivery into this mailbox costs ${precio} tok; send the envelope to POST /inbound with a stamp field`,
|
|
2167
|
+
});
|
|
2168
|
+
// Segunda forma de pago: dólares de verdad, si el agente declaró a dónde cobrarlos y a qué
|
|
2169
|
+
// precio. NO convertimos tokens a dólares: inventar un tipo de cambio sería justo la cifra
|
|
2170
|
+
// sin respaldo que este protocolo existe para encarecer. El precio en dólares lo pone el
|
|
2171
|
+
// agente o no hay opción en dólares.
|
|
2172
|
+
// Una entrada por cada red que el agente declaró Y que sabemos liquidar. El estándar dice
|
|
2173
|
+
// que `accepts` es una lista y que el cliente elige, así que ofrecer una sola red sería
|
|
2174
|
+
// desperdiciar el mecanismo: quien sólo puede pagar en una cadena encuentra la suya.
|
|
2175
|
+
const ws = rec.wallets || (rec.wallet ? [rec.wallet] : []);
|
|
2176
|
+
if (ws.length && Number.isInteger(rec.inbox?.price_usd) && rec.inbox.price_usd > 0) {
|
|
2177
|
+
for (const w of ws) {
|
|
2178
|
+
const t = x402.TOKEN_USD[w?.network];
|
|
2179
|
+
if (!t || !w?.address) continue;
|
|
2180
|
+
pr.accepts.push(...x402.requisitosEvm({
|
|
2181
|
+
url, amount: rec.inbox.price_usd, payTo: w.address, network: w.network,
|
|
2182
|
+
asset: t.asset, tokenName: t.name, tokenVersion: t.version,
|
|
2183
|
+
}).accepts);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
// Catalogable: el facilitador que vea este 402 puede publicarlo en su directorio, que es
|
|
2187
|
+
// donde un agente busca. Se declara lo que se vende de verdad, no una promesa más grande.
|
|
2188
|
+
pr.extensions = { bazaar: x402.bazaarBuzon({ url, direccion: `${local}@${this.domain}` }) };
|
|
2189
|
+
return { status: 402, body: pr, headers: { 'payment-required': x402.cabeceraRequerido(pr) } };
|
|
2190
|
+
}
|
|
861
2191
|
if (rx.method === 'GET' && path === '/.well-known/nyx5.json') return send(200, await this.domainCard());
|
|
862
|
-
let m;
|
|
863
2192
|
// Reputación = una consulta al libro. Es PÚBLICA a propósito: sirve justamente para que
|
|
864
2193
|
// un desconocido decida antes de contratar, igual que la tarjeta. No expone contenido ni
|
|
865
2194
|
// contrapartes: solo cuántas entregas, cuántas fianzas y cuántos tokens se movieron.
|
|
2195
|
+
// Historial en LOTE (NX-905): el rastreo del índice pedía uno por agente, 1+N subpeticiones
|
|
2196
|
+
// por casa ajena, y el edge las tiene contadas. Público como el individual, hasta 50
|
|
2197
|
+
// direcciones por petición, limitado por IP. Una dirección que no existe, que es de otra
|
|
2198
|
+
// casa o que es secreta para quien pregunta vale `null`: la misma respuesta, sin oráculo.
|
|
2199
|
+
// `requested`/`found` son el denominador: cuántas se pidieron y cuántas se resolvieron.
|
|
2200
|
+
if (rx.method === 'GET' && path === '/agents/historial' && rx.query.has('addresses')) {
|
|
2201
|
+
if (!await this.rate.allow(`historial:${rx.ip || 'x'}`)) return tarde({ reason: 'too many requests' });
|
|
2202
|
+
const pedidas = [...new Set(String(rx.query.get('addresses')).split(',').map((x) => x.trim().toLowerCase()).filter(Boolean))];
|
|
2203
|
+
if (!pedidas.length) return send(400, { reason: 'addresses must list at least one address, separated by commas' });
|
|
2204
|
+
if (pedidas.length > HISTORIAL_LOTE_MAX) return send(400, { reason: `addresses: at most ${HISTORIAL_LOTE_MAX} per request, got ${pedidas.length}` });
|
|
2205
|
+
const quien = await this._quienPregunta(rx, path);
|
|
2206
|
+
// Los contratos se leen UNA vez para todo el lote, no una por dirección.
|
|
2207
|
+
const contratos = await this.store.libroListContracts();
|
|
2208
|
+
const historiales = {};
|
|
2209
|
+
for (const dir of pedidas) {
|
|
2210
|
+
let local = dir, dom = this.domain;
|
|
2211
|
+
if (dir.includes('@')) { try { ({ local, domain: dom } = parseAddress(dir)); } catch { historiales[dir] = null; continue; } }
|
|
2212
|
+
const rec = dom === this.domain && Estafeta.validLocal(local) ? await this.store.getAgent(local) : null;
|
|
2213
|
+
historiales[dir] = rec && await this._visibleA(rec, quien) ? await this.libro.historial(`${local}@${this.domain}`, { contratos }) : null;
|
|
2214
|
+
}
|
|
2215
|
+
return send(200, { house: this.domain, requested: pedidas.length, found: Object.values(historiales).filter(Boolean).length, historiales });
|
|
2216
|
+
}
|
|
866
2217
|
if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)\/historial$/.exec(path))) {
|
|
867
|
-
const local =
|
|
868
|
-
|
|
2218
|
+
const local = dec(m[1]).toLowerCase();
|
|
2219
|
+
const quien = await this._quienPregunta(rx, path);
|
|
2220
|
+
const rec = Estafeta.validLocal(local) ? await this.store.getAgent(local) : null;
|
|
2221
|
+
if (!rec || !await this._visibleA(rec, quien)) return send(404, { reason: 'no such agent' });
|
|
869
2222
|
return send(200, await this.libro.historial(`${local}@${this.domain}`));
|
|
870
2223
|
}
|
|
2224
|
+
// ----- Notaría (NX-601): verificación PÚBLICA de sellos, sin cuenta. Se limita por IP como
|
|
2225
|
+
// /resolve. Un hash sin sellos y un id inexistente contestan el mismo 404. El nombre del
|
|
2226
|
+
// declarante se muestra sólo si «nadie» lo vería (_visibleA con quien = null).
|
|
2227
|
+
if (rx.method === 'GET' && (m = /^\/notaria\/(?:sello\/([^/]+)|([0-9a-fA-F]{64}))$/.exec(path))) {
|
|
2228
|
+
if (!await this.rate.allow(`notaria:${rx.ip || 'x'}`)) return tarde({ reason: 'too many requests' });
|
|
2229
|
+
let idSello = null; if (m[1]) { idSello = dec(m[1]); }
|
|
2230
|
+
const docs = idSello ? [await this.store.notariaGet(idSello)].filter(Boolean) : await this.store.notariaList(m[2].toLowerCase());
|
|
2231
|
+
if (!docs.length) return send(404, { reason: 'no such seal' });
|
|
2232
|
+
const seals = [];
|
|
2233
|
+
for (const d of docs) seals.push(selloPublico(d, await this._declaranteVisible(d)));
|
|
2234
|
+
return m[1] ? send(200, seals[0]) : send(200, { sha256: m[2].toLowerCase(), house: this.domain, seals });
|
|
2235
|
+
}
|
|
871
2236
|
if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)$/.exec(path))) {
|
|
872
|
-
|
|
873
|
-
|
|
2237
|
+
// Un secreto responde a quien no lo ve EXACTAMENTE lo que un inexistente: mismo cuerpo,
|
|
2238
|
+
// mismas cabeceras. Lo cuida test/visibilidad.test.js comparando los dos byte a byte.
|
|
2239
|
+
// Quién pregunta se resuelve ANTES de mirar si existe: el orden es parte de la igualdad.
|
|
2240
|
+
const quien = await this._quienPregunta(rx, path);
|
|
2241
|
+
const card = await this.agentCard(dec(m[1]).toLowerCase());
|
|
2242
|
+
if (!card || !await this._visibleA(card, quien)) return send(404, { reason: 'no such agent' });
|
|
2243
|
+
return send(200, card);
|
|
874
2244
|
}
|
|
875
2245
|
if (rx.method === 'GET' && path === '/agents') {
|
|
876
2246
|
const p = Object.fromEntries(rx.query);
|
|
@@ -880,7 +2250,8 @@ export class Estafeta {
|
|
|
880
2250
|
const body = rx.body || {};
|
|
881
2251
|
const local = String(body.local || '').toLowerCase();
|
|
882
2252
|
if (!Estafeta.validLocal(local)) return send(400, { reason: 'invalid agent name' });
|
|
883
|
-
const
|
|
2253
|
+
const previo = await this.store.getAgent(local);
|
|
2254
|
+
const exists = !!previo;
|
|
884
2255
|
const isAdmin = (rx.headers.authorization || '') === `Bearer ${this.adminToken}`;
|
|
885
2256
|
let ok = isAdmin, via = 'admin';
|
|
886
2257
|
if (!ok && rx.headers.authorization?.startsWith('Nyx5 ')) {
|
|
@@ -890,21 +2261,26 @@ export class Estafeta {
|
|
|
890
2261
|
}
|
|
891
2262
|
if (!ok) {
|
|
892
2263
|
// Auto-registro: el cuerpo viene firmado por la clave que se inscribe (prueba de posesión).
|
|
893
|
-
|
|
2264
|
+
// «Nombre tomado» también confirma existencia: un secreto recorre el MISMO camino que un
|
|
2265
|
+
// nombre que no existe (firma, fecha, tasa, invitación) y recién al final recibe el 409 de
|
|
2266
|
+
// un nombre reservado por el protocolo, que es donde lo recibiría un reservado de verdad.
|
|
2267
|
+
const secreto = exists && previo.visibility === 'secret';
|
|
2268
|
+
if (exists && !secreto) return send(409, { reason: 'that name is taken; only its owner or the house can update it' });
|
|
894
2269
|
if (!body.signature || body.signature.kid !== body.sig || !verifyObject(body, body.sig)) return send(401, { reason: 'to self-register, sign the body with the same sig key you are enrolling (proof of possession)' });
|
|
895
2270
|
if (Math.abs(now() - Date.parse(body.ts || 0)) > 300_000) return send(401, { reason: 'the signed request needs a ts (ISO) within 5 minutes' });
|
|
896
|
-
if (!this.regRate.allow(rx.ip || 'x')) return
|
|
2271
|
+
if (!await this.regRate.allow(rx.ip || 'x')) return tarde({ reason: 'too many registrations from this address' });
|
|
897
2272
|
if (this.policy.registration === 'open') via = 'open';
|
|
898
2273
|
else if (this.policy.registration === 'invite') { const inv = await this._consumeInvite(body.invite); via = `invite:${inv.code}`; if (inv.welcome != null) body._welcome = inv.welcome; }
|
|
899
2274
|
else return send(403, { reason: `this house does not accept self-registration (registration=${this.policy.registration}); ask for an invitation` });
|
|
2275
|
+
if (secreto) return send(409, { reason: `name reserved by the protocol: ${local}` });
|
|
900
2276
|
}
|
|
901
2277
|
// `source` es atribución y NO entra en la tarjeta: se descarta aquí y viaja al evento.
|
|
902
|
-
|
|
2278
|
+
// `custody` y `revoked` los pone sólo la casa: nadie se declara custodiado ni des-revocado.
|
|
2279
|
+
const { signature: _s, ts: _t, invite: _i, _welcome, source: _src, custody: _cu, revoked: _rv, ...clean } = body;
|
|
903
2280
|
const card = await this.registerAgent({ ...clean, welcome: _welcome });
|
|
904
2281
|
this.log(`registro ${card.address} via ${via}`);
|
|
905
2282
|
if (!card.delegation) {
|
|
906
|
-
|
|
907
|
-
await this._evento('join', card.address, { via, listed: card.capabilities?.listed === true, source: fuente || null });
|
|
2283
|
+
await this._evento('join', card.address, { via, listed: card.capabilities?.listed === true, source: Estafeta.fuenteLimpia(_src) });
|
|
908
2284
|
}
|
|
909
2285
|
return send(201, { ...card, registered_via: via });
|
|
910
2286
|
}
|
|
@@ -933,11 +2309,11 @@ export class Estafeta {
|
|
|
933
2309
|
const r = await this.inbound(rx.body, rx.headers['x-nyx5-relay']);
|
|
934
2310
|
// pending: los avisos por webhook que nacieron aquí. El adaptador los pasa a waitUntil;
|
|
935
2311
|
// sin eso el runtime cancela el fetch al cerrar la respuesta y el aviso nunca sale.
|
|
936
|
-
return { ...send(r.code || 400, r), kick: true, pending: this.flushPushes() };
|
|
2312
|
+
return { ...send(r.code || 400, r, r.code === 429 ? this._retryAfter() : null), ...this._cabecerasX402(r), kick: true, pending: this.flushPushes() };
|
|
937
2313
|
}
|
|
938
2314
|
if (rx.method === 'GET' && (m = /^\/mailbox\/([^/]+)$/.exec(path))) {
|
|
939
2315
|
const who = await this._authenticate(rx, path);
|
|
940
|
-
if (who.local !==
|
|
2316
|
+
if (who.local !== dec(m[1]).toLowerCase()) return send(403, { reason: 'not your mailbox' });
|
|
941
2317
|
const limit = Number(rx.query.get('limit') || 50);
|
|
942
2318
|
// los N más recientes (listMail viene en orden cronológico): con muchos mensajes viejos
|
|
943
2319
|
// sin ackear, slice(0,limit) escondía justo los nuevos. slice(-limit) muestra los últimos.
|
|
@@ -945,22 +2321,122 @@ export class Estafeta {
|
|
|
945
2321
|
}
|
|
946
2322
|
if (rx.method === 'POST' && (m = /^\/mailbox\/([^/]+)\/ack$/.exec(path))) {
|
|
947
2323
|
const who = await this._authenticate(rx, path);
|
|
948
|
-
if (who.local !==
|
|
2324
|
+
if (who.local !== dec(m[1]).toLowerCase()) return send(403, { reason: 'not your mailbox' });
|
|
949
2325
|
const { ids = [] } = rx.body || {};
|
|
950
2326
|
const acked = [];
|
|
951
2327
|
for (const id of ids) if (await this.store.ackMail(who.local, id)) acked.push(id);
|
|
2328
|
+
// Acuse de lectura (13-sep-2026), opt-in del que lee: «entregado» no decía si alguien leyó.
|
|
2329
|
+
// Sale de la casa, que da fe de la hora en que ese buzón confirmó; sólo la primera vez.
|
|
2330
|
+
if (acked.length && who.record.capabilities?.read_receipts === true) await this._acusesDeLectura(who, acked);
|
|
952
2331
|
return send(200, { acked });
|
|
953
2332
|
}
|
|
2333
|
+
// ----- Adjuntos (NX-413): el almacén de objetos de la casa -----
|
|
2334
|
+
// Subir pide la firma del dueño de la casa; bajar y borrar la aceptan de cualquier casa,
|
|
2335
|
+
// porque el destinatario de un sobre casi siempre vive en otra. El ORDEN importa: primero la
|
|
2336
|
+
// autenticación y después mirar si el objeto existe, para que un extraño reciba exactamente
|
|
2337
|
+
// el mismo 401 exista o no (si fuera al revés, /blob sería un oráculo de existencia).
|
|
2338
|
+
if ((m = /^\/blob\/([^/]+)$/.exec(path))) {
|
|
2339
|
+
if (!['PUT', 'GET', 'DELETE'].includes(rx.method)) return send(405, { reason: 'use PUT to upload, GET to download, DELETE to remove an attachment' });
|
|
2340
|
+
if (!this.objetos) return send(503, { reason: 'this house does not host attachments: it has no object store' });
|
|
2341
|
+
const quien = await this._authenticate(rx, path, { allowForeign: rx.method !== 'PUT' });
|
|
2342
|
+
if (!await this.rate.allow(`blob:${quien.address}`)) return tarde({ reason: 'too many requests' });
|
|
2343
|
+
const blob = dec(m[1]);
|
|
2344
|
+
if (rx.method === 'PUT') return subirAdjunto(this, quien, blob, rx.raw, { to: rx.query.get('to') || '', nowMs: now() });
|
|
2345
|
+
if (rx.method === 'DELETE') return borrarAdjunto(this, quien, blob, { nowMs: now() });
|
|
2346
|
+
return bajarAdjunto(this, quien, blob, { nowMs: now() });
|
|
2347
|
+
}
|
|
2348
|
+
// ----- Tiempo real, historial, conectores y tarjetas ajenas -----
|
|
2349
|
+
if (rx.method === 'GET' && (m = /^\/mailbox\/([^/]+)\/wait$/.exec(path))) {
|
|
2350
|
+
const who = await this._authenticate(rx, path);
|
|
2351
|
+
if (who.local !== dec(m[1]).toLowerCase()) return send(403, { reason: 'not your mailbox' });
|
|
2352
|
+
const q = Object.fromEntries(rx.query);
|
|
2353
|
+
const segundos = Math.max(0, Math.min(Number(q.timeout ?? 25) || 0, 90));
|
|
2354
|
+
const project = this._proyectoPedido(rx.query);
|
|
2355
|
+
if (project === false) return send(400, { reason: 'invalid project: empty after normalization, or a reserved word' });
|
|
2356
|
+
const msg = await this.esperarCorreo(who.local, { from: q.from || null, thread: q.thread || null, since: q.since || null, project, timeoutMs: segundos * 1000 });
|
|
2357
|
+
return send(200, { message: msg });
|
|
2358
|
+
}
|
|
2359
|
+
if (rx.method === 'GET' && (m = /^\/conversations\/([^/]+)$/.exec(path))) {
|
|
2360
|
+
const who = await this._authenticate(rx, path);
|
|
2361
|
+
if (who.local !== dec(m[1]).toLowerCase()) return send(403, { reason: 'not your conversations' });
|
|
2362
|
+
const con = rx.query.get('with');
|
|
2363
|
+
const limit = Math.max(1, Math.min(Number(rx.query.get('limit') || 50) || 50, 500));
|
|
2364
|
+
const project = this._proyectoPedido(rx.query);
|
|
2365
|
+
if (project === false) return send(400, { reason: 'invalid project: empty after normalization, or a reserved word' });
|
|
2366
|
+
if (con) return send(200, { with: con.toLowerCase(), messages: await this.conversacion(who.local, { con: con.toLowerCase(), limit, project }) });
|
|
2367
|
+
return send(200, { conversations: await this.conversacion(who.local, { project }) });
|
|
2368
|
+
}
|
|
2369
|
+
if (rx.method === 'GET' && (m = /^\/delegations\/([^/]+)$/.exec(path))) {
|
|
2370
|
+
const who = await this._authenticate(rx, path);
|
|
2371
|
+
if (who.local !== dec(m[1]).toLowerCase()) return send(403, { reason: 'not your delegations' });
|
|
2372
|
+
return send(200, { delegations: await this.delegados(who.local) });
|
|
2373
|
+
}
|
|
2374
|
+
if (rx.method === 'POST' && (m = /^\/agents\/([^/]+)\/revoke$/.exec(path))) {
|
|
2375
|
+
const sub = dec(m[1]).toLowerCase();
|
|
2376
|
+
if (!Estafeta.validLocal(sub)) return send(400, { reason: 'invalid agent name' });
|
|
2377
|
+
const rec = await this.store.getAgent(sub);
|
|
2378
|
+
if (!rec?.delegation) return send(404, { reason: 'no such subagent' });
|
|
2379
|
+
let por;
|
|
2380
|
+
if ((rx.headers.authorization || '') === `Bearer ${this.adminToken}`) por = `casa@${this.domain}`;
|
|
2381
|
+
else {
|
|
2382
|
+
const who = await this._authenticate(rx, path);
|
|
2383
|
+
if (who.address !== rec.delegation.by && who.local !== sub) return send(403, { reason: 'only the owner who delegated it can revoke it' });
|
|
2384
|
+
por = who.address;
|
|
2385
|
+
}
|
|
2386
|
+
if (rec.revoked) return send(200, { address: rec.address, revoked: rec.revoked, already: true });
|
|
2387
|
+
const card = await this.revocarDelegado(sub, por);
|
|
2388
|
+
return send(200, { address: card.address, revoked: card.revoked });
|
|
2389
|
+
}
|
|
2390
|
+
// La tarjeta verificada de CUALQUIER dirección, resuelta por la casa. La app del navegador no
|
|
2391
|
+
// puede pedirle la tarjeta a otra casa (CSP y CORS lo impiden, y está bien), y sin la llave de
|
|
2392
|
+
// cifrado del destinatario tendría que mandar en claro.
|
|
2393
|
+
if (rx.method === 'GET' && (m = /^\/resolve\/([^/]+)$/.exec(path))) {
|
|
2394
|
+
if (!await this.rate.allow(`resolve:${rx.ip || 'x'}`)) return tarde({ reason: 'too many requests' });
|
|
2395
|
+
let addr; try { addr = dec(m[1]).toLowerCase(); parseAddress(addr); } catch { return send(400, { reason: 'invalid address' }); }
|
|
2396
|
+
// Para quién se resuelve: el agente de esta casa que firma la petición, si alguno. A una
|
|
2397
|
+
// dirección secreta de esta casa se le aplica la misma regla que en /agents/<l>; a una de
|
|
2398
|
+
// otra casa, la casa pregunta «para» ese agente y la otra decide.
|
|
2399
|
+
const quien = await this._quienPregunta(rx, path);
|
|
2400
|
+
try {
|
|
2401
|
+
if (parseAddress(addr).domain === this.domain) {
|
|
2402
|
+
const rec = Estafeta.validLocal(parseAddress(addr).local) ? await this.store.getAgent(parseAddress(addr).local) : null;
|
|
2403
|
+
if (!rec || !await this._visibleA(rec, quien)) return send(404, { reason: 'no such agent' });
|
|
2404
|
+
}
|
|
2405
|
+
const { _estafeta, _domain, delegation, ...card } = await this.resolver.agentCard(addr, { onBehalfOf: quien });
|
|
2406
|
+
const { _parent, ...d } = delegation || {};
|
|
2407
|
+
// La presencia va FUERA de la tarjeta firmada (no altera la certificación) y sólo si es de
|
|
2408
|
+
// esta casa y el dueño la activó.
|
|
2409
|
+
const p = parseAddress(addr);
|
|
2410
|
+
const last_seen = p.domain === this.domain ? await this.presenciaDe(p.local) : null;
|
|
2411
|
+
return send(200, { ...card, ...(delegation ? { delegation: d } : {}), ...(last_seen ? { presence: { last_seen } } : {}) });
|
|
2412
|
+
} catch (e) { return send(e.permanent ? 404 : 502, { reason: e.message }); }
|
|
2413
|
+
}
|
|
954
2414
|
// ----- Libro (lecturas directas; las operaciones van por correo a libro@) -----
|
|
2415
|
+
// Estado de cuenta por rango (NX-501): ?desde&hasta ISO-8601, ?formato=json|csv, ?limit.
|
|
2416
|
+
// La misma firma que /libro/cuenta; el dueño sólo ve su cuenta (?account= ajena → 403).
|
|
2417
|
+
if (rx.method === 'GET' && path === '/libro/estado') {
|
|
2418
|
+
const who = await this._authenticate(rx, path, { allowForeign: true });
|
|
2419
|
+
const p = Object.fromEntries(rx.query || []);
|
|
2420
|
+
const cuenta = String(p.account || who.address).toLowerCase();
|
|
2421
|
+
if (cuenta !== who.address) return send(403, { reason: 'not your account' });
|
|
2422
|
+
const formato = p.formato || 'json';
|
|
2423
|
+
if (!['json', 'csv'].includes(formato)) return send(400, { reason: 'formato must be json or csv' });
|
|
2424
|
+
const estado = await estadoDeCuenta(this.libro, cuenta, { since: p.desde, until: p.hasta, limit: p.limit, max: 1000 });
|
|
2425
|
+
// Diario y saldos que no cuadran es la primera señal de un libro corrompido: se anota y se
|
|
2426
|
+
// registra como evento, no se devuelve en silencio dentro del JSON.
|
|
2427
|
+
if (estado.reconciled === false) { this.log(`libro: el diario y los saldos de ${cuenta} divergen (${estado.closing_balance} vs ${estado.ledger_balance})`); await this._evento('ledger_divergence', cuenta, { closing_balance: estado.closing_balance, ledger_balance: estado.ledger_balance }); }
|
|
2428
|
+
if (formato === 'json') return send(200, estado);
|
|
2429
|
+
return { status: 200, contentType: 'text/csv; charset=utf-8', headers: { 'content-disposition': `attachment; filename="${nombreCsv(estado)}"` }, body: csvDe(estado) };
|
|
2430
|
+
}
|
|
955
2431
|
if (rx.method === 'GET' && (m = /^\/libro\/cuenta\/([^/]+)$/.exec(path))) {
|
|
956
2432
|
const who = await this._authenticate(rx, path, { allowForeign: true });
|
|
957
|
-
const address =
|
|
2433
|
+
const address = dec(m[1]).toLowerCase();
|
|
958
2434
|
if (who.address !== address) return send(403, { reason: 'cuenta ajena' });
|
|
959
2435
|
return send(200, await this.libro.account(address));
|
|
960
2436
|
}
|
|
961
2437
|
if (rx.method === 'GET' && (m = /^\/libro\/contrato\/([^/]+)$/.exec(path))) {
|
|
962
2438
|
const who = await this._authenticate(rx, path, { allowForeign: true });
|
|
963
|
-
const c = await this.store.libroGetContract(
|
|
2439
|
+
const c = await this.store.libroGetContract(dec(m[1]));
|
|
964
2440
|
if (!c) return send(404, { reason: 'contrato inexistente' });
|
|
965
2441
|
if (![c.seller, c.buyer, c.verifier, c.arbiter].includes(who.address)) return send(403, { reason: 'you are not a party to this' });
|
|
966
2442
|
return send(200, contratoPublico(c));
|
|
@@ -989,6 +2465,21 @@ export class Estafeta {
|
|
|
989
2465
|
mostrador: desk, arbitro: arbiter, tareas: publicadas, _deprecated: ['mostrador', 'arbitro', 'tareas'],
|
|
990
2466
|
});
|
|
991
2467
|
}
|
|
2468
|
+
// El embudo (NX-801), en PRIVADO: lleva direcciones y la fecha de cada etapa por persona,
|
|
2469
|
+
// así que nunca va en /report, que es público y promete no nombrar a nadie. Misma llave
|
|
2470
|
+
// que /eventos. Se calcula al pedirlo; si el almacén no responde, se dice, no se inventa.
|
|
2471
|
+
if (rx.method === 'GET' && (path === '/informe' || path === '/informe.json')) {
|
|
2472
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house reads its funnel' });
|
|
2473
|
+
try {
|
|
2474
|
+
const dias = Math.min(90, Math.max(1, Number(rx.query.get('days') || 7) || 7));
|
|
2475
|
+
const [d, embudo] = await Promise.all([datosInforme(this, { dias }), datosEmbudo(this)]);
|
|
2476
|
+
if (path === '/informe.json') return send(200, { ...d, embudo });
|
|
2477
|
+
return { status: 200, contentType: 'text/html; charset=utf-8', body: informeHtml(this.domain, d, { embudo }) };
|
|
2478
|
+
} catch (e) {
|
|
2479
|
+
this.log(`embudo no disponible: ${e.message}`);
|
|
2480
|
+
return send(503, { reason: 'the ledger could not be read right now' });
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
992
2483
|
if (rx.method === 'GET' && path === '/eventos') {
|
|
993
2484
|
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house reads its events' });
|
|
994
2485
|
const p = Object.fromEntries(rx.query);
|
|
@@ -1003,12 +2494,12 @@ export class Estafeta {
|
|
|
1003
2494
|
}
|
|
1004
2495
|
if (rx.method === 'GET' && (m = /^\/outbox\/([^/]+)$/.exec(path))) {
|
|
1005
2496
|
const who = await this._authenticate(rx, path);
|
|
1006
|
-
if (who.local !==
|
|
2497
|
+
if (who.local !== dec(m[1]).toLowerCase()) return send(403, { reason: 'bandeja ajena' });
|
|
1007
2498
|
return send(200, { sent: await this.store.listOutbox(who.local) });
|
|
1008
2499
|
}
|
|
1009
2500
|
// ----- Índice federado -----
|
|
1010
2501
|
if (this.index.enabled && rx.method === 'POST' && path === '/index/houses') {
|
|
1011
|
-
if (!this.rate.allow(`index:${rx.ip || 'x'}`)) return
|
|
2502
|
+
if (!await this.rate.allow(`index:${rx.ip || 'x'}`)) return tarde({ reason: 'too many requests' });
|
|
1012
2503
|
const h = await this.indexAddHouse((rx.body || {}).domain);
|
|
1013
2504
|
return send(201, h);
|
|
1014
2505
|
}
|
|
@@ -1017,17 +2508,16 @@ export class Estafeta {
|
|
|
1017
2508
|
return send(200, { total: houses.length, houses });
|
|
1018
2509
|
}
|
|
1019
2510
|
if (this.index.enabled && rx.method === 'GET' && path === '/index/agents') {
|
|
2511
|
+
// Paginación por cursor opaco, nunca por offset; los filtros se validan en indexSearch.
|
|
1020
2512
|
const p = Object.fromEntries(rx.query);
|
|
1021
|
-
|
|
1022
|
-
const offset = Number.isInteger(Number(p.offset)) && Number(p.offset) >= 0 ? Number(p.offset) : 0;
|
|
1023
|
-
return send(200, await this.indexSearch({ q: p.q, capability: p.capability, accepts: p.accepts, house: p.house, limit, offset }));
|
|
2513
|
+
return send(200, await this.indexSearch({ ...Object.fromEntries(FILTROS.map((k) => [k, p[k]])), offset: p.offset }));
|
|
1024
2514
|
}
|
|
1025
2515
|
return send(404, { reason: 'unknown route' });
|
|
1026
2516
|
} catch (e) {
|
|
1027
2517
|
// Falla cerrado y con código HTTP válido: e.code puede ser un string del sistema ('ENOENT').
|
|
1028
2518
|
const status = Number.isInteger(e.status) ? e.status : (Number.isInteger(e.code) ? e.code : 500);
|
|
1029
2519
|
if (status >= 500) this.log(`error ${rx.method} ${path}: ${e.message}`);
|
|
1030
|
-
return send(status, { reason: e.message });
|
|
2520
|
+
return send(status, { reason: e.message }, e.headers || null);
|
|
1031
2521
|
}
|
|
1032
2522
|
}
|
|
1033
2523
|
|