@nyx5/nyx5 0.5.0 → 0.7.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 +36 -5
- package/bin/nyx5.js +29 -3
- package/demo/edge-local.mjs +41 -0
- package/demo/x402-testnet.mjs +80 -0
- package/docs/ARQUITECTURA.md +2 -0
- package/docs/SPEC.md +77 -9
- package/migrations/0006_puente_remoto.sql +19 -0
- package/migrations/0007_indice_reputacion.sql +31 -0
- package/migrations/0008_notaria.sql +18 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/correo/agente.js +132 -18
- package/src/correo/asistente.js +174 -0
- package/src/correo/estafeta.js +1040 -43
- package/src/correo/indice.js +178 -0
- package/src/correo/politica.js +189 -0
- package/src/correo/resolver.js +35 -11
- package/src/correo/unirse.js +6 -2
- 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 +77 -10
- package/src/libro/notaria.js +74 -0
- package/src/nucleo/almacen-d1.js +123 -13
- package/src/nucleo/almacen.js +109 -12
- package/src/nucleo/boveda.js +37 -0
- package/src/nucleo/crypto.js +19 -12
- 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 +10 -3
- package/src/plataformas/spec-html.js +1 -1
- package/src/plataformas/worker.js +33 -8
- package/src/puentes/herramientas.js +190 -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.js +266 -0
- package/src/version.js +4 -0
package/src/nucleo/almacen.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import fs from 'node:fs';
|
|
17
17
|
import path from 'node:path';
|
|
18
|
+
import { filaDeIndice, buscarEnMemoria } from '../correo/indice.js';
|
|
18
19
|
|
|
19
20
|
const readJson = (p, fallback = null) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : fallback);
|
|
20
21
|
const writeJson = (p, v) => {
|
|
@@ -27,7 +28,7 @@ const writeJson = (p, v) => {
|
|
|
27
28
|
export class FileStore {
|
|
28
29
|
constructor(dir) {
|
|
29
30
|
this.dir = dir;
|
|
30
|
-
for (const d of ['agents', 'mailbox', 'queue', 'outbox', 'invitations', 'libro/diario', 'libro/contratos', 'libro/mandatos', 'libro/ops', 'eventos', 'indice/casas', 'indice/agentes']) fs.mkdirSync(path.join(dir, d), { recursive: true });
|
|
31
|
+
for (const d of ['agents', 'mailbox', 'queue', 'outbox', 'invitations', 'libro/diario', 'libro/contratos', 'libro/mandatos', 'libro/ops', 'libro/notaria', 'eventos', 'indice/casas', 'indice/agentes']) fs.mkdirSync(path.join(dir, d), { recursive: true });
|
|
31
32
|
this.nonces = new Map(); // anti-replay: en FileStore basta memoria (un proceso)
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -87,6 +88,15 @@ export class FileStore {
|
|
|
87
88
|
fs.renameSync(p, archived);
|
|
88
89
|
return true;
|
|
89
90
|
}
|
|
91
|
+
// Historial: lo pendiente y lo ya confirmado (archivado), en orden de llegada, los últimos N.
|
|
92
|
+
listMailHistory(local, { limit = 200 } = {}) {
|
|
93
|
+
const leer = (d) => (fs.existsSync(d) ? fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f))) : []);
|
|
94
|
+
const vivos = leer(path.join(this.dir, 'mailbox', local));
|
|
95
|
+
const archivados = leer(path.join(this.dir, 'archive', local)).map((m) => ({ ...m, acked: m.acked || true }));
|
|
96
|
+
return [...vivos, ...archivados].sort((a, b) => String(a.received).localeCompare(String(b.received))).slice(-limit);
|
|
97
|
+
}
|
|
98
|
+
// Lo pendiente que llegó después de un momento dado (la espera en tiempo real pregunta esto).
|
|
99
|
+
listMailSince(local, sinceIso) { return this.listMail(local).filter((m) => !sinceIso || String(m.received) > sinceIso); }
|
|
90
100
|
|
|
91
101
|
// --- cola de salida (store-and-forward) ---
|
|
92
102
|
enqueue(job) { writeJson(path.join(this.dir, 'queue', `${job.id}.json`), job); }
|
|
@@ -112,6 +122,46 @@ export class FileStore {
|
|
|
112
122
|
return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(dir, f)));
|
|
113
123
|
}
|
|
114
124
|
|
|
125
|
+
// --- kv con vencimiento: clientes OAuth, códigos de un uso, tokens (hash) y la bóveda ---
|
|
126
|
+
_kvPath(ns, key) { return path.join(this.dir, 'kv', ns, `${encodeURIComponent(key)}.json`); }
|
|
127
|
+
kvGet(ns, key, nowMs = Date.now()) {
|
|
128
|
+
const r = readJson(this._kvPath(ns, key));
|
|
129
|
+
if (!r || (r.expires != null && r.expires <= nowMs)) return null;
|
|
130
|
+
return r.doc;
|
|
131
|
+
}
|
|
132
|
+
kvPut(ns, key, doc, expires = null) { writeJson(this._kvPath(ns, key), { doc, expires, created: new Date().toISOString() }); }
|
|
133
|
+
kvPutIfAbsent(ns, key, doc, expires = null, nowMs = Date.now()) {
|
|
134
|
+
if (this.kvGet(ns, key, nowMs) !== null) return false;
|
|
135
|
+
this.kvPut(ns, key, doc, expires);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
// Tomar y borrar en un paso: un código de autorización se usa UNA vez.
|
|
139
|
+
kvTake(ns, key, nowMs = Date.now()) {
|
|
140
|
+
const p = this._kvPath(ns, key);
|
|
141
|
+
const r = readJson(p);
|
|
142
|
+
if (!r) return null;
|
|
143
|
+
fs.unlinkSync(p);
|
|
144
|
+
return (r.expires != null && r.expires <= nowMs) ? null : r.doc;
|
|
145
|
+
}
|
|
146
|
+
// Contador atómico con vencimiento (límites de tasa durables). Un contador vencido arranca en 1.
|
|
147
|
+
kvIncrement(ns, key, expires = null, nowMs = Date.now()) {
|
|
148
|
+
const n = (Number(this.kvGet(ns, key, nowMs)) || 0) + 1;
|
|
149
|
+
this.kvPut(ns, key, n, expires);
|
|
150
|
+
return n;
|
|
151
|
+
}
|
|
152
|
+
kvDelete(ns, key) { const p = this._kvPath(ns, key); if (fs.existsSync(p)) fs.unlinkSync(p); }
|
|
153
|
+
kvPurge(nowMs = Date.now()) {
|
|
154
|
+
const raiz = path.join(this.dir, 'kv');
|
|
155
|
+
if (!fs.existsSync(raiz)) return;
|
|
156
|
+
for (const ns of fs.readdirSync(raiz)) {
|
|
157
|
+
for (const f of fs.readdirSync(path.join(raiz, ns))) {
|
|
158
|
+
const p = path.join(raiz, ns, f);
|
|
159
|
+
const r = readJson(p);
|
|
160
|
+
if (r?.expires != null && r.expires <= nowMs) fs.unlinkSync(p);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
115
165
|
// --- anti-replay de tokens de auth ---
|
|
116
166
|
useNonce(key, tsMs) {
|
|
117
167
|
if (this.nonces.has(key)) return false;
|
|
@@ -151,6 +201,23 @@ export class FileStore {
|
|
|
151
201
|
libroListMandates() { const d = path.join(this.dir, 'libro', 'mandatos'); return fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f))); }
|
|
152
202
|
libroGetOp(id) { return readJson(path.join(this.dir, 'libro', 'ops', `${id}.json`)); }
|
|
153
203
|
libroPutOp(id, v) { writeJson(path.join(this.dir, 'libro', 'ops', `${id}.json`), v); }
|
|
204
|
+
|
|
205
|
+
// --- notaría (NX-601): sellos de hash. Un archivo por sello; la lectura por hash recorre el
|
|
206
|
+
// directorio (FileStore es para pruebas y desarrollo; en D1 lo sirve el índice).
|
|
207
|
+
notariaGet(id) { return readJson(path.join(this.dir, 'libro', 'notaria', `${id}.json`)); }
|
|
208
|
+
_notariaTodos() { const d = path.join(this.dir, 'libro', 'notaria'); return fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f))); }
|
|
209
|
+
notariaList(sha256) { return this._notariaTodos().filter((s) => s.sha256 === sha256).sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)); }
|
|
210
|
+
notariaFind(sha256, by) { return this._notariaTodos().find((s) => s.sha256 === sha256 && s.by === by) || null; }
|
|
211
|
+
// Mismo candado que el índice único de D1: un segundo sello del mismo agente para el mismo hash
|
|
212
|
+
// falla cerrado con 421 (reintentable), así FileStore no es más permisivo que producción.
|
|
213
|
+
notariaPut(s) {
|
|
214
|
+
if (this.notariaFind(s.sha256, s.by)) {
|
|
215
|
+
const e = new Error(`ledger concurrency conflict: seal already exists for ${s.sha256} by ${s.by}`);
|
|
216
|
+
e.code = 421; e.transient = true;
|
|
217
|
+
throw e;
|
|
218
|
+
}
|
|
219
|
+
writeJson(path.join(this.dir, 'libro', 'notaria', `${s.id}.json`), s);
|
|
220
|
+
}
|
|
154
221
|
// ---------- instrumentación (nombre, fecha, actor y números; nunca contenido) ----------
|
|
155
222
|
putEvent(e) { writeJson(path.join(this.dir, 'eventos', `${e.id}.json`), e); }
|
|
156
223
|
listEvents({ name = null, since = null, limit = 500 } = {}) {
|
|
@@ -160,8 +227,20 @@ export class FileStore {
|
|
|
160
227
|
.filter((e) => e && (!name || e.name === name) && (!since || e.ts >= since))
|
|
161
228
|
.sort((a, b) => (a.ts < b.ts ? -1 : 1)).slice(-limit);
|
|
162
229
|
}
|
|
163
|
-
|
|
164
|
-
|
|
230
|
+
// Extracto por cuenta en el rango [since, until) sobre `at` (ISO ya normalizado por quien
|
|
231
|
+
// llama, ver estado.js): los `limit` asientos MÁS RECIENTES del rango, y `total` = cuántos hay
|
|
232
|
+
// en el rango, se muestren o no (el denominador viaja con el resultado).
|
|
233
|
+
libroStatementRange(account, { since = null, until = null, limit = 20 } = {}) {
|
|
234
|
+
const todos = this.libroJournal().filter((a) => a.lines.some((l) => l.account === account) && (!since || a.at >= since) && (!until || a.at < until));
|
|
235
|
+
return { entries: todos.slice(-limit), total: todos.length };
|
|
236
|
+
}
|
|
237
|
+
libroStatement(account, limit) { return this.libroStatementRange(account, { limit }).entries; }
|
|
238
|
+
// Saldo de una cuenta ANTES del asiento `n` y/o de la fecha `at`: la suma de sus deltas hasta
|
|
239
|
+
// ahí, leída del diario y no de saldos.json, para que un extracto cuadre por sí mismo.
|
|
240
|
+
libroBalanceBefore(account, { n = null, at = null } = {}) {
|
|
241
|
+
return this.libroJournal()
|
|
242
|
+
.filter((a) => (n == null || a.n < n) && (at == null || a.at < at))
|
|
243
|
+
.reduce((s, a) => s + a.lines.filter((l) => l.account === account).reduce((t, l) => t + l.delta, 0), 0);
|
|
165
244
|
}
|
|
166
245
|
|
|
167
246
|
// Un movimiento completo del Libro, junto. En D1: un batch atómico donde el PK del asiento (n)
|
|
@@ -183,6 +262,7 @@ export class FileStore {
|
|
|
183
262
|
if (bundle.state) this.libroPutState(bundle.state);
|
|
184
263
|
for (const c of bundle.contracts || []) this.libroPutContract(c);
|
|
185
264
|
for (const m of bundle.mandates || []) this.libroPutMandate(m);
|
|
265
|
+
for (const s of bundle.sellos || []) this.notariaPut(s);
|
|
186
266
|
if (bundle.op) this.libroPutOp(bundle.op.id, bundle.op.result);
|
|
187
267
|
}
|
|
188
268
|
|
|
@@ -198,15 +278,32 @@ export class FileStore {
|
|
|
198
278
|
indexGetHouse(domain) { return readJson(path.join(this.dir, 'indice', 'casas', `${domain}.json`)); }
|
|
199
279
|
indexPutHouse(h) { writeJson(path.join(this.dir, 'indice', 'casas', `${h.domain}.json`), h); }
|
|
200
280
|
indexListHouses() { const d = path.join(this.dir, 'indice', 'casas'); return fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f))); }
|
|
201
|
-
|
|
202
|
-
|
|
281
|
+
// Cada casa rastreada es un archivo con FILAS: la tarjeta (doc) más las columnas que el índice
|
|
282
|
+
// extrae de ella (indice.js) y la generación, para que un recorrido por cursor sobrevida a un
|
|
283
|
+
// rastreo que cambie los puntajes (ver la cabecera de src/correo/indice.js).
|
|
284
|
+
indexReplaceAgents(domain, cards) {
|
|
285
|
+
const archivo = path.join(this.dir, 'indice', 'agentes', `${domain}.json`);
|
|
286
|
+
const previo = new Map(this._indexRows(domain).map((r) => [r.address, r]));
|
|
287
|
+
const gen = this._indexGen() + 1;
|
|
288
|
+
const rows = cards.map((c) => filaDeIndice(domain, c, gen, previo.get(c.address) || null));
|
|
289
|
+
writeJson(archivo, { domain, gen, rows, updated: new Date().toISOString() });
|
|
290
|
+
}
|
|
291
|
+
_indexRows(domain) {
|
|
292
|
+
const f = readJson(path.join(this.dir, 'indice', 'agentes', `${domain}.json`));
|
|
293
|
+
if (!f) return [];
|
|
294
|
+
// Archivo del formato anterior (sólo tarjetas): filas sin columnas ni generación.
|
|
295
|
+
if (!f.rows && Array.isArray(f.cards)) return f.cards.map((c) => filaDeIndice(domain, c, 0, null));
|
|
296
|
+
return f.rows || [];
|
|
297
|
+
}
|
|
298
|
+
_indexGen() {
|
|
299
|
+
const d = path.join(this.dir, 'indice', 'agentes');
|
|
300
|
+
return fs.readdirSync(d).filter((f) => f.endsWith('.json')).reduce((m, f) => Math.max(m, readJson(path.join(d, f))?.gen || 0), 0);
|
|
301
|
+
}
|
|
302
|
+
indexSearch(f = {}) {
|
|
203
303
|
const d = path.join(this.dir, 'indice', 'agentes');
|
|
204
|
-
|
|
205
|
-
.filter((
|
|
206
|
-
.flatMap((
|
|
207
|
-
|
|
208
|
-
if (accepts) cards = cards.filter((c) => c.capabilities?.accepts?.includes(accepts));
|
|
209
|
-
if (q) { const needle = String(q).toLowerCase(); cards = cards.filter((c) => c.address.includes(needle) || JSON.stringify(c.capabilities || {}).toLowerCase().includes(needle)); }
|
|
210
|
-
return { total: cards.length, offset, agents: cards.slice(offset, offset + limit) };
|
|
304
|
+
const filas = fs.readdirSync(d).filter((x) => x.endsWith('.json'))
|
|
305
|
+
.filter((x) => !f.house || x === `${f.house}.json`)
|
|
306
|
+
.flatMap((x) => this._indexRows(x.slice(0, -5)));
|
|
307
|
+
return buscarEnMemoria(filas, f);
|
|
211
308
|
}
|
|
212
309
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Nyx5/1 — La bóveda: las llaves de los subagentes delegados que la casa guarda para que un
|
|
2
|
+
// conector remoto (el Claude de un teléfono) pueda firmar en nombre de ese subagente.
|
|
3
|
+
//
|
|
4
|
+
// Qué se guarda aquí y qué NO: sólo llaves de subagentes `claude.<dueño>@<casa>`, de alcance
|
|
5
|
+
// limitado a mensajes, con vencimiento y revocables por el dueño. La llave raíz de una persona
|
|
6
|
+
// nunca pasa por la casa: vive en su navegador y es la que firma la delegación.
|
|
7
|
+
//
|
|
8
|
+
// Cifrado en reposo con AES-256-GCM y una llave que vive SÓLO en un secret del Worker
|
|
9
|
+
// (NYX5_VAULT_KEY). Un volcado de la base no entrega ninguna llave. La AAD amarra cada sello a
|
|
10
|
+
// su dueño: una fila copiada bajo otro nombre no abre.
|
|
11
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
12
|
+
import { b64u, unb64u } from './crypto.js';
|
|
13
|
+
|
|
14
|
+
const AAD = (dueno) => Buffer.from(`nyx5/1 boveda ${dueno}`);
|
|
15
|
+
|
|
16
|
+
export function abrirBoveda(secreto) {
|
|
17
|
+
if (!secreto) return null;
|
|
18
|
+
const llave = Buffer.from(String(secreto).trim(), 'base64');
|
|
19
|
+
if (llave.length !== 32) throw new Error('NYX5_VAULT_KEY must be exactly 32 bytes, base64-encoded');
|
|
20
|
+
return {
|
|
21
|
+
sellar(obj, dueno) {
|
|
22
|
+
const iv = randomBytes(12);
|
|
23
|
+
const c = createCipheriv('aes-256-gcm', llave, iv);
|
|
24
|
+
c.setAAD(AAD(dueno));
|
|
25
|
+
const ct = Buffer.concat([c.update(Buffer.from(JSON.stringify(obj))), c.final()]);
|
|
26
|
+
return `v1.${b64u(iv)}.${b64u(ct)}.${b64u(c.getAuthTag())}`;
|
|
27
|
+
},
|
|
28
|
+
abrir(sello, dueno) {
|
|
29
|
+
const [v, iv, ct, tag] = String(sello).split('.');
|
|
30
|
+
if (v !== 'v1' || !iv || !ct || !tag) throw new Error('unreadable vault seal');
|
|
31
|
+
const d = createDecipheriv('aes-256-gcm', llave, unb64u(iv));
|
|
32
|
+
d.setAAD(AAD(dueno));
|
|
33
|
+
d.setAuthTag(unb64u(tag));
|
|
34
|
+
return JSON.parse(Buffer.concat([d.update(unb64u(ct)), d.final()]).toString());
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/nucleo/crypto.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import {
|
|
9
9
|
generateKeyPairSync, createPrivateKey, createPublicKey,
|
|
10
10
|
sign as nodeSign, verify as nodeVerify,
|
|
11
|
-
randomBytes, randomUUID, createHash,
|
|
11
|
+
randomBytes, randomUUID, createHash, hkdfSync,
|
|
12
12
|
createCipheriv, createDecipheriv,
|
|
13
13
|
} from 'node:crypto';
|
|
14
14
|
|
|
@@ -40,8 +40,18 @@ export function generateKeys() {
|
|
|
40
40
|
|
|
41
41
|
const sigPrivateKey = (d, x) => createPrivateKey({ key: { kty: 'OKP', crv: 'Ed25519', d, x }, format: 'jwk' });
|
|
42
42
|
const sigPublicKey = (x) => createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x }, format: 'jwk' });
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// El acuerdo de claves X25519 va por WebCrypto, NO por node:crypto. Medido el 10-sep-2026 en el
|
|
44
|
+
// runtime real de Cloudflare (workerd, scripts/sonda-workerd.mjs): `diffieHellman` no existe allá,
|
|
45
|
+
// ni con objetos de llave ni desde JWK, mientras que `crypto.subtle` X25519 sí, e importa las
|
|
46
|
+
// mismas JWK que genera Node. En Node y en los tests todo andaba: el defecto sólo se veía cuando
|
|
47
|
+
// el edge mismo cifraba, que es justo lo que hace el conector remoto. El secreto es el mismo
|
|
48
|
+
// (X25519 es estándar), así que el formato de los sobres no cambia y lo cifrado antes se abre igual.
|
|
49
|
+
async function x25519(d, x, publico) {
|
|
50
|
+
const s = globalThis.crypto.subtle;
|
|
51
|
+
const priv = await s.importKey('jwk', { kty: 'OKP', crv: 'X25519', x, d }, { name: 'X25519' }, false, ['deriveBits']);
|
|
52
|
+
const pub = await s.importKey('jwk', { kty: 'OKP', crv: 'X25519', x: publico }, { name: 'X25519' }, true, []);
|
|
53
|
+
return Buffer.from(await s.deriveBits({ name: 'X25519', public: pub }, priv, 256));
|
|
54
|
+
}
|
|
45
55
|
|
|
46
56
|
// ---------- Firma de objetos ----------
|
|
47
57
|
// Firma todo el objeto menos el campo indicado (por defecto "signature").
|
|
@@ -77,7 +87,7 @@ export function verifyBytes(data, signature, pub) {
|
|
|
77
87
|
// El AAD amarra el cifrado al sobre (id/from/to) para impedir reenvíos con otro remitente.
|
|
78
88
|
const INFO = Buffer.from('nyx5/1 cek-wrap');
|
|
79
89
|
|
|
80
|
-
export function encryptContent(content, recipients, aad) {
|
|
90
|
+
export async function encryptContent(content, recipients, aad) {
|
|
81
91
|
const cek = randomBytes(32);
|
|
82
92
|
const iv = randomBytes(12);
|
|
83
93
|
const cipher = createCipheriv('aes-256-gcm', cek, iv);
|
|
@@ -85,11 +95,11 @@ export function encryptContent(content, recipients, aad) {
|
|
|
85
95
|
const ct = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(content))), cipher.final()]);
|
|
86
96
|
const tag = cipher.getAuthTag();
|
|
87
97
|
|
|
88
|
-
const eph = generateKeyPairSync('x25519');
|
|
89
|
-
const epk = eph.
|
|
98
|
+
const eph = generateKeyPairSync('x25519').privateKey.export({ format: 'jwk' });
|
|
99
|
+
const epk = eph.x;
|
|
90
100
|
const keys = {};
|
|
91
101
|
for (const r of recipients) {
|
|
92
|
-
const shared =
|
|
102
|
+
const shared = await x25519(eph.d, eph.x, r.enc);
|
|
93
103
|
const kek = Buffer.from(hkdfSync('sha256', shared, Buffer.from(epk), INFO, 32));
|
|
94
104
|
const wiv = randomBytes(12);
|
|
95
105
|
const c = createCipheriv('aes-256-gcm', kek, wiv);
|
|
@@ -99,13 +109,10 @@ export function encryptContent(content, recipients, aad) {
|
|
|
99
109
|
return { alg: 'X25519+HKDF-SHA256+A256GCM', epk, iv: b64u(iv), ct: b64u(ct), tag: b64u(tag), keys };
|
|
100
110
|
}
|
|
101
111
|
|
|
102
|
-
export function decryptContent(encrypted, address, keys, aad) {
|
|
112
|
+
export async function decryptContent(encrypted, address, keys, aad) {
|
|
103
113
|
const slot = encrypted?.keys?.[address];
|
|
104
114
|
if (!slot) throw new Error(`no wrapped key for ${address}`);
|
|
105
|
-
const shared =
|
|
106
|
-
privateKey: encPrivateKey(keys.encPriv, keys.enc),
|
|
107
|
-
publicKey: encPublicKey(encrypted.epk),
|
|
108
|
-
});
|
|
115
|
+
const shared = await x25519(keys.encPriv, keys.enc, encrypted.epk);
|
|
109
116
|
const kek = Buffer.from(hkdfSync('sha256', shared, Buffer.from(encrypted.epk), INFO, 32));
|
|
110
117
|
const d = createDecipheriv('aes-256-gcm', kek, unb64u(slot.iv));
|
|
111
118
|
d.setAuthTag(unb64u(slot.tag));
|
|
@@ -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)}}\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>\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=\"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) {\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: null, in_reply_to: null, type: 'message' };\n const contenido = { media: 'text/plain', body: texto };\n if (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 sobre.content = contenido;\n return api('POST', '/outbound', await firmaObjeto(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) {\n const e = m.envelope || {};\n if (e.encrypted) { try { return { texto: textoDe(await descifrar(e), e.from), cifrado: true }; } 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 };\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', 'ajustes']) $(s).hidden = s !== n;\n ACTUAL = n;\n $('atras').hidden = n !== 'chat';\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 $('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 = [];\n for (const m of msgs) {\n const a = await abrir(m);\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 partes.push('<div class=\"msg ' + (mio ? 'mio' : 'suyo') + (a.ilegible ? ' ilegible' : '') + '\">' + escapar(a.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 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-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 = () => { 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
|
|
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";
|
|
@@ -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,11 +3,17 @@
|
|
|
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
|
}
|
|
@@ -27,8 +33,9 @@ export async function startNodeServer(estafeta, { port, host }) {
|
|
|
27
33
|
ip: req.socket?.remoteAddress || null,
|
|
28
34
|
};
|
|
29
35
|
const out = await estafeta.handleRequest(rx);
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
const extra = out.headers || {};
|
|
37
|
+
if (out.contentType) { res.writeHead(out.status, { ...extra, 'content-type': out.contentType }); res.end(out.body); }
|
|
38
|
+
else { res.writeHead(out.status, { ...extra, 'content-type': 'application/json' }); res.end(JSON.stringify(out.body)); }
|
|
32
39
|
if (out.pending) out.pending.catch(() => {});
|
|
33
40
|
if (out.kick) setImmediate(() => estafeta.tick().catch((e) => estafeta.log('tick error', e.message)));
|
|
34
41
|
} catch (e) {
|