docs: T-HUB README and validation guide

README: architecture diagrams (single account, shared accounts,
components), status per platform, outreach, hardware and bandwidth
requirements, ports, security practices and what to monitor.
docs/T-HUB-VALIDACION.txt: per-commit test plan for reviewers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hacklab 2026-10-15 14:15:04 +02:00
parent d2a4209b0a
commit 533a0fb1c0
2 changed files with 417 additions and 6 deletions

267
README.md
View file

@ -1,4 +1,259 @@
# Tactical Media Broadcast Hub
# T-HUB
<p align="center"><img src="docs/brand/t-hub.svg" width="160" alt="T-HUB"></p>
Copia de trabajo del [Tactical Media Broadcast Hub](https://gitlab.moxexley.com/mediatools/tacticalmediahub)
para la red de acampadas: [acampada26s.net](https://acampada26s.net),
[tomalacalle.net](https://tomalacalle.net) y [takethestreet.net](https://takethestreet.net).
El despliegue en uso es [app.occupymedia.cc](https://app.occupymedia.cc).
Seguimos la rama `main` de upstream y mandamos allí los cambios que les sirvan.
Lo propio de T-HUB va en este repo. Para revisar y validar los cambios, ver
[docs/T-HUB-VALIDACION.txt](docs/T-HUB-VALIDACION.txt).
Cambios respecto a upstream:
- Instagram registrado en conexión y publicación; claves de la app de Meta solo en el entorno del servidor.
- Panel **Outreach** para escribir por privado a mano, contacto a contacto, con seguimiento.
- RLS forzado en `media_assets` (migración 042).
- Estilo de acampada26s.net en la web: negro, blanco, neón `#00e5ff` y títulos en Anton.
| Remoto | URL |
|---|---|
| `origin` | `https://gitea.laenre.net/hacklab/T-HUB.git` |
| `upstream` | `https://gitlab.moxexley.com/mediatools/tacticalmediahub.git` |
```bash
git fetch upstream && git merge upstream/main
```
## Arquitectura
### 1. Una cuenta
Una persona conecta una cuenta del colectivo una sola vez. A partir de ahí
redacta, alguien aprueba y el Hub publica. La contraseña de la red social no
pasa nunca por el Hub: la red devuelve un token y el Hub lo guarda cifrado.
```mermaid
flowchart LR
P([Persona]) -->|1. Conectar| H[T-HUB]
H -->|2. Pantalla de permiso| R[(Red social)]
R -->|3. Token| H
P -->|4. Redacta| H
H -->|5. Aprobación| A([Otra persona del colectivo])
A -->|6. Visto bueno| H
H -->|7. Publica a su hora| R
```
### 2. Varias cuentas compartidas
Cada colectivo tiene sus cuentas y sus reglas de aprobación. Un borrador puede
ir a varias cuentas a la vez: el Hub crea una copia por destino y cada una pasa
la regla de su cuenta (automática, una persona, mayoría o consenso). Lo que deba
salir en toda la red sube a la cola del colectivo padre.
```mermaid
flowchart TB
subgraph TTS[TAKE THE STREET]
Q[Cola común] --> IG1[Instagram internacional]
Q --> M1[Mastodon @takethestreet]
end
subgraph S26[ACAMPADA 26S]
D[Borrador] --> IG2[Instagram @acampada26s]
D --> M2[Mastodon @26s]
D --> T2[Telegram acampada26s]
end
subgraph SUB[Acampadas locales]
B[Barcelona] --> IG3[Instagram propio]
S[Sevilla] --> IG4[Instagram propio]
end
S26 -->|Sube para toda la red| Q
SUB -->|Sube a 26S| D
IG2 -.->|Regla: 2 aprobaciones| G{{Aprobación}}
M2 -.->|Regla: automática| G
```
### 3. Parte técnica
La web solo habla con la API. La API guarda el estado en Postgres con
aislamiento por colectivo (RLS), los tokens van en un vault cifrado y el
envío programado lo hace el scheduler con Temporal. Cada red es un conector;
fuera de los conectores no hay código que distinga redes.
```mermaid
flowchart LR
subgraph Cliente
W[apps/web<br/>Next.js]
end
subgraph Control
API[apps/api<br/>NestJS]
SCH[apps/scheduler<br/>Temporal worker]
REG[ConnectorRegistry<br/>packages/connector-*]
end
subgraph Datos
PG[(PostgreSQL<br/>RLS por colectivo)]
RD[(Redis<br/>estado OAuth 10 min)]
V[Vault<br/>AES-256-GCM + KEK]
S3[(S3 / R2<br/>medios cifrados)]
end
subgraph Medios
MW[apps/media-worker<br/>FFmpeg]
PT[PeerTube<br/>tube.occupymedia.cc]
end
subgraph Redes
MA[Mastodon API]
IG[Graph API<br/>Instagram]
TG[Telegram Bot API]
end
W -->|HTTPS + sesión| API
API --> PG
API --> RD
API --> V
V --> PG
API -->|connect / identity| REG
SCH -->|publish| REG
SCH --> PG
REG -->|providerFetch<br/>control de salida| MA
REG --> IG
REG --> TG
MW --> S3
MW -->|RTMP| PT
```
Conectar una cuenta (OAuth con `state` de un solo uso y PKCE donde la red lo admite):
1. `POST /connections/authorize`: la API guarda en Redis, cifrado, el estado de la petición y devuelve la URL de la red.
2. La persona acepta en la red y la red vuelve a `GET /connections/callback`.
3. La API comprueba `state`, cookie y permisos, canjea el código y guarda el token en el vault.
## Estado por red
| Red | Conectar | Publicar | Notas |
|---|---|---|---|
| Mastodon | Sí | Texto | Sin claves de servidor; cada cuenta registra la app en su instancia. |
| Telegram | Sí | Texto | Token del bot de BotFather. |
| Instagram | Sí, con `INSTAGRAM_CLIENT_ID/SECRET` | No todavía | Meta descarga la imagen de una URL pública y los medios están cifrados: falta un endpoint temporal. Ver [docs/oauth-connect.md](docs/oauth-connect.md). |
| PeerTube | No | Directo sí | Falta el puente OAuth en tube.occupymedia.cc. |
| X | No | No | Sin conector. API de pago por uso. |
| TikTok | No | No | Fuera de alcance: la API exige auditoría y sin ella solo publica en privado. |
## Panel Outreach (mensajes privados)
Lista de cuentas públicas (artistas, colectivos, medios) a las que escribir por
privado para pedir difusión. Cada contacto tiene su mensaje, se puede rellenar
con una plantilla (`{name}`, `{handle}`) y se marca enviado, respondido o
rechazado. Se importa en CSV:
```
name,platform,handle,profileUrl,category,language
Climáximo,bluesky,climaximopt.bsky.social,https://bsky.app/profile/climaximopt.bsky.social,clima,pt
```
El Hub **no envía** los mensajes. "Copy & open profile" copia el texto y abre el
perfil; la persona lo pega y lo envía desde la propia red. Motivos:
| Red | Abrir un privado por API | Problema |
|---|---|---|
| Instagram | No | La API solo deja responder a quien te escribió en las últimas 24 h. |
| X | Sí, de pago | Mensajes en masa a desconocidos van contra sus normas de automatización: suspensión. |
| Mastodon | Sí (mención privada) | Las instancias silencian o bloquean cuentas que lo hacen en masa; puede caer la instancia entera. |
| Bluesky | Sí | Mismo criterio de spam; los chats de desconocidos suelen estar cerrados. |
El contador de la pantalla avisa a partir de 20 primeros mensajes al día por
colectivo. Repartidlos entre varias personas y días, y personalizad cada uno.
## Requisitos
Medido en el despliegue actual (ver
[docs/ops/2026-10-12-cloud-hosting-requirements.md](docs/ops/2026-10-12-cloud-hosting-requirements.md)).
Los medios van a R2, así que el disco no crece con los vídeos.
| Nivel | CPU | RAM | Disco | Para qué |
|---|---|---|---|---|
| Mínimo | 4 vCPU | 8 GB | 160 GB SSD | Todo el stack, transcodificación fuera de la máquina |
| Recomendado | 8 vCPU | 16 GB | 240 GB SSD | Directos, transcodificación en la máquina, Collabora |
| Nodo de render (opcional) | 4+ núcleos o GPU con NVENC | 8 GB | 50 GB | Se une por Tailscale; nunca guarda claves |
Uso real hoy: unos 2,6 GB de RAM entre todos los servicios.
Ancho de banda:
| Uso | Necesario |
|---|---|
| Panel, publicación, APIs de redes | < 1 Mbps sostenido |
| Un directo saliente (1080p30 hacia PeerTube) | 6 Mbps de subida por directo; 3 Mbps a 720p |
| Espectadores | Los sirve Cloudflare desde la caché de HLS; el origen no escala con la audiencia |
| Subidas de vídeo | Picos de lo que dé la línea; límite de 2 GiB por archivo |
Coste orientativo: VPS de 30 €/mes + R2 de 1 a 5 $/mes. Cloudflare en plan gratuito.
## Red y puertos
Entrada desde internet: **ninguna** directa. Todo entra por el túnel de
Cloudflare; el nginx de borde solo acepta rangos de Cloudflare en 80/443.
| Puerto | Servicio | Exponer a |
|---|---|---|
| 443/tcp | nginx de borde | Solo rangos de Cloudflare |
| 80/tcp | nginx de borde (redirección) | Solo rangos de Cloudflare |
| 3000/tcp | API | Solo 127.0.0.1 |
| 3001/tcp | Web | Solo 127.0.0.1 |
| 9000/tcp | PeerTube | Solo 127.0.0.1 o Tailscale |
| 1935/tcp | RTMP de PeerTube | Solo Tailscale (worker de medios) |
| 6167/tcp | Matrix (conduwuit) | Solo 127.0.0.1 |
| 9200, 9980/tcp | oCIS, Collabora | Solo 127.0.0.1 |
| 5432, 6379, 7233 | Postgres, Redis, Temporal | Nunca fuera de la red de Docker |
Salida que hace falta: 443/tcp a las APIs de las redes y a R2, 7844/tcp+udp
para `cloudflared`, 41641/udp para Tailscale.
Docker publica puertos saltándose ufw. Para cerrarlos: publicar en `127.0.0.1:PUERTO`
o en la IP de Tailscale, o añadir reglas en la cadena `DOCKER-USER`.
## Seguridad y monitorización
Prácticas para cualquier despliegue. Los fallos de seguridad se comunican en
privado a quien mantiene el código, no en issues públicas.
- **Registro solo por invitación.** Un registro abierto da a cualquiera un colectivo propio y permisos de Owner sobre él.
- **Un secreto por servicio.** La web y los workers no necesitan `SUPERPOSTER_KEK`, `S3_KEK` ni `SESSION_SECRET`. Claves distintas para el vault y para los medios.
- **FFmpeg siempre en sandbox** (`NODE_ENV=production`, `MEDIA_SANDBOX=on`), sin red y sin variables de entorno heredadas.
- **Cabeceras**: CSP estricta, HSTS, `X-Content-Type-Options`, `Referrer-Policy` en web y API.
- **Límites de peticiones** en `/auth/*`, invitaciones, OIDC y subidas públicas, por `CF-Connecting-IP`.
- **Metadatos fuera**: quitar EXIF/GPS de fotos y vídeos antes de guardarlos o publicarlos.
- **Copias cifradas** (`age` o GPG) y fuera de la máquina; probar la restauración.
- **Auditoría inalterable**: quitar `UPDATE`/`DELETE` sobre `audit_log` al rol `sp_app` y anclar fuera la cabeza de la cadena.
Qué vigilar (Prometheus + Grafana ya están en `infra/`; Grafana nunca con acceso anónimo):
| Señal | Alerta si |
|---|---|
| `401`/`403` en `/auth/login` por IP | > 20 en 5 min |
| Registros nuevos / invitaciones canjeadas | Cualquiera fuera de lo previsto |
| Eventos `audit_log`: `member.*`, `connection.removed`, `group.deleted`, `outreach.removed` | Picos o actores raros |
| `connections` en estado `needs_credentials` | Cualquiera: token revocado o caducado |
| Errores 5xx de la API | > 1 % durante 5 min |
| Memoria de API y workers | > 80 % |
| Cola de publicación `failed`/`unknown` | Cualquiera |
| Logins al panel de Cloudflare y cambios de DNS | Siempre |
## Desarrollo
```bash
corepack enable
pnpm install
pnpm exec turbo run build typecheck --filter=@tactical-media-broadcast-hub/api...
pnpm --filter @tactical-media-broadcast-hub/connector-instagram test
```
Los tests de integración de `apps/api` que usan Postgres se saltan si no hay
base de datos configurada.
---
## Upstream: Tactical Media Broadcast Hub
Self-hosted multi-tenant social / video / livestream management platform with a
generic connector system and a Virtual Channel / Auto Director subsystem.
@ -8,7 +263,7 @@ concentrating access in one person's hands. Every credential lives in an
encrypted vault. Every editorial decision lands in a tamper-evident audit log.
Every feature is multi-tenant with row-level security.
## Features
### Features
- **Publishing pipeline** — drafts → group approval → schedule → fenced dispatch → receipts. Per-connection publish governance: `auto`, one-admin, majority or consensus sign-off per account — boosts included. Multi-platform fan-out (Mastodon, Telegram, Bluesky, PeerTube, Facebook, Instagram, Threads, LinkedIn, Reddit, Twitch, Discord, Slack). DST-proof scheduling.
- **Files SSO** — the Hub is the OIDC issuer for oCIS Drive: one sign-in, auto-provisioned personal drives, rotating refresh tokens that die with your Hub session (logout propagates).
@ -20,7 +275,7 @@ Every feature is multi-tenant with row-level security.
- **Archive** — content-addressed encrypted S3, dedup, full-text search, CSV/JSON export. Deleted platform content stays citable.
- **Analytics** — month-partitioned snapshots, current + previous month with deltas, per-provider breakdown.
## Quickstart
### Quickstart
```bash
pnpm install
@ -38,7 +293,7 @@ curl localhost:3000/healthz
curl localhost:3000/readyz
```
## Documentation
### Documentation
| Section | Path |
|---|---|
@ -49,13 +304,13 @@ curl localhost:3000/readyz
| Streaming architecture | [`docs/design/streaming-architecture.md`](docs/design/streaming-architecture.md) |
| Deployment (occupymedia.cc) | [`infra/occupymedia/DEPLOY.md`](infra/occupymedia/DEPLOY.md) |
## Deterministic live-source testing
### Deterministic live-source testing
```bash
pnpm --filter @tactical-media-broadcast-hub/media-worker soak -- --hours 8 --out
```
## Security & trust model
### Security & trust model
Self-hosted, end to end — no corporate cloud, no accounts needed to run the
system itself. Multi-tenant with row-level security; platform credentials in an

156
docs/T-HUB-VALIDACION.txt Normal file
View file

@ -0,0 +1,156 @@
T-HUB - Guía de validación de cambios
=====================================
Para: quien revise y valide los cambios de T-HUB antes de llevarlos a
upstream (gitlab.moxexley.com/mediatools/tacticalmediahub) o a producción.
Base: upstream main a6843e9.
Commits a revisar (en orden):
feat: wire the Instagram connector into connect and publishing
feat: outreach contacts - track hand-written DMs per collective
chore(db): force row-level security on media_assets
style / docs: estilo de la web, logo y documentación
Cada commit se puede revisar y aplicar por separado.
0. PREPARACIÓN
--------------
corepack enable
pnpm install --frozen-lockfile
pnpm exec turbo run build typecheck --filter=@tactical-media-broadcast-hub/api...
Para los tests que usan Postgres hacen falta las variables *_IT_DATABASE_URL
de cada suite; sin ellas se saltan (comportamiento de upstream).
Lint: apps/api ya tenía 54 errores antes de estos cambios y sigue teniendo
los mismos 54. Los archivos tocados pasan eslint sin errores.
1. INSTAGRAM
----------------------
Qué cambia
- createInstagramConnector registrado en
apps/api/src/connections/connector-registry.ts y en connectorFor de
apps/api/src/publishing/publishing.service.ts.
- Política de publicación instagram: { idempotency: "none" } en
packages/connector-runtime/src/policy.ts (Graph no tiene clave de
idempotencia; un envío dudoso no se repite).
- Las credenciales de la app de Meta se leen SOLO de
INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET. Se quitan los campos
clientId/clientSecret del formulario y ya no se copian al vault.
- El proveedor sale desactivado (connectEnabled=false) sin esas dos
variables, con una nota que lo explica.
- Si la persona autoriza varias cuentas de Instagram en el diálogo de
Meta, la conexión falla en vez de elegir la primera.
Tests automáticos
pnpm --filter @tactical-media-broadcast-hub/connector-instagram test
esperado: 37 pass
cd apps/api && npx vitest run src/connections
esperado: 18 pass (incluye "offers Instagram only with the Hub's
Facebook app configured, and keeps the app secret out of the browser")
Prueba manual (entorno de pruebas, app de Meta en modo desarrollo)
1. Sin variables: GET /connections/providers -> instagram enabled=false.
2. Con INSTAGRAM_CLIENT_ID/SECRET: enabled=true.
3. En la app de Meta, Valid OAuth Redirect URI =
${API_URL}/connections/callback (exacto).
4. Canales -> Conectar Instagram con una cuenta Tester Business/Creator
vinculada a una página -> vuelve con connect=success y la conexión
sale healthy con @usuario.
5. Autorizar dos cuentas a la vez -> connect=failure.
Límites conocidos (no resueltos en este commit)
- Publicar imágenes NO funciona todavía: Meta descarga la imagen de una
URL pública y los medios están cifrados en el almacén. Hace falta un
endpoint temporal que sirva el JPEG descifrado (TTL corto, solo
borradores aprobados). Además la regla del proyecto es que los bytes
no pasen por la API. Decisión de diseño pendiente.
- refreshCredentials usa grant_type=ig_refresh_token contra
graph.facebook.com, que es el método de "Instagram Login", no de
Facebook Login. Comprobar con un token real; posible caducidad a 60
días.
2. OUTREACH
---------------------
Qué cambia
- Migración 041_outreach.sql: tabla outreach_contacts con RLS
ENABLE+FORCE y WITH CHECK, índice único (workspace, platform,
lower(handle)), trigger de workspace inmutable, permisos
outreach.view (Owner, Admin, Editor, Moderator) y outreach.manage
(Owner, Admin, Editor).
- API: GET/POST/PATCH/DELETE /outreach/contacts (apps/api/src/outreach).
Importación de hasta 500 por petición; las cuentas ya existentes se
saltan. La auditoría guarda altas, bajas y cambios de estado, nunca el
texto del mensaje.
- Web: /outreach (barra lateral y "More"). Importar CSV, plantilla con
{name} y {handle}, filtros, "Copy & open profile", contador diario
con aviso a partir de 20.
- El Hub no envía mensajes. No hay llamadas a APIs de redes en este
módulo. Ver README, sección "Panel Outreach", para el motivo.
Tests automáticos
cd apps/api && npx vitest run src/outreach esperado: 8 pass
pnpm --filter @tactical-media-broadcast-hub/web test:outreach
esperado: 4 pass
Verificado además (fuera del repo): la migración 041 y todas las
consultas del servicio se ejecutaron sobre PGlite (Postgres compilado a WASM)
como rol sin privilegios con app.workspace_id fijado: aislamiento entre
colectivos, salto de duplicados en mayúsculas/minúsculas, sellos de
sent_at/replied_at, vuelta a "todo" limpia los sellos.
Prueba manual
1. Aplicar migraciones (src/db/migrate.cli.ts).
2. Como Editor: importar
name,platform,handle,profileUrl,category,language
Prueba,bluesky,prueba.bsky.social,https://bsky.app/profile/prueba.bsky.social,test,es
-> "1 contacts added". Reimportar -> "0 added, 1 already on the list".
3. Plantilla "Hola {name}" -> el contacto pendiente recibe "Hola Prueba".
4. Copy & open profile -> abre el perfil en otra pestaña con el texto
en el portapapeles.
5. Mark sent -> estado sent y sello de fecha; aparece outreach.sent en
/audit sin el texto del mensaje.
6. Como Analyst (sin outreach.view) -> GET /outreach/contacts da 403.
7. Desde otro colectivo -> la lista no muestra los contactos del primero
y PATCH sobre su id da 404.
8. profileUrl con http:// o javascript: -> 400.
3. RLS EN media_assets
----------------------
Qué cambia
- 042_media_assets_rls.sql: ENABLE + FORCE RLS y política con
WITH CHECK.
- Las consultas a media_assets (media.service.ts, publishing.service.ts)
filtran también por app.workspace_id.
Verificación
- cd apps/api && npx vitest run src/media src/publishing
esperado: 114 pass, 49 skipped (necesitan Postgres).
- En la base de datos, después de migrar:
SELECT relrowsecurity, relforcerowsecurity
FROM pg_class WHERE relname = 'media_assets'; -> t, t
- Prueba manual: subir un medio en el colectivo A y pedir
GET /media/<id> con sesión del colectivo B -> 404.
4. ESTILO Y LOGO
----------------
- apps/web/src/app/globals.css: paleta de acampada26s.net sobre fondo
oscuro (negro, blanco, neón #00e5ff), títulos h1/h2 en Anton en
mayúsculas. Regla que pone texto negro en botones neón.
- Fuente Anton en apps/web/public/fonts (licencia SIL OFL incluida).
- Logo: docs/brand/t-hub.svg, también como favicon.
- Página pública de buzón (/submit/[token]) con el mismo color.
Comprobar visualmente: login, Compose, Approvals, Channels, Outreach,
Control Room y /submit/<token>, en escritorio y móvil. Buscar textos
blancos sobre neón o bloques que hayan quedado con el rojo anterior.
5. SEGURIDAD
------------
La revisión de seguridad se entrega en privado a quien mantiene el
despliegue. Los fallos se comunican por canal privado, no en issues.