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.


3b. PUBLICAR CON IMÁGENES (scheduler)
-------------------------------------
Problema
  Desde 71db0a0/a6843e9 un borrador aprobado puede llevar mediaAssetIds y
  altTexts, pero el registro del scheduler
  (packages/connector-runtime/src/registry.ts) creaba los conectores sin
  loadAsset/openAsset: Mastodon y Telegram devolvían rejected_permanent
  INVALID_POST y el post no salía. Los altTexts no llegaban al conector.

Qué cambia
  - packages/connector-runtime/src/media.ts: loadAsset lee media_assets
    dentro del colectivo de la ejecución (app.workspace_id, RLS forzado);
    openAsset descarga el objeto, abre el marco SPENC1 con S3_KEK y
    comprueba el hash del contenido. Solo lectura. Los bytes van
    worker -> red; nunca pasan por la API.
  - preparePostForDispatch convierte altTexts[i] en
    providerMetadata.altText[mediaAssetIds[i]] (lo que lee Mastodon).
  - Instagram registrado también en el scheduler: rechaza con error
    explícito en vez de "proveedor desconocido". Sigue sin publicar fotos
    (necesita URL pública; decisión pendiente).

Tests
  cd packages/connector-runtime && npx vitest run     esperado: 33 pass
  cd apps/scheduler && npx vitest run                  esperado: 96 pass

Prueba manual (entorno de pruebas)
  1. Compose: post con 2 imágenes y texto alternativo a Mastodon.
  2. Aprobar y programar a 1 minuto.
  3. El post sale con las 2 imágenes y su descripción en la instancia.
  4. Con una imagen de otro colectivo forzada en mediaAssetIds -> falla
     (MEDIA_ASSET_NOT_FOUND), no se publica nada.


3c. PRODUCTOR DE VÍDEO
----------------------
  - Quitado MEDIA_DEBUG_STDERR=1 del compose de producción (escribía la
    salida de ffmpeg sin censurar en /tmp).
  - El logo superpuesto solo acepta imágenes del propio colectivo
    (adjuntos, recursos de grupo o la marca). Antes bastaba una clave con
    formato válido.
  cd apps/api && npx vitest run src/video                esperado: 38 pass


4. ESTILO Y LOGO
----------------
  - apps/web/src/app/globals.css: paleta de acampada26s.net. Fondo
    blanco, texto y bordes negros, botones negros con sombra neón
    desplazada, foco y elemento activo en neón #00e5ff. Títulos h1/h2 en
    Anton en mayúsculas con la sombra neón escalonada de la web.
  - Iconos de navegación en negro; iconos de redes con trazo neón.
  - Logo: tres tiendas iglú cableadas a un switch con antena, en el
    estilo a tinta del logo de la acampada (docs/brand/t-hub.svg;
    t-hub-lockup.svg/png con el nombre en Anton convertido a trazos).
    Componente LogoMark sustituido; nombre visible "T-HUB".
  - Fuente Anton en apps/web/public/fonts (licencia SIL OFL incluida).
  - Página pública de buzón (/submit/[token]) en claro.
Revisado con capturas (Chromium sin interfaz, modo offline con datos de
ejemplo): Streams, Compose, Outreach, Channels, Approvals, Onboarding,
/submit en escritorio y móvil. Falta revisar Control Room y Calendar con
datos reales.


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.
