@nyx5/nyx5 0.9.1 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyx5/nyx5",
3
- "version": "0.9.1",
3
+ "version": "0.9.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",
package/server.json CHANGED
@@ -17,13 +17,13 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "0.9.1",
20
+ "version": "0.9.3",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "registryBaseUrl": "https://registry.npmjs.org",
25
25
  "identifier": "@nyx5/nyx5",
26
- "version": "0.9.1",
26
+ "version": "0.9.3",
27
27
  "runtimeHint": "npx",
28
28
  "transport": {
29
29
  "type": "stdio"
@@ -90,6 +90,17 @@ export class Agent {
90
90
  // Proyecto y rol viajan como extensión firmada: el buzón del otro lado filtra por proyecto.
91
91
  // Un proyecto que no normaliza a un nombre (sólo invisibles, o `__proto__`/`constructor`) se
92
92
  // rechaza aquí, con nombre: antes viajaba como `project: null` y el remitente creía haberlo etiquetado.
93
+ // Un sobre sin NADA dentro no es un mensaje. Nació de un defecto medido (4-nov-2026): una
94
+ // llamada con `text` en vez de `body` construyó `content = { media, body: undefined }`, la
95
+ // clave desapareció al serializar, la casa lo aceptó y el destinatario recibió un sobre que
96
+ // no se puede abrir — mientras el remitente leía `id` y `encrypted: true`. El puente ya
97
+ // comprueba lo que su esquema declara obligatorio, pero ESE guardia sólo cubre a su
98
+ // consumidor: aquí es la fuente, y la usan el CLI, las rutinas y cualquier código.
99
+ // Con precisión, no con cobertura: un sobre que lleva adjuntos y ningún texto SÍ es un
100
+ // mensaje («aquí van los archivos»), y un body `0`, `false` o `''` es contenido.
101
+ if ((body === undefined || body === null) && !(attachments && attachments.length)) {
102
+ throw new Error('send: body is required (an envelope with no body and no attachments carries nothing). Nothing was sent.');
103
+ }
93
104
  const proyecto = project ? nombreDeProyecto(project) : null;
94
105
  if (project && !proyecto) throw new Error(`invalid project name: ${JSON.stringify(String(project).slice(0, 40))} (empty after normalization, or a reserved word)`);
95
106
  if (proyecto || role) extensions = { ...(extensions || {}), [EXT_PROYECTO]: { ...(proyecto ? { project: proyecto } : {}), ...(role ? { role: String(role).trim().slice(0, 40) } : {}) } };
@@ -3,6 +3,7 @@
3
3
  // Orden de resolución del dominio (equivalente al registro MX del correo):
4
4
  // 1. Override local (hosts.json / opción hosts) -> pruebas y redes privadas
5
5
  // 2. DNS TXT en _nyx5.<dominio> -> ancla pública de confianza
6
+ // 2b. el MISMO TXT por DNS-sobre-HTTPS -> cuando UDP/53 no contesta
6
7
  // 3. https://<dominio>/.well-known/nyx5.json -> fallback sin DNS
7
8
  //
8
9
  // Cadena de confianza: clave del dominio (anclada en DNS o pineada) -> certifica la tarjeta
@@ -11,6 +12,16 @@
11
12
  import dns from 'node:dns/promises';
12
13
  import { verifyObject } from '../nucleo/crypto.js';
13
14
 
15
+ // El ancla del DNS viaja por DOS transportes. Nace de un defecto medido (4-nov-2026): en una red
16
+ // sin UDP/53 — un contenedor, un runner de CI, una red corporativa, el propio Mac de Nicholas —
17
+ // `dns.resolveTxt` da ETIMEOUT y el agente no podía unirse, aunque el registro TXT estuviera
18
+ // perfecto. Pedir el MISMO `_nyx5.<dominio>` por HTTPS no degrada el ancla: sigue siendo el
19
+ // registro del DNS, y sobre TLS a un resolutor conocido es MÁS difícil de falsear que el UDP
20
+ // sin cifrar ni autenticar que ya creíamos. Lo que NO se hace nunca es caer a well-known
21
+ // porque el DNS no contestó: eso es lo que un atacante conseguiría bloqueándolo.
22
+ const SIN_REGISTRO = Symbol('sin-registro');
23
+ export const DOH_POR_DEFECTO = 'https://cloudflare-dns.com/dns-query';
24
+
14
25
  export function parseAddress(address) {
15
26
  const m = /^([a-z0-9][a-z0-9._-]{0,63})@([a-z0-9.-]+)$/i.exec(String(address || ''));
16
27
  if (!m) throw new Error(`invalid address: ${address}`);
@@ -27,7 +38,7 @@ export function parseTxtRecord(txt) {
27
38
  }
28
39
 
29
40
  export class Resolver {
30
- constructor({ hosts = {}, fetchImpl = globalThis.fetch, cacheTtlMs = 5 * 60 * 1000, pins = {}, timeoutMs = 5000, onPin = null, self = null, auth = null } = {}) {
41
+ constructor({ hosts = {}, fetchImpl = globalThis.fetch, cacheTtlMs = 5 * 60 * 1000, pins = {}, timeoutMs = 5000, onPin = null, self = null, auth = null, dnsImpl = dns, doh = DOH_POR_DEFECTO } = {}) {
31
42
  // auth = { domain, header(method, path) }: un CLIENTE (Agent) firma la petición a su propia casa
32
43
  // cuando una tarjeta no aparece sin autenticar; así un contacto resuelve a un agente secreto.
33
44
  this.auth = auth;
@@ -41,6 +52,10 @@ export class Resolver {
41
52
  // (Cloudflare corta la conexión: 522) y en cualquier runtime sería una vuelta inútil.
42
53
  // self = { domain, estafeta, domainCard(), agentCard(local) }
43
54
  this.self = self;
55
+ // El ancla del DNS por DOS transportes. `dnsImpl` se inyecta en las pruebas para reproducir
56
+ // una red sin UDP/53; `doh` es el resolutor por HTTPS (null lo apaga).
57
+ this.dns = dnsImpl;
58
+ this.doh = doh;
44
59
  this.cache = new Map();
45
60
  }
46
61
 
@@ -66,22 +81,56 @@ export class Resolver {
66
81
  domain = domain.toLowerCase();
67
82
  if (this.hosts[domain]) return { source: 'override', ...this.hosts[domain] };
68
83
  try {
69
- const records = await dns.resolveTxt(`_nyx5.${domain}`);
70
- for (const chunks of records) {
71
- const rec = parseTxtRecord(chunks.join(''));
72
- if (rec.v === 'nyx51' && rec.url) return { source: 'dns', url: rec.url, sig: rec.sig };
73
- }
84
+ const records = await this.dns.resolveTxt(`_nyx5.${domain}`);
85
+ const ancla = this._anclaEnRegistros(records);
86
+ if (ancla) return { source: 'dns', ...ancla };
74
87
  } catch (e) {
75
88
  // "No existe el registro" (NXDOMAIN/sin datos) es el fallback legítimo a well-known.
76
89
  // Cualquier OTRA falla de DNS (red, timeout, servfail) NO degrada el ancla en silencio:
77
90
  // un atacante que bloquee la respuesta DNS no puede empujarnos a well-known + TOFU.
91
+ // Antes de rendirse se pide el MISMO registro por HTTPS (ver DOH_POR_DEFECTO): el que está
92
+ // detrás de una red sin UDP/53 se une, y el que bloquea el DNS sigue sin conseguir nada.
78
93
  if (e.code !== 'ENOTFOUND' && e.code !== 'ENODATA') {
79
- throw Object.assign(new Error(`DNS unavailable for ${domain}: ${e.message}`), { permanent: false });
94
+ const porHttps = await this._anclaPorHttps(domain, e); // lanza si tampoco se pudo
95
+ if (porHttps !== SIN_REGISTRO) return { source: 'doh', ...porHttps };
80
96
  }
81
97
  }
82
98
  return { source: 'well-known', url: `https://${domain}` };
83
99
  }
84
100
 
101
+ // UNA lectura del registro TXT, la use el transporte que la use.
102
+ _anclaEnRegistros(records) {
103
+ for (const chunks of records || []) {
104
+ const rec = parseTxtRecord(Array.isArray(chunks) ? chunks.join('') : String(chunks));
105
+ if (rec.v === 'nyx51' && rec.url) return { url: rec.url, sig: rec.sig };
106
+ }
107
+ return null;
108
+ }
109
+
110
+ // El mismo `_nyx5.<dominio>` TXT por DNS-sobre-HTTPS. Devuelve el ancla, o SIN_REGISTRO cuando
111
+ // el resolutor afirma que no existe (y entonces well-known es legítimo, igual que un NXDOMAIN
112
+ // por UDP). Si NO se pudo preguntar, relanza la falla dura: no inventamos una respuesta.
113
+ async _anclaPorHttps(domain, causa) {
114
+ const duro = (msg) => Object.assign(new Error(`DNS unavailable for ${domain}: ${msg}`), { permanent: false });
115
+ if (!this.doh) throw duro(causa?.message || 'no DNS');
116
+ let cuerpo;
117
+ try {
118
+ const url = `${this.doh}?name=${encodeURIComponent(`_nyx5.${domain}`)}&type=TXT`;
119
+ const res = await this.fetch(url, { headers: { accept: 'application/dns-json' }, signal: AbortSignal.timeout(this.timeoutMs) });
120
+ if (!res.ok) throw new Error(`DoH -> ${res.status}`);
121
+ cuerpo = await res.json();
122
+ } catch (e) {
123
+ throw duro(`${causa?.message || 'no DNS'} (y por HTTPS: ${e.message})`);
124
+ }
125
+ // Status 3 = NXDOMAIN. Es una afirmación de que no hay registro, no una falla.
126
+ if (cuerpo?.Status === 3) return SIN_REGISTRO;
127
+ if (cuerpo?.Status !== 0) throw duro(`${causa?.message || 'no DNS'} (y por HTTPS: status ${cuerpo?.Status})`);
128
+ // El TXT viene entrecomillado y puede llegar partido en varias cadenas: se unen, como en UDP.
129
+ const textos = (cuerpo.Answer || []).filter((a) => a.type === 16)
130
+ .map((a) => String(a.data).replace(/"\s*"/g, '').replace(/^"|"$/g, ''));
131
+ return this._anclaEnRegistros(textos.map((t) => [t])) || SIN_REGISTRO;
132
+ }
133
+
85
134
  // Tarjeta del dominio, verificada y (si corresponde) contrastada con la clave anclada.
86
135
  async domainCard(domain) {
87
136
  domain = domain.toLowerCase();
@@ -161,6 +161,19 @@ export function instrucciones({ remoto = false, address = null, contactos = [] }
161
161
  export async function llamar(agent, name, args = {}, { permitidas = null, esperaMaxS = 90 } = {}) {
162
162
  const text = (v) => ({ content: [{ type: 'text', text: typeof v === 'string' ? v : JSON.stringify(v, null, 2) }] });
163
163
  if (permitidas && !permitidas.has(name)) return { ...text(`${name} is not available here: this is a messages-only address (no ledger, no payments)`), isError: true };
164
+ // Lo que el esquema declara OBLIGATORIO se comprueba aquí, antes de actuar. Nació de un defecto
165
+ // medido (4-nov-2026): una llamada a nyx5_send con `text` en vez de `body` —el nombre está en el
166
+ // esquema, no en la memoria de nadie— SALIÓ, y devolvió `id` y `encrypted: true`. O sea un éxito
167
+ // reportado con un sobre que el destinatario no puede abrir: silencio, no error. 22 de las 31
168
+ // herramientas declaran campos obligatorios y ninguna los comprobaba. Va en el despachador y no
169
+ // en cada caso a propósito: un guardia por herramienta cubre la de hoy (memoria del proyecto:
170
+ // «guardia en el escritor, no en la fuente»). Y dice QUÉ falta y qué sí se recibió, porque el
171
+ // que se equivoca de nombre no puede adivinar cuál era.
172
+ const falta = (TOOLS.find((t) => t.name === name)?.inputSchema?.required || []).filter((k) => args[k] === undefined || args[k] === null);
173
+ if (falta.length) {
174
+ const sobran = Object.keys(args).filter((k) => k !== 'undefined');
175
+ return { ...text(`${name}: missing required ${falta.map((k) => `\`${k}\``).join(', ')}. You passed: ${sobran.length ? sobran.join(', ') : '(nothing)'}. Nothing was sent.`), isError: true };
176
+ }
164
177
  switch (name) {
165
178
  case 'nyx5_send': { const r = await agent.send({ to: args.to, body: args.body, type: args.type, thread: args.thread, inReplyTo: args.in_reply_to, encrypt: args.encrypt ?? true, project: args.project, role: args.role, summary: args.summary, extensions: args.aval ? { 'urn:nyx5:ext:aval': args.aval } : undefined }); return text({ id: r.id, jobs: r.jobs, encrypted: r.encrypted, summary: r.envelope?.extensions?.['urn:nyx5:ext:resumen']?.text ?? null }); }
166
179
  case 'nyx5_inbox': {
package/src/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // La versión que el servidor declara de sí mismo. Un literal y no una lectura de package.json
2
2
  // porque este módulo también corre en el edge, donde no hay sistema de archivos. Que no se
3
3
  // desincronice lo cuida test/documentacion.test.js: si package.json cambia y esto no, falla.
4
- export const VERSION = '0.9.1';
4
+ export const VERSION = '0.9.3';