@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
@@ -8,9 +8,212 @@
8
8
 
9
9
  import { checkPow } from '../nucleo/crypto.js';
10
10
  import { parseAddress } from './resolver.js';
11
+ import { PRUEBAS } from '../libro/verifica.js';
12
+ import { CONTRATOS } from '../libro/contratos.js';
11
13
 
12
14
  export const TYPES = new Set(['message', 'task', 'result', 'receipt', 'intro']);
13
15
 
16
+ // Proyecto y rol de un sobre (13-sep-2026): un mismo dueño con varios chats sobre un solo conector
17
+ // distingue «Sigo Main» de «Rosetta Lab» con una extensión firmada dentro del sobre, y el buzón
18
+ // filtra por ella. Va en claro (las extensiones no se cifran): el nombre de un proyecto no es secreto.
19
+ export const EXT_PROYECTO = 'urn:nyx5:ext:proyecto';
20
+ // Revisión del 13-sep-2026: un `project` de 200.000 caracteres o un `role` con escapes de terminal
21
+ // llegaban tal cual a la herramienta del Claude remoto. Se limpian y acotan como la ficha, y el
22
+ // proyecto se normaliza (NFKC, sin invisibles) para que un homógrafo no esconda un mensaje del filtro.
23
+ export const proyectoDe = (env) => { const p = env?.extensions?.[EXT_PROYECTO]?.project; return typeof p === 'string' ? nombreDeProyecto(p) : null; };
24
+ // Devuelve null cuando lo que queda no es un nombre: vacío tras limpiar, o un nombre que es
25
+ // propiedad propia de Object.prototype (`__proto__`, `constructor`). Revisión del 14-sep-2026:
26
+ // `pending_by_project` viaja como objeto JSON y un lector que haga `mapa[p]` con ese nombre ausente
27
+ // recibe una función en vez de un número (o, al armar el objeto, pisa el prototipo). Es UNA función
28
+ // para las dos puertas: el cliente rechaza al enviar y la casa lee «sin proyecto» si llega de fuera.
29
+ export const nombreDeProyecto = (p) => { const n = limpio(String(p).normalize('NFKC'), 40).toLowerCase(); return n && !Object.hasOwn(Object.prototype, n) ? n : null; };
30
+ export const rolDe = (env) => { const r = env?.extensions?.[EXT_PROYECTO]?.role; return typeof r === 'string' ? (limpio(r, 40) || null) : null; };
31
+
32
+ // El RESUMEN EN CLARO (29-oct-2026, decisión de Nicholas). El aviso por correo sólo podía decir
33
+ // QUIÉN le escribió, nunca QUÉ: el contenido va cifrado y la casa no lo lee (invariante 8). Un
34
+ // timbre que no dice quién está en la puerta es ruido, y él tiene que abrir la app para saber si
35
+ // algo le toca. Ahora el REMITENTE puede adjuntar una línea que viaja SIN CIFRAR, y la casa la
36
+ // pone en el asunto del aviso.
37
+ //
38
+ // Quién decide qué se expone: el que escribe, no la casa. Por eso la línea es opcional y por eso
39
+ // la descripción de la herramienta dice, con todas sus letras, que la leen la casa y el correo.
40
+ // Nunca va ahí lo que el sobre protege: va de qué se trata, no lo que dice.
41
+ //
42
+ // Se limpia como el proyecto y se acota a UNA línea: `limpio` cambia los controles por espacios y
43
+ // colapsa, así que un salto de línea no puede partir el asunto de un correo en dos.
44
+ export const EXT_RESUMEN = 'urn:nyx5:ext:resumen';
45
+ export const RESUMEN_MAX = 140;
46
+ export const resumenDe = (env) => {
47
+ const r = env?.extensions?.[EXT_RESUMEN]?.text;
48
+ return typeof r === 'string' ? (limpio(r, RESUMEN_MAX) || null) : null;
49
+ };
50
+
51
+ // Ficha pública del agente (13-sep-2026): qué hace, en qué idiomas, de quién es, con qué etiquetas.
52
+ // Va DENTRO de la tarjeta certificada, la firma el dueño al declararla y la casa al certificarla.
53
+ // Vocabulario CERRADO, como el alcance de un mandato: una clave desconocida se rechaza nombrándola,
54
+ // porque una ficha es lo que otros agentes leen antes de contratar, y no puede llevar de todo.
55
+ // Lo declarado NO está verificado (eso es el sello de dueño, otra pieza): es lo que el dueño dice.
56
+ const PERFIL_CLAVES = ['display_name', 'summary', 'description', 'languages', 'tags', 'owner', 'links', 'services'];
57
+ // Fuera: controles C0 y C1, invisibles de ancho cero, y las marcas bidi que voltean el texto en
58
+ // pantalla (un nombre que se lee al revés es una suplantación). Los espacios se colapsan.
59
+ // Los controles pasan a espacio (separaban algo); los invisibles se quitan (no separaban nada, y
60
+ // "sig<ancho cero>o" tiene que quedar "sigo", no "sig o").
61
+ const CONTROLES = /[\x00-\x1f\x7f-\x9f]/g;
62
+ const INVISIBLES = /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g;
63
+ export const limpio = (s, max) => String(s).replace(INVISIBLES, '').replace(CONTROLES, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
64
+ export function validarPerfil(p) {
65
+ if (p === null) return { perfil: null };
66
+ if (typeof p !== 'object' || Array.isArray(p)) return { error: 'profile must be an object' };
67
+ const ajenas = Object.keys(p).filter((k) => !PERFIL_CLAVES.includes(k));
68
+ if (ajenas.length) return { error: `profile carries ${ajenas.map((k) => JSON.stringify(k)).join(', ')}, which this house does not publish; it accepts: ${PERFIL_CLAVES.join(', ')}` };
69
+ const out = {};
70
+ for (const [k, max] of [['display_name', 80], ['summary', 280], ['description', 2000]]) {
71
+ if (p[k] == null) continue;
72
+ if (typeof p[k] !== 'string') return { error: `profile.${k} must be a string` };
73
+ const v = limpio(p[k], max); if (v) out[k] = v;
74
+ }
75
+ if (p.languages != null) {
76
+ // Tope por etiqueta (35, el máximo razonable de BCP 47): sin él, diez etiquetas de 180 KB
77
+ // hacían una tarjeta de 1,8 MB firmada por la casa (revisión del 13-sep-2026).
78
+ if (!Array.isArray(p.languages) || p.languages.length > 10 || !p.languages.every((l) => typeof l === 'string' && l.length <= 35 && /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(l))) return { error: 'profile.languages must be up to 10 language tags like "es", "en-US"' };
79
+ out.languages = [...new Set(p.languages)];
80
+ }
81
+ if (p.tags != null) {
82
+ if (!Array.isArray(p.tags) || p.tags.length > 20 || !p.tags.every((t) => typeof t === 'string' && /^[a-z0-9-]{1,32}$/.test(t))) return { error: 'profile.tags must be up to 20 tags of lowercase letters, digits and dashes' };
83
+ out.tags = [...new Set(p.tags)];
84
+ }
85
+ if (p.owner != null) {
86
+ const o = p.owner;
87
+ if (typeof o !== 'object' || Array.isArray(o) || !['person', 'org'].includes(o.kind) || typeof o.name !== 'string' || Object.keys(o).some((k) => !['kind', 'name'].includes(k))) return { error: 'profile.owner must be { kind: person|org, name }' };
88
+ out.owner = { kind: o.kind, name: limpio(o.name, 120) };
89
+ }
90
+ if (p.links != null) {
91
+ if (!Array.isArray(p.links) || p.links.length > 5) return { error: 'profile.links must be up to 5 https URLs' };
92
+ // Sin usuario ni clave en la URL: `https://nyx5.com@evil.example/` se lee como nyx5.com y va a evil.
93
+ for (const l of p.links) { let u; try { u = new URL(String(l)); } catch { u = null; } if (!u || u.protocol !== 'https:' || u.username || u.password || String(l).length > 200) return { error: `profile.links: not a plain https URL: ${String(l).slice(0, 60)}` }; }
94
+ out.links = [...new Set(p.links.map(String))];
95
+ }
96
+ if (p.services != null) {
97
+ if (!Array.isArray(p.services) || p.services.length > SERVICIOS_MAX) return { error: `profile.services must be a list of up to ${SERVICIOS_MAX} services` };
98
+ const ids = new Set(), lista = [];
99
+ for (const s of p.services) {
100
+ const v = validarServicio(s);
101
+ if (v.error) return { error: v.error };
102
+ if (ids.has(v.servicio.id)) return { error: `profile.services: the id "${v.servicio.id}" appears twice` };
103
+ ids.add(v.servicio.id); lista.push(v.servicio);
104
+ }
105
+ if (lista.length) out.services = lista;
106
+ }
107
+ return { perfil: out };
108
+ }
109
+
110
+ // Catálogo de servicios en la ficha (NX-301): lo que el agente vende, con precio y contrato
111
+ // publicados. Una cotización que nombre un `service` se compara contra ESTO al aceptar
112
+ // (`Libro.verifyQuote`): el precio y el contrato de la cotización tienen que ser los publicados,
113
+ // igual que una tarea sembrada se cotiza con los términos del catálogo tal cual.
114
+ // Vocabulario cerrado por servicio, como el resto de la ficha. `acceptance.kind` sólo puede ser una
115
+ // prueba que verifica@ sabe correr (la lista es la de verifica.js, importada, no copiada) y
116
+ // `contract` sólo un tipo cotizable (la lista es la de contratos.js). `template` es texto para
117
+ // quien va a cotizar: no es la prueba concreta, ésa va en `terms.verify` de cada cotización.
118
+ // `price.usd` es un decimal en texto (no flotante) y SÓLO tiene sentido si la tarjeta declara
119
+ // billetera: eso lo comprueba `usdSinBilletera` en la ruta, porque este validador no ve la tarjeta.
120
+ const SERVICIOS_MAX = 20;
121
+ const SERVICIO_CLAVES = ['id', 'name', 'summary', 'price', 'unit', 'contract', 'acceptance'];
122
+ const UNIDADES = ['job', 'call', 'hour'];
123
+ const contratosCotizables = () => Object.keys(CONTRATOS).filter((k) => CONTRATOS[k]?.quoteable);
124
+ export function validarServicio(s) {
125
+ if (typeof s !== 'object' || s === null || Array.isArray(s)) return { error: 'profile.services: each service must be an object' };
126
+ const ajenas = Object.keys(s).filter((k) => !SERVICIO_CLAVES.includes(k));
127
+ if (ajenas.length) return { error: `profile.services carries ${ajenas.map((k) => JSON.stringify(k)).join(', ')}, which this house does not publish; a service accepts: ${SERVICIO_CLAVES.join(', ')}` };
128
+ if (typeof s.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(s.id)) return { error: 'profile.services[].id must be 1-40 lowercase letters, digits and dashes' };
129
+ const donde = `profile.services[${s.id}]`;
130
+ if (typeof s.name !== 'string' || !limpio(s.name, 80)) return { error: `${donde}.name must be a non-empty string` };
131
+ const out = { id: s.id, name: limpio(s.name, 80) };
132
+ if (s.summary != null) { if (typeof s.summary !== 'string') return { error: `${donde}.summary must be a string` }; const v = limpio(s.summary, 280); if (v) out.summary = v; }
133
+ const pr = s.price;
134
+ if (typeof pr !== 'object' || pr === null || Array.isArray(pr) || Object.keys(pr).some((k) => !['tokens', 'usd'].includes(k))) return { error: `${donde}.price must be { tokens, usd? }` };
135
+ if (!Number.isInteger(pr.tokens) || pr.tokens <= 0) return { error: `${donde}.price.tokens must be a positive integer` };
136
+ out.price = { tokens: pr.tokens };
137
+ if (pr.usd != null) {
138
+ // Decimal en texto con hasta 6 decimales (los de USDC), mayor que cero. Nunca un número: 0.1+0.2.
139
+ if (typeof pr.usd !== 'string' || !/^\d{1,9}(\.\d{1,6})?$/.test(pr.usd) || Number(pr.usd) <= 0) return { error: `${donde}.price.usd must be a positive decimal string like "1.50"` };
140
+ out.price.usd = pr.usd;
141
+ }
142
+ if (!UNIDADES.includes(s.unit)) return { error: `${donde}.unit must be one of ${UNIDADES.join(', ')}` };
143
+ out.unit = s.unit;
144
+ const cotizables = contratosCotizables();
145
+ if (!cotizables.includes(s.contract)) return { error: `${donde}.contract must be one of ${cotizables.join(', ')}` };
146
+ out.contract = s.contract;
147
+ if (s.acceptance != null) {
148
+ const a = s.acceptance;
149
+ if (typeof a !== 'object' || a === null || Array.isArray(a) || Object.keys(a).some((k) => !['kind', 'template'].includes(k))) return { error: `${donde}.acceptance must be { kind, template }` };
150
+ if (!PRUEBAS.includes(a.kind)) return { error: `${donde}.acceptance.kind must be a test verifica@ can run: ${PRUEBAS.join(', ')}` };
151
+ if (typeof a.template !== 'string' || !limpio(a.template, 280)) return { error: `${donde}.acceptance.template must be a non-empty string` };
152
+ out.acceptance = { kind: a.kind, template: limpio(a.template, 280) };
153
+ }
154
+ return { servicio: out };
155
+ }
156
+ // Un precio en dólares sólo se publica si hay a dónde cobrarlo. La comprobación vive aparte de
157
+ // validarPerfil porque la billetera está en la tarjeta, no en la ficha. Devuelve el error o null.
158
+ export function usdSinBilletera(perfil, wallets) {
159
+ const con = (perfil?.services || []).filter((s) => s.price?.usd != null);
160
+ if (!con.length || (Array.isArray(wallets) && wallets.length)) return null;
161
+ return `service "${con[0].id}" declares a usd price but the card has no wallet; declare wallets first`;
162
+ }
163
+
164
+ // ---------- adjuntos (NX-413) ----------
165
+ // El archivo NO viaja en el sobre: viaja cifrado al almacén de objetos de la casa del remitente y
166
+ // el sobre lleva la referencia. Esta validación es la que decide qué puede verse desde afuera.
167
+ //
168
+ // En un sobre CIFRADO el array de afuera lleva SÓLO `blob` y `bytes`, y el vocabulario es CERRADO:
169
+ // una clave de más se RECHAZA con nombre, no se ignora. Contradice el invariante 7 a propósito, por
170
+ // la misma razón que `ALCANCE_MANDATO` (9-sep-2026): ese invariante es para extender mensajes sin
171
+ // romper nada, y aquí ignorar un campo de más NO es neutro — `name: "balance-despido-javier.pdf"`
172
+ // afuera del cifrado le cuenta a la casa y a cualquier estafeta del camino casi todo el contenido.
173
+ // Si alguien "arregla" esta inconsistencia para que sea permisiva, reabre la fuga.
174
+ //
175
+ // En un sobre EN CLARO el array lleva la ficha completa, `key` incluida: el adjunto sigue cifrado
176
+ // en el almacén, pero la llave viaja a la vista y la casa SÍ puede abrirlo. Se permite (hay usos
177
+ // legítimos: un adjunto público) y la herramienta lo advierte antes de que un agente lo elija.
178
+ export const ADJUNTO_CIFRADO = ['blob', 'bytes'];
179
+ export const ADJUNTO_CLARO = ['blob', 'bytes', 'name', 'media', 'sha256', 'key'];
180
+ export const ADJUNTOS = { max: 10, bytesPorSobre: 25 * 1024 * 1024, bytesPorAdjunto: 10 * 1024 * 1024, nombreMax: 120, mediaMax: 100 };
181
+ const UUID_BLOB = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
182
+ const MEDIA_TIPO = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i;
183
+ const ENTERO = (v, max) => Number.isSafeInteger(v) && v > 0 && v <= max;
184
+
185
+ // Devuelve el motivo del rechazo (string) o null si los adjuntos están bien.
186
+ export function validarAdjuntos(attachments, { cifrado = true } = {}) {
187
+ if (!Array.isArray(attachments)) return 'attachments must be a list';
188
+ if (attachments.length > ADJUNTOS.max) return `an envelope carries at most ${ADJUNTOS.max} attachments, not ${attachments.length}`;
189
+ const permitidas = cifrado ? ADJUNTO_CIFRADO : ADJUNTO_CLARO;
190
+ const vistos = new Set();
191
+ let total = 0;
192
+ for (const [i, a] of attachments.entries()) {
193
+ const donde = `attachments[${i}]`;
194
+ if (!a || typeof a !== 'object' || Array.isArray(a)) return `${donde} must be an object`;
195
+ for (const k of Object.keys(a)) {
196
+ if (!permitidas.includes(k)) {
197
+ return cifrado
198
+ ? `${donde}.${k} must not travel outside the encrypted block (an attachment of an encrypted envelope carries only ${ADJUNTO_CIFRADO.join(' and ')}; name, media, sha256 and key go inside)`
199
+ : `${donde}.${k} is not an attachment field (${ADJUNTO_CLARO.join(', ')})`;
200
+ }
201
+ }
202
+ if (!UUID_BLOB.test(String(a.blob || ''))) return `${donde}.blob must be the uuid of an uploaded object`;
203
+ if (vistos.has(String(a.blob).toLowerCase())) return `${donde}.blob repeats an attachment of this same envelope`;
204
+ vistos.add(String(a.blob).toLowerCase());
205
+ if (!ENTERO(a.bytes, ADJUNTOS.bytesPorAdjunto)) return `${donde}.bytes must be an integer between 1 and ${ADJUNTOS.bytesPorAdjunto} (${Math.round(ADJUNTOS.bytesPorAdjunto / 1024 / 1024)} MB per attachment)`;
206
+ total += a.bytes;
207
+ if (cifrado) continue;
208
+ if (typeof a.name !== 'string' || !limpio(a.name, ADJUNTOS.nombreMax)) return `${donde}.name must be a non-empty string of at most ${ADJUNTOS.nombreMax} characters`;
209
+ if (typeof a.media !== 'string' || a.media.length > ADJUNTOS.mediaMax || !MEDIA_TIPO.test(a.media)) return `${donde}.media must be a media type like application/pdf`;
210
+ if (typeof a.sha256 !== 'string' || !/^[0-9a-f]{64}$/i.test(a.sha256)) return `${donde}.sha256 must be the hex sha256 of the plaintext (64 characters)`;
211
+ if (a.key != null && (typeof a.key !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(a.key))) return `${donde}.key must be the base64url of a 32-byte key`;
212
+ }
213
+ if (total > ADJUNTOS.bytesPorSobre) return `the attachments of one envelope add up to ${total} bytes and the maximum is ${ADJUNTOS.bytesPorSobre} (${Math.round(ADJUNTOS.bytesPorSobre / 1024 / 1024)} MB)`;
214
+ return null;
215
+ }
216
+
14
217
  export function validateEnvelope(env, { maxBytes = 1_048_576 } = {}) {
15
218
  const fail = (reason) => ({ ok: false, code: 400, reason });
16
219
  if (!env || typeof env !== 'object') return fail('sobre no es un objeto');
@@ -34,8 +237,13 @@ export function validateEnvelope(env, { maxBytes = 1_048_576 } = {}) {
34
237
  if (!hasPlain && !hasEnc) return fail('el sobre necesita content o encrypted');
35
238
  if (hasPlain && hasEnc) return fail('content y encrypted son excluyentes');
36
239
  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)');
240
+ // `!!hasEnc` y no `hasEnc`: para un sobre en claro `hasEnc` vale undefined, no false, y un
241
+ // undefined ACTIVA el valor por defecto del parámetro (`cifrado = true`), así que los adjuntos
242
+ // de un sobre EN CLARO se validaban con las reglas del cifrado y se rechazaban todos. Se vio
243
+ // al probar el caso en claro con un adjunto real. El defecto caía del lado estricto, que es el
244
+ // que corresponde si hay que equivocarse, pero era el branch equivocado igual.
245
+ const e = validarAdjuntos(env.attachments, { cifrado: !!hasEnc });
246
+ if (e) return fail(e);
39
247
  }
40
248
  if (!env.signature || env.signature.alg !== 'Ed25519' || !env.signature.kid || !env.signature.value) return fail('sobre sin firma');
41
249
  const bytes = Buffer.byteLength(JSON.stringify(env));
@@ -43,6 +251,41 @@ export function validateEnvelope(env, { maxBytes = 1_048_576 } = {}) {
43
251
  return { ok: true };
44
252
  }
45
253
 
254
+ // ---------- la puerta del correo ----------
255
+ // Un correo entra al buzón SIN firma y sin cuenta en el Libro. Durante un tiempo entró además sin
256
+ // pasar por ninguna política: un buzón que cobraba 500 y otro con lista blanca cerrada aceptaban
257
+ // los dos un correo de cualquier desconocido, gratis. O sea que la estampilla, la lista y la
258
+ // prueba de trabajo defendían una puerta mientras la de al lado quedaba abierta, y el precio que
259
+ // la casa anuncia por x402 era evitable escribiendo un correo.
260
+ //
261
+ // Esta función es esa puerta. Falla CERRADO: si la política del buzón no se puede expresar sobre
262
+ // correo, no se deja pasar. Un mecanismo que no existe en este canal no es un permiso.
263
+ //
264
+ // Se compara contra el remitente REAL del correo, no contra `email@<casa>`, que es la pasarela y
265
+ // sería la misma para todo el mundo.
266
+ export function applyEmailPolicy(agentRecord, emailFrom) {
267
+ const inbox = agentRecord.inbox || { policy: 'open' };
268
+ const dominio = String(emailFrom).slice(String(emailFrom).lastIndexOf('@') + 1).toLowerCase();
269
+ const de = String(emailFrom).toLowerCase();
270
+ switch (inbox.policy) {
271
+ case undefined:
272
+ case 'open':
273
+ return { ok: true };
274
+ case 'allowlist': {
275
+ const ok = inbox.allowlist?.some((x) => String(x).toLowerCase() === de || String(x).toLowerCase() === dominio);
276
+ // El "intro" y el aval del canal firmado NO tienen equivalente aquí: uno se apoya en el tipo
277
+ // de sobre y el otro en una fianza del Libro, y un correo no trae ninguno de los dos.
278
+ return ok ? { ok: true } : { ok: false, code: 403, reason: 'this mailbox only accepts listed senders' };
279
+ }
280
+ case 'pow':
281
+ return { ok: false, code: 403, reason: `this mailbox requires proof-of-work, which an email cannot carry; write to ${agentRecord.address} with the protocol instead` };
282
+ case 'stamp':
283
+ return { ok: false, code: 402, reason: `this mailbox charges ${inbox.price ?? 1} tok for delivery, and an email carries no payment; join the house and send a signed envelope with a stamp` };
284
+ default:
285
+ return { ok: false, code: 403, reason: `unknown mailbox policy (${inbox.policy})` };
286
+ }
287
+ }
288
+
46
289
  // Limitador de tasa por clave (dominio emisor), ventana deslizante simple.
47
290
  export class RateLimiter {
48
291
  constructor({ perMinute = 120 } = {}) { this.perMinute = perMinute; this.hits = new Map(); }
@@ -52,6 +295,34 @@ export class RateLimiter {
52
295
  if (arr.length >= this.perMinute) { this.hits.set(key, arr); return false; }
53
296
  arr.push(now); this.hits.set(key, arr); return true;
54
297
  }
298
+ retryAfter() { return 60; }
299
+ }
300
+
301
+ // Límite de tasa DURABLE (13-sep-2026, NX-901): el contador vive en el almacén, no en la memoria del
302
+ // proceso. En el edge cada isolate contaba por su lado, así que el límite real era N veces el
303
+ // declarado y nadie sabía cuánto era N. Ventana fija de un minuto por clave: una fila por clave y
304
+ // minuto, que vence sola dos minutos después. La misma interfaz que el de memoria (`allow` se
305
+ // espera con await en los dos: en el de memoria el await no cuesta nada).
306
+ // Si el almacén falla, DEJA PASAR y lo anota: un límite de tasa caído no puede tumbar el correo de
307
+ // todos. Precisión sobre cobertura: retener trabajo bueno es peor que dejar pasar un minuto.
308
+ export class RateLimiterDurable {
309
+ constructor({ store, perMinute = 120, ns = 'tasa', log = () => {} } = {}) {
310
+ if (!store?.kvIncrement) throw new Error('RateLimiterDurable needs a store with kvIncrement');
311
+ this.store = store; this.perMinute = perMinute; this.ns = ns; this.log = log;
312
+ this.cerradas = new Map(); // clave -> ventana ya agotada, por proceso: un flood no escribe una fila por golpe
313
+ }
314
+ static ventana(nowMs = Date.now()) { return Math.floor(nowMs / 60_000); }
315
+ async allow(key, nowMs = Date.now()) {
316
+ const v = RateLimiterDurable.ventana(nowMs);
317
+ if (this.cerradas.get(key) === v) return false;
318
+ try {
319
+ const n = await this.store.kvIncrement(this.ns, `${key}:${v}`, (v + 2) * 60_000, nowMs);
320
+ if (n > this.perMinute) { if (this.cerradas.size > 10_000) this.cerradas.clear(); this.cerradas.set(key, v); return false; }
321
+ return true;
322
+ } catch (e) { this.log(`tasa: el almacén no contó (${e.message}); se deja pasar`); return true; }
323
+ }
324
+ // Segundos hasta que abra la ventana siguiente: es lo que va en Retry-After.
325
+ retryAfter(nowMs = Date.now()) { return Math.max(1, 60 - Math.floor((nowMs % 60_000) / 1000)); }
55
326
  }
56
327
 
57
328
  // Política del agente destino sobre un sobre ya verificado criptográficamente.
@@ -67,6 +338,13 @@ export function applyInboxPolicy(env, agentRecord, senderDomain) {
67
338
  const esLibro = String(agentRecord.address || '').startsWith('libro@');
68
339
  if (!esLibro && senderDomain?.policy?.outbound === 'sealed' && !env.encrypted) return permanent('el dominio emisor exige cifrado y el sobre viene en claro');
69
340
 
341
+ // Un sobre de una dirección a SÍ MISMA pasa su propia política (26-oct-2026). Es lo que sostiene
342
+ // `recordar`, la memoria entre sesiones: un sobre diferido al propio buzón. Antes rebotaba contra
343
+ // su propia lista blanca — `nyx5_remind` quedaba decorativo en todo buzón por lista — y en un buzón
344
+ // con estampilla se cobraba a sí mismo. Una lista, una prueba de trabajo o una estampilla defienden
345
+ // de OTROS; no abren nada, porque sólo quien tiene esa llave puede firmar como esa dirección.
346
+ if (env.from === agentRecord.address) return { ok: true };
347
+
70
348
  switch (inbox.policy) {
71
349
  case 'open':
72
350
  break;
@@ -27,7 +27,10 @@ export function parseTxtRecord(txt) {
27
27
  }
28
28
 
29
29
  export class Resolver {
30
- constructor({ hosts = {}, fetchImpl = globalThis.fetch, cacheTtlMs = 5 * 60 * 1000, pins = {}, timeoutMs = 5000, onPin = null, self = null } = {}) {
30
+ constructor({ hosts = {}, fetchImpl = globalThis.fetch, cacheTtlMs = 5 * 60 * 1000, pins = {}, timeoutMs = 5000, onPin = null, self = null, auth = null } = {}) {
31
+ // auth = { domain, header(method, path) }: un CLIENTE (Agent) firma la petición a su propia casa
32
+ // cuando una tarjeta no aparece sin autenticar; así un contacto resuelve a un agente secreto.
33
+ this.auth = auth;
31
34
  this.hosts = { ...hosts }; // { "beta.local": { url: "http://localhost:4002", sig?: "<pub>" } }
32
35
  this.fetch = (...a) => fetchImpl(...a); // envuelto: workerd exige fetch con this=globalThis
33
36
  this.cacheTtlMs = cacheTtlMs;
@@ -42,7 +45,8 @@ export class Resolver {
42
45
  }
43
46
 
44
47
  setHost(domain, entry) { this.hosts[domain] = entry; this.cache.clear(); }
45
- invalidate(key) { if (key) this.cache.delete(key); else this.cache.clear(); }
48
+ // Invalidar `agent:x@casa` borra también lo recordado «para» alguien de esa misma tarjeta.
49
+ invalidate(key) { if (!key) { this.cache.clear(); return; } for (const k of this.cache.keys()) if (k === key || k.startsWith(`${key}|for:`)) this.cache.delete(k); }
46
50
 
47
51
  _cached(key) {
48
52
  const hit = this.cache.get(key);
@@ -51,8 +55,8 @@ export class Resolver {
51
55
  }
52
56
  _remember(key, value) { this.cache.set(key, { value, until: Date.now() + this.cacheTtlMs }); }
53
57
 
54
- async _get(url) {
55
- const res = await this.fetch(url, { signal: AbortSignal.timeout(this.timeoutMs) });
58
+ async _get(url, headers = {}) {
59
+ const res = await this.fetch(url, { headers, signal: AbortSignal.timeout(this.timeoutMs) });
56
60
  if (!res.ok) throw Object.assign(new Error(`GET ${url} -> ${res.status}`), { permanent: res.status === 404 || res.status === 410 });
57
61
  return res.json();
58
62
  }
@@ -111,7 +115,10 @@ export class Resolver {
111
115
  }
112
116
 
113
117
  // Tarjeta del agente, certificada por la clave del dominio.
114
- async agentCard(address) {
118
+ // `onBehalfOf` (NX-202): para quién se pide. Un agente SECRETO sólo se sirve a quien está en su
119
+ // lista, así que la casa que pregunta firma «lo pido para bob@mi-casa» con su llave de dominio, y
120
+ // un cliente firma con la suya ante su propia casa. Sin eso, un secreto se ve como inexistente.
121
+ async agentCard(address, { onBehalfOf = null } = {}) {
115
122
  const { local, domain } = parseAddress(address);
116
123
  // La propia casa sirve la tarjeta sin red, pero pasa por la MISMA verificación que una ajena:
117
124
  // venir de casa no la exime de estar certificada por el dominio y firmada por su padre.
@@ -122,13 +129,29 @@ export class Resolver {
122
129
  return this._verifyAgentCard(card, dc, address, local, domain);
123
130
  }
124
131
  const key = `agent:${local}@${domain}`;
125
- const cached = this._cached(key);
132
+ // Una tarjeta secreta se recuerda SÓLO para quien la pidió: servida a otro, sería una fuga.
133
+ const keyPara = onBehalfOf ? `${key}|for:${String(onBehalfOf).toLowerCase()}` : null;
134
+ const cached = this._cached(key) || (keyPara && this._cached(keyPara));
126
135
  if (cached) return cached;
127
136
 
128
137
  const dc = await this.domainCard(domain);
129
- const card = await this._get(`${dc._estafeta}/agents/${encodeURIComponent(local)}`);
138
+ const url = `${dc._estafeta}/agents/${encodeURIComponent(local)}`;
139
+ const headers = {};
140
+ let paraDom = null; try { paraDom = onBehalfOf ? parseAddress(onBehalfOf).domain : null; } catch { paraDom = null; }
141
+ if (onBehalfOf && this.self?.firmarPara && paraDom === this.self.domain) headers['x-nyx5-for'] = this.self.firmarPara(String(onBehalfOf).toLowerCase(), domain);
142
+ let card;
143
+ try { card = await this._get(url, headers); }
144
+ catch (e) {
145
+ // Un cliente que no la ve sin autenticar la vuelve a pedir firmando: a su propia casa si el
146
+ // agente es de ahí; si es de otra casa, le pide a la suya que resuelva «para» él (la casa
147
+ // firma con su llave de dominio y la otra decide). La tarjeta se verifica igual al llegar.
148
+ if (!(e.permanent && this.auth)) throw e;
149
+ if (domain === this.auth.domain) card = await this._get(url, { authorization: this.auth.header('GET', `/agents/${encodeURIComponent(local)}`) });
150
+ else if (this.auth.url) { const p = `/resolve/${encodeURIComponent(`${local}@${domain}`)}`; const { presence: _p, ...c } = await this._get(`${this.auth.url}${p}`, { authorization: this.auth.header('GET', p) }); card = c; }
151
+ else throw e;
152
+ }
130
153
  const value = await this._verifyAgentCard(card, dc, address, local, domain);
131
- this._remember(key, value);
154
+ this._remember(value.visibility === 'secret' ? (keyPara || `${key}|for:${this.auth?.domain || 'self'}`) : key, value);
132
155
  return value;
133
156
  }
134
157
 
@@ -161,12 +184,13 @@ export class Resolver {
161
184
 
162
185
  // Igual que agentCard, pero si el sobre viene firmado con una clave que la tarjeta en caché no
163
186
  // reconoce (rotación reciente), refresca la tarjeta una vez antes de rechazar.
164
- async agentCardForKid(address, kid) {
165
- let card = await this.agentCard(address);
187
+ async agentCardForKid(address, kid, opts = {}) {
188
+ let card = await this.agentCard(address, opts);
166
189
  if (!Resolver.acceptedKids(card).includes(kid)) {
167
190
  const { local, domain } = parseAddress(address);
168
191
  this.invalidate(`agent:${local}@${domain}`);
169
- card = await this.agentCard(address);
192
+ if (opts.onBehalfOf) this.invalidate(`agent:${local}@${domain}|for:${String(opts.onBehalfOf).toLowerCase()}`);
193
+ card = await this.agentCard(address, opts);
170
194
  }
171
195
  return card;
172
196
  }
@@ -13,8 +13,11 @@
13
13
 
14
14
  import { sha256hex, canonical, uuid } from '../nucleo/crypto.js';
15
15
  import { parseAddress } from '../correo/resolver.js';
16
+ import { applyInboxPolicy } from '../correo/politica.js';
16
17
 
17
18
  import { LibroError } from './errores.js';
19
+ import { estadoDeCuenta } from './estado.js';
20
+ import { notarize } from './notaria.js';
18
21
 
19
22
  const iso = () => new Date().toISOString();
20
23
  const fail = (code, msg) => { throw new LibroError(code, msg); };
@@ -33,6 +36,36 @@ function scopeCap(ctx, amount, what) {
33
36
  if (cap != null && amount > cap) fail(403, `${what}: ${amount} tok exceeds the subagent cap of ${cap}`);
34
37
  }
35
38
  function must(cond, code, msg) { if (!cond) fail(code, msg); }
39
+
40
+ // ---------- alcance de un mandato: vocabulario CERRADO, y se falla cerrado ----------
41
+ // Un mandato es autoridad de gasto. Si el mandante escribe una restricción que el Libro no sabe
42
+ // aplicar, la opción segura NO es guardarla y seguir: es negarse.
43
+ //
44
+ // Nació de un defecto real (9-sep-2026): un mandato con `max_per_charge: 500` y una lista de
45
+ // destinatarios permitidos guardó las dos, las FIRMÓ, y después dejó pasar un cobro único de
46
+ // 90.000. La restricción se veía al leer el mandato de vuelta y no hacía nada. Un instrumento que
47
+ // falla hacia el ruido es peor que uno que no existe: te deja construir encima.
48
+ //
49
+ // Esto contradice a propósito el invariante 7 (los campos desconocidos se conservan y se ignoran).
50
+ // Ese invariante es para MENSAJES, donde ignorar lo que no entiendes es lo que permite extender el
51
+ // protocolo sin romperlo. Para autoridad de gasto es lo contrario: ignorar una restricción
52
+ // autoriza MÁS de lo que el mandante quiso.
53
+ export const ALCANCE_MANDATO = Object.freeze(['concepts', 'max_per_charge']);
54
+
55
+ function validarAlcance(scope, cap) {
56
+ if (scope == null) return {};
57
+ must(typeof scope === 'object' && !Array.isArray(scope), 400, 'scope must be an object');
58
+ const ajenas = Object.keys(scope).filter((k) => !ALCANCE_MANDATO.includes(k));
59
+ must(!ajenas.length, 400, `this ledger cannot enforce ${ajenas.map((k) => JSON.stringify(k)).join(', ')} in a mandate scope, so it refuses to store it as if it could. It enforces: ${ALCANCE_MANDATO.join(', ')}`);
60
+ if (scope.concepts != null) {
61
+ must(Array.isArray(scope.concepts) && scope.concepts.every((c) => typeof c === 'string'), 400, 'scope.concepts must be a list of strings');
62
+ }
63
+ if (scope.max_per_charge != null) {
64
+ must(Number.isInteger(scope.max_per_charge) && scope.max_per_charge > 0, 400, 'scope.max_per_charge must be a positive integer');
65
+ must(cap == null || scope.max_per_charge <= cap, 400, `scope.max_per_charge (${scope.max_per_charge}) cannot exceed the mandate cap (${cap})`);
66
+ }
67
+ return scope;
68
+ }
36
69
  const parties = (c) => [c.seller, c.buyer, c.verifier, c.arbiter].filter(Boolean);
37
70
 
38
71
  // ============ Operaciones ============
@@ -48,7 +81,7 @@ const ops = {
48
81
  const c = {
49
82
  id: uuid(), kind: q.contract, house: libro.domain, seller: q.seller, buyer: from, amount: q.price,
50
83
  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,
84
+ quote_id: q.id, quote_sha256: sha256hex(canonical(q)), accept_sha256: ctx.opHash, ...(q.service ? { service: q.service } : {}),
52
85
  state: 'accepted', created: iso(), history: [],
53
86
  };
54
87
  const refs = { contract: c.id, quote: q.id, quote_sha256: c.quote_sha256, op: ctx.env.id, op_sha256: ctx.opHash };
@@ -112,6 +145,43 @@ const ops = {
112
145
  return { result: { contract: c, asiento }, recibos: [{ to: parties(c), thread: c.id, body: { contract: c, asiento } }] };
113
146
  },
114
147
 
148
+ // --- escrow que vence (NX-503): el comprador recupera; una entrega sin objeción se libera ---
149
+ // Antes un contrato vencido sólo mandaba un aviso y la plata quedaba retenida para siempre.
150
+ // reclaim: sólo el comprador, sólo sin entrega, y sólo pasado el plazo más la gracia de la casa.
151
+ async reclaim(ctx) {
152
+ const { libro, from, body } = ctx;
153
+ const c = await getContract(libro, body.contract);
154
+ must(c.kind === 'escrow', 409, 'reclaim only applies to escrow');
155
+ must(from === c.buyer, 403, 'only the buyer reclaims an expired escrow');
156
+ must(c.state === 'held', 409, c.state === 'delivered' ? 'the seller already delivered: refund needs the seller or the arbiter, or the review window' : `state ${c.state}`);
157
+ const plazo = Date.parse(c.terms?.deadline || '');
158
+ must(!Number.isNaN(plazo), 409, 'this escrow has no deadline: only the seller or the arbiter can refund it');
159
+ const desde = plazo + libro.reclaimGraceMs;
160
+ must(Date.now() >= desde, 409, `the deadline plus the grace period has not passed yet (reclaimable from ${new Date(desde).toISOString()})`);
161
+ const asiento = await libro.refund(c.id, c.buyer, c.amount, `escrow vencido ${c.id}: ${c.concept}`, { contract: c.id, kind: 'reclaim' }, { op: ctx.env.id, op_sha256: ctx.opHash });
162
+ c.state = 'refunded';
163
+ record(libro, c, 'reclaim', from, { asiento: asiento.id, deadline: c.terms.deadline });
164
+ return { result: { contract: c, asiento }, recibos: [{ to: parties(c), thread: c.id, body: { contract: c, asiento } }] };
165
+ },
166
+ // expire: lo firma la CASA (libro@) desde el reloj, nunca una parte. Entregado, con plazo vencido,
167
+ // y pasada la ventana de revisión sin que nadie devolviera ni liberara: se libera al vendedor.
168
+ // «Sin disputa» quiere decir que ni el comprador ni el árbitro hicieron refund en la ventana.
169
+ async expire(ctx) {
170
+ const { libro, from, body } = ctx;
171
+ must(from === libro.address, 403, 'only the house settles an expired escrow');
172
+ const c = await getContract(libro, body.contract);
173
+ must(c.kind === 'escrow' && c.state === 'delivered', 409, `state ${c.state}, expected a delivered escrow`);
174
+ const plazo = Date.parse(c.terms?.deadline || '');
175
+ must(!Number.isNaN(plazo), 409, 'no deadline: a delivery without a deadline waits for the buyer or the arbiter');
176
+ const entregado = Date.parse(c.history.findLast?.((h) => h.op === 'deliver')?.at || c.history.filter((h) => h.op === 'deliver').at(-1)?.at || '');
177
+ const desde = Math.max(plazo, entregado || 0) + libro.reviewWindowMs;
178
+ must(Date.now() >= desde, 409, `the review window is still open (until ${new Date(desde).toISOString()})`);
179
+ const asiento = await libro.release(c.id, c.seller, c.amount, `liberación por ventana vencida ${c.id}: ${c.concept}`, { contract: c.id, kind: 'expire' }, { op: ctx.env.id, op_sha256: ctx.opHash }, c.referrer);
180
+ c.state = 'released';
181
+ record(libro, c, 'expire', from, { asiento: asiento.id, review_until: new Date(desde).toISOString() });
182
+ return { result: { contract: c, asiento }, recibos: [{ to: parties(c), thread: c.id, body: { contract: c, asiento, note: 'delivered, deadline passed, not refunded within the review window: released by the house clock' } }] };
183
+ },
184
+
115
185
  // --- fianza: el que afirma deposita; si la verificación lo derriba, la pierde ---
116
186
  async bond(ctx) {
117
187
  const { libro, from, body } = ctx;
@@ -161,7 +231,7 @@ const ops = {
161
231
  must(body.cap <= parent.cap - parent.spent, 403, `the sub-mandate (${body.cap}) exceeds what the parent has left (${parent.cap - parent.spent})`);
162
232
  if (parent.expires) must(!body.expires || Date.parse(body.expires) <= Date.parse(parent.expires), 403, 'the sub-mandate cannot outlast its parent');
163
233
  }
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,
234
+ const m = { id: uuid(), house: libro.domain, grantor: from, grantee: body.grantee, cap: body.cap, spent: 0, scope: validarAlcance(body.scope, body.cap), expires: body.expires || parent?.expires || null,
165
235
  parent: parent?.id || null, root: parent ? parent.root : from, chain: [], state: 'active', created: iso(), op_sha256: ctx.opHash };
166
236
  m.chain = parent ? [...parent.chain, m.id] : [m.id];
167
237
  libro.putMandate(m);
@@ -180,7 +250,14 @@ const ops = {
180
250
  must(cur.state === 'active', 409, `mandate ${cur.id} is not active`);
181
251
  must(!cur.expires || Date.parse(cur.expires) > Date.now(), 410, `mandato ${cur.id} vencido`);
182
252
  must(cur.cap - cur.spent >= body.amount, 402, `mandate ${cur.id}: ${cur.cap - cur.spent} left, ${body.amount} requested`);
253
+ // Fallar cerrado: un mandato guardado ANTES de este candado puede llevar una restricción que
254
+ // no sabemos aplicar. No se cobra contra él hasta que su mandante lo rehaga.
255
+ const ajenas = Object.keys(cur.scope || {}).filter((k) => !ALCANCE_MANDATO.includes(k));
256
+ must(!ajenas.length, 403, `mandate ${cur.id} carries ${ajenas.map((k) => JSON.stringify(k)).join(', ')} in its scope, which this ledger cannot enforce; it refuses to charge against a limit it cannot apply`);
183
257
  if (cur.scope?.concepts?.length) must(cur.scope.concepts.includes(body.concept), 403, `concept "${body.concept}" is outside the mandate scope`);
258
+ // Tope por cobro: el total ya no se puede vaciar de un solo golpe. Se comprueba en CADA
259
+ // eslabón, así que el tope del padre acota lo que cobra el nieto.
260
+ if (cur.scope?.max_per_charge != null) must(body.amount <= cur.scope.max_per_charge, 403, `mandate ${cur.id} caps a single charge at ${cur.scope.max_per_charge}, and ${body.amount} was requested`);
184
261
  chain.push(cur);
185
262
  }
186
263
  // El descuento de la cadena y el asiento van en la MISMA transacción: si el commit falla,
@@ -202,22 +279,54 @@ const ops = {
202
279
  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
280
  },
204
281
 
282
+ // --- pagar directo: sin cotización ni contrato. Lo firma el que paga; el que recibe no hace nada.
283
+ // Es el "te mando plata" entre dos personas. Sólo dentro de la casa: aquí no hay cómo comprobar
284
+ // que una dirección de otra casa existe, y un pago a nadie deja tokens varados para siempre.
285
+ async pay(ctx) {
286
+ const { libro, from, body } = ctx;
287
+ let to; try { to = parseAddress(body.to); } catch { fail(400, 'pay needs "to": the address that receives'); }
288
+ const address = `${to.local}@${to.domain}`;
289
+ must(to.domain === libro.domain, 400, `pay moves tokens inside ${libro.domain}; ${to.domain} keeps its own ledger`);
290
+ must(address !== from, 400, 'paying yourself moves nothing');
291
+ must(Number.isSafeInteger(body.amount) && body.amount > 0, 400, 'amount must be a positive integer');
292
+ scopeCap(ctx, body.amount, 'paying');
293
+ const rec = await libro.store.getAgent(to.local);
294
+ must(rec && !rec.revoked, 404, `${address} does not exist in this house`);
295
+ // Un subagente de sólo mensajes nunca podrá gastar: lo que le llegue quedaría varado. Se le paga al dueño.
296
+ must(!rec.delegation?.scope?.messages_only, 400, `${address} only carries messages and could never spend this; pay its owner, ${rec.delegation?.by}`);
297
+ const concept = typeof body.concept === 'string' && body.concept.trim() ? body.concept.trim().slice(0, 200) : `pago de ${from}`;
298
+ // Sin fee (decidido por Nicholas el 11-sep-2026): mandarle tokens a una persona es gratis, como un
299
+ // mensaje. El fee de la casa (0,5%) es para el trabajo que alguien encarga: spot, escrow, mandatos.
300
+ const asiento = await libro.post(concept, [{ account: from, delta: -body.amount }, { account: address, delta: body.amount }], { kind: 'pay', to: address, fee: 0 }, { op: ctx.env.id, op_sha256: ctx.opHash });
301
+ // El aviso al que recibe obedece a su buzón, como si el pagador le escribiera. Defecto real
302
+ // (revisión del 11-sep-2026): con pay sin fee, un pago de 1 token dejaba un sobre de libro@ en un
303
+ // buzón que cobra 500 por mensaje. El pago ocurre igual y el saldo lo muestra; el aviso no pasa.
304
+ const p = applyInboxPolicy({ from, to: [address], type: 'receipt' }, rec, null);
305
+ const avisar = p.ok === true && (!p.stamp || body.amount >= p.stamp.price);
306
+ return { result: { asiento, notified: avisar }, recibos: [{ to: avisar ? [from, address] : [from], body: { asiento, pay: { from, to: address, amount: body.amount, concept } } }] };
307
+ },
308
+
205
309
  // --- lecturas: la respuesta vuelve por correo como recibo ---
206
310
  async balance(ctx) {
207
311
  const acc = await ctx.libro.account(ctx.from);
208
312
  return { result: acc, recibos: [{ to: [ctx.from], body: { account: acc.account, balance: acc.balance, contracts: acc.contracts.length, mandates: acc.mandates.length } }] };
209
313
  },
314
+ // Estado de cuenta (NX-501): `since`/`until` ISO-8601 acotan el rango, `limit` cuántos asientos
315
+ // (tope 200 por correo: el recibo viaja en un sobre). Devuelve saldo inicial, filas (fee y
316
+ // comisión como filas propias), saldo final y totales que cuadran. Ver estado.js.
210
317
  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 } }] };
318
+ const { since, until, limit } = ctx.body;
319
+ const estado = await estadoDeCuenta(ctx.libro, ctx.from, { since, until, limit, max: 200 });
320
+ return { result: estado, recibos: [{ to: [ctx.from], body: estado }] };
215
321
  },
216
322
  async contract(ctx) {
217
323
  const c = await getContract(ctx.libro, ctx.body.contract);
218
324
  must(parties(c).includes(ctx.from), 403, 'you are not a party to this contract');
219
325
  return { result: { contract: c }, recibos: [{ to: [ctx.from], thread: c.id, body: { contract: c } }] };
220
326
  },
327
+
328
+ // --- notaría (NX-601): sellar el hash de un documento con fecha y firma de la casa. Sin dinero.
329
+ notarize,
221
330
  };
222
331
 
223
332
  // ============ Tipos de contrato cotizables ============
@@ -235,7 +344,7 @@ export const CONTRATOS = {
235
344
  } },
236
345
  // Medido: la aceptación crea un mandato del comprador al vendedor con tope = precio cotizado.
237
346
  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,
347
+ const m = { id: uuid(), house: libro.domain, grantor: c.buyer, grantee: c.seller, cap: c.amount, spent: 0, scope: validarAlcance(q.terms?.scope, c.amount), expires: q.terms?.expires || q.expires || null,
239
348
  parent: null, root: c.buyer, chain: [], state: 'active', created: iso(), contract: c.id, op_sha256: refs.op_sha256 };
240
349
  m.chain = [m.id];
241
350
  libro.putMandate(m);