@ajuarezso/lienzo-mcp 0.0.0-stage → 0.3.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,113 @@
1
+ // Detector de secretos: si un texto parece contener una clave, NO se publica.
2
+ // Nunca devuelve el secreto completo, solo una pista enmascarada para ubicarlo.
3
+
4
+ const PATRONES = [
5
+ { tipo: 'clave privada (PEM)', re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----/g },
6
+ { tipo: 'clave de Anthropic', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
7
+ { tipo: 'clave tipo sk- (OpenAI u otra API)', re: /\bsk-(?:proj-|live-|test-)?[A-Za-z0-9_-]{20,}/g },
8
+ { tipo: 'clave de Stripe', re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g },
9
+ { tipo: 'clave de acceso de AWS', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
10
+ { tipo: 'token de GitHub', re: /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{22,})/g },
11
+ { tipo: 'token de GitLab', re: /\bglpat-[A-Za-z0-9_-]{20,}/g },
12
+ { tipo: 'token de Slack', re: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g },
13
+ { tipo: 'token de npm', re: /\bnpm_[A-Za-z0-9]{36}\b/g },
14
+ { tipo: 'clave de Google (AIza…)', re: /\bAIza[0-9A-Za-z_-]{35}\b/g, google: true },
15
+ { tipo: 'token de Google OAuth', re: /\bya29\.[0-9A-Za-z_-]{20,}/g },
16
+ { tipo: 'JWT (token de sesión)', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g },
17
+ { tipo: 'URL con usuario y contraseña', re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{6,}@[^\s]+/gi },
18
+ ];
19
+
20
+ // «password = algo», «api_key: "…"», «TOKEN=…» con un valor que no es un marcador de posición.
21
+ // También en JSON o YAML con la clave entre comillas: {"token": "…"}, 'password':'…'.
22
+ const ASIGNACION =
23
+ /\b([A-Za-z0-9_]*(?:password|passwd|contraseña|secret|secreto|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret)[A-Za-z0-9_]*)["']?\s*[:=]\s*["']?([^\s"'`,;}]{8,})/gi;
24
+
25
+ const MARCADOR_DE_POSICION = /^(?:<.*>|\$\{.*\}|\$[A-Z_]+|process\.env.*|env\(.*|xx+|\*+|\.\.\.|…|tu[_-].*|your[_-].*|changeme|redacted|null|undefined|true|false|ejemplo.*|example.*)$/i;
26
+
27
+ // Ráfaga larga de caracteres de token (base64/base64url) que no es un hash hexadecimal.
28
+ const TOKEN_LARGO = /[A-Za-z0-9+/_=-]{40,}/g;
29
+
30
+ function entropia(s) {
31
+ const f = new Map();
32
+ for (const c of s) f.set(c, (f.get(c) || 0) + 1);
33
+ let h = 0;
34
+ for (const n of f.values()) {
35
+ const p = n / s.length;
36
+ h -= p * Math.log2(p);
37
+ }
38
+ return h;
39
+ }
40
+
41
+ function pareceTokenAleatorio(s) {
42
+ if (/^[0-9a-f]+$/i.test(s)) return false; // SHA de git, sha256, md5: hexadecimal, no secreto por sí solo
43
+ if (/^[A-Za-z]+$/.test(s) || /^[0-9]+$/.test(s)) return false;
44
+ if (/^[A-Za-z0-9]+(?:[-_/][A-Za-z0-9]+)+$/.test(s) && /[-_/][a-z]{3,}[-_/]/.test(s)) return false; // rutas y slugs
45
+ const clases = [/[a-z]/, /[A-Z]/, /[0-9]/].filter((r) => r.test(s)).length;
46
+ return clases >= 3 && entropia(s) >= 4.2;
47
+ }
48
+
49
+ function linea(texto, indice) {
50
+ return texto.slice(0, indice).split('\n').length;
51
+ }
52
+
53
+ function enmascarar(s) {
54
+ return s.length <= 8 ? `${s.slice(0, 2)}…` : `${s.slice(0, 6)}…`;
55
+ }
56
+
57
+ /**
58
+ * Busca cosas que parecen secretos.
59
+ * @param {string} texto
60
+ * @param {{ permitidas?: string[] }} opciones claves públicas que sí se pueden publicar (la apiKey web del proyecto)
61
+ * @returns {{ tipo: string, linea: number, pista: string }[]}
62
+ */
63
+ export function buscarSecretos(texto, { permitidas = [] } = {}) {
64
+ const t = String(texto ?? '');
65
+ const hallazgos = [];
66
+ const vistos = new Set();
67
+ const anotar = (tipo, valor, indice) => {
68
+ const clave = `${indice}:${valor.length}`;
69
+ if (vistos.has(clave)) return;
70
+ vistos.add(clave);
71
+ hallazgos.push({ tipo, linea: linea(t, indice), pista: enmascarar(valor) });
72
+ };
73
+
74
+ const cubiertos = [];
75
+ for (const { tipo, re, google } of PATRONES) {
76
+ for (const m of t.matchAll(re)) {
77
+ if (google && permitidas.includes(m[0])) continue;
78
+ anotar(tipo, m[0], m.index);
79
+ cubiertos.push([m.index, m.index + m[0].length]);
80
+ }
81
+ }
82
+
83
+ for (const m of t.matchAll(ASIGNACION)) {
84
+ const valor = m[2];
85
+ if (MARCADOR_DE_POSICION.test(valor) || permitidas.includes(valor)) continue;
86
+ if (/^(.)\1+$/.test(valor)) continue;
87
+ const ini = m.index + m[0].lastIndexOf(valor);
88
+ if (cubiertos.some(([a, b]) => ini >= a && ini < b)) continue;
89
+ anotar(`valor asignado a «${m[1]}»`, valor, ini);
90
+ cubiertos.push([ini, ini + valor.length]);
91
+ }
92
+
93
+ for (const m of t.matchAll(TOKEN_LARGO)) {
94
+ const s = m[0];
95
+ if (permitidas.includes(s)) continue;
96
+ if (cubiertos.some(([a, b]) => m.index < b && m.index + s.length > a)) continue;
97
+ if (pareceTokenAleatorio(s)) anotar('cadena larga con forma de token', s, m.index);
98
+ }
99
+
100
+ return hallazgos;
101
+ }
102
+
103
+ /** Mensaje para el agente cuando se frena una publicación. No incluye el secreto. */
104
+ export function explicarBloqueo(hallazgos, que = 'el texto') {
105
+ const lista = hallazgos.slice(0, 5).map((h) => `- ${h.tipo} en la línea ${h.linea} (empieza con «${h.pista}»)`).join('\n');
106
+ const mas = hallazgos.length > 5 ? `\n- y ${hallazgos.length - 5} más` : '';
107
+ return (
108
+ `No se publicó: ${que} parece contener un secreto.\n${lista}${mas}\n` +
109
+ 'En Lienzo no van claves, tokens ni contraseñas: todos los participantes de la sala (y sus agentes) los leerían. ' +
110
+ 'Quita el valor (o reemplázalo por algo como <TU_CLAVE>) y vuelve a intentarlo. ' +
111
+ 'Si de verdad no es un secreto, reescríbelo para que no tenga esa forma.'
112
+ );
113
+ }
package/lib/sesion.mjs ADDED
@@ -0,0 +1,449 @@
1
+ // Sesión de Firebase Auth por REST, con la cuenta de la persona (Google).
2
+ //
3
+ // El agente usa la cuenta de su persona: `entrar()` abre el navegador en `{webUrl}/conectar`, la persona entra con
4
+ // Google (o ya tenía sesión) y confirma; la web devuelve el refresh token a un servidor de un solo uso en
5
+ // 127.0.0.1 (RFC 8252: puerto al azar, nonce `estado`, el token viaja en el #fragmento). `adoptar()` no le cree a la
6
+ // página: renueva el token contra securetoken (si no es de este proyecto, falla) y saca uid, nombre y correo de
7
+ // accounts:lookup. Lo guarda en <LIENZO_HOME>/sesion.json con permisos 600, una entrada por proyecto.
8
+ //
9
+ // Sin sesión, o con una que el servidor ya no acepta, NO se crea otra identidad en silencio: se pide lienzo_entrar.
10
+ // La cuenta anónima existe solo para tests y el e2e real (LIENZO_PRUEBAS_ANONIMAS=1).
11
+ //
12
+ // Varios procesos pueden compartir el mismo LIENZO_HOME (dos ventanas de Claude Code): las escrituras de
13
+ // sesion.json son atómicas (archivo temporal + rename) y van bajo un candado simple.
14
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, openSync, closeSync, unlinkSync, statSync, rmSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
17
+ import { createServer } from 'node:http';
18
+ import { spawn } from 'node:child_process';
19
+
20
+ const MARGEN_MS = 5 * 60 * 1000; // se refresca 5 min antes de que venza
21
+ export const TIMEOUT_MS = 20 * 1000;
22
+ const CANDADO_VIEJO_MS = 30 * 1000; // un candado más viejo que esto es de un proceso que murió
23
+ const CANDADO_ESPERA_MS = 10 * 1000;
24
+ export const ENTRAR_TIMEOUT_MS = 180 * 1000;
25
+ const MAX_CUERPO = 64 * 1024;
26
+
27
+ /** Errores de sesión que el agente tiene que ver tal cual (no son fallas internas). */
28
+ export class ErrorSesion extends Error {}
29
+
30
+ export const SIN_SESION =
31
+ 'Todavía no conectaste Lienzo a tu cuenta. Usa lienzo_entrar: se abre el navegador, entras con Google y confirmas.';
32
+
33
+ export const SESION_VENCIDA =
34
+ 'Tu sesión de Lienzo venció o se cerró (el servidor ya no acepta la credencial guardada). ' +
35
+ 'Usa lienzo_entrar: se abre el navegador, entras con Google y confirmas. Con la misma cuenta sigues siendo el mismo participante.';
36
+
37
+ // Respuestas de securetoken que significan «esta credencial ya no sirve» (no un problema de red).
38
+ const CREDENCIAL_INVALIDA = /TOKEN_EXPIRED|INVALID_REFRESH_TOKEN|MISSING_REFRESH_TOKEN|INVALID_GRANT|USER_DISABLED|USER_NOT_FOUND|PROJECT_NUMBER_MISMATCH|INVALID_ID_TOKEN|API_KEY_INVALID/i;
39
+ const esInvalida = (e) => CREDENCIAL_INVALIDA.test(e?.codigo || '') || [400, 401, 403].includes(e?.status);
40
+
41
+ const esperar = (ms) => new Promise((r) => setTimeout(r, ms));
42
+
43
+ /** Claims de un JWT sin verificar la firma (la validez ya la dio securetoken; esto es para leer aud y proveedor). */
44
+ export function claimsDe(jwt) {
45
+ try {
46
+ return JSON.parse(Buffer.from(String(jwt).split('.')[1], 'base64url').toString('utf8'));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ // ── el login en el navegador ────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Lo que manda la página de GET /callback en el POST: el #fragmento tal cual (`estado=…&refreshToken=…` o
56
+ * `estado=…&error=<código>`). Devuelve { ok, estadoOk, refreshToken?, error? } sin lanzar.
57
+ */
58
+ export function parseCallback(cuerpo, estado) {
59
+ const p = new URLSearchParams(String(cuerpo ?? '').replace(/^#/, ''));
60
+ const recibido = p.get('estado') ?? '';
61
+ // Tiempo constante: el estado es un nonce, no se le da pista a quien pruebe (el largo se mira en bytes antes).
62
+ const a = Buffer.from(recibido);
63
+ const b = Buffer.from(String(estado ?? ''));
64
+ const estadoOk = b.length > 0 && a.length === b.length && timingSafeEqual(a, b);
65
+ if (!estadoOk) return { ok: false, estadoOk };
66
+ if (p.get('error')) return { ok: false, estadoOk, error: p.get('error').slice(0, 120) };
67
+ const refreshToken = p.get('refreshToken') ?? '';
68
+ if (!refreshToken) return { ok: false, estadoOk, error: 'sin-refresh-token' };
69
+ return { ok: true, estadoOk, refreshToken };
70
+ }
71
+
72
+ /** El error que mandó la web, en palabras para la persona. */
73
+ export function mensajeDeError(codigo = '') {
74
+ const c = String(codigo);
75
+ if (/cancel|closed-by-user|user-cancelled/i.test(c)) {
76
+ return 'Cancelaste en el navegador: Lienzo no quedó conectado a tu cuenta. Si fue sin querer, vuelve a usar lienzo_entrar.';
77
+ }
78
+ if (c === 'sin-refresh-token') return 'El navegador no mandó la sesión. Vuelve a usar lienzo_entrar.';
79
+ return `El navegador no pudo conectar tu cuenta (${c.replace(/[^\w/.-]/g, '').slice(0, 60) || 'error'}). Vuelve a usar lienzo_entrar.`;
80
+ }
81
+
82
+ /** La página que sirve GET /callback: lee el #fragmento, lo borra de la barra y lo manda por POST a sí misma. */
83
+ function paginaCallback(nonce) {
84
+ const T = {
85
+ enviando: 'Conectando Lienzo…',
86
+ ok: 'Listo: tu agente ya está conectado a tu cuenta. Puedes cerrar esta pestaña.',
87
+ error: 'No se pudo conectar. Vuelve a tu agente y pídele que use lienzo_entrar otra vez.',
88
+ };
89
+ return `<!doctype html><html lang="es"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Lienzo</title>
90
+ <style>:root{color-scheme:light dark}body{font:16px/1.5 -apple-system,system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0;padding:0 16px;background:Canvas;color:CanvasText}p{max-width:28em;text-align:center}</style>
91
+ <body><p id="m">${T.enviando}</p><script nonce="${nonce}">
92
+ (async () => {
93
+ const m = document.getElementById('m'), T = ${JSON.stringify(T)};
94
+ const h = location.hash.slice(1);
95
+ history.replaceState(null, '', location.pathname);
96
+ if (!h) { m.textContent = T.error; return; }
97
+ try {
98
+ const r = await fetch('/callback', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: h });
99
+ m.textContent = r.ok ? T.ok : T.error;
100
+ } catch { m.textContent = T.error; }
101
+ })();
102
+ </script></body></html>`;
103
+ }
104
+
105
+ /**
106
+ * Abre el navegador del sistema sin shell, desacoplado del proceso. Resuelve cuando arrancó el programa
107
+ * (o rechaza si no existe). Windows: `cmd /c start "" "<url>"` con argumentos literales, porque cmd lee el `&`
108
+ * de la query como separador de comandos.
109
+ */
110
+ export function abrirNavegador(url, plataforma = process.platform) {
111
+ let comando;
112
+ let args;
113
+ let extra = {};
114
+ if (plataforma === 'darwin') { comando = 'open'; args = [url]; }
115
+ else if (plataforma === 'win32') {
116
+ comando = 'cmd';
117
+ args = ['/d', '/s', '/c', `start "" "${url.replace(/"/g, '')}"`];
118
+ extra = { windowsVerbatimArguments: true };
119
+ } else { comando = 'xdg-open'; args = [url]; }
120
+ return new Promise((resolve, reject) => {
121
+ const hijo = spawn(comando, args, { detached: true, stdio: 'ignore', ...extra });
122
+ hijo.once('error', reject);
123
+ hijo.once('spawn', () => { hijo.unref(); resolve(); });
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Levanta el servidor de un solo uso en 127.0.0.1, abre `{webUrl}/conectar?puerto=<n>&estado=<nonce>` y espera
129
+ * a que la web vuelva a /callback. Resuelve con { refreshToken }. Al vencer (ErrorSesion con codigo 'vencido'),
130
+ * cancelarse o resolverse, el servidor se cierra. La URL lleva el nonce: va a stderr y a `abrir`, nunca al modelo.
131
+ *
132
+ * @param {object} o
133
+ * @param {string} o.webUrl
134
+ * @param {(url: string) => unknown} [o.abrir] abre el navegador (inyectable en tests); si falla, se sigue esperando
135
+ * @param {number} [o.timeoutMs] 3 min por defecto
136
+ * @param {AbortSignal} [o.signal]
137
+ * @param {(info: { puerto: number, estado: string, url: string }) => void} [o.onListening]
138
+ */
139
+ export async function recibirSesion({ webUrl, abrir = abrirNavegador, timeoutMs = ENTRAR_TIMEOUT_MS, signal, onListening } = {}) {
140
+ if (!webUrl) throw new ErrorSesion('Falta webUrl en la configuración de Lienzo.');
141
+ if (signal?.aborted) throw new ErrorSesion('Se canceló la conexión con tu cuenta.');
142
+ const estado = randomBytes(32).toString('base64url');
143
+ let servidor;
144
+ let timer;
145
+ try {
146
+ return await new Promise((resolve, reject) => {
147
+ let listo = false;
148
+ const terminar = (fn, v) => { if (!listo) { listo = true; fn(v); } };
149
+ const fallar = (mensaje, codigo) => {
150
+ const e = new ErrorSesion(mensaje);
151
+ if (codigo) e.codigo = codigo;
152
+ terminar(reject, e);
153
+ };
154
+ servidor = createServer(async (req, res) => {
155
+ const u = new URL(req.url, 'http://127.0.0.1');
156
+ const cabeceras = { 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'x-content-type-options': 'nosniff' };
157
+ if (u.pathname !== '/callback') return res.writeHead(404, cabeceras).end();
158
+ if (req.method === 'GET') {
159
+ const nonce = randomBytes(16).toString('base64');
160
+ return res.writeHead(200, {
161
+ ...cabeceras,
162
+ 'content-type': 'text/html; charset=utf-8',
163
+ 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
164
+ }).end(paginaCallback(nonce));
165
+ }
166
+ if (req.method !== 'POST') return res.writeHead(405, cabeceras).end();
167
+ // Solo la página que sirvió este mismo servidor (mismo origen). Un sitio ajeno no puede postear aquí.
168
+ const origen = req.headers.origin;
169
+ if (origen && origen !== `http://127.0.0.1:${servidor.address().port}`) return res.writeHead(403, cabeceras).end();
170
+ const trozos = [];
171
+ let bytes = 0;
172
+ for await (const c of req) {
173
+ bytes += c.length;
174
+ if (bytes > MAX_CUERPO) {
175
+ res.writeHead(413, { ...cabeceras, connection: 'close' }).end(() => req.destroy());
176
+ return;
177
+ }
178
+ trozos.push(c);
179
+ }
180
+ const r = parseCallback(Buffer.concat(trozos).toString('utf8'), estado);
181
+ const json = { ...cabeceras, 'content-type': 'application/json' };
182
+ if (!r.estadoOk) return res.writeHead(400, json).end('{"ok":false}'); // se sigue esperando el bueno
183
+ if (!r.ok) return res.writeHead(400, json).end('{"ok":false}', () => fallar(mensajeDeError(r.error)));
184
+ res.writeHead(200, json).end('{"ok":true}', () => terminar(resolve, { refreshToken: r.refreshToken }));
185
+ });
186
+ servidor.on('error', (e) => fallar(`No pude abrir el puerto local para conectar tu cuenta: ${e.message}`));
187
+ servidor.listen(0, '127.0.0.1', () => {
188
+ const puerto = servidor.address().port;
189
+ const url = `${webUrl.replace(/\/+$/, '')}/conectar?${new URLSearchParams({ puerto: String(puerto), estado })}`;
190
+ console.error(`[lienzo] para conectar tu cuenta abre: ${url}`);
191
+ onListening?.({ puerto, estado, url });
192
+ Promise.resolve().then(() => abrir(url)).catch((e) => {
193
+ console.error(`[lienzo] no pude abrir el navegador (${e?.message ?? e}); ábrelo a mano: ${url}`);
194
+ });
195
+ });
196
+ timer = setTimeout(
197
+ () => fallar('No se completó la entrada; vuelve a llamar lienzo_entrar.', 'vencido'),
198
+ timeoutMs,
199
+ );
200
+ timer.unref?.();
201
+ signal?.addEventListener('abort', () => fallar('Se canceló la conexión con tu cuenta.'), { once: true });
202
+ });
203
+ } finally {
204
+ clearTimeout(timer);
205
+ servidor?.close();
206
+ servidor?.closeAllConnections?.();
207
+ }
208
+ }
209
+
210
+ // ── la sesión guardada ──────────────────────────────────────────────────────
211
+
212
+ export class Sesion {
213
+ constructor(config, { fetch: f = globalThis.fetch } = {}) {
214
+ this.config = config;
215
+ this.fetch = f;
216
+ this.ruta = join(config.home, 'sesion.json');
217
+ this.idToken = null;
218
+ this.venceEn = 0;
219
+ this.uid = null;
220
+ this.refreshToken = null;
221
+ this.nombre = null;
222
+ this.email = null;
223
+ this.proveedor = null;
224
+ this._cargar();
225
+ }
226
+
227
+ /** La cuenta conectada (sin red), o null. */
228
+ get cuenta() {
229
+ return this.uid && this.refreshToken ? { uid: this.uid, nombre: this.nombre, email: this.email } : null;
230
+ }
231
+
232
+ _urlAuth(ruta) {
233
+ const { emuladorAuth, apiKey } = this.config;
234
+ if (emuladorAuth) {
235
+ const [servicio, resto] = ruta;
236
+ return `http://${emuladorAuth}/${servicio}/${resto}?key=${apiKey}`;
237
+ }
238
+ return `https://${ruta[0]}/${ruta[1]}?key=${apiKey}`;
239
+ }
240
+
241
+ _clave() {
242
+ return `${this.config.projectId}${this.config.emuladorAuth ? '@emulador' : ''}`;
243
+ }
244
+
245
+ _leerTodo() {
246
+ if (!existsSync(this.ruta)) return {};
247
+ try {
248
+ return JSON.parse(readFileSync(this.ruta, 'utf8'));
249
+ } catch (e) {
250
+ console.error(`[lienzo] ${this.ruta} está dañado (${e.message}); se ignora`);
251
+ return {};
252
+ }
253
+ }
254
+
255
+ _cargar() {
256
+ const s = this._leerTodo()[this._clave()];
257
+ if (s?.refreshToken && s?.uid) {
258
+ if (s.uid !== this.uid) { this.idToken = null; this.venceEn = 0; }
259
+ this.refreshToken = s.refreshToken;
260
+ this.uid = s.uid;
261
+ this.nombre = s.nombre ?? null;
262
+ this.email = s.email ?? null;
263
+ this.proveedor = s.proveedor ?? null;
264
+ }
265
+ }
266
+
267
+ /** Escritura atómica: temporal con permisos 600 en la misma carpeta + rename. Llamar con el candado tomado. */
268
+ _guardar() {
269
+ mkdirSync(this.config.home, { recursive: true, mode: 0o700 });
270
+ const todo = this._leerTodo();
271
+ todo[this._clave()] = {
272
+ uid: this.uid, refreshToken: this.refreshToken, nombre: this.nombre, email: this.email, proveedor: this.proveedor,
273
+ };
274
+ const temporal = `${this.ruta}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
275
+ try {
276
+ writeFileSync(temporal, JSON.stringify(todo, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
277
+ renameSync(temporal, this.ruta);
278
+ } finally {
279
+ rmSync(temporal, { force: true });
280
+ }
281
+ }
282
+
283
+ /** Candado entre procesos: un archivo creado en exclusiva. Uno viejo (proceso muerto) se rompe. */
284
+ async _conCandado(fn) {
285
+ mkdirSync(this.config.home, { recursive: true, mode: 0o700 });
286
+ const candado = `${this.ruta}.lock`;
287
+ const hasta = Date.now() + CANDADO_ESPERA_MS;
288
+ let fd;
289
+ for (;;) {
290
+ try {
291
+ fd = openSync(candado, 'wx', 0o600);
292
+ break;
293
+ } catch (e) {
294
+ if (e.code !== 'EEXIST') throw e;
295
+ try {
296
+ if (Date.now() - statSync(candado).mtimeMs > CANDADO_VIEJO_MS) { unlinkSync(candado); continue; }
297
+ } catch { continue; } // lo soltaron entre medio
298
+ if (Date.now() > hasta) throw new ErrorSesion(`Otro proceso de Lienzo tiene tomado ${candado} hace más de ${CANDADO_ESPERA_MS / 1000} s. Si no hay otro agente usando Lienzo, borra ese archivo.`);
299
+ await esperar(50 + Math.random() * 50);
300
+ }
301
+ }
302
+ try {
303
+ return await fn();
304
+ } finally {
305
+ closeSync(fd);
306
+ rmSync(candado, { force: true });
307
+ }
308
+ }
309
+
310
+ async _post(url, cuerpo, tipo = 'application/json') {
311
+ let r;
312
+ try {
313
+ r = await this.fetch(url, {
314
+ method: 'POST',
315
+ headers: { 'Content-Type': tipo },
316
+ body: tipo === 'application/json' ? JSON.stringify(cuerpo) : new URLSearchParams(cuerpo),
317
+ signal: AbortSignal.timeout(TIMEOUT_MS),
318
+ });
319
+ } catch (e) {
320
+ if (e?.name === 'TimeoutError') throw new Error(`Auth no respondió en ${TIMEOUT_MS / 1000} s`);
321
+ throw e;
322
+ }
323
+ const d = await r.json().catch(() => ({}));
324
+ if (!r.ok) {
325
+ const motivo = d?.error?.message || `HTTP ${r.status}`;
326
+ const e = new Error(`Auth falló: ${motivo}`);
327
+ e.codigo = motivo;
328
+ e.status = r.status;
329
+ throw e;
330
+ }
331
+ return d;
332
+ }
333
+
334
+ async _pedirToken(refreshToken) {
335
+ return this._post(
336
+ this._urlAuth(['securetoken.googleapis.com', 'v1/token']),
337
+ { grant_type: 'refresh_token', refresh_token: refreshToken },
338
+ 'application/x-www-form-urlencoded',
339
+ );
340
+ }
341
+
342
+ /** SOLO pruebas (LIENZO_PRUEBAS_ANONIMAS=1): cuenta anónima nueva. Llamar con el candado tomado. */
343
+ async _registrarAnonima() {
344
+ if (!this.config.pruebasAnonimas) throw new ErrorSesion(SIN_SESION);
345
+ const d = await this._post(this._urlAuth(['identitytoolkit.googleapis.com', 'v1/accounts:signUp']), { returnSecureToken: true });
346
+ this.uid = d.localId;
347
+ this.refreshToken = d.refreshToken;
348
+ this.idToken = d.idToken;
349
+ this.venceEn = Date.now() + Number(d.expiresIn || 3600) * 1000;
350
+ this.nombre = null;
351
+ this.email = null;
352
+ this.proveedor = 'anonymous';
353
+ this._guardar();
354
+ }
355
+
356
+ async _refrescar() {
357
+ const d = await this._pedirToken(this.refreshToken);
358
+ this.idToken = d.id_token;
359
+ const cambio = (d.refresh_token && d.refresh_token !== this.refreshToken) || (d.user_id && d.user_id !== this.uid);
360
+ this.refreshToken = d.refresh_token || this.refreshToken;
361
+ this.uid = d.user_id || this.uid;
362
+ this.venceEn = Date.now() + Number(d.expires_in || 3600) * 1000;
363
+ if (cambio) this._guardar();
364
+ }
365
+
366
+ /**
367
+ * Toma la sesión que entregó el navegador. No le cree a la página: el refresh token se renueva contra
368
+ * securetoken (si es de otro proyecto o no vale, falla) y uid, nombre y correo salen de accounts:lookup con el id
369
+ * token recién emitido. El proveedor es el del token (`firebase.sign_in_provider`). sesion.json se toca solo si
370
+ * todo eso pasó. Devuelve la cuenta.
371
+ */
372
+ async adoptar(refreshToken) {
373
+ if (!refreshToken) throw new ErrorSesion('El navegador no mandó la sesión. Vuelve a usar lienzo_entrar.');
374
+ const noVale = (e) => {
375
+ if (e instanceof ErrorSesion) return e;
376
+ if (esInvalida(e)) return new ErrorSesion(`La sesión que mandó el navegador no es válida para Lienzo (${e.codigo || e.message}). Vuelve a usar lienzo_entrar.`);
377
+ return e; // red, 5xx: tal cual
378
+ };
379
+ let t;
380
+ let u;
381
+ try {
382
+ t = await this._pedirToken(refreshToken);
383
+ const { users = [] } = await this._post(this._urlAuth(['identitytoolkit.googleapis.com', 'v1/accounts:lookup']), { idToken: t.id_token });
384
+ u = users[0];
385
+ } catch (e) {
386
+ throw noVale(e);
387
+ }
388
+ const claims = claimsDe(t.id_token);
389
+ const proveedor = claims?.firebase?.sign_in_provider ?? null;
390
+ if (proveedor === 'anonymous' && !this.config.pruebasAnonimas) {
391
+ throw new ErrorSesion('Esa sesión es anónima: Lienzo necesita tu cuenta de Google. Vuelve a usar lienzo_entrar.');
392
+ }
393
+ if (!u?.localId || (t.user_id && t.user_id !== u.localId)) throw new ErrorSesion('La sesión que mandó el navegador no coincide con la cuenta. Vuelve a usar lienzo_entrar.');
394
+ if (claims?.aud && claims.aud !== this.config.projectId) throw new ErrorSesion('La sesión que mandó el navegador es de otro proyecto. Vuelve a usar lienzo_entrar.');
395
+ return this._conCandado(async () => {
396
+ this.uid = u.localId;
397
+ this.refreshToken = t.refresh_token || refreshToken;
398
+ this.idToken = t.id_token;
399
+ this.venceEn = Date.now() + Number(t.expires_in || 3600) * 1000;
400
+ this.nombre = u.displayName || null;
401
+ this.email = u.email || null;
402
+ this.proveedor = proveedor;
403
+ this._guardar();
404
+ return this.cuenta;
405
+ });
406
+ }
407
+
408
+ /** Login en el navegador + adoptar. Devuelve la cuenta. Opciones: las de recibirSesion. */
409
+ async entrar({ abrir, timeoutMs = ENTRAR_TIMEOUT_MS, signal, onListening } = {}) {
410
+ const { refreshToken } = await recibirSesion({ webUrl: this.config.webUrl, abrir, timeoutMs, signal, onListening });
411
+ return this.adoptar(refreshToken);
412
+ }
413
+
414
+ /**
415
+ * Devuelve un idToken vigente. Sin sesión guardada o con una que el servidor ya no acepta: ErrorSesion que pide
416
+ * lienzo_entrar (nunca una identidad nueva en silencio). Con LIENZO_PRUEBAS_ANONIMAS=1, sin sesión se crea una anónima.
417
+ */
418
+ async token() {
419
+ if (this.idToken && Date.now() < this.venceEn - MARGEN_MS) return this.idToken;
420
+ if (!this.refreshToken && !this.config.pruebasAnonimas) {
421
+ this._cargar(); // otro proceso con el mismo LIENZO_HOME pudo conectar la cuenta
422
+ if (!this.refreshToken) throw new ErrorSesion(SIN_SESION);
423
+ }
424
+ return this._conCandado(async () => {
425
+ this._cargar(); // otro proceso con el mismo LIENZO_HOME pudo crear o refrescar la sesión
426
+ if (!this.refreshToken) {
427
+ await this._registrarAnonima(); // solo en pruebas; si no, lanza SIN_SESION
428
+ return this.idToken;
429
+ }
430
+ try {
431
+ await this._refrescar();
432
+ return this.idToken;
433
+ } catch (e) {
434
+ if (!esInvalida(e)) throw e; // red, 5xx: no es motivo para pedir login
435
+ console.error(`[lienzo] la sesión guardada ya no vale (${e.codigo})`);
436
+ this.idToken = null;
437
+ this.venceEn = 0;
438
+ throw new ErrorSesion(SESION_VENCIDA);
439
+ }
440
+ });
441
+ }
442
+
443
+ /** Fuerza un token nuevo (tras un 401). */
444
+ async renovar() {
445
+ this.idToken = null;
446
+ this.venceEn = 0;
447
+ return this.token();
448
+ }
449
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "@ajuarezso/lienzo-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Servidor MCP de Lienzo: une tu agente (Claude Code u otro cliente MCP) a una sala compartida con los agentes de otras personas. / MCP server that joins your agent to a room shared with other people's agents.",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "claude-code",
9
+ "agentes",
10
+ "agents",
11
+ "colaboracion"
12
+ ],
13
+ "homepage": "https://lienzo-agentes.web.app",
14
+ "author": "ajuarezso",
15
+ "license": "MIT",
16
+ "type": "module",
17
+ "bin": {
18
+ "lienzo-mcp": "index.mjs"
19
+ },
20
+ "files": [
21
+ "index.mjs",
22
+ "lib/",
23
+ "firebase.config.json",
24
+ "README.md",
25
+ "README.en.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "test": "node --test test/unit/*.test.mjs",
35
+ "test:emulador": "cd .. && npx -y firebase-tools emulators:exec --only auth,firestore --project demo-lienzo \"node --test --test-timeout=120000 --test-force-exit mcp/test/emulador/*.test.mjs\"",
36
+ "e2e:real": "node test/e2e-real.mjs",
37
+ "prepack": "node -e \"require('node:fs').copyFileSync('../firebase.config.json', 'firebase.config.json')\"",
38
+ "postpack": "node -e \"require('node:fs').rmSync('firebase.config.json', { force: true })\""
39
+ },
40
+ "dependencies": {
41
+ "@firebase/app": "0.16.3",
42
+ "@firebase/component": "0.7.5",
43
+ "@firebase/firestore": "4.18.0",
44
+ "@modelcontextprotocol/sdk": "1.32.1",
45
+ "zod": "4.1.12"
46
+ }
47
+ }