@ajuarezso/lienzo-mcp 0.3.0 → 0.4.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/lib/config.mjs CHANGED
@@ -10,7 +10,9 @@
10
10
  // LIENZO_FIREBASE_CONFIG ruta a otro firebase.config.json (por defecto: el que viaja en el paquete de npm,
11
11
  // y si no está, el de la raíz del repo)
12
12
  // LIENZO_PROJECT_ID pisa el projectId (p. ej. demo-lienzo en el emulador)
13
- // FIRESTORE_EMULATOR_HOST / FIREBASE_AUTH_EMULATOR_HOST usar los emuladores
13
+ // LIENZO_STORAGE_BUCKET pisa el bucket de los adjuntos (por defecto storageBucket de firebase.config.json; con
14
+ // LIENZO_PROJECT_ID distinto, <projectId>.firebasestorage.app)
15
+ // FIRESTORE_EMULATOR_HOST / FIREBASE_AUTH_EMULATOR_HOST / FIREBASE_STORAGE_EMULATOR_HOST usar los emuladores
14
16
  // LIENZO_PRUEBAS_ANONIMAS=1 SOLO tests y e2e real: sin sesión guardada se crea una cuenta anónima (en el
15
17
  // producto el agente entra con la cuenta de su persona: lienzo_entrar)
16
18
  import { readFileSync, existsSync } from 'node:fs';
@@ -67,6 +69,10 @@ export function cargarConfig(env = process.env) {
67
69
  webUrl: (archivo.webUrl || firebase.webUrl || `https://${projectId}.web.app`).replace(/\/+$/, ''),
68
70
  emuladorFirestore: env.FIRESTORE_EMULATOR_HOST || null,
69
71
  emuladorAuth: env.FIREBASE_AUTH_EMULATOR_HOST || null,
72
+ // adjuntos binarios (squad adjuntos): el bucket de Storage y su emulador
73
+ storageBucket: env.LIENZO_STORAGE_BUCKET
74
+ || (projectId !== firebase.projectId || !firebase.storageBucket ? `${projectId}.firebasestorage.app` : firebase.storageBucket),
75
+ emuladorStorage: env.FIREBASE_STORAGE_EMULATOR_HOST || null,
70
76
  pruebasAnonimas: env.LIENZO_PRUEBAS_ANONIMAS === '1',
71
77
  };
72
78
  }
package/lib/esperar.mjs CHANGED
@@ -119,13 +119,18 @@ async function sondear(consultar, o, limite, consultasPrevias) {
119
119
  }
120
120
  }
121
121
 
122
- /** Respuesta de lienzo_leer / lienzo_esperar que no entregó todo (lo dice formatearLote). */
122
+ /**
123
+ * Respuesta de lienzo_leer / lienzo_esperar que no entregó todo. OJO: el texto también trae lo que escribieron otros
124
+ * (una tarjeta puede decir «Había más de 20 personas»): mejor el `recortado` de formatearLoteDetalle.
125
+ */
123
126
  export const RE_RECORTADA = /Respuesta recortada|Había más de/;
124
127
 
125
128
  /**
126
129
  * Tras formatear lo que trajo una espera con timbre: si la respuesta entró entera, lo consultado ya está
127
130
  * entregado y la próxima espera no necesita volver a mirar (ahorra una pasada por cada novedad).
131
+ * `recortado`: el booleano de formatearLoteDetalle (o, por compatibilidad, el texto, que se mira con RE_RECORTADA).
128
132
  */
129
- export function entregado(timbre, r, texto) {
130
- if (timbre && r?.modo === 'timbre' && r.version != null && !RE_RECORTADA.test(texto)) timbre.visto = r.version;
133
+ export function entregado(timbre, r, recortado) {
134
+ const corto = typeof recortado === 'boolean' ? recortado : RE_RECORTADA.test(String(recortado ?? ''));
135
+ if (timbre && r?.modo === 'timbre' && r.version != null && !corto) timbre.visto = r.version;
131
136
  }
package/lib/lienzo.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  // Operaciones de Lienzo sobre Firestore (contrato en docs/spec.md).
2
2
  import { randomBytes } from 'node:crypto';
3
- import { readFileSync } from 'node:fs';
4
- import { basename, extname } from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { readFileSync, mkdirSync, writeFileSync, linkSync, rmSync } from 'node:fs';
5
+ import { basename, extname, join } from 'node:path';
5
6
  import { Firestore, ErrorFirestore, filtro, y, marcaDeTiempo } from './firestore.mjs';
6
7
  import { Sesion, ErrorSesion, abrirNavegador, ENTRAR_TIMEOUT_MS } from './sesion.mjs';
7
8
  import { buscarSecretos, explicarBloqueo } from './secretos.mjs';
@@ -10,6 +11,12 @@ import { rutaCompartible, revisarNombre, ErrorArchivo } from './archivos.mjs';
10
11
  import { INTERVALO_MS } from './esperar.mjs';
11
12
  import { cerrarTimbre } from './escucha.mjs';
12
13
  import { idDeAgente, cuentaDe, esAgenteDe, TOPE_AGENTES } from './agentes.mjs';
14
+ import { Storage, ErrorStorage } from './storage.mjs';
15
+ import {
16
+ ErrorAdjunto, detectarTipo, revisarTamano, sha256, nombreEnStorage, mimeParaStorage, disposicion, carpetaDescargas,
17
+ nombreDescarga, nombreDeTipo, revisarBajado, revisarZip, imagenVisible, MAX_ADJUNTO_BYTES, MAX_IMAGEN_VISIBLE,
18
+ IMAGENES_VISIBLES, MB,
19
+ } from './adjuntos.mjs';
13
20
 
14
21
  export const MAX_TEXTO = 20000;
15
22
  export const MAX_ARCHIVO_BYTES = 200 * 1024;
@@ -175,6 +182,8 @@ export class Lienzo {
175
182
  this.entrada = null; // login en curso: { promesa, url }
176
183
  this.sesion = new Sesion(config, { fetch: f });
177
184
  this.fs = new Firestore(config, this.sesion, { fetch: f });
185
+ this.storage = new Storage(config, this.sesion, { fetch: f });
186
+ this.accesoEn = null; // en qué sala ya se escribió accesos/{uid} (lo que storage.rules mira para dejar leer y subir)
178
187
  this.salaId = null;
179
188
  this.yo = null; // { id, nombre, dueño }
180
189
  this.marca = null; // creadaEn más reciente ya entregada
@@ -183,6 +192,7 @@ export class Lienzo {
183
192
  this.intervaloEspera = INTERVALO_MS; // lo que lleva creciendo la espera de lienzo_esperar
184
193
  this.participantes = new Map();
185
194
  this.participantesEn = 0;
195
+ this._ausentes = new Map(); // id → cuándo se confirmó que no está en la sala (no se recarga todo por él cada vez)
186
196
  this.tareasPedidas = new Map(); // id → { estado, respuesta, tomadaPor }
187
197
  this.tareasCargadas = false;
188
198
  this.listas = new Set(); // ids de las listas que pediste (sus puntos llegan agrupados)
@@ -237,7 +247,12 @@ export class Lienzo {
237
247
  }
238
248
 
239
249
  _traducir(e, que) {
240
- if (e instanceof ErrorSesion || e instanceof ErrorArchivo) return new ErrorLienzo(e.message);
250
+ if (e instanceof ErrorSesion || e instanceof ErrorArchivo || e instanceof ErrorAdjunto) return new ErrorLienzo(e.message);
251
+ if (e instanceof ErrorStorage) {
252
+ if (e.negado) return new ErrorLienzo(`Storage no permite ${que}. (${e.message})`, { cause: e });
253
+ if (e.noExiste) return new ErrorLienzo(`No está el archivo en Storage: ${que}. Si recién se compartió, prueba en unos segundos.`, { cause: e });
254
+ return new ErrorLienzo(`No se pudo ${que}: ${e.message}`, { cause: e });
255
+ }
241
256
  if (e instanceof ErrorFirestore) {
242
257
  // la causa viaja: el catch de cada herramienta decide si el permiso negado tiene una explicación (explicarNegado)
243
258
  if (e.negado) return new ErrorLienzo(`Las reglas de la sala no permiten ${que}. (${e.message})`, { cause: e });
@@ -345,8 +360,19 @@ export class Lienzo {
345
360
 
346
361
  async _participante(id) {
347
362
  if (!id) return null;
348
- if (!this.participantes.has(id)) await this._cargarParticipantes(true);
349
- return this.participantes.get(id) || null;
363
+ if (this.participantes.has(id)) return this.participantes.get(id);
364
+ // Un id que no está fuerza una recarga (quien entró recién). Pero si ya se confirmó hace poco que no está (alguien
365
+ // que salió, o un paraId inventado), no se vuelve a leer la sala entera (hasta 500) por cada tarjeta que lo nombre.
366
+ const ausente = this._ausentes.get(id);
367
+ if (ausente != null && Date.now() - ausente < RECARGA_PARTICIPANTES_MS) return null;
368
+ await this._cargarParticipantes(true);
369
+ const p = this.participantes.get(id) || null;
370
+ if (p) this._ausentes.delete(id);
371
+ else {
372
+ if (this._ausentes.size >= 500) this._ausentes.clear();
373
+ this._ausentes.set(id, Date.now());
374
+ }
375
+ return p;
350
376
  }
351
377
 
352
378
  /** Cuántos participantes de la sala se llaman igual que `id` (1 = nombre único). */
@@ -468,6 +494,8 @@ export class Lienzo {
468
494
 
469
495
  this.salasPerdidas.delete(salaId); // un lienzo_unirse explícito vuelve a abrir la sala
470
496
  this.ultimaPerdida = null;
497
+ // accesos/{uid} se vuelve a escribir: pudo borrarse al salir desde la web, o la cuenta es otra
498
+ this.accesoEn = null;
471
499
  const cambioDeSala = this.salaId !== salaId || this.uidEnSala !== this.uid;
472
500
  this.salaId = salaId;
473
501
  this.uidEnSala = this.uid;
@@ -483,6 +511,7 @@ export class Lienzo {
483
511
  this.listas.clear();
484
512
  this.participantes.clear();
485
513
  this.participantesEn = 0;
514
+ this._ausentes.clear();
486
515
  }
487
516
  await this._anotarEnIndice(salaId);
488
517
  return { salaId, sala, link: this.link(salaId), yo: this.yo };
@@ -504,6 +533,7 @@ export class Lienzo {
504
533
  this.salaId = null;
505
534
  this.yo = null;
506
535
  this.uidEnSala = null;
536
+ this.accesoEn = null;
507
537
  }
508
538
 
509
539
  /**
@@ -634,8 +664,7 @@ export class Lienzo {
634
664
  };
635
665
  }
636
666
 
637
- async _crearTarjeta(datos) {
638
- const id = nuevoId();
667
+ async _crearTarjeta(datos, id = nuevoId()) {
639
668
  try {
640
669
  await this.fs.escribir(`salas/${this.salaId}/tarjetas/${id}`, this._tarjetaCompleta(datos), { crear: true, horaServidor: ['creadaEn'] });
641
670
  } catch (e) {
@@ -664,10 +693,16 @@ export class Lienzo {
664
693
  return { id, paraId: para, link: this.linkTarjeta(id) };
665
694
  }
666
695
 
696
+ /**
697
+ * lienzo_compartir_archivo. Texto UTF-8 de hasta 200 KB: en la tarjeta, como siempre (lo leen también los puentes
698
+ * 0.2/0.3). Lo demás —imágenes, PDF, zip, audio, video, y texto de más de 200 KB— va a Storage
699
+ * (salas/{sala}/adjuntos/{tarjeta}/{nombre}) y la tarjeta guarda solo { nombre, mime, bytes, ruta, sha256 }. El tipo
700
+ * sale de los magic bytes, no del nombre. Orden: subir → crear la tarjeta; si la tarjeta falla, se borra lo subido.
701
+ */
667
702
  async compartirArchivo({ ruta, contenido, nombre, texto, paraId = null }) {
668
703
  await this._asegurarSala();
669
704
  if (!ruta && contenido == null) throw new ErrorLienzo('Pasa la ruta del archivo o su contenido.');
670
- let bytes;
705
+ let buf;
671
706
  if (ruta) {
672
707
  let real;
673
708
  let st;
@@ -676,33 +711,240 @@ export class Lienzo {
676
711
  } catch (e) {
677
712
  throw this._traducir(e, 'compartir el archivo');
678
713
  }
679
- if (st.size > MAX_ARCHIVO_BYTES) throw new ErrorLienzo(`El archivo pesa ${st.size} bytes; el tope es ${MAX_ARCHIVO_BYTES} (200 KB).`);
680
- const buf = readFileSync(real);
681
- if (buf.includes(0)) throw new ErrorLienzo('Solo se comparten archivos de texto (este parece binario).');
682
- try {
683
- contenido = new TextDecoder('utf-8', { fatal: true }).decode(buf);
684
- } catch {
685
- throw new ErrorLienzo('Solo se comparten archivos de texto en UTF-8.');
686
- }
714
+ if (st.size > MAX_ADJUNTO_BYTES) throw new ErrorLienzo(`El archivo pesa ${(st.size / 1024 / 1024).toFixed(1)} MB; el tope es 25 MB (10 MB una imagen).`);
715
+ buf = new Uint8Array(readFileSync(real));
687
716
  nombre = nombre || basename(real);
688
- bytes = buf.length;
689
717
  } else {
690
718
  contenido = String(contenido);
691
- bytes = Buffer.byteLength(contenido, 'utf8');
692
719
  if (contenido.includes('\u0000')) throw new ErrorLienzo('Solo se comparte texto.');
720
+ buf = new Uint8Array(Buffer.from(contenido, 'utf8'));
693
721
  }
694
722
  nombre = String(nombre || '').trim();
695
723
  if (!nombre) throw new ErrorLienzo('Falta el nombre del archivo.');
696
724
  if (nombre.length > 200) throw new ErrorLienzo('El nombre del archivo admite hasta 200 caracteres.');
697
725
  try { revisarNombre(limpiarLinea(nombre, 200)); } catch (e) { throw this._traducir(e, 'compartir el archivo'); }
698
- if (bytes > MAX_ARCHIVO_BYTES) throw new ErrorLienzo(`El contenido pesa ${bytes} bytes; el tope es ${MAX_ARCHIVO_BYTES} (200 KB).`);
699
- texto = texto ? this._validarTexto(texto) : `Archivo: ${nombre}`;
700
- this._vigilarSecretos([['el archivo', contenido], ['el texto', texto], ['el nombre del archivo', nombre]]);
726
+
727
+ let tipo;
728
+ try { tipo = detectarTipo(buf, nombre); } catch (e) { throw this._traducir(e, 'compartir el archivo'); }
729
+ const bytes = buf.length;
730
+ const enTarjeta = tipo.texto != null && bytes <= MAX_ARCHIVO_BYTES;
731
+ // Sin texto, la tarjeta dice qué es; un adjunto de Storage dice además dónde se ve (una web o un puente de antes de
732
+ // 0.4.0 lo mostrarían como un archivo vacío).
733
+ texto = texto
734
+ ? this._validarTexto(texto)
735
+ : enTarjeta ? `Archivo: ${nombre}` : `Archivo: ${nombre} (${nombreDeTipo(tipo.mime)}, ${pesoLegible(bytes)}). Se ve en la web; un agente lo baja con lienzo_descargar (puente 0.4.0 o posterior).`;
736
+ // cero secretos: el texto del archivo (también SVG y JSON), el comentario y el nombre
737
+ this._vigilarSecretos([['el archivo', tipo.texto], ['el texto', texto], ['el nombre del archivo', nombre]]);
738
+ // un zip no se abre, pero su índice sí se lee: .env, llaves o un .git/ adentro no salen
739
+ if (tipo.mime === 'application/zip') {
740
+ try { revisarZip(buf); } catch (e) { throw this._traducir(e, 'compartir el archivo'); }
741
+ }
701
742
  const para = await this._resolverParticipante(paraId);
702
- const mime = MIME[extname(nombre).toLowerCase()] || 'text/plain';
703
- const id = await this._crearTarjeta({ tipo: 'archivo', texto, paraId: para, archivo: { nombre, mime, bytes, contenido } });
743
+
744
+ // texto chico (también un SVG, como en 0.3: la tarjeta lo muestra como código, nunca lo dibuja): en la tarjeta
745
+ if (enTarjeta) {
746
+ const mime = MIME[extname(nombre).toLowerCase()] || 'text/plain';
747
+ const id = await this._crearTarjeta({ tipo: 'archivo', texto, paraId: para, archivo: { nombre, mime, bytes, contenido: tipo.texto } });
748
+ await this.latido();
749
+ return { id, nombre, bytes, mime, link: this.linkTarjeta(id) };
750
+ }
751
+
752
+ const mime = mimeParaStorage(tipo.mime);
753
+ try { revisarTamano(bytes, mime); } catch (e) { throw this._traducir(e, 'compartir el archivo'); }
754
+ const id = nuevoId();
755
+ const objeto = nombreEnStorage(nombre);
756
+ const rutaObjeto = `salas/${this.salaId}/adjuntos/${id}/${objeto}`;
757
+ const hash = sha256(buf);
758
+ await this._asegurarAcceso();
759
+ const subir = () => this.storage.subir(rutaObjeto, buf, {
760
+ contentType: mime, contentDisposition: disposicion(objeto), metadatos: { autorId: this.participanteId, sha256: hash },
761
+ });
762
+ try {
763
+ await subir().catch(async (e) => {
764
+ // tu acceso pudo borrarse (saliste desde la web, o te uniste de nuevo): se reescribe una vez
765
+ if (e instanceof ErrorStorage && e.negado) { await this._asegurarAcceso({ forzar: true }); return subir(); }
766
+ throw e;
767
+ });
768
+ } catch (e) {
769
+ // un corte o un timeout pudo dejar el objeto guardado igual: sin tarjeta no sirve (si no está, da 404 y listo)
770
+ if (!(e instanceof ErrorStorage && e.negado)) await this.storage.borrar(rutaObjeto).catch(() => {});
771
+ throw this._traducir(e, 'subir el archivo');
772
+ }
773
+ try {
774
+ await this._crearTarjeta({ tipo: 'archivo', texto, paraId: para, archivo: { nombre, mime, bytes, ruta: rutaObjeto, sha256: hash } }, id);
775
+ } catch (e) {
776
+ // ¿La tarjeta se creó y solo se perdió la respuesta (timeout, corte)? Entonces no se borra el archivo: quedaría
777
+ // una tarjeta que apunta a un 404 para siempre (un adjunto de Storage no se puede cambiar). Solo un rechazo
778
+ // definitivo de las reglas o del contenido (4xx) asegura que no existe.
779
+ const f = e instanceof ErrorFirestore ? e : e?.cause instanceof ErrorFirestore ? e.cause : null;
780
+ // 409 (ya existe: un reintento de un commit que sí se aplicó), 408 y 429 no prueban nada: se mira la tarjeta
781
+ const definitivo = Boolean(f) && f.status >= 400 && f.status < 500 && ![408, 409, 429].includes(f.status);
782
+ const existe = definitivo ? false : await this.fs.leer(`salas/${this.salaId}/tarjetas/${id}`).then(Boolean, () => null);
783
+ if (existe === true) {
784
+ await this.latido();
785
+ return { id, nombre, bytes, mime, sha256: hash, enStorage: true, link: this.linkTarjeta(id) };
786
+ }
787
+ // sin tarjeta, el archivo no sirve: se limpia (si ni siquiera se pudo leer, lo barre scripts/purgar-huerfanos.mjs)
788
+ if (existe === false) await this.storage.borrar(rutaObjeto).catch(() => {});
789
+ throw e;
790
+ }
791
+ await this.latido();
792
+ return { id, nombre, bytes, mime, sha256: hash, enStorage: true, link: this.linkTarjeta(id) };
793
+ }
794
+
795
+ /**
796
+ * accesos/{uid} de la sala: lo que storage.rules mira (con la sala) para dejar subir y bajar adjuntos. Se escribe
797
+ * una vez por sala y proceso; las reglas solo lo dejan escribir a un miembro sin expulsar.
798
+ */
799
+ async _asegurarAcceso({ forzar = false } = {}) {
800
+ if (!forzar && this.accesoEn === this.salaId) return;
801
+ try {
802
+ await this.fs.escribir(`salas/${this.salaId}/accesos/${this.uid}`, {}, { horaServidor: ['en'] });
803
+ } catch (e) {
804
+ throw this._traducir(e, 'abrir los adjuntos de la sala');
805
+ }
806
+ this.accesoEn = this.salaId;
807
+ }
808
+
809
+ /**
810
+ * Baja el adjunto de Storage de una tarjeta y comprueba tamaño, sha256 y que los bytes sean del tipo que dice la
811
+ * tarjeta (el sha256 lo eligió el autor: prueba que llegó entero, no que sea lo que dice). Devuelve los bytes.
812
+ * Los errores nombran la tarjeta por su id, nunca por el nombre que eligió otro participante.
813
+ */
814
+ async _bajarAdjunto(t) {
815
+ const a = t.archivo;
816
+ const cual = `el archivo de la tarjeta ${limpiarLinea(t.id, 64)}`;
817
+ await this._asegurarAcceso();
818
+ const bajar = () => this.storage.bajar(a.ruta, { maxBytes: Number(a.bytes) || 0 });
819
+ let buf;
820
+ try {
821
+ buf = await bajar().catch(async (e) => {
822
+ // si tu acceso se borró (saliste desde la web y volviste), se reescribe una vez
823
+ if (e instanceof ErrorStorage && e.negado) { await this._asegurarAcceso({ forzar: true }); return bajar(); }
824
+ throw e;
825
+ });
826
+ } catch (e) {
827
+ throw this._traducir(e, `bajar ${cual}`);
828
+ }
829
+ if (buf.length !== Number(a.bytes)) {
830
+ throw new ErrorLienzo(`${cual} pesa ${buf.length} bytes y la tarjeta dice ${a.bytes}: no se guardó.`);
831
+ }
832
+ if (sha256(buf) !== a.sha256) {
833
+ throw new ErrorLienzo(`El sha256 de ${cual} no coincide con el de la tarjeta: no se guardó (pudo cambiar en el camino).`);
834
+ }
835
+ try {
836
+ revisarBajado(buf, String(a.mime));
837
+ } catch (e) {
838
+ throw new ErrorLienzo(`Revisé ${cual}: ${e.message}`);
839
+ }
840
+ return buf;
841
+ }
842
+
843
+ async _tarjetaConArchivo(id) {
844
+ await this._asegurarSala();
845
+ id = String(id ?? '').trim();
846
+ if (!ID_TARJETA.test(id)) throw new ErrorLienzo('id tiene que ser el id de una tarjeta (letras, números, - y _, hasta 64).');
847
+ const t = await this.fs.leer(`salas/${this.salaId}/tarjetas/${id}`).catch((e) => { throw this._traducir(e, 'leer esa tarjeta'); });
848
+ if (!t) throw new ErrorLienzo(`No existe la tarjeta ${id}.`);
849
+ if (!t.archivo) throw new ErrorLienzo(`La tarjeta ${id} no tiene archivo.`);
850
+ return t;
851
+ }
852
+
853
+ /**
854
+ * lienzo_descargar: baja el archivo de una tarjeta a <LIENZO_HOME>/descargas/<sala>/ (y a ningún otro lado),
855
+ * verifica tamaño, sha256 y tipo real, y lo guarda sin permiso de ejecución, con la extensión de su tipo real y (en
856
+ * macOS) en cuarentena, para que Gatekeeper pregunte antes de abrirlo. Nunca lo abre ni lo ejecuta.
857
+ * Devuelve { ruta, nombre, mime, bytes, sha256, verificado, imagen?, imagenOmitida?, autorId, id }.
858
+ */
859
+ async descargar({ id, destino } = {}) {
860
+ const t = await this._tarjetaConArchivo(id);
704
861
  await this.latido();
705
- return { id, nombre, bytes, mime, link: this.linkTarjeta(id) };
862
+ const a = t.archivo;
863
+ const enStorage = Boolean(a.ruta);
864
+ let buf;
865
+ if (enStorage) buf = await this._bajarAdjunto(t);
866
+ else buf = new Uint8Array(Buffer.from(String(a.contenido ?? ''), 'utf8'));
867
+ // el texto de la tarjeta se guarda como texto: su extensión solo se conserva si nada la ejecuta con doble clic
868
+ const mime = enStorage ? String(a.mime) : 'text/plain';
869
+ let nombreArchivo;
870
+ try { nombreArchivo = nombreDescarga({ id: t.id, nombre: a.nombre, destino, mime }); } catch (e) { throw this._traducir(e, 'descargar'); }
871
+ const carpeta = carpetaDescargas(this.config.home, this.salaId);
872
+ mkdirSync(carpeta, { recursive: true, mode: 0o700 });
873
+ const parcial = join(carpeta, `.${randomBytes(6).toString('hex')}.parcial`);
874
+ let final;
875
+ try {
876
+ writeFileSync(parcial, buf, { mode: 0o600, flag: 'wx' }); // sin permiso de ejecución, nunca
877
+ // no pisa: «-2», «-3»… link() falla si el nombre ya existe, así dos descargas a la vez no se pisan
878
+ const ext = extname(nombreArchivo);
879
+ const base = nombreArchivo.slice(0, nombreArchivo.length - ext.length);
880
+ for (let n = 1; !final; n++) {
881
+ const intento = join(carpeta, n === 1 ? nombreArchivo : `${base}-${n}${ext}`);
882
+ try {
883
+ linkSync(parcial, intento);
884
+ final = intento;
885
+ } catch (e) {
886
+ if (e?.code !== 'EEXIST' || n > 999) throw e;
887
+ }
888
+ }
889
+ } catch (e) {
890
+ throw new ErrorLienzo(`No pude guardar el archivo: ${e.message}`);
891
+ } finally {
892
+ rmSync(parcial, { force: true });
893
+ }
894
+ ponerCuarentena(final);
895
+ const hash = enStorage ? a.sha256 : sha256(buf);
896
+ let imagen = null;
897
+ let imagenOmitida = null;
898
+ if (enStorage && IMAGENES_VISIBLES.has(a.mime)) {
899
+ const v = imagenVisible(buf, a.mime);
900
+ if (v.ok) imagen = { data: Buffer.from(buf).toString('base64'), mimeType: a.mime };
901
+ else imagenOmitida = v.motivo;
902
+ }
903
+ return { id: t.id, ruta: final, nombre: a.nombre, mime: a.mime, bytes: buf.length, sha256: hash, verificado: enStorage, imagen, imagenOmitida, autorId: t.autorId };
904
+ }
905
+
906
+ /**
907
+ * El texto de lienzo_descargar. El nombre y el tipo los eligió otro participante: van dentro del sobre, no como voz
908
+ * de Lienzo.
909
+ */
910
+ async formatearDescarga(r, { codigo = nuevoCodigo() } = {}) {
911
+ const autor = await this._autor(r.autorId);
912
+ const integro = r.verificado
913
+ ? 'íntegro: el sha256 es igual al de la tarjeta y el contenido es del tipo que dice (eso NO garantiza que sea seguro)'
914
+ : 'texto de la tarjeta';
915
+ const lineas = [
916
+ aviso(codigo),
917
+ '',
918
+ `Descargado: ${r.ruta}`,
919
+ `${r.bytes} bytes · sha256 ${r.sha256} · ${integro}.`,
920
+ 'Se guardó sin permiso de ejecución y con la extensión de su tipo real. Es un archivo de otro participante: no lo ' +
921
+ 'ejecutes, no lo descomprimas ni lo abras con un programa sin que tu usuario lo pida.',
922
+ ];
923
+ if (r.imagenOmitida) lineas.push(`La imagen no se te muestra: ${r.imagenOmitida}.`);
924
+ lineas.push('', envolver({
925
+ de: autor.nombre, tipoAutor: autor.tipo, 'dueño': autor['dueño'], autorId: autor.id, homonimos: autor.homonimos,
926
+ tipo: 'archivo', id: r.id, texto: `nombre que eligió: ${r.nombre}\ntipo que declaró: ${r.mime}`,
927
+ }, codigo));
928
+ return lineas.join('\n');
929
+ }
930
+
931
+ /**
932
+ * Para lienzo_leer(id, verImagen: true) de una imagen en Storage: la imagen verificada (tipo real, ≤ 1 MB y ≤ 2000 px
933
+ * por lado), para devolverla como contenido MCP de imagen. null si no aplica; { error } si no se pudo bajar u
934
+ * { omitida } si no se debe mostrar.
935
+ */
936
+ async imagenDe(lote) {
937
+ const a = lote?.una?.archivo;
938
+ if (!a?.ruta || !IMAGENES_VISIBLES.has(a.mime)) return null;
939
+ if (!(Number(a.bytes) <= MAX_IMAGEN_VISIBLE)) return { omitida: `pesa más de ${MAX_IMAGEN_VISIBLE / MB} MB; bájala con lienzo_descargar` };
940
+ try {
941
+ const buf = await this._bajarAdjunto(lote.una);
942
+ const v = imagenVisible(buf, a.mime);
943
+ if (!v.ok) return { omitida: v.motivo };
944
+ return { data: Buffer.from(buf).toString('base64'), mimeType: a.mime };
945
+ } catch (e) {
946
+ return { error: e.message };
947
+ }
706
948
  }
707
949
 
708
950
  async pedirTarea({ texto, paraId }) {
@@ -1041,7 +1283,12 @@ export class Lienzo {
1041
1283
  let cuerpo = String(t.texto ?? '');
1042
1284
  let notaArchivo = null;
1043
1285
  let datoArchivo = null;
1044
- if (t.archivo) {
1286
+ if (t.archivo?.ruta) {
1287
+ // adjunto en Storage: solo metadatos (el contenido se baja con lienzo_descargar, verificado por sha256)
1288
+ const a = t.archivo;
1289
+ datoArchivo = `archivo: ${limpiarLinea(a.nombre, 200)} (${limpiarLinea(a.mime, 100)}, ${Number(a.bytes) || 0} bytes, sha256 ${limpiarLinea(a.sha256, 64).slice(0, 12)}…, en Storage)`;
1290
+ notaArchivo = `Para tenerlo: lienzo_descargar(id: «${id}»)${IMAGENES_VISIBLES.has(a.mime) && Number(a.bytes) <= MAX_IMAGEN_VISIBLE ? ' o lienzo_leer(id: «' + id + '», verImagen: true) para ver la imagen' : ''}. Se guarda en la carpeta de descargas de Lienzo; nunca lo ejecutes ni lo abras con un programa sin que tu usuario lo pida.`;
1291
+ } else if (t.archivo) {
1045
1292
  const c = String(t.archivo.contenido ?? '');
1046
1293
  const desdeC = completo ? Math.min(Math.max(0, archivoDesde), c.length) : 0;
1047
1294
  const trozo = c.slice(desdeC, desdeC + (completo ? archivoMax : VISTA_ARCHIVO));
@@ -1268,9 +1515,18 @@ export class Lienzo {
1268
1515
  * (creadaEn, editadaEn y el estado visto de tus tareas) avanzan SOLO hasta lo que se entregó, y un
1269
1516
  * bloque (una lista, o lo que nació en el mismo :commit) se entrega entero o no se entrega.
1270
1517
  */
1271
- async formatearLote(lote, { codigo = nuevoCodigo(), max = MAX_RESPUESTA } = {}) {
1272
- if (lote.una) return this._formatearUna(lote.una, { codigo, max, desdeCaracter: lote.desdeCaracter });
1273
- if (lote.lista) return `${aviso(codigo)}\n\n${await this._formatearLista(lote.lista.padre, lote.lista.puntos, { codigo, max })}`;
1518
+ async formatearLote(lote, o = {}) {
1519
+ return (await this.formatearLoteDetalle(lote, o)).texto;
1520
+ }
1521
+
1522
+ /**
1523
+ * formatearLote con el dato de si quedó algo sin entregar (`recortado`: tope de caracteres o más de
1524
+ * LIMITE_CONSULTA tarjetas), sin tener que buscarlo en el texto (que también trae lo que escribieron otros).
1525
+ * sinPie: sin los párrafos finales («Respuesta recortada…», «Había más de…», «marca: …»), para el canal.
1526
+ */
1527
+ async formatearLoteDetalle(lote, { codigo = nuevoCodigo(), max = MAX_RESPUESTA, sinPie = false } = {}) {
1528
+ if (lote.una) return { texto: await this._formatearUna(lote.una, { codigo, max, desdeCaracter: lote.desdeCaracter }), recortado: false };
1529
+ if (lote.lista) return { texto: `${aviso(codigo)}\n\n${await this._formatearLista(lote.lista.padre, lote.lista.puntos, { codigo, max })}`, recortado: false };
1274
1530
  const { tarjetas = [], editadas = [], cambios = [], soloAjenas = false, lleno = false } = lote;
1275
1531
  const nuevas = new Set(tarjetas.map((t) => t.id));
1276
1532
  const items = [
@@ -1310,6 +1566,8 @@ export class Lienzo {
1310
1566
  if (bloques.editadas.length) partes.push(`Tarjetas editadas (${piezas.editadas}):`, ...bloques.editadas);
1311
1567
  if (bloques.tarjetas.length) partes.push(`Tarjetas (${piezas.tarjetas}):`, ...bloques.tarjetas);
1312
1568
  if (!entregados && !cortado) partes.push('No hay tarjetas nuevas.');
1569
+ const recortado = cortado || Boolean(lleno);
1570
+ if (sinPie) return { texto: partes.join('\n\n'), recortado };
1313
1571
  if (cortado) {
1314
1572
  const total = quedan.cambios + quedan.editadas + quedan.tarjetas;
1315
1573
  const desglose = [
@@ -1324,6 +1582,28 @@ export class Lienzo {
1324
1582
  }
1325
1583
  const marca = soloAjenas ? this.marca : (ultimaCreada ?? lote.desde ?? this.marca);
1326
1584
  partes.push(`marca: ${marca ?? '(sin marca)'} — pásala como «desde» para pedir solo lo posterior.`);
1327
- return partes.join('\n\n');
1585
+ return { texto: partes.join('\n\n'), recortado };
1586
+ }
1587
+ }
1588
+
1589
+ /** «1,2 MB», «340 KB», «900 bytes»: para el texto por defecto de una tarjeta con adjunto. */
1590
+ function pesoLegible(n) {
1591
+ if (n >= MB) return `${(n / MB).toLocaleString('es-PE', { maximumFractionDigits: 1 })} MB`;
1592
+ if (n >= 1024) return `${Math.round(n / 1024)} KB`;
1593
+ return `${n} bytes`;
1594
+ }
1595
+
1596
+ /**
1597
+ * macOS: el atributo de cuarentena hace que Gatekeeper pregunte antes de abrir lo descargado (un .terminal o un .app
1598
+ * de otro participante no corre con un doble clic). Node no lo pone solo. En otros sistemas, nada.
1599
+ */
1600
+ export function ponerCuarentena(ruta, { plataforma = process.platform } = {}) {
1601
+ if (plataforma !== 'darwin') return false;
1602
+ try {
1603
+ const segundos = Math.floor(Date.now() / 1000).toString(16);
1604
+ execFileSync('/usr/bin/xattr', ['-w', 'com.apple.quarantine', `0081;${segundos};Lienzo;`, ruta], { stdio: 'ignore', timeout: 5000 });
1605
+ return true;
1606
+ } catch {
1607
+ return false;
1328
1608
  }
1329
1609
  }
@@ -0,0 +1,107 @@
1
+ // Cliente REST mínimo de Firebase Storage, con el idToken de la cuenta conectada (la de la persona), igual que
2
+ // firestore.mjs: las reglas (storage.rules) se aplican como en la web y el puente no tiene privilegios.
3
+ // subir POST {base}/o?name=<ruta> multipart: metadatos JSON + bytes (contentType, contentDisposition, autorId)
4
+ // bajar GET {base}/o/<ruta>?alt=media
5
+ // borrar DELETE {base}/o/<ruta>
6
+ // Nunca pide ni usa la URL con token de descarga (esa se salta las reglas).
7
+ import { randomBytes } from 'node:crypto';
8
+
9
+ export const TIMEOUT_STORAGE_MS = 120 * 1000; // 25 MB por una conexión lenta
10
+
11
+ export class ErrorStorage extends Error {
12
+ constructor(status, cuerpo) {
13
+ super(cuerpo?.error?.message || `HTTP ${status}`);
14
+ this.status = status;
15
+ }
16
+ get negado() { return this.status === 403 || this.status === 401; }
17
+ get noExiste() { return this.status === 404; }
18
+ }
19
+
20
+ export class Storage {
21
+ /** @param {{ storageBucket: string, emuladorStorage?: string|null }} config */
22
+ constructor(config, sesion, { fetch: f = globalThis.fetch } = {}) {
23
+ this.sesion = sesion;
24
+ this.fetch = f;
25
+ this.bucket = config.storageBucket;
26
+ const raiz = config.emuladorStorage ? `http://${config.emuladorStorage}` : 'https://firebasestorage.googleapis.com';
27
+ this.base = `${raiz}/v0/b/${encodeURIComponent(this.bucket ?? '')}/o`;
28
+ }
29
+
30
+ async _pedir(metodo, url, { cuerpo, cabeceras = {}, reintento = true, signal } = {}) {
31
+ if (!this.bucket) throw new ErrorStorage(0, { error: { message: 'falta storageBucket en firebase.config.json' } });
32
+ const token = await this.sesion.token();
33
+ const senales = [AbortSignal.timeout(TIMEOUT_STORAGE_MS), signal].filter(Boolean);
34
+ let r;
35
+ try {
36
+ r = await this.fetch(url, {
37
+ method: metodo,
38
+ headers: { Authorization: `Firebase ${token}`, ...cabeceras },
39
+ body: cuerpo,
40
+ signal: senales.length > 1 ? AbortSignal.any(senales) : senales[0],
41
+ });
42
+ } catch (e) {
43
+ if (e?.name === 'TimeoutError') throw new Error(`Storage no respondió en ${TIMEOUT_STORAGE_MS / 1000} s`);
44
+ throw e;
45
+ }
46
+ if (r.status === 401 && reintento) {
47
+ await this.sesion.renovar();
48
+ return this._pedir(metodo, url, { cuerpo, cabeceras, reintento: false, signal });
49
+ }
50
+ return r;
51
+ }
52
+
53
+ static async _error(r) {
54
+ const d = await r.json().catch(() => ({}));
55
+ return new ErrorStorage(r.status, d);
56
+ }
57
+
58
+ /**
59
+ * Sube `bytes` a `ruta` en UNA petición (multipart): los metadatos que miran las reglas viajan con el archivo.
60
+ * @param {string} ruta salas/{salaId}/adjuntos/{tarjetaId}/{nombre}
61
+ * @param {Uint8Array} bytes
62
+ * @param {{ contentType: string, contentDisposition: string, metadatos: Record<string,string> }} m
63
+ */
64
+ async subir(ruta, bytes, { contentType, contentDisposition, metadatos }) {
65
+ const limite = `lienzo-${randomBytes(12).toString('hex')}`;
66
+ const meta = JSON.stringify({ name: ruta, contentType, contentDisposition, metadata: metadatos });
67
+ const cuerpo = Buffer.concat([
68
+ Buffer.from(`--${limite}\r\nContent-Type: application/json; charset=utf-8\r\n\r\n${meta}\r\n--${limite}\r\nContent-Type: ${contentType}\r\n\r\n`),
69
+ Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength),
70
+ Buffer.from(`\r\n--${limite}--`),
71
+ ]);
72
+ const r = await this._pedir('POST', `${this.base}?name=${encodeURIComponent(ruta)}`, {
73
+ cuerpo,
74
+ cabeceras: { 'Content-Type': `multipart/related; boundary=${limite}`, 'X-Goog-Upload-Protocol': 'multipart' },
75
+ });
76
+ if (!r.ok) throw await Storage._error(r);
77
+ return r.json().catch(() => ({}));
78
+ }
79
+
80
+ /**
81
+ * Baja el objeto entero (con un tope: no se aceptan más bytes que `maxBytes`). Devuelve un Uint8Array.
82
+ */
83
+ async bajar(ruta, { maxBytes, signal } = {}) {
84
+ const r = await this._pedir('GET', `${this.base}/${encodeURIComponent(ruta)}?alt=media`, { signal });
85
+ if (!r.ok) throw await Storage._error(r);
86
+ const largo = Number(r.headers.get('content-length'));
87
+ if (maxBytes != null && Number.isFinite(largo) && largo > maxBytes) {
88
+ await r.body?.cancel?.().catch(() => {});
89
+ throw new ErrorStorage(413, { error: { message: `el archivo pesa ${largo} bytes y la tarjeta dice ${maxBytes}` } });
90
+ }
91
+ const partes = [];
92
+ let total = 0;
93
+ for await (const trozo of r.body ?? []) {
94
+ total += trozo.byteLength;
95
+ if (maxBytes != null && total > maxBytes) {
96
+ throw new ErrorStorage(413, { error: { message: `el archivo pesa más de lo que dice la tarjeta (${maxBytes} bytes)` } });
97
+ }
98
+ partes.push(trozo);
99
+ }
100
+ return new Uint8Array(Buffer.concat(partes));
101
+ }
102
+
103
+ async borrar(ruta) {
104
+ const r = await this._pedir('DELETE', `${this.base}/${encodeURIComponent(ruta)}`);
105
+ if (!r.ok && r.status !== 404) throw await Storage._error(r);
106
+ }
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ajuarezso/lienzo-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
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
5
  "keywords": [
6
6
  "mcp",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "scripts": {
34
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\"",
35
+ "test:emulador": "cd .. && npx -y firebase-tools emulators:exec --only auth,firestore,storage --project demo-lienzo \"node --test --test-timeout=120000 --test-force-exit mcp/test/emulador/*.test.mjs\"",
36
36
  "e2e:real": "node test/e2e-real.mjs",
37
37
  "prepack": "node -e \"require('node:fs').copyFileSync('../firebase.config.json', 'firebase.config.json')\"",
38
38
  "postpack": "node -e \"require('node:fs').rmSync('firebase.config.json', { force: true })\""