@nyx5/nyx5 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -7
- package/bin/nyx5.js +81 -2
- package/demo/edge-local.mjs +41 -0
- package/demo/x402-pagar.mjs +64 -0
- package/demo/x402-testnet.mjs +80 -0
- package/docs/ARQUITECTURA.md +354 -0
- package/docs/SPEC.md +414 -15
- package/examples/ci/nyx5-ejecutor.yml +82 -0
- package/examples/frameworks/README.md +55 -0
- package/examples/frameworks/__pycache__/crewai_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/langgraph_nyx5.cpython-314.pyc +0 -0
- package/examples/frameworks/__pycache__/nyx5_http.cpython-314.pyc +0 -0
- package/examples/frameworks/claude-agent-sdk.mjs +48 -0
- package/examples/frameworks/crewai_nyx5.py +72 -0
- package/examples/frameworks/http.md +117 -0
- package/examples/frameworks/langgraph_nyx5.py +90 -0
- package/examples/frameworks/nyx5_http.py +255 -0
- package/examples/frameworks/openai-agents.mjs +41 -0
- package/examples/hola-mundo.mjs +4 -4
- package/migrations/0006_puente_remoto.sql +19 -0
- package/migrations/0007_indice_reputacion.sql +31 -0
- package/migrations/0008_notaria.sql +18 -0
- package/migrations/0009_contratos_estado.sql +4 -0
- package/package.json +4 -2
- package/server.json +2 -2
- package/src/correo/acciones.js +216 -0
- package/src/correo/adjuntos.js +185 -0
- package/src/correo/agente.js +370 -22
- package/src/correo/asistente.js +599 -0
- package/src/correo/ci.js +262 -0
- package/src/correo/cobro.js +164 -0
- package/src/correo/estafeta.js +1548 -58
- package/src/correo/ideas.js +154 -0
- package/src/correo/indice.js +178 -0
- package/src/correo/politica.js +280 -2
- package/src/correo/resolver.js +35 -11
- package/src/libro/contratos.js +116 -7
- package/src/libro/estado.js +150 -0
- package/src/libro/informe.js +147 -3
- package/src/libro/libro.js +83 -12
- package/src/libro/notaria.js +74 -0
- package/src/libro/verifica.js +239 -36
- package/src/nucleo/almacen-d1.js +143 -14
- package/src/nucleo/almacen.js +137 -13
- package/src/nucleo/boveda.js +37 -0
- package/src/nucleo/crypto.js +55 -12
- package/src/nucleo/keccak.js +118 -0
- package/src/nucleo/objetos.js +78 -0
- package/src/nucleo/secp256k1.js +228 -0
- package/src/plataformas/app-html.js +1 -1
- package/src/plataformas/home-html.js +1 -1
- package/src/plataformas/iconos.js +6 -0
- package/src/plataformas/node.js +28 -4
- package/src/plataformas/spec-html.js +1 -1
- package/src/plataformas/terms-html.js +2 -0
- package/src/plataformas/worker.js +52 -7
- package/src/puentes/ejecutor.js +118 -0
- package/src/puentes/herramientas.js +315 -0
- package/src/puentes/mcp-remoto.js +71 -0
- package/src/puentes/mcp.js +5 -99
- package/src/puentes/oauth.js +361 -0
- package/src/puentes/x402-pagador.js +248 -0
- package/src/puentes/x402.js +266 -0
- package/src/version.js +4 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Nyx5/1 — Puente MCP remoto: el mismo conjunto de herramientas, por Streamable HTTP en /mcp.
|
|
2
|
+
//
|
|
3
|
+
// Es lo que deja a cualquier Claude (claude.ai en el navegador, Desktop, el teléfono) usar Nyx5
|
|
4
|
+
// agregando una URL, sin instalar nada. Cada llamada llega con un token OAuth que apunta a un
|
|
5
|
+
// subagente delegado cuya llave guarda la casa (ver src/puentes/oauth.js); el puente arma ese
|
|
6
|
+
// agente en el mismo proceso y ejecuta la herramienta con él. Nunca con la llave del dueño.
|
|
7
|
+
//
|
|
8
|
+
// Del transporte (MCP 2025-06-18 / 2025-11-25) se usa lo mínimo que un servidor de sólo
|
|
9
|
+
// herramientas necesita: un POST por mensaje JSON-RPC, respuesta application/json, 202 para
|
|
10
|
+
// notificaciones, 405 al GET (no hay flujo iniciado por el servidor) y sin sesión: cada petición
|
|
11
|
+
// trae su token, así que no hay estado que perder entre instancias del edge.
|
|
12
|
+
import { TOOLS, MENSAJERIA, llamar, instrucciones } from './herramientas.js';
|
|
13
|
+
import { validarAcceso } from './oauth.js';
|
|
14
|
+
import { VERSION } from '../version.js';
|
|
15
|
+
|
|
16
|
+
export const VERSIONES = ['2025-11-25', '2025-06-18', '2025-03-26'];
|
|
17
|
+
const HERRAMIENTAS = TOOLS.filter((t) => MENSAJERIA.has(t.name));
|
|
18
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
19
|
+
|
|
20
|
+
// Un Origin presente tiene que ser un origen real: el spec pide 403 ante uno inválido (protección
|
|
21
|
+
// contra DNS rebinding). No se exige una lista cerrada: la protección de fondo es el token.
|
|
22
|
+
function origenValido(o) {
|
|
23
|
+
try { const u = new URL(o); return u.protocol === 'https:' || (u.protocol === 'http:' && LOOPBACK.has(u.hostname)); }
|
|
24
|
+
catch { return false; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function atenderMcp(est, rx) {
|
|
28
|
+
if (rx.method !== 'POST') return { status: 405, headers: { allow: 'POST' }, body: { reason: 'this MCP endpoint takes POST only; it does not open a server-initiated stream' } };
|
|
29
|
+
if (rx.headers.origin !== undefined && !origenValido(rx.headers.origin)) return { status: 403, body: { reason: 'invalid Origin header' } };
|
|
30
|
+
const acceso = await validarAcceso(est, rx);
|
|
31
|
+
if (!acceso.ok) return acceso.out;
|
|
32
|
+
const pv = rx.headers['mcp-protocol-version'];
|
|
33
|
+
if (pv && !VERSIONES.includes(pv)) return { status: 400, body: { jsonrpc: '2.0', id: null, error: { code: -32600, message: `unsupported MCP-Protocol-Version ${pv}; supported: ${VERSIONES.join(', ')}` } } };
|
|
34
|
+
const sub = acceso.token.sub;
|
|
35
|
+
if (!await est.remotoRate.allow(`mcp:${sub}`)) return { status: 429, headers: est._retryAfter(), body: { jsonrpc: '2.0', id: null, error: { code: -32000, message: 'too many requests; slow down' } } };
|
|
36
|
+
|
|
37
|
+
const msg = rx.body;
|
|
38
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return { status: 400, body: { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'send exactly one JSON-RPC message per POST' } } };
|
|
39
|
+
// Notificación (sin id) o respuesta del cliente: se acepta y no se contesta nada.
|
|
40
|
+
if (msg.id === undefined || msg.id === null || typeof msg.method !== 'string') return { status: 202, contentType: 'application/json', body: '' };
|
|
41
|
+
|
|
42
|
+
const { id, method, params } = msg;
|
|
43
|
+
const ok = (result, extra = {}) => ({ status: 200, body: { jsonrpc: '2.0', id, result }, ...extra });
|
|
44
|
+
switch (method) {
|
|
45
|
+
case 'initialize': {
|
|
46
|
+
const pedida = params?.protocolVersion;
|
|
47
|
+
const propia = `${sub}@${est.domain}`;
|
|
48
|
+
const contactos = ((await est.store.getAgent(sub))?.inbox?.allowlist || []).filter((x) => x !== propia);
|
|
49
|
+
return ok({
|
|
50
|
+
protocolVersion: VERSIONES.includes(pedida) ? pedida : '2025-06-18',
|
|
51
|
+
capabilities: { tools: { listChanged: false } },
|
|
52
|
+
serverInfo: { name: 'nyx5', title: 'Nyx5', version: VERSION },
|
|
53
|
+
instructions: instrucciones({ remoto: true, address: propia, contactos }),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
case 'ping': return ok({});
|
|
57
|
+
case 'tools/list': return ok({ tools: HERRAMIENTAS });
|
|
58
|
+
case 'tools/call': {
|
|
59
|
+
const agent = await est.agenteDeBoveda(sub);
|
|
60
|
+
if (!agent) return (await validarAcceso(est, { headers: {} })).out;
|
|
61
|
+
let result;
|
|
62
|
+
// Un error de la herramienta vuelve como resultado con isError (el modelo puede corregirse),
|
|
63
|
+
// no como error del protocolo (spec 2025-11-25).
|
|
64
|
+
try { result = await llamar(agent, params?.name, params?.arguments || {}, { permitidas: MENSAJERIA, esperaMaxS: 90 }); }
|
|
65
|
+
catch (e) { result = { content: [{ type: 'text', text: `error: ${e.message}` }], isError: true }; }
|
|
66
|
+
// kick: lo que la herramienta dejó en la cola sale ya, no en el próximo minuto del cron.
|
|
67
|
+
return ok(result, { kick: true });
|
|
68
|
+
}
|
|
69
|
+
default: return { status: 200, body: { jsonrpc: '2.0', id, error: { code: -32601, message: `method not supported: ${method}` } } };
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/puentes/mcp.js
CHANGED
|
@@ -6,113 +6,19 @@
|
|
|
6
6
|
|
|
7
7
|
import readline from 'node:readline';
|
|
8
8
|
import { Agent } from '../correo/agente.js';
|
|
9
|
+
import { TOOLS, llamar, INSTRUCCIONES } from './herramientas.js';
|
|
10
|
+
import { VERSION } from '../version.js';
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
// la suya hace imposible depurar "en qué versión pasó esto". Si no se puede leer, se declara
|
|
12
|
-
// desconocida en vez de inventar un número.
|
|
13
|
-
let VERSION = '0.0.0-unknown';
|
|
14
|
-
try {
|
|
15
|
-
const { readFileSync } = await import('node:fs');
|
|
16
|
-
VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version || VERSION;
|
|
17
|
-
} catch {}
|
|
12
|
+
export { TOOLS };
|
|
18
13
|
|
|
19
14
|
const PROTOCOL = '2025-06-18';
|
|
20
15
|
const SUPPORTED = new Set(['2025-06-18', '2025-03-26']);
|
|
21
16
|
|
|
22
|
-
const TOOLS = [
|
|
23
|
-
{ name: 'nyx5_send', description: 'Delegate a task to another agent even if it is switched off: it waits in their mailbox and their reply reaches you signed when they answer. Use it when you need someone to do something and do not know whether they are available now. The recipient verifies it is really you, and nobody else can read the content (encrypted).',
|
|
24
|
-
inputSchema: { type: 'object', required: ['to', 'body'], properties: {
|
|
25
|
-
to: { type: 'array', items: { type: 'string' }, description: 'Direcciones destino, ej. ["asistente@beta.local"]' },
|
|
26
|
-
body: { description: 'Contenido: texto o JSON' },
|
|
27
|
-
type: { type: 'string', enum: ['message', 'task', 'result', 'receipt', 'intro'], default: 'message' },
|
|
28
|
-
thread: { type: 'string' }, in_reply_to: { type: 'string' },
|
|
29
|
-
aval: { type: 'object', description: 'para entrar a un buzón con lista blanca sin estar en ella: { voucher, bond } de un tercero de la allowlist que te respaldó con una fianza', properties: { voucher: { type: 'string' }, bond: { type: 'string' } } },
|
|
30
|
-
encrypt: { type: 'boolean', default: true } } } },
|
|
31
|
-
{ name: 'nyx5_inbox', description: 'What others sent you while you were not looking. Every envelope arrives with a verified signature (you know who really sent it) and comes decrypted. Check it when you start and before treating anything as unanswered: a reply may have landed between sessions.',
|
|
32
|
-
inputSchema: { type: 'object', properties: { limit: { type: 'integer', default: 20 } } } },
|
|
33
|
-
{ name: 'nyx5_ack', description: 'Close the envelopes in your mailbox that you already handled so they stop coming back. Use it after acting on a message; what you acknowledge stays in the record.',
|
|
34
|
-
inputSchema: { type: 'object', required: ['ids'], properties: { ids: { type: 'array', items: { type: 'string' } } } } },
|
|
35
|
-
{ name: 'nyx5_resolve', description: 'Check who an address really is before trusting it: returns their card, certified by their domain (verified identity, what they can do, how they charge). Use it before sending anything sensitive or paying them.',
|
|
36
|
-
inputSchema: { type: 'object', required: ['address'], properties: { address: { type: 'string' } } } },
|
|
37
|
-
{ name: 'nyx5_outbox', description: 'What happened to the signed envelopes you sent: delivered, retrying, or bounced with the reason. Use it when you are unsure whether your message arrived; the estafeta records every attempt.',
|
|
38
|
-
inputSchema: { type: 'object', properties: {} } },
|
|
39
|
-
{ name: 'nyx5_directory', description: 'Which agents a house offers and what each one does, every card certified by the domain. Use it when you are looking for a provider inside a house you already know.',
|
|
40
|
-
inputSchema: { type: 'object', properties: { house: { type: 'string' }, capability: { type: 'string' }, accepts: { type: 'string' }, q: { type: 'string' }, limit: { type: 'integer' } } } },
|
|
41
|
-
{ name: 'nyx5_search', description: 'Find an agent that does what you need in any house, not just yours. Use it when you know nobody who solves your problem. The index answers signed and you verify the card before trusting: it is a hint, not an authority.',
|
|
42
|
-
inputSchema: { type: 'object', required: ['index'], properties: { index: { type: 'string', description: 'dominio de la casa del índice, o URL' }, q: { type: 'string' }, capability: { type: 'string' }, accepts: { type: 'string' }, house: { type: 'string' }, limit: { type: 'integer' } } } },
|
|
43
|
-
// ----- Libro -----
|
|
44
|
-
{ name: 'nyx5_quote', description: 'Offer another agent a service with a price and the exact condition that must be met to get paid. In escrow the payment is held until the proof passes. Use it to sell something with an agreement that carries weight, not a spoken promise.',
|
|
45
|
-
inputSchema: { type: 'object', required: ['to', 'price', 'concept'], properties: {
|
|
46
|
-
to: { type: 'string' }, contract: { type: 'string', enum: ['spot', 'escrow', 'metered'], default: 'spot' },
|
|
47
|
-
price: { type: 'integer', description: 'tokens, entero' }, concept: { type: 'string' },
|
|
48
|
-
terms: { type: 'object', description: 'criterio de aceptación, plazo, scope del mandato, etc.' },
|
|
49
|
-
arbiter: { type: 'string' }, expires: { type: 'string', description: 'ISO-8601' },
|
|
50
|
-
referrer: { type: 'object', description: 'comisión de referido: { address, share } en basis points; la paga el vendedor de su parte, el comprador paga igual', properties: { address: { type: 'string' }, share: { type: 'integer' } } } } } },
|
|
51
|
-
{ name: 'nyx5_accept', description: 'Accept an offer and commit the payment. In escrow the money is held: the seller does not get paid until they deliver and meet the condition. You receive a signed receipt that no party can deny later.',
|
|
52
|
-
inputSchema: { type: 'object', required: ['quote'], properties: { quote: { type: 'object' } } } },
|
|
53
|
-
{ name: 'nyx5_libro', description: 'Move a deal forward in the ledger, leaving a signed and irreversible entry at every step: deliver, release the payment if the proof passed, refund if it failed, back a claim with money (you lose it if you lied), or delegate spending with a cap. ops: deliver {contract, evidence_sha256}, release {contract}, refund {contract}, bond {amount, claim, verifier}, forfeit {contract, reason}, mandate {grantee, cap, scope, expires, parent}, charge {mandate, amount, concept}, revoke {mandate}, balance, statement {limit}, contract {contract}. The answer arrives as a signed receipt in your mailbox.',
|
|
54
|
-
inputSchema: { type: 'object', required: ['op'], properties: { house: { type: 'string', description: 'dominio de la casa; por defecto el propio' }, op: { type: 'string' }, args: { type: 'object' } } } },
|
|
55
|
-
{ name: 'nyx5_balance', description: 'How much you hold, which contracts and which spending permissions are active. Check it before committing a payment. A direct read authenticated with your signature, without going through the mail.',
|
|
56
|
-
inputSchema: { type: 'object', properties: { house: { type: 'string' } } } },
|
|
57
|
-
{ name: 'nyx5_remind', description: 'Leave yourself a message that reaches you in the future, in your own mailbox, encrypted. Use it when a task must be picked up in hours or days and your session will end before then: your future self finds the context with the full thread, without depending on anyone waking you.',
|
|
58
|
-
inputSchema: { type: 'object', required: ['cuando', 'body'], properties: { cuando: { type: 'string', description: 'ISO-8601: cuándo debe llegarte' }, body: { description: 'lo que tu yo futuro necesita saber' }, thread: { type: 'string' } } } },
|
|
59
|
-
{ name: 'nyx5_contract', description: 'The state and full history of a deal you are party to: every step with its hash and its signature. Use it to see where an escrow or a bond stands.',
|
|
60
|
-
inputSchema: { type: 'object', required: ['contract'], properties: { house: { type: 'string' }, contract: { type: 'string' } } } },
|
|
61
|
-
{ name: 'nyx5_historial', description: 'The reputation of an agent is its ledger: deliveries accepted against returned, bonds standing against forfeited, with amounts. Check it before hiring a stranger. Every point of it cost tokens and is tied to a verified delivery, so it cannot be inflated by talking; with no record it returns null (nothing yet, not perfect).',
|
|
62
|
-
inputSchema: { type: 'object', properties: { address: { type: 'string', description: 'a quién mirar; por defecto, tú mismo' } } } },
|
|
63
|
-
{ name: 'nyx5_tareas', description: 'Paid work a house publishes that you can take right now: what to do, what it pays, and the deterministic check it will be verified with. Use it when you just joined and have no record yet, or when you need tokens to back your own claims with a bond.',
|
|
64
|
-
inputSchema: { type: 'object', properties: { house: { type: 'string' } } } },
|
|
65
|
-
{ name: 'nyx5_tomar', description: 'Take a published task: the house holds the payment in a signed entry before you work, and releases it on its own when the deterministic check passes. If it fails, it is refunded and stays in your record. Use it to earn your first tokens; terms are copied from the catalogue and are not negotiable.',
|
|
66
|
-
inputSchema: { type: 'object', required: ['id'], properties: { id: { type: 'string', description: 'id de la tarea, de nyx5_tareas' }, house: { type: 'string' } } } },
|
|
67
|
-
{ name: 'nyx5_email', description: 'Write by email to a human who is not on Nyx5 yet. Use it when the recipient has no agent address: their reply comes back to your mailbox (Reply-To). It enters unsigned, marked as not verified, never disguised; when they want the real thing, they register.',
|
|
68
|
-
inputSchema: { type: 'object', required: ['to', 'body'], properties: { to: { type: 'string', description: 'dirección de correo, ej. persona@gmail.com' }, subject: { type: 'string' }, body: { description: 'el texto del correo' } } } },
|
|
69
|
-
];
|
|
70
|
-
|
|
71
17
|
export async function runMcpServer({ agentFile, hosts = {} }) {
|
|
72
18
|
const agent = await Agent.load(agentFile, { hosts });
|
|
73
19
|
const out = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
|
|
74
20
|
const text = (v) => ({ content: [{ type: 'text', text: typeof v === 'string' ? v : JSON.stringify(v, null, 2) }] });
|
|
75
|
-
|
|
76
|
-
async function call(name, args = {}) {
|
|
77
|
-
switch (name) {
|
|
78
|
-
case 'nyx5_send': { const r = await agent.send({ to: args.to, body: args.body, type: args.type, thread: args.thread, inReplyTo: args.in_reply_to, encrypt: args.encrypt ?? true, extensions: args.aval ? { 'urn:nyx5:ext:aval': args.aval } : undefined }); return text({ id: r.id, jobs: r.jobs }); }
|
|
79
|
-
case 'nyx5_inbox': {
|
|
80
|
-
const msgs = await agent.inbox({ limit: args.limit ?? 20 });
|
|
81
|
-
const opened = [];
|
|
82
|
-
for (const m of msgs) { try { opened.push(await agent.open(m.envelope)); } catch (e) { opened.push({ id: m.envelope.id, from: m.envelope.from, error: e.message }); } }
|
|
83
|
-
return text(opened.map(({ sender, ...o }) => o));
|
|
84
|
-
}
|
|
85
|
-
case 'nyx5_ack': return text({ acked: await agent.ack(args.ids) });
|
|
86
|
-
case 'nyx5_resolve': { const { _domain, ...card } = await agent.resolver.agentCard(args.address); return text(card); }
|
|
87
|
-
case 'nyx5_outbox': return text(await agent.outbox());
|
|
88
|
-
case 'nyx5_directory': return text(await agent.directory(args.house, args));
|
|
89
|
-
case 'nyx5_search': return text(await agent.search(args.index, args));
|
|
90
|
-
case 'nyx5_quote': { const r = await agent.quote({ to: args.to, contract: args.contract, price: args.price, concept: args.concept, terms: args.terms, arbiter: args.arbiter, expires: args.expires, referrer: args.referrer }); return text({ id: r.id, quote_id: r.quote.id, contract: r.quote.contract, price: r.quote.price }); }
|
|
91
|
-
case 'nyx5_accept': { const r = await agent.accept(args.quote); return text({ id: r.id, note: 'el recibo de libro@ llegará al buzón (nyx5_inbox)' }); }
|
|
92
|
-
case 'nyx5_libro': { const r = await agent.libroOp(args.house || agent.domain, { op: args.op, ...(args.args || {}) }); return text({ id: r.id, note: 'la respuesta llega como recibo de libro@ al buzón' }); }
|
|
93
|
-
case 'nyx5_balance': return text(await agent.balance(args.house));
|
|
94
|
-
case 'nyx5_remind': { const r = await agent.recordar({ cuando: args.cuando, body: args.body, thread: args.thread }); return text({ id: r.id, note: `te llegará a tu buzón el ${args.cuando}` }); }
|
|
95
|
-
case 'nyx5_historial': return text(await agent.historial(args.address || agent.address));
|
|
96
|
-
case 'nyx5_tareas': {
|
|
97
|
-
const dc = await agent.resolver.domainCard(args.house || agent.domain);
|
|
98
|
-
const res = await agent.fetch(`${dc._estafeta}/tareas`);
|
|
99
|
-
const j = await res.json();
|
|
100
|
-
if (!res.ok) return text({ error: j.reason || `HTTP ${res.status}` });
|
|
101
|
-
return text(j);
|
|
102
|
-
}
|
|
103
|
-
case 'nyx5_tomar': {
|
|
104
|
-
const dc = await agent.resolver.domainCard(args.house || agent.domain);
|
|
105
|
-
const j = await (await agent.fetch(`${dc._estafeta}/tareas`)).json();
|
|
106
|
-
const t = (j.tasks || j.tareas || []).find((x) => x.id === args.id);
|
|
107
|
-
if (!t) return text({ error: `no task with id ${args.id}`, available: (j.tasks || j.tareas || []).map((x) => x.id) });
|
|
108
|
-
const enviada = await agent.quote({ to: (j.desk || j.mostrador), contract: 'escrow', price: t.price, concept: t.concept, arbiter: (j.arbiter || j.arbitro), terms: t.terms });
|
|
109
|
-
return text({ enviada: enviada.id, tarea: t.id, price: t.price, siguiente: 'si hay cupo, el contrato te llega al buzón (nyx5_inbox); al terminar, nyx5_libro op=deliver' });
|
|
110
|
-
}
|
|
111
|
-
case 'nyx5_email': { const r = await agent.email({ to: args.to, subject: args.subject, body: args.body }); return text(r); }
|
|
112
|
-
case 'nyx5_contract': return text(await agent.contract(args.house || agent.domain, args.contract));
|
|
113
|
-
default: return { ...text(`herramienta desconocida: ${name}`), isError: true };
|
|
114
|
-
}
|
|
115
|
-
}
|
|
21
|
+
const call = (name, args) => llamar(agent, name, args);
|
|
116
22
|
|
|
117
23
|
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
118
24
|
for await (const line of rl) {
|
|
@@ -123,7 +29,7 @@ export async function runMcpServer({ agentFile, hosts = {} }) {
|
|
|
123
29
|
if (method === 'notifications/initialized' || method?.startsWith('notifications/')) continue;
|
|
124
30
|
try {
|
|
125
31
|
let result;
|
|
126
|
-
if (method === 'initialize') result = { protocolVersion: SUPPORTED.has(params?.protocolVersion) ? params.protocolVersion : PROTOCOL, capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'nyx5', version: VERSION }, instructions:
|
|
32
|
+
if (method === 'initialize') result = { protocolVersion: SUPPORTED.has(params?.protocolVersion) ? params.protocolVersion : PROTOCOL, capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'nyx5', version: VERSION }, instructions: INSTRUCCIONES };
|
|
127
33
|
else if (method === 'ping') result = {};
|
|
128
34
|
else if (method === 'tools/list') result = { tools: TOOLS };
|
|
129
35
|
else if (method === 'tools/call') { try { result = await call(params?.name, params?.arguments); } catch (e) { result = { ...text(`error: ${e.message}`), isError: true }; } }
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// Nyx5/1 — Servidor de autorización OAuth 2.1 para el conector MCP remoto.
|
|
2
|
+
//
|
|
3
|
+
// Para qué existe: que el Claude de un teléfono (claude.ai web, Desktop, móvil) use Nyx5 sin
|
|
4
|
+
// instalar nada y SIN copiar la llave de nadie. El dueño autoriza desde su navegador, donde vive su
|
|
5
|
+
// llave raíz, y esa llave firma una delegación para un SUBAGENTE: claude.<dueño>@<casa>. La casa
|
|
6
|
+
// guarda la llave de ese subagente, y sólo la de ese, cifrada en la bóveda. El subagente:
|
|
7
|
+
// - sólo manda y lee mensajes: no opera el Libro ni paga estampillas (scope.messages_only);
|
|
8
|
+
// - vence (30 días) y el dueño lo revoca desde su teléfono cuando quiera;
|
|
9
|
+
// - lo declara en su tarjeta (custody.keys = house): quien le escribe sabe que la casa puede leer
|
|
10
|
+
// lo que le llega, porque la casa tiene su llave.
|
|
11
|
+
// Aprobado por Nicholas el 10-sep-2026 con esos tres límites.
|
|
12
|
+
//
|
|
13
|
+
// Lo que se cumple del spec (MCP 2025-06-18 y 2025-11-25) y de lo que Claude exige en la práctica:
|
|
14
|
+
// RFC 9728 metadatos del recurso · RFC 8414 metadatos del servidor · RFC 7591 registro dinámico
|
|
15
|
+
// PKCE S256 obligatorio · RFC 8707 `resource` amarrado al token · refresh con rotación (Claude es
|
|
16
|
+
// cliente público) · /oauth/token acepta form-urlencoded · errores RFC 6749 (`invalid_grant`).
|
|
17
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
18
|
+
import { b64u, generateKeys, verifyObject } from '../nucleo/crypto.js';
|
|
19
|
+
import { parseAddress } from '../correo/resolver.js';
|
|
20
|
+
|
|
21
|
+
export const ALCANCE = 'messages';
|
|
22
|
+
const DIA = 86_400_000;
|
|
23
|
+
const sha = (s) => createHash('sha256').update(String(s)).digest('hex');
|
|
24
|
+
const aleatorio = () => b64u(randomBytes(32));
|
|
25
|
+
const s256 = (verifier) => b64u(createHash('sha256').update(String(verifier)).digest());
|
|
26
|
+
const iso = (t = Date.now()) => new Date(t).toISOString();
|
|
27
|
+
const err = (status, error, error_description) => ({ status, body: { error, error_description } });
|
|
28
|
+
|
|
29
|
+
export function urlsDe(est, ruta = '/mcp') {
|
|
30
|
+
const base = est.publicUrl.replace(/\/+$/, '');
|
|
31
|
+
return { base, recurso: `${base}${ruta}`, prm: `${base}/.well-known/oauth-protected-resource${ruta}` };
|
|
32
|
+
}
|
|
33
|
+
// Una invitación viaja DENTRO de la URL del conector: /mcp/i/<código>. Claude la devuelve sola como
|
|
34
|
+
// `resource` al pedir autorización, así que la pantalla de consentimiento sabe quién invitó sin
|
|
35
|
+
// depender de lo que haya guardado el navegador (en iPhone, la ventana de autorización y Safari
|
|
36
|
+
// pueden no compartir almacenamiento). La URL sigue siendo el conector de la persona para siempre,
|
|
37
|
+
// aunque la invitación ya se haya usado: el código sólo decide el prellenado, no el acceso.
|
|
38
|
+
export const RUTA_INVITACION = /^\/mcp\/i\/([A-Za-z0-9_-]{16,64})$/;
|
|
39
|
+
|
|
40
|
+
export function metadatosRecurso(est, ruta = '/mcp') {
|
|
41
|
+
const { base, recurso } = urlsDe(est, ruta);
|
|
42
|
+
return { resource: recurso, authorization_servers: [base], bearer_methods_supported: ['header'], scopes_supported: [ALCANCE], resource_name: `Nyx5 (${est.domain})`, resource_documentation: `${base}/spec` };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function metadatosServidor(est) {
|
|
46
|
+
const { base } = urlsDe(est);
|
|
47
|
+
return {
|
|
48
|
+
issuer: base,
|
|
49
|
+
authorization_endpoint: `${base}/oauth/authorize`,
|
|
50
|
+
token_endpoint: `${base}/oauth/token`,
|
|
51
|
+
registration_endpoint: `${base}/oauth/register`,
|
|
52
|
+
response_types_supported: ['code'],
|
|
53
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
54
|
+
code_challenge_methods_supported: ['S256'],
|
|
55
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
56
|
+
scopes_supported: [ALCANCE, 'offline_access'],
|
|
57
|
+
service_documentation: `${base}/spec`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------- URIs de retorno ----------
|
|
62
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
63
|
+
function redirectValida(u) {
|
|
64
|
+
let x; try { x = new URL(u); } catch { return false; }
|
|
65
|
+
if (x.hash) return false;
|
|
66
|
+
if (x.protocol === 'https:') return true;
|
|
67
|
+
return x.protocol === 'http:' && LOOPBACK.has(x.hostname);
|
|
68
|
+
}
|
|
69
|
+
// Exacta, salvo en loopback: ahí el puerto lo elige el cliente en cada sesión (RFC 8252 §7.3), y
|
|
70
|
+
// Claude Code declara http://localhost/callback sin puerto.
|
|
71
|
+
function coincideRedirect(registradas, pedida) {
|
|
72
|
+
if (registradas.includes(pedida)) return true;
|
|
73
|
+
let p; try { p = new URL(pedida); } catch { return false; }
|
|
74
|
+
if (p.protocol !== 'http:' || !LOOPBACK.has(p.hostname)) return false;
|
|
75
|
+
return registradas.some((r) => {
|
|
76
|
+
try { const u = new URL(r); return u.protocol === 'http:' && u.hostname === p.hostname && u.pathname === p.pathname && u.search === p.search; }
|
|
77
|
+
catch { return false; }
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizarUrl(u) {
|
|
82
|
+
try { const x = new URL(u); if (x.hash) return null; return `${x.protocol.toLowerCase()}//${x.host.toLowerCase()}${x.pathname.replace(/\/+$/, '')}${x.search}`; }
|
|
83
|
+
catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
// La ruta de NUESTRO recurso a la que apunta una URL (el conector, el de una invitación, o la casa
|
|
86
|
+
// misma), o null si no es nuestra. El token vale sólo para nuestros recursos (RFC 8707).
|
|
87
|
+
export function rutaDeRecurso(est, r) {
|
|
88
|
+
const n = normalizarUrl(r);
|
|
89
|
+
const b = normalizarUrl(urlsDe(est).base);
|
|
90
|
+
if (!n || !b) return null;
|
|
91
|
+
if (n === b) return '/mcp';
|
|
92
|
+
if (!n.startsWith(`${b}/`)) return null;
|
|
93
|
+
const ruta = n.slice(b.length);
|
|
94
|
+
return ruta === '/mcp' || RUTA_INVITACION.test(ruta) ? ruta : null;
|
|
95
|
+
}
|
|
96
|
+
export function recursoValido(est, r) {
|
|
97
|
+
if (r == null || r === '') return true;
|
|
98
|
+
return rutaDeRecurso(est, r) !== null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------- registro dinámico de clientes (RFC 7591) ----------
|
|
102
|
+
export async function registrar(est, body, ip) {
|
|
103
|
+
if (!await est.remotoRate.allow(`dcr:${ip || 'x'}`)) return err(429, 'invalid_client_metadata', 'too many client registrations from this address; try again in a minute');
|
|
104
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
105
|
+
const uris = b.redirect_uris;
|
|
106
|
+
if (!Array.isArray(uris) || !uris.length || uris.length > 5 || !uris.every((u) => typeof u === 'string' && u.length <= 512 && redirectValida(u))) {
|
|
107
|
+
return err(400, 'invalid_redirect_uri', 'redirect_uris: 1 to 5 URIs, https or loopback http, without fragment');
|
|
108
|
+
}
|
|
109
|
+
const grants = b.grant_types ?? ['authorization_code', 'refresh_token'];
|
|
110
|
+
if (!Array.isArray(grants) || !grants.every((g) => g === 'authorization_code' || g === 'refresh_token')) return err(400, 'invalid_client_metadata', 'grant_types: only authorization_code and refresh_token');
|
|
111
|
+
const respuestas = b.response_types ?? ['code'];
|
|
112
|
+
if (!Array.isArray(respuestas) || respuestas.some((r) => r !== 'code')) return err(400, 'invalid_client_metadata', 'response_types: only code');
|
|
113
|
+
const nombre = typeof b.client_name === 'string' ? b.client_name.replace(/[\u0000-\u001f<>"]/g, '').trim().slice(0, 80) : '';
|
|
114
|
+
const cliente = {
|
|
115
|
+
client_id: `nyx5c_${aleatorio()}`,
|
|
116
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
117
|
+
redirect_uris: uris,
|
|
118
|
+
grant_types: grants,
|
|
119
|
+
response_types: ['code'],
|
|
120
|
+
// Siempre público: Claude se registra así y PKCE es lo que protege el canje.
|
|
121
|
+
token_endpoint_auth_method: 'none',
|
|
122
|
+
...(nombre ? { client_name: nombre } : {}),
|
|
123
|
+
};
|
|
124
|
+
await est.store.kvPut('cliente', cliente.client_id, cliente, Date.now() + 90 * DIA);
|
|
125
|
+
return { status: 201, body: cliente };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---------- la solicitud de autorización ----------
|
|
129
|
+
async function validarPedido(est, q) {
|
|
130
|
+
const g = (k) => { const v = q instanceof URLSearchParams ? q.get(k) : q?.[k]; return typeof v === 'string' ? v : null; };
|
|
131
|
+
const client_id = g('client_id');
|
|
132
|
+
const cliente = client_id ? await est.store.kvGet('cliente', client_id) : null;
|
|
133
|
+
if (!cliente) return { ok: false, redirigible: false, error: 'invalid_client', description: 'unknown client_id: register the client first' };
|
|
134
|
+
let redirect_uri = g('redirect_uri');
|
|
135
|
+
if (!redirect_uri && cliente.redirect_uris.length === 1) redirect_uri = cliente.redirect_uris[0];
|
|
136
|
+
if (!redirect_uri || !coincideRedirect(cliente.redirect_uris, redirect_uri)) return { ok: false, redirigible: false, error: 'invalid_request', description: 'redirect_uri does not match any registered for this client' };
|
|
137
|
+
const state = g('state');
|
|
138
|
+
const pedido = { client_id, cliente, redirect_uri, state: state && state.length <= 1024 ? state : null };
|
|
139
|
+
const mal = (error, description) => ({ ok: false, redirigible: true, error, description, pedido });
|
|
140
|
+
if (g('response_type') !== 'code') return mal('unsupported_response_type', 'only response_type=code');
|
|
141
|
+
const reto = g('code_challenge');
|
|
142
|
+
if (!reto || !/^[A-Za-z0-9_-]{43,128}$/.test(reto) || g('code_challenge_method') !== 'S256') return mal('invalid_request', 'PKCE with code_challenge_method=S256 is required');
|
|
143
|
+
const resource = g('resource');
|
|
144
|
+
if (!recursoValido(est, resource)) return mal('invalid_target', `this server only issues tokens for ${urlsDe(est).recurso}`);
|
|
145
|
+
// Una invitación vigente y sin usar prellena la pantalla. Sale del recurso, no del navegador.
|
|
146
|
+
let invitacion = null;
|
|
147
|
+
const mi = RUTA_INVITACION.exec(resource ? rutaDeRecurso(est, resource) || '' : '');
|
|
148
|
+
if (mi) { const inv = await est.store.kvGet('invitacion', mi[1]); if (inv && !inv.used_by) invitacion = inv; }
|
|
149
|
+
return { ok: true, pedido: { ...pedido, code_challenge: reto, resource: resource || urlsDe(est).recurso, scope: ALCANCE, invitacion } };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const escapar = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
153
|
+
function paginaError(texto) {
|
|
154
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Nyx5 · authorization</title><style>body{font:16px/1.5 -apple-system,system-ui,sans-serif;max-width:480px;margin:3rem auto;padding:0 1.2rem;color:#1A1712}h1{font-family:Georgia,serif;font-weight:400}</style></head><body><h1>This authorization cannot continue</h1><p>${escapar(texto)}</p><p>Go back to Claude and add the connector again.</p></body></html>`;
|
|
155
|
+
}
|
|
156
|
+
function volver(redirect_uri, params) {
|
|
157
|
+
const u = new URL(redirect_uri);
|
|
158
|
+
for (const [k, v] of Object.entries(params)) if (v != null) u.searchParams.set(k, v);
|
|
159
|
+
return { status: 302, headers: { location: u.toString() }, contentType: 'text/plain; charset=utf-8', body: '' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// El nombre que sugiere una invitación se mira AL MOSTRAR, no al crearla: puede tomarse entre medio.
|
|
163
|
+
// Defecto real (11-sep-2026): la invitación de la Pauli prellenaba "pauli", que existía desde el 7-sep,
|
|
164
|
+
// y su primer toque en "Connect" iba a rebotar con "that name is taken".
|
|
165
|
+
async function sugerible(est, hint) {
|
|
166
|
+
return hint && (await est.nombreDisponible(hint)) ? hint : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// GET /oauth/authorize: la pantalla de consentimiento es la misma app, en modo "autorizar". La
|
|
170
|
+
// solicitud llega VALIDADA a la página: el navegador nunca decide a qué URI se redirige.
|
|
171
|
+
export async function paginaAutorizar(est, query, appHtml) {
|
|
172
|
+
const v = await validarPedido(est, query);
|
|
173
|
+
if (!v.ok && !v.redirigible) return { status: 400, contentType: 'text/html; charset=utf-8', body: paginaError(v.description) };
|
|
174
|
+
if (!v.ok) return volver(v.pedido.redirect_uri, { error: v.error, error_description: v.description, state: v.pedido.state });
|
|
175
|
+
const p = v.pedido;
|
|
176
|
+
const destino = new URL(p.redirect_uri);
|
|
177
|
+
const datos = {
|
|
178
|
+
client_id: p.client_id, redirect_uri: p.redirect_uri, state: p.state, code_challenge: p.code_challenge, resource: p.resource,
|
|
179
|
+
client_name: p.cliente.client_name || null, redirect_host: destino.host,
|
|
180
|
+
// El spec pide mostrar con claridad a dónde vuelve el permiso, y advertir si es sólo loopback.
|
|
181
|
+
loopback: LOOPBACK.has(destino.hostname), house: est.domain, days: est.remoto.dias,
|
|
182
|
+
invite: p.invitacion ? { inviter: p.invitacion.inviter, inviter_claude: p.invitacion.inviter_claude || null, contacts: p.invitacion.contacts || [], name_hint: await sugerible(est, p.invitacion.name_hint) } : null,
|
|
183
|
+
};
|
|
184
|
+
const inyectado = `<script>window.NYX5_OAUTH=${JSON.stringify(datos).replace(/</g, '\\u003c')}</script>`;
|
|
185
|
+
return { status: 200, contentType: 'text/html; charset=utf-8', body: appHtml.replace('<!--OAUTH-->', inyectado) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const nombreSub = (raiz) => { const n = `claude.${raiz}`; return n.length <= 64 ? n : null; };
|
|
189
|
+
const DOMINIO = /^(?=.{1,253}$)([a-z0-9-]{1,63}\.)+[a-z]{2,63}$/;
|
|
190
|
+
function listaDeEscritura(entrada, dueno, propia) {
|
|
191
|
+
if (entrada === 'open') return null;
|
|
192
|
+
const lista = new Set([dueno, propia]);
|
|
193
|
+
for (const x of (Array.isArray(entrada) ? entrada : []).slice(0, 50)) {
|
|
194
|
+
const v = String(x || '').trim().toLowerCase();
|
|
195
|
+
if (!v) continue;
|
|
196
|
+
if (v.includes('@')) { try { parseAddress(v); } catch { throw Object.assign(new Error(`not a valid address: ${v}`), { status: 400 }); } lista.add(v); }
|
|
197
|
+
else if (DOMINIO.test(v)) lista.add(v);
|
|
198
|
+
else throw Object.assign(new Error(`not an address or a domain: ${v}`), { status: 400 });
|
|
199
|
+
}
|
|
200
|
+
return [...lista];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// POST /oauth/prepare (firmado por la llave raíz del dueño): la casa entrega la llave PÚBLICA del
|
|
204
|
+
// subagente para que el navegador firme la delegación. Si ese Claude ya existe y está vigente, se
|
|
205
|
+
// reusa: autorizar desde otro dispositivo no debe cambiarle la identidad a tu Claude.
|
|
206
|
+
export async function preparar(est, rx) {
|
|
207
|
+
const who = await est._authenticate(rx, rx.path);
|
|
208
|
+
if (who.record.delegation) return { status: 403, body: { reason: 'a delegated address cannot authorize another one; use your own address' } };
|
|
209
|
+
const v = await validarPedido(est, rx.body || {});
|
|
210
|
+
if (!v.ok) return { status: 400, body: { reason: v.description } };
|
|
211
|
+
const sub = nombreSub(who.local);
|
|
212
|
+
if (!sub) return { status: 400, body: { reason: 'your address is too long to hold a Claude subaddress' } };
|
|
213
|
+
let keys = await est.llavesDeBoveda(sub);
|
|
214
|
+
if (!keys) {
|
|
215
|
+
const pend = await est.store.kvGet('pendiente', sub);
|
|
216
|
+
if (pend) { try { keys = est.boveda.abrir(pend.sellado, sub); } catch { keys = null; } }
|
|
217
|
+
if (!keys) {
|
|
218
|
+
keys = generateKeys();
|
|
219
|
+
await est.store.kvPut('pendiente', sub, { sellado: est.boveda.sellar(keys, sub), root: who.address }, Date.now() + 15 * 60_000);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
status: 200,
|
|
224
|
+
body: {
|
|
225
|
+
address: `${sub}@${est.domain}`, sig: keys.sig, enc: keys.enc, parent: who.address,
|
|
226
|
+
scope: { messages_only: true }, valid_until: iso(Date.now() + est.remoto.dias * DIA),
|
|
227
|
+
client_name: v.pedido.cliente.client_name || null, redirect_host: new URL(v.pedido.redirect_uri).host,
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// POST /oauth/approve (firmado por la llave raíz): la delegación que firmó el navegador se inscribe,
|
|
233
|
+
// la llave del subagente pasa de "pendiente" a la bóveda, y sale el código de un uso.
|
|
234
|
+
export async function aprobar(est, rx) {
|
|
235
|
+
const who = await est._authenticate(rx, rx.path);
|
|
236
|
+
if (who.record.delegation) return { status: 403, body: { reason: 'a delegated address cannot authorize another one; use your own address' } };
|
|
237
|
+
const b = rx.body || {};
|
|
238
|
+
const v = await validarPedido(est, b);
|
|
239
|
+
if (!v.ok) return { status: 400, body: { reason: v.description } };
|
|
240
|
+
const sub = nombreSub(who.local);
|
|
241
|
+
const address = `${sub}@${est.domain}`;
|
|
242
|
+
let keys = await est.llavesDeBoveda(sub);
|
|
243
|
+
let dePendiente = false;
|
|
244
|
+
if (!keys) {
|
|
245
|
+
const pend = await est.store.kvGet('pendiente', sub);
|
|
246
|
+
if (pend) { try { keys = est.boveda.abrir(pend.sellado, sub); dePendiente = true; } catch { keys = null; } }
|
|
247
|
+
}
|
|
248
|
+
if (!keys) return { status: 400, body: { reason: 'this authorization expired; add the connector again from Claude' } };
|
|
249
|
+
// La delegación la firma el navegador del dueño con su llave raíz. La casa no puede fabricarla.
|
|
250
|
+
const d = b.delegation;
|
|
251
|
+
if (!d || d.by !== who.address || d.address !== address || d.sig !== keys.sig || d.scope?.messages_only !== true || !verifyObject(d, who.record.sig)) {
|
|
252
|
+
return { status: 400, body: { reason: 'the delegation must be signed by your own key, for this address and this key, and limited to messages' } };
|
|
253
|
+
}
|
|
254
|
+
const tope = Date.now() + est.remoto.dias * DIA + 5 * 60_000;
|
|
255
|
+
if (!d.valid_until || Number.isNaN(Date.parse(d.valid_until)) || Date.parse(d.valid_until) > tope || Date.parse(d.valid_until) < Date.now()) {
|
|
256
|
+
return { status: 400, body: { reason: `valid_until must be in the future and at most ${est.remoto.dias} days away` } };
|
|
257
|
+
}
|
|
258
|
+
let allowlist = listaDeEscritura(b.allowlist, who.address, address);
|
|
259
|
+
// Reconectar no borra contactos (12-sep-2026). La pantalla viene marcada en "sólo tú", y quien
|
|
260
|
+
// reconectaba su Claude perdía a quienes podían escribirle: a Basti le habría cortado las respuestas
|
|
261
|
+
// del agente de Sigo. Si ya había una lista viva, se suma a la nueva; "cualquiera" sigue abierto.
|
|
262
|
+
const previo = await est.store.getAgent(sub);
|
|
263
|
+
if (allowlist && previo?.inbox?.policy === 'allowlist' && !previo.revoked) allowlist = [...new Set([...allowlist, ...(previo.inbox.allowlist || [])])];
|
|
264
|
+
// Invitación: se toma en UN paso (dos aprobaciones simultáneas no la usan las dos) y sus contactos
|
|
265
|
+
// entran a la lista. Quién invitó lo dice la casa, no el navegador.
|
|
266
|
+
let inv = v.pedido.invitacion ? await est.store.kvTake('invitacion', v.pedido.invitacion.code) : null;
|
|
267
|
+
if (inv && inv.inviter === who.address) { await est.store.kvPut('invitacion', inv.code, inv, Date.parse(inv.expires)); inv = null; }
|
|
268
|
+
if (inv && allowlist) allowlist = [...new Set([...allowlist, inv.inviter, ...(inv.inviter_claude ? [inv.inviter_claude] : []), ...(inv.contacts || [])])];
|
|
269
|
+
const card = await est.registerAgent({
|
|
270
|
+
local: sub, sig: keys.sig, enc: keys.enc, delegation: d, valid_until: d.valid_until,
|
|
271
|
+
capabilities: { accepts: ['text/plain', 'application/json'] },
|
|
272
|
+
inbox: allowlist ? { policy: 'allowlist', allowlist } : { policy: 'open' },
|
|
273
|
+
custody: { keys: 'house', via: 'oauth', since: iso() },
|
|
274
|
+
});
|
|
275
|
+
await est.store.kvPut('boveda', sub, { sellado: est.boveda.sellar(keys, sub), root: who.address, since: iso() });
|
|
276
|
+
if (dePendiente) await est.store.kvDelete('pendiente', sub);
|
|
277
|
+
if (inv) {
|
|
278
|
+
// El contacto queda en los dos sentidos: el Claude de quien invitó acepta al nuevo. Sin esto, la
|
|
279
|
+
// primera respuesta del Claude invitado rebotaba contra la lista del que invitó.
|
|
280
|
+
await est.store.kvPut('invitacion', inv.code, { ...inv, used_by: who.address, used_at: iso(), claude: address }, Date.now() + 30 * DIA);
|
|
281
|
+
for (const c of [inv.inviter_claude, ...(inv.contacts || [])].filter(Boolean)) await est.agregarContactos(parseAddress(c).local, [address, who.address]);
|
|
282
|
+
await est._evento('invitation_accepted', who.address, { by: inv.inviter });
|
|
283
|
+
}
|
|
284
|
+
const code = aleatorio();
|
|
285
|
+
const p = v.pedido;
|
|
286
|
+
await est.store.kvPut('codigo', sha(code), { client_id: p.client_id, redirect_uri: p.redirect_uri, code_challenge: p.code_challenge, resource: p.resource, scope: p.scope, sub, root: who.address }, Date.now() + 5 * 60_000);
|
|
287
|
+
await est._evento('connector_authorized', who.address, { client: p.cliente.client_name || null, open: !allowlist });
|
|
288
|
+
// Embudo (NX-801), etapa 3: el Claude del dueño quedó conectado. `code` enlaza este recorrido con
|
|
289
|
+
// el `open_invite` del enlace que lo trajo (y con su fuente); sin invitación, ambos van en null.
|
|
290
|
+
await est._evento('claude_connected', who.address, { invited_by: inv?.inviter || null, code: inv?.code || null });
|
|
291
|
+
const u = new URL(p.redirect_uri);
|
|
292
|
+
u.searchParams.set('code', code);
|
|
293
|
+
if (p.state) u.searchParams.set('state', p.state);
|
|
294
|
+
return { status: 200, body: { redirect: u.toString(), address: card.address, invited_by: inv?.inviter || null } };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------- /oauth/token ----------
|
|
298
|
+
async function vigente(est, sub) {
|
|
299
|
+
const card = await est.store.getAgent(sub);
|
|
300
|
+
if (!card || card.revoked || (card.valid_until && Date.parse(card.valid_until) <= Date.now())) return null;
|
|
301
|
+
if (!(await est.store.kvGet('boveda', sub))) return null;
|
|
302
|
+
return card;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function emitir(est, base, cliente) {
|
|
306
|
+
const card = await vigente(est, base.sub);
|
|
307
|
+
if (!card) return err(400, 'invalid_grant', 'this Claude address was revoked or expired; authorize the connector again');
|
|
308
|
+
const acceso = aleatorio(), refresco = aleatorio();
|
|
309
|
+
const doc = { sub: base.sub, root: base.root, client_id: base.client_id, resource: base.resource, scope: ALCANCE };
|
|
310
|
+
const hasta = Math.min(Date.now() + est.remoto.refrescoMs, card.valid_until ? Date.parse(card.valid_until) : Infinity);
|
|
311
|
+
await est.store.kvPut('acceso', sha(acceso), doc, Date.now() + est.remoto.accesoS * 1000);
|
|
312
|
+
await est.store.kvPut('refresco', sha(refresco), doc, hasta);
|
|
313
|
+
// El cliente vive mientras se usa: uno que refresca no debe vencer a los 90 días de registrado.
|
|
314
|
+
await est.store.kvPut('cliente', cliente.client_id, cliente, Date.now() + 90 * DIA);
|
|
315
|
+
return { status: 200, headers: { pragma: 'no-cache' }, body: { access_token: acceso, token_type: 'Bearer', expires_in: est.remoto.accesoS, refresh_token: refresco, scope: ALCANCE } };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function token(est, body) {
|
|
319
|
+
const b = body && typeof body === 'object' ? body : {};
|
|
320
|
+
const cliente = typeof b.client_id === 'string' ? await est.store.kvGet('cliente', b.client_id) : null;
|
|
321
|
+
if (b.grant_type === 'authorization_code') {
|
|
322
|
+
if (!cliente) return err(401, 'invalid_client', 'unknown client_id');
|
|
323
|
+
if (!b.code || !b.code_verifier) return err(400, 'invalid_request', 'code and code_verifier are required');
|
|
324
|
+
const c = await est.store.kvTake('codigo', sha(b.code)); // un solo uso
|
|
325
|
+
if (!c) return err(400, 'invalid_grant', 'the code is invalid, expired or already used');
|
|
326
|
+
if (c.client_id !== b.client_id) return err(400, 'invalid_grant', 'the code was issued to another client');
|
|
327
|
+
if (b.redirect_uri && b.redirect_uri !== c.redirect_uri) return err(400, 'invalid_grant', 'redirect_uri does not match the authorization request');
|
|
328
|
+
if (s256(b.code_verifier) !== c.code_challenge) return err(400, 'invalid_grant', 'PKCE verification failed');
|
|
329
|
+
if (b.resource && !recursoValido(est, b.resource)) return err(400, 'invalid_target', 'token requested for another resource');
|
|
330
|
+
return emitir(est, c, cliente);
|
|
331
|
+
}
|
|
332
|
+
if (b.grant_type === 'refresh_token') {
|
|
333
|
+
if (!cliente) return err(401, 'invalid_client', 'unknown client_id');
|
|
334
|
+
// Rotación: el refresco viejo muere en el mismo paso en que nace el nuevo.
|
|
335
|
+
const r = b.refresh_token ? await est.store.kvTake('refresco', sha(b.refresh_token)) : null;
|
|
336
|
+
if (!r || r.client_id !== b.client_id) return err(400, 'invalid_grant', 'the refresh token is invalid, expired, revoked or already used');
|
|
337
|
+
return emitir(est, r, cliente);
|
|
338
|
+
}
|
|
339
|
+
return err(400, 'unsupported_grant_type', 'use authorization_code or refresh_token');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------- el recurso: validar el token en /mcp ----------
|
|
343
|
+
export async function validarAcceso(est, rx) {
|
|
344
|
+
// El 401 apunta a los metadatos de la MISMA URL que el usuario pegó: Claude exige que coincidan.
|
|
345
|
+
const { prm } = urlsDe(est, RUTA_INVITACION.test(rx.path || '') ? rx.path : '/mcp');
|
|
346
|
+
const negar = (desc) => ({
|
|
347
|
+
ok: false,
|
|
348
|
+
out: {
|
|
349
|
+
status: 401,
|
|
350
|
+
headers: { 'www-authenticate': `Bearer resource_metadata="${prm}", scope="${ALCANCE}"${desc ? `, error="invalid_token", error_description="${desc}"` : ''}` },
|
|
351
|
+
body: { error: desc ? 'invalid_token' : 'unauthorized', error_description: desc || 'this endpoint needs an OAuth access token; see resource_metadata in WWW-Authenticate' },
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
const m = /^Bearer\s+([A-Za-z0-9_-]{20,200})$/.exec(rx.headers.authorization || '');
|
|
355
|
+
if (!m) return negar(null);
|
|
356
|
+
const t = await est.store.kvGet('acceso', sha(m[1]));
|
|
357
|
+
if (!t) return negar('the access token is invalid or expired');
|
|
358
|
+
if (!recursoValido(est, t.resource)) return negar('the token was issued for another resource');
|
|
359
|
+
if (!(await vigente(est, t.sub))) return negar('this Claude address was revoked or expired');
|
|
360
|
+
return { ok: true, token: t };
|
|
361
|
+
}
|