@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,24 @@
|
|
|
1
|
+
// El "hola mundo" de Nyx5: una casa, dos agentes, un mensaje cifrado que viaja firmado
|
|
2
|
+
// y aparece en el buzón del otro. Sin servidor aparte, sin dependencias.
|
|
3
|
+
//
|
|
4
|
+
// node examples/hola-mundo.mjs
|
|
5
|
+
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
|
|
6
|
+
import { Estafeta } from '../src/correo/estafeta.js';
|
|
7
|
+
import { Agent } from '../src/correo/agente.js';
|
|
8
|
+
|
|
9
|
+
const url = 'http://127.0.0.1:4321', hosts = { 'casa.local': { url } };
|
|
10
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'chasqui-hola-'));
|
|
11
|
+
const casa = await new Estafeta({ domain: 'casa.local', port: 4321, dataDir: dir, adminToken: 's', hosts, log: () => {} }).start();
|
|
12
|
+
|
|
13
|
+
const ana = Agent.create('ana@casa.local', url, { hosts });
|
|
14
|
+
const bruno = Agent.create('bruno@casa.local', url, { hosts });
|
|
15
|
+
await ana.register({ adminToken: 's' });
|
|
16
|
+
await bruno.register({ adminToken: 's' });
|
|
17
|
+
|
|
18
|
+
await ana.send({ to: bruno.address, body: '¡Hola, Bruno! Este sobre viaja firmado y cifrado.' });
|
|
19
|
+
const m = await bruno.waitFor((e) => e.from === ana.address); // Bruno espera su carta
|
|
20
|
+
const abierto = await bruno.open(m.envelope); // solo Bruno puede descifrarla
|
|
21
|
+
|
|
22
|
+
console.log('De:', abierto.from);
|
|
23
|
+
console.log('Dice:', abierto.content.body);
|
|
24
|
+
await casa.stop();
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
-- Chasqui/1 — esquema D1 (SQLite). Conviven con las tablas del token-wallet v0 (0001) en la
|
|
2
|
+
-- misma base; el Libro de Chasqui es el sucesor del ledger v0 y usa sus propias tablas.
|
|
3
|
+
--
|
|
4
|
+
-- Los invariantes viven en constraints, no en chequeos de aplicación:
|
|
5
|
+
-- diario.n PRIMARY KEY -> dos asientos concurrentes con el mismo número: el segundo falla cerrado
|
|
6
|
+
-- ops.id PRIMARY KEY -> una operación reentregada jamás se ejecuta dos veces
|
|
7
|
+
-- contratos.quote_id UNIQUE -> una cotización se acepta una sola vez
|
|
8
|
+
-- seen.id PRIMARY KEY -> un sobre se procesa una sola vez por destinatario (lista en doc)
|
|
9
|
+
|
|
10
|
+
CREATE TABLE IF NOT EXISTS nyx5_domain (
|
|
11
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
12
|
+
doc TEXT NOT NULL
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS nyx5_agents (
|
|
16
|
+
local TEXT PRIMARY KEY,
|
|
17
|
+
doc TEXT NOT NULL,
|
|
18
|
+
updated TEXT NOT NULL
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE TABLE IF NOT EXISTS nyx5_invitations (
|
|
22
|
+
code TEXT PRIMARY KEY,
|
|
23
|
+
doc TEXT NOT NULL
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS nyx5_seen (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
doc TEXT NOT NULL
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS nyx5_mailbox (
|
|
32
|
+
local TEXT NOT NULL,
|
|
33
|
+
id TEXT NOT NULL,
|
|
34
|
+
doc TEXT NOT NULL,
|
|
35
|
+
received TEXT NOT NULL,
|
|
36
|
+
acked TEXT,
|
|
37
|
+
PRIMARY KEY (local, id)
|
|
38
|
+
);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_nyx5_mailbox_pendiente ON nyx5_mailbox (local, received) WHERE acked IS NULL;
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS nyx5_queue (
|
|
42
|
+
id TEXT PRIMARY KEY,
|
|
43
|
+
doc TEXT NOT NULL,
|
|
44
|
+
next_attempt TEXT NOT NULL,
|
|
45
|
+
status TEXT NOT NULL,
|
|
46
|
+
claimed_until INTEGER NOT NULL DEFAULT 0
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_nyx5_queue_due ON nyx5_queue (next_attempt) WHERE status IN ('queued','retrying','inflight');
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS nyx5_outbox (
|
|
51
|
+
local TEXT NOT NULL,
|
|
52
|
+
job TEXT NOT NULL,
|
|
53
|
+
doc TEXT NOT NULL,
|
|
54
|
+
PRIMARY KEY (local, job)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS nyx5_nonces (
|
|
58
|
+
key TEXT PRIMARY KEY,
|
|
59
|
+
ts INTEGER NOT NULL
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS nyx5_pins (
|
|
63
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
64
|
+
doc TEXT NOT NULL
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
-- ===== Libro =====
|
|
68
|
+
CREATE TABLE IF NOT EXISTS nyx5_libro_state (
|
|
69
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
70
|
+
seq INTEGER NOT NULL,
|
|
71
|
+
balances TEXT NOT NULL
|
|
72
|
+
);
|
|
73
|
+
INSERT OR IGNORE INTO nyx5_libro_state (id, seq, balances) VALUES (1, 0, '{}');
|
|
74
|
+
|
|
75
|
+
CREATE TABLE IF NOT EXISTS nyx5_libro_diario (
|
|
76
|
+
n INTEGER PRIMARY KEY,
|
|
77
|
+
id TEXT NOT NULL UNIQUE,
|
|
78
|
+
doc TEXT NOT NULL
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
-- Líneas desnormalizadas del diario: statement/balance por cuenta sin escanear todo.
|
|
82
|
+
CREATE TABLE IF NOT EXISTS nyx5_libro_lineas (
|
|
83
|
+
n INTEGER NOT NULL,
|
|
84
|
+
account TEXT NOT NULL,
|
|
85
|
+
delta INTEGER NOT NULL
|
|
86
|
+
);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_nyx5_lineas_account ON nyx5_libro_lineas (account, n);
|
|
88
|
+
|
|
89
|
+
CREATE TABLE IF NOT EXISTS nyx5_libro_contratos (
|
|
90
|
+
id TEXT PRIMARY KEY,
|
|
91
|
+
quote_id TEXT UNIQUE,
|
|
92
|
+
doc TEXT NOT NULL
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
CREATE TABLE IF NOT EXISTS nyx5_libro_mandatos (
|
|
96
|
+
id TEXT PRIMARY KEY,
|
|
97
|
+
doc TEXT NOT NULL
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
CREATE TABLE IF NOT EXISTS nyx5_libro_ops (
|
|
101
|
+
id TEXT PRIMARY KEY,
|
|
102
|
+
doc TEXT NOT NULL
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
-- ===== Índice federado =====
|
|
106
|
+
CREATE TABLE IF NOT EXISTS nyx5_indice_casas (
|
|
107
|
+
domain TEXT PRIMARY KEY,
|
|
108
|
+
doc TEXT NOT NULL
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
CREATE TABLE IF NOT EXISTS nyx5_indice_agentes (
|
|
112
|
+
house TEXT NOT NULL,
|
|
113
|
+
address TEXT NOT NULL,
|
|
114
|
+
doc TEXT NOT NULL,
|
|
115
|
+
PRIMARY KEY (house, address)
|
|
116
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Chasqui/1 — candado del ledger.
|
|
2
|
+
-- Toda mutación del Libro (aunque no mueva dinero: revocar, entregar, mandar) consume un número
|
|
3
|
+
-- de asiento. Ese número es PRIMARY KEY, así que dos operaciones que leyeron el mismo estado no
|
|
4
|
+
-- pueden cometer las dos: la segunda choca y falla cerrado. Sin esto, una op sin dinero comete
|
|
5
|
+
-- sin chocar con nadie y pisa lo que otra decidió en paralelo (revocación borrada por un cobro).
|
|
6
|
+
--
|
|
7
|
+
-- No hay cambio de esquema: el mecanismo vive en el código (asientos de control con lines=[]).
|
|
8
|
+
-- Esta migración existe para dejar constancia del contrato y para verificarlo:
|
|
9
|
+
-- un asiento de control es un asiento normal con lines vacío -> sigue sumando cero.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- Chasqui/1 — pins de claves de dominios ajenos, uno por fila.
|
|
2
|
+
-- Antes vivían en un único documento JSON que cada isolate reescribía COMPLETO desde su memoria:
|
|
3
|
+
-- el isolate B, inicializado antes, borraba los pines que el isolate A acababa de aprender.
|
|
4
|
+
-- Con el ancla DNS aún pendiente, el pin es la única defensa anti-suplantación: perderlo en
|
|
5
|
+
-- silencio permite que un tercer isolate vuelva a confiar en la clave que sirva un atacante.
|
|
6
|
+
-- Una fila por dominio con PK: el primer pin gana (TOFU) y nadie lo pisa.
|
|
7
|
+
CREATE TABLE IF NOT EXISTS nyx5_pin (
|
|
8
|
+
domain TEXT PRIMARY KEY,
|
|
9
|
+
kid TEXT NOT NULL,
|
|
10
|
+
at TEXT NOT NULL
|
|
11
|
+
);
|
|
12
|
+
-- Migración de los pines que estuvieran en el documento antiguo.
|
|
13
|
+
INSERT OR IGNORE INTO nyx5_pin (domain, kid, at)
|
|
14
|
+
SELECT je.key, je.value, datetime('now')
|
|
15
|
+
FROM nyx5_pins, json_each(nyx5_pins.doc) je WHERE nyx5_pins.id = 1;
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nyx5/nyx5",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Nyx5/1 — correo y libro para agentes de IA: direcciones agente@dominio, buzón store-and-forward, sobres firmados y cifrados, y un ledger de doble entrada con contratos (escrow, fianza, medido). Implementación de referencia, sin dependencias.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"homepage": "https://nyx5.com",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/Nicoiakl/nyx5.git" },
|
|
9
|
+
"bugs": { "url": "https://github.com/Nicoiakl/nyx5/issues" },
|
|
10
|
+
"engines": { "node": ">=20" },
|
|
11
|
+
"bin": { "nyx5": "./bin/nyx5.js" },
|
|
12
|
+
"files": [
|
|
13
|
+
"src/",
|
|
14
|
+
"bin/",
|
|
15
|
+
"migrations/",
|
|
16
|
+
"demo/",
|
|
17
|
+
"examples/",
|
|
18
|
+
"docs/SPEC.md",
|
|
19
|
+
"docs/ARQUITECTURA.md",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"CONTRIBUTING.md",
|
|
23
|
+
"SECURITY.md"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"agents",
|
|
27
|
+
"agent-communication",
|
|
28
|
+
"protocol",
|
|
29
|
+
"messaging",
|
|
30
|
+
"ledger",
|
|
31
|
+
"escrow",
|
|
32
|
+
"ed25519",
|
|
33
|
+
"end-to-end-encryption",
|
|
34
|
+
"cloudflare-workers",
|
|
35
|
+
"mcp"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"demo": "node demo/e2e.js",
|
|
39
|
+
"demo:offline": "node demo/offline.js",
|
|
40
|
+
"demo:spam": "node demo/spam.js",
|
|
41
|
+
"demo:contratos": "node demo/contratos.js",
|
|
42
|
+
"demo:piloto": "node demo/piloto-d4.mjs",
|
|
43
|
+
"test": "node --test test/*.test.js",
|
|
44
|
+
"estafeta": "node bin/nyx5.js estafeta",
|
|
45
|
+
"build:spec": "node scripts/build-spec-site.mjs",
|
|
46
|
+
"build:app": "node scripts/build-app-html.mjs",
|
|
47
|
+
"build:home": "node scripts/build-home.mjs"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Nyx5/1 — Cliente de agente: la app de correo y la billetera en una sola pieza.
|
|
2
|
+
// Correo: firma, cifra, envía, lee, confirma. Libro: cotiza, acepta, entrega, libera, afianza, manda, cobra.
|
|
3
|
+
// Las operaciones del Libro son sobres firmados a libro@<casa>; las respuestas vuelven como recibos al buzón.
|
|
4
|
+
|
|
5
|
+
import { Resolver, parseAddress } from './resolver.js';
|
|
6
|
+
import { generateKeys, signObject, verifyObject, signBytes, canonical, b64u, uuid, encryptContent, decryptContent, mintPow, sha256hex } from '../nucleo/crypto.js';
|
|
7
|
+
import { Libro, MEDIA } from '../libro/libro.js';
|
|
8
|
+
|
|
9
|
+
const iso = (t = Date.now()) => new Date(t).toISOString();
|
|
10
|
+
|
|
11
|
+
export class Agent {
|
|
12
|
+
constructor({ address, keys, estafeta, resolver, hosts = {}, fetchImpl = globalThis.fetch }) {
|
|
13
|
+
const { local, domain } = parseAddress(address);
|
|
14
|
+
this.address = `${local}@${domain}`; this.local = local; this.domain = domain;
|
|
15
|
+
this.keys = keys;
|
|
16
|
+
this.estafeta = estafeta.replace(/\/$/, '');
|
|
17
|
+
this.fetch = (...a) => fetchImpl(...a); // envuelto: workerd exige fetch con this=globalThis
|
|
18
|
+
this.resolver = resolver || new Resolver({ hosts: { [domain]: { url: this.estafeta }, ...hosts }, fetchImpl });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static create(address, estafeta, opts = {}) { return new Agent({ address, estafeta, keys: generateKeys(), ...opts }); }
|
|
22
|
+
// load/save usan node:fs por import dinámico: el módulo carga limpio en Workers (donde no se usan).
|
|
23
|
+
static async load(file, opts = {}) { const fs = await import('node:fs'); const j = JSON.parse(fs.readFileSync(file, 'utf8')); return new Agent({ ...j, ...opts }); }
|
|
24
|
+
async save(file) { const fs = await import('node:fs'); fs.mkdirSync(require_dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify({ address: this.address, estafeta: this.estafeta, keys: this.keys }, null, 2), { mode: 0o600 }); }
|
|
25
|
+
|
|
26
|
+
// ---------- auth ante la propia estafeta ----------
|
|
27
|
+
_auth(method, path, keys = this.keys, base = this.estafeta) {
|
|
28
|
+
// `host` amarra el token a la casa destino: capturado, no sirve contra otra estafeta.
|
|
29
|
+
const claims = { address: this.address, ts: iso(), nonce: uuid(), method, path, host: new URL(base).host };
|
|
30
|
+
const token = b64u(canonical(claims));
|
|
31
|
+
return `Nyx5 ${token}.${signBytes(canonical(claims), keys)}`;
|
|
32
|
+
}
|
|
33
|
+
async _call(method, path, body, { admin, noAuth, authKeys } = {}) {
|
|
34
|
+
const headers = { 'content-type': 'application/json' };
|
|
35
|
+
if (admin) headers.authorization = `Bearer ${admin}`; else if (!noAuth) headers.authorization = this._auth(method, path.split('?')[0], authKeys);
|
|
36
|
+
const res = await this.fetch(`${this.estafeta}${path}`, {
|
|
37
|
+
method, headers,
|
|
38
|
+
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10_000),
|
|
39
|
+
});
|
|
40
|
+
const json = await res.json().catch(() => ({}));
|
|
41
|
+
if (!res.ok) throw Object.assign(new Error(json.reason || `HTTP ${res.status}`), { status: res.status, body: json });
|
|
42
|
+
return json;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------- registro ----------
|
|
46
|
+
// Tres caminos: adminToken (la casa inscribe), invite (código de la casa) o abierto si la casa lo permite.
|
|
47
|
+
// Sin adminToken y sin estar registrado, el cuerpo va firmado con la propia clave (prueba de posesión).
|
|
48
|
+
async register({ adminToken, invite, capabilities, inbox, webhook, valid_until } = {}) {
|
|
49
|
+
const body = { local: this.local, sig: this.keys.sig, enc: this.keys.enc, capabilities, inbox, webhook, valid_until };
|
|
50
|
+
if (adminToken) this.card = await this._call('POST', '/agents', body, { admin: adminToken });
|
|
51
|
+
else if (this.card) this.card = await this._call('POST', '/agents', body);
|
|
52
|
+
else this.card = await this._call('POST', '/agents', signObject({ ...body, invite: invite || undefined, ts: iso() }, this.keys), { noAuth: true });
|
|
53
|
+
this.resolver.invalidate(`agent:${this.address}`);
|
|
54
|
+
return this.card;
|
|
55
|
+
}
|
|
56
|
+
// Directorio público de una casa (por defecto la propia): filtra por capacidad, media aceptado o texto.
|
|
57
|
+
async directory(house, { capability, accepts, q, limit, offset } = {}) {
|
|
58
|
+
const params = new URLSearchParams(Object.entries({ capability, accepts, q, limit, offset }).filter(([, v]) => v != null));
|
|
59
|
+
const base = !house || house === this.domain ? this.estafeta : (await this.resolver.domainCard(house))._estafeta;
|
|
60
|
+
const res = await this.fetch(`${base}/agents?${params}`, { signal: AbortSignal.timeout(10_000) });
|
|
61
|
+
return res.json();
|
|
62
|
+
}
|
|
63
|
+
// Rotación: el cuerpo lleva las claves nuevas; la autenticación se firma con las viejas (o usa adminToken).
|
|
64
|
+
async rotateKeys({ adminToken } = {}) {
|
|
65
|
+
const old = this.keys;
|
|
66
|
+
const fresh = { ...this.keys, ...generateKeys() };
|
|
67
|
+
const body = { local: this.local, sig: fresh.sig, enc: fresh.enc, capabilities: this.card?.capabilities, inbox: this.card?.inbox };
|
|
68
|
+
// Las claves nuevas se adoptan DESPUÉS de que la estafeta confirma: si el POST falla,
|
|
69
|
+
// este agente sigue firmando con las viejas y no queda inutilizable.
|
|
70
|
+
this.card = await this._call('POST', '/agents', body, adminToken ? { admin: adminToken } : { authKeys: old });
|
|
71
|
+
this.keys = fresh;
|
|
72
|
+
this.resolver.invalidate(`agent:${this.address}`);
|
|
73
|
+
return this.card;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------- envío ----------
|
|
77
|
+
async send({ to, type = 'message', body, media, encrypt = true, thread, inReplyTo, expires, deliverAfter, attachments, extensions, receipt }) {
|
|
78
|
+
const recipients = Array.isArray(to) ? to : [to];
|
|
79
|
+
const id = uuid();
|
|
80
|
+
const base = {
|
|
81
|
+
nyx5: '1', id, from: this.address, to: recipients, created: iso(),
|
|
82
|
+
expires: expires ?? null, deliver_after: deliverAfter ?? undefined, thread: thread ?? null, in_reply_to: inReplyTo ?? null, type,
|
|
83
|
+
attachments, extensions, receipt,
|
|
84
|
+
};
|
|
85
|
+
const content = { media: media || (typeof body === 'string' ? 'text/plain' : 'application/json'), body };
|
|
86
|
+
|
|
87
|
+
// Tarjetas de los destinatarios: para cifrar (clave enc) y para saber si exigen proof-of-work.
|
|
88
|
+
const cards = await Promise.all(recipients.map((r) => this.resolver.agentCard(r).catch((e) => ({ address: r, _error: e.message }))));
|
|
89
|
+
const missing = cards.filter((c) => c._error);
|
|
90
|
+
if (missing.length) throw new Error(`no se pudo resolver: ${missing.map((c) => `${c.address} (${c._error})`).join(', ')}`);
|
|
91
|
+
|
|
92
|
+
let env;
|
|
93
|
+
if (encrypt && cards.every((c) => c.enc)) {
|
|
94
|
+
env = { ...base, encrypted: encryptContent(content, cards.map((c) => ({ address: c.address, enc: c.enc })), aad(base)) };
|
|
95
|
+
} else {
|
|
96
|
+
if (encrypt === 'required') throw new Error('algún destinatario no publica clave de cifrado');
|
|
97
|
+
env = { ...base, content };
|
|
98
|
+
}
|
|
99
|
+
const powBits = Math.max(0, ...cards.map((c) => (c.inbox?.policy === 'pow' ? c.inbox.pow_bits ?? 16 : 0)));
|
|
100
|
+
if (powBits) env.pow = mintPow(id, powBits);
|
|
101
|
+
const stamped = cards.find((c) => c.inbox?.policy === 'stamp');
|
|
102
|
+
if (stamped) {
|
|
103
|
+
if (recipients.length > 1) throw new Error('un sobre con estampilla lleva un solo destinatario');
|
|
104
|
+
env.stamp = { house: stamped.inbox.house || parseAddress(stamped.address).domain, amount: stamped.inbox.price ?? 1 };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const signed = signObject(env, this.keys);
|
|
108
|
+
const r = await this._call('POST', '/outbound', signed);
|
|
109
|
+
return { id, envelope: signed, jobs: r.jobs };
|
|
110
|
+
}
|
|
111
|
+
// Un mensaje a tu yo futuro: llega a tu propio buzón en la fecha indicada, cifrado (solo tú lo abres).
|
|
112
|
+
// La cola ya lo sostiene; esto le da a un agente memoria operativa entre sesiones.
|
|
113
|
+
recordar({ cuando, body, thread, type = 'message' } = {}) {
|
|
114
|
+
return this.send({ to: this.address, body, type, thread, deliverAfter: cuando, encrypt: true });
|
|
115
|
+
}
|
|
116
|
+
// Escribirle a una dirección de correo del mundo real por el puente de la casa. Si el humano
|
|
117
|
+
// responde, su respuesta vuelve a tu buzón (Reply-To = tu dirección). Sin proveedor de salida
|
|
118
|
+
// configurado, devuelve { pending: true } en vez de fallar: el canal aún no existe.
|
|
119
|
+
async email({ to, subject, body }) {
|
|
120
|
+
try { return await this._call('POST', '/email/out', { to, subject, body }); }
|
|
121
|
+
catch (e) { if (e.status === 503 && e.body?.pending) return e.body; throw e; }
|
|
122
|
+
}
|
|
123
|
+
reply(envelope, body, opts = {}) {
|
|
124
|
+
return this.send({ to: envelope.from, thread: envelope.thread || envelope.id, inReplyTo: envelope.id, type: opts.type || 'result', body, ...opts });
|
|
125
|
+
}
|
|
126
|
+
// Recibo no repudiable: firmado por este agente e incluye el hash del sobre original.
|
|
127
|
+
receipt(envelope, status, reason) {
|
|
128
|
+
return this.send({ to: envelope.from, type: 'receipt', inReplyTo: envelope.id, thread: envelope.thread || envelope.id, body: { of: envelope.id, sha256: sha256hex(canonical(envelope)), status, reason }, encrypt: false });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------- delegación de identidad: un subagente con tarjeta firmada por este agente ----------
|
|
132
|
+
// El subagente genera sus propias claves; este agente firma { by, address, sig, scope, valid_until };
|
|
133
|
+
// la estafeta certifica la tarjeta. scope: { types?: [...], to_domains?: [...], cap?: tokens }.
|
|
134
|
+
async delegate(name, { scope = {}, valid_until = null, capabilities, inbox } = {}) {
|
|
135
|
+
const sub = Agent.create(`${name}.${this.local}@${this.domain}`, this.estafeta, { resolver: this.resolver, fetchImpl: this.fetch });
|
|
136
|
+
const delegation = signObject({ by: this.address, address: sub.address, sig: sub.keys.sig, scope, valid_until, issued: iso() }, this.keys);
|
|
137
|
+
const body = { local: sub.local, sig: sub.keys.sig, enc: sub.keys.enc, capabilities, inbox, delegation };
|
|
138
|
+
sub.card = await this._call('POST', '/agents', body);
|
|
139
|
+
return sub;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------- Libro: cotizaciones y contratos ----------
|
|
143
|
+
// Una cotización es un documento firmado por el vendedor; viaja dentro de un sobre (cifrado) al comprador.
|
|
144
|
+
async quote({ to, contract = 'spot', price, concept, terms, expires, arbiter, house, referrer }) {
|
|
145
|
+
const q = Libro.buildQuote({ seller: this.address, buyer: to, house: house || parseAddress(to).domain, contract, price, concept, terms, expires, arbiter, referrer }, this.keys);
|
|
146
|
+
const sent = await this.send({ to, type: 'message', media: MEDIA.cotizacion, body: q, expires: expires ?? null });
|
|
147
|
+
return { quote: q, ...sent };
|
|
148
|
+
}
|
|
149
|
+
// Operación genérica: sobre firmado, sin cifrar, a libro@<casa>. La respuesta llega como recibo.
|
|
150
|
+
libroOp(house, body, opts = {}) {
|
|
151
|
+
return this.send({ to: `libro@${house}`, type: 'task', media: MEDIA.op, body, encrypt: false, ...opts });
|
|
152
|
+
}
|
|
153
|
+
accept(quote) { return this.libroOp(quote.house, { op: 'accept', quote }); }
|
|
154
|
+
deliver(house, contract, { evidence_sha256, note } = {}) { return this.libroOp(house, { op: 'deliver', contract, evidence_sha256, note }); }
|
|
155
|
+
release(house, contract) { return this.libroOp(house, { op: 'release', contract }); }
|
|
156
|
+
refund(house, contract, note) { return this.libroOp(house, { op: 'refund', contract, note }); }
|
|
157
|
+
bond(house, { amount, claim, verifier, beneficiary, arbiter, evidence_sha256, expires, vouchee }) { return this.libroOp(house, { op: 'bond', amount, claim, verifier, beneficiary, arbiter, evidence_sha256, expires, vouchee }); }
|
|
158
|
+
// Avalar a un desconocido para que entre a un buzón con lista blanca: una fianza en la casa del
|
|
159
|
+
// receptor, con el receptor como verificador y beneficiario. Si la presentación es basura, la ejecuta.
|
|
160
|
+
vouch(house, { forAddress, receiver, amount, claim, expires }) {
|
|
161
|
+
return this.bond(house, { amount, claim: claim || `avalo a ${forAddress} ante ${receiver}`, verifier: receiver, beneficiary: receiver, vouchee: forAddress, expires });
|
|
162
|
+
}
|
|
163
|
+
forfeit(house, contract, reason) { return this.libroOp(house, { op: 'forfeit', contract, reason }); }
|
|
164
|
+
mandate(house, { grantee, cap, scope, expires, parent }) { return this.libroOp(house, { op: 'mandate', grantee, cap, scope, expires, parent }); }
|
|
165
|
+
charge(house, { mandate, amount, concept }) { return this.libroOp(house, { op: 'charge', mandate, amount, concept }); }
|
|
166
|
+
revoke(house, mandate) { return this.libroOp(house, { op: 'revoke', mandate }); }
|
|
167
|
+
|
|
168
|
+
// Lecturas directas (sin pasar por correo) en la casa indicada; por defecto, la propia estafeta.
|
|
169
|
+
async balance(house) { return this._callAt(house, 'GET', `/libro/cuenta/${encodeURIComponent(this.address)}`); }
|
|
170
|
+
async contract(house, id) { return this._callAt(house, 'GET', `/libro/contrato/${encodeURIComponent(id)}`); }
|
|
171
|
+
async _callAt(house, method, path) {
|
|
172
|
+
if (!house || house === this.domain) return this._call(method, path);
|
|
173
|
+
const dc = await this.resolver.domainCard(house);
|
|
174
|
+
const res = await this.fetch(`${dc._estafeta}${path}`, { method, headers: { authorization: this._auth(method, path.split('?')[0], this.keys, dc._estafeta) }, signal: AbortSignal.timeout(10_000) });
|
|
175
|
+
const json = await res.json().catch(() => ({}));
|
|
176
|
+
if (!res.ok) throw Object.assign(new Error(json.reason || `HTTP ${res.status}`), { status: res.status });
|
|
177
|
+
return json;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Espera el recibo del Libro que responde a una operación (por in_reply_to) y lo abre.
|
|
181
|
+
async awaitReceipt(sentId, { timeoutMs = 10_000 } = {}) {
|
|
182
|
+
const m = await this.waitFor((e) => e.type === 'receipt' && e.in_reply_to === sentId, { timeoutMs });
|
|
183
|
+
const opened = await this.open(m.envelope);
|
|
184
|
+
return { ...opened, receipt: opened.content.body, envelope: m.envelope };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Búsqueda en un índice federado (urn:nyx5:ext:indice): por casa que lo opera o URL directa.
|
|
188
|
+
// El índice es una pista: cada tarjeta se re-verifica por la cadena normal al usarla.
|
|
189
|
+
async search(index, { q, capability, accepts, house, limit, offset } = {}) {
|
|
190
|
+
const params = new URLSearchParams(Object.entries({ q, capability, accepts, house, limit, offset }).filter(([, v]) => v != null));
|
|
191
|
+
const base = index.startsWith('http') ? index.replace(/\/$/, '') : (await this.resolver.domainCard(index))._estafeta;
|
|
192
|
+
const res = await this.fetch(`${base}/index/agents?${params}`, { signal: AbortSignal.timeout(10_000) });
|
|
193
|
+
const json = await res.json().catch(() => ({}));
|
|
194
|
+
if (!res.ok) throw Object.assign(new Error(json.reason || `HTTP ${res.status}`), { status: res.status });
|
|
195
|
+
return json;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------- lectura ----------
|
|
199
|
+
async inbox({ limit = 50 } = {}) { return (await this._call('GET', `/mailbox/${this.local}?limit=${limit}`)).messages; }
|
|
200
|
+
async ack(ids) { return (await this._call('POST', `/mailbox/${this.local}/ack`, { ids: Array.isArray(ids) ? ids : [ids] })).acked; }
|
|
201
|
+
async outbox() { return (await this._call('GET', `/outbox/${this.local}`)).sent; }
|
|
202
|
+
|
|
203
|
+
// Verifica la cadena de confianza del remitente y descifra si corresponde.
|
|
204
|
+
async open(envelope) {
|
|
205
|
+
// Correo entrante por el puente (urn:nyx5:ext:email): sin firma, en claro, marcado como NO
|
|
206
|
+
// verificado. No se disfraza de sobre firmado: se abre explícitamente como lo que es.
|
|
207
|
+
const em = envelope.extensions?.['urn:nyx5:ext:email'];
|
|
208
|
+
if (em && !envelope.signature) {
|
|
209
|
+
return { id: envelope.id, from: em.from, to: envelope.to, type: envelope.type, created: envelope.created, verified: false, via: 'email', subject: em.subject || null, content: envelope.content };
|
|
210
|
+
}
|
|
211
|
+
const card = await this.resolver.agentCardForKid(envelope.from, envelope.signature?.kid);
|
|
212
|
+
const verified = Resolver.acceptedKids(card).includes(envelope.signature?.kid) && verifyObject(envelope, envelope.signature.kid);
|
|
213
|
+
if (!verified) throw new Error(`firma inválida en sobre ${envelope.id} de ${envelope.from}`);
|
|
214
|
+
if (envelope.expires && Date.parse(envelope.expires) < Date.now()) throw new Error(`sobre vencido: ${envelope.id}`);
|
|
215
|
+
if (!envelope.encrypted && !envelope.to.includes(this.address)) throw new Error(`sobre ${envelope.id} no dirigido a ${this.address}`);
|
|
216
|
+
const content = envelope.encrypted ? decryptContent(envelope.encrypted, this.address, this.keys, aad(envelope)) : envelope.content;
|
|
217
|
+
return { id: envelope.id, from: envelope.from, to: envelope.to, type: envelope.type, thread: envelope.thread, in_reply_to: envelope.in_reply_to, created: envelope.created, encrypted: !!envelope.encrypted, sender: card, content };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Espera hasta que llegue un sobre que cumpla el filtro (útil para pruebas y flujos síncronos).
|
|
221
|
+
async waitFor(predicate = () => true, { timeoutMs = 10_000, everyMs = 250 } = {}) {
|
|
222
|
+
const until = Date.now() + timeoutMs;
|
|
223
|
+
while (Date.now() < until) {
|
|
224
|
+
for (const m of await this.inbox({ limit: 200 })) if (predicate(m.envelope, m)) return m;
|
|
225
|
+
await new Promise((r) => setTimeout(r, everyMs));
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`timeout esperando sobre en ${this.address}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// AAD del cifrado: amarra el contenido a id/from/to para que un relay no pueda re-dirigir el sobre.
|
|
232
|
+
function aad(env) { return canonical({ id: env.id, from: env.from, to: env.to }); }
|
|
233
|
+
function require_dirname(p) { return p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '.'; }
|