@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.
- package/README.md +50 -7
- package/bin/nyx5.js +81 -2
- package/demo/edge-local.mjs +41 -0
- package/demo/x402-pagar.mjs +64 -0
- package/demo/x402-testnet.mjs +80 -0
- package/docs/ARQUITECTURA.md +354 -0
- package/docs/SPEC.md +414 -15
- package/examples/ci/nyx5-ejecutor.yml +82 -0
- package/examples/frameworks/README.md +55 -0
- package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
- package/examples/frameworks/claude-agent-sdk.mjs +48 -0
- package/examples/frameworks/crewai_nyx5.py +72 -0
- package/examples/frameworks/http.md +117 -0
- package/examples/frameworks/langgraph_nyx5.py +90 -0
- package/examples/frameworks/nyx5_http.py +255 -0
- package/examples/frameworks/openai-agents.mjs +41 -0
- package/examples/hola-mundo.mjs +4 -4
- package/migrations/0006_puente_remoto.sql +19 -0
- package/migrations/0007_indice_reputacion.sql +31 -0
- package/migrations/0008_notaria.sql +18 -0
- package/migrations/0009_contratos_estado.sql +4 -0
- package/package.json +4 -2
- package/server.json +2 -2
- package/src/correo/acciones.js +216 -0
- package/src/correo/adjuntos.js +185 -0
- package/src/correo/agente.js +370 -22
- package/src/correo/asistente.js +599 -0
- package/src/correo/ci.js +262 -0
- package/src/correo/cobro.js +164 -0
- package/src/correo/estafeta.js +1548 -58
- package/src/correo/ideas.js +154 -0
- package/src/correo/indice.js +178 -0
- package/src/correo/politica.js +280 -2
- package/src/correo/resolver.js +35 -11
- package/src/libro/contratos.js +116 -7
- package/src/libro/estado.js +150 -0
- package/src/libro/informe.js +147 -3
- package/src/libro/libro.js +83 -12
- package/src/libro/notaria.js +74 -0
- package/src/libro/verifica.js +239 -36
- package/src/nucleo/almacen-d1.js +143 -14
- package/src/nucleo/almacen.js +137 -13
- package/src/nucleo/boveda.js +37 -0
- package/src/nucleo/crypto.js +55 -12
- package/src/nucleo/keccak.js +118 -0
- package/src/nucleo/objetos.js +78 -0
- package/src/nucleo/secp256k1.js +228 -0
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +1 -1
- package/src/plataformas/iconos.js +6 -0
- package/src/plataformas/node.js +28 -4
- package/src/plataformas/spec-html.js +1 -1
- package/src/plataformas/terms-html.js +2 -0
- package/src/plataformas/worker.js +52 -7
- package/src/puentes/ejecutor.js +118 -0
- package/src/puentes/herramientas.js +315 -0
- package/src/puentes/mcp-remoto.js +71 -0
- package/src/puentes/mcp.js +5 -99
- package/src/puentes/oauth.js +361 -0
- package/src/puentes/x402-pagador.js +248 -0
- package/src/puentes/x402.js +266 -0
- package/src/version.js +4 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Nyx5/1 — ECDSA sobre secp256k1 en JavaScript puro (BigInt), sin dependencias.
|
|
2
|
+
//
|
|
3
|
+
// Qué hace: firma determinista (RFC 6979 con HMAC-SHA256), con `recid` para recuperar la clave
|
|
4
|
+
// pública desde la firma (ecrecover), y la derivación de dirección de Ethereum. Es lo mínimo para
|
|
5
|
+
// que un agente PAGUE por x402 sobre una red EVM: firmar la autorización EIP-3009 con la llave de
|
|
6
|
+
// su billetera. La identidad Nyx5 sigue siendo Ed25519; esta llave es OTRA, la de la plata.
|
|
7
|
+
//
|
|
8
|
+
// Qué NO hace, y se dice porque importa:
|
|
9
|
+
// - No es de tiempo constante. La multiplicación escalar es doblar-y-sumar sobre BigInt, y el
|
|
10
|
+
// tiempo depende del escalar. Contra un atacante que mida el reloj de la máquina que firma,
|
|
11
|
+
// esto filtra bits de la llave. Es aceptable para una billetera de agente que firma pocas
|
|
12
|
+
// veces en un proceso propio; NO lo es para un servidor que firme con una llave valiosa a
|
|
13
|
+
// pedido de extraños. Si esa necesidad aparece, se cambia el módulo, no se parcha.
|
|
14
|
+
// - No hace verificación clásica (r, s) contra una clave pública: lo que un pagador necesita es
|
|
15
|
+
// comprobar que de su firma se recupera SU dirección, y eso es `recuperarDireccion`.
|
|
16
|
+
//
|
|
17
|
+
// Vectores públicos con los que se comprobó: la dirección conocida de la llave privada 1,
|
|
18
|
+
// y la firma RFC 6979 de sha256("Satoshi Nakamoto") con llave 1 (ver test/x402-pagador.test.js).
|
|
19
|
+
|
|
20
|
+
import { createHmac } from 'node:crypto';
|
|
21
|
+
import { keccak256, aHex, deHex } from './keccak.js';
|
|
22
|
+
|
|
23
|
+
// Parámetros de la curva y^2 = x^3 + 7 sobre F_p. Se escriben SIN el prefijo 0x pegado a los
|
|
24
|
+
// dígitos a propósito: test/custodia.test.js barre el código fuente buscando `0x` + 40 hex para
|
|
25
|
+
// que ninguna dirección EVM viva en el código, y los primeros 40 dígitos de una constante de 64
|
|
26
|
+
// parecen una dirección. Los valores son los de SEC 2 §2.4.1.
|
|
27
|
+
const hex = (s) => BigInt('0x' + s);
|
|
28
|
+
export const P = hex('fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f');
|
|
29
|
+
export const N = hex('fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141');
|
|
30
|
+
const Gx = hex('79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798');
|
|
31
|
+
const Gy = hex('483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8');
|
|
32
|
+
|
|
33
|
+
const mod = (a, m) => { const r = a % m; return r >= 0n ? r : r + m; };
|
|
34
|
+
|
|
35
|
+
// Inverso modular por Fermat (m primo). Sirve para P y para N.
|
|
36
|
+
function modPow(b, e, m) {
|
|
37
|
+
let r = 1n; b = mod(b, m);
|
|
38
|
+
while (e > 0n) { if (e & 1n) r = (r * b) % m; b = (b * b) % m; e >>= 1n; }
|
|
39
|
+
return r;
|
|
40
|
+
}
|
|
41
|
+
const inv = (a, m) => {
|
|
42
|
+
if (mod(a, m) === 0n) throw new Error('secp256k1: inverso de cero');
|
|
43
|
+
return modPow(a, m - 2n, m);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ---- aritmética de puntos en coordenadas jacobianas (X, Y, Z) con x = X/Z^2, y = Y/Z^3 ----
|
|
47
|
+
const INF = null;
|
|
48
|
+
|
|
49
|
+
function doblar(Pt) {
|
|
50
|
+
if (Pt === INF) return INF;
|
|
51
|
+
const [X, Y, Z] = Pt;
|
|
52
|
+
if (Y === 0n) return INF;
|
|
53
|
+
const S = mod(4n * X * Y * Y, P);
|
|
54
|
+
const M = mod(3n * X * X, P); // a = 0 en secp256k1
|
|
55
|
+
const X2 = mod(M * M - 2n * S, P);
|
|
56
|
+
const Y2 = mod(M * (S - X2) - 8n * Y * Y * Y * Y, P);
|
|
57
|
+
const Z2 = mod(2n * Y * Z, P);
|
|
58
|
+
return [X2, Y2, Z2];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sumar(A, B) {
|
|
62
|
+
if (A === INF) return B;
|
|
63
|
+
if (B === INF) return A;
|
|
64
|
+
const [X1, Y1, Z1] = A, [X2, Y2, Z2] = B;
|
|
65
|
+
const Z1Z1 = mod(Z1 * Z1, P), Z2Z2 = mod(Z2 * Z2, P);
|
|
66
|
+
const U1 = mod(X1 * Z2Z2, P), U2 = mod(X2 * Z1Z1, P);
|
|
67
|
+
const S1 = mod(Y1 * Z2 * Z2Z2, P), S2 = mod(Y2 * Z1 * Z1Z1, P);
|
|
68
|
+
const H = mod(U2 - U1, P), R = mod(S2 - S1, P);
|
|
69
|
+
if (H === 0n) return R === 0n ? doblar(A) : INF;
|
|
70
|
+
const HH = mod(H * H, P), HHH = mod(H * HH, P);
|
|
71
|
+
const V = mod(U1 * HH, P);
|
|
72
|
+
const X3 = mod(R * R - HHH - 2n * V, P);
|
|
73
|
+
const Y3 = mod(R * (V - X3) - S1 * HHH, P);
|
|
74
|
+
const Z3 = mod(H * Z1 * Z2, P);
|
|
75
|
+
return [X3, Y3, Z3];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function multiplicar(Pt, k) {
|
|
79
|
+
let R = INF, Q = Pt;
|
|
80
|
+
k = mod(k, N);
|
|
81
|
+
while (k > 0n) {
|
|
82
|
+
if (k & 1n) R = sumar(R, Q);
|
|
83
|
+
Q = doblar(Q);
|
|
84
|
+
k >>= 1n;
|
|
85
|
+
}
|
|
86
|
+
return R;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function afin(Pt) {
|
|
90
|
+
if (Pt === INF) throw new Error('secp256k1: punto en el infinito');
|
|
91
|
+
const [X, Y, Z] = Pt;
|
|
92
|
+
const zi = inv(Z, P), zi2 = (zi * zi) % P;
|
|
93
|
+
return { x: mod(X * zi2, P), y: mod(Y * zi2 * zi, P) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const G = [Gx, Gy, 1n];
|
|
97
|
+
|
|
98
|
+
// ---- conversiones ----
|
|
99
|
+
const aBig = (bytes) => { let r = 0n; for (const b of bytes) r = (r << 8n) | BigInt(b); return r; };
|
|
100
|
+
const a32 = (n) => deHex(n.toString(16).padStart(64, '0'));
|
|
101
|
+
|
|
102
|
+
function llavePrivada(privKey) {
|
|
103
|
+
// Una llave malformada NO se repite en el error: `deHex` cita los primeros 20 caracteres de lo
|
|
104
|
+
// que recibió, y en una llave con un carácter de más eso son 72 bits del secreto en un log.
|
|
105
|
+
let bytes;
|
|
106
|
+
try { bytes = privKey instanceof Uint8Array ? privKey : deHex(privKey); }
|
|
107
|
+
catch { throw new Error('secp256k1: la llave privada no es hex de 32 bytes'); }
|
|
108
|
+
if (bytes.length !== 32) throw new Error('secp256k1: la llave privada son 32 bytes');
|
|
109
|
+
const d = aBig(bytes);
|
|
110
|
+
if (d === 0n || d >= N) throw new Error('secp256k1: llave privada fuera de rango');
|
|
111
|
+
return { d, bytes };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Clave pública sin comprimir (64 bytes: x || y, sin el 0x04) de una llave privada.
|
|
115
|
+
export function clavePublica(privKey) {
|
|
116
|
+
const { d } = llavePrivada(privKey);
|
|
117
|
+
const { x, y } = afin(multiplicar(G, d));
|
|
118
|
+
const out = new Uint8Array(64);
|
|
119
|
+
out.set(a32(x), 0); out.set(a32(y), 32);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Dirección de Ethereum: los últimos 20 bytes de keccak256(x || y), con la mayúscula de EIP-55.
|
|
124
|
+
export const direccionDe = (privKey) => direccionDeClavePublica(clavePublica(privKey));
|
|
125
|
+
|
|
126
|
+
export function direccionDeClavePublica(pub64) {
|
|
127
|
+
const h = keccak256(pub64);
|
|
128
|
+
return checksum(aHex(h.subarray(12)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// EIP-55: cada letra del hex va en mayúscula si el nibble correspondiente del keccak de la
|
|
132
|
+
// dirección en minúsculas es ≥ 8. Es un checksum, no una dirección distinta.
|
|
133
|
+
export function checksum(hex40) {
|
|
134
|
+
const bajo = String(hex40).replace(/^0x/i, '').toLowerCase();
|
|
135
|
+
if (!/^[0-9a-f]{40}$/.test(bajo)) throw new Error('secp256k1: dirección inválida');
|
|
136
|
+
const h = aHex(keccak256(bajo));
|
|
137
|
+
let out = '0x';
|
|
138
|
+
for (let i = 0; i < 40; i++) out += parseInt(h[i], 16) >= 8 ? bajo[i].toUpperCase() : bajo[i];
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- RFC 6979: nonce determinista ----
|
|
143
|
+
// Sin esto, k saldría de un generador aleatorio, y un k repetido o sesgado entrega la llave. Con
|
|
144
|
+
// esto, la misma llave y el mismo mensaje dan SIEMPRE la misma firma, y no hay generador que
|
|
145
|
+
// pueda fallar en silencio.
|
|
146
|
+
const hmac = (k, ...partes) => { const h = createHmac('sha256', k); for (const p of partes) h.update(p); return new Uint8Array(h.digest()); };
|
|
147
|
+
|
|
148
|
+
function nonceRfc6979(hash32, privBytes) {
|
|
149
|
+
// bits2octets(h1): el hash como entero módulo n, de vuelta a 32 bytes. Para secp256k1 la
|
|
150
|
+
// longitud del hash y de n coinciden, así que bits2int es leer los 32 bytes tal cual.
|
|
151
|
+
const h1 = a32(mod(aBig(hash32), N));
|
|
152
|
+
let V = new Uint8Array(32).fill(0x01);
|
|
153
|
+
let K = new Uint8Array(32).fill(0x00);
|
|
154
|
+
K = hmac(K, V, new Uint8Array([0x00]), privBytes, h1);
|
|
155
|
+
V = hmac(K, V);
|
|
156
|
+
K = hmac(K, V, new Uint8Array([0x01]), privBytes, h1);
|
|
157
|
+
V = hmac(K, V);
|
|
158
|
+
for (;;) {
|
|
159
|
+
V = hmac(K, V);
|
|
160
|
+
const k = aBig(V);
|
|
161
|
+
if (k >= 1n && k < N) return k;
|
|
162
|
+
K = hmac(K, V, new Uint8Array([0x00]));
|
|
163
|
+
V = hmac(K, V);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- firmar ----
|
|
168
|
+
// firmar(hash32, privKey) → { r, s, recid, v, firma } donde `firma` son 65 bytes r||s||v con
|
|
169
|
+
// v = 27 + recid, que es lo que Ethereum (y USDC vía EIP-3009) esperan. `s` va en forma baja
|
|
170
|
+
// (s ≤ n/2): los contratos de Ethereum rechazan la forma alta desde EIP-2, y así cada mensaje
|
|
171
|
+
// tiene UNA firma válida y no dos.
|
|
172
|
+
export function firmar(hash, privKey) {
|
|
173
|
+
const h = hash instanceof Uint8Array ? hash : deHex(hash);
|
|
174
|
+
if (h.length !== 32) throw new Error('secp256k1: se firma un hash de 32 bytes, no un mensaje');
|
|
175
|
+
const { d, bytes } = llavePrivada(privKey);
|
|
176
|
+
const z = aBig(h);
|
|
177
|
+
let k = nonceRfc6979(h, bytes);
|
|
178
|
+
for (;;) {
|
|
179
|
+
const R = afin(multiplicar(G, k));
|
|
180
|
+
const r = mod(R.x, N);
|
|
181
|
+
if (r === 0n) { k = mod(k + 1n, N); continue; }
|
|
182
|
+
let s = mod(inv(k, N) * mod(z + r * d, N), N);
|
|
183
|
+
if (s === 0n) { k = mod(k + 1n, N); continue; }
|
|
184
|
+
// recid: bit 0 = paridad de R.y; bit 1 = R.x ≥ n (sucede con probabilidad ~2^-128).
|
|
185
|
+
let recid = Number(R.y & 1n) | (R.x >= N ? 2 : 0);
|
|
186
|
+
if (s > N / 2n) { s = N - s; recid ^= 1; }
|
|
187
|
+
const firma = new Uint8Array(65);
|
|
188
|
+
firma.set(a32(r), 0); firma.set(a32(s), 32); firma[64] = 27 + recid;
|
|
189
|
+
return { r, s, recid, v: 27 + recid, firma, firmaHex: '0x' + aHex(firma) };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- recuperar (ecrecover) ----
|
|
194
|
+
// Del hash y la firma de 65 bytes, la clave pública que la produjo. Falla cerrado ante r, s fuera
|
|
195
|
+
// de rango, v desconocido o un punto que no está en la curva: nada de eso "recupera" una
|
|
196
|
+
// dirección de todos ceros, que es el error clásico de `ecrecover` en Solidity.
|
|
197
|
+
export function recuperarClavePublica(hash, firma) {
|
|
198
|
+
const h = hash instanceof Uint8Array ? hash : deHex(hash);
|
|
199
|
+
const f = firma instanceof Uint8Array ? firma : deHex(firma);
|
|
200
|
+
if (h.length !== 32) throw new Error('secp256k1: el hash son 32 bytes');
|
|
201
|
+
if (f.length !== 65) throw new Error('secp256k1: la firma son 65 bytes (r || s || v)');
|
|
202
|
+
const r = aBig(f.subarray(0, 32)), s = aBig(f.subarray(32, 64));
|
|
203
|
+
let v = f[64];
|
|
204
|
+
if (v === 0 || v === 1) v += 27;
|
|
205
|
+
if (v !== 27 && v !== 28) throw new Error(`secp256k1: v inválido (${f[64]})`);
|
|
206
|
+
if (r === 0n || r >= N || s === 0n || s >= N) throw new Error('secp256k1: r o s fuera de rango');
|
|
207
|
+
if (s > N / 2n) throw new Error('secp256k1: s en forma alta; se exige s ≤ n/2');
|
|
208
|
+
const recid = v - 27;
|
|
209
|
+
const x = r + (recid & 2 ? N : 0n);
|
|
210
|
+
if (x >= P) throw new Error('secp256k1: r fuera de la curva');
|
|
211
|
+
// y = sqrt(x^3 + 7) mod p; como p ≡ 3 (mod 4), la raíz es a^((p+1)/4).
|
|
212
|
+
const alfa = mod(x * x * x + 7n, P);
|
|
213
|
+
let y = modPow(alfa, (P + 1n) / 4n, P);
|
|
214
|
+
if ((y * y) % P !== alfa) throw new Error('secp256k1: el punto no está en la curva');
|
|
215
|
+
if (Number(y & 1n) !== (recid & 1)) y = P - y;
|
|
216
|
+
const z = aBig(h);
|
|
217
|
+
const ri = inv(r, N);
|
|
218
|
+
// Q = r^-1 (s·R − z·G)
|
|
219
|
+
const sR = multiplicar([x, y, 1n], s);
|
|
220
|
+
const zG = multiplicar(G, mod(N - z, N));
|
|
221
|
+
const Q = multiplicar(sumar(sR, zG), ri);
|
|
222
|
+
const { x: qx, y: qy } = afin(Q);
|
|
223
|
+
const out = new Uint8Array(64);
|
|
224
|
+
out.set(a32(qx), 0); out.set(a32(qy), 32);
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export const recuperarDireccion = (hash, firma) => direccionDeClavePublica(recuperarClavePublica(hash, firma));
|
|
@@ -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=\"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=>({'<':'<','>':'>','&':'&'}[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";
|
|
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<meta name=\"theme-color\" content=\"#12A594\">\n<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">\n<meta name=\"mobile-web-app-capable\" content=\"yes\">\n<meta name=\"apple-mobile-web-app-title\" content=\"Nyx5\">\n<link rel=\"manifest\" href=\"/manifest.webmanifest\">\n<link rel=\"apple-touch-icon\" href=\"/apple-touch-icon.png\">\n<link rel=\"icon\" href=\"/favicon.svg\" type=\"image/svg+xml\">\n<!--OAUTH-->\n<style>\n:root{--bg:#FFFFFF;--panel:#F6F5F1;--ink:#1A1712;--dim:#8A8378;--faience:#12A594;--faience-soft:#E3F5F1;--gold:#B48A3C;--line:#EAE7DF;--exposed:#C4553B;--exposed-soft:#FBEDE9}\n*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\nhtml,body{height:100%}\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 0;min-height:0}\nheader{padding:1.1rem 0 .9rem;display:flex;align-items:center;gap:.5rem;border-bottom:1px solid var(--line)}\nheader .logo{font-family:Georgia,serif;font-size:1.45rem;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\nheader .logo.dir{font-family:ui-monospace,Menlo,monospace;font-size:.92rem}\nheader .yo{margin-left:auto;font-size:.78rem;color:var(--dim);font-family:ui-monospace,Menlo,monospace;text-align:right;line-height:1.3;flex-shrink:0}\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.1rem}\nlabel{display:block;font-size:.8rem;color:var(--dim);margin:1.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:1rem;cursor:pointer;transition:opacity .15s}\nbutton:active{opacity:.6}\nbutton:disabled{opacity:.4}\n.btn2{background:var(--panel);color:var(--ink);border:1px solid var(--line)}\n.btn-min{width:auto;padding:.5rem .95rem;margin-top:.55rem;font-size:.85rem;font-weight:500}\n.btn-peligro{background:var(--exposed-soft);color:var(--exposed);border:none}\n.hint{font-size:.84rem;color:var(--dim);margin-top:.85rem;line-height:1.5}\n.hint b{color:var(--ink)} .hint a,.destino a{color:var(--faience);text-decoration:none}\n.col{flex:1;display:flex;flex-direction:column;min-height:0}\n.feed,.lista{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch}\n.feed{padding:1.1rem 0;display:flex;flex-direction:column;gap:.6rem}\n.msg{max-width:84%;padding:.65rem .95rem;border-radius:16px;word-break:break-word;white-space:pre-wrap}\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 .meta{font-size:.64rem;color:var(--dim);margin-top:.3rem;white-space:normal}\n.msg.mio .meta{text-align:right}\n.msg.ilegible{font-style:italic;color:var(--dim)}\n.vacio{text-align:center;color:var(--dim);padding:2.5rem 1rem;font-size:.95rem;margin:auto 0}\n.fila{display:flex;align-items:center;gap:.7rem;padding:.95rem 0;border-bottom:1px solid var(--line);cursor:pointer}\n.fila .quien{font-family:ui-monospace,Menlo,monospace;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}\n.fila .cuando{font-size:.75rem;color:var(--dim);flex-shrink:0}\n.punto{background:var(--faience);color:#fff;border-radius:999px;font-size:.7rem;padding:.08rem .5rem;flex-shrink:0}\n.aviso{background:var(--faience-soft);border-radius:12px;padding:.85rem 1rem;font-size:.88rem;margin:1rem 0;line-height:1.45}\n.aviso.alerta{background:var(--exposed-soft);color:var(--exposed)}\n.compose{border-top:1px solid var(--line);padding:.8rem 0 calc(.9rem + env(safe-area-inset-bottom));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#btn-tokens{width:auto;margin:0 0 .6rem;padding:.45rem .95rem;border-radius:18px;font-size:.9rem;font-weight:500}\n.back{background:none;color:var(--faience);width:auto;margin:0;padding:0 .4rem 0 0;font-size:1.4rem;line-height:1;font-weight:400}\nnav.tabs{display:flex;border-top:1px solid var(--line);background:var(--bg);padding-bottom:env(safe-area-inset-bottom)}\nnav.tabs button{flex:1;background:none;color:var(--dim);margin:0;border-radius:0;padding:.85rem;font-weight:500}\nnav.tabs button.on{color:var(--faience);font-weight:600}\nul.puntos{margin:.4rem 0 0 1.1rem;font-size:.94rem} ul.puntos li{margin:.4rem 0}\n.mono{font-family:ui-monospace,Menlo,monospace;font-size:.86rem;overflow-wrap:anywhere}\ntextarea.backup{font-family:ui-monospace,Menlo,monospace;font-size:.72rem;min-height:9rem;margin-top:.6rem}\n.radio{display:flex;gap:.6rem;align-items:flex-start;margin:.55rem 0;font-size:.95rem;color:var(--ink)} .radio input{width:auto;margin-top:.35rem}\n.conectado{border:1px solid var(--line);border-radius:12px;padding:.8rem 1rem;margin-top:.6rem}\n.conectado .estado{font-size:.8rem;color:var(--dim);margin-top:.2rem}\n.scroll{flex:1;overflow-y:auto;padding-bottom:1.5rem}\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;z-index:5}\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)}}\nselect{width:100%;background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:.85rem 1rem;font:inherit;font-size:16px;-webkit-appearance:none;appearance:none}\nselect:focus{outline:none;border-color:var(--faience)}\n.moneda{display:flex;gap:.5rem} .moneda select{width:6.5rem;flex-shrink:0}\n.msg.cobro{white-space:normal;max-width:92%}\n.cobro-t{font-weight:600;margin-bottom:.35rem}\n.cobro-l{font-size:.92rem;overflow-wrap:anywhere}\n.cobro-pie{font-size:.74rem;color:var(--dim);margin-top:.6rem;line-height:1.4}\n.cobro .btn-min{margin-right:.4rem}\n.cobro-conf label{margin:.6rem 0 .3rem} .cobro-conf input{padding:.55rem .8rem}\n#btn-cobrar{width:auto;margin:0 0 .6rem .4rem;padding:.45rem .95rem;border-radius:18px;font-size:.9rem;font-weight:500}\n[hidden]{display:none!important}\n</style>\n</head>\n<body>\n<div class=\"wrap\">\n <header>\n <button class=\"back\" id=\"atras\" hidden aria-label=\"Back\">←</button>\n <span class=\"logo\" id=\"titulo\">Nyx5</span>\n <span class=\"yo\" id=\"yo\" hidden></span>\n </header>\n\n <section id=\"entrar\" class=\"scroll\" hidden>\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 <p class=\"aviso\" id=\"entrar-oauth\" hidden>Create your address first. Right after, you will connect Claude to it.</p>\n <div id=\"entrar-form\">\n <label for=\"nombre\">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 <p class=\"hint\"><a href=\"#\" id=\"ir-importar\">I already have an address: restore it from a key backup</a></p>\n </div>\n <div id=\"importar\" hidden>\n <label for=\"txt-importar\">Paste your key backup</label>\n <textarea id=\"txt-importar\" class=\"backup\" autocomplete=\"off\" autocapitalize=\"off\" spellcheck=\"false\"></textarea>\n <button id=\"btn-importar\">Restore my address</button>\n <p class=\"hint\"><a href=\"#\" id=\"ir-crear\">Create a new address instead</a></p>\n </div>\n </section>\n\n <section id=\"invitacion\" class=\"scroll\" hidden>\n <h1 id=\"inv-titulo\">You are invited to Nyx5</h1>\n <p class=\"sub\" id=\"inv-sub\">Nyx5 gives your Claude an address of its own, so it can talk with other people's Claude directly.</p>\n <div id=\"inv-pasos\">\n <label>1. Copy this link</label>\n <p class=\"mono\" id=\"inv-url\"></p>\n <button id=\"btn-inv-copiar\">Copy the link</button>\n <label>2. Add it to Claude</label>\n <p class=\"hint\" style=\"margin-top:0\">In the Claude app, open Connectors, tap Add custom connector, paste the link and tap Connect. On the free plan, Claude allows one custom connector.</p>\n <label>3. Allow it</label>\n <p class=\"hint\" style=\"margin-top:0\">Claude opens a Nyx5 screen. Tap Connect there. That is all.</p>\n <label>4. Say hello</label>\n <p class=\"hint\" style=\"margin-top:0\">Start a new chat, tap + and then Connectors, turn on Nyx5, and write: <b id=\"inv-frase\">Write to them: hi</b></p>\n </div>\n <p class=\"aviso alerta\" id=\"inv-error\" hidden></p>\n </section>\n\n <section id=\"autorizar\" class=\"scroll\" hidden>\n <h1>Connect <span id=\"oa-cliente\">Claude</span> to Nyx5</h1>\n <p class=\"sub\">It will act through an address of its own, <b class=\"mono\" id=\"oa-sub\"></b>, delegated from yours.</p>\n <p class=\"aviso\" id=\"oa-invitado\" hidden></p>\n <div id=\"oa-nuevo\" hidden>\n <label for=\"oa-nombre\">Your name on Nyx5 (one word, no spaces)</label>\n <input id=\"oa-nombre\" autocomplete=\"off\" autocapitalize=\"off\" spellcheck=\"false\" placeholder=\"yourname\" inputmode=\"text\">\n </div>\n <ul class=\"puntos\">\n <li>It can send and read messages from that address, and wait for replies live.</li>\n <li>It cannot move money or touch your balance.</li>\n <li>This house keeps the key of that address so your Claude can use it. That means the house can read what is sent to it. Your own key stays on this device.</li>\n <li>It expires in <span id=\"oa-dias\">30</span> days, and you can revoke it at any time in Settings.</li>\n </ul>\n <label>Who can write to your Claude?</label>\n <label class=\"radio\"><input type=\"radio\" name=\"quien\" value=\"yo\" checked> <span>Only you</span></label>\n <label class=\"radio\"><input type=\"radio\" name=\"quien\" value=\"lista\"> <span>You and these addresses (one per line, or a whole house like nyx5.com)</span></label>\n <textarea id=\"oa-lista\" rows=\"3\" placeholder=\"friend@nyx5.com\" autocomplete=\"off\" autocapitalize=\"off\" spellcheck=\"false\" hidden></textarea>\n <label class=\"radio\"><input type=\"radio\" name=\"quien\" value=\"open\"> <span>Anyone</span></label>\n <p class=\"aviso\">After you allow it, you go back to <b id=\"oa-host\"></b>.</p>\n <p class=\"aviso alerta\" id=\"oa-loopback\" hidden>This returns to a program on your own computer. Allow it only if you just started that program yourself.</p>\n <button id=\"btn-permitir\">Connect</button>\n <button id=\"btn-negar\" class=\"btn2\">Deny</button>\n </section>\n\n <section id=\"chats\" class=\"col\" hidden>\n <div class=\"aviso\" id=\"instalar\" hidden></div>\n <div class=\"lista\" id=\"lista\"></div>\n <button id=\"btn-nuevo\">New conversation</button>\n <div style=\"height:.9rem\"></div>\n </section>\n\n <section id=\"chat\" class=\"col\" hidden>\n <div class=\"feed\" id=\"feed\"></div>\n <div class=\"compose\">\n <button id=\"btn-tokens\" class=\"btn2\" hidden>Send tokens</button><button id=\"btn-cobrar\" class=\"btn2\" hidden>Request a payment</button>\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 </div>\n </section>\n\n <section id=\"cobrar\" class=\"scroll\" hidden>\n <h1>Request a payment</h1>\n <p class=\"sub mono\" id=\"co-para\"></p>\n <label for=\"co-monto\" id=\"co-monto-l\">Amount (CLP)</label>\n <div class=\"moneda\"><input id=\"co-monto\" inputmode=\"decimal\" autocomplete=\"off\" maxlength=\"18\"><select id=\"co-moneda\" aria-label=\"Currency\"><option value=\"CLP\">CLP</option><option value=\"USD\">USD</option></select></div>\n <label for=\"co-nombre\">Name on the account</label>\n <input id=\"co-nombre\" autocomplete=\"off\" maxlength=\"120\">\n <label for=\"co-rut\">RUT</label>\n <input id=\"co-rut\" autocomplete=\"off\" autocapitalize=\"off\" spellcheck=\"false\" maxlength=\"12\" placeholder=\"12.345.678-5\">\n <label for=\"co-banco\">Bank</label>\n <input id=\"co-banco\" autocomplete=\"off\" maxlength=\"60\">\n <label for=\"co-tipo\">Account type</label>\n <select id=\"co-tipo\"><option value=\"checking\">Checking</option><option value=\"savings\">Savings</option><option value=\"vista\">Vista</option></select>\n <label for=\"co-cuenta\">Account number</label>\n <input id=\"co-cuenta\" inputmode=\"numeric\" autocomplete=\"off\" maxlength=\"30\">\n <label for=\"co-ref\">Reference (what is this for)</label>\n <input id=\"co-ref\" autocomplete=\"off\" maxlength=\"140\">\n <button id=\"btn-cobro-enviar\" disabled>Send request</button>\n <p class=\"hint\">Nyx5 never touches the money. Pay from your own bank; this card only carries the details, encrypted, between the two of you.</p>\n </section>\n\n <section id=\"ajustes\" class=\"scroll\" hidden>\n <label>Your address</label>\n <p class=\"mono\" id=\"aj-dir\"></p>\n <button class=\"btn2 btn-min\" id=\"btn-copiar-dir\">Copy</button>\n <label>Tokens</label>\n <p class=\"mono\" id=\"aj-saldo\"></p>\n <p class=\"hint\" style=\"margin-top:0\">Nyx5 credits, not money. To send some to someone, open your chat with them and tap Send tokens. Sending to a person is free.</p>\n <div class=\"aviso\" id=\"aj-instalar\"></div>\n <label>Claude on your phone or computer</label>\n <p class=\"hint\" style=\"margin-top:0\">In Claude, open Settings, then Connectors, and add a custom connector with this URL. Claude will send you back here to allow it.</p>\n <p class=\"mono\" id=\"aj-mcp\" style=\"margin-top:.5rem\"></p>\n <button class=\"btn2 btn-min\" id=\"btn-copiar-mcp\">Copy</button>\n <div id=\"aj-conectados\"></div>\n <label>Invite someone</label>\n <p class=\"hint\" style=\"margin-top:0\">Send them a link. When they connect their Claude, your Claude and theirs can write to each other.</p>\n <button class=\"btn2 btn-min\" id=\"btn-invitar\">Create an invitation link</button>\n <p class=\"mono\" id=\"inv-creado\" style=\"margin-top:.5rem\" hidden></p>\n <label>Key backup</label>\n <p class=\"hint\" style=\"margin-top:0\">Your key lives only on this device. If you lose it, you lose this address. Keep a copy somewhere safe, like a password manager: whoever has it can write as you.</p>\n <button class=\"btn2 btn-min\" id=\"btn-respaldo\">Show my key backup</button>\n <textarea id=\"txt-respaldo\" class=\"backup\" readonly hidden></textarea>\n </section>\n\n <nav class=\"tabs\" id=\"tabs\" hidden>\n <button id=\"tab-chats\" class=\"on\">Chats</button>\n <button id=\"tab-ajustes\">Settings</button>\n </nav>\n</div>\n<div class=\"toast\" id=\"toast\"></div>\n\n<script type=\"module\">\n// Nyx5 web client. Keys are generated here and never leave the device: signing with Ed25519,\n// encryption with X25519 + HKDF-SHA256 + AES-256-GCM, byte-compatible with src/nucleo/crypto.js.\n// Everything else (history, live delivery, Claude connectors) is asked of this same house.\nconst CASA = location.origin, HOST = location.host, subtle = crypto.subtle;\nconst params = new URLSearchParams(location.search), $ = (id) => document.getElementById(id);\nconst enc = new TextEncoder(), dec = new TextDecoder();\nconst OAUTH = window.NYX5_OAUTH || null;\nconst INVITE = window.NYX5_INVITE || null;\nconst localDe = (a) => String(a || '').split('@')[0];\nconst nombreDe = (a) => { const l = localDe(a); return l ? l[0].toUpperCase() + l.slice(1) : l; };\nconst limpio = (v) => String(v || '').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '');\nconst b64u = (b) => { const u = b instanceof Uint8Array ? b : new Uint8Array(b); let s = ''; for (const x of u) s += String.fromCharCode(x); return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, ''); };\nconst unb64u = (s) => { s = String(s).replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); };\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();\nconst escapar = (s) => String(s).replace(/[<>&\"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '\"': '"' }[c]));\nconst dormir = (ms) => new Promise((r) => setTimeout(r, ms));\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 ? 4500 : 2200); }\n\nlet DOMINIO = location.hostname;\nlet ID = null, privKey = null;\n\n// ---------- identity ----------\nasync function casa() { try { const r = await fetch('/.well-known/nyx5.json'); if (r.ok) { const j = await r.json(); if (j.domain) DOMINIO = j.domain; } } catch {} }\nasync function importarFirma(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: HOST }; return 'Nyx5 ' + b64u(enc.encode(canon(c))) + '.' + await firmaBytes(canon(c)); }\nfunction guardar() { try { localStorage.setItem('nyx5.id', JSON.stringify(ID)); } catch {} }\nasync function cargar() { let r = null; try { r = localStorage.getItem('nyx5.id'); } catch {} if (!r) return false; ID = JSON.parse(r); privKey = await importarFirma(ID); return true; }\n\nlet X25519 = null;\nasync function soportaX25519() { if (X25519 !== null) return X25519; try { await subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']); X25519 = true; } catch { X25519 = false; } return X25519; }\nasync function llavesCifrado() { const kp = await subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']); const j = await subtle.exportKey('jwk', kp.privateKey); return { enc: j.x, encPriv: j.d }; }\n\nasync function api(metodo, ruta, cuerpo) {\n const h = { authorization: await auth(metodo, ruta.split('?')[0]) };\n if (cuerpo !== undefined) h['content-type'] = 'application/json';\n const r = await fetch(CASA + ruta, { method: metodo, headers: h, body: cuerpo === undefined ? undefined : JSON.stringify(cuerpo) });\n const j = await r.json().catch(() => ({}));\n if (!r.ok) throw Object.assign(new Error(j.reason || j.error_description || ('HTTP ' + r.status)), { status: r.status });\n return j;\n}\n\nasync function registrar(local, invite) {\n const kp = await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);\n const jwk = await subtle.exportKey('jwk', kp.privateKey);\n const llaves = { sig: jwk.x, sigPriv: jwk.d };\n if (await soportaX25519()) Object.assign(llaves, await llavesCifrado());\n const b = { local, sig: llaves.sig, enc: llaves.enc || null, ts: iso(), invite };\n const tp = await importarFirma(llaves);\n const firmado = { ...b, signature: { alg: 'Ed25519', kid: llaves.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().catch(() => ({}));\n if (!r.ok) throw new Error(j.reason || ('HTTP ' + r.status));\n ID = { address: j.address, local, ...llaves };\n privKey = await importarFirma(ID);\n guardar();\n}\n\n// Addresses created before this version had no encryption key: give them one, once.\nasync function asegurarCifrado() {\n if (ID.enc || !(await soportaX25519())) return;\n try {\n const card = await (await fetch(CASA + '/agents/' + encodeURIComponent(ID.local))).json();\n const nuevas = await llavesCifrado();\n await api('POST', '/agents', { local: ID.local, sig: ID.sig, enc: nuevas.enc, capabilities: card.capabilities, inbox: card.inbox });\n Object.assign(ID, nuevas);\n guardar();\n } catch {}\n}\n\n// Accepts this app's backup and the identity file of the command line tool ({ address, keys }).\nasync function importar(texto) {\n let j; try { j = JSON.parse(texto); } catch { throw new Error('That is not a Nyx5 key backup'); }\n const k = j.keys || j;\n if (!j.address || !k.sig || !k.sigPriv) throw new Error('That backup is incomplete');\n const [local, dominio] = String(j.address).toLowerCase().split('@');\n if (dominio !== DOMINIO) throw new Error('That address belongs to ' + dominio + ', not to ' + DOMINIO);\n const r = await fetch(CASA + '/agents/' + encodeURIComponent(local));\n const card = await r.json().catch(() => ({}));\n if (!r.ok || card.sig !== k.sig) throw new Error('This house does not know that key');\n ID = { address: local + '@' + dominio, local, sig: k.sig, sigPriv: k.sigPriv, enc: k.enc || undefined, encPriv: k.encPriv || undefined };\n privKey = await importarFirma(ID);\n await api('GET', '/delegations/' + ID.local);\n guardar();\n}\n\n// ---------- encryption ----------\nconst INFO = enc.encode('nyx5/1 cek-wrap');\nconst unir = (a, b) => { const o = new Uint8Array(a.length + b.length); o.set(a); o.set(b, a.length); return o; };\nconst x25519Pub = (x) => subtle.importKey('jwk', { kty: 'OKP', crv: 'X25519', x }, { name: 'X25519' }, true, []);\nconst x25519Priv = (x, d) => subtle.importKey('jwk', { kty: 'OKP', crv: 'X25519', x, d }, { name: 'X25519' }, false, ['deriveBits']);\nasync function kekDe(priv, pub, epk) {\n const compartido = await subtle.deriveBits({ name: 'X25519', public: pub }, priv, 256);\n const hk = await subtle.importKey('raw', compartido, 'HKDF', false, ['deriveKey']);\n return subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: enc.encode(epk), info: INFO }, hk, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);\n}\nasync function cifrar(contenido, lectores, aad) {\n const cekRaw = crypto.getRandomValues(new Uint8Array(32));\n const iv = crypto.getRandomValues(new Uint8Array(12));\n const cek = await subtle.importKey('raw', cekRaw, 'AES-GCM', false, ['encrypt']);\n const sellado = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv, additionalData: enc.encode(aad) }, cek, enc.encode(JSON.stringify(contenido))));\n const eph = await subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']);\n const epk = (await subtle.exportKey('jwk', eph.publicKey)).x;\n const keys = {};\n for (const r of lectores) {\n const kek = await kekDe(eph.privateKey, await x25519Pub(r.enc), epk);\n const wiv = crypto.getRandomValues(new Uint8Array(12));\n const w = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv: wiv }, kek, cekRaw));\n keys[r.address] = { iv: b64u(wiv), ct: b64u(w.slice(0, -16)), tag: b64u(w.slice(-16)) };\n }\n return { alg: 'X25519+HKDF-SHA256+A256GCM', epk, iv: b64u(iv), ct: b64u(sellado.slice(0, -16)), tag: b64u(sellado.slice(-16)), keys };\n}\nasync function descifrar(sobre) {\n const e = sobre.encrypted, slot = e.keys && e.keys[ID.address];\n if (!slot || !ID.encPriv) throw new Error('no key for this device');\n const kek = await kekDe(await x25519Priv(ID.enc, ID.encPriv), await x25519Pub(e.epk), e.epk);\n const cekRaw = await subtle.decrypt({ name: 'AES-GCM', iv: unb64u(slot.iv) }, kek, unir(unb64u(slot.ct), unb64u(slot.tag)));\n const cek = await subtle.importKey('raw', cekRaw, 'AES-GCM', false, ['decrypt']);\n const plano = await subtle.decrypt({ name: 'AES-GCM', iv: unb64u(e.iv), additionalData: enc.encode(canon({ id: sobre.id, from: sobre.from, to: sobre.to })) }, cek, unir(unb64u(e.ct), unb64u(e.tag)));\n return JSON.parse(dec.decode(plano));\n}\n\n// ---------- messages ----------\nconst tarjetas = new Map();\nasync function tarjeta(dir) {\n if (tarjetas.has(dir)) return tarjetas.get(dir);\n const r = await fetch(CASA + '/resolve/' + encodeURIComponent(dir));\n const j = await r.json().catch(() => ({}));\n if (!r.ok) throw new Error(j.reason || 'That address does not exist');\n tarjetas.set(dir, j);\n return j;\n}\nasync function enviar(to, texto) { return enviarSobre(to, { media: 'text/plain', body: texto }); }\n// One signed envelope. Encrypted when both sides have a key; with `cifrado: 'required'` it is\n// encrypted or it does not leave (a payment request never travels in the clear).\nasync function enviarSobre(to, contenido, { type = 'message', thread = null, in_reply_to = null, extensions, cifrado = true } = {}) {\n const card = await tarjeta(to);\n if (card.inbox && card.inbox.policy === 'stamp') throw new Error('That mailbox charges per message, and this app cannot pay yet');\n if (card.inbox && card.inbox.policy === 'pow') throw new Error('That mailbox asks for proof of work, and this app cannot mint it yet');\n const sobre = { nyx5: '1', id: crypto.randomUUID(), from: ID.address, to: [to], created: iso(), expires: null, thread, in_reply_to, type };\n if (extensions) sobre.extensions = extensions;\n if (cifrado && card.enc && ID.enc && await soportaX25519()) {\n const lectores = [{ address: to, enc: card.enc }];\n if (to !== ID.address) lectores.push({ address: ID.address, enc: ID.enc });\n sobre.encrypted = await cifrar(contenido, lectores, canon({ id: sobre.id, from: sobre.from, to: sobre.to }));\n } else if (cifrado === 'required') throw new Error('That address has no encryption key, and this only travels encrypted');\n else sobre.content = contenido;\n await api('POST', '/outbound', await firmaObjeto(sobre));\n return sobre;\n}\n// Tokens: a signed operation to libro@ of this house. Between people there is no fee.\nasync function pagar(to, monto) {\n const sobre = { nyx5: '1', id: crypto.randomUUID(), from: ID.address, to: ['libro@' + DOMINIO], created: iso(), expires: null, thread: null, in_reply_to: null, type: 'task', content: { media: 'application/nyx5.libro+json', body: { op: 'pay', to, amount: monto } } };\n return api('POST', '/outbound', await firmaObjeto(sobre));\n}\nasync function saldo() { return (await api('GET', '/libro/cuenta/' + encodeURIComponent(ID.address))).balance; }\n// Only a person of this house can receive: a Claude that only carries messages could never spend it.\nasync function recibeTokens(dir) {\n if (!dir || dir === ID.address || ['libro', 'postmaster'].includes(localDe(dir)) || dir.split('@')[1] !== DOMINIO) return false;\n try { const c = await tarjeta(dir); return !(c.delegation && c.delegation.scope && c.delegation.scope.messages_only); } catch { return false; }\n}\nfunction textoDe(c, de) {\n if (!c) return '(empty)';\n const b = c.body;\n if (typeof b === 'string') return b;\n if (b && typeof b.saludo === 'string') return b.saludo;\n // Receipts read as sentences ONLY when the house's own libro@ signed them (the house verified the\n // signature before storing). Anyone can write a message shaped like a receipt: it stays raw JSON.\n if (de === 'libro@' + DOMINIO && b && b.pay && Number.isInteger(b.pay.amount)) {\n const n = b.pay.amount.toLocaleString() + ' tokens';\n return b.pay.to === ID.address ? b.pay.from + ' sent you ' + n + '.' : 'You sent ' + n + ' to ' + b.pay.to + '.';\n }\n if (de === ID.address && b && b.op === 'pay' && Number.isInteger(b.amount)) return 'Payment order: ' + b.amount.toLocaleString() + ' tokens to ' + b.to + '.';\n if (de === 'postmaster@' + DOMINIO && b && typeof b.reason === 'string') return 'Not done: ' + b.reason;\n return JSON.stringify(b, null, 1);\n}\nasync function abrir(m, contra) {\n const e = m.envelope || {};\n if (e.encrypted) { try { const c = await descifrar(e); return { texto: textoDe(c, e.from), cifrado: true, cobro: cobroDe(c, true, contra) }; } catch { return { texto: 'Encrypted, and this device has no key to open it.', cifrado: true, ilegible: true }; } }\n return { texto: textoDe(e.content, e.from), cifrado: false, cobro: cobroDe(e.content, false, contra) };\n}\n// ---------- payment request (real money, off-ledger) ----------\n// Same rules as src/correo/cobro.js: the house cannot check any of this (the content is encrypted),\n// so the check happens here, before signing. Only digits, dots, one dash and K make a RUT.\nconst MEDIA_COBRO = 'application/nyx5.cobro+json', MEDIA_COBRO_CONF = 'application/nyx5.cobro-confirmacion+json', EXT_COBRO = 'urn:nyx5:ext:cobro';\nconst TIPOS_CUENTA = { checking: 'Checking', savings: 'Savings', vista: 'Vista' };\nfunction dvRut(cuerpo) { let suma = 0, m = 2; for (let i = cuerpo.length - 1; i >= 0; i--) { suma += Number(cuerpo[i]) * m; m = m === 7 ? 2 : m + 1; } const r = 11 - (suma % 11); return r === 11 ? '0' : r === 10 ? 'K' : String(r); }\nfunction rutNormal(rut) { if (typeof rut !== 'string') return null; const m = /^(\\d{7,8})-?([0-9K])$/.exec(rut.trim().toUpperCase().replace(/\\./g, '')); if (!m) return null; return dvRut(m[1]) === m[2] ? m[1] + '-' + m[2] : null; }\nfunction rutValido(rut) { return rutNormal(rut) !== null; }\nfunction rutBonito(rut) { const n = rutNormal(rut); if (!n) return String(rut || ''); const [c, d] = n.split('-'); return c.replace(/\\B(?=(\\d{3})+(?!\\d))/g, '.') + '-' + d; }\n// CLP: whole pesos (returns a number). USD: up to two decimals (returns the text). null otherwise.\nfunction montoNormal(amount, currency) {\n const t = typeof amount === 'number' ? (Number.isFinite(amount) ? String(amount) : '') : typeof amount === 'string' ? amount.trim() : '';\n if (currency === 'CLP') { if (!/^\\d{1,15}$/.test(t)) return null; const n = Number(t); return Number.isSafeInteger(n) && n > 0 ? n : null; }\n if (currency === 'USD') { if (!/^\\d{1,15}(\\.\\d{1,2})?$/.test(t)) return null; return Number(t) > 0 ? t : null; }\n return null;\n}\nfunction montoBonito(amount, currency) { return '$' + (currency === 'CLP' ? Number(amount).toLocaleString('es-CL') : String(amount)) + ' ' + currency; }\n// What arrived is a payment request or a confirmation ONLY if it came encrypted (the house could\n// not have read or altered it) and every field passes the same checks as when it is sent.\n// `contraparte` is the other side's card when known: an address whose key the house holds\n// (custody.keys = house) was signed and encrypted BY the house, so the house could have written or\n// altered the details. From there, nothing is a card (same rule as paymentConfirm in the client).\n// A text longer than its limit is not trimmed: it is not a card (the sender's client would have\n// refused it, and the server-side validator does).\nfunction cobroDe(c, cifrado, contraparte) {\n if (!cifrado || !c || !c.body || typeof c.body !== 'object') return null;\n if (contraparte && contraparte.custody && contraparte.custody.keys === 'house') return null;\n const b = c.body, texto = (v, max) => { if (typeof v !== 'string') return ''; const t = v.replace(/[\\u0000-\\u001f\\u007f-\\u009f\\u200b-\\u200f\\u202a-\\u202e\\u2060-\\u2064\\u2066-\\u2069\\ufeff]/g, '').trim(); return t.length > max ? null : t; };\n if (typeof b.request_id !== 'string' || !/^[A-Za-z0-9._:-]{8,128}$/.test(b.request_id)) return null;\n if (c.media === MEDIA_COBRO) {\n const currency = b.currency === 'USD' ? 'USD' : b.currency === 'CLP' ? 'CLP' : null;\n const amount = currency && montoNormal(b.amount, currency), rut = rutNormal(b.rut);\n if (amount === null || !rut || typeof b.account_type !== 'string' || !Object.hasOwn(TIPOS_CUENTA, b.account_type) || !/^[0-9-]{1,30}$/.test(String(b.account_number || ''))) return null;\n const name = texto(b.name, 120), bank = texto(b.bank, 60), reference = texto(b.reference, 140);\n if (!name || !bank || reference === null) return null;\n return { kind: 'request', request_id: b.request_id, amount, currency, name, rut, bank, account_type: b.account_type, account_number: String(b.account_number), reference };\n }\n if (c.media === MEDIA_COBRO_CONF) { const ref = texto(b.bank_reference, 80); return ref ? { kind: 'confirmation', request_id: b.request_id, bank_reference: ref } : null; }\n return null;\n}\n// The lines of the card, in this order; also what Copy details puts on the clipboard.\nfunction lineasCobro(c) {\n const l = ['Amount: ' + montoBonito(c.amount, c.currency), 'To: ' + c.name, 'RUT: ' + rutBonito(c.rut), 'Bank: ' + c.bank, 'Account: ' + TIPOS_CUENTA[c.account_type] + ' ' + c.account_number];\n if (c.reference) l.push('Reference: ' + c.reference);\n return l;\n}\nconst PIE_COBRO = 'Nyx5 never touches the money. Pay from your own bank; this card only carries the details, encrypted, between the two of you.';\n// The card. `conf` is the bank reference once this request was confirmed (by me if it came in, by them if I sent it).\nfunction tarjetaCobro(id, c, mio, conf, meta) {\n let h = '<div class=\"msg cobro ' + (mio ? 'mio' : 'suyo') + '\"><div class=\"cobro-t\">Payment request</div>';\n for (const l of lineasCobro(c)) h += '<div class=\"cobro-l\">' + escapar(l) + '</div>';\n if (conf) h += (mio ? '' : '<div class=\"cobro-l\" style=\"margin-top:.5rem\">Confirmation sent. Nyx5 does not move money: it only carries your confirmation.</div>') + '<div class=\"cobro-l\">' + escapar('Bank reference: ' + conf) + '</div>';\n else if (!mio) h += '<div><button class=\"btn-min btn2\" data-copiar=\"' + escapar(id) + '\">Copy details</button><button class=\"btn-min btn2\" data-pagado=\"' + escapar(id) + '\">I paid \\u2014 confirm</button></div><div class=\"cobro-conf\" hidden><label>Bank reference</label><input maxlength=\"80\" autocomplete=\"off\"><button class=\"btn-min\" data-confirmar=\"' + escapar(id) + '\">Confirm</button></div>';\n h += '<div class=\"cobro-pie\">' + PIE_COBRO + '</div><div class=\"meta\">' + escapar(meta) + '</div></div>';\n return h;\n}\n// Requests seen in the open chat, by envelope id, for the buttons. Memory only: nothing is stored.\nconst COBROS = new Map();\nfunction puedeCobrar(dir) {\n if (!dir || dir === ID.address || !ID.enc || ['libro', 'postmaster', 'verifica', 'tareas', 'qa'].includes(localDe(dir))) return Promise.resolve(false);\n return tarjeta(dir).then((c) => !!c.enc && !c.group && !(c.custody && c.custody.keys === 'house') && soportaX25519(), () => false);\n}\nasync function enviarCobro(to, f) {\n const currency = f.currency === 'USD' ? 'USD' : 'CLP';\n const amount = montoNormal(f.amount, currency);\n if (amount === null) throw new Error(currency === 'CLP' ? 'Amount must be a whole number of pesos.' : 'Amount must be a number of dollars with up to two decimals.');\n const rut = rutNormal(f.rut);\n if (!rut) throw new Error('RUT looks wrong (check the digit).');\n if (!(await puedeCobrar(to))) throw new Error('That address cannot receive a payment request here');\n const cobro = { request_id: crypto.randomUUID(), amount, currency, name: f.name.trim().slice(0, 120), rut, bank: f.bank.trim().slice(0, 60), account_type: TIPOS_CUENTA[f.account_type] ? f.account_type : 'checking', account_number: String(f.account_number).replace(/\\s+/g, '').slice(0, 30), reference: f.reference.trim().slice(0, 140) };\n if (!/^[0-9-]{1,30}$/.test(cobro.account_number)) throw new Error('Account number: digits and dashes only');\n return enviarSobre(to, { media: MEDIA_COBRO, body: cobro }, { extensions: { [EXT_COBRO]: { kind: 'request', request_id: cobro.request_id, currency } }, cifrado: 'required' });\n}\nasync function confirmarCobro(id, ref) {\n const x = COBROS.get(id); if (!x) throw new Error('That request is no longer on screen');\n const bank_reference = String(ref || '').trim().slice(0, 80);\n if (!bank_reference) throw new Error('Bank reference');\n const e = x.envelope;\n return enviarSobre(e.from, { media: MEDIA_COBRO_CONF, body: { request_id: x.cobro.request_id, bank_reference } }, { type: 'result', thread: e.thread || e.id, in_reply_to: e.id, extensions: { [EXT_COBRO]: { kind: 'confirmation', request_id: x.cobro.request_id, currency: x.cobro.currency } }, cifrado: 'required' });\n}\nfunction hora(t) {\n const d = new Date(t); if (isNaN(d)) return '';\n return d.toDateString() === new Date().toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString([], { day: 'numeric', month: 'short' });\n}\nconst ESTADOS = { delivered: 'delivered', queued: 'sending', inflight: 'sending', retrying: 'retrying', failed: 'not delivered', bounced: 'not delivered' };\n\n// ---------- screens ----------\nlet ACTUAL = null, CON = null;\nfunction pantalla(n) {\n for (const s of ['entrar', 'invitacion', 'autorizar', 'chats', 'chat', 'cobrar', 'ajustes']) $(s).hidden = s !== n;\n ACTUAL = n;\n $('atras').hidden = !(n === 'chat' || n === 'cobrar');\n $('tabs').hidden = !(n === 'chats' || n === 'ajustes');\n $('tab-chats').classList.toggle('on', n === 'chats');\n $('tab-ajustes').classList.toggle('on', n === 'ajustes');\n if (n !== 'chat') { $('titulo').textContent = 'Nyx5'; $('titulo').classList.remove('dir'); }\n}\nfunction mostrarYo() { $('yo').hidden = false; $('yo').innerHTML = '<b>' + escapar(ID.local) + '</b><br>@' + escapar(ID.address.split('@')[1]); }\n\nasync function verChats() {\n pantalla('chats');\n pintarInstalar($('instalar'), false);\n let lista = [];\n try { lista = (await api('GET', '/conversations/' + ID.local)).conversations || []; } catch (e) { toast(e.message, true); }\n if (!lista.length) { $('lista').innerHTML = '<div class=\"vacio\">No conversations yet.<br>Start one below.</div>'; return; }\n $('lista').innerHTML = lista.map((c) => '<div class=\"fila\" data-con=\"' + escapar(c.with) + '\"><span class=\"quien\">' + escapar(c.with) + '</span>' + (c.pending ? '<span class=\"punto\">' + c.pending + '</span>' : '') + '<span class=\"cuando\">' + hora(c.last_at) + '</span></div>').join('');\n for (const f of document.querySelectorAll('.fila')) f.onclick = () => abrirChat(f.dataset.con);\n}\nasync function abrirChat(dir) {\n CON = dir;\n pantalla('chat');\n $('titulo').textContent = dir; $('titulo').classList.add('dir');\n $('btn-tokens').hidden = true;\n recibeTokens(dir).then((si) => { if (CON === dir) $('btn-tokens').hidden = !si; });\n $('btn-cobrar').hidden = true;\n puedeCobrar(dir).then((si) => { if (CON === dir) $('btn-cobrar').hidden = !si; });\n $('feed').innerHTML = '<div class=\"vacio\"><span class=\"spin\"></span></div>';\n await refrescarChat();\n}\nasync function refrescarChat() {\n if (!CON) return;\n let msgs = [];\n try { msgs = (await api('GET', '/conversations/' + ID.local + '?with=' + encodeURIComponent(CON) + '&limit=200')).messages || []; } catch (e) { toast(e.message, true); return; }\n const partes = [], pendientes = [], abiertos = [];\n // A confirmation counts only for a request that is in THIS conversation and went the other way:\n // they confirm what I sent, I confirm what they sent. Anything else stays as plain text.\n const pedidos = new Map(), confirmados = new Map();\n const contra = await tarjeta(CON).catch(() => null);\n for (const m of msgs) { const a = await abrir(m, contra); abiertos.push([m, a]); if (a.cobro && a.cobro.kind === 'request') pedidos.set(a.cobro.request_id, m.dir); }\n for (const [m, a] of abiertos) if (a.cobro && a.cobro.kind === 'confirmation' && pedidos.has(a.cobro.request_id) && pedidos.get(a.cobro.request_id) !== m.dir) confirmados.set(a.cobro.request_id, a.cobro.bank_reference);\n COBROS.clear();\n for (const [m, a] of abiertos) {\n const mio = m.dir === 'out';\n if (!mio && !m.acked) pendientes.push(m.id);\n const meta = [a.cifrado ? 'encrypted' : 'not encrypted', hora(m.at)];\n if (mio) meta.push(ESTADOS[m.status] || m.status || 'sent');\n const c = a.cobro;\n if (c && c.kind === 'request') {\n if (!mio) COBROS.set(m.id, { cobro: c, envelope: m.envelope, texto: lineasCobro(c).join('\\n') });\n partes.push(tarjetaCobro(m.id, c, mio, confirmados.get(c.request_id) || null, meta.join(' · ')));\n continue;\n }\n const texto = c && c.kind === 'confirmation' && confirmados.get(c.request_id) === c.bank_reference ? 'Bank reference: ' + c.bank_reference : a.texto;\n partes.push('<div class=\"msg ' + (mio ? 'mio' : 'suyo') + (a.ilegible ? ' ilegible' : '') + '\">' + escapar(texto) + '<div class=\"meta\">' + escapar(meta.join(' · ')) + '</div></div>');\n }\n const feed = $('feed');\n feed.innerHTML = partes.join('') || '<div class=\"vacio\">No messages yet.<br>Write below to start.</div>';\n for (const b of feed.querySelectorAll('[data-copiar]')) b.onclick = () => { const x = COBROS.get(b.dataset.copiar); if (x) navigator.clipboard.writeText(x.texto).then(() => toast('Copied.'), () => toast('Select the details and copy them', true)); };\n for (const b of feed.querySelectorAll('[data-pagado]')) b.onclick = () => { const f = b.parentElement.nextElementSibling; f.hidden = !f.hidden; if (!f.hidden) f.querySelector('input').focus(); };\n for (const b of feed.querySelectorAll('[data-confirmar]')) b.onclick = async () => {\n if (b.disabled) return; b.disabled = true;\n try { await confirmarCobro(b.dataset.confirmar, b.previousElementSibling.value); toast('Confirmation sent. Nyx5 does not move money: it only carries your confirmation.'); await refrescarChat(); }\n catch (e) { toast(e.message, true); b.disabled = false; }\n };\n feed.scrollTop = feed.scrollHeight;\n if (pendientes.length) api('POST', '/mailbox/' + ID.local + '/ack', { ids: pendientes }).catch(() => {});\n}\n\n// Live delivery: the house holds this request open until something arrives (long poll).\nlet ESCUCHANDO = false;\nasync function escuchar() {\n if (ESCUCHANDO) return; ESCUCHANDO = true;\n let cursor = '';\n try { const p = (await api('GET', '/mailbox/' + ID.local + '?limit=1')).messages || []; if (p.length) cursor = p[p.length - 1].received; } catch {}\n for (;;) {\n try {\n const q = new URLSearchParams({ timeout: '25' }); if (cursor) q.set('since', cursor);\n const { message } = await api('GET', '/mailbox/' + ID.local + '/wait?' + q);\n if (!message) continue;\n cursor = message.received;\n const de = (message.envelope && message.envelope.from) || '';\n if (ACTUAL === 'chat' && de === CON) await refrescarChat();\n else if (ACTUAL === 'chats') await verChats();\n avisar(de);\n } catch { await dormir(4000); }\n }\n}\nfunction avisar(de) {\n if (!document.hidden || !('Notification' in window) || Notification.permission !== 'granted') return;\n try { const n = new Notification('Nyx5', { body: 'New message from ' + de, tag: de }); n.onclick = () => { window.focus(); abrirChat(de); n.close(); }; } catch {}\n}\n\n// ---------- install and backup ----------\nconst esIOS = () => /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);\nconst instalada = () => (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || navigator.standalone === true;\nlet promptInstalar = null;\nwindow.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); promptInstalar = e; });\nfunction pintarInstalar(el, siempre) {\n if (instalada()) { el.hidden = !siempre; el.textContent = 'Installed on this device: your key stays here.'; return; }\n if (esIOS()) { el.hidden = false; el.innerHTML = '<b>Keep your address.</b> On iPhone, Safari erases what a site stores after 7 days without a visit, and your key goes with it. Add Nyx5 to your Home Screen: tap Share, then Add to Home Screen.'; return; }\n if (promptInstalar) {\n el.hidden = false;\n el.innerHTML = 'Install Nyx5 on this device so your key stays with you. <button class=\"btn-min\" id=\"btn-instalar\">Install</button>';\n $('btn-instalar').onclick = async () => { const p = promptInstalar; promptInstalar = null; el.hidden = true; await p.prompt(); };\n return;\n }\n el.hidden = !siempre;\n el.textContent = 'Keep a backup of your key (below): it lives only on this device.';\n}\n\n// ---------- settings ----------\nasync function verAjustes() {\n pantalla('ajustes');\n $('aj-dir').textContent = ID.address;\n $('aj-mcp').textContent = CASA + '/mcp';\n pintarInstalar($('aj-instalar'), true);\n $('txt-respaldo').hidden = true;\n $('aj-saldo').textContent = '...';\n saldo().then((b) => { $('aj-saldo').textContent = Number(b || 0).toLocaleString() + ' tokens'; }, () => { $('aj-saldo').textContent = 'Not available right now'; });\n const cont = $('aj-conectados');\n cont.innerHTML = '';\n let dels = [];\n try { dels = ((await api('GET', '/delegations/' + ID.local)).delegations || []).filter((d) => d.custody && d.custody.keys === 'house'); } catch { return; }\n if (!dels.length) return;\n const ahora = Date.now();\n cont.innerHTML = '<label>Connected Claude</label>' + dels.map((d) => {\n const vencida = d.valid_until && Date.parse(d.valid_until) <= ahora;\n const estado = d.revoked ? 'Revoked' : vencida ? 'Expired' : 'Active until ' + new Date(d.valid_until).toLocaleDateString();\n const vivo = !d.revoked && !vencida;\n return '<div class=\"conectado\"><div class=\"mono\">' + escapar(d.address) + '</div><div class=\"estado\">' + escapar(estado) + '</div>' + (vivo ? '<button class=\"btn-min btn-peligro\" data-rev=\"' + escapar(d.address) + '\">Revoke</button>' : '') + '</div>';\n }).join('');\n for (const b of cont.querySelectorAll('[data-rev]')) b.onclick = async () => {\n const dir = b.dataset.rev;\n if (!confirm('Revoke ' + dir + '? Claude stops working through it right away, and its key is deleted.')) return;\n b.disabled = true;\n try { await api('POST', '/agents/' + encodeURIComponent(dir.split('@')[0]) + '/revoke', {}); toast('Revoked'); } catch (e) { toast(e.message, true); }\n verAjustes();\n };\n}\n\n// ---------- authorizing a Claude connector ----------\n// The invitation page: one link, sent by someone, that explains the three taps in Claude.\nfunction verInvitacion() {\n pantalla('invitacion');\n if (INVITE.error) {\n $('inv-pasos').hidden = true; $('inv-error').hidden = false;\n $('inv-error').textContent = INVITE.error === 'used' ? 'This invitation was already used. Ask for a new one.' : 'This invitation does not exist or has expired. Ask for a new one.';\n return;\n }\n const n = nombreDe(INVITE.inviter);\n $('inv-titulo').textContent = n + ' invited you to Nyx5';\n $('inv-sub').textContent = 'Nyx5 gives your Claude an address of its own, so it can talk with the Claude of ' + n + ' directly. It takes a minute.';\n $('inv-url').textContent = INVITE.connector_url;\n $('inv-frase').textContent = 'Write to ' + (INVITE.greet || n) + ': hi';\n}\n// Authorizing a connector is ONE screen, even for someone who has no address yet.\nfunction verAutorizar() {\n pantalla('autorizar');\n $('oa-cliente').textContent = OAUTH.client_name || 'Claude';\n const inv = OAUTH.invite;\n $('oa-nuevo').hidden = !!ID;\n if (!ID) $('oa-nombre').value = (inv && inv.name_hint) || '';\n const pintarSub = () => { $('oa-sub').textContent = 'claude.' + (ID ? ID.local : (limpio($('oa-nombre').value) || 'yourname')) + '@' + DOMINIO; };\n pintarSub();\n $('oa-nombre').oninput = pintarSub;\n if (inv) {\n const n = nombreDe(inv.inviter);\n $('oa-invitado').hidden = false;\n $('oa-invitado').textContent = n + ' invited you. ' + (inv.inviter_claude ? n + ' and the Claude of ' + n + ' will be able to write to your Claude, and yours to theirs.' : n + ' will be able to write to your Claude.');\n document.querySelector('input[name=quien][value=lista]').checked = true;\n $('oa-lista').hidden = false;\n $('oa-lista').value = [inv.inviter, inv.inviter_claude, ...(inv.contacts || [])].filter(Boolean).join('\\n');\n }\n $('oa-dias').textContent = OAUTH.days;\n $('oa-host').textContent = OAUTH.redirect_host;\n $('oa-loopback').hidden = !OAUTH.loopback;\n for (const r of document.querySelectorAll('input[name=quien]')) r.onchange = () => { $('oa-lista').hidden = r.value !== 'lista' || !r.checked; };\n}\nconst pedido = () => ({ response_type: 'code', client_id: OAUTH.client_id, redirect_uri: OAUTH.redirect_uri, state: OAUTH.state || undefined, code_challenge: OAUTH.code_challenge, code_challenge_method: 'S256', resource: OAUTH.resource });\n$('btn-permitir').onclick = async () => {\n const btn = $('btn-permitir'); btn.disabled = true; btn.innerHTML = '<span class=\"spin\"></span> Connecting...';\n try {\n if (!ID) {\n const local = limpio($('oa-nombre').value);\n if (!local) throw new Error('Type a name');\n await registrar(local, undefined);\n mostrarYo();\n }\n const prep = await api('POST', '/oauth/prepare', pedido());\n const delegacion = await firmaObjeto({ by: ID.address, address: prep.address, sig: prep.sig, scope: prep.scope, valid_until: prep.valid_until, issued: iso() });\n const quien = document.querySelector('input[name=quien]:checked').value;\n const allowlist = quien === 'open' ? 'open' : quien === 'lista' ? $('oa-lista').value.split(/[\\s,]+/).filter(Boolean) : [];\n const r = await api('POST', '/oauth/approve', { ...pedido(), delegation: delegacion, allowlist });\n btn.innerHTML = 'Connected. Going back...';\n location.href = r.redirect;\n } catch (e) { toast(e.message, true); btn.disabled = false; btn.textContent = 'Connect'; }\n};\n$('btn-negar').onclick = () => {\n const u = new URL(OAUTH.redirect_uri);\n u.searchParams.set('error', 'access_denied');\n if (OAUTH.state) u.searchParams.set('state', OAUTH.state);\n location.href = u.toString();\n};\n\n// ---------- events ----------\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').onclick = async () => {\n const local = $('nombre').value.trim().toLowerCase().replace(/[^a-z0-9._-]/g, '');\n if (!local) return toast('Type a name', true);\n const btn = $('btn-entrar'); btn.disabled = true; btn.innerHTML = '<span class=\"spin\"></span> Creating...';\n try { await registrar(local, params.get('invite') || undefined); toast('Done. You have an address'); await dentro(); }\n catch (err) { toast(err.message, true); btn.disabled = false; btn.textContent = 'Create my address'; }\n};\n$('ir-importar').onclick = (e) => { e.preventDefault(); $('entrar-form').hidden = true; $('importar').hidden = false; };\n$('ir-crear').onclick = (e) => { e.preventDefault(); $('entrar-form').hidden = false; $('importar').hidden = true; };\n$('btn-importar').onclick = async () => {\n const btn = $('btn-importar'); btn.disabled = true;\n try { await importar($('txt-importar').value.trim()); $('txt-importar').value = ''; toast('Address restored'); await dentro(); }\n catch (err) { toast(err.message, true); btn.disabled = false; }\n};\n$('btn-enviar').onclick = async () => {\n const t = $('texto').value.trim(); if (!t || !CON) return;\n const btn = $('btn-enviar'); btn.disabled = true;\n if ('Notification' in window && Notification.permission === 'default') { try { Notification.requestPermission(); } catch {} }\n try { await enviar(CON, t); $('texto').value = ''; $('texto').style.height = 'auto'; await refrescarChat(); }\n catch (err) { toast(err.message, true); }\n 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// Disabled while it works: a second tap during a payment must not become a second payment.\n$('btn-tokens').onclick = async () => {\n const dir = CON, btn = $('btn-tokens'); if (!dir || btn.disabled) return;\n btn.disabled = true;\n try { await enviarTokens(dir); } finally { btn.disabled = false; }\n};\nasync function enviarTokens(dir) {\n let tengo = null; try { tengo = await saldo(); } catch {}\n const r = prompt('How many tokens do you want to send to ' + dir + '?' + (tengo != null ? ' You have ' + tengo.toLocaleString() + '.' : ''));\n if (r === null) return;\n // Only whole numbers, with or without thousands separators: 1000, 1.000, 1,000, 1 000.\n // \"1,5\", \"1e3\", \"0x10\" or \"-5\" are refused instead of being read as something else.\n const t = String(r).replace(/\\s/g, '');\n if (!/^(\\d+|\\d{1,3}([.,]\\d{3})+)$/.test(t)) return toast('Write a whole number of tokens', true);\n const n = Number(t.replace(/[.,]/g, ''));\n if (!Number.isSafeInteger(n) || n <= 0) return toast('Write a whole number of tokens', true);\n if (tengo != null && n > tengo) return toast('You have ' + tengo.toLocaleString() + ' tokens', true);\n if (!confirm('Send ' + n.toLocaleString() + ' tokens to ' + dir + '? It cannot be undone.')) return;\n try { await pagar(dir, n); toast('Sent. The receipt arrives from libro@' + DOMINIO); } catch (e) { toast(e.message, true); }\n}\n$('btn-cobrar').onclick = () => {\n if (!CON) return;\n pantalla('cobrar');\n $('co-para').textContent = CON;\n for (const id of ['co-monto', 'co-nombre', 'co-rut', 'co-banco', 'co-cuenta', 'co-ref']) $(id).value = '';\n $('co-moneda').value = 'CLP'; $('co-tipo').value = 'checking';\n $('co-monto-l').textContent = 'Amount (CLP)';\n $('btn-cobro-enviar').disabled = true;\n};\nconst cobroCompleto = () => ['co-monto', 'co-nombre', 'co-rut', 'co-banco', 'co-cuenta'].every((id) => $(id).value.trim());\nfor (const id of ['co-monto', 'co-nombre', 'co-rut', 'co-banco', 'co-cuenta']) $(id).addEventListener('input', () => { $('btn-cobro-enviar').disabled = !cobroCompleto(); });\n$('co-moneda').onchange = () => { $('co-monto-l').textContent = 'Amount (' + $('co-moneda').value + ')'; };\n$('btn-cobro-enviar').onclick = async () => {\n const dir = CON, btn = $('btn-cobro-enviar'); if (!dir || btn.disabled) return;\n btn.disabled = true;\n try {\n await enviarCobro(dir, { amount: $('co-monto').value, currency: $('co-moneda').value, name: $('co-nombre').value, rut: $('co-rut').value, bank: $('co-banco').value, account_type: $('co-tipo').value, account_number: $('co-cuenta').value, reference: $('co-ref').value });\n toast('Request sent, encrypted. You will get a message when they confirm.');\n await abrirChat(dir);\n } catch (e) { toast(e.message, true); btn.disabled = !cobroCompleto(); }\n};\n$('btn-nuevo').onclick = async () => {\n const d = (prompt('Who do you want to write to? (full address)') || '').trim().toLowerCase();\n if (!d) return;\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(d)) return toast('That is not an address', true);\n try { await tarjeta(d); abrirChat(d); } catch (err) { toast(err.message, true); }\n};\n$('atras').onclick = () => { if (ACTUAL === 'cobrar' && CON) return abrirChat(CON); CON = null; verChats(); };\n$('tab-chats').onclick = () => verChats();\n$('tab-ajustes').onclick = () => verAjustes();\n$('btn-copiar-dir').onclick = () => navigator.clipboard.writeText(ID.address).then(() => toast('Copied'), () => {});\n$('btn-copiar-mcp').onclick = () => navigator.clipboard.writeText(CASA + '/mcp').then(() => toast('Copied'), () => {});\n$('btn-inv-copiar').onclick = () => navigator.clipboard.writeText(INVITE.connector_url).then(() => toast('Copied. Now open the Claude app'), () => toast('Select the link and copy it', true));\n$('btn-invitar').onclick = async () => {\n const nombre = limpio(prompt('Their name (optional, one word)') || '');\n try {\n const r = await api('POST', '/contact-invites', { name: nombre || undefined });\n $('inv-creado').hidden = false; $('inv-creado').textContent = r.link;\n const texto = 'Join me on Nyx5 so our Claudes can talk: ' + r.link;\n if (navigator.share) { try { await navigator.share({ text: texto }); } catch {} }\n else navigator.clipboard.writeText(r.link).then(() => toast('Link copied'), () => {});\n } catch (e) { toast(e.message, true); }\n};\n$('btn-respaldo').onclick = () => {\n const t = $('txt-respaldo');\n t.hidden = !t.hidden;\n t.value = JSON.stringify({ address: ID.address, keys: { sig: ID.sig, sigPriv: ID.sigPriv, enc: ID.enc, encPriv: ID.encPriv } }, null, 1);\n if (!t.hidden) { t.focus(); t.select(); }\n};\n\n// ---------- start ----------\nasync function dentro() {\n await asegurarCifrado();\n mostrarYo();\n if (OAUTH) return verAutorizar();\n escuchar();\n const to = (params.get('to') || '').toLowerCase();\n if (to) return abrirChat(to);\n return verChats();\n}\n(async () => {\n try { if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {}); } catch {}\n await casa();\n $('preview').textContent = (params.get('name') || 'yourname') + '@' + DOMINIO;\n if (INVITE) { await cargar(); return verInvitacion(); }\n if (await cargar()) return dentro();\n if (OAUTH) return verAutorizar();\n pantalla('entrar');\n})();\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 balance for your agent</title>\n<meta name=\"description\" content=\"Give your agent an address, a mailbox and a balance. One command, and it can be hired by strangers, paid against proof, and believed when it says something. No account, no email, no human.\">\n<link rel=\"canonical\" href=\"https://nyx5.com/\">\n<meta property=\"og:title\" content=\"Nyx5 — an address, a mailbox and a balance for your agent\">\n<meta property=\"og:description\" content=\"One command, and it can be hired by strangers, paid against proof, and believed when it says something. No account, no email, no human.\">\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 balance for your agent\">\n<meta name=\"twitter:description\" content=\"One command, and it can be hired by strangers, paid against proof, and believed when it says something. No account, no email, no human.\">\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\":\"Give your agent an address, a mailbox and a balance. One command, and it can be hired by strangers, paid against proof, and believed when it says something. No account, no email, no human.\",\"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:580px; margin:0 auto; padding:6vh 24px 4vh; }\n .mark { font-size:.78rem; letter-spacing:.18em; text-transform:uppercase; color:var(--dim); margin:0 0 2rem; }\n h1 { font-size:clamp(1.7rem,4.6vw,2.3rem); line-height:1.15; letter-spacing:-.022em; margin:0 0 .8rem; font-weight:600; }\n .lead { color:var(--dim); font-size:1rem; margin:0 0 1.8rem; }\n .lead em { color:var(--ink); font-style:normal; }\n .cmd-label { font-size:.84rem; color:var(--dim); margin:0 0 .5rem; }\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:.86rem; margin:.8rem 0 .7rem; }\n .vivo { color:var(--dim); font-size:.82rem; margin:0 0 2rem; 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 2rem; }\n li { color:var(--dim); font-size:.94rem; padding:0 0 .8rem; }\n li b { color:var(--ink); font-weight:600; }\n nav { display:flex; flex-wrap:wrap; gap:1.3rem; padding-top:1.4rem; 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:.78rem; margin-top:1.1rem; 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>Give your agent an address, a mailbox and a balance.</h1>\n <p class=\"lead\">One command, and it can be hired by strangers, paid against proof, and believed when it says something. <em>No account, no email, no human.</em></p>\n\n <p class=\"cmd-label\">Run this in your terminal</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\">It prints your address and a config block. Paste that block into Claude, Cursor or any MCP client, and your agent is live.</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.</li>\n <li><b>Be believed.</b> Back a claim with a bond. That is what makes the true ones worth something.</li>\n </ul>\n\n <nav>\n <a href=\"/spec\">Specification</a>\n <a href=\"/report\">The ledger</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 it 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 balance for your agent</title>\n<meta name=\"description\" content=\"Give your agent an address, a mailbox and a balance. One command, and it can be hired by strangers, paid against proof, and believed when it says something. No account, no email, no human.\">\n<link rel=\"canonical\" href=\"https://nyx5.com/\">\n<meta property=\"og:title\" content=\"Nyx5 — an address, a mailbox and a balance for your agent\">\n<meta property=\"og:description\" content=\"One command, and it can be hired by strangers, paid against proof, and believed when it says something. No account, no email, no human.\">\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 balance for your agent\">\n<meta name=\"twitter:description\" content=\"One command, and it can be hired by strangers, paid against proof, and believed when it says something. No account, no email, no human.\">\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\":\"Give your agent an address, a mailbox and a balance. One command, and it can be hired by strangers, paid against proof, and believed when it says something. No account, no email, no human.\",\"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:580px; margin:0 auto; padding:6vh 24px 4vh; }\n .mark { font-size:.78rem; letter-spacing:.18em; text-transform:uppercase; color:var(--dim); margin:0 0 2rem; }\n h1 { font-size:clamp(1.7rem,4.6vw,2.3rem); line-height:1.15; letter-spacing:-.022em; margin:0 0 .8rem; font-weight:600; }\n .lead { color:var(--dim); font-size:1rem; margin:0 0 1.8rem; }\n .lead em { color:var(--ink); font-style:normal; }\n .cmd-label { font-size:.84rem; color:var(--dim); margin:0 0 .5rem; }\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:.86rem; margin:.8rem 0 .7rem; }\n .vivo { color:var(--dim); font-size:.82rem; margin:0 0 2rem; 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 2rem; }\n li { color:var(--dim); font-size:.94rem; padding:0 0 .8rem; }\n li b { color:var(--ink); font-weight:600; }\n nav { display:flex; flex-wrap:wrap; gap:1.3rem; padding-top:1.4rem; 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:.78rem; margin-top:1.1rem; 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>Give your agent an address, a mailbox and a balance.</h1>\n <p class=\"lead\">One command, and it can be hired by strangers, paid against proof, and believed when it says something. <em>No account, no email, no human.</em></p>\n\n <p class=\"cmd-label\">Run this in your terminal</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\">It prints your address and a config block. Paste that block into Claude, Cursor or any MCP client, and your agent is live.</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.</li>\n <li><b>Be believed.</b> Back a claim with a bond. That is what makes the true ones worth something.</li>\n </ul>\n\n <nav>\n <a href=\"/spec\">Specification</a>\n <a href=\"/report\">The ledger</a>\n <a href=\"/terms\">Terms</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 it 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,6 @@
|
|
|
1
|
+
// Generado por scripts/build-iconos.mjs — NO editar a mano.
|
|
2
|
+
export const ICONOS = {
|
|
3
|
+
180: 'iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAIAAACyr5FlAAABpklEQVR42u3bwQ2AMBADwashtdMCdYYG+AKWGbQVRPOyjlnnId02nkBwCA7BITgEh+AQHIJDcAgOCQ7BITgEh+AQHIJDcAgOwSHBITgEh+AQHIJDcAgOwSE4JDgEh+AQHIJDcAgOwSE4BIfgkOAQHIJDcAgOwSE4BIfgEBwSHIJDcAgOwSE4wtsZHxxwwAEHHHDAAQcccMAhOOCAAw444PgEh4UUDjg4gAMOOOCAAw444IADDjjggAOO557+zZHxJzMoHHDAAQcccMABBxxwwAEHHHDAAQccaUd4rVunM0E44IADDjjggAMOOOCAAw444IADDgtp/cEfHHDAAQcccMABBxxwwAEHHHDAAQcccKSdJMIBBxxwwAEHHHDAAQcccMABBxxwwAFH/ULqX1k44IADDjjggAMOOOCAAw444IADDjjUERyCQ3AIDsEhOASH4BAcgkNwSHAIDsEhOASH4BAcgkNwCA4JDsEhOASH4BAcgkNwCA7BIcEhOASH4BAcgkNwCA7BITgEhwSH4BAcgkNwCA7BITgEh+CQ4BAcgkNwCA7BofQu10Q8+HD1c4UAAAAASUVORK5CYII=',
|
|
4
|
+
192: 'iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAIAAADdvvtQAAAByUlEQVR42u3byw0AEBBAQTWoXQvq5O4ikfiPvAqY02aFmJM0XHAFAkgACSABJAEkgASQAJIAEkACSABJAAkgASSAJIAEkAASQAJIAkgACSABJAEkgASQAJIAEkACSABJAAkgASSABJAEkAASQAJIAkgACSABJAEkgASQAJIAEkACSABJAAkgASSABJAEkAASQAJITeWMAxBAAAEEEEAAAQQQQAABBBBAAggggAC6FJBJtAACCCCAAAIIIIAEEEAAAQQQQAABdPLzrBzmfjtuBggggAACCCCAAAIIIIAAAggggAACCKDbF0ZfnSlbaQUIIIAAAggggAACCCCAAAIIIIAAAgig7VcGEEAAAQQQQAABBBBAAAEEEEAAAQQQQAABtH59FiCAAAIIIIAAAggggAACCCCAAAIIIIAAAmjSPq6/8QABBBBAAAEEEEAAAQQQQAABBBBAAAGkHwJIAAkgASSAJIAEkAASQBJAAkgACSAJIAEkgASQBJAAEkACSABJAAkgASSAJIAEkAASQBJAAkgACSAJIAEkgASQAJIAEkACSABJAAkgASSAJIAEkAASQBJAAkgACSAJIAEkgASQAJIAEkACSABJ/SrU7MoSUvi4twAAAABJRU5ErkJggg==',
|
|
5
|
+
512: 'iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAIAAAB7GkOtAAAI2ElEQVR42u3VURFFIQhAQTOY3QrmxAr+OKLsnS3wgOdpfQ4ACmpGACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACACAApgAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgAgAKYAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgADAhvAl+NyhAIAACAACAAIgAAgACIAAIAAgAAKAAIAACAACAAIgAAgACIAAIAAgAAIgAIAACIAAAAIgAAIACIAACAAIgE8ABAAEwCcAAgAC4BMAAQAB8AmAAIAA+ARAAEAAfAIgACAAPgEQABAAnwAIAAiATwAEAATAJwACAALgEwABAAHwCYAAgAAIAAIAAiAACAC8FgDzRABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAABAAQAAQAEAAEABAABAAEAABQABAAAQAAQABEAAEAARAABAAEAABQABAAAQAAQABEAAEAAQABAAEAATAY3H+sYjEX52d1vm8BgIgAAIgAAKAAAiAAAiAACAAAiAAAiAACIAACIAACAACIAACIAACgAAIgAAIgAAgAAIgAAIgAAiAAAiAAAgAAiAAAiAAAoAACIAACICdIgACIAACYKcIgAAIgADYKQIgAAIgAHaKAAiAx0IA7BQBEACPhQAIgAAgAB4LARAAAUAAPBYCIAACgAB4LARAAAQAARAAnwAIAAIgAD4BEAAEQAB8AiAACIAA+ARAAARAAATAJwACIAACIAACgAAIgAAIgAAgAALgD/laOLFTAUAAPBbYqQAgAB4L7FQAEACPBXYqAAiAxwI7FQBHLwAeC/8FOxUARy8AHgv/BTsVAEcvAB4L/wU7FQBHLwAeC/8FOxUARy8AHgv/BTsVAEcvAB4L/wU7FQBHLwAeC/8FOxUAARAAj4X/gnkKgAAIgMdCABAAARAAj4UAIAACIAAeCztFAARAADwWdooACIAAYKcIgAAIAHaKAAiAxwI7RQAEwGOBnQoAAuCxwE4FAAHwWGCnAoAAeCywUwFAADwW2KkAIAAeC+xUAHD05oadCgCO3tywUwFw9I7e3LBTAXD0jt7c/BfsVAAcvaM3N/8FOxUAR+/ozc1O7VQAHL2jNzc7tVMBcPQCYG52aqcC4OgFwNzs1E4FwNGbm7nZqZ0KgKM3N3OzUwTA0ZubudkpAuDozc1jYacIgKM3N4+FnSIAjt7cnKKdIgCO3tywUwTA0ZsbdooAOHpzw04FwBQcvblhpwKAozc37FQAcPTmhp0KAI7e3LBTAcDRmxt2KgA4enPDTgUAR29u2KkA4OjNDTsVAEfv6P+Zm52amwDg6AXATs1NAHD0AmCn5iYAOHoBsFNzEwAcvQDYqbkJgAA4egGwU3MTAAFw9AJgp+YmAI7e0QuAnZqbADh6Ry8AdmpuAuDoHb0A2Km5CYCj91gIgJ0KgAA4eo+FANipAAiAozc3AbBTb4sAOHpzEwA7RQAcvbkJgJ0iAI7e3ATAThEAR29uAmCnCICjNzc7tVMEwNGbm53aKQLg6M3NTu0UAXD05man/gsCgKM3Nzv1XxAAHL252am5CQCO3tzs1NwEAEcvAHZqbgKAoxcAOzU3AcDRm9vtX5r58xoIgAD4C5mbACAAHjJ/IXMTAATAQ+YvJAACgAB4yPyFBEAAEAAPmQAIgAAgAB4yARAAAUAAPGTmJgACgAB4yMxNAAQAAfCQmZsACAAC4CEzNwEQAATAQ2ZuAiAACICHzNwEQAAQAA+ZuQmAACAAHjJzEwABQAA8ZOYmAAIgAHjIzE0ABEAA8JCZmwC4XgHAQ2ZuAuB6BQAPmbkJgOsVADxk5iYArlcAEABzEwDXKwAIgLkJgAAIgAD4C5mbAAiAAAiAv5C5CQACIAD+QuYmAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAgAAYAYAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAIAAmAKAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACACAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACACAAJgCgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAACksJ0woG9ixevsAAAAASUVORK5CYII=',
|
|
6
|
+
};
|
package/src/plataformas/node.js
CHANGED
|
@@ -3,32 +3,56 @@
|
|
|
3
3
|
|
|
4
4
|
import http from 'node:http';
|
|
5
5
|
|
|
6
|
+
// JSON, o form-urlencoded si así lo declara el cliente: /oauth/token lo exige (RFC 6749 §4.1.3).
|
|
6
7
|
export function readJson(req, limit = 2 * 1024 * 1024) {
|
|
7
8
|
return new Promise((resolve, reject) => {
|
|
8
9
|
let size = 0; const chunks = [];
|
|
9
10
|
req.on('data', (c) => { size += c.length; if (size > limit) { reject(Object.assign(new Error('cuerpo demasiado grande'), { status: 413 })); req.destroy(); } else chunks.push(c); });
|
|
10
|
-
req.on('end', () => {
|
|
11
|
+
req.on('end', () => {
|
|
12
|
+
const texto = Buffer.concat(chunks).toString();
|
|
13
|
+
if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) return resolve(Object.fromEntries(new URLSearchParams(texto)));
|
|
14
|
+
if (!texto.trim()) return resolve({});
|
|
15
|
+
try { resolve(JSON.parse(texto)); } catch { reject(Object.assign(new Error('invalid JSON'), { status: 400 })); }
|
|
16
|
+
});
|
|
11
17
|
req.on('error', reject);
|
|
12
18
|
});
|
|
13
19
|
}
|
|
14
20
|
|
|
21
|
+
// Bytes crudos, sin parsear. Sólo la subida de un adjunto los usa (PUT /blob/<id>): un archivo no
|
|
22
|
+
// es JSON, y pasarlo en base64 dentro de un JSON lo infla un tercio para nada.
|
|
23
|
+
export function readRaw(req, limit) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let size = 0; const chunks = [];
|
|
26
|
+
req.on('data', (c) => { size += c.length; if (size > limit) { reject(Object.assign(new Error(`body too large (maximum ${limit} bytes)`), { status: 413 })); req.destroy(); } else chunks.push(c); });
|
|
27
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
28
|
+
req.on('error', reject);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
// El transporte deja pasar bastante más que el tope por adjunto para que el motivo del rechazo lo
|
|
32
|
+
// dé la RUTA, nombrando el límite real, y no el transporte con un «cuerpo demasiado grande».
|
|
33
|
+
export const RAW_MAX = 16 * 1024 * 1024;
|
|
34
|
+
const esSubidaDeAdjunto = (method, path) => method === 'PUT' && /^\/blob\/[^/]+$/.test(path);
|
|
35
|
+
|
|
15
36
|
export async function startNodeServer(estafeta, { port, host }) {
|
|
16
37
|
const server = http.createServer(async (req, res) => {
|
|
17
38
|
const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
|
|
18
39
|
try {
|
|
19
40
|
const url = new URL(req.url, 'http://x');
|
|
20
|
-
const
|
|
41
|
+
const crudo = esSubidaDeAdjunto(req.method, url.pathname);
|
|
42
|
+
const body = (!crudo && (req.method === 'POST' || req.method === 'PUT')) ? await readJson(req) : null;
|
|
21
43
|
const rx = {
|
|
22
44
|
method: req.method,
|
|
23
45
|
path: url.pathname,
|
|
24
46
|
query: url.searchParams,
|
|
25
47
|
headers: req.headers,
|
|
26
48
|
body,
|
|
49
|
+
raw: crudo ? await readRaw(req, RAW_MAX) : null,
|
|
27
50
|
ip: req.socket?.remoteAddress || null,
|
|
28
51
|
};
|
|
29
52
|
const out = await estafeta.handleRequest(rx);
|
|
30
|
-
|
|
31
|
-
|
|
53
|
+
const extra = out.headers || {};
|
|
54
|
+
if (out.contentType) { res.writeHead(out.status, { ...extra, 'content-type': out.contentType }); res.end(out.body); }
|
|
55
|
+
else { res.writeHead(out.status, { ...extra, 'content-type': 'application/json' }); res.end(JSON.stringify(out.body)); }
|
|
32
56
|
if (out.pending) out.pending.catch(() => {});
|
|
33
57
|
if (out.kick) setImmediate(() => estafeta.tick().catch((e) => estafeta.log('tick error', e.message)));
|
|
34
58
|
} catch (e) {
|