@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.
@@ -0,0 +1,107 @@
1
+ // Nyx5/1 — Validación de sobres y política de entrada (anti-spam y anti-abuso).
2
+ //
3
+ // Toda estafeta receptora aplica, en este orden:
4
+ // 1. forma del sobre (schema mínimo) y tamaño
5
+ // 2. vigencia (expires) y duplicados (id)
6
+ // 3. cadena de firma: dominio -> agente -> sobre (obligatoria; sin firma no hay entrega)
7
+ // 4. política del agente destino: open | allowlist | pow | stamp (+ límite de tasa por dominio emisor)
8
+
9
+ import { checkPow } from '../nucleo/crypto.js';
10
+ import { parseAddress } from './resolver.js';
11
+
12
+ export const TYPES = new Set(['message', 'task', 'result', 'receipt', 'intro']);
13
+
14
+ export function validateEnvelope(env, { maxBytes = 1_048_576 } = {}) {
15
+ const fail = (reason) => ({ ok: false, code: 400, reason });
16
+ if (!env || typeof env !== 'object') return fail('sobre no es un objeto');
17
+ if (env.nyx5 !== '1') return fail('versión no soportada (se espera nyx5="1")');
18
+ // Charset acotado: el id viaja como clave al almacenamiento (dedupe, buzones); nada de traversal.
19
+ if (typeof env.id !== 'string' || !/^[A-Za-z0-9._:-]{8,128}$/.test(env.id)) return fail('id inválido (se espera [A-Za-z0-9._:-]{8,128})');
20
+ try { parseAddress(env.from); } catch { return fail('from inválido'); }
21
+ if (!Array.isArray(env.to) || env.to.length < 1 || env.to.length > 50) return fail('to debe ser una lista de 1 a 50 direcciones');
22
+ for (const t of env.to) { try { parseAddress(t); } catch { return fail(`destinatario inválido: ${t}`); } }
23
+ if (!TYPES.has(env.type)) return fail(`type inválido: ${env.type}`);
24
+ if (Number.isNaN(Date.parse(env.created))) return fail('created debe ser ISO-8601');
25
+ if (env.expires != null && Number.isNaN(Date.parse(env.expires))) return fail('expires debe ser ISO-8601');
26
+ // Entrega diferida: el sobre espera en la cola hasta esta fecha (la cola ya programa por next_attempt).
27
+ if (env.deliver_after != null) {
28
+ if (Number.isNaN(Date.parse(env.deliver_after))) return fail('deliver_after debe ser ISO-8601');
29
+ // Un sobre que vence antes de la fecha en que debe entregarse jamás llegaría: se rechaza al enviar.
30
+ if (env.expires != null && Date.parse(env.expires) <= Date.parse(env.deliver_after)) return fail('deliver_after es posterior a expires: el sobre vencería antes de entregarse');
31
+ }
32
+ const hasPlain = env.content && typeof env.content === 'object' && typeof env.content.media === 'string';
33
+ const hasEnc = env.encrypted && typeof env.encrypted === 'object' && typeof env.encrypted.ct === 'string';
34
+ if (!hasPlain && !hasEnc) return fail('el sobre necesita content o encrypted');
35
+ if (hasPlain && hasEnc) return fail('content y encrypted son excluyentes');
36
+ if (env.attachments != null) {
37
+ if (!Array.isArray(env.attachments)) return fail('attachments debe ser lista');
38
+ for (const a of env.attachments) if (!a?.name || !a?.media || !a?.sha256 || !a?.url) return fail('adjunto incompleto (name, media, sha256, url)');
39
+ }
40
+ if (!env.signature || env.signature.alg !== 'Ed25519' || !env.signature.kid || !env.signature.value) return fail('sobre sin firma');
41
+ const bytes = Buffer.byteLength(JSON.stringify(env));
42
+ if (bytes > maxBytes) return { ok: false, code: 413, reason: `sobre de ${bytes} bytes supera el máximo ${maxBytes}` };
43
+ return { ok: true };
44
+ }
45
+
46
+ // Limitador de tasa por clave (dominio emisor), ventana deslizante simple.
47
+ export class RateLimiter {
48
+ constructor({ perMinute = 120 } = {}) { this.perMinute = perMinute; this.hits = new Map(); }
49
+ allow(key) {
50
+ const now = Date.now();
51
+ const arr = (this.hits.get(key) || []).filter((t) => now - t < 60_000);
52
+ if (arr.length >= this.perMinute) { this.hits.set(key, arr); return false; }
53
+ arr.push(now); this.hits.set(key, arr); return true;
54
+ }
55
+ }
56
+
57
+ // Política del agente destino sobre un sobre ya verificado criptográficamente.
58
+ export function applyInboxPolicy(env, agentRecord, senderDomain) {
59
+ const inbox = agentRecord.inbox || { policy: 'open' };
60
+ const { domain: fromDomain } = parseAddress(env.from);
61
+ const permanent = (reason) => ({ ok: false, code: 403, reason });
62
+
63
+ if (inbox.blocklist?.some((x) => x === env.from || x === fromDomain)) return permanent('remitente bloqueado');
64
+ // La promesa "sealed" del dominio emisor se hace cumplir ANTES de cualquier política:
65
+ // si fuera después, la rama stamp (que retorna temprano) la saltaría — y justo en los buzones pagados.
66
+ // Única excepción: sobres a libro@ (operaciones del Libro), que exigen claro por diseño (la casa debe leerlos).
67
+ const esLibro = String(agentRecord.address || '').startsWith('libro@');
68
+ if (!esLibro && senderDomain?.policy?.outbound === 'sealed' && !env.encrypted) return permanent('el dominio emisor exige cifrado y el sobre viene en claro');
69
+
70
+ switch (inbox.policy) {
71
+ case 'open':
72
+ break;
73
+ case 'allowlist': {
74
+ const ok = inbox.allowlist?.some((x) => x === env.from || x === fromDomain);
75
+ if (ok) break;
76
+ // Un desconocido puede presentarse con un "intro" pequeño.
77
+ if (env.type === 'intro' && Buffer.byteLength(JSON.stringify(env)) <= 4096) break;
78
+ // O con un AVAL: un tercero de la allowlist lo respalda con una fianza en la casa del receptor.
79
+ // Aquí solo comprobamos que el avalador esté en la allowlist; la estafeta verifica la fianza
80
+ // contra su Libro (existe, activa, avala a este remitente, con el receptor como beneficiario).
81
+ const aval = env.extensions?.['urn:nyx5:ext:aval'];
82
+ if (aval && aval.voucher && aval.bond) {
83
+ let voucherDomain; try { voucherDomain = parseAddress(aval.voucher).domain; } catch { voucherDomain = null; }
84
+ if (inbox.allowlist?.some((x) => x === aval.voucher || x === voucherDomain)) {
85
+ return { ok: true, vouch: { voucher: aval.voucher, bond: aval.bond, vouchee: env.from, beneficiary: agentRecord.address } };
86
+ }
87
+ return permanent('el avalador de la presentación no está en la allowlist');
88
+ }
89
+ return permanent('remitente no está en la allowlist (se acepta un intro ≤4 KB o un aval con fianza de un avalador de la allowlist)');
90
+ }
91
+ case 'pow': {
92
+ const bits = inbox.pow_bits ?? 16;
93
+ const exempt = inbox.allowlist?.some((x) => x === env.from || x === fromDomain);
94
+ if (!exempt && !checkPow(env.id, env.pow, bits)) return { ok: false, code: 402, reason: `se requiere proof-of-work de ${bits} bits`, pow_bits: bits };
95
+ break;
96
+ }
97
+ case 'stamp': {
98
+ // Estampilla pagada en el Libro de la casa del receptor. La cobra la estafeta al aceptar.
99
+ const exempt = inbox.allowlist?.some((x) => x === env.from || x === fromDomain);
100
+ if (!exempt) return { ok: true, stamp: { price: inbox.price ?? 1, house: inbox.house } };
101
+ break;
102
+ }
103
+ default:
104
+ return permanent(`política de buzón desconocida: ${inbox.policy}`);
105
+ }
106
+ return { ok: true };
107
+ }
@@ -0,0 +1,173 @@
1
+ // Nyx5/1 — Resolver: de una dirección agente@dominio a una tarjeta verificada.
2
+ //
3
+ // Orden de resolución del dominio (equivalente al registro MX del correo):
4
+ // 1. Override local (hosts.json / opción hosts) -> pruebas y redes privadas
5
+ // 2. DNS TXT en _nyx5.<dominio> -> ancla pública de confianza
6
+ // 3. https://<dominio>/.well-known/nyx5.json -> fallback sin DNS
7
+ //
8
+ // Cadena de confianza: clave del dominio (anclada en DNS o pineada) -> certifica la tarjeta
9
+ // del agente -> la clave del agente firma cada sobre.
10
+
11
+ import dns from 'node:dns/promises';
12
+ import { verifyObject } from '../nucleo/crypto.js';
13
+
14
+ export function parseAddress(address) {
15
+ const m = /^([a-z0-9][a-z0-9._-]{0,63})@([a-z0-9.-]+)$/i.exec(String(address || ''));
16
+ if (!m) throw new Error(`dirección inválida: ${address}`);
17
+ return { local: m[1].toLowerCase(), domain: m[2].toLowerCase() };
18
+ }
19
+
20
+ export function parseTxtRecord(txt) {
21
+ const out = {};
22
+ for (const part of txt.split(';')) {
23
+ const [k, ...rest] = part.trim().split('=');
24
+ if (k && rest.length) out[k.trim()] = rest.join('=').trim();
25
+ }
26
+ return out;
27
+ }
28
+
29
+ export class Resolver {
30
+ constructor({ hosts = {}, fetchImpl = globalThis.fetch, cacheTtlMs = 5 * 60 * 1000, pins = {}, timeoutMs = 5000, onPin = null, self = null } = {}) {
31
+ this.hosts = { ...hosts }; // { "beta.local": { url: "http://localhost:4002", sig?: "<pub>" } }
32
+ this.fetch = (...a) => fetchImpl(...a); // envuelto: workerd exige fetch con this=globalThis
33
+ this.cacheTtlMs = cacheTtlMs;
34
+ this.pins = { ...pins }; // dominio -> clave pública pineada (TOFU o DNS)
35
+ this.timeoutMs = timeoutMs;
36
+ this.onPin = onPin; // callback(pins) para PERSISTIR un pin nuevo: un pin solo en memoria no protege entre procesos/isolates
37
+ // La propia casa se sirve sin red: un Worker no puede pedirse su propia URL pública
38
+ // (Cloudflare corta la conexión: 522) y en cualquier runtime sería una vuelta inútil.
39
+ // self = { domain, estafeta, domainCard(), agentCard(local) }
40
+ this.self = self;
41
+ this.cache = new Map();
42
+ }
43
+
44
+ setHost(domain, entry) { this.hosts[domain] = entry; this.cache.clear(); }
45
+ invalidate(key) { if (key) this.cache.delete(key); else this.cache.clear(); }
46
+
47
+ _cached(key) {
48
+ const hit = this.cache.get(key);
49
+ if (hit && hit.until > Date.now()) return hit.value;
50
+ return null;
51
+ }
52
+ _remember(key, value) { this.cache.set(key, { value, until: Date.now() + this.cacheTtlMs }); }
53
+
54
+ async _get(url) {
55
+ const res = await this.fetch(url, { signal: AbortSignal.timeout(this.timeoutMs) });
56
+ if (!res.ok) throw Object.assign(new Error(`GET ${url} -> ${res.status}`), { permanent: res.status === 404 || res.status === 410 });
57
+ return res.json();
58
+ }
59
+
60
+ // Devuelve { url, sig? } donde vive la estafeta del dominio.
61
+ async locate(domain) {
62
+ domain = domain.toLowerCase();
63
+ if (this.hosts[domain]) return { source: 'override', ...this.hosts[domain] };
64
+ try {
65
+ const records = await dns.resolveTxt(`_nyx5.${domain}`);
66
+ for (const chunks of records) {
67
+ const rec = parseTxtRecord(chunks.join(''));
68
+ if (rec.v === 'nyx51' && rec.url) return { source: 'dns', url: rec.url, sig: rec.sig };
69
+ }
70
+ } catch (e) {
71
+ // "No existe el registro" (NXDOMAIN/sin datos) es el fallback legítimo a well-known.
72
+ // Cualquier OTRA falla de DNS (red, timeout, servfail) NO degrada el ancla en silencio:
73
+ // un atacante que bloquee la respuesta DNS no puede empujarnos a well-known + TOFU.
74
+ if (e.code !== 'ENOTFOUND' && e.code !== 'ENODATA') {
75
+ throw Object.assign(new Error(`DNS no disponible para ${domain}: ${e.message}`), { permanent: false });
76
+ }
77
+ }
78
+ return { source: 'well-known', url: `https://${domain}` };
79
+ }
80
+
81
+ // Tarjeta del dominio, verificada y (si corresponde) contrastada con la clave anclada.
82
+ async domainCard(domain) {
83
+ domain = domain.toLowerCase();
84
+ if (this.self && domain === this.self.domain) {
85
+ const card = await this.self.domainCard();
86
+ return { ...card, _estafeta: this.self.estafeta, _source: 'local' };
87
+ }
88
+ const cached = this._cached(`domain:${domain}`);
89
+ if (cached) return cached;
90
+
91
+ const loc = await this.locate(domain);
92
+ const card = await this._get(`${loc.url.replace(/\/$/, '')}/.well-known/nyx5.json`);
93
+ if (card.nyx5 !== '1' || card.domain !== domain) throw Object.assign(new Error(`tarjeta de dominio inválida para ${domain}`), { permanent: true });
94
+ const keyIds = (card.keys || []).map((k) => k.sig);
95
+ if (!keyIds.includes(card.signature?.kid) || !verifyObject(card, card.signature.kid)) {
96
+ throw Object.assign(new Error(`firma de dominio inválida para ${domain}`), { permanent: true });
97
+ }
98
+ // Ancla: DNS/override dice qué clave debe tener el dominio. Si no hay ancla, TOFU (pin en primer uso).
99
+ const anchor = loc.sig || this.pins[domain];
100
+ if (anchor && !keyIds.includes(anchor)) throw Object.assign(new Error(`la clave del dominio ${domain} no coincide con la anclada`), { permanent: true });
101
+ if (!anchor) {
102
+ this.pins[domain] = card.signature.kid;
103
+ // Se persiste ESE pin, no el mapa entero: escribir el mapa desde la memoria de un proceso
104
+ // borraba los pines que otro había aprendido.
105
+ try { await this.onPin?.(domain, card.signature.kid); } catch { /* mejor-esfuerzo; el pin en memoria ya rige */ }
106
+ }
107
+
108
+ const value = { ...card, _estafeta: loc.url.replace(/\/$/, ''), _source: loc.source };
109
+ this._remember(`domain:${domain}`, value);
110
+ return value;
111
+ }
112
+
113
+ // Tarjeta del agente, certificada por la clave del dominio.
114
+ async agentCard(address) {
115
+ const { local, domain } = parseAddress(address);
116
+ // La propia casa sirve la tarjeta sin red, pero pasa por la MISMA verificación que una ajena:
117
+ // venir de casa no la exime de estar certificada por el dominio y firmada por su padre.
118
+ if (this.self && domain === this.self.domain) {
119
+ const card = await this.self.agentCard(local);
120
+ if (!card) throw Object.assign(new Error(`agente inexistente: ${address}`), { permanent: true });
121
+ const dc = await this.domainCard(domain);
122
+ return this._verifyAgentCard(card, dc, address, local, domain);
123
+ }
124
+ const key = `agent:${local}@${domain}`;
125
+ const cached = this._cached(key);
126
+ if (cached) return cached;
127
+
128
+ const dc = await this.domainCard(domain);
129
+ const card = await this._get(`${dc._estafeta}/agents/${encodeURIComponent(local)}`);
130
+ const value = await this._verifyAgentCard(card, dc, address, local, domain);
131
+ this._remember(key, value);
132
+ return value;
133
+ }
134
+
135
+ // La verificación de una tarjeta de agente, en un solo lugar: certificación del dominio,
136
+ // vigencia y cadena de delegación. La usan el camino local y el remoto por igual.
137
+ async _verifyAgentCard(card, dc, address, local, domain) {
138
+ if (card.nyx5 !== '1' || card.address !== `${local}@${domain}`) throw Object.assign(new Error(`tarjeta de agente inválida: ${address}`), { permanent: true });
139
+ const domainKeys = dc.keys.map((k) => k.sig);
140
+ if (!domainKeys.includes(card.certification?.kid) || !verifyObject(card, card.certification.kid, 'certification')) {
141
+ throw Object.assign(new Error(`certificación inválida para ${address}`), { permanent: true });
142
+ }
143
+ if (card.valid_until && Date.parse(card.valid_until) < Date.now()) throw Object.assign(new Error(`tarjeta vencida: ${address}`), { permanent: true });
144
+ if (card.delegation) {
145
+ // Cadena de delegación: el padre (ya certificado por el dominio) firmó esta tarjeta.
146
+ const d = card.delegation;
147
+ const { local: parentLocal, domain: parentDomain } = parseAddress(d.by);
148
+ if (parentDomain !== domain || !local.endsWith(`.${parentLocal}`) || d.address !== card.address || d.sig !== card.sig) throw Object.assign(new Error(`delegación inconsistente en ${address}`), { permanent: true });
149
+ const parent = await this.agentCard(d.by);
150
+ if (!Resolver.acceptedKids(parent).includes(d.signature?.kid) || !verifyObject(d, d.signature.kid)) throw Object.assign(new Error(`delegación no firmada por ${d.by}`), { permanent: true });
151
+ card = { ...card, delegation: { ...d, _parent: parent } };
152
+ }
153
+ return { ...card, _estafeta: dc._estafeta, _domain: dc };
154
+ }
155
+
156
+ // Claves aceptables de una tarjeta: la vigente más las anteriores dentro del período de gracia.
157
+ // Una clave previa SIN vencimiento no cuenta: la gracia es acotada o no es gracia (falla cerrado).
158
+ static acceptedKids(card) {
159
+ return [card.sig, ...(card.previous || []).filter((p) => p.until && Date.parse(p.until) > Date.now()).map((p) => p.sig)];
160
+ }
161
+
162
+ // Igual que agentCard, pero si el sobre viene firmado con una clave que la tarjeta en caché no
163
+ // reconoce (rotación reciente), refresca la tarjeta una vez antes de rechazar.
164
+ async agentCardForKid(address, kid) {
165
+ let card = await this.agentCard(address);
166
+ if (!Resolver.acceptedKids(card).includes(kid)) {
167
+ const { local, domain } = parseAddress(address);
168
+ this.invalidate(`agent:${local}@${domain}`);
169
+ card = await this.agentCard(address);
170
+ }
171
+ return card;
172
+ }
173
+ }
@@ -0,0 +1,247 @@
1
+ // Nyx5/1 — Contratos: máquinas de estado sobre las primitivas del Libro.
2
+ //
3
+ // Cada operación recibe un contexto { libro, env, from, body, senderCard, opHash, scope } y devuelve
4
+ // { result, recibos: [{ to: [...], thread, body }] }. Los recibos los envía la estafeta desde
5
+ // `libro@<dominio>`, firmados con la clave de la casa: son la verdad compartida entre las partes.
6
+ //
7
+ // Agregar un contrato nuevo = agregar entradas a `ops` y, si se cotiza, a CONTRATOS.<kind>.
8
+ // El kernel (libro.js) no se toca.
9
+ //
10
+ // Las operaciones corren DENTRO de la transacción que abre Libro.handle(): leen con
11
+ // libro.getContract/getMandate (que ven las escrituras pendientes) y escriben con
12
+ // libro.putContract/putMandate (que quedan en la transacción). El commit lo sella handle().
13
+
14
+ import { sha256hex, canonical, uuid } from '../nucleo/crypto.js';
15
+ import { parseAddress } from '../correo/resolver.js';
16
+
17
+ import { LibroError } from './errores.js';
18
+
19
+ const iso = () => new Date().toISOString();
20
+ const fail = (code, msg) => { throw new LibroError(code, msg); };
21
+
22
+ // --- helpers ---
23
+ async function getContract(libro, id) { const c = await libro.getContract(id); if (!c) fail(404, `contrato inexistente: ${id}`); return c; }
24
+ async function getMandate(libro, id) { const m = await libro.getMandate(id); if (!m) fail(404, `mandato inexistente: ${id}`); return m; }
25
+ function record(libro, c, op, by, extra = {}) {
26
+ c.history.push({ at: iso(), op, by, ...extra });
27
+ c.updated = iso();
28
+ libro.putContract(c);
29
+ return c;
30
+ }
31
+ function scopeCap(ctx, amount, what) {
32
+ const cap = ctx.scope?.cap;
33
+ if (cap != null && amount > cap) fail(403, `${what}: ${amount} tok supera el tope ${cap} del agente delegado`);
34
+ }
35
+ function must(cond, code, msg) { if (!cond) fail(code, msg); }
36
+ const parties = (c) => [c.seller, c.buyer, c.verifier, c.arbiter].filter(Boolean);
37
+
38
+ // ============ Operaciones ============
39
+ const ops = {
40
+
41
+ // --- aceptar una cotización: crea el contrato y ejecuta el primer asiento ---
42
+ async accept(ctx) {
43
+ const { libro, from, body } = ctx;
44
+ const q = body.quote;
45
+ await libro.verifyQuote(q, from); // valida forma, firma, vigencia, no-reuso y que q.contract sea cotizable
46
+ scopeCap(ctx, q.price, 'aceptar cotización');
47
+ const kind = CONTRATOS[q.contract];
48
+ const c = {
49
+ id: uuid(), kind: q.contract, house: libro.domain, seller: q.seller, buyer: from, amount: q.price,
50
+ concept: q.concept, terms: q.terms || {}, arbiter: q.arbiter || null, referrer: q.referrer || null,
51
+ quote_id: q.id, quote_sha256: sha256hex(canonical(q)), accept_sha256: ctx.opHash,
52
+ state: 'accepted', created: iso(), history: [],
53
+ };
54
+ const refs = { contract: c.id, quote: q.id, quote_sha256: c.quote_sha256, op: ctx.env.id, op_sha256: ctx.opHash };
55
+ const out = await kind.onAccept({ libro, c, q, refs });
56
+ record(libro, c, 'accept', from, { asiento: out.asiento?.id, mandate: out.mandate?.id });
57
+ // Aviso de plazo: si el trato tiene fecha límite, el Libro se programa un sobre a las partes
58
+ // para ese día. Un escrow que llegó a su deadline sin liberarse deja de quedar mudo.
59
+ const plazo = c.terms?.deadline;
60
+ const avisos = plazo && !Number.isNaN(Date.parse(plazo)) ? [{
61
+ to: parties(c), thread: c.id, deliver_after: new Date(Date.parse(plazo)).toISOString(),
62
+ body: { aviso: 'plazo', contract: c.id, kind: c.kind, deadline: plazo, mensaje: `El plazo del contrato ${c.id} (${c.concept}) llegó. Estado al programarse: ${c.state}.` },
63
+ }] : [];
64
+ return { result: { contract: c, asiento: out.asiento, mandate: out.mandate },
65
+ recibos: [{ to: [c.buyer, c.seller], thread: c.id, body: { contract: c, asiento: out.asiento, mandate: out.mandate, cotizacion_sha256: c.quote_sha256 } }],
66
+ avisos };
67
+ },
68
+
69
+ // --- escrow: el vendedor declara entregado, con hash de la evidencia ---
70
+ async deliver(ctx) {
71
+ const { libro, from, body } = ctx;
72
+ const c = await getContract(libro, body.contract);
73
+ must(c.kind === 'escrow', 409, 'deliver solo aplica a escrow');
74
+ must(from === c.seller, 403, 'solo el vendedor puede declarar la entrega');
75
+ must(c.state === 'held', 409, `estado ${c.state}, se esperaba held`);
76
+ c.state = 'delivered'; c.evidence_sha256 = body.evidence_sha256 || null;
77
+ record(libro, c, 'deliver', from, { evidence_sha256: c.evidence_sha256, note: body.note });
78
+ return { result: { contract: c }, recibos: [{ to: parties(c), thread: c.id, body: { contract: c } }] };
79
+ },
80
+
81
+ // --- liberar fondos retenidos: escrow -> vendedor (con fee); fianza -> vuelve al que afianzó ---
82
+ async release(ctx) {
83
+ const { libro, from, body } = ctx;
84
+ const c = await getContract(libro, body.contract);
85
+ let asiento;
86
+ if (c.kind === 'escrow') {
87
+ must([c.buyer, c.arbiter].includes(from), 403, 'solo el comprador o el árbitro liberan el escrow');
88
+ must(['held', 'delivered'].includes(c.state), 409, `estado ${c.state}`);
89
+ asiento = await libro.release(c.id, c.seller, c.amount, `liberación escrow ${c.id}: ${c.concept}`, { contract: c.id }, { op: ctx.env.id, op_sha256: ctx.opHash }, c.referrer);
90
+ c.state = 'released';
91
+ } else if (c.kind === 'bond') {
92
+ const expired = c.expires && Date.parse(c.expires) < Date.now();
93
+ must([c.verifier, c.arbiter].includes(from) || (from === c.seller && expired), 403, 'la fianza la libera el verificador o el árbitro; el afianzado solo cuando vence');
94
+ must(c.state === 'posted', 409, `estado ${c.state}`);
95
+ asiento = await libro.refund(c.id, c.seller, c.amount, `fianza liberada ${c.id}: ${c.claim}`, { contract: c.id, kind: 'bond-release' }, { op: ctx.env.id, op_sha256: ctx.opHash });
96
+ c.state = 'released';
97
+ } else fail(409, `release no aplica a ${c.kind}`);
98
+ record(libro, c, 'release', from, { asiento: asiento.id });
99
+ return { result: { contract: c, asiento }, recibos: [{ to: parties(c), thread: c.id, body: { contract: c, asiento } }] };
100
+ },
101
+
102
+ // --- devolver escrow al comprador (sin fee) ---
103
+ async refund(ctx) {
104
+ const { libro, from, body } = ctx;
105
+ const c = await getContract(libro, body.contract);
106
+ must(c.kind === 'escrow', 409, 'refund solo aplica a escrow');
107
+ must([c.seller, c.arbiter].includes(from) || (from === c.buyer && c.state === 'held'), 403, 'devuelven el vendedor o el árbitro; el comprador solo si aún no hay entrega');
108
+ must(['held', 'delivered'].includes(c.state), 409, `estado ${c.state}`);
109
+ const asiento = await libro.refund(c.id, c.buyer, c.amount, `devolución escrow ${c.id}: ${c.concept}`, { contract: c.id }, { op: ctx.env.id, op_sha256: ctx.opHash });
110
+ c.state = 'refunded';
111
+ record(libro, c, 'refund', from, { asiento: asiento.id, note: body.note });
112
+ return { result: { contract: c, asiento }, recibos: [{ to: parties(c), thread: c.id, body: { contract: c, asiento } }] };
113
+ },
114
+
115
+ // --- fianza: el que afirma deposita; si la verificación lo derriba, la pierde ---
116
+ async bond(ctx) {
117
+ const { libro, from, body } = ctx;
118
+ must(typeof body.claim === 'string' && body.claim.length > 0, 400, 'la fianza necesita claim (la afirmación)');
119
+ parseAddress(body.verifier); must(body.verifier !== from, 400, 'el verificador no puede ser el mismo afianzado');
120
+ // beneficiary y arbiter entran a cuentas del ledger: direcciones válidas o nada.
121
+ if (body.beneficiary) { try { parseAddress(body.beneficiary); } catch { fail(400, 'beneficiary debe ser una dirección de agente válida'); } }
122
+ if (body.arbiter) { try { parseAddress(body.arbiter); } catch { fail(400, 'arbiter debe ser una dirección de agente válida'); } }
123
+ // vouchee (avalado): si esta fianza avala a un tercero para presentarse ante un buzón con lista
124
+ // blanca, nombra a quién avala. La política de entrada exige que coincida con el remitente.
125
+ if (body.vouchee) { try { parseAddress(body.vouchee); } catch { fail(400, 'vouchee debe ser una dirección de agente válida'); } }
126
+ scopeCap(ctx, body.amount, 'afianzar');
127
+ const c = {
128
+ id: uuid(), kind: 'bond', house: libro.domain, seller: from, verifier: body.verifier, arbiter: body.arbiter || null,
129
+ beneficiary: body.beneficiary || libro.casa, vouchee: body.vouchee || null, amount: body.amount, claim: body.claim, evidence_sha256: body.evidence_sha256 || null,
130
+ expires: body.expires || null, claim_sha256: ctx.opHash, state: 'posted', created: iso(), history: [],
131
+ };
132
+ const asiento = await libro.hold(from, c.id, c.amount, `fianza ${c.id}: ${c.claim}`, { contract: c.id, kind: 'bond' }, { op: ctx.env.id, op_sha256: ctx.opHash });
133
+ record(libro, c, 'bond', from, { asiento: asiento.id });
134
+ return { result: { contract: c, asiento }, recibos: [{ to: [c.seller, c.verifier], thread: c.id, body: { contract: c, asiento } }] };
135
+ },
136
+
137
+ // --- ejecutar la fianza: la afirmación era falsa ---
138
+ async forfeit(ctx) {
139
+ const { libro, from, body } = ctx;
140
+ const c = await getContract(libro, body.contract);
141
+ must(c.kind === 'bond', 409, 'forfeit solo aplica a fianzas');
142
+ must([c.verifier, c.arbiter].includes(from), 403, 'solo el verificador o el árbitro ejecutan la fianza');
143
+ must(c.state === 'posted', 409, `estado ${c.state}`);
144
+ const asiento = await libro.post(`fianza ejecutada ${c.id}: ${c.claim}`, [{ account: `escrow:${c.id}`, delta: -c.amount }, { account: c.beneficiary, delta: c.amount }], { kind: 'forfeit', contract: c.id, reason: body.reason }, { op: ctx.env.id, op_sha256: ctx.opHash });
145
+ c.state = 'forfeited';
146
+ record(libro, c, 'forfeit', from, { asiento: asiento.id, reason: body.reason });
147
+ return { result: { contract: c, asiento }, recibos: [{ to: parties(c), thread: c.id, body: { contract: c, asiento } }] };
148
+ },
149
+
150
+ // --- mandato: autoridad de gasto delegada, en cadena ---
151
+ async mandate(ctx) {
152
+ const { libro, from, body } = ctx;
153
+ parseAddress(body.grantee);
154
+ must(Number.isInteger(body.cap) && body.cap > 0, 400, 'cap debe ser entero positivo');
155
+ scopeCap(ctx, body.cap, 'otorgar mandato');
156
+ let parent = null;
157
+ if (body.parent) {
158
+ parent = await getMandate(libro, body.parent);
159
+ must(parent.state === 'active', 409, 'el mandato padre no está activo');
160
+ must(parent.grantee === from, 403, 'solo el mandatario del padre puede sub-delegar');
161
+ must(body.cap <= parent.cap - parent.spent, 403, `el sub-mandato (${body.cap}) supera lo disponible del padre (${parent.cap - parent.spent})`);
162
+ if (parent.expires) must(!body.expires || Date.parse(body.expires) <= Date.parse(parent.expires), 403, 'el sub-mandato no puede durar más que el padre');
163
+ }
164
+ const m = { id: uuid(), house: libro.domain, grantor: from, grantee: body.grantee, cap: body.cap, spent: 0, scope: body.scope || {}, expires: body.expires || parent?.expires || null,
165
+ parent: parent?.id || null, root: parent ? parent.root : from, chain: [], state: 'active', created: iso(), op_sha256: ctx.opHash };
166
+ m.chain = parent ? [...parent.chain, m.id] : [m.id];
167
+ libro.putMandate(m);
168
+ return { result: { mandate: m }, recibos: [{ to: [m.grantor, m.grantee], thread: m.chain[0], body: { mandate: m } }] };
169
+ },
170
+
171
+ // --- cobrar bajo mandato: paga el mandante raíz; toda la cadena descuenta ---
172
+ async charge(ctx) {
173
+ const { libro, from, body } = ctx;
174
+ const m = await getMandate(libro, body.mandate);
175
+ must(from === m.grantee, 403, 'solo el mandatario cobra bajo el mandato');
176
+ must(Number.isInteger(body.amount) && body.amount > 0, 400, 'monto inválido');
177
+ scopeCap(ctx, body.amount, 'cobrar');
178
+ const chain = [];
179
+ for (let cur = m; cur; cur = cur.parent ? await getMandate(libro, cur.parent) : null) {
180
+ must(cur.state === 'active', 409, `mandato ${cur.id} no está activo`);
181
+ must(!cur.expires || Date.parse(cur.expires) > Date.now(), 410, `mandato ${cur.id} vencido`);
182
+ must(cur.cap - cur.spent >= body.amount, 402, `mandato ${cur.id}: quedan ${cur.cap - cur.spent}, se piden ${body.amount}`);
183
+ if (cur.scope?.concepts?.length) must(cur.scope.concepts.includes(body.concept), 403, `concepto "${body.concept}" fuera del ámbito del mandato`);
184
+ chain.push(cur);
185
+ }
186
+ // El descuento de la cadena y el asiento van en la MISMA transacción: si el commit falla,
187
+ // ni el dinero se movió ni ningún eslabón descontó (nada de sobre-gasto por caída a medias).
188
+ const asiento = await libro.transfer(m.root, m.grantee, body.amount, body.concept || `cobro bajo mandato ${m.id}`, { mandate: m.id, chain: chain.map((x) => x.id) }, { op: ctx.env.id, op_sha256: ctx.opHash });
189
+ for (const cur of chain) { cur.spent += body.amount; libro.putMandate(cur); }
190
+ const everyone = [...new Set(chain.flatMap((x) => [x.grantor, x.grantee]))];
191
+ return { result: { asiento, mandate: chain[0] }, recibos: [{ to: everyone, thread: chain.at(-1).id, body: { asiento, mandate: chain[0], chain: chain.map((x) => ({ id: x.id, grantor: x.grantor, grantee: x.grantee, cap: x.cap, spent: x.spent })) } }] };
192
+ },
193
+
194
+ // --- revocar un mandato y todo lo que cuelga de él ---
195
+ async revoke(ctx) {
196
+ const { libro, from, body } = ctx;
197
+ const m = await getMandate(libro, body.mandate);
198
+ const ancestors = []; for (let cur = m; cur; cur = cur.parent ? await getMandate(libro, cur.parent) : null) ancestors.push(cur.grantor);
199
+ must(ancestors.includes(from), 403, 'solo el mandante o un mandante superior revoca');
200
+ const affected = (await libro.store.libroListMandates()).filter((x) => x.chain.includes(m.id) && x.state === 'active');
201
+ for (const x of affected) { x.state = 'revoked'; x.revoked = { at: iso(), by: from }; libro.putMandate(x); }
202
+ return { result: { revoked: affected.map((x) => x.id) }, recibos: [{ to: [...new Set(affected.flatMap((x) => [x.grantor, x.grantee]))], thread: m.chain[0], body: { revoked: affected.map((x) => x.id), by: from } }] };
203
+ },
204
+
205
+ // --- lecturas: la respuesta vuelve por correo como recibo ---
206
+ async balance(ctx) {
207
+ const acc = await ctx.libro.account(ctx.from);
208
+ return { result: acc, recibos: [{ to: [ctx.from], body: { account: acc.account, balance: acc.balance, contracts: acc.contracts.length, mandates: acc.mandates.length } }] };
209
+ },
210
+ async statement(ctx) {
211
+ const raw = Number(ctx.body.limit);
212
+ const n = Number.isInteger(raw) && raw > 0 ? Math.min(raw, 200) : 20;
213
+ const entries = await ctx.libro.store.libroStatement(ctx.from, n);
214
+ return { result: { entries }, recibos: [{ to: [ctx.from], body: { entries } }] };
215
+ },
216
+ async contract(ctx) {
217
+ const c = await getContract(ctx.libro, ctx.body.contract);
218
+ must(parties(c).includes(ctx.from), 403, 'no eres parte de este contrato');
219
+ return { result: { contract: c }, recibos: [{ to: [ctx.from], thread: c.id, body: { contract: c } }] };
220
+ },
221
+ };
222
+
223
+ // ============ Tipos de contrato cotizables ============
224
+ export const CONTRATOS = {
225
+ ops,
226
+ // Spot: cotizar -> cobrar. Comprar un dato, un informe, una verificación.
227
+ spot: { quoteable: true, async onAccept({ libro, c, refs }) {
228
+ c.state = 'settled';
229
+ return { asiento: await libro.transfer(c.buyer, c.seller, c.amount, `spot ${c.id}: ${c.concept}`, { contract: c.id }, refs, c.referrer) };
230
+ } },
231
+ // Escrow: retener al encargar -> liberar si la prueba pasa, devolver si falla. La cajita con dientes.
232
+ escrow: { quoteable: true, async onAccept({ libro, c, refs }) {
233
+ c.state = 'held';
234
+ return { asiento: await libro.hold(c.buyer, c.id, c.amount, `escrow ${c.id}: ${c.concept}`, { contract: c.id }, refs) };
235
+ } },
236
+ // Medido: la aceptación crea un mandato del comprador al vendedor con tope = precio cotizado.
237
+ metered: { quoteable: true, async onAccept({ libro, c, q, refs }) {
238
+ const m = { id: uuid(), house: libro.domain, grantor: c.buyer, grantee: c.seller, cap: c.amount, spent: 0, scope: q.terms?.scope || {}, expires: q.terms?.expires || q.expires || null,
239
+ parent: null, root: c.buyer, chain: [], state: 'active', created: iso(), contract: c.id, op_sha256: refs.op_sha256 };
240
+ m.chain = [m.id];
241
+ libro.putMandate(m);
242
+ c.state = 'active'; c.mandate = m.id;
243
+ return { mandate: m };
244
+ } },
245
+ // Bond (fianza) no se cotiza: se deposita directamente con la operación `bond`.
246
+ bond: { quoteable: false },
247
+ };
@@ -0,0 +1,4 @@
1
+ // Nyx5/1 — Error del Libro con código HTTP-like (402 saldo, 403 parte, 404 inexistente, 409 estado, 410 vencido).
2
+ export class LibroError extends Error {
3
+ constructor(code, message) { super(message); this.name = 'LibroError'; this.code = code; }
4
+ }