@nyx5/nyx5 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +50 -7
  2. package/bin/nyx5.js +81 -2
  3. package/demo/edge-local.mjs +41 -0
  4. package/demo/x402-pagar.mjs +64 -0
  5. package/demo/x402-testnet.mjs +80 -0
  6. package/docs/ARQUITECTURA.md +354 -0
  7. package/docs/SPEC.md +414 -15
  8. package/examples/ci/nyx5-ejecutor.yml +82 -0
  9. package/examples/frameworks/README.md +55 -0
  10. package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
  11. package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
  12. package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
  13. package/examples/frameworks/claude-agent-sdk.mjs +48 -0
  14. package/examples/frameworks/crewai_nyx5.py +72 -0
  15. package/examples/frameworks/http.md +117 -0
  16. package/examples/frameworks/langgraph_nyx5.py +90 -0
  17. package/examples/frameworks/nyx5_http.py +255 -0
  18. package/examples/frameworks/openai-agents.mjs +41 -0
  19. package/examples/hola-mundo.mjs +4 -4
  20. package/migrations/0006_puente_remoto.sql +19 -0
  21. package/migrations/0007_indice_reputacion.sql +31 -0
  22. package/migrations/0008_notaria.sql +18 -0
  23. package/migrations/0009_contratos_estado.sql +4 -0
  24. package/package.json +4 -2
  25. package/server.json +2 -2
  26. package/src/correo/acciones.js +216 -0
  27. package/src/correo/adjuntos.js +185 -0
  28. package/src/correo/agente.js +370 -22
  29. package/src/correo/asistente.js +599 -0
  30. package/src/correo/ci.js +262 -0
  31. package/src/correo/cobro.js +164 -0
  32. package/src/correo/estafeta.js +1548 -58
  33. package/src/correo/ideas.js +154 -0
  34. package/src/correo/indice.js +178 -0
  35. package/src/correo/politica.js +280 -2
  36. package/src/correo/resolver.js +35 -11
  37. package/src/libro/contratos.js +116 -7
  38. package/src/libro/estado.js +150 -0
  39. package/src/libro/informe.js +147 -3
  40. package/src/libro/libro.js +83 -12
  41. package/src/libro/notaria.js +74 -0
  42. package/src/libro/verifica.js +239 -36
  43. package/src/nucleo/almacen-d1.js +143 -14
  44. package/src/nucleo/almacen.js +137 -13
  45. package/src/nucleo/boveda.js +37 -0
  46. package/src/nucleo/crypto.js +55 -12
  47. package/src/nucleo/keccak.js +118 -0
  48. package/src/nucleo/objetos.js +78 -0
  49. package/src/nucleo/secp256k1.js +228 -0
  50. package/src/plataformas/app-html.js +1 -1
  51. package/src/plataformas/home-html.js +1 -1
  52. package/src/plataformas/iconos.js +6 -0
  53. package/src/plataformas/node.js +28 -4
  54. package/src/plataformas/spec-html.js +1 -1
  55. package/src/plataformas/terms-html.js +2 -0
  56. package/src/plataformas/worker.js +52 -7
  57. package/src/puentes/ejecutor.js +118 -0
  58. package/src/puentes/herramientas.js +315 -0
  59. package/src/puentes/mcp-remoto.js +71 -0
  60. package/src/puentes/mcp.js +5 -99
  61. package/src/puentes/oauth.js +361 -0
  62. package/src/puentes/x402-pagador.js +248 -0
  63. package/src/puentes/x402.js +266 -0
  64. package/src/version.js +4 -0
@@ -0,0 +1,185 @@
1
+ // Nyx5/1 — Adjuntos cifrados (NX-413, 2-nov-2026): el sobre lleva archivos de verdad.
2
+ //
3
+ // POR QUÉ EXISTE: `attachments` estaba en la spec desde la fase 0 y NINGUNA casa alojaba un byte.
4
+ // En la práctica un agente que dice «te adjunto los archivos» manda rutas de SU máquina, y el que
5
+ // recibe —otra máquina— no las ve. Le pasó varias veces a Nicholas entre un agente de cowork y uno
6
+ // de code. No es una función nueva: es cerrar una promesa publicada.
7
+ //
8
+ // CÓMO, en una línea: el remitente cifra el archivo con una llave suya, lo sube al almacén de
9
+ // objetos de su casa, y el sobre lleva la referencia. La casa aloja bytes que no puede abrir.
10
+ //
11
+ // Lo que la casa SABE de un adjunto (metadatos, espacio `adjuntos` del kv) es SOBRE, no contenido:
12
+ // { blob, by, allow, bytes, sha256, created, until, got }
13
+ // Lo que la casa NO sabe y no puede deducir: el nombre del archivo, su tipo y la llave. Eso viaja
14
+ // dentro del bloque cifrado del sobre (`attachments[]` del contenido). `balance-despido-javier.pdf`
15
+ // dice casi todo sin abrirlo: el nombre es contenido, y por eso no está aquí.
16
+ //
17
+ // Tres decisiones que no se reabren (encargo del 2-nov-2026):
18
+ // 1. El archivo va CIFRADO. Un adjunto en claro sería el agujero por donde la estafeta lee lo que
19
+ // el invariante 8 promete no leer. Lo cuida la primera prueba de test/adjuntos.test.js.
20
+ // 2. La `url` NO viaja en el sobre: se deriva (`https://<casa del remitente>/blob/<blobId>`).
21
+ // Una url en el sobre es una url que alguien puede cambiar por otra.
22
+ // 3. En un sobre cifrado, el array de AFUERA lleva sólo `blob` y `bytes` (lo valida politica.js).
23
+ //
24
+ // Y una del método: el cupo se incrementa ANTES de comparar, como `puertaIdeas()`. Dos relojes que
25
+ // suben a la vez pueden pasarse por uno, nunca por mucho.
26
+
27
+ import { parseAddress } from './resolver.js';
28
+ import { sha256hex, IV_BLOB, TAG_BLOB } from '../nucleo/crypto.js';
29
+
30
+ // Cupos, decididos en el encargo. Son del OBJETO guardado (lo que la casa paga), no del claro:
31
+ // el objeto cifrado mide 28 bytes más (iv + tag), y por eso el tope por adjunto los suma, para que
32
+ // un archivo de exactamente 10 MB quepa.
33
+ export const LIMITES = {
34
+ porAdjunto: 10 * 1024 * 1024,
35
+ adjuntosPorSobre: 10,
36
+ porSobre: 25 * 1024 * 1024,
37
+ porRemitenteDia: 100 * 1024 * 1024,
38
+ porCasa: 1024 * 1024 * 1024,
39
+ };
40
+ export const NS = 'adjuntos'; // metadatos: clave = blobId
41
+ export const NS_CUPO = 'adjuntos-cupo'; // `<from>:<día>:bytes` (vence) y `casa:bytes` (permanente)
42
+ export const RETENCION = 30 * 24 * 3600 * 1000; // lo normal
43
+ export const RETENCION_BAJADO = 7 * 24 * 3600 * 1000; // una vez que todos los destinatarios lo tienen
44
+ const DIA_MS = 24 * 3600 * 1000;
45
+ export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
46
+
47
+ // La clave del objeto. NUNCA el nombre del archivo: un nombre trae `../` y revela contenido.
48
+ export const claveObjeto = (blobId) => `blobs/${String(blobId).toLowerCase()}`;
49
+ // El AAD ata el cifrado a ESTE objeto y a ESTE remitente: un adjunto no se reusa desde otra
50
+ // dirección ni se hace pasar por otro objeto, aunque alguien tenga la llave.
51
+ export const aadDe = (blobId, from) => `nyx5:blob:${String(blobId).toLowerCase()}:${String(from).toLowerCase()}`;
52
+ // La url se DERIVA; no viaja en el sobre.
53
+ export const urlDe = (estafeta, blobId) => `${String(estafeta).replace(/\/$/, '')}/blob/${String(blobId).toLowerCase()}`;
54
+
55
+ const minus = (x) => String(x || '').toLowerCase();
56
+ const iso = (t) => new Date(t).toISOString();
57
+ const dia = (nowMs) => iso(nowMs).slice(0, 10);
58
+ const venceDia = (nowMs) => (Math.floor(nowMs / DIA_MS) + 2) * DIA_MS;
59
+ const MB = (n) => `${Math.round(n / 1024 / 1024)} MB`;
60
+
61
+ // Lo que la casa anuncia de su almacén de objetos, para la tarjeta del dominio y la spec.
62
+ export const capacidadAdjuntos = () => ({
63
+ max_bytes: LIMITES.porAdjunto,
64
+ max_per_envelope: LIMITES.adjuntosPorSobre,
65
+ max_bytes_per_envelope: LIMITES.porSobre,
66
+ retention_days: RETENCION / DIA_MS,
67
+ encryption: 'A256GCM',
68
+ });
69
+
70
+ // ---------- subir ----------
71
+ // PUT /blob/<blobId>?to=a@casa,b@otra · cuerpo = el objeto CIFRADO, tal cual.
72
+ // La casa no mira dentro, no sabe de qué archivo es, y guarda el sha256 del CIFRADO para su propia
73
+ // integridad (el del CLARO viaja en el sobre, para que el receptor compruebe que descifró lo correcto).
74
+ export async function subirAdjunto(est, who, blobId, objeto, { to = '', nowMs = Date.now() } = {}) {
75
+ if (!UUID.test(String(blobId))) return { status: 400, body: { reason: 'the blob id must be a uuid: the house never names an object after the file' } };
76
+ const bytes = objeto ? objeto.length : 0;
77
+ if (!bytes) return { status: 400, body: { reason: 'empty body: the request carries the encrypted object as raw bytes' } };
78
+ // El tope por adjunto se comprueba ANTES de tocar un contador: un archivo demasiado grande no
79
+ // gasta el cupo del día de quien lo intentó (no hay carrera que lo justifique, es aritmética).
80
+ if (bytes > LIMITES.porAdjunto + IV_BLOB + TAG_BLOB) {
81
+ return { status: 413, body: { reason: `attachment of ${bytes} bytes exceeds the maximum ${LIMITES.porAdjunto} (${MB(LIMITES.porAdjunto)}) per attachment` } };
82
+ }
83
+ const destinos = [];
84
+ for (const t of String(to).split(',').map((s) => s.trim()).filter(Boolean)) {
85
+ try { const { local, domain } = parseAddress(t); destinos.push(`${local}@${domain}`); } catch { return { status: 400, body: { reason: `invalid recipient: ${t}` } }; }
86
+ }
87
+ if (destinos.length > 50) return { status: 400, body: { reason: 'an attachment authorizes at most 50 recipients' } };
88
+
89
+ const clave = `${minus(who.address)}:${dia(nowMs)}:bytes`;
90
+ // Se suma ANTES de comparar: con dos relojes subiendo a la vez, el que se pasa cierra el día.
91
+ // Un intento rechazado por cupo SÍ gasta cupo: es lo que corta una inundación.
92
+ const delDia = await est.store.kvIncrement(NS_CUPO, clave, venceDia(nowMs), nowMs, bytes);
93
+ if (delDia > LIMITES.porRemitenteDia) {
94
+ return { status: 403, body: { reason: `this sender reached its attachment quota of ${MB(LIMITES.porRemitenteDia)} (${LIMITES.porRemitenteDia} bytes) per UTC day` } };
95
+ }
96
+ // El de la casa es un cupo de lo GUARDADO, no de lo histórico: sube al guardar y BAJA al borrar.
97
+ // Por eso, a diferencia del diario, un intento que no llega a guardar se devuelve (si no, el
98
+ // 1 GB de la casa se erosionaría con cada rechazo y acabaría bloqueando a todo el mundo).
99
+ // Ojo con el contrato del almacén: FileStore implementa en SÍNCRONO y D1Store en async real, así
100
+ // que un `.catch()` pegado al resultado reventaría en local y andaría en producción. Se espera
101
+ // con await, siempre, y lo que falle al devolver el cupo se anota (no puede tapar el motivo real).
102
+ const devolver = async () => { try { await est.store.kvIncrement(NS_CUPO, 'casa:bytes', null, nowMs, -bytes); } catch (e) { est.log(`adjuntos: no se pudo devolver el cupo de la casa: ${e.message}`); } };
103
+ const deLaCasa = await est.store.kvIncrement(NS_CUPO, 'casa:bytes', null, nowMs, bytes);
104
+ if (deLaCasa > LIMITES.porCasa) {
105
+ await devolver();
106
+ return { status: 507, body: { reason: `this house reached its attachment storage of ${LIMITES.porCasa / (1024 * 1024 * 1024)} GB (${LIMITES.porCasa} bytes)` } };
107
+ }
108
+
109
+ const meta = {
110
+ blob: minus(blobId), by: minus(who.address),
111
+ allow: [...new Set([minus(who.address), ...destinos.map(minus)])],
112
+ bytes, sha256: sha256hex(objeto), created: iso(nowMs), until: iso(nowMs + RETENCION), got: [],
113
+ };
114
+ // Un blobId ya usado no se pisa: el candado es el kv, no una lectura previa (dos isolates).
115
+ if (!await est.store.kvPutIfAbsent(NS, meta.blob, meta, null, nowMs)) {
116
+ await devolver();
117
+ return { status: 409, body: { reason: 'that blob id is already taken: generate another' } };
118
+ }
119
+ try {
120
+ await est.objetos.put(claveObjeto(meta.blob), objeto);
121
+ } catch (e) {
122
+ // Metadatos sin objeto es un adjunto que el destinatario no puede bajar y nadie limpia: se
123
+ // deshace el alta completa antes de contestar.
124
+ try { await est.store.kvDelete(NS, meta.blob); } catch (e2) { est.log(`adjuntos: metadatos huérfanos de ${meta.blob}: ${e2.message}`); }
125
+ await devolver();
126
+ throw e;
127
+ }
128
+ return { status: 201, body: { blob: meta.blob, bytes, sha256: meta.sha256, url: urlDe(est.publicUrl, meta.blob), until: meta.until, allow: meta.allow } };
129
+ }
130
+
131
+ // ---------- bajar ----------
132
+ // GET /blob/<blobId>, con firma Ed25519 como el buzón. Sin firma 401 (exista o no el objeto: la
133
+ // autenticación va ANTES de mirar si existe, para no ser un oráculo). Firma de un tercero, 403.
134
+ export async function bajarAdjunto(est, who, blobId, { nowMs = Date.now() } = {}) {
135
+ if (!UUID.test(String(blobId))) return { status: 404, body: { reason: 'no such attachment' } };
136
+ const meta = await est.store.kvGet(NS, minus(blobId), nowMs);
137
+ // La retención se aplica al LEER, no sólo cuando el reloj pasa: si el reloj se cae, un adjunto
138
+ // vencido sigue sin poder bajarse. El reloj recupera el espacio; esto cumple la promesa.
139
+ if (!meta || Date.parse(meta.until) <= nowMs) return { status: 404, body: { reason: 'no such attachment' } };
140
+ if (!meta.allow.includes(minus(who.address))) return { status: 403, body: { reason: 'this attachment was sent to someone else' } };
141
+ const objeto = await est.objetos.get(claveObjeto(meta.blob));
142
+ if (!objeto) return { status: 404, body: { reason: 'no such attachment' } };
143
+
144
+ // Quién ya lo tiene. Cuando TODOS los destinatarios lo bajaron, la casa no necesita guardarlo un
145
+ // mes: la retención se acorta a 7 días. «Confirmar» aquí es haberlo bajado — es lo único que esta
146
+ // puerta puede medir, y la spec lo dice con esas palabras en vez de prometer otra cosa.
147
+ if (minus(who.address) !== meta.by && !meta.got.includes(minus(who.address))) {
148
+ const got = [...meta.got, minus(who.address)];
149
+ const faltan = meta.allow.filter((a) => a !== meta.by && !got.includes(a));
150
+ const until = faltan.length ? meta.until : iso(Math.min(Date.parse(meta.until), nowMs + RETENCION_BAJADO));
151
+ await est.store.kvPut(NS, meta.blob, { ...meta, got, until });
152
+ }
153
+ return { status: 200, contentType: 'application/octet-stream', body: Buffer.from(objeto), headers: { 'x-nyx5-blob-sha256': meta.sha256 } };
154
+ }
155
+
156
+ // ---------- borrar ----------
157
+ // Sólo quien lo subió. Un destinatario no borra un adjunto ajeno: la copia es del remitente.
158
+ export async function borrarAdjunto(est, who, blobId, { nowMs = Date.now() } = {}) {
159
+ if (!UUID.test(String(blobId))) return { status: 404, body: { reason: 'no such attachment' } };
160
+ const meta = await est.store.kvGet(NS, minus(blobId), nowMs);
161
+ if (!meta) return { status: 404, body: { reason: 'no such attachment' } };
162
+ if (minus(who.address) !== meta.by) return { status: 403, body: { reason: 'only the sender deletes an attachment' } };
163
+ await _retirar(est, meta, nowMs);
164
+ return { status: 200, body: { blob: meta.blob, deleted: true, bytes: meta.bytes } };
165
+ }
166
+
167
+ async function _retirar(est, meta, nowMs) {
168
+ await est.objetos.delete(claveObjeto(meta.blob));
169
+ await est.store.kvDelete(NS, meta.blob);
170
+ await est.store.kvIncrement(NS_CUPO, 'casa:bytes', null, nowMs, -meta.bytes);
171
+ }
172
+
173
+ // ---------- limpieza por reloj ----------
174
+ // La retención se cumple borrando: 30 días, o 7 desde que el último destinatario lo bajó. Se
175
+ // recorre por METADATOS, no por el bucket: en R2 listar es paginado y caro, y la fila es la verdad.
176
+ export async function limpiarAdjuntos(est, { nowMs = Date.now(), max = 100 } = {}) {
177
+ if (!est.objetos || !est.store.kvList) return 0;
178
+ let n = 0;
179
+ for (const { doc } of await est.store.kvList(NS, { limit: max }, nowMs)) {
180
+ if (!doc?.until || Date.parse(doc.until) > nowMs) continue;
181
+ await _retirar(est, doc, nowMs);
182
+ n++;
183
+ }
184
+ return n;
185
+ }