@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/libro/verifica.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// Nyx5/1 — verifica@: el evaluador de referencia de la casa.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Pruebas DETERMINISTAS y nada más. Sin juicio de modelo, a propósito: un verificador
|
|
4
4
|
// que se equivoca castiga a un inocente y quema la credibilidad del sistema en un día. Si
|
|
5
5
|
// una prueba no puede decidir sola y sin ambigüedad, este verificador no la acepta.
|
|
6
6
|
//
|
|
7
7
|
// http_status GET a una URL -> el código es el esperado (200 por defecto)
|
|
8
8
|
// sha256 el cuerpo de una URL (o el texto entregado) hashea a lo declarado
|
|
9
|
-
// json_path un campo de un JSON servido en una URL vale exactamente lo esperado
|
|
9
|
+
// json_path un campo de un JSON servido en una URL vale exactamente lo esperado, o existe
|
|
10
|
+
// regex el cuerpo de una URL (primer MB) casa con una expresión regular acotada
|
|
11
|
+
// size el cuerpo de una URL pesa a lo sumo / al menos tantos bytes
|
|
12
|
+
// header una cabecera de la respuesta vale exactamente lo esperado
|
|
10
13
|
// exit_0 un comando termina con código 0 [solo fuera del edge: necesita shell]
|
|
11
14
|
//
|
|
12
15
|
// El veredicto es una función pura de (prueba, mundo): dos corridas con el mismo mundo dan
|
|
@@ -14,7 +17,7 @@
|
|
|
14
17
|
|
|
15
18
|
import { sha256hex, canonical } from '../nucleo/crypto.js';
|
|
16
19
|
|
|
17
|
-
export const PRUEBAS = ['http_status', 'sha256', 'json_path', 'exit_0'];
|
|
20
|
+
export const PRUEBAS = ['http_status', 'sha256', 'json_path', 'regex', 'size', 'header', 'exit_0'];
|
|
18
21
|
// En el edge no hay shell. Dos señales, porque una sola engañaba:
|
|
19
22
|
// - con nodejs_compat, Workers expone `process` Y deja importar node:child_process, así que
|
|
20
23
|
// ni la variable ni el import distinguen el runtime; la casa anunciaba exit_0 y luego no
|
|
@@ -32,38 +35,145 @@ const enWorkers = typeof navigator !== 'undefined' && /Cloudflare-Workers/i.test
|
|
|
32
35
|
export const conShell = !enWorkers && typeof process !== 'undefined' && !!process?.versions?.node;
|
|
33
36
|
export const pruebasDisponibles = () => (conShell ? PRUEBAS : PRUEBAS.filter((p) => p !== 'exit_0'));
|
|
34
37
|
|
|
38
|
+
// Tope de lo que se descarga para mirar un cuerpo (regex, size). Un cuerpo más grande no se lee
|
|
39
|
+
// entero: el verificador no es un espejo, y un servidor hostil no puede hacerle tragar gigas.
|
|
40
|
+
export const CUERPO_MAX = 1_048_576;
|
|
41
|
+
// regex mira menos: 256 KB y líneas de 2 KB, porque su costo depende del patrón (ver patronSeguro).
|
|
42
|
+
export const REGEX_MAX = 262_144;
|
|
43
|
+
export const REGEX_LINEA = 2048;
|
|
44
|
+
export const PATRON_MAX = 256;
|
|
45
|
+
const BANDERAS = /^[imsu]{0,4}$/;
|
|
46
|
+
|
|
35
47
|
const recorta = (s, n = 300) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}…` : s);
|
|
48
|
+
// Sólo https y nunca hacia adentro: verifica@ hace la petición desde la casa, y una URL que apunte
|
|
49
|
+
// a loopback, a una red privada o a la metadata de la nube convertiría a la casa en un diputado
|
|
50
|
+
// confundido (revisión del 14-sep-2026). Los redirects no se siguen por la misma razón.
|
|
51
|
+
const HOST_PRIVADO = /^(localhost|.*\.localhost|.*\.local|.*\.internal|0\.0\.0\.0|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|169\.254\.\d+\.\d+|\[?::1\]?|\[?f[cd][0-9a-f]{2}:.*|\[?fe80:.*|metadata\.google\.internal)$/i;
|
|
52
|
+
// `privados: true` sólo en pruebas locales (la casa lo decide en su config, nunca el contrato).
|
|
53
|
+
let permitirPrivados = false;
|
|
54
|
+
const esHttps = (u) => { try { const x = new URL(String(u || '')); return x.protocol === 'https:' && (permitirPrivados || !HOST_PRIVADO.test(x.hostname)) && !x.username && !x.password; } catch { return false; } };
|
|
55
|
+
const metodoDe = (p) => { const m = String(p.method || 'GET').toUpperCase(); return m === 'GET' || m === 'HEAD' ? m : null; };
|
|
56
|
+
|
|
57
|
+
// Lee el cuerpo de una respuesta hasta `max` bytes y dice si había más. Con un cuerpo en flujo
|
|
58
|
+
// (fetch real) corta la descarga; con un doble de pruebas que sólo tiene `text()` recorta lo leído.
|
|
59
|
+
async function leerCuerpo(res, max) {
|
|
60
|
+
if (res.body && typeof res.body.getReader === 'function') {
|
|
61
|
+
const lector = res.body.getReader();
|
|
62
|
+
const partes = []; let bytes = 0; let truncado = false;
|
|
63
|
+
for (;;) {
|
|
64
|
+
const { done, value } = await lector.read();
|
|
65
|
+
if (done) break;
|
|
66
|
+
const sobra = bytes + value.byteLength - max;
|
|
67
|
+
if (sobra > 0) { partes.push(value.subarray(0, value.byteLength - sobra)); bytes = max; truncado = true; try { await lector.cancel(); } catch { /* ya se leyó lo que se necesitaba */ } break; }
|
|
68
|
+
partes.push(value); bytes += value.byteLength;
|
|
69
|
+
}
|
|
70
|
+
const buf = new Uint8Array(bytes); let o = 0;
|
|
71
|
+
for (const p of partes) { buf.set(p, o); o += p.byteLength; }
|
|
72
|
+
return { texto: new TextDecoder().decode(buf), bytes, truncado };
|
|
73
|
+
}
|
|
74
|
+
const texto = String(await res.text());
|
|
75
|
+
const codificado = new TextEncoder().encode(texto);
|
|
76
|
+
if (codificado.byteLength > max) return { texto: new TextDecoder().decode(codificado.subarray(0, max)), bytes: max, truncado: true };
|
|
77
|
+
return { texto, bytes: codificado.byteLength, truncado: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Camino literal de json_path: `a.b.0.c` o `a.b[0].c`. Sin comodines ni expresiones. Devuelve
|
|
81
|
+
// la lista de segmentos, o null si la forma no es ésa.
|
|
82
|
+
export function segmentosDe(path) {
|
|
83
|
+
if (typeof path !== 'string' || !/^[^.[\]]+(\[\d+\])*(\.[^.[\]]+(\[\d+\])*)*$/.test(path)) return null;
|
|
84
|
+
return path.split('.').flatMap((s) => { const [cabeza, ...idx] = s.split('['); return [cabeza, ...idx.map((i) => i.slice(0, -1))]; });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Lo que se acepta como patrón de `regex`. Un motor de expresiones regulares con retroceso puede
|
|
88
|
+
// tardar tiempo exponencial con ciertas formas, y el verificador no puede interrumpir una prueba a
|
|
89
|
+
// medias: el edge lo mataría por CPU y el contrato quedaría indeciso para siempre. En vez de medir
|
|
90
|
+
// el tiempo se RESTRINGE la sintaxis a lo que no lo sufre:
|
|
91
|
+
// - sin referencias hacia atrás (\1, \k<n>);
|
|
92
|
+
// - ningún cuantificador sobre un grupo que por dentro tiene otro cuantificador o una
|
|
93
|
+
// alternancia: (a+)+, (a|ab)*, (?:x*)? quedan fuera; [ab]+ y (ab)+ entran;
|
|
94
|
+
// - largo máximo PATRON_MAX y banderas sólo i, m, s, u.
|
|
95
|
+
// Lo que NO cubre: no es una prueba formal de linealidad; deja fuera patrones inocentes con esa
|
|
96
|
+
// forma (falso positivo visible al cotizar, nunca silencioso) y no acota patrones largos sin
|
|
97
|
+
// grupos, que sobre 1 MB siguen siendo lineales.
|
|
98
|
+
export function patronSeguro(pattern, flags = '') {
|
|
99
|
+
if (typeof pattern !== 'string' || !pattern.length) return { ok: false, razon: 'regex needs a non-empty pattern' };
|
|
100
|
+
if (pattern.length > PATRON_MAX) return { ok: false, razon: `pattern is longer than ${PATRON_MAX} characters` };
|
|
101
|
+
if (typeof flags !== 'string' || !BANDERAS.test(flags)) return { ok: false, razon: 'flags may only combine i, m, s and u' };
|
|
102
|
+
if (/\\[1-9]|\\k</.test(pattern)) return { ok: false, razon: 'backreferences are not accepted: they can backtrack exponentially' };
|
|
103
|
+
const pila = []; let enClase = false;
|
|
104
|
+
const cuant = (ch) => ch !== undefined && /[*+?{]/.test(ch);
|
|
105
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
106
|
+
const c = pattern[i];
|
|
107
|
+
if (c === '\\') { i++; continue; }
|
|
108
|
+
if (enClase) { if (c === ']') enClase = false; continue; }
|
|
109
|
+
if (c === '[') { enClase = true; continue; }
|
|
110
|
+
if (c === '(') {
|
|
111
|
+
pila.push({ cuant: false, alt: false });
|
|
112
|
+
// Prefijos de grupo: (?: (?= (?! (?<= (?<! (?<nombre>. El `?` de ahí no es un cuantificador.
|
|
113
|
+
if (pattern[i + 1] === '?') { i += 2; if (pattern[i] === '<' && !/[=!]/.test(pattern[i + 1] || '')) i = pattern.indexOf('>', i); if (i < 0) return { ok: false, razon: 'malformed group' }; }
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (c === ')') {
|
|
117
|
+
const g = pila.pop();
|
|
118
|
+
if (!g) return { ok: false, razon: 'unbalanced parentheses' };
|
|
119
|
+
const sigue = cuant(pattern[i + 1]);
|
|
120
|
+
if (sigue && (g.cuant || g.alt)) return { ok: false, razon: 'a quantifier over a group that contains a quantifier or an alternation is not accepted (it can backtrack exponentially); use a character class like [ab]+' };
|
|
121
|
+
if (pila.length) { const p = pila.at(-1); p.cuant = p.cuant || g.cuant || sigue; p.alt = p.alt || g.alt; }
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (c === '|') { if (pila.length) pila.at(-1).alt = true; continue; }
|
|
125
|
+
if (cuant(c) && pila.length) pila.at(-1).cuant = true;
|
|
126
|
+
}
|
|
127
|
+
if (pila.length) return { ok: false, razon: 'unbalanced parentheses' };
|
|
128
|
+
// Revisión del 14-sep-2026 (ALTO probado): `a*a*b` o `\\w*\\w*x` pasaban el guardia de grupos y
|
|
129
|
+
// colgaban más de 20 s sobre 1 MB: dos cuantificadores hermanos sobre lo mismo son polinómicos,
|
|
130
|
+
// y en el edge eso congela la verificación de toda la casa. Regla acotada por CONSTRUCCIÓN, no
|
|
131
|
+
// por medición: un solo cuantificador sin tope en todo el patrón, los con tope hasta {n,64}, y no
|
|
132
|
+
// más de cuatro cuantificadores en total. Además el texto se prueba línea por línea (ver regex).
|
|
133
|
+
const sinClases = pattern.replace(/\\./g, '__').replace(/\[[^\]]*\]/g, 'C');
|
|
134
|
+
const sinTope = (sinClases.match(/[*+]|\{\d+,\}/g) || []).length;
|
|
135
|
+
const conTope = [...sinClases.matchAll(/\{(\d+)(?:,(\d+))?\}/g)].filter((m) => m[2] !== undefined || !m[0].endsWith(',}'));
|
|
136
|
+
if (sinTope > 1) return { ok: false, razon: 'only one unbounded quantifier (*, + or {n,}) per pattern: two of them over overlapping text backtrack polynomially over a large body' };
|
|
137
|
+
if (conTope.some((m) => Number(m[2] ?? m[1]) > 64)) return { ok: false, razon: 'bounded quantifiers may repeat at most 64 times' };
|
|
138
|
+
if (sinTope + conTope.length + (sinClases.match(/\?/g) || []).length > 4) return { ok: false, razon: 'at most four quantifiers per pattern' };
|
|
139
|
+
try { new RegExp(pattern, flags); } catch (e) { return { ok: false, razon: `invalid pattern: ${e.message}` }; }
|
|
140
|
+
return { ok: true };
|
|
141
|
+
}
|
|
36
142
|
|
|
37
143
|
/**
|
|
38
144
|
* Corre UNA prueba y devuelve un veredicto con su evidencia.
|
|
39
|
-
* @returns {Promise<{pasa:boolean, prueba:string, razon:string, evidencia:object}>}
|
|
145
|
+
* @returns {Promise<{pasa:boolean, prueba:string, razon:string, evidencia:object, indeciso?:boolean}>}
|
|
40
146
|
*/
|
|
41
|
-
export async function correrPrueba(prueba, { fetchImpl = globalThis.fetch, timeoutMs = 10_000, entregado = null, entregadoSha256 = null } = {}) {
|
|
147
|
+
export async function correrPrueba(prueba, { fetchImpl = globalThis.fetch, timeoutMs = 10_000, entregado = null, entregadoSha256 = null, privados = false } = {}) {
|
|
148
|
+
permitirPrivados = privados === true;
|
|
42
149
|
const tipo = prueba?.type;
|
|
43
150
|
if (!PRUEBAS.includes(tipo)) {
|
|
44
151
|
return { pasa: false, prueba: tipo || '(sin tipo)', razon: `prueba desconocida: ${tipo}. Las que este verificador acepta: ${pruebasDisponibles().join(', ')}`, evidencia: {} };
|
|
45
152
|
}
|
|
46
153
|
if (tipo === 'exit_0' && !conShell) {
|
|
47
|
-
return { pasa: false, prueba: tipo, razon: 'este verificador corre en el edge y no tiene shell; usa http_status o
|
|
154
|
+
return { pasa: false, prueba: tipo, razon: 'este verificador corre en el edge y no tiene shell; usa http_status, sha256, json_path, regex, size o header', evidencia: {} };
|
|
48
155
|
}
|
|
156
|
+
const falla = (razon, evidencia = {}) => ({ pasa: false, prueba: tipo, razon, evidencia });
|
|
157
|
+
// Un 52x no lo emite el servidor que se está comprobando: lo emite la infraestructura que
|
|
158
|
+
// hay delante cuando no logra llegar. Tratarlo como "la afirmación es falsa" castiga al
|
|
159
|
+
// agente por una red que no controla — pasó de verdad: una tarea sembrada apuntaba a la
|
|
160
|
+
// propia casa, el borde devolvió 522 y el trabajo se devolvió como si fuera mentira.
|
|
161
|
+
const delBorde = (res) => (res.status >= 520 && res.status <= 527
|
|
162
|
+
? { pasa: false, indeciso: true, prueba: tipo, razon: `could not reach ${prueba.url} (${res.status} from the edge, not from the server being checked)`, evidencia: { url: prueba.url, status: res.status } }
|
|
163
|
+
: null);
|
|
49
164
|
try {
|
|
50
165
|
if (tipo === 'http_status') {
|
|
51
166
|
const esperado = Number(prueba.expect ?? 200);
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// agente por una red que no controla — pasó de verdad: una tarea sembrada apuntaba a la
|
|
57
|
-
// propia casa, el borde devolvió 522 y el trabajo se devolvió como si fuera mentira.
|
|
58
|
-
if (res.status >= 520 && res.status <= 527) {
|
|
59
|
-
return { pasa: false, indeciso: true, prueba: tipo, razon: `could not reach ${prueba.url} (${res.status} from the edge, not from the server being checked)`, evidencia: { url: prueba.url, status: res.status } };
|
|
60
|
-
}
|
|
167
|
+
if (!esHttps(prueba.url)) return falla('the URL to verify must be https and public', { url: prueba.url });
|
|
168
|
+
if (!metodoDe(prueba)) return falla(`method must be GET or HEAD, not ${String(prueba.method)}`, { url: prueba.url });
|
|
169
|
+
const res = await fetchImpl(prueba.url, { method: metodoDe(prueba), redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
|
|
170
|
+
const borde = delBorde(res); if (borde) return borde;
|
|
61
171
|
const pasa = res.status === esperado;
|
|
62
172
|
return { pasa, prueba: tipo, razon: pasa ? `${prueba.url} responded ${res.status}` : `${prueba.url} responded ${res.status}, expected ${esperado}`, evidencia: { url: prueba.url, status: res.status, expect: esperado } };
|
|
63
173
|
}
|
|
64
174
|
if (tipo === 'sha256') {
|
|
65
175
|
const esperado = String(prueba.expect || '').toLowerCase();
|
|
66
|
-
if (!/^[0-9a-f]{64}$/.test(esperado)) return
|
|
176
|
+
if (!/^[0-9a-f]{64}$/.test(esperado)) return falla('expect debe ser un sha256 en hexadecimal (64 caracteres)');
|
|
67
177
|
// El agente pudo declarar el hash de su resultado al entregar (deliver.evidence_sha256).
|
|
68
178
|
// Ese es el caso normal cuando el trabajo no vive en una URL: se compara lo que DIJO
|
|
69
179
|
// contra lo que la tarea exige. Si además hay url, gana lo que se puede descargar.
|
|
@@ -74,9 +184,10 @@ export async function correrPrueba(prueba, { fetchImpl = globalThis.fetch, timeo
|
|
|
74
184
|
}
|
|
75
185
|
let texto = entregado;
|
|
76
186
|
if (prueba.url) {
|
|
77
|
-
if (
|
|
78
|
-
const res = await fetchImpl(prueba.url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
79
|
-
|
|
187
|
+
if (!esHttps(prueba.url)) return falla('la URL a verificar debe ser https', { url: prueba.url });
|
|
188
|
+
const res = await fetchImpl(prueba.url, { redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
|
|
189
|
+
const borde = delBorde(res); if (borde) return borde;
|
|
190
|
+
if (!res.ok) return falla(`${prueba.url} respondió ${res.status}: no hay qué hashear`, { url: prueba.url, status: res.status });
|
|
80
191
|
texto = await res.text();
|
|
81
192
|
}
|
|
82
193
|
if (texto == null) return { pasa: false, prueba: tipo, razon: 'no hay contenido que hashear: la prueba necesita una url, o la entrega debe declarar evidence_sha256', evidencia: {}, indeciso: true };
|
|
@@ -85,34 +196,96 @@ export async function correrPrueba(prueba, { fetchImpl = globalThis.fetch, timeo
|
|
|
85
196
|
return { pasa, prueba: tipo, razon: pasa ? `el hash coincide (${visto.slice(0, 12)}…)` : `el hash no coincide: se vio ${visto.slice(0, 12)}…, se esperaba ${esperado.slice(0, 12)}…`, evidencia: { sha256: visto, expect: esperado, url: prueba.url || null } };
|
|
86
197
|
}
|
|
87
198
|
if (tipo === 'json_path') {
|
|
88
|
-
// Un campo de un JSON, comparado por IGUALDAD ESTRICTA contra un valor esperado
|
|
89
|
-
// permite arbitrar trabajo de verdad ("tu endpoint debe
|
|
90
|
-
// sin abrir la puerta a criterios que opinan. El camino
|
|
91
|
-
// `a.b.0.c`. Nada de expresiones — una consulta
|
|
92
|
-
// determinista, y este verificador solo acepta lo que
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
199
|
+
// Un campo de un JSON, comparado por IGUALDAD ESTRICTA contra un valor esperado, o su
|
|
200
|
+
// mera EXISTENCIA. Es lo que permite arbitrar trabajo de verdad ("tu endpoint debe
|
|
201
|
+
// responder {ok:true, version:3}") sin abrir la puerta a criterios que opinan. El camino
|
|
202
|
+
// es literal y sin comodines: `a.b.0.c` o `a.b[0].c`. Nada de expresiones — una consulta
|
|
203
|
+
// que hay que interpretar deja de ser determinista, y este verificador solo acepta lo que
|
|
204
|
+
// decide igual dos veces.
|
|
205
|
+
if (!esHttps(prueba.url)) return falla('the URL to verify must be https and public', { url: prueba.url });
|
|
206
|
+
const segmentos = segmentosDe(prueba.path);
|
|
207
|
+
if (!segmentos) return falla('json_path needs a path: literal, for example "status", "data.0.id" or "data[0].id"');
|
|
208
|
+
const conValor = 'expect' in prueba || 'equals' in prueba;
|
|
209
|
+
const conExiste = 'exists' in prueba;
|
|
210
|
+
if (conValor && conExiste) return falla('json_path takes either expect (or equals) or exists, not both');
|
|
211
|
+
if (!conValor && !conExiste) return falla('json_path needs an expect value to compare against, or exists: true|false');
|
|
212
|
+
if (conExiste && typeof prueba.exists !== 'boolean') return falla('json_path exists must be true or false');
|
|
213
|
+
const esperado = 'expect' in prueba ? prueba.expect : prueba.equals;
|
|
214
|
+
const res = await fetchImpl(prueba.url, { headers: { accept: 'application/json' }, redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
|
|
215
|
+
const borde = delBorde(res); if (borde) return borde;
|
|
216
|
+
if (!res.ok) return falla(`${prueba.url} responded ${res.status}: nothing to read`, { url: prueba.url, status: res.status });
|
|
98
217
|
let doc;
|
|
99
|
-
try { doc = JSON.parse(await res.
|
|
100
|
-
catch
|
|
218
|
+
try { doc = JSON.parse((await leerCuerpo(res, CUERPO_MAX)).texto); }
|
|
219
|
+
catch { return falla(`${prueba.url} did not return valid JSON`, { url: prueba.url }); }
|
|
101
220
|
let visto = doc;
|
|
102
|
-
for (const seg of
|
|
221
|
+
for (const seg of segmentos) {
|
|
103
222
|
if (visto == null || typeof visto !== 'object') { visto = undefined; break; }
|
|
104
223
|
visto = Array.isArray(visto) ? visto[Number(seg)] : visto[seg];
|
|
105
224
|
}
|
|
106
|
-
// La comparación es por forma canónica: {a:1,b:2} y {b:2,a:1} son el mismo valor.
|
|
107
|
-
const igual = canonical(visto ?? null) === canonical(prueba.expect ?? null);
|
|
108
225
|
const corto = (v) => recorta(JSON.stringify(v ?? null), 120);
|
|
109
|
-
|
|
226
|
+
if (conExiste) {
|
|
227
|
+
const hay = visto !== undefined;
|
|
228
|
+
const pasa = hay === prueba.exists;
|
|
229
|
+
return { pasa, prueba: tipo, razon: `${prueba.path} ${hay ? 'exists' : 'does not exist'}${pasa ? '' : `, expected it ${prueba.exists ? 'to exist' : 'not to exist'}`}`, evidencia: { url: prueba.url, path: prueba.path, exists: hay, expect_exists: prueba.exists } };
|
|
230
|
+
}
|
|
231
|
+
// La comparación es por forma canónica: {a:1,b:2} y {b:2,a:1} son el mismo valor.
|
|
232
|
+
const igual = visto !== undefined && canonical(visto ?? null) === canonical(esperado ?? null);
|
|
233
|
+
return { pasa: igual, prueba: tipo, razon: igual ? `${prueba.path} is ${corto(visto)}` : `${prueba.path} is ${visto === undefined ? 'missing' : corto(visto)}, expected ${corto(esperado)}`, evidencia: { url: prueba.url, path: prueba.path, seen: visto ?? null, expect: esperado ?? null } };
|
|
234
|
+
}
|
|
235
|
+
if (tipo === 'regex') {
|
|
236
|
+
// Sobre el primer MB del cuerpo, con un patrón acotado (ver patronSeguro). Si el cuerpo
|
|
237
|
+
// era más largo, el veredicto lo dice: lo que se miró es lo que se descargó.
|
|
238
|
+
if (!esHttps(prueba.url)) return falla('the URL to verify must be https and public', { url: prueba.url });
|
|
239
|
+
const flags = prueba.flags ?? '';
|
|
240
|
+
const seguro = patronSeguro(prueba.pattern, flags);
|
|
241
|
+
if (!seguro.ok) return falla(seguro.razon, { pattern: recorta(String(prueba.pattern ?? ''), 80) });
|
|
242
|
+
const res = await fetchImpl(prueba.url, { redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
|
|
243
|
+
const borde = delBorde(res); if (borde) return borde;
|
|
244
|
+
if (!res.ok) return falla(`${prueba.url} responded ${res.status}: nothing to read`, { url: prueba.url, status: res.status });
|
|
245
|
+
const { texto, bytes, truncado } = await leerCuerpo(res, REGEX_MAX);
|
|
246
|
+
// Línea por línea y cada línea recortada a 2 KB: con un solo cuantificador sin tope el peor
|
|
247
|
+
// caso es cuadrático sobre 2 KB, no sobre 1 MB. Un patrón no puede casar a través de líneas.
|
|
248
|
+
const re = new RegExp(prueba.pattern, flags.replace('s', ''));
|
|
249
|
+
const pasa = texto.split('\n').some((l) => re.test(l.length > REGEX_LINEA ? l.slice(0, REGEX_LINEA) : l));
|
|
250
|
+
const alcance = truncado ? ` (only the first ${REGEX_MAX} bytes were read)` : '';
|
|
251
|
+
return { pasa, prueba: tipo, razon: pasa ? `the body of ${prueba.url} matches /${prueba.pattern}/${flags}${alcance}` : `the body of ${prueba.url} does not match /${prueba.pattern}/${flags}${alcance}`, evidencia: { url: prueba.url, pattern: prueba.pattern, flags, bytes_read: bytes, truncated: truncado } };
|
|
252
|
+
}
|
|
253
|
+
if (tipo === 'size') {
|
|
254
|
+
if (!esHttps(prueba.url)) return falla('the URL to verify must be https and public', { url: prueba.url });
|
|
255
|
+
const max = prueba.max_bytes, min = prueba.min_bytes;
|
|
256
|
+
const entero = (v) => v === undefined || (Number.isInteger(v) && v >= 0);
|
|
257
|
+
if (!entero(max) || !entero(min) || (max === undefined && min === undefined)) return falla('size needs max_bytes and/or min_bytes as non-negative integers');
|
|
258
|
+
if (max !== undefined && min !== undefined && min > max) return falla('size: min_bytes cannot exceed max_bytes');
|
|
259
|
+
const res = await fetchImpl(prueba.url, { redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
|
|
260
|
+
const borde = delBorde(res); if (borde) return borde;
|
|
261
|
+
if (!res.ok) return falla(`${prueba.url} responded ${res.status}: nothing to measure`, { url: prueba.url, status: res.status });
|
|
262
|
+
// Se cuenta lo que llega, no lo que dice content-length (que puede faltar o mentir). Se
|
|
263
|
+
// lee hasta un byte más que el tope más alto que importa: con eso ya se sabe de qué lado cae.
|
|
264
|
+
const tope = Math.max(CUERPO_MAX, max ?? 0, min ?? 0) + 1;
|
|
265
|
+
const { bytes, truncado } = await leerCuerpo(res, tope);
|
|
266
|
+
const visto = truncado ? `more than ${tope - 1}` : String(bytes);
|
|
267
|
+
const bajoMax = max === undefined || (!truncado && bytes <= max);
|
|
268
|
+
const sobreMin = min === undefined || bytes >= min;
|
|
269
|
+
const pasa = bajoMax && sobreMin;
|
|
270
|
+
const limites = [max !== undefined ? `at most ${max}` : null, min !== undefined ? `at least ${min}` : null].filter(Boolean).join(' and ');
|
|
271
|
+
return { pasa, prueba: tipo, razon: pasa ? `${prueba.url} is ${visto} bytes (${limites})` : `${prueba.url} is ${visto} bytes, expected ${limites}`, evidencia: { url: prueba.url, bytes: truncado ? null : bytes, more_than: truncado ? tope - 1 : null, max_bytes: max ?? null, min_bytes: min ?? null } };
|
|
272
|
+
}
|
|
273
|
+
if (tipo === 'header') {
|
|
274
|
+
if (!esHttps(prueba.url)) return falla('the URL to verify must be https and public', { url: prueba.url });
|
|
275
|
+
if (typeof prueba.name !== 'string' || !/^[A-Za-z0-9-]{1,80}$/.test(prueba.name)) return falla('header needs a name (letters, digits and dashes)');
|
|
276
|
+
if (typeof prueba.equals !== 'string') return falla('header needs equals: the exact value expected, as a string');
|
|
277
|
+
const res = await fetchImpl(prueba.url, { method: prueba.method || 'GET', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
|
|
278
|
+
const borde = delBorde(res); if (borde) return borde;
|
|
279
|
+
const nombre = prueba.name.toLowerCase();
|
|
280
|
+
const visto = typeof res.headers?.get === 'function' ? res.headers.get(nombre) : (res.headers?.[nombre] ?? null);
|
|
281
|
+
const pasa = visto === prueba.equals;
|
|
282
|
+
return { pasa, prueba: tipo, razon: pasa ? `${prueba.url} sends ${nombre}: ${recorta(visto, 120)}` : (visto == null ? `${prueba.url} does not send the ${nombre} header, expected ${recorta(prueba.equals, 120)}` : `${prueba.url} sends ${nombre}: ${recorta(visto, 120)}, expected ${recorta(prueba.equals, 120)}`), evidencia: { url: prueba.url, name: nombre, seen: visto, equals: prueba.equals, status: res.status } };
|
|
110
283
|
}
|
|
111
284
|
// exit_0: solo en Node, sin shell interpretado (nada de `sh -c`), con timeout y sin heredar stdio.
|
|
112
285
|
const { spawn } = await import('node:child_process');
|
|
113
286
|
const argv = Array.isArray(prueba.argv) ? prueba.argv : null;
|
|
114
287
|
if (!argv || !argv.length || typeof argv[0] !== 'string') {
|
|
115
|
-
return
|
|
288
|
+
return falla('exit_0 necesita argv: ["comando","arg1",…]; no se acepta una línea de shell');
|
|
116
289
|
}
|
|
117
290
|
const res = await new Promise((resolve) => {
|
|
118
291
|
const p = spawn(argv[0], argv.slice(1), { cwd: prueba.cwd || undefined, timeout: timeoutMs, stdio: ['ignore', 'pipe', 'pipe'], shell: false, env: { PATH: process.env.PATH } });
|
|
@@ -148,6 +321,36 @@ export async function veredicto(pruebas, opts = {}) {
|
|
|
148
321
|
return { pasa, indeciso, razon, resultados };
|
|
149
322
|
}
|
|
150
323
|
|
|
324
|
+
// NX-305 — La prueba de un contrato que nace de un PEDIDO al catálogo. La ficha publica el
|
|
325
|
+
// `kind` y una descripción en prosa (`template`); los valores concretos (la URL, el hash, el
|
|
326
|
+
// camino) los pone quien contrata, en el `input` del pedido. Sólo entran los campos que la
|
|
327
|
+
// prueba lee: ni el comprador cuela uno ajeno ni el vendedor uno que la debilite.
|
|
328
|
+
//
|
|
329
|
+
// `exit_0` NO se deriva de un pedido a propósito: el `argv` lo escribiría el comprador y lo
|
|
330
|
+
// correría la casa del vendedor, que cotiza sola. Un vendedor que auto-cotiza sería el proxy
|
|
331
|
+
// de cualquiera para ejecutar comandos en la casa. Se cotiza a mano o no se cotiza.
|
|
332
|
+
export const CAMPOS_PRUEBA = {
|
|
333
|
+
http_status: { requiere: ['url'], opcionales: ['expect', 'method'] },
|
|
334
|
+
sha256: { requiere: ['expect'], opcionales: ['url'] },
|
|
335
|
+
json_path: { requiere: ['url', 'path', 'expect'], opcionales: [] },
|
|
336
|
+
};
|
|
337
|
+
export function pruebaDeAceptacion(acceptance, input = {}) {
|
|
338
|
+
const kind = acceptance?.kind;
|
|
339
|
+
const campos = CAMPOS_PRUEBA[kind];
|
|
340
|
+
if (!campos) throw new Error(`acceptance.kind "${kind}" cannot be derived from a request; a request can only fill: ${Object.keys(CAMPOS_PRUEBA).join(', ')}`);
|
|
341
|
+
const dado = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
342
|
+
const faltan = campos.requiere.filter((k) => dado[k] === undefined);
|
|
343
|
+
if (faltan.length) throw new Error(`the ${kind} test needs ${faltan.join(', ')} in the request input (${acceptance.template})`);
|
|
344
|
+
const verify = { type: kind };
|
|
345
|
+
for (const k of [...campos.requiere, ...campos.opcionales]) if (dado[k] !== undefined) verify[k] = dado[k];
|
|
346
|
+
// Lo que verifica@ va a rechazar al correr se rechaza AQUÍ, antes de firmar: una prueba que
|
|
347
|
+
// nunca puede pasar castigaría al vendedor por lo que escribió el comprador.
|
|
348
|
+
if (verify.url !== undefined && !/^https:\/\//.test(String(verify.url))) throw new Error(`the ${kind} test needs an https url; the request gave ${JSON.stringify(verify.url)}`);
|
|
349
|
+
if (kind === 'sha256' && !/^[0-9a-f]{64}$/.test(String(verify.expect).toLowerCase())) throw new Error('the sha256 test needs expect as 64 hex characters');
|
|
350
|
+
if (kind === 'json_path' && (typeof verify.path !== 'string' || !verify.path.length)) throw new Error('the json_path test needs path as a non-empty string');
|
|
351
|
+
return verify;
|
|
352
|
+
}
|
|
353
|
+
|
|
151
354
|
// Un contrato es verificable por la casa si declara pruebas en sus términos y nombra
|
|
152
355
|
// como árbitro al verificador de la casa. Sin ambas cosas, nadie toca ese escrow.
|
|
153
356
|
export function pruebasDe(contrato) {
|
package/src/nucleo/almacen-d1.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// el roundtrip y las firmas siguen verificando (invariante 7).
|
|
9
9
|
|
|
10
10
|
import { LibroError } from '../libro/errores.js';
|
|
11
|
+
import { filaDeIndice, codificarCursor, CADUCO } from '../correo/indice.js';
|
|
11
12
|
|
|
12
13
|
const j = (v) => JSON.stringify(v);
|
|
13
14
|
const p = (row, col = 'doc') => (row ? JSON.parse(row[col]) : null);
|
|
@@ -71,6 +72,16 @@ export class D1Store {
|
|
|
71
72
|
const r = await this.db.prepare('UPDATE nyx5_mailbox SET acked = ? WHERE local = ? AND id = ? AND acked IS NULL').bind(iso(), local, id).run();
|
|
72
73
|
return r.meta.changes === 1;
|
|
73
74
|
}
|
|
75
|
+
// Historial: lo pendiente y lo confirmado, los últimos N en orden de llegada (índice 0006).
|
|
76
|
+
async listMailHistory(local, { limit = 200 } = {}) {
|
|
77
|
+
const r = await this.db.prepare('SELECT doc, acked FROM nyx5_mailbox WHERE local = ? ORDER BY received DESC, id DESC LIMIT ?').bind(local, limit).all();
|
|
78
|
+
return r.results.map((row) => ({ ...JSON.parse(row.doc), acked: row.acked || null })).reverse();
|
|
79
|
+
}
|
|
80
|
+
// Lo pendiente llegado después de un momento: la espera en tiempo real pregunta esto cada segundo,
|
|
81
|
+
// así que filtra en SQL en vez de traer el buzón entero.
|
|
82
|
+
async listMailSince(local, sinceIso) {
|
|
83
|
+
return (await this.db.prepare('SELECT doc FROM nyx5_mailbox WHERE local = ? AND acked IS NULL AND received > ? ORDER BY received, id').bind(local, sinceIso || '').all()).results.map((r) => JSON.parse(r.doc));
|
|
84
|
+
}
|
|
74
85
|
|
|
75
86
|
// --- cola de salida ---
|
|
76
87
|
_jobRow(job) { return [job.id, j(job), job.next_attempt, job.status, job.claimed_until || 0]; }
|
|
@@ -108,6 +119,49 @@ export class D1Store {
|
|
|
108
119
|
}
|
|
109
120
|
async pruneNonces(beforeMs) { await this.db.prepare('DELETE FROM nyx5_nonces WHERE ts < ?').bind(beforeMs).run(); }
|
|
110
121
|
|
|
122
|
+
// --- kv con vencimiento (0006): clientes OAuth, códigos de un uso, tokens (hash) y la bóveda ---
|
|
123
|
+
async kvGet(ns, key, nowMs = Date.now()) {
|
|
124
|
+
return p(await this.db.prepare('SELECT doc FROM nyx5_kv WHERE ns = ? AND key = ? AND (expires IS NULL OR expires > ?)').bind(ns, key, nowMs).first());
|
|
125
|
+
}
|
|
126
|
+
async kvPut(ns, key, doc, expires = null) {
|
|
127
|
+
await this.db.prepare('INSERT INTO nyx5_kv (ns, key, doc, expires, created) VALUES (?, ?, ?, ?, ?) ON CONFLICT(ns, key) DO UPDATE SET doc = excluded.doc, expires = excluded.expires')
|
|
128
|
+
.bind(ns, key, j(doc), expires, iso()).run();
|
|
129
|
+
}
|
|
130
|
+
async kvPutIfAbsent(ns, key, doc, expires = null, nowMs = Date.now()) {
|
|
131
|
+
await this.db.prepare('DELETE FROM nyx5_kv WHERE ns = ? AND key = ? AND expires IS NOT NULL AND expires <= ?').bind(ns, key, nowMs).run();
|
|
132
|
+
const r = await this.db.prepare('INSERT OR IGNORE INTO nyx5_kv (ns, key, doc, expires, created) VALUES (?, ?, ?, ?, ?)').bind(ns, key, j(doc), expires, iso()).run();
|
|
133
|
+
return r.meta.changes === 1;
|
|
134
|
+
}
|
|
135
|
+
// Tomar y borrar en UNA sentencia: dos canjes simultáneos del mismo código no pueden ganar los dos.
|
|
136
|
+
async kvTake(ns, key, nowMs = Date.now()) {
|
|
137
|
+
const r = await this.db.prepare('DELETE FROM nyx5_kv WHERE ns = ? AND key = ? RETURNING doc, expires').bind(ns, key).all();
|
|
138
|
+
const row = r.results[0];
|
|
139
|
+
if (!row || (row.expires != null && row.expires <= nowMs)) return null;
|
|
140
|
+
return JSON.parse(row.doc);
|
|
141
|
+
}
|
|
142
|
+
// Contador atómico con vencimiento en UNA sentencia: dos isolates que suman a la vez no pierden
|
|
143
|
+
// ninguna. Un contador ya vencido arranca de nuevo en 1 (la purga puede no haber pasado aún).
|
|
144
|
+
// `by`: cuánto suma (1 por defecto; los bytes de un cupo diario, por ejemplo).
|
|
145
|
+
async kvIncrement(ns, key, expires = null, nowMs = Date.now(), by = 1) {
|
|
146
|
+
const paso = String(Math.trunc(Number(by) || 0));
|
|
147
|
+
const r = await this.db.prepare(`INSERT INTO nyx5_kv (ns, key, doc, expires, created) VALUES (?, ?, ?, ?, ?)
|
|
148
|
+
ON CONFLICT(ns, key) DO UPDATE SET
|
|
149
|
+
doc = CASE WHEN nyx5_kv.expires IS NOT NULL AND nyx5_kv.expires <= ? THEN ? ELSE CAST(CAST(nyx5_kv.doc AS INTEGER) + CAST(? AS INTEGER) AS TEXT) END,
|
|
150
|
+
expires = excluded.expires
|
|
151
|
+
RETURNING doc`).bind(ns, key, paso, expires, iso(), nowMs, paso, paso).all();
|
|
152
|
+
return Number(r.results[0].doc);
|
|
153
|
+
}
|
|
154
|
+
async kvDelete(ns, key) { await this.db.prepare('DELETE FROM nyx5_kv WHERE ns = ? AND key = ?').bind(ns, key).run(); }
|
|
155
|
+
// Las claves vivas de un espacio que empiezan por `prefix`, en orden de clave. `%` y `_` del
|
|
156
|
+
// prefijo se escapan: son comodines de LIKE y un prefijo no es un patrón.
|
|
157
|
+
// `after`: sólo claves estrictamente mayores (paginación por clave).
|
|
158
|
+
async kvList(ns, { prefix = '', limit = 1000, after } = {}, nowMs = Date.now()) {
|
|
159
|
+
const patron = `${String(prefix).replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
|
|
160
|
+
const r = await this.db.prepare(`SELECT key, doc FROM nyx5_kv WHERE ns = ? AND key LIKE ? ESCAPE '\\' AND key > ? AND (expires IS NULL OR expires > ?) ORDER BY key LIMIT ?`).bind(ns, patron, after == null ? '' : String(after), nowMs, limit).all();
|
|
161
|
+
return r.results.map((row) => ({ key: row.key, doc: JSON.parse(row.doc) }));
|
|
162
|
+
}
|
|
163
|
+
async kvPurge(nowMs = Date.now()) { await this.db.prepare('DELETE FROM nyx5_kv WHERE expires IS NOT NULL AND expires <= ?').bind(nowMs).run(); }
|
|
164
|
+
|
|
111
165
|
// --- pins (una fila por dominio: el primero gana y ningún isolate pisa lo que otro aprendió) ---
|
|
112
166
|
async getPins() {
|
|
113
167
|
const r = await this.db.prepare('SELECT domain, kid FROM nyx5_pin').all();
|
|
@@ -139,7 +193,16 @@ export class D1Store {
|
|
|
139
193
|
return this.db.prepare('INSERT INTO nyx5_libro_contratos (id, quote_id, doc) VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc, quote_id = excluded.quote_id').bind(c.id, c.quote_id || null, j(c));
|
|
140
194
|
}
|
|
141
195
|
async libroPutContract(c) { await this._contractStmt(c).run(); }
|
|
142
|
-
|
|
196
|
+
// Filtro por estado sobre json_extract(doc,'$.state'), cubierto por el índice de expresión de la
|
|
197
|
+
// migración 0009: el reloj de la casa lee sólo los contratos abiertos, no la tabla entera. Sin
|
|
198
|
+
// columna nueva: el estado ya vive en el doc y una columna sería una segunda verdad que mantener.
|
|
199
|
+
async libroListContracts({ state = null } = {}) {
|
|
200
|
+
if (state == null) return (await this.db.prepare('SELECT doc FROM nyx5_libro_contratos').all()).results.map((r) => JSON.parse(r.doc));
|
|
201
|
+
const estados = Array.isArray(state) ? state : [state];
|
|
202
|
+
if (!estados.length) return [];
|
|
203
|
+
const sql = `SELECT doc FROM nyx5_libro_contratos WHERE json_extract(doc, '$.state') IN (${estados.map(() => '?').join(', ')})`;
|
|
204
|
+
return (await this.db.prepare(sql).bind(...estados.map(String)).all()).results.map((r) => JSON.parse(r.doc));
|
|
205
|
+
}
|
|
143
206
|
async libroFindContractByQuote(quoteId) { return p(await this.db.prepare('SELECT doc FROM nyx5_libro_contratos WHERE quote_id = ?').bind(quoteId).first()); }
|
|
144
207
|
async libroGetMandate(id) { return p(await this.db.prepare('SELECT doc FROM nyx5_libro_mandatos WHERE id = ?').bind(id).first()); }
|
|
145
208
|
_mandateStmt(m) { return this.db.prepare('INSERT INTO nyx5_libro_mandatos (id, doc) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc').bind(m.id, j(m)); }
|
|
@@ -147,6 +210,15 @@ export class D1Store {
|
|
|
147
210
|
async libroListMandates() { return (await this.db.prepare('SELECT doc FROM nyx5_libro_mandatos').all()).results.map((r) => JSON.parse(r.doc)); }
|
|
148
211
|
async libroGetOp(id) { return p(await this.db.prepare('SELECT doc FROM nyx5_libro_ops WHERE id = ?').bind(id).first()); }
|
|
149
212
|
async libroPutOp(id, v) { await this.db.prepare('INSERT INTO nyx5_libro_ops (id, doc) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc').bind(id, j(v)).run(); }
|
|
213
|
+
|
|
214
|
+
// --- notaría (NX-601): sellos de hash (migrations/0008_notaria.sql) ---
|
|
215
|
+
async notariaGet(id) { return p(await this.db.prepare('SELECT doc FROM nyx5_notaria WHERE id = ?').bind(id).first()); }
|
|
216
|
+
async notariaList(sha256) { return (await this.db.prepare('SELECT doc FROM nyx5_notaria WHERE sha256 = ? ORDER BY at, id').bind(sha256).all()).results.map((r) => JSON.parse(r.doc)); }
|
|
217
|
+
async notariaFind(sha256, by) { return p(await this.db.prepare('SELECT doc FROM nyx5_notaria WHERE sha256 = ? AND "by" = ?').bind(sha256, by).first()); }
|
|
218
|
+
// Sin ON CONFLICT a propósito: el índice único (sha256, by) es el candado, y un choque tiene
|
|
219
|
+
// que tirar el batch entero (421 reintentable), no pisar el sello anterior en silencio.
|
|
220
|
+
_selloStmt(s) { return this.db.prepare('INSERT INTO nyx5_notaria (id, sha256, "by", at, doc) VALUES (?, ?, ?, ?, ?)').bind(s.id, s.sha256, s.by, s.at, j(s)); }
|
|
221
|
+
async notariaPut(s) { try { await this._selloStmt(s).run(); } catch (e) { throw this._traducirConflicto(e); } }
|
|
150
222
|
// ---------- instrumentación ----------
|
|
151
223
|
async putEvent(e) { await this.db.prepare('INSERT OR IGNORE INTO nyx5_eventos (id, name, ts, actor, data) VALUES (?, ?, ?, ?, ?)').bind(e.id, e.name, e.ts, e.actor || null, j(e.data || {})).run(); }
|
|
152
224
|
async listEvents({ name = null, since = null, limit = 500 } = {}) {
|
|
@@ -156,11 +228,28 @@ export class D1Store {
|
|
|
156
228
|
const r = await this.db.prepare(`SELECT id, name, ts, actor, data FROM nyx5_eventos WHERE ${cond.join(' AND ')} ORDER BY ts DESC LIMIT ?`).bind(...bind, Number(limit)).all();
|
|
157
229
|
return r.results.map((x) => ({ ...x, data: JSON.parse(x.data) })).reverse();
|
|
158
230
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
231
|
+
// Extracto por cuenta en [since, until) sobre `at` del asiento (json_extract sobre el doc: la
|
|
232
|
+
// fecha ya vive ahí, sin migración). Los `limit` más recientes y el total del rango.
|
|
233
|
+
_statementWhere(account, since, until) {
|
|
234
|
+
const cond = ['n IN (SELECT DISTINCT n FROM nyx5_libro_lineas WHERE account = ?)']; const binds = [account];
|
|
235
|
+
if (since) { cond.push("json_extract(doc, '$.at') >= ?"); binds.push(since); }
|
|
236
|
+
if (until) { cond.push("json_extract(doc, '$.at') < ?"); binds.push(until); }
|
|
237
|
+
return { where: cond.join(' AND '), binds };
|
|
238
|
+
}
|
|
239
|
+
async libroStatementRange(account, { since = null, until = null, limit = 20 } = {}) {
|
|
240
|
+
const { where, binds } = this._statementWhere(account, since, until);
|
|
241
|
+
const total = Number((await this.db.prepare(`SELECT COUNT(*) AS c FROM nyx5_libro_diario WHERE ${where}`).bind(...binds).first())?.c || 0);
|
|
242
|
+
const r = await this.db.prepare(`SELECT doc FROM nyx5_libro_diario WHERE ${where} ORDER BY n DESC LIMIT ?`).bind(...binds, limit).all();
|
|
243
|
+
return { entries: r.results.map((row) => JSON.parse(row.doc)).reverse(), total };
|
|
244
|
+
}
|
|
245
|
+
async libroStatement(account, limit) { return (await this.libroStatementRange(account, { limit })).entries; }
|
|
246
|
+
// Saldo de una cuenta antes del asiento `n` y/o de la fecha `at`, sumado de las líneas.
|
|
247
|
+
async libroBalanceBefore(account, { n = null, at = null } = {}) {
|
|
248
|
+
const cond = ['l.account = ?']; const binds = [account];
|
|
249
|
+
if (n != null) { cond.push('l.n < ?'); binds.push(n); }
|
|
250
|
+
if (at != null) { cond.push("json_extract(d.doc, '$.at') < ?"); binds.push(at); }
|
|
251
|
+
const row = await this.db.prepare(`SELECT COALESCE(SUM(l.delta), 0) AS s FROM nyx5_libro_lineas l JOIN nyx5_libro_diario d ON d.n = l.n WHERE ${cond.join(' AND ')}`).bind(...binds).first();
|
|
252
|
+
return Number(row?.s || 0);
|
|
164
253
|
}
|
|
165
254
|
|
|
166
255
|
// Un movimiento del Libro, atómico. El orden importa: el diario va primero (PK n = candado
|
|
@@ -171,6 +260,7 @@ export class D1Store {
|
|
|
171
260
|
if (bundle.state) stmts.push(this.db.prepare('UPDATE nyx5_libro_state SET seq = ?, balances = ? WHERE id = 1').bind(bundle.state.seq, j(bundle.state.balances)));
|
|
172
261
|
for (const c of bundle.contracts || []) stmts.push(this._contractStmt(c));
|
|
173
262
|
for (const m of bundle.mandates || []) stmts.push(this._mandateStmt(m));
|
|
263
|
+
for (const s of bundle.sellos || []) stmts.push(this._selloStmt(s));
|
|
174
264
|
if (bundle.op) stmts.push(this.db.prepare('INSERT INTO nyx5_libro_ops (id, doc) VALUES (?, ?)').bind(bundle.op.id, j(bundle.op.result)));
|
|
175
265
|
return stmts;
|
|
176
266
|
}
|
|
@@ -212,21 +302,60 @@ export class D1Store {
|
|
|
212
302
|
async indexGetHouse(domain) { return p(await this.db.prepare('SELECT doc FROM nyx5_indice_casas WHERE domain = ?').bind(domain).first()); }
|
|
213
303
|
async indexPutHouse(h) { await this.db.prepare('INSERT INTO nyx5_indice_casas (domain, doc) VALUES (?, ?) ON CONFLICT(domain) DO UPDATE SET doc = excluded.doc').bind(h.domain, j(h)).run(); }
|
|
214
304
|
async indexListHouses() { return (await this.db.prepare('SELECT doc FROM nyx5_indice_casas').all()).results.map((r) => JSON.parse(r.doc)); }
|
|
305
|
+
// Reemplazo por casa con generación: cada fila conserva un historial corto de sus puntajes
|
|
306
|
+
// (hist, first_gen) para que un recorrido por cursor no repita ni salte a quien cambió de
|
|
307
|
+
// puntaje entre página y página (cabecera de src/correo/indice.js).
|
|
215
308
|
async indexReplaceAgents(domain, cards) {
|
|
309
|
+
const previas = (await this.db.prepare('SELECT address, first_gen, hist FROM nyx5_indice_agentes WHERE house = ?').bind(domain).all()).results;
|
|
310
|
+
const previo = new Map(previas.map((r) => [r.address, { first_gen: r.first_gen, hist: JSON.parse(r.hist || '[]') }]));
|
|
311
|
+
const gen = ((await this.db.prepare('SELECT COALESCE(MAX(gen), 0) AS g FROM nyx5_indice_agentes').first()).g || 0) + 1;
|
|
216
312
|
await this.db.batch([
|
|
217
313
|
this.db.prepare('DELETE FROM nyx5_indice_agentes WHERE house = ?').bind(domain),
|
|
218
|
-
...cards.map((c) =>
|
|
314
|
+
...cards.map((c) => {
|
|
315
|
+
const r = filaDeIndice(domain, c, gen, previo.get(c.address) || null);
|
|
316
|
+
return this.db.prepare('INSERT INTO nyx5_indice_agentes (house, address, doc, tags, langs, price_min, score, jobs_done, gen, first_gen, hist) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
317
|
+
.bind(domain, c.address, j(c), j(r.tags), j(r.langs), r.price_min, r.score, r.jobs_done, r.gen, r.first_gen, j(r.hist));
|
|
318
|
+
}),
|
|
219
319
|
]);
|
|
220
320
|
}
|
|
221
|
-
|
|
321
|
+
// La misma búsqueda que buscarEnMemoria (indice.js), en SQL. `g` va como literal entero (ya
|
|
322
|
+
// validado como entero seguro) porque la clave de orden aparece varias veces en la consulta.
|
|
323
|
+
async indexSearch(f = {}) {
|
|
324
|
+
const gActual = (await this.db.prepare('SELECT COALESCE(MAX(gen), 0) AS g FROM nyx5_indice_agentes').first()).g || 0;
|
|
325
|
+
if (f.cursor && f.cursor.g > gActual) throw Object.assign(new Error('invalid cursor: it names a generation of the index that does not exist'), { status: 400 });
|
|
326
|
+
const g = f.cursor ? f.cursor.g : gActual;
|
|
327
|
+
if (!Number.isSafeInteger(g) || g < 0) throw Object.assign(new Error('invalid cursor'), { status: 400 });
|
|
328
|
+
// El puntaje congelado en g: la última entrada del historial con generación <= g; NULL si la
|
|
329
|
+
// dirección entró al índice después de g (claveCongelada, en indice.js, es la referencia).
|
|
330
|
+
const ULT = `(SELECT json_extract(e.value, '$.s') FROM json_each(hist) e WHERE json_extract(e.value, '$.g') <= ${g} ORDER BY json_extract(e.value, '$.g') DESC LIMIT 1)`;
|
|
331
|
+
const K = `(CASE WHEN first_gen > ${g} THEN NULL ELSE ${ULT} END)`;
|
|
222
332
|
const where = []; const binds = [];
|
|
223
|
-
if (house) { where.push('house = ?'); binds.push(house); }
|
|
224
|
-
if (capability) { where.push("json_extract(doc, '$.capabilities.' || ?) IS NOT NULL"); binds.push(capability); }
|
|
225
|
-
if (accepts) { where.push("EXISTS (SELECT 1 FROM json_each(doc, '$.capabilities.accepts') WHERE value = ?)"); binds.push(accepts); }
|
|
226
|
-
if (
|
|
333
|
+
if (f.house) { where.push('house = ?'); binds.push(f.house); }
|
|
334
|
+
if (f.capability) { where.push("json_extract(doc, '$.capabilities.' || ?) IS NOT NULL"); binds.push(f.capability); }
|
|
335
|
+
if (f.accepts) { where.push("EXISTS (SELECT 1 FROM json_each(doc, '$.capabilities.accepts') WHERE value = ?)"); binds.push(f.accepts); }
|
|
336
|
+
if (f.tag) { where.push('EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)'); binds.push(f.tag); }
|
|
337
|
+
if (f.lang) { where.push("EXISTS (SELECT 1 FROM json_each(langs) WHERE value = ? OR value LIKE ? || '-%')"); binds.push(f.lang, f.lang); }
|
|
338
|
+
if (f.price_max != null) { where.push('price_min IS NOT NULL AND price_min <= ?'); binds.push(f.price_max); }
|
|
339
|
+
if (f.q) { where.push("lower(doc) LIKE ? ESCAPE '\\'"); binds.push(`%${String(f.q).toLowerCase().replace(/[\\%_]/g, (c) => '\\' + c)}%`); }
|
|
340
|
+
const wBase = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
341
|
+
if (f.cursor) {
|
|
342
|
+
// Caduco: una fila que ya existía en g y cuyo historial no conserva ninguna entrada <= g.
|
|
343
|
+
const caduco = (await this.db.prepare(`SELECT COUNT(*) AS c FROM nyx5_indice_agentes ${wBase} ${where.length ? 'AND' : 'WHERE'} first_gen <= ${g} AND NOT EXISTS (SELECT 1 FROM json_each(hist) e WHERE json_extract(e.value, '$.g') <= ${g})`).bind(...binds).first()).c;
|
|
344
|
+
if (caduco > 0) throw Object.assign(new Error(CADUCO), { status: 410 });
|
|
345
|
+
}
|
|
346
|
+
if (f.min_score != null) { where.push(`${K} >= ?`); binds.push(f.min_score); }
|
|
227
347
|
const w = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
|
228
348
|
const total = (await this.db.prepare(`SELECT COUNT(*) AS c FROM nyx5_indice_agentes ${w}`).bind(...binds).first()).c;
|
|
229
|
-
const
|
|
230
|
-
|
|
349
|
+
const keyset = []; const kb = [];
|
|
350
|
+
if (f.cursor) {
|
|
351
|
+
if (f.cursor.s == null) { keyset.push(`${K} IS NULL AND address > ?`); kb.push(f.cursor.a); }
|
|
352
|
+
else { keyset.push(`(${K} < ? OR (${K} = ? AND address > ?) OR ${K} IS NULL)`); kb.push(f.cursor.s, f.cursor.s, f.cursor.a); }
|
|
353
|
+
}
|
|
354
|
+
const wPag = [...where, ...keyset].length ? `WHERE ${[...where, ...keyset].join(' AND ')}` : '';
|
|
355
|
+
const rows = (await this.db.prepare(`SELECT address, ${K} AS k, doc FROM nyx5_indice_agentes ${wPag} ORDER BY k DESC NULLS LAST, address ASC LIMIT ?`).bind(...binds, ...kb, f.limit + 1).all()).results;
|
|
356
|
+
const pagina = rows.slice(0, f.limit);
|
|
357
|
+
const ultima = pagina[pagina.length - 1];
|
|
358
|
+
const next_cursor = rows.length > f.limit ? codificarCursor({ s: ultima.k ?? null, a: ultima.address, g }) : null;
|
|
359
|
+
return { total, agents: pagina.map((r) => JSON.parse(r.doc)), next_cursor };
|
|
231
360
|
}
|
|
232
361
|
}
|