@nyx5/nyx5 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -5
- package/agents.md +24 -7
- package/bin/nyx5.js +153 -6
- package/demo/x402-pagar.mjs +64 -0
- package/docs/ARQUITECTURA.md +352 -0
- package/docs/HOUSE.es.md +237 -0
- package/docs/HOUSE.md +234 -0
- package/docs/SPEC.md +337 -6
- 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/0009_contratos_estado.sql +4 -0
- package/package.json +6 -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 +247 -13
- package/src/correo/asistente.js +502 -77
- package/src/correo/ci.js +262 -0
- package/src/correo/cobro.js +164 -0
- package/src/correo/estafeta.js +558 -59
- package/src/correo/ideas.js +154 -0
- package/src/correo/identidad.js +81 -0
- package/src/correo/politica.js +92 -3
- package/src/correo/unirse.js +14 -4
- package/src/libro/libro.js +6 -2
- package/src/libro/verifica.js +239 -36
- package/src/nucleo/almacen-d1.js +24 -5
- package/src/nucleo/almacen.js +31 -4
- package/src/nucleo/crypto.js +36 -0
- 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/node.js +18 -1
- package/src/plataformas/spec-html.js +2 -2
- package/src/plataformas/terms-html.js +2 -0
- package/src/plataformas/worker.js +21 -1
- package/src/puentes/ejecutor.js +118 -0
- package/src/puentes/herramientas.js +137 -12
- package/src/puentes/mcp.js +4 -2
- package/src/puentes/x402-pagador.js +248 -0
- package/src/version.js +1 -1
package/src/correo/estafeta.js
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
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, applyEmailPolicy, RateLimiter, RateLimiterDurable, proyectoDe, nombreDeProyecto, validarPerfil, usdSinBilletera } 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';
|
|
@@ -40,7 +41,11 @@ import { atenderMcp } from '../puentes/mcp-remoto.js';
|
|
|
40
41
|
import { abrirBoveda } from '../nucleo/boveda.js';
|
|
41
42
|
import { ICONOS } from '../plataformas/iconos.js';
|
|
42
43
|
import { Agent } from './agente.js';
|
|
43
|
-
import {
|
|
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';
|
|
44
49
|
import { validarFiltros, puntajeDe, precioMinimo, FILTROS } from './indice.js';
|
|
45
50
|
|
|
46
51
|
const now = () => Date.now();
|
|
@@ -60,13 +65,19 @@ const rutaCors = (p) => p.startsWith('/.well-known/oauth-') || p === '/oauth/reg
|
|
|
60
65
|
// del usuario a los siete días sin abrirla.
|
|
61
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' }] };
|
|
62
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;
|
|
73
|
+
|
|
63
74
|
export class Estafeta {
|
|
64
75
|
constructor({
|
|
65
76
|
domain, dataDir, store, adminToken,
|
|
66
77
|
port = 4000, host = '127.0.0.1', publicUrl,
|
|
67
78
|
hosts = {}, fetchImpl = globalThis.fetch,
|
|
68
|
-
policy = {}, retry = {}, workerIntervalMs = 1000, libro = {},
|
|
69
|
-
index = {}, email = {}, verifica = {}, eventos = true, tareas = {}, terms = null, remoto = {}, asistente = {},
|
|
79
|
+
policy = {}, retry = {}, workerIntervalMs = 1000, libro = {}, objetos, sinObjetos = false,
|
|
80
|
+
index = {}, email = {}, verifica = {}, eventos = true, tareas = {}, terms = null, remoto = {}, asistente = {}, ideas = {},
|
|
70
81
|
extensions = null,
|
|
71
82
|
log = (...a) => console.log(`[estafeta ${domain}]`, ...a),
|
|
72
83
|
}) {
|
|
@@ -119,6 +130,11 @@ export class Estafeta {
|
|
|
119
130
|
this.hostsOverride = hosts;
|
|
120
131
|
|
|
121
132
|
this.store = store || new FileStore(dataDir);
|
|
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);
|
|
122
138
|
// Límites de tasa durables (NX-901): cuentan en el almacén, así que valen para toda la casa y
|
|
123
139
|
// no por isolate. Un almacén sin contador (uno ajeno, mínimo) cae al de memoria.
|
|
124
140
|
const limitador = (perMinute, ns) => (this.store.kvIncrement ? new RateLimiterDurable({ store: this.store, perMinute, ns, log: (m) => this.log(m) }) : new RateLimiter({ perMinute }));
|
|
@@ -134,6 +150,10 @@ export class Estafeta {
|
|
|
134
150
|
// Asistentes (src/correo/asistente.js): existen sólo si la casa tiene una clave de la API de
|
|
135
151
|
// Anthropic. La clave no se guarda aparte: sólo el asistente la usa, al llamar.
|
|
136
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 };
|
|
137
157
|
this._ready = null;
|
|
138
158
|
this._domainCardCache = null; // { value, until }
|
|
139
159
|
this._pushes = []; // avisos por webhook en vuelo (los espera flushPushes)
|
|
@@ -236,13 +256,20 @@ export class Estafeta {
|
|
|
236
256
|
this._primeros.add(raiz);
|
|
237
257
|
} catch (e) { this.log(`first_message ${raiz} no registrado: ${e.message}`); }
|
|
238
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
|
+
}
|
|
239
264
|
isSystem(local) { return ['postmaster', 'libro', 'verifica', 'tareas'].includes(local); }
|
|
240
265
|
// ¿Podría alguien registrarse HOY con este nombre? Mismas reglas que registerAgent, sin registrar nada.
|
|
241
266
|
async nombreDisponible(local) {
|
|
242
267
|
if (!local || Estafeta.RESERVED.has(local) || local.length < (this.policy.min_name_length || 0)) return false;
|
|
243
268
|
return !(await this.store.getAgent(local));
|
|
244
269
|
}
|
|
245
|
-
|
|
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']);
|
|
246
273
|
|
|
247
274
|
// ---------- servicio de registro ----------
|
|
248
275
|
// Invitaciones: la casa emite códigos con usos y vencimiento; un agente los presenta al inscribirse.
|
|
@@ -290,6 +317,10 @@ export class Estafeta {
|
|
|
290
317
|
nyx5: '1', domain: this.domain, estafeta: this.publicUrl,
|
|
291
318
|
keys: rec.keys.map((k) => ({ sig: k.sig, created: k.created })),
|
|
292
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() } : {}),
|
|
293
324
|
extensions: this.extensions,
|
|
294
325
|
};
|
|
295
326
|
const guardada = rec.card;
|
|
@@ -312,7 +343,10 @@ export class Estafeta {
|
|
|
312
343
|
|
|
313
344
|
// ---------- agentes ----------
|
|
314
345
|
static VISIBILIDADES = ['public', 'private', 'secret'];
|
|
315
|
-
|
|
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 }) {
|
|
316
350
|
// Visibilidad (NX-202): public = en el directorio; private = existe, lo encuentra quien sabe la
|
|
317
351
|
// dirección (lo de siempre; es el defecto); secret = a quien no está en su lista se le responde
|
|
318
352
|
// exactamente lo que a un nombre inexistente. Se conserva entre re-certificaciones.
|
|
@@ -328,11 +362,11 @@ export class Estafeta {
|
|
|
328
362
|
local = String(local).toLowerCase();
|
|
329
363
|
const address = `${local}@${this.domain}`;
|
|
330
364
|
parseAddress(address);
|
|
331
|
-
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 });
|
|
332
366
|
// Nombres de 1 a 3 caracteres: reservados en una casa de registro abierto. Son lo primero
|
|
333
367
|
// que alguien acapara para revender o para suplantar (a@casa se confunde con cualquiera), y
|
|
334
368
|
// un agente que llega no necesita un nombre corto: necesita uno suyo. Los de sistema pasan.
|
|
335
|
-
if (local.length <= this.policy.min_name_length - 1 && !this.isSystem(local) && !delegation) {
|
|
369
|
+
if (local.length <= this.policy.min_name_length - 1 && !this.isSystem(local) && !delegation && !system) {
|
|
336
370
|
throw Object.assign(new Error(`names shorter than ${this.policy.min_name_length} characters are reserved in this house`), { status: 409 });
|
|
337
371
|
}
|
|
338
372
|
if (delegation) {
|
|
@@ -410,7 +444,7 @@ export class Estafeta {
|
|
|
410
444
|
await this.store.putAgent(local, { ...card, webhook, notify_email });
|
|
411
445
|
}
|
|
412
446
|
const gift = welcome ?? this.libro.welcome;
|
|
413
|
-
if (creado && !prev && !this.isSystem(local) && !delegation && !group && 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 });
|
|
414
448
|
return card;
|
|
415
449
|
}
|
|
416
450
|
|
|
@@ -665,16 +699,51 @@ export class Estafeta {
|
|
|
665
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]));
|
|
666
700
|
const orden = (a, b) => String(a.at).localeCompare(String(b.at));
|
|
667
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.
|
|
668
707
|
const mapa = new Map();
|
|
669
708
|
for (const x of todos) {
|
|
670
709
|
const c = contraparte(x);
|
|
671
|
-
const r = mapa.get(c) || { with: c, count: 0, pending: 0, last_at: '', last_dir: null, last_id: null };
|
|
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() };
|
|
672
711
|
r.count++;
|
|
673
|
-
|
|
712
|
+
const pendiente = x.dir === 'in' && !x.acked;
|
|
713
|
+
if (pendiente) r.pending++;
|
|
674
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
|
+
}
|
|
675
721
|
mapa.set(c, r);
|
|
676
722
|
}
|
|
677
|
-
return [...mapa.values()].sort((a, b) => b.last_at.localeCompare(a.last_at))
|
|
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;
|
|
678
747
|
}
|
|
679
748
|
// Los subagentes que un dueño delegó (sus Claude conectados), con lo necesario para revocarlos.
|
|
680
749
|
async delegados(local) {
|
|
@@ -771,13 +840,195 @@ export class Estafeta {
|
|
|
771
840
|
// seal: cada respuesta termina con su sha256, para sellarla en la notaría (qa@, NX-606).
|
|
772
841
|
const seal = c.seal ?? base.seal ?? false;
|
|
773
842
|
if (typeof seal !== 'boolean') return { error: 'seal must be true or false' };
|
|
774
|
-
|
|
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 } };
|
|
775
1018
|
}
|
|
776
1019
|
async _adminAsistente(rx, local, accion) {
|
|
777
1020
|
const b = rx.body || {};
|
|
778
1021
|
const indice = async () => (await this.store.kvGet('asistente', '_indice')) || [];
|
|
779
1022
|
if (rx.method === 'POST' && !local && !accion) {
|
|
780
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);
|
|
781
1032
|
const l = String(b.local || '').toLowerCase();
|
|
782
1033
|
const rec = Estafeta.validLocal(l) ? await this.store.getAgent(l) : null;
|
|
783
1034
|
if (!rec?.delegation || rec.revoked) return { status: 404, body: { reason: 'an assistant must be an existing delegated address' } };
|
|
@@ -812,19 +1063,115 @@ export class Estafeta {
|
|
|
812
1063
|
if (rx.method === 'PUT' && accion === 'config') {
|
|
813
1064
|
const v = this._configAsistente(b, cfg);
|
|
814
1065
|
if (v.error) return { status: 400, body: { reason: v.error } };
|
|
815
|
-
|
|
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() };
|
|
816
1069
|
await this.store.kvPut('asistente', local, nueva);
|
|
817
|
-
return { status: 200, body: { model: nueva.model, effort: nueva.effort, max_tokens: nueva.max_tokens, budget_usd: nueva.budget_usd, seal: nueva.seal === true } };
|
|
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 || [] } };
|
|
818
1071
|
}
|
|
1072
|
+
if (rx.method === 'DELETE' && !accion) return this._quitarAsistente(local, cfg);
|
|
819
1073
|
if (rx.method === 'POST' && (accion === 'pause' || accion === 'resume')) {
|
|
820
1074
|
await this.store.kvPut('asistente', local, { ...cfg, enabled: accion === 'resume' });
|
|
821
1075
|
return { status: 200, body: { enabled: accion === 'resume' } };
|
|
822
1076
|
}
|
|
823
1077
|
if (rx.method === 'GET' && !accion) {
|
|
824
1078
|
const mes = new Date().toISOString().slice(0, 7);
|
|
825
|
-
const gasto =
|
|
1079
|
+
const gasto = await gastoDe(this.store, local, mes);
|
|
826
1080
|
const con = await this.store.kvGet('asistente-conocimiento', local);
|
|
827
|
-
return { status: 200, body: { address: `${local}@${this.domain}`, enabled: cfg.enabled, model: cfg.model, effort: cfg.effort, budget_usd: cfg.budget_usd, month: mes, spent_usd: Math.round(gasto.usd * 10000) / 10000, calls: gasto.llamadas, knowledge_bytes: con ? Buffer.byteLength(con.texto) : 0, knowledge_updated: con?.updated || null, api_key: !!this.asistente, pending: (await this.store.listMail(local)).length } };
|
|
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 } };
|
|
828
1175
|
}
|
|
829
1176
|
return { status: 405, body: { reason: 'method not allowed here' } };
|
|
830
1177
|
}
|
|
@@ -972,6 +1319,11 @@ export class Estafeta {
|
|
|
972
1319
|
// petición no tiene por qué pagarlo.
|
|
973
1320
|
if (programado) await this._liberarVencidos();
|
|
974
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}`); } }
|
|
975
1327
|
await this.flushPushes();
|
|
976
1328
|
}
|
|
977
1329
|
async _deliver(job) {
|
|
@@ -1051,13 +1403,13 @@ export class Estafeta {
|
|
|
1051
1403
|
async _verificarPendientes() {
|
|
1052
1404
|
const arbitro = `verifica@${this.domain}`;
|
|
1053
1405
|
let contratos;
|
|
1054
|
-
try { contratos = await this.store.libroListContracts(); } catch { return; }
|
|
1406
|
+
try { contratos = await this.store.libroListContracts({ state: ['held', 'delivered'] }); } catch { return; }
|
|
1055
1407
|
const candidatos = contratos.filter((c) => c.kind === 'escrow' && ['held', 'delivered'].includes(c.state) && c.arbiter === arbitro && pruebasDe(c));
|
|
1056
1408
|
for (const c of candidatos.slice(0, this.verifica.maxPorTick)) {
|
|
1057
1409
|
// Solo se verifica lo que ya se declaró entregado, salvo que el contrato pida verificar
|
|
1058
1410
|
// desde el arranque (terms.verify_on: 'accept'), útil para un endpoint que ya debía estar en pie.
|
|
1059
1411
|
if (c.state === 'held' && (c.terms?.verify_on || 'deliver') !== 'accept') continue;
|
|
1060
|
-
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 });
|
|
1061
1413
|
if (v.indeciso) { this.log(`verifica ${c.id}: sin veredicto (${v.razon})`); continue; }
|
|
1062
1414
|
// La decisión viaja como sobre firmado a libro@ y entra por `inbound`, la MISMA puerta
|
|
1063
1415
|
// que usa cualquier agente (invariante 2). No se toca el Libro por dentro: si la firma
|
|
@@ -1085,7 +1437,7 @@ export class Estafeta {
|
|
|
1085
1437
|
// comprobar las fechas; aquí sólo se elige a quién mirar.
|
|
1086
1438
|
async _liberarVencidos() {
|
|
1087
1439
|
let contratos;
|
|
1088
|
-
try { contratos = await this.store.libroListContracts(); } catch { return; }
|
|
1440
|
+
try { contratos = await this.store.libroListContracts({ state: 'delivered' }); } catch { return; }
|
|
1089
1441
|
const ahora = now();
|
|
1090
1442
|
const vencidos = contratos.filter((c) => {
|
|
1091
1443
|
if (c.kind !== 'escrow' || c.state !== 'delivered') return false;
|
|
@@ -1244,6 +1596,12 @@ export class Estafeta {
|
|
|
1244
1596
|
if (!rec || !await this._visibleA(rec, env.from)) { rejected.push({ to, code: 404, reason: 'no such agent' }); continue; }
|
|
1245
1597
|
const p = applyInboxPolicy(env, rec, senderCard._domain);
|
|
1246
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
|
+
}
|
|
1247
1605
|
try {
|
|
1248
1606
|
if (local === 'libro') {
|
|
1249
1607
|
// Operación del Libro: se ejecuta (idempotente por id de sobre), no se almacena;
|
|
@@ -1327,6 +1685,10 @@ export class Estafeta {
|
|
|
1327
1685
|
mails, libro: libroBundles,
|
|
1328
1686
|
});
|
|
1329
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);
|
|
1330
1692
|
}
|
|
1331
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 } });
|
|
1332
1694
|
// Avisos de plazo: sobres del Libro programados para el futuro (un escrow que llega a su
|
|
@@ -1372,6 +1734,36 @@ export class Estafeta {
|
|
|
1372
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) });
|
|
1373
1735
|
}).catch((e) => this.log(`webhook ${local} falló: ${e.message} / ${e.cause?.message}`));
|
|
1374
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);
|
|
1375
1767
|
return pendiente;
|
|
1376
1768
|
}
|
|
1377
1769
|
// Aviso por email: si el agente registró `notify_email` y la casa tiene proveedor de salida, le
|
|
@@ -1386,9 +1778,23 @@ export class Estafeta {
|
|
|
1386
1778
|
const emailOrig = env.extensions?.['urn:nyx5:ext:email']?.from;
|
|
1387
1779
|
if (emailOrig && emailOrig === rec.notify_email) return; // no te avises de tu propio correo
|
|
1388
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);
|
|
1389
1791
|
await this.emailOut({ fromAgent: `${local}@${this.domain}`, to: rec.notify_email,
|
|
1390
|
-
subject:
|
|
1391
|
-
|
|
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).` });
|
|
1392
1798
|
}).catch((e) => this.log(`notify_email ${local} falló: ${e.message}`));
|
|
1393
1799
|
this._pushes.push(pendiente);
|
|
1394
1800
|
return pendiente;
|
|
@@ -1459,24 +1865,45 @@ export class Estafeta {
|
|
|
1459
1865
|
// tumba el rastreo: esa tarjeta entra sin puntaje (al final del orden), y el número queda escrito.
|
|
1460
1866
|
async _indexReputacion(cards, dc, propia) {
|
|
1461
1867
|
const cuenta = { pedidas: cards.length, con_puntaje: 0, sin_historial: 0, sin_arbitrados: 0, fallidas: 0 };
|
|
1462
|
-
const
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
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;
|
|
1467
1886
|
try {
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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) });
|
|
1472
1902
|
if (!res.ok) throw new Error(`historial -> ${res.status}`);
|
|
1473
|
-
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
c._score = r.score; c._jobs_done = r.jobs_done;
|
|
1478
|
-
if (r.motivo) cuenta[r.motivo] += 1; else cuenta.con_puntaje += 1;
|
|
1479
|
-
}));
|
|
1903
|
+
anotar(c, await res.json());
|
|
1904
|
+
} catch (e) { fallo(c, e); }
|
|
1905
|
+
}));
|
|
1906
|
+
}
|
|
1480
1907
|
}
|
|
1481
1908
|
return cuenta;
|
|
1482
1909
|
}
|
|
@@ -1497,6 +1924,10 @@ export class Estafeta {
|
|
|
1497
1924
|
// la casa no manda correo a una dirección que no verificó (nada de backscatter).
|
|
1498
1925
|
const pol = applyEmailPolicy(rec, from);
|
|
1499
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' };
|
|
1500
1931
|
// Y un buzón abierto tampoco es un embudo infinito: se limita por dominio del remitente.
|
|
1501
1932
|
if (!await this.rate.allow(`email:${String(from).slice(String(from).lastIndexOf('@') + 1).toLowerCase()}`)) {
|
|
1502
1933
|
return { ok: false, code: 429, reason: 'rate limit for the sending domain' };
|
|
@@ -1542,6 +1973,10 @@ export class Estafeta {
|
|
|
1542
1973
|
const send = (status, body, headers = null) => (headers ? { status, body, headers } : { status, body });
|
|
1543
1974
|
// Un 429 dice cuándo volver: Retry-After en segundos hasta la ventana siguiente.
|
|
1544
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; } };
|
|
1545
1980
|
let m;
|
|
1546
1981
|
try {
|
|
1547
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 });
|
|
@@ -1558,14 +1993,38 @@ export class Estafeta {
|
|
|
1558
1993
|
if (this.remoto.enabled && rx.method === 'POST' && path === '/contact-invites') return this.crearInvitacion(rx);
|
|
1559
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);
|
|
1560
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
|
+
}
|
|
1561
2000
|
if ((m = /^\/admin\/assistants(?:\/([^/]+))?(?:\/(knowledge|config|pause|resume))?$/.exec(path))) {
|
|
1562
2001
|
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'only the house configures assistants' });
|
|
1563
|
-
return this._adminAsistente(rx, m[1] ?
|
|
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 })) });
|
|
1564
2023
|
}
|
|
1565
2024
|
// ----- Ficha pública: la edita el dueño de la dirección o el dueño de su delegación -----
|
|
1566
2025
|
if (rx.method === 'POST' && (m = /^\/agents\/([^/]+)\/profile$/.exec(path))) {
|
|
1567
2026
|
const who = await this._authenticate(rx, path);
|
|
1568
|
-
const l =
|
|
2027
|
+
const l = dec(m[1]).toLowerCase();
|
|
1569
2028
|
const rec = Estafeta.validLocal(l) ? await this.store.getAgent(l) : null;
|
|
1570
2029
|
if (!rec || rec.revoked) return send(404, { reason: 'no such agent' });
|
|
1571
2030
|
// Una dirección de sólo mensajes no edita ficha alguna, ni la suya: lo que se publica en
|
|
@@ -1586,7 +2045,7 @@ export class Estafeta {
|
|
|
1586
2045
|
// ----- Presencia: «visto por última vez», sólo con opt-in del dueño -----
|
|
1587
2046
|
if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)\/presence$/.exec(path))) {
|
|
1588
2047
|
if (!await this.rate.allow(`resolve:${rx.ip || 'x'}`)) return tarde({ reason: 'too many requests' });
|
|
1589
|
-
const l =
|
|
2048
|
+
const l = dec(m[1]).toLowerCase();
|
|
1590
2049
|
if (!Estafeta.validLocal(l)) return send(400, { reason: 'invalid agent name' });
|
|
1591
2050
|
// Un inexistente contesta «sin presencia»; un secreto, a quien no lo ve, exactamente lo mismo.
|
|
1592
2051
|
const quien = await this._quienPregunta(rx, path);
|
|
@@ -1601,7 +2060,7 @@ export class Estafeta {
|
|
|
1601
2060
|
}
|
|
1602
2061
|
if ((m = /^\/groups\/([^/]+)\/members$/.exec(path))) {
|
|
1603
2062
|
const who = await this._authenticate(rx, path);
|
|
1604
|
-
const l =
|
|
2063
|
+
const l = dec(m[1]).toLowerCase();
|
|
1605
2064
|
if (!Estafeta.validLocal(l)) return send(400, { reason: 'invalid group name' });
|
|
1606
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' } };
|
|
1607
2066
|
return send(r.status, r.body);
|
|
@@ -1674,7 +2133,7 @@ export class Estafeta {
|
|
|
1674
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` };
|
|
1675
2134
|
}
|
|
1676
2135
|
if (rx.method === 'GET' && path === '/sitemap.xml') {
|
|
1677
|
-
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'] : []);
|
|
1678
2137
|
const hoy = iso().slice(0, 10);
|
|
1679
2138
|
return { status: 200, contentType: 'application/xml; charset=utf-8',
|
|
1680
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` };
|
|
@@ -1693,7 +2152,7 @@ export class Estafeta {
|
|
|
1693
2152
|
// lo dice: "no hay nada que pagar" es una respuesta, no un error.
|
|
1694
2153
|
const mX402 = rx.method === 'GET' ? /^\/x402\/inbox\/([^/]+)$/.exec(path) : null;
|
|
1695
2154
|
if (mX402) {
|
|
1696
|
-
const local =
|
|
2155
|
+
const local = dec(mX402[1]).toLowerCase();
|
|
1697
2156
|
const quien = await this._quienPregunta(rx, path);
|
|
1698
2157
|
const rec = await this.store.getAgent(local);
|
|
1699
2158
|
if (!rec || !await this._visibleA(rec, quien)) return send(404, { reason: 'no such agent' });
|
|
@@ -1733,8 +2192,30 @@ export class Estafeta {
|
|
|
1733
2192
|
// Reputación = una consulta al libro. Es PÚBLICA a propósito: sirve justamente para que
|
|
1734
2193
|
// un desconocido decida antes de contratar, igual que la tarjeta. No expone contenido ni
|
|
1735
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
|
+
}
|
|
1736
2217
|
if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)\/historial$/.exec(path))) {
|
|
1737
|
-
const local =
|
|
2218
|
+
const local = dec(m[1]).toLowerCase();
|
|
1738
2219
|
const quien = await this._quienPregunta(rx, path);
|
|
1739
2220
|
const rec = Estafeta.validLocal(local) ? await this.store.getAgent(local) : null;
|
|
1740
2221
|
if (!rec || !await this._visibleA(rec, quien)) return send(404, { reason: 'no such agent' });
|
|
@@ -1745,7 +2226,7 @@ export class Estafeta {
|
|
|
1745
2226
|
// declarante se muestra sólo si «nadie» lo vería (_visibleA con quien = null).
|
|
1746
2227
|
if (rx.method === 'GET' && (m = /^\/notaria\/(?:sello\/([^/]+)|([0-9a-fA-F]{64}))$/.exec(path))) {
|
|
1747
2228
|
if (!await this.rate.allow(`notaria:${rx.ip || 'x'}`)) return tarde({ reason: 'too many requests' });
|
|
1748
|
-
let idSello = null; if (m[1]) {
|
|
2229
|
+
let idSello = null; if (m[1]) { idSello = dec(m[1]); }
|
|
1749
2230
|
const docs = idSello ? [await this.store.notariaGet(idSello)].filter(Boolean) : await this.store.notariaList(m[2].toLowerCase());
|
|
1750
2231
|
if (!docs.length) return send(404, { reason: 'no such seal' });
|
|
1751
2232
|
const seals = [];
|
|
@@ -1757,7 +2238,7 @@ export class Estafeta {
|
|
|
1757
2238
|
// mismas cabeceras. Lo cuida test/visibilidad.test.js comparando los dos byte a byte.
|
|
1758
2239
|
// Quién pregunta se resuelve ANTES de mirar si existe: el orden es parte de la igualdad.
|
|
1759
2240
|
const quien = await this._quienPregunta(rx, path);
|
|
1760
|
-
const card = await this.agentCard(
|
|
2241
|
+
const card = await this.agentCard(dec(m[1]).toLowerCase());
|
|
1761
2242
|
if (!card || !await this._visibleA(card, quien)) return send(404, { reason: 'no such agent' });
|
|
1762
2243
|
return send(200, card);
|
|
1763
2244
|
}
|
|
@@ -1832,7 +2313,7 @@ export class Estafeta {
|
|
|
1832
2313
|
}
|
|
1833
2314
|
if (rx.method === 'GET' && (m = /^\/mailbox\/([^/]+)$/.exec(path))) {
|
|
1834
2315
|
const who = await this._authenticate(rx, path);
|
|
1835
|
-
if (who.local !==
|
|
2316
|
+
if (who.local !== dec(m[1]).toLowerCase()) return send(403, { reason: 'not your mailbox' });
|
|
1836
2317
|
const limit = Number(rx.query.get('limit') || 50);
|
|
1837
2318
|
// los N más recientes (listMail viene en orden cronológico): con muchos mensajes viejos
|
|
1838
2319
|
// sin ackear, slice(0,limit) escondía justo los nuevos. slice(-limit) muestra los últimos.
|
|
@@ -1840,7 +2321,7 @@ export class Estafeta {
|
|
|
1840
2321
|
}
|
|
1841
2322
|
if (rx.method === 'POST' && (m = /^\/mailbox\/([^/]+)\/ack$/.exec(path))) {
|
|
1842
2323
|
const who = await this._authenticate(rx, path);
|
|
1843
|
-
if (who.local !==
|
|
2324
|
+
if (who.local !== dec(m[1]).toLowerCase()) return send(403, { reason: 'not your mailbox' });
|
|
1844
2325
|
const { ids = [] } = rx.body || {};
|
|
1845
2326
|
const acked = [];
|
|
1846
2327
|
for (const id of ids) if (await this.store.ackMail(who.local, id)) acked.push(id);
|
|
@@ -1849,31 +2330,49 @@ export class Estafeta {
|
|
|
1849
2330
|
if (acked.length && who.record.capabilities?.read_receipts === true) await this._acusesDeLectura(who, acked);
|
|
1850
2331
|
return send(200, { acked });
|
|
1851
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
|
+
}
|
|
1852
2348
|
// ----- Tiempo real, historial, conectores y tarjetas ajenas -----
|
|
1853
2349
|
if (rx.method === 'GET' && (m = /^\/mailbox\/([^/]+)\/wait$/.exec(path))) {
|
|
1854
2350
|
const who = await this._authenticate(rx, path);
|
|
1855
|
-
if (who.local !==
|
|
2351
|
+
if (who.local !== dec(m[1]).toLowerCase()) return send(403, { reason: 'not your mailbox' });
|
|
1856
2352
|
const q = Object.fromEntries(rx.query);
|
|
1857
2353
|
const segundos = Math.max(0, Math.min(Number(q.timeout ?? 25) || 0, 90));
|
|
1858
|
-
const
|
|
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 });
|
|
1859
2357
|
return send(200, { message: msg });
|
|
1860
2358
|
}
|
|
1861
2359
|
if (rx.method === 'GET' && (m = /^\/conversations\/([^/]+)$/.exec(path))) {
|
|
1862
2360
|
const who = await this._authenticate(rx, path);
|
|
1863
|
-
if (who.local !==
|
|
2361
|
+
if (who.local !== dec(m[1]).toLowerCase()) return send(403, { reason: 'not your conversations' });
|
|
1864
2362
|
const con = rx.query.get('with');
|
|
1865
2363
|
const limit = Math.max(1, Math.min(Number(rx.query.get('limit') || 50) || 50, 500));
|
|
1866
|
-
const project =
|
|
2364
|
+
const project = this._proyectoPedido(rx.query);
|
|
2365
|
+
if (project === false) return send(400, { reason: 'invalid project: empty after normalization, or a reserved word' });
|
|
1867
2366
|
if (con) return send(200, { with: con.toLowerCase(), messages: await this.conversacion(who.local, { con: con.toLowerCase(), limit, project }) });
|
|
1868
2367
|
return send(200, { conversations: await this.conversacion(who.local, { project }) });
|
|
1869
2368
|
}
|
|
1870
2369
|
if (rx.method === 'GET' && (m = /^\/delegations\/([^/]+)$/.exec(path))) {
|
|
1871
2370
|
const who = await this._authenticate(rx, path);
|
|
1872
|
-
if (who.local !==
|
|
2371
|
+
if (who.local !== dec(m[1]).toLowerCase()) return send(403, { reason: 'not your delegations' });
|
|
1873
2372
|
return send(200, { delegations: await this.delegados(who.local) });
|
|
1874
2373
|
}
|
|
1875
2374
|
if (rx.method === 'POST' && (m = /^\/agents\/([^/]+)\/revoke$/.exec(path))) {
|
|
1876
|
-
const sub =
|
|
2375
|
+
const sub = dec(m[1]).toLowerCase();
|
|
1877
2376
|
if (!Estafeta.validLocal(sub)) return send(400, { reason: 'invalid agent name' });
|
|
1878
2377
|
const rec = await this.store.getAgent(sub);
|
|
1879
2378
|
if (!rec?.delegation) return send(404, { reason: 'no such subagent' });
|
|
@@ -1893,7 +2392,7 @@ export class Estafeta {
|
|
|
1893
2392
|
// cifrado del destinatario tendría que mandar en claro.
|
|
1894
2393
|
if (rx.method === 'GET' && (m = /^\/resolve\/([^/]+)$/.exec(path))) {
|
|
1895
2394
|
if (!await this.rate.allow(`resolve:${rx.ip || 'x'}`)) return tarde({ reason: 'too many requests' });
|
|
1896
|
-
let addr; try { addr =
|
|
2395
|
+
let addr; try { addr = dec(m[1]).toLowerCase(); parseAddress(addr); } catch { return send(400, { reason: 'invalid address' }); }
|
|
1897
2396
|
// Para quién se resuelve: el agente de esta casa que firma la petición, si alguno. A una
|
|
1898
2397
|
// dirección secreta de esta casa se le aplica la misma regla que en /agents/<l>; a una de
|
|
1899
2398
|
// otra casa, la casa pregunta «para» ese agente y la otra decide.
|
|
@@ -1931,13 +2430,13 @@ export class Estafeta {
|
|
|
1931
2430
|
}
|
|
1932
2431
|
if (rx.method === 'GET' && (m = /^\/libro\/cuenta\/([^/]+)$/.exec(path))) {
|
|
1933
2432
|
const who = await this._authenticate(rx, path, { allowForeign: true });
|
|
1934
|
-
const address =
|
|
2433
|
+
const address = dec(m[1]).toLowerCase();
|
|
1935
2434
|
if (who.address !== address) return send(403, { reason: 'cuenta ajena' });
|
|
1936
2435
|
return send(200, await this.libro.account(address));
|
|
1937
2436
|
}
|
|
1938
2437
|
if (rx.method === 'GET' && (m = /^\/libro\/contrato\/([^/]+)$/.exec(path))) {
|
|
1939
2438
|
const who = await this._authenticate(rx, path, { allowForeign: true });
|
|
1940
|
-
const c = await this.store.libroGetContract(
|
|
2439
|
+
const c = await this.store.libroGetContract(dec(m[1]));
|
|
1941
2440
|
if (!c) return send(404, { reason: 'contrato inexistente' });
|
|
1942
2441
|
if (![c.seller, c.buyer, c.verifier, c.arbiter].includes(who.address)) return send(403, { reason: 'you are not a party to this' });
|
|
1943
2442
|
return send(200, contratoPublico(c));
|
|
@@ -1995,7 +2494,7 @@ export class Estafeta {
|
|
|
1995
2494
|
}
|
|
1996
2495
|
if (rx.method === 'GET' && (m = /^\/outbox\/([^/]+)$/.exec(path))) {
|
|
1997
2496
|
const who = await this._authenticate(rx, path);
|
|
1998
|
-
if (who.local !==
|
|
2497
|
+
if (who.local !== dec(m[1]).toLowerCase()) return send(403, { reason: 'bandeja ajena' });
|
|
1999
2498
|
return send(200, { sent: await this.store.listOutbox(who.local) });
|
|
2000
2499
|
}
|
|
2001
2500
|
// ----- Índice federado -----
|