boosterarena-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/sesion.mjs ADDED
@@ -0,0 +1,245 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // LA PUERTA — cómo un agente en una TERMINAL entra a la cuenta del maestro.
3
+ //
4
+ // El editor entra con el login del navegador; un agente no tiene navegador. Así que se hace
5
+ // lo mismo que hace Claude Code: el CLI abre el navegador, el maestro entra como entra
6
+ // siempre, y el navegador devuelve la llave a un servidor minúsculo que el CLI dejó
7
+ // escuchando en su propia máquina.
8
+ //
9
+ // POR QUÉ ASÍ Y NO CON UNA CLAVE DE API
10
+ //
11
+ // · No hay que hospedar nada. El MCP corre en la máquina del maestro; lo que necesita
12
+ // permiso (guardar el proyecto, subir un archivo, quitar un fondo) va a las mismas
13
+ // funciones de `/api` que ya usa el navegador, con el mismo token. Cero infraestructura
14
+ // nueva, y por tanto cero costo — que importa, porque el MCP va a ser gratis.
15
+ // · El candado del plan YA existe ahí. `functions/api/quitar-fondo.ts` y `openai.ts`
16
+ // comprueban el plan y descuentan cuota contra el JWT. No hay un segundo sistema de
17
+ // permisos que mantener ni que se pueda desincronizar del primero.
18
+ // · Revocar es cerrar la sesión en Supabase. Si alguien pierde el portátil, se corta ahí.
19
+ //
20
+ // ⚠️ PKCE, NO EL FLUJO IMPLÍCITO. El código que vuelve al loopback no sirve de nada sin el
21
+ // `verifier` que solo tiene este proceso, así que otro programa de la misma máquina que
22
+ // espiara el puerto no se llevaría nada. Es lo que manda la RFC 8252 para apps nativas.
23
+ //
24
+ // ⚠️ Y NO SE USA CORREO+CONTRASEÑA AUNQUE SEA LO QUE MÁS USAN (5 de 8 cuentas, medido el
25
+ // 2026-09-07). Un CLI que pide la contraseña enseña a la gente a teclearla en sitios raros,
26
+ // y además tendría que tocarla. El enlace por correo llega a la MISMA cuenta y no expone
27
+ // nada: es el camino que usan `gh` y compañía por la misma razón.
28
+ //
29
+ // node mcp/sesion.mjs entrar # con Google
30
+ // node mcp/sesion.mjs entrar --correo tu@correo.com
31
+ // node mcp/sesion.mjs quien # a nombre de quién estoy
32
+ // node mcp/sesion.mjs salir # borra la credencial de esta máquina
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+ import { createServer } from 'node:http';
35
+ import { spawn } from 'node:child_process';
36
+ import { randomBytes } from 'node:crypto';
37
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
38
+ import { homedir } from 'node:os';
39
+ import { join } from 'node:path';
40
+ import { pathToFileURL } from 'node:url';
41
+ import { createClient } from '@supabase/supabase-js';
42
+
43
+ // Los mismos valores públicos que el navegador. La `publishable` no es un secreto: acaba en el
44
+ // bundle de cualquier app de Supabase, y lo que protege los datos es el RLS.
45
+ const SUPABASE_URL = 'https://kjoadzhtgbrngpppbysh.supabase.co';
46
+ const SUPABASE_KEY = 'sb_publishable_Zkvuw-xVLuns3msI14xH0g_YG5SYo7G';
47
+
48
+ const CARPETA = join(homedir(), '.booster');
49
+ const CREDENCIAL = join(CARPETA, 'credenciales.json');
50
+
51
+ // ── Almacén en memoria ───────────────────────────────────────────────────────
52
+ // `supabase-js` guarda el `code_verifier` de PKCE en el almacén mientras dura el viaje al
53
+ // navegador. En Node no hay `localStorage`, y aquí no hace falta que sobreviva al proceso: el
54
+ // mismo que abre el navegador es el que canjea el código.
55
+ const memoria = new Map();
56
+ const almacen = {
57
+ getItem: (k) => memoria.get(k) ?? null,
58
+ setItem: (k, v) => void memoria.set(k, v),
59
+ removeItem: (k) => void memoria.delete(k),
60
+ };
61
+
62
+ function cliente() {
63
+ return createClient(SUPABASE_URL, SUPABASE_KEY, {
64
+ auth: {
65
+ flowType: 'pkce', // ← lo que hace que el código del loopback no valga solo
66
+ storage: almacen,
67
+ persistSession: true,
68
+ autoRefreshToken: false, // el refresco lo lleva el MCP, no este comando suelto
69
+ detectSessionInUrl: false,
70
+ },
71
+ });
72
+ }
73
+
74
+ // ── La credencial en disco ───────────────────────────────────────────────────
75
+ // Se guarda el `refresh_token`, no el de acceso: el de acceso caduca en una hora y se renueva
76
+ // solo. Este archivo ES una llave, así que va a 600 (solo su dueño), igual que `~/.claude`.
77
+ function guardar(sesion) {
78
+ if (!existsSync(CARPETA)) mkdirSync(CARPETA, { recursive: true, mode: 0o700 });
79
+ const datos = {
80
+ refresh_token: sesion.refresh_token,
81
+ correo: sesion.user?.email ?? null,
82
+ guardado: new Date().toISOString(),
83
+ };
84
+ writeFileSync(CREDENCIAL, JSON.stringify(datos, null, 2), { mode: 0o600 });
85
+ try { chmodSync(CREDENCIAL, 0o600); } catch { /* Windows no siempre deja; el aviso va abajo */ }
86
+ }
87
+
88
+ export function leerCredencial() {
89
+ if (!existsSync(CREDENCIAL)) return null;
90
+ try { return JSON.parse(readFileSync(CREDENCIAL, 'utf8')); } catch { return null; }
91
+ }
92
+
93
+ /** Devuelve un token de acceso FRESCO, o null si no hay sesión. Es lo que usará el MCP. */
94
+ export async function tokenDeAcceso() {
95
+ const cred = leerCredencial();
96
+ if (!cred?.refresh_token) return null;
97
+ const sb = cliente();
98
+ const { data, error } = await sb.auth.refreshSession({ refresh_token: cred.refresh_token });
99
+ if (error || !data.session) return null;
100
+ guardar(data.session); // el refresh token ROTA: si no se reescribe, la próxima vez falla
101
+ return data.session.access_token;
102
+ }
103
+
104
+ // ── Abrir el navegador ───────────────────────────────────────────────────────
105
+ function abrirNavegador(url) {
106
+ const p = process.platform;
107
+ const [cmd, args] = p === 'win32' ? ['cmd', ['/c', 'start', '', url]]
108
+ : p === 'darwin' ? ['open', [url]]
109
+ : ['xdg-open', [url]];
110
+ try { spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); return true; } catch { return false; }
111
+ }
112
+
113
+ export async function entrar(correo) {
114
+ const estado = randomBytes(16).toString('hex');
115
+ // El servidor primero: hay que saber el puerto ANTES de pedirle a Supabase el enlace, porque
116
+ // el puerto va dentro del `redirectTo`.
117
+ const s = createServer(() => {});
118
+ await new Promise((r) => s.listen(0, '127.0.0.1', r));
119
+ const puerto = s.address().port;
120
+ s.close();
121
+ const volverA = `http://127.0.0.1:${puerto}/callback`;
122
+
123
+ const sb = cliente();
124
+ let url;
125
+ if (correo) {
126
+ const { error } = await sb.auth.signInWithOtp({ email: correo, options: { emailRedirectTo: `${volverA}?state=${estado}` } });
127
+ if (error) throw new Error(`No se pudo mandar el enlace: ${error.message}`);
128
+ console.log(`\n 📧 Te mandé un enlace a ${correo}.`);
129
+ console.log(' Ábrelo en este mismo equipo y volverás aquí solo.');
130
+ // Mientras Booster no tenga servidor de correo propio, esto sale con la cara de Supabase.
131
+ // Medido el 2026-09-07: remitente `noreply@mail.app.supabase.io`, en inglés, y un tope de
132
+ // DOS por hora en todo el proyecto. Decirlo por delante evita que acabe en spam y que el
133
+ // maestro escriba diciendo que no le llegó nada.
134
+ console.log('\n ⚠️ De momento el enlace llega firmado por «Supabase» y en inglés, porque');
135
+ console.log(' Booster todavía no tiene servidor de correo propio. Míralo en spam si no');
136
+ console.log(' aparece. Y solo salen 2 por hora: si llevas prisa, entra con Google.\n');
137
+ } else {
138
+ const { data, error } = await sb.auth.signInWithOAuth({
139
+ provider: 'google',
140
+ options: { redirectTo: `${volverA}?state=${estado}`, skipBrowserRedirect: true },
141
+ });
142
+ if (error) throw new Error(`No se pudo preparar el acceso: ${error.message}`);
143
+ url = data.url;
144
+ console.log('\n Abriendo el navegador para que entres…');
145
+ console.log(` Si no se abre solo, pega esto:\n\n ${url}\n`);
146
+ abrirNavegador(url);
147
+ }
148
+
149
+ const codigo = await escuchar(puerto, estado);
150
+ const { data, error } = await sb.auth.exchangeCodeForSession(codigo);
151
+ if (error) throw new Error(`El código no valía: ${error.message}`);
152
+ guardar(data.session);
153
+ console.log(`\n ✓ Dentro como ${data.session.user.email}`);
154
+ console.log(` La credencial queda en ${CREDENCIAL} (solo tuya, permisos 600).`);
155
+ if (process.platform === 'win32') {
156
+ console.log(' ⚠️ En Windows los permisos de archivo no funcionan igual: cualquier programa');
157
+ console.log(' que corra con tu usuario puede leerla. Es la misma regla que ~/.claude.');
158
+ }
159
+ }
160
+
161
+ /** Levanta el loopback en un puerto ya elegido y espera el `?code=`. */
162
+ function escuchar(puerto, estado) {
163
+ return new Promise((resolve, reject) => {
164
+ const s = createServer((req, res) => {
165
+ const u = new URL(req.url, 'http://127.0.0.1');
166
+ if (u.pathname !== '/callback') { res.writeHead(404).end(); return; }
167
+ const pagina = (titulo, cuerpo, ok) => {
168
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
169
+ res.end(`<!doctype html><meta charset="utf-8"><title>${titulo}</title>`
170
+ + '<body style="font:16px/1.5 system-ui;background:#0b1220;color:#e2e8f0;display:grid;'
171
+ + 'place-items:center;height:100vh;margin:0;text-align:center">'
172
+ + `<div><div style="font-size:44px">${ok ? '✓' : '✕'}</div>`
173
+ + `<h1 style="font-size:20px;margin:.4em 0">${titulo}</h1>`
174
+ + `<p style="color:#94a3b8">${cuerpo}</p></div>`);
175
+ };
176
+ if (u.searchParams.get('state') !== estado) {
177
+ pagina('Esa autorización no era para aquí', 'Vuelve a la terminal e inténtalo otra vez.', false);
178
+ return;
179
+ }
180
+ const err = u.searchParams.get('error_description') || u.searchParams.get('error');
181
+ if (err) { pagina('No se pudo entrar', err, false); s.close(); reject(new Error(err)); return; }
182
+ const codigo = u.searchParams.get('code');
183
+ if (!codigo) { pagina('Falta el código', 'Vuelve a la terminal e inténtalo otra vez.', false); return; }
184
+ pagina('Listo', 'Ya puedes cerrar esta pestaña y volver a la terminal.', true);
185
+ s.close();
186
+ resolve(codigo);
187
+ });
188
+ s.on('error', reject);
189
+ s.listen(puerto, '127.0.0.1');
190
+ // Si nadie vuelve en cinco minutos, no dejamos el proceso colgado para siempre.
191
+ setTimeout(() => { s.close(); reject(new Error('Nadie volvió del navegador en 5 minutos.')); }, 5 * 60_000).unref();
192
+ });
193
+ }
194
+
195
+ export async function quien() {
196
+ const cred = leerCredencial();
197
+ if (!cred) { console.log(` No hay sesión en este equipo. Usa: ${YO} entrar`); return; }
198
+ const token = await tokenDeAcceso();
199
+ if (!token) { console.log(` La sesión de ${cred.correo ?? 'este equipo'} ya no vale. Vuelve a entrar.`); return; }
200
+ const r = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
201
+ headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${token}` },
202
+ });
203
+ const u = await r.json();
204
+ console.log(` ✓ ${u.email}`);
205
+ console.log(` id: ${u.id}`);
206
+ console.log(` credencial guardada el ${cred.guardado}`);
207
+ }
208
+
209
+ export function salir() {
210
+ if (existsSync(CREDENCIAL)) { rmSync(CREDENCIAL); console.log(' Credencial borrada de este equipo.'); }
211
+ else console.log(' No había ninguna credencial que borrar.');
212
+ console.log(' (La sesión sigue viva en Supabase: para cortarla de verdad, ciérrala desde el sitio.)');
213
+ }
214
+
215
+ // ── Entrada ──────────────────────────────────────────────────────────────────
216
+ // La ayuda repite el comando TAL Y COMO lo invocaron. Escribir `node mcp/sesion.mjs` a secas
217
+ // mandaba a la gente a una ruta relativa que solo vale desde la carpeta del proyecto: Antonio se
218
+ // lo comió en la primera prueba (2026-09-07) y perdió un intento con un MODULE_NOT_FOUND.
219
+ const YO = `node ${process.argv[1] ?? 'mcp/sesion.mjs'}`;
220
+ const [, , orden, ...resto] = process.argv;
221
+ const correo = resto.includes('--correo') ? resto[resto.indexOf('--correo') + 1] : null;
222
+
223
+ // ⚠️ Solo cuando ESTE archivo es el que se ejecutó. Sin este candado, cualquiera que importara
224
+ // `tokenDeAcceso()` se comía además el menú de ayuda por pantalla — que es justo lo que hizo
225
+ // `proyectos.mjs` la primera vez que se probó.
226
+ // `pathToFileURL` y no una plantilla a mano: en Windows la URL lleva TRES barras
227
+ // (`file:///C:/...`), y compararla con dos hacía que el candado no abriera nunca — el comando
228
+ // se quedaba mudo, sin error. Que es la peor manera de fallar.
229
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
230
+ try {
231
+ if (orden === 'entrar') await entrar(correo);
232
+ else if (orden === 'quien') await quien();
233
+ else if (orden === 'salir') salir();
234
+ else {
235
+ console.log('\n La puerta del MCP de Booster\n');
236
+ console.log(` ${YO} entrar entrar con Google (recomendado)`);
237
+ console.log(` ${YO} entrar --correo tu@correo entrar con un enlace por correo`);
238
+ console.log(` ${YO} quien a nombre de quién estoy`);
239
+ console.log(` ${YO} salir borrar la credencial de aquí\n`);
240
+ }
241
+ } catch (e) {
242
+ console.error(`\n ✕ ${e.message}\n`);
243
+ process.exit(1);
244
+ }
245
+ }
package/subidas.mjs ADDED
@@ -0,0 +1,84 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // SUBIR ARCHIVOS DESDE LA TERMINAL.
3
+ //
4
+ // Es la pieza que le faltaba a la fábrica de Planificatron. Ellos generan FUERA de Booster
5
+ // —las imágenes con Flux en su propia gráfica, la voz con edge-tts— y el resultado entra por
6
+ // aquí. Booster no genera ninguna de las dos cosas (se retiró por costo, y el audio nunca se
7
+ // generó) y esto no lo cambia: la fábrica no le añade capacidades a Booster, le añade un
8
+ // PROVEEDOR DE ARCHIVOS. Un MP3 de un TTS y un MP3 que subió un maestro son el mismo elemento
9
+ // con una URL de R2, y el documento no puede notar la diferencia.
10
+ //
11
+ // Pasa por la MISMA función que usa el navegador (`functions/api/upload.ts`), con el token del
12
+ // maestro: valida el JWT, sanea la extensión y escribe a R2 por binding. Nada nuevo que
13
+ // mantener, y las mismas reglas para los dos caminos.
14
+ //
15
+ // ⚠️ EL NOMBRE DEL ARCHIVO EN R2 ES EL HASH DE SU CONTENIDO. Subir dos veces lo mismo devuelve
16
+ // la MISMA URL y ocupa una sola vez. Para una fábrica que repite la misma ilustración en
17
+ // cientos de clases, eso no es un detalle.
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+ import { readFile, stat } from 'node:fs/promises';
20
+ import { extname, basename } from 'node:path';
21
+
22
+ const SUBIDA = 'https://boosterarena.com/api/upload';
23
+
24
+ /** Lo que el servidor acepta (ver `EXT_OK` en `functions/api/upload.ts`). */
25
+ const TIPOS = {
26
+ webp: 'image/webp', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
27
+ gif: 'image/gif', svg: 'image/svg+xml',
28
+ mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg', aac: 'audio/aac',
29
+ json: 'application/json', lottie: 'application/json',
30
+ };
31
+ const IMAGENES = ['webp', 'png', 'jpg', 'jpeg', 'gif', 'svg'];
32
+ const AUDIOS = ['mp3', 'wav', 'm4a', 'ogg', 'aac'];
33
+
34
+ /**
35
+ * El tope del guardián del peso de las arenas: un PNG/JPG de más de 300 KB es, casi siempre,
36
+ * uno que nadie pasó por `aligerar.mjs`. Aquí no se rechaza —el archivo es del maestro— pero se
37
+ * DICE, porque esto se le descarga al alumno y un agente no tiene ojos para notarlo.
38
+ */
39
+ const AVISO_PESO = 300 * 1024;
40
+
41
+ export async function subirArchivo(token, ruta, familia) {
42
+ let info;
43
+ try {
44
+ info = await stat(ruta);
45
+ } catch {
46
+ throw new Error(`No encuentro el archivo "${ruta}". Dame la ruta completa, y recuerda que `
47
+ + 'tiene que estar en ESTA computadora (la que corre el servidor).');
48
+ }
49
+ if (!info.isFile()) throw new Error(`"${ruta}" no es un archivo.`);
50
+
51
+ const ext = extname(ruta).slice(1).toLowerCase();
52
+ const tipo = TIPOS[ext];
53
+ const permitidas = familia === 'imagen' ? IMAGENES : AUDIOS;
54
+ if (!tipo || !permitidas.includes(ext)) {
55
+ throw new Error(`"${ext || 'sin extensión'}" no vale para ${familia}. Se aceptan: ${permitidas.join(', ')}.`);
56
+ }
57
+
58
+ const cuerpo = await readFile(ruta);
59
+ const r = await fetch(SUBIDA, {
60
+ method: 'POST',
61
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': tipo, 'X-Ext': ext },
62
+ body: cuerpo,
63
+ });
64
+ if (!r.ok) {
65
+ let detalle = String(r.status);
66
+ try { detalle = (await r.json())?.detail || detalle; } catch { /* respuesta no-JSON */ }
67
+ if (r.status === 401) throw new Error('La sesión ya no vale. Corre `node mcp/sesion.mjs entrar`.');
68
+ throw new Error(`No se pudo subir: ${detalle}`);
69
+ }
70
+ const { url } = await r.json();
71
+
72
+ const kb = Math.round(info.size / 1024);
73
+ const pesado = familia === 'imagen' && ['png', 'jpg', 'jpeg'].includes(ext) && info.size > AVISO_PESO;
74
+ return {
75
+ url,
76
+ nombre: basename(ruta),
77
+ kb,
78
+ aviso: pesado
79
+ ? `⚠️ Pesa ${kb} KB y es .${ext}. Esto se le descarga al ALUMNO cuando abra el slide: `
80
+ + 'conviértelo a WebP antes de subirlo y bajará a la décima parte. Se subió igual, pero '
81
+ + 'si vas a repetirlo en muchas clases, arréglalo ahora.'
82
+ : '',
83
+ };
84
+ }