Hub de difusión para la red de acampadas (fork de trabajo del Tactical Media Broadcast Hub)
Find a file
glass beadian 74f0802f1a fix: oidc hardening — sid-less consent refused, rotation race closed, iss on errors, logout honesty
From the logic audit:
- consent rejects sid-less bearers and requires a LIVE hub session
  (sessionAlive) before minting codes — a logged-out bearer can no longer
  mint downstream tokens inside its TTL
- refresh rotation race closed: consume returns whether THIS call won;
  the loser revokes the family (reuse detection now holds under
  concurrency)
- error redirects carry iss (RFC 9207) on error responses too
- logout awaits the revocation and no longer returns ok for missing or
  forged bearers
2026-10-18 11:54:59 +00:00
.github/workflows Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
apps fix: oidc hardening — sid-less consent refused, rotation race closed, iss on errors, logout honesty 2026-10-18 11:54:59 +00:00
docs feat: wire the Instagram connector into connect and publishing 2026-10-18 10:35:33 +00:00
infra fix(video): logo overlays must belong to the workspace; drop debug stderr 2026-10-18 10:35:33 +00:00
packages feat: wire the Instagram connector into connect and publishing 2026-10-18 10:35:33 +00:00
scripts e2e: brand palette guard — fails on blue-family colors in the embedded surfaces 2026-10-13 19:42:06 +00:00
.dockerignore Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
.editorconfig Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
.gitignore Track the files scaffold; sync deployed oCIS compose requirements 2026-10-11 07:51:59 +00:00
eslint.config.mjs Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
LICENSE Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
package.json Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
pnpm-lock.yaml feat: wire the Instagram connector into connect and publishing 2026-10-18 10:35:33 +00:00
pnpm-workspace.yaml Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
README.md docs: README — architecture diagrams, network status, outreach, requirements, ports, monitoring 2026-10-18 10:57:19 +00:00
tsconfig.base.json Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00
turbo.json Tactical Media Broadcast Hub — initial release 2026-10-10 11:56:42 +00:00

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.

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.

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.

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.
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

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
Admin guide docs/admin-guide.md
User manual docs/user-manual-v2.md
Design docs docs/design/
Streaming architecture docs/design/streaming-architecture.md
Deployment (occupymedia.cc) infra/occupymedia/DEPLOY.md

Deterministic live-source testing

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).