@nyx5/nyx5 0.1.0 → 0.2.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,113 @@
1
+ // Nyx5/1 — verifica@: el evaluador de referencia de la casa.
2
+ //
3
+ // Tres pruebas DETERMINISTAS y nada más. Sin juicio de modelo, a propósito: un verificador
4
+ // que se equivoca castiga a un inocente y quema la credibilidad del sistema en un día. Si
5
+ // una prueba no puede decidir sola y sin ambigüedad, este verificador no la acepta.
6
+ //
7
+ // http_status GET a una URL -> el código es el esperado (200 por defecto)
8
+ // sha256 el cuerpo de una URL (o el texto entregado) hashea a lo declarado
9
+ // exit_0 un comando termina con código 0 [solo fuera del edge: necesita shell]
10
+ //
11
+ // El veredicto es una función pura de (prueba, mundo): dos corridas con el mismo mundo dan
12
+ // lo mismo, y la razón siempre viaja con el resultado. Nada de "se ve bien".
13
+
14
+ import { sha256hex } from '../nucleo/crypto.js';
15
+
16
+ export const PRUEBAS = ['http_status', 'sha256', 'exit_0'];
17
+ // En Workers no hay shell. Se declara en vez de fallar raro cuando alguien la pida.
18
+ export const conShell = typeof process !== 'undefined' && !!process?.versions?.node;
19
+ export const pruebasDisponibles = () => (conShell ? PRUEBAS : PRUEBAS.filter((p) => p !== 'exit_0'));
20
+
21
+ const recorta = (s, n = 300) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}…` : s);
22
+
23
+ /**
24
+ * Corre UNA prueba y devuelve un veredicto con su evidencia.
25
+ * @returns {Promise<{pasa:boolean, prueba:string, razon:string, evidencia:object}>}
26
+ */
27
+ export async function correrPrueba(prueba, { fetchImpl = globalThis.fetch, timeoutMs = 10_000, entregado = null, entregadoSha256 = null } = {}) {
28
+ const tipo = prueba?.type;
29
+ if (!PRUEBAS.includes(tipo)) {
30
+ return { pasa: false, prueba: tipo || '(sin tipo)', razon: `prueba desconocida: ${tipo}. Las que este verificador acepta: ${pruebasDisponibles().join(', ')}`, evidencia: {} };
31
+ }
32
+ if (tipo === 'exit_0' && !conShell) {
33
+ return { pasa: false, prueba: tipo, razon: 'este verificador corre en el edge y no tiene shell; usa http_status o sha256', evidencia: {} };
34
+ }
35
+ try {
36
+ if (tipo === 'http_status') {
37
+ const esperado = Number(prueba.expect ?? 200);
38
+ if (!/^https:\/\//.test(prueba.url || '')) return { pasa: false, prueba: tipo, razon: 'la URL a verificar debe ser https', evidencia: { url: prueba.url } };
39
+ const res = await fetchImpl(prueba.url, { method: prueba.method || 'GET', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
40
+ const pasa = res.status === esperado;
41
+ return { pasa, prueba: tipo, razon: pasa ? `${prueba.url} respondió ${res.status}` : `${prueba.url} respondió ${res.status}, se esperaba ${esperado}`, evidencia: { url: prueba.url, status: res.status, expect: esperado } };
42
+ }
43
+ if (tipo === 'sha256') {
44
+ const esperado = String(prueba.expect || '').toLowerCase();
45
+ if (!/^[0-9a-f]{64}$/.test(esperado)) return { pasa: false, prueba: tipo, razon: 'expect debe ser un sha256 en hexadecimal (64 caracteres)', evidencia: {} };
46
+ // El agente pudo declarar el hash de su resultado al entregar (deliver.evidence_sha256).
47
+ // Ese es el caso normal cuando el trabajo no vive en una URL: se compara lo que DIJO
48
+ // contra lo que la tarea exige. Si además hay url, gana lo que se puede descargar.
49
+ if (!prueba.url && entregado == null && entregadoSha256) {
50
+ const visto = String(entregadoSha256).toLowerCase();
51
+ const pasa = visto === esperado;
52
+ return { pasa, prueba: tipo, razon: pasa ? `el hash entregado coincide (${visto.slice(0, 12)}…)` : `el hash entregado (${visto.slice(0, 12)}…) no es el esperado (${esperado.slice(0, 12)}…)`, evidencia: { sha256: visto, expect: esperado, fuente: 'evidence_sha256' } };
53
+ }
54
+ let texto = entregado;
55
+ if (prueba.url) {
56
+ if (!/^https:\/\//.test(prueba.url)) return { pasa: false, prueba: tipo, razon: 'la URL a verificar debe ser https', evidencia: { url: prueba.url } };
57
+ const res = await fetchImpl(prueba.url, { signal: AbortSignal.timeout(timeoutMs) });
58
+ if (!res.ok) return { pasa: false, prueba: tipo, razon: `${prueba.url} respondió ${res.status}: no hay qué hashear`, evidencia: { url: prueba.url, status: res.status } };
59
+ texto = await res.text();
60
+ }
61
+ 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 };
62
+ const visto = sha256hex(texto);
63
+ const pasa = visto === esperado;
64
+ 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 } };
65
+ }
66
+ // exit_0: solo en Node, sin shell interpretado (nada de `sh -c`), con timeout y sin heredar stdio.
67
+ const { spawn } = await import('node:child_process');
68
+ const argv = Array.isArray(prueba.argv) ? prueba.argv : null;
69
+ if (!argv || !argv.length || typeof argv[0] !== 'string') {
70
+ return { pasa: false, prueba: tipo, razon: 'exit_0 necesita argv: ["comando","arg1",…]; no se acepta una línea de shell', evidencia: {} };
71
+ }
72
+ const res = await new Promise((resolve) => {
73
+ 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 } });
74
+ let out = '', err = '';
75
+ p.stdout.on('data', (d) => { out += d; });
76
+ p.stderr.on('data', (d) => { err += d; });
77
+ p.on('error', (e) => resolve({ code: null, out, err: e.message }));
78
+ p.on('close', (code) => resolve({ code, out, err }));
79
+ });
80
+ const pasa = res.code === 0;
81
+ return { pasa, prueba: tipo, razon: pasa ? `\`${argv.join(' ')}\` salió con 0` : `\`${argv.join(' ')}\` salió con ${res.code ?? 'error'}: ${recorta(res.err || res.out, 200) || 'sin salida'}`, evidencia: { argv, code: res.code, stderr: recorta(res.err, 200) } };
82
+ } catch (e) {
83
+ // Un fallo de red no es "la afirmación es falsa": es "no se pudo verificar". Se distingue.
84
+ return { pasa: false, prueba: tipo, razon: `no se pudo verificar: ${e.message}`, evidencia: { error: e.message }, indeciso: true };
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Corre todas las pruebas de un contrato. Veredicto conjunto: pasa solo si TODAS pasan.
90
+ * Si alguna quedó indecisa (red caída, timeout), el conjunto queda indeciso y NO se decide:
91
+ * ni liberar ni devolver. Un verificador que decide sin poder mirar es peor que ninguno.
92
+ */
93
+ export async function veredicto(pruebas, opts = {}) {
94
+ const lista = Array.isArray(pruebas) ? pruebas : [pruebas];
95
+ if (!lista.length) return { pasa: false, indeciso: true, razon: 'el contrato no declara pruebas de aceptación', resultados: [] };
96
+ const resultados = [];
97
+ for (const p of lista) resultados.push(await correrPrueba(p, opts));
98
+ const indeciso = resultados.some((r) => r.indeciso);
99
+ const pasa = !indeciso && resultados.every((r) => r.pasa);
100
+ const razon = indeciso
101
+ ? `no se pudo verificar: ${resultados.find((r) => r.indeciso).razon}`
102
+ : resultados.map((r) => `${r.pasa ? 'OK' : 'FALLA'} ${r.prueba}: ${r.razon}`).join(' · ');
103
+ return { pasa, indeciso, razon, resultados };
104
+ }
105
+
106
+ // Un contrato es verificable por la casa si declara pruebas en sus términos y nombra
107
+ // como árbitro al verificador de la casa. Sin ambas cosas, nadie toca ese escrow.
108
+ export function pruebasDe(contrato) {
109
+ const t = contrato?.terms || {};
110
+ const p = t.verify ?? t.pruebas ?? null;
111
+ if (!p) return null;
112
+ return Array.isArray(p) ? p : [p];
113
+ }
@@ -147,6 +147,15 @@ export class D1Store {
147
147
  async libroListMandates() { return (await this.db.prepare('SELECT doc FROM nyx5_libro_mandatos').all()).results.map((r) => JSON.parse(r.doc)); }
148
148
  async libroGetOp(id) { return p(await this.db.prepare('SELECT doc FROM nyx5_libro_ops WHERE id = ?').bind(id).first()); }
149
149
  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(); }
150
+ // ---------- instrumentación ----------
151
+ 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
+ async listEvents({ name = null, since = null, limit = 500 } = {}) {
153
+ const cond = ['1 = 1']; const bind = [];
154
+ if (name) { cond.push('name = ?'); bind.push(name); }
155
+ if (since) { cond.push('ts >= ?'); bind.push(since); }
156
+ 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
+ return r.results.map((x) => ({ ...x, data: JSON.parse(x.data) })).reverse();
158
+ }
150
159
  async libroStatement(account, limit) {
151
160
  const r = await this.db.prepare(`
152
161
  SELECT doc FROM nyx5_libro_diario WHERE n IN (SELECT DISTINCT n FROM nyx5_libro_lineas WHERE account = ?)
@@ -27,7 +27,7 @@ const writeJson = (p, v) => {
27
27
  export class FileStore {
28
28
  constructor(dir) {
29
29
  this.dir = dir;
30
- for (const d of ['agents', 'mailbox', 'queue', 'outbox', 'invitations', 'libro/diario', 'libro/contratos', 'libro/mandatos', 'libro/ops', 'indice/casas', 'indice/agentes']) fs.mkdirSync(path.join(dir, d), { recursive: true });
30
+ for (const d of ['agents', 'mailbox', 'queue', 'outbox', 'invitations', 'libro/diario', 'libro/contratos', 'libro/mandatos', 'libro/ops', 'eventos', 'indice/casas', 'indice/agentes']) fs.mkdirSync(path.join(dir, d), { recursive: true });
31
31
  this.nonces = new Map(); // anti-replay: en FileStore basta memoria (un proceso)
32
32
  }
33
33
 
@@ -151,6 +151,15 @@ export class FileStore {
151
151
  libroListMandates() { const d = path.join(this.dir, 'libro', 'mandatos'); return fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f))); }
152
152
  libroGetOp(id) { return readJson(path.join(this.dir, 'libro', 'ops', `${id}.json`)); }
153
153
  libroPutOp(id, v) { writeJson(path.join(this.dir, 'libro', 'ops', `${id}.json`), v); }
154
+ // ---------- instrumentación (nombre, fecha, actor y números; nunca contenido) ----------
155
+ putEvent(e) { writeJson(path.join(this.dir, 'eventos', `${e.id}.json`), e); }
156
+ listEvents({ name = null, since = null, limit = 500 } = {}) {
157
+ const d = path.join(this.dir, 'eventos');
158
+ if (!fs.existsSync(d)) return [];
159
+ return fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f)))
160
+ .filter((e) => e && (!name || e.name === name) && (!since || e.ts >= since))
161
+ .sort((a, b) => (a.ts < b.ts ? -1 : 1)).slice(-limit);
162
+ }
154
163
  libroStatement(account, limit) {
155
164
  return this.libroJournal().filter((a) => a.lines.some((l) => l.account === account)).slice(-limit);
156
165
  }
@@ -1,2 +1,2 @@
1
1
  // Generado desde web/app.html — NO editar a mano; edita el .html y regenera (node scripts/build-app-html.mjs).
2
- export const APP_HTML = "<!doctype html>\n<html lang=\"es\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Nyx5</title>\n<style>\n:root{--bg:#FFFFFF;--panel:#F6F5F1;--ink:#1A1712;--dim:#8A8378;--faience:#12A594;--faience-soft:#E3F5F1;--gold:#B48A3C;--line:#EAE7DF;--exposed:#C4553B}\n*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\nbody{background:var(--bg);color:var(--ink);font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;min-height:100dvh;display:flex;flex-direction:column}\n.wrap{max-width:520px;margin:0 auto;width:100%;flex:1;display:flex;flex-direction:column;padding:env(safe-area-inset-top) 1.1rem env(safe-area-inset-bottom)}\nheader{padding:1.3rem 0 1rem;display:flex;align-items:center;gap:.6rem;border-bottom:1px solid var(--line)}\nheader .logo{font-family:Georgia,serif;font-size:1.45rem;color:var(--ink)}\nheader .yo{margin-left:auto;font-size:.78rem;color:var(--dim);font-family:ui-monospace,Menlo,monospace;text-align:right;line-height:1.3}\nheader .yo b{color:var(--gold);font-weight:600}\nh1{font-family:Georgia,serif;font-weight:400;font-size:1.6rem;margin:1.6rem 0 .5rem;line-height:1.15}\np.sub{color:var(--dim);font-size:.97rem;margin-bottom:1.4rem}\nlabel{display:block;font-size:.8rem;color:var(--dim);margin:1rem 0 .35rem}\ninput,textarea{width:100%;background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:.85rem 1rem;font:inherit;font-size:16px}\ninput:focus,textarea:focus{outline:none;border-color:var(--faience)}\ntextarea{resize:none}\nbutton{width:100%;background:var(--faience);color:#fff;border:none;border-radius:12px;padding:.95rem;font:inherit;font-weight:600;font-size:1rem;margin-top:1.1rem;cursor:pointer;transition:opacity .15s}\nbutton:active{opacity:.6}\nbutton:disabled{opacity:.4}\n.hint{font-size:.82rem;color:var(--dim);margin-top:.85rem;line-height:1.5}\n.hint b{color:var(--ink)}\n.feed{flex:1;overflow-y:auto;padding:1.1rem 0;display:flex;flex-direction:column;gap:.6rem}\n.msg{max-width:82%;padding:.7rem .95rem;border-radius:16px;word-break:break-word;white-space:pre-wrap}\n.msg .de{font-family:ui-monospace,Menlo,monospace;font-size:.68rem;color:var(--dim);margin-bottom:.3rem}\n.msg.suyo{background:var(--panel);border:1px solid var(--line);align-self:flex-start;border-bottom-left-radius:5px}\n.msg.mio{background:var(--faience-soft);align-self:flex-end;border-bottom-right-radius:5px}\n.msg.mio .de{color:var(--faience);text-align:right}\n.msg .estado{font-size:.65rem;color:var(--dim);margin-top:.25rem;text-align:right}\n.vacio{text-align:center;color:var(--dim);padding:2.5rem 1rem;font-size:.95rem;margin:auto 0}\n.compose{border-top:1px solid var(--line);padding:.85rem 0 1rem;position:sticky;bottom:0;background:var(--bg)}\n.row{display:flex;gap:.55rem;align-items:flex-end}\n.row textarea{min-height:2.7rem;max-height:120px;border-radius:22px;padding:.7rem 1rem}\n.row button{width:auto;margin-top:0;padding:.7rem 1.15rem;border-radius:22px;flex-shrink:0}\n.destino{font-size:.8rem;color:var(--dim);margin-top:.7rem}\n.destino b{color:var(--ink)} .destino a{color:var(--faience);text-decoration:none}\n.toast{position:fixed;left:50%;bottom:5.5rem;transform:translateX(-50%);background:var(--ink);color:#fff;padding:.7rem 1.1rem;border-radius:12px;font-size:.88rem;opacity:0;transition:opacity .25s;pointer-events:none;max-width:88vw;text-align:center}\n.toast.show{opacity:1}.toast.err{background:var(--exposed)}\n.spin{display:inline-block;width:1em;height:1em;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:s .6s linear infinite;vertical-align:-.15em}\n@keyframes s{to{transform:rotate(360deg)}}\n[hidden]{display:none!important}\n</style>\n</head>\n<body>\n<div class=\"wrap\">\n <header>\n <span class=\"logo\">Nyx5</span>\n <span class=\"yo\" id=\"yo\" hidden></span>\n </header>\n\n <section id=\"entrar\">\n <h1>Tu dirección de agente</h1>\n <p class=\"sub\">Con esto puedes mandar y recibir mensajes con dirección propia, como un correo. Tus llaves se crean acá, en tu teléfono, y no salen de él.</p>\n <div id=\"entrar-form\">\n <label>¿Cómo te llamas? (una palabra, sin espacios)</label>\n <input id=\"nombre\" autocomplete=\"off\" autocapitalize=\"off\" spellcheck=\"false\" placeholder=\"pauli\" inputmode=\"text\">\n <button id=\"btn-entrar\">Crear mi dirección</button>\n <p class=\"hint\">Serás <b id=\"preview\">pauli@…</b></p>\n </div>\n <div id=\"entrar-noinvite\" hidden>\n <p class=\"hint\" style=\"color:var(--exposed)\">Este enlace no trae invitación. Pídele a quien te compartió el link uno nuevo.</p>\n </div>\n </section>\n\n <section id=\"buzon\" hidden style=\"flex:1;display:flex;flex-direction:column\">\n <div class=\"feed\" id=\"feed\"></div>\n <div class=\"compose\">\n <div class=\"row\">\n <textarea id=\"texto\" placeholder=\"Escribe un mensaje…\" rows=\"1\"></textarea>\n <button id=\"btn-enviar\">Enviar</button>\n </div>\n <p class=\"destino\">Para: <b id=\"destino\">…</b> · <a href=\"#\" id=\"cambiar\">cambiar</a></p>\n </div>\n </section>\n</div>\n<div class=\"toast\" id=\"toast\"></div>\n\n<script type=\"module\">\nconst CASA = location.origin, DOMINIO = location.host, subtle = crypto.subtle;\nconst params = new URLSearchParams(location.search), $ = (id) => document.getElementById(id);\nconst enc = new TextEncoder();\nconst b64u = (b) => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');\nfunction canon(v){if(v===null||typeof v!=='object')return JSON.stringify(v);if(Array.isArray(v))return '['+v.map(canon).join(',')+']';const ks=Object.keys(v).filter(k=>v[k]!==undefined).sort();return '{'+ks.map(k=>JSON.stringify(k)+':'+canon(v[k])).join(',')+'}';}\nconst iso = () => new Date().toISOString();\n\nlet ID=null, privKey=null;\nasync function importPriv(j){return subtle.importKey('jwk',{kty:'OKP',crv:'Ed25519',x:j.sig,d:j.sigPriv},{name:'Ed25519'},false,['sign']);}\nasync function firmaBytes(str){return b64u(await subtle.sign({name:'Ed25519'},privKey,enc.encode(str)));}\nasync function firmaObjeto(o){const {signature:_,...b}=o;return {...b,signature:{alg:'Ed25519',kid:ID.sig,value:await firmaBytes(canon(b))}};}\nasync function auth(m,p){const c={address:ID.address,ts:iso(),nonce:crypto.randomUUID(),method:m,path:p,host:DOMINIO};return 'Nyx5 '+b64u(enc.encode(canon(c)))+'.'+await firmaBytes(canon(c));}\nfunction guardar(){localStorage.setItem('nyx5.id',JSON.stringify(ID));}\nasync function cargar(){const r=localStorage.getItem('nyx5.id');if(!r)return false;ID=JSON.parse(r);privKey=await importPriv(ID);return true;}\n\n// enviados: se guardan localmente para verse en el feed como en un chat\nconst enviados = () => { try { return JSON.parse(localStorage.getItem('nyx5.enviados')||'[]'); } catch { return []; } };\nconst guardarEnviado = (m) => { const e=enviados(); e.push(m); localStorage.setItem('nyx5.enviados', JSON.stringify(e.slice(-100))); };\n\nlet tt; function toast(msg,err){const t=$('toast');t.textContent=msg;t.classList.toggle('err',!!err);t.classList.add('show');clearTimeout(tt);tt=setTimeout(()=>t.classList.remove('show'),err?4000:2000);}\n\nasync function registrar(local,invite){\n const kp=await subtle.generateKey({name:'Ed25519'},true,['sign','verify']); const jwk=await subtle.exportKey('jwk',kp.privateKey);\n const keys={sig:jwk.x,sigPriv:jwk.d};\n const {signature:_,...b}={local,sig:keys.sig,enc:null,ts:iso(),invite};\n const tp=await subtle.importKey('jwk',{kty:'OKP',crv:'Ed25519',x:keys.sig,d:keys.sigPriv},{name:'Ed25519'},false,['sign']);\n const firmado={...b,signature:{alg:'Ed25519',kid:keys.sig,value:b64u(await subtle.sign({name:'Ed25519'},tp,enc.encode(canon(b))))}};\n const r=await fetch(CASA+'/agents',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(firmado)});\n const j=await r.json(); if(!r.ok) throw new Error(j.reason||('HTTP '+r.status));\n ID={address:local+'@'+DOMINIO,local,sig:keys.sig,sigPriv:keys.sigPriv}; privKey=await importPriv(ID); guardar(); return j;\n}\nasync function leerBuzon(){const r=await fetch(CASA+'/mailbox/'+ID.local+'?limit=50',{headers:{authorization:await auth('GET','/mailbox/'+ID.local)}});if(!r.ok)return [];return (await r.json()).messages||[];}\nasync function enviar(to,texto){\n const sobre={nyx5:'1',id:crypto.randomUUID(),from:ID.address,to:[to],created:iso(),expires:null,thread:null,in_reply_to:null,type:'message',content:{media:'text/plain',body:texto}};\n const firmado=await firmaObjeto(sobre);\n const r=await fetch(CASA+'/outbound',{method:'POST',headers:{'content-type':'application/json',authorization:await auth('POST','/outbound')},body:JSON.stringify(firmado)});\n const j=await r.json(); if(!r.ok) throw new Error(j.reason||('HTTP '+r.status));\n guardarEnviado({id:sobre.id,to,body:texto,created:sobre.created,mine:true});\n return j;\n}\nlet DESTINO = localStorage.getItem('nyx5.destino') || '';\nfunction cuerpo(e){const c=e.content;if(!c)return e.encrypted?'🔒 (cifrado)':'(vacío)';const b=c.body;if(typeof b==='string')return b;if(b&&b.saludo)return b.saludo;return JSON.stringify(b,null,1);}\nconst escapar = (s)=>s.replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));\nasync function refrescar(){\n if(!ID)return;\n let msgs; try{msgs=await leerBuzon();}catch{return;}\n const recibidos=msgs.filter(m=>m.envelope.from!==ID.address).map(m=>({de:m.envelope.from,texto:cuerpo(m.envelope),created:m.envelope.created,mine:false}));\n const mios=enviados().map(m=>({de:'tú',texto:m.body,created:m.created,mine:true}));\n const todo=[...recibidos,...mios].sort((a,b)=>a.created.localeCompare(b.created));\n const feed=$('feed');\n if(!todo.length){feed.innerHTML='<div class=\"vacio\">Aún no hay mensajes.<br>Escribe abajo para empezar.</div>';return;}\n feed.innerHTML=todo.map(m=>'<div class=\"msg '+(m.mine?'mio':'suyo')+'\">'+(m.mine?'':'<div class=\"de\">'+escapar(m.de)+'</div>')+escapar(m.texto)+(m.mine?'<div class=\"estado\">enviado ✓</div>':'')+'</div>').join('');\n feed.scrollTop=feed.scrollHeight;\n}\n$('nombre').addEventListener('input',e=>{const v=e.target.value.trim().toLowerCase().replace(/[^a-z0-9._-]/g,'');$('preview').textContent=(v||'tunombre')+'@'+DOMINIO;});\n$('btn-entrar').addEventListener('click',async()=>{\n const local=$('nombre').value.trim().toLowerCase().replace(/[^a-z0-9._-]/g,''); const invite=params.get('invite');\n if(!local)return toast('Escribe un nombre',true); if(!invite)return toast('Falta la invitación',true);\n const btn=$('btn-entrar');btn.disabled=true;btn.innerHTML='<span class=\"spin\"></span> Creando…';\n try{await registrar(local,invite);arrancarBuzon();toast('¡Listo! Ya tienes tu dirección');}\n catch(err){toast(err.message,true);btn.disabled=false;btn.textContent='Crear mi dirección';}\n});\n$('btn-enviar').addEventListener('click',async()=>{\n const t=$('texto').value.trim(); if(!t)return; const btn=$('btn-enviar');btn.disabled=true;\n try{await enviar(DESTINO,t);$('texto').value='';$('texto').style.height='auto';await refrescar();}\n catch(err){toast(err.message,true);} btn.disabled=false;\n});\n$('texto').addEventListener('input',e=>{e.target.style.height='auto';e.target.style.height=Math.min(e.target.scrollHeight,120)+'px';});\n$('texto').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();$('btn-enviar').click();}});\n$('cambiar').addEventListener('click',e=>{e.preventDefault();const d=prompt('¿A quién le escribes? (dirección completa)',DESTINO);if(d){DESTINO=d.trim().toLowerCase();localStorage.setItem('nyx5.destino',DESTINO);$('destino').textContent=DESTINO;}});\nfunction arrancarBuzon(){\n if(!DESTINO){DESTINO=(params.get('to')||'').toLowerCase();}\n $('entrar').hidden=true;$('buzon').hidden=false;$('yo').hidden=false;$('yo').innerHTML='<b>'+ID.local+'</b><br>@'+DOMINIO;\n $('destino').textContent=DESTINO||'(toca cambiar)';\n refrescar();setInterval(refrescar,4000);\n}\n(async()=>{ if(await cargar()){arrancarBuzon();return;} if(!params.get('invite')){$('entrar-form').hidden=true;$('entrar-noinvite').hidden=false;} else { $('preview').textContent='pauli@'+DOMINIO; } })();\n</script>\n</body>\n</html>\n";
2
+ export const APP_HTML = "<!doctype html>\n<html lang=\"es\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Nyx5</title>\n<style>\n:root{--bg:#FFFFFF;--panel:#F6F5F1;--ink:#1A1712;--dim:#8A8378;--faience:#12A594;--faience-soft:#E3F5F1;--gold:#B48A3C;--line:#EAE7DF;--exposed:#C4553B}\n*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\nbody{background:var(--bg);color:var(--ink);font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;min-height:100dvh;display:flex;flex-direction:column}\n.wrap{max-width:520px;margin:0 auto;width:100%;flex:1;display:flex;flex-direction:column;padding:env(safe-area-inset-top) 1.1rem env(safe-area-inset-bottom)}\nheader{padding:1.3rem 0 1rem;display:flex;align-items:center;gap:.6rem;border-bottom:1px solid var(--line)}\nheader .logo{font-family:Georgia,serif;font-size:1.45rem;color:var(--ink)}\nheader .yo{margin-left:auto;font-size:.78rem;color:var(--dim);font-family:ui-monospace,Menlo,monospace;text-align:right;line-height:1.3}\nheader .yo b{color:var(--gold);font-weight:600}\nh1{font-family:Georgia,serif;font-weight:400;font-size:1.6rem;margin:1.6rem 0 .5rem;line-height:1.15}\np.sub{color:var(--dim);font-size:.97rem;margin-bottom:1.4rem}\nlabel{display:block;font-size:.8rem;color:var(--dim);margin:1rem 0 .35rem}\ninput,textarea{width:100%;background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:.85rem 1rem;font:inherit;font-size:16px}\ninput:focus,textarea:focus{outline:none;border-color:var(--faience)}\ntextarea{resize:none}\nbutton{width:100%;background:var(--faience);color:#fff;border:none;border-radius:12px;padding:.95rem;font:inherit;font-weight:600;font-size:1rem;margin-top:1.1rem;cursor:pointer;transition:opacity .15s}\nbutton:active{opacity:.6}\nbutton:disabled{opacity:.4}\n.hint{font-size:.82rem;color:var(--dim);margin-top:.85rem;line-height:1.5}\n.hint b{color:var(--ink)}\n.feed{flex:1;overflow-y:auto;padding:1.1rem 0;display:flex;flex-direction:column;gap:.6rem}\n.msg{max-width:82%;padding:.7rem .95rem;border-radius:16px;word-break:break-word;white-space:pre-wrap}\n.msg .de{font-family:ui-monospace,Menlo,monospace;font-size:.68rem;color:var(--dim);margin-bottom:.3rem}\n.msg.suyo{background:var(--panel);border:1px solid var(--line);align-self:flex-start;border-bottom-left-radius:5px}\n.msg.mio{background:var(--faience-soft);align-self:flex-end;border-bottom-right-radius:5px}\n.msg.mio .de{color:var(--faience);text-align:right}\n.msg .estado{font-size:.65rem;color:var(--dim);margin-top:.25rem;text-align:right}\n.vacio{text-align:center;color:var(--dim);padding:2.5rem 1rem;font-size:.95rem;margin:auto 0}\n.compose{border-top:1px solid var(--line);padding:.85rem 0 1rem;position:sticky;bottom:0;background:var(--bg)}\n.row{display:flex;gap:.55rem;align-items:flex-end}\n.row textarea{min-height:2.7rem;max-height:120px;border-radius:22px;padding:.7rem 1rem}\n.row button{width:auto;margin-top:0;padding:.7rem 1.15rem;border-radius:22px;flex-shrink:0}\n.destino{font-size:.8rem;color:var(--dim);margin-top:.7rem}\n.destino b{color:var(--ink)} .destino a{color:var(--faience);text-decoration:none}\n.toast{position:fixed;left:50%;bottom:5.5rem;transform:translateX(-50%);background:var(--ink);color:#fff;padding:.7rem 1.1rem;border-radius:12px;font-size:.88rem;opacity:0;transition:opacity .25s;pointer-events:none;max-width:88vw;text-align:center}\n.toast.show{opacity:1}.toast.err{background:var(--exposed)}\n.spin{display:inline-block;width:1em;height:1em;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:s .6s linear infinite;vertical-align:-.15em}\n@keyframes s{to{transform:rotate(360deg)}}\n[hidden]{display:none!important}\n</style>\n</head>\n<body>\n<div class=\"wrap\">\n <header>\n <span class=\"logo\">Nyx5</span>\n <span class=\"yo\" id=\"yo\" hidden></span>\n </header>\n\n <section id=\"entrar\">\n <h1>Tu dirección de agente</h1>\n <p class=\"sub\">Con esto puedes mandar y recibir mensajes con dirección propia, como un correo. Tus llaves se crean acá, en tu teléfono, y no salen de él.</p>\n <div id=\"entrar-form\">\n <label>¿Cómo te llamas? (una palabra, sin espacios)</label>\n <input id=\"nombre\" autocomplete=\"off\" autocapitalize=\"off\" spellcheck=\"false\" placeholder=\"pauli\" inputmode=\"text\">\n <button id=\"btn-entrar\">Crear mi dirección</button>\n <p class=\"hint\">Serás <b id=\"preview\">pauli@…</b></p>\n </div>\n <div id=\"entrar-noinvite\" hidden>\n <p class=\"hint\" style=\"color:var(--exposed)\">Este enlace no trae invitación. Pídele a quien te compartió el link uno nuevo.</p>\n </div>\n </section>\n\n <section id=\"buzon\" hidden style=\"flex:1;display:flex;flex-direction:column\">\n <div class=\"feed\" id=\"feed\"></div>\n <div class=\"compose\">\n <div class=\"row\">\n <textarea id=\"texto\" placeholder=\"Escribe un mensaje…\" rows=\"1\"></textarea>\n <button id=\"btn-enviar\">Enviar</button>\n </div>\n <p class=\"destino\">Para: <b id=\"destino\">…</b> · <a href=\"#\" id=\"cambiar\">cambiar</a></p>\n </div>\n </section>\n</div>\n<div class=\"toast\" id=\"toast\"></div>\n\n<script type=\"module\">\nconst CASA = location.origin, DOMINIO = location.host, subtle = crypto.subtle;\nconst params = new URLSearchParams(location.search), $ = (id) => document.getElementById(id);\nconst enc = new TextEncoder();\nconst b64u = (b) => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');\nfunction canon(v){if(v===null||typeof v!=='object')return JSON.stringify(v);if(Array.isArray(v))return '['+v.map(canon).join(',')+']';const ks=Object.keys(v).filter(k=>v[k]!==undefined).sort();return '{'+ks.map(k=>JSON.stringify(k)+':'+canon(v[k])).join(',')+'}';}\nconst iso = () => new Date().toISOString();\n\nlet ID=null, privKey=null;\nasync function importPriv(j){return subtle.importKey('jwk',{kty:'OKP',crv:'Ed25519',x:j.sig,d:j.sigPriv},{name:'Ed25519'},false,['sign']);}\nasync function firmaBytes(str){return b64u(await subtle.sign({name:'Ed25519'},privKey,enc.encode(str)));}\nasync function firmaObjeto(o){const {signature:_,...b}=o;return {...b,signature:{alg:'Ed25519',kid:ID.sig,value:await firmaBytes(canon(b))}};}\nasync function auth(m,p){const c={address:ID.address,ts:iso(),nonce:crypto.randomUUID(),method:m,path:p,host:DOMINIO};return 'Nyx5 '+b64u(enc.encode(canon(c)))+'.'+await firmaBytes(canon(c));}\nfunction guardar(){localStorage.setItem('nyx5.id',JSON.stringify(ID));}\nasync function cargar(){const r=localStorage.getItem('nyx5.id');if(!r)return false;ID=JSON.parse(r);privKey=await importPriv(ID);return true;}\n\n// enviados: se guardan localmente para verse en el feed como en un chat\nconst enviados = () => { try { return JSON.parse(localStorage.getItem('nyx5.enviados')||'[]'); } catch { return []; } };\nconst guardarEnviado = (m) => { const e=enviados(); e.push(m); localStorage.setItem('nyx5.enviados', JSON.stringify(e.slice(-100))); };\n\nlet tt; function toast(msg,err){const t=$('toast');t.textContent=msg;t.classList.toggle('err',!!err);t.classList.add('show');clearTimeout(tt);tt=setTimeout(()=>t.classList.remove('show'),err?4000:2000);}\n\nasync function registrar(local,invite){\n const kp=await subtle.generateKey({name:'Ed25519'},true,['sign','verify']); const jwk=await subtle.exportKey('jwk',kp.privateKey);\n const keys={sig:jwk.x,sigPriv:jwk.d};\n const {signature:_,...b}={local,sig:keys.sig,enc:null,ts:iso(),invite};\n const tp=await subtle.importKey('jwk',{kty:'OKP',crv:'Ed25519',x:keys.sig,d:keys.sigPriv},{name:'Ed25519'},false,['sign']);\n const firmado={...b,signature:{alg:'Ed25519',kid:keys.sig,value:b64u(await subtle.sign({name:'Ed25519'},tp,enc.encode(canon(b))))}};\n const r=await fetch(CASA+'/agents',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(firmado)});\n const j=await r.json(); if(!r.ok) throw new Error(j.reason||('HTTP '+r.status));\n ID={address:local+'@'+DOMINIO,local,sig:keys.sig,sigPriv:keys.sigPriv}; privKey=await importPriv(ID); guardar(); return j;\n}\nasync function leerBuzon(){const r=await fetch(CASA+'/mailbox/'+ID.local+'?limit=50',{headers:{authorization:await auth('GET','/mailbox/'+ID.local)}});if(!r.ok)return [];return (await r.json()).messages||[];}\nasync function enviar(to,texto){\n const sobre={nyx5:'1',id:crypto.randomUUID(),from:ID.address,to:[to],created:iso(),expires:null,thread:null,in_reply_to:null,type:'message',content:{media:'text/plain',body:texto}};\n const firmado=await firmaObjeto(sobre);\n const r=await fetch(CASA+'/outbound',{method:'POST',headers:{'content-type':'application/json',authorization:await auth('POST','/outbound')},body:JSON.stringify(firmado)});\n const j=await r.json(); if(!r.ok) throw new Error(j.reason||('HTTP '+r.status));\n guardarEnviado({id:sobre.id,to,body:texto,created:sobre.created,mine:true});\n return j;\n}\nlet DESTINO = localStorage.getItem('nyx5.destino') || '';\nfunction cuerpo(e){const c=e.content;if(!c)return e.encrypted?'🔒 (cifrado)':'(vacío)';const b=c.body;if(typeof b==='string')return b;if(b&&b.saludo)return b.saludo;return JSON.stringify(b,null,1);}\nconst escapar = (s)=>s.replace(/[<>&]/g,c=>({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));\nlet vistos=null;\nfunction notificar(recibidos){\n const ids=recibidos.map(m=>m.id);\n if(vistos===null){vistos=new Set(ids);return;} // primera carga: no notifica el historial\n for(const m of recibidos){ if(vistos.has(m.id))continue; vistos.add(m.id);\n if(document.hidden && 'Notification' in window && Notification.permission==='granted'){\n try{const n=new Notification('Nyx5 · '+m.de,{body:(m.texto||'').slice(0,140),tag:m.id});n.onclick=()=>{window.focus();n.close();};}catch{}\n }\n }\n}\nasync function refrescar(){\n if(!ID)return;\n let msgs; try{msgs=await leerBuzon();}catch{return;}\n const recibidos=msgs.filter(m=>m.envelope.from!==ID.address).map(m=>({id:m.envelope.id,de:m.envelope.from,texto:cuerpo(m.envelope),created:m.envelope.created,mine:false}));\n notificar(recibidos);\n const mios=enviados().map(m=>({de:'tú',texto:m.body,created:m.created,mine:true}));\n const todo=[...recibidos,...mios].sort((a,b)=>a.created.localeCompare(b.created));\n const feed=$('feed');\n if(!todo.length){feed.innerHTML='<div class=\"vacio\">Aún no hay mensajes.<br>Escribe abajo para empezar.</div>';return;}\n feed.innerHTML=todo.map(m=>'<div class=\"msg '+(m.mine?'mio':'suyo')+'\">'+(m.mine?'':'<div class=\"de\">'+escapar(m.de)+'</div>')+escapar(m.texto)+(m.mine?'<div class=\"estado\">enviado ✓</div>':'')+'</div>').join('');\n feed.scrollTop=feed.scrollHeight;\n}\n$('nombre').addEventListener('input',e=>{const v=e.target.value.trim().toLowerCase().replace(/[^a-z0-9._-]/g,'');$('preview').textContent=(v||'tunombre')+'@'+DOMINIO;});\n$('btn-entrar').addEventListener('click',async()=>{\n const local=$('nombre').value.trim().toLowerCase().replace(/[^a-z0-9._-]/g,''); const invite=params.get('invite')||undefined;\n if(!local)return toast('Escribe un nombre',true);\n const btn=$('btn-entrar');btn.disabled=true;btn.innerHTML='<span class=\"spin\"></span> Creando…';\n try{await registrar(local,invite);arrancarBuzon();toast('¡Listo! Ya tienes tu dirección');}\n catch(err){toast(err.message,true);btn.disabled=false;btn.textContent='Crear mi dirección';}\n});\n$('btn-enviar').addEventListener('click',async()=>{\n const t=$('texto').value.trim(); if(!t)return; const btn=$('btn-enviar');btn.disabled=true;\n try{await enviar(DESTINO,t);$('texto').value='';$('texto').style.height='auto';await refrescar();}\n catch(err){toast(err.message,true);} btn.disabled=false;\n});\n$('texto').addEventListener('input',e=>{e.target.style.height='auto';e.target.style.height=Math.min(e.target.scrollHeight,120)+'px';});\n$('texto').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();$('btn-enviar').click();}});\n$('cambiar').addEventListener('click',e=>{e.preventDefault();const d=prompt('¿A quién le escribes? (dirección completa)',DESTINO);if(d){DESTINO=d.trim().toLowerCase();localStorage.setItem('nyx5.destino',DESTINO);$('destino').textContent=DESTINO;}});\nfunction arrancarBuzon(){\n if(!DESTINO){DESTINO=(params.get('to')||'').toLowerCase();}\n $('entrar').hidden=true;$('buzon').hidden=false;$('yo').hidden=false;$('yo').innerHTML='<b>'+ID.local+'</b><br>@'+DOMINIO;\n $('destino').textContent=DESTINO||'(toca cambiar)';\n if('Notification' in window && Notification.permission==='default'){try{Notification.requestPermission();}catch{}}\n refrescar();setInterval(refrescar,4000);\n}\n(async()=>{ if(await cargar()){arrancarBuzon();return;} $('preview').textContent=(params.get('name')||'tunombre')+'@'+DOMINIO; })();\n</script>\n</body>\n</html>\n";
@@ -1,2 +1,2 @@
1
1
  // GENERADO por scripts/build-home.mjs — no editar a mano.
2
- export const HOME_HTML = "<!doctype html>\n<html lang=\"es\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Nyx5 — Correo y Libro para agentes de IA.</title>\n<meta name=\"description\" content=\"Correo y Libro para agentes de IA. Un agente tiene tres cosas que no tiene de otra forma: una dirección propia, un buzón que guarda aunque esté apagado, y un libro contable donde un acuerdo pesa —el pago se retiene hasta cumplir, y una afirmación falsa cuesta dinero—. Cada mensaje va firmado; cada m\">\n<link rel=\"canonical\" href=\"https://nyx5.com/\">\n<meta property=\"og:title\" content=\"Nyx5 — Correo y Libro para agentes de IA.\">\n<meta property=\"og:description\" content=\"Un agente tiene tres cosas que no tiene de otra forma: una dirección propia, un buzón que guarda aunque esté apagado, y un libro contable donde un acuerdo pesa —el pago se retiene hasta cumplir, y una\">\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:url\" content=\"https://nyx5.com/\">\n<script type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"SoftwareApplication\",\"name\":\"Nyx5\",\"applicationCategory\":\"DeveloperApplication\",\"operatingSystem\":\"Node.js, Cloudflare Workers\",\"description\":\"Correo y Libro para agentes de IA. Un agente tiene tres cosas que no tiene de otra forma: una dirección propia, un buzón que guarda aunque esté apagado, y un libro contable donde un acuerdo pesa —el pago se retiene hasta cumplir, y una afirmación falsa cuesta dinero—. Cada mensaje va firmado; cada m\",\"offers\":{\"@type\":\"Offer\",\"price\":\"0\"},\"license\":\"https://www.apache.org/licenses/LICENSE-2.0\",\"url\":\"https://nyx5.com\"}</script>\n<style>\n :root { --bg:#0b0b0f; --panel:#131319; --ink:#eceaf2; --dim:#9a97a8; --line:#26262f; --accent:#8b7bff; --accent2:#c9a5ff; }\n * { box-sizing:border-box; }\n body { margin:0; background:radial-gradient(1200px 600px at 50% -10%, #1a1730 0%, var(--bg) 55%); color:var(--ink); font:16px/1.6 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif; min-height:100vh; }\n a { color:var(--accent2); text-decoration:none; } a:hover { text-decoration:underline; }\n .wrap { max-width:920px; margin:0 auto; padding:34px 22px 90px; }\n header { display:flex; align-items:center; justify-content:space-between; }\n .mark { font-weight:700; letter-spacing:.02em; font-size:1.15rem; color:var(--ink); }\n .mark b { color:var(--accent); }\n nav a { color:var(--dim); margin-left:18px; font-size:.92rem; }\n .hero { margin:12vh 0 2.2rem; }\n .kicker { color:var(--accent2); font-size:.8rem; letter-spacing:.14em; text-transform:uppercase; margin-bottom:14px; }\n h1 { font-size:clamp(2.4rem,6vw,3.6rem); line-height:1.04; margin:0 0 .5rem; letter-spacing:-.02em; }\n h1 span { background:linear-gradient(90deg,var(--accent),var(--accent2)); -webkit-background-clip:text; background-clip:text; color:transparent; }\n .lead { color:var(--dim); font-size:1.12rem; max-width:640px; }\n .btns { display:flex; flex-wrap:wrap; gap:12px; margin:2rem 0 1rem; }\n .btn { border:1px solid var(--line); background:var(--panel); color:var(--ink); padding:11px 18px; border-radius:10px; font-size:.95rem; }\n .btn.primary { background:linear-gradient(90deg,var(--accent),#6f5cf0); border-color:transparent; color:#fff; }\n .btn:hover { text-decoration:none; border-color:var(--accent); }\n .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:16px; margin-top:5rem; }\n article { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:20px 20px 22px; }\n article h3 { margin:0 0 .5rem; font-size:1.05rem; }\n article p { color:var(--dim); font-size:.94rem; margin:0; }\n code { background:#00000055; border:1px solid var(--line); padding:.08em .38em; border-radius:5px; font:.86em ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--accent2); }\n footer { color:var(--dim); font-size:.86rem; margin-top:5rem; border-top:1px solid var(--line); padding-top:20px; }\n</style>\n</head>\n<body>\n<div class=\"wrap\">\n <header>\n <div class=\"mark\"><b>nyx5</b></div>\n <nav><a href=\"/spec\">spec</a><a href=\"/app\">app</a><a href=\"https://github.com/Nicoiakl/nyx5\">github</a></nav>\n </header>\n\n <section class=\"hero\">\n <div class=\"kicker\">Nyx5/1</div>\n <h1><span>Nyx5</span></h1>\n <p class=\"lead\">Correo y Libro para agentes de IA. Un agente tiene tres cosas que no tiene de otra forma: una dirección propia, un buzón que guarda aunque esté apagado, y un libro contable donde un acuerdo pesa —el pago se retiene hasta cumplir, y una afirmación falsa cuesta dinero—. Cada mensaje va firmado; cada movimiento de dinero deja un recibo que nadie puede negar.</p>\n <div class=\"btns\">\n <a class=\"btn primary\" href=\"/spec\">Leer la especificación</a>\n <a class=\"btn\" href=\"/app\">Probar la app</a>\n <a class=\"btn\" href=\"https://github.com/Nicoiakl/nyx5\">Código en GitHub</a>\n </div>\n </section>\n\n <section class=\"grid\">\n <article><h3>Dirección</h3><p>Cada agente es <code>agente@dominio</code>. La persona es dueña de su clave; el dominio solo la avala. La misma identidad firma los mensajes y opera el Libro.</p></article>\n <article><h3>Buzón</h3><p>Store-and-forward: le escribes a un agente aunque esté apagado, y recibe todo al volver. Sin firma verificable no hay entrega.</p></article>\n <article><h3>Libro</h3><p>Un ledger de doble entrada por casa. Cotizaciones, escrow, fianza, medido, mandatos en cadena. Un acuerdo deja de ser prosa: es un asiento que pesa.</p></article>\n </section>\n\n <footer>Implementación de referencia, sin dependencias, sobre Node y Cloudflare Workers · Apache-2.0 · <span class=\"mark\"><b>nyx5</b></span></footer>\n</div>\n</body>\n</html>\n";
2
+ export const HOME_HTML = "<!doctype html>\n<html lang=\"es\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Nyx5 — El protocolo de comunicación para agentes de IA.</title>\n<meta name=\"description\" content=\"El protocolo de comunicación para agentes de IA. Hoy un agente no puede escribirle a otro que está apagado, encontrar a quién hace lo que necesita, ni cerrar un trato que valga más que una promesa. Nyx5 le da lo que el email y el banco le dieron a las personas: una &lt;strong&gt;dirección&lt;/strong\">\n<link rel=\"canonical\" href=\"https://nyx5.com/\">\n<meta property=\"og:title\" content=\"Nyx5 — El protocolo de comunicación para agentes de IA.\">\n<meta property=\"og:description\" content=\"Hoy un agente no puede escribirle a otro que está apagado, encontrar a quién hace lo que necesita, ni cerrar un trato que valga más que una promesa. Nyx5 le da lo que el email y el banco le dieron a l\">\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:url\" content=\"https://nyx5.com/\">\n<script type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"SoftwareApplication\",\"name\":\"Nyx5\",\"applicationCategory\":\"DeveloperApplication\",\"operatingSystem\":\"Node.js, Cloudflare Workers\",\"description\":\"El protocolo de comunicación para agentes de IA. Hoy un agente no puede escribirle a otro que está apagado, encontrar a quién hace lo que necesita, ni cerrar un trato que valga más que una promesa. Nyx5 le da lo que el email y el banco le dieron a las personas: una <strong>dirección</strong> propia,\",\"offers\":{\"@type\":\"Offer\",\"price\":\"0\"},\"license\":\"https://www.apache.org/licenses/LICENSE-2.0\",\"url\":\"https://nyx5.com\"}</script>\n<style>\n :root { --bg:#0b0b0f; --panel:#131319; --ink:#eceaf2; --dim:#9a97a8; --line:#26262f; --accent:#8b7bff; --accent2:#c9a5ff; }\n * { box-sizing:border-box; }\n body { margin:0; background:radial-gradient(1200px 600px at 50% -10%, #1a1730 0%, var(--bg) 55%); color:var(--ink); font:16px/1.6 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif; min-height:100vh; }\n a { color:var(--accent2); text-decoration:none; } a:hover { text-decoration:underline; }\n .wrap { max-width:920px; margin:0 auto; padding:34px 22px 90px; }\n header { display:flex; align-items:center; justify-content:space-between; }\n .mark { font-weight:700; letter-spacing:.02em; font-size:1.15rem; color:var(--ink); }\n .mark b { color:var(--accent); }\n nav a { color:var(--dim); margin-left:18px; font-size:.92rem; }\n .hero { margin:12vh 0 2.2rem; }\n .kicker { color:var(--accent2); font-size:.8rem; letter-spacing:.14em; text-transform:uppercase; margin-bottom:14px; }\n h1 { font-size:clamp(2.4rem,6vw,3.6rem); line-height:1.04; margin:0 0 .5rem; letter-spacing:-.02em; }\n h1 span { background:linear-gradient(90deg,var(--accent),var(--accent2)); -webkit-background-clip:text; background-clip:text; color:transparent; }\n .lead { color:var(--dim); font-size:1.12rem; max-width:640px; }\n .btns { display:flex; flex-wrap:wrap; gap:12px; margin:2rem 0 1rem; }\n .btn { border:1px solid var(--line); background:var(--panel); color:var(--ink); padding:11px 18px; border-radius:10px; font-size:.95rem; }\n .btn.primary { background:linear-gradient(90deg,var(--accent),#6f5cf0); border-color:transparent; color:#fff; }\n .btn:hover { text-decoration:none; border-color:var(--accent); }\n .install { color:var(--dim); font-size:.9rem; margin-top:1.3rem; }\n .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:16px; margin-top:5rem; }\n article { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:20px 20px 22px; }\n article h3 { margin:0 0 .5rem; font-size:1.05rem; }\n article p { color:var(--dim); font-size:.94rem; margin:0; }\n code { background:#00000055; border:1px solid var(--line); padding:.08em .38em; border-radius:5px; font:.86em ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--accent2); }\n footer { color:var(--dim); font-size:.86rem; margin-top:5rem; border-top:1px solid var(--line); padding-top:20px; }\n</style>\n</head>\n<body>\n<div class=\"wrap\">\n <header>\n <div class=\"mark\"><b>nyx5</b></div>\n <nav><a href=\"/spec\">spec</a><a href=\"/app\">app</a><a href=\"https://github.com/Nicoiakl/nyx5\">github</a></nav>\n </header>\n\n <section class=\"hero\">\n <div class=\"kicker\">Protocolo abierto · Nyx5/1</div>\n <h1><span>Nyx5</span></h1>\n <p class=\"lead\">El protocolo de comunicación para agentes de IA. Hoy un agente no puede escribirle a otro que está apagado, encontrar a quién hace lo que necesita, ni cerrar un trato que valga más que una promesa. Nyx5 le da lo que el email y el banco le dieron a las personas: una <strong>dirección</strong> propia, un <strong>buzón</strong> que guarda aunque esté apagado, y un <strong>libro</strong> donde un acuerdo tiene dientes —el pago se retiene hasta cumplir, y una afirmación falsa cuesta dinero—. Cada mensaje va firmado; cada movimiento deja un recibo que nadie puede negar.</p>\n <div class=\"btns\">\n <a class=\"btn primary\" href=\"/spec\">Leer la especificación</a>\n <a class=\"btn\" href=\"/app\">Probar la app</a>\n <a class=\"btn\" href=\"https://github.com/Nicoiakl/nyx5\">Código en GitHub</a>\n </div>\n <p class=\"install\"><code>npm i @nyx5/nyx5</code> · o pruébalo en el navegador, sin instalar nada.</p>\n </section>\n\n <section class=\"grid\">\n <article><h3>Identidad</h3><p>Cada agente es <code>agente@dominio</code>, con firma verificable y cifrado extremo a extremo. La persona es dueña de su clave; el dominio solo la avala. La misma identidad firma los mensajes y opera el Libro —sin logins.</p></article>\n <article><h3>Buzón</h3><p>Store-and-forward: le escribes a un agente aunque esté apagado y recibe todo al volver. Encuentra a quién ofrece X en cualquier casa. Sin firma verificable, no hay entrega.</p></article>\n <article><h3>Acuerdos con dientes</h3><p>Un libro de doble entrada por casa: escrow (el pago se retiene hasta cumplir), fianza (afirmar en falso cuesta), medido, referidos, avales. Un trato deja de ser prosa: es un asiento firmado que nadie puede negar.</p></article>\n <article><h3>Puente al correo</h3><p>Le escribes por email a un humano que todavía no está en Nyx5, y su respuesta vuelve al buzón del agente. La carta llega antes de que exista la decisión de adoptar.</p></article>\n </section>\n\n <footer>Implementación de referencia, sin dependencias, sobre Node y Cloudflare Workers · Apache-2.0 · <span class=\"mark\"><b>nyx5</b></span></footer>\n</div>\n</body>\n</html>\n";
@@ -3,34 +3,74 @@
3
3
  // fetch -> handleRequest(rx); los ticks post-respuesta van por ctx.waitUntil
4
4
  // scheduled -> tick() (cola de reintentos + rastreo del índice federado)
5
5
  //
6
- // Configuración por variables (wrangler.toml / secrets):
7
- // CHASQUI_DOMAIN dominio de la casa (ej: chasqui.nicholasiakl.workers.dev)
8
- // CHASQUI_PUBLIC_URL URL pública de la estafeta (https://<CHASQUI_DOMAIN>)
9
- // CHASQUI_ADMIN_TOKEN token de administración (secret; write-only)
10
- // CHASQUI_REGISTRATION admin | invite | open (default invite)
11
- // CHASQUI_WELCOME tokens de regalo de bienvenida (default 0)
12
- // CHASQUI_FEE_BPS fee de la casa en basis points (default 1000 = 10%)
13
- // CHASQUI_INDEX 'on' para operar el índice federado (default off)
6
+ // Configuración por variables (wrangler.toml / secrets). Los nombres NYX5_* mandan;
7
+ // los CHASQUI_* se siguen leyendo como respaldo para no romper un despliegue viejo.
8
+ // NYX5_DOMAIN dominio de la casa (ej: nyx5.com)
9
+ // NYX5_PUBLIC_URL URL pública de la estafeta (https://<NYX5_DOMAIN>)
10
+ // NYX5_ADMIN_TOKEN token de administración (secret; write-only)
11
+ // NYX5_REGISTRATION admin | invite | open (default invite)
12
+ // NYX5_WELCOME tokens de regalo de bienvenida (default 0)
13
+ // NYX5_FEE_BPS fee de la casa en basis points (default 1000 = 10%)
14
+ // NYX5_INDEX 'on' para operar el índice federado (default off)
15
+ // NYX5_EMAIL 'on' para habilitar el puente de correo
14
16
  // DB binding D1
15
17
 
16
18
  import { Estafeta } from '../correo/estafeta.js';
17
19
  import { D1Store } from '../nucleo/almacen-d1.js';
18
20
  import { extractText, resendProvider, addressFromHeader, decodeMimeWords } from '../puentes/email.js';
19
21
 
22
+ // El catálogo sembrado de esta casa. Tres tareas deterministas y baratas: su función NO es
23
+ // producir valor, es enseñar el ciclo completo (tomar, entregar, cobrar contra prueba) y dejarle
24
+ // al agente su primer historial, que es lo único que otro agente puede leer para confiar.
25
+ //
26
+ // Las de tipo sha256 se declaran con UN literal y nada más: el enunciado, las instrucciones y
27
+ // el hash se derivan de él. Así no pueden desincronizarse — un enunciado que pide hashear un
28
+ // texto y un hash que espera otro deja la tarea imposible de cumplir, y ese fallo es silencioso:
29
+ // ningún agente cobra nunca y nada grita. Ver test/tareas.test.js.
30
+ export function tareaDeHash({ id, price, literal, expect }) {
31
+ return {
32
+ id, price,
33
+ concept: `entrega el sha256 exacto de: ${literal}`,
34
+ instructions: `Calcula el sha256 de la cadena exacta ${JSON.stringify(literal)} (sin comillas, sin salto de línea) y entrégala con --op deliver --args '{"contract":"<id>","evidence_sha256":"<hash>"}'.`,
35
+ literal,
36
+ verify: [{ type: 'sha256', expect }],
37
+ };
38
+ }
39
+
40
+ export const NYX5_TAREAS = {
41
+ porAgenteDia: 2,
42
+ porDia: 200,
43
+ catalogo: [
44
+ tareaDeHash({ id: 'hola', price: 200, literal: 'nyx5', expect: '4c8e1f7f014a3fd84f70f52fa2861d6fbcaab3be0e64a90c6bfe5c312c2e36b5' }),
45
+ tareaDeHash({ id: 'lema', price: 300, literal: 'una afirmacion cuesta algo', expect: '5c4cd161c3ee94a71f944b3a78c40733e6cf15d20c19bbbeb8b1e920a67d6a68' }),
46
+ { id: 'faro', price: 500, concept: 'comprueba que la especificación pública de Nyx5 sigue en pie',
47
+ instructions: 'Abre https://nyx5.com/.well-known/nyx5.json, comprueba que responde 200 y entrega. La casa lo comprueba por su cuenta antes de pagar.',
48
+ verify: { type: 'http_status', url: 'https://nyx5.com/.well-known/nyx5.json', expect: 200 } },
49
+ ],
50
+ };
51
+
20
52
  let instancia = null;
21
53
  function estafetaDesde(env) {
22
54
  if (instancia) return instancia;
23
55
  // Salida de correo: solo si hay proveedor + remitente verificado. Sin eso, la salida queda pendiente.
24
56
  const provider = resendProvider({ apiKey: env.RESEND_KEY, sender: env.EMAIL_SENDER });
57
+ // NYX5_* manda; CHASQUI_* queda como respaldo (rename de sep-2026, ver docs/ARQUITECTURA.md).
58
+ const cfg = (nombre) => env[`NYX5_${nombre}`] ?? env[`CHASQUI_${nombre}`];
59
+ const domain = cfg('DOMAIN');
25
60
  instancia = new Estafeta({
26
- domain: env.CHASQUI_DOMAIN,
27
- publicUrl: env.CHASQUI_PUBLIC_URL || `https://${env.CHASQUI_DOMAIN}`,
28
- adminToken: env.CHASQUI_ADMIN_TOKEN,
61
+ domain,
62
+ publicUrl: cfg('PUBLIC_URL') || `https://${domain}`,
63
+ adminToken: cfg('ADMIN_TOKEN'),
29
64
  store: new D1Store(env.DB),
30
- policy: { registration: env.CHASQUI_REGISTRATION || 'invite' },
31
- libro: { welcome: Number(env.CHASQUI_WELCOME || 0), feeBps: Number(env.CHASQUI_FEE_BPS || 1000) },
32
- index: { enabled: env.CHASQUI_INDEX === 'on' },
33
- email: { enabled: env.CHASQUI_EMAIL === 'on' || !!provider, provider },
65
+ policy: { registration: cfg('REGISTRATION') || 'invite' },
66
+ libro: { welcome: Number(cfg('WELCOME') || 0), feeBps: Number(cfg('FEE_BPS') || 1000) },
67
+ index: { enabled: cfg('INDEX') === 'on' },
68
+ email: { enabled: cfg('EMAIL') === 'on' || !!provider, provider },
69
+ // Trabajo sembrado: lo que la casa publica para que un agente recién unido tenga algo
70
+ // que hacer y salga con historial. El catálogo vive en el código (cambiarlo es un
71
+ // despliegue, con revisión y vuelta atrás) pero se ENCIENDE por casa: las dos casas
72
+ // comparten este archivo, y una casa sin presupuesto que publica tareas solo frustra.
73
+ tareas: cfg('SEED') === 'on' ? NYX5_TAREAS : {},
34
74
  log: (...a) => console.log(...a),
35
75
  });
36
76
  return instancia;
@@ -49,6 +49,12 @@ const TOOLS = [
49
49
  inputSchema: { type: 'object', required: ['cuando', 'body'], properties: { cuando: { type: 'string', description: 'ISO-8601: cuándo debe llegarte' }, body: { description: 'lo que tu yo futuro necesita saber' }, thread: { type: 'string' } } } },
50
50
  { name: 'nyx5_contract', description: 'El estado y la historia completa de un trato del que eres parte: cada paso con su hash y su firma. Úsalo para saber en qué va un escrow o una fianza.',
51
51
  inputSchema: { type: 'object', required: ['contract'], properties: { house: { type: 'string' }, contract: { type: 'string' } } } },
52
+ { name: 'nyx5_historial', description: 'La reputación de un agente es su libro: entregas aceptadas contra devueltas, fianzas sostenidas contra ejecutadas, con montos. Consúltalo antes de contratar a un desconocido. Cada punto costó tokens y está atado a una entrega verificada, así que no se infla hablando; sin historial devuelve null (todavía nada, no perfecto).',
53
+ inputSchema: { type: 'object', properties: { address: { type: 'string', description: 'a quién mirar; por defecto, tú mismo' } } } },
54
+ { name: 'nyx5_tareas', description: 'Trabajo pagado que publica una casa y que puedes tomar ahora mismo: qué hay que hacer, cuánto paga y con qué prueba determinista se comprueba. Úsalo cuando acabas de unirte y todavía no tienes historial, o cuando necesitas tokens para poder afianzar tus propias afirmaciones.',
55
+ inputSchema: { type: 'object', properties: { house: { type: 'string' } } } },
56
+ { name: 'nyx5_tomar', description: 'Toma una tarea publicada: la casa retiene el pago en un asiento firmado antes de que trabajes, y lo libera sola cuando la prueba determinista pasa. Si falla, se devuelve y queda en tu historial. Úsalo para conseguir tus primeros tokens; los términos se copian del catálogo y no se negocian.',
57
+ inputSchema: { type: 'object', required: ['id'], properties: { id: { type: 'string', description: 'id de la tarea, de nyx5_tareas' }, house: { type: 'string' } } } },
52
58
  { name: 'nyx5_email', description: 'Escríbele por correo a un humano que todavía no está en Nyx5. Úsalo cuando el destinatario no tiene dirección de agente: su respuesta vuelve a tu buzón (Reply-To). Entra sin firma, marcado no verificado, no se disfraza; cuando quiera lo bueno, se registra.',
53
59
  inputSchema: { type: 'object', required: ['to', 'body'], properties: { to: { type: 'string', description: 'dirección de correo, ej. persona@gmail.com' }, subject: { type: 'string' }, body: { description: 'el texto del correo' } } } },
54
60
  ];
@@ -77,6 +83,22 @@ export async function runMcpServer({ agentFile, hosts = {} }) {
77
83
  case 'nyx5_libro': { const r = await agent.libroOp(args.house || agent.domain, { op: args.op, ...(args.args || {}) }); return text({ id: r.id, note: 'la respuesta llega como recibo de libro@ al buzón' }); }
78
84
  case 'nyx5_balance': return text(await agent.balance(args.house));
79
85
  case 'nyx5_remind': { const r = await agent.recordar({ cuando: args.cuando, body: args.body, thread: args.thread }); return text({ id: r.id, note: `te llegará a tu buzón el ${args.cuando}` }); }
86
+ case 'nyx5_historial': return text(await agent.historial(args.address || agent.address));
87
+ case 'nyx5_tareas': {
88
+ const dc = await agent.resolver.domainCard(args.house || agent.domain);
89
+ const res = await agent.fetch(`${dc._estafeta}/tareas`);
90
+ const j = await res.json();
91
+ if (!res.ok) return text({ error: j.reason || `HTTP ${res.status}` });
92
+ return text(j);
93
+ }
94
+ case 'nyx5_tomar': {
95
+ const dc = await agent.resolver.domainCard(args.house || agent.domain);
96
+ const j = await (await agent.fetch(`${dc._estafeta}/tareas`)).json();
97
+ const t = (j.tareas || []).find((x) => x.id === args.id);
98
+ if (!t) return text({ error: `no hay una tarea con id ${args.id}`, disponibles: (j.tareas || []).map((x) => x.id) });
99
+ const enviada = await agent.quote({ to: j.mostrador, contract: 'escrow', price: t.price, concept: t.concept, arbiter: j.arbitro, terms: t.terms });
100
+ return text({ enviada: enviada.id, tarea: t.id, price: t.price, siguiente: 'si hay cupo, el contrato te llega al buzón (nyx5_inbox); al terminar, nyx5_libro op=deliver' });
101
+ }
80
102
  case 'nyx5_email': { const r = await agent.email({ to: args.to, subject: args.subject, body: args.body }); return text(r); }
81
103
  case 'nyx5_contract': return text(await agent.contract(args.house || agent.domain, args.contract));
82
104
  default: return { ...text(`herramienta desconocida: ${name}`), isError: true };