@nyx5/nyx5 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +50 -7
  2. package/bin/nyx5.js +81 -2
  3. package/demo/edge-local.mjs +41 -0
  4. package/demo/x402-pagar.mjs +64 -0
  5. package/demo/x402-testnet.mjs +80 -0
  6. package/docs/ARQUITECTURA.md +354 -0
  7. package/docs/SPEC.md +414 -15
  8. package/examples/ci/nyx5-ejecutor.yml +82 -0
  9. package/examples/frameworks/README.md +55 -0
  10. package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
  11. package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
  12. package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
  13. package/examples/frameworks/claude-agent-sdk.mjs +48 -0
  14. package/examples/frameworks/crewai_nyx5.py +72 -0
  15. package/examples/frameworks/http.md +117 -0
  16. package/examples/frameworks/langgraph_nyx5.py +90 -0
  17. package/examples/frameworks/nyx5_http.py +255 -0
  18. package/examples/frameworks/openai-agents.mjs +41 -0
  19. package/examples/hola-mundo.mjs +4 -4
  20. package/migrations/0006_puente_remoto.sql +19 -0
  21. package/migrations/0007_indice_reputacion.sql +31 -0
  22. package/migrations/0008_notaria.sql +18 -0
  23. package/migrations/0009_contratos_estado.sql +4 -0
  24. package/package.json +4 -2
  25. package/server.json +2 -2
  26. package/src/correo/acciones.js +216 -0
  27. package/src/correo/adjuntos.js +185 -0
  28. package/src/correo/agente.js +370 -22
  29. package/src/correo/asistente.js +599 -0
  30. package/src/correo/ci.js +262 -0
  31. package/src/correo/cobro.js +164 -0
  32. package/src/correo/estafeta.js +1548 -58
  33. package/src/correo/ideas.js +154 -0
  34. package/src/correo/indice.js +178 -0
  35. package/src/correo/politica.js +280 -2
  36. package/src/correo/resolver.js +35 -11
  37. package/src/libro/contratos.js +116 -7
  38. package/src/libro/estado.js +150 -0
  39. package/src/libro/informe.js +147 -3
  40. package/src/libro/libro.js +83 -12
  41. package/src/libro/notaria.js +74 -0
  42. package/src/libro/verifica.js +239 -36
  43. package/src/nucleo/almacen-d1.js +143 -14
  44. package/src/nucleo/almacen.js +137 -13
  45. package/src/nucleo/boveda.js +37 -0
  46. package/src/nucleo/crypto.js +55 -12
  47. package/src/nucleo/keccak.js +118 -0
  48. package/src/nucleo/objetos.js +78 -0
  49. package/src/nucleo/secp256k1.js +228 -0
  50. package/src/plataformas/app-html.js +1 -1
  51. package/src/plataformas/home-html.js +1 -1
  52. package/src/plataformas/iconos.js +6 -0
  53. package/src/plataformas/node.js +28 -4
  54. package/src/plataformas/spec-html.js +1 -1
  55. package/src/plataformas/terms-html.js +2 -0
  56. package/src/plataformas/worker.js +52 -7
  57. package/src/puentes/ejecutor.js +118 -0
  58. package/src/puentes/herramientas.js +315 -0
  59. package/src/puentes/mcp-remoto.js +71 -0
  60. package/src/puentes/mcp.js +5 -99
  61. package/src/puentes/oauth.js +361 -0
  62. package/src/puentes/x402-pagador.js +248 -0
  63. package/src/puentes/x402.js +266 -0
  64. package/src/version.js +4 -0
@@ -0,0 +1,150 @@
1
+ // Nyx5/1 — Boleta del asiento y estado de cuenta (NX-501).
2
+ //
3
+ // Dos lecturas del mismo diario, sin tocar el kernel:
4
+ // boletaDe(asiento) -> qué parte del movimiento fue fee de la casa y qué parte comisión de
5
+ // referido, LEÍDO de las líneas del asiento. No se recalcula: si el
6
+ // asiento y una tasa discreparan, gana la línea, que es lo que movió.
7
+ // estadoDeCuenta(...) -> el extracto de una cuenta en un rango de fechas: saldo inicial,
8
+ // movimientos, saldo final y totales que cuadran. JSON o CSV (RFC 4180).
9
+ //
10
+ // Modelo de las filas: el fee y la comisión son filas PROPIAS, atribuidas a quien recibe el monto
11
+ // bruto. El fee sale de lo que recibe el vendedor (el comprador paga el precio, ni más ni menos).
12
+ // Para el vendedor de un spot de 200 con fee 10 % y comisión 15 %:
13
+ // in 200 (bruto, contraparte el comprador) · out 20 fee 20 (casa@) · out 30 (referidor)
14
+ // y siempre opening + Σin − Σout = closing. `fee` es un subconjunto de `out`: marca qué salida
15
+ // fue para la casa. Las filas de un mismo asiento comparten `entry`; el saldo tras la última
16
+ // fila del asiento es el del Libro (los intermedios son de presentación).
17
+ //
18
+ // Qué NO cubre, dicho antes de que alguien lo suponga:
19
+ // - Movimientos donde la cuenta no aparece en una línea. Una fianza ejecutada mueve
20
+ // escrow → beneficiario: el afianzado salió del diario al depositarla.
21
+ // - El fee de una estampilla no pasa por un recibo del Libro (viaja en la respuesta x402 como
22
+ // `transaction`); sí aparece en el estado de cuenta del que la cobró.
23
+ // - Si la cuenta es a la vez referidor y pagador (net negativo), no se desglosa: una sola fila.
24
+
25
+ import { LibroError } from './errores.js';
26
+
27
+ export const CSV_CABECERA = Object.freeze(['date', 'entry', 'concept', 'counterparty', 'in', 'out', 'fee', 'balance']);
28
+
29
+ // Fee y comisión de un asiento, leídos de sus líneas. `feeBps` (tasa de la casa) y `share` (bps
30
+ // del referido) son contexto para que la boleta diga la tasa; el MONTO es siempre el de la línea.
31
+ // `meta.fee > 0` distingue un fee de otra entrada positiva de la casa (una fianza ejecutada a su
32
+ // favor no es fee). Nunca lanza: corre dentro de Libro.handle después de que la op ya decidió.
33
+ export function boletaDe(asiento, { casa, feeBps = null, share = null } = {}) {
34
+ if (!asiento || !Array.isArray(asiento.lines)) return {};
35
+ const meta = asiento.meta || {};
36
+ const out = {};
37
+ const fee = meta.fee > 0 ? asiento.lines.find((l) => l.account === casa && l.delta > 0) : null;
38
+ if (fee) out.fee = { account: casa, amount: fee.delta, ...(feeBps != null ? { bps: feeBps } : {}) };
39
+ const com = meta.commission > 0 && meta.referrer ? asiento.lines.find((l) => l.account === meta.referrer && l.delta > 0) : null;
40
+ if (com) out.commission = { account: meta.referrer, amount: com.delta, ...(share != null ? { bps: share } : {}) };
41
+ return out;
42
+ }
43
+
44
+ // Las filas de UNA cuenta en UN asiento. [] si la cuenta no aparece en ninguna línea.
45
+ export function filasDe(asiento, account, casa) {
46
+ const mias = asiento.lines.filter((l) => l.account === account);
47
+ if (!mias.length) return [];
48
+ const propio = mias.reduce((s, l) => s + l.delta, 0);
49
+ const meta = asiento.meta || {};
50
+ const { fee, commission } = boletaDe(asiento, { casa });
51
+ // Contraparte: las líneas del signo contrario que no son el fee ni la comisión (salvo que la
52
+ // cuenta sea justamente la casa o el referidor: entonces esas líneas son las suyas).
53
+ const excluidas = new Set([account, ...(fee ? [casa] : []), ...(commission ? [meta.referrer] : [])]);
54
+ const contra = asiento.lines.filter((l) => !excluidas.has(l.account) && Math.sign(l.delta) === -Math.sign(propio)).map((l) => l.account);
55
+ const base = { date: asiento.at, entry: asiento.id, n: asiento.n, concept: asiento.concept, counterparty: contra.join(';') };
56
+ // Quien recibe el bruto: net positivo, no es la casa ni el referidor, y el asiento repartió.
57
+ const bruto = propio > 0 && account !== casa && account !== meta.referrer && (fee || commission);
58
+ if (!bruto) return [{ ...base, kind: 'movement', in: propio > 0 ? propio : 0, out: propio < 0 ? -propio : 0, fee: 0 }];
59
+ const filas = [{ ...base, kind: 'movement', in: propio + (fee?.amount || 0) + (commission?.amount || 0), out: 0, fee: 0 }];
60
+ if (fee) filas.push({ ...base, kind: 'fee', concept: 'house fee', counterparty: casa, in: 0, out: fee.amount, fee: fee.amount });
61
+ if (commission) filas.push({ ...base, kind: 'commission', concept: 'referral commission', counterparty: meta.referrer, in: 0, out: commission.amount, fee: 0 });
62
+ return filas;
63
+ }
64
+
65
+ // Fecha ISO normalizada al formato exacto de `asiento.at` (toISOString), para que la comparación
66
+ // lexicográfica en el almacén sea la misma que la numérica. Una fecha sola ("2026-09-13") es la
67
+ // medianoche UTC de ese día: como `until` es exclusivo, `hasta=2026-09-14` cubre el 13 entero.
68
+ function fechaIso(v, nombre) {
69
+ if (v == null || v === '') return null;
70
+ // Sin zona horaria, Node la interpreta en la del proceso y workerd en UTC: se exige Z u offset
71
+ // (una fecha sola, sin hora, es UTC en las dos).
72
+ const t = typeof v === 'string' && (/^\d{4}-\d{2}-\d{2}$/.test(v) || /(Z|[+-]\d{2}:?\d{2})$/.test(v)) ? Date.parse(v) : NaN;
73
+ if (Number.isNaN(t)) throw new LibroError(400, `${nombre} must be an ISO-8601 date, got ${JSON.stringify(v)}`);
74
+ return new Date(t).toISOString();
75
+ }
76
+
77
+ // El estado de cuenta de `account` en [since, until). `limit` acota cuántos asientos se listan
78
+ // (se conservan los MÁS RECIENTES, como el statement de siempre); `max` es el techo que pone el
79
+ // llamador (200 por correo, 1000 por HTTP). Sin rango, el límite por defecto sigue siendo 20.
80
+ //
81
+ // opening_balance es el saldo justo antes del primer asiento listado (sumado de sus líneas, no
82
+ // leído de saldos), así que cuadra también cuando la lista está truncada. `ledger_balance` es lo
83
+ // que dice el estado de saldos hoy: cuando el extracto llega hasta ahora, los dos tienen que
84
+ // coincidir, y `reconciled` los resta. Si un día no coinciden, el diario y los saldos divergieron
85
+ // y esto es lo primero que lo dice.
86
+ export async function estadoDeCuenta(libro, account, { since = null, until = null, limit = null, max = 200 } = {}) {
87
+ const desde = fechaIso(since, 'since'), hasta = fechaIso(until, 'until');
88
+ if (desde && hasta && desde > hasta) throw new LibroError(400, `since (${desde}) is after until (${hasta})`);
89
+ const raw = Number(limit);
90
+ const n = Number.isInteger(raw) && raw > 0 ? Math.min(raw, max) : (desde || hasta ? max : 20);
91
+ const store = libro.store, casa = libro.casa;
92
+ const ledger = await libro.balance(account);
93
+ const { entries, total } = await store.libroStatementRange(account, { since: desde, until: hasta, limit: n });
94
+ const opening = entries.length
95
+ ? await store.libroBalanceBefore(account, { n: entries[0].n })
96
+ : (desde ? await store.libroBalanceBefore(account, { at: desde }) : 0);
97
+ const totals = { in: 0, out: 0, fees: 0, commissions: 0 };
98
+ const rows = [];
99
+ let saldo = opening;
100
+ for (const a of entries) {
101
+ for (const r of filasDe(a, account, casa)) {
102
+ saldo += r.in - r.out;
103
+ totals.in += r.in; totals.out += r.out; totals.fees += r.fee;
104
+ if (r.kind === 'commission') totals.commissions += r.out;
105
+ rows.push({ ...r, balance: saldo });
106
+ }
107
+ }
108
+ // Cuadre interno: lo que dicen las filas tiene que ser lo que dicen las líneas del diario.
109
+ const neto = entries.reduce((s, a) => s + a.lines.filter((l) => l.account === account).reduce((t, l) => t + l.delta, 0), 0);
110
+ if (opening + neto !== saldo) throw new LibroError(500, `statement does not reconcile with the journal (${opening} + ${neto} != ${saldo})`);
111
+ // Sin `until` la lista termina en el último asiento de la cuenta (se conservan los más
112
+ // recientes): el saldo final tiene que ser el saldo de hoy, esté o no truncada la lista.
113
+ const reconciled = hasta ? null : saldo === ledger;
114
+ return {
115
+ account, house: libro.domain, since: desde, until: hasta,
116
+ opening_balance: opening, entries: rows, closing_balance: saldo, totals,
117
+ rows: rows.length, entries_shown: entries.length, entries_total: total, truncated: total > entries.length,
118
+ ledger_balance: ledger, reconciled,
119
+ };
120
+ }
121
+
122
+ // ---------- CSV (RFC 4180) ----------
123
+ // Un campo con coma, comillas o salto de línea va entre comillas, y las comillas se doblan.
124
+ export function campoCsv(v) {
125
+ const s = v == null ? '' : String(v);
126
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
127
+ }
128
+ // Texto que escribió otro agente (concepto, contraparte) y que una hoja de cálculo podría tomar
129
+ // por fórmula: se antepone un apóstrofo, como recomienda OWASP. Los números son nuestros.
130
+ function textoCsv(s) {
131
+ const t = s == null ? '' : String(s);
132
+ return /^[=+\-@\t\r]/.test(t) ? `'${t}` : t;
133
+ }
134
+ export function csvDe(estado) {
135
+ const num = (x) => (x ? String(x) : '');
136
+ const lineas = [CSV_CABECERA.join(',')];
137
+ for (const r of estado.entries) {
138
+ lineas.push([r.date, r.entry, textoCsv(r.concept), textoCsv(r.counterparty), num(r.in), num(r.out), num(r.fee), String(r.balance)].map(campoCsv).join(','));
139
+ }
140
+ return `${lineas.join('\r\n')}\r\n`;
141
+ }
142
+ // estado-<local>-<desde>-<hasta>.csv; sin rango: inicio / la fecha de hoy. Sólo caracteres seguros
143
+ // para un nombre de archivo en una cabecera.
144
+ export function nombreCsv(estado, hoy = new Date()) {
145
+ const [local, domain] = String(estado.account).split('@');
146
+ const quien = domain && domain !== estado.house ? `${local}.${domain}` : local;
147
+ const desde = estado.since ? estado.since.slice(0, 10) : 'inicio';
148
+ const hasta = estado.until ? estado.until.slice(0, 10) : hoy.toISOString().slice(0, 10);
149
+ return `estado-${quien}-${desde}-${hasta}.csv`.replace(/[^A-Za-z0-9._-]/g, '-');
150
+ }
@@ -51,6 +51,119 @@ export async function datosInforme(estafeta, { dias = 7 } = {}) {
51
51
  };
52
52
  }
53
53
 
54
+ // ---------- Embudo (NX-801): de punta a punta, por fuente y por semana ----------
55
+ // Seis etapas, en el orden en que una persona las cruza. Cada una es UN evento del diario; el
56
+ // informe no infiere ninguna. `open_invite` cuenta visitas al enlace (una vista previa de
57
+ // WhatsApp también abre); las otras cinco cuentan direcciones raíz, una vez cada una.
58
+ export const ETAPAS = [
59
+ { id: 'open_invite', label: 'Invite opened' },
60
+ { id: 'join', label: 'Address created' },
61
+ { id: 'claude_connected', label: 'Claude connected' },
62
+ { id: 'first_message', label: 'First message sent' },
63
+ { id: 'first_quote', label: 'First contract' },
64
+ { id: 'mandate_created', label: 'First mandate' },
65
+ ];
66
+ // `connector_authorized` existía antes de NX-801 y se emite en el mismo punto que
67
+ // `claude_connected`: leerlo como alias hace que los recorridos anteriores al despliegue (Basti,
68
+ // 11-sep) muestren la etapa 3 en vez de un hueco. Las etapas 1 y 4 no tienen alias: antes no se
69
+ // medían, y el informe las muestra vacías en vez de inventarlas.
70
+ const ALIAS = { connector_authorized: 'claude_connected' };
71
+
72
+ // Semana ISO-8601 (lunes a domingo, la semana 1 es la del primer jueves), en UTC como el diario.
73
+ export function semanaIso(ts) {
74
+ const d = new Date(ts);
75
+ if (Number.isNaN(d.getTime())) return null;
76
+ d.setUTCHours(0, 0, 0, 0);
77
+ d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
78
+ const primero = Date.UTC(d.getUTCFullYear(), 0, 1);
79
+ return `${d.getUTCFullYear()}-W${String(Math.ceil(((d - primero) / 86_400_000 + 1) / 7)).padStart(2, '0')}`;
80
+ }
81
+
82
+ // Qué lee: los `join` de siempre (son la lista de raíces y de fuentes) y todos los eventos de la
83
+ // ventana. Qué NO cubre: un delegado cuyo nombre no termine en `.<raíz>` de una raíz conocida queda
84
+ // en `sinRaiz`, contado y no atribuido; si el diario supera el tope, `truncado` lo dice.
85
+ export async function datosEmbudo(estafeta, { ventanaDias = 90, semanas = 8, diasRecorridos = 30, tope = 5000 } = {}) {
86
+ const ahora = Date.now();
87
+ const desde = new Date(ahora - ventanaDias * 86_400_000).toISOString();
88
+ const lee = async (q) => (await estafeta.store.listEvents?.(q)) || [];
89
+ const [eventos, joins] = await Promise.all([lee({ since: desde, limit: tope }), lee({ name: 'join', limit: tope })]);
90
+
91
+ // Raíces conocidas: quien hizo join, y quien las etapas 3 y 4 nombran (por construcción, raíces).
92
+ const raices = new Set(joins.map((e) => e.actor).filter(Boolean));
93
+ for (const e of eventos) if (['claude_connected', 'connector_authorized', 'first_message'].includes(e.name) && e.actor) raices.add(e.actor);
94
+ // Un delegado se llama <nombre>.<raíz>@casa: se prueba cada sufijo hasta dar con una raíz conocida.
95
+ const raizDe = (dir) => {
96
+ if (typeof dir !== 'string' || !dir.includes('@')) return null;
97
+ if (raices.has(dir)) return dir;
98
+ const [local, dominio] = dir.split('@');
99
+ const partes = local.split('.');
100
+ for (let i = 1; i < partes.length; i++) { const c = `${partes.slice(i).join('.')}@${dominio}`; if (raices.has(c)) return c; }
101
+ return null;
102
+ };
103
+ const recorridos = new Map();
104
+ const alcanza = (raiz, etapa, ts) => {
105
+ const r = recorridos.get(raiz) || { address: raiz, source: null, code: null, etapas: {} };
106
+ if (!r.etapas[etapa] || ts < r.etapas[etapa]) r.etapas[etapa] = ts;
107
+ recorridos.set(raiz, r);
108
+ return r;
109
+ };
110
+ const sinRaiz = {};
111
+ const aperturas = [];
112
+ for (const e of joins) { const r = alcanza(e.actor, 'join', e.ts); r.source = r.source || e.data?.source || null; }
113
+ for (const e of eventos) {
114
+ const etapa = ALIAS[e.name] || e.name;
115
+ if (etapa === 'open_invite') { aperturas.push(e); continue; }
116
+ if (etapa === 'join' || !ETAPAS.some((x) => x.id === etapa)) continue;
117
+ // Un contrato lo alcanzan las dos partes: el actor de `first_quote` es el comprador, `seller` la otra.
118
+ for (const p of etapa === 'first_quote' ? [e.actor, e.data?.seller] : [e.actor]) {
119
+ const raiz = raizDe(p);
120
+ if (!raiz) { if (p) sinRaiz[etapa] = (sinRaiz[etapa] || 0) + 1; continue; }
121
+ const r = alcanza(raiz, etapa, e.ts);
122
+ if (etapa === 'claude_connected' && e.data?.code && !r.code) r.code = e.data.code;
123
+ }
124
+ }
125
+ // Enlace abierto -> dirección: por el código de la invitación que `claude_connected` trae. Si el
126
+ // join no declaró fuente, la del enlace es la atribución (así se atribuye a quien llegó por la app).
127
+ const porCodigo = new Map();
128
+ for (const a of aperturas) { const c = a.data?.code; if (c && (!porCodigo.has(c) || a.ts < porCodigo.get(c).ts)) porCodigo.set(c, a); }
129
+ for (const r of recorridos.values()) {
130
+ const a = r.code && porCodigo.get(r.code);
131
+ if (a) { alcanza(r.address, 'open_invite', a.ts); r.source = r.source || a.data?.source || null; }
132
+ }
133
+ const NO = '(not declared)';
134
+ const inc = (tabla, fila, etapa) => { const f = tabla.get(fila) || {}; f[etapa] = (f[etapa] || 0) + 1; tabla.set(fila, f); };
135
+
136
+ // Por fuente, en la ventana: aperturas por su propia fuente; direcciones cuyo join cae en la
137
+ // ventana (o que no tienen join registrado) por la fuente del recorrido.
138
+ const porFuente = new Map();
139
+ for (const a of aperturas) inc(porFuente, a.data?.source || NO, 'open_invite');
140
+ for (const r of recorridos.values()) {
141
+ if (r.etapas.join && r.etapas.join < desde) continue;
142
+ for (const etapa of Object.keys(r.etapas)) if (etapa !== 'open_invite') inc(porFuente, r.source || NO, etapa);
143
+ }
144
+ // Por semana ISO: las últimas N, la actual incluida. Cada etapa cuenta en la semana en que se alcanzó.
145
+ const semanasLista = [];
146
+ for (let i = semanas - 1; i >= 0; i--) semanasLista.push(semanaIso(ahora - i * 7 * 86_400_000));
147
+ const porSemana = new Map(semanasLista.map((s) => [s, {}]));
148
+ for (const a of aperturas) { const s = semanaIso(a.ts); if (porSemana.has(s)) inc(porSemana, s, 'open_invite'); }
149
+ for (const r of recorridos.values()) for (const [etapa, ts] of Object.entries(r.etapas)) { if (etapa === 'open_invite') continue; const s = semanaIso(ts); if (porSemana.has(s)) inc(porSemana, s, etapa); }
150
+ // Recorridos individuales: los que alcanzaron alguna etapa en los últimos N días, el más reciente primero.
151
+ const corte = new Date(ahora - diasRecorridos * 86_400_000).toISOString();
152
+ const ultima = (r) => Object.values(r.etapas).sort().at(-1) || '';
153
+ const recientes = [...recorridos.values()].filter((r) => ultima(r) >= corte).sort((a, b) => (ultima(a) < ultima(b) ? 1 : -1))
154
+ .map((r) => ({ address: r.address, source: r.source, etapas: Object.fromEntries(ETAPAS.map((x) => [x.id, r.etapas[x.id] || null])) }));
155
+ const fila = (t) => ETAPAS.map((x) => t[x.id] || 0);
156
+ return {
157
+ generado: new Date(ahora).toISOString(), desde, ventanaDias, semanas, diasRecorridos,
158
+ eventosLeidos: eventos.length, joinsLeidos: joins.length, tope, truncado: eventos.length >= tope || joins.length >= tope,
159
+ etapas: ETAPAS.map((x) => x.label),
160
+ porFuente: [...porFuente.entries()].sort((a, b) => (b[1].join || 0) - (a[1].join || 0) || (b[1].open_invite || 0) - (a[1].open_invite || 0)).map(([fuente, t]) => ({ fuente, conteos: fila(t) })),
161
+ porSemana: [...porSemana.entries()].map(([semana, t]) => ({ semana, conteos: fila(t) })),
162
+ recorridos: recientes,
163
+ raices: recorridos.size, sinRaiz,
164
+ };
165
+ }
166
+
54
167
  // Las frases que interpretan los números. Cada una puede ser mala noticia, y esa es la idea.
55
168
  export function lecturas(d) {
56
169
  const L = [];
@@ -67,7 +180,31 @@ export function lecturas(d) {
67
180
  return L;
68
181
  }
69
182
 
70
- export function informeHtml(dominio, d) {
183
+ // La sección privada del embudo: tablas sin JS, cada celda escapada. Sólo se arma cuando la
184
+ // ruta privada la pide; /report nunca la recibe.
185
+ export function embudoHtml(e) {
186
+ const th = (xs) => `<tr>${xs.map((x) => `<th>${esc(x)}</th>`).join('')}</tr>`;
187
+ const tr = (cabeza, xs) => `<tr><td class="k">${esc(cabeza)}</td>${xs.map((x) => `<td class="n">${esc(x)}</td>`).join('')}</tr>`;
188
+ const fecha = (ts) => (ts ? ts.slice(0, 10) : '—');
189
+ const tabla = (cabeza, filas) => `<div class="ancha"><table class="embudo">${th(cabeza)}${filas.join('')}</table></div>`;
190
+ const vacio = (texto) => `<p class="rango">${esc(texto)}</p>`;
191
+ return `
192
+ <h2>Funnel</h2>
193
+ <p class="rango">Private: this section names addresses. Read from the events diary at load time: ${esc(e.eventosLeidos)} events in the last ${esc(e.ventanaDias)} days and ${esc(e.joinsLeidos)} joins overall (limit ${esc(e.tope)} each${e.truncado ? ', REACHED: older events were left out' : ''}). "Invite opened" counts link visits, previews included; the other five count addresses once each. A missing stage is shown as a dash, never guessed.</p>
194
+
195
+ <h3>By source, last ${esc(e.ventanaDias)} days</h3>
196
+ ${e.porFuente.length ? tabla(['Source', ...e.etapas], e.porFuente.map((f) => tr(f.fuente, f.conteos))) : vacio('No joins and no invite opened in this window.')}
197
+
198
+ <h3>By ISO week, last ${esc(e.semanas)}</h3>
199
+ ${tabla(['Week', ...e.etapas], e.porSemana.map((s) => tr(s.semana, s.conteos)))}
200
+
201
+ <h3>Journeys, last ${esc(e.diasRecorridos)} days</h3>
202
+ ${e.recorridos.length ? tabla(['Address', 'Source', ...e.etapas], e.recorridos.map((r) => tr(r.address, [r.source || '(not declared)', ...ETAPAS.map((x) => fecha(r.etapas[x.id]))]))) : vacio('Nobody reached any stage in this window.')}
203
+ ${Object.keys(e.sinRaiz).length ? `<p class="rango">Not attributed to any known root address: ${esc(Object.entries(e.sinRaiz).map(([k, v]) => `${k} ${v}`).join(', '))}.</p>` : ''}
204
+ `;
205
+ }
206
+
207
+ export function informeHtml(dominio, d, { embudo = null } = {}) {
71
208
  const fila = (k, v) => `<tr><td>${esc(k)}</td><td class="n">${esc(v)}</td></tr>`;
72
209
  const fuentes = Object.entries(d.porFuente).sort((a, b) => b[1] - a[1]);
73
210
  return `<!doctype html>
@@ -99,6 +236,13 @@ export function informeHtml(dominio, d) {
99
236
  li b { color:var(--ink); }
100
237
  footer { color:var(--dim); font-size:.82rem; border-top:1px solid var(--line); padding-top:1.4rem; }
101
238
  a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; }
239
+ h3 { font-size:.95rem; font-weight:600; margin:0 0 .6rem; }
240
+ .ancha { overflow-x:auto; margin:0 0 2rem; }
241
+ table.embudo { font-size:.8rem; margin:0; }
242
+ table.embudo th { text-align:right; font-weight:600; color:var(--dim); padding:.35rem .4rem; border-bottom:1px solid var(--line); white-space:nowrap; }
243
+ table.embudo th:first-child, table.embudo td.k { text-align:left; padding-left:0; }
244
+ table.embudo td { padding:.35rem .4rem; }
245
+ table.embudo td.n { font-size:.8rem; }
102
246
  </style>
103
247
  </head>
104
248
  <body>
@@ -124,9 +268,9 @@ export function informeHtml(dominio, d) {
124
268
 
125
269
  <h2>Where they came from</h2>
126
270
  ${fuentes.length ? `<table>${fuentes.map(([f, n]) => fila(f, n)).join('')}</table><p class="rango">Joins, not visits. A page view is not an agent.</p>` : '<p class="rango">Nobody joined, so there is nothing to attribute.</p>'}
127
-
271
+ ${embudo ? embudoHtml(embudo) : ''}
128
272
  <footer>
129
- No agent names, no counterparties and no message content appear here — only how many and how much.
273
+ ${embudo ? 'This page is private to the house and names addresses; the public <code>/report</code> does not.' : 'No agent names, no counterparties and no message content appear here — only how many and how much.'}
130
274
  Each agent's own record is public and verifiable separately at <code>/agents/&lt;name&gt;/historial</code>.
131
275
  <br><a href="/">Home</a> · <a href="/spec">Specification</a>
132
276
  </footer>
@@ -28,6 +28,7 @@
28
28
  import { signObject, verifyObject, canonical, sha256hex, uuid } from '../nucleo/crypto.js';
29
29
  import { Resolver, parseAddress } from '../correo/resolver.js';
30
30
  import { CONTRATOS } from './contratos.js';
31
+ import { boletaDe } from './estado.js';
31
32
  import { LibroError } from './errores.js';
32
33
  export { LibroError };
33
34
 
@@ -35,6 +36,8 @@ export const MEDIA = {
35
36
  op: 'application/nyx5.libro+json',
36
37
  cotizacion: 'application/nyx5.cotizacion+json',
37
38
  recibo: 'application/nyx5.recibo+json',
39
+ // NX-305: un comprador PIDE un servicio del catálogo; el vendedor contesta con la cotización.
40
+ pedido: 'application/nyx5.pedido+json',
38
41
  };
39
42
 
40
43
  const iso = () => new Date().toISOString();
@@ -42,10 +45,13 @@ const iso = () => new Date().toISOString();
42
45
  export class Libro {
43
46
  // feeBps: fee de la casa en basis points enteros (1000 = 10%). `feePct` sigue aceptándose
44
47
  // como azúcar (0.10 -> 1000) pero el cálculo es siempre entero: sin punto flotante en el dinero.
45
- constructor({ domain, store, keys, resolver, feeBps = null, feePct = null, welcome = 0, log = () => {} }) {
48
+ // reclaimGraceMs / reviewWindowMs (NX-503): cuánto después del plazo el comprador recupera un
49
+ // escrow sin entrega, y cuánto tiene para objetar una entrega antes de que se libere sola.
50
+ constructor({ domain, store, keys, resolver, feeBps = null, feePct = null, welcome = 0, reclaimGraceMs = 24 * 3600_000, reviewWindowMs = 72 * 3600_000, log = () => {} }) {
46
51
  this.domain = domain; this.store = store; this.keys = keys; this.resolver = resolver;
47
52
  this.feeBps = feeBps ?? (feePct != null ? Math.round(feePct * 10_000) : 1000);
48
53
  this.welcome = welcome; this.log = log;
54
+ this.reclaimGraceMs = reclaimGraceMs; this.reviewWindowMs = reviewWindowMs;
49
55
  this.casa = `casa@${domain}`;
50
56
  this.address = `libro@${domain}`;
51
57
  this.tx = null; // transacción en curso: { state, asientos, contracts: Map, mandates: Map, op }
@@ -62,11 +68,11 @@ export class Libro {
62
68
  // y ambos entrarían, superando el tope.
63
69
  async _begin(concepto = 'control', refs = {}) {
64
70
  const base = await this.store.libroState();
65
- this.tx = { base, state: null, asientos: [], contracts: new Map(), mandates: new Map(), op: null, concepto, refs };
71
+ this.tx = { base, state: null, asientos: [], contracts: new Map(), mandates: new Map(), sellos: [], op: null, concepto, refs };
66
72
  }
67
73
  _bundle() {
68
74
  const t = this.tx;
69
- return { state: t.state, base: t.base, asientos: t.asientos, contracts: [...t.contracts.values()], mandates: [...t.mandates.values()], op: t.op };
75
+ return { state: t.state, base: t.base, asientos: t.asientos, contracts: [...t.contracts.values()], mandates: [...t.mandates.values()], sellos: t.sellos, op: t.op };
70
76
  }
71
77
  async _commit() {
72
78
  const t = this.tx;
@@ -90,6 +96,9 @@ export class Libro {
90
96
  putContract(c) { if (!this.tx) throw new LibroError(500, 'putContract fuera de transacción'); this.tx.contracts.set(c.id, c); }
91
97
  async getMandate(id) { return this.tx?.mandates.get(id) ?? await this.store.libroGetMandate(id); }
92
98
  putMandate(m) { if (!this.tx) throw new LibroError(500, 'putMandate fuera de transacción'); this.tx.mandates.set(m.id, m); }
99
+ // Sello de notaría (NX-601): no mueve dinero ni consume número de asiento. Su candado contra el
100
+ // doble sello es el índice único (sha256, by) del almacén, que hace fallar cerrado el commit.
101
+ putSello(s) { if (!this.tx) throw new LibroError(500, 'putSello fuera de transacción'); this.tx.sellos.push(s); }
93
102
 
94
103
  // ---------- consultas ----------
95
104
  get ops() { return Object.keys(CONTRATOS.ops); }
@@ -114,13 +123,17 @@ export class Libro {
114
123
  // bond released | forfeited afirmación sostenida | derribada
115
124
  static TERMINALES = { spot: ['settled'], escrow: ['released', 'refunded'], bond: ['released', 'forfeited'], metered: [] };
116
125
 
117
- async historial(address) {
118
- const todos = await this.store.libroListContracts();
126
+ // `contratos`: la lista ya leída, para que un lote de historiales (la ruta en lote, el rastreo
127
+ // del índice) no vuelva a leer la tabla por cada dirección.
128
+ async historial(address, { contratos = null } = {}) {
129
+ const todos = contratos ?? await this.store.libroListContracts();
119
130
  const mios = todos.filter((c) => [c.seller, c.buyer].includes(address));
120
131
  const cuenta = () => ({ n: 0, tokens: 0 });
121
132
  const h = {
122
133
  address, house: this.domain,
123
- vendiendo: { entregas_aceptadas: cuenta(), entregas_devueltas: cuenta(), ventas_directas: cuenta() },
134
+ // entregas_por_silencio: liberadas por el reloj de la casa al vencer la ventana de revisión
135
+ // (NX-503). Silencio no es aceptación: se cuentan aparte, y también dentro de aceptadas.
136
+ vendiendo: { entregas_aceptadas: cuenta(), entregas_por_silencio: cuenta(), entregas_devueltas: cuenta(), ventas_directas: cuenta() },
124
137
  comprando: { encargos_liberados: cuenta(), encargos_devueltos: cuenta(), compras_directas: cuenta() },
125
138
  afirmando: { fianzas_sostenidas: cuenta(), fianzas_ejecutadas: cuenta(), fianzas_vigentes: cuenta() },
126
139
  avalando: { avales_sostenidos: cuenta(), avales_ejecutados: cuenta() },
@@ -142,7 +155,10 @@ export class Libro {
142
155
  else sumar(sostenida ? grupo.fianzas_sostenidas : grupo.fianzas_ejecutadas, monto);
143
156
  } else if (c.seller === address) {
144
157
  if (c.kind === 'spot') sumar(h.vendiendo.ventas_directas, monto);
145
- else sumar(c.state === 'released' ? h.vendiendo.entregas_aceptadas : h.vendiendo.entregas_devueltas, monto);
158
+ else {
159
+ sumar(c.state === 'released' ? h.vendiendo.entregas_aceptadas : h.vendiendo.entregas_devueltas, monto);
160
+ if (c.state === 'released' && c.history?.some((x) => x.op === 'expire')) sumar(h.vendiendo.entregas_por_silencio, monto);
161
+ }
146
162
  } else {
147
163
  if (c.kind === 'spot') sumar(h.comprando.compras_directas, monto);
148
164
  else sumar(c.state === 'released' ? h.comprando.encargos_liberados : h.comprando.encargos_devueltos, monto);
@@ -150,6 +166,24 @@ export class Libro {
150
166
  }
151
167
  // Fianzas todavía en pie: no son historial cumplido, pero sí tokens en juego ahora mismo.
152
168
  for (const c of mios) if (c.kind === 'bond' && c.state === 'posted' && c.seller === address) sumar(h.afirmando.fianzas_vigentes, Number(c.amount) || 0);
169
+ // Arbitrados por la casa (NX-302): sólo los contratos en que este agente VENDÍA, el árbitro
170
+ // era `verifica@<casa>` y el veredicto terminal (release / refund / forfeit) lo dio ese
171
+ // árbitro, no una parte. Es la porción del historial que dos cómplices no pueden fabricar
172
+ // entre sí: para sumar aquí hay que pasar una prueba determinista que corre la casa. Un
173
+ // escrow liberado por el comprador, o por silencio (`expire`), queda fuera aunque el
174
+ // contrato nombrara a verifica@ de árbitro. Campo ADITIVO: la forma anterior no cambia.
175
+ const arbitro = `verifica@${this.domain}`;
176
+ h.arbitrados = { arbitro, liberados: cuenta(), devueltos: cuenta(), ejecutadas: cuenta() };
177
+ for (const c of mios) {
178
+ if (c.seller !== address || c.arbiter !== arbitro) continue;
179
+ if (!(Libro.TERMINALES[c.kind] || []).includes(c.state)) continue;
180
+ const veredicto = (c.history || []).filter((x) => ['release', 'refund', 'forfeit'].includes(x.op)).at(-1);
181
+ if (!veredicto || veredicto.by !== arbitro) continue;
182
+ const monto = Number(c.amount) || 0;
183
+ if (veredicto.op === 'release') sumar(h.arbitrados.liberados, monto);
184
+ else if (veredicto.op === 'refund') sumar(h.arbitrados.devueltos, monto);
185
+ else sumar(h.arbitrados.ejecutadas, monto);
186
+ }
153
187
  // El resumen es lo que un agente lee para decidir en una línea; el detalle queda arriba.
154
188
  const entregadas = h.vendiendo.entregas_aceptadas.n + h.vendiendo.ventas_directas.n;
155
189
  const falladas = h.vendiendo.entregas_devueltas.n;
@@ -162,6 +196,8 @@ export class Libro {
162
196
  // Sin historial no hay tasa: cero de cero no es 100%, es "todavía nada". Lo decimos así.
163
197
  cumplimiento: entregadas + falladas > 0 ? Number((entregadas / (entregadas + falladas)).toFixed(4)) : null,
164
198
  veracidad: afirmaciones > 0 ? Number((h.afirmando.fianzas_sostenidas.n / afirmaciones).toFixed(4)) : null,
199
+ // Lo que el índice federado usa para ordenar (§13): ponderado por tokens, null sin historial arbitrado.
200
+ puntaje_arbitrado: puntajeArbitrado(h.arbitrados),
165
201
  };
166
202
  return h;
167
203
  }
@@ -177,7 +213,7 @@ export class Libro {
177
213
  const next = { ...state.balances };
178
214
  for (const l of lines) {
179
215
  next[l.account] = (next[l.account] || 0) + l.delta;
180
- if (next[l.account] < 0 && l.account !== this.casa) throw new LibroError(402, `saldo insuficiente en ${l.account} (tiene ${state.balances[l.account] || 0}, necesita ${-l.delta})`);
216
+ if (next[l.account] < 0 && l.account !== this.casa) throw new LibroError(402, `insufficient balance in ${l.account} (has ${state.balances[l.account] || 0}, needs ${-l.delta})`);
181
217
  }
182
218
  const asiento = signObject({ id: uuid(), n: state.seq + 1, at: iso(), house: this.domain, concept, lines, meta, refs }, this.keys);
183
219
  if (this.tx) {
@@ -228,8 +264,11 @@ export class Libro {
228
264
 
229
265
  // ---------- cotizaciones: documentos firmados por el vendedor ----------
230
266
  // Una cotización viaja adentro de un sobre (cifrado si se quiere) y se presenta al Libro al aceptar.
231
- static buildQuote({ seller, buyer, house, contract = 'spot', price, concept, terms = {}, expires, arbiter = null, referrer = null }, sellerKeys) {
267
+ static buildQuote({ seller, buyer, house, contract = 'spot', price, concept, terms = {}, expires, arbiter = null, referrer = null, service = null }, sellerKeys) {
232
268
  if (!CONTRATOS[contract]?.quoteable) throw new LibroError(400, `contrato no cotizable: ${contract}`);
269
+ // `service` (NX-301): el id de un servicio publicado en la ficha del vendedor. Va firmado en la
270
+ // cotización y verifyQuote lo cruza con la ficha al aceptar: precio y contrato tienen que ser los publicados.
271
+ if (service != null && (typeof service !== 'string' || !/^[a-z0-9-]{1,40}$/.test(service))) throw new LibroError(400, 'service debe ser el id de un servicio de la ficha (letras minúsculas, dígitos y guiones)');
233
272
  if (arbiter) parseAddress(arbiter);
234
273
  // Comisión de referido: el vendedor firma en su cotización que le paga `share` (en basis points)
235
274
  // a quien trajo el trato. La comisión sale de LO QUE RECIBE el vendedor, no se suma al precio:
@@ -238,7 +277,7 @@ export class Libro {
238
277
  parseAddress(referrer.address);
239
278
  if (!Number.isInteger(referrer.share) || referrer.share <= 0) throw new LibroError(400, 'referrer.share debe ser un entero de basis points > 0');
240
279
  }
241
- return signObject({ tipo: 'cotizacion', id: uuid(), house, seller, buyer, contract, price, currency: 'tok', concept, terms, arbiter, referrer: referrer || undefined, issued: iso(), expires: expires || null }, sellerKeys);
280
+ return signObject({ tipo: 'cotizacion', id: uuid(), house, seller, buyer, contract, price, currency: 'tok', concept, terms, arbiter, referrer: referrer || undefined, service: service || undefined, issued: iso(), expires: expires || null }, sellerKeys);
242
281
  }
243
282
  async verifyQuote(q, buyer) {
244
283
  if (q?.tipo !== 'cotizacion' || !q.id || !q.seller || !q.signature) throw new LibroError(400, 'cotización malformada');
@@ -260,6 +299,19 @@ export class Libro {
260
299
  let card;
261
300
  try { card = await this.resolver.agentCardForKid(q.seller, q.signature.kid); } catch (e) { throw new LibroError(e.permanent ? 403 : 421, `no se pudo verificar al vendedor: ${e.message}`); }
262
301
  if (!Resolver.acceptedKids(card).includes(q.signature.kid) || !verifyObject(q, q.signature.kid)) throw new LibroError(403, 'firma de la cotización inválida');
302
+ // Una dirección de sólo mensajes (la del Claude de un teléfono, cuya llave guarda la casa) no
303
+ // vende: aceptarle una cotización le movería saldo, que es justo lo que su dueño no autorizó.
304
+ if (card.delegation?.scope?.messages_only) throw new LibroError(403, 'the seller is a messages-only address: it cannot sell');
305
+ // Cotización sobre un servicio publicado (NX-301): se compara contra la ficha CERTIFICADA del
306
+ // vendedor tal como está hoy, nunca contra lo que la cotización dice de sí misma. Mismo espíritu
307
+ // que tareas.coincide: un precio o un contrato distintos del publicado son rechazo, no negociación.
308
+ // Si el vendedor cambió su catálogo después de cotizar, la cotización vieja ya no coincide y se rechaza.
309
+ if (q.service != null) {
310
+ if (typeof q.service !== 'string') throw new LibroError(400, 'service must be the id of a published service');
311
+ const s = (card.profile?.services || []).find((x) => x.id === q.service);
312
+ if (!s) throw new LibroError(400, `service "${q.service}" is not published in the profile of ${q.seller}`);
313
+ if (s.price.tokens !== q.price || s.contract !== q.contract) throw new LibroError(400, `service ${s.id} is published at ${s.price.tokens} tok as ${s.contract}; the quote says ${q.price} as ${q.contract}`);
314
+ }
263
315
  if (await this.store.libroFindContractByQuote(q.id)) throw new LibroError(409, 'cotización ya aceptada');
264
316
  return card;
265
317
  }
@@ -273,6 +325,9 @@ export class Libro {
273
325
  const body = env.content.body || {};
274
326
  const op = CONTRATOS.ops[body.op];
275
327
  if (!op) return { ok: false, code: 400, reason: `unknown operation: ${body.op}. Valid ones: ${Object.keys(CONTRATOS.ops).join(', ')}` };
328
+ // Sólo mensajes no opera el Libro, entre el sobre por donde entre: la regla vive donde se mueve
329
+ // el dinero, no sólo en la puerta (revisión del 11-sep-2026).
330
+ if (senderCard?.delegation?.scope?.messages_only) return { ok: false, code: 403, reason: 'this is a messages-only address: it cannot operate the ledger' };
276
331
  const ctx = { libro: this, env, from: env.from, body, senderCard, opHash: sha256hex(canonical(env)), scope: senderCard?.delegation?.scope || null };
277
332
  return this._serial(async () => {
278
333
  await this._begin(`op ${body.op}`, { op: env.id, op_sha256: ctx.opHash });
@@ -283,7 +338,11 @@ export class Libro {
283
338
  if (e instanceof LibroError) return { ok: false, code: e.code, reason: e.message };
284
339
  throw e;
285
340
  }
286
- const out = { ok: true, code: 202, result: result.result, recibos: (result.recibos || []).map((r) => ({ ...r, body: { ...r.body, of: env.id, op: body.op, op_sha256: ctx.opHash, from: env.from } })), avisos: result.avisos || [] };
341
+ // Boleta (NX-501): todo recibo que lleva un asiento con reparto dice cuánto fue fee de la casa
342
+ // y cuánto comisión de referido, leído de las líneas del asiento (estado.js), en el único
343
+ // punto donde se arman los recibos.
344
+ const boleta = (b) => boletaDe(b.asiento, { casa: this.casa, feeBps: this.feeBps, share: b.contract?.referrer?.share ?? null });
345
+ const out = { ok: true, code: 202, result: result.result, recibos: (result.recibos || []).map((r) => ({ ...r, body: { ...r.body, ...boleta(r.body), of: env.id, op: body.op, op_sha256: ctx.opHash, from: env.from } })), avisos: result.avisos || [] };
287
346
  this.tx.op = { id: env.id, result: out };
288
347
  try { await this._commit(); }
289
348
  catch (e) {
@@ -301,7 +360,7 @@ export class Libro {
301
360
  // dedupe del sobre (inboundCommit) — así una reentrega jamás cobra la estampilla dos veces.
302
361
  async stamp(env, recipient, price) {
303
362
  const s = env.stamp;
304
- if (!s || s.house !== this.domain || !Number.isInteger(s.amount) || s.amount < price) throw new LibroError(402, `este buzón exige estampilla de ${price} tok en la casa ${this.domain} (campo stamp: {house, amount})`);
363
+ if (!s || s.house !== this.domain || !Number.isInteger(s.amount) || s.amount < price) throw new LibroError(402, `this mailbox requires a stamp of ${price} tok in house ${this.domain} (stamp field: {house, amount})`);
305
364
  return this._serial(async () => {
306
365
  await this._begin('estampilla', { envelope: env.id });
307
366
  try {
@@ -313,3 +372,15 @@ export class Libro {
313
372
  });
314
373
  }
315
374
  }
375
+
376
+ // Puntaje de reputación arbitrada: tokens liberados sobre tokens decididos por verifica@
377
+ // (liberados + devueltos + fianzas ejecutadas). Ponderado por monto, no por cuenta: una entrega
378
+ // de 5.000 pesa más que diez de 10. `null` cuando no hay nada decidido: cero de cero no es 100 %.
379
+ // UNA definición: la usan `historial` (resumen.puntaje_arbitrado) y el índice federado.
380
+ export function puntajeArbitrado(arbitrados) {
381
+ if (!arbitrados || typeof arbitrados !== 'object') return null;
382
+ const t = (k) => Number(arbitrados[k]?.tokens) || 0;
383
+ const total = t('liberados') + t('devueltos') + t('ejecutadas');
384
+ if (total <= 0) return null;
385
+ return Number((t('liberados') / total).toFixed(4));
386
+ }
@@ -0,0 +1,74 @@
1
+ // Nyx5/1 — Notaría (NX-601): la casa sella el hash de un documento con fecha y firma.
2
+ //
3
+ // Qué certifica un sello: que en el instante `at` la casa recibió un sobre firmado (hash
4
+ // `op_sha256`) en el que `by` declaró el hash `sha256`. Nada más. La casa no ve el documento, no
5
+ // afirma que exista ni qué contiene: quien lo tenga puede demostrar que ya existía a esa fecha
6
+ // presentando el documento (su hash coincide) y el sello (la firma de la casa verifica con la
7
+ // tarjeta del dominio, sin cuenta).
8
+ //
9
+ // No mueve dinero: no hay asiento. El candado contra el doble sello es el índice único
10
+ // (sha256, by) del almacén, no el número de asiento: dos sobres distintos del mismo agente con el
11
+ // mismo hash producen UN sello (el primero) y la segunda petición recibe ese mismo sello. Dos
12
+ // agentes distintos sí producen dos sellos: cada uno atestigua que ÉL lo declaró.
13
+ //
14
+ // Dos versiones firmadas del mismo sello, con el mismo id: `sello` (con `by`) y `anonimo` (con
15
+ // `by: null`). La pública sirve la primera si el agente es visible y la segunda si es secreto,
16
+ // para que un secreto no quede confirmado por su propio sello (NX-202) y aun así la firma
17
+ // verifique. Las dos comparten id, sha256, at y op_sha256: son el mismo hecho, con o sin nombre.
18
+ //
19
+ // Qué NO cubre: no prueba autoría del documento (prueba quién lo DECLARÓ), no prueba que el
20
+ // documento sea anterior a `at` (sólo que no es posterior), y no verifica que `media` o `name`
21
+ // describan al documento: son lo que el declarante dijo.
22
+
23
+ import { signObject, uuid } from '../nucleo/crypto.js';
24
+ import { limpio } from '../correo/politica.js';
25
+ import { LibroError } from './errores.js';
26
+
27
+ export const SHA256_HEX = /^[0-9a-fA-F]{64}$/;
28
+ export const LIMITES = Object.freeze({ name: 120, media: 100, note: 500 });
29
+
30
+ const fail = (code, msg) => { throw new LibroError(code, msg); };
31
+
32
+ // Valida el cuerpo de la op. Campos desconocidos se ignoran (invariante 7). El hash se normaliza
33
+ // a minúsculas: el mismo documento no puede tener dos sellos por cómo se escribió su hash.
34
+ export function validarNotarize(body = {}) {
35
+ if (typeof body.sha256 !== 'string' || !SHA256_HEX.test(body.sha256)) fail(400, 'sha256 must be the hex digest of the document: 64 hexadecimal characters');
36
+ const texto = (k) => {
37
+ if (body[k] == null) return null;
38
+ if (typeof body[k] !== 'string') fail(400, `${k} must be a string`);
39
+ return limpio(body[k], LIMITES[k]) || null;
40
+ };
41
+ return { sha256: body.sha256.toLowerCase(), name: texto('name'), media: texto('media'), note: texto('note') };
42
+ }
43
+
44
+ // Construye el registro firmado. `keys` son las llaves del dominio (las mismas que firman recibos).
45
+ export function sellar({ sha256, name, media, note, by, house, at, op_sha256, secret = false }, keys) {
46
+ const cuerpo = { nyx5: '1', tipo: 'sello', id: uuid(), sha256, name, media, note, by, house, at, op_sha256 };
47
+ return {
48
+ id: cuerpo.id, sha256, by, at,
49
+ // `secret` es la visibilidad del declarante cuando selló: un agente de OTRA casa no se puede
50
+ // re-consultar en vivo desde aquí, así que lo que se supo al sellar es lo que manda para él.
51
+ secret: !!secret,
52
+ sello: signObject(cuerpo, keys),
53
+ anonimo: signObject({ ...cuerpo, by: null }, keys),
54
+ };
55
+ }
56
+
57
+ // Lo que se sirve al público por un registro: la versión con nombre si el declarante es
58
+ // visible para «nadie» (quien pregunta sin cuenta), la anónima si no.
59
+ export function selloPublico(doc, visible) { return visible ? doc.sello : doc.anonimo; }
60
+
61
+ // La op del Libro. Corre dentro de la transacción de Libro.handle(): escribe con libro.putSello.
62
+ export async function notarize(ctx) {
63
+ const { libro, from, body, senderCard, opHash } = ctx;
64
+ const v = validarNotarize(body);
65
+ const previo = await libro.store.notariaFind(v.sha256, from);
66
+ if (previo) {
67
+ // El mismo declarante ya selló este hash: se le devuelve ESE sello, no se crea otro. La
68
+ // receta lleva el hash del sobre que la pidió (invariante 5) aunque el sello sea anterior.
69
+ return { result: { seal: previo.sello, existing: true }, recibos: [{ to: [from], body: { seal: previo.sello, existing: true } }] };
70
+ }
71
+ const doc = sellar({ ...v, by: from, house: libro.domain, at: new Date().toISOString(), op_sha256: opHash, secret: senderCard?.visibility === 'secret' }, libro.keys);
72
+ libro.putSello(doc);
73
+ return { result: { seal: doc.sello, existing: false }, recibos: [{ to: [from], body: { seal: doc.sello, existing: false } }] };
74
+ }