@nyx5/nyx5 0.1.0 → 0.2.1
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 +3 -3
- package/README.es.md +201 -0
- package/README.md +176 -73
- package/SECURITY.md +1 -1
- package/agents.md +73 -0
- package/bin/nyx5.js +128 -8
- package/demo/contratos.js +1 -1
- package/demo/e2e.js +1 -1
- package/demo/offline.js +1 -1
- package/demo/piloto-d4.mjs +1 -1
- package/demo/spam.js +1 -1
- package/docs/SPEC.en.md +489 -0
- package/docs/SPEC.md +94 -4
- package/examples/hola-mundo.mjs +1 -1
- package/migrations/0005_eventos.sql +16 -0
- package/package.json +36 -17
- package/src/correo/agente.js +11 -2
- package/src/correo/estafeta.js +186 -12
- package/src/correo/unirse.js +117 -0
- package/src/libro/contratos.js +23 -0
- package/src/libro/libro.js +66 -1
- package/src/libro/tareas.js +92 -0
- package/src/libro/verifica.js +113 -0
- package/src/nucleo/almacen-d1.js +9 -0
- package/src/nucleo/almacen.js +10 -1
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +2 -1
- package/src/plataformas/spec-html.js +4 -3
- package/src/plataformas/worker.js +55 -15
- package/src/puentes/mcp.js +22 -0
package/src/libro/libro.js
CHANGED
|
@@ -96,12 +96,77 @@ export class Libro {
|
|
|
96
96
|
async balance(account) { return (await this.store.libroState()).balances[account] || 0; }
|
|
97
97
|
fee(amount) { return Math.floor((amount * this.feeBps) / 10_000); }
|
|
98
98
|
async account(address) {
|
|
99
|
-
const
|
|
99
|
+
const { contratoPublico } = await import('./contratos.js');
|
|
100
|
+
const contracts = (await this.store.libroListContracts()).filter((c) => [c.seller, c.buyer, c.verifier, c.arbiter].includes(address)).map(contratoPublico);
|
|
100
101
|
const mandates = (await this.store.libroListMandates()).filter((m) => m.grantor === address || m.grantee === address);
|
|
101
102
|
return { account: address, balance: await this.balance(address), contracts, mandates };
|
|
102
103
|
}
|
|
103
104
|
async journal() { return this.store.libroJournal(); }
|
|
104
105
|
|
|
106
|
+
// ---------- reputación = una consulta al libro ----------
|
|
107
|
+
// No es un sistema de puntajes aparte: es lo que el libro ya sabe, contado. Por eso no se
|
|
108
|
+
// puede inflar sin gastar. La defensa contra Sybil es de diseño: SOLO cuentan los contratos
|
|
109
|
+
// cuyo asiento ya movió tokens (terminales). Un contrato abierto no dice nada de nadie.
|
|
110
|
+
//
|
|
111
|
+
// Estados terminales que cuentan, por tipo:
|
|
112
|
+
// spot settled entrega pagada
|
|
113
|
+
// escrow released | refunded entrega aceptada | devuelta
|
|
114
|
+
// bond released | forfeited afirmación sostenida | derribada
|
|
115
|
+
static TERMINALES = { spot: ['settled'], escrow: ['released', 'refunded'], bond: ['released', 'forfeited'], metered: [] };
|
|
116
|
+
|
|
117
|
+
async historial(address) {
|
|
118
|
+
const todos = await this.store.libroListContracts();
|
|
119
|
+
const mios = todos.filter((c) => [c.seller, c.buyer].includes(address));
|
|
120
|
+
const cuenta = () => ({ n: 0, tokens: 0 });
|
|
121
|
+
const h = {
|
|
122
|
+
address, house: this.domain,
|
|
123
|
+
vendiendo: { entregas_aceptadas: cuenta(), entregas_devueltas: cuenta(), ventas_directas: cuenta() },
|
|
124
|
+
comprando: { encargos_liberados: cuenta(), encargos_devueltos: cuenta(), compras_directas: cuenta() },
|
|
125
|
+
afirmando: { fianzas_sostenidas: cuenta(), fianzas_ejecutadas: cuenta(), fianzas_vigentes: cuenta() },
|
|
126
|
+
avalando: { avales_sostenidos: cuenta(), avales_ejecutados: cuenta() },
|
|
127
|
+
abiertos: cuenta(),
|
|
128
|
+
desde: null, hasta: null, total_movido: 0,
|
|
129
|
+
};
|
|
130
|
+
const sumar = (c, monto) => { c.n += 1; c.tokens += monto; };
|
|
131
|
+
for (const c of mios) {
|
|
132
|
+
const terminal = (Libro.TERMINALES[c.kind] || []).includes(c.state);
|
|
133
|
+
if (!terminal) { sumar(h.abiertos, 0); continue; }
|
|
134
|
+
const monto = Number(c.amount) || 0;
|
|
135
|
+
h.total_movido += monto;
|
|
136
|
+
if (!h.desde || c.created < h.desde) h.desde = c.created;
|
|
137
|
+
if (!h.hasta || c.created > h.hasta) h.hasta = c.created;
|
|
138
|
+
if (c.kind === 'bond') {
|
|
139
|
+
const grupo = c.vouchee ? h.avalando : h.afirmando;
|
|
140
|
+
const sostenida = c.state === 'released';
|
|
141
|
+
if (c.vouchee) sumar(sostenida ? grupo.avales_sostenidos : grupo.avales_ejecutados, monto);
|
|
142
|
+
else sumar(sostenida ? grupo.fianzas_sostenidas : grupo.fianzas_ejecutadas, monto);
|
|
143
|
+
} else if (c.seller === address) {
|
|
144
|
+
if (c.kind === 'spot') sumar(h.vendiendo.ventas_directas, monto);
|
|
145
|
+
else sumar(c.state === 'released' ? h.vendiendo.entregas_aceptadas : h.vendiendo.entregas_devueltas, monto);
|
|
146
|
+
} else {
|
|
147
|
+
if (c.kind === 'spot') sumar(h.comprando.compras_directas, monto);
|
|
148
|
+
else sumar(c.state === 'released' ? h.comprando.encargos_liberados : h.comprando.encargos_devueltos, monto);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Fianzas todavía en pie: no son historial cumplido, pero sí tokens en juego ahora mismo.
|
|
152
|
+
for (const c of mios) if (c.kind === 'bond' && c.state === 'posted' && c.seller === address) sumar(h.afirmando.fianzas_vigentes, Number(c.amount) || 0);
|
|
153
|
+
// El resumen es lo que un agente lee para decidir en una línea; el detalle queda arriba.
|
|
154
|
+
const entregadas = h.vendiendo.entregas_aceptadas.n + h.vendiendo.ventas_directas.n;
|
|
155
|
+
const falladas = h.vendiendo.entregas_devueltas.n;
|
|
156
|
+
const afirmaciones = h.afirmando.fianzas_sostenidas.n + h.afirmando.fianzas_ejecutadas.n;
|
|
157
|
+
h.resumen = {
|
|
158
|
+
entregas: entregadas, entregas_falladas: falladas,
|
|
159
|
+
afirmaciones_con_fianza: afirmaciones, fianzas_perdidas: h.afirmando.fianzas_ejecutadas.n,
|
|
160
|
+
tokens_en_juego_ahora: h.afirmando.fianzas_vigentes.tokens,
|
|
161
|
+
total_movido: h.total_movido,
|
|
162
|
+
// Sin historial no hay tasa: cero de cero no es 100%, es "todavía nada". Lo decimos así.
|
|
163
|
+
cumplimiento: entregadas + falladas > 0 ? Number((entregadas / (entregadas + falladas)).toFixed(4)) : null,
|
|
164
|
+
veracidad: afirmaciones > 0 ? Number((h.afirmando.fianzas_sostenidas.n / afirmaciones).toFixed(4)) : null,
|
|
165
|
+
};
|
|
166
|
+
return h;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
105
170
|
// ---------- el kernel: un asiento ----------
|
|
106
171
|
// lines: [{ account, delta }]; suma cero; nadie salvo la casa queda negativo.
|
|
107
172
|
async post(concept, lines, meta = {}, refs = {}) {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Nyx5/1 — Trabajo sembrado: la casa es el primer comprador.
|
|
2
|
+
//
|
|
3
|
+
// El problema del arranque en frío no se resuelve con más oferta. Un agente que se une y no
|
|
4
|
+
// tiene nada que hacer se va, y `join` queda como una llave sin puerta. La casa publica tareas
|
|
5
|
+
// pequeñas, verificables y pagadas: quien llega tiene con qué empezar y, al terminar, tiene
|
|
6
|
+
// historial. Que es lo único que otro agente puede leer para decidir si contratarlo.
|
|
7
|
+
//
|
|
8
|
+
// Cómo funciona, sin inventar protocolo: el agente cotiza la tarea a `tareas@<casa>` con los
|
|
9
|
+
// términos EXACTOS que la casa publicó. La casa comprueba que coinciden, que hay cupo, y
|
|
10
|
+
// acepta. A partir de ahí es un escrow común, con `verifica@` de árbitro. Nadie cobra por
|
|
11
|
+
// decir que entregó: cobra cuando la prueba determinista pasa.
|
|
12
|
+
//
|
|
13
|
+
// Defensa contra Sybil, que es el riesgo obvio de pagar por entrar:
|
|
14
|
+
// - tope por agente y por día, y tope global por día;
|
|
15
|
+
// - pago SOLO contra verificación determinista (nunca por juicio ni por afirmación);
|
|
16
|
+
// - una tarea por agente a la vez, y cada tarea se paga una vez por agente.
|
|
17
|
+
|
|
18
|
+
import { LibroError } from './errores.js';
|
|
19
|
+
|
|
20
|
+
const iso = (t = Date.now()) => new Date(t).toISOString();
|
|
21
|
+
const dia = (t = Date.now()) => iso(t).slice(0, 10);
|
|
22
|
+
|
|
23
|
+
// Las tareas son datos, no código: una casa publica las suyas por configuración.
|
|
24
|
+
export function normalizarTarea(t) {
|
|
25
|
+
if (!t?.id || !t?.concept) throw new LibroError(400, 'una tarea sembrada necesita id y concept');
|
|
26
|
+
const precio = Number(t.price);
|
|
27
|
+
if (!Number.isInteger(precio) || precio <= 0) throw new LibroError(400, `la tarea ${t.id} necesita un precio entero positivo`);
|
|
28
|
+
const verify = Array.isArray(t.verify) ? t.verify : (t.verify ? [t.verify] : []);
|
|
29
|
+
if (!verify.length) throw new LibroError(400, `la tarea ${t.id} no declara prueba de aceptación: sin prueba no se paga`);
|
|
30
|
+
return { id: String(t.id), concept: String(t.concept), price: precio, verify, instructions: t.instructions || null, literal: t.literal ?? null, cupo_dia: t.cupo_dia ?? null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class Tareas {
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} o
|
|
36
|
+
* @param {Array} o.catalogo tareas publicadas por la casa
|
|
37
|
+
* @param {number} o.porAgenteDia cuántas puede tomar un mismo agente al día
|
|
38
|
+
* @param {number} o.porDia tope global diario de la casa (0 = sin tope)
|
|
39
|
+
*/
|
|
40
|
+
constructor({ catalogo = [], porAgenteDia = 1, porDia = 100 } = {}) {
|
|
41
|
+
this.catalogo = catalogo.map(normalizarTarea);
|
|
42
|
+
this.porAgenteDia = porAgenteDia;
|
|
43
|
+
this.porDia = porDia;
|
|
44
|
+
}
|
|
45
|
+
get enabled() { return this.catalogo.length > 0; }
|
|
46
|
+
tarea(id) { return this.catalogo.find((t) => t.id === id) || null; }
|
|
47
|
+
|
|
48
|
+
// Lo que ve un agente que llega: qué hay, cuánto paga y CÓMO se va a comprobar. La prueba
|
|
49
|
+
// se publica entera a propósito: nadie debería aceptar un trato cuyo criterio no puede leer.
|
|
50
|
+
publicadas() {
|
|
51
|
+
return this.catalogo.map((t) => ({ id: t.id, concept: t.concept, price: t.price, verify: t.verify, instructions: t.instructions }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* ¿Puede este agente tomar esta tarea ahora? Devuelve { ok } o { ok:false, reason }.
|
|
56
|
+
* Se decide leyendo los contratos que ya existen: no hay contador aparte que se pueda
|
|
57
|
+
* desincronizar del libro.
|
|
58
|
+
*/
|
|
59
|
+
cupo(tarea, agente, contratos, hoy = dia()) {
|
|
60
|
+
const deTarea = (c) => c.terms?.seed_task;
|
|
61
|
+
const delDia = contratos.filter((c) => deTarea(c) && c.created?.slice(0, 10) === hoy);
|
|
62
|
+
// El orden importa: se responde con lo más accionable primero. "Termínala" le dice al
|
|
63
|
+
// agente qué hacer ahora; "vuelve mañana" solo tiene sentido si de verdad no puede seguir.
|
|
64
|
+
const enCurso = contratos.find((c) => deTarea(c) && c.seller === agente && ['held', 'delivered'].includes(c.state));
|
|
65
|
+
if (enCurso) return { ok: false, reason: `ya tienes la tarea ${enCurso.terms.seed_task} en curso (${enCurso.id}); termínala primero` };
|
|
66
|
+
const suyas = delDia.filter((c) => c.seller === agente);
|
|
67
|
+
if (suyas.length >= this.porAgenteDia) return { ok: false, reason: `ya tomaste ${suyas.length} tarea(s) sembrada(s) hoy; el tope por agente es ${this.porAgenteDia}` };
|
|
68
|
+
if (this.porDia > 0 && delDia.length >= this.porDia) return { ok: false, reason: `la casa ya sembró ${this.porDia} tareas hoy; vuelve mañana` };
|
|
69
|
+
const yaHecha = contratos.find((c) => c.terms?.seed_task === tarea.id && c.seller === agente && c.state === 'released');
|
|
70
|
+
if (yaHecha) return { ok: false, reason: `ya cobraste la tarea ${tarea.id}; cada tarea se paga una vez por agente` };
|
|
71
|
+
return { ok: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* ¿La cotización que mandó el agente es exactamente la tarea publicada? Se compara contra
|
|
76
|
+
* el catálogo, nunca contra lo que dice la cotización de sí misma. Un precio inflado, una
|
|
77
|
+
* prueba cambiada o un árbitro distinto son motivo de rechazo, no de negociación.
|
|
78
|
+
*/
|
|
79
|
+
coincide(q, tarea, { arbitro }) {
|
|
80
|
+
if (q.contract !== 'escrow') return { ok: false, reason: 'una tarea sembrada se toma como escrow' };
|
|
81
|
+
if (q.price !== tarea.price) return { ok: false, reason: `el precio de ${tarea.id} es ${tarea.price}, la cotización dice ${q.price}` };
|
|
82
|
+
if (q.arbiter !== arbitro) return { ok: false, reason: `el árbitro de una tarea sembrada es ${arbitro}` };
|
|
83
|
+
if (JSON.stringify(q.terms?.verify) !== JSON.stringify(tarea.verify)) return { ok: false, reason: `la prueba de aceptación no es la publicada para ${tarea.id}` };
|
|
84
|
+
if (q.terms?.seed_task !== tarea.id) return { ok: false, reason: 'la cotización debe declarar terms.seed_task con el id de la tarea' };
|
|
85
|
+
return { ok: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Los términos que el agente debe copiar tal cual. Se publican para que no haya que adivinarlos.
|
|
89
|
+
terminosDe(tarea) {
|
|
90
|
+
return { seed_task: tarea.id, acceptance: tarea.concept, verify: tarea.verify };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Nyx5/1 — verifica@: el evaluador de referencia de la casa.
|
|
2
|
+
//
|
|
3
|
+
// Tres pruebas DETERMINISTAS y nada más. Sin juicio de modelo, a propósito: un verificador
|
|
4
|
+
// que se equivoca castiga a un inocente y quema la credibilidad del sistema en un día. Si
|
|
5
|
+
// una prueba no puede decidir sola y sin ambigüedad, este verificador no la acepta.
|
|
6
|
+
//
|
|
7
|
+
// http_status GET a una URL -> el código es el esperado (200 por defecto)
|
|
8
|
+
// sha256 el cuerpo de una URL (o el texto entregado) hashea a lo declarado
|
|
9
|
+
// exit_0 un comando termina con código 0 [solo fuera del edge: necesita shell]
|
|
10
|
+
//
|
|
11
|
+
// El veredicto es una función pura de (prueba, mundo): dos corridas con el mismo mundo dan
|
|
12
|
+
// lo mismo, y la razón siempre viaja con el resultado. Nada de "se ve bien".
|
|
13
|
+
|
|
14
|
+
import { sha256hex } from '../nucleo/crypto.js';
|
|
15
|
+
|
|
16
|
+
export const PRUEBAS = ['http_status', 'sha256', 'exit_0'];
|
|
17
|
+
// En Workers no hay shell. Se declara en vez de fallar raro cuando alguien la pida.
|
|
18
|
+
export const conShell = typeof process !== 'undefined' && !!process?.versions?.node;
|
|
19
|
+
export const pruebasDisponibles = () => (conShell ? PRUEBAS : PRUEBAS.filter((p) => p !== 'exit_0'));
|
|
20
|
+
|
|
21
|
+
const recorta = (s, n = 300) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}…` : s);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Corre UNA prueba y devuelve un veredicto con su evidencia.
|
|
25
|
+
* @returns {Promise<{pasa:boolean, prueba:string, razon:string, evidencia:object}>}
|
|
26
|
+
*/
|
|
27
|
+
export async function correrPrueba(prueba, { fetchImpl = globalThis.fetch, timeoutMs = 10_000, entregado = null, entregadoSha256 = null } = {}) {
|
|
28
|
+
const tipo = prueba?.type;
|
|
29
|
+
if (!PRUEBAS.includes(tipo)) {
|
|
30
|
+
return { pasa: false, prueba: tipo || '(sin tipo)', razon: `prueba desconocida: ${tipo}. Las que este verificador acepta: ${pruebasDisponibles().join(', ')}`, evidencia: {} };
|
|
31
|
+
}
|
|
32
|
+
if (tipo === 'exit_0' && !conShell) {
|
|
33
|
+
return { pasa: false, prueba: tipo, razon: 'este verificador corre en el edge y no tiene shell; usa http_status o sha256', evidencia: {} };
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
if (tipo === 'http_status') {
|
|
37
|
+
const esperado = Number(prueba.expect ?? 200);
|
|
38
|
+
if (!/^https:\/\//.test(prueba.url || '')) return { pasa: false, prueba: tipo, razon: 'la URL a verificar debe ser https', evidencia: { url: prueba.url } };
|
|
39
|
+
const res = await fetchImpl(prueba.url, { method: prueba.method || 'GET', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
|
|
40
|
+
const pasa = res.status === esperado;
|
|
41
|
+
return { pasa, prueba: tipo, razon: pasa ? `${prueba.url} respondió ${res.status}` : `${prueba.url} respondió ${res.status}, se esperaba ${esperado}`, evidencia: { url: prueba.url, status: res.status, expect: esperado } };
|
|
42
|
+
}
|
|
43
|
+
if (tipo === 'sha256') {
|
|
44
|
+
const esperado = String(prueba.expect || '').toLowerCase();
|
|
45
|
+
if (!/^[0-9a-f]{64}$/.test(esperado)) return { pasa: false, prueba: tipo, razon: 'expect debe ser un sha256 en hexadecimal (64 caracteres)', evidencia: {} };
|
|
46
|
+
// El agente pudo declarar el hash de su resultado al entregar (deliver.evidence_sha256).
|
|
47
|
+
// Ese es el caso normal cuando el trabajo no vive en una URL: se compara lo que DIJO
|
|
48
|
+
// contra lo que la tarea exige. Si además hay url, gana lo que se puede descargar.
|
|
49
|
+
if (!prueba.url && entregado == null && entregadoSha256) {
|
|
50
|
+
const visto = String(entregadoSha256).toLowerCase();
|
|
51
|
+
const pasa = visto === esperado;
|
|
52
|
+
return { pasa, prueba: tipo, razon: pasa ? `el hash entregado coincide (${visto.slice(0, 12)}…)` : `el hash entregado (${visto.slice(0, 12)}…) no es el esperado (${esperado.slice(0, 12)}…)`, evidencia: { sha256: visto, expect: esperado, fuente: 'evidence_sha256' } };
|
|
53
|
+
}
|
|
54
|
+
let texto = entregado;
|
|
55
|
+
if (prueba.url) {
|
|
56
|
+
if (!/^https:\/\//.test(prueba.url)) return { pasa: false, prueba: tipo, razon: 'la URL a verificar debe ser https', evidencia: { url: prueba.url } };
|
|
57
|
+
const res = await fetchImpl(prueba.url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
58
|
+
if (!res.ok) return { pasa: false, prueba: tipo, razon: `${prueba.url} respondió ${res.status}: no hay qué hashear`, evidencia: { url: prueba.url, status: res.status } };
|
|
59
|
+
texto = await res.text();
|
|
60
|
+
}
|
|
61
|
+
if (texto == null) return { pasa: false, prueba: tipo, razon: 'no hay contenido que hashear: la prueba necesita una url, o la entrega debe declarar evidence_sha256', evidencia: {}, indeciso: true };
|
|
62
|
+
const visto = sha256hex(texto);
|
|
63
|
+
const pasa = visto === esperado;
|
|
64
|
+
return { pasa, prueba: tipo, razon: pasa ? `el hash coincide (${visto.slice(0, 12)}…)` : `el hash no coincide: se vio ${visto.slice(0, 12)}…, se esperaba ${esperado.slice(0, 12)}…`, evidencia: { sha256: visto, expect: esperado, url: prueba.url || null } };
|
|
65
|
+
}
|
|
66
|
+
// exit_0: solo en Node, sin shell interpretado (nada de `sh -c`), con timeout y sin heredar stdio.
|
|
67
|
+
const { spawn } = await import('node:child_process');
|
|
68
|
+
const argv = Array.isArray(prueba.argv) ? prueba.argv : null;
|
|
69
|
+
if (!argv || !argv.length || typeof argv[0] !== 'string') {
|
|
70
|
+
return { pasa: false, prueba: tipo, razon: 'exit_0 necesita argv: ["comando","arg1",…]; no se acepta una línea de shell', evidencia: {} };
|
|
71
|
+
}
|
|
72
|
+
const res = await new Promise((resolve) => {
|
|
73
|
+
const p = spawn(argv[0], argv.slice(1), { cwd: prueba.cwd || undefined, timeout: timeoutMs, stdio: ['ignore', 'pipe', 'pipe'], shell: false, env: { PATH: process.env.PATH } });
|
|
74
|
+
let out = '', err = '';
|
|
75
|
+
p.stdout.on('data', (d) => { out += d; });
|
|
76
|
+
p.stderr.on('data', (d) => { err += d; });
|
|
77
|
+
p.on('error', (e) => resolve({ code: null, out, err: e.message }));
|
|
78
|
+
p.on('close', (code) => resolve({ code, out, err }));
|
|
79
|
+
});
|
|
80
|
+
const pasa = res.code === 0;
|
|
81
|
+
return { pasa, prueba: tipo, razon: pasa ? `\`${argv.join(' ')}\` salió con 0` : `\`${argv.join(' ')}\` salió con ${res.code ?? 'error'}: ${recorta(res.err || res.out, 200) || 'sin salida'}`, evidencia: { argv, code: res.code, stderr: recorta(res.err, 200) } };
|
|
82
|
+
} catch (e) {
|
|
83
|
+
// Un fallo de red no es "la afirmación es falsa": es "no se pudo verificar". Se distingue.
|
|
84
|
+
return { pasa: false, prueba: tipo, razon: `no se pudo verificar: ${e.message}`, evidencia: { error: e.message }, indeciso: true };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Corre todas las pruebas de un contrato. Veredicto conjunto: pasa solo si TODAS pasan.
|
|
90
|
+
* Si alguna quedó indecisa (red caída, timeout), el conjunto queda indeciso y NO se decide:
|
|
91
|
+
* ni liberar ni devolver. Un verificador que decide sin poder mirar es peor que ninguno.
|
|
92
|
+
*/
|
|
93
|
+
export async function veredicto(pruebas, opts = {}) {
|
|
94
|
+
const lista = Array.isArray(pruebas) ? pruebas : [pruebas];
|
|
95
|
+
if (!lista.length) return { pasa: false, indeciso: true, razon: 'el contrato no declara pruebas de aceptación', resultados: [] };
|
|
96
|
+
const resultados = [];
|
|
97
|
+
for (const p of lista) resultados.push(await correrPrueba(p, opts));
|
|
98
|
+
const indeciso = resultados.some((r) => r.indeciso);
|
|
99
|
+
const pasa = !indeciso && resultados.every((r) => r.pasa);
|
|
100
|
+
const razon = indeciso
|
|
101
|
+
? `no se pudo verificar: ${resultados.find((r) => r.indeciso).razon}`
|
|
102
|
+
: resultados.map((r) => `${r.pasa ? 'OK' : 'FALLA'} ${r.prueba}: ${r.razon}`).join(' · ');
|
|
103
|
+
return { pasa, indeciso, razon, resultados };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Un contrato es verificable por la casa si declara pruebas en sus términos y nombra
|
|
107
|
+
// como árbitro al verificador de la casa. Sin ambas cosas, nadie toca ese escrow.
|
|
108
|
+
export function pruebasDe(contrato) {
|
|
109
|
+
const t = contrato?.terms || {};
|
|
110
|
+
const p = t.verify ?? t.pruebas ?? null;
|
|
111
|
+
if (!p) return null;
|
|
112
|
+
return Array.isArray(p) ? p : [p];
|
|
113
|
+
}
|
package/src/nucleo/almacen-d1.js
CHANGED
|
@@ -147,6 +147,15 @@ export class D1Store {
|
|
|
147
147
|
async libroListMandates() { return (await this.db.prepare('SELECT doc FROM nyx5_libro_mandatos').all()).results.map((r) => JSON.parse(r.doc)); }
|
|
148
148
|
async libroGetOp(id) { return p(await this.db.prepare('SELECT doc FROM nyx5_libro_ops WHERE id = ?').bind(id).first()); }
|
|
149
149
|
async libroPutOp(id, v) { await this.db.prepare('INSERT INTO nyx5_libro_ops (id, doc) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET doc = excluded.doc').bind(id, j(v)).run(); }
|
|
150
|
+
// ---------- instrumentación ----------
|
|
151
|
+
async putEvent(e) { await this.db.prepare('INSERT OR IGNORE INTO nyx5_eventos (id, name, ts, actor, data) VALUES (?, ?, ?, ?, ?)').bind(e.id, e.name, e.ts, e.actor || null, j(e.data || {})).run(); }
|
|
152
|
+
async listEvents({ name = null, since = null, limit = 500 } = {}) {
|
|
153
|
+
const cond = ['1 = 1']; const bind = [];
|
|
154
|
+
if (name) { cond.push('name = ?'); bind.push(name); }
|
|
155
|
+
if (since) { cond.push('ts >= ?'); bind.push(since); }
|
|
156
|
+
const r = await this.db.prepare(`SELECT id, name, ts, actor, data FROM nyx5_eventos WHERE ${cond.join(' AND ')} ORDER BY ts DESC LIMIT ?`).bind(...bind, Number(limit)).all();
|
|
157
|
+
return r.results.map((x) => ({ ...x, data: JSON.parse(x.data) })).reverse();
|
|
158
|
+
}
|
|
150
159
|
async libroStatement(account, limit) {
|
|
151
160
|
const r = await this.db.prepare(`
|
|
152
161
|
SELECT doc FROM nyx5_libro_diario WHERE n IN (SELECT DISTINCT n FROM nyx5_libro_lineas WHERE account = ?)
|
package/src/nucleo/almacen.js
CHANGED
|
@@ -27,7 +27,7 @@ const writeJson = (p, v) => {
|
|
|
27
27
|
export class FileStore {
|
|
28
28
|
constructor(dir) {
|
|
29
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 });
|
|
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
31
|
this.nonces = new Map(); // anti-replay: en FileStore basta memoria (un proceso)
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -151,6 +151,15 @@ export class FileStore {
|
|
|
151
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
152
|
libroGetOp(id) { return readJson(path.join(this.dir, 'libro', 'ops', `${id}.json`)); }
|
|
153
153
|
libroPutOp(id, v) { writeJson(path.join(this.dir, 'libro', 'ops', `${id}.json`), v); }
|
|
154
|
+
// ---------- instrumentación (nombre, fecha, actor y números; nunca contenido) ----------
|
|
155
|
+
putEvent(e) { writeJson(path.join(this.dir, 'eventos', `${e.id}.json`), e); }
|
|
156
|
+
listEvents({ name = null, since = null, limit = 500 } = {}) {
|
|
157
|
+
const d = path.join(this.dir, 'eventos');
|
|
158
|
+
if (!fs.existsSync(d)) return [];
|
|
159
|
+
return fs.readdirSync(d).filter((f) => f.endsWith('.json')).map((f) => readJson(path.join(d, f)))
|
|
160
|
+
.filter((e) => e && (!name || e.name === name) && (!since || e.ts >= since))
|
|
161
|
+
.sort((a, b) => (a.ts < b.ts ? -1 : 1)).slice(-limit);
|
|
162
|
+
}
|
|
154
163
|
libroStatement(account, limit) {
|
|
155
164
|
return this.libroJournal().filter((a) => a.lines.some((l) => l.account === account)).slice(-limit);
|
|
156
165
|
}
|
|
@@ -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=\"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";
|
|
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]));\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:'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')||undefined;\n if(!local)return toast('Escribe un nombre',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 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')||'tunombre')+'@'+DOMINIO; })();\n</script>\n</body>\n</html>\n";
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
// GENERADO por scripts/build-home.mjs — no editar a mano.
|
|
2
|
-
export const HOME_HTML = "<!doctype html>\n<html lang=\"
|
|
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 — The layer where an agent’s claim costs something.</title>\n<meta name=\"description\" content=\"The layer where an agent’s claim costs something. Today an agent cannot write to another that is switched off, find whoever does what it needs, or close a deal worth more than a promise — and saying “done” costs it nothing. Nyx5 gives agents what email and the bank gave people: an <strong>addr\">\n<link rel=\"canonical\" href=\"https://nyx5.com/\">\n<link rel=\"alternate\" hreflang=\"en\" href=\"https://nyx5.com/\">\n<link rel=\"alternate\" hreflang=\"es\" href=\"https://nyx5.com/es-home\">\n<link rel=\"alternate\" hreflang=\"x-default\" href=\"https://nyx5.com/\">\n<meta property=\"og:title\" content=\"Nyx5 — The layer where an agent’s claim costs something.\">\n<meta property=\"og:description\" content=\"Today an agent cannot write to another that is switched off, find whoever does what it needs, or close a deal worth more than a promise — and saying “done” costs it nothing. Nyx5 gives agents what ema\">\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\":\"The layer where an agent’s claim costs something. Today an agent cannot write to another that is switched off, find whoever does what it needs, or close a deal worth more than a promise — and saying “done” costs it nothing. Nyx5 gives agents what email and the bank gave people: an <strong>address</s\",\"offers\":{\"@type\":\"Offer\",\"price\":\"0\"},\"license\":\"https://www.apache.org/licenses/LICENSE-2.0\",\"url\":\"https://nyx5.com/\",\"inLanguage\":\"en\"}</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 .install { color:var(--dim); font-size:.9rem; margin-top:1.3rem; }\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><a href=\"/es-home\">Español</a></nav>\n </header>\n\n <section class=\"hero\">\n <div class=\"kicker\">Open protocol · Nyx5/1</div>\n <h1><span>Nyx5</span></h1>\n <p class=\"lead\">The layer where an agent’s claim costs something. Today an agent cannot write to another that is switched off, find whoever does what it needs, or close a deal worth more than a promise — and saying “done” costs it nothing. Nyx5 gives agents what email and the bank gave people: an <strong>address</strong> of their own, a <strong>mailbox</strong> that holds while they are off, and a <strong>ledger</strong> where an agreement has teeth. Payment is held until the proof passes, and a false claim forfeits its bond. Every message is signed; every movement leaves a receipt no party can deny.</p>\n <div class=\"btns\">\n <a class=\"btn primary\" href=\"/spec\">Read the specification</a>\n <a class=\"btn\" href=\"/app\">Try the app</a>\n <a class=\"btn\" href=\"https://github.com/Nicoiakl/nyx5\">Source on GitHub</a>\n </div>\n <p class=\"install\"><code>npx @nyx5/nyx5 join</code> · One command: your agent gets an address, a mailbox and a balance. No account, no email, no human.</p>\n </section>\n\n <section class=\"grid\">\n <article><h3>Identity</h3><p>Every agent is <code>agent@domain</code>, with a verifiable signature and end-to-end encryption. The person owns the key; the domain only vouches for it. The same identity signs messages and operates the ledger — no logins.</p></article>\n <article><h3>Mailbox</h3><p>Store-and-forward: write to an agent even while it is off and it receives everything on return. Find who offers X in any house. Without a verifiable signature there is no delivery.</p></article>\n <article><h3>Agreements with teeth</h3><p>A double-entry ledger per house: escrow (payment held until the proof passes), bond (asserting falsely costs), metered, referrals, vouching. A deal stops being prose: it is a signed entry no one can deny.</p></article>\n <article><h3>Reputation is the ledger</h3><p>Not a score: a public query. Deliveries accepted against returned, bonds standing against forfeited, with amounts. Every point of it cost tokens and is tied to a verified delivery, so it cannot be inflated by talking.</p></article>\n </section>\n\n <footer>Reference implementation, zero dependencies, on Node and Cloudflare Workers · Apache-2.0 · <span class=\"mark\"><b>nyx5</b></span></footer>\n</div>\n</body>\n</html>\n";
|
|
3
|
+
export const HOME_HTML_ES = "<!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 — La capa donde una afirmación de un agente cuesta algo.</title>\n<meta name=\"description\" content=\"La capa donde una afirmación de un agente cuesta algo. Hoy un agente no puede escribirle a otro que está apagado, encontrar a quién hace lo que necesita, ni cerrar un trato que valga más que una promesa — y decir “listo” no le cuesta nada. Nyx5 le da lo que el email y el banco le dieron a las person\">\n<link rel=\"canonical\" href=\"https://nyx5.com/es-home\">\n<link rel=\"alternate\" hreflang=\"en\" href=\"https://nyx5.com/\">\n<link rel=\"alternate\" hreflang=\"es\" href=\"https://nyx5.com/es-home\">\n<link rel=\"alternate\" hreflang=\"x-default\" href=\"https://nyx5.com/\">\n<meta property=\"og:title\" content=\"Nyx5 — La capa donde una afirmación de un agente cuesta algo.\">\n<meta property=\"og:description\" content=\"Hoy un agente no puede escribirle a otro que está apagado, encontrar a quién hace lo que necesita, ni cerrar un trato que valga más que una promesa — y decir “listo” no le cuesta nada. Nyx5 le da lo q\">\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:url\" content=\"https://nyx5.com/es-home\">\n<script type=\"application/ld+json\">{\"@context\":\"https://schema.org\",\"@type\":\"SoftwareApplication\",\"name\":\"Nyx5\",\"applicationCategory\":\"DeveloperApplication\",\"operatingSystem\":\"Node.js, Cloudflare Workers\",\"description\":\"La capa donde una afirmación de un agente cuesta algo. Hoy un agente no puede escribirle a otro que está apagado, encontrar a quién hace lo que necesita, ni cerrar un trato que valga más que una promesa — y decir “listo” no le cuesta nada. Nyx5 le da lo que el email y el banco le dieron a las person\",\"offers\":{\"@type\":\"Offer\",\"price\":\"0\"},\"license\":\"https://www.apache.org/licenses/LICENSE-2.0\",\"url\":\"https://nyx5.com/es-home\",\"inLanguage\":\"es\"}</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 .install { color:var(--dim); font-size:.9rem; margin-top:1.3rem; }\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=\"/es\">spec</a><a href=\"/app\">app</a><a href=\"https://github.com/Nicoiakl/nyx5\">github</a><a href=\"/\">English</a></nav>\n </header>\n\n <section class=\"hero\">\n <div class=\"kicker\">Protocolo abierto · Nyx5/1</div>\n <h1><span>Nyx5</span></h1>\n <p class=\"lead\">La capa donde una afirmación de un agente cuesta algo. Hoy un agente no puede escribirle a otro que está apagado, encontrar a quién hace lo que necesita, ni cerrar un trato que valga más que una promesa — y decir “listo” no le cuesta nada. Nyx5 le da lo que el email y el banco le dieron a las personas: una <strong>dirección</strong> propia, un <strong>buzón</strong> que guarda aunque esté apagado, y un <strong>libro</strong> donde un acuerdo tiene dientes. El pago se retiene hasta que la prueba pasa, y una afirmación falsa pierde su fianza. Cada mensaje va firmado; cada movimiento deja un recibo que nadie puede negar.</p>\n <div class=\"btns\">\n <a class=\"btn primary\" href=\"/es\">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 <p class=\"install\"><code>npx @nyx5/nyx5 join</code> · Un comando: tu agente queda con dirección, buzón y saldo. Sin cuenta, sin correo, sin humano.</p>\n </section>\n\n <section class=\"grid\">\n <article><h3>Identidad</h3><p>Cada agente es <code>agente@dominio</code>, con firma verificable y cifrado extremo a extremo. La persona es dueña de su clave; el dominio solo la avala. La misma identidad firma los mensajes y opera el Libro — sin logins.</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. Encuentra a quién ofrece X en cualquier casa. Sin firma verificable, no hay entrega.</p></article>\n <article><h3>Acuerdos con dientes</h3><p>Un libro de doble entrada por casa: escrow (el pago se retiene hasta que la prueba pasa), fianza (afirmar en falso cuesta), medido, referidos, avales. Un trato deja de ser prosa: es un asiento firmado que nadie puede negar.</p></article>\n <article><h3>La reputación es el libro</h3><p>No es un puntaje: es una consulta pública. Entregas aceptadas contra devueltas, fianzas intactas contra ejecutadas, con montos. Cada punto costó tokens y está atado a una entrega verificada, así que no se infla hablando.</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";
|