@nyx5/nyx5 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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
+ }
@@ -0,0 +1,266 @@
1
+ // Nyx5/1 — Adaptador x402 (transporte HTTP v2).
2
+ //
3
+ // x402 es el estándar de "402 Payment Required" para agentes: el servidor responde 402 con lo que
4
+ // cuesta el recurso, el cliente vuelve con la prueba de pago, el servidor responde 200 con el
5
+ // recibo de liquidación. Todo el protocolo viaja en CABECERAS, en base64; el cuerpo es asunto de
6
+ // cada implementación.
7
+ //
8
+ // Qué hace este módulo y qué NO hace:
9
+ // SÍ habla el cable de x402 v2: arma `PAYMENT-REQUIRED`, lee `PAYMENT-SIGNATURE`, arma
10
+ // `PAYMENT-RESPONSE`, y declara capacidades en /x402/supported.
11
+ // NO inventa dinero nuevo. El pago sigue ocurriendo en el Libro, con un sobre firmado, como
12
+ // siempre. x402 es el ANUNCIO del precio y el RECIBO de la liquidación, no una vía paralela
13
+ // para mover tokens sin firma (eso rompería el invariante 2).
14
+ //
15
+ // Por qué `nyx5:1` y no `nyx5:<casa>`: CAIP-2 exige `namespace:reference` con
16
+ // namespace [-a-z0-9]{3,8} y reference [-_a-zA-Z0-9]{1,32}. Un dominio lleva puntos, y el punto
17
+ // NO es legal en una reference. Así que la red es el protocolo y la casa viaja en `payTo`, que ya
18
+ // es `agente@dominio`. Poner `nyx5:nyx5-com` sería una reference inventada que nadie sabe deshacer.
19
+ //
20
+ // Precedente de que esto es conforme sin blockchain: `cloudflare:402` en el repo oficial de x402
21
+ // liquida en fiat, firma con Ed25519, no emite hash de transacción y no usa facilitador externo.
22
+ // Ver docs/interop/x402.md, que dice también qué NO reclamamos.
23
+
24
+ export const X402_VERSION = 2;
25
+ export const RED = 'nyx5:1';
26
+ export const ACTIVO = 'NYX'; // el token de la casa: ni contrato ni ISO-4217, y se dice
27
+ export const CAIP2 = /^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}$/;
28
+
29
+ const b64 = (obj) => Buffer.from(JSON.stringify(obj), 'utf8').toString('base64');
30
+ const deB64 = (s) => JSON.parse(Buffer.from(String(s), 'base64').toString('utf8'));
31
+
32
+ // El importe va como CADENA de unidades atómicas. Un number aquí es un defecto: 2^53 y los
33
+ // decimales de coma flotante no son opinables cuando lo que se transporta es plata.
34
+ const monto = (n) => {
35
+ if (!Number.isInteger(n) || n < 0) throw new Error(`x402: amount must be a non-negative integer, got ${n}`);
36
+ return String(n);
37
+ };
38
+
39
+ // ---- lo que el servidor anuncia: 402 + PAYMENT-REQUIRED ----
40
+ export function requisitos({ url, amount, payTo, description, mimeType = 'application/json', flow = 'authorization', method = 'nyx5-envelope', error, serviceName, tags }) {
41
+ if (!url) throw new Error('x402: resource.url is required');
42
+ if (!payTo) throw new Error('x402: payTo is required');
43
+ const pr = {
44
+ x402Version: X402_VERSION,
45
+ error: error || 'PAYMENT-SIGNATURE header is required',
46
+ resource: { url, mimeType, ...(description ? { description } : {}), ...(serviceName ? { serviceName: serviceName.slice(0, 32) } : {}), ...(tags?.length ? { tags: tags.slice(0, 5).map((t) => String(t).slice(0, 32)) } : {}) },
47
+ accepts: [{
48
+ scheme: 'exact',
49
+ network: RED,
50
+ amount: monto(amount),
51
+ asset: ACTIVO,
52
+ payTo,
53
+ maxTimeoutSeconds: 60,
54
+ // `paymentFlow` y `assetTransferMethod` son claves RESERVADAS por el protocolo dentro de
55
+ // `extra`; el resto de `extra` es del scheme. No inventar ahí.
56
+ extra: { paymentFlow: flow, assetTransferMethod: method, house: payTo.split('@')[1] || null },
57
+ }],
58
+ extensions: {},
59
+ };
60
+ validarRequisitos(pr);
61
+ return pr;
62
+ }
63
+
64
+ // Falla cerrado: si el objeto que vamos a publicar no cumple las reglas duras del spec, mejor
65
+ // romper aquí que emitir un 402 que un cliente conforme rechaza sin decir por qué.
66
+ export function validarRequisitos(pr) {
67
+ if (pr.x402Version !== X402_VERSION) throw new Error(`x402: x402Version must be ${X402_VERSION}`);
68
+ if (!pr.resource?.url) throw new Error('x402: resource.url is required');
69
+ if (!Array.isArray(pr.accepts) || !pr.accepts.length) throw new Error('x402: accepts must be a non-empty array');
70
+ for (const a of pr.accepts) {
71
+ for (const campo of ['scheme', 'network', 'amount', 'asset', 'payTo']) if (!a[campo]) throw new Error(`x402: accepts[].${campo} is required`);
72
+ if (typeof a.amount !== 'string' || !/^\d+$/.test(a.amount)) throw new Error('x402: amount must be a string of atomic units');
73
+ if (!CAIP2.test(a.network)) throw new Error(`x402: network "${a.network}" is not a CAIP-2 identifier`);
74
+ if (!Number.isFinite(a.maxTimeoutSeconds)) throw new Error('x402: maxTimeoutSeconds is required');
75
+ }
76
+ return pr;
77
+ }
78
+
79
+ export const cabeceraRequerido = (pr) => b64(validarRequisitos(pr));
80
+
81
+ // ---- lo que el cliente manda: PAYMENT-SIGNATURE ----
82
+ // Devuelve { ok, payload } o { ok:false, code, reason }. 400 (no 402) cuando el sobre x402 está
83
+ // malformado: el spec separa "no pagaste" de "lo que mandaste no se entiende".
84
+ export function leerPago(cabecera) {
85
+ if (!cabecera) return { ok: false, code: 402, reason: 'PAYMENT-SIGNATURE header is required' };
86
+ let p;
87
+ try { p = deB64(cabecera); } catch { return { ok: false, code: 400, reason: 'PAYMENT-SIGNATURE is not base64-encoded JSON' }; }
88
+ if (p?.x402Version !== X402_VERSION) return { ok: false, code: 400, reason: `unsupported x402Version (this house speaks ${X402_VERSION})` };
89
+ if (!p.accepted || typeof p.accepted !== 'object') return { ok: false, code: 400, reason: 'accepted (the chosen PaymentRequirements) is required' };
90
+ if (!p.payload || typeof p.payload !== 'object') return { ok: false, code: 400, reason: 'payload is required' };
91
+ if (p.accepted.network !== RED) return { ok: false, code: 400, reason: `this house settles on ${RED}, not ${p.accepted.network}` };
92
+ return { ok: true, payload: p };
93
+ }
94
+
95
+ // ---- lo que el servidor devuelve al liquidar: PAYMENT-RESPONSE ----
96
+ // `transaction` y `network` son OBLIGATORIOS aunque no haya cadena: cuando no hay hash, va cadena
97
+ // vacía. Aquí sí hay algo mejor que una cadena vacía — el id del asiento del Libro, que cualquiera
98
+ // puede pedir. Es la pieza que un explorador on-chain resuelve por hash.
99
+ export function liquidacion({ success = true, transaction = '', payer, amount, errorReason }) {
100
+ const s = { success: !!success, transaction: String(transaction || ''), network: RED };
101
+ if (payer) s.payer = payer;
102
+ if (amount != null) s.amount = monto(amount);
103
+ if (errorReason) s.errorReason = String(errorReason);
104
+ return s;
105
+ }
106
+ export const cabeceraLiquidacion = (s) => b64(s);
107
+
108
+ // ---- declaración de capacidades (el `GET /supported` del facilitador, auto-alojado) ----
109
+ // La casa es su propio facilitador: verifica y liquida contra su propio Libro. El spec lo permite
110
+ // explícitamente ("or host the endpoints themselves").
111
+ export function soportado(dominio, clavesCasa = []) {
112
+ return {
113
+ x402Version: X402_VERSION,
114
+ kinds: [{ x402Version: X402_VERSION, scheme: 'exact', network: RED, extra: { asset: ACTIVO, house: dominio, assetTransferMethod: 'nyx5-envelope' } }],
115
+ extensions: [],
116
+ signers: { [RED]: clavesCasa },
117
+ };
118
+ }
119
+
120
+ // ---------- red real: USDC sobre una cadena EVM ----------
121
+ // La casa NO custodia nada. En el esquema `exact` el pagador firma una autorización EIP-3009 y el
122
+ // facilitador la difunde: el dinero va del pagador al que cobra, directo, y el facilitador ni
123
+ // siquiera puede cambiar el monto ni el destino. Nosotros sólo anunciamos el precio, preguntamos
124
+ // si la firma sirve, y pedimos que se liquide. Cero criptografía de cadena de nuestro lado.
125
+ //
126
+ // Por eso esto cabe en un módulo sin dependencias: todo lo que hace falta es base64, JSON y fetch.
127
+ export function requisitosEvm({ url, amount, payTo, network, asset, tokenName, tokenVersion, description, flow = 'authorization' }) {
128
+ for (const [k, v] of Object.entries({ url, payTo, network, asset, tokenName, tokenVersion })) {
129
+ if (!v) throw new Error(`x402 evm: falta ${k}`);
130
+ }
131
+ const pr = {
132
+ x402Version: X402_VERSION,
133
+ error: 'PAYMENT-SIGNATURE header is required',
134
+ resource: { url, mimeType: 'application/json', ...(description ? { description } : {}) },
135
+ accepts: [{
136
+ scheme: 'exact', network, amount: monto(amount), asset, payTo, maxTimeoutSeconds: 60,
137
+ // `name` y `version` son el dominio EIP-712 del CONTRATO del token, y cambian entre redes:
138
+ // en Base Sepolia el USDC de Circle se llama "USDC" y en Base mainnet "USD Coin". Ponerlo
139
+ // mal hace que la firma del pagador no valide, y el error no dice por qué.
140
+ extra: { paymentFlow: flow, assetTransferMethod: 'eip3009', name: tokenName, version: tokenVersion },
141
+ }],
142
+ extensions: {},
143
+ };
144
+ validarRequisitos(pr);
145
+ return pr;
146
+ }
147
+
148
+ // Cliente del facilitador. Es todo el "código de cadena" que necesita un servidor de recurso.
149
+ export function facilitador(base, { fetchImpl = globalThis.fetch, timeoutMs = 20_000 } = {}) {
150
+ const raiz = String(base).replace(/\/$/, '');
151
+ const llamar = async (ruta, cuerpo) => {
152
+ const r = await fetchImpl(`${raiz}${ruta}`, {
153
+ method: cuerpo ? 'POST' : 'GET',
154
+ headers: cuerpo ? { 'content-type': 'application/json' } : {},
155
+ body: cuerpo ? JSON.stringify(cuerpo) : undefined,
156
+ signal: AbortSignal.timeout(timeoutMs),
157
+ });
158
+ const txt = await r.text();
159
+ let json; try { json = JSON.parse(txt); } catch { json = { _raw: txt.slice(0, 300) }; }
160
+ return { status: r.status, body: json };
161
+ };
162
+ const sobre = (paymentPayload, paymentRequirements) => ({ x402Version: X402_VERSION, paymentPayload, paymentRequirements });
163
+ return {
164
+ soportado: () => llamar('/supported'),
165
+ // `verify` es SOLO LECTURA por el spec: un isValid:true NO es dinero. Entre verificar y
166
+ // liquidar el saldo puede gastarse. Se usa para rechazar temprano, nunca como garantía.
167
+ verificar: (pago, req) => llamar('/verify', sobre(pago, req)),
168
+ liquidar: (pago, req) => llamar('/settle', sobre(pago, req)),
169
+ };
170
+ }
171
+
172
+ // Clave de deduplicación de un pago. El servidor de referencia de x402 NO deduplica: dos
173
+ // peticiones simultáneas con la MISMA firma pasan las dos por `verify` (que es de solo lectura),
174
+ // ejecutan las dos el trabajo, y sólo una liquida. O sea, entregas dos veces y cobras una.
175
+ // Aquí ya tenemos el candado que hace falta (`markSeenIfNew`, invariante 4); esto sólo nombra la
176
+ // clave. La red pone el nonce justamente para esto: es único por autorización.
177
+ export function claveDePago(pago) {
178
+ const a = pago?.payload?.authorization;
179
+ const red = pago?.accepted?.network;
180
+ if (!red) return null;
181
+ if (a?.nonce) return `x402:${red}:${String(a.nonce).toLowerCase()}`;
182
+ // Sin nonce no hay antirreplay posible: mejor decirlo que fingir que se dedujo algo.
183
+ return null;
184
+ }
185
+
186
+ // Dónde vive el dólar digital en cada red. Los valores de `name`/`version` son el dominio EIP-712
187
+ // del CONTRATO y NO se recuerdan: están leídos del contrato (`name()` y `version()`), porque
188
+ // cambian entre redes y equivocarse hace que ninguna firma valide sin decir por qué.
189
+ // Comprobado el 9-sep-2026 contra ambas cadenas.
190
+ export const TOKEN_USD = Object.freeze({
191
+ 'eip155:1': { asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', name: 'USD Coin', version: '2', decimals: 6, red: 'Ethereum', facilitador: 'https://facilitator.ultravioletadao.xyz' },
192
+ 'eip155:8453': { asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', name: 'USD Coin', version: '2', decimals: 6, red: 'Base', facilitador: 'https://x402.dexter.cash' },
193
+ 'eip155:137': { asset: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', name: 'USD Coin', version: '2', decimals: 6, red: 'Polygon', facilitador: 'https://x402.dexter.cash' },
194
+ 'eip155:42161': { asset: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', name: 'USD Coin', version: '2', decimals: 6, red: 'Arbitrum', facilitador: 'https://x402.dexter.cash' },
195
+ 'eip155:10': { asset: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', name: 'USD Coin', version: '2', decimals: 6, red: 'Optimism', facilitador: 'https://x402.dexter.cash' },
196
+ 'eip155:43114': { asset: '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E', name: 'USD Coin', version: '2', decimals: 6, red: 'Avalanche', facilitador: 'https://x402.dexter.cash' },
197
+ 'eip155:84532': { asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', name: 'USDC', version: '2', decimals: 6, red: 'Base Sepolia', facilitador: 'https://x402.org/facilitator' },
198
+ });
199
+
200
+ // Una billetera declarada en una tarjeta. Es sólo una dirección a la que cobrar: la casa no la
201
+ // controla, no la custodia y no puede mover nada de ella. Falla cerrado ante una red que no
202
+ // conocemos, porque anunciar un precio en una red que no sabemos liquidar es prometer de gratis.
203
+ // Varias billeteras: una por red. El estándar dice que `accepts` es una LISTA de formas de pago
204
+ // aceptables y que el cliente elige, así que ofrecer una sola red es desperdiciar el mecanismo.
205
+ // Se rechazan los duplicados de red: dos precios para la misma red es una ambigüedad, no una opción.
206
+ export function validarBilleteras(ws) {
207
+ if (ws == null) return null;
208
+ const lista = Array.isArray(ws) ? ws : [ws];
209
+ if (!lista.length) return null;
210
+ if (lista.length > 8) throw new Error('at most 8 wallets');
211
+ const vistas = new Set(), out = [];
212
+ for (const w of lista) {
213
+ const v = validarBilletera(w);
214
+ if (!v) continue;
215
+ if (vistas.has(v.network)) throw new Error(`two wallets for the same network (${v.network}); pick one`);
216
+ vistas.add(v.network); out.push(v);
217
+ }
218
+ return out.length ? out : null;
219
+ }
220
+
221
+ export function validarBilletera(w) {
222
+ if (w == null) return null;
223
+ if (typeof w !== 'object' || Array.isArray(w)) throw new Error('wallet must be an object');
224
+ const { network, address } = w;
225
+ if (!TOKEN_USD[network]) throw new Error(`this house cannot settle on ${network}; it knows: ${Object.keys(TOKEN_USD).join(', ')}`);
226
+ if (typeof address !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(address)) throw new Error('wallet.address must be a 0x EVM address');
227
+ return { network, address };
228
+ }
229
+
230
+ // ---------- extensión `bazaar`: quedar catalogado donde un agente busca ----------
231
+ // Un agente no navega: consulta directorios. La extensión `bazaar` deja que un servidor describa
232
+ // cómo se le llama, y el facilitador lo cataloga y lo expone en su `/discovery/resources`.
233
+ //
234
+ // Lo que se declara tiene que ser lo que de verdad se vende. Aquí es la entrega de un sobre en un
235
+ // buzón que cobra: se paga, el sobre entra. Describir algo más grande de lo que hacemos sería
236
+ // exactamente la afirmación gratis que este protocolo existe para encarecer.
237
+ export function bazaarBuzon({ url, direccion }) {
238
+ return {
239
+ info: {
240
+ input: {
241
+ type: 'http', method: 'POST', bodyType: 'json',
242
+ // El sobre va firmado; el cuerpo es el sobre Nyx5 tal cual.
243
+ body: { nyx5: '1', to: [direccion], type: 'message', content: { media: 'text/plain', body: '…' } },
244
+ },
245
+ output: { type: 'json', example: { ok: true, code: 202, accepted: [direccion] } },
246
+ },
247
+ schema: {
248
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
249
+ type: 'object',
250
+ properties: {
251
+ input: {
252
+ type: 'object',
253
+ properties: {
254
+ type: { type: 'string', const: 'http' },
255
+ method: { type: 'string', const: 'POST' },
256
+ bodyType: { type: 'string', const: 'json' },
257
+ body: { type: 'object' },
258
+ },
259
+ required: ['type', 'method', 'body'],
260
+ },
261
+ output: { type: 'object' },
262
+ },
263
+ required: ['input', 'output'],
264
+ },
265
+ };
266
+ }
package/src/version.js ADDED
@@ -0,0 +1,4 @@
1
+ // La versión que el servidor declara de sí mismo. Un literal y no una lectura de package.json
2
+ // porque este módulo también corre en el edge, donde no hay sistema de archivos. Que no se
3
+ // desincronice lo cuida test/documentacion.test.js: si package.json cambia y esto no, falla.
4
+ export const VERSION = '0.7.0';