@nyx5/nyx5 0.1.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/CONTRIBUTING.md +38 -0
- package/LICENSE +202 -0
- package/README.md +133 -0
- package/SECURITY.md +28 -0
- package/bin/nyx5.js +184 -0
- package/demo/contratos.js +117 -0
- package/demo/e2e.js +57 -0
- package/demo/offline.js +46 -0
- package/demo/piloto-d4.mjs +146 -0
- package/demo/spam.js +65 -0
- package/docs/ARQUITECTURA.md +220 -0
- package/docs/SPEC.md +396 -0
- package/examples/hola-mundo.mjs +24 -0
- package/migrations/0002_chasqui.sql +116 -0
- package/migrations/0003_candado.sql +9 -0
- package/migrations/0004_pins.sql +15 -0
- package/package.json +49 -0
- package/src/correo/agente.js +233 -0
- package/src/correo/estafeta.js +777 -0
- package/src/correo/politica.js +107 -0
- package/src/correo/resolver.js +173 -0
- package/src/libro/contratos.js +247 -0
- package/src/libro/errores.js +4 -0
- package/src/libro/libro.js +250 -0
- package/src/nucleo/almacen-d1.js +223 -0
- package/src/nucleo/almacen.js +203 -0
- package/src/nucleo/crypto.js +141 -0
- package/src/nucleo/d1-local.js +78 -0
- package/src/plataformas/app-html.js +2 -0
- package/src/plataformas/home-html.js +2 -0
- package/src/plataformas/node.js +40 -0
- package/src/plataformas/spec-html.js +3 -0
- package/src/plataformas/worker.js +87 -0
- package/src/puentes/email.js +136 -0
- package/src/puentes/mcp.js +105 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
// Nyx5/1 — Estafeta: el servidor de un dominio (equivale al servidor de correo de gmail.com).
|
|
2
|
+
// Aloja los dos componentes del sistema: el Correo (sobres, buzones, cola) y el Libro (ledger y
|
|
3
|
+
// contratos). El Libro no tiene puerta propia: se opera escribiéndole a libro@<dominio>.
|
|
4
|
+
//
|
|
5
|
+
// Responsabilidades:
|
|
6
|
+
// - publicar la tarjeta del dominio y certificar las tarjetas de sus agentes
|
|
7
|
+
// - servicio de registro: alta por administrador, por invitación o abierta (con prueba de posesión
|
|
8
|
+
// de la clave), nombres reservados, y un directorio público de los agentes de la casa
|
|
9
|
+
// - recibir sobres de agentes propios (/outbound) y encolarlos: store-and-forward con reintentos
|
|
10
|
+
// - recibir sobres de otras estafetas (/inbound), verificar la cadena de firmas y aplicar política
|
|
11
|
+
// - guardar cada sobre en el buzón del destinatario hasta que el agente lo confirme (ack)
|
|
12
|
+
// - avisar por webhook si el agente registró uno (push); si no, el agente hace poll
|
|
13
|
+
// - entregar a libro@ los sobres de operación del Libro y repartir los recibos resultantes
|
|
14
|
+
// - cobrar estampillas en buzones con política `stamp`
|
|
15
|
+
// - opcionalmente, operar un índice federado de agentes (urn:nyx5:ext:indice)
|
|
16
|
+
//
|
|
17
|
+
// RUNTIME: esta clase no conoce node:http ni Workers. El transporte vive en src/plataformas/
|
|
18
|
+
// (node.js y worker.js) y habla con `handleRequest(rx)`: rx = { method, path, query, headers,
|
|
19
|
+
// body, ip } -> { status, body }. El almacenamiento es async (FileStore local, D1Store en el edge).
|
|
20
|
+
|
|
21
|
+
import { FileStore } from '../nucleo/almacen.js';
|
|
22
|
+
import { Resolver, parseAddress } from './resolver.js';
|
|
23
|
+
import { validateEnvelope, applyInboxPolicy, RateLimiter } from './politica.js';
|
|
24
|
+
import { generateSigningKeys, signObject, verifyObject, signBytes, verifyBytes, canonical, uuid, unb64u, sha256hex } from '../nucleo/crypto.js';
|
|
25
|
+
import { Libro, MEDIA, LibroError } from '../libro/libro.js';
|
|
26
|
+
import { APP_HTML } from '../plataformas/app-html.js';
|
|
27
|
+
import { SPEC_HTML, LLMS_TXT } from '../plataformas/spec-html.js';
|
|
28
|
+
import { HOME_HTML } from '../plataformas/home-html.js';
|
|
29
|
+
import { inboundEnvelope, outboundPayload, isEmailAddress } from '../puentes/email.js';
|
|
30
|
+
|
|
31
|
+
const now = () => Date.now();
|
|
32
|
+
const iso = (t = now()) => new Date(t).toISOString();
|
|
33
|
+
const RETRYABLE = new Set([408, 421, 425, 429, 500, 502, 503, 504]);
|
|
34
|
+
|
|
35
|
+
export class Estafeta {
|
|
36
|
+
constructor({
|
|
37
|
+
domain, dataDir, store, adminToken,
|
|
38
|
+
port = 4000, host = '127.0.0.1', publicUrl,
|
|
39
|
+
hosts = {}, fetchImpl = globalThis.fetch,
|
|
40
|
+
policy = {}, retry = {}, workerIntervalMs = 1000, libro = {},
|
|
41
|
+
index = {}, email = {},
|
|
42
|
+
extensions = null,
|
|
43
|
+
log = (...a) => console.log(`[estafeta ${domain}]`, ...a),
|
|
44
|
+
}) {
|
|
45
|
+
if (!domain || !adminToken || (!dataDir && !store)) throw new Error('domain, adminToken y (dataDir o store) son obligatorios');
|
|
46
|
+
this.domain = domain.toLowerCase();
|
|
47
|
+
this.port = port; this.host = host;
|
|
48
|
+
this.publicUrl = (publicUrl || `http://${host}:${port}`).replace(/\/$/, '');
|
|
49
|
+
this.authHost = new URL(this.publicUrl).host;
|
|
50
|
+
this.adminToken = adminToken;
|
|
51
|
+
this.fetch = (...a) => fetchImpl(...a); // envuelto: workerd exige fetch con this=globalThis
|
|
52
|
+
this.log = log;
|
|
53
|
+
// registration: 'admin' (solo la casa inscribe) | 'invite' (código emitido por la casa) | 'open' (cualquiera, con prueba de posesión de clave)
|
|
54
|
+
this.policy = { inbound: 'verified', max_bytes: 1_048_576, rate_per_minute: 120, registration: 'admin', registrations_per_minute: 10, ...policy };
|
|
55
|
+
this.retry = { baseMs: 1000, maxMs: 60_000, giveUpMs: 3 * 24 * 3600 * 1000, ...retry };
|
|
56
|
+
this.workerIntervalMs = workerIntervalMs;
|
|
57
|
+
this.index = { enabled: false, crawlMinutes: 15, maxHouses: 500, ...index };
|
|
58
|
+
// Puente de correo: entrada siempre disponible si la casa la enciende; salida solo si hay proveedor.
|
|
59
|
+
// `email.provider` es una función async(payload) (ver src/puentes/email.js); sin ella, la salida queda pendiente.
|
|
60
|
+
this.email = { enabled: !!(email.enabled || email.provider), provider: email.provider || null };
|
|
61
|
+
this.extensions = extensions || [
|
|
62
|
+
'urn:nyx5:ext:mcp', 'urn:nyx5:ext:a2a', 'urn:nyx5:ext:libro',
|
|
63
|
+
...(this.index.enabled ? ['urn:nyx5:ext:indice'] : []),
|
|
64
|
+
...(this.email.enabled ? ['urn:nyx5:ext:email'] : []),
|
|
65
|
+
];
|
|
66
|
+
this.libroOpts = libro;
|
|
67
|
+
this.hostsOverride = hosts;
|
|
68
|
+
|
|
69
|
+
this.store = store || new FileStore(dataDir);
|
|
70
|
+
this.rate = new RateLimiter({ perMinute: this.policy.rate_per_minute });
|
|
71
|
+
this.regRate = new RateLimiter({ perMinute: this.policy.registrations_per_minute });
|
|
72
|
+
this._ready = null;
|
|
73
|
+
this._domainCardCache = null; // { value, until }
|
|
74
|
+
this._pushes = []; // avisos por webhook en vuelo (los espera flushPushes)
|
|
75
|
+
this._lastCrawl = 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Inicialización perezosa e idempotente (los adaptadores y cada request la esperan).
|
|
79
|
+
async init() {
|
|
80
|
+
if (!this._ready) this._ready = this._init();
|
|
81
|
+
return this._ready;
|
|
82
|
+
}
|
|
83
|
+
async _init() {
|
|
84
|
+
this.keys = await this._loadOrCreateDomainKeys();
|
|
85
|
+
this.resolver = new Resolver({
|
|
86
|
+
hosts: { [this.domain]: { url: this.publicUrl }, ...this.hostsOverride },
|
|
87
|
+
pins: await this.store.getPins(),
|
|
88
|
+
fetchImpl: this.fetch,
|
|
89
|
+
onPin: (domain, kid) => this.store.putPin(domain, kid),
|
|
90
|
+
// Resolución local de la propia casa: sin esto, verificar a un vendedor de casa exige un
|
|
91
|
+
// fetch del Worker a su propio dominio público, que Cloudflare corta (522) y deja la
|
|
92
|
+
// operación reintentando para siempre.
|
|
93
|
+
self: { domain: this.domain, estafeta: this.publicUrl, domainCard: () => this.domainCard(), agentCard: (local) => this.agentCard(local) },
|
|
94
|
+
});
|
|
95
|
+
this.libro = new Libro({ domain: this.domain, store: this.store, keys: this.keys, resolver: this.resolver, log: this.log, ...this.libroOpts });
|
|
96
|
+
await this._ensureSystemAgents();
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------- identidad del dominio ----------
|
|
101
|
+
async _loadOrCreateDomainKeys() {
|
|
102
|
+
let rec = await this.store.getDomain();
|
|
103
|
+
if (!rec) {
|
|
104
|
+
const k = generateSigningKeys();
|
|
105
|
+
rec = { domain: this.domain, keys: [{ ...k, created: iso() }], created: iso() };
|
|
106
|
+
// Si otro proceso/isolate llegó primero, sus claves mandan (putDomainIfAbsent falla cerrado).
|
|
107
|
+
const won = await (this.store.putDomainIfAbsent ? this.store.putDomainIfAbsent(rec) : (this.store.putDomain(rec), true));
|
|
108
|
+
if (!won) rec = await this.store.getDomain();
|
|
109
|
+
}
|
|
110
|
+
return rec.keys[0];
|
|
111
|
+
}
|
|
112
|
+
async _ensureSystemAgents() {
|
|
113
|
+
// Agentes de sistema, firman con la clave del dominio:
|
|
114
|
+
// postmaster@ -> avisos de entrega y rebotes libro@ -> operaciones y recibos del Libro
|
|
115
|
+
if (!await this.store.getAgent('postmaster')) await this.registerAgent({ local: 'postmaster', sig: this.keys.sig, capabilities: { accepts: [] }, inbox: { policy: 'allowlist', allowlist: [] } });
|
|
116
|
+
if (!await this.store.getAgent('libro')) await this.registerAgent({ local: 'libro', sig: this.keys.sig, capabilities: { accepts: [MEDIA.op], libro: { fee_bps: this.libro.feeBps, ops: this.libro.ops } }, inbox: { policy: 'open' } });
|
|
117
|
+
}
|
|
118
|
+
isSystem(local) { return local === 'postmaster' || local === 'libro'; }
|
|
119
|
+
static RESERVED = new Set(['postmaster', 'libro', 'casa', 'admin', 'root', 'abuse', 'security', 'hostmaster', 'noreply', 'no-reply', 'support', 'estafeta', 'nyx5', 'indice']);
|
|
120
|
+
|
|
121
|
+
// ---------- servicio de registro ----------
|
|
122
|
+
// Invitaciones: la casa emite códigos con usos y vencimiento; un agente los presenta al inscribirse.
|
|
123
|
+
async createInvite({ uses = 1, expires = null, note = null, welcome = null } = {}) {
|
|
124
|
+
const inv = { code: uuid().replace(/-/g, '').slice(0, 20), uses, used: 0, expires, note, welcome, created: iso(), by: 'admin' };
|
|
125
|
+
await this.store.putInvite(inv);
|
|
126
|
+
return inv;
|
|
127
|
+
}
|
|
128
|
+
// El consumo es atómico: leer-comprobar-escribir dejaba que un solo código de un uso sirviera
|
|
129
|
+
// N veces en paralelo, multiplicando el regalo de bienvenida (emisión no autorizada).
|
|
130
|
+
async _consumeInvite(code) {
|
|
131
|
+
const inv = code && await this.store.getInvite(String(code));
|
|
132
|
+
if (!inv) throw Object.assign(new Error('invitación inexistente'), { status: 403 });
|
|
133
|
+
if (inv.expires && Date.parse(inv.expires) < now()) throw Object.assign(new Error('invitación vencida'), { status: 403 });
|
|
134
|
+
const usada = await this.store.consumeInvite(String(code), iso());
|
|
135
|
+
if (!usada) throw Object.assign(new Error('invitación agotada'), { status: 403 });
|
|
136
|
+
return usada;
|
|
137
|
+
}
|
|
138
|
+
// Directorio público de la casa: tarjetas sin datos privados, con filtros por capacidad.
|
|
139
|
+
async directory({ capability, accepts, q, limit = 50, offset = 0 } = {}) {
|
|
140
|
+
limit = Number.isInteger(Number(limit)) && Number(limit) > 0 ? Math.min(Number(limit), 200) : 50;
|
|
141
|
+
offset = Number.isInteger(Number(offset)) && Number(offset) >= 0 ? Number(offset) : 0;
|
|
142
|
+
const locals = (await this.store.listAgents()).sort();
|
|
143
|
+
const cards = [];
|
|
144
|
+
for (const l of locals) { const c = await this.agentCard(l); if (c) cards.push(c); }
|
|
145
|
+
// Opt-in: nadie aparece en el directorio (ni, por lo tanto, en el índice federado que lo rastrea)
|
|
146
|
+
// sin haberlo pedido con `capabilities.listed: true`. El default es no figurar. El lookup directo
|
|
147
|
+
// por dirección (`/agents/:local`) sigue disponible: no listar no es esconder a quien ya te conoce.
|
|
148
|
+
let out = cards.filter((c) => c.capabilities?.listed === true);
|
|
149
|
+
if (capability) out = out.filter((c) => c.capabilities?.[capability]);
|
|
150
|
+
if (accepts) out = out.filter((c) => c.capabilities?.accepts?.includes(accepts));
|
|
151
|
+
if (q) { const needle = String(q).toLowerCase(); out = out.filter((c) => c.address.includes(needle) || JSON.stringify(c.capabilities).toLowerCase().includes(needle)); }
|
|
152
|
+
return { total: out.length, offset, agents: out.slice(offset, offset + limit).map(({ delegation, ...c }) => ({ ...c, delegated_by: delegation?.by })) };
|
|
153
|
+
}
|
|
154
|
+
async domainCard() {
|
|
155
|
+
// La tarjeta se re-firma solo cuando expira el caché (firmar en cada GET es CPU regalada).
|
|
156
|
+
if (this._domainCardCache && this._domainCardCache.until > now()) return this._domainCardCache.value;
|
|
157
|
+
const rec = await this.store.getDomain();
|
|
158
|
+
const card = signObject({
|
|
159
|
+
nyx5: '1', domain: this.domain, estafeta: this.publicUrl,
|
|
160
|
+
keys: rec.keys.map((k) => ({ sig: k.sig, created: k.created })),
|
|
161
|
+
policy: { inbound: this.policy.inbound, max_bytes: this.policy.max_bytes, registration: this.policy.registration, ...(this.policy.outbound ? { outbound: this.policy.outbound } : {}) },
|
|
162
|
+
extensions: this.extensions,
|
|
163
|
+
issued: iso(),
|
|
164
|
+
}, this.keys);
|
|
165
|
+
this._domainCardCache = { value: card, until: now() + 60_000 };
|
|
166
|
+
return card;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------- agentes ----------
|
|
170
|
+
async registerAgent({ local, sig, enc = null, capabilities = {}, inbox = { policy: 'open' }, webhook = null, valid_until = null, delegation = null, welcome = null }) {
|
|
171
|
+
local = String(local).toLowerCase();
|
|
172
|
+
const address = `${local}@${this.domain}`;
|
|
173
|
+
parseAddress(address);
|
|
174
|
+
if (Estafeta.RESERVED.has(local) && !this.isSystem(local)) throw Object.assign(new Error(`nombre reservado: ${local}`), { status: 409 });
|
|
175
|
+
if (delegation) {
|
|
176
|
+
// Tarjeta delegada: el agente padre firma { by, address, sig, scope, valid_until }; el dominio la certifica igual.
|
|
177
|
+
const { local: parentLocal, domain: parentDomain } = parseAddress(delegation.by);
|
|
178
|
+
if (parentDomain !== this.domain || !local.endsWith(`.${parentLocal}`)) throw Object.assign(new Error(`un agente delegado de ${delegation.by} debe llamarse <nombre>.${parentLocal}@${this.domain}`), { status: 400 });
|
|
179
|
+
const parent = await this.store.getAgent(parentLocal);
|
|
180
|
+
if (!parent) throw Object.assign(new Error('agente padre inexistente'), { status: 404 });
|
|
181
|
+
if (delegation.address !== address || delegation.sig !== sig || !verifyObject(delegation, parent.sig)) throw Object.assign(new Error('delegación inválida: debe estar firmada por el padre y coincidir con la tarjeta'), { status: 403 });
|
|
182
|
+
// Un delegado nunca tiene más ámbito que su padre (invariante 6). Lo que el hijo no declara
|
|
183
|
+
// lo HEREDA: declarar menos no puede ser una forma de escapar del tope de la cadena.
|
|
184
|
+
const ps = parent.delegation?.scope;
|
|
185
|
+
if (ps) {
|
|
186
|
+
const hs = delegation.scope || {};
|
|
187
|
+
if (ps.cap != null && (hs.cap == null || hs.cap > ps.cap)) {
|
|
188
|
+
if (hs.cap != null) throw Object.assign(new Error(`el delegado no puede tener más tope que su padre (${ps.cap})`), { status: 403 });
|
|
189
|
+
hs.cap = ps.cap; // sin tope declarado: hereda el del padre
|
|
190
|
+
}
|
|
191
|
+
for (const campo of ['types', 'to_domains']) {
|
|
192
|
+
if (ps[campo]?.length) {
|
|
193
|
+
if (!hs[campo]?.length) hs[campo] = [...ps[campo]];
|
|
194
|
+
else if (!hs[campo].every((x) => ps[campo].includes(x))) throw Object.assign(new Error(`el delegado no puede ampliar ${campo} respecto de su padre`), { status: 403 });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
delegation = { ...delegation, scope: hs };
|
|
198
|
+
}
|
|
199
|
+
valid_until = valid_until || delegation.valid_until || null;
|
|
200
|
+
}
|
|
201
|
+
const prev = await this.store.getAgent(local);
|
|
202
|
+
const previous = [];
|
|
203
|
+
if (prev && prev.sig !== sig) previous.push({ sig: prev.sig, until: iso(now() + 7 * 24 * 3600 * 1000) }, ...(prev.previous || []));
|
|
204
|
+
const card = signObject({
|
|
205
|
+
nyx5: '1', address, sig, enc,
|
|
206
|
+
capabilities: { accepts: ['text/plain', 'application/json'], ...capabilities },
|
|
207
|
+
inbox: { policy: 'open', ...inbox },
|
|
208
|
+
valid_from: iso(), valid_until, previous: previous.slice(0, 3),
|
|
209
|
+
delegation: delegation || undefined,
|
|
210
|
+
}, this.keys, 'certification');
|
|
211
|
+
// Alta atómica cuando el nombre es nuevo: dos altas concurrentes del mismo nombre no pueden
|
|
212
|
+
// pisarse la clave (el perdedor cree tener el nombre y su correo iría al otro), ni cobrar el
|
|
213
|
+
// regalo de bienvenida dos veces por la misma dirección.
|
|
214
|
+
let creado = true;
|
|
215
|
+
if (!prev) {
|
|
216
|
+
creado = await (this.store.putAgentIfAbsent
|
|
217
|
+
? this.store.putAgentIfAbsent(local, { ...card, webhook })
|
|
218
|
+
: (this.store.putAgent(local, { ...card, webhook }), true));
|
|
219
|
+
if (!creado) throw Object.assign(new Error('ese nombre acaba de ser tomado; solo su dueño o la casa pueden actualizarlo'), { status: 409 });
|
|
220
|
+
} else {
|
|
221
|
+
await this.store.putAgent(local, { ...card, webhook });
|
|
222
|
+
}
|
|
223
|
+
const gift = welcome ?? this.libro.welcome;
|
|
224
|
+
if (creado && !prev && !this.isSystem(local) && !delegation && gift > 0) await this.libro.topup(address, gift, 'regalo de bienvenida', { agent: address });
|
|
225
|
+
return card;
|
|
226
|
+
}
|
|
227
|
+
// El nombre local viaja como clave al almacenamiento: se valida SIEMPRE aquí, no se confía en
|
|
228
|
+
// que el store lo sanee. Sin esto, `GET /agents/..%2Fdomain` leía el archivo del dominio y
|
|
229
|
+
// devolvía la clave PRIVADA de la casa (compromiso total).
|
|
230
|
+
static LOCAL = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
231
|
+
static validLocal(local) { return typeof local === 'string' && Estafeta.LOCAL.test(local); }
|
|
232
|
+
async agentCard(local) {
|
|
233
|
+
if (!Estafeta.validLocal(local)) return null;
|
|
234
|
+
const rec = await this.store.getAgent(local);
|
|
235
|
+
if (!rec) return null;
|
|
236
|
+
const { webhook, ...card } = rec;
|
|
237
|
+
return card;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------- autenticación de agentes propios ----------
|
|
241
|
+
// Authorization: Nyx5 <b64u(canonical({address,ts,nonce,method,path,host}))>.<firma Ed25519>
|
|
242
|
+
// `host` amarra el token a ESTA estafeta: el mismo header no sirve contra otra casa.
|
|
243
|
+
// Con allowForeign, un agente de otra casa también puede autenticarse: su clave se obtiene por el
|
|
244
|
+
// resolver (cadena DNS -> dominio -> agente). Así un foráneo consulta su cuenta en este Libro sin login.
|
|
245
|
+
async _authenticate(rx, path, { allowForeign = false } = {}) {
|
|
246
|
+
const h = rx.headers.authorization || '';
|
|
247
|
+
const m = /^Nyx5\s+([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/.exec(h);
|
|
248
|
+
if (!m) throw Object.assign(new Error('falta Authorization: Nyx5 <token>.<firma>'), { status: 401 });
|
|
249
|
+
let claims;
|
|
250
|
+
try { claims = JSON.parse(unb64u(m[1]).toString()); } catch { throw Object.assign(new Error('token ilegible'), { status: 401 }); }
|
|
251
|
+
const { local, domain } = parseAddress(claims.address);
|
|
252
|
+
let rec;
|
|
253
|
+
if (domain === this.domain) {
|
|
254
|
+
rec = await this.store.getAgent(local);
|
|
255
|
+
if (!rec) throw Object.assign(new Error('agente no registrado'), { status: 401 });
|
|
256
|
+
} else {
|
|
257
|
+
if (!allowForeign) throw Object.assign(new Error('el agente no pertenece a este dominio'), { status: 401 });
|
|
258
|
+
try { rec = await this.resolver.agentCard(claims.address); } catch (e) { throw Object.assign(new Error(`agente foráneo no verificable: ${e.message}`), { status: 401 }); }
|
|
259
|
+
}
|
|
260
|
+
if (Math.abs(now() - Date.parse(claims.ts)) > 300_000) throw Object.assign(new Error('token vencido (ventana de 5 min)'), { status: 401 });
|
|
261
|
+
if (claims.method !== rx.method || claims.path !== path) throw Object.assign(new Error('token no corresponde a esta petición'), { status: 401 });
|
|
262
|
+
if (claims.host !== this.authHost) throw Object.assign(new Error(`token emitido para otra casa (host ${claims.host || 'ausente'}, se espera ${this.authHost})`), { status: 401 });
|
|
263
|
+
if (!verifyBytes(canonical(claims), m[2], rec.sig)) throw Object.assign(new Error('firma de token inválida'), { status: 401 });
|
|
264
|
+
if (!await this.store.useNonce(`${claims.address}:${claims.nonce}`, now())) throw Object.assign(new Error('nonce reutilizado'), { status: 401 });
|
|
265
|
+
return { local, address: claims.address, record: rec };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------- salida: el agente entrega un sobre a su estafeta ----------
|
|
269
|
+
async outbound(env, submitter) {
|
|
270
|
+
const v = validateEnvelope(env, { maxBytes: this.policy.max_bytes });
|
|
271
|
+
if (!v.ok) return v;
|
|
272
|
+
if (env.from !== submitter.address) return { ok: false, code: 403, reason: 'from no coincide con el agente autenticado' };
|
|
273
|
+
if (env.signature.kid !== submitter.record.sig || !verifyObject(env, submitter.record.sig)) return { ok: false, code: 403, reason: 'firma del sobre inválida' };
|
|
274
|
+
const scope = submitter.record.delegation?.scope;
|
|
275
|
+
if (scope?.types?.length && !scope.types.includes(env.type)) return { ok: false, code: 403, reason: `agente delegado: solo puede enviar type ${scope.types.join('|')}` };
|
|
276
|
+
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(', ')}` };
|
|
277
|
+
|
|
278
|
+
const byDomain = new Map();
|
|
279
|
+
for (const to of env.to) { const { domain } = parseAddress(to); byDomain.set(domain, [...(byDomain.get(domain) || []), to]); }
|
|
280
|
+
const jobs = [];
|
|
281
|
+
for (const [domain, to] of byDomain) {
|
|
282
|
+
// Entrega diferida: si el sobre trae deliver_after futuro, el job espera en la cola hasta esa
|
|
283
|
+
// fecha (claimDueJobs no lo reclama antes). En el pasado o ausente, se entrega de inmediato.
|
|
284
|
+
const first = (env.deliver_after && Date.parse(env.deliver_after) > now()) ? iso(Date.parse(env.deliver_after)) : iso();
|
|
285
|
+
const job = { id: uuid(), envelope: env, domain, to, from_local: submitter.local, attempts: 0, next_attempt: first, created: iso(), status: 'queued', log: [] };
|
|
286
|
+
await this.store.enqueue(job);
|
|
287
|
+
await this._outbox(job);
|
|
288
|
+
jobs.push({ job: job.id, domain, to });
|
|
289
|
+
}
|
|
290
|
+
return { ok: true, code: 202, id: env.id, jobs };
|
|
291
|
+
}
|
|
292
|
+
async _outbox(job, extra = {}) {
|
|
293
|
+
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, ...extra });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------- trabajador de entrega (store-and-forward) ----------
|
|
297
|
+
// El reclamo es exclusivo (claimDueJobs): dos ticks concurrentes (cron solapado, multi-isolate)
|
|
298
|
+
// no toman el mismo trabajo. Un trabajo reclamado y no resuelto vuelve a ser reclamable al minuto.
|
|
299
|
+
async tick() {
|
|
300
|
+
await this.init();
|
|
301
|
+
const due = await this.store.claimDueJobs(now(), 20);
|
|
302
|
+
for (const job of due) await this._deliver(job);
|
|
303
|
+
await this.store.pruneNonces?.(now() - 600_000);
|
|
304
|
+
if (this.index.enabled) await this._indexCrawlIfDue();
|
|
305
|
+
await this.flushPushes();
|
|
306
|
+
}
|
|
307
|
+
async _deliver(job) {
|
|
308
|
+
// Un sobre diferido pudo vencer mientras esperaba: no desaparece en silencio, rebota al remitente.
|
|
309
|
+
if (job.envelope.expires && Date.parse(job.envelope.expires) < now()) {
|
|
310
|
+
for (const to of job.to) await this._bounce(job, to, 'el sobre venció esperando en la cola');
|
|
311
|
+
job.status = 'failed'; await this.store.removeJob(job.id); await this._outbox(job); return;
|
|
312
|
+
}
|
|
313
|
+
job.attempts += 1;
|
|
314
|
+
let outcome;
|
|
315
|
+
if (job.domain === this.domain) {
|
|
316
|
+
// Entrega local: sin red, directo a inbound (misma verificación, cero riesgo de auto-fetch).
|
|
317
|
+
const relay = `nyx51 domain=${this.domain}; kid=${this.keys.sig}; sig=${signBytes(`relay:${job.envelope.id}:${this.domain}`, this.keys)}`;
|
|
318
|
+
try {
|
|
319
|
+
const r = await this.inbound(job.envelope, relay);
|
|
320
|
+
outcome = { status: r.code, body: r };
|
|
321
|
+
} catch (e) {
|
|
322
|
+
outcome = { status: 500, error: e.message };
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
try {
|
|
326
|
+
const dc = await this.resolver.domainCard(job.domain);
|
|
327
|
+
const relay = `nyx51 domain=${this.domain}; kid=${this.keys.sig}; sig=${signBytes(`relay:${job.envelope.id}:${job.domain}`, this.keys)}`;
|
|
328
|
+
const res = await this.fetch(`${dc._estafeta}/inbound`, {
|
|
329
|
+
method: 'POST', headers: { 'content-type': 'application/json', 'x-nyx5-relay': relay },
|
|
330
|
+
body: JSON.stringify(job.envelope), signal: AbortSignal.timeout(10_000),
|
|
331
|
+
});
|
|
332
|
+
const body = await res.json().catch(() => null);
|
|
333
|
+
// Un 200/202 sin cuerpo interpretable NO es una entrega: es un intermediario contestando
|
|
334
|
+
// por la estafeta. Se trata como transitorio; jamás se declara entregado sin evidencia.
|
|
335
|
+
if ((res.status === 200 || res.status === 202) && (!body || (!Array.isArray(body.accepted) && !Array.isArray(body.rejected)))) {
|
|
336
|
+
outcome = { status: 502, error: 'respuesta sin forma de estafeta (¿intermediario?)' };
|
|
337
|
+
} else {
|
|
338
|
+
outcome = { status: res.status, body: body || {} };
|
|
339
|
+
}
|
|
340
|
+
} catch (e) {
|
|
341
|
+
outcome = { status: 0, error: e.message };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
job.log.push({ at: iso(), attempt: job.attempts, status: outcome.status, detail: outcome.error || outcome.body?.reason || outcome.body?.rejected || 'ok' });
|
|
345
|
+
|
|
346
|
+
// Clasificación por destinatario
|
|
347
|
+
const delivered = [], failed = [], retry = [];
|
|
348
|
+
if (outcome.status === 200 || outcome.status === 202) {
|
|
349
|
+
for (const a of outcome.body.accepted || []) delivered.push(a);
|
|
350
|
+
for (const r of outcome.body.rejected || []) (RETRYABLE.has(r.code) ? retry : failed).push(r);
|
|
351
|
+
// Destinatarios que la respuesta no menciona: transitorio, no éxito silencioso.
|
|
352
|
+
const mentioned = new Set([...delivered, ...(outcome.body.rejected || []).map((r) => r.to)]);
|
|
353
|
+
for (const to of job.to) if (!mentioned.has(to)) retry.push({ to, code: outcome.status, reason: 'destinatario sin veredicto en la respuesta' });
|
|
354
|
+
} else if (outcome.status === 0 || RETRYABLE.has(outcome.status)) {
|
|
355
|
+
retry.push(...job.to.map((to) => ({ to, code: outcome.status, reason: outcome.error || outcome.body?.reason })));
|
|
356
|
+
} else {
|
|
357
|
+
failed.push(...job.to.map((to) => ({ to, code: outcome.status, reason: outcome.body?.reason || 'rechazo permanente' })));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
for (const f of failed) await this._bounce(job, f.to, `rechazado (${f.code}): ${f.reason}`);
|
|
361
|
+
if (delivered.length && job.envelope.receipt === 'delivered') for (const to of delivered) await this._notify(job, to, 'delivered', 'entregado en la estafeta destino');
|
|
362
|
+
|
|
363
|
+
if (retry.length) {
|
|
364
|
+
const age = now() - Date.parse(job.created);
|
|
365
|
+
if (age > this.retry.giveUpMs) {
|
|
366
|
+
for (const r of retry) await this._bounce(job, r.to, `sin respuesta tras ${job.attempts} intentos: ${r.reason}`);
|
|
367
|
+
job.status = 'failed'; await this.store.removeJob(job.id); await this._outbox(job); return;
|
|
368
|
+
}
|
|
369
|
+
const backoff = Math.min(this.retry.baseMs * 2 ** (job.attempts - 1), this.retry.maxMs) * (0.8 + Math.random() * 0.4);
|
|
370
|
+
job.to = retry.map((r) => r.to); job.status = 'retrying'; job.next_attempt = iso(now() + backoff); job.claimed_until = 0;
|
|
371
|
+
await this.store.updateJob(job); await this._outbox(job);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
job.status = failed.length && !delivered.length ? 'failed' : 'delivered';
|
|
375
|
+
await this.store.removeJob(job.id); await this._outbox(job, { delivered: delivered.length, failed: failed.length });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Sobres emitidos por los agentes de sistema (postmaster@, libro@), firmados con la clave del dominio.
|
|
379
|
+
// Destinatarios locales: directo al buzón. Remotos: por la cola, como cualquier envío.
|
|
380
|
+
async _systemSend(fromLocal, to, { type = 'receipt', content, thread = null, in_reply_to = null, deliverAfter = null }) {
|
|
381
|
+
const env = signObject({ nyx5: '1', id: uuid(), from: `${fromLocal}@${this.domain}`, to, created: iso(), expires: null, deliver_after: deliverAfter ?? undefined, thread, in_reply_to, type, content }, this.keys);
|
|
382
|
+
// Un aviso diferido (deliver_after futuro) SIEMPRE va por la cola, aunque el destino sea local:
|
|
383
|
+
// la cola es lo único que respeta la fecha. Sin fecha, el destinatario local recibe al instante.
|
|
384
|
+
const diferido = deliverAfter && Date.parse(deliverAfter) > now();
|
|
385
|
+
const byDomain = new Map();
|
|
386
|
+
for (const t of to) {
|
|
387
|
+
const { local, domain } = parseAddress(t);
|
|
388
|
+
if (domain === this.domain && !diferido) {
|
|
389
|
+
if (await this.store.getAgent(local)) { await this.store.putMail(local, env, { via: fromLocal, from_verified: true }); this._push(local, env); }
|
|
390
|
+
else this.log(`recibo de ${fromLocal}@ a ${t} descartado: agente inexistente`);
|
|
391
|
+
} else byDomain.set(domain, [...(byDomain.get(domain) || []), t]);
|
|
392
|
+
}
|
|
393
|
+
for (const [domain, dest] of byDomain) await this.store.enqueue({ id: uuid(), envelope: env, domain, to: dest, from_local: fromLocal, attempts: 0, next_attempt: diferido ? iso(Date.parse(deliverAfter)) : iso(), created: iso(), status: 'queued', log: [] });
|
|
394
|
+
return env;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Avisos del postmaster al remitente (rebotes y acuses de entrega). Llevan el hash del sobre original.
|
|
398
|
+
async _notify(job, to, status, reason) {
|
|
399
|
+
await this._systemSend('postmaster', [job.envelope.from], {
|
|
400
|
+
in_reply_to: job.envelope.id, thread: job.envelope.thread || job.envelope.id,
|
|
401
|
+
content: { media: 'application/json', body: { of: job.envelope.id, sha256: sha256hex(canonical(job.envelope)), to, status, reason } },
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
async _bounce(job, to, reason) { this.log(`rebote ${job.envelope.id} -> ${to}: ${reason}`); await this._notify(job, to, 'failed', reason); }
|
|
405
|
+
|
|
406
|
+
// ---------- entrada: otra estafeta nos entrega un sobre ----------
|
|
407
|
+
async inbound(env, relayHeader) {
|
|
408
|
+
const v = validateEnvelope(env, { maxBytes: this.policy.max_bytes });
|
|
409
|
+
if (!v.ok) return v;
|
|
410
|
+
if (env.expires && Date.parse(env.expires) < now()) return { ok: false, code: 410, reason: 'sobre vencido' };
|
|
411
|
+
|
|
412
|
+
// Dedupe POR DESTINATARIO: lo ya aceptado no se re-procesa ni se re-cobra; lo pendiente
|
|
413
|
+
// (una entrega parcial que la estafeta emisora reintenta) SÍ se procesa. La respuesta de un
|
|
414
|
+
// duplicado dice la verdad: solo lo que de verdad se aceptó.
|
|
415
|
+
const seen = await this.store.getSeen(env.id);
|
|
416
|
+
const yaAceptados = new Set(seen?.accepted || []);
|
|
417
|
+
|
|
418
|
+
const locals = env.to.filter((t) => parseAddress(t).domain === this.domain);
|
|
419
|
+
if (!locals.length) return { ok: false, code: 404, reason: 'ningún destinatario pertenece a este dominio' };
|
|
420
|
+
const pendientes = locals.filter((t) => !yaAceptados.has(t));
|
|
421
|
+
if (!pendientes.length) return { ok: true, code: 200, duplicate: true, accepted: [...yaAceptados], rejected: [] };
|
|
422
|
+
|
|
423
|
+
// Cadena de confianza: dominio emisor -> agente emisor -> firma del sobre
|
|
424
|
+
let senderCard;
|
|
425
|
+
try { senderCard = await this.resolver.agentCardForKid(env.from, env.signature.kid); }
|
|
426
|
+
catch (e) { return { ok: false, code: e.permanent ? 403 : 421, reason: `no se pudo verificar al remitente: ${e.message}` }; }
|
|
427
|
+
const validKids = Resolver.acceptedKids(senderCard);
|
|
428
|
+
if (!validKids.includes(env.signature.kid) || !verifyObject(env, env.signature.kid)) return { ok: false, code: 403, reason: 'la firma del sobre no corresponde al remitente' };
|
|
429
|
+
|
|
430
|
+
// Firma de relay (segunda capa: la estafeta emisora también firma, análogo a SPF/DKIM)
|
|
431
|
+
const { domain: fromDomain } = parseAddress(env.from);
|
|
432
|
+
let relayVerified = false;
|
|
433
|
+
if (relayHeader) {
|
|
434
|
+
const r = Object.fromEntries(relayHeader.replace(/^nyx51\s*/, '').split(';').map((p) => p.trim().split('=').map((x) => x.trim())).filter((p) => p[0]));
|
|
435
|
+
relayVerified = r.domain === fromDomain && senderCard._domain.keys.some((k) => k.sig === r.kid) && verifyBytes(`relay:${env.id}:${this.domain}`, r.sig, r.kid);
|
|
436
|
+
}
|
|
437
|
+
if (this.policy.require_relay && !relayVerified) return { ok: false, code: 403, reason: 'este dominio exige firma de relay válida' };
|
|
438
|
+
if (!this.rate.allow(fromDomain)) return { ok: false, code: 429, reason: 'límite de tasa del dominio emisor' };
|
|
439
|
+
|
|
440
|
+
const accepted = [], rejected = [], results = {};
|
|
441
|
+
const mails = [], libroBundles = [];
|
|
442
|
+
const recibosPendientes = [];
|
|
443
|
+
const avisosPendientes = [];
|
|
444
|
+
let stampUsed = false;
|
|
445
|
+
for (const to of pendientes) {
|
|
446
|
+
const { local } = parseAddress(to);
|
|
447
|
+
const rec = await this.store.getAgent(local);
|
|
448
|
+
if (!rec) { rejected.push({ to, code: 404, reason: 'agente inexistente' }); continue; }
|
|
449
|
+
const p = applyInboxPolicy(env, rec, senderCard._domain);
|
|
450
|
+
if (!p.ok) { rejected.push({ to, ...p, ok: undefined }); continue; }
|
|
451
|
+
try {
|
|
452
|
+
if (local === 'libro') {
|
|
453
|
+
// Operación del Libro: se ejecuta (idempotente por id de sobre), no se almacena;
|
|
454
|
+
// los recibos salen firmados por la casa después del commit del sobre.
|
|
455
|
+
const r = await this.libro.handle(env, senderCard);
|
|
456
|
+
if (!r.ok) { rejected.push({ to, code: r.code, reason: r.reason }); continue; }
|
|
457
|
+
for (const rc of r.recibos || []) recibosPendientes.push(rc);
|
|
458
|
+
for (const av of r.avisos || []) avisosPendientes.push(av);
|
|
459
|
+
results[to] = r.result;
|
|
460
|
+
} else if (p.stamp) {
|
|
461
|
+
// Una estampilla paga UN buzón: el sobre declara un monto, no un monto por destinatario.
|
|
462
|
+
if (stampUsed) { rejected.push({ to, code: 402, reason: 'la estampilla del sobre ya se usó en otro destinatario' }); continue; }
|
|
463
|
+
const { asiento, bundle } = await this.libro.stamp(env, to, p.stamp.price);
|
|
464
|
+
stampUsed = true;
|
|
465
|
+
libroBundles.push(bundle);
|
|
466
|
+
mails.push({ local, envelope: env, meta: { from_verified: true, relay_verified: relayVerified, sender_kid: env.signature.kid, stamp: asiento.id } });
|
|
467
|
+
} else if (p.vouch) {
|
|
468
|
+
// Aval con fianza: la política vio un avalador de la allowlist; aquí se comprueba la fianza
|
|
469
|
+
// contra el Libro de ESTA casa. Sin fianza válida (activa, para este remitente, con el
|
|
470
|
+
// receptor como beneficiario y verificador), no entra. Avalar no es gratis.
|
|
471
|
+
const b = await this.libro.getContract(p.vouch.bond);
|
|
472
|
+
const malo = !b ? 'la fianza del aval no existe'
|
|
473
|
+
: b.kind !== 'bond' ? 'el contrato del aval no es una fianza'
|
|
474
|
+
: b.state !== 'posted' ? `la fianza del aval está ${b.state}, no activa`
|
|
475
|
+
: b.seller !== p.vouch.voucher ? 'la fianza no la puso el avalador declarado'
|
|
476
|
+
: b.vouchee !== p.vouch.vouchee ? 'la fianza no avala a este remitente'
|
|
477
|
+
: b.beneficiary !== p.vouch.beneficiary ? 'la fianza no tiene al receptor como beneficiario'
|
|
478
|
+
: b.verifier !== p.vouch.beneficiary ? 'el receptor no puede ejecutar la fianza (no es su verificador)'
|
|
479
|
+
: null;
|
|
480
|
+
if (malo) { rejected.push({ to, code: 403, reason: `aval inválido: ${malo}` }); continue; }
|
|
481
|
+
mails.push({ local, envelope: env, meta: { from_verified: true, relay_verified: relayVerified, sender_kid: env.signature.kid, vouched_by: b.seller, vouch_bond: b.id } });
|
|
482
|
+
} else {
|
|
483
|
+
mails.push({ local, envelope: env, meta: { from_verified: true, relay_verified: relayVerified, sender_kid: env.signature.kid } });
|
|
484
|
+
}
|
|
485
|
+
} catch (e) {
|
|
486
|
+
if (e instanceof LibroError) { rejected.push({ to, code: e.code, reason: e.message }); continue; }
|
|
487
|
+
throw e;
|
|
488
|
+
}
|
|
489
|
+
accepted.push(to);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Un solo commit: buzones + estampillas + dedupe, juntos. En D1 es un batch atómico:
|
|
493
|
+
// una reentrega concurrente no duplica buzón ni cobra la estampilla dos veces (invariante 4).
|
|
494
|
+
const union = [...yaAceptados, ...accepted];
|
|
495
|
+
if (accepted.length) {
|
|
496
|
+
await this.store.inboundCommit({
|
|
497
|
+
seen: { id: env.id, rec: { from: env.from, accepted: union } },
|
|
498
|
+
mails, libro: libroBundles,
|
|
499
|
+
});
|
|
500
|
+
for (const m of mails) this._push(m.local, env);
|
|
501
|
+
}
|
|
502
|
+
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 } });
|
|
503
|
+
// Avisos de plazo: sobres del Libro programados para el futuro (un escrow que llega a su
|
|
504
|
+
// deadline, una fianza que vence). No desaparecen mudos: llegan a las partes en la fecha.
|
|
505
|
+
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 });
|
|
506
|
+
|
|
507
|
+
if (!union.length) return { ok: false, code: rejected[0].code, reason: rejected[0].reason, rejected, accepted: [] };
|
|
508
|
+
return { ok: true, code: 202, accepted: union, rejected, results, ...(seen ? { duplicate: true } : {}) };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Los avisos por webhook se acumulan para que el ciclo (y ctx.waitUntil en el edge) los espere:
|
|
512
|
+
// una promesa suelta la cancela el runtime al cerrar el request y el aviso nunca sale.
|
|
513
|
+
_push(local, env) {
|
|
514
|
+
const pendiente = Promise.resolve(this.store.getAgent(local)).then((rec) => {
|
|
515
|
+
if (!rec?.webhook) return;
|
|
516
|
+
const body = JSON.stringify({ envelope: env });
|
|
517
|
+
const sig = signBytes(`push:${env.id}`, this.keys);
|
|
518
|
+
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) });
|
|
519
|
+
}).catch((e) => this.log(`webhook ${local} falló: ${e.message} / ${e.cause?.message}`));
|
|
520
|
+
this._pushes.push(pendiente);
|
|
521
|
+
return pendiente;
|
|
522
|
+
}
|
|
523
|
+
// Espera los avisos en vuelo y vacía la lista.
|
|
524
|
+
flushPushes() { const p = this._pushes; this._pushes = []; return Promise.allSettled(p); }
|
|
525
|
+
|
|
526
|
+
// ---------- índice federado (opcional) ----------
|
|
527
|
+
// Cualquier casa puede correr un índice: registra casas verificables, rastrea sus directorios
|
|
528
|
+
// públicos y sirve la búsqueda. El índice es una PISTA, no una autoridad: cada tarjeta se
|
|
529
|
+
// verifica igual por la cadena normal (DNS -> dominio -> agente) al momento de usarla.
|
|
530
|
+
async indexAddHouse(domain) {
|
|
531
|
+
domain = String(domain || '').toLowerCase();
|
|
532
|
+
if (!/^[a-z0-9.-]+$/.test(domain)) throw Object.assign(new Error('dominio inválido'), { status: 400 });
|
|
533
|
+
const houses = await this.store.indexListHouses();
|
|
534
|
+
if (houses.length >= this.index.maxHouses && !houses.some((h) => h.domain === domain)) throw Object.assign(new Error('índice lleno'), { status: 507 });
|
|
535
|
+
// La verificación ES la puerta: solo se lista lo que resuelve y firma como casa Nyx5.
|
|
536
|
+
const dc = await this.resolver.domainCard(domain).catch((e) => { throw Object.assign(new Error(`casa no verificable: ${e.message}`), { status: 422 }); });
|
|
537
|
+
const h = { domain, estafeta: dc._estafeta, added: iso(), last_ok: null, fails: 0 };
|
|
538
|
+
await this.store.indexPutHouse(h);
|
|
539
|
+
await this._indexCrawlHouse(h);
|
|
540
|
+
return h;
|
|
541
|
+
}
|
|
542
|
+
async _indexCrawlIfDue() {
|
|
543
|
+
if (now() - this._lastCrawl < this.index.crawlMinutes * 60_000) return;
|
|
544
|
+
this._lastCrawl = now();
|
|
545
|
+
for (const h of await this.store.indexListHouses()) await this._indexCrawlHouse(h).catch((e) => this.log(`índice: ${h.domain} falló: ${e.message}`));
|
|
546
|
+
}
|
|
547
|
+
async _indexCrawlHouse(h) {
|
|
548
|
+
try {
|
|
549
|
+
const dc = await this.resolver.domainCard(h.domain); // re-verifica firma y ancla en cada pasada
|
|
550
|
+
const cards = [];
|
|
551
|
+
// La casa del índice también es una casa: su directorio se lee local. Pedírselo por su
|
|
552
|
+
// propia URL pública no funciona (el Worker no puede llamarse a sí mismo) y además sobra.
|
|
553
|
+
const propia = h.domain === this.domain;
|
|
554
|
+
for (let offset = 0; offset < 2000;) {
|
|
555
|
+
let page;
|
|
556
|
+
if (propia) {
|
|
557
|
+
page = await this.directory({ limit: 200, offset });
|
|
558
|
+
} else {
|
|
559
|
+
const res = await this.fetch(`${dc._estafeta}/agents?limit=200&offset=${offset}`, { signal: AbortSignal.timeout(10_000) });
|
|
560
|
+
if (!res.ok) throw new Error(`GET /agents -> ${res.status}`);
|
|
561
|
+
page = await res.json();
|
|
562
|
+
}
|
|
563
|
+
const batch = page.agents || [];
|
|
564
|
+
// Solo tarjetas cuya certificación firma el dominio: el índice no ingiere lo que no verifica.
|
|
565
|
+
const domainKeys = dc.keys.map((k) => k.sig);
|
|
566
|
+
for (const c of batch) if (domainKeys.includes(c.certification?.kid) && verifyObject(c, c.certification.kid, 'certification')) cards.push({ ...c, _house: h.domain });
|
|
567
|
+
offset += batch.length;
|
|
568
|
+
if (batch.length < 200 || offset >= page.total) break;
|
|
569
|
+
}
|
|
570
|
+
await this.store.indexReplaceAgents(h.domain, cards);
|
|
571
|
+
h.last_ok = iso(); h.fails = 0; h.agents = cards.length;
|
|
572
|
+
await this.store.indexPutHouse(h);
|
|
573
|
+
} catch (e) {
|
|
574
|
+
h.fails = (h.fails || 0) + 1; h.last_error = `${iso()} ${e.message}`;
|
|
575
|
+
await this.store.indexPutHouse(h);
|
|
576
|
+
throw e;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// ---------- puente de correo (urn:nyx5:ext:email) ----------
|
|
580
|
+
// ENTRADA: un email real entra al buzón del destinatario como sobre SIN FIRMA, marcado
|
|
581
|
+
// from_verified:false y via:'email'. No pasa por /inbound ni finge estar firmado (invariante 1).
|
|
582
|
+
async receiveEmail({ from, to, subject, text, messageId } = {}) {
|
|
583
|
+
await this.init();
|
|
584
|
+
if (!isEmailAddress(from)) return { ok: false, code: 400, reason: 'remitente de correo inválido' };
|
|
585
|
+
let local, dom;
|
|
586
|
+
try { ({ local, domain: dom } = parseAddress(to)); } catch { return { ok: false, code: 400, reason: 'destinatario inválido' }; }
|
|
587
|
+
if (dom !== this.domain) return { ok: false, code: 400, reason: `el correo es para ${dom}, no ${this.domain}` };
|
|
588
|
+
const rec = await this.store.getAgent(local);
|
|
589
|
+
if (!rec) return { ok: false, code: 404, reason: 'agente inexistente' };
|
|
590
|
+
const env = inboundEnvelope({ from, to: `${local}@${this.domain}`, subject, text, messageId });
|
|
591
|
+
// dedupe por id (message-id del correo o uuid) igual que un sobre normal
|
|
592
|
+
const nuevo = await this.store.markSeenIfNew(env.id, { from: `email:${from}`, accepted: [to] });
|
|
593
|
+
if (!nuevo) return { ok: true, code: 200, duplicate: true };
|
|
594
|
+
await this.store.putMail(local, env, { from_verified: false, via: 'email', email_from: from });
|
|
595
|
+
this._push(local, env);
|
|
596
|
+
return { ok: true, code: 202, to: `${local}@${this.domain}` };
|
|
597
|
+
}
|
|
598
|
+
// SALIDA: un agente le escribe a una dirección de correo. Con proveedor, se envía (Reply-To = el
|
|
599
|
+
// agente, para que la respuesta vuelva por ENTRADA). Sin proveedor, queda pendiente: no se inventa canal.
|
|
600
|
+
async emailOut({ fromAgent, to, subject, text }) {
|
|
601
|
+
if (!isEmailAddress(to)) return { ok: false, code: 400, reason: 'destino de correo inválido' };
|
|
602
|
+
const payload = outboundPayload({ fromAgent, to, subject, text });
|
|
603
|
+
if (!this.email.provider) return { ok: false, code: 503, pending: true, reason: 'el puente de correo no tiene proveedor de salida configurado' };
|
|
604
|
+
try { const r = await this.email.provider(payload); return { ok: true, code: 202, provider: r?.id || null }; }
|
|
605
|
+
catch (e) { return { ok: false, code: 502, reason: `el proveedor de correo falló: ${e.message}` }; }
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async indexSearch(params) {
|
|
609
|
+
const out = await this.store.indexSearch(params);
|
|
610
|
+
// Respuesta firmada por la casa del índice: otro índice (u otra casa) puede ingerirla verificada.
|
|
611
|
+
return signObject({ nyx5: '1', index: this.domain, issued: iso(), ...out }, this.keys);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ---------- HTTP (agnóstico de runtime) ----------
|
|
615
|
+
// rx = { method, path, query: URLSearchParams, headers: {minúsculas}, body: objeto|null, ip }
|
|
616
|
+
async handleRequest(rx) {
|
|
617
|
+
await this.init();
|
|
618
|
+
const path = rx.path;
|
|
619
|
+
const send = (status, body) => ({ status, body });
|
|
620
|
+
try {
|
|
621
|
+
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 });
|
|
622
|
+
// Cliente web para personas: se sirve desde la propia casa (mismo origen, sin CORS).
|
|
623
|
+
// El HTML genera las llaves en el navegador del usuario; la casa nunca las ve.
|
|
624
|
+
if (rx.method === 'GET' && path === '/') return { status: 200, body: HOME_HTML, contentType: 'text/html; charset=utf-8' };
|
|
625
|
+
if (rx.method === 'GET' && (path === '/app' || path === '/app/')) return { status: 200, body: APP_HTML, contentType: 'text/html; charset=utf-8' };
|
|
626
|
+
// La especificación en una página, indexable. Se genera desde docs/SPEC.md (build:spec).
|
|
627
|
+
if (rx.method === 'GET' && (path === '/spec' || path === '/spec/')) return { status: 200, body: SPEC_HTML, contentType: 'text/html; charset=utf-8' };
|
|
628
|
+
if (rx.method === 'GET' && path === '/llms.txt') return { status: 200, body: LLMS_TXT, contentType: 'text/plain; charset=utf-8' };
|
|
629
|
+
if (rx.method === 'GET' && path === '/.well-known/nyx5.json') return send(200, await this.domainCard());
|
|
630
|
+
let m;
|
|
631
|
+
if (rx.method === 'GET' && (m = /^\/agents\/([^/]+)$/.exec(path))) {
|
|
632
|
+
const card = await this.agentCard(decodeURIComponent(m[1]).toLowerCase());
|
|
633
|
+
return card ? send(200, card) : send(404, { reason: 'agente inexistente' });
|
|
634
|
+
}
|
|
635
|
+
if (rx.method === 'GET' && path === '/agents') {
|
|
636
|
+
const p = Object.fromEntries(rx.query);
|
|
637
|
+
return send(200, await this.directory({ capability: p.capability, accepts: p.accepts, q: p.q, limit: p.limit ?? 50, offset: p.offset ?? 0 }));
|
|
638
|
+
}
|
|
639
|
+
if (rx.method === 'POST' && path === '/agents') {
|
|
640
|
+
const body = rx.body || {};
|
|
641
|
+
const local = String(body.local || '').toLowerCase();
|
|
642
|
+
if (!Estafeta.validLocal(local)) return send(400, { reason: 'nombre de agente inválido' });
|
|
643
|
+
const exists = !!(await this.store.getAgent(local));
|
|
644
|
+
const isAdmin = (rx.headers.authorization || '') === `Bearer ${this.adminToken}`;
|
|
645
|
+
let ok = isAdmin, via = 'admin';
|
|
646
|
+
if (!ok && rx.headers.authorization?.startsWith('Nyx5 ')) {
|
|
647
|
+
const who = await this._authenticate(rx, path);
|
|
648
|
+
ok = who.local === local || (body.delegation && who.address === body.delegation.by);
|
|
649
|
+
via = body.delegation ? 'delegation' : 'self';
|
|
650
|
+
}
|
|
651
|
+
if (!ok) {
|
|
652
|
+
// Auto-registro: el cuerpo viene firmado por la clave que se inscribe (prueba de posesión).
|
|
653
|
+
if (exists) return send(409, { reason: 'ese nombre ya está tomado; solo su dueño o la casa pueden actualizarlo' });
|
|
654
|
+
if (!body.signature || body.signature.kid !== body.sig || !verifyObject(body, body.sig)) return send(401, { reason: 'para auto-registrarse, firma el cuerpo con la clave sig que inscribes (prueba de posesión)' });
|
|
655
|
+
if (Math.abs(now() - Date.parse(body.ts || 0)) > 300_000) return send(401, { reason: 'la solicitud firmada necesita ts (ISO) dentro de 5 minutos' });
|
|
656
|
+
if (!this.regRate.allow(rx.ip || 'x')) return send(429, { reason: 'demasiados registros desde esta dirección' });
|
|
657
|
+
if (this.policy.registration === 'open') via = 'open';
|
|
658
|
+
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; }
|
|
659
|
+
else return send(403, { reason: `esta casa no acepta auto-registro (registration=${this.policy.registration}); pide una invitación` });
|
|
660
|
+
}
|
|
661
|
+
const { signature: _s, ts: _t, invite: _i, _welcome, ...clean } = body;
|
|
662
|
+
const card = await this.registerAgent({ ...clean, welcome: _welcome });
|
|
663
|
+
this.log(`registro ${card.address} via ${via}`);
|
|
664
|
+
return send(201, { ...card, registered_via: via });
|
|
665
|
+
}
|
|
666
|
+
if (rx.method === 'POST' && path === '/invitations') {
|
|
667
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'solo la casa emite invitaciones' });
|
|
668
|
+
return send(201, await this.createInvite(rx.body || {}));
|
|
669
|
+
}
|
|
670
|
+
if (rx.method === 'GET' && path === '/invitations') {
|
|
671
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'solo la casa lista invitaciones' });
|
|
672
|
+
return send(200, { invitations: await this.store.listInvites() });
|
|
673
|
+
}
|
|
674
|
+
if (rx.method === 'POST' && path === '/outbound') {
|
|
675
|
+
const who = await this._authenticate(rx, path);
|
|
676
|
+
const r = await this.outbound(rx.body, who);
|
|
677
|
+
// kick: el adaptador dispara un tick tras responder (setImmediate en Node, waitUntil en Workers)
|
|
678
|
+
return { ...send(r.code || 400, r), kick: true };
|
|
679
|
+
}
|
|
680
|
+
if (rx.method === 'POST' && path === '/email/out') {
|
|
681
|
+
// El agente autenticado le escribe a una dirección de correo del mundo real.
|
|
682
|
+
const who = await this._authenticate(rx, path);
|
|
683
|
+
const { to, subject, body } = rx.body || {};
|
|
684
|
+
const r = await this.emailOut({ fromAgent: who.address, to, subject, text: typeof body === 'string' ? body : JSON.stringify(body) });
|
|
685
|
+
return send(r.code || 400, r);
|
|
686
|
+
}
|
|
687
|
+
if (rx.method === 'POST' && path === '/inbound') {
|
|
688
|
+
const r = await this.inbound(rx.body, rx.headers['x-nyx5-relay']);
|
|
689
|
+
// pending: los avisos por webhook que nacieron aquí. El adaptador los pasa a waitUntil;
|
|
690
|
+
// sin eso el runtime cancela el fetch al cerrar la respuesta y el aviso nunca sale.
|
|
691
|
+
return { ...send(r.code || 400, r), kick: true, pending: this.flushPushes() };
|
|
692
|
+
}
|
|
693
|
+
if (rx.method === 'GET' && (m = /^\/mailbox\/([^/]+)$/.exec(path))) {
|
|
694
|
+
const who = await this._authenticate(rx, path);
|
|
695
|
+
if (who.local !== decodeURIComponent(m[1]).toLowerCase()) return send(403, { reason: 'buzón ajeno' });
|
|
696
|
+
const limit = Number(rx.query.get('limit') || 50);
|
|
697
|
+
// los N más recientes (listMail viene en orden cronológico): con muchos mensajes viejos
|
|
698
|
+
// sin ackear, slice(0,limit) escondía justo los nuevos. slice(-limit) muestra los últimos.
|
|
699
|
+
return send(200, { messages: (await this.store.listMail(who.local)).slice(-limit) });
|
|
700
|
+
}
|
|
701
|
+
if (rx.method === 'POST' && (m = /^\/mailbox\/([^/]+)\/ack$/.exec(path))) {
|
|
702
|
+
const who = await this._authenticate(rx, path);
|
|
703
|
+
if (who.local !== decodeURIComponent(m[1]).toLowerCase()) return send(403, { reason: 'buzón ajeno' });
|
|
704
|
+
const { ids = [] } = rx.body || {};
|
|
705
|
+
const acked = [];
|
|
706
|
+
for (const id of ids) if (await this.store.ackMail(who.local, id)) acked.push(id);
|
|
707
|
+
return send(200, { acked });
|
|
708
|
+
}
|
|
709
|
+
// ----- Libro (lecturas directas; las operaciones van por correo a libro@) -----
|
|
710
|
+
if (rx.method === 'GET' && (m = /^\/libro\/cuenta\/([^/]+)$/.exec(path))) {
|
|
711
|
+
const who = await this._authenticate(rx, path, { allowForeign: true });
|
|
712
|
+
const address = decodeURIComponent(m[1]).toLowerCase();
|
|
713
|
+
if (who.address !== address) return send(403, { reason: 'cuenta ajena' });
|
|
714
|
+
return send(200, await this.libro.account(address));
|
|
715
|
+
}
|
|
716
|
+
if (rx.method === 'GET' && (m = /^\/libro\/contrato\/([^/]+)$/.exec(path))) {
|
|
717
|
+
const who = await this._authenticate(rx, path, { allowForeign: true });
|
|
718
|
+
const c = await this.store.libroGetContract(decodeURIComponent(m[1]));
|
|
719
|
+
if (!c) return send(404, { reason: 'contrato inexistente' });
|
|
720
|
+
if (![c.seller, c.buyer, c.verifier, c.arbiter].includes(who.address)) return send(403, { reason: 'no eres parte' });
|
|
721
|
+
return send(200, c);
|
|
722
|
+
}
|
|
723
|
+
if (rx.method === 'POST' && path === '/libro/topup') {
|
|
724
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'solo la casa carga saldo' });
|
|
725
|
+
const { account, amount, concept } = rx.body || {};
|
|
726
|
+
return send(201, await this.libro.topup(account, amount, concept || 'carga de la casa'));
|
|
727
|
+
}
|
|
728
|
+
if (rx.method === 'GET' && path === '/libro/diario') {
|
|
729
|
+
if ((rx.headers.authorization || '') !== `Bearer ${this.adminToken}`) return send(401, { reason: 'solo la casa lee el diario completo' });
|
|
730
|
+
return send(200, { balances: (await this.store.libroState()).balances, journal: await this.libro.journal() });
|
|
731
|
+
}
|
|
732
|
+
if (rx.method === 'GET' && (m = /^\/outbox\/([^/]+)$/.exec(path))) {
|
|
733
|
+
const who = await this._authenticate(rx, path);
|
|
734
|
+
if (who.local !== decodeURIComponent(m[1]).toLowerCase()) return send(403, { reason: 'bandeja ajena' });
|
|
735
|
+
return send(200, { sent: await this.store.listOutbox(who.local) });
|
|
736
|
+
}
|
|
737
|
+
// ----- Índice federado -----
|
|
738
|
+
if (this.index.enabled && rx.method === 'POST' && path === '/index/houses') {
|
|
739
|
+
if (!this.rate.allow(`index:${rx.ip || 'x'}`)) return send(429, { reason: 'demasiadas solicitudes' });
|
|
740
|
+
const h = await this.indexAddHouse((rx.body || {}).domain);
|
|
741
|
+
return send(201, h);
|
|
742
|
+
}
|
|
743
|
+
if (this.index.enabled && rx.method === 'GET' && path === '/index/houses') {
|
|
744
|
+
const houses = (await this.store.indexListHouses()).map(({ domain, estafeta, last_ok, agents }) => ({ domain, estafeta, last_ok, agents }));
|
|
745
|
+
return send(200, { total: houses.length, houses });
|
|
746
|
+
}
|
|
747
|
+
if (this.index.enabled && rx.method === 'GET' && path === '/index/agents') {
|
|
748
|
+
const p = Object.fromEntries(rx.query);
|
|
749
|
+
const limit = Number.isInteger(Number(p.limit)) && Number(p.limit) > 0 ? Math.min(Number(p.limit), 200) : 50;
|
|
750
|
+
const offset = Number.isInteger(Number(p.offset)) && Number(p.offset) >= 0 ? Number(p.offset) : 0;
|
|
751
|
+
return send(200, await this.indexSearch({ q: p.q, capability: p.capability, accepts: p.accepts, house: p.house, limit, offset }));
|
|
752
|
+
}
|
|
753
|
+
return send(404, { reason: 'ruta desconocida' });
|
|
754
|
+
} catch (e) {
|
|
755
|
+
// Falla cerrado y con código HTTP válido: e.code puede ser un string del sistema ('ENOENT').
|
|
756
|
+
const status = Number.isInteger(e.status) ? e.status : (Number.isInteger(e.code) ? e.code : 500);
|
|
757
|
+
if (status >= 500) this.log(`error ${rx.method} ${path}: ${e.message}`);
|
|
758
|
+
return send(status, { reason: e.message });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ---------- ciclo de vida en Node (los tests, demos y la CLI lo usan tal cual) ----------
|
|
763
|
+
async start() {
|
|
764
|
+
await this.init();
|
|
765
|
+
const { startNodeServer } = await import('../plataformas/node.js');
|
|
766
|
+
this._node = await startNodeServer(this, { port: this.port, host: this.host });
|
|
767
|
+
this.timer = setInterval(() => this.tick().catch((e) => this.log('tick error', e.message)), this.workerIntervalMs);
|
|
768
|
+
this.timer.unref?.();
|
|
769
|
+
this.log(`escuchando en ${this.publicUrl}`);
|
|
770
|
+
return this;
|
|
771
|
+
}
|
|
772
|
+
async stop() {
|
|
773
|
+
clearInterval(this.timer);
|
|
774
|
+
if (this._node) await this._node.close();
|
|
775
|
+
this._node = null;
|
|
776
|
+
}
|
|
777
|
+
}
|