@nyx5/nyx5 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +50 -7
  2. package/bin/nyx5.js +81 -2
  3. package/demo/edge-local.mjs +41 -0
  4. package/demo/x402-pagar.mjs +64 -0
  5. package/demo/x402-testnet.mjs +80 -0
  6. package/docs/ARQUITECTURA.md +354 -0
  7. package/docs/SPEC.md +414 -15
  8. package/examples/ci/nyx5-ejecutor.yml +82 -0
  9. package/examples/frameworks/README.md +55 -0
  10. package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
  11. package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
  12. package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
  13. package/examples/frameworks/claude-agent-sdk.mjs +48 -0
  14. package/examples/frameworks/crewai_nyx5.py +72 -0
  15. package/examples/frameworks/http.md +117 -0
  16. package/examples/frameworks/langgraph_nyx5.py +90 -0
  17. package/examples/frameworks/nyx5_http.py +255 -0
  18. package/examples/frameworks/openai-agents.mjs +41 -0
  19. package/examples/hola-mundo.mjs +4 -4
  20. package/migrations/0006_puente_remoto.sql +19 -0
  21. package/migrations/0007_indice_reputacion.sql +31 -0
  22. package/migrations/0008_notaria.sql +18 -0
  23. package/migrations/0009_contratos_estado.sql +4 -0
  24. package/package.json +4 -2
  25. package/server.json +2 -2
  26. package/src/correo/acciones.js +216 -0
  27. package/src/correo/adjuntos.js +185 -0
  28. package/src/correo/agente.js +370 -22
  29. package/src/correo/asistente.js +599 -0
  30. package/src/correo/ci.js +262 -0
  31. package/src/correo/cobro.js +164 -0
  32. package/src/correo/estafeta.js +1548 -58
  33. package/src/correo/ideas.js +154 -0
  34. package/src/correo/indice.js +178 -0
  35. package/src/correo/politica.js +280 -2
  36. package/src/correo/resolver.js +35 -11
  37. package/src/libro/contratos.js +116 -7
  38. package/src/libro/estado.js +150 -0
  39. package/src/libro/informe.js +147 -3
  40. package/src/libro/libro.js +83 -12
  41. package/src/libro/notaria.js +74 -0
  42. package/src/libro/verifica.js +239 -36
  43. package/src/nucleo/almacen-d1.js +143 -14
  44. package/src/nucleo/almacen.js +137 -13
  45. package/src/nucleo/boveda.js +37 -0
  46. package/src/nucleo/crypto.js +55 -12
  47. package/src/nucleo/keccak.js +118 -0
  48. package/src/nucleo/objetos.js +78 -0
  49. package/src/nucleo/secp256k1.js +228 -0
  50. package/src/plataformas/app-html.js +1 -1
  51. package/src/plataformas/home-html.js +1 -1
  52. package/src/plataformas/iconos.js +6 -0
  53. package/src/plataformas/node.js +28 -4
  54. package/src/plataformas/spec-html.js +1 -1
  55. package/src/plataformas/terms-html.js +2 -0
  56. package/src/plataformas/worker.js +52 -7
  57. package/src/puentes/ejecutor.js +118 -0
  58. package/src/puentes/herramientas.js +315 -0
  59. package/src/puentes/mcp-remoto.js +71 -0
  60. package/src/puentes/mcp.js +5 -99
  61. package/src/puentes/oauth.js +361 -0
  62. package/src/puentes/x402-pagador.js +248 -0
  63. package/src/puentes/x402.js +266 -0
  64. package/src/version.js +4 -0
@@ -0,0 +1,262 @@
1
+ // Nyx5/1 — El EJECUTOR: el aviso de la casa hacia CI, para los roles que trabajan sobre archivos.
2
+ //
3
+ // Por qué existe (NX-412, 27-oct-2026). `asistente.js` + `acciones.js` despiertan a los agentes que
4
+ // PIENSAN: viven en el Worker, contestan solos y sus cinco manos no tocan un disco. Un rol de la
5
+ // fábrica de Rosetta no cabe ahí: lee un repositorio, escribe archivos, corre python. Medido con
6
+ // Rosetta: 7 de sus 8 roles ejecutan. Para esos, la casa no hace el trabajo — AVISA a un ejecutor
7
+ // de fuera (hoy, un workflow de GitHub Actions) y el ejecutor contesta firmando con la llave del
8
+ // propio rol, que vive en los secrets de CI y nunca pasa por la casa ni por el chat.
9
+ //
10
+ // Tres decisiones que no son detalle:
11
+ //
12
+ // 1. **El aviso NO lleva el contenido.** Viaja sólo el sobre: quién escribió, qué tipo, qué
13
+ // proyecto, su id y la firma de la casa. El cuerpo va cifrado y lo abre el ejecutor con la llave
14
+ // del rol, leyendo `GET /mailbox/<local>` como cualquier agente. Así el invariante 8 queda
15
+ // intacto y el texto de un encargo no aparece en el registro de un tercero (el payload de un
16
+ // `repository_dispatch` queda en la pantalla de Actions, legible para quien tenga el repo).
17
+ //
18
+ // 2. **El destino se DERIVA, nunca se recibe.** La casa guarda un token con permiso de disparar CI;
19
+ // si además aceptara una URL libre, sería un diputado confundido que manda ese token a donde le
20
+ // digan. `PROVEEDORES` es una lista CERRADA, la URL se arma del `repo` validado por sintaxis, y
21
+ // el token no vuelve por ninguna lectura (como los secrets de Cloudflare: se rota, no se lee).
22
+ //
23
+ // 3. **El aviso no se cierra el sobre.** La casa avisa; el sobre sigue pendiente hasta que el
24
+ // ejecutor lo confirme. Un despacho perdido se reintenta con espera creciente y, agotados los
25
+ // intentos, el dueño recibe un aviso NOMBRADO: el `task` que estuvo seis semanas en silencio en
26
+ // el buzón de qa@ nació justo de un camino que fallaba sin decirlo.
27
+
28
+ import { parseAddress } from './resolver.js';
29
+ import { esEncargo, porGrupo } from './asistente.js';
30
+ import { signBytes } from '../nucleo/crypto.js';
31
+ import { proyectoDe } from './politica.js';
32
+
33
+ // Direcciones de sistema de esta casa: sus recibos y rebotes no son un encargo.
34
+ const SISTEMA = new Set(['postmaster', 'libro', 'verifica', 'tareas']);
35
+ // Los tipos que son «te encargo esto» o su resultado. Lo demás no despierta a un ejecutor.
36
+ export const TIPOS_DESPACHADOS = Object.freeze(['message', 'task', 'result']);
37
+ // Despachos por dirección y día UTC. Cada despacho arranca un job de CI, o sea PLATA: el cupo es
38
+ // el freno, igual que el tope mensual lo es para un cerebro.
39
+ export const CUPO_DESPACHOS_DIA = 20;
40
+ // Intentos por sobre y espera antes de cada reintento (el primero sale al entregar, sin esperar).
41
+ export const INTENTOS = 3;
42
+ export const ESPERAS = Object.freeze([60_000, 300_000, 1_500_000]);
43
+ // Lo que se guarda de un despacho fallido, recortado: un cuerpo de error ajeno no crece sin tope.
44
+ const RAZON_MAX = 300;
45
+
46
+ const REPO = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
47
+ const EVENTO = /^[a-z0-9][a-z0-9._-]{0,99}$/;
48
+ const dia = (ms) => new Date(ms).toISOString().slice(0, 10);
49
+
50
+ // Un token de CI no se cita nunca, ni en un log ni en el estado de un despacho. Nació de una
51
+ // lección medida en el pagador x402: un error que repetía la llave malformada entregaba 72 bits
52
+ // del secreto. Aquí el riesgo es el cuerpo que devuelve el proveedor, que no controlamos.
53
+ export function sinSecretos(texto, token = null) {
54
+ const crudo = String(texto == null ? '' : texto);
55
+ // El valor exacto primero: la redacción por FORMA no conoce la del token de un proveedor nuevo.
56
+ return (token ? crudo.split(String(token)).join('[token]') : crudo)
57
+ .replace(/gh[pousr]_[A-Za-z0-9_]{10,}/g, '[token]')
58
+ .replace(/github_pat_[A-Za-z0-9_]{10,}/g, '[token]')
59
+ .replace(/\bBearer\s+\S+/gi, 'Bearer [token]')
60
+ .slice(0, RAZON_MAX);
61
+ }
62
+
63
+ // La lista CERRADA de proveedores. Agregar uno es una decisión, no una configuración: se le da a la
64
+ // casa un token con permiso de arrancar trabajo, y el host al que ese token puede viajar se escribe
65
+ // aquí o no existe.
66
+ export const PROVEEDORES = Object.freeze({
67
+ github: Object.freeze({
68
+ url: (cfg) => `https://api.github.com/repos/${cfg.repo}/dispatches`,
69
+ cabeceras: (token) => ({
70
+ accept: 'application/vnd.github+json',
71
+ authorization: `Bearer ${token}`,
72
+ 'content-type': 'application/json',
73
+ 'user-agent': 'nyx5/1',
74
+ 'x-github-api-version': '2022-11-28',
75
+ }),
76
+ cuerpo: (cfg, aviso) => JSON.stringify({ event_type: cfg.event_type, client_payload: aviso }),
77
+ ok: (status) => status === 204,
78
+ }),
79
+ });
80
+
81
+ // Valida la configuración de un ejecutor. Falla CERRADO: lo que no se entiende no se guarda, y un
82
+ // campo ajeno se NOMBRA en vez de ignorarse — es autoridad de gasto, no un mensaje (el mismo
83
+ // motivo por el que `ALCANCE_MANDATO` es un vocabulario cerrado y contradice el invariante 7).
84
+ export function validarConfig(b = {}, previa = null) {
85
+ const conocidos = new Set(['provider', 'repo', 'event_type', 'owner', 'cupo_dia', 'enabled', 'token']);
86
+ const ajenos = Object.keys(b).filter((k) => !conocidos.has(k));
87
+ if (ajenos.length) return { error: `unknown field(s) for an executor: ${ajenos.join(', ')}. This house applies: ${[...conocidos].join(', ')}` };
88
+
89
+ const provider = b.provider ?? previa?.provider;
90
+ if (!provider || !Object.hasOwn(PROVEEDORES, provider)) {
91
+ return { error: `provider must be one of: ${Object.keys(PROVEEDORES).join(', ')}` };
92
+ }
93
+ const repo = String(b.repo ?? previa?.repo ?? '');
94
+ if (!REPO.test(repo) || repo.includes('..')) return { error: 'repo must be owner/name (letters, digits, dot, dash, underscore)' };
95
+ const event_type = String(b.event_type ?? previa?.event_type ?? 'nyx5');
96
+ if (!EVENTO.test(event_type)) return { error: 'event_type must be lowercase letters, digits, dot, dash or underscore (max 100)' };
97
+ const owner = String(b.owner ?? previa?.owner ?? '').toLowerCase();
98
+ try { if (!owner || !parseAddress(owner)) return { error: 'owner must be the address that gets told when CI never takes the work' }; }
99
+ catch { return { error: 'owner must be the address that gets told when CI never takes the work' }; }
100
+ const cupo = b.cupo_dia ?? previa?.cupo_dia ?? CUPO_DESPACHOS_DIA;
101
+ if (!Number.isInteger(cupo) || cupo < 1 || cupo > 500) return { error: 'cupo_dia must be a whole number between 1 and 500' };
102
+ if (b.enabled != null && typeof b.enabled !== 'boolean') return { error: 'enabled must be true or false' };
103
+ if (b.token != null && (typeof b.token !== 'string' || !b.token.trim() || b.token.length > 500)) {
104
+ return { error: 'token must be a non-empty string of at most 500 characters' };
105
+ }
106
+ if (!previa && !b.token) return { error: 'a token is needed to dispatch: it is write-only, it is never read back' };
107
+ return { provider, repo, event_type, owner, cupo_dia: cupo };
108
+ }
109
+
110
+ export function urlDe(cfg) { return PROVEEDORES[cfg.provider].url(cfg); }
111
+
112
+ // El aviso: nueve campos. `client_payload` de GitHub admite diez propiedades de primer nivel, y el
113
+ // décimo que podría faltar no vale lo que costaría (el contenido no viaja: ver cabecera).
114
+ export function avisoDe(est, local, env) {
115
+ const to = `${local}@${est.domain}`;
116
+ return {
117
+ nyx5: '1',
118
+ house: est.domain,
119
+ to,
120
+ envelope: env.id,
121
+ from: env.from,
122
+ type: env.type || 'message',
123
+ project: proyectoDe(env) || '',
124
+ at: env.created || new Date().toISOString(),
125
+ // La firma viaja DENTRO del payload porque un `repository_dispatch` no conserva cabeceras: es lo
126
+ // que deja al workflow comprobar que el disparo salió de esta casa y no de cualquiera con el token.
127
+ sig: signBytes(`ci:${env.id}:${to}`, est.keys),
128
+ };
129
+ }
130
+
131
+ // ¿Este sobre despierta a un ejecutor? Las razones son las mismas que ya filtran a un cerebro
132
+ // (revisiones del 13-sep y del 26-oct), por los mismos motivos: cada despacho arranca un job que se
133
+ // paga, y lo que la lista del buzón no eligió no manda trabajo.
134
+ export function despachable(est, local, rec, m) {
135
+ const env = m?.envelope || {};
136
+ // Correo que entró por el puente: sin firma no se sabe quién pide. No se despacha y no se cierra.
137
+ if (!env.signature || m.from_verified === false) return false;
138
+ let de; try { de = parseAddress(env.from); } catch { return false; }
139
+ if (de.domain === est.domain && SISTEMA.has(de.local)) return false;
140
+ // Un grupo despierta sólo si lo que llegó es un ENCARGO, y el criterio es UNA función compartida
141
+ // con el cerebro (`esEncargo` en asistente.js): dos definiciones de «esto es un encargo» se
142
+ // separan en cuanto alguien toque una, y entonces el mismo sobre despierta a un cerebro y no a un
143
+ // ejecutor. Lo cruza una prueba.
144
+ if (porGrupo(env) && !esEncargo(env)) return false;
145
+ if (!TIPOS_DESPACHADOS.includes(env.type)) return false;
146
+ if (env.from === `${local}@${est.domain}`) return true; // un recordatorio propio: despertarse a sí mismo
147
+ // Un `intro` de 4 KB o un avalado entran a un buzón por lista SIN estar en la lista (§9).
148
+ if (rec?.inbox?.policy === 'allowlist') {
149
+ const lista = new Set((rec.inbox.allowlist || []).filter((x) => typeof x === 'string').map((x) => x.toLowerCase()));
150
+ if (!lista.has(String(env.from).toLowerCase()) && !lista.has(de.domain)) return false;
151
+ }
152
+ return true;
153
+ }
154
+
155
+ // Despacha UN sobre. Devuelve `{ ok, status, reason, skipped }`. No confirma el sobre: eso es del
156
+ // ejecutor. El cupo se cobra al DESPACHAR (no al mirar: el cupo de despertares se gastaba sin
157
+ // despertar a nadie, medido el 26-oct) y el turno es por intento, para que dos relojes —dos
158
+ // isolates del edge— no arranquen dos veces el mismo job.
159
+ export async function despachar(est, local, env, { cfg = null, nowMs = Date.now() } = {}) {
160
+ const conf = cfg || await est.store.kvGet('ci', local);
161
+ if (!conf?.enabled) return { ok: false, skipped: 'disabled' };
162
+ const clave = `${local}:${env.id}`;
163
+ const estado = await est.store.kvGet('ci-despacho', clave);
164
+ if (estado?.estado === 'ok') return { ok: false, skipped: 'already dispatched' };
165
+ const intentos = Number(estado?.intentos || 0);
166
+ if (intentos >= INTENTOS) return { ok: false, skipped: 'attempts exhausted' };
167
+ // Un turno por (sobre, intento): dos relojes que vean el mismo pendiente no disparan dos jobs.
168
+ if (!await est.store.kvPutIfAbsent('ci-turno', `${clave}:${intentos}`, { at: new Date(nowMs).toISOString() }, nowMs + 15 * 60_000, nowMs)) {
169
+ return { ok: false, skipped: 'another clock took this turn' };
170
+ }
171
+ // El token ANTES del cupo: sin token no sale nada a la red, así que cobrar el despacho del día
172
+ // sería gastar el freno de plata en algo que nunca pasó.
173
+ const sello = await est.store.kvGet('boveda', `ci:${local}`);
174
+ let token = null;
175
+ try { token = est.boveda && sello ? est.boveda.abrir(sello.sellado, `ci:${local}`).token : null; } catch { token = null; }
176
+ if (!token) {
177
+ const razon = 'this house has no readable CI token for that address';
178
+ await est.store.kvPut('ci-despacho', clave, { intentos: INTENTOS, ultimo: new Date(nowMs).toISOString(), estado: 'fallo', razon }, nowMs + 7 * 24 * 3600_000);
179
+ await avisarAgotado(est, local, env, conf, razon);
180
+ return { ok: false, reason: razon };
181
+ }
182
+
183
+ const cupoClave = `${local}:${dia(nowMs)}`;
184
+ const usado = await est.store.kvIncrement('ci-cupo', cupoClave, nowMs + 36 * 3600_000, nowMs, 1);
185
+ if (usado > conf.cupo_dia) {
186
+ const razon = `the daily dispatch quota (${conf.cupo_dia}) for ${dia(nowMs)} is used up`;
187
+ await est.store.kvPut('ci-despacho', clave, { intentos, ultimo: new Date(nowMs).toISOString(), estado: 'cupo', razon }, nowMs + 7 * 24 * 3600_000);
188
+ est.log(`ci ${local}: ${razon}`);
189
+ return { ok: false, skipped: 'quota', reason: razon };
190
+ }
191
+
192
+ const prov = PROVEEDORES[conf.provider];
193
+ let status = 0, razon = '';
194
+ try {
195
+ const r = await est.fetch(urlDe(conf), {
196
+ method: 'POST',
197
+ headers: prov.cabeceras(token),
198
+ body: prov.cuerpo(conf, avisoDe(est, local, env)),
199
+ signal: AbortSignal.timeout(10_000),
200
+ });
201
+ status = r.status || 0;
202
+ if (!prov.ok(status)) razon = sinSecretos(`${status} ${await r.text().catch(() => '')}`, token);
203
+ } catch (e) {
204
+ razon = sinSecretos(`${e.message}${e.cause?.message ? ` / ${e.cause.message}` : ''}`, token);
205
+ }
206
+ const ok = !razon;
207
+ const nuevos = intentos + 1;
208
+ await est.store.kvPut('ci-despacho', clave, {
209
+ intentos: nuevos, ultimo: new Date(nowMs).toISOString(), estado: ok ? 'ok' : 'fallo', status, razon,
210
+ }, nowMs + 7 * 24 * 3600_000);
211
+ if (ok) {
212
+ est.log(`ci ${local}: sobre ${env.id} despachado a ${conf.provider} ${conf.repo} (${conf.event_type})`);
213
+ await est._evento?.('ci_dispatched', `${local}@${est.domain}`, { provider: conf.provider, repo: conf.repo, envelope: env.id });
214
+ } else {
215
+ est.log(`ci ${local}: despacho ${nuevos}/${INTENTOS} del sobre ${env.id} falló: ${razon}`);
216
+ if (nuevos >= INTENTOS) await avisarAgotado(est, local, env, conf, razon);
217
+ }
218
+ return { ok, status, reason: razon || undefined };
219
+ }
220
+
221
+ // Agotados los intentos, el dueño se entera. Un sobre que nadie toma no se queda en silencio: es
222
+ // exactamente el defecto que costó seis semanas en el buzón de qa@. El aviso se reserva con
223
+ // `kvPutIfAbsent` para que dos relojes no manden dos.
224
+ async function avisarAgotado(est, local, env, conf, razon) {
225
+ if (!await est.store.kvPutIfAbsent('ci-aviso', `${local}:${env.id}`, { at: new Date().toISOString() }, Date.now() + 30 * 24 * 3600_000)) return;
226
+ await est._systemSend('postmaster', [conf.owner], {
227
+ type: 'message',
228
+ thread: env.thread || env.id,
229
+ content: {
230
+ media: 'text/plain',
231
+ body: `The executor of ${local}@${est.domain} never took envelope ${env.id} from ${env.from}. ${INTENTOS} dispatches to ${conf.provider} ${conf.repo} failed: ${razon}. The envelope is still in the mailbox, unanswered; nothing was lost. Fix the executor or the token and dispatch it again with POST /admin/executors/${local}/dispatch.`,
232
+ },
233
+ }).catch((e) => est.log(`ci ${local}: no se pudo avisar a ${conf.owner}: ${e.message}`));
234
+ }
235
+
236
+ // El reloj: recorre los ejecutores encendidos y reintenta lo que no se despachó. El primer intento
237
+ // sale al entregar (desde `_push`); esto es la red que atrapa lo que se perdió.
238
+ export async function atenderEjecutores(est, { nowMs = Date.now(), maxPorTick = 20 } = {}) {
239
+ if (!est.store.kvGet) return 0;
240
+ const indice = (await est.store.kvGet('ci', '_indice')) || [];
241
+ let n = 0;
242
+ for (const local of indice) {
243
+ const cfg = await est.store.kvGet('ci', local);
244
+ if (!cfg?.enabled) continue;
245
+ const rec = await est.store.getAgent(local);
246
+ if (!rec || rec.revoked) continue;
247
+ for (const m of await est.store.listMail(local)) {
248
+ if (n >= maxPorTick) return n;
249
+ const env = m.envelope || {};
250
+ if (!despachable(est, local, rec, m)) continue;
251
+ const estado = await est.store.kvGet('ci-despacho', `${local}:${env.id}`);
252
+ if (estado?.estado === 'ok') continue;
253
+ const intentos = Number(estado?.intentos || 0);
254
+ if (intentos >= INTENTOS) continue;
255
+ // La espera crece con el número de intentos: un CI caído no se golpea cada minuto.
256
+ if (intentos > 0 && nowMs - Date.parse(estado?.ultimo || 0) < ESPERAS[Math.min(intentos - 1, ESPERAS.length - 1)]) continue;
257
+ const r = await despachar(est, local, env, { cfg, nowMs });
258
+ if (!r.skipped) n++;
259
+ }
260
+ }
261
+ return n;
262
+ }
@@ -0,0 +1,164 @@
1
+ // Nyx5/1 — Pedido de pago por transferencia (NX-502): dinero REAL, fuera del Libro.
2
+ //
3
+ // Un agente le pide a otro una transferencia bancaria (Chile: CLP o USD) con un sobre CIFRADO cuyo
4
+ // contenido es el cobro: monto, moneda, nombre, RUT, banco, tipo y número de cuenta, referencia.
5
+ // Quien pagó contesta con una confirmación (la referencia bancaria) en el hilo del pedido.
6
+ //
7
+ // Tres reglas que este módulo hace cumplir y que no se relajan:
8
+ // 1. Los datos bancarios NUNCA pasan por la casa en claro: el sobre va cifrado (`encrypt: 'required'`)
9
+ // y jamás a una dirección cuya llave guarda la casa (`custody.keys = house`), porque la casa
10
+ // descifraría en su nombre. Tampoco se guardan en la tarjeta ni en la ficha: cada pedido los lleva.
11
+ // 2. La casa NO mueve dinero ni lo ve: anota `payment_requested` / `payment_confirmed` como eventos
12
+ // (instrumentación) a partir de una extensión firmada EN CLARO que sólo lleva `{ kind, request_id,
13
+ // currency }`. Ni monto ni cuenta: `eventoDeCobro` copia campo por campo, no el objeto.
14
+ // 3. El RUT se valida aquí (módulo 11) y en la app: la casa no puede hacerlo porque va cifrado
15
+ // (invariante 8). Falla CERRADO ante cualquier carácter que no sea dígito, punto, guion o K.
16
+ //
17
+ // Los textos de `TEXTOS` los aprobó Nicholas el 14-sep-2026 (plan/textos-app-borrador.md, NX-502):
18
+ // se usan literales en la app y en la herramienta. Los demás mensajes de validación los lee un
19
+ // modelo (herramienta MCP) o un formulario que ya acota el campo; no son texto aprobado.
20
+
21
+ import { limpio } from './politica.js';
22
+
23
+ export const MEDIA_COBRO = 'application/nyx5.cobro+json';
24
+ export const MEDIA_COBRO_CONFIRMACION = 'application/nyx5.cobro-confirmacion+json';
25
+ // Extensión en claro, firmada por el remitente: lo único del cobro que la casa ve.
26
+ export const EXT_COBRO = 'urn:nyx5:ext:cobro';
27
+ export const MONEDAS = ['CLP', 'USD'];
28
+ export const TIPOS_CUENTA = ['checking', 'savings', 'vista'];
29
+ export const EVENTOS_COBRO = { request: 'payment_requested', confirmation: 'payment_confirmed' };
30
+
31
+ // Textos aprobados (14-sep-2026). No se cambian sin volver a mostrarlos.
32
+ export const TEXTOS = {
33
+ rut: 'RUT looks wrong (check the digit).',
34
+ monto_clp: 'Amount must be a whole number of pesos.',
35
+ };
36
+
37
+ const LIMITES = { name: 120, bank: 60, account_number: 30, reference: 140, bank_reference: 80 };
38
+ const ID_PEDIDO = /^[A-Za-z0-9._:-]{8,128}$/;
39
+
40
+ // ---------- RUT ----------
41
+ // Dígito verificador, módulo 11: multiplicadores 2..7 de derecha a izquierda; 11 -> 0, 10 -> K.
42
+ export function digitoVerificador(cuerpo) {
43
+ let suma = 0, m = 2;
44
+ for (let i = cuerpo.length - 1; i >= 0; i--) { suma += Number(cuerpo[i]) * m; m = m === 7 ? 2 : m + 1; }
45
+ const r = 11 - (suma % 11);
46
+ return r === 11 ? '0' : r === 10 ? 'K' : String(r);
47
+ }
48
+ // Normaliza `12.345.678-5`, `12345678-5`, `123456785` a `12345678-5`; null si no es un RUT válido.
49
+ // Sólo se aceptan dígitos, puntos, un guion y K: un carácter de control o invisible NO se limpia,
50
+ // invalida (un RUT con basura adentro no es un RUT con el que alguien pueda transferir).
51
+ export function normalizarRut(rut) {
52
+ if (typeof rut !== 'string') return null;
53
+ const s = rut.trim().toUpperCase().replace(/\./g, '');
54
+ const m = /^(\d{7,8})-?([0-9K])$/.exec(s);
55
+ if (!m) return null;
56
+ const [, cuerpo, dv] = m;
57
+ return digitoVerificador(cuerpo) === dv ? `${cuerpo}-${dv}` : null;
58
+ }
59
+ export const rutValido = (rut) => normalizarRut(rut) !== null;
60
+ // Para mostrar: `12.345.678-5`.
61
+ export function formatearRut(rut) {
62
+ const n = normalizarRut(rut); if (!n) return String(rut ?? '');
63
+ const [cuerpo, dv] = n.split('-');
64
+ return `${cuerpo.replace(/\B(?=(\d{3})+(?!\d))/g, '.')}-${dv}`;
65
+ }
66
+
67
+ // ---------- monto ----------
68
+ // CLP: entero en pesos (número o texto de dígitos). USD: texto con hasta dos decimales.
69
+ // Devuelve el monto normalizado (CLP: Number; USD: string como "12.50") o null.
70
+ export function normalizarMonto(amount, currency) {
71
+ const s = typeof amount === 'number' ? (Number.isFinite(amount) ? String(amount) : '') : typeof amount === 'string' ? amount.trim() : '';
72
+ if (currency === 'CLP') {
73
+ if (!/^\d{1,15}$/.test(s)) return null;
74
+ const n = Number(s);
75
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
76
+ }
77
+ if (currency === 'USD') {
78
+ if (!/^\d{1,15}(\.\d{1,2})?$/.test(s)) return null;
79
+ return Number(s) > 0 ? s : null;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ // ---------- el pedido ----------
85
+ // { ok: true, cobro } con todos los campos normalizados, o { ok: false, field, error }.
86
+ export function validarCobro(x) {
87
+ const mal = (field, error) => ({ ok: false, field, error });
88
+ if (!x || typeof x !== 'object' || Array.isArray(x)) return mal('cobro', 'the payment request must be an object');
89
+ const currency = typeof x.currency === 'string' ? x.currency.trim().toUpperCase() : 'CLP';
90
+ if (!MONEDAS.includes(currency)) return mal('currency', `currency must be one of ${MONEDAS.join(', ')}`);
91
+ const amount = normalizarMonto(x.amount, currency);
92
+ if (amount === null) return mal('amount', currency === 'CLP' ? TEXTOS.monto_clp : 'Amount must be a number of dollars with up to two decimals.');
93
+ const rut = normalizarRut(x.rut);
94
+ if (!rut) return mal('rut', TEXTOS.rut);
95
+ const texto = (k, requerido) => {
96
+ const v = x[k] == null ? '' : typeof x[k] === 'string' ? limpio(x[k], LIMITES[k] + 1) : null;
97
+ if (v === null) return { error: `${k} must be text` };
98
+ if (requerido && !v) return { error: `${k} is required` };
99
+ if (v.length > LIMITES[k]) return { error: `${k}: up to ${LIMITES[k]} characters` };
100
+ return { v };
101
+ };
102
+ const name = texto('name', true); if (name.error) return mal('name', name.error);
103
+ const bank = texto('bank', true); if (bank.error) return mal('bank', bank.error);
104
+ const account_type = typeof x.account_type === 'string' ? x.account_type.trim().toLowerCase() : '';
105
+ if (!TIPOS_CUENTA.includes(account_type)) return mal('account_type', `account_type must be one of ${TIPOS_CUENTA.join(', ')}`);
106
+ const account_number = typeof x.account_number === 'string' || typeof x.account_number === 'number' ? String(x.account_number).replace(/\s+/g, '') : '';
107
+ if (!/^[0-9-]{1,30}$/.test(account_number)) return mal('account_number', 'account_number: digits and dashes, up to 30 characters');
108
+ const reference = texto('reference', false); if (reference.error) return mal('reference', reference.error);
109
+ let request_id = x.request_id == null ? undefined : x.request_id;
110
+ if (request_id !== undefined && (typeof request_id !== 'string' || !ID_PEDIDO.test(request_id))) return mal('request_id', 'request_id must be [A-Za-z0-9._:-]{8,128}');
111
+ return { ok: true, cobro: { ...(request_id ? { request_id } : {}), amount, currency, name: name.v, rut, bank: bank.v, account_type, account_number, reference: reference.v } };
112
+ }
113
+ // Igual que validarCobro, pero lanza: para el cliente y la herramienta.
114
+ export function cobroValido(x) {
115
+ const r = validarCobro(x);
116
+ if (!r.ok) throw Object.assign(new Error(r.error), { field: r.field });
117
+ return r.cobro;
118
+ }
119
+
120
+ // ---------- la confirmación ----------
121
+ export function validarConfirmacion(x) {
122
+ const mal = (field, error) => ({ ok: false, field, error });
123
+ if (!x || typeof x !== 'object' || Array.isArray(x)) return mal('confirmacion', 'the confirmation must be an object');
124
+ if (typeof x.request_id !== 'string' || !ID_PEDIDO.test(x.request_id)) return mal('request_id', 'request_id must be [A-Za-z0-9._:-]{8,128}');
125
+ const bank_reference = typeof x.bank_reference === 'string' ? limpio(x.bank_reference, LIMITES.bank_reference + 1) : null;
126
+ if (bank_reference === null || !bank_reference) return mal('bank_reference', 'bank_reference is required');
127
+ if (bank_reference.length > LIMITES.bank_reference) return mal('bank_reference', `bank_reference: up to ${LIMITES.bank_reference} characters`);
128
+ return { ok: true, confirmacion: { request_id: x.request_id, bank_reference } };
129
+ }
130
+ export function confirmacionValida(x) {
131
+ const r = validarConfirmacion(x);
132
+ if (!r.ok) throw Object.assign(new Error(r.error), { field: r.field });
133
+ return r.confirmacion;
134
+ }
135
+
136
+ // ---------- lo que la casa ve ----------
137
+ // La extensión en claro que viaja firmada con el sobre. Sólo tres campos, y ninguno es dinero.
138
+ export function extensionDeCobro(kind, { request_id, currency }) {
139
+ return { [EXT_COBRO]: { kind, request_id, currency } };
140
+ }
141
+ // El evento que la casa anota al recibir un sobre con la extensión: { name, data } o null si la
142
+ // extensión no tiene la forma exacta. Se copian los campos UNO A UNO: si el remitente mete
143
+ // `amount` o `rut` en la extensión, no llegan al diario de eventos.
144
+ export function eventoDeCobro(env) {
145
+ const x = env?.extensions?.[EXT_COBRO];
146
+ if (!x || typeof x !== 'object' || Array.isArray(x)) return null;
147
+ const name = EVENTOS_COBRO[x.kind];
148
+ if (!name) return null;
149
+ if (typeof x.request_id !== 'string' || !ID_PEDIDO.test(x.request_id)) return null;
150
+ if (!MONEDAS.includes(x.currency)) return null;
151
+ return { name, data: { request_id: x.request_id, currency: x.currency } };
152
+ }
153
+
154
+ // ¿Puede esta tarjeta recibir un cobro? Devuelve la razón por la que no, o null.
155
+ // No a la casa (libro@, postmaster@...), no a un grupo, no sin llave de cifrado, y no a una
156
+ // dirección cuya llave guarda la casa: ahí la casa descifra, y los datos bancarios pasarían por
157
+ // ella en claro (decisión del ítem NX-502: «un cobro a claude.nico se rechaza en el cliente»).
158
+ export function razonParaNoCobrar(card) {
159
+ if (!card) return 'the recipient card could not be resolved';
160
+ if (card.group) return `${card.address} is a group: a payment request goes to one person`;
161
+ if (card.custody?.keys === 'house') return `${card.address} is an address whose key the house holds (a connected Claude): the house would read the bank details. Send the request to ${card.delegation?.by || 'its owner'} instead`;
162
+ if (!card.enc) return `${card.address} publishes no encryption key: a payment request only travels encrypted`;
163
+ return null;
164
+ }