@nyx5/nyx5 0.7.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 +15 -3
- package/bin/nyx5.js +72 -0
- package/demo/x402-pagar.mjs +64 -0
- package/docs/ARQUITECTURA.md +352 -0
- package/docs/SPEC.md +337 -6
- 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/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 +247 -13
- package/src/correo/asistente.js +502 -77
- package/src/correo/ci.js +262 -0
- package/src/correo/cobro.js +164 -0
- package/src/correo/estafeta.js +558 -59
- package/src/correo/ideas.js +154 -0
- package/src/correo/politica.js +92 -3
- package/src/libro/libro.js +6 -2
- package/src/libro/verifica.js +239 -36
- package/src/nucleo/almacen-d1.js +24 -5
- package/src/nucleo/almacen.js +31 -4
- package/src/nucleo/crypto.js +36 -0
- 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/node.js +18 -1
- package/src/plataformas/spec-html.js +1 -1
- package/src/plataformas/terms-html.js +2 -0
- package/src/plataformas/worker.js +21 -1
- package/src/puentes/ejecutor.js +118 -0
- package/src/puentes/herramientas.js +137 -12
- package/src/puentes/x402-pagador.js +248 -0
- package/src/version.js +1 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Nyx5/1 — ideas@: el buzón automático de vacaciones. RECIBE, GUARDA y CONFIRMA. NUNCA EJECUTA.
|
|
2
|
+
//
|
|
3
|
+
// Nació del pedido de Nicholas del 14-sep-2026 (se va un mes): un lugar donde dejar una idea desde
|
|
4
|
+
// el teléfono y saber que quedó anotada, sin que nadie la ejecute ni la interprete. Corre desde el
|
|
5
|
+
// reloj programado de la casa, sin el Mac y sin ningún modelo de lenguaje.
|
|
6
|
+
//
|
|
7
|
+
// Límites, y son DE CÓDIGO, no de configuración:
|
|
8
|
+
// - Este módulo no importa nada que hable con la API de Anthropic ni con el Libro, y no tiene
|
|
9
|
+
// `fetch`. Lo único que sabe mandar es la confirmación a quien escribió (`agente.send` una vez,
|
|
10
|
+
// al `from` del sobre recibido). `test/ideas.test.js` lo comprueba por inspección de la fuente
|
|
11
|
+
// (lista CERRADA de lo que puede tocar de la estafeta, del almacén y del agente) y espiando las
|
|
12
|
+
// salidas de la casa durante el tick.
|
|
13
|
+
// - Sólo registra sobres FIRMADOS de quien está en la lista del buzón. La puerta (`puertaIdeas`,
|
|
14
|
+
// que `inbound` consulta para este buzón) rechaza al resto ANTES de guardar nada: ni el `intro`
|
|
15
|
+
// corto ni el aval con fianza que la política general deja pasar (§9) sirven aquí, porque este
|
|
16
|
+
// buzón no tiene a nadie que los lea. El tick vuelve a mirar la lista por si algo entró por
|
|
17
|
+
// otra puerta (un correo, un sobre anterior al alta): se cierra sin registrar y sin contestar.
|
|
18
|
+
// - Cupo diario por remitente en la puerta (revisión adversarial del 14-sep-2026: una dirección
|
|
19
|
+
// de la lista podía meter 112 MB por minuto en el buzón, que no se borra nunca): 200 ideas y
|
|
20
|
+
// 5 MB por día UTC; lo que sobra rebota con motivo desde la puerta, no desde este módulo.
|
|
21
|
+
// - El contenido no se guarda aparte: sigue cifrado en el buzón de ideas@. El registro lleva
|
|
22
|
+
// sólo el número, quién, cuándo, el hash del sobre, el hilo y el proyecto.
|
|
23
|
+
// - El texto de la confirmación es FIJO (aprobado con el pedido: «registra y confirma, nunca
|
|
24
|
+
// ejecuta») y vive en un solo lugar: `CONFIRMACION`.
|
|
25
|
+
import { parseAddress } from './resolver.js';
|
|
26
|
+
import { sha256hex, canonical } from '../nucleo/crypto.js';
|
|
27
|
+
import { proyectoDe, rolDe } from './politica.js';
|
|
28
|
+
|
|
29
|
+
export const LOCAL = 'ideas';
|
|
30
|
+
export const NS = 'ideas'; // nyx5_kv: `_n` (contador), `n:<000001>` (registro), `sobre:<id>` (idempotencia)
|
|
31
|
+
export const NS_CUPO = 'ideas-cupo'; // nyx5_kv: `<from>:<día>:n` y `<from>:<día>:bytes`, vencen al día siguiente
|
|
32
|
+
export const VIA = 'ideas'; // custody.via de la tarjeta: la casa firma y descifra en su nombre
|
|
33
|
+
export const CUPO_DIARIO = { ideas: 200, bytes: 5 * 1024 * 1024 };
|
|
34
|
+
export const PAGINA = 1000; // GET /ideas devuelve hasta esto por página, con `next`
|
|
35
|
+
const SISTEMA = new Set(['postmaster', 'libro', 'verifica', 'tareas']);
|
|
36
|
+
const TURNO_MS = 15 * 60_000;
|
|
37
|
+
const DIA_MS = 24 * 3600 * 1000;
|
|
38
|
+
|
|
39
|
+
export const idDeIdea = (n) => `IDEA-${String(n).padStart(3, '0')}`;
|
|
40
|
+
const claveDe = (n) => `n:${String(n).padStart(6, '0')}`;
|
|
41
|
+
// «2026-09-15 10:22» a partir de una fecha ISO (siempre UTC).
|
|
42
|
+
export const fechaCorta = (isoStr) => String(isoStr).slice(0, 16).replace('T', ' ');
|
|
43
|
+
export const CONFIRMACION = (id, at) => `Saved as ${id} on ${fechaCorta(at)} UTC. Nothing was executed: this mailbox only records and confirms. Nicholas reads it when he is back.`;
|
|
44
|
+
|
|
45
|
+
const minus = (x) => String(x || '').toLowerCase();
|
|
46
|
+
function enLista(lista, from) {
|
|
47
|
+
const f = minus(from);
|
|
48
|
+
let dominio = ''; try { dominio = parseAddress(f).domain; } catch { dominio = ''; }
|
|
49
|
+
return lista.some((x) => x === f || (dominio && x === dominio));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// La puerta de ideas@, consultada por `inbound` DESPUÉS de la política general y ANTES de guardar.
|
|
53
|
+
// Devuelve null si el sobre puede entrar, o `{ code, reason }` para rechazarlo (el rebote lo hace la
|
|
54
|
+
// estafeta emisora, como con cualquier rechazo de la puerta: no es una salida de este módulo).
|
|
55
|
+
// Rechaza (403, permanente) a quien no está en la lista y a quien agotó su cupo del día: un
|
|
56
|
+
// rechazo transitorio (429) dejaría los sobres reintentando en la cola de la otra casa un día entero.
|
|
57
|
+
// Lo que NO cubre: el cupo se cuenta al aceptar en la puerta, así que dos casas emisoras que
|
|
58
|
+
// entreguen a la vez pueden pasarse por uno; y sólo protege el buzón de ideas@, no el resto de la casa.
|
|
59
|
+
export async function puertaIdeas(est, env, rec, { cupo = CUPO_DIARIO, nowMs = Date.now() } = {}) {
|
|
60
|
+
const lista = (rec?.inbox?.allowlist || []).map(minus);
|
|
61
|
+
if (!enLista(lista, env.from)) return { code: 403, reason: 'the ideas mailbox records only signed envelopes from its list: not recorded' };
|
|
62
|
+
if (!est.store.kvIncrement) return null;
|
|
63
|
+
const dia = new Date(nowMs).toISOString().slice(0, 10);
|
|
64
|
+
const vence = (Math.floor(nowMs / DIA_MS) + 2) * DIA_MS;
|
|
65
|
+
const clave = `${minus(env.from)}:${dia}`;
|
|
66
|
+
const bytes = Buffer.byteLength(JSON.stringify(env));
|
|
67
|
+
// Se suma ANTES de comparar: con dos relojes contando a la vez, el que se pasa cierra el día.
|
|
68
|
+
const n = await est.store.kvIncrement(NS_CUPO, `${clave}:n`, vence, nowMs);
|
|
69
|
+
const total = await est.store.kvIncrement(NS_CUPO, `${clave}:bytes`, vence, nowMs, bytes);
|
|
70
|
+
if (n > cupo.ideas || total > cupo.bytes) return { code: 403, reason: `daily limit of the ideas mailbox reached for this sender (${cupo.ideas} ideas or ${Math.round(cupo.bytes / 1024 / 1024)} MB per UTC day): not recorded` };
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// El registro ordenado por número, para GET /ideas: hasta `limit` registros con número > `after`.
|
|
75
|
+
export async function listarIdeas(est, { after = 0, limit = PAGINA } = {}) {
|
|
76
|
+
const filas = await est.store.kvList(NS, { prefix: 'n:', after: after > 0 ? claveDe(after) : undefined, limit });
|
|
77
|
+
return filas.map((f) => f.doc).sort((a, b) => a.n - b.n);
|
|
78
|
+
}
|
|
79
|
+
// Lo que contesta GET /ideas: `total` es el último número asignado (puede superar a los registros
|
|
80
|
+
// si un reloj cayó entre asignar el número y escribir el registro), `next` es el número desde el
|
|
81
|
+
// que sigue la página siguiente, o null si ésta es la última.
|
|
82
|
+
export async function paginaDeIdeas(est, { after = 0 } = {}) {
|
|
83
|
+
const ideas = await listarIdeas(est, { after, limit: PAGINA });
|
|
84
|
+
const total = Number(await est.store.kvGet(NS, '_n')) || 0;
|
|
85
|
+
return { total, count: ideas.length, ideas, next: ideas.length === PAGINA ? ideas[ideas.length - 1].n : null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Un tick: cada sobre pendiente del buzón se registra (número correlativo durable) y se confirma.
|
|
89
|
+
// Devuelve cuántas ideas registró. Dos relojes sobre el mismo almacén no repiten número ni
|
|
90
|
+
// confirmación: un turno por sobre (`kvPutIfAbsent`) y un número por sobre (`sobre:<id>`).
|
|
91
|
+
// `maxPorTick` acota las ideas registradas por pasada; `maxDescartesPorTick`, lo que se cierra sin
|
|
92
|
+
// registrar (correo sin firma, rebotes del postmaster, algo anterior al alta): un buzón lleno de
|
|
93
|
+
// eso no puede comerse el minuto del reloj, y tampoco puede dejar sin turno a las ideas de verdad
|
|
94
|
+
// (son cupos separados).
|
|
95
|
+
export async function atenderIdeas(est, { maxPorTick = 20, maxDescartesPorTick = 200 } = {}) {
|
|
96
|
+
if (!est.ideas?.enabled || !est.store.kvIncrement) return 0;
|
|
97
|
+
const rec = await est.store.getAgent(LOCAL);
|
|
98
|
+
if (!rec || rec.revoked || rec.custody?.via !== VIA) return 0;
|
|
99
|
+
const lista = (rec.inbox?.allowlist || []).map(minus);
|
|
100
|
+
const propia = `${LOCAL}@${est.domain}`;
|
|
101
|
+
let registradas = 0;
|
|
102
|
+
let vistos = 0, descartes = 0;
|
|
103
|
+
for (const m of await est.store.listMail(LOCAL)) {
|
|
104
|
+
if (vistos >= maxPorTick && descartes >= maxDescartesPorTick) break;
|
|
105
|
+
const e = m.envelope || {};
|
|
106
|
+
let de; try { de = parseAddress(e.from); } catch { continue; }
|
|
107
|
+
// Lo que manda la casa (un rebote, un recibo) o el propio buzón no es una idea: se cierra y sigue.
|
|
108
|
+
const deSistema = (de.domain === est.domain && SISTEMA.has(de.local)) || e.from === propia || e.type === 'receipt';
|
|
109
|
+
// Sin firma (un correo) o fuera de la lista (algo que entró antes del alta o por otra puerta): se
|
|
110
|
+
// cierra sin registrar y sin contestar. Contestarle a un desconocido sería la salida que este
|
|
111
|
+
// buzón no tiene.
|
|
112
|
+
const ajeno = !e.signature || m.from_verified === false || !enLista(lista, e.from);
|
|
113
|
+
if (deSistema || ajeno) {
|
|
114
|
+
if (descartes >= maxDescartesPorTick) continue;
|
|
115
|
+
descartes++;
|
|
116
|
+
if (!deSistema) est.log(`ideas@: sobre ${e.id} de ${e.from} descartado (${!e.signature || m.from_verified === false ? 'sin firma' : 'fuera de la lista'})`);
|
|
117
|
+
await est.store.ackMail(LOCAL, e.id);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (vistos >= maxPorTick) continue;
|
|
121
|
+
vistos++;
|
|
122
|
+
if (!(await est.store.kvPutIfAbsent('ideas-turno', e.id, { at: new Date().toISOString() }, Date.now() + TURNO_MS))) continue;
|
|
123
|
+
try {
|
|
124
|
+
const agente = await est.agenteDeBoveda(LOCAL);
|
|
125
|
+
if (!agente) throw new Error('la bóveda no tiene la llave de ideas@');
|
|
126
|
+
// Un número por sobre: si un tick anterior asignó número y no llegó a confirmar, se reutiliza.
|
|
127
|
+
let n = Number((await est.store.kvGet(NS, `sobre:${e.id}`))?.n) || 0;
|
|
128
|
+
if (!n) {
|
|
129
|
+
n = await est.store.kvIncrement(NS, '_n');
|
|
130
|
+
await est.store.kvPut(NS, `sobre:${e.id}`, { n });
|
|
131
|
+
}
|
|
132
|
+
// El registro se escribe si no está, aunque el número ya estuviera asignado: un reloj que cayó
|
|
133
|
+
// entre asignar el número y escribir el registro no puede dejar una idea confirmada y sin
|
|
134
|
+
// registro (revisión adversarial del 14-sep-2026). No depende del contenido: si el sobre no se
|
|
135
|
+
// puede abrir, se registra igual por su hash.
|
|
136
|
+
let registro = await est.store.kvGet(NS, claveDe(n));
|
|
137
|
+
if (!registro) {
|
|
138
|
+
let abierto = false;
|
|
139
|
+
try { await agente.open(e); abierto = true; } catch { abierto = false; }
|
|
140
|
+
registro = { n, id: idDeIdea(n), id_sobre: e.id, from: e.from, at: new Date().toISOString(), sha256_sobre: sha256hex(canonical(e)), thread: e.thread || e.id, project: proyectoDe(e), role: rolDe(e), opened: abierto };
|
|
141
|
+
await est.store.kvPut(NS, claveDe(n), registro);
|
|
142
|
+
}
|
|
143
|
+
// La ÚNICA salida de este módulo: la confirmación a quien escribió, en su hilo y su proyecto.
|
|
144
|
+
await agente.send({ to: e.from, body: CONFIRMACION(registro.id, registro.at), thread: e.thread || e.id, inReplyTo: e.id, project: registro.project || undefined, role: registro.role || undefined });
|
|
145
|
+
await est.store.ackMail(LOCAL, e.id);
|
|
146
|
+
registradas++;
|
|
147
|
+
await est._evento('idea_saved', e.from, { idea: registro.id, sobre: e.id });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
// El turno queda tomado 15 minutos y se reintenta; el número, si ya salió, se conserva.
|
|
150
|
+
est.log(`ideas@: ${e.id} no se pudo confirmar: ${err.message}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return registradas;
|
|
154
|
+
}
|
package/src/correo/politica.js
CHANGED
|
@@ -21,9 +21,33 @@ export const EXT_PROYECTO = 'urn:nyx5:ext:proyecto';
|
|
|
21
21
|
// llegaban tal cual a la herramienta del Claude remoto. Se limpian y acotan como la ficha, y el
|
|
22
22
|
// proyecto se normaliza (NFKC, sin invisibles) para que un homógrafo no esconda un mensaje del filtro.
|
|
23
23
|
export const proyectoDe = (env) => { const p = env?.extensions?.[EXT_PROYECTO]?.project; return typeof p === 'string' ? nombreDeProyecto(p) : null; };
|
|
24
|
-
|
|
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; };
|
|
25
30
|
export const rolDe = (env) => { const r = env?.extensions?.[EXT_PROYECTO]?.role; return typeof r === 'string' ? (limpio(r, 40) || null) : null; };
|
|
26
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
|
+
|
|
27
51
|
// Ficha pública del agente (13-sep-2026): qué hace, en qué idiomas, de quién es, con qué etiquetas.
|
|
28
52
|
// Va DENTRO de la tarjeta certificada, la firma el dueño al declararla y la casa al certificarla.
|
|
29
53
|
// Vocabulario CERRADO, como el alcance de un mandato: una clave desconocida se rechaza nombrándola,
|
|
@@ -137,6 +161,59 @@ export function usdSinBilletera(perfil, wallets) {
|
|
|
137
161
|
return `service "${con[0].id}" declares a usd price but the card has no wallet; declare wallets first`;
|
|
138
162
|
}
|
|
139
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
|
+
|
|
140
217
|
export function validateEnvelope(env, { maxBytes = 1_048_576 } = {}) {
|
|
141
218
|
const fail = (reason) => ({ ok: false, code: 400, reason });
|
|
142
219
|
if (!env || typeof env !== 'object') return fail('sobre no es un objeto');
|
|
@@ -160,8 +237,13 @@ export function validateEnvelope(env, { maxBytes = 1_048_576 } = {}) {
|
|
|
160
237
|
if (!hasPlain && !hasEnc) return fail('el sobre necesita content o encrypted');
|
|
161
238
|
if (hasPlain && hasEnc) return fail('content y encrypted son excluyentes');
|
|
162
239
|
if (env.attachments != null) {
|
|
163
|
-
|
|
164
|
-
|
|
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);
|
|
165
247
|
}
|
|
166
248
|
if (!env.signature || env.signature.alg !== 'Ed25519' || !env.signature.kid || !env.signature.value) return fail('sobre sin firma');
|
|
167
249
|
const bytes = Buffer.byteLength(JSON.stringify(env));
|
|
@@ -256,6 +338,13 @@ export function applyInboxPolicy(env, agentRecord, senderDomain) {
|
|
|
256
338
|
const esLibro = String(agentRecord.address || '').startsWith('libro@');
|
|
257
339
|
if (!esLibro && senderDomain?.policy?.outbound === 'sealed' && !env.encrypted) return permanent('el dominio emisor exige cifrado y el sobre viene en claro');
|
|
258
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
|
+
|
|
259
348
|
switch (inbox.policy) {
|
|
260
349
|
case 'open':
|
|
261
350
|
break;
|
package/src/libro/libro.js
CHANGED
|
@@ -36,6 +36,8 @@ export const MEDIA = {
|
|
|
36
36
|
op: 'application/nyx5.libro+json',
|
|
37
37
|
cotizacion: 'application/nyx5.cotizacion+json',
|
|
38
38
|
recibo: 'application/nyx5.recibo+json',
|
|
39
|
+
// NX-305: un comprador PIDE un servicio del catálogo; el vendedor contesta con la cotización.
|
|
40
|
+
pedido: 'application/nyx5.pedido+json',
|
|
39
41
|
};
|
|
40
42
|
|
|
41
43
|
const iso = () => new Date().toISOString();
|
|
@@ -121,8 +123,10 @@ export class Libro {
|
|
|
121
123
|
// bond released | forfeited afirmación sostenida | derribada
|
|
122
124
|
static TERMINALES = { spot: ['settled'], escrow: ['released', 'refunded'], bond: ['released', 'forfeited'], metered: [] };
|
|
123
125
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
+
// `contratos`: la lista ya leída, para que un lote de historiales (la ruta en lote, el rastreo
|
|
127
|
+
// del índice) no vuelva a leer la tabla por cada dirección.
|
|
128
|
+
async historial(address, { contratos = null } = {}) {
|
|
129
|
+
const todos = contratos ?? await this.store.libroListContracts();
|
|
126
130
|
const mios = todos.filter((c) => [c.seller, c.buyer].includes(address));
|
|
127
131
|
const cuenta = () => ({ n: 0, tokens: 0 });
|
|
128
132
|
const h = {
|