@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.
- package/README.md +50 -7
- package/bin/nyx5.js +81 -2
- package/demo/edge-local.mjs +41 -0
- package/demo/x402-pagar.mjs +64 -0
- package/demo/x402-testnet.mjs +80 -0
- package/docs/ARQUITECTURA.md +354 -0
- package/docs/SPEC.md +414 -15
- package/examples/ci/nyx5-ejecutor.yml +82 -0
- package/examples/frameworks/README.md +55 -0
- package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
- package/examples/frameworks/claude-agent-sdk.mjs +48 -0
- package/examples/frameworks/crewai_nyx5.py +72 -0
- package/examples/frameworks/http.md +117 -0
- package/examples/frameworks/langgraph_nyx5.py +90 -0
- package/examples/frameworks/nyx5_http.py +255 -0
- package/examples/frameworks/openai-agents.mjs +41 -0
- package/examples/hola-mundo.mjs +4 -4
- package/migrations/0006_puente_remoto.sql +19 -0
- package/migrations/0007_indice_reputacion.sql +31 -0
- package/migrations/0008_notaria.sql +18 -0
- package/migrations/0009_contratos_estado.sql +4 -0
- package/package.json +4 -2
- package/server.json +2 -2
- package/src/correo/acciones.js +216 -0
- package/src/correo/adjuntos.js +185 -0
- package/src/correo/agente.js +370 -22
- package/src/correo/asistente.js +599 -0
- package/src/correo/ci.js +262 -0
- package/src/correo/cobro.js +164 -0
- package/src/correo/estafeta.js +1548 -58
- package/src/correo/ideas.js +154 -0
- package/src/correo/indice.js +178 -0
- package/src/correo/politica.js +280 -2
- package/src/correo/resolver.js +35 -11
- package/src/libro/contratos.js +116 -7
- package/src/libro/estado.js +150 -0
- package/src/libro/informe.js +147 -3
- package/src/libro/libro.js +83 -12
- package/src/libro/notaria.js +74 -0
- package/src/libro/verifica.js +239 -36
- package/src/nucleo/almacen-d1.js +143 -14
- package/src/nucleo/almacen.js +137 -13
- package/src/nucleo/boveda.js +37 -0
- package/src/nucleo/crypto.js +55 -12
- package/src/nucleo/keccak.js +118 -0
- package/src/nucleo/objetos.js +78 -0
- package/src/nucleo/secp256k1.js +228 -0
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +1 -1
- package/src/plataformas/iconos.js +6 -0
- package/src/plataformas/node.js +28 -4
- package/src/plataformas/spec-html.js +1 -1
- package/src/plataformas/terms-html.js +2 -0
- package/src/plataformas/worker.js +52 -7
- package/src/puentes/ejecutor.js +118 -0
- package/src/puentes/herramientas.js +315 -0
- package/src/puentes/mcp-remoto.js +71 -0
- package/src/puentes/mcp.js +5 -99
- package/src/puentes/oauth.js +361 -0
- package/src/puentes/x402-pagador.js +248 -0
- package/src/puentes/x402.js +266 -0
- package/src/version.js +4 -0
package/src/correo/politica.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
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;
|
package/src/correo/resolver.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/libro/contratos.js
CHANGED
|
@@ -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
|
|
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
|
|
212
|
-
const
|
|
213
|
-
|
|
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
|
|
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);
|