# 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. Built for collectives that need to coordinate media across platforms without 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 - **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). - **Column wall** — live multi-platform feed columns, TweetDeck-style. Safe rendering (hostile-HTML tested). Per-source cooldown + adaptive backoff. - **Live Control Room** — browser multiview, TAKE/CUT, preview/program, lower-thirds, scrolling chyrons, audio mixing, tally. - **Auto-Director** — priority rules switch sources automatically so the stream never dies mid-action. - **Chat governance** — bridged chats (Telegram/Twitch/Discord) into one overlay. Polls with configurable quorum. Approval gates on every post. Machine-readable reason codes. - **Distributed transcode workers** — laptops and GPUs join as render workers over Tailscale. Per-job sandboxed FFmpeg (UID/seccomp/no-network). - **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. ## Architecture Three flows explain the whole system. ### 1. One account One person connects a collective's account once. From then on: draft, someone approves, the Hub publishes. The social network's password never passes through the Hub — the network returns a token and the Hub stores it encrypted. ```mermaid flowchart LR P([Person]) -->|1. Connect| H[Hub] H -->|2. Permission screen| R[(Social network)] R -->|3. Token| H P -->|4. Drafts| H H -->|5. Approval| A([Another crew member]) A -->|6. Sign-off| H H -->|7. Publishes on time| R ``` ### 2. Several shared accounts Each collective has its own accounts and approval rules. A draft can go to several accounts at once: the Hub creates one copy per destination and each passes its own account rule (automatic, one person, majority or consensus). Anything meant for the whole network rises to the parent collective's queue. ```mermaid flowchart TB subgraph TTS[NETWORK QUEUE] Q[Shared queue] --> IG1[Instagram international] Q --> M1[Mastodon @network] end subgraph S26[COLLECTIVE A] D[Draft] --> IG2[Instagram @collectiveA] D --> M2[Mastodon @collectiveA] D --> T2[Telegram collectiveA] end subgraph SUB[Local collectives] B[Barcelona] --> IG3[Own Instagram] S[Sevilla] --> IG4[Own Instagram] end S26 -->|Uploads for the network| Q SUB -->|Uploads to A| D IG2 -.->|Rule: 2 approvals| G{{Approval}} M2 -.->|Rule: automatic| G ``` ### 3. The technical half The web only talks to the API. The API keeps state in Postgres with per-collective row-level security, credentials live in an encrypted vault, and scheduled publishing runs through the scheduler with Temporal. Each network is a connector; outside the connectors there is no code that distinguishes networks. Media bytes are end-to-end encrypted (SPENC1 server-sealed, SPENC2 browser-sealed) in content-addressed R2 storage; heavy processing runs in workers that pull sealed jobs — the api box never touches plaintext. ```mermaid flowchart LR subgraph Client W[apps/web — Next.js] end subgraph Control API[apps/api — NestJS] SCH[apps/scheduler — Temporal] REG[ConnectorRegistry — packages/connector-*] PW[produce-worker — probe / compose / assemble] end subgraph Data PG[(PostgreSQL — RLS per collective)] RD[(Redis — OAuth state 10 min)] V[Vault — AES-256-GCM + KEK] S3[(R2 — SPENC1/SPENC2 sealed media)] end subgraph Media MW[apps/media-worker — FFmpeg] PT[PeerTube — tube.occupymedia.cc] end subgraph Networks MA[Mastodon API] IG[Instagram Graph API] TG[Telegram Bot API] end W -->|HTTPS + session| API API --> PG API --> RD API --> V V --> PG API -->|connect / identity| REG SCH -->|publish| REG SCH --> PG W -->|SPENC2 sealed| S3 PW -->|decrypt + render| S3 PW --> PG REG -->|providerFetch egress control| MA REG --> IG REG --> TG MW --> S3 MW -->|RTMP| PT ``` Connecting an account (OAuth with one-time `state` and PKCE where the network supports it): 1. `POST /connections/authorize`: the API stores the request state encrypted in Redis and returns the network's URL. 2. The person accepts on the network, which returns to `GET /connections/callback`. 3. The API checks `state`, cookie and permissions, redeems the code and stores the token in the vault. ## Network status | Network | Connect | Publish | Notes | |---|---|---|---| | Mastodon | Yes | Text | No server keys; each account registers the app on its instance. | | Telegram | Yes | Text | Bot token from BotFather. | | Instagram | Yes, with `INSTAGRAM_CLIENT_ID/SECRET` | Not yet | Meta fetches the image from a public URL and media are encrypted: a temporary endpoint is pending. See [docs/oauth-connect.md](docs/oauth-connect.md). | | PeerTube | No | Live only | The OAuth bridge on tube.occupymedia.cc is pending. | | X | No | No | No connector. Paid per-use API. | | TikTok | No | No | Out of scope: the API requires an audit; without it only private publishing. | ## Outreach (hand-written DMs) A list of public accounts (artists, collectives, media) to write to privately asking for amplification. Each contact has its drafted message, fillable from a template (`{name}`, `{handle}`), marked sent / replied / declined. CSV import: ``` name,platform,handle,profileUrl,category,language Climáximo,bluesky,climaximopt.bsky.social,https://bsky.app/profile/climaximopt.bsky.social,climate,pt ``` The Hub **does not send** the messages. "Copy & open profile" copies the text and opens the profile; the person pastes and sends it from the network's own DM box. Reasons: | Network | Open a DM via API | Problem | |---|---|---| | Instagram | No | The API only allows replying to whoever wrote you in the last 24 h. | | X | Yes, paid | Mass DMs to strangers break automation rules: suspension. | | Mastodon | Yes (private mention) | Instances mute or block accounts doing it in bulk; the whole instance can suffer. | | Bluesky | Yes | Same spam criteria; stranger chats are usually closed. | The screen counter warns from 20 first messages per day per collective. Spread them across people and days, and personalise each one. ## Requirements Measured on the current deployment. Media goes to R2, so the disk does not grow with videos. | Tier | CPU | RAM | Disk | For | |---|---|---|---|---| | Minimum | 4 vCPU | 8 GB | 160 GB SSD | The whole stack, transcoding off-box | | Recommended | 8 vCPU | 16 GB | 240 GB SSD | Livestreams, on-box transcoding, Collabora | | Render node (optional) | 4+ cores or a GPU with NVENC | 8 GB | 50 GB | Joins over Tailscale; never holds keys | Real usage today: ~2.6 GB of RAM across all services. Bandwidth: | Use | Needed | |---|---| | Panel, publishing, network APIs | < 1 Mbps sustained | | One outgoing live (1080p30 to PeerTube) | 6 Mbps upload per live; 3 Mbps at 720p | | Viewers | Served by Cloudflare from the HLS cache; the origin does not scale with the audience | | Video uploads | Bursts as fast as the line allows; 100 GiB per file via chunked ingest | ## Network and ports Inbound from the internet: **none direct**. Everything enters through the Cloudflare tunnel (with an ingress failover to a standby proxy); the edge nginx only accepts Cloudflare ranges on 80/443. | Port | Service | Expose to | |---|---|---| | 443/tcp | Edge nginx | Cloudflare ranges only | | 80/tcp | Edge nginx (redirect) | Cloudflare ranges only | | 3000/tcp | API | 127.0.0.1 only | | 3001/tcp | Web | 127.0.0.1 only | | 9000/tcp | PeerTube | 127.0.0.1 or Tailscale | | 1935/tcp | PeerTube RTMP | Tailscale only (media worker) | | 6167/tcp | Matrix (conduwuit) | 127.0.0.1 | | 9200, 9980/tcp | oCIS, Collabora | 127.0.0.1 | | 5432, 6379, 7233 | Postgres, Redis, Temporal | Never outside the Docker network | Required egress: 443/tcp to the networks' APIs and to R2, 7844/tcp+udp for `cloudflared`, 41641/udp for Tailscale. Docker publishes ports bypassing ufw. To close them: publish on `127.0.0.1:PORT`, on the Tailscale IP, or add rules to the `DOCKER-USER` chain. ## Security and monitoring Practices for any deployment. Report security failures privately to the maintainers — never in public issues. - **Invite-only registration.** Open registration hands anyone a collective of their own with Owner rights on it. - **One secret per service.** The web and workers never need `SUPERPOSTER_KEK`, `S3_KEK` or `SESSION_SECRET`. Different keys for the vault and for media. - **FFmpeg always sandboxed** (`NODE_ENV=production`, `MEDIA_SANDBOX=on`), no network, no inherited environment variables. - **Headers**: strict CSP, HSTS, `X-Content-Type-Options`, `Referrer-Policy` on web and API. - **Request limits** on `/auth/*`, invitations, OIDC and public uploads, keyed by `CF-Connecting-IP`. - **Metadata out**: strip EXIF/GPS from photos and videos before storing or publishing. - **Encrypted backups** (`age` or GPG) kept off-machine; test the restore. - **Tamper-proof audit**: revoke `UPDATE`/`DELETE` on `audit_log` from the `sp_app` role and anchor the chain head off-box. What to watch (Prometheus + Grafana ship in `infra/`; Grafana never anonymous): | Signal | Alert if | |---|---| | `401`/`403` on `/auth/login` per IP | > 20 in 5 min | | New registrations / redeemed invitations | Anything unplanned | | `audit_log` events: `member.*`, `connection.removed`, `group.deleted`, `outreach.removed` | Spikes or odd actors | | `connections` in `needs_credentials` state | Any: token revoked or expired | | API 5xx rate | > 1 % over 5 min | | API and worker memory | > 80 % | | Publishing queue `failed`/`unknown` | Any | | Cloudflare panel logins and DNS changes | Always | ## Quickstart ```bash pnpm install pnpm lint && pnpm typecheck && pnpm build # infrastructure docker compose -f infra/docker-compose.yml up -d postgres redis temporal minio otel-collector prometheus grafana # everything incl. media stub + mock live source + mock RTMP sink docker compose -f infra/docker-compose.yml --profile dev up -d # apps pnpm dev # api :3000, web :3001(adjust if colliding), scheduler, media-worker :3100 curl localhost:3000/healthz curl localhost:3000/readyz ``` ## Documentation | Section | Path | |---|---| | Engineering guide | [`docs/engineering-guide.md`](docs/engineering-guide.md) | | Admin guide | [`docs/admin-guide.md`](docs/admin-guide.md) | | User manual | [`docs/user-manual-v2.md`](docs/user-manual-v2.md) | | Design docs | [`docs/design/`](docs/design/) | | 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 ```bash pnpm --filter @tactical-media-broadcast-hub/media-worker soak -- --hours 8 --out ``` ## 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 encrypted vault the database never sees in plaintext; every editorial action in a hash-chained audit log; anonymous-infrastructure friendly (the host carries boxes, not content — storage is ciphertext).