@nyx5/nyx5 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ // Nyx5/1 — búsqueda del índice federado con reputación y precio (NX-302).
2
+ //
3
+ // UNA definición para los dos almacenes: qué columnas se extraen de una tarjeta, cómo se
4
+ // codifica el cursor, qué filtros se aceptan y cómo se pagina. FileStore usa `buscarEnMemoria`
5
+ // tal cual; D1Store traduce las mismas reglas a SQL. Si las dos implementaciones divergen, el
6
+ // test de paginación (test/busqueda.test.js) corre sobre ambas y lo dice.
7
+ //
8
+ // Orden: (puntaje DESC, los sin puntaje al FINAL, dirección ASC). Cursor opaco = base64url de
9
+ // JSON { s, a, g }: el puntaje y la dirección de la última fila servida, y la GENERACIÓN del
10
+ // índice en la que empezó el recorrido. Nunca un offset: un offset sobre un conjunto que cambia
11
+ // entre páginas salta o repite filas (trampa «ventana horneada e isla»).
12
+ //
13
+ // Por qué la generación: el puntaje CAMBIA con cada rastreo, y un cursor por (puntaje,
14
+ // dirección) sobre un puntaje que se mueve repite al que bajó y se salta al que subió. Cada
15
+ // fila guarda, además del puntaje actual, un historial corto de sus cambios: `hist` =
16
+ // [{ g, s }] (generación en que el puntaje pasó a valer s), los últimos HIST_MAX cambios, y
17
+ // `first_gen`, la generación en que la dirección entró al índice. Un recorrido que empezó en
18
+ // la generación g ordena cada fila por el puntaje que tenía en g: la última entrada de `hist`
19
+ // con g' <= g. Si esa entrada ya se descartó (más de HIST_MAX cambios desde que empezó el
20
+ // recorrido), el puntaje congelado no existe: el recorrido se declara caduco (410) y quien
21
+ // recorre vuelve a empezar; nunca se sirve una página en silencio con otro orden.
22
+ // Lo que NO cubre: un agente que ENTRA al índice después de empezar el recorrido va al final
23
+ // (clave nula) y se ve sólo si el recorrido no pasó ya por los nulos; uno que SALE deja de
24
+ // verse; y los filtros de etiqueta/idioma/precio miran la fila ACTUAL (quien cambió de ficha a
25
+ // mitad del recorrido entra o sale del conjunto según su ficha de hoy).
26
+
27
+ import { puntajeArbitrado } from '../libro/libro.js';
28
+
29
+ const fallo = (status, message) => Object.assign(new Error(message), { status });
30
+
31
+ // ---------- columnas que el índice extrae de una tarjeta ----------
32
+ // El precio sale de `profile.services[].price.tokens` (la ficha con servicios la agrega otro
33
+ // frente; aquí sólo se lee si existe). Sin servicios con precio en tokens, `price_min` es null
34
+ // y el agente NO pasa un filtro `price_max`: no anunciar precio no es ser gratis.
35
+ export function precioMinimo(card) {
36
+ const servicios = card?.profile?.services;
37
+ if (!Array.isArray(servicios)) return null;
38
+ const precios = servicios.map((s) => s?.price?.tokens).filter((t) => Number.isInteger(t) && t >= 0);
39
+ return precios.length ? Math.min(...precios) : null;
40
+ }
41
+
42
+ export function columnasDeIndice(card) {
43
+ const tags = Array.isArray(card?.profile?.tags) ? card.profile.tags.filter((t) => typeof t === 'string') : [];
44
+ const langs = Array.isArray(card?.profile?.languages) ? card.profile.languages.filter((l) => typeof l === 'string').map((l) => l.toLowerCase()) : [];
45
+ const score = typeof card?._score === 'number' && Number.isFinite(card._score) ? card._score : null;
46
+ const jobs_done = Number.isInteger(card?._jobs_done) ? card._jobs_done : null;
47
+ return { tags, langs, price_min: precioMinimo(card), score, jobs_done };
48
+ }
49
+
50
+ // El puntaje de un agente a partir de su historial público (§21). Sólo cuenta lo arbitrado por
51
+ // `verifica@` de su casa; una casa que no distingue arbitrados (versión anterior) no da puntaje.
52
+ // Devuelve también el motivo cuando no hay puntaje, para que el rastreo cuente su denominador.
53
+ export function puntajeDe(historial) {
54
+ if (!historial || typeof historial !== 'object') return { score: null, jobs_done: null, motivo: 'sin_historial' };
55
+ if (!historial.arbitrados || typeof historial.arbitrados !== 'object') return { score: null, jobs_done: null, motivo: 'sin_arbitrados' };
56
+ const score = puntajeArbitrado(historial.arbitrados);
57
+ if (score == null) return { score: null, jobs_done: 0, motivo: 'sin_historial' };
58
+ return { score, jobs_done: Number(historial.arbitrados.liberados?.n) || 0, motivo: null };
59
+ }
60
+
61
+ // ---------- cursor ----------
62
+ export function codificarCursor({ s, a, g }) {
63
+ return Buffer.from(JSON.stringify({ s: s ?? null, a, g })).toString('base64url');
64
+ }
65
+ export function decodificarCursor(texto) {
66
+ const invalido = () => fallo(400, 'invalid cursor: use the next_cursor the index gave you, or none for the first page');
67
+ if (typeof texto !== 'string' || !/^[A-Za-z0-9_-]{1,600}$/.test(texto)) throw invalido();
68
+ let c;
69
+ try { c = JSON.parse(Buffer.from(texto, 'base64url').toString('utf8')); } catch { throw invalido(); }
70
+ if (!c || typeof c !== 'object' || Array.isArray(c)) throw invalido();
71
+ if (Object.keys(c).some((k) => !['s', 'a', 'g'].includes(k))) throw invalido();
72
+ if (!(c.s === null || (typeof c.s === 'number' && Number.isFinite(c.s) && c.s >= 0 && c.s <= 1))) throw invalido();
73
+ if (typeof c.a !== 'string' || !c.a || c.a.length > 320) throw invalido();
74
+ if (!Number.isSafeInteger(c.g) || c.g < 0) throw invalido();
75
+ return c;
76
+ }
77
+
78
+ // ---------- filtros de la consulta ----------
79
+ // `p` son los parámetros crudos (strings de la URL o valores de la herramienta MCP). Falla con
80
+ // 400 ante lo que no se puede interpretar; `limit` se acota como siempre (nunca fue un 400).
81
+ export const FILTROS = ['q', 'tag', 'lang', 'capability', 'accepts', 'house', 'price_max', 'min_score', 'limit', 'cursor'];
82
+ export function validarFiltros(p = {}) {
83
+ const vacio = (v) => v == null || v === '';
84
+ if (!vacio(p.offset)) throw fallo(400, 'offset is not supported: page with cursor (next_cursor of the previous page)');
85
+ const f = {};
86
+ for (const k of ['q', 'capability', 'accepts', 'house']) {
87
+ if (vacio(p[k])) continue;
88
+ if (typeof p[k] !== 'string' || p[k].length > 200) throw fallo(400, `${k} must be a string of up to 200 characters`);
89
+ if (k === 'capability' && !/^[a-z0-9_-]{1,64}$/.test(p[k])) throw fallo(400, 'capability must be 1-64 lowercase letters, digits, dashes or underscores');
90
+ f[k] = k === 'house' ? p[k].toLowerCase() : p[k];
91
+ }
92
+ if (!vacio(p.tag)) {
93
+ if (typeof p.tag !== 'string' || !/^[a-z0-9-]{1,32}$/.test(p.tag)) throw fallo(400, 'tag must be 1-32 lowercase letters, digits and dashes');
94
+ f.tag = p.tag;
95
+ }
96
+ if (!vacio(p.lang)) {
97
+ const l = String(p.lang).toLowerCase();
98
+ if (!/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/.test(l)) throw fallo(400, 'lang must be a language tag like "es" or "en-US"');
99
+ f.lang = l;
100
+ }
101
+ if (!vacio(p.price_max)) {
102
+ const n = Number(p.price_max);
103
+ if (!Number.isInteger(n) || n < 0) throw fallo(400, 'price_max must be a non-negative integer (tokens)');
104
+ f.price_max = n;
105
+ }
106
+ if (!vacio(p.min_score)) {
107
+ const n = Number(p.min_score);
108
+ if (!Number.isFinite(n) || n < 0 || n > 1) throw fallo(400, 'min_score must be a number between 0 and 1');
109
+ f.min_score = n;
110
+ }
111
+ const lim = Number(p.limit);
112
+ f.limit = Number.isInteger(lim) && lim > 0 ? Math.min(lim, 200) : 50;
113
+ if (!vacio(p.cursor)) f.cursor = decodificarCursor(p.cursor);
114
+ return f;
115
+ }
116
+
117
+ // ---------- filas: historial de puntajes ----------
118
+ export const HIST_MAX = 16;
119
+ // La fila nueva de una tarjeta al reemplazar la casa en la generación `gen`, dada la fila previa
120
+ // de esa dirección (o null si es nueva). Sólo se anota un cambio cuando el puntaje cambia.
121
+ export function filaDeIndice(house, card, gen, previa) {
122
+ const col = columnasDeIndice(card);
123
+ const hist = Array.isArray(previa?.hist) ? previa.hist.slice() : [];
124
+ if (!hist.length || (hist[hist.length - 1].s ?? null) !== col.score) hist.push({ g: gen, s: col.score });
125
+ return { house, address: card.address, doc: card, ...col, gen, first_gen: previa?.first_gen ?? gen, hist: hist.slice(-HIST_MAX) };
126
+ }
127
+ // Puntaje congelado de una fila para un recorrido que empezó en g. `undefined` = caduco.
128
+ export function claveCongelada(r, g) {
129
+ if ((r.first_gen ?? 0) > g) return null;
130
+ const e = (r.hist || []).filter((x) => x.g <= g).at(-1);
131
+ if (!e) return undefined;
132
+ return e.s ?? null;
133
+ }
134
+
135
+ // ---------- la búsqueda, en memoria (FileStore y referencia para D1) ----------
136
+ // `filas`: [{ house, address, doc, tags, langs, price_min, score, jobs_done, gen, first_gen, hist }]
137
+ export function buscarEnMemoria(filas, f) {
138
+ const gActual = filas.reduce((m, r) => Math.max(m, r.gen || 0), 0);
139
+ if (f.cursor && f.cursor.g > gActual) throw fallo(400, 'invalid cursor: it names a generation of the index that does not exist');
140
+ const g = f.cursor ? f.cursor.g : gActual;
141
+ let sel = filas.filter((r) => filtraFila(r, f)).map((r) => ({ r, k: claveCongelada(r, g) }));
142
+ if (sel.some((x) => x.k === undefined)) throw fallo(410, CADUCO);
143
+ if (f.min_score != null) sel = sel.filter((x) => x.k != null && x.k >= f.min_score);
144
+ const total = sel.length;
145
+ if (f.cursor) sel = sel.filter((x) => despuesDelCursor(x.k, x.r.address, f.cursor));
146
+ sel.sort((x, y) => compararFilas(x.k, x.r.address, y.k, y.r.address));
147
+ const pagina = sel.slice(0, f.limit);
148
+ const ultima = pagina[pagina.length - 1];
149
+ const next_cursor = sel.length > f.limit ? codificarCursor({ s: ultima.k, a: ultima.r.address, g }) : null;
150
+ return { total, agents: pagina.map((x) => x.r.doc), next_cursor };
151
+ }
152
+ export const CADUCO = 'cursor expired: the scores this walk was ordered by are no longer kept by the index; start again without cursor';
153
+
154
+ export function filtraFila(r, f) {
155
+ const c = r.doc || {};
156
+ if (f.house && r.house !== f.house) return false;
157
+ if (f.capability && !c.capabilities?.[f.capability]) return false;
158
+ if (f.accepts && !c.capabilities?.accepts?.includes(f.accepts)) return false;
159
+ if (f.tag && !(r.tags || []).includes(f.tag)) return false;
160
+ if (f.lang && !(r.langs || []).some((l) => l === f.lang || l.startsWith(`${f.lang}-`))) return false;
161
+ if (f.price_max != null && !(r.price_min != null && r.price_min <= f.price_max)) return false;
162
+ if (f.q && !JSON.stringify(c).toLowerCase().includes(String(f.q).toLowerCase())) return false;
163
+ return true;
164
+ }
165
+
166
+ // Mismo orden que el SQL: puntaje DESC con nulos al final, dirección ASC (comparación binaria).
167
+ export function compararFilas(kx, ax, ky, ay) {
168
+ if (kx == null && ky == null) return ax < ay ? -1 : ax > ay ? 1 : 0;
169
+ if (kx == null) return 1;
170
+ if (ky == null) return -1;
171
+ if (kx !== ky) return ky - kx;
172
+ return ax < ay ? -1 : ax > ay ? 1 : 0;
173
+ }
174
+
175
+ export function despuesDelCursor(k, address, cur) {
176
+ if (cur.s == null) return k == null && address > cur.a;
177
+ return (k != null && k < cur.s) || (k === cur.s && address > cur.a) || k == null;
178
+ }
@@ -8,9 +8,135 @@
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
+ export const nombreDeProyecto = (p) => limpio(String(p).normalize('NFKC'), 40).toLowerCase() || null;
25
+ export const rolDe = (env) => { const r = env?.extensions?.[EXT_PROYECTO]?.role; return typeof r === 'string' ? (limpio(r, 40) || null) : null; };
26
+
27
+ // Ficha pública del agente (13-sep-2026): qué hace, en qué idiomas, de quién es, con qué etiquetas.
28
+ // Va DENTRO de la tarjeta certificada, la firma el dueño al declararla y la casa al certificarla.
29
+ // Vocabulario CERRADO, como el alcance de un mandato: una clave desconocida se rechaza nombrándola,
30
+ // porque una ficha es lo que otros agentes leen antes de contratar, y no puede llevar de todo.
31
+ // Lo declarado NO está verificado (eso es el sello de dueño, otra pieza): es lo que el dueño dice.
32
+ const PERFIL_CLAVES = ['display_name', 'summary', 'description', 'languages', 'tags', 'owner', 'links', 'services'];
33
+ // Fuera: controles C0 y C1, invisibles de ancho cero, y las marcas bidi que voltean el texto en
34
+ // pantalla (un nombre que se lee al revés es una suplantación). Los espacios se colapsan.
35
+ // Los controles pasan a espacio (separaban algo); los invisibles se quitan (no separaban nada, y
36
+ // "sig<ancho cero>o" tiene que quedar "sigo", no "sig o").
37
+ const CONTROLES = /[\x00-\x1f\x7f-\x9f]/g;
38
+ const INVISIBLES = /[\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g;
39
+ export const limpio = (s, max) => String(s).replace(INVISIBLES, '').replace(CONTROLES, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
40
+ export function validarPerfil(p) {
41
+ if (p === null) return { perfil: null };
42
+ if (typeof p !== 'object' || Array.isArray(p)) return { error: 'profile must be an object' };
43
+ const ajenas = Object.keys(p).filter((k) => !PERFIL_CLAVES.includes(k));
44
+ 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(', ')}` };
45
+ const out = {};
46
+ for (const [k, max] of [['display_name', 80], ['summary', 280], ['description', 2000]]) {
47
+ if (p[k] == null) continue;
48
+ if (typeof p[k] !== 'string') return { error: `profile.${k} must be a string` };
49
+ const v = limpio(p[k], max); if (v) out[k] = v;
50
+ }
51
+ if (p.languages != null) {
52
+ // Tope por etiqueta (35, el máximo razonable de BCP 47): sin él, diez etiquetas de 180 KB
53
+ // hacían una tarjeta de 1,8 MB firmada por la casa (revisión del 13-sep-2026).
54
+ 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"' };
55
+ out.languages = [...new Set(p.languages)];
56
+ }
57
+ if (p.tags != null) {
58
+ 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' };
59
+ out.tags = [...new Set(p.tags)];
60
+ }
61
+ if (p.owner != null) {
62
+ const o = p.owner;
63
+ 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 }' };
64
+ out.owner = { kind: o.kind, name: limpio(o.name, 120) };
65
+ }
66
+ if (p.links != null) {
67
+ if (!Array.isArray(p.links) || p.links.length > 5) return { error: 'profile.links must be up to 5 https URLs' };
68
+ // Sin usuario ni clave en la URL: `https://nyx5.com@evil.example/` se lee como nyx5.com y va a evil.
69
+ 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)}` }; }
70
+ out.links = [...new Set(p.links.map(String))];
71
+ }
72
+ if (p.services != null) {
73
+ if (!Array.isArray(p.services) || p.services.length > SERVICIOS_MAX) return { error: `profile.services must be a list of up to ${SERVICIOS_MAX} services` };
74
+ const ids = new Set(), lista = [];
75
+ for (const s of p.services) {
76
+ const v = validarServicio(s);
77
+ if (v.error) return { error: v.error };
78
+ if (ids.has(v.servicio.id)) return { error: `profile.services: the id "${v.servicio.id}" appears twice` };
79
+ ids.add(v.servicio.id); lista.push(v.servicio);
80
+ }
81
+ if (lista.length) out.services = lista;
82
+ }
83
+ return { perfil: out };
84
+ }
85
+
86
+ // Catálogo de servicios en la ficha (NX-301): lo que el agente vende, con precio y contrato
87
+ // publicados. Una cotización que nombre un `service` se compara contra ESTO al aceptar
88
+ // (`Libro.verifyQuote`): el precio y el contrato de la cotización tienen que ser los publicados,
89
+ // igual que una tarea sembrada se cotiza con los términos del catálogo tal cual.
90
+ // Vocabulario cerrado por servicio, como el resto de la ficha. `acceptance.kind` sólo puede ser una
91
+ // prueba que verifica@ sabe correr (la lista es la de verifica.js, importada, no copiada) y
92
+ // `contract` sólo un tipo cotizable (la lista es la de contratos.js). `template` es texto para
93
+ // quien va a cotizar: no es la prueba concreta, ésa va en `terms.verify` de cada cotización.
94
+ // `price.usd` es un decimal en texto (no flotante) y SÓLO tiene sentido si la tarjeta declara
95
+ // billetera: eso lo comprueba `usdSinBilletera` en la ruta, porque este validador no ve la tarjeta.
96
+ const SERVICIOS_MAX = 20;
97
+ const SERVICIO_CLAVES = ['id', 'name', 'summary', 'price', 'unit', 'contract', 'acceptance'];
98
+ const UNIDADES = ['job', 'call', 'hour'];
99
+ const contratosCotizables = () => Object.keys(CONTRATOS).filter((k) => CONTRATOS[k]?.quoteable);
100
+ export function validarServicio(s) {
101
+ if (typeof s !== 'object' || s === null || Array.isArray(s)) return { error: 'profile.services: each service must be an object' };
102
+ const ajenas = Object.keys(s).filter((k) => !SERVICIO_CLAVES.includes(k));
103
+ 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(', ')}` };
104
+ 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' };
105
+ const donde = `profile.services[${s.id}]`;
106
+ if (typeof s.name !== 'string' || !limpio(s.name, 80)) return { error: `${donde}.name must be a non-empty string` };
107
+ const out = { id: s.id, name: limpio(s.name, 80) };
108
+ 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; }
109
+ const pr = s.price;
110
+ 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? }` };
111
+ if (!Number.isInteger(pr.tokens) || pr.tokens <= 0) return { error: `${donde}.price.tokens must be a positive integer` };
112
+ out.price = { tokens: pr.tokens };
113
+ if (pr.usd != null) {
114
+ // Decimal en texto con hasta 6 decimales (los de USDC), mayor que cero. Nunca un número: 0.1+0.2.
115
+ 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"` };
116
+ out.price.usd = pr.usd;
117
+ }
118
+ if (!UNIDADES.includes(s.unit)) return { error: `${donde}.unit must be one of ${UNIDADES.join(', ')}` };
119
+ out.unit = s.unit;
120
+ const cotizables = contratosCotizables();
121
+ if (!cotizables.includes(s.contract)) return { error: `${donde}.contract must be one of ${cotizables.join(', ')}` };
122
+ out.contract = s.contract;
123
+ if (s.acceptance != null) {
124
+ const a = s.acceptance;
125
+ 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 }` };
126
+ if (!PRUEBAS.includes(a.kind)) return { error: `${donde}.acceptance.kind must be a test verifica@ can run: ${PRUEBAS.join(', ')}` };
127
+ if (typeof a.template !== 'string' || !limpio(a.template, 280)) return { error: `${donde}.acceptance.template must be a non-empty string` };
128
+ out.acceptance = { kind: a.kind, template: limpio(a.template, 280) };
129
+ }
130
+ return { servicio: out };
131
+ }
132
+ // Un precio en dólares sólo se publica si hay a dónde cobrarlo. La comprobación vive aparte de
133
+ // validarPerfil porque la billetera está en la tarjeta, no en la ficha. Devuelve el error o null.
134
+ export function usdSinBilletera(perfil, wallets) {
135
+ const con = (perfil?.services || []).filter((s) => s.price?.usd != null);
136
+ if (!con.length || (Array.isArray(wallets) && wallets.length)) return null;
137
+ return `service "${con[0].id}" declares a usd price but the card has no wallet; declare wallets first`;
138
+ }
139
+
14
140
  export function validateEnvelope(env, { maxBytes = 1_048_576 } = {}) {
15
141
  const fail = (reason) => ({ ok: false, code: 400, reason });
16
142
  if (!env || typeof env !== 'object') return fail('sobre no es un objeto');
@@ -43,6 +169,41 @@ export function validateEnvelope(env, { maxBytes = 1_048_576 } = {}) {
43
169
  return { ok: true };
44
170
  }
45
171
 
172
+ // ---------- la puerta del correo ----------
173
+ // Un correo entra al buzón SIN firma y sin cuenta en el Libro. Durante un tiempo entró además sin
174
+ // pasar por ninguna política: un buzón que cobraba 500 y otro con lista blanca cerrada aceptaban
175
+ // los dos un correo de cualquier desconocido, gratis. O sea que la estampilla, la lista y la
176
+ // prueba de trabajo defendían una puerta mientras la de al lado quedaba abierta, y el precio que
177
+ // la casa anuncia por x402 era evitable escribiendo un correo.
178
+ //
179
+ // Esta función es esa puerta. Falla CERRADO: si la política del buzón no se puede expresar sobre
180
+ // correo, no se deja pasar. Un mecanismo que no existe en este canal no es un permiso.
181
+ //
182
+ // Se compara contra el remitente REAL del correo, no contra `email@<casa>`, que es la pasarela y
183
+ // sería la misma para todo el mundo.
184
+ export function applyEmailPolicy(agentRecord, emailFrom) {
185
+ const inbox = agentRecord.inbox || { policy: 'open' };
186
+ const dominio = String(emailFrom).slice(String(emailFrom).lastIndexOf('@') + 1).toLowerCase();
187
+ const de = String(emailFrom).toLowerCase();
188
+ switch (inbox.policy) {
189
+ case undefined:
190
+ case 'open':
191
+ return { ok: true };
192
+ case 'allowlist': {
193
+ const ok = inbox.allowlist?.some((x) => String(x).toLowerCase() === de || String(x).toLowerCase() === dominio);
194
+ // El "intro" y el aval del canal firmado NO tienen equivalente aquí: uno se apoya en el tipo
195
+ // de sobre y el otro en una fianza del Libro, y un correo no trae ninguno de los dos.
196
+ return ok ? { ok: true } : { ok: false, code: 403, reason: 'this mailbox only accepts listed senders' };
197
+ }
198
+ case 'pow':
199
+ 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` };
200
+ case 'stamp':
201
+ 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` };
202
+ default:
203
+ return { ok: false, code: 403, reason: `unknown mailbox policy (${inbox.policy})` };
204
+ }
205
+ }
206
+
46
207
  // Limitador de tasa por clave (dominio emisor), ventana deslizante simple.
47
208
  export class RateLimiter {
48
209
  constructor({ perMinute = 120 } = {}) { this.perMinute = perMinute; this.hits = new Map(); }
@@ -52,6 +213,34 @@ export class RateLimiter {
52
213
  if (arr.length >= this.perMinute) { this.hits.set(key, arr); return false; }
53
214
  arr.push(now); this.hits.set(key, arr); return true;
54
215
  }
216
+ retryAfter() { return 60; }
217
+ }
218
+
219
+ // Límite de tasa DURABLE (13-sep-2026, NX-901): el contador vive en el almacén, no en la memoria del
220
+ // proceso. En el edge cada isolate contaba por su lado, así que el límite real era N veces el
221
+ // declarado y nadie sabía cuánto era N. Ventana fija de un minuto por clave: una fila por clave y
222
+ // minuto, que vence sola dos minutos después. La misma interfaz que el de memoria (`allow` se
223
+ // espera con await en los dos: en el de memoria el await no cuesta nada).
224
+ // Si el almacén falla, DEJA PASAR y lo anota: un límite de tasa caído no puede tumbar el correo de
225
+ // todos. Precisión sobre cobertura: retener trabajo bueno es peor que dejar pasar un minuto.
226
+ export class RateLimiterDurable {
227
+ constructor({ store, perMinute = 120, ns = 'tasa', log = () => {} } = {}) {
228
+ if (!store?.kvIncrement) throw new Error('RateLimiterDurable needs a store with kvIncrement');
229
+ this.store = store; this.perMinute = perMinute; this.ns = ns; this.log = log;
230
+ this.cerradas = new Map(); // clave -> ventana ya agotada, por proceso: un flood no escribe una fila por golpe
231
+ }
232
+ static ventana(nowMs = Date.now()) { return Math.floor(nowMs / 60_000); }
233
+ async allow(key, nowMs = Date.now()) {
234
+ const v = RateLimiterDurable.ventana(nowMs);
235
+ if (this.cerradas.get(key) === v) return false;
236
+ try {
237
+ const n = await this.store.kvIncrement(this.ns, `${key}:${v}`, (v + 2) * 60_000, nowMs);
238
+ if (n > this.perMinute) { if (this.cerradas.size > 10_000) this.cerradas.clear(); this.cerradas.set(key, v); return false; }
239
+ return true;
240
+ } catch (e) { this.log(`tasa: el almacén no contó (${e.message}); se deja pasar`); return true; }
241
+ }
242
+ // Segundos hasta que abra la ventana siguiente: es lo que va en Retry-After.
243
+ retryAfter(nowMs = Date.now()) { return Math.max(1, 60 - Math.floor((nowMs % 60_000) / 1000)); }
55
244
  }
56
245
 
57
246
  // Política del agente destino sobre un sobre ya verificado criptográficamente.
@@ -27,7 +27,10 @@ export function parseTxtRecord(txt) {
27
27
  }
28
28
 
29
29
  export class Resolver {
30
- constructor({ hosts = {}, fetchImpl = globalThis.fetch, cacheTtlMs = 5 * 60 * 1000, pins = {}, timeoutMs = 5000, onPin = null, self = null } = {}) {
30
+ constructor({ hosts = {}, fetchImpl = globalThis.fetch, cacheTtlMs = 5 * 60 * 1000, pins = {}, timeoutMs = 5000, onPin = null, self = null, auth = null } = {}) {
31
+ // auth = { domain, header(method, path) }: un CLIENTE (Agent) firma la petición a su propia casa
32
+ // cuando una tarjeta no aparece sin autenticar; así un contacto resuelve a un agente secreto.
33
+ this.auth = auth;
31
34
  this.hosts = { ...hosts }; // { "beta.local": { url: "http://localhost:4002", sig?: "<pub>" } }
32
35
  this.fetch = (...a) => fetchImpl(...a); // envuelto: workerd exige fetch con this=globalThis
33
36
  this.cacheTtlMs = cacheTtlMs;
@@ -42,7 +45,8 @@ export class Resolver {
42
45
  }
43
46
 
44
47
  setHost(domain, entry) { this.hosts[domain] = entry; this.cache.clear(); }
45
- invalidate(key) { if (key) this.cache.delete(key); else this.cache.clear(); }
48
+ // Invalidar `agent:x@casa` borra también lo recordado «para» alguien de esa misma tarjeta.
49
+ invalidate(key) { if (!key) { this.cache.clear(); return; } for (const k of this.cache.keys()) if (k === key || k.startsWith(`${key}|for:`)) this.cache.delete(k); }
46
50
 
47
51
  _cached(key) {
48
52
  const hit = this.cache.get(key);
@@ -51,8 +55,8 @@ export class Resolver {
51
55
  }
52
56
  _remember(key, value) { this.cache.set(key, { value, until: Date.now() + this.cacheTtlMs }); }
53
57
 
54
- async _get(url) {
55
- const res = await this.fetch(url, { signal: AbortSignal.timeout(this.timeoutMs) });
58
+ async _get(url, headers = {}) {
59
+ const res = await this.fetch(url, { headers, signal: AbortSignal.timeout(this.timeoutMs) });
56
60
  if (!res.ok) throw Object.assign(new Error(`GET ${url} -> ${res.status}`), { permanent: res.status === 404 || res.status === 410 });
57
61
  return res.json();
58
62
  }
@@ -111,7 +115,10 @@ export class Resolver {
111
115
  }
112
116
 
113
117
  // Tarjeta del agente, certificada por la clave del dominio.
114
- async agentCard(address) {
118
+ // `onBehalfOf` (NX-202): para quién se pide. Un agente SECRETO sólo se sirve a quien está en su
119
+ // lista, así que la casa que pregunta firma «lo pido para bob@mi-casa» con su llave de dominio, y
120
+ // un cliente firma con la suya ante su propia casa. Sin eso, un secreto se ve como inexistente.
121
+ async agentCard(address, { onBehalfOf = null } = {}) {
115
122
  const { local, domain } = parseAddress(address);
116
123
  // La propia casa sirve la tarjeta sin red, pero pasa por la MISMA verificación que una ajena:
117
124
  // venir de casa no la exime de estar certificada por el dominio y firmada por su padre.
@@ -122,13 +129,29 @@ export class Resolver {
122
129
  return this._verifyAgentCard(card, dc, address, local, domain);
123
130
  }
124
131
  const key = `agent:${local}@${domain}`;
125
- const cached = this._cached(key);
132
+ // Una tarjeta secreta se recuerda SÓLO para quien la pidió: servida a otro, sería una fuga.
133
+ const keyPara = onBehalfOf ? `${key}|for:${String(onBehalfOf).toLowerCase()}` : null;
134
+ const cached = this._cached(key) || (keyPara && this._cached(keyPara));
126
135
  if (cached) return cached;
127
136
 
128
137
  const dc = await this.domainCard(domain);
129
- const card = await this._get(`${dc._estafeta}/agents/${encodeURIComponent(local)}`);
138
+ const url = `${dc._estafeta}/agents/${encodeURIComponent(local)}`;
139
+ const headers = {};
140
+ let paraDom = null; try { paraDom = onBehalfOf ? parseAddress(onBehalfOf).domain : null; } catch { paraDom = null; }
141
+ if (onBehalfOf && this.self?.firmarPara && paraDom === this.self.domain) headers['x-nyx5-for'] = this.self.firmarPara(String(onBehalfOf).toLowerCase(), domain);
142
+ let card;
143
+ try { card = await this._get(url, headers); }
144
+ catch (e) {
145
+ // Un cliente que no la ve sin autenticar la vuelve a pedir firmando: a su propia casa si el
146
+ // agente es de ahí; si es de otra casa, le pide a la suya que resuelva «para» él (la casa
147
+ // firma con su llave de dominio y la otra decide). La tarjeta se verifica igual al llegar.
148
+ if (!(e.permanent && this.auth)) throw e;
149
+ if (domain === this.auth.domain) card = await this._get(url, { authorization: this.auth.header('GET', `/agents/${encodeURIComponent(local)}`) });
150
+ else if (this.auth.url) { const p = `/resolve/${encodeURIComponent(`${local}@${domain}`)}`; const { presence: _p, ...c } = await this._get(`${this.auth.url}${p}`, { authorization: this.auth.header('GET', p) }); card = c; }
151
+ else throw e;
152
+ }
130
153
  const value = await this._verifyAgentCard(card, dc, address, local, domain);
131
- this._remember(key, value);
154
+ this._remember(value.visibility === 'secret' ? (keyPara || `${key}|for:${this.auth?.domain || 'self'}`) : key, value);
132
155
  return value;
133
156
  }
134
157
 
@@ -161,12 +184,13 @@ export class Resolver {
161
184
 
162
185
  // Igual que agentCard, pero si el sobre viene firmado con una clave que la tarjeta en caché no
163
186
  // reconoce (rotación reciente), refresca la tarjeta una vez antes de rechazar.
164
- async agentCardForKid(address, kid) {
165
- let card = await this.agentCard(address);
187
+ async agentCardForKid(address, kid, opts = {}) {
188
+ let card = await this.agentCard(address, opts);
166
189
  if (!Resolver.acceptedKids(card).includes(kid)) {
167
190
  const { local, domain } = parseAddress(address);
168
191
  this.invalidate(`agent:${local}@${domain}`);
169
- card = await this.agentCard(address);
192
+ if (opts.onBehalfOf) this.invalidate(`agent:${local}@${domain}|for:${String(opts.onBehalfOf).toLowerCase()}`);
193
+ card = await this.agentCard(address, opts);
170
194
  }
171
195
  return card;
172
196
  }