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.
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.BlobRefevoluciona (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 cifradoPublishMediaStream(roomID string, r io.Reader, meta MediaMeta) (BlobRef, error): lee delio.Readeren chunks (no carga el archivo entero en RAM), cifra y sube cada chunk, y construye el manifiesto. ElPublishMedia([]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 unio.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)yFetchFile(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) yGET /api/media/{room}/{hash}(descarga descifrada con los headersContent-Type/Content-Dispositionderivados de la metadata). - unibots: una tool
send_filepara 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-*)
- 0002a — BlobRef con chunks (compatible) — extender el tipo + tests de
marshalling con
Chunksvacío (v1) y con chunks (v2). Sin cambiar clientes aún. - 0002b — PublishMediaStream / FetchMediaStream — API de streaming en
pkg/clientsobreio.Reader/io.ReadCloser, cifrado por chunk, subida/descarga paralela acotada. Tests con un archivo > tamaño de chunk. - 0002c — metadata mime+name (en el campo cifrado) + sniffing.
- 0002d — GC del object store — refcount +
membershipd blobs gc+ tests de "no borrar referenciado / borrar huérfano". - 0002e — exponer en clientes — binding móvil (
SendFile/FetchFile), gateway web (/api/media), toolsend_fileen 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 gccon 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 /blobsantes 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.