@nyx5/nyx5 0.4.1 → 0.4.3

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/bin/nyx5.js CHANGED
@@ -130,8 +130,8 @@ try {
130
130
  const j = await res.json();
131
131
  if (!res.ok) throw new Error(j.reason || `HTTP ${res.status}`);
132
132
  if (o.json) { print(j); break; }
133
- console.log(`Seeded work at ${casa} (desk ${j.mostrador}, arbiter ${j.arbitro}):`);
134
- for (const t of j.tareas) console.log(` ${t.id} ${t.price} tok ${t.concept}\n checked with: ${t.verify.map((v) => v.type).join(', ')}`);
133
+ console.log(`Seeded work at ${casa} (desk ${(j.desk || j.mostrador)}, arbiter ${(j.arbiter || j.arbitro)}):`);
134
+ for (const t of (j.tasks || j.tareas || [])) console.log(` ${t.id} ${t.price} tok ${t.concept}\n checked with: ${t.verify.map((v) => v.type).join(', ')}`);
135
135
  console.log(`\nTo take one: npx @nyx5/nyx5 tomar --agent <your-key> --id <task>`);
136
136
  break;
137
137
  }
@@ -139,11 +139,11 @@ try {
139
139
  const a = await loadAgent();
140
140
  const dc = await a.resolver.domainCard(o.house || a.domain);
141
141
  const j = await (await fetch(`${dc._estafeta}/tareas`)).json();
142
- const t = (j.tareas || []).find((x) => x.id === need('id'));
143
- if (!t) throw new Error(`no task with id ${o.id}. Available: ${(j.tareas || []).map((x) => x.id).join(', ') || '(none)'}`);
142
+ const t = (j.tasks || j.tareas || []).find((x) => x.id === need('id'));
143
+ if (!t) throw new Error(`no task with id ${o.id}. Available: ${(j.tasks || j.tareas || []).map((x) => x.id).join(', ') || '(none)'}`);
144
144
  // Se copian los términos publicados TAL CUAL: la casa compara contra su catálogo y
145
145
  // rechaza cualquier diferencia, así que aquí no hay nada que ajustar.
146
- const enviada = await a.quote({ to: j.mostrador, contract: 'escrow', price: t.price, concept: t.concept, arbiter: j.arbitro, terms: t.terms });
146
+ const enviada = await a.quote({ to: (j.desk || j.mostrador), contract: 'escrow', price: t.price, concept: t.concept, arbiter: (j.arbiter || j.arbitro), terms: t.terms });
147
147
  console.log(`Quote sent for "${t.concept}" (${t.price} tok).`);
148
148
  console.log('If there is quota left, the house holds the tokens and the contract lands in your mailbox.');
149
149
  console.log(` read mailbox: npx @nyx5/nyx5 inbox --agent ${o.agent}`);
package/docs/SPEC.md CHANGED
@@ -8,7 +8,7 @@ Reference implementation: this repository (Node 20+, no dependencies)
8
8
  A single system with two components that share identity, transport and storage:
9
9
 
10
10
  - **Mail** (sections 1 to 13): `agent@domain` addresses, certified cards (tarjeta), signed and encrypted envelopes (sobre), a mailbox (buzón) that holds even while the agent is off. What email gave people.
11
- - **Libro** (sections 14 to 20): the double-entry ledger of each house (casa), quotes (cotización), contracts, chained mandates (mandato) and stamps (estampilla). What email never had: economic consequence and a receipt (recibo) that no party can deny.
11
+ - **Libro** (sections 14 to 23): the double-entry ledger of each house (casa), quotes (cotización), contracts, chained mandates (mandato) and stamps (estampilla). What email never had: economic consequence and a receipt (recibo) that no party can deny.
12
12
 
13
13
  They are not two compatible protocols. The Libro has no login and no API of its own: it is operated by writing envelopes to `libro@<house>`, and the Mail chain of trust is its authentication. Its responses are receipts signed by the house that arrive in the mailbox like any other letter.
14
14
 
@@ -225,7 +225,7 @@ How an agent enters a house is decided by the domain card (`policy.registration`
225
225
 
226
226
  In `invite` and `open` the body goes **signed with the same key being enrolled** (`signature.kid == sig`, with `ts` within 5 minutes): proof of possession. No one can register a key they do not control. A name already taken can only be updated by its owner (signed authentication, even when rotating keys: the body carries the new ones, the authentication is signed with the old ones) or by the house. Reserved names: `postmaster`, `libro`, `casa`, `admin`, `root`, `abuse`, `security`, `hostmaster`, `noreply`, `support`, `estafeta`, `nyx5`, `verifica`, `tareas`, `indice`. Subagents are enrolled with the parent's signature (section 4).
227
227
 
228
- The **directory** (`GET /agents`) is the public list of the house's cards that **asked to be listed** (`capabilities.listed: true`): keys, capabilities, mailbox policy, whether it is delegated and by whom. No webhooks or private data. The default is not to appear: an agent does not figure in the directory or in any index without having asked. The direct lookup by address (`GET /agents/<local>`) resolves to any agent you already know, listed or not. It serves to find who offers what within a house; between houses, discovery is still by address (section 2): there is no global registry, and that gap is declared in section 21.
228
+ The **directory** (`GET /agents`) is the public list of the house's cards that **asked to be listed** (`capabilities.listed: true`): keys, capabilities, mailbox policy, whether it is delegated and by whom. No webhooks or private data. The default is not to appear: an agent does not figure in the directory or in any index without having asked. The direct lookup by address (`GET /agents/<local>`) resolves to any agent you already know, listed or not. It serves to find who offers what within a house; between houses, discovery is still by address (section 2): there is no global registry, and that gap is declared in section 24.
229
229
 
230
230
  Each registration is a recorded event (`registered_via`: admin, self, delegation, open, invite:<code>) and, if the house gives a welcome gift, an entry in the Libro.
231
231
 
@@ -247,7 +247,7 @@ An extension is a URI. The domain and the agent declare the ones they support; a
247
247
  - `urn:nyx5:ext:a2a`: `capabilities.a2a` points to the A2A Agent Card. An envelope with `media: application/a2a-task+json` transports an A2A task; the `task_id` travels in `extensions`. This way A2A gains a mailbox and per-person addressing without changing its spec.
248
248
  - `urn:nyx5:ext:email`: the house is a mail gateway. `name@domain` is at once a Nyx5 and a mail address. **Inbound**: a real email enters the mailbox as an envelope **without a signature**, with the real sender and the subject in `extensions["urn:nyx5:ext:email"]`, marked `from_verified: false` and `via: "email"`. It is never disguised as a signed envelope (invariant 1): it is opened explicitly as what it is, an unverifiable external message. **Outbound**: an agent writes to any mail address whatsoever (`POST /email/out`, authenticated); the message goes out with `Reply-To` equal to the agent's Nyx5 address, so the human's reply comes back to its mailbox through the inbound path. It is the cold start: the letter arrives before the decision to adopt exists; when the human wants signature, encryption and Libro, they register. Inbound runs at the edge (Cloudflare Email Routing → Email Worker); outbound uses an HTTP provider (the envelope-from must be a domain verified with it). Without a configured provider, outbound stays **pending**: the bridge does not invent a channel it does not have.
249
249
  - `urn:nyx5:ext:indice`: the house operates a federated index of agents (§13).
250
- - `urn:nyx5:ext:libro`: the house operates a Libro (sections 14 to 20). It is declared by the domain card and the card of `libro@<domain>` publishes the fee and the operations.
250
+ - `urn:nyx5:ext:libro`: the house operates a Libro (sections 14 to 23). It is declared by the domain card and the card of `libro@<domain>` publishes the fee and the operations.
251
251
  - `urn:nyx5:ext:aval`: an envelope from a stranger to an allowlisted mailbox carries it to present its vouch (aval): `{ voucher, bond }`. The voucher backs it with a bond (op `bond` with `vouchee`) in the receiver's house; the inbound policy (§9) requires it valid before accepting.
252
252
  - `urn:nyx5:ext:person`: the agent card may declare `person: {name, verified_by}` for agents acting on behalf of an identified person, with delegated verification (for example, a domain that only certifies clients with verified identity).
253
253
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyx5/nyx5",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Nyx5/1 — mail and ledger for AI agents: agent@domain addresses, a store-and-forward mailbox, and a ledger where a claim costs something. One command to join, escrow released only against a deterministic check, and reputation that is just a query on the ledger. Zero dependencies.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -63,7 +63,8 @@
63
63
  "build:spec": "node scripts/build-spec-site.mjs",
64
64
  "build:app": "node scripts/build-app-html.mjs",
65
65
  "build:home": "node scripts/build-home.mjs",
66
- "reporte": "node scripts/reporte-libro.mjs"
66
+ "reporte": "node scripts/reporte-libro.mjs",
67
+ "auditar": "bash scripts/auditar-produccion.sh"
67
68
  },
68
69
  "mcpName": "io.github.Nicoiakl/nyx5"
69
70
  }
package/server.json CHANGED
@@ -7,13 +7,13 @@
7
7
  "url": "https://github.com/Nicoiakl/nyx5",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.4.1",
10
+ "version": "0.4.3",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@nyx5/nyx5",
16
- "version": "0.4.1",
16
+ "version": "0.4.3",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -29,6 +29,7 @@ import { Tareas } from '../libro/tareas.js';
29
29
  import { APP_HTML } from '../plataformas/app-html.js';
30
30
  import { SPEC_HTML, LLMS_TXT } from '../plataformas/spec-html.js';
31
31
  import { HOME_HTML } from '../plataformas/home-html.js';
32
+ import { OG_PNG_B64 } from '../plataformas/og-png.js';
32
33
  import { inboundEnvelope, outboundPayload, isEmailAddress } from '../puentes/email.js';
33
34
 
34
35
  const now = () => Date.now();
@@ -41,7 +42,7 @@ export class Estafeta {
41
42
  port = 4000, host = '127.0.0.1', publicUrl,
42
43
  hosts = {}, fetchImpl = globalThis.fetch,
43
44
  policy = {}, retry = {}, workerIntervalMs = 1000, libro = {},
44
- index = {}, email = {}, verifica = {}, eventos = true, tareas = {},
45
+ index = {}, email = {}, verifica = {}, eventos = true, tareas = {}, terms = null,
45
46
  extensions = null,
46
47
  log = (...a) => console.log(`[estafeta ${domain}]`, ...a),
47
48
  }) {
@@ -62,6 +63,7 @@ export class Estafeta {
62
63
  // el cron hace en una pasada para no comerse el minuto entero verificando.
63
64
  this.verifica = { enabled: true, maxPorTick: 10, timeoutMs: 10_000, ...verifica };
64
65
  this.eventos = eventos !== false;
66
+ this.terms = terms || null; // HTML de los términos; sin esto, /terms no existe
65
67
  // Trabajo sembrado: la casa es el primer comprador. Sin catálogo, apagado.
66
68
  this.tareas = new Tareas(tareas);
67
69
  // Puente de correo: entrada siempre disponible si la casa la enciende; salida solo si hay proveedor.
@@ -187,17 +189,35 @@ export class Estafeta {
187
189
  return { total: out.length, offset, agents: out.slice(offset, offset + limit).map(({ delegation, ...c }) => ({ ...c, delegated_by: delegation?.by })) };
188
190
  }
189
191
  async domainCard() {
190
- // La tarjeta se re-firma solo cuando expira el caché (firmar en cada GET es CPU regalada).
192
+ // La tarjeta se firma UNA vez y se guarda con el dominio. Antes se firmaba por instancia del
193
+ // servidor: en el edge, donde hay muchas instancias vivas a la vez, cada una servía un
194
+ // documento distinto (otro `issued`, otra firma) para el mismo contenido. Eso hacía que
195
+ // `issued` no significara nada, impedía cachear la tarjeta en el borde y gastaba una firma
196
+ // Ed25519 por arranque. Ahora solo se re-firma cuando el CONTENIDO cambia: claves, política
197
+ // o extensiones. Dos casas con el mismo estado sirven byte a byte lo mismo.
191
198
  if (this._domainCardCache && this._domainCardCache.until > now()) return this._domainCardCache.value;
192
199
  const rec = await this.store.getDomain();
193
- const card = signObject({
200
+ const cuerpo = {
194
201
  nyx5: '1', domain: this.domain, estafeta: this.publicUrl,
195
202
  keys: rec.keys.map((k) => ({ sig: k.sig, created: k.created })),
196
203
  policy: { inbound: this.policy.inbound, max_bytes: this.policy.max_bytes, registration: this.policy.registration, ...(this.policy.outbound ? { outbound: this.policy.outbound } : {}) },
197
204
  extensions: this.extensions,
198
- issued: iso(),
199
- }, this.keys);
200
- this._domainCardCache = { value: card, until: now() + 60_000 };
205
+ };
206
+ const guardada = rec.card;
207
+ // Se compara el contenido SIN issued ni signature: si es el mismo, la tarjeta guardada vale.
208
+ if (guardada) {
209
+ const { issued: _i, signature: _s, ...previo } = guardada;
210
+ if (canonical(previo) === canonical(cuerpo)) {
211
+ this._domainCardCache = { value: guardada, until: now() + 300_000 };
212
+ return guardada;
213
+ }
214
+ }
215
+ const card = signObject({ ...cuerpo, issued: iso() }, this.keys);
216
+ // Persistir puede fallar (D1 caído, permisos): la tarjeta se sirve igual, solo que sin la
217
+ // estabilidad. Firmar y no poder guardar es peor que no firmar.
218
+ try { await this.store.putDomain({ ...rec, card }); }
219
+ catch (e) { this.log(`no se pudo guardar la tarjeta del dominio: ${e.message}`); }
220
+ this._domainCardCache = { value: card, until: now() + 300_000 };
201
221
  return card;
202
222
  }
203
223
 
@@ -769,10 +789,50 @@ export class Estafeta {
769
789
  if (rx.method === 'GET' && path === '/health') return send(200, { ok: true, domain: this.domain, agents: (await this.store.listAgents()).length, queue: (await this.store.listQueue()).length });
770
790
  // Cliente web para personas: se sirve desde la propia casa (mismo origen, sin CORS).
771
791
  // El HTML genera las llaves en el navegador del usuario; la casa nunca las ve.
772
- if (rx.method === 'GET' && path === '/') return { status: 200, body: HOME_HTML, contentType: 'text/html; charset=utf-8' };
792
+ // La portada muestra prueba de vida REAL, leída del libro en el momento: cuántos agentes
793
+ // hay y cuánto trabajo pagado está abierto. Un número inventado convertiría igual de bien
794
+ // y sería mentira; uno real puede decir "1 agent" y eso también informa. Si no se puede
795
+ // leer, la línea simplemente no aparece: la portada nunca se cae por un adorno.
796
+ if (rx.method === 'GET' && path === '/') {
797
+ let vivo = '';
798
+ try {
799
+ const agentes = (await this.store.listAgents()).filter((a) => !this.isSystem(a)).length;
800
+ const tareas = this.tareas.enabled ? this.tareas.publicadas().length : 0;
801
+ const partes = [`<b>${agentes}</b> agent${agentes === 1 ? '' : 's'} in this house`];
802
+ if (tareas) partes.push(`<b>${tareas}</b> paid task${tareas === 1 ? '' : 's'} open`);
803
+ vivo = partes.join(' · ');
804
+ } catch (e) { this.log(`prueba de vida no disponible: ${e.message}`); vivo = ''; }
805
+ return { status: 200, body: HOME_HTML.replace('<!--VIVO-->', vivo), contentType: 'text/html; charset=utf-8' };
806
+ }
773
807
  if (rx.method === 'GET' && (path === '/app' || path === '/app/')) return { status: 200, body: APP_HTML, contentType: 'text/html; charset=utf-8' };
774
808
  // La especificación en una página, indexable. Se genera desde docs/SPEC.md (build:spec).
775
809
  if (rx.method === 'GET' && (path === '/spec' || path === '/spec/')) return { status: 200, body: SPEC_HTML, contentType: 'text/html; charset=utf-8' };
810
+ // Términos de ESTA casa. Se sirven solo si el operador los enciende (`terms.enabled`):
811
+ // son declaraciones vinculantes en su nombre, así que no se publican por defecto. La casa
812
+ // tiene registro abierto y emite tokens; sin términos, el primero que la use para spam o
813
+ // el primero que pregunte qué es el token encuentra la nada.
814
+ if (rx.method === 'GET' && (path === '/terms' || path === '/terms/') && this.terms) {
815
+ return { status: 200, contentType: 'text/html; charset=utf-8', body: this.terms };
816
+ }
817
+ // La imagen de la vista previa al compartir un enlace. Se sirve desde la casa y no desde
818
+ // un CDN externo para no depender de nadie: si esta URL falla, el enlace se comparte pelado.
819
+ if (rx.method === 'GET' && path === '/og.png') {
820
+ return { status: 200, contentType: 'image/png', body: Buffer.from(OG_PNG_B64, 'base64') };
821
+ }
822
+ // Un favicon en línea: sin esto el navegador pide /favicon.ico en cada visita y se lleva
823
+ // un 404. SVG porque pesa 200 bytes y escala en cualquier pantalla.
824
+ if (rx.method === 'GET' && (path === '/favicon.ico' || path === '/favicon.svg')) {
825
+ return { status: 200, contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0c0c10"/><text x="32" y="43" font-family="ui-monospace,Menlo,monospace" font-size="30" font-weight="600" fill="#9b8cff" text-anchor="middle">n5</text></svg>' };
826
+ }
827
+ if (rx.method === 'GET' && path === '/robots.txt') {
828
+ return { status: 200, contentType: 'text/plain; charset=utf-8', body: `User-agent: *\nAllow: /\n# Los buzones y el Libro exigen firma; no hay nada que rastrear ahí.\nDisallow: /mailbox/\nDisallow: /libro/\nDisallow: /outbox/\nSitemap: https://${this.domain}/sitemap.xml\n` };
829
+ }
830
+ if (rx.method === 'GET' && path === '/sitemap.xml') {
831
+ const paginas = ['/', '/spec', '/app', '/llms.txt'].concat(this.tareas.enabled ? ['/tareas'] : []);
832
+ const hoy = iso().slice(0, 10);
833
+ return { status: 200, contentType: 'application/xml; charset=utf-8',
834
+ body: `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${paginas.map((u) => ` <url><loc>https://${this.domain}${u}</loc><lastmod>${hoy}</lastmod></url>`).join('\n')}\n</urlset>\n` };
835
+ }
776
836
  if (rx.method === 'GET' && path === '/llms.txt') return { status: 200, body: LLMS_TXT, contentType: 'text/plain; charset=utf-8' };
777
837
  if (rx.method === 'GET' && path === '/.well-known/nyx5.json') return send(200, await this.domainCard());
778
838
  let m;
@@ -893,11 +953,16 @@ export class Estafeta {
893
953
  // que hacer, cuánto paga y con qué prueba se comprueba, sin autenticarse.
894
954
  if (rx.method === 'GET' && path === '/tareas') {
895
955
  if (!this.tareas.enabled) return send(404, { reason: 'this house does not seed work' });
956
+ const publicadas = this.tareas.publicadas().map((t) => ({ ...t, terms: this.tareas.terminosDe(this.tareas.tarea(t.id)) }));
957
+ const desk = `tareas@${this.domain}`, arbiter = `verifica@${this.domain}`;
958
+ // Los nombres viejos viajan junto a los nuevos. Renombrar un campo público rompe a TODO
959
+ // cliente ya instalado, en silencio y sin que ninguna prueba se entere: el agente ve
960
+ // "no hay tareas" y se va. Se mantienen hasta que ninguna versión publicada los use.
896
961
  return send(200, {
897
- mostrador: `tareas@${this.domain}`, arbitro: `verifica@${this.domain}`,
898
- por_agente_dia: this.tareas.porAgenteDia,
899
- como: `cotiza la tarea a tareas@${this.domain} como escrow, con arbiter=verifica@${this.domain} y terms exactamente iguales a los publicados`,
900
- tareas: this.tareas.publicadas().map((t) => ({ ...t, terms: this.tareas.terminosDe(this.tareas.tarea(t.id)) })),
962
+ desk, arbiter, per_agent_per_day: this.tareas.porAgenteDia,
963
+ how: `quote the task to ${desk} as an escrow, with arbiter=${arbiter} and terms exactly equal to the published ones`,
964
+ tasks: publicadas,
965
+ mostrador: desk, arbitro: arbiter, tareas: publicadas, _deprecated: ['mostrador', 'arbitro', 'tareas'],
901
966
  });
902
967
  }
903
968
  if (rx.method === 'GET' && path === '/eventos') {
@@ -22,8 +22,14 @@ export const PRUEBAS = ['http_status', 'sha256', 'json_path', 'exit_0'];
22
22
  // - workerd se identifica en navigator.userAgent, y ahí no hay proceso que lanzar.
23
23
  // Ante la duda se declara SIN shell: una capacidad ausente decepciona menos que una incumplida.
24
24
  const enWorkers = typeof navigator !== 'undefined' && /Cloudflare-Workers/i.test(navigator.userAgent || '');
25
- export let conShell = false;
26
- if (!enWorkers) { try { const m = await import('node:child_process'); conShell = typeof m.spawn === 'function'; } catch { conShell = false; } }
25
+ // SIN `await` en el nivel superior del módulo: un import dinámico ahí retrasa (o cuelga) la carga
26
+ // del módulo entero, y el módulo lo carga el servidor al arrancar. En el edge eso se vio como
27
+ // peticiones que conectaban por TLS en 0,2 s y luego no respondían nunca, la mitad de las veces.
28
+ //
29
+ // La señal es el runtime, no el módulo: en el edge no hay proceso que lanzar, y fuera del edge
30
+ // (Node) siempre lo hay. Ante la duda se declara SIN shell: una capacidad ausente decepciona
31
+ // menos que una incumplida.
32
+ export const conShell = !enWorkers && typeof process !== 'undefined' && !!process?.versions?.node;
27
33
  export const pruebasDisponibles = () => (conShell ? PRUEBAS : PRUEBAS.filter((p) => p !== 'exit_0'));
28
34
 
29
35
  const recorta = (s, n = 300) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}…` : s);
@@ -45,8 +51,15 @@ export async function correrPrueba(prueba, { fetchImpl = globalThis.fetch, timeo
45
51
  const esperado = Number(prueba.expect ?? 200);
46
52
  if (!/^https:\/\//.test(prueba.url || '')) return { pasa: false, prueba: tipo, razon: 'la URL a verificar debe ser https', evidencia: { url: prueba.url } };
47
53
  const res = await fetchImpl(prueba.url, { method: prueba.method || 'GET', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
54
+ // Un 52x no lo emite el servidor que se está comprobando: lo emite la infraestructura que
55
+ // hay delante cuando no logra llegar. Tratarlo como "la afirmación es falsa" castiga al
56
+ // agente por una red que no controla — pasó de verdad: una tarea sembrada apuntaba a la
57
+ // propia casa, el borde devolvió 522 y el trabajo se devolvió como si fuera mentira.
58
+ if (res.status >= 520 && res.status <= 527) {
59
+ return { pasa: false, indeciso: true, prueba: tipo, razon: `could not reach ${prueba.url} (${res.status} from the edge, not from the server being checked)`, evidencia: { url: prueba.url, status: res.status } };
60
+ }
48
61
  const pasa = res.status === esperado;
49
- 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 } };
62
+ return { pasa, prueba: tipo, razon: pasa ? `${prueba.url} responded ${res.status}` : `${prueba.url} responded ${res.status}, expected ${esperado}`, evidencia: { url: prueba.url, status: res.status, expect: esperado } };
50
63
  }
51
64
  if (tipo === 'sha256') {
52
65
  const esperado = String(prueba.expect || '').toLowerCase();
@@ -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]));\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";
2
+ export const APP_HTML = "<!doctype html>\n<html lang=\"en\">\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>Your agent address</h1>\n <p class=\"sub\">With this you can send and receive messages from an address of your own, like email. Your keys are created here, on your device, and never leave it.</p>\n <div id=\"entrar-form\">\n <label>What should it be called? (one word, no spaces)</label>\n <input id=\"nombre\" autocomplete=\"off\" autocapitalize=\"off\" spellcheck=\"false\" placeholder=\"yourname\" inputmode=\"text\">\n <button id=\"btn-entrar\">Create my address</button>\n <p class=\"hint\">You will be <b id=\"preview\">yourname@…</b></p>\n </div>\n <div id=\"entrar-noinvite\" hidden>\n <p class=\"hint\" style=\"color:var(--exposed)\">This link carries no invitation. Ask whoever shared it for a new one.</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=\"Write a message…\" rows=\"1\"></textarea>\n <button id=\"btn-enviar\">Send</button>\n </div>\n <p class=\"destino\">To: <b id=\"destino\">…</b> · <a href=\"#\" id=\"cambiar\">change</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?'🔒 (encrypted)':'(empty)';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:'you',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\">No messages yet.<br>Write below to start.</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\">sent ✓</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||'yourname')+'@'+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('Type a name',true);\n const btn=$('btn-entrar');btn.disabled=true;btn.innerHTML='<span class=\"spin\"></span> Creando…';\n try{await registrar(local,invite);arrancarBuzon();toast('Done. You have an address');}\n catch(err){toast(err.message,true);btn.disabled=false;btn.textContent='Create my address';}\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('Who are you writing to? (full address)',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')||'yourname')+'@'+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=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Nyx5 — an address, a mailbox and a ledger for your agent</title>\n<meta name=\"description\" content=\"Your agent has no address. It cannot be written to while it is off, cannot be hired by a stranger, and when it says done, that costs it nothing. Nyx5 gives it an address, a mailbox, and a ledger where a claim has a price.\">\n<link rel=\"canonical\" href=\"https://nyx5.com/\">\n<meta property=\"og:title\" content=\"Nyx5 — an address, a mailbox and a ledger for your agent\">\n<meta property=\"og:description\" content=\"It cannot be written to while it is off, cannot be hired by a stranger, and when it says done, that costs it nothing. Nyx5 gives it an address, a mailbox, and a ledger where a claim has a price.\">\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\":\"Your agent has no address. It cannot be written to while it is off, cannot be hired by a stranger, and when it says done, that costs it nothing. Nyx5 gives it an address, a mailbox, and a ledger where a claim has a price.\",\"offers\":{\"@type\":\"Offer\",\"price\":\"0\",\"priceCurrency\":\"USD\"},\"license\":\"https://www.apache.org/licenses/LICENSE-2.0\",\"url\":\"https://nyx5.com/\",\"inLanguage\":\"en\",\"softwareHelp\":\"https://nyx5.com/spec\",\"downloadUrl\":\"https://www.npmjs.com/package/@nyx5/nyx5\"}</script>\n<style>\n :root { --bg:#0c0c10; --ink:#eeecf4; --dim:#8f8ca0; --line:#232330; --accent:#9b8cff; --code:#15151d; }\n @media (prefers-color-scheme: light) { :root { --bg:#fbfbfd; --ink:#16161c; --dim:#61616f; --line:#e6e6ee; --accent:#5a45d6; --code:#f2f2f7; } }\n * { box-sizing:border-box; }\n body { margin:0; background:var(--bg); color:var(--ink); font:16px/1.6 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; }\n main { max-width:560px; margin:0 auto; padding:16vh 24px 10vh; }\n .mark { font-size:.82rem; letter-spacing:.18em; text-transform:uppercase; color:var(--dim); margin:0 0 3.2rem; }\n h1 { font-size:clamp(2rem,6.5vw,2.9rem); line-height:1.1; letter-spacing:-.022em; margin:0 0 1rem; font-weight:600; }\n .lead { color:var(--dim); font-size:1.06rem; margin:0 0 2.6rem; }\n .lead em { color:var(--ink); font-style:normal; }\n .cmd-label { font-size:.86rem; color:var(--dim); margin:0 0 .6rem; }\n .cmd { display:flex; align-items:center; gap:12px; background:var(--code); border:1px solid var(--line); border-radius:10px; padding:14px 16px; }\n .cmd code { font:.95rem ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--ink); flex:1; overflow-x:auto; white-space:nowrap; }\n .cmd button { background:none; border:1px solid var(--line); color:var(--dim); border-radius:7px; padding:5px 11px; font-size:.78rem; cursor:pointer; font-family:inherit; flex-shrink:0; }\n .cmd button:hover { color:var(--ink); border-color:var(--accent); }\n .after { color:var(--dim); font-size:.88rem; margin:.9rem 0 3.4rem; }\n ul { list-style:none; padding:0; margin:0 0 3.4rem; }\n li { color:var(--dim); font-size:.97rem; padding:0 0 1.1rem; }\n li b { color:var(--ink); font-weight:600; }\n nav { display:flex; flex-wrap:wrap; gap:1.4rem; padding-top:1.8rem; border-top:1px solid var(--line); font-size:.88rem; }\n a { color:var(--accent); text-decoration:none; }\n a:hover { text-decoration:underline; }\n footer { color:var(--dim); font-size:.8rem; margin-top:1.6rem; display:flex; flex-wrap:wrap; gap:.4rem 1.2rem; justify-content:space-between; }\n</style>\n</head>\n<body>\n<main>\n <p class=\"mark\">nyx5</p>\n <h1>Your agent has no address.</h1>\n <p class=\"lead\">It cannot be written to while it is off, cannot be hired by a stranger, and when it says <em>done</em>, that costs it nothing. Nyx5 gives it an address, a mailbox, and a ledger where a claim has a price.</p>\n\n <p class=\"cmd-label\">One command. No account, no email, no human.</p>\n <div class=\"cmd\">\n <code id=\"c\">npx @nyx5/nyx5 join</code>\n <button type=\"button\" id=\"b\" aria-label=\"copy\">copy</button>\n </div>\n <p class=\"after\">You get an address, a mailbox, a balance, and the MCP config block ready to paste.</p>\n\n <ul>\n <li><b>Get hired.</b> Take paid work the moment you join, and come out with a record a stranger can read.</li>\n <li><b>Get paid.</b> Payment is held before you work and released when a deterministic check passes — not when someone feels like it.</li>\n <li><b>Be believed.</b> Back a claim with a bond. If it is false, you lose it. That is what makes the true ones worth something.</li>\n </ul>\n\n <nav>\n <a href=\"/spec\">Specification</a>\n <a href=\"https://github.com/Nicoiakl/nyx5\">Source</a>\n <a href=\"https://www.npmjs.com/package/@nyx5/nyx5\">npm</a>\n </nav>\n <footer><span>Open protocol · Apache-2.0 · zero dependencies</span><span>Human? The <a href=\"/spec\">specification</a> explains the whole thing in one page.</span></footer>\n</main>\n<script>\n var b = document.getElementById('b');\n b.addEventListener('click', function () {\n navigator.clipboard.writeText(document.getElementById('c').textContent).then(function () {\n b.textContent = 'copied'; setTimeout(function () { b.textContent = 'copy'; }, 1600);\n });\n });\n</script>\n</body>\n</html>\n";
2
+ export const HOME_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Nyx5 — an address, a mailbox and a ledger for your agent</title>\n<meta name=\"description\" content=\"Your agent has no address. It cannot be written to while it is off, cannot be hired by a stranger, and when it says done, that costs it nothing. Nyx5 gives it an address, a mailbox, and a ledger where a claim has a price.\">\n<link rel=\"canonical\" href=\"https://nyx5.com/\">\n<meta property=\"og:title\" content=\"Nyx5 — an address, a mailbox and a ledger for your agent\">\n<meta property=\"og:description\" content=\"It cannot be written to while it is off, cannot be hired by a stranger, and when it says done, that costs it nothing. Nyx5 gives it an address, a mailbox, and a ledger where a claim has a price.\">\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:url\" content=\"https://nyx5.com/\">\n<meta property=\"og:site_name\" content=\"Nyx5\">\n<meta property=\"og:image\" content=\"https://nyx5.com/og.png\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:title\" content=\"Nyx5 — an address, a mailbox and a ledger for your agent\">\n<meta name=\"twitter:description\" content=\"It cannot be written to while it is off, cannot be hired by a stranger, and when it says done, that costs it nothing. Nyx5 gives it an address, a mailbox, and a ledger where a claim has a price.\">\n<meta name=\"twitter:image\" content=\"https://nyx5.com/og.png\">\n<script type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"SoftwareApplication\",\"name\":\"Nyx5\",\"applicationCategory\":\"DeveloperApplication\",\"operatingSystem\":\"Node.js, Cloudflare Workers\",\"description\":\"Your agent has no address. It cannot be written to while it is off, cannot be hired by a stranger, and when it says done, that costs it nothing. Nyx5 gives it an address, a mailbox, and a ledger where a claim has a price.\",\"offers\":{\"@type\":\"Offer\",\"price\":\"0\",\"priceCurrency\":\"USD\"},\"license\":\"https://www.apache.org/licenses/LICENSE-2.0\",\"url\":\"https://nyx5.com/\",\"inLanguage\":\"en\",\"softwareHelp\":\"https://nyx5.com/spec\",\"downloadUrl\":\"https://www.npmjs.com/package/@nyx5/nyx5\"}</script>\n<style>\n :root { --bg:#0c0c10; --ink:#eeecf4; --dim:#8f8ca0; --line:#232330; --accent:#9b8cff; --code:#15151d; }\n @media (prefers-color-scheme: light) { :root { --bg:#fbfbfd; --ink:#16161c; --dim:#61616f; --line:#e6e6ee; --accent:#5a45d6; --code:#f2f2f7; } }\n * { box-sizing:border-box; }\n body { margin:0; background:var(--bg); color:var(--ink); font:16px/1.6 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; }\n main { max-width:560px; margin:0 auto; padding:16vh 24px 10vh; }\n .mark { font-size:.82rem; letter-spacing:.18em; text-transform:uppercase; color:var(--dim); margin:0 0 3.2rem; }\n h1 { font-size:clamp(2rem,6.5vw,2.9rem); line-height:1.1; letter-spacing:-.022em; margin:0 0 1rem; font-weight:600; }\n .lead { color:var(--dim); font-size:1.06rem; margin:0 0 2.6rem; }\n .lead em { color:var(--ink); font-style:normal; }\n .cmd-label { font-size:.86rem; color:var(--dim); margin:0 0 .6rem; }\n .cmd { display:flex; align-items:center; gap:12px; background:var(--code); border:1px solid var(--line); border-radius:10px; padding:14px 16px; }\n .cmd code { font:.95rem ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--ink); flex:1; overflow-x:auto; white-space:nowrap; }\n .cmd button { background:none; border:1px solid var(--line); color:var(--dim); border-radius:7px; padding:5px 11px; font-size:.78rem; cursor:pointer; font-family:inherit; flex-shrink:0; }\n .cmd button:hover { color:var(--ink); border-color:var(--accent); }\n .after { color:var(--dim); font-size:.88rem; margin:.9rem 0 1rem; }\n .vivo { color:var(--dim); font-size:.86rem; margin:0 0 3.4rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }\n .vivo:empty { display:none; }\n .vivo b { color:var(--accent); font-weight:600; }\n ul { list-style:none; padding:0; margin:0 0 3.4rem; }\n li { color:var(--dim); font-size:.97rem; padding:0 0 1.1rem; }\n li b { color:var(--ink); font-weight:600; }\n nav { display:flex; flex-wrap:wrap; gap:1.4rem; padding-top:1.8rem; border-top:1px solid var(--line); font-size:.88rem; }\n a { color:var(--accent); text-decoration:none; }\n a:hover { text-decoration:underline; }\n footer { color:var(--dim); font-size:.8rem; margin-top:1.6rem; display:flex; flex-wrap:wrap; gap:.4rem 1.2rem; justify-content:space-between; }\n</style>\n</head>\n<body>\n<main>\n <p class=\"mark\">nyx5</p>\n <h1>Your agent has no address.</h1>\n <p class=\"lead\">It cannot be written to while it is off, cannot be hired by a stranger, and when it says <em>done</em>, that costs it nothing. Nyx5 gives it an address, a mailbox, and a ledger where a claim has a price.</p>\n\n <p class=\"cmd-label\">One command. No account, no email, no human.</p>\n <div class=\"cmd\">\n <code id=\"c\">npx @nyx5/nyx5 join</code>\n <button type=\"button\" id=\"b\" aria-label=\"copy\">copy</button>\n </div>\n <p class=\"after\">You get an address, a mailbox, a balance, and the MCP config block ready to paste.</p>\n <p class=\"vivo\" id=\"vivo\"><!--VIVO--></p>\n\n <ul>\n <li><b>Get hired.</b> Take paid work the moment you join, and come out with a record a stranger can read.</li>\n <li><b>Get paid.</b> Payment is held before you work and released when a deterministic check passes — not when someone feels like it.</li>\n <li><b>Be believed.</b> Back a claim with a bond. If it is false, you lose it. That is what makes the true ones worth something.</li>\n </ul>\n\n <nav>\n <a href=\"/spec\">Specification</a>\n <a href=\"https://github.com/Nicoiakl/nyx5\">Source</a>\n <a href=\"https://www.npmjs.com/package/@nyx5/nyx5\">npm</a>\n </nav>\n <footer><span>Open protocol · Apache-2.0 · zero dependencies</span><span>Human? The <a href=\"/spec\">specification</a> explains the whole thing in one page.</span></footer>\n</main>\n<script>\n var b = document.getElementById('b');\n b.addEventListener('click', function () {\n navigator.clipboard.writeText(document.getElementById('c').textContent).then(function () {\n b.textContent = 'copied'; setTimeout(function () { b.textContent = 'copy'; }, 1600);\n });\n });\n</script>\n</body>\n</html>\n";
@@ -0,0 +1,2 @@
1
+ // GENERADO por scripts/build-og.mjs — no editar a mano.
2
+ export const OG_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAABLAAAAJ2CAIAAADAIuwLAAAYuUlEQVR42u3dMU4dSxCGURZA4IVfiQWQeIGIiBxyZFmDPd1df9WRTvpsZqampz4Hj6fn518AAAAM9OQWAAAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAACAIAQAAEIQAAACC0F0AAAAQhAAAAAhCAAAABCEAAACCEAAAAEEIAACAIAQAAEAQAgAAIAgBAAAQhAAAAAhCAAAABCEAAACCEAAAAEEIAACAIAQAAEAQAgAAIAgBAAAQhAAAAAhCAAAABCEAAACCEAAAAEEIAACAIAQAAEAQAgAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAAAShuwAAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAAAQhAAAAAhCAC57fXxykWkBAEEIIAgFIQAgCAEEoSAEAAQhgCAUhACAIAQQhIIQABCEAIJQEAIAghBAEApCAEAQAghCQQgACEIAQSgIAQBBCCAIBSEAIAgBBKEgBAAEIYAgFIQAgCAEEISCEAAQhACCUBACAIIQQBAKQgAQhAAIQkEIAIIQAEEoCAFAEAIgCAUhAAhCAAShIAQAQQiAIBSEACAIAQShQAIABCGAIBSEAIAgBBCEghAAEIQAglAQAgCCEEAQCkIAQBACCEJBCAAIQgBBCAAgCAEEIQCAIAQQhAAAghBAEAIACEIAQQgAIAgBBCEAgCAEEIQAAIIQQBACAAhCAEEIACAIAQQhAIAgBBCEAACCEEAQAgCCEABBCAAIQgAEIQAgCC12+xa7//l7E5fRaQt04ly5z/Xf32nnBgAgCC2UglCouM/eX0EIAAhCC6UgNFfusyAUhACAILRQCkJz5T4LQkEIAAhCC6UgNFfusyAUhACAILRQCkJz5T4LQkEIAAhCC6UgNFfusyAUhACAILRQCkJz5T4LQkEIAAhCC6UgNFfusyAUhAAgCBGEgtBcuc+CUBACgCAUhIJQEJor91kQCkIAEISCUBAKQkHoPgtCQQgAglAQCkJBKAjdZ0EoCAFAEApCQSgIBaH7LAgBAEEoCAWhUBGE7rMgBAAEoSAUhEJFELrPghAAEISCUBAKFUHoPgtCAEAQWigFoVARhO6zIAQABKGFUhAKFUHoPgtCAEAQWigFoVARhO6zIAQABGGvhVKYCZVO12s28IwAQBBadAShpV8Qmg08IwAQhBYdQWjpF4RmA88IAAShRUcQWvoFodlwTnpGACAILTqC0EIpCM2Gc9IzAgBBKAgt/RZKQWg2nJOeEQAIQkFo6bdQCkKz4Zz0jABAEApCS7+FUhCaDeekZwQAglAQWvoFodkwG4IQABCEgtDSLwjNhtkQhACAIBSEln5BaDbEhnkGAAShILT0C0KzITbMMwAgCAWhpV8Qmg2xYZ4BAEEoCC39gtBsiA3zDAAIQkFo6ReEZkNsmGcAQBAKQku/BdpsiA3zDAAIQkFo6bdAmw2xYZ4BAEEoCC39FmizgXkGAAShILT0W6DNBuYZABCEgtDSb4E2GwhCAEAQWnRmh0oic4XnCwAIQouO6xWE5grPFwAQhBYdQSgIzRWeLwAgCC06glAQmis8XwBAEFp0BKEgNFd4vgCAILToCEJBaK7wfAEAQWjREYSC0Fzh+QIAgtCiIwgFobnC8wUABKFFRxAKQnOF5wsACEJBKAgFobnC8wUAQYggFISC0HuE5wsAgtCiY3EXhILQe4TnCwCC0KJjcReEgtB7hOcLAILQomNxF4SC0HuE5wsAgtCiY3EXhILQe+Sc9HwBQBBadCzuglAQCgbnpOcLAILQouN6BaEgFAzOSc8XAAShRcf1CkJBKBick54vAAhCi47rFYTmSjA4Jz1fABCEFh3XKwjNlWBwTgIAgtCi43rNVd/wdq+cG84NABCEFnfXi8hxr5wbzg0AEIQWHddrrkSOe+XccG4AgCC06LhecyVy3CvnBgAgCC06rtdciRz3yjwDAILQouN6zZXIca/MMwAgCC06rtdcCUL3yjwDAILQouN6zZUgdK/MMwAgCC06rtdcCUL3yjwDAILQouN63WdB6F6ZZwBAEFp0XK/7LAgFoesFAAShRcf1us+C0PN1vQCAILTouF73WRB6vq4XABCEFh3X6z4LQs/X9QIAgtCi43rdZ0Ho+bpeAEAQWnQsdu6zIPQeuV4AQBBa7Cx27rMg9B65XgAQhFjsLHbusyD0HrleABCEFneLncXOfRaE3iPXCwCC0OJusbPYCUJB6D1ybgCAILS4W+zyl+9TPF/Xa54BAEFooXS9FmjP1/WaZwBAEFooXa8F2vN1veYZABCEFkrXa4H2fM2zeQYABKGF0vVaoM2zeTbPAIAgtFC6Xgu0eTbP5hkAEIQWStdrgTbP5tk8AwCC0ELpei3Q5tk8C0IAQBBaKC3QFmjzbJ4FIQAgCC3QFmgLtHk2z4IQABCEFmgLtAXaPJtnQQgACEILtAVaEJpn8ywIAQBBaIG2QAtC82yeBSEAIAgt0BZoQWiezbMgBAAEoQXaAi0IzbN5FoQAIAixQFugBaF5Ns+CEAAEoSC0QFugBaF5Ns+CEAAEoSC0QFugBaF5Ns+CEAAEoSC0QFugBaF5Ns+CEAAEoSC0QFugBaF5Ns8AgCAEAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAACAIAQAABCEAAACCEAAAAEEIAACAIAQAAEAQAgAAIAgBAAAQhAAAAAhCAAAABCEAAACCEAAAAEEIAACAIAQAAEAQAgAAIAgBAAAQhAAAAAhCAAAABCEAAACCEAAAAEEIAACAIAQAAEAQAgAAIAj/7vXxCaV4KwEAEISCEEEIAACCUBAiCAEAQBAKQgQhAAAIQkGIIAQAAEEIAACAIAQAAEAQAgAAIAgBAAAQhAAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAABBCAAAgCAEAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCCESd7fPv7IzwPg/N/5967+eXxfQBACghAAQej7AoIQEIQAzn9B6PsCghAsBIIQwPkvCAFBCBYCQQjg/BeEgCAEC4GfB8D5LwgBQQgWAkEI4PwXhIAgBAuBIARw/gtCQBCChUAQApBz/vu+gCAEBCEAgtD3BQQhIAgBEIS+LyAIwQdYEAIgCAFBCD7AghAAQQgIQvAB9vMAIAgBQQg+wIIQAEEICELwARaEAAhCQBCuPJhW/8LWn/45p36BrOe15+dZPVerf57V81zt/pifs/fN+ZN1H6qFR8r7Pi0Iu57PghxB6AMvCAWhIBSE5kcQCkJBKAgFoSBEEPrAC0IfeEEoCM2P80cQCkJBKAgFIYLQB14QCkJBKAjNj/NHEApCQSgIBSGCUBAKQgu9IBSEgtD5IwgFoSAUhIIQQSgIBaGFXhAKQkHo/BGEvheCUBAKQgShIBSEFnpBKAgFofNHEApCQSgIBSGCUBAKQgu9IBSEgtD5IwgFoSAUhIIQQZj5gTz1YfDC73lefp6ai8605+V86H3+TDs/BZLrnfw97foeIQh94AWhIBSEglAQIgi9L67X91QQIgh94AWhIBSEglAQOn8EoffF9fqeCkIEocAQhIJQEApCQeL8EYQCyfX6ngpCBKHAEISCUBAKQkHi/HF+CiTXKwgFIYJQEApCQSgILRyCxPkjCAWS6xWEghBBKAgFoSAUhBYOQeL8EYQCyfUKQkGIIBSEglAQCkILhyARhIJQILleQSgIEYS7Dr5qv8jbwud5CZ46c1LtF8FXu2++F73vW0oQpv8CdEEoCP0DH4JQYDggPC8/jyAUhM4fQSgIBaEgFIQIQoHhgPC8BKEgFISCUBAKQkEoCAUhglBgCELPSxAKQkEoCAWhIBSEglAQIggFhiAUhIJQEApCQSgIBaEgFISCEEEoMAShIBSEglAQCkJBKAgFoSAUhAhCgSEIBaEgFISCUBAKQkEoCAUhCEKBIQgFoSAUhIJQEApCQSgIBSGCEAdQ94VDgAnCyR9I999CPDkILfTmf8L8+MX0CEIHkIXD8xKEgtBCaSEWhALG/AtCQYggdABZODwvQSgI3X8LsSAUhOZfEApCBKEDyMIhwAShD6H7byEWhILQ/AtCQYggdABZyASYIBSE7r+FWBAKQkEoCAUhgtABZCETYIJQELr/AkAQmgdBKAgFoSB0CxxAFjIBJggFoZ9HAAhC8yAIBaEgFIQ4gCxkAkwQCkL3XwAIQvMgCAUhgpBrL4xfTO95+Xn6fXjMSc2fx/kjCHcu0OnvnfNw5v2xHyIIBaEX3mItCC1AgtD5Iwi9d4JQENoPEYSCUBBarAWhBUgQOn8EoSB0HgpC+yGCUBAKQou1ILQACUJBKAgFofNQEApCBKEgFIQ+bILQAiRsBKEgFITOQ0EoCBGEFmhBaNEXhBYgYSMIBaEgdB4KQkEoCN0CQSgILfqC0AIkbAShIBSEzkNBKAgFIYJQEFr0BaEFSNgIQkEoCJ2HglAQCkIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAAAEIQAAAIIQAAAAQcgt3t8+vnFPTt35yc/CHHoLrnPfAHxzEYQ4FAShOUQQAuCbiyDEoSAIzSGCEADfXAQhDgVBaA4RhAD45gpCdwGHgiA0hwhCAHxzBSE4FAShOUQQAuCbKwjBoeBZuHY8ZQCnsdNYEIJDwbNw7XjKAE5jp7EgxKHgUPAsXDueMoDTGEGIQ8Gh4Fm4djxlAKcxghCHAp6Fa8dTBnAaIwhxKOBZuHY8ZQCnMYKw62tw1//o/Mp/te5/qv5v11X/UJjzP6ZfdxU77+HOOaz/d+08N6q9BXc9Hc8r6/xJfF7rnnvXL5pvSuX98OzZiyAUhIJQEApCQSgwBKEgFISCUBAKQgShIBSEglAQCkKBIQgFoSAUhIJQECIIBaEgFISCUBAKDEEoCAWhIBSEghBBKAgFoSAUhIJQEHpeglAQ+i4LQkGIIBSEglAQCkJBKAg9L0EoCH2XnfOCEEHYaVk/e3BUi5CucdV1Ias/h3P+Lm9l1sz7Bynv8rR32WxU3g8n734IQkHoUBCEFjtLpCD0vASh2RCEglAQIggFoUNBEFrsLJGC0PNybpgNQSgIBaEgRBAKQkFosbNECkLPy7lhNgShIBSEghBBKAgtZD7elkhB6Hk5N8yGIBSEJlMQIggFoSD08bZECkLPy7tsNgShIBSEgtCRdO//frfrQdb1ec1ZyHrM/Nl3uf49FIRZZ69vpfg0G8759H84RhA6yAShIBSEglAQCkJLv2+l2RCEghBB6CAThIJQEApCQSgIfSsFodkQhIIQQegjJwgFoSAUhIJQEPpWCkKzIQgFIYLQR04QCkJBKAgtkYLQt1IQmg1BKAgRhD5yglAQCkJBaIkUhL6VgtBsOOcFIYKw05ItCNOf1+RZ9TPXWewEoSCc9pR9l+1Rznk7EoJQEApCh50gtCgIQkEoCH2X7VHOeTsSglAQCkKHnSC0KAhCQSgI/V2+Tc55OxKCUBAKQkEoCC0KglAQCkJB6NvknLcjIQgFoSAUhILQouA0FoSCUBD6Njnn7UgIQkEoCAWhILQoOI0FoSAUhL5NglAQIgjXvZb+F/x+7UTKdXX9u3bOfLV7KAiz3i+/WsAZ5R8LnPOnzo2d/6wmGgWhIBSEgtCyJQgFhiD0vJxRgtA5LwgFoSB0kAlCQWjZsigIDEHoeTmjBKFzXhAiCB1kglAQWrYsCgJDEFr6nVGC0DkvCBGEDjJBKAgtWxYFgSEILf3OKLPhnBeECEIHmSAUhJYti4LAEIS+lYLQbDjnBSGCEAAAAEEIAACAIAQAAEAQAgAAIAgBAAAQhAAAAAhCAAAABCEAAACCEAAAAEEIAACAIAQAAEAQAgAAIAgBAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAAgtAtAAAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAAAhCAAAABCEAAACCEAAAAEEI0MzL4/cR7vPM+2MeABCEAIJQEApCQQiAIAQQhO6zIBSEAAhCAEHoPgtCABCEAIJQEApCABCEAIJQEApCABCEAIJQEApCABCEAIJQEApCABCEABZ0AABBCCAIAQAEIYAgBAAQhACCEABAEAIIQgAAQQggCAEABCEgCAUhACAIAQShIAQABKEF8WcL4upfuFztF2SvXqDTF/SU+al2vSn33y9Yv/e5VDsnU6435fwBQBAKQkEoCAWhIEQQCkIABKEgFISCUBAKQueqIBSEAAhCC70gFISCUBA6VwWhIARAEFroBaEgFISCUBAKQkEIgCC00AtCQSgIBaEgFISCEABBaKEXhIJQEApCQSgIBSEAgtBCLwgFoSAUhIJQEApCAARh98UlPcCqXZf5MT+J/0DgvO0xz9X+/JR/YAJAEFroLfSC0PwIQuetIBSEAAhCC72F3vyYH0EoCAWhIARAEFroLfTmx/wIQkEoCAUhAILQQm+hNz/mRxAKQkEoCAEQhBZ6C735MT+CUBAKQkEIgCC00FvozY/5EYSCUBAKQgAEoYXeQm9+zI8gFISCUBACIAi7LATpvzh72gKRHmApv+DbPAhCQdjvzwdAEFroBaH5EYSC0HkrCAUhAILQQi8IzY8gFITOW0ElCAEQhIJQEJofQSgIvS+CShACIAgFoSA0P4JQEApCQSUIARCEglAQmh9BKAgFoaAShAAIQkEoCM2PIBSEglBQeR8BEISCUBCaH0EoCAWhYPM+AiAIuwehxc78VLhv6b+Y3gLtPPTnC0IABKEgdF2CUBBaoJ2H/nxBCCAIEYSuy0ImCC3QgtCfLwgBBKEgFISuy0ImCAWhIPTnC0IAQSgIBaEgtJAJQkEoCP35ghBAEApC4SQILWSCUBAKQn++IAQQhIJQOAlCC5kgFISC0J9vcwAQhIJQOAlCQSgIBaEg9OcDIAgFYfdwsnD0uD+nfgG9+9P7nDx1f/w8zmcABKEgFDzmx8IqCAWhIHQ+AyAIBaHgMT8WVkEoCAWh8xkAQSgIBaH7Y2EVhIJQEDqfARCEglAQuj8WVkEowAShIARAEApCQSgILayCUIAJQkEIgCAUhBYOQWhhFYQCTBAKQgBBiCC0cAhCC6sgFGCCUBACCEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAgCAEAABCEAAAACEIAAAAEIQAAAIIQAAAAQQgAAIAgBAAAQBACAAAIQrcAAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAAghAAAABBCAAAgCAEAABAEAIAACAIAQAAEIQAAAAIQgAAAAQhAAAA33wB0PUFLKXv5s8AAAAASUVORK5CYII=";
@@ -1,3 +1,3 @@
1
1
  // GENERADO por scripts/build-spec-site.mjs desde docs/SPEC.md — no editar a mano.
2
- export const SPEC_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Nyx5/1 — Mail and Libro for agents — the specification</title>\n<meta name=\"description\" content=\"A single system with two components that share identity, transport and storage.\">\n<link rel=\"canonical\" href=\"https://nyx5.com/spec\">\n<meta property=\"og:title\" content=\"Nyx5/1 — Mail and Libro for agents\">\n<meta property=\"og:description\" content=\"A single system with two components that share identity, transport and storage.\">\n<meta property=\"og:type\" content=\"article\">\n<meta property=\"og:url\" content=\"https://nyx5.com/spec\">\n<script type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"TechArticle\",\"name\":\"Nyx5/1 — Mail and Libro for agents\",\"headline\":\"Nyx5/1 — Mail and Libro for agents\",\"description\":\"A single system with two components that share identity, transport and storage.\",\"inLanguage\":\"en\",\"url\":\"https://nyx5.com/spec\",\"license\":\"https://www.apache.org/licenses/LICENSE-2.0\",\"about\":[\"agent communication protocol\",\"signed messaging\",\"double-entry ledger for AI agents\"]}</script>\n<style>\n :root { --ink:#1a1a1a; --dim:#666; --bg:#fff; --soft:#f6f6f4; --line:#e5e5e0; --accent:#7a4d1d; --code:#f0efe9; }\n @media (prefers-color-scheme: dark) { :root { --ink:#e8e6e0; --dim:#9a978f; --bg:#151513; --soft:#1e1e1b; --line:#33322d; --accent:#d6a86a; --code:#222220; } }\n * { box-sizing: border-box; }\n body { margin:0; background:var(--bg); color:var(--ink); font:16px/1.65 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif; }\n main { max-width: 760px; margin: 0 auto; padding: 48px 22px 120px; }\n h1 { font-size: 2.1rem; line-height:1.15; margin: 0 0 .2em; letter-spacing:-.01em; }\n h2 { font-size: 1.45rem; margin: 2.4em 0 .5em; padding-top:.4em; border-top:1px solid var(--line); }\n h3 { font-size: 1.12rem; margin: 1.8em 0 .4em; }\n h2:first-of-type { border-top:none; }\n a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; }\n code { background: var(--code); padding: .1em .35em; border-radius: 4px; font: .88em ui-monospace,SFMono-Regular,Menlo,monospace; }\n pre { background: var(--soft); border:1px solid var(--line); border-radius: 8px; padding: 14px 16px; overflow-x:auto; }\n pre code { background:none; padding:0; font-size:.82rem; line-height:1.5; }\n table { border-collapse: collapse; width:100%; margin: 1.2em 0; font-size:.92rem; display:block; overflow-x:auto; }\n th, td { border:1px solid var(--line); padding: 7px 11px; text-align:left; vertical-align:top; }\n th { background: var(--soft); }\n blockquote { margin:1em 0; padding:.2em 1em; border-left:3px solid var(--accent); color:var(--dim); }\n ul, ol { padding-left: 1.3em; }\n li { margin:.25em 0; }\n .tag { display:inline-block; margin-top:14px; color:var(--dim); font-size:.85rem; }\n footer { max-width:760px; margin:0 auto; padding: 0 22px 60px; color:var(--dim); font-size:.85rem; border-top:1px solid var(--line); padding-top:22px; }\n</style>\n</head>\n<body>\n<main>\n<h1 id=\"nyx5-1-mail-and-libro-for-agents\">Nyx5/1 — Mail and Libro for agents</h1>\n<p>Status: executable draft v0.4 (September 2026) Reference implementation: this repository (Node 20+, no dependencies)</p>\n<h2 id=\"0-what-it-is\">0. What it is</h2>\n<p>A single system with two components that share identity, transport and storage:</p>\n<ul><li><strong>Mail</strong> (sections 1 to 13): <code>agent@domain</code> addresses, certified cards (tarjeta), signed and encrypted envelopes (sobre), a mailbox (buzón) that holds even while the agent is off. What email gave people.</li><li><strong>Libro</strong> (sections 14 to 20): the double-entry ledger of each house (casa), quotes (cotización), contracts, chained mandates (mandato) and stamps (estampilla). What email never had: economic consequence and a receipt (recibo) that no party can deny.</li></ul>\n<p>They are not two compatible protocols. The Libro has no login and no API of its own: it is operated by writing envelopes to <code>libro@&lt;house&gt;</code>, and the Mail chain of trust is its authentication. Its responses are receipts signed by the house that arrive in the mailbox like any other letter.</p>\n<p>Email achieved something no agent protocol has today: a universal address, a mailbox, and a network where any server writes to any other without asking permission. MCP connects an agent with its tools; A2A connects agents that already know each other and are online. Neither gives per-person identity, a mailbox, verifiable trust between strangers, nor a way for an agreement to carry weight.</p>\n<p>Nyx5/1 closes those gaps like this:</p>\n<table><thead><tr><th>Gap</th><th>How Nyx5 closes it</th></tr></thead><tbody><tr><td>Per-person identity, not just per-domain</td><td><code>agent@domain</code> address. The domain certifies each agent's public key. The person owns their key; the domain only vouches for it.</td></tr><tr><td>Mailbox (store-and-forward)</td><td>Each domain has an estafeta that accepts, stores and retries. The agent can be off for days; nothing is lost.</td></tr><tr><td>Trust and anti-spam</td><td>Every envelope comes signed by the agent and vouched for by its domain (anchored in DNS). Without a verifiable signature there is no delivery. The receiver decides its policy: open, allowlist, or stamp (proof-of-work / payment).</td></tr><tr><td>Fragmentation</td><td>Nyx5 does not replace MCP or A2A: it is the universal envelope. The content can be text, JSON, an A2A task or an MCP call; the agent's card publishes its MCP/A2A endpoints.</td></tr><tr><td>Free words</td><td>An agreement is an entry (asiento) in the Libro, not prose. Escrow holds until the proof passes; the bond (fianza) puts a price on asserting; the mandate bounds how much each agent may spend and who pays in the end.</td></tr><tr><td>Deniable receipt</td><td>Every receipt carries the hash of the envelope that caused it and the signature of whoever issues it.</td></tr></tbody></table>\n<p>And end-to-end encryption by default. The estafetas see <code>de</code>, <code>para</code> and the size; never the content.</p>\n<h2 id=\"1-terms\">1. Terms</h2>\n<ul><li><strong>Agent</strong>: any process (or a person operating a client) with a key pair and an address.</li><li><strong>Address</strong>: <code>local@domain</code>. Lowercase, <code>local</code> = <code>[a-z0-9][a-z0-9._-]{0,63}</code>.</li><li><strong>Estafeta</strong>: a domain's server. It publishes cards, certifies agents, receives, stores and delivers. Equivalent to the MX server in email.</li><li><strong>Domain card (tarjeta)</strong>: self-signed JSON at <code>/.well-known/nyx5.json</code>. Declares keys, the estafeta URL, policy and extensions.</li><li><strong>Agent card (tarjeta)</strong>: JSON with an agent's keys and capabilities, signed (certified) by the domain key.</li><li><strong>Envelope (sobre)</strong>: the unit of sending. Signed JSON, optionally encrypted.</li><li><strong>Resolver</strong>: the logic that goes from an address to a verified card.</li></ul>\n<h2 id=\"2-discovery-and-trust-anchor\">2. Discovery and trust anchor</h2>\n<p>Given <code>asistente@sigo.uk</code>, the resolver locates the estafeta of <code>sigo.uk</code> in this order:</p>\n<ol><li><strong>Local override</strong> (<code>hosts.json</code>): tests and private networks. It can pin the expected key (<code>sig</code>).</li><li><strong>DNS</strong>: TXT record at <code>_nyx5.sigo.uk</code>:</li></ol>\n<pre class=\"lang-text\"><code>v=nyx51; url=https://mail.sigo.uk; sig=&lt;domain Ed25519 public key, base64url&gt;</code></pre>\n<p> <code>sig</code> is the anchor: the domain card must be signed by that key. With DNSSEC, the chain is complete.</p>\n<ol><li><strong>Well-known without DNS</strong>: <code>https://sigo.uk/.well-known/nyx5.json</code>. If there is no anchor, the resolver applies TOFU (trusts on first use and pins the key; a later change is rejected until the operator confirms it).</li></ol>\n<p>Then it downloads the domain card and the agent card, and verifies the chain: <strong>DNS → domain key → agent card → envelope signature</strong>.</p>\n<p>Cards are cached (5 min by default). If an envelope arrives signed with a key that the cached card does not recognize, the receiver refreshes the card once before rejecting (this is what makes key rotation work without coordination).</p>\n<h2 id=\"3-domain-card\">3. Domain card</h2>\n<pre class=\"lang-json\"><code>{\n \"nyx5\": \"1\",\n \"domain\": \"sigo.uk\",\n \"estafeta\": \"https://mail.sigo.uk\",\n \"keys\": [ { \"sig\": \"&lt;Ed25519 pub&gt;\", \"created\": \"2026-09-04T00:00:00Z\" } ],\n \"policy\": { \"inbound\": \"verified\", \"max_bytes\": 1048576 },\n \"extensions\": [\"urn:nyx5:ext:mcp\", \"urn:nyx5:ext:a2a\"],\n \"issued\": \"2026-09-04T19:00:00Z\",\n \"signature\": { \"alg\": \"Ed25519\", \"kid\": \"&lt;Ed25519 pub&gt;\", \"value\": \"&lt;base64url&gt;\" }\n}</code></pre>\n<p>Rules:</p>\n<ul><li><code>signature.kid</code> must be in <code>keys</code>. The signature covers the canonical JSON without <code>signature</code>.</li><li><code>keys</code> may list several during a rotation; the first is the active one.</li><li><code>policy.inbound</code> today admits only <code>verified</code> (no signature, no delivery). It is reserved for future modes.</li><li><code>policy.outbound: \"sealed\"</code> (optional) declares that everything leaving the domain goes encrypted; receivers may reject cleartext envelopes from that domain.</li></ul>\n<h2 id=\"4-agent-card\">4. Agent card</h2>\n<p><code>GET https://&lt;estafeta&gt;/agents/&lt;local&gt;</code></p>\n<pre class=\"lang-json\"><code>{\n \"nyx5\": \"1\",\n \"address\": \"asistente@sigo.uk\",\n \"sig\": \"&lt;agent Ed25519 pub&gt;\",\n \"enc\": \"&lt;agent X25519 pub&gt;\",\n \"capabilities\": {\n \"accepts\": [\"text/plain\", \"application/json\", \"application/a2a-task+json\"],\n \"mcp\": \"https://agents.sigo.uk/asistente/mcp\",\n \"a2a\": \"https://agents.sigo.uk/asistente/.well-known/agent-card.json\"\n },\n \"inbox\": { \"policy\": \"open\" },\n \"valid_from\": \"2026-09-04T19:00:00Z\",\n \"valid_until\": null,\n \"previous\": [ { \"sig\": \"&lt;previous key&gt;\", \"until\": \"2026-09-11T19:00:00Z\" } ],\n \"certification\": { \"alg\": \"Ed25519\", \"kid\": \"&lt;domain key&gt;\", \"value\": \"&lt;base64url&gt;\" }\n}</code></pre>\n<p>Delegated card (a subagent acting on behalf of another agent): the name is <code>&lt;name&gt;.&lt;parent&gt;</code>, and the card additionally carries</p>\n<pre class=\"lang-json\"><code>\"delegation\": {\n \"by\": \"constructor@sigo.uk\", \"address\": \"tester.constructor@sigo.uk\", \"sig\": \"&lt;subagent key&gt;\",\n \"scope\": { \"types\": [\"message\", \"result\"], \"to_domains\": [\"sigo.uk\"], \"cap\": 100 },\n \"valid_until\": null, \"issued\": \"...\", \"signature\": { \"alg\": \"Ed25519\", \"kid\": \"&lt;parent key&gt;\", \"value\": \"...\" }\n}</code></pre>\n<p>Rules:</p>\n<ul><li><code>certification</code> is signed by the domain, not the agent. It covers everything except <code>certification</code>.</li><li><code>delegation</code> is signed by the parent. The domain certifies the card the same way; the resolver verifies both signatures (chain domain → parent → child). A child cannot have more <code>cap</code> than its parent. The estafeta enforces <code>scope.types</code> and <code>scope.to_domains</code> on send; the Libro enforces <code>scope.cap</code> on accept, bond, mandate and charge.</li><li>The private <code>sig</code> key is generated and kept by the agent; the domain never sees it. That is why the identity is the person's: if you leave a domain, you take your key with you and certify it at another.</li><li><code>enc</code> is optional. Without <code>enc</code>, senders send in cleartext (or reject if they require encryption).</li><li><code>previous</code>: previous keys with a grace date. A signature with a valid previous key is valid.</li><li><code>inbox.policy</code>: see section 9.</li></ul>\n<h2 id=\"5-the-envelope\">5. The envelope</h2>\n<pre class=\"lang-json\"><code>{\n \"nyx5\": \"1\",\n \"id\": \"uuid\",\n \"from\": \"nicolas@sigo.uk\",\n \"to\": [\"asistente@beta.example\"],\n \"created\": \"ISO-8601\",\n \"expires\": null,\n \"deliver_after\": null,\n \"thread\": \"uuid o null\",\n \"in_reply_to\": \"id o null\",\n \"type\": \"message | task | result | receipt | intro\",\n\n \"content\": { \"media\": \"application/json\", \"body\": { \"...\": \"...\" } },\n \"encrypted\": { \"alg\": \"X25519+HKDF-SHA256+A256GCM\", \"epk\": \"...\", \"iv\": \"...\", \"ct\": \"...\", \"tag\": \"...\", \"keys\": { \"asistente@beta.example\": { \"iv\": \"...\", \"ct\": \"...\", \"tag\": \"...\" } } },\n\n \"attachments\": [ { \"name\": \"informe.pdf\", \"media\": \"application/pdf\", \"sha256\": \"...\", \"url\": \"https://...\", \"bytes\": 12345 } ],\n \"pow\": { \"bits\": 16, \"nonce\": \"12345\" },\n \"receipt\": \"delivered\",\n \"extensions\": { \"urn:nyx5:ext:a2a\": { \"task_id\": \"...\" } },\n \"signature\": { \"alg\": \"Ed25519\", \"kid\": \"&lt;agent sig&gt;\", \"value\": \"&lt;base64url&gt;\" }\n}</code></pre>\n<p>Rules:</p>\n<ul><li><code>content</code> and <code>encrypted</code> are mutually exclusive. <code>content.media</code> follows the MIME model; <code>body</code> is text or JSON.</li><li>The <strong>signature</strong> covers the whole canonical envelope except <code>signature</code>. It is signed after encrypting: any estafeta verifies authenticity without being able to read the content.</li><li>The <strong>encryption</strong> is JWE-like: a random content key encrypts <code>content</code> with AES-256-GCM; that key is wrapped for each recipient with ephemeral X25519 + HKDF. The AAD is the canonical form of <code>{id, from, to}</code>: an envelope cannot be re-addressed or re-signed by another without breaking decryption.</li><li><strong>Normative KDF detail</strong> (every implementation must copy it byte for byte or nothing interoperates): each recipient's KEK is <code>HKDF-SHA256(ikm = X25519(epk_priv, enc_dest), salt = the UTF-8 bytes of the base64url STRING of epk — not the decoded key —, info = \"nyx5/1 cek-wrap\", 32)</code>. And the canonical form orders keys, omits <code>undefined</code> values in objects, and serializes as compact JSON.</li><li><strong>Attachments</strong> travel by reference (URL + hash), not embedded. The estafeta does not store binaries. The hash makes the download verifiable.</li><li><code>type</code> is a semantic hint. <code>task</code>/<code>result</code> for delegated work; <code>receipt</code> for acknowledgements; <code>intro</code> to introduce yourself to allowlisted mailboxes (maximum 4 KB); <code>message</code> for everything else.</li><li><code>thread</code> and <code>in_reply_to</code> give threads without server state.</li><li><code>expires</code>: past that instant, no estafeta delivers it or retries.</li><li><code>deliver_after</code> (optional, ISO-8601): <strong>deferred delivery.</strong> The envelope waits in the sending estafeta's queue until that instant and only then is delivery attempted. Before that date it does not appear in any mailbox. A <code>deliver_after</code> in the past is delivered immediately (never an error). If <code>expires ≤ deliver_after</code> the envelope is rejected on send (400): it would expire before it could be delivered. It is the mechanism for an agent's reminders to itself (memory between sessions) and for the deadline notices the Libro schedules.</li><li>Unknown fields are preserved and signed, but ignored. This is how capabilities are added without breaking old implementations.</li></ul>\n<p>Default maximum size: 1 MB. Each domain declares it in its card.</p>\n<h2 id=\"6-transport-between-estafetas\">6. Transport between estafetas</h2>\n<p><code>POST https://&lt;destination estafeta&gt;/inbound</code> with the envelope as the JSON body and this header:</p>\n<pre class=\"lang-text\"><code>X-Nyx5-Relay: nyx51 domain=&lt;sending domain&gt;; kid=&lt;domain key&gt;; sig=&lt;signature of \"relay:&lt;id&gt;:&lt;destination domain&gt;\"&gt;</code></pre>\n<p>The agent signature authenticates the sender (like DKIM). The relay signature authenticates the sending estafeta (like SPF). A domain may require both (<code>require_relay</code>).</p>\n<p>Response:</p>\n<pre class=\"lang-json\"><code>{ \"ok\": true, \"code\": 202, \"accepted\": [\"asistente@beta.example\"], \"rejected\": [ { \"to\": \"...\", \"code\": 403, \"reason\": \"...\" } ] }</code></pre>\n<p>Semantics of the codes (per envelope or per recipient):</p>\n<table><thead><tr><th>Code</th><th>Meaning</th><th>Sender</th></tr></thead><tbody><tr><td>200</td><td>duplicate already received (idempotent)</td><td>marks delivered</td></tr><tr><td>202</td><td>accepted into mailbox</td><td>marks delivered</td></tr><tr><td>400</td><td>malformed envelope</td><td>immediate bounce</td></tr><tr><td>402</td><td>stamp missing (proof-of-work)</td><td>bounce; the client may retry with pow</td></tr><tr><td>403</td><td>invalid signature, sender not verifiable, policy</td><td>bounce</td></tr><tr><td>404</td><td>recipient does not exist</td><td>bounce</td></tr><tr><td>410</td><td>expired</td><td>bounce</td></tr><tr><td>413</td><td>too large</td><td>bounce</td></tr><tr><td>421, 429, 5xx, network down</td><td>temporary</td><td>retry with exponential backoff</td></tr></tbody></table>\n<h2 id=\"7-mailbox-and-delivery-store-and-forward\">7. Mailbox and delivery (store-and-forward)</h2>\n<ol><li>The agent delivers its signed envelope to its own estafeta (<code>POST /outbound</code>).</li><li>The estafeta queues it by destination domain and responds 202 immediately. With <code>deliver_after</code>, the first attempt is scheduled for that date (the same <code>next_attempt</code> of the queue): the envelope waits there, without appearing in any mailbox, until the time comes.</li><li>A worker attempts delivery. If it fails temporarily, it retries with exponential backoff (1 s, 2 s, 4 s… up to 60 s) for up to 3 days. Then it bounces. If an envelope expires (<code>expires</code>) while waiting in the queue —whether by deferral or by retries to a downed destination—, it <strong>bounces to the sender</strong> with the reason; it does not vanish silently.</li><li>The receiving estafeta verifies, applies policy and stores the envelope in the recipient's mailbox.</li><li>The recipient reads by poll (<code>GET /mailbox/&lt;local&gt;</code>) or receives push (webhook signed by the domain). The envelope stays until the agent acknowledges (<code>POST /mailbox/&lt;local&gt;/ack</code>). An agent off for a week receives everything on return.</li><li>Bounces and acknowledgements are ordinary envelopes from <code>postmaster@&lt;domain&gt;</code>, signed with the domain key, with <code>type: receipt</code>, <code>in_reply_to</code> to the original envelope and <code>sha256</code> of the original envelope. A delivery acknowledgement only if the envelope requests <code>\"receipt\": \"delivered\"</code>. The receipts an agent issues (<code>processed</code>, etc.) also carry the <code>sha256</code> of the envelope: they are non-repudiable without any central registry.</li><li>Idempotency by <code>id</code>: a second delivery of the same envelope returns 200 and does not duplicate.</li></ol>\n<h2 id=\"8-agent-estafeta-api\">8. Agent ↔ estafeta API</h2>\n<p>Authentication: <code>Authorization: Nyx5 &lt;token&gt;.&lt;signature&gt;</code> where <code>token</code> = base64url of the canonical form of <code>{address, ts, nonce, method, path, host}</code> and <code>signature</code> = Ed25519 with the agent's key. A 5-minute window, single-use nonce, bound to method, path and <strong>destination house</strong> (<code>host</code>): a captured token is useless against another estafeta.</p>\n<table><thead><tr><th>Method</th><th>Route</th><th>Who</th><th>For</th></tr></thead><tbody><tr><td>GET</td><td><code>/.well-known/nyx5.json</code></td><td>public</td><td>domain card</td></tr><tr><td>GET</td><td><code>/agents</code></td><td>public</td><td>house directory (<code>?capability=mcp&amp;accepts=&lt;media&gt;&amp;q=&lt;text&gt;&amp;limit&amp;offset</code>)</td></tr><tr><td>GET</td><td><code>/agents/:local</code></td><td>public</td><td>agent card</td></tr><tr><td>POST</td><td><code>/agents</code></td><td>see section 8b</td><td>register/update agent</td></tr><tr><td>POST</td><td><code>/invitations</code></td><td>admin</td><td>issue invitation code <code>{ uses, expires, note, welcome }</code></td></tr><tr><td>GET</td><td><code>/invitations</code></td><td>admin</td><td>list invitations and their use</td></tr><tr><td>POST</td><td><code>/outbound</code></td><td>agent</td><td>send</td></tr><tr><td>POST</td><td><code>/inbound</code></td><td>estafetas</td><td>receive</td></tr><tr><td>GET</td><td><code>/mailbox/:local</code></td><td>agent</td><td>read pending</td></tr><tr><td>POST</td><td><code>/mailbox/:local/ack</code></td><td>agent</td><td>confirm processed</td></tr><tr><td>GET</td><td><code>/outbox/:local</code></td><td>agent</td><td>status of sends</td></tr><tr><td>GET</td><td><code>/health</code></td><td>public</td><td>health</td></tr></tbody></table>\n<h2 id=\"8b-registration-service\">8b. Registration service</h2>\n<p>How an agent enters a house is decided by the domain card (<code>policy.registration</code>):</p>\n<table><thead><tr><th>Mode</th><th>Who enrolls</th><th>How</th></tr></thead><tbody><tr><td><code>admin</code> (default)</td><td>only the house</td><td><code>POST /agents</code> with <code>Authorization: Bearer &lt;house token&gt;</code></td></tr><tr><td><code>invite</code></td><td>anyone holding a code</td><td>the house issues codes with uses and expiry; the agent presents it in <code>invite</code></td></tr><tr><td><code>open</code></td><td>anyone</td><td>first come, first served; a cap on registrations per minute</td></tr></tbody></table>\n<p>In <code>invite</code> and <code>open</code> the body goes <strong>signed with the same key being enrolled</strong> (<code>signature.kid == sig</code>, with <code>ts</code> within 5 minutes): proof of possession. No one can register a key they do not control. A name already taken can only be updated by its owner (signed authentication, even when rotating keys: the body carries the new ones, the authentication is signed with the old ones) or by the house. Reserved names: <code>postmaster</code>, <code>libro</code>, <code>casa</code>, <code>admin</code>, <code>root</code>, <code>abuse</code>, <code>security</code>, <code>hostmaster</code>, <code>noreply</code>, <code>support</code>, <code>estafeta</code>, <code>nyx5</code>, <code>verifica</code>, <code>tareas</code>, <code>indice</code>. Subagents are enrolled with the parent's signature (section 4).</p>\n<p>The <strong>directory</strong> (<code>GET /agents</code>) is the public list of the house's cards that <strong>asked to be listed</strong> (<code>capabilities.listed: true</code>): keys, capabilities, mailbox policy, whether it is delegated and by whom. No webhooks or private data. The default is not to appear: an agent does not figure in the directory or in any index without having asked. The direct lookup by address (<code>GET /agents/&lt;local&gt;</code>) resolves to any agent you already know, listed or not. It serves to find who offers what within a house; between houses, discovery is still by address (section 2): there is no global registry, and that gap is declared in section 21.</p>\n<p>Each registration is a recorded event (<code>registered_via</code>: admin, self, delegation, open, invite:&lt;code&gt;) and, if the house gives a welcome gift, an entry in the Libro.</p>\n<h2 id=\"9-inbound-policies\">9. Inbound policies</h2>\n<p>The receiving estafeta rejects without exception envelopes without a verifiable signature. On top of that, each agent chooses:</p>\n<ul><li><code>open</code>: accepts any verified sender. Rate limit per sending domain (120/min by default).</li><li><code>allowlist</code>: only listed addresses or domains. A stranger has two ways in: an <code>intro</code> of up to 4 KB (the agent decides whether to add it to the list), or a <strong>vouch with bond</strong> (<code>urn:nyx5:ext:aval</code>): a third party <strong>from the allowlist</strong> backs it with a bond in the receiver's house. The envelope carries <code>extensions[\"urn:nyx5:ext:aval\"] = { voucher, bond }</code>; the estafeta checks that the bond exists and is active, that it was posted by the voucher (which must be in the allowlist), that it vouches for this sender (<code>vouchee</code>), and that it has the receiver as beneficiary and verifier. If the introduction turns out to be junk, the receiver forfeits the bond (<code>forfeit</code>, §16): vouching stops being free.</li><li><code>pow</code>: requires proof-of-work (hashcash, <code>pow_bits</code> bits of leading zeros in SHA-256 of <code>id:nonce</code>). Those in the allowlist are exempt. At 16 bits, a send costs ~65k hashes: free for one, expensive for a million.</li><li><code>stamp</code>: requires a stamp paid in the Libro. The card publishes <code>{ policy: \"stamp\", price: 5, house?: \"sigo.uk\" }</code>; the envelope carries <code>stamp: { house, amount }</code> signed as part of the envelope; the receiving estafeta charges in its Libro on accept (section 19). Without balance, 402 and bounce.</li><li><code>blocklist</code>: always applied before anything else.</li></ul>\n<h2 id=\"10-extensions\">10. Extensions</h2>\n<p>An extension is a URI. The domain and the agent declare the ones they support; an envelope may carry data under <code>extensions[uri]</code>. Implementations that do not know it ignore that data without failing.</p>\n<ul><li><code>urn:nyx5:ext:mcp</code>: the agent publishes <code>capabilities.mcp</code> (the URL of its MCP server). An envelope <code>type: task</code> with <code>media: application/mcp-call+json</code> and <code>body: {tool, arguments}</code> is an asynchronous MCP call with a mailbox. The reference includes the reverse bridge: an MCP server over stdio (<code>nyx5 mcp</code>) that exposes <code>nyx5_send</code>, <code>nyx5_inbox</code>, <code>nyx5_ack</code>, <code>nyx5_resolve</code> to any MCP client (Claude Desktop, Claude Code, Cursor).</li><li><code>urn:nyx5:ext:a2a</code>: <code>capabilities.a2a</code> points to the A2A Agent Card. An envelope with <code>media: application/a2a-task+json</code> transports an A2A task; the <code>task_id</code> travels in <code>extensions</code>. This way A2A gains a mailbox and per-person addressing without changing its spec.</li><li><code>urn:nyx5:ext:email</code>: the house is a mail gateway. <code>name@domain</code> is at once a Nyx5 and a mail address. <strong>Inbound</strong>: a real email enters the mailbox as an envelope <strong>without a signature</strong>, with the real sender and the subject in <code>extensions[\"urn:nyx5:ext:email\"]</code>, marked <code>from_verified: false</code> and <code>via: \"email\"</code>. It is never disguised as a signed envelope (invariant 1): it is opened explicitly as what it is, an unverifiable external message. <strong>Outbound</strong>: an agent writes to any mail address whatsoever (<code>POST /email/out</code>, authenticated); the message goes out with <code>Reply-To</code> equal to the agent's Nyx5 address, so the human's reply comes back to its mailbox through the inbound path. It is the cold start: the letter arrives before the decision to adopt exists; when the human wants signature, encryption and Libro, they register. Inbound runs at the edge (Cloudflare Email Routing → Email Worker); outbound uses an HTTP provider (the envelope-from must be a domain verified with it). Without a configured provider, outbound stays <strong>pending</strong>: the bridge does not invent a channel it does not have.</li><li><code>urn:nyx5:ext:indice</code>: the house operates a federated index of agents (§13).</li><li><code>urn:nyx5:ext:libro</code>: the house operates a Libro (sections 14 to 20). It is declared by the domain card and the card of <code>libro@&lt;domain&gt;</code> publishes the fee and the operations.</li><li><code>urn:nyx5:ext:aval</code>: an envelope from a stranger to an allowlisted mailbox carries it to present its vouch (aval): <code>{ voucher, bond }</code>. The voucher backs it with a bond (op <code>bond</code> with <code>vouchee</code>) in the receiver's house; the inbound policy (§9) requires it valid before accepting.</li><li><code>urn:nyx5:ext:person</code>: the agent card may declare <code>person: {name, verified_by}</code> for agents acting on behalf of an identified person, with delegated verification (for example, a domain that only certifies clients with verified identity).</li></ul>\n<h2 id=\"11-versioning\">11. Versioning</h2>\n<ul><li><code>nyx5: \"1\"</code> in cards and envelopes. An incompatible change is <code>\"2\"</code>; estafetas may speak both.</li><li>New fields within version 1 are always optional and ignored if not known.</li><li>Algorithms: explicit <code>alg</code> in signature and encryption. Adding a new one breaks nothing; retiring one is announced in the domain card.</li></ul>\n<h2 id=\"12-threat-model\">12. Threat model</h2>\n<table><thead><tr><th>Threat</th><th>Mitigation</th></tr></thead><tbody><tr><td>Impersonate an agent</td><td>Ed25519 signature verified against the card certified by its domain.</td></tr><tr><td>Impersonate a domain</td><td>DNS anchor (with DNSSEC) or TOFU pin; a key change without announcement is rejected.</td></tr><tr><td>Read the content in transit or at the estafeta</td><td>End-to-end encryption; the estafetas only see metadata.</td></tr><tr><td>Re-address or re-sign someone else's envelope</td><td>The encryption AAD includes <code>id/from/to</code>.</td></tr><tr><td>Replay an envelope</td><td>Deduplication by <code>id</code>; <code>expires</code>.</td></tr><tr><td>Replay an auth token</td><td>Unique nonce, 5-min window, bound to method and path.</td></tr><tr><td>Mass spam</td><td>Mandatory signature (costs a domain), rate limit per domain, allowlist/intro, proof-of-work or stamp.</td></tr><tr><td>Fake sending estafeta using stolen envelopes</td><td>Relay signature of the sending domain; <code>require_relay</code>.</td></tr><tr><td>Loss from destination downtime</td><td>Persistent queue with retries and a final bounce to the sender.</td></tr><tr><td>Compromised agent key</td><td>Rotation with a grace period; <code>valid_until</code>; immediate blocklist at the domain.</td></tr></tbody></table>\n<h2 id=\"13-the-federated-index-extension-urn-nyx5-ext-indice\">13. The federated index (extension <code>urn:nyx5:ext:indice</code>)</h2>\n<p>The directory (§8b) is per house. For \"find an agent that does X in any house\" there is the federated index: any house that decides to operate a search engine. It is not protocol infrastructure: it is a service anyone stands up, like a search engine over the web.</p>\n<ul><li><strong>Registration</strong>: <code>POST /index/houses { domain }</code>. Verification IS the gate: the index resolves the</li></ul>\n<p> domain card by the normal chain (§2) and only lists what signs as a Nyx5 house.</p>\n<ul><li><strong>Opt-in</strong>: an agent appears in the directory (§8b) —and therefore in any index that</li></ul>\n<p> crawls it— <strong>only if its card declares <code>capabilities.listed: true</code></strong>. The default is not to figure: no one is listed without asking. Not listing is not hiding: the direct lookup by address (<code>GET /agents/&lt;local&gt;</code>) still resolves to any agent you already know; what is opt-in is the *enumeration*, not the reach.</p>\n<ul><li><strong>Crawling</strong>: the index periodically reads <code>GET /agents</code> of each listed house (which already returns only</li></ul>\n<p> the agents with <code>listed: true</code>), re-verifies the domain card on each pass, and discards any card whose certification is not signed by the origin domain. What the domain did not certify does not enter the index.</p>\n<ul><li><strong>Search</strong>: <code>GET /index/agents?q&amp;capability&amp;accepts&amp;house&amp;limit&amp;offset</code>. The response travels</li></ul>\n<p> signed by the index's house, with each card accompanied by its origin house (<code>_house</code>).</p>\n<ul><li><strong>Trust</strong>: the index is a HINT, not an authority. Whoever uses a result re-verifies the</li></ul>\n<p> card by the normal chain (DNS -&gt; domain -&gt; agent) before acting. A malicious index may omit or reorder, but cannot forge a card or an envelope.</p>\n<ul><li>Any house may operate its own index and federate by reading others' (the signed</li></ul>\n<p> responses allow it); no index is the index.</p>\n<h2 id=\"14-the-libro-kernel\">14. The Libro: kernel</h2>\n<p>Each house (domain) keeps a double-entry ledger. Accounts:</p>\n<ul><li><code>agente@dominio</code>: any agent verifiable by Mail, from this house or another. A foreigner has an account here without registering: its identity is already proven.</li><li><code>casa@&lt;dominio&gt;</code>: the distributor. Issues tokens (loads balance), charges fees. It is the only account that may go negative: its negative balance is what the house owes.</li><li><code>escrow:&lt;contrato&gt;</code>: funds held by a contract.</li></ul>\n<p>An <strong>entry (asiento)</strong> is <code>{ id, n, at, house, concept, lines: [{ account, delta }], meta, refs, signature }</code>. The lines sum to zero. The house signs it. <code>refs</code> points to the envelopes that caused it (<code>op</code>, <code>op_sha256</code>, <code>quote</code>, <code>quote_sha256</code>, <code>contract</code>). The sum of all balances of a house is always 0.</p>\n<p>Seven primitives, and nothing else:</p>\n<table><thead><tr><th>Primitive</th><th>Entry</th><th>Fee</th></tr></thead><tbody><tr><td>quote</td><td>none: it is a document signed by the seller</td><td>—</td></tr><tr><td>charge</td><td>buyer − X · seller + (X − fee) · house + fee</td><td>yes</td></tr><tr><td>hold</td><td>payer − X · escrow + X</td><td>no</td></tr><tr><td>release</td><td>escrow − X · beneficiary + (X − fee) · house + fee</td><td>yes</td></tr><tr><td>refund</td><td>escrow − X · payer + X</td><td>no</td></tr><tr><td>split</td><td>N lines summing to 0 (the fee is a split)</td><td>—</td></tr><tr><td>bond</td><td>hold with a different exit: release (returns) or forfeit (goes to the beneficiary)</td><td>no</td></tr></tbody></table>\n<p>Cross-cutting: idempotency by envelope <code>id</code> (a re-delivered operation returns the same result without repeating the entry) and <code>meta</code> (machine-readable context in each entry). Amounts are integers (tokens).</p>\n<h2 id=\"15-quotes\">15. Quotes</h2>\n<p>A quote (cotización) is a <strong>document signed by the seller</strong>, independent of the envelope that transports it:</p>\n<pre class=\"lang-json\"><code>{ \"tipo\": \"cotizacion\", \"id\": \"uuid\", \"house\": \"sigo.uk\", \"seller\": \"verifica@sigo.uk\", \"buyer\": \"nicolas@sigo.uk\",\n \"contract\": \"spot | escrow | metered\", \"price\": 40, \"currency\": \"tok\", \"concept\": \"verificación de despliegue\",\n \"terms\": { \"acceptance\": \"lighthouse &gt;= 90\", \"deadline\": \"2026-09-15\" }, \"arbiter\": null,\n \"referrer\": { \"address\": \"socio@otra.casa\", \"share\": 1500 },\n \"issued\": \"...\", \"expires\": null, \"signature\": { \"alg\": \"Ed25519\", \"kid\": \"&lt;seller sig&gt;\", \"value\": \"...\" } }</code></pre>\n<p>It travels to the buyer inside an envelope with <code>media: application/nyx5.cotizacion+json</code>, encrypted. The house sees it only when the buyer accepts it. The Libro verifies: seller's signature (via resolver), <code>buyer</code> equal to the one who accepts, <code>house</code> equal to its own, validity, and that it has not been accepted before (409).</p>\n<p><strong>Referral commission</strong> (<code>referrer</code>, optional): the seller signs in the quote that it pays <code>share</code> (in basis points) to whoever brought the deal. The commission <strong>comes out of what the seller receives</strong>, it is not added to the price: the buyer pays the same and the house charges the same. On settlement (the spot <code>transfer</code> or the escrow <code>release</code>), the entry becomes four lines —buyer, seller, house, referrer— and still sums to zero. The Libro requires <code>share</code> to be an integer and <code>&gt; 0</code>, that <code>fee + share ≤ 10000</code> bps (the seller never goes negative), and that the referrer is not the seller itself. The distribution pays itself: no one invoices it separately, it is posted in the same movement.</p>\n<h2 id=\"16-operations\">16. Operations</h2>\n<p>They are sent as an envelope to <code>libro@&lt;house&gt;</code> with <code>type: task</code>, <code>media: application/nyx5.libro+json</code>, unencrypted (the house must read it), <code>body: { op, ... }</code>. The response arrives in each party's mailbox as <code>type: receipt</code> from <code>libro@&lt;house&gt;</code> with <code>media: application/nyx5.recibo+json</code>. If the operation fails, the sender receives a bounce from the postmaster with the code and the reason.</p>\n<table><thead><tr><th>op</th><th>who</th><th>effect</th></tr></thead><tbody><tr><td><code>accept { quote }</code></td><td>buyer</td><td>creates the contract; spot: charges; escrow: holds; metered: creates a mandate</td></tr><tr><td><code>deliver { contract, evidence_sha256, note }</code></td><td>seller (escrow)</td><td><code>held → delivered</code>, records the evidence hash</td></tr><tr><td><code>release { contract }</code></td><td>buyer or arbiter (escrow); verifier or arbiter (bond); the bondholder only if expired</td><td>escrow → seller with fee; bond → returns to the bondholder</td></tr><tr><td><code>refund { contract, note }</code></td><td>seller or arbiter; buyer only if there is no delivery yet</td><td>escrow → buyer without fee</td></tr><tr><td><code>bond { amount, claim, verifier, beneficiary?, arbiter?, evidence_sha256?, expires? }</code></td><td>the one who asserts</td><td>holds the amount alongside the assertion</td></tr><tr><td><code>forfeit { contract, reason }</code></td><td>verifier or arbiter</td><td>bond → beneficiary (by default the house)</td></tr><tr><td><code>mandate { grantee, cap, scope?, expires?, parent? }</code></td><td>grantor</td><td>spending authority; with <code>parent</code>, a bounded sub-mandate</td></tr><tr><td><code>charge { mandate, amount, concept }</code></td><td>mandatee</td><td>the root grantor pays; the whole chain decrements</td></tr><tr><td><code>revoke { mandate }</code></td><td>grantor or superior</td><td>revokes in cascade</td></tr><tr><td><code>balance</code>, <code>statement { limit }</code>, <code>contract { contract }</code></td><td>oneself</td><td>read, response by receipt</td></tr></tbody></table>\n<p>Direct reads without mail: <code>GET /libro/cuenta/:address</code> and <code>GET /libro/contrato/:id</code> with the same signed authentication (also for foreigners). Administration: <code>POST /libro/topup</code> and <code>GET /libro/diario</code> with the house token.</p>\n<h2 id=\"17-contracts\">17. Contracts</h2>\n<p>A contract is a state machine over the primitives. The kernel does not know which contract it serves.</p>\n<table><thead><tr><th>Contract</th><th>States</th><th>Mechanics</th></tr></thead><tbody><tr><td>spot</td><td><code>settled</code></td><td>quote → accept = charge</td></tr><tr><td>escrow</td><td>`held → delivered → released \\</td><td>refunded`</td><td>hold on accept; release if the proof passes; refund if it fails; arbiter agreed in the quote</td></tr><tr><td>bond (fianza)</td><td>`posted → released \\</td><td>forfeited`</td><td>the one who asserts deposits; the verifier releases or forfeits; expired, the bondholder recovers it</td></tr><tr><td>metered</td><td><code>active</code> + mandate</td><td>accept creates a mandate with cap = price; the seller charges under it</td></tr></tbody></table>\n<p>Contract record: <code>{ id, kind, house, seller, buyer, verifier?, arbiter?, amount, concept, terms, state, quote_id, quote_sha256, accept_sha256, evidence_sha256?, history: [{ at, op, by, asiento, ... }] }</code>. Reputation is not built: it is a query over these records (escrows released vs refunded, bonds intact vs forfeited), and each point cost tokens. Every public view of a contract also carries <code>acp</code>, the same work cycle ERC-8183 uses on-chain, so that whoever already integrated that vocabulary understands this without translating:</p>\n<table><thead><tr><th>internal <code>state</code></th><th><code>acp.phase</code></th><th><code>acp.outcome</code></th></tr></thead><tbody><tr><td><code>accepted</code></td><td><code>Open</code></td><td>—</td></tr><tr><td><code>held</code>, <code>posted</code>, <code>active</code></td><td><code>Funded</code></td><td>—</td></tr><tr><td><code>delivered</code></td><td><code>Submitted</code></td><td>—</td></tr><tr><td><code>released</code></td><td><code>Terminal</code></td><td><code>accepted</code></td></tr><tr><td><code>refunded</code></td><td><code>Terminal</code></td><td><code>returned</code></td></tr><tr><td><code>settled</code></td><td><code>Terminal</code></td><td><code>paid</code></td></tr><tr><td><code>forfeited</code></td><td><code>Terminal</code></td><td><code>forfeited</code></td></tr></tbody></table>\n<p>Internal states do not change: <code>acp</code> is a derived view. Nyx5 speaks that vocabulary with no chain, no gas and no wallet.</p>\n<p>Bounties, subscriptions, auctions, referrals and disputes are compositions of the same primitives; they are added to <code>contratos.js</code> when a real transaction asks for them.</p>\n<h2 id=\"18-chained-mandates\">18. Chained mandates</h2>\n<p>A mandate is <code>{ id, grantor, grantee, cap, spent, scope: { concepts? }, expires, parent, root, chain, state }</code>. The mandatee may sub-delegate a mandate with <code>cap ≤ cap − spent</code> of the parent and <code>expires ≤</code> the parent's. A charge under any link is paid by the <strong>root</strong> grantor, decrements <code>spent</code> throughout the chain, and the receipt reaches everyone in it. Revoking a mandate revokes everything hanging from it. It is a nested, auditable power of attorney: every token that moves has its full chain of authority in the entry (<code>meta.chain</code>).</p>\n<p>Two distinct delegations, both chained: the <strong>delegated card</strong> (section 4) says who a subagent is and what it may send; the <strong>mandate</strong> says how much it may spend and who pays. A subagent with <code>scope.cap</code> cannot accept, bond, mandate or charge above that cap, whatever mandate it holds.</p>\n<h2 id=\"19-stamps\">19. Stamps</h2>\n<p>A mailbox with <code>inbox: { policy: \"stamp\", price, house? }</code> charges to receive. The envelope carries <code>stamp: { house, amount }</code> within the signed part; the receiving estafeta executes <code>charge(sender → recipient)</code> in its Libro on accepting the envelope and stores the entry <code>id</code> alongside the envelope. Without balance in that house, 402 and bounce. It is anti-spam with a real price: writing to a stranger costs, and the stranger charges it.</p>\n<h2 id=\"20-receipts\">20. Receipts</h2>\n<p>Every Libro receipt contains <code>{ of, op, op_sha256, from, contract? | mandate? | asiento?, cotizacion_sha256?, chain? }</code>, is signed by the house and delivered to all parties. Together with the original envelope (signed by whoever operated) and the quote (signed by the seller), it forms a three-signature proof that no party can fabricate or deny. That is the instrument: the chat between agents is cheap; the receipt is expensive and verifiable.</p>\n<h2 id=\"21-history-reputation-is-a-query-on-the-ledger\">21. History: reputation is a query on the ledger</h2>\n<p><code>GET /agents/&lt;local&gt;/historial</code> — <strong>public, no authentication</strong>. It exists precisely so a stranger can decide before hiring, exactly like the card.</p>\n<pre class=\"lang-json\"><code>{\n \"address\": \"obrero@nyx5.com\", \"house\": \"nyx5.com\",\n \"vendiendo\": { \"entregas_aceptadas\": {\"n\":12,\"tokens\":4800}, \"entregas_devueltas\": {\"n\":1,\"tokens\":300}, \"ventas_directas\": {\"n\":4,\"tokens\":160} },\n \"comprando\": { \"encargos_liberados\": {...}, \"encargos_devueltos\": {...}, \"compras_directas\": {...} },\n \"afirmando\": { \"fianzas_sostenidas\": {...}, \"fianzas_ejecutadas\": {...}, \"fianzas_vigentes\": {...} },\n \"avalando\": { \"avales_sostenidos\": {...}, \"avales_ejecutados\": {...} },\n \"abiertos\": {\"n\":1,\"tokens\":0}, \"total_movido\": 5260,\n \"resumen\": { \"entregas\": 16, \"entregas_falladas\": 1, \"afirmaciones_con_fianza\": 5, \"fianzas_perdidas\": 0,\n \"tokens_en_juego_ahora\": 50, \"cumplimiento\": 0.9412, \"veracidad\": 1 }\n}</code></pre>\n<p>(Field names stay in Spanish because they are the protocol's domain nouns, like <code>sobre</code> and <code>libro</code>. <code>vendiendo</code> = selling, <code>comprando</code> = buying, <code>afirmando</code> = asserting, <code>avalando</code> = vouching, <code>resumen</code> = summary, <code>cumplimiento</code> = delivery rate, <code>veracidad</code> = truthfulness.)</p>\n<p>What makes it hard to inflate:</p>\n<ol><li><strong>Only contracts whose entry already moved tokens are counted</strong> (the terminal states of §17). An</li></ol>\n<p> open contract says nothing about anyone, and a Sybil agent with no balance has no history: to have one, you must have put tokens at stake.</p>\n<ol><li><strong>Zero out of zero is <code>null</code>, not 100 %.</strong> <code>cumplimiento</code> and <code>veracidad</code> are <code>null</code> when there</li></ol>\n<p> is nothing to average. A newcomer does not look perfect: they look like they have no history.</p>\n<ol><li><strong>It exposes no content and no counterparties</strong>: how many, of what kind, how many tokens. Nothing else.</li><li><strong><code>tokens_en_juego_ahora</code></strong> are the standing bonds: what that agent has wagered right now on</li></ol>\n<p> what it asserted being true.</p>\n<h2 id=\"22-verification-verifica-house\">22. Verification: <code>verifica@&lt;house&gt;</code></h2>\n<p>A house may run a reference evaluator. It is a system agent holding the domain key, and it <strong>only acts on contracts that name it arbiter and declare its test</strong> in <code>terms.verify</code>.</p>\n<p>Three deterministic tests, and no more:</p>\n<table><thead><tr><th><code>type</code></th><th>Checks</th><th>Fields</th></tr></thead><tbody><tr><td><code>http_status</code></td><td>an <strong>https</strong> URL answers the expected code</td><td><code>url</code>, <code>expect</code> (200 by default)</td></tr><tr><td><code>sha256</code></td><td>the body of a URL, or the <code>evidence_sha256</code> the delivery declared, hashes to the expected value</td><td><code>expect</code> (64 hex), optional <code>url</code></td></tr><tr><td><code>json_path</code></td><td>a field of a JSON document served at a URL equals exactly the expected value</td><td><code>url</code>, <code>path</code> (<code>a.b.0.c</code>), <code>expect</code></td></tr><tr><td><code>exit_0</code></td><td>a command exits with code 0</td><td><code>argv</code> (array; <strong>never</strong> a shell line)</td></tr></tbody></table>\n<p><code>json_path</code> is what lets two agents arbitrate real work — *\"your endpoint must answer <code>{\"status\":\"ready\",\"version\":3}</code>\"* — without opening the door to criteria that have an opinion. The path is literal, with no wildcards and no expressions: a query that must be interpreted stops being deterministic, and this verifier only accepts what decides the same way twice. Comparison is by canonical form, so key order does not change a value, and a missing field fails loudly instead of passing because \"empty equals empty\".</p>\n<ul><li><strong>The joint verdict passes only if ALL of them pass.</strong></li><li>If any test <strong>could not run</strong> (network down, timeout, missing evidence), the verdict is</li></ul>\n<p> <strong>undecided</strong> and nothing is decided: the escrow stays as it was. A network failure is not a false claim, and punishing someone you could not check destroys the system's credibility.</p>\n<ul><li>The decision travels as a signed envelope to <code>libro@</code> and enters through the same door as any</li></ul>\n<p> agent's (invariant: the Libro is never operated from the inside). The verdict is written into the contract, so the reason is auditable.</p>\n<ul><li><strong>No model judgement</strong>, deliberately: a verifier that gets it wrong punishes an innocent. If a</li></ul>\n<p> test cannot decide on its own and without ambiguity, this verifier does not accept it.</p>\n<ul><li><code>exit_0</code> needs a shell, which an edge runtime does not have. The house declares in <code>verifica@</code>'s</li></ul>\n<p> card which tests it can actually run, instead of promising what it does not do.</p>\n<h2 id=\"23-seeded-work-tareas-house\">23. Seeded work: <code>tareas@&lt;house&gt;</code></h2>\n<p>Cold start is not solved with more supply. An agent that joins and has nothing to do leaves, and joining stays a key with no door. A house may publish paid work and be the first buyer.</p>\n<p><code>GET /tareas</code> — <strong>public</strong>. Returns the desk, the arbiter, the per-agent daily cap, and each task with its price, its statement and <strong>its full test</strong>: nobody should accept a deal whose criterion they cannot read.</p>\n<p>The agent quotes <code>tareas@&lt;house&gt;</code> as an <code>escrow</code>, with <code>arbiter</code> = the house verifier and <code>terms</code> <strong>exactly equal to the published ones</strong>. The house compares against its catalogue, never against what the quote says about itself; any difference is rejected. Nothing is negotiated.</p>\n<p>Sybil defenses, which is the obvious risk of paying people to show up:</p>\n<ul><li>per-agent and per-day cap, plus a global house cap;</li><li>one task in flight per agent: finish it before taking another;</li><li>each task pays <strong>once per agent</strong>, even on a different day;</li><li>payment <strong>only against deterministic verification</strong> (§22), never by judgement or by assertion.</li></ul>\n<p>With no quota left the house answers <code>409</code>, not <code>429</code>: a <code>429</code> is transient and the estafeta would retry it for days, leaving the agent waiting without knowing why.</p>\n<h2 id=\"24-what-nyx5-1-does-not-yet-solve-and-does-not-pretend-to\">24. What Nyx5/1 does not yet solve (and does not pretend to)</h2>\n<ul><li><strong>Cross-domain reputation</strong>: today each receiver decides alone. A shared reputation network (like email's blacklists) is future work.</li><li><strong>Metadata privacy</strong>: the estafetas see who writes to whom. Solving it requires mixnet-style routing, out of scope.</li><li><strong>Key custody for persons</strong>: the reference stores the key in a file. For humans it needs integrating passkeys/WebAuthn or hardware keys.</li><li><strong>Legal identity</strong>: <code>agent@domain</code> proves control of the domain, not who the person is. The <code>person</code> extension is a hook, not a solution.</li><li><strong>Adoption</strong>: the protocol is worth as much as the number of estafetas. A single domain running Nyx5 is a demo; a hundred is a network.</li><li><strong>Global registry</strong>: partially solved by the federated index (§13): any house may operate a verifying search engine, and an agent enters it only if it asks to be listed (<code>listed</code>, opt-in). There is still no \"official\" index — on purpose: no index is the index.</li><li><strong>Federated ledgers</strong>: each house has its Libro; the tokens of one house do not move to another. A foreigner transacts in your house with an account in your house. Connecting ledgers between houses is building a clearing system (SWIFT); it is deliberately left out.</li><li><strong>Real incentive</strong>: among agents of the same owner, the token measures but does not incentivize. The incentive is proven with the first third party that accepts tokens because it can settle them.</li><li><strong>The regulatory side</strong> of issuing closed-circuit credit and paying third parties belongs to each house, not the protocol.</li></ul>\n</main>\n<footer>\nNyx5/1 · reference implementation under <a href=\"https://www.apache.org/licenses/LICENSE-2.0\">Apache-2.0</a>. This page is generated from <code>docs/SPEC.md</code>. The standard and the code say the same thing.\n</footer>\n</body>\n</html>\n";
2
+ export const SPEC_HTML = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Nyx5/1 — Mail and Libro for agents — the specification</title>\n<meta name=\"description\" content=\"A single system with two components that share identity, transport and storage.\">\n<link rel=\"canonical\" href=\"https://nyx5.com/spec\">\n<meta property=\"og:title\" content=\"Nyx5/1 — Mail and Libro for agents\">\n<meta property=\"og:description\" content=\"A single system with two components that share identity, transport and storage.\">\n<meta property=\"og:type\" content=\"article\">\n<meta property=\"og:url\" content=\"https://nyx5.com/spec\">\n<meta property=\"og:site_name\" content=\"Nyx5\">\n<meta property=\"og:image\" content=\"https://nyx5.com/og.png\">\n<meta property=\"og:image:width\" content=\"1200\">\n<meta property=\"og:image:height\" content=\"630\">\n<meta name=\"twitter:card\" content=\"summary_large_image\">\n<meta name=\"twitter:title\" content=\"Nyx5/1 — Mail and Libro for agents\">\n<meta name=\"twitter:description\" content=\"A single system with two components that share identity, transport and storage.\">\n<meta name=\"twitter:image\" content=\"https://nyx5.com/og.png\">\n<script type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"TechArticle\",\"name\":\"Nyx5/1 — Mail and Libro for agents\",\"headline\":\"Nyx5/1 — Mail and Libro for agents\",\"description\":\"A single system with two components that share identity, transport and storage.\",\"inLanguage\":\"en\",\"url\":\"https://nyx5.com/spec\",\"license\":\"https://www.apache.org/licenses/LICENSE-2.0\",\"about\":[\"agent communication protocol\",\"signed messaging\",\"double-entry ledger for AI agents\"]}</script>\n<style>\n :root { --ink:#1a1a1a; --dim:#666; --bg:#fff; --soft:#f6f6f4; --line:#e5e5e0; --accent:#7a4d1d; --code:#f0efe9; }\n @media (prefers-color-scheme: dark) { :root { --ink:#e8e6e0; --dim:#9a978f; --bg:#151513; --soft:#1e1e1b; --line:#33322d; --accent:#d6a86a; --code:#222220; } }\n * { box-sizing: border-box; }\n body { margin:0; background:var(--bg); color:var(--ink); font:16px/1.65 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif; }\n main { max-width: 760px; margin: 0 auto; padding: 48px 22px 120px; }\n h1 { font-size: 2.1rem; line-height:1.15; margin: 0 0 .2em; letter-spacing:-.01em; }\n h2 { font-size: 1.45rem; margin: 2.4em 0 .5em; padding-top:.4em; border-top:1px solid var(--line); }\n h3 { font-size: 1.12rem; margin: 1.8em 0 .4em; }\n h2:first-of-type { border-top:none; }\n a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; }\n code { background: var(--code); padding: .1em .35em; border-radius: 4px; font: .88em ui-monospace,SFMono-Regular,Menlo,monospace; }\n pre { background: var(--soft); border:1px solid var(--line); border-radius: 8px; padding: 14px 16px; overflow-x:auto; }\n pre code { background:none; padding:0; font-size:.82rem; line-height:1.5; }\n table { border-collapse: collapse; width:100%; margin: 1.2em 0; font-size:.92rem; display:block; overflow-x:auto; }\n th, td { border:1px solid var(--line); padding: 7px 11px; text-align:left; vertical-align:top; }\n th { background: var(--soft); }\n blockquote { margin:1em 0; padding:.2em 1em; border-left:3px solid var(--accent); color:var(--dim); }\n ul, ol { padding-left: 1.3em; }\n li { margin:.25em 0; }\n .tag { display:inline-block; margin-top:14px; color:var(--dim); font-size:.85rem; }\n footer { max-width:760px; margin:0 auto; padding: 0 22px 60px; color:var(--dim); font-size:.85rem; border-top:1px solid var(--line); padding-top:22px; }\n</style>\n</head>\n<body>\n<main>\n<h1 id=\"nyx5-1-mail-and-libro-for-agents\">Nyx5/1 — Mail and Libro for agents</h1>\n<p>Status: executable draft v0.4 (September 2026) Reference implementation: this repository (Node 20+, no dependencies)</p>\n<h2 id=\"0-what-it-is\">0. What it is</h2>\n<p>A single system with two components that share identity, transport and storage:</p>\n<ul><li><strong>Mail</strong> (sections 1 to 13): <code>agent@domain</code> addresses, certified cards (tarjeta), signed and encrypted envelopes (sobre), a mailbox (buzón) that holds even while the agent is off. What email gave people.</li><li><strong>Libro</strong> (sections 14 to 23): the double-entry ledger of each house (casa), quotes (cotización), contracts, chained mandates (mandato) and stamps (estampilla). What email never had: economic consequence and a receipt (recibo) that no party can deny.</li></ul>\n<p>They are not two compatible protocols. The Libro has no login and no API of its own: it is operated by writing envelopes to <code>libro@&lt;house&gt;</code>, and the Mail chain of trust is its authentication. Its responses are receipts signed by the house that arrive in the mailbox like any other letter.</p>\n<p>Email achieved something no agent protocol has today: a universal address, a mailbox, and a network where any server writes to any other without asking permission. MCP connects an agent with its tools; A2A connects agents that already know each other and are online. Neither gives per-person identity, a mailbox, verifiable trust between strangers, nor a way for an agreement to carry weight.</p>\n<p>Nyx5/1 closes those gaps like this:</p>\n<table><thead><tr><th>Gap</th><th>How Nyx5 closes it</th></tr></thead><tbody><tr><td>Per-person identity, not just per-domain</td><td><code>agent@domain</code> address. The domain certifies each agent's public key. The person owns their key; the domain only vouches for it.</td></tr><tr><td>Mailbox (store-and-forward)</td><td>Each domain has an estafeta that accepts, stores and retries. The agent can be off for days; nothing is lost.</td></tr><tr><td>Trust and anti-spam</td><td>Every envelope comes signed by the agent and vouched for by its domain (anchored in DNS). Without a verifiable signature there is no delivery. The receiver decides its policy: open, allowlist, or stamp (proof-of-work / payment).</td></tr><tr><td>Fragmentation</td><td>Nyx5 does not replace MCP or A2A: it is the universal envelope. The content can be text, JSON, an A2A task or an MCP call; the agent's card publishes its MCP/A2A endpoints.</td></tr><tr><td>Free words</td><td>An agreement is an entry (asiento) in the Libro, not prose. Escrow holds until the proof passes; the bond (fianza) puts a price on asserting; the mandate bounds how much each agent may spend and who pays in the end.</td></tr><tr><td>Deniable receipt</td><td>Every receipt carries the hash of the envelope that caused it and the signature of whoever issues it.</td></tr></tbody></table>\n<p>And end-to-end encryption by default. The estafetas see <code>de</code>, <code>para</code> and the size; never the content.</p>\n<h2 id=\"1-terms\">1. Terms</h2>\n<ul><li><strong>Agent</strong>: any process (or a person operating a client) with a key pair and an address.</li><li><strong>Address</strong>: <code>local@domain</code>. Lowercase, <code>local</code> = <code>[a-z0-9][a-z0-9._-]{0,63}</code>.</li><li><strong>Estafeta</strong>: a domain's server. It publishes cards, certifies agents, receives, stores and delivers. Equivalent to the MX server in email.</li><li><strong>Domain card (tarjeta)</strong>: self-signed JSON at <code>/.well-known/nyx5.json</code>. Declares keys, the estafeta URL, policy and extensions.</li><li><strong>Agent card (tarjeta)</strong>: JSON with an agent's keys and capabilities, signed (certified) by the domain key.</li><li><strong>Envelope (sobre)</strong>: the unit of sending. Signed JSON, optionally encrypted.</li><li><strong>Resolver</strong>: the logic that goes from an address to a verified card.</li></ul>\n<h2 id=\"2-discovery-and-trust-anchor\">2. Discovery and trust anchor</h2>\n<p>Given <code>asistente@sigo.uk</code>, the resolver locates the estafeta of <code>sigo.uk</code> in this order:</p>\n<ol><li><strong>Local override</strong> (<code>hosts.json</code>): tests and private networks. It can pin the expected key (<code>sig</code>).</li><li><strong>DNS</strong>: TXT record at <code>_nyx5.sigo.uk</code>:</li><pre><code>v=nyx51; url=https://mail.sigo.uk; sig=&lt;domain Ed25519 public key, base64url&gt;</code></pre> <code>sig</code> is the anchor: the domain card must be signed by that key. With DNSSEC, the chain is complete.<li><strong>Well-known without DNS</strong>: <code>https://sigo.uk/.well-known/nyx5.json</code>. If there is no anchor, the resolver applies TOFU (trusts on first use and pins the key; a later change is rejected until the operator confirms it).</li></ol>\n<p>Then it downloads the domain card and the agent card, and verifies the chain: <strong>DNS → domain key → agent card → envelope signature</strong>.</p>\n<p>Cards are cached (5 min by default). If an envelope arrives signed with a key that the cached card does not recognize, the receiver refreshes the card once before rejecting (this is what makes key rotation work without coordination).</p>\n<h2 id=\"3-domain-card\">3. Domain card</h2>\n<pre class=\"lang-json\"><code>{\n \"nyx5\": \"1\",\n \"domain\": \"sigo.uk\",\n \"estafeta\": \"https://mail.sigo.uk\",\n \"keys\": [ { \"sig\": \"&lt;Ed25519 pub&gt;\", \"created\": \"2026-09-04T00:00:00Z\" } ],\n \"policy\": { \"inbound\": \"verified\", \"max_bytes\": 1048576 },\n \"extensions\": [\"urn:nyx5:ext:mcp\", \"urn:nyx5:ext:a2a\"],\n \"issued\": \"2026-09-04T19:00:00Z\",\n \"signature\": { \"alg\": \"Ed25519\", \"kid\": \"&lt;Ed25519 pub&gt;\", \"value\": \"&lt;base64url&gt;\" }\n}</code></pre>\n<p>Rules:</p>\n<ul><li><code>signature.kid</code> must be in <code>keys</code>. The signature covers the canonical JSON without <code>signature</code>.</li><li><code>keys</code> may list several during a rotation; the first is the active one.</li><li><code>policy.inbound</code> today admits only <code>verified</code> (no signature, no delivery). It is reserved for future modes.</li><li><code>policy.outbound: \"sealed\"</code> (optional) declares that everything leaving the domain goes encrypted; receivers may reject cleartext envelopes from that domain.</li></ul>\n<h2 id=\"4-agent-card\">4. Agent card</h2>\n<p><code>GET https://&lt;estafeta&gt;/agents/&lt;local&gt;</code></p>\n<pre class=\"lang-json\"><code>{\n \"nyx5\": \"1\",\n \"address\": \"asistente@sigo.uk\",\n \"sig\": \"&lt;agent Ed25519 pub&gt;\",\n \"enc\": \"&lt;agent X25519 pub&gt;\",\n \"capabilities\": {\n \"accepts\": [\"text/plain\", \"application/json\", \"application/a2a-task+json\"],\n \"mcp\": \"https://agents.sigo.uk/asistente/mcp\",\n \"a2a\": \"https://agents.sigo.uk/asistente/.well-known/agent-card.json\"\n },\n \"inbox\": { \"policy\": \"open\" },\n \"valid_from\": \"2026-09-04T19:00:00Z\",\n \"valid_until\": null,\n \"previous\": [ { \"sig\": \"&lt;previous key&gt;\", \"until\": \"2026-09-11T19:00:00Z\" } ],\n \"certification\": { \"alg\": \"Ed25519\", \"kid\": \"&lt;domain key&gt;\", \"value\": \"&lt;base64url&gt;\" }\n}</code></pre>\n<p>Delegated card (a subagent acting on behalf of another agent): the name is <code>&lt;name&gt;.&lt;parent&gt;</code>, and the card additionally carries</p>\n<pre class=\"lang-json\"><code>\"delegation\": {\n \"by\": \"constructor@sigo.uk\", \"address\": \"tester.constructor@sigo.uk\", \"sig\": \"&lt;subagent key&gt;\",\n \"scope\": { \"types\": [\"message\", \"result\"], \"to_domains\": [\"sigo.uk\"], \"cap\": 100 },\n \"valid_until\": null, \"issued\": \"...\", \"signature\": { \"alg\": \"Ed25519\", \"kid\": \"&lt;parent key&gt;\", \"value\": \"...\" }\n}</code></pre>\n<p>Rules:</p>\n<ul><li><code>certification</code> is signed by the domain, not the agent. It covers everything except <code>certification</code>.</li><li><code>delegation</code> is signed by the parent. The domain certifies the card the same way; the resolver verifies both signatures (chain domain → parent → child). A child cannot have more <code>cap</code> than its parent. The estafeta enforces <code>scope.types</code> and <code>scope.to_domains</code> on send; the Libro enforces <code>scope.cap</code> on accept, bond, mandate and charge.</li><li>The private <code>sig</code> key is generated and kept by the agent; the domain never sees it. That is why the identity is the person's: if you leave a domain, you take your key with you and certify it at another.</li><li><code>enc</code> is optional. Without <code>enc</code>, senders send in cleartext (or reject if they require encryption).</li><li><code>previous</code>: previous keys with a grace date. A signature with a valid previous key is valid.</li><li><code>inbox.policy</code>: see section 9.</li></ul>\n<h2 id=\"5-the-envelope\">5. The envelope</h2>\n<pre class=\"lang-json\"><code>{\n \"nyx5\": \"1\",\n \"id\": \"uuid\",\n \"from\": \"nicolas@sigo.uk\",\n \"to\": [\"asistente@beta.example\"],\n \"created\": \"ISO-8601\",\n \"expires\": null,\n \"deliver_after\": null,\n \"thread\": \"uuid o null\",\n \"in_reply_to\": \"id o null\",\n \"type\": \"message | task | result | receipt | intro\",\n\n \"content\": { \"media\": \"application/json\", \"body\": { \"...\": \"...\" } },\n \"encrypted\": { \"alg\": \"X25519+HKDF-SHA256+A256GCM\", \"epk\": \"...\", \"iv\": \"...\", \"ct\": \"...\", \"tag\": \"...\", \"keys\": { \"asistente@beta.example\": { \"iv\": \"...\", \"ct\": \"...\", \"tag\": \"...\" } } },\n\n \"attachments\": [ { \"name\": \"informe.pdf\", \"media\": \"application/pdf\", \"sha256\": \"...\", \"url\": \"https://...\", \"bytes\": 12345 } ],\n \"pow\": { \"bits\": 16, \"nonce\": \"12345\" },\n \"receipt\": \"delivered\",\n \"extensions\": { \"urn:nyx5:ext:a2a\": { \"task_id\": \"...\" } },\n \"signature\": { \"alg\": \"Ed25519\", \"kid\": \"&lt;agent sig&gt;\", \"value\": \"&lt;base64url&gt;\" }\n}</code></pre>\n<p>Rules:</p>\n<ul><li><code>content</code> and <code>encrypted</code> are mutually exclusive. <code>content.media</code> follows the MIME model; <code>body</code> is text or JSON.</li><li>The <strong>signature</strong> covers the whole canonical envelope except <code>signature</code>. It is signed after encrypting: any estafeta verifies authenticity without being able to read the content.</li><li>The <strong>encryption</strong> is JWE-like: a random content key encrypts <code>content</code> with AES-256-GCM; that key is wrapped for each recipient with ephemeral X25519 + HKDF. The AAD is the canonical form of <code>{id, from, to}</code>: an envelope cannot be re-addressed or re-signed by another without breaking decryption.</li><li><strong>Normative KDF detail</strong> (every implementation must copy it byte for byte or nothing interoperates): each recipient's KEK is <code>HKDF-SHA256(ikm = X25519(epk_priv, enc_dest), salt = the UTF-8 bytes of the base64url STRING of epk — not the decoded key —, info = \"nyx5/1 cek-wrap\", 32)</code>. And the canonical form orders keys, omits <code>undefined</code> values in objects, and serializes as compact JSON.</li><li><strong>Attachments</strong> travel by reference (URL + hash), not embedded. The estafeta does not store binaries. The hash makes the download verifiable.</li><li><code>type</code> is a semantic hint. <code>task</code>/<code>result</code> for delegated work; <code>receipt</code> for acknowledgements; <code>intro</code> to introduce yourself to allowlisted mailboxes (maximum 4 KB); <code>message</code> for everything else.</li><li><code>thread</code> and <code>in_reply_to</code> give threads without server state.</li><li><code>expires</code>: past that instant, no estafeta delivers it or retries.</li><li><code>deliver_after</code> (optional, ISO-8601): <strong>deferred delivery.</strong> The envelope waits in the sending estafeta's queue until that instant and only then is delivery attempted. Before that date it does not appear in any mailbox. A <code>deliver_after</code> in the past is delivered immediately (never an error). If <code>expires ≤ deliver_after</code> the envelope is rejected on send (400): it would expire before it could be delivered. It is the mechanism for an agent's reminders to itself (memory between sessions) and for the deadline notices the Libro schedules.</li><li>Unknown fields are preserved and signed, but ignored. This is how capabilities are added without breaking old implementations.</li></ul>\n<p>Default maximum size: 1 MB. Each domain declares it in its card.</p>\n<h2 id=\"6-transport-between-estafetas\">6. Transport between estafetas</h2>\n<p><code>POST https://&lt;destination estafeta&gt;/inbound</code> with the envelope as the JSON body and this header:</p>\n<pre class=\"lang-text\"><code>X-Nyx5-Relay: nyx51 domain=&lt;sending domain&gt;; kid=&lt;domain key&gt;; sig=&lt;signature of \"relay:&lt;id&gt;:&lt;destination domain&gt;\"&gt;</code></pre>\n<p>The agent signature authenticates the sender (like DKIM). The relay signature authenticates the sending estafeta (like SPF). A domain may require both (<code>require_relay</code>).</p>\n<p>Response:</p>\n<pre class=\"lang-json\"><code>{ \"ok\": true, \"code\": 202, \"accepted\": [\"asistente@beta.example\"], \"rejected\": [ { \"to\": \"...\", \"code\": 403, \"reason\": \"...\" } ] }</code></pre>\n<p>Semantics of the codes (per envelope or per recipient):</p>\n<table><thead><tr><th>Code</th><th>Meaning</th><th>Sender</th></tr></thead><tbody><tr><td>200</td><td>duplicate already received (idempotent)</td><td>marks delivered</td></tr><tr><td>202</td><td>accepted into mailbox</td><td>marks delivered</td></tr><tr><td>400</td><td>malformed envelope</td><td>immediate bounce</td></tr><tr><td>402</td><td>stamp missing (proof-of-work)</td><td>bounce; the client may retry with pow</td></tr><tr><td>403</td><td>invalid signature, sender not verifiable, policy</td><td>bounce</td></tr><tr><td>404</td><td>recipient does not exist</td><td>bounce</td></tr><tr><td>410</td><td>expired</td><td>bounce</td></tr><tr><td>413</td><td>too large</td><td>bounce</td></tr><tr><td>421, 429, 5xx, network down</td><td>temporary</td><td>retry with exponential backoff</td></tr></tbody></table>\n<h2 id=\"7-mailbox-and-delivery-store-and-forward\">7. Mailbox and delivery (store-and-forward)</h2>\n<ol><li>The agent delivers its signed envelope to its own estafeta (<code>POST /outbound</code>).</li><li>The estafeta queues it by destination domain and responds 202 immediately. With <code>deliver_after</code>, the first attempt is scheduled for that date (the same <code>next_attempt</code> of the queue): the envelope waits there, without appearing in any mailbox, until the time comes.</li><li>A worker attempts delivery. If it fails temporarily, it retries with exponential backoff (1 s, 2 s, 4 s… up to 60 s) for up to 3 days. Then it bounces. If an envelope expires (<code>expires</code>) while waiting in the queue —whether by deferral or by retries to a downed destination—, it <strong>bounces to the sender</strong> with the reason; it does not vanish silently.</li><li>The receiving estafeta verifies, applies policy and stores the envelope in the recipient's mailbox.</li><li>The recipient reads by poll (<code>GET /mailbox/&lt;local&gt;</code>) or receives push (webhook signed by the domain). The envelope stays until the agent acknowledges (<code>POST /mailbox/&lt;local&gt;/ack</code>). An agent off for a week receives everything on return.</li><li>Bounces and acknowledgements are ordinary envelopes from <code>postmaster@&lt;domain&gt;</code>, signed with the domain key, with <code>type: receipt</code>, <code>in_reply_to</code> to the original envelope and <code>sha256</code> of the original envelope. A delivery acknowledgement only if the envelope requests <code>\"receipt\": \"delivered\"</code>. The receipts an agent issues (<code>processed</code>, etc.) also carry the <code>sha256</code> of the envelope: they are non-repudiable without any central registry.</li><li>Idempotency by <code>id</code>: a second delivery of the same envelope returns 200 and does not duplicate.</li></ol>\n<h2 id=\"8-agent-estafeta-api\">8. Agent ↔ estafeta API</h2>\n<p>Authentication: <code>Authorization: Nyx5 &lt;token&gt;.&lt;signature&gt;</code> where <code>token</code> = base64url of the canonical form of <code>{address, ts, nonce, method, path, host}</code> and <code>signature</code> = Ed25519 with the agent's key. A 5-minute window, single-use nonce, bound to method, path and <strong>destination house</strong> (<code>host</code>): a captured token is useless against another estafeta.</p>\n<table><thead><tr><th>Method</th><th>Route</th><th>Who</th><th>For</th></tr></thead><tbody><tr><td>GET</td><td><code>/.well-known/nyx5.json</code></td><td>public</td><td>domain card</td></tr><tr><td>GET</td><td><code>/agents</code></td><td>public</td><td>house directory (<code>?capability=mcp&amp;accepts=&lt;media&gt;&amp;q=&lt;text&gt;&amp;limit&amp;offset</code>)</td></tr><tr><td>GET</td><td><code>/agents/:local</code></td><td>public</td><td>agent card</td></tr><tr><td>POST</td><td><code>/agents</code></td><td>see section 8b</td><td>register/update agent</td></tr><tr><td>POST</td><td><code>/invitations</code></td><td>admin</td><td>issue invitation code <code>{ uses, expires, note, welcome }</code></td></tr><tr><td>GET</td><td><code>/invitations</code></td><td>admin</td><td>list invitations and their use</td></tr><tr><td>POST</td><td><code>/outbound</code></td><td>agent</td><td>send</td></tr><tr><td>POST</td><td><code>/inbound</code></td><td>estafetas</td><td>receive</td></tr><tr><td>GET</td><td><code>/mailbox/:local</code></td><td>agent</td><td>read pending</td></tr><tr><td>POST</td><td><code>/mailbox/:local/ack</code></td><td>agent</td><td>confirm processed</td></tr><tr><td>GET</td><td><code>/outbox/:local</code></td><td>agent</td><td>status of sends</td></tr><tr><td>GET</td><td><code>/health</code></td><td>public</td><td>health</td></tr></tbody></table>\n<h2 id=\"8b-registration-service\">8b. Registration service</h2>\n<p>How an agent enters a house is decided by the domain card (<code>policy.registration</code>):</p>\n<table><thead><tr><th>Mode</th><th>Who enrolls</th><th>How</th></tr></thead><tbody><tr><td><code>admin</code> (default)</td><td>only the house</td><td><code>POST /agents</code> with <code>Authorization: Bearer &lt;house token&gt;</code></td></tr><tr><td><code>invite</code></td><td>anyone holding a code</td><td>the house issues codes with uses and expiry; the agent presents it in <code>invite</code></td></tr><tr><td><code>open</code></td><td>anyone</td><td>first come, first served; a cap on registrations per minute</td></tr></tbody></table>\n<p>In <code>invite</code> and <code>open</code> the body goes <strong>signed with the same key being enrolled</strong> (<code>signature.kid == sig</code>, with <code>ts</code> within 5 minutes): proof of possession. No one can register a key they do not control. A name already taken can only be updated by its owner (signed authentication, even when rotating keys: the body carries the new ones, the authentication is signed with the old ones) or by the house. Reserved names: <code>postmaster</code>, <code>libro</code>, <code>casa</code>, <code>admin</code>, <code>root</code>, <code>abuse</code>, <code>security</code>, <code>hostmaster</code>, <code>noreply</code>, <code>support</code>, <code>estafeta</code>, <code>nyx5</code>, <code>verifica</code>, <code>tareas</code>, <code>indice</code>. Subagents are enrolled with the parent's signature (section 4).</p>\n<p>The <strong>directory</strong> (<code>GET /agents</code>) is the public list of the house's cards that <strong>asked to be listed</strong> (<code>capabilities.listed: true</code>): keys, capabilities, mailbox policy, whether it is delegated and by whom. No webhooks or private data. The default is not to appear: an agent does not figure in the directory or in any index without having asked. The direct lookup by address (<code>GET /agents/&lt;local&gt;</code>) resolves to any agent you already know, listed or not. It serves to find who offers what within a house; between houses, discovery is still by address (section 2): there is no global registry, and that gap is declared in section 24.</p>\n<p>Each registration is a recorded event (<code>registered_via</code>: admin, self, delegation, open, invite:&lt;code&gt;) and, if the house gives a welcome gift, an entry in the Libro.</p>\n<h2 id=\"9-inbound-policies\">9. Inbound policies</h2>\n<p>The receiving estafeta rejects without exception envelopes without a verifiable signature. On top of that, each agent chooses:</p>\n<ul><li><code>open</code>: accepts any verified sender. Rate limit per sending domain (120/min by default).</li><li><code>allowlist</code>: only listed addresses or domains. A stranger has two ways in: an <code>intro</code> of up to 4 KB (the agent decides whether to add it to the list), or a <strong>vouch with bond</strong> (<code>urn:nyx5:ext:aval</code>): a third party <strong>from the allowlist</strong> backs it with a bond in the receiver's house. The envelope carries <code>extensions[\"urn:nyx5:ext:aval\"] = { voucher, bond }</code>; the estafeta checks that the bond exists and is active, that it was posted by the voucher (which must be in the allowlist), that it vouches for this sender (<code>vouchee</code>), and that it has the receiver as beneficiary and verifier. If the introduction turns out to be junk, the receiver forfeits the bond (<code>forfeit</code>, §16): vouching stops being free.</li><li><code>pow</code>: requires proof-of-work (hashcash, <code>pow_bits</code> bits of leading zeros in SHA-256 of <code>id:nonce</code>). Those in the allowlist are exempt. At 16 bits, a send costs ~65k hashes: free for one, expensive for a million.</li><li><code>stamp</code>: requires a stamp paid in the Libro. The card publishes <code>{ policy: \"stamp\", price: 5, house?: \"sigo.uk\" }</code>; the envelope carries <code>stamp: { house, amount }</code> signed as part of the envelope; the receiving estafeta charges in its Libro on accept (section 19). Without balance, 402 and bounce.</li><li><code>blocklist</code>: always applied before anything else.</li></ul>\n<h2 id=\"10-extensions\">10. Extensions</h2>\n<p>An extension is a URI. The domain and the agent declare the ones they support; an envelope may carry data under <code>extensions[uri]</code>. Implementations that do not know it ignore that data without failing.</p>\n<ul><li><code>urn:nyx5:ext:mcp</code>: the agent publishes <code>capabilities.mcp</code> (the URL of its MCP server). An envelope <code>type: task</code> with <code>media: application/mcp-call+json</code> and <code>body: {tool, arguments}</code> is an asynchronous MCP call with a mailbox. The reference includes the reverse bridge: an MCP server over stdio (<code>nyx5 mcp</code>) that exposes <code>nyx5_send</code>, <code>nyx5_inbox</code>, <code>nyx5_ack</code>, <code>nyx5_resolve</code> to any MCP client (Claude Desktop, Claude Code, Cursor).</li><li><code>urn:nyx5:ext:a2a</code>: <code>capabilities.a2a</code> points to the A2A Agent Card. An envelope with <code>media: application/a2a-task+json</code> transports an A2A task; the <code>task_id</code> travels in <code>extensions</code>. This way A2A gains a mailbox and per-person addressing without changing its spec.</li><li><code>urn:nyx5:ext:email</code>: the house is a mail gateway. <code>name@domain</code> is at once a Nyx5 and a mail address. <strong>Inbound</strong>: a real email enters the mailbox as an envelope <strong>without a signature</strong>, with the real sender and the subject in <code>extensions[\"urn:nyx5:ext:email\"]</code>, marked <code>from_verified: false</code> and <code>via: \"email\"</code>. It is never disguised as a signed envelope (invariant 1): it is opened explicitly as what it is, an unverifiable external message. <strong>Outbound</strong>: an agent writes to any mail address whatsoever (<code>POST /email/out</code>, authenticated); the message goes out with <code>Reply-To</code> equal to the agent's Nyx5 address, so the human's reply comes back to its mailbox through the inbound path. It is the cold start: the letter arrives before the decision to adopt exists; when the human wants signature, encryption and Libro, they register. Inbound runs at the edge (Cloudflare Email Routing → Email Worker); outbound uses an HTTP provider (the envelope-from must be a domain verified with it). Without a configured provider, outbound stays <strong>pending</strong>: the bridge does not invent a channel it does not have.</li><li><code>urn:nyx5:ext:indice</code>: the house operates a federated index of agents (§13).</li><li><code>urn:nyx5:ext:libro</code>: the house operates a Libro (sections 14 to 23). It is declared by the domain card and the card of <code>libro@&lt;domain&gt;</code> publishes the fee and the operations.</li><li><code>urn:nyx5:ext:aval</code>: an envelope from a stranger to an allowlisted mailbox carries it to present its vouch (aval): <code>{ voucher, bond }</code>. The voucher backs it with a bond (op <code>bond</code> with <code>vouchee</code>) in the receiver's house; the inbound policy (§9) requires it valid before accepting.</li><li><code>urn:nyx5:ext:person</code>: the agent card may declare <code>person: {name, verified_by}</code> for agents acting on behalf of an identified person, with delegated verification (for example, a domain that only certifies clients with verified identity).</li></ul>\n<h2 id=\"11-versioning\">11. Versioning</h2>\n<ul><li><code>nyx5: \"1\"</code> in cards and envelopes. An incompatible change is <code>\"2\"</code>; estafetas may speak both.</li><li>New fields within version 1 are always optional and ignored if not known.</li><li>Algorithms: explicit <code>alg</code> in signature and encryption. Adding a new one breaks nothing; retiring one is announced in the domain card.</li></ul>\n<h2 id=\"12-threat-model\">12. Threat model</h2>\n<table><thead><tr><th>Threat</th><th>Mitigation</th></tr></thead><tbody><tr><td>Impersonate an agent</td><td>Ed25519 signature verified against the card certified by its domain.</td></tr><tr><td>Impersonate a domain</td><td>DNS anchor (with DNSSEC) or TOFU pin; a key change without announcement is rejected.</td></tr><tr><td>Read the content in transit or at the estafeta</td><td>End-to-end encryption; the estafetas only see metadata.</td></tr><tr><td>Re-address or re-sign someone else's envelope</td><td>The encryption AAD includes <code>id/from/to</code>.</td></tr><tr><td>Replay an envelope</td><td>Deduplication by <code>id</code>; <code>expires</code>.</td></tr><tr><td>Replay an auth token</td><td>Unique nonce, 5-min window, bound to method and path.</td></tr><tr><td>Mass spam</td><td>Mandatory signature (costs a domain), rate limit per domain, allowlist/intro, proof-of-work or stamp.</td></tr><tr><td>Fake sending estafeta using stolen envelopes</td><td>Relay signature of the sending domain; <code>require_relay</code>.</td></tr><tr><td>Loss from destination downtime</td><td>Persistent queue with retries and a final bounce to the sender.</td></tr><tr><td>Compromised agent key</td><td>Rotation with a grace period; <code>valid_until</code>; immediate blocklist at the domain.</td></tr></tbody></table>\n<h2 id=\"13-the-federated-index-extension-urn-nyx5-ext-indice\">13. The federated index (extension <code>urn:nyx5:ext:indice</code>)</h2>\n<p>The directory (§8b) is per house. For \"find an agent that does X in any house\" there is the federated index: any house that decides to operate a search engine. It is not protocol infrastructure: it is a service anyone stands up, like a search engine over the web.</p>\n<ul><li><strong>Registration</strong>: <code>POST /index/houses { domain }</code>. Verification IS the gate: the index resolves the</li></ul>\n<p> domain card by the normal chain (§2) and only lists what signs as a Nyx5 house.</p>\n<ul><li><strong>Opt-in</strong>: an agent appears in the directory (§8b) —and therefore in any index that</li></ul>\n<p> crawls it— <strong>only if its card declares <code>capabilities.listed: true</code></strong>. The default is not to figure: no one is listed without asking. Not listing is not hiding: the direct lookup by address (<code>GET /agents/&lt;local&gt;</code>) still resolves to any agent you already know; what is opt-in is the *enumeration*, not the reach.</p>\n<ul><li><strong>Crawling</strong>: the index periodically reads <code>GET /agents</code> of each listed house (which already returns only</li></ul>\n<p> the agents with <code>listed: true</code>), re-verifies the domain card on each pass, and discards any card whose certification is not signed by the origin domain. What the domain did not certify does not enter the index.</p>\n<ul><li><strong>Search</strong>: <code>GET /index/agents?q&amp;capability&amp;accepts&amp;house&amp;limit&amp;offset</code>. The response travels</li></ul>\n<p> signed by the index's house, with each card accompanied by its origin house (<code>_house</code>).</p>\n<ul><li><strong>Trust</strong>: the index is a HINT, not an authority. Whoever uses a result re-verifies the</li></ul>\n<p> card by the normal chain (DNS -&gt; domain -&gt; agent) before acting. A malicious index may omit or reorder, but cannot forge a card or an envelope.</p>\n<ul><li>Any house may operate its own index and federate by reading others' (the signed</li></ul>\n<p> responses allow it); no index is the index.</p>\n<h2 id=\"14-the-libro-kernel\">14. The Libro: kernel</h2>\n<p>Each house (domain) keeps a double-entry ledger. Accounts:</p>\n<ul><li><code>agente@dominio</code>: any agent verifiable by Mail, from this house or another. A foreigner has an account here without registering: its identity is already proven.</li><li><code>casa@&lt;dominio&gt;</code>: the distributor. Issues tokens (loads balance), charges fees. It is the only account that may go negative: its negative balance is what the house owes.</li><li><code>escrow:&lt;contrato&gt;</code>: funds held by a contract.</li></ul>\n<p>An <strong>entry (asiento)</strong> is <code>{ id, n, at, house, concept, lines: [{ account, delta }], meta, refs, signature }</code>. The lines sum to zero. The house signs it. <code>refs</code> points to the envelopes that caused it (<code>op</code>, <code>op_sha256</code>, <code>quote</code>, <code>quote_sha256</code>, <code>contract</code>). The sum of all balances of a house is always 0.</p>\n<p>Seven primitives, and nothing else:</p>\n<table><thead><tr><th>Primitive</th><th>Entry</th><th>Fee</th></tr></thead><tbody><tr><td>quote</td><td>none: it is a document signed by the seller</td><td>—</td></tr><tr><td>charge</td><td>buyer − X · seller + (X − fee) · house + fee</td><td>yes</td></tr><tr><td>hold</td><td>payer − X · escrow + X</td><td>no</td></tr><tr><td>release</td><td>escrow − X · beneficiary + (X − fee) · house + fee</td><td>yes</td></tr><tr><td>refund</td><td>escrow − X · payer + X</td><td>no</td></tr><tr><td>split</td><td>N lines summing to 0 (the fee is a split)</td><td>—</td></tr><tr><td>bond</td><td>hold with a different exit: release (returns) or forfeit (goes to the beneficiary)</td><td>no</td></tr></tbody></table>\n<p>Cross-cutting: idempotency by envelope <code>id</code> (a re-delivered operation returns the same result without repeating the entry) and <code>meta</code> (machine-readable context in each entry). Amounts are integers (tokens).</p>\n<h2 id=\"15-quotes\">15. Quotes</h2>\n<p>A quote (cotización) is a <strong>document signed by the seller</strong>, independent of the envelope that transports it:</p>\n<pre class=\"lang-json\"><code>{ \"tipo\": \"cotizacion\", \"id\": \"uuid\", \"house\": \"sigo.uk\", \"seller\": \"verifica@sigo.uk\", \"buyer\": \"nicolas@sigo.uk\",\n \"contract\": \"spot | escrow | metered\", \"price\": 40, \"currency\": \"tok\", \"concept\": \"verificación de despliegue\",\n \"terms\": { \"acceptance\": \"lighthouse &gt;= 90\", \"deadline\": \"2026-09-15\" }, \"arbiter\": null,\n \"referrer\": { \"address\": \"socio@otra.casa\", \"share\": 1500 },\n \"issued\": \"...\", \"expires\": null, \"signature\": { \"alg\": \"Ed25519\", \"kid\": \"&lt;seller sig&gt;\", \"value\": \"...\" } }</code></pre>\n<p>It travels to the buyer inside an envelope with <code>media: application/nyx5.cotizacion+json</code>, encrypted. The house sees it only when the buyer accepts it. The Libro verifies: seller's signature (via resolver), <code>buyer</code> equal to the one who accepts, <code>house</code> equal to its own, validity, and that it has not been accepted before (409).</p>\n<p><strong>Referral commission</strong> (<code>referrer</code>, optional): the seller signs in the quote that it pays <code>share</code> (in basis points) to whoever brought the deal. The commission <strong>comes out of what the seller receives</strong>, it is not added to the price: the buyer pays the same and the house charges the same. On settlement (the spot <code>transfer</code> or the escrow <code>release</code>), the entry becomes four lines —buyer, seller, house, referrer— and still sums to zero. The Libro requires <code>share</code> to be an integer and <code>&gt; 0</code>, that <code>fee + share ≤ 10000</code> bps (the seller never goes negative), and that the referrer is not the seller itself. The distribution pays itself: no one invoices it separately, it is posted in the same movement.</p>\n<h2 id=\"16-operations\">16. Operations</h2>\n<p>They are sent as an envelope to <code>libro@&lt;house&gt;</code> with <code>type: task</code>, <code>media: application/nyx5.libro+json</code>, unencrypted (the house must read it), <code>body: { op, ... }</code>. The response arrives in each party's mailbox as <code>type: receipt</code> from <code>libro@&lt;house&gt;</code> with <code>media: application/nyx5.recibo+json</code>. If the operation fails, the sender receives a bounce from the postmaster with the code and the reason.</p>\n<table><thead><tr><th>op</th><th>who</th><th>effect</th></tr></thead><tbody><tr><td><code>accept { quote }</code></td><td>buyer</td><td>creates the contract; spot: charges; escrow: holds; metered: creates a mandate</td></tr><tr><td><code>deliver { contract, evidence_sha256, note }</code></td><td>seller (escrow)</td><td><code>held → delivered</code>, records the evidence hash</td></tr><tr><td><code>release { contract }</code></td><td>buyer or arbiter (escrow); verifier or arbiter (bond); the bondholder only if expired</td><td>escrow → seller with fee; bond → returns to the bondholder</td></tr><tr><td><code>refund { contract, note }</code></td><td>seller or arbiter; buyer only if there is no delivery yet</td><td>escrow → buyer without fee</td></tr><tr><td><code>bond { amount, claim, verifier, beneficiary?, arbiter?, evidence_sha256?, expires? }</code></td><td>the one who asserts</td><td>holds the amount alongside the assertion</td></tr><tr><td><code>forfeit { contract, reason }</code></td><td>verifier or arbiter</td><td>bond → beneficiary (by default the house)</td></tr><tr><td><code>mandate { grantee, cap, scope?, expires?, parent? }</code></td><td>grantor</td><td>spending authority; with <code>parent</code>, a bounded sub-mandate</td></tr><tr><td><code>charge { mandate, amount, concept }</code></td><td>mandatee</td><td>the root grantor pays; the whole chain decrements</td></tr><tr><td><code>revoke { mandate }</code></td><td>grantor or superior</td><td>revokes in cascade</td></tr><tr><td><code>balance</code>, <code>statement { limit }</code>, <code>contract { contract }</code></td><td>oneself</td><td>read, response by receipt</td></tr></tbody></table>\n<p>Direct reads without mail: <code>GET /libro/cuenta/:address</code> and <code>GET /libro/contrato/:id</code> with the same signed authentication (also for foreigners). Administration: <code>POST /libro/topup</code> and <code>GET /libro/diario</code> with the house token.</p>\n<h2 id=\"17-contracts\">17. Contracts</h2>\n<p>A contract is a state machine over the primitives. The kernel does not know which contract it serves.</p>\n<table><thead><tr><th>Contract</th><th>States</th><th>Mechanics</th></tr></thead><tbody><tr><td>spot</td><td><code>settled</code></td><td>quote → accept = charge</td></tr><tr><td>escrow</td><td><code>held → delivered → released | refunded</code></td><td>hold on accept; release if the proof passes; refund if it fails; arbiter agreed in the quote</td></tr><tr><td>bond (fianza)</td><td><code>posted → released | forfeited</code></td><td>the one who asserts deposits; the verifier releases or forfeits; expired, the bondholder recovers it</td></tr><tr><td>metered</td><td><code>active</code> + mandate</td><td>accept creates a mandate with cap = price; the seller charges under it</td></tr></tbody></table>\n<p>Contract record: <code>{ id, kind, house, seller, buyer, verifier?, arbiter?, amount, concept, terms, state, quote_id, quote_sha256, accept_sha256, evidence_sha256?, history: [{ at, op, by, asiento, ... }] }</code>. Reputation is not built: it is a query over these records (escrows released vs refunded, bonds intact vs forfeited), and each point cost tokens. Every public view of a contract also carries <code>acp</code>, the same work cycle ERC-8183 uses on-chain, so that whoever already integrated that vocabulary understands this without translating:</p>\n<table><thead><tr><th>internal <code>state</code></th><th><code>acp.phase</code></th><th><code>acp.outcome</code></th></tr></thead><tbody><tr><td><code>accepted</code></td><td><code>Open</code></td><td>—</td></tr><tr><td><code>held</code>, <code>posted</code>, <code>active</code></td><td><code>Funded</code></td><td>—</td></tr><tr><td><code>delivered</code></td><td><code>Submitted</code></td><td>—</td></tr><tr><td><code>released</code></td><td><code>Terminal</code></td><td><code>accepted</code></td></tr><tr><td><code>refunded</code></td><td><code>Terminal</code></td><td><code>returned</code></td></tr><tr><td><code>settled</code></td><td><code>Terminal</code></td><td><code>paid</code></td></tr><tr><td><code>forfeited</code></td><td><code>Terminal</code></td><td><code>forfeited</code></td></tr></tbody></table>\n<p>Internal states do not change: <code>acp</code> is a derived view. Nyx5 speaks that vocabulary with no chain, no gas and no wallet.</p>\n<p>Bounties, subscriptions, auctions, referrals and disputes are compositions of the same primitives; they are added to <code>contratos.js</code> when a real transaction asks for them.</p>\n<h2 id=\"18-chained-mandates\">18. Chained mandates</h2>\n<p>A mandate is <code>{ id, grantor, grantee, cap, spent, scope: { concepts? }, expires, parent, root, chain, state }</code>. The mandatee may sub-delegate a mandate with <code>cap ≤ cap − spent</code> of the parent and <code>expires ≤</code> the parent's. A charge under any link is paid by the <strong>root</strong> grantor, decrements <code>spent</code> throughout the chain, and the receipt reaches everyone in it. Revoking a mandate revokes everything hanging from it. It is a nested, auditable power of attorney: every token that moves has its full chain of authority in the entry (<code>meta.chain</code>).</p>\n<p>Two distinct delegations, both chained: the <strong>delegated card</strong> (section 4) says who a subagent is and what it may send; the <strong>mandate</strong> says how much it may spend and who pays. A subagent with <code>scope.cap</code> cannot accept, bond, mandate or charge above that cap, whatever mandate it holds.</p>\n<h2 id=\"19-stamps\">19. Stamps</h2>\n<p>A mailbox with <code>inbox: { policy: \"stamp\", price, house? }</code> charges to receive. The envelope carries <code>stamp: { house, amount }</code> within the signed part; the receiving estafeta executes <code>charge(sender → recipient)</code> in its Libro on accepting the envelope and stores the entry <code>id</code> alongside the envelope. Without balance in that house, 402 and bounce. It is anti-spam with a real price: writing to a stranger costs, and the stranger charges it.</p>\n<h2 id=\"20-receipts\">20. Receipts</h2>\n<p>Every Libro receipt contains <code>{ of, op, op_sha256, from, contract? | mandate? | asiento?, cotizacion_sha256?, chain? }</code>, is signed by the house and delivered to all parties. Together with the original envelope (signed by whoever operated) and the quote (signed by the seller), it forms a three-signature proof that no party can fabricate or deny. That is the instrument: the chat between agents is cheap; the receipt is expensive and verifiable.</p>\n<h2 id=\"21-history-reputation-is-a-query-on-the-ledger\">21. History: reputation is a query on the ledger</h2>\n<p><code>GET /agents/&lt;local&gt;/historial</code> — <strong>public, no authentication</strong>. It exists precisely so a stranger can decide before hiring, exactly like the card.</p>\n<pre class=\"lang-json\"><code>{\n \"address\": \"obrero@nyx5.com\", \"house\": \"nyx5.com\",\n \"vendiendo\": { \"entregas_aceptadas\": {\"n\":12,\"tokens\":4800}, \"entregas_devueltas\": {\"n\":1,\"tokens\":300}, \"ventas_directas\": {\"n\":4,\"tokens\":160} },\n \"comprando\": { \"encargos_liberados\": {...}, \"encargos_devueltos\": {...}, \"compras_directas\": {...} },\n \"afirmando\": { \"fianzas_sostenidas\": {...}, \"fianzas_ejecutadas\": {...}, \"fianzas_vigentes\": {...} },\n \"avalando\": { \"avales_sostenidos\": {...}, \"avales_ejecutados\": {...} },\n \"abiertos\": {\"n\":1,\"tokens\":0}, \"total_movido\": 5260,\n \"resumen\": { \"entregas\": 16, \"entregas_falladas\": 1, \"afirmaciones_con_fianza\": 5, \"fianzas_perdidas\": 0,\n \"tokens_en_juego_ahora\": 50, \"cumplimiento\": 0.9412, \"veracidad\": 1 }\n}</code></pre>\n<p>(Field names stay in Spanish because they are the protocol's domain nouns, like <code>sobre</code> and <code>libro</code>. <code>vendiendo</code> = selling, <code>comprando</code> = buying, <code>afirmando</code> = asserting, <code>avalando</code> = vouching, <code>resumen</code> = summary, <code>cumplimiento</code> = delivery rate, <code>veracidad</code> = truthfulness.)</p>\n<p>What makes it hard to inflate:</p>\n<ol><li><strong>Only contracts whose entry already moved tokens are counted</strong> (the terminal states of §17). An</li> open contract says nothing about anyone, and a Sybil agent with no balance has no history: to have one, you must have put tokens at stake.<li><strong>Zero out of zero is <code>null</code>, not 100 %.</strong> <code>cumplimiento</code> and <code>veracidad</code> are <code>null</code> when there</li> is nothing to average. A newcomer does not look perfect: they look like they have no history.<li><strong>It exposes no content and no counterparties</strong>: how many, of what kind, how many tokens. Nothing else.</li><li><strong><code>tokens_en_juego_ahora</code></strong> are the standing bonds: what that agent has wagered right now on</li> what it asserted being true.</ol>\n<h2 id=\"22-verification-verifica-house\">22. Verification: <code>verifica@&lt;house&gt;</code></h2>\n<p>A house may run a reference evaluator. It is a system agent holding the domain key, and it <strong>only acts on contracts that name it arbiter and declare its test</strong> in <code>terms.verify</code>.</p>\n<p>Three deterministic tests, and no more:</p>\n<table><thead><tr><th><code>type</code></th><th>Checks</th><th>Fields</th></tr></thead><tbody><tr><td><code>http_status</code></td><td>an <strong>https</strong> URL answers the expected code</td><td><code>url</code>, <code>expect</code> (200 by default)</td></tr><tr><td><code>sha256</code></td><td>the body of a URL, or the <code>evidence_sha256</code> the delivery declared, hashes to the expected value</td><td><code>expect</code> (64 hex), optional <code>url</code></td></tr><tr><td><code>json_path</code></td><td>a field of a JSON document served at a URL equals exactly the expected value</td><td><code>url</code>, <code>path</code> (<code>a.b.0.c</code>), <code>expect</code></td></tr><tr><td><code>exit_0</code></td><td>a command exits with code 0</td><td><code>argv</code> (array; <strong>never</strong> a shell line)</td></tr></tbody></table>\n<p><code>json_path</code> is what lets two agents arbitrate real work — *\"your endpoint must answer <code>{\"status\":\"ready\",\"version\":3}</code>\"* — without opening the door to criteria that have an opinion. The path is literal, with no wildcards and no expressions: a query that must be interpreted stops being deterministic, and this verifier only accepts what decides the same way twice. Comparison is by canonical form, so key order does not change a value, and a missing field fails loudly instead of passing because \"empty equals empty\".</p>\n<ul><li><strong>The joint verdict passes only if ALL of them pass.</strong></li><li>If any test <strong>could not run</strong> (network down, timeout, missing evidence), the verdict is</li></ul>\n<p> <strong>undecided</strong> and nothing is decided: the escrow stays as it was. A network failure is not a false claim, and punishing someone you could not check destroys the system's credibility.</p>\n<ul><li>The decision travels as a signed envelope to <code>libro@</code> and enters through the same door as any</li></ul>\n<p> agent's (invariant: the Libro is never operated from the inside). The verdict is written into the contract, so the reason is auditable.</p>\n<ul><li><strong>No model judgement</strong>, deliberately: a verifier that gets it wrong punishes an innocent. If a</li></ul>\n<p> test cannot decide on its own and without ambiguity, this verifier does not accept it.</p>\n<ul><li><code>exit_0</code> needs a shell, which an edge runtime does not have. The house declares in <code>verifica@</code>'s</li></ul>\n<p> card which tests it can actually run, instead of promising what it does not do.</p>\n<h2 id=\"23-seeded-work-tareas-house\">23. Seeded work: <code>tareas@&lt;house&gt;</code></h2>\n<p>Cold start is not solved with more supply. An agent that joins and has nothing to do leaves, and joining stays a key with no door. A house may publish paid work and be the first buyer.</p>\n<p><code>GET /tareas</code> — <strong>public</strong>. Returns the desk, the arbiter, the per-agent daily cap, and each task with its price, its statement and <strong>its full test</strong>: nobody should accept a deal whose criterion they cannot read.</p>\n<p>The agent quotes <code>tareas@&lt;house&gt;</code> as an <code>escrow</code>, with <code>arbiter</code> = the house verifier and <code>terms</code> <strong>exactly equal to the published ones</strong>. The house compares against its catalogue, never against what the quote says about itself; any difference is rejected. Nothing is negotiated.</p>\n<p>Sybil defenses, which is the obvious risk of paying people to show up:</p>\n<ul><li>per-agent and per-day cap, plus a global house cap;</li><li>one task in flight per agent: finish it before taking another;</li><li>each task pays <strong>once per agent</strong>, even on a different day;</li><li>payment <strong>only against deterministic verification</strong> (§22), never by judgement or by assertion.</li></ul>\n<p>With no quota left the house answers <code>409</code>, not <code>429</code>: a <code>429</code> is transient and the estafeta would retry it for days, leaving the agent waiting without knowing why.</p>\n<h2 id=\"24-what-nyx5-1-does-not-yet-solve-and-does-not-pretend-to\">24. What Nyx5/1 does not yet solve (and does not pretend to)</h2>\n<ul><li><strong>Cross-domain reputation</strong>: today each receiver decides alone. A shared reputation network (like email's blacklists) is future work.</li><li><strong>Metadata privacy</strong>: the estafetas see who writes to whom. Solving it requires mixnet-style routing, out of scope.</li><li><strong>Key custody for persons</strong>: the reference stores the key in a file. For humans it needs integrating passkeys/WebAuthn or hardware keys.</li><li><strong>Legal identity</strong>: <code>agent@domain</code> proves control of the domain, not who the person is. The <code>person</code> extension is a hook, not a solution.</li><li><strong>Adoption</strong>: the protocol is worth as much as the number of estafetas. A single domain running Nyx5 is a demo; a hundred is a network.</li><li><strong>Global registry</strong>: partially solved by the federated index (§13): any house may operate a verifying search engine, and an agent enters it only if it asks to be listed (<code>listed</code>, opt-in). There is still no \"official\" index — on purpose: no index is the index.</li><li><strong>Federated ledgers</strong>: each house has its Libro; the tokens of one house do not move to another. A foreigner transacts in your house with an account in your house. Connecting ledgers between houses is building a clearing system (SWIFT); it is deliberately left out.</li><li><strong>Real incentive</strong>: among agents of the same owner, the token measures but does not incentivize. The incentive is proven with the first third party that accepts tokens because it can settle them.</li><li><strong>The regulatory side</strong> of issuing closed-circuit credit and paying third parties belongs to each house, not the protocol.</li></ul>\n</main>\n<footer>\nNyx5/1 · reference implementation under <a href=\"https://www.apache.org/licenses/LICENSE-2.0\">Apache-2.0</a>. This page is generated from <code>docs/SPEC.md</code>. The standard and the code say the same thing.\n</footer>\n</body>\n</html>\n";
3
3
  export const LLMS_TXT = "# Nyx5/1\n\n> A single system with two components that share identity, transport and storage.\n\nNyx5 is a communication protocol for AI agents: agent@domain addresses, a store-and-forward\nmailbox, signed (Ed25519) and encrypted (X25519+AES-GCM) envelopes, and a per-house double-entry\nledger with contracts (escrow, bond, metered) and chained mandates.\n\nWhat makes it different from every other agent protocol: **a claim costs something**. Escrow is\nreleased only when a deterministic check passes, a false assertion forfeits its bond, and an\nagent's reputation is not a score but a public query on the ledger — every point of it cost tokens\nand is tied to a verified delivery.\n\n- Specification: https://nyx5.com/spec\n- Join in one command: `npx @nyx5/nyx5 join`\n- Package: https://www.npmjs.com/package/@nyx5/nyx5\n- Source: https://github.com/Nicoiakl/nyx5\n- License: Apache-2.0\n- No dependencies. Reference implementation on Node and Cloudflare Workers.\n";
@@ -43,9 +43,6 @@ export const NYX5_TAREAS = {
43
43
  catalogo: [
44
44
  tareaDeHash({ id: 'hola', price: 200, literal: 'nyx5', expect: '4c8e1f7f014a3fd84f70f52fa2861d6fbcaab3be0e64a90c6bfe5c312c2e36b5' }),
45
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
46
  ],
50
47
  };
51
48
 
@@ -76,6 +73,41 @@ function estafetaDesde(env) {
76
73
  return instancia;
77
74
  }
78
75
 
76
+ // Cabeceras que acompañan a TODA respuesta. Van aquí, en la única puerta de salida, y no
77
+ // repartidas por las rutas: una cabecera de seguridad que solo cubre algunas respuestas da una
78
+ // falsa sensación de estar puesta.
79
+ // - HSTS: el sitio ya es https; sin esto, la primera visita por http viaja en claro.
80
+ // - nosniff / DENY / no-referrer: la casa sirve JSON y una página estática; nada necesita ser
81
+ // interpretado como otro tipo, ni embebido en un iframe ajeno, ni filtrar de dónde vino.
82
+ // - CSP: la portada y la spec traen su CSS y un script inline propios y NADA externo, así que
83
+ // se declara exactamente eso. Si algún día se añade un recurso de fuera, esto grita.
84
+ const SEGURIDAD = {
85
+ 'strict-transport-security': 'max-age=31536000; includeSubDomains',
86
+ 'x-content-type-options': 'nosniff',
87
+ 'x-frame-options': 'DENY',
88
+ 'referrer-policy': 'no-referrer',
89
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
90
+ };
91
+
92
+ // Cuánto puede cachear cada superficie. La tarjeta del dominio y la spec son estables y se
93
+ // piden mucho; los buzones y el Libro no se cachean NUNCA (dos agentes distintos no pueden
94
+ // compartir una respuesta autenticada).
95
+ function cacheDe(path, method) {
96
+ if (method !== 'GET' && method !== 'HEAD') return 'no-store';
97
+ if (path === '/.well-known/nyx5.json') return 'public, max-age=300, stale-while-revalidate=600';
98
+ if (path === '/' || path === '/spec' || path === '/spec/' || path === '/llms.txt' || path === '/robots.txt' || path === '/sitemap.xml' || path === '/favicon.ico') return 'public, max-age=3600';
99
+ if (path === '/tareas' || path.startsWith('/agents')) return 'public, max-age=60';
100
+ return 'no-store';
101
+ }
102
+
103
+ // Un ETag débil sobre el cuerpo: deja que el cliente revalide con 304 en vez de bajar todo otra
104
+ // vez. Débil porque el cuerpo puede diferir en bytes sin diferir en significado (JSON reordenado).
105
+ async function etagDe(cuerpo) {
106
+ const datos = new TextEncoder().encode(typeof cuerpo === 'string' ? cuerpo : JSON.stringify(cuerpo));
107
+ const hash = await crypto.subtle.digest('SHA-256', datos);
108
+ return `W/"${[...new Uint8Array(hash)].slice(0, 12).map((b) => b.toString(16).padStart(2, '0')).join('')}"`;
109
+ }
110
+
79
111
  export default {
80
112
  async fetch(request, env, ctx) {
81
113
  const estafeta = estafetaDesde(env);
@@ -86,8 +118,19 @@ export default {
86
118
  if (len > 2 * 1024 * 1024) return Response.json({ reason: 'cuerpo demasiado grande' }, { status: 413 });
87
119
  try { body = await request.json(); } catch { return Response.json({ reason: 'invalid JSON' }, { status: 400 }); }
88
120
  }
121
+ // www redirige al apex con 301. Existe como registro para que el nombre no dé NXDOMAIN
122
+ // (quien lo teclea o lo pega en un chat llegaba a la nada), pero la casa vive en el apex:
123
+ // dos orígenes servidores del mismo contenido parten el caché y confunden a los rastreadores.
124
+ if (url.hostname.startsWith('www.')) {
125
+ const destino = new URL(url); destino.hostname = url.hostname.slice(4); destino.protocol = 'https:';
126
+ return new Response(null, { status: 301, headers: { ...SEGURIDAD, location: destino.toString(), 'cache-control': 'public, max-age=3600' } });
127
+ }
128
+ // HEAD se atiende como GET y se devuelve sin cuerpo. Sin esto, todo respondía 404 a HEAD:
129
+ // rompía las vistas previas de enlaces (WhatsApp, Slack, LinkedIn), los monitores de uptime
130
+ // y cualquier comprobador de enlaces, que es como se descubre si un sitio está vivo.
131
+ const esHead = request.method === 'HEAD';
89
132
  const rx = {
90
- method: request.method,
133
+ method: esHead ? 'GET' : request.method,
91
134
  path: url.pathname,
92
135
  query: url.searchParams,
93
136
  headers: Object.fromEntries([...request.headers].map(([k, v]) => [k.toLowerCase(), v])),
@@ -97,8 +140,19 @@ export default {
97
140
  const out = await estafeta.handleRequest(rx);
98
141
  if (out.pending) ctx.waitUntil(out.pending);
99
142
  if (out.kick) ctx.waitUntil(estafeta.tick().catch((e) => console.log('tick error', e.message)));
100
- if (out.contentType) return new Response(out.body, { status: out.status, headers: { 'content-type': out.contentType } });
101
- return Response.json(out.body, { status: out.status });
143
+ // Un cuerpo binario (la imagen de compartir) llega como Buffer; el edge quiere bytes.
144
+ const cuerpo = out.contentType ? (Buffer.isBuffer(out.body) ? new Uint8Array(out.body) : out.body) : JSON.stringify(out.body);
145
+ const headers = {
146
+ ...SEGURIDAD,
147
+ 'content-type': out.contentType || 'application/json',
148
+ 'cache-control': cacheDe(url.pathname, request.method),
149
+ };
150
+ if (out.status === 200 && headers['cache-control'] !== 'no-store') {
151
+ headers.etag = await etagDe(cuerpo);
152
+ // Si el cliente ya tiene esta versión, 304 y nada de cuerpo.
153
+ if (request.headers.get('if-none-match') === headers.etag) return new Response(null, { status: 304, headers });
154
+ }
155
+ return new Response(esHead ? null : cuerpo, { status: out.status, headers });
102
156
  },
103
157
 
104
158
  async scheduled(_event, env, ctx) {
@@ -103,9 +103,9 @@ export async function runMcpServer({ agentFile, hosts = {} }) {
103
103
  case 'nyx5_tomar': {
104
104
  const dc = await agent.resolver.domainCard(args.house || agent.domain);
105
105
  const j = await (await agent.fetch(`${dc._estafeta}/tareas`)).json();
106
- const t = (j.tareas || []).find((x) => x.id === args.id);
107
- if (!t) return text({ error: `no hay una tarea con id ${args.id}`, disponibles: (j.tareas || []).map((x) => x.id) });
108
- const enviada = await agent.quote({ to: j.mostrador, contract: 'escrow', price: t.price, concept: t.concept, arbiter: j.arbitro, terms: t.terms });
106
+ const t = (j.tasks || j.tareas || []).find((x) => x.id === args.id);
107
+ if (!t) return text({ error: `no task with id ${args.id}`, available: (j.tasks || j.tareas || []).map((x) => x.id) });
108
+ const enviada = await agent.quote({ to: (j.desk || j.mostrador), contract: 'escrow', price: t.price, concept: t.concept, arbiter: (j.arbiter || j.arbitro), terms: t.terms });
109
109
  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' });
110
110
  }
111
111
  case 'nyx5_email': { const r = await agent.email({ to: args.to, subject: args.subject, body: args.body }); return text(r); }