Files
unibus/dev/issues/0002-media-v2.md
T
agent bcd02716d5 docs(issues): encolar 0002 (media v2), 0003 (descentralización HA), 0004 (hardening seguridad)
Specs de los tres issues siguientes del bus, derivados de esta sesión:
- 0002 media v2: chunking, mimetype, GC del object store, exponer en clientes.
- 0003 descentralización/HA: cluster NATS magnus+homer (R1→R3), control plane
  SQLite→JetStream KV, quorum, failover. Tercer nodo = homer (141.94.69.66).
- 0004 hardening: cierra los hallazgos de la auditoría red-team (report 0004):
  DoS pre-auth, fail-open, autorización por pertenencia, ACL NATS, TLS control plane.
2026-06-07 14:04:33 +02:00

7.8 KiB

issue, title, status, created, domain, scope, depends_on
issue title status created domain scope depends_on
0002 Media v2 — archivos grandes (chunking), metadata, GC del object store, exponer en clientes spec 2026-06-07 media unibus (pkg/blobstore, pkg/frame, pkg/client, pkg/membership) + clientes (mobile binding, gateway web, unibots) 0001 (la auth firmada del control plane debe cubrir /blobs antes de exponer media)

Objetivo

El envío de archivos (imágenes, audio, vídeo) ya funciona en v1, pero con límites que lo hacen inviable para vídeo grande y poco usable para los clientes. Este issue lleva la media a un estado de producción: archivos grandes por chunks, metadata de tipo/nombre, recolección de basura del object store, y exposición en los frontends.

Contexto — cómo funciona media v1 (hoy)

PublishMedia(roomID, data []byte) cifra el archivo entero con la clave de la room (SealAEAD), lo sube entero al object store (pkg/blobstore, content-addressed por hash) vía el control plane (POST /blobs), y publica por el bus solo una referencia frame.BlobRef{Hash, Nonce, Size}. FetchMedia baja el ciphertext por hash (GET /blobs/{hash}) y lo descifra. El binario nunca viaja por NATS; el bus solo lleva la referencia. El object store guarda solo ciphertext (E2E real). Es correcto y simple, pero:

Limitación v1 Consecuencia
Todo el archivo en RAM (cifra y sube de una vez) imágenes/audio OK; vídeo grande (cientos MB/GB) revienta memoria
BlobRef solo lleva hash+nonce+size el receptor no sabe mimetype/filename; no puede renderizar bien
Sin resumable si falla la subida de un archivo grande, reempezar de cero
Object store sin GC blobs content-addressed crecen indefinidamente, sin refcount ni TTL
mobile/ solo expone Publish (texto) no se puede enviar una foto desde el móvil
Gateway web sin endpoints de media la SPA no sube/baja archivos

Fuera de alcance de este issue (sería otro): streaming en vivo (videollamada, audio en tiempo real) — eso no es modelo blob, requiere WebRTC señalizado por el bus.

Diseño

Pieza 1 — Chunking de archivos grandes

Partir el archivo en chunks de tamaño fijo (propuesta: 4 MB), cifrar cada chunk de forma independiente con la clave de la room (nonce por chunk), y subir cada chunk como un blob propio (content-addressed). La referencia pasa de un solo blob a un manifiesto de chunks.

  • frame.BlobRef evoluciona (de forma compatible) a soportar lista de chunks:
    BlobRef{
      Hash    string        // hash del manifiesto (o del blob único si no hay chunks)
      Nonce   []byte        // nonce del manifiesto / del blob único
      Size    int64         // tamaño total en claro
      Chunks  []ChunkRef    // vacío en archivos pequeños (camino v1 intacto)
    }
    ChunkRef{ Hash string; Nonce []byte; Size int64 }  // por chunk cifrado
    
  • PublishMediaStream(roomID string, r io.Reader, meta MediaMeta) (BlobRef, error): lee del io.Reader en chunks (no carga el archivo entero en RAM), cifra y sube cada chunk, y construye el manifiesto. El PublishMedia([]byte) v1 se mantiene como atajo para archivos pequeños (sin chunks).
  • FetchMediaStream(roomID, BlobRef) (io.ReadCloser, error): baja y descifra chunks bajo demanda, exponiendo un io.Reader (descarga progresiva, no todo en RAM).
  • Subida/descarga de chunks en paralelo acotado (p. ej. 4 a la vez) para throughput.

Pieza 2 — Metadata (mimetype + filename)

Añadir a BlobRef (o a un sidecar cifrado) los campos Mime string y Name string, de modo que el receptor sepa renderizar (imagen inline, reproductor de audio/vídeo, icono de descarga). Como Name/Mime pueden ser sensibles, viajan dentro del campo cifrado del frame, no en claro. Detección de mimetype por sniffing del primer chunk + extensión.

Pieza 3 — Garbage collection del object store

Hoy los blobs no se borran nunca. Introducir refcount o barrido:

  • Refcount por referencia: una tabla blob_refs(hash, room_id, msg_id) en el control plane; al expirar un mensaje de una room efímera o al purgar historial de una room persistente, decrementar y borrar el blob cuando llega a cero.
  • Alternativa TTL: blobs de rooms efímeras con TTL; blobs de rooms persistentes viven mientras viva el mensaje en JetStream.
  • Comando membershipd blobs gc [--dry-run] para barrido manual + métrica de espacio. Debe ser idempotente y seguro (nunca borrar un blob aún referenciado).

Pieza 4 — Exponer media en los clientes

  • Binding móvil (mobile/unibus.go): SendFile(roomID, path, mime) y FetchFile(roomID, frameJSON) -> path (escribe a un archivo local del sandbox de la app y devuelve la ruta; no pasa []byte grandes por el puente gomobile).
  • Gateway web (playground/server.go): POST /api/media (multipart, streaming al store) y GET /api/media/{room}/{hash} (descarga descifrada con los headers Content-Type/Content-Disposition derivados de la metadata).
  • unibots: una tool send_file para que un bot pueda adjuntar archivos.

Decisiones técnicas

Decisión Elegido Alternativa Razón
Tamaño de chunk 4 MB 1 MB / 16 MB equilibrio RAM vs overhead de manifiesto
Cifrado por chunk nonce independiente por chunk, misma clave de room re-cifrar todo permite descarga/borrado parcial y paralelismo
Metadata sensible dentro del frame cifrado en claro en BlobRef filename/mime pueden filtrar info
GC refcount en control plane solo TTL preciso, no borra lo aún referenciado
Compatibilidad v1 Chunks vacío = camino v1 romper formato no romper media ya enviada

Fases (TBD, ramas issue/0002x-*)

  1. 0002a — BlobRef con chunks (compatible) — extender el tipo + tests de marshalling con Chunks vacío (v1) y con chunks (v2). Sin cambiar clientes aún.
  2. 0002b — PublishMediaStream / FetchMediaStream — API de streaming en pkg/client sobre io.Reader/io.ReadCloser, cifrado por chunk, subida/descarga paralela acotada. Tests con un archivo > tamaño de chunk.
  3. 0002c — metadata mime+name (en el campo cifrado) + sniffing.
  4. 0002d — GC del object store — refcount + membershipd blobs gc + tests de "no borrar referenciado / borrar huérfano".
  5. 0002e — exponer en clientes — binding móvil (SendFile/FetchFile), gateway web (/api/media), tool send_file en unibots.

Definition of Done (evidencia ejecutable)

  • Golden: enviar y recibir una imagen pequeña (camino v1, sin chunks) sigue funcionando; enviar y recibir un archivo de 50 MB por chunks sin cargar 50 MB en RAM (medir RSS durante la operación).
  • Edge: archivo cuyo tamaño es múltiplo exacto del chunk; archivo de 1 byte; archivo justo por debajo y por encima del umbral de chunking.
  • Error path: chunk corrupto/no descifrable → error claro, no panic; blobs gc con un blob aún referenciado → NO lo borra (assert).
  • CGO_ENABLED=0 go test ./... verde.

Riesgos y mitigaciones

Riesgo Mitigación
Romper media v1 ya enviada Chunks vacío preserva el camino v1; tests de compatibilidad
GC borra un blob aún referenciado refcount + barrido conservador + --dry-run por defecto en CI
Puente gomobile con []byte grandes el binding trabaja con rutas de archivo, no buffers en memoria
Paralelismo de chunks satura el control plane límite de concurrencia (4) + el endurecimiento de auth del issue 0001

Relación con otros issues

  • 0001 (seguridad) — prerequisito: la auth firmada del control plane debe cubrir POST/GET /blobs antes de exponer media públicamente; si no, cualquiera llena el store o descarga ciphertext ajeno.
  • Streaming en vivo (futuro, no este issue) — videollamada/audio en tiempo real = WebRTC con el bus como canal de señalización; modelo distinto al blob.