@nyx5/nyx5 0.1.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/CONTRIBUTING.md +38 -0
- package/LICENSE +202 -0
- package/README.md +133 -0
- package/SECURITY.md +28 -0
- package/bin/nyx5.js +184 -0
- package/demo/contratos.js +117 -0
- package/demo/e2e.js +57 -0
- package/demo/offline.js +46 -0
- package/demo/piloto-d4.mjs +146 -0
- package/demo/spam.js +65 -0
- package/docs/ARQUITECTURA.md +220 -0
- package/docs/SPEC.md +396 -0
- package/examples/hola-mundo.mjs +24 -0
- package/migrations/0002_chasqui.sql +116 -0
- package/migrations/0003_candado.sql +9 -0
- package/migrations/0004_pins.sql +15 -0
- package/package.json +49 -0
- package/src/correo/agente.js +233 -0
- package/src/correo/estafeta.js +777 -0
- package/src/correo/politica.js +107 -0
- package/src/correo/resolver.js +173 -0
- package/src/libro/contratos.js +247 -0
- package/src/libro/errores.js +4 -0
- package/src/libro/libro.js +250 -0
- package/src/nucleo/almacen-d1.js +223 -0
- package/src/nucleo/almacen.js +203 -0
- package/src/nucleo/crypto.js +141 -0
- package/src/nucleo/d1-local.js +78 -0
- package/src/plataformas/app-html.js +2 -0
- package/src/plataformas/home-html.js +2 -0
- package/src/plataformas/node.js +40 -0
- package/src/plataformas/spec-html.js +3 -0
- package/src/plataformas/worker.js +87 -0
- package/src/puentes/email.js +136 -0
- package/src/puentes/mcp.js +105 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Nyx5/1 — Almacenamiento de una estafeta (Correo) y su Libro en archivos JSON.
|
|
2
|
+
// Es deliberadamente simple e inspeccionable. En producción se reemplaza por una clase con la
|
|
3
|
+
// misma interfaz sobre D1/Postgres (ver almacen-d1.js y docs/ARQUITECTURA.md).
|
|
4
|
+
//
|
|
5
|
+
// CONTRATO: la interfaz es async — todo método puede devolver Promise y los llamadores hacen
|
|
6
|
+
// await siempre. FileStore implementa en síncrono (un solo proceso Node: sin carreras); D1Store
|
|
7
|
+
// implementa en async real y da la atomicidad por transacción donde aquí la da el proceso único.
|
|
8
|
+
//
|
|
9
|
+
// Operaciones compuestas del contrato (las que en D1 son una transacción):
|
|
10
|
+
// markSeenIfNew(id, meta) -> boolean dedupe atómico de sobres
|
|
11
|
+
// claimDueJobs(nowMs, max) -> jobs[] reclamo exclusivo de trabajos de la cola
|
|
12
|
+
// useNonce(key, ts) -> boolean anti-replay de tokens de auth
|
|
13
|
+
// libroCommit(bundle) un asiento del Libro con todo lo que lo acompaña
|
|
14
|
+
// inboundCommit(bundle) la entrega de un sobre: seen + buzones + Libro, junto
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
const readJson = (p, fallback = null) => (fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : fallback);
|
|
20
|
+
const writeJson = (p, v) => {
|
|
21
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
22
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
23
|
+
fs.writeFileSync(tmp, JSON.stringify(v, null, 2));
|
|
24
|
+
fs.renameSync(tmp, p);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class FileStore {
|
|
28
|
+
constructor(dir) {
|
|
29
|
+
this.dir = dir;
|
|
30
|
+
for (const d of ['agents', 'mailbox', 'queue', 'outbox', 'invitations', 'libro/diario', 'libro/contratos', 'libro/mandatos', 'libro/ops', 'indice/casas', 'indice/agentes']) fs.mkdirSync(path.join(dir, d), { recursive: true });
|
|
31
|
+
this.nonces = new Map(); // anti-replay: en FileStore basta memoria (un proceso)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --- dominio ---
|
|
35
|
+
getDomain() { return readJson(path.join(this.dir, 'domain.json')); }
|
|
36
|
+
putDomain(v) { writeJson(path.join(this.dir, 'domain.json'), v); }
|
|
37
|
+
|
|
38
|
+
// --- agentes ---
|
|
39
|
+
getAgent(local) { return readJson(path.join(this.dir, 'agents', `${local}.json`)); }
|
|
40
|
+
putAgent(local, v) { writeJson(path.join(this.dir, 'agents', `${local}.json`), v); }
|
|
41
|
+
putAgentIfAbsent(local, v) {
|
|
42
|
+
if (this.getAgent(local)) return false;
|
|
43
|
+
this.putAgent(local, v);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
listAgents() { return fs.readdirSync(path.join(this.dir, 'agents')).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5)); }
|
|
47
|
+
|
|
48
|
+
// --- invitaciones de registro ---
|
|
49
|
+
getInvite(code) { return readJson(path.join(this.dir, 'invitations', `${code}.json`)); }
|
|
50
|
+
putInvite(inv) { writeJson(path.join(this.dir, 'invitations', `${inv.code}.json`), inv); }
|
|
51
|
+
listInvites() { const d = path.join(this.dir, 'invitations'); return fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f))); }
|
|
52
|
+
// Consumo de un uso, condicional: misma semántica que el UPDATE atómico de D1.
|
|
53
|
+
consumeInvite(code, atIso) {
|
|
54
|
+
const inv = this.getInvite(code);
|
|
55
|
+
if (!inv || inv.used >= inv.uses) return null;
|
|
56
|
+
inv.used += 1; inv.last_used = atIso;
|
|
57
|
+
this.putInvite(inv);
|
|
58
|
+
return inv;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- deduplicación de sobres recibidos ---
|
|
62
|
+
getSeen(id) { return readJson(path.join(this.dir, 'seen', `${id}.json`)); }
|
|
63
|
+
putSeen(id, rec) { writeJson(path.join(this.dir, 'seen', `${id}.json`), { id, at: new Date().toISOString(), ...rec }); }
|
|
64
|
+
// Dedupe atómico: true si es la primera vez. En D1: INSERT con PK y changes === 1.
|
|
65
|
+
markSeenIfNew(id, meta = {}) {
|
|
66
|
+
if (this.getSeen(id)) return false;
|
|
67
|
+
this.putSeen(id, meta);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- buzones ---
|
|
72
|
+
putMail(local, envelope, meta = {}) {
|
|
73
|
+
// `received` es del sistema: la meta no puede pisarlo.
|
|
74
|
+
writeJson(path.join(this.dir, 'mailbox', local, `${envelope.id}.json`), { ...meta, received: new Date().toISOString(), envelope });
|
|
75
|
+
}
|
|
76
|
+
listMail(local) {
|
|
77
|
+
const dir = path.join(this.dir, 'mailbox', local);
|
|
78
|
+
if (!fs.existsSync(dir)) return [];
|
|
79
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(dir, f)))
|
|
80
|
+
.sort((a, b) => String(a.received).localeCompare(String(b.received)));
|
|
81
|
+
}
|
|
82
|
+
ackMail(local, id) {
|
|
83
|
+
const p = path.join(this.dir, 'mailbox', local, `${id}.json`);
|
|
84
|
+
if (!fs.existsSync(p)) return false;
|
|
85
|
+
const archived = path.join(this.dir, 'archive', local, `${id}.json`);
|
|
86
|
+
fs.mkdirSync(path.dirname(archived), { recursive: true });
|
|
87
|
+
fs.renameSync(p, archived);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- cola de salida (store-and-forward) ---
|
|
92
|
+
enqueue(job) { writeJson(path.join(this.dir, 'queue', `${job.id}.json`), job); }
|
|
93
|
+
listQueue() { return fs.readdirSync(path.join(this.dir, 'queue')).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(this.dir, 'queue', f))); }
|
|
94
|
+
updateJob(job) { this.enqueue(job); }
|
|
95
|
+
removeJob(id) { const p = path.join(this.dir, 'queue', `${id}.json`); if (fs.existsSync(p)) fs.unlinkSync(p); }
|
|
96
|
+
// Reclamo exclusivo: devuelve los trabajos vencidos y los marca en vuelo con un plazo.
|
|
97
|
+
// En D1 es un solo UPDATE ... RETURNING; aquí, el proceso único lo hace seguro.
|
|
98
|
+
claimDueJobs(nowMs, max = 20) {
|
|
99
|
+
const due = this.listQueue().filter((j) =>
|
|
100
|
+
Date.parse(j.next_attempt) <= nowMs &&
|
|
101
|
+
(j.status === 'queued' || j.status === 'retrying' || (j.status === 'inflight' && (j.claimed_until || 0) < nowMs)))
|
|
102
|
+
.slice(0, max);
|
|
103
|
+
for (const j of due) { j.status = 'inflight'; j.claimed_until = nowMs + 60_000; this.updateJob(j); }
|
|
104
|
+
return due;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- historial de salida por agente (estado de cada envío) ---
|
|
108
|
+
putOutbox(local, entry) { writeJson(path.join(this.dir, 'outbox', local, `${entry.job}.json`), entry); }
|
|
109
|
+
listOutbox(local) {
|
|
110
|
+
const dir = path.join(this.dir, 'outbox', local);
|
|
111
|
+
if (!fs.existsSync(dir)) return [];
|
|
112
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(dir, f)));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- anti-replay de tokens de auth ---
|
|
116
|
+
useNonce(key, tsMs) {
|
|
117
|
+
if (this.nonces.has(key)) return false;
|
|
118
|
+
this.nonces.set(key, tsMs);
|
|
119
|
+
if (this.nonces.size > 10_000) { const cut = Date.now() - 600_000; for (const [k, t] of this.nonces) if (t < cut) this.nonces.delete(k); }
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
pruneNonces(beforeMs) { for (const [k, t] of this.nonces) if (t < beforeMs) this.nonces.delete(k); }
|
|
123
|
+
|
|
124
|
+
// --- pins de claves de dominios ajenos (TOFU) ---
|
|
125
|
+
getPins() { return readJson(path.join(this.dir, 'pins.json'), {}); }
|
|
126
|
+
// TOFU: el primer pin de un dominio manda; putPins agrega, nunca borra lo que otro aprendió.
|
|
127
|
+
putPin(domain, kid) {
|
|
128
|
+
const pins = this.getPins();
|
|
129
|
+
if (pins[domain]) return false;
|
|
130
|
+
pins[domain] = kid;
|
|
131
|
+
writeJson(path.join(this.dir, 'pins.json'), pins);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
putPins(v) { for (const [d, k] of Object.entries(v || {})) this.putPin(d, k); }
|
|
135
|
+
|
|
136
|
+
// ===== Libro (ledger de doble entrada) =====
|
|
137
|
+
// saldos.json = { seq: <último asiento>, balances: { cuenta: saldo } }
|
|
138
|
+
libroState() { return readJson(path.join(this.dir, 'libro', 'saldos.json'), { seq: 0, balances: {} }); }
|
|
139
|
+
libroPutState(v) { writeJson(path.join(this.dir, 'libro', 'saldos.json'), v); }
|
|
140
|
+
libroAppend(asiento) { writeJson(path.join(this.dir, 'libro', 'diario', `${String(asiento.n).padStart(12, '0')}.json`), asiento); }
|
|
141
|
+
libroJournal() {
|
|
142
|
+
const dir = path.join(this.dir, 'libro', 'diario');
|
|
143
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(dir, f))).sort((a, b) => a.n - b.n);
|
|
144
|
+
}
|
|
145
|
+
libroGetContract(id) { return readJson(path.join(this.dir, 'libro', 'contratos', `${id}.json`)); }
|
|
146
|
+
libroPutContract(c) { writeJson(path.join(this.dir, 'libro', 'contratos', `${c.id}.json`), c); }
|
|
147
|
+
libroListContracts() { const d = path.join(this.dir, 'libro', 'contratos'); return fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f))); }
|
|
148
|
+
libroFindContractByQuote(quoteId) { return this.libroListContracts().find((c) => c.quote_id === quoteId) || null; }
|
|
149
|
+
libroGetMandate(id) { return readJson(path.join(this.dir, 'libro', 'mandatos', `${id}.json`)); }
|
|
150
|
+
libroPutMandate(m) { writeJson(path.join(this.dir, 'libro', 'mandatos', `${m.id}.json`), m); }
|
|
151
|
+
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
|
+
libroGetOp(id) { return readJson(path.join(this.dir, 'libro', 'ops', `${id}.json`)); }
|
|
153
|
+
libroPutOp(id, v) { writeJson(path.join(this.dir, 'libro', 'ops', `${id}.json`), v); }
|
|
154
|
+
libroStatement(account, limit) {
|
|
155
|
+
return this.libroJournal().filter((a) => a.lines.some((l) => l.account === account)).slice(-limit);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Un movimiento completo del Libro, junto. En D1: un batch atómico donde el PK del asiento (n)
|
|
159
|
+
// y el PK de la op (id de sobre) hacen fallar cerrado la concurrencia y la reentrega.
|
|
160
|
+
// bundle = { state, asientos: [], contracts: [], mandates: [], op: { id, result } | null }
|
|
161
|
+
libroCommit(bundle) {
|
|
162
|
+
// Mismo candado que D1: si otra operación cometió desde que ésta leyó el estado, el número de
|
|
163
|
+
// asiento ya está tomado y esto falla cerrado en vez de pisar. Sin esto, FileStore sería más
|
|
164
|
+
// permisivo que producción y un defecto de concurrencia no se vería en las pruebas locales.
|
|
165
|
+
if (bundle.base) {
|
|
166
|
+
const actual = this.libroState();
|
|
167
|
+
if (actual.seq !== bundle.base.seq) {
|
|
168
|
+
const e = new Error(`conflicto de concurrencia en el ledger: el estado cambió (seq ${bundle.base.seq} -> ${actual.seq})`);
|
|
169
|
+
e.code = 421; e.transient = true;
|
|
170
|
+
throw e;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const a of bundle.asientos || []) this.libroAppend(a);
|
|
174
|
+
if (bundle.state) this.libroPutState(bundle.state);
|
|
175
|
+
for (const c of bundle.contracts || []) this.libroPutContract(c);
|
|
176
|
+
for (const m of bundle.mandates || []) this.libroPutMandate(m);
|
|
177
|
+
if (bundle.op) this.libroPutOp(bundle.op.id, bundle.op.result);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// La entrega de un sobre entero, junta: dedupe + buzones + movimientos del Libro (estampillas, ops).
|
|
181
|
+
// bundle = { seen: { id, rec } | null, mails: [{ local, envelope, meta }], libro: [libroBundle...] }
|
|
182
|
+
inboundCommit(bundle) {
|
|
183
|
+
for (const m of bundle.mails || []) this.putMail(m.local, m.envelope, m.meta);
|
|
184
|
+
for (const lb of bundle.libro || []) this.libroCommit(lb);
|
|
185
|
+
if (bundle.seen) this.putSeen(bundle.seen.id, bundle.seen.rec);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ===== Índice federado (opcional: solo casas que corren un índice) =====
|
|
189
|
+
indexGetHouse(domain) { return readJson(path.join(this.dir, 'indice', 'casas', `${domain}.json`)); }
|
|
190
|
+
indexPutHouse(h) { writeJson(path.join(this.dir, 'indice', 'casas', `${h.domain}.json`), h); }
|
|
191
|
+
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))); }
|
|
192
|
+
indexReplaceAgents(domain, cards) { writeJson(path.join(this.dir, 'indice', 'agentes', `${domain}.json`), { domain, cards, updated: new Date().toISOString() }); }
|
|
193
|
+
indexSearch({ q, capability, accepts, house, limit = 50, offset = 0 } = {}) {
|
|
194
|
+
const d = path.join(this.dir, 'indice', 'agentes');
|
|
195
|
+
let cards = fs.readdirSync(d).filter((f) => f.endsWith('.json'))
|
|
196
|
+
.filter((f) => !house || f === `${house}.json`)
|
|
197
|
+
.flatMap((f) => (readJson(path.join(d, f))?.cards || []));
|
|
198
|
+
if (capability) cards = cards.filter((c) => c.capabilities?.[capability]);
|
|
199
|
+
if (accepts) cards = cards.filter((c) => c.capabilities?.accepts?.includes(accepts));
|
|
200
|
+
if (q) { const needle = String(q).toLowerCase(); cards = cards.filter((c) => c.address.includes(needle) || JSON.stringify(c.capabilities || {}).toLowerCase().includes(needle)); }
|
|
201
|
+
return { total: cards.length, offset, agents: cards.slice(offset, offset + limit) };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Nyx5/1 — primitivas criptográficas (solo node:crypto, sin dependencias)
|
|
2
|
+
//
|
|
3
|
+
// Firma: Ed25519 (identidad del agente y del dominio)
|
|
4
|
+
// Cifrado: X25519 (ECDH efímero) + HKDF-SHA256 + AES-256-GCM (extremo a extremo)
|
|
5
|
+
// Anti-spam: proof-of-work tipo hashcash (opcional, lo exige el receptor)
|
|
6
|
+
// Canónico: JSON con claves ordenadas y sin espacios, para que la firma sea determinista
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
generateKeyPairSync, createPrivateKey, createPublicKey,
|
|
10
|
+
sign as nodeSign, verify as nodeVerify,
|
|
11
|
+
randomBytes, randomUUID, createHash, diffieHellman, hkdfSync,
|
|
12
|
+
createCipheriv, createDecipheriv,
|
|
13
|
+
} from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
export const b64u = (buf) => Buffer.from(buf).toString('base64url');
|
|
16
|
+
export const unb64u = (s) => Buffer.from(s, 'base64url');
|
|
17
|
+
export const uuid = () => randomUUID();
|
|
18
|
+
export const sha256hex = (data) => createHash('sha256').update(data).digest('hex');
|
|
19
|
+
|
|
20
|
+
// ---------- JSON canónico ----------
|
|
21
|
+
export function canonical(value) {
|
|
22
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
23
|
+
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
|
|
24
|
+
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
|
|
25
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonical(value[k])).join(',') + '}';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------- Claves ----------
|
|
29
|
+
export function generateSigningKeys() {
|
|
30
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
31
|
+
return { sig: publicKey.export({ format: 'jwk' }).x, sigPriv: privateKey.export({ format: 'jwk' }).d };
|
|
32
|
+
}
|
|
33
|
+
export function generateEncryptionKeys() {
|
|
34
|
+
const { publicKey, privateKey } = generateKeyPairSync('x25519');
|
|
35
|
+
return { enc: publicKey.export({ format: 'jwk' }).x, encPriv: privateKey.export({ format: 'jwk' }).d };
|
|
36
|
+
}
|
|
37
|
+
export function generateKeys() {
|
|
38
|
+
return { ...generateSigningKeys(), ...generateEncryptionKeys() };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sigPrivateKey = (d, x) => createPrivateKey({ key: { kty: 'OKP', crv: 'Ed25519', d, x }, format: 'jwk' });
|
|
42
|
+
const sigPublicKey = (x) => createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x }, format: 'jwk' });
|
|
43
|
+
const encPrivateKey = (d, x) => createPrivateKey({ key: { kty: 'OKP', crv: 'X25519', d, x }, format: 'jwk' });
|
|
44
|
+
const encPublicKey = (x) => createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x }, format: 'jwk' });
|
|
45
|
+
|
|
46
|
+
// ---------- Firma de objetos ----------
|
|
47
|
+
// Firma todo el objeto menos el campo indicado (por defecto "signature").
|
|
48
|
+
export function signObject(obj, keys, field = 'signature') {
|
|
49
|
+
const { [field]: _omit, ...body } = obj;
|
|
50
|
+
const value = b64u(nodeSign(null, Buffer.from(canonical(body)), sigPrivateKey(keys.sigPriv, keys.sig)));
|
|
51
|
+
return { ...body, [field]: { alg: 'Ed25519', kid: keys.sig, value } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function verifyObject(obj, expectedPub, field = 'signature') {
|
|
55
|
+
const sig = obj?.[field];
|
|
56
|
+
if (!sig || sig.alg !== 'Ed25519' || typeof sig.value !== 'string') return false;
|
|
57
|
+
if (expectedPub && sig.kid !== expectedPub) return false;
|
|
58
|
+
const { [field]: _omit, ...body } = obj;
|
|
59
|
+
try {
|
|
60
|
+
return nodeVerify(null, Buffer.from(canonical(body)), sigPublicKey(sig.kid), unb64u(sig.value));
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Firma "suelta" de bytes (para el header de relay entre estafetas y tokens de auth)
|
|
67
|
+
export function signBytes(data, keys) {
|
|
68
|
+
return b64u(nodeSign(null, Buffer.from(data), sigPrivateKey(keys.sigPriv, keys.sig)));
|
|
69
|
+
}
|
|
70
|
+
export function verifyBytes(data, signature, pub) {
|
|
71
|
+
try { return nodeVerify(null, Buffer.from(data), sigPublicKey(pub), unb64u(signature)); } catch { return false; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------- Cifrado extremo a extremo ----------
|
|
75
|
+
// Estructura tipo JWE: una clave de contenido (CEK) aleatoria cifra el cuerpo con AES-256-GCM;
|
|
76
|
+
// la CEK se envuelve para cada destinatario con X25519(ephemeral, destinatario) -> HKDF -> AES-GCM.
|
|
77
|
+
// El AAD amarra el cifrado al sobre (id/from/to) para impedir reenvíos con otro remitente.
|
|
78
|
+
const INFO = Buffer.from('nyx5/1 cek-wrap');
|
|
79
|
+
|
|
80
|
+
export function encryptContent(content, recipients, aad) {
|
|
81
|
+
const cek = randomBytes(32);
|
|
82
|
+
const iv = randomBytes(12);
|
|
83
|
+
const cipher = createCipheriv('aes-256-gcm', cek, iv);
|
|
84
|
+
cipher.setAAD(Buffer.from(aad));
|
|
85
|
+
const ct = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(content))), cipher.final()]);
|
|
86
|
+
const tag = cipher.getAuthTag();
|
|
87
|
+
|
|
88
|
+
const eph = generateKeyPairSync('x25519');
|
|
89
|
+
const epk = eph.publicKey.export({ format: 'jwk' }).x;
|
|
90
|
+
const keys = {};
|
|
91
|
+
for (const r of recipients) {
|
|
92
|
+
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: encPublicKey(r.enc) });
|
|
93
|
+
const kek = Buffer.from(hkdfSync('sha256', shared, Buffer.from(epk), INFO, 32));
|
|
94
|
+
const wiv = randomBytes(12);
|
|
95
|
+
const c = createCipheriv('aes-256-gcm', kek, wiv);
|
|
96
|
+
const wrapped = Buffer.concat([c.update(cek), c.final()]);
|
|
97
|
+
keys[r.address] = { iv: b64u(wiv), ct: b64u(wrapped), tag: b64u(c.getAuthTag()) };
|
|
98
|
+
}
|
|
99
|
+
return { alg: 'X25519+HKDF-SHA256+A256GCM', epk, iv: b64u(iv), ct: b64u(ct), tag: b64u(tag), keys };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function decryptContent(encrypted, address, keys, aad) {
|
|
103
|
+
const slot = encrypted?.keys?.[address];
|
|
104
|
+
if (!slot) throw new Error(`no hay clave envuelta para ${address}`);
|
|
105
|
+
const shared = diffieHellman({
|
|
106
|
+
privateKey: encPrivateKey(keys.encPriv, keys.enc),
|
|
107
|
+
publicKey: encPublicKey(encrypted.epk),
|
|
108
|
+
});
|
|
109
|
+
const kek = Buffer.from(hkdfSync('sha256', shared, Buffer.from(encrypted.epk), INFO, 32));
|
|
110
|
+
const d = createDecipheriv('aes-256-gcm', kek, unb64u(slot.iv));
|
|
111
|
+
d.setAuthTag(unb64u(slot.tag));
|
|
112
|
+
const cek = Buffer.concat([d.update(unb64u(slot.ct)), d.final()]);
|
|
113
|
+
const d2 = createDecipheriv('aes-256-gcm', cek, unb64u(encrypted.iv));
|
|
114
|
+
d2.setAAD(Buffer.from(aad));
|
|
115
|
+
d2.setAuthTag(unb64u(encrypted.tag));
|
|
116
|
+
const plain = Buffer.concat([d2.update(unb64u(encrypted.ct)), d2.final()]);
|
|
117
|
+
return JSON.parse(plain.toString());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------- Proof-of-work (hashcash) ----------
|
|
121
|
+
function leadingZeroBits(hex) {
|
|
122
|
+
let bits = 0;
|
|
123
|
+
for (const ch of hex) {
|
|
124
|
+
const n = parseInt(ch, 16);
|
|
125
|
+
if (n === 0) { bits += 4; continue; }
|
|
126
|
+
bits += Math.clz32(n) - 28;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
return bits;
|
|
130
|
+
}
|
|
131
|
+
export function mintPow(id, bits) {
|
|
132
|
+
if (!bits) return null;
|
|
133
|
+
for (let nonce = 0; ; nonce++) {
|
|
134
|
+
if (leadingZeroBits(sha256hex(`${id}:${nonce}`)) >= bits) return { bits, nonce: String(nonce) };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function checkPow(id, pow, requiredBits) {
|
|
138
|
+
if (!requiredBits) return true;
|
|
139
|
+
if (!pow || pow.bits < requiredBits) return false;
|
|
140
|
+
return leadingZeroBits(sha256hex(`${id}:${pow.nonce}`)) >= requiredBits;
|
|
141
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// D1 local: la API de Cloudflare D1 sobre node:sqlite (Node 20+ con --experimental-sqlite, 22.5+ nativo).
|
|
2
|
+
// Para tests y desarrollo: la misma D1Store corre aquí y en el edge sin cambiar una línea.
|
|
3
|
+
// Cubre el subconjunto que usa la D1Store: prepare().bind().first()/all()/run()/raw(), batch(), exec().
|
|
4
|
+
|
|
5
|
+
// Carga perezosa de node:sqlite: no existe en Node 20 y necesita --experimental-sqlite en 22.
|
|
6
|
+
// Así, importar este módulo nunca rompe; las suites D1 comprueban `sqliteAvailable` y saltan si falta.
|
|
7
|
+
let DatabaseSync = null;
|
|
8
|
+
try { ({ DatabaseSync } = await import('node:sqlite')); } catch { /* Node <22 o sin el flag: openLocalD1 avisa al usarse */ }
|
|
9
|
+
export const sqliteAvailable = !!DatabaseSync;
|
|
10
|
+
|
|
11
|
+
class D1PreparedLocal {
|
|
12
|
+
constructor(raw, sql, params = []) {
|
|
13
|
+
this.raw = raw;
|
|
14
|
+
this.sql = sql;
|
|
15
|
+
this.params = params;
|
|
16
|
+
}
|
|
17
|
+
bind(...params) {
|
|
18
|
+
return new D1PreparedLocal(this.raw, this.sql, params.map(normalize));
|
|
19
|
+
}
|
|
20
|
+
async first(column) {
|
|
21
|
+
const row = this.raw.prepare(this.sql).get(...this.params) ?? null;
|
|
22
|
+
if (row === null) return null;
|
|
23
|
+
return column === undefined ? row : (row[column] ?? null);
|
|
24
|
+
}
|
|
25
|
+
async all() {
|
|
26
|
+
const results = this.raw.prepare(this.sql).all(...this.params);
|
|
27
|
+
return { results, success: true, meta: {} };
|
|
28
|
+
}
|
|
29
|
+
async run() {
|
|
30
|
+
const info = this.raw.prepare(this.sql).run(...this.params);
|
|
31
|
+
return { success: true, meta: { changes: Number(info.changes), last_row_id: Number(info.lastInsertRowid) } };
|
|
32
|
+
}
|
|
33
|
+
async raw({ columnNames = false } = {}) {
|
|
34
|
+
const stmt = this.raw.prepare(this.sql);
|
|
35
|
+
const rows = stmt.all(...this.params);
|
|
36
|
+
const cols = rows.length ? Object.keys(rows[0]) : [];
|
|
37
|
+
const values = rows.map(r => cols.map(c => r[c]));
|
|
38
|
+
return columnNames ? [cols, ...values] : values;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalize(v) {
|
|
43
|
+
if (v === undefined) return null;
|
|
44
|
+
if (typeof v === 'boolean') return v ? 1 : 0;
|
|
45
|
+
return v;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function openLocalD1(path = ':memory:') {
|
|
49
|
+
if (!DatabaseSync) throw new Error('node:sqlite no disponible: se necesita Node 22+ (con --experimental-sqlite) o 24+ nativo');
|
|
50
|
+
const raw = new DatabaseSync(path);
|
|
51
|
+
raw.exec('PRAGMA foreign_keys = ON');
|
|
52
|
+
let lock = Promise.resolve(); // D1 real serializa los batch; el emulador tambien
|
|
53
|
+
return {
|
|
54
|
+
prepare: (sql) => new D1PreparedLocal(raw, sql),
|
|
55
|
+
async batch(statements) {
|
|
56
|
+
// D1 aplica el batch de forma atomica y serializada; aqui igual: una transaccion a la vez.
|
|
57
|
+
const run = lock.then(async () => {
|
|
58
|
+
raw.exec('BEGIN');
|
|
59
|
+
try {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const s of statements) out.push(await s.run());
|
|
62
|
+
raw.exec('COMMIT');
|
|
63
|
+
return out;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
raw.exec('ROLLBACK');
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
lock = run.catch(() => {});
|
|
70
|
+
return run;
|
|
71
|
+
},
|
|
72
|
+
async exec(sql) {
|
|
73
|
+
raw.exec(sql);
|
|
74
|
+
return { count: sql.split(';').filter(s => s.trim()).length, duration: 0 };
|
|
75
|
+
},
|
|
76
|
+
_raw: raw,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// Generado desde web/app.html — NO editar a mano; edita el .html y regenera (node scripts/build-app-html.mjs).
|
|
2
|
+
export const APP_HTML = "<!doctype html>\n<html lang=\"es\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">\n<title>Nyx5</title>\n<style>\n:root{--bg:#FFFFFF;--panel:#F6F5F1;--ink:#1A1712;--dim:#8A8378;--faience:#12A594;--faience-soft:#E3F5F1;--gold:#B48A3C;--line:#EAE7DF;--exposed:#C4553B}\n*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}\nbody{background:var(--bg);color:var(--ink);font:16px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;min-height:100dvh;display:flex;flex-direction:column}\n.wrap{max-width:520px;margin:0 auto;width:100%;flex:1;display:flex;flex-direction:column;padding:env(safe-area-inset-top) 1.1rem env(safe-area-inset-bottom)}\nheader{padding:1.3rem 0 1rem;display:flex;align-items:center;gap:.6rem;border-bottom:1px solid var(--line)}\nheader .logo{font-family:Georgia,serif;font-size:1.45rem;color:var(--ink)}\nheader .yo{margin-left:auto;font-size:.78rem;color:var(--dim);font-family:ui-monospace,Menlo,monospace;text-align:right;line-height:1.3}\nheader .yo b{color:var(--gold);font-weight:600}\nh1{font-family:Georgia,serif;font-weight:400;font-size:1.6rem;margin:1.6rem 0 .5rem;line-height:1.15}\np.sub{color:var(--dim);font-size:.97rem;margin-bottom:1.4rem}\nlabel{display:block;font-size:.8rem;color:var(--dim);margin:1rem 0 .35rem}\ninput,textarea{width:100%;background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:.85rem 1rem;font:inherit;font-size:16px}\ninput:focus,textarea:focus{outline:none;border-color:var(--faience)}\ntextarea{resize:none}\nbutton{width:100%;background:var(--faience);color:#fff;border:none;border-radius:12px;padding:.95rem;font:inherit;font-weight:600;font-size:1rem;margin-top:1.1rem;cursor:pointer;transition:opacity .15s}\nbutton:active{opacity:.6}\nbutton:disabled{opacity:.4}\n.hint{font-size:.82rem;color:var(--dim);margin-top:.85rem;line-height:1.5}\n.hint b{color:var(--ink)}\n.feed{flex:1;overflow-y:auto;padding:1.1rem 0;display:flex;flex-direction:column;gap:.6rem}\n.msg{max-width:82%;padding:.7rem .95rem;border-radius:16px;word-break:break-word;white-space:pre-wrap}\n.msg .de{font-family:ui-monospace,Menlo,monospace;font-size:.68rem;color:var(--dim);margin-bottom:.3rem}\n.msg.suyo{background:var(--panel);border:1px solid var(--line);align-self:flex-start;border-bottom-left-radius:5px}\n.msg.mio{background:var(--faience-soft);align-self:flex-end;border-bottom-right-radius:5px}\n.msg.mio .de{color:var(--faience);text-align:right}\n.msg .estado{font-size:.65rem;color:var(--dim);margin-top:.25rem;text-align:right}\n.vacio{text-align:center;color:var(--dim);padding:2.5rem 1rem;font-size:.95rem;margin:auto 0}\n.compose{border-top:1px solid var(--line);padding:.85rem 0 1rem;position:sticky;bottom:0;background:var(--bg)}\n.row{display:flex;gap:.55rem;align-items:flex-end}\n.row textarea{min-height:2.7rem;max-height:120px;border-radius:22px;padding:.7rem 1rem}\n.row button{width:auto;margin-top:0;padding:.7rem 1.15rem;border-radius:22px;flex-shrink:0}\n.destino{font-size:.8rem;color:var(--dim);margin-top:.7rem}\n.destino b{color:var(--ink)} .destino a{color:var(--faience);text-decoration:none}\n.toast{position:fixed;left:50%;bottom:5.5rem;transform:translateX(-50%);background:var(--ink);color:#fff;padding:.7rem 1.1rem;border-radius:12px;font-size:.88rem;opacity:0;transition:opacity .25s;pointer-events:none;max-width:88vw;text-align:center}\n.toast.show{opacity:1}.toast.err{background:var(--exposed)}\n.spin{display:inline-block;width:1em;height:1em;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:s .6s linear infinite;vertical-align:-.15em}\n@keyframes s{to{transform:rotate(360deg)}}\n[hidden]{display:none!important}\n</style>\n</head>\n<body>\n<div class=\"wrap\">\n <header>\n <span class=\"logo\">Nyx5</span>\n <span class=\"yo\" id=\"yo\" hidden></span>\n </header>\n\n <section id=\"entrar\">\n <h1>Tu dirección de agente</h1>\n <p class=\"sub\">Con esto puedes mandar y recibir mensajes con dirección propia, como un correo. Tus llaves se crean acá, en tu teléfono, y no salen de él.</p>\n <div id=\"entrar-form\">\n <label>¿Cómo te llamas? (una palabra, sin espacios)</label>\n <input id=\"nombre\" autocomplete=\"off\" autocapitalize=\"off\" spellcheck=\"false\" placeholder=\"pauli\" inputmode=\"text\">\n <button id=\"btn-entrar\">Crear mi dirección</button>\n <p class=\"hint\">Serás <b id=\"preview\">pauli@…</b></p>\n </div>\n <div id=\"entrar-noinvite\" hidden>\n <p class=\"hint\" style=\"color:var(--exposed)\">Este enlace no trae invitación. Pídele a quien te compartió el link uno nuevo.</p>\n </div>\n </section>\n\n <section id=\"buzon\" hidden style=\"flex:1;display:flex;flex-direction:column\">\n <div class=\"feed\" id=\"feed\"></div>\n <div class=\"compose\">\n <div class=\"row\">\n <textarea id=\"texto\" placeholder=\"Escribe un mensaje…\" rows=\"1\"></textarea>\n <button id=\"btn-enviar\">Enviar</button>\n </div>\n <p class=\"destino\">Para: <b id=\"destino\">…</b> · <a href=\"#\" id=\"cambiar\">cambiar</a></p>\n </div>\n </section>\n</div>\n<div class=\"toast\" id=\"toast\"></div>\n\n<script type=\"module\">\nconst CASA = location.origin, DOMINIO = location.host, subtle = crypto.subtle;\nconst params = new URLSearchParams(location.search), $ = (id) => document.getElementById(id);\nconst enc = new TextEncoder();\nconst b64u = (b) => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');\nfunction canon(v){if(v===null||typeof v!=='object')return JSON.stringify(v);if(Array.isArray(v))return '['+v.map(canon).join(',')+']';const ks=Object.keys(v).filter(k=>v[k]!==undefined).sort();return '{'+ks.map(k=>JSON.stringify(k)+':'+canon(v[k])).join(',')+'}';}\nconst iso = () => new Date().toISOString();\n\nlet ID=null, privKey=null;\nasync function importPriv(j){return subtle.importKey('jwk',{kty:'OKP',crv:'Ed25519',x:j.sig,d:j.sigPriv},{name:'Ed25519'},false,['sign']);}\nasync function firmaBytes(str){return b64u(await subtle.sign({name:'Ed25519'},privKey,enc.encode(str)));}\nasync function firmaObjeto(o){const {signature:_,...b}=o;return {...b,signature:{alg:'Ed25519',kid:ID.sig,value:await firmaBytes(canon(b))}};}\nasync function auth(m,p){const c={address:ID.address,ts:iso(),nonce:crypto.randomUUID(),method:m,path:p,host:DOMINIO};return 'Nyx5 '+b64u(enc.encode(canon(c)))+'.'+await firmaBytes(canon(c));}\nfunction guardar(){localStorage.setItem('nyx5.id',JSON.stringify(ID));}\nasync function cargar(){const r=localStorage.getItem('nyx5.id');if(!r)return false;ID=JSON.parse(r);privKey=await importPriv(ID);return true;}\n\n// enviados: se guardan localmente para verse en el feed como en un chat\nconst enviados = () => { try { return JSON.parse(localStorage.getItem('nyx5.enviados')||'[]'); } catch { return []; } };\nconst guardarEnviado = (m) => { const e=enviados(); e.push(m); localStorage.setItem('nyx5.enviados', JSON.stringify(e.slice(-100))); };\n\nlet tt; function toast(msg,err){const t=$('toast');t.textContent=msg;t.classList.toggle('err',!!err);t.classList.add('show');clearTimeout(tt);tt=setTimeout(()=>t.classList.remove('show'),err?4000:2000);}\n\nasync function registrar(local,invite){\n const kp=await subtle.generateKey({name:'Ed25519'},true,['sign','verify']); const jwk=await subtle.exportKey('jwk',kp.privateKey);\n const keys={sig:jwk.x,sigPriv:jwk.d};\n const {signature:_,...b}={local,sig:keys.sig,enc:null,ts:iso(),invite};\n const tp=await subtle.importKey('jwk',{kty:'OKP',crv:'Ed25519',x:keys.sig,d:keys.sigPriv},{name:'Ed25519'},false,['sign']);\n const firmado={...b,signature:{alg:'Ed25519',kid:keys.sig,value:b64u(await subtle.sign({name:'Ed25519'},tp,enc.encode(canon(b))))}};\n const r=await fetch(CASA+'/agents',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(firmado)});\n const j=await r.json(); if(!r.ok) throw new Error(j.reason||('HTTP '+r.status));\n ID={address:local+'@'+DOMINIO,local,sig:keys.sig,sigPriv:keys.sigPriv}; privKey=await importPriv(ID); guardar(); return j;\n}\nasync function leerBuzon(){const r=await fetch(CASA+'/mailbox/'+ID.local+'?limit=50',{headers:{authorization:await auth('GET','/mailbox/'+ID.local)}});if(!r.ok)return [];return (await r.json()).messages||[];}\nasync function enviar(to,texto){\n const sobre={nyx5:'1',id:crypto.randomUUID(),from:ID.address,to:[to],created:iso(),expires:null,thread:null,in_reply_to:null,type:'message',content:{media:'text/plain',body:texto}};\n const firmado=await firmaObjeto(sobre);\n const r=await fetch(CASA+'/outbound',{method:'POST',headers:{'content-type':'application/json',authorization:await auth('POST','/outbound')},body:JSON.stringify(firmado)});\n const j=await r.json(); if(!r.ok) throw new Error(j.reason||('HTTP '+r.status));\n guardarEnviado({id:sobre.id,to,body:texto,created:sobre.created,mine:true});\n return j;\n}\nlet DESTINO = localStorage.getItem('nyx5.destino') || '';\nfunction cuerpo(e){const c=e.content;if(!c)return e.encrypted?'🔒 (cifrado)':'(vacío)';const b=c.body;if(typeof b==='string')return b;if(b&&b.saludo)return b.saludo;return JSON.stringify(b,null,1);}\nconst escapar = (s)=>s.replace(/[<>&]/g,c=>({'<':'<','>':'>','&':'&'}[c]));\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=>({de:m.envelope.from,texto:cuerpo(m.envelope),created:m.envelope.created,mine:false}));\n const mios=enviados().map(m=>({de:'tú',texto:m.body,created:m.created,mine:true}));\n const todo=[...recibidos,...mios].sort((a,b)=>a.created.localeCompare(b.created));\n const feed=$('feed');\n if(!todo.length){feed.innerHTML='<div class=\"vacio\">Aún no hay mensajes.<br>Escribe abajo para empezar.</div>';return;}\n feed.innerHTML=todo.map(m=>'<div class=\"msg '+(m.mine?'mio':'suyo')+'\">'+(m.mine?'':'<div class=\"de\">'+escapar(m.de)+'</div>')+escapar(m.texto)+(m.mine?'<div class=\"estado\">enviado ✓</div>':'')+'</div>').join('');\n feed.scrollTop=feed.scrollHeight;\n}\n$('nombre').addEventListener('input',e=>{const v=e.target.value.trim().toLowerCase().replace(/[^a-z0-9._-]/g,'');$('preview').textContent=(v||'tunombre')+'@'+DOMINIO;});\n$('btn-entrar').addEventListener('click',async()=>{\n const local=$('nombre').value.trim().toLowerCase().replace(/[^a-z0-9._-]/g,''); const invite=params.get('invite');\n if(!local)return toast('Escribe un nombre',true); if(!invite)return toast('Falta la invitación',true);\n const btn=$('btn-entrar');btn.disabled=true;btn.innerHTML='<span class=\"spin\"></span> Creando…';\n try{await registrar(local,invite);arrancarBuzon();toast('¡Listo! Ya tienes tu dirección');}\n catch(err){toast(err.message,true);btn.disabled=false;btn.textContent='Crear mi dirección';}\n});\n$('btn-enviar').addEventListener('click',async()=>{\n const t=$('texto').value.trim(); if(!t)return; const btn=$('btn-enviar');btn.disabled=true;\n try{await enviar(DESTINO,t);$('texto').value='';$('texto').style.height='auto';await refrescar();}\n catch(err){toast(err.message,true);} btn.disabled=false;\n});\n$('texto').addEventListener('input',e=>{e.target.style.height='auto';e.target.style.height=Math.min(e.target.scrollHeight,120)+'px';});\n$('texto').addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();$('btn-enviar').click();}});\n$('cambiar').addEventListener('click',e=>{e.preventDefault();const d=prompt('¿A quién le escribes? (dirección completa)',DESTINO);if(d){DESTINO=d.trim().toLowerCase();localStorage.setItem('nyx5.destino',DESTINO);$('destino').textContent=DESTINO;}});\nfunction arrancarBuzon(){\n if(!DESTINO){DESTINO=(params.get('to')||'').toLowerCase();}\n $('entrar').hidden=true;$('buzon').hidden=false;$('yo').hidden=false;$('yo').innerHTML='<b>'+ID.local+'</b><br>@'+DOMINIO;\n $('destino').textContent=DESTINO||'(toca cambiar)';\n refrescar();setInterval(refrescar,4000);\n}\n(async()=>{ if(await cargar()){arrancarBuzon();return;} if(!params.get('invite')){$('entrar-form').hidden=true;$('entrar-noinvite').hidden=false;} else { $('preview').textContent='pauli@'+DOMINIO; } })();\n</script>\n</body>\n</html>\n";
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
// GENERADO por scripts/build-home.mjs — no editar a mano.
|
|
2
|
+
export const HOME_HTML = "<!doctype html>\n<html lang=\"es\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Nyx5 — Correo y Libro para agentes de IA.</title>\n<meta name=\"description\" content=\"Correo y Libro para agentes de IA. Un agente tiene tres cosas que no tiene de otra forma: una dirección propia, un buzón que guarda aunque esté apagado, y un libro contable donde un acuerdo pesa —el pago se retiene hasta cumplir, y una afirmación falsa cuesta dinero—. Cada mensaje va firmado; cada m\">\n<link rel=\"canonical\" href=\"https://nyx5.com/\">\n<meta property=\"og:title\" content=\"Nyx5 — Correo y Libro para agentes de IA.\">\n<meta property=\"og:description\" content=\"Un agente tiene tres cosas que no tiene de otra forma: una dirección propia, un buzón que guarda aunque esté apagado, y un libro contable donde un acuerdo pesa —el pago se retiene hasta cumplir, y una\">\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:url\" content=\"https://nyx5.com/\">\n<script type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"SoftwareApplication\",\"name\":\"Nyx5\",\"applicationCategory\":\"DeveloperApplication\",\"operatingSystem\":\"Node.js, Cloudflare Workers\",\"description\":\"Correo y Libro para agentes de IA. Un agente tiene tres cosas que no tiene de otra forma: una dirección propia, un buzón que guarda aunque esté apagado, y un libro contable donde un acuerdo pesa —el pago se retiene hasta cumplir, y una afirmación falsa cuesta dinero—. Cada mensaje va firmado; cada m\",\"offers\":{\"@type\":\"Offer\",\"price\":\"0\"},\"license\":\"https://www.apache.org/licenses/LICENSE-2.0\",\"url\":\"https://nyx5.com\"}</script>\n<style>\n :root { --bg:#0b0b0f; --panel:#131319; --ink:#eceaf2; --dim:#9a97a8; --line:#26262f; --accent:#8b7bff; --accent2:#c9a5ff; }\n * { box-sizing:border-box; }\n body { margin:0; background:radial-gradient(1200px 600px at 50% -10%, #1a1730 0%, var(--bg) 55%); color:var(--ink); font:16px/1.6 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif; min-height:100vh; }\n a { color:var(--accent2); text-decoration:none; } a:hover { text-decoration:underline; }\n .wrap { max-width:920px; margin:0 auto; padding:34px 22px 90px; }\n header { display:flex; align-items:center; justify-content:space-between; }\n .mark { font-weight:700; letter-spacing:.02em; font-size:1.15rem; color:var(--ink); }\n .mark b { color:var(--accent); }\n nav a { color:var(--dim); margin-left:18px; font-size:.92rem; }\n .hero { margin:12vh 0 2.2rem; }\n .kicker { color:var(--accent2); font-size:.8rem; letter-spacing:.14em; text-transform:uppercase; margin-bottom:14px; }\n h1 { font-size:clamp(2.4rem,6vw,3.6rem); line-height:1.04; margin:0 0 .5rem; letter-spacing:-.02em; }\n h1 span { background:linear-gradient(90deg,var(--accent),var(--accent2)); -webkit-background-clip:text; background-clip:text; color:transparent; }\n .lead { color:var(--dim); font-size:1.12rem; max-width:640px; }\n .btns { display:flex; flex-wrap:wrap; gap:12px; margin:2rem 0 1rem; }\n .btn { border:1px solid var(--line); background:var(--panel); color:var(--ink); padding:11px 18px; border-radius:10px; font-size:.95rem; }\n .btn.primary { background:linear-gradient(90deg,var(--accent),#6f5cf0); border-color:transparent; color:#fff; }\n .btn:hover { text-decoration:none; border-color:var(--accent); }\n .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:16px; margin-top:5rem; }\n article { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:20px 20px 22px; }\n article h3 { margin:0 0 .5rem; font-size:1.05rem; }\n article p { color:var(--dim); font-size:.94rem; margin:0; }\n code { background:#00000055; border:1px solid var(--line); padding:.08em .38em; border-radius:5px; font:.86em ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--accent2); }\n footer { color:var(--dim); font-size:.86rem; margin-top:5rem; border-top:1px solid var(--line); padding-top:20px; }\n</style>\n</head>\n<body>\n<div class=\"wrap\">\n <header>\n <div class=\"mark\"><b>nyx5</b></div>\n <nav><a href=\"/spec\">spec</a><a href=\"/app\">app</a><a href=\"https://github.com/Nicoiakl/nyx5\">github</a></nav>\n </header>\n\n <section class=\"hero\">\n <div class=\"kicker\">Nyx5/1</div>\n <h1><span>Nyx5</span></h1>\n <p class=\"lead\">Correo y Libro para agentes de IA. Un agente tiene tres cosas que no tiene de otra forma: una dirección propia, un buzón que guarda aunque esté apagado, y un libro contable donde un acuerdo pesa —el pago se retiene hasta cumplir, y una afirmación falsa cuesta dinero—. Cada mensaje va firmado; cada movimiento de dinero deja un recibo que nadie puede negar.</p>\n <div class=\"btns\">\n <a class=\"btn primary\" href=\"/spec\">Leer la especificación</a>\n <a class=\"btn\" href=\"/app\">Probar la app</a>\n <a class=\"btn\" href=\"https://github.com/Nicoiakl/nyx5\">Código en GitHub</a>\n </div>\n </section>\n\n <section class=\"grid\">\n <article><h3>Dirección</h3><p>Cada agente es <code>agente@dominio</code>. La persona es dueña de su clave; el dominio solo la avala. La misma identidad firma los mensajes y opera el Libro.</p></article>\n <article><h3>Buzón</h3><p>Store-and-forward: le escribes a un agente aunque esté apagado, y recibe todo al volver. Sin firma verificable no hay entrega.</p></article>\n <article><h3>Libro</h3><p>Un ledger de doble entrada por casa. Cotizaciones, escrow, fianza, medido, mandatos en cadena. Un acuerdo deja de ser prosa: es un asiento que pesa.</p></article>\n </section>\n\n <footer>Implementación de referencia, sin dependencias, sobre Node y Cloudflare Workers · Apache-2.0 · <span class=\"mark\"><b>nyx5</b></span></footer>\n</div>\n</body>\n</html>\n";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Nyx5/1 — Adaptador Node: sirve una Estafeta con node:http.
|
|
2
|
+
// La Estafeta no conoce el transporte; este archivo traduce req/res -> handleRequest(rx).
|
|
3
|
+
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
|
|
6
|
+
export function readJson(req, limit = 2 * 1024 * 1024) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
let size = 0; const chunks = [];
|
|
9
|
+
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', () => { try { resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {}); } catch { reject(Object.assign(new Error('JSON inválido'), { status: 400 })); } });
|
|
11
|
+
req.on('error', reject);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function startNodeServer(estafeta, { port, host }) {
|
|
16
|
+
const server = http.createServer(async (req, res) => {
|
|
17
|
+
const send = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(req.url, 'http://x');
|
|
20
|
+
const body = (req.method === 'POST' || req.method === 'PUT') ? await readJson(req) : null;
|
|
21
|
+
const rx = {
|
|
22
|
+
method: req.method,
|
|
23
|
+
path: url.pathname,
|
|
24
|
+
query: url.searchParams,
|
|
25
|
+
headers: req.headers,
|
|
26
|
+
body,
|
|
27
|
+
ip: req.socket?.remoteAddress || null,
|
|
28
|
+
};
|
|
29
|
+
const out = await estafeta.handleRequest(rx);
|
|
30
|
+
if (out.contentType) { res.writeHead(out.status, { 'content-type': out.contentType }); res.end(out.body); }
|
|
31
|
+
else send(out.status, out.body);
|
|
32
|
+
if (out.pending) out.pending.catch(() => {});
|
|
33
|
+
if (out.kick) setImmediate(() => estafeta.tick().catch((e) => estafeta.log('tick error', e.message)));
|
|
34
|
+
} catch (e) {
|
|
35
|
+
send(Number.isInteger(e.status) ? e.status : 500, { reason: e.message });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
await new Promise((r) => server.listen(port, host, r));
|
|
39
|
+
return { server, close: () => new Promise((r) => server.close(r)) };
|
|
40
|
+
}
|