Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
ed763712a4 docs/brand: Matrix version of the tents mark
Some checks are pending
ci / verify (push) Waiting to run
Same drawing: phosphor green fill, white lines with a green glow, black
background, faint katakana rain behind (and a version without it).
2026-10-17 17:48:43 +02:00
20d8d812a0 docs/brand: acampada tents mark with cable and antenna
The acampada26s.net mark as is (two tents), with a cable coming out of
the bottom tent's door to a radio antenna. Colour (blue/orange) and
white versions, SVG and 1024px PNG.
2026-10-17 17:47:02 +02:00
b4557ae23a docs: media publishing and producer checks in README and validation guide 2026-10-15 15:09:07 +02:00
1b58768738 fix(video): logo overlays must belong to the workspace; drop debug stderr
- produceVideo checks every logo overlay key against the workspace's
  media_assets, group_assets or branding logo before queueing; a
  well-formed content address alone no longer suffices.
- Remove MEDIA_DEBUG_STDERR=1 from the production compose: it wrote raw
  ffmpeg stderr to an unrotated file, bypassing redaction.
2026-10-15 15:09:07 +02:00
dd5a6c8b35 feat(runtime): publish approved media from the scheduler
Approved posts may carry mediaAssetIds and altTexts since the repository
gate was lifted, but the scheduler built connectors without an asset
loader, so Mastodon and Telegram rejected every post with media as
INVALID_POST and alt texts never reached them.

- media.ts: loadAsset reads media_assets inside the execution's tenant
  (app.workspace_id, RLS forced); openAsset fetches the sealed object,
  opens the SPENC1 frame with S3_KEK and checks the content hash.
  Read-only; bytes go worker to provider and never through the API.
- preparePostForDispatch maps positional altTexts onto
  providerMetadata.altText keyed by asset id.
- Instagram joins the scheduler registry so its posts fail with an
  explicit connector error instead of UnknownProviderError.
2026-10-15 15:09:07 +02:00
c423f172d0 style: light theme and hand-inked logo matching acampada26s.net
The previous pass kept the dark base and the red icons, which did not
read as the acampada. Now:

- Light theme: white page, black ink and borders, black buttons with a
  hard neon offset shadow, neon for focus and the active nav item;
  h1/h2 in Anton with the stepped neon shadow of the site headings.
- Nav, empty-state, spinner and pattern assets in black; platform icons
  keep their dark disc with a neon stroke.
- New mark: three dome tents wired from their doors into a switch with
  an antenna, drawn in the inked style of the acampada logo (curved
  poles, ball ends, hatching) over a neon offset copy. LogoMark renders
  it inline; visible product name is T-HUB.
- docs/brand: t-hub.svg and a lockup with the Anton wordmark converted
  to outlines so it renders without the font.
- Public submission page moved to the light palette.

Checked with headless Chromium screenshots (offline sample data) on
desktop and mobile.
2026-10-15 14:40:02 +02:00
c7d133c395 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.
2026-10-15 14:15:04 +02:00
f6840ff69b style: acampada26s.net palette, Anton titles and T-HUB logo
Black/white/neon (#00e5ff) on the existing dark base, h1/h2 in Anton
uppercase with the neon offset used on acampada26s.net. Neon fills get
dark text. The public submission page and the control-room focus ring
follow the same accent. Anton ships under the SIL OFL (license copied
next to the font). New logo in docs/brand/t-hub.svg, also the favicon.
2026-10-15 14:15:03 +02:00
fb81fab7bb chore(db): force row-level security on media_assets
Migration 042 enables and forces the tenant_isolation policy on
media_assets with an explicit WITH CHECK; media and publishing queries
also scope by app.workspace_id.
2026-10-15 14:15:03 +02:00
ad0f1be6cf feat: outreach contacts — track hand-written DMs per collective
A list of public accounts a collective wants to write to (artists,
collectives, public figures), with a drafted message per contact and the
state of the conversation: todo, sent, replied, declined.

The Hub does not send these messages. "Copy & open profile" puts the
draft on the clipboard and opens the profile; a person sends it from the
platform's own DM box and marks it sent. Unsolicited bulk DMs break the
terms of every commercial platform and get accounts restricted, and
Instagram's API cannot open a conversation at all.

API (OutreachModule):
- migration 041: outreach_contacts, RLS enabled + forced with WITH CHECK,
  unique per (workspace, platform, lower(handle)); permissions
  outreach.view (Owner/Admin/Editor/Moderator) and outreach.manage
  (Owner/Admin/Editor).
- GET/POST/PATCH/DELETE /outreach/contacts; bulk import up to 500 rows,
  known accounts skipped. Audit records list changes and status moves,
  never the message text.

Web: /outreach page (sidebar + More), CSV import, {name}/{handle}
template for pending contacts, status filters, a daily counter that
warns past 20 first messages.

Tests: service and request parsing with the DB faked; the migration and
every service query were also run against PGlite as the non-superuser
app role (tenant isolation, conflict skip, status timestamps).
2026-10-15 14:15:03 +02:00
80aa198843 feat: wire the Instagram connector into connect and publishing
The Instagram connector existed but was never registered, so Channels showed
it as "provider unavailable" and publishing could not resolve it.

- Register createInstagramConnector in the connections registry and the
  publishing registry; add the connector-runtime publishing policy
  (idempotency "none": media_publish has no key, never replay).
- The Facebook app credentials identify the Hub, not a collective: read them
  only from INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET. Drop the
  clientId/clientSecret connect fields and stop copying the app secret into
  each connection's vault entry. The provider is connectable only when both
  are set (connectEnabled + connectNote).
- Fail the connect when several Instagram accounts are granted instead of
  silently publishing as the first Page's account.
- Fix the connector package's pre-existing lint errors (it now ships in api).
- Tests: contract cases for env-only credentials and the multi-account
  refusal; connections integration case for provider gating and the
  Facebook dialog redirect.
- docs/oauth-connect.md: Instagram setup, callback URI and limits.

Not in this change: publishing images needs a public plaintext JPEG URL that
Meta downloads, and storage is client-side encrypted, so presigned S3 URLs
cannot serve it. Until a short-lived media endpoint exists, publishing to
Instagram fails with an explicit error. Token refresh via ig_refresh_token
on graph.facebook.com also needs checking against Facebook Login tokens.
2026-10-15 14:15:03 +02:00
93 changed files with 2462 additions and 271 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-lockup.png" width="520" 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: fondo blanco, tinta negra, neón `#00e5ff` en sombras y foco, títulos en Anton. Logo de tres tiendas cableadas a un switch con antena, dibujado como el de la acampada (`docs/brand/`).
| 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 e imágenes (con texto alternativo) | Sin claves de servidor; cada cuenta registra la app en su instancia. |
| Telegram | Sí | Texto e imágenes | 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

View file

@ -0,0 +1,51 @@
-- 041: outreach — per-workspace list of public accounts to contact by hand.
--
-- The Hub never sends these messages. A person opens the contact's profile,
-- pastes the drafted text into the platform's own DM box and marks the row
-- sent; replies are marked the same way. Only public accounts belong here:
-- names and contact details of private persons stay out of the Hub.
CREATE TABLE outreach_contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200),
platform text NOT NULL CHECK (platform ~ '^[a-z0-9][a-z0-9-]{1,31}$'),
handle text NOT NULL CHECK (char_length(handle) BETWEEN 1 AND 200),
profile_url text NOT NULL CHECK (profile_url ~ '^https://[^\s]+$' AND char_length(profile_url) <= 500),
category text CHECK (category IS NULL OR char_length(category) <= 100),
language text CHECK (language IS NULL OR language ~ '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$'),
message text NOT NULL DEFAULT '' CHECK (char_length(message) <= 4000),
notes text NOT NULL DEFAULT '' CHECK (char_length(notes) <= 2000),
status text NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'sent', 'replied', 'declined')),
sent_at timestamptz,
sent_by uuid REFERENCES users(id) ON DELETE SET NULL,
replied_at timestamptz,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (id, workspace_id)
);
CREATE UNIQUE INDEX outreach_contacts_account ON outreach_contacts (workspace_id, platform, lower(handle));
CREATE INDEX outreach_contacts_status ON outreach_contacts (workspace_id, status);
CREATE TRIGGER trg_outreach_contacts_ws_immutable BEFORE UPDATE ON outreach_contacts
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE outreach_contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE outreach_contacts FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON outreach_contacts
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON outreach_contacts TO sp_app;
-- Reads for anyone who already sees the inbox; edits for the roles that write drafts.
INSERT INTO role_permissions (role, permission)
SELECT 'Owner', 'outreach.view'
UNION ALL SELECT 'Admin', 'outreach.view'
UNION ALL SELECT 'Editor', 'outreach.view'
UNION ALL SELECT 'Moderator', 'outreach.view'
UNION ALL SELECT 'Owner', 'outreach.manage'
UNION ALL SELECT 'Admin', 'outreach.manage'
UNION ALL SELECT 'Editor', 'outreach.manage'
ON CONFLICT DO NOTHING;

View file

@ -0,0 +1,11 @@
-- 042: media_assets row-level security.
--
-- Enable and force the tenant_isolation policy declared in 038, with an
-- explicit WITH CHECK. All media_assets queries run inside withTenant.
ALTER TABLE media_assets ENABLE ROW LEVEL SECURITY;
ALTER TABLE media_assets FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tenant_isolation ON media_assets;
CREATE POLICY tenant_isolation ON media_assets
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);

View file

@ -13,6 +13,7 @@
"dependencies": {
"@tactical-media-broadcast-hub/connector-sdk": "workspace:*",
"@tactical-media-broadcast-hub/connector-telegram": "workspace:*",
"@tactical-media-broadcast-hub/connector-instagram": "workspace:*",
"@tactical-media-broadcast-hub/core": "workspace:*",
"@aws-sdk/client-s3": "^3.1146.0",
"@aws-sdk/s3-request-presigner": "^3.1146.0",

View file

@ -43,9 +43,10 @@ import { MatrixSsoModule } from "./matrix-sso/matrix-sso.module.js";
import { OidcModule } from "./oidc/oidc.module.js";
import { SubmissionsModule } from "./submissions/submissions.module.js";
import { MediaModule } from "./media/media.module.js";
import { OutreachModule } from "./outreach/outreach.module.js";
@Module({
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule, SubmissionsModule, MediaModule],
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule, MatrixSsoModule, OidcModule, SubmissionsModule, MediaModule, OutreachModule],
controllers: [
DemoController,
PublishingController,

View file

@ -9,6 +9,8 @@ import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
import { createMastodonConnector } from "../../../../packages/connector-mastodon/src/index.js";
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js";
import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram";
import { connectionRegistry } from "./connector-registry.js";
import { Vault, type SealedCredential } from "../vault/vault.js";
import type { SessionPayload } from "../auth/session.js";
import { RbacGuard } from "../rbac/rbac.guard.js";
@ -105,12 +107,13 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
let issued = 0;
let failExchange = false;
let bridgeConfigured = false;
let instagramConfigured = false;
const botToken = "12345:bot_token_secret";
beforeEach(() => {
store.rows.clear(); store.audit.length = 0; store.authorized = true;
redis.entries.clear(); activeTokens.clear(); grants.clear(); revokedTokens.length = 0;
nextAccount = "account-1"; issued = 0; failExchange = false; bridgeConfigured = false;
nextAccount = "account-1"; issued = 0; failExchange = false; bridgeConfigured = false; instagramConfigured = false;
warn.mockClear(); info.mockClear();
});
@ -180,6 +183,9 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
}));
const botFetch: typeof fetch = (input, init) => fetch(String(input).replace("https://api.telegram.org", origin), init);
registry.register(createTelegramConnector({ credentials, fetchImpl: botFetch, requestsPerSecond: 100_000 }));
registry.register(createInstagramConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000,
env: instagramConfigured ? { INSTAGRAM_CLIENT_ID: "hub-app-id", INSTAGRAM_CLIENT_SECRET: "hub-app-secret" } : {},
}));
return registry;
},
};
@ -342,6 +348,25 @@ describe("HTTP OAuth connect with real connectors and mock authorization server"
bridgeConfigured = false;
});
it("offers Instagram only with the Hub's Facebook app configured, and keeps the app secret out of the browser", async () => {
expect(connectionRegistry({} as never).has("instagram")).toBe(true);
const providersOf = async () => await (await api("/providers")).json() as Array<{ id: string; enabled: boolean; note?: string }>;
expect((await providersOf()).find((provider) => provider.id === "instagram")).toMatchObject({ enabled: false, note: expect.stringContaining("INSTAGRAM_CLIENT_ID") });
expect((await api("/authorize", "POST", { provider: "instagram" })).status).toBe(400);
instagramConfigured = true;
expect((await providersOf()).find((provider) => provider.id === "instagram")?.enabled).toBe(true);
const response = await api("/authorize", "POST", { provider: "instagram" });
expect(response.status).toBe(201);
const { redirectUrl } = await response.json() as { redirectUrl: string };
const dialog = new URL(redirectUrl);
expect(dialog.origin + dialog.pathname).toBe("https://www.facebook.com/v21.0/dialog/oauth");
expect(dialog.searchParams.get("client_id")).toBe("hub-app-id");
expect(dialog.searchParams.get("redirect_uri")).toBe(`${apiOrigin}/connections/callback`);
expect(dialog.searchParams.get("state")).toMatch(/.{32,}/);
expect(redirectUrl).not.toContain("hub-app-secret");
instagramConfigured = false;
});
it("sets, validates and clears per-connection publish governance", async () => {
await finish(await begin());
const id = (await listed())[0]?.id ?? "";

View file

@ -1,5 +1,7 @@
import { ConnectorRegistry, type CredentialAccessor } from "@tactical-media-broadcast-hub/connector-sdk";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js";
@ -13,5 +15,6 @@ export function connectionRegistry(credentials: CredentialAccessor, fetchImpl: t
registry.register(createMastodonConnector(options));
registry.register(createPeerTubeConnector(options));
registry.register(createTelegramConnector(options));
registry.register(createInstagramConnector(options));
return registry;
}

View file

@ -170,7 +170,7 @@ export class MediaService {
/** Auth-scoped read: RLS already fences this to the caller's workspace. */
async readAsset(workspaceId: string, id: string): Promise<MediaAssetRecord & { bytes: Buffer }> {
const asset = await withTenant(workspaceId, async (client) => {
const result = await client.query<MediaRow>("SELECT * FROM media_assets WHERE id = $1", [id]);
const result = await client.query<MediaRow>("SELECT * FROM media_assets WHERE id = $1 AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid", [id]);
const row = result.rows[0];
if (row === undefined) throw new NotFoundException(`media asset ${id} not found`);
return fromRow(row);
@ -192,7 +192,7 @@ export class MediaService {
): Promise<void> {
if (mediaAssetIds.length === 0) return;
const result = await client.query(
"SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[])",
"SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[]) AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid",
[mediaAssetIds],
);
const count = Number(result.rows[0]?.count ?? 0);

View file

@ -0,0 +1,141 @@
/**
* Outreach contacts (manual DM campaign tracking).
*
* GET /outreach/contacts?status= outreach.view — list (≤ 2000)
* POST /outreach/contacts outreach.manage — { contacts: [...] } bulk add, ≤ 500
* PATCH /outreach/contacts/:id outreach.manage — { message?, notes?, status? }
* DELETE /outreach/contacts/:id outreach.manage
*
* No route sends a message: see OutreachService.
*/
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
Inject,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { requireObject, requireSession } from "../http/validation.js";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import {
OUTREACH_MAX_IMPORT,
OutreachService,
isOutreachStatus,
type OutreachContact,
type OutreachContactInput,
type OutreachContactPatch,
type OutreachStatus,
} from "./outreach.service.js";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const PLATFORM = /^[a-z0-9][a-z0-9-]{1,31}$/;
const LANGUAGE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})?$/;
@Controller("outreach")
@UseGuards(RbacGuard)
export class OutreachController {
constructor(@Inject(OutreachService) private readonly outreach: OutreachService) {}
@Get("contacts")
@RequirePermission("outreach.view")
async list(@Req() request: AuthenticatedRequest, @Query("status") status?: string): Promise<OutreachContact[]> {
const session = requireSession(request);
if (status !== undefined && !isOutreachStatus(status)) throw new BadRequestException("unknown status");
return this.outreach.list(session.workspaceId, status as OutreachStatus | undefined);
}
@Post("contacts")
@RequirePermission("outreach.manage")
async import(@Req() request: AuthenticatedRequest, @Body() body: unknown): Promise<{ added: number; skipped: number }> {
const session = requireSession(request);
const raw = requireObject(body, "body").contacts;
if (!Array.isArray(raw) || raw.length === 0 || raw.length > OUTREACH_MAX_IMPORT) {
throw new BadRequestException(`contacts must be an array of 1 to ${OUTREACH_MAX_IMPORT} items`);
}
const contacts = raw.map((item, index) => parseContact(item, `contacts[${index}]`));
return this.outreach.import(session.workspaceId, contacts, { userId: session.userId, role: session.role });
}
@Patch("contacts/:id")
@RequirePermission("outreach.manage")
async update(@Req() request: AuthenticatedRequest, @Param("id") id: string, @Body() body: unknown): Promise<OutreachContact> {
const session = requireSession(request);
if (!UUID.test(id)) throw new BadRequestException("id must be a UUID");
return this.outreach.update(session.workspaceId, id, parsePatch(body), { userId: session.userId, role: session.role });
}
@Delete("contacts/:id")
@HttpCode(204)
@RequirePermission("outreach.manage")
async remove(@Req() request: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
const session = requireSession(request);
if (!UUID.test(id)) throw new BadRequestException("id must be a UUID");
await this.outreach.remove(session.workspaceId, id, { userId: session.userId, role: session.role });
}
}
function text(value: unknown, field: string, max: number, required: boolean): string | undefined {
if (value === undefined || value === null || value === "") {
if (required) throw new BadRequestException(`${field} is required`);
return undefined;
}
if (typeof value !== "string") throw new BadRequestException(`${field} must be a string`);
const trimmed = value.trim();
if (required && trimmed === "") throw new BadRequestException(`${field} is required`);
if (trimmed.length > max) throw new BadRequestException(`${field} must be at most ${max} characters`);
return trimmed;
}
export function parseContact(value: unknown, field: string): OutreachContactInput {
const item = requireObject(value, field);
const name = text(item.name, `${field}.name`, 200, true) as string;
const platform = (text(item.platform, `${field}.platform`, 32, true) as string).toLowerCase();
if (!PLATFORM.test(platform)) throw new BadRequestException(`${field}.platform must be a lowercase id`);
const handle = text(item.handle, `${field}.handle`, 200, true) as string;
const profileUrl = text(item.profileUrl, `${field}.profileUrl`, 500, true) as string;
let url: URL;
try {
url = new URL(profileUrl);
} catch {
throw new BadRequestException(`${field}.profileUrl must be a URL`);
}
if (url.protocol !== "https:" || /\s/.test(profileUrl)) throw new BadRequestException(`${field}.profileUrl must be an https URL`);
const language = text(item.language, `${field}.language`, 12, false);
if (language !== undefined && !LANGUAGE.test(language)) throw new BadRequestException(`${field}.language must be a language tag such as es or pt-BR`);
const contact: OutreachContactInput = { name, platform, handle, profileUrl };
const category = text(item.category, `${field}.category`, 100, false);
const message = text(item.message, `${field}.message`, 4000, false);
const notes = text(item.notes, `${field}.notes`, 2000, false);
if (category !== undefined) contact.category = category;
if (language !== undefined) contact.language = language;
if (message !== undefined) contact.message = message;
if (notes !== undefined) contact.notes = notes;
return contact;
}
export function parsePatch(value: unknown): OutreachContactPatch {
const body = requireObject(value, "body");
const patch: OutreachContactPatch = {};
if (body.message !== undefined) {
if (typeof body.message !== "string" || body.message.length > 4000) throw new BadRequestException("message must be a string of at most 4000 characters");
patch.message = body.message;
}
if (body.notes !== undefined) {
if (typeof body.notes !== "string" || body.notes.length > 2000) throw new BadRequestException("notes must be a string of at most 2000 characters");
patch.notes = body.notes;
}
if (body.status !== undefined) {
if (!isOutreachStatus(body.status)) throw new BadRequestException("status must be todo, sent, replied or declined");
patch.status = body.status;
}
if (Object.keys(patch).length === 0) throw new BadRequestException("nothing to update");
return patch;
}

View file

@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { OutreachController } from "./outreach.controller.js";
import { OutreachService } from "./outreach.service.js";
/**
* Outreach contacts: the list of public accounts a collective writes to by
* hand, with the drafted message and the sent/replied state per contact.
*/
@Module({
controllers: [OutreachController],
providers: [OutreachService],
})
export class OutreachModule {}

View file

@ -0,0 +1,229 @@
/**
* Outreach: a workspace's list of public accounts to contact one by one.
*
* The Hub stores the contact, a drafted message and where the conversation
* stands (todo → sent → replied/declined). It never sends anything: the
* person copies the text, opens the profile and writes from the platform's
* own DM box. Platforms forbid or rate-limit unsolicited bulk DMs, so
* sending stays human and the API has no provider calls here.
*
* Audit entries record status changes and membership of the list, never the
* message text.
*/
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { appendTenantAudit } from "../audit/audit.service.js";
import { withTenant } from "../db/client.js";
export const OUTREACH_STATUSES = ["todo", "sent", "replied", "declined"] as const;
export type OutreachStatus = (typeof OUTREACH_STATUSES)[number];
/** One import call; larger lists go in several requests. */
export const OUTREACH_MAX_IMPORT = 500;
export const OUTREACH_MAX_LIST = 2000;
export interface OutreachContactInput {
name: string;
platform: string;
handle: string;
profileUrl: string;
category?: string;
language?: string;
message?: string;
notes?: string;
}
export interface OutreachContactPatch {
message?: string;
notes?: string;
status?: OutreachStatus;
}
export interface OutreachContact {
id: string;
name: string;
platform: string;
handle: string;
profileUrl: string;
category: string | null;
language: string | null;
message: string;
notes: string;
status: OutreachStatus;
sentAt: string | null;
repliedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface OutreachActor {
userId: string;
role: string;
}
interface OutreachRow {
id: string;
name: string;
platform: string;
handle: string;
profile_url: string;
category: string | null;
language: string | null;
message: string;
notes: string;
status: OutreachStatus;
sent_at: Date | null;
replied_at: Date | null;
created_at: Date;
updated_at: Date;
}
const COLUMNS = "id, name, platform, handle, profile_url, category, language, message, notes, status, sent_at, replied_at, created_at, updated_at";
function fromRow(row: OutreachRow): OutreachContact {
return {
id: row.id,
name: row.name,
platform: row.platform,
handle: row.handle,
profileUrl: row.profile_url,
category: row.category,
language: row.language,
message: row.message,
notes: row.notes,
status: row.status,
sentAt: row.sent_at?.toISOString() ?? null,
repliedAt: row.replied_at?.toISOString() ?? null,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}
export function isOutreachStatus(value: unknown): value is OutreachStatus {
return typeof value === "string" && (OUTREACH_STATUSES as readonly string[]).includes(value);
}
@Injectable()
export class OutreachService {
async list(workspaceId: string, status?: OutreachStatus): Promise<OutreachContact[]> {
return withTenant(workspaceId, async (client) => {
const result = await client.query<OutreachRow>(
`SELECT ${COLUMNS} FROM outreach_contacts
WHERE ($1::text IS NULL OR status = $1)
ORDER BY lower(name), platform
LIMIT ${OUTREACH_MAX_LIST}`,
[status ?? null],
);
return result.rows.map(fromRow);
});
}
/** Bulk add; an account already on the list (same platform + handle) is skipped. */
async import(workspaceId: string, contacts: OutreachContactInput[], actor: OutreachActor): Promise<{ added: number; skipped: number }> {
if (contacts.length === 0) throw new BadRequestException("contacts must not be empty");
if (contacts.length > OUTREACH_MAX_IMPORT) {
throw new BadRequestException(`at most ${OUTREACH_MAX_IMPORT} contacts per import`);
}
const added = await withTenant(workspaceId, async (client) => {
const result = await client.query<{ id: string }>(
`INSERT INTO outreach_contacts
(workspace_id, name, platform, handle, profile_url, category, language, message, notes, created_by)
SELECT $1, c.name, c.platform, c.handle, c.profile_url, c.category, c.language,
coalesce(c.message, ''), coalesce(c.notes, ''), $2
FROM jsonb_to_recordset($3::jsonb) AS c(
name text, platform text, handle text, profile_url text,
category text, language text, message text, notes text)
ON CONFLICT (workspace_id, platform, lower(handle)) DO NOTHING
RETURNING id`,
[workspaceId, actor.userId, JSON.stringify(contacts.map(toRecord))],
);
return result.rowCount ?? result.rows.length;
});
if (added > 0) {
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "outreach.imported",
targetType: "outreach_contact",
targetId: workspaceId,
diff: { added, skipped: contacts.length - added },
});
}
return { added, skipped: contacts.length - added };
}
/**
* Edit the draft/notes or move the status. Marking `sent` stamps who and
* when; `replied` stamps when; going back to `todo` clears both.
*/
async update(workspaceId: string, id: string, patch: OutreachContactPatch, actor: OutreachActor): Promise<OutreachContact> {
const { contact, previous } = await withTenant(workspaceId, async (client) => {
const current = await client.query<{ status: OutreachStatus }>(
"SELECT status FROM outreach_contacts WHERE id = $1 FOR UPDATE",
[id],
);
const before = current.rows[0];
if (before === undefined) throw new NotFoundException(`outreach contact ${id} not found`);
const status = patch.status ?? before.status;
const changed = status !== before.status;
const result = await client.query<OutreachRow>(
`UPDATE outreach_contacts SET
message = coalesce($2, message),
notes = coalesce($3, notes),
status = $4,
sent_at = CASE WHEN NOT $5 THEN sent_at WHEN $4 = 'todo' THEN NULL WHEN $4 = 'sent' THEN now() ELSE coalesce(sent_at, now()) END,
sent_by = CASE WHEN NOT $5 THEN sent_by WHEN $4 = 'todo' THEN NULL WHEN $4 = 'sent' THEN $6::uuid ELSE coalesce(sent_by, $6::uuid) END,
replied_at = CASE WHEN NOT $5 THEN replied_at WHEN $4 = 'replied' THEN now() ELSE NULL END,
updated_at = now()
WHERE id = $1
RETURNING ${COLUMNS}`,
[id, patch.message ?? null, patch.notes ?? null, status, changed, actor.userId],
);
const row = result.rows[0];
if (row === undefined) throw new NotFoundException(`outreach contact ${id} not found`);
return { contact: fromRow(row), previous: before.status };
});
if (contact.status !== previous) {
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: `outreach.${contact.status}`,
targetType: "outreach_contact",
targetId: contact.id,
diff: { from: previous, to: contact.status, platform: contact.platform, handle: contact.handle },
});
}
return contact;
}
async remove(workspaceId: string, id: string, actor: OutreachActor): Promise<void> {
const removed = await withTenant(workspaceId, async (client) => {
const result = await client.query<{ platform: string; handle: string }>(
"DELETE FROM outreach_contacts WHERE id = $1 RETURNING platform, handle",
[id],
);
return result.rows[0];
});
if (removed === undefined) throw new NotFoundException(`outreach contact ${id} not found`);
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "outreach.removed",
targetType: "outreach_contact",
targetId: id,
diff: { platform: removed.platform, handle: removed.handle },
});
}
}
function toRecord(contact: OutreachContactInput): Record<string, string | null> {
return {
name: contact.name,
platform: contact.platform,
handle: contact.handle,
profile_url: contact.profileUrl,
category: contact.category ?? null,
language: contact.language ?? null,
message: contact.message ?? null,
notes: contact.notes ?? null,
};
}

View file

@ -0,0 +1,136 @@
import "reflect-metadata";
import type { PoolClient } from "pg";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { appendTenantAudit } from "../audit/audit.service.js";
import { parseContact, parsePatch } from "./outreach.controller.js";
import { OutreachService } from "./outreach.service.js";
/**
* Outreach service + request parsing with the DB faked. RLS and the migration
* shape belong to a live-DB suite.
*/
const dbMock = vi.hoisted(() => ({ withTenant: vi.fn() }));
vi.mock("../db/client.js", () => ({ withTenant: dbMock.withTenant }));
vi.mock("../audit/audit.service.js", () => ({ appendTenantAudit: vi.fn(async () => "audit-id") }));
const WS = "0d9a1b40-6f0f-4b1e-8f0e-3f3c9e6b4a10";
const ID = "11111111-2222-4333-8444-555555555555";
const ACTOR = { userId: "8f7f6e50-7b0f-4a2e-9d3e-2f2d1a0c5b21", role: "Editor" };
const row = (overrides: Record<string, unknown> = {}) => ({
id: ID,
name: "Climáximo",
platform: "bluesky",
handle: "climaximopt.bsky.social",
profile_url: "https://bsky.app/profile/climaximopt.bsky.social",
category: "clima",
language: "pt",
message: "Olá!",
notes: "",
status: "todo",
sent_at: null,
replied_at: null,
created_at: new Date("2026-10-15T10:00:00Z"),
updated_at: new Date("2026-10-15T10:00:00Z"),
...overrides,
});
type Query = (sql: string, values?: unknown[]) => Promise<{ rows: unknown[]; rowCount?: number }>;
function tenant(query: Query): Array<{ sql: string; values: unknown[] }> {
const calls: Array<{ sql: string; values: unknown[] }> = [];
dbMock.withTenant.mockImplementation(async (_ws: string, fn: (client: PoolClient) => Promise<unknown>) =>
fn({
query: async (sql: string, values: unknown[] = []) => {
calls.push({ sql, values });
return query(sql, values);
},
} as unknown as PoolClient),
);
return calls;
}
beforeEach(() => {
dbMock.withTenant.mockReset();
vi.mocked(appendTenantAudit).mockClear();
});
describe("parseContact", () => {
const valid = { name: " DAL ", platform: "Instagram-x", handle: "@federationdal", profileUrl: "https://example.org/federationdal", language: "fr" };
it("trims and lowercases the platform id", () => {
expect(parseContact(valid, "c")).toEqual({ name: "DAL", platform: "instagram-x", handle: "@federationdal", profileUrl: "https://example.org/federationdal", language: "fr" });
});
it("rejects non-https profile links and odd platform ids", () => {
expect(() => parseContact({ ...valid, profileUrl: "http://example.org/x" }, "c")).toThrow(/https/);
expect(() => parseContact({ ...valid, profileUrl: "javascript:alert(1)" }, "c")).toThrow(/https/);
expect(() => parseContact({ ...valid, platform: "x y" }, "c")).toThrow(/platform/);
expect(() => parseContact({ ...valid, language: "spanish" }, "c")).toThrow(/language/);
expect(() => parseContact({ ...valid, name: " " }, "c")).toThrow(/name/);
});
});
describe("parsePatch", () => {
it("accepts the known statuses only and refuses an empty patch", () => {
expect(parsePatch({ status: "sent" })).toEqual({ status: "sent" });
expect(() => parsePatch({ status: "spam" })).toThrow(/status/);
expect(() => parsePatch({})).toThrow(/nothing/);
});
});
describe("OutreachService", () => {
const service = new OutreachService();
it("imports in one statement, skips known accounts and audits counts only", async () => {
const calls = tenant(async () => ({ rows: [{ id: ID }], rowCount: 1 }));
const result = await service.import(WS, [
{ name: "A", platform: "bluesky", handle: "a", profileUrl: "https://bsky.app/profile/a", message: "private draft" },
{ name: "B", platform: "bluesky", handle: "b", profileUrl: "https://bsky.app/profile/b" },
], ACTOR);
expect(result).toEqual({ added: 1, skipped: 1 });
expect(calls).toHaveLength(1);
expect(calls[0]?.sql).toContain("ON CONFLICT (workspace_id, platform, lower(handle)) DO NOTHING");
expect(JSON.parse(String(calls[0]?.values[2]))).toHaveLength(2);
expect(appendTenantAudit).toHaveBeenCalledWith(WS, expect.objectContaining({ action: "outreach.imported", diff: { added: 1, skipped: 1 } }));
expect(JSON.stringify(vi.mocked(appendTenantAudit).mock.calls)).not.toContain("private draft");
});
it("does not audit an import that added nothing", async () => {
tenant(async () => ({ rows: [], rowCount: 0 }));
expect(await service.import(WS, [{ name: "A", platform: "bluesky", handle: "a", profileUrl: "https://bsky.app/profile/a" }], ACTOR)).toEqual({ added: 0, skipped: 1 });
expect(appendTenantAudit).not.toHaveBeenCalled();
});
it("marks a contact sent and audits the transition without the message", async () => {
const calls = tenant(async (sql) => {
if (sql.startsWith("SELECT status")) return { rows: [{ status: "todo" }] };
if (sql.startsWith("UPDATE outreach_contacts")) return { rows: [row({ status: "sent", message: "Olá, secret text", sent_at: new Date("2026-10-15T11:00:00Z") })] };
throw new Error(`unexpected query: ${sql}`);
});
const contact = await service.update(WS, ID, { status: "sent", message: "Olá, secret text" }, ACTOR);
expect(contact.status).toBe("sent");
expect(contact.sentAt).toBe("2026-10-15T11:00:00.000Z");
expect(calls[1]?.values).toEqual([ID, "Olá, secret text", null, "sent", true, ACTOR.userId]);
expect(appendTenantAudit).toHaveBeenCalledWith(WS, expect.objectContaining({ action: "outreach.sent", diff: expect.objectContaining({ from: "todo", to: "sent" }) }));
expect(JSON.stringify(vi.mocked(appendTenantAudit).mock.calls)).not.toContain("secret text");
});
it("edits the draft without a status change and without an audit entry", async () => {
const calls = tenant(async (sql) => {
if (sql.startsWith("SELECT status")) return { rows: [{ status: "sent" }] };
return { rows: [row({ status: "sent", message: "v2" })] };
});
await service.update(WS, ID, { message: "v2" }, ACTOR);
expect(calls[1]?.values).toEqual([ID, "v2", null, "sent", false, ACTOR.userId]);
expect(appendTenantAudit).not.toHaveBeenCalled();
});
it("404s on an unknown contact", async () => {
tenant(async () => ({ rows: [] }));
await expect(service.update(WS, ID, { status: "sent" }, ACTOR)).rejects.toThrow(/not found/);
await expect(service.remove(WS, ID, ACTOR)).rejects.toThrow(/not found/);
expect(appendTenantAudit).not.toHaveBeenCalled();
});
});

View file

@ -1,5 +1,7 @@
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException, Optional, ConflictException } from "@nestjs/common";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
import { isPublishingProvider, providerFetch } from "@tactical-media-broadcast-hub/connector-runtime";
import { ConnectorRegistry, type CredentialAccessor, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
@ -129,6 +131,7 @@ function connectorFor(provider: string): PlatformConnector {
};
registry.register(createMastodonConnector(options));
registry.register(createTelegramConnector(options));
registry.register(createInstagramConnector(options));
return registry;
})();
return publishingConnectors.get(provider);
@ -654,7 +657,7 @@ export class PublishingService {
async function assertMediaAssetsExist(client: PoolClient, mediaAssetIds: string[]): Promise<void> {
if (mediaAssetIds.length === 0) return;
const result = await client.query<{ count: string | number }>(
"SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[])",
"SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[]) AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid",
[mediaAssetIds],
);
if (Number(result.rows[0]?.count ?? 0) !== mediaAssetIds.length) {

View file

@ -16,6 +16,8 @@ export interface FakeVideoDatabase {
transitions?: string[];
/** Raw INSERT statements seen, for asserting what intake wrote. */
statements?: Array<{ sql: string; values: readonly unknown[] }>;
/** Image storage keys this workspace holds (logo overlay ownership). */
ownedImageKeys?: string[];
}
function result(rows: Array<Record<string, unknown>>): QueryResult<Record<string, unknown>> {
@ -88,6 +90,9 @@ export function serviceWithDatabase(
if (sql === "SELECT * FROM video_assets WHERE id = $1") {
return result(database.asset === undefined ? [] : [database.asset]);
}
if (sql.includes("branding->>'logoAssetKey'")) {
return result(database.ownedImageKeys?.includes(String(values[0])) === true ? [{}] : []);
}
if (sql.includes("SET state = 'probing'")) {
if (database.job?.state !== "queued") return result([]);
database.job.state = "probing";

View file

@ -148,6 +148,19 @@ describe("video byte ingest and processing-job state machine", () => {
});
});
it("accepts a logo overlay only when the image belongs to the workspace", async () => {
const owned = `sha256/${"a".repeat(64)}.png`;
const foreign = `sha256/${"b".repeat(64)}.png`;
const database: FakeVideoDatabase = { ownedImageKeys: [owned] };
const { service } = serviceWithDatabase(database, async () => ({}));
await service.uploadVideoBytes(workspaceId, Buffer.from("source"), "video/mp4");
const logo = (key: string): ProduceRequest => ({ overlays: [{ kind: "logo", logoStorageKey: key, position: { x: 90, y: 5 } }] });
await expect(service.produceVideo(assetId, workspaceId, logo(foreign), "u")).rejects.toMatchObject({ status: 400 });
expect(database.statements?.some((s) => s.sql.includes("INSERT INTO video_processing_jobs") && String(s.values[3]).includes(foreign))).toBe(false);
const produced = await service.produceVideo(assetId, workspaceId, logo(owned), "u");
expect(produced.job.state).toBe("queued");
});
it("refuses to produce from an unknown asset", async () => {
const { service } = serviceWithDatabase({}, async () => ({}));
await expect(service.produceVideo("30000000-0000-4000-8000-000000000005", workspaceId, {}, "u")).rejects.toMatchObject({ status: 404 });

View file

@ -381,6 +381,7 @@ export class VideoService {
const created = await this.dependencies.tenantTransaction(workspaceId, async (client) => {
const source = await client.query("SELECT * FROM video_assets WHERE id = $1", [assetId]);
if (source.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`);
await assertLogoKeysOwned(client, workspaceId, request);
const jobResult = await client.query(
`INSERT INTO video_processing_jobs (workspace_id, video_asset_id, state, kind, input_json)
VALUES ($1, $2, 'queued', $3, $4::jsonb) RETURNING *`,
@ -595,3 +596,25 @@ function decodeCursor(cursor: string): VideoCursor {
function isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
/**
* Storage keys are content-addressed in one shared bucket, so a well-formed key
* alone proves nothing about who owns the object. A logo overlay may only use
* an image this workspace holds: an attachment, a group asset or its branding.
*/
async function assertLogoKeysOwned(
client: { query: (sql: string, values: unknown[]) => Promise<{ rowCount: number | null }> },
workspaceId: string,
request: ProduceRequest,
): Promise<void> {
const keys = [...new Set((request.overlays ?? []).flatMap((overlay) => (overlay.logoStorageKey === undefined ? [] : [overlay.logoStorageKey])))];
for (const key of keys) {
const owned = await client.query(
`SELECT 1 WHERE EXISTS (SELECT 1 FROM media_assets WHERE storage_key = $1 AND workspace_id = $2)
OR EXISTS (SELECT 1 FROM group_assets WHERE asset_id = $1 AND workspace_id = $2)
OR EXISTS (SELECT 1 FROM workspaces WHERE id = $2 AND branding->>'logoAssetKey' = $1)`,
[key, workspaceId],
);
if (owned.rowCount !== 1) throw new BadRequestException("logoStorageKey is not an image of this collective");
}
}

View file

@ -8,7 +8,8 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test:manual": "node --require ./scripts/control-room-register.cjs --test src/lib/participation-manual.test.ts",
"test:register": "node --require ./scripts/control-room-register.cjs --test src/lib/register-form.test.ts"
"test:register": "node --require ./scripts/control-room-register.cjs --test src/lib/register-form.test.ts",
"test:outreach": "node --require ./scripts/control-room-register.cjs --test src/lib/outreach-types.test.ts"
},
"dependencies": {
"@tactical-media-broadcast-hub/connector-sdk": "workspace:^",

View file

@ -1,11 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 1700 1700">
<g transform="rotate(-17.5 850 850)" opacity="0.25">
<circle cx="850" cy="850" r="350" fill="none" stroke="#FF1610" stroke-width="60" stroke-linecap="round"/>
<circle cx="850" cy="850" r="350" fill="none" stroke="#000000" stroke-width="60" stroke-linecap="round"/>
<path d="M 850 405 L 464.77 1269.52 M 850 405 L 1235.23 1269.52 M 345 850 L 1355 850"
fill="none" stroke="#FF1610" stroke-width="75" stroke-linecap="round" stroke-linejoin="round"/>
fill="none" stroke="#000000" stroke-width="75" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<g opacity="0.6">
<polygon points="1450,850 1550,810 1550,890" fill="#FF1610"/>
<line x1="1280" y1="850" x2="1470" y2="850" stroke="#FF1610" stroke-width="30" stroke-linecap="round"/>
<polygon points="1450,850 1550,810 1550,890" fill="#000000"/>
<line x1="1280" y1="850" x2="1470" y2="850" stroke="#000000" stroke-width="30" stroke-linecap="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 665 B

After

Width:  |  Height:  |  Size: 665 B

Before After
Before After

View file

@ -1,7 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<g transform="rotate(-17.5 850 850)">
<circle cx="850" cy="850" r="350" fill="none" stroke="#FF1610" stroke-width="74" stroke-linecap="round"/>
<path d="M 850 405 L 464.77 1269.52 M 850 405 L 1235.23 1269.52 M 345 850 L 1355 850"
fill="none" stroke="#FF1610" stroke-width="88" stroke-linecap="round" stroke-linejoin="round"/>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 400 400" width="400" height="400" role="img" aria-labelledby="t">
<title id="t">T-HUB</title>
<defs>
<!-- Dome tent in the hand-inked style of the acampada26s.net mark:
curved poles with ball ends, centre seam, hatched panel, door.
Base midpoint at 0,0. -->
<g id="tienda" stroke-linecap="round" stroke-linejoin="round">
<path fill="none" d="M-58 0 Q-56 -66 -1 -72 Q55 -66 58 0" stroke-width="7"/>
<path fill="none" d="M-64 1 Q0 -3 64 1" stroke-width="6"/>
<path fill="none" d="M-44 0 Q-30 -58 -1 -72" stroke-width="4.5"/>
<path fill="none" d="M44 0 Q30 -58 -1 -72" stroke-width="4.5"/>
<path d="M-15 0 Q-14 -30 0 -33 Q14 -30 15 0 Z" stroke-width="4"/>
<path fill="none" d="M-50 -14 L-41 -25 M-50 -4 L-37 -19 M-43 -1 L-33 -13 M-52 -27 L-45 -36" stroke-width="2.6"/>
<path fill="none" d="M28 -50 L37 -42 M24 -40 L36 -30 M22 -29 L34 -19" stroke-width="2.6"/>
<circle cx="-64" cy="1" r="5.5" stroke="none"/>
<circle cx="64" cy="1" r="5.5" stroke="none"/>
<circle cx="-1" cy="-73" r="5.5" stroke="none"/>
</g>
<g id="marca" stroke-linecap="round" stroke-linejoin="round">
<!-- cables: out of each tent door to the switch -->
<path fill="none" d="M105 150 Q112 206 168 214" stroke-width="5"/>
<path fill="none" d="M295 150 Q288 206 232 214" stroke-width="5"/>
<path fill="none" d="M200 318 Q197 280 200 236" stroke-width="5"/>
<!-- antenna -->
<path fill="none" d="M200 200 Q198 160 200 118" stroke-width="6"/>
<path fill="none" d="M184 98 Q200 82 216 98" stroke-width="5"/>
<path fill="none" d="M171 88 Q200 58 229 88" stroke-width="5"/>
<circle cx="200" cy="112" r="7" stroke="none"/>
<!-- switch -->
<path d="M168 201 L233 200 L234 235 L167 236 Z" stroke-width="6" fill="#fff"/>
<path d="M179 212 h9 v11 h-9 z M196 212 h9 v11 h-9 z M213 212 h9 v11 h-9 z" stroke="none"/>
<use xlink:href="#tienda" transform="translate(105,150)"/>
<use xlink:href="#tienda" transform="translate(295,150)"/>
<use xlink:href="#tienda" transform="translate(200,340)"/>
</g>
</defs>
<!-- neon offset underneath, ink on top, as on acampada26s.net -->
<g transform="translate(7,7)" stroke="#00e5ff" fill="#00e5ff">
<use xlink:href="#marca"/>
</g>
</svg>
<g stroke="#000" fill="#000">
<use xlink:href="#marca"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 447 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<title>Bluesky</title>
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
transform="rotate(-17.5 850 850)"/>
<path d="M 350 450 Q 400 350 500 400 L 600 480 L 700 400 Q 800 350 850 450 Q 850 550 750 650 L 600 780 L 450 650 Q 350 550 350 450 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 350 450 Q 400 350 500 400 L 600 480 L 700 400 Q 800 350 850 450 Q 850 550 750 650 L 600 780 L 450 650 Q 350 550 350 450 Z" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 483 B

After

Width:  |  Height:  |  Size: 483 B

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<title>Discord</title>
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
transform="rotate(-17.5 850 850)"/>
<path d="M 450 450 Q 550 400 600 400 Q 650 400 750 450 L 800 500 Q 850 600 800 700 L 750 750 L 700 700 L 650 720 L 550 720 L 500 700 L 450 750 L 400 700 Q 350 600 400 500 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linejoin="round"/><circle cx="520" cy="570" r="18" fill="#FF1610"/><circle cx="680" cy="570" r="18" fill="#FF1610"/>
<path d="M 450 450 Q 550 400 600 400 Q 650 400 750 450 L 800 500 Q 850 600 800 700 L 750 750 L 700 700 L 650 720 L 550 720 L 500 700 L 450 750 L 400 700 Q 350 600 400 500 Z" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linejoin="round"/><circle cx="520" cy="570" r="18" fill="#00E5FF"/><circle cx="680" cy="570" r="18" fill="#00E5FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 598 B

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<title>Facebook</title>
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
transform="rotate(-17.5 850 850)"/>
<path d="M 500 450 L 500 550 L 450 550 L 450 650 L 500 650 L 500 850 L 600 850 L 600 650 L 680 650 L 700 550 L 600 550 L 600 480 Q 600 450 630 450 L 700 450 L 700 380 L 620 380 Q 500 380 500 450 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 500 450 L 500 550 L 450 550 L 450 650 L 500 650 L 500 850 L 600 850 L 600 650 L 680 650 L 700 550 L 600 550 L 600 480 Q 600 450 630 450 L 700 450 L 700 380 L 620 380 Q 500 380 500 450 Z" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 548 B

After

Width:  |  Height:  |  Size: 548 B

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<title>Instagram</title>
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
transform="rotate(-17.5 850 850)"/>
<rect x="350" y="350" width="500" height="500" rx="120" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="600" cy="600" r="130" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="770" cy="430" r="25" fill="#FF1610"/>
<rect x="350" y="350" width="500" height="500" rx="120" fill="none" stroke="#00E5FF" stroke-width="35"/><circle cx="600" cy="600" r="130" fill="none" stroke="#00E5FF" stroke-width="35"/><circle cx="770" cy="430" r="25" fill="#00E5FF"/>
</svg>

Before

Width:  |  Height:  |  Size: 491 B

After

Width:  |  Height:  |  Size: 491 B

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<title>Linkedin</title>
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
transform="rotate(-17.5 850 850)"/>
<rect x="350" y="350" width="500" height="100" rx="20" fill="none" stroke="#FF1610" stroke-width="35"/><line x1="400" y1="500" x2="400" y2="750" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="500" y1="500" x2="500" y2="750" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><path d="M 500 500 Q 550 450 600 500 L 600 600 Q 650 550 700 600 Q 750 650 700 700 Q 650 750 500 750" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="350" y="350" width="500" height="100" rx="20" fill="none" stroke="#00E5FF" stroke-width="35"/><line x1="400" y1="500" x2="400" y2="750" stroke="#00E5FF" stroke-width="35" stroke-linecap="round"/><line x1="500" y1="500" x2="500" y2="750" stroke="#00E5FF" stroke-width="35" stroke-linecap="round"/><path d="M 500 500 Q 550 450 600 500 L 600 600 Q 650 550 700 600 Q 750 650 700 700 Q 650 750 500 750" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 757 B

After

Width:  |  Height:  |  Size: 757 B

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<title>Mastodon</title>
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
transform="rotate(-17.5 850 850)"/>
<path d="M 400 500 L 400 700 Q 400 780 480 780 L 520 780 Q 560 780 560 740 L 560 560 Q 560 540 580 540 Q 600 540 600 560 L 600 740 Q 600 780 640 780 L 680 780 Q 760 780 760 700 L 760 500 Q 760 420 680 420 L 520 420 Q 440 420 400 500 Z M 400 500 L 360 460 Q 320 420 320 500 L 320 660 Q 320 740 380 760" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 400 500 L 400 700 Q 400 780 480 780 L 520 780 Q 560 780 560 740 L 560 560 Q 560 540 580 540 Q 600 540 600 560 L 600 740 Q 600 780 640 780 L 680 780 Q 760 780 760 700 L 760 500 Q 760 420 680 420 L 520 420 Q 440 420 400 500 Z M 400 500 L 360 460 Q 320 420 320 500 L 320 660 Q 320 740 380 760" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 652 B

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<title>Peertube</title>
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
transform="rotate(-17.5 850 850)"/>
<path d="M 400 350 L 400 850 L 800 600 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linejoin="round"/>
<path d="M 400 350 L 400 850 L 800 600 Z" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 369 B

After

Width:  |  Height:  |  Size: 369 B

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<title>Reddit</title>
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
transform="rotate(-17.5 850 850)"/>
<circle cx="600" cy="500" r="50" fill="#FF1610"/><path d="M 550 500 L 400 450" stroke="#FF1610" stroke-width="25" stroke-linecap="round"/><path d="M 650 500 L 800 450" stroke="#FF1610" stroke-width="25" stroke-linecap="round"/><ellipse cx="600" cy="650" rx="200" ry="140" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="500" cy="620" r="20" fill="#FF1610"/><circle cx="700" cy="620" r="20" fill="#FF1610"/><path d="M 520 700 Q 600 740 680 700" fill="none" stroke="#FF1610" stroke-width="25" stroke-linecap="round"/>
<circle cx="600" cy="500" r="50" fill="#00E5FF"/><path d="M 550 500 L 400 450" stroke="#00E5FF" stroke-width="25" stroke-linecap="round"/><path d="M 650 500 L 800 450" stroke="#00E5FF" stroke-width="25" stroke-linecap="round"/><ellipse cx="600" cy="650" rx="200" ry="140" fill="none" stroke="#00E5FF" stroke-width="35"/><circle cx="500" cy="620" r="20" fill="#00E5FF"/><circle cx="700" cy="620" r="20" fill="#00E5FF"/><path d="M 520 700 Q 600 740 680 700" fill="none" stroke="#00E5FF" stroke-width="25" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 780 B

After

Width:  |  Height:  |  Size: 780 B

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<title>Slack</title>
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
transform="rotate(-17.5 850 850)"/>
<rect x="450" y="350" width="80" height="140" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="670" y="350" width="80" height="140" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="350" y="530" width="140" height="80" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="710" y="530" width="140" height="80" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="450" y="700" width="80" height="140" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><rect x="670" y="700" width="80" height="140" rx="20" fill="none" stroke="#FF1610" stroke-width="30"/><path d="M 530 570 L 670 570" stroke="#FF1610" stroke-width="30" stroke-linecap="round"/>
<rect x="450" y="350" width="80" height="140" rx="20" fill="none" stroke="#00E5FF" stroke-width="30"/><rect x="670" y="350" width="80" height="140" rx="20" fill="none" stroke="#00E5FF" stroke-width="30"/><rect x="350" y="530" width="140" height="80" rx="20" fill="none" stroke="#00E5FF" stroke-width="30"/><rect x="710" y="530" width="140" height="80" rx="20" fill="none" stroke="#00E5FF" stroke-width="30"/><rect x="450" y="700" width="80" height="140" rx="20" fill="none" stroke="#00E5FF" stroke-width="30"/><rect x="670" y="700" width="80" height="140" rx="20" fill="none" stroke="#00E5FF" stroke-width="30"/><path d="M 530 570 L 670 570" stroke="#00E5FF" stroke-width="30" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 953 B

After

Width:  |  Height:  |  Size: 953 B

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<title>Telegram</title>
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
transform="rotate(-17.5 850 850)"/>
<path d="M 300 600 L 850 400 L 700 800 L 550 650 L 300 600 Z M 550 650 L 550 750 L 620 680" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 300 600 L 850 400 L 700 800 L 550 650 L 300 600 Z M 550 650 L 550 750 L 620 680" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 442 B

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<title>Threads</title>
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
transform="rotate(-17.5 850 850)"/>
<path d="M 600 350 Q 450 350 400 500 Q 350 650 400 750 Q 450 850 600 850 Q 750 850 800 750 Q 850 650 800 500 Q 750 350 600 350 Z M 550 550 Q 600 500 650 550 Q 700 600 650 650 Q 600 700 550 650 Q 500 600 550 550 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 600 350 Q 450 350 400 500 Q 350 650 400 750 Q 450 850 600 850 Q 750 850 800 750 Q 850 650 800 500 Q 750 350 600 350 Z M 550 550 Q 600 500 650 550 Q 700 600 650 650 Q 600 700 550 650 Q 500 600 550 550 Z" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 563 B

After

Width:  |  Height:  |  Size: 563 B

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1700 1700">
<title>Twitch</title>
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#FF1610" stroke-width="38"
<circle cx="850" cy="850" r="560" fill="#0a0a0b" stroke="#00E5FF" stroke-width="38"
transform="rotate(-17.5 850 850)"/>
<path d="M 400 350 L 800 350 L 800 600 L 650 750 L 500 750 L 400 850 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linejoin="round"/><line x1="550" y1="450" x2="550" y2="550" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="670" y1="450" x2="670" y2="550" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/>
<path d="M 400 350 L 800 350 L 800 600 L 650 750 L 500 750 L 400 850 Z" fill="none" stroke="#00E5FF" stroke-width="35" stroke-linejoin="round"/><line x1="550" y1="450" x2="550" y2="550" stroke="#00E5FF" stroke-width="35" stroke-linecap="round"/><line x1="670" y1="450" x2="670" y2="550" stroke="#00E5FF" stroke-width="35" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 599 B

After

Width:  |  Height:  |  Size: 599 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Analytics</title>
<line x1="350" y1="850" x2="850" y2="850" stroke="#FF1610" stroke-width="40" stroke-linecap="round"/><line x1="420" y1="700" x2="420" y2="850" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="600" y1="500" x2="600" y2="850" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="780" y1="400" x2="780" y2="850" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/>
<line x1="350" y1="850" x2="850" y2="850" stroke="#000000" stroke-width="40" stroke-linecap="round"/><line x1="420" y1="700" x2="420" y2="850" stroke="#000000" stroke-width="35" stroke-linecap="round"/><line x1="600" y1="500" x2="600" y2="850" stroke="#000000" stroke-width="35" stroke-linecap="round"/><line x1="780" y1="400" x2="780" y2="850" stroke="#000000" stroke-width="35" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 532 B

After

Width:  |  Height:  |  Size: 532 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Approvals</title>
<path d="M 240 640 L 500 880 L 960 320" fill="none" stroke="#FF1610" stroke-width="90" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 240 640 L 500 880 L 960 320" fill="none" stroke="#000000" stroke-width="90" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 264 B

After

Width:  |  Height:  |  Size: 264 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Audit</title>
<path d="M 500 350 L 700 350 L 700 500 L 800 500 L 600 850 L 400 500 L 500 500 Z" fill="none" stroke="#FF1610" stroke-width="35" stroke-linejoin="round"/>
<path d="M 500 350 L 700 350 L 700 500 L 800 500 L 600 850 L 400 500 L 500 500 Z" fill="none" stroke="#000000" stroke-width="35" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 278 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Calendar</title>
<rect x="350" y="400" width="500" height="450" rx="30" fill="none" stroke="#FF1610" stroke-width="40"/><line x1="350" y1="520" x2="850" y2="520" stroke="#FF1610" stroke-width="30"/><line x1="450" y1="400" x2="450" y2="350" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><line x1="750" y1="400" x2="750" y2="350" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/><circle cx="500" cy="620" r="20" fill="#FF1610"/><circle cx="600" cy="620" r="20" fill="#FF1610"/><circle cx="700" cy="620" r="20" fill="#FF1610"/>
<rect x="350" y="400" width="500" height="450" rx="30" fill="none" stroke="#000000" stroke-width="40"/><line x1="350" y1="520" x2="850" y2="520" stroke="#000000" stroke-width="30"/><line x1="450" y1="400" x2="450" y2="350" stroke="#000000" stroke-width="35" stroke-linecap="round"/><line x1="750" y1="400" x2="750" y2="350" stroke="#000000" stroke-width="35" stroke-linecap="round"/><circle cx="500" cy="620" r="20" fill="#000000"/><circle cx="600" cy="620" r="20" fill="#000000"/><circle cx="700" cy="620" r="20" fill="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 657 B

After

Width:  |  Height:  |  Size: 657 B

Before After
Before After

View file

@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Chat</title>
<path d="M 220 300 A 80 80 0 0 1 300 220 L 900 220 A 80 80 0 0 1 980 300 L 980 660 A 80 80 0 0 1 900 740 L 520 740 L 340 920 L 340 740 L 300 740 A 80 80 0 0 1 220 660 Z" fill="none" stroke="#FF1610" stroke-width="55" stroke-linejoin="round"/>
<circle cx="450" cy="480" r="45" fill="#FF1610"/>
<circle cx="600" cy="480" r="45" fill="#FF1610"/>
<circle cx="750" cy="480" r="45" fill="#FF1610"/>
<path d="M 220 300 A 80 80 0 0 1 300 220 L 900 220 A 80 80 0 0 1 980 300 L 980 660 A 80 80 0 0 1 900 740 L 520 740 L 340 920 L 340 740 L 300 740 A 80 80 0 0 1 220 660 Z" fill="none" stroke="#000000" stroke-width="55" stroke-linejoin="round"/>
<circle cx="450" cy="480" r="45" fill="#000000"/>
<circle cx="600" cy="480" r="45" fill="#000000"/>
<circle cx="750" cy="480" r="45" fill="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 522 B

After

Width:  |  Height:  |  Size: 522 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Compose</title>
<path d="M 400 800 L 350 850 L 400 850 L 850 400 L 800 350 L 350 800" fill="none" stroke="#FF1610" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/><line x1="750" y1="400" x2="800" y2="450" stroke="#FF1610" stroke-width="30"/>
<path d="M 400 800 L 350 850 L 400 850 L 850 400 L 800 350 L 350 800" fill="none" stroke="#000000" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/><line x1="750" y1="400" x2="800" y2="450" stroke="#000000" stroke-width="30"/>
</svg>

Before

Width:  |  Height:  |  Size: 369 B

After

Width:  |  Height:  |  Size: 369 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Connections</title>
<circle cx="450" cy="500" r="80" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="750" cy="700" r="80" fill="none" stroke="#FF1610" stroke-width="35"/><line x1="500" y1="550" x2="700" y2="650" stroke="#FF1610" stroke-width="35" stroke-linecap="round"/>
<circle cx="450" cy="500" r="80" fill="none" stroke="#000000" stroke-width="35"/><circle cx="750" cy="700" r="80" fill="none" stroke="#000000" stroke-width="35"/><line x1="500" y1="550" x2="700" y2="650" stroke="#000000" stroke-width="35" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 393 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Files</title>
<path d="M 220 380 L 470 380 L 560 470 L 980 470 L 980 860 A 60 60 0 0 1 920 920 L 280 920 A 60 60 0 0 1 220 860 Z" fill="none" stroke="#FF1610" stroke-width="60" stroke-linejoin="round" stroke-linecap="round"/>
<path d="M 220 380 L 470 380 L 560 470 L 980 470 L 980 860 A 60 60 0 0 1 920 920 L 280 920 A 60 60 0 0 1 220 860 Z" fill="none" stroke="#000000" stroke-width="60" stroke-linejoin="round" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 336 B

After

Width:  |  Height:  |  Size: 336 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Home</title>
<path d="M 600 350 L 850 550 L 850 850 L 350 850 L 350 550 Z" fill="none" stroke="#FF1610" stroke-width="40" stroke-linejoin="round"/><line x1="600" y1="350" x2="600" y2="850" stroke="#FF1610" stroke-width="30"/>
<path d="M 600 350 L 850 550 L 850 850 L 350 850 L 350 550 Z" fill="none" stroke="#000000" stroke-width="40" stroke-linejoin="round"/><line x1="600" y1="350" x2="600" y2="850" stroke="#000000" stroke-width="30"/>
</svg>

Before

Width:  |  Height:  |  Size: 335 B

After

Width:  |  Height:  |  Size: 335 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Inbox</title>
<path d="M 350 400 L 850 400 L 850 700 L 700 700 L 600 800 L 500 700 L 350 700 Z" fill="none" stroke="#FF1610" stroke-width="40" stroke-linejoin="round"/>
<path d="M 350 400 L 850 400 L 850 700 L 700 700 L 600 800 L 500 700 L 350 700 Z" fill="none" stroke="#000000" stroke-width="40" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 278 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav More</title>
<circle cx="500" cy="600" r="30" fill="#FF1610"/><circle cx="600" cy="600" r="30" fill="#FF1610"/><circle cx="700" cy="600" r="30" fill="#FF1610"/>
<circle cx="500" cy="600" r="30" fill="#000000"/><circle cx="600" cy="600" r="30" fill="#000000"/><circle cx="700" cy="600" r="30" fill="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 270 B

After

Width:  |  Height:  |  Size: 270 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Search</title>
<circle cx="550" cy="550" r="200" fill="none" stroke="#FF1610" stroke-width="40"/><line x1="700" y1="700" x2="850" y2="850" stroke="#FF1610" stroke-width="40" stroke-linecap="round"/>
<circle cx="550" cy="550" r="200" fill="none" stroke="#000000" stroke-width="40"/><line x1="700" y1="700" x2="850" y2="850" stroke="#000000" stroke-width="40" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 308 B

After

Width:  |  Height:  |  Size: 308 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Settings</title>
<circle cx="600" cy="600" r="150" fill="none" stroke="#FF1610" stroke-width="40"/><g stroke="#FF1610" stroke-width="35" stroke-linecap="round"><line x1="600" y1="350" x2="600" y2="420"/><line x1="600" y1="780" x2="600" y2="850"/><line x1="350" y1="600" x2="420" y2="600"/><line x1="780" y1="600" x2="850" y2="600"/><line x1="420" y1="420" x2="470" y2="470"/><line x1="730" y1="730" x2="780" y2="780"/><line x1="420" y1="780" x2="470" y2="730"/><line x1="730" y1="470" x2="780" y2="420"/></g>
<circle cx="600" cy="600" r="150" fill="none" stroke="#000000" stroke-width="40"/><g stroke="#000000" stroke-width="35" stroke-linecap="round"><line x1="600" y1="350" x2="600" y2="420"/><line x1="600" y1="780" x2="600" y2="850"/><line x1="350" y1="600" x2="420" y2="600"/><line x1="780" y1="600" x2="850" y2="600"/><line x1="420" y1="420" x2="470" y2="470"/><line x1="730" y1="730" x2="780" y2="780"/><line x1="420" y1="780" x2="470" y2="730"/><line x1="730" y1="470" x2="780" y2="420"/></g>
</svg>

Before

Width:  |  Height:  |  Size: 618 B

After

Width:  |  Height:  |  Size: 618 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Team</title>
<circle cx="500" cy="500" r="70" fill="none" stroke="#FF1610" stroke-width="35"/><circle cx="700" cy="500" r="70" fill="none" stroke="#FF1610" stroke-width="35"/><path d="M 400 750 Q 400 650 500 650 Q 600 650 600 750" fill="none" stroke="#FF1610" stroke-width="35"/><path d="M 600 750 Q 600 650 700 650 Q 800 650 800 750" fill="none" stroke="#FF1610" stroke-width="35"/>
<circle cx="500" cy="500" r="70" fill="none" stroke="#000000" stroke-width="35"/><circle cx="700" cy="500" r="70" fill="none" stroke="#000000" stroke-width="35"/><path d="M 400 750 Q 400 650 500 650 Q 600 650 600 750" fill="none" stroke="#000000" stroke-width="35"/><path d="M 600 750 Q 600 650 700 650 Q 800 650 800 750" fill="none" stroke="#000000" stroke-width="35"/>
</svg>

Before

Width:  |  Height:  |  Size: 493 B

After

Width:  |  Height:  |  Size: 493 B

Before After
Before After

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 1200 1200">
<title>Nav Video</title>
<rect x="260" y="400" width="680" height="400" rx="70" fill="none" stroke="#FF1610" stroke-width="70"/><path d="M 940 600 L 1120 480 L 1120 720 Z" fill="#FF1610"/><path d="M 360 470 L 360 730" stroke="#FF1610" stroke-width="40" stroke-linecap="round"/><path d="M 480 470 L 480 730" stroke="#FF1610" stroke-width="40" stroke-linecap="round"/>
<rect x="260" y="400" width="680" height="400" rx="70" fill="none" stroke="#000000" stroke-width="70"/><path d="M 940 600 L 1120 480 L 1120 720 Z" fill="#000000"/><path d="M 360 470 L 360 730" stroke="#000000" stroke-width="40" stroke-linecap="round"/><path d="M 480 470 L 480 730" stroke="#000000" stroke-width="40" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 466 B

After

Width:  |  Height:  |  Size: 466 B

Before After
Before After

View file

@ -3,8 +3,8 @@
<defs>
<g id="ca">
<g transform="rotate(-17.5 0 0)">
<circle cx="0" cy="0" r="45" fill="none" stroke="#FF1610" stroke-width="10"/>
<path d="M 0 -38 L -28 38 M 0 -38 L 28 38 M -34 0 L 34 0" fill="none" stroke="#FF1610" stroke-width="12" stroke-linecap="round"/>
<circle cx="0" cy="0" r="45" fill="none" stroke="#000000" stroke-width="10"/>
<path d="M 0 -38 L -28 38 M 0 -38 L 28 38 M -34 0 L 34 0" fill="none" stroke="#000000" stroke-width="12" stroke-linecap="round"/>
</g>
</g>
</defs>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

View file

@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 1700 1700">
<g transform="rotate(-17.5 850 850)">
<path d="M 850 500 A 350 350 0 0 1 1200 850" fill="none" stroke="#FF1610" stroke-width="70" stroke-linecap="round"/>
<path d="M 1200 850 A 350 350 0 0 1 850 1200" fill="none" stroke="#FF1610" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
<path d="M 850 1200 A 350 350 0 0 1 500 850" fill="none" stroke="#FF1610" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
<path d="M 500 850 A 350 350 0 0 1 850 500" fill="none" stroke="#FF1610" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
<path d="M 850 500 A 350 350 0 0 1 1200 850" fill="none" stroke="#000000" stroke-width="70" stroke-linecap="round"/>
<path d="M 1200 850 A 350 350 0 0 1 850 1200" fill="none" stroke="#000000" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
<path d="M 850 1200 A 350 350 0 0 1 500 850" fill="none" stroke="#000000" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
<path d="M 500 850 A 350 350 0 0 1 850 500" fill="none" stroke="#000000" stroke-width="70" stroke-linecap="round" opacity="0.2"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 667 B

After

Width:  |  Height:  |  Size: 667 B

Before After
Before After

View file

@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 400 400" width="400" height="400" role="img" aria-labelledby="t">
<title id="t">T-HUB</title>
<defs>
<!-- Dome tent in the hand-inked style of the acampada26s.net mark:
curved poles with ball ends, centre seam, hatched panel, door.
Base midpoint at 0,0. -->
<g id="tienda" stroke-linecap="round" stroke-linejoin="round">
<path fill="none" d="M-58 0 Q-56 -66 -1 -72 Q55 -66 58 0" stroke-width="7"/>
<path fill="none" d="M-64 1 Q0 -3 64 1" stroke-width="6"/>
<path fill="none" d="M-44 0 Q-30 -58 -1 -72" stroke-width="4.5"/>
<path fill="none" d="M44 0 Q30 -58 -1 -72" stroke-width="4.5"/>
<path d="M-15 0 Q-14 -30 0 -33 Q14 -30 15 0 Z" stroke-width="4"/>
<path fill="none" d="M-50 -14 L-41 -25 M-50 -4 L-37 -19 M-43 -1 L-33 -13 M-52 -27 L-45 -36" stroke-width="2.6"/>
<path fill="none" d="M28 -50 L37 -42 M24 -40 L36 -30 M22 -29 L34 -19" stroke-width="2.6"/>
<circle cx="-64" cy="1" r="5.5" stroke="none"/>
<circle cx="64" cy="1" r="5.5" stroke="none"/>
<circle cx="-1" cy="-73" r="5.5" stroke="none"/>
</g>
<g id="marca" stroke-linecap="round" stroke-linejoin="round">
<!-- cables: out of each tent door to the switch -->
<path fill="none" d="M105 150 Q112 206 168 214" stroke-width="5"/>
<path fill="none" d="M295 150 Q288 206 232 214" stroke-width="5"/>
<path fill="none" d="M200 318 Q197 280 200 236" stroke-width="5"/>
<!-- antenna -->
<path fill="none" d="M200 200 Q198 160 200 118" stroke-width="6"/>
<path fill="none" d="M184 98 Q200 82 216 98" stroke-width="5"/>
<path fill="none" d="M171 88 Q200 58 229 88" stroke-width="5"/>
<circle cx="200" cy="112" r="7" stroke="none"/>
<!-- switch -->
<path d="M168 201 L233 200 L234 235 L167 236 Z" stroke-width="6" fill="#fff"/>
<path d="M179 212 h9 v11 h-9 z M196 212 h9 v11 h-9 z M213 212 h9 v11 h-9 z" stroke="none"/>
<use xlink:href="#tienda" transform="translate(105,150)"/>
<use xlink:href="#tienda" transform="translate(295,150)"/>
<use xlink:href="#tienda" transform="translate(200,340)"/>
</g>
</defs>
<!-- neon offset underneath, ink on top, as on acampada26s.net -->
<g transform="translate(7,7)" stroke="#00e5ff" fill="#00e5ff">
<use xlink:href="#marca"/>
</g>
<g stroke="#000" fill="#000">
<use xlink:href="#marca"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,93 @@
Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View file

@ -13,4 +13,4 @@
}
.control-room label { max-width: 100%; }
.control-room button { min-width: 44px; }
.control-room :focus-visible { outline: 2px solid #ff1610; outline-offset: 3px; }
.control-room :focus-visible { outline: 2px solid #00e5ff; outline-offset: 3px; }

View file

@ -16,7 +16,7 @@ import { useIsMobile } from "../../hooks/use-is-mobile";
const titles: Record<string, string> = {
streams: "Home", search: "Search", inbox: "Inbox", approvals: "Approvals", more: "More", compose: "New post",
calendar: "Calendar", video: "Video", analytics: "Analytics", connections: "Connections", team: "Team",
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", chat: "Chat",
audit: "Audit log", onboarding: "Welcome", settings: "Settings", archive: "Archive", files: "Files", chat: "Chat", outreach: "Outreach",
};
const rootTabs = new Set(["/streams", "/search", "/inbox", "/more"]);
const scrollPositions = new Map<string, { main: number; nested: number[] }>();
@ -49,7 +49,7 @@ function Shell({ children }: { children: ReactNode }): React.ReactElement {
scrollPositions.set(pathname, { main: main.scrollTop, nested: scrollableChildren(main).map((element) => element.scrollTop) });
};
}, [pathname, mobile]);
const title = titles[pathname.split("/")[1] ?? ""] ?? "Tactical Media Broadcast Hub";
const title = titles[pathname.split("/")[1] ?? ""] ?? "T-HUB";
return (
<div className="flex h-dvh min-w-0 overflow-hidden lg:h-screen">
<Sidebar />

View file

@ -48,7 +48,7 @@ export default function MorePage(): React.ReactElement {
<section className="flex items-center gap-4 px-5 py-5">
<div className="scale-125"><Avatar handle={session?.user.displayName || session?.user.username || "AP"} /></div>
<div className="min-w-0 flex-1 pl-2">
<h1 className="truncate text-xl font-bold">{session?.user.displayName || session?.user.username || "Tactical Media Broadcast Hub"}</h1>
<h1 className="truncate text-xl font-bold">{session?.user.displayName || session?.user.username || "T-HUB"}</h1>
<p className="truncate text-xs text-text-faint">@{session?.user.username || "offline"}{session?.user.email ? ` · ${session.user.email}` : ""}</p>
<div className="mt-2 flex items-center gap-2"><span className="rounded-full bg-accent px-2.5 py-1 text-[11px] font-bold uppercase text-white">{session?.role || "Preview"}</span><span className="truncate text-xs text-text-dim">{session?.workspace.name || "Studio"}</span></div>
</div>
@ -71,6 +71,7 @@ export default function MorePage(): React.ReactElement {
<h2 className="px-4 pt-6 pb-2 text-xs font-bold uppercase tracking-wider text-text-faint">Settings</h2>
<div className="border-y border-border-subtle bg-surface">
<Row href="/connections" title="Connections" detail="Accounts and health" />
<Row href="/outreach" title="Outreach" detail="Contacts to write to by hand" />
<Row href="/settings" title="Settings" />
<Row href="/settings" title="Security & sessions" />
<Row href="/calendar" title="Calendar" />

View file

@ -221,15 +221,15 @@ export default function OnboardingPage(): React.ReactElement {
<div className="mb-5">
<div className="-m-6 mb-5 overflow-hidden bg-black">
<div className="flex items-center gap-5 px-8 py-8">
<LogoMark size={96} className="shrink-0 text-accent" />
<LogoMark size={96} className="shrink-0 text-white" />
<div className="min-w-0">
<div className="text-xl font-semibold leading-tight text-white">Horizontal Collaborative Broadcast System</div>
<div className="mt-1 text-xs uppercase tracking-wider text-text-faint">Tactical Media Broadcast Hub</div>
<div className="mt-1 text-xs uppercase tracking-wider text-[#00e5ff]">T-HUB</div>
</div>
</div>
</div>
<div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight">Tactical Media Broadcast Hub</h1>
<h1 className="text-2xl font-semibold tracking-tight">T-HUB</h1>
<p className="mt-1 text-2xs text-text-faint">Your platform. Their feeds. No algorithm in between.</p>
</div>
</div>

View file

@ -0,0 +1,325 @@
"use client";
import { useMemo, useState } from "react";
import { Badge, Button, Card } from "../../../components/ui/primitives";
import { Loading } from "../../../components/brand/loading";
import { EmptyState } from "../../../components/brand/empty-state";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { api, errorMessage } from "../../../lib/api-client";
import { useApiResource } from "../../../lib/use-api";
import { useAuth } from "../../../lib/auth-store";
import {
OUTREACH_DAILY_SOFT_CAP,
OUTREACH_STATUSES,
fillTemplate,
parseImport,
sentToday,
type OutreachContact,
type OutreachStatus,
} from "../../../lib/outreach-types";
/**
* Outreach — write to artists, collectives and public figures one by one.
*
* The list lives in GET /outreach/contacts. For each contact the screen keeps
* a drafted message; "Copy & open" puts it on the clipboard and opens the
* profile so a person sends it from the platform's own DM box, then marks the
* contact sent. Nothing is sent by the Hub: unsolicited bulk DMs get accounts
* suspended, and a personal message is what gets an answer.
*/
const SAMPLE: OutreachContact[] = [
{
id: "sample-1", name: "Sample collective", platform: "example", handle: "@sample@example.social",
profileUrl: "https://example.social/@sample", category: "housing", language: "en", message: "", notes: "",
status: "todo", sentAt: null, repliedAt: null, createdAt: "2026-10-15T00:00:00Z", updatedAt: "2026-10-15T00:00:00Z",
},
];
const identity = (contacts: OutreachContact[]): OutreachContact[] => contacts;
const STATUS_TONE: Record<OutreachStatus, "neutral" | "accent" | "live" | "danger"> = {
todo: "neutral", sent: "accent", replied: "live", declined: "danger",
};
const TEXTAREA = "w-full rounded-md border border-border bg-surface-2 p-2 text-sm focus:border-accent focus:outline-none";
export default function OutreachPage(): React.ReactElement {
const { can } = useAuth();
const contacts = useApiResource<OutreachContact[], OutreachContact[]>("/outreach/contacts", identity, SAMPLE);
const live = contacts.source === "live";
const canManage = live && can("outreach.manage");
const [filter, setFilter] = useState<OutreachStatus | "all">("todo");
const [category, setCategory] = useState<string>("all");
const [openId, setOpenId] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const categories = useMemo(
() => [...new Set(contacts.data.map((contact) => contact.category).filter((value): value is string => value !== null))].sort(),
[contacts.data],
);
const visible = contacts.data.filter(
(contact) => (filter === "all" || contact.status === filter) && (category === "all" || contact.category === category),
);
const counts = Object.fromEntries(
OUTREACH_STATUSES.map((status) => [status, contacts.data.filter((contact) => contact.status === status).length]),
) as Record<OutreachStatus, number>;
const today = sentToday(contacts.data);
function replace(updated: OutreachContact): void {
contacts.setData((current) => current.map((contact) => (contact.id === updated.id ? updated : contact)));
}
async function patch(id: string, body: Partial<Pick<OutreachContact, "message" | "notes" | "status">>): Promise<void> {
try {
replace(await api.patch<OutreachContact>(`/outreach/contacts/${id}`, body));
} catch (err) {
setNotice(errorMessage(err));
}
}
async function copyAndOpen(contact: OutreachContact): Promise<void> {
if (contact.message.trim() !== "") {
try {
await navigator.clipboard.writeText(contact.message);
setNotice(`Message for ${contact.name} copied. Paste it in their DMs, then mark it sent.`);
} catch {
setNotice("Could not copy to the clipboard. Select the text and copy it by hand.");
}
}
window.open(contact.profileUrl, "_blank", "noopener,noreferrer");
}
async function remove(contact: OutreachContact): Promise<void> {
if (!window.confirm(`Remove ${contact.name} from the list?`)) return;
try {
await api.del(`/outreach/contacts/${contact.id}`);
contacts.setData((current) => current.filter((row) => row.id !== contact.id));
} catch (err) {
setNotice(errorMessage(err));
}
}
async function applyTemplate(template: string): Promise<void> {
const targets = visible.filter((contact) => contact.status === "todo" && contact.message.trim() === "");
if (targets.length === 0) {
setNotice("Every pending contact in this view already has a message.");
return;
}
setBusy(true);
for (const contact of targets) await patch(contact.id, { message: fillTemplate(template, contact) });
setBusy(false);
setNotice(`Template applied to ${targets.length} contacts. Review each one before sending.`);
}
return (
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
<div className="mb-3 flex flex-wrap items-center gap-3">
<h1 className="text-lg font-semibold">Outreach</h1>
<OfflineBadge source={contacts.source} />
{!live && <Badge tone="warn">read-only sample</Badge>}
</div>
<p className="mb-4 max-w-2xl text-xs leading-relaxed text-text-faint">
Public accounts to write to by hand. Draft a message per contact, copy it, open the profile and send it from
the platform. Mark who answered so nobody writes twice.
</p>
<div className="mb-4 flex flex-wrap items-center gap-2">
{(["all", ...OUTREACH_STATUSES] as const).map((status) => (
<Button key={status} size="sm" variant={filter === status ? "outline" : "ghost"} onClick={() => setFilter(status)}>
{status} {status === "all" ? contacts.data.length : counts[status]}
</Button>
))}
{categories.length > 0 && (
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-2xs"
aria-label="Category"
>
<option value="all">all categories</option>
{categories.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
)}
<span className="ml-auto text-2xs text-text-faint">
Sent today: <span className={today >= OUTREACH_DAILY_SOFT_CAP ? "text-warn" : "text-text-dim"}>{today}</span>
</span>
</div>
{today >= OUTREACH_DAILY_SOFT_CAP && (
<Card className="mb-4 p-3">
<p className="text-xs text-warn">
{today} first messages today from this collective. Spread the rest over the next days: platforms
restrict accounts that send many first messages in a short time.
</p>
</Card>
)}
{(notice !== null || contacts.error !== null) && (
<Card className="mb-4 flex items-start justify-between gap-3 p-3">
<p className={`text-xs ${contacts.error !== null ? "text-danger" : "text-text-dim"}`}>{contacts.error ?? notice}</p>
{notice !== null && <Button size="sm" onClick={() => setNotice(null)}>Dismiss</Button>}
</Card>
)}
{canManage && <Tools busy={busy} onImported={contacts.reload} onTemplate={applyTemplate} onNotice={setNotice} />}
{contacts.source === "loading" ? (
<div className="flex justify-center py-10"><Loading size={28} label="Loading contacts" /></div>
) : visible.length === 0 ? (
<EmptyState title="No contacts here" hint={canManage ? "Import a list above or change the filter." : "Change the filter."} />
) : (
<div className="flex flex-col gap-2">
{visible.map((contact) => (
<ContactRow
key={contact.id}
contact={contact}
open={openId === contact.id}
canManage={canManage}
onToggle={() => setOpenId(openId === contact.id ? null : contact.id)}
onPatch={(body) => patch(contact.id, body)}
onCopyOpen={() => copyAndOpen(contact)}
onRemove={() => remove(contact)}
/>
))}
</div>
)}
</div>
);
}
function ContactRow({ contact, open, canManage, onToggle, onPatch, onCopyOpen, onRemove }: {
contact: OutreachContact;
open: boolean;
canManage: boolean;
onToggle: () => void;
onPatch: (body: Partial<Pick<OutreachContact, "message" | "notes" | "status">>) => Promise<void>;
onCopyOpen: () => Promise<void>;
onRemove: () => Promise<void>;
}): React.ReactElement {
const [message, setMessage] = useState(contact.message);
const [notes, setNotes] = useState(contact.notes);
return (
<Card className="p-3">
<button type="button" onClick={onToggle} className="flex w-full flex-wrap items-center gap-2 text-left">
<span className="text-sm font-medium">{contact.name}</span>
<span className="text-2xs text-text-faint">{contact.platform} · {contact.handle}</span>
{contact.category !== null && <Badge>{contact.category}</Badge>}
{contact.language !== null && <Badge>{contact.language}</Badge>}
<span className="ml-auto"><Badge tone={STATUS_TONE[contact.status]}>{contact.status}</Badge></span>
</button>
{open && (
<div className="mt-3 flex flex-col gap-2">
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
onBlur={() => { if (canManage && message !== contact.message) void onPatch({ message }); }}
rows={5}
maxLength={4000}
readOnly={!canManage}
placeholder="Message for this contact"
className={TEXTAREA}
/>
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
onBlur={() => { if (canManage && notes !== contact.notes) void onPatch({ notes }); }}
rows={2}
maxLength={2000}
readOnly={!canManage}
placeholder="Notes (who wrote, what they said)"
className={TEXTAREA}
/>
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" onClick={() => void onCopyOpen()}>Copy &amp; open profile</Button>
{canManage && contact.status !== "sent" && <Button variant="outline" size="sm" onClick={() => void onPatch({ status: "sent" })}>Mark sent</Button>}
{canManage && contact.status !== "replied" && <Button size="sm" onClick={() => void onPatch({ status: "replied" })}>Replied</Button>}
{canManage && contact.status !== "declined" && <Button size="sm" onClick={() => void onPatch({ status: "declined" })}>Declined</Button>}
{canManage && contact.status !== "todo" && <Button size="sm" onClick={() => void onPatch({ status: "todo" })}>Back to pending</Button>}
{canManage && <Button variant="danger" size="sm" className="ml-auto" onClick={() => void onRemove()}>Remove</Button>}
</div>
{contact.sentAt !== null && (
<p className="text-2xs text-text-faint">
Sent {new Date(contact.sentAt).toLocaleString()}
{contact.repliedAt !== null && ` · replied ${new Date(contact.repliedAt).toLocaleString()}`}
</p>
)}
</div>
)}
</Card>
);
}
function Tools({ busy, onImported, onTemplate, onNotice }: {
busy: boolean;
onImported: () => void;
onTemplate: (template: string) => Promise<void>;
onNotice: (message: string) => void;
}): React.ReactElement {
const [csv, setCsv] = useState("");
const [template, setTemplate] = useState("");
const [importing, setImporting] = useState(false);
async function submit(): Promise<void> {
const { rows, errors } = parseImport(csv);
if (errors.length > 0) {
onNotice(errors.slice(0, 3).join("; "));
return;
}
if (rows.length === 0) return;
setImporting(true);
try {
let added = 0;
let skipped = 0;
for (let start = 0; start < rows.length; start += 500) {
const result = await api.post<{ added: number; skipped: number }>("/outreach/contacts", { contacts: rows.slice(start, start + 500) });
added += result.added;
skipped += result.skipped;
}
onNotice(`${added} contacts added, ${skipped} already on the list.`);
setCsv("");
onImported();
} catch (err) {
onNotice(errorMessage(err));
} finally {
setImporting(false);
}
}
return (
<div className="mb-4 grid gap-3 lg:grid-cols-2">
<Card className="p-3">
<h2 className="mb-1 text-xs font-semibold">Import</h2>
<p className="mb-2 text-2xs text-text-faint">One per line: name, platform, handle, profileUrl, category, language</p>
<textarea
value={csv}
onChange={(event) => setCsv(event.target.value)}
rows={4}
placeholder="Name,platform,handle,https://profile-url,category,es"
className={`${TEXTAREA} font-mono text-2xs`}
/>
<Button variant="outline" size="sm" className="mt-2" disabled={importing || csv.trim() === ""} onClick={() => void submit()}>
{importing ? "Importing…" : "Import"}
</Button>
</Card>
<Card className="p-3">
<h2 className="mb-1 text-xs font-semibold">Template</h2>
<p className="mb-2 text-2xs text-text-faint">
Fills pending contacts in the current view that have no message yet. Use {"{name}"} and {"{handle}"}.
</p>
<textarea
value={template}
onChange={(event) => setTemplate(event.target.value)}
rows={4}
placeholder="Hola {name}, os escribimos desde la Acampada 26S…"
className={TEXTAREA}
/>
<Button variant="outline" size="sm" className="mt-2" disabled={busy || template.trim() === ""} onClick={() => void onTemplate(template)}>
{busy ? "Applying…" : "Apply to pending"}
</Button>
</Card>
</div>
);
}

View file

@ -1,25 +1,36 @@
@import "tailwindcss";
/* Anton (SIL OFL 1.1, public/fonts/Anton-OFL.txt) — same title face as acampada26s.net. */
@font-face {
font-family: "Anton";
src: url("/fonts/anton.woff2") format("woff2");
font-display: swap;
}
@theme {
--color-bg: oklch(0.11 0.008 25);
--color-surface: oklch(0.15 0.01 25);
--color-surface-2: oklch(0.19 0.012 25);
--color-border: oklch(0.26 0.02 25);
--color-border-subtle: oklch(0.22 0.015 25);
--color-text: oklch(0.93 0.005 25);
--color-text-dim: oklch(0.62 0.02 25);
--color-text-faint: oklch(0.46 0.02 25);
--color-accent: #ff1610;
--color-accent-hover: #e3130e;
--color-accent-dim: rgb(255 22 16 / 0.15);
--color-live: oklch(0.72 0.19 145);
--color-warn: oklch(0.78 0.16 85);
--color-danger: oklch(0.55 0.24 15);
--color-added: oklch(0.72 0.16 145);
--color-removed: oklch(0.55 0.24 15);
/* T-HUB: the acampada26s.net palette. White page, black ink and borders,
neon #00e5ff for hover, focus and the offset shadows. */
--color-bg: #fff;
--color-surface: #fff;
--color-surface-2: #f2f2f2;
--color-border: #000;
--color-border-subtle: #d4d4d4;
--color-text: #000;
--color-text-dim: #333;
--color-text-faint: #666;
--color-accent: #000;
--color-accent-hover: #222;
--color-accent-dim: rgb(0 229 255 / 0.28);
--color-neon: #00e5ff;
--color-live: #007a3d;
--color-warn: #8a5a00;
--color-danger: #c4001a;
--color-added: #007a3d;
--color-removed: #c4001a;
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "SF Mono", Menlo, monospace;
--font-title: "Anton", Impact, "Arial Narrow", sans-serif;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
@ -42,7 +53,7 @@
}
html {
color-scheme: dark;
color-scheme: light;
}
body {
@ -55,6 +66,30 @@ body {
html, body { max-width: 100%; }
/* Titles as on acampada26s.net: Anton, uppercase, hard neon offset. */
h1, h2 {
font-family: var(--font-title);
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.01em;
}
h1 {
font-size: 1.75rem;
line-height: 1.05;
text-shadow: 0.01em 0.01em 0 var(--color-neon), 0.02em 0.02em 0 var(--color-neon), 0.03em 0.03em 0 var(--color-neon), 0.04em 0.04em 0 var(--color-neon);
}
/* Buttons as on acampada26s.net: hard offset shadow, neon on hover. */
button.bg-accent, a.bg-accent {
box-shadow: 0 3px 0 var(--color-neon);
}
button.bg-accent:hover, a.bg-accent:hover, button.bg-accent:focus-visible, a.bg-accent:focus-visible {
box-shadow: 0 5px 0 var(--color-neon);
}
:focus-visible {
outline-color: var(--color-neon);
}
@media (max-width: 1023px) {
body { overflow-x: hidden; }
[data-mobile-main] :is(button, a[href], input, select, textarea, [role="tab"]) {

View file

@ -324,11 +324,11 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
<Card className="w-[520px] max-w-full p-6">
<div className="mb-5 text-center">
<img
src="/brand/hero.png"
alt="Tactical Media Broadcast Hub — arrows from a circle, warm red on black"
width={1024}
height={1024}
className="mx-auto mb-3 h-20 w-20 rounded-lg object-cover"
src="/brand/t-hub.svg"
alt="T-HUB: three tents wired to a switch with an antenna"
width={400}
height={400}
className="mx-auto mb-3 h-20 w-20"
/>
<h1 className="text-xl font-semibold tracking-tight">Join {info.workspaceName}</h1>
<p className="mt-1 text-2xs text-text-faint">

View file

@ -3,8 +3,8 @@ import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Tactical Media Broadcast Hub",
description: "Tactical Media Broadcast Hub — post, schedule and amplify across every network you refuse to be owned by.",
title: "T-HUB",
description: "T-HUB — post, schedule and amplify across every network you refuse to be owned by. Based on Tactical Media Broadcast Hub.",
icons: { icon: "/brand/favicon.svg" },
};
@ -13,7 +13,7 @@ export const viewport: Viewport = {
initialScale: 1,
viewportFit: "cover",
interactiveWidget: "resizes-content",
themeColor: "#FF1610",
themeColor: "#ffffff",
};
export default function RootLayout({ children }: { children: ReactNode }): React.ReactNode {

View file

@ -40,7 +40,7 @@ export default function LoginPage(): React.ReactElement {
<Card className="w-[420px] max-w-full p-6">
<div className="mb-5 text-center">
<h1 className="text-xl font-semibold tracking-tight">Sign in</h1>
<p className="mt-1 text-2xs text-text-faint">Tactical Media Broadcast Hub</p>
<p className="mt-1 text-2xs text-text-faint">T-HUB</p>
</div>
<form onSubmit={(e) => void submit(e)}>
<div className="space-y-2">

View file

@ -116,7 +116,7 @@ function ManualLanding(): React.ReactElement {
<header className="sticky top-0 z-10 border-b border-border-subtle bg-surface">
<div className="mx-auto flex max-w-2xl items-center gap-3 px-4 py-4">
<Logo size={26} withText={false} />
<span className="flex-1 truncate text-xs font-bold tracking-tight text-accent">Tactical Media Broadcast Hub</span>
<span className="flex-1 truncate text-xs font-bold tracking-tight text-accent">T-HUB</span>
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label={t({ es: "Idioma", en: "Language" })}>
{(["es", "en"] as const).map((code) => (
<button

View file

@ -119,10 +119,10 @@ export default function SubmitPage({ params }: { params: { token: string } }): R
if (phase === "checking") {
return (
<main className="flex min-h-dvh items-center justify-center bg-[#060403] px-6">
<main className="flex min-h-dvh items-center justify-center bg-white px-6">
<div className="flex flex-col items-center gap-3">
<Loading size={32} label="Opening the box…" />
<span className="text-2xs text-[#a4a09e]">Opening the box…</span>
<span className="text-2xs text-[#555]">Opening the box…</span>
</div>
</main>
);
@ -130,12 +130,12 @@ export default function SubmitPage({ params }: { params: { token: string } }): R
if (phase === "unreachable") {
return (
<main className="flex min-h-dvh items-center justify-center bg-[#060403] p-6">
<main className="flex min-h-dvh items-center justify-center bg-white p-6">
<DeadCard
tone="warn"
title="Can't reach the server"
body="Your link is still fine — the server isn't answering. Try again in a moment."
action={<button type="button" onClick={() => setAttempt((n) => n + 1)} className="mt-6 rounded-md bg-[#FF1610] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#e3130e]">Retry</button>}
action={<button type="button" onClick={() => setAttempt((n) => n + 1)} className="mt-6 rounded-md bg-[#00E5FF] px-3 py-1.5 text-xs font-medium text-black hover:bg-[#5cefff]">Retry</button>}
/>
</main>
);
@ -143,7 +143,7 @@ export default function SubmitPage({ params }: { params: { token: string } }): R
if (phase === "gone" || box === null) {
return (
<main className="flex min-h-dvh items-center justify-center bg-[#060403] p-6">
<main className="flex min-h-dvh items-center justify-center bg-white p-6">
<DeadCard
tone="danger"
title="This box isn't open"
@ -154,7 +154,7 @@ export default function SubmitPage({ params }: { params: { token: string } }): R
}
return (
<main className="min-h-dvh bg-[#060403] px-4 py-10 sm:px-6">
<main className="min-h-dvh bg-white px-4 py-10 sm:px-6">
<SubmitForm key={token} token={token} box={box} />
</main>
);
@ -162,16 +162,16 @@ export default function SubmitPage({ params }: { params: { token: string } }): R
function DeadCard({ tone, title, body, action }: { tone: "warn" | "danger"; title: string; body: string; action?: React.ReactNode }): React.ReactElement {
return (
<div className="w-full max-w-md rounded-lg border border-[#2a2320] bg-[#0F0A09] p-6 text-center">
<div className="w-full max-w-md rounded-lg border border-[#d4d4d4] bg-[#f2f2f2] p-6 text-center">
<div
className={`mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full text-lg ${
tone === "warn" ? "bg-[#FF1610]/15 text-[#FF1610]" : "bg-[#FF1610]/15 text-[#FF1610]"
tone === "warn" ? "bg-[#00E5FF]/15 text-[#00798a]" : "bg-[#00E5FF]/15 text-[#00798a]"
}`}
>
{tone === "warn" ? "!" : "✕"}
</div>
<h1 className="text-base font-semibold text-[#f5efed]">{title}</h1>
<p className="mt-2 text-xs leading-relaxed text-[#a4a09e]">{body}</p>
<h1 className="text-base font-semibold text-black">{title}</h1>
<p className="mt-2 text-xs leading-relaxed text-[#555]">{body}</p>
{action}
</div>
);
@ -282,13 +282,13 @@ function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }):
if (phase === "sent" && done !== null) {
return (
<div className="mx-auto flex w-full max-w-xl flex-col items-center gap-4 rounded-lg border border-[#2a2320] bg-[#0F0A09] p-8 text-center">
<LogoMark size={44} className="text-[#FF1610]" title="" />
<h1 className="text-xl font-semibold tracking-tight text-[#f5efed]">Received</h1>
<p className="max-w-sm text-sm leading-relaxed text-[#c7c3c1]">
<div className="mx-auto flex w-full max-w-xl flex-col items-center gap-4 rounded-lg border border-[#d4d4d4] bg-[#f2f2f2] p-8 text-center">
<LogoMark size={44} className="text-black" title="" />
<h1 className="text-xl font-semibold tracking-tight text-black">Received</h1>
<p className="max-w-sm text-sm leading-relaxed text-[#333]">
Received — the collective will review it. Nothing is published until they approve it.
</p>
<p className="max-w-sm text-2xs leading-relaxed text-[#a4a09e]">
<p className="max-w-sm text-2xs leading-relaxed text-[#555]">
Your footage stays under {box.licenseDefault}: it may be shared and remixed with attribution.
</p>
</div>
@ -301,13 +301,13 @@ function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }):
return (
<form
onSubmit={(event) => void submit(event)}
className="mx-auto flex w-full max-w-xl flex-col gap-6 rounded-lg border border-[#2a2320] bg-[#0F0A09] p-6 sm:p-8"
className="mx-auto flex w-full max-w-xl flex-col gap-6 rounded-lg border border-[#d4d4d4] bg-[#f2f2f2] p-6 sm:p-8"
>
<header className="flex flex-col items-center gap-3 text-center">
<LogoMark size={36} className="text-[#FF1610]" title="Tactical Media Broadcast Hub" />
<LogoMark size={36} className="text-black" />
<div>
<h1 className="text-xl font-semibold tracking-tight text-[#f5efed]">Send footage to the collective</h1>
<p className="mt-1 text-xs leading-relaxed text-[#a4a09e]">
<h1 className="text-xl font-semibold tracking-tight text-black">Send footage to the collective</h1>
<p className="mt-1 text-xs leading-relaxed text-[#555]">
No account needed. mp4 or webm video up to 200 MiB, jpeg or png photo up to 50 MiB.
</p>
</div>
@ -339,33 +339,33 @@ function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }):
}}
className={`flex min-h-[180px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors duration-150 ${
uploading
? "cursor-not-allowed border-[#3a312e] opacity-60"
: "cursor-pointer border-[#3a312e] hover:border-[#FF1610] focus-visible:border-[#FF1610]"
? "cursor-not-allowed border-black opacity-60"
: "cursor-pointer border-black hover:border-[#00E5FF] focus-visible:border-[#00E5FF]"
}`}
>
{uploading ? (
<div className="w-full max-w-md space-y-2">
<span className="inline-flex items-center gap-2 text-xs text-[#c7c3c1]">
<span className="inline-flex items-center gap-2 text-xs text-[#333]">
<Loading size={14} label="Uploading" />
{progress < 100 ? `Uploading ${progress}%` : "Upload sent — the collective's server is taking it in…"}{" "}
{file !== null && `${file.name} (${formatBytes(file.size)})`}
</span>
<progress className="h-2 w-full accent-[#FF1610]" aria-label="Upload progress" value={progress} max={100} />
<progress className="h-2 w-full accent-[#00E5FF]" aria-label="Upload progress" value={progress} max={100} />
</div>
) : file !== null && kind !== null ? (
<>
<span className="text-sm font-medium text-[#f5efed]">{file.name}</span>
<span className="text-2xs text-[#a4a09e]">
<span className="text-sm font-medium text-black">{file.name}</span>
<span className="text-2xs text-[#555]">
{kind === "video" ? "video" : "photo"} · {formatBytes(file.size)} · click or drop to choose another
</span>
</>
) : (
<>
<span className="text-sm text-[#c7c3c1]">Drop your video or photo here</span>
<span className="text-2xs leading-relaxed text-[#a4a09e]">
<span className="text-sm text-[#333]">Drop your video or photo here</span>
<span className="text-2xs leading-relaxed text-[#555]">
or browse your device, or paste a file. Nothing is sent until you press Send.
</span>
<span className="mt-2 rounded-md bg-[#FF1610] px-3 py-1.5 text-xs font-medium text-white">Browse files</span>
<span className="mt-2 rounded-md bg-[#00E5FF] px-3 py-1.5 text-xs font-medium text-black">Browse files</span>
</>
)}
<input
@ -381,24 +381,24 @@ function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }):
/>
</div>
{fileError !== null && <p role="alert" className="text-xs text-[#ff6f6b]">{fileError}</p>}
{error !== null && <p role="alert" className="rounded-md border border-[#FF1610]/30 bg-[#FF1610]/10 px-3 py-2 text-xs text-[#ff9b97]">{error}</p>}
{fileError !== null && <p role="alert" className="text-xs text-[#c4001a]">{fileError}</p>}
{error !== null && <p role="alert" className="rounded-md border border-[#00E5FF]/30 bg-[#00E5FF]/10 px-3 py-2 text-xs text-[#c4001a]">{error}</p>}
<div className="space-y-4">
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border border-[#3a312e] bg-[#060403] p-3">
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border border-black bg-white p-3">
<input
type="checkbox"
checked={licence}
onChange={(event) => setLicence(event.target.checked)}
disabled={uploading}
className="mt-0.5 h-4 w-4 shrink-0 accent-[#FF1610]"
className="mt-0.5 h-4 w-4 shrink-0 accent-[#00E5FF]"
/>
<span className="text-xs leading-relaxed text-[#c7c3c1]">
{SUBMISSION_LICENSE_TEXT} <span className="text-[#FF1610]">(required)</span>
<span className="text-xs leading-relaxed text-[#333]">
{SUBMISSION_LICENSE_TEXT} <span className="text-[#00798a]">(required)</span>
</span>
</label>
<label className="block text-2xs text-[#a4a09e]">
<label className="block text-2xs text-[#555]">
Credit name (optional, max {SUBMISSION_CREDIT_MAX})
<input
type="text"
@ -407,12 +407,12 @@ function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }):
onChange={(event) => setCreditName(event.target.value)}
disabled={uploading}
placeholder="How your name should appear in the credit"
className="mt-1 w-full rounded-md border border-[#3a312e] bg-[#060403] px-3 py-2 text-sm text-[#f5efed] placeholder:text-[#6b6563] focus:border-[#FF1610] focus:outline-none"
className="mt-1 w-full rounded-md border border-black bg-white px-3 py-2 text-sm text-black placeholder:text-[#888] focus:border-[#00E5FF] focus:outline-none"
/>
{creditTooLong && <span className="mt-1 block text-[#ff6f6b]">Up to {SUBMISSION_CREDIT_MAX} characters.</span>}
{creditTooLong && <span className="mt-1 block text-[#c4001a]">Up to {SUBMISSION_CREDIT_MAX} characters.</span>}
</label>
<label className="block text-2xs text-[#a4a09e]">
<label className="block text-2xs text-[#555]">
Contact (optional, max {SUBMISSION_CONTACT_MAX})
<textarea
value={contact}
@ -421,20 +421,20 @@ function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }):
disabled={uploading}
rows={2}
placeholder="How licensors can reach you — shown only to admins"
className="mt-1 w-full resize-none rounded-md border border-[#3a312e] bg-[#060403] px-3 py-2 text-sm text-[#f5efed] placeholder:text-[#6b6563] focus:border-[#FF1610] focus:outline-none"
className="mt-1 w-full resize-none rounded-md border border-black bg-white px-3 py-2 text-sm text-black placeholder:text-[#888] focus:border-[#00E5FF] focus:outline-none"
/>
{contactTooLong && <span className="mt-1 block text-[#ff6f6b]">Up to {SUBMISSION_CONTACT_MAX} characters.</span>}
{contactTooLong && <span className="mt-1 block text-[#c4001a]">Up to {SUBMISSION_CONTACT_MAX} characters.</span>}
</label>
</div>
<button
type="submit"
disabled={!ready || uploading}
className="w-full rounded-md bg-[#FF1610] px-4 py-2.5 text-sm font-semibold text-white transition-colors duration-150 hover:bg-[#e3130e] disabled:cursor-not-allowed disabled:opacity-40"
className="w-full rounded-md bg-[#00E5FF] px-4 py-2.5 text-sm font-semibold text-black transition-colors duration-150 hover:bg-[#5cefff] disabled:cursor-not-allowed disabled:opacity-40"
>
{uploading ? `Sending… ${progress}%` : "Send to the collective"}
</button>
<p className="text-center text-2xs leading-relaxed text-[#a4a09e]">
<p className="text-center text-2xs leading-relaxed text-[#555]">
The collective reviews every submission before anything is used. Your contact details stay with their admins.
</p>
</form>

View file

@ -1,51 +1,62 @@
import type { ReactElement } from "react";
/**
* The Tactical Media Broadcast Hub mark, inline.
*
* Source of truth: `public/brand/tactical-media-broadcast-hub.svg` — the arrows-from-circle
* mark, tilted −17.5° counterclockwise so the top arrow points upper-left
* (nothing static; everything points outward). The strokes use `currentColor`
* so the mark always rides the workspace accent, which in the warm palette
* lands within a hair of the brand red (#FF1610 ≈ oklch(0.63 0.24 29) vs the
* token's oklch(0.62 0.22 30)). Geometry is copied 1:1 from the asset on its
* 1700×1700 grid, minus the black plate and the screen-print distress paths
* (those only read on pure black at full size).
* The T-HUB mark, inline: three dome tents in a circle, each with a cable
* out of its door into a switch with an antenna. Drawn in the hand-inked
* style of the acampada26s.net mark, ink in `currentColor` over a neon
* offset copy. Source: docs/brand/t-hub.svg (same 400×400 geometry).
*/
const TENTS: ReadonlyArray<readonly [number, number]> = [[105, 150], [295, 150], [200, 340]];
function Tent({ x, y }: { x: number; y: number }): ReactElement {
return (
<g transform={`translate(${x},${y})`}>
<path fill="none" d="M-58 0 Q-56 -66 -1 -72 Q55 -66 58 0" strokeWidth="7" />
<path fill="none" d="M-64 1 Q0 -3 64 1" strokeWidth="6" />
<path fill="none" d="M-44 0 Q-30 -58 -1 -72" strokeWidth="4.5" />
<path fill="none" d="M44 0 Q30 -58 -1 -72" strokeWidth="4.5" />
<path d="M-15 0 Q-14 -30 0 -33 Q14 -30 15 0 Z" strokeWidth="4" />
<path fill="none" d="M-50 -14 L-41 -25 M-50 -4 L-37 -19 M-43 -1 L-33 -13 M-52 -27 L-45 -36" strokeWidth="2.6" />
<path fill="none" d="M28 -50 L37 -42 M24 -40 L36 -30 M22 -29 L34 -19" strokeWidth="2.6" />
<circle cx="-64" cy="1" r="5.5" stroke="none" />
<circle cx="64" cy="1" r="5.5" stroke="none" />
<circle cx="-1" cy="-73" r="5.5" stroke="none" />
</g>
);
}
function Mark({ paper }: { paper: string }): ReactElement {
return (
<>
<path fill="none" d="M105 150 Q112 206 168 214" strokeWidth="5" />
<path fill="none" d="M295 150 Q288 206 232 214" strokeWidth="5" />
<path fill="none" d="M200 318 Q197 280 200 236" strokeWidth="5" />
<path fill="none" d="M200 200 Q198 160 200 118" strokeWidth="6" />
<path fill="none" d="M184 98 Q200 82 216 98" strokeWidth="5" />
<path fill="none" d="M171 88 Q200 58 229 88" strokeWidth="5" />
<circle cx="200" cy="112" r="7" stroke="none" />
<path d="M168 201 L233 200 L234 235 L167 236 Z" strokeWidth="6" fill={paper} />
<path d="M179 212 h9 v11 h-9 z M196 212 h9 v11 h-9 z M213 212 h9 v11 h-9 z" stroke="none" />
{TENTS.map(([x, y]) => <Tent key={`${x}-${y}`} x={x} y={y} />)}
</>
);
}
export function LogoMark({
size = 32,
className = "text-accent",
title = "Tactical Media Broadcast Hub",
className = "text-text",
title = "T-HUB",
}: {
size?: number;
className?: string;
title?: string;
}): ReactElement {
return (
<svg
width={size}
height={size}
viewBox="0 0 1700 1700"
role="img"
aria-label={title}
className={className}
>
<g transform="rotate(-17.5 850 850)">
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
<circle cx="850" cy="850" r="350" strokeWidth="74" />
<path d="M 850 405 L 464.77 1269.52" strokeWidth="88" />
<path d="M 850 405 L 1235.23 1269.52" strokeWidth="88" />
<path d="M 850 405 L 850 350" strokeWidth="88" />
<path d="M 345 850 L 1355 850" strokeWidth="88" />
</g>
<g fill="currentColor">
<polygon points="850,240 925,385 775,385" />
<polygon points="235,850 380,775 380,925" />
<polygon points="1465,850 1320,925 1320,775" />
<polygon points="420,1370 410.51,1207.03 547.52,1268.08" />
<polygon points="1280,1370 1152.48,1268.08 1289.49,1207.03" />
</g>
<svg width={size} height={size} viewBox="0 0 400 400" role="img" aria-label={title} className={className}>
<g strokeLinecap="round" strokeLinejoin="round">
<g transform="translate(7,7)" stroke="#00e5ff" fill="#00e5ff"><Mark paper="#fff" /></g>
<g stroke="currentColor" fill="currentColor"><Mark paper="#fff" /></g>
</g>
</svg>
);
@ -112,10 +123,14 @@ export function Logo({ size = 32, withText = true }: { size?: number; withText?:
<span className={`inline-flex ${stacked ? "flex-col items-center gap-3" : "items-center gap-2.5"}`}>
<LogoMark size={size} />
<span
className={`font-semibold tracking-tight text-text ${stacked ? "text-2xl" : ""}`}
style={stacked ? undefined : { fontSize: `${Math.max(13, Math.round(size * 0.45))}px` }}
className={`uppercase tracking-wide text-text ${stacked ? "text-4xl" : ""}`}
style={{
fontFamily: "var(--font-title)",
textShadow: "0.04em 0.04em 0 var(--color-neon)",
...(stacked ? {} : { fontSize: `${Math.max(16, Math.round(size * 0.6))}px` }),
}}
>
Tactical Media Broadcast Hub
T-HUB
</span>
</span>
);

View file

@ -46,7 +46,7 @@ export function CommandPalette(): React.ReactElement {
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<div className="flex items-center gap-2.5 border-b border-border-subtle px-4">
<LogoMark size={16} title="Tactical Media Broadcast Hub" />
<LogoMark size={16} />
<Command.Input
autoFocus
placeholder="Type a command or search…"

View file

@ -18,6 +18,7 @@ const NAV: { href: string; label: string; icon: string; external?: boolean }[] =
{ href: "/files", label: "Files", icon: "files" },
{ href: "/archive", label: "Archive", icon: "files" },
{ href: "/inbox", label: "Inbox", icon: "inbox" },
{ href: "/outreach", label: "Outreach", icon: "team" },
{ href: "/approvals", label: "Approvals", icon: "approvals" },
{ href: "/video", label: "Video", icon: "video" },
{ href: "/analytics", label: "Analytics", icon: "analytics" },
@ -54,7 +55,7 @@ export function Sidebar(): React.ReactElement {
className="mt-0.5 h-[30px] w-[30px] shrink-0 rounded object-contain"
/>
) : (
<LogoMark size={30} title="Tactical Media Broadcast Hub" />
<LogoMark size={30} />
)}
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold leading-tight tracking-tight" title={workspaceName}>
@ -66,7 +67,7 @@ export function Sidebar(): React.ReactElement {
</div>
) : (
<div className="truncate text-2xs text-text-faint" title={workspaceName}>
Tactical Media Broadcast Hub
T-HUB
</div>
)}
</div>

View file

@ -0,0 +1,30 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { fillTemplate, parseImport, sentToday, type OutreachContact } from "./outreach-types";
test("parseImport reads quoted fields, skips header, blanks and comments", () => {
const { rows, errors } = parseImport([
"name,platform,handle,profileUrl,category,language",
"# afines",
"",
'"Stop the War, UK",Web,@STWuk,https://example.org/stw,paz,en',
].join("\n"));
assert.deepEqual(errors, []);
assert.deepEqual(rows, [{ name: "Stop the War, UK", platform: "web", handle: "@STWuk", profileUrl: "https://example.org/stw", category: "paz", language: "en" }]);
});
test("parseImport reports missing columns and non-https links by line", () => {
const { rows, errors } = parseImport("A,web,@a\nB,web,@b,http://example.org/b");
assert.deepEqual(rows, []);
assert.deepEqual(errors, ["line 1: needs name, platform, handle and profileUrl", "line 2: profileUrl must start with https://"]);
});
test("fillTemplate replaces every placeholder", () => {
assert.equal(fillTemplate("Hola {name} ({handle}), {name}", { name: "DAL", handle: "@dal" }), "Hola DAL (@dal), DAL");
});
test("sentToday counts first messages sent on the same local day", () => {
const now = new Date(2026, 9, 15, 18);
const at = (d: Date | null) => ({ sentAt: d === null ? null : d.toISOString() }) as OutreachContact;
assert.equal(sentToday([at(new Date(2026, 9, 15, 9)), at(new Date(2026, 9, 14, 23)), at(null)], now), 1);
});

View file

@ -0,0 +1,93 @@
/** Mirrors apps/api/src/outreach/outreach.service.ts (OutreachContact). */
export const OUTREACH_STATUSES = ["todo", "sent", "replied", "declined"] as const;
export type OutreachStatus = (typeof OUTREACH_STATUSES)[number];
export interface OutreachContact {
id: string;
name: string;
platform: string;
handle: string;
profileUrl: string;
category: string | null;
language: string | null;
message: string;
notes: string;
status: OutreachStatus;
sentAt: string | null;
repliedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface OutreachImportRow {
name: string;
platform: string;
handle: string;
profileUrl: string;
category?: string;
language?: string;
}
/** First messages per day above which platforms start flagging an account. */
export const OUTREACH_DAILY_SOFT_CAP = 20;
export const IMPORT_COLUMNS = ["name", "platform", "handle", "profileUrl", "category", "language"] as const;
/**
* Parse pasted CSV (one contact per line, IMPORT_COLUMNS order, optional
* header). Double quotes wrap fields that contain commas. Blank lines and
* lines starting with # are skipped. Returns rows plus per-line errors.
*/
export function parseImport(text: string): { rows: OutreachImportRow[]; errors: string[] } {
const rows: OutreachImportRow[] = [];
const errors: string[] = [];
text.split(/\r?\n/).forEach((line, index) => {
const trimmed = line.trim();
if (trimmed === "" || trimmed.startsWith("#")) return;
const cells = splitCsvLine(trimmed);
if (index === 0 && cells[0]?.toLowerCase() === "name") return;
const [name, platform, handle, profileUrl, category, language] = cells;
if (!name || !platform || !handle || !profileUrl) {
errors.push(`line ${index + 1}: needs name, platform, handle and profileUrl`);
return;
}
if (!profileUrl.startsWith("https://")) {
errors.push(`line ${index + 1}: profileUrl must start with https://`);
return;
}
const row: OutreachImportRow = { name, platform: platform.toLowerCase(), handle, profileUrl };
if (category) row.category = category;
if (language) row.language = language;
rows.push(row);
});
return { rows, errors };
}
function splitCsvLine(line: string): string[] {
const cells: string[] = [];
let current = "";
let quoted = false;
for (let i = 0; i < line.length; i += 1) {
const char = line[i];
if (quoted) {
if (char === '"' && line[i + 1] === '"') { current += '"'; i += 1; }
else if (char === '"') quoted = false;
else current += char;
} else if (char === '"') quoted = true;
else if (char === ",") { cells.push(current.trim()); current = ""; }
else current += char;
}
cells.push(current.trim());
return cells;
}
/** Fill a template: {name} and {handle} are replaced per contact. */
export function fillTemplate(template: string, contact: Pick<OutreachContact, "name" | "handle">): string {
return template.replaceAll("{name}", contact.name).replaceAll("{handle}", contact.handle);
}
export function sentToday(contacts: OutreachContact[], now = new Date()): number {
const day = now.toDateString();
return contacts.filter((contact) => contact.sentAt !== null && new Date(contact.sentAt).toDateString() === day).length;
}

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

@ -0,0 +1,205 @@
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.

BIN
docs/brand/t-hub-lockup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 120 KiB

44
docs/brand/t-hub.svg Normal file
View file

@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 400 400" width="400" height="400" role="img" aria-labelledby="t">
<title id="t">T-HUB</title>
<defs>
<!-- Dome tent in the hand-inked style of the acampada26s.net mark:
curved poles with ball ends, centre seam, hatched panel, door.
Base midpoint at 0,0. -->
<g id="tienda" stroke-linecap="round" stroke-linejoin="round">
<path fill="none" d="M-58 0 Q-56 -66 -1 -72 Q55 -66 58 0" stroke-width="7"/>
<path fill="none" d="M-64 1 Q0 -3 64 1" stroke-width="6"/>
<path fill="none" d="M-44 0 Q-30 -58 -1 -72" stroke-width="4.5"/>
<path fill="none" d="M44 0 Q30 -58 -1 -72" stroke-width="4.5"/>
<path d="M-15 0 Q-14 -30 0 -33 Q14 -30 15 0 Z" stroke-width="4"/>
<path fill="none" d="M-50 -14 L-41 -25 M-50 -4 L-37 -19 M-43 -1 L-33 -13 M-52 -27 L-45 -36" stroke-width="2.6"/>
<path fill="none" d="M28 -50 L37 -42 M24 -40 L36 -30 M22 -29 L34 -19" stroke-width="2.6"/>
<circle cx="-64" cy="1" r="5.5" stroke="none"/>
<circle cx="64" cy="1" r="5.5" stroke="none"/>
<circle cx="-1" cy="-73" r="5.5" stroke="none"/>
</g>
<g id="marca" stroke-linecap="round" stroke-linejoin="round">
<!-- cables: out of each tent door to the switch -->
<path fill="none" d="M105 150 Q112 206 168 214" stroke-width="5"/>
<path fill="none" d="M295 150 Q288 206 232 214" stroke-width="5"/>
<path fill="none" d="M200 318 Q197 280 200 236" stroke-width="5"/>
<!-- antenna -->
<path fill="none" d="M200 200 Q198 160 200 118" stroke-width="6"/>
<path fill="none" d="M184 98 Q200 82 216 98" stroke-width="5"/>
<path fill="none" d="M171 88 Q200 58 229 88" stroke-width="5"/>
<circle cx="200" cy="112" r="7" stroke="none"/>
<!-- switch -->
<path d="M168 201 L233 200 L234 235 L167 236 Z" stroke-width="6" fill="#fff"/>
<path d="M179 212 h9 v11 h-9 z M196 212 h9 v11 h-9 z M213 212 h9 v11 h-9 z" stroke="none"/>
<use xlink:href="#tienda" transform="translate(105,150)"/>
<use xlink:href="#tienda" transform="translate(295,150)"/>
<use xlink:href="#tienda" transform="translate(200,340)"/>
</g>
</defs>
<!-- neon offset underneath, ink on top, as on acampada26s.net -->
<g transform="translate(7,7)" stroke="#00e5ff" fill="#00e5ff">
<use xlink:href="#marca"/>
</g>
<g stroke="#000" fill="#000">
<use xlink:href="#marca"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -1,6 +1,6 @@
# Connecting platform accounts
The API resolves authorization behavior through connector methods and manifests. It currently registers Mastodon, PeerTube, and Telegram. Other providers are not offered as connectable until a connector implements the same authorization surface.
The API resolves authorization behavior through connector methods and manifests. It currently registers Mastodon, PeerTube, Telegram, and Instagram. Other providers are not offered as connectable until a connector implements the same authorization surface.
## Configuration
@ -16,6 +16,7 @@ The API resolves authorization behavior through connector methods and manifests.
| `MASTODON_CLIENT_ID`, `MASTODON_CLIENT_SECRET` | Optional pre-registered Mastodon application credentials, used only for `MASTODON_INSTANCE_URL`. |
| `PEERTUBE_INSTANCE_URL` | Instance origin to which the configured PeerTube bridge client belongs. |
| `PEERTUBE_CLIENT_ID`, `PEERTUBE_CLIENT_SECRET` | Optional credentials for that instance. Otherwise the connector discovers the local client through `/api/v1/oauth-clients/local`. |
| `INSTAGRAM_CLIENT_ID`, `INSTAGRAM_CLIENT_SECRET` | The Hub's Facebook app (App ID / App Secret from developers.facebook.com → App settings → Basic). One pair per deployment; Instagram is offered as connectable only when both are set. |
| `PEERTUBE_AUTHORIZE_PATH`, `PEERTUBE_TOKEN_PATH` | Required to enable a PeerTube redirect bridge. Both must be paths beginning with `/`, without a second leading slash, on the selected instance origin. |
Use HTTPS and the same browser site for the web and API (for example `app.example.org` and `api.example.org`), or proxy the API under the web origin. The callback binding cookie is HttpOnly, SameSite=Lax, and Secure in production. For development, consistently use `localhost` or consistently use `127.0.0.1` for both web and API; mixing the two breaks browser cookie delivery.
@ -30,6 +31,14 @@ Stock PeerTube documents local OAuth client discovery followed by a **password g
An instance administrator can enable a compatible OAuth bridge, then configure the two paths above. Its authorize endpoint must preserve `state`, register/validate the redirect URI, support `response_type=code` and S256 PKCE, and redirect back with a one-use code. Its token endpoint must validate `client_id`, `client_secret`, `redirect_uri`, `code`, and `code_verifier`, returning **actual PeerTube API** `access_token` and `refresh_token` values. Identity, refresh, and revoke then use PeerTube's native `/api/v1/users/me`, `/api/v1/users/token`, and `/api/v1/users/revoke-token` endpoints. These settings do not add an authorization-code grant to a stock instance, and an OIDC identity token alone is insufficient. The mock integration test proves this adapter contract; no third-party bridge is included in this change.
### Instagram
Instagram connects through Facebook Login against the Hub's own Facebook app. The App ID and App Secret identify the Hub, not a collective: they are read only from `INSTAGRAM_CLIENT_ID` / `INSTAGRAM_CLIENT_SECRET`, never entered on the connect form and never written to the vault or the pending store. In the Facebook app, add the product *Facebook Login for Business* and register the exact `CONNECTIONS_CALLBACK_URL` (default `${API_URL}/connections/callback`) as a Valid OAuth Redirect URI.
Each person connects their own account through Meta's consent dialog; the Hub keeps the resulting long-lived token encrypted per connection. Requirements per account: an Instagram **Business or Creator** account linked to a Facebook Page, and — while the app is in Development mode — a Tester role on the app. Grant exactly one Instagram account in the dialog: when several are granted the connect fails instead of guessing which one to publish as. Public use needs Meta App Review for `instagram_basic`, `instagram_content_publish` and `pages_show_list`.
Limitations: Instagram has no text-only posts and no binary upload — Meta downloads each JPEG from a public https URL. Media storage is client-side encrypted, so S3 presigned URLs cannot serve it; publishing images requires a short-lived plaintext media endpoint that is not part of this change. Until then publishing to Instagram fails with an explicit error. Stories are not publishable through the API; Meta caps API publishing at 25 posts per rolling 24 h per account.
### Telegram
Create a bot in BotFather and enter its bot token. The connector validates it with `getMe`, then stores the token through the same encrypted vault envelope as OAuth credentials. Optionally supply a chat/channel ID (`@channel` or a numeric chat ID); add the bot to the target and grant any necessary posting rights. Identity health confirms token validity, not permission to publish to every chat. Reconnect accepts a replacement bot token for the same bot and rotates the credential row in place. Telegram's Bot API cannot revoke its own bot token: Remove clears local authorization; revoke/rotate the token through BotFather when needed.

View file

@ -176,7 +176,6 @@ services:
<<: *appenv
MEDIA_WORKER_LANE: local
MEDIA_SCRATCH_DIR: /var/lib/media-worker/produce
MEDIA_DEBUG_STDERR: "1"
volumes: [mediaworkerstate:/var/lib/media-worker]
depends_on:
postgres: { condition: service_healthy }

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ConnectorConnection, NormalizedPost } from "@tactical-media-broadcast-hub/core";
import { AuthError, InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
import { InMemoryCredentials } from "@tactical-media-broadcast-hub/connector-sdk";
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
import {
AuthorizationRedirectRequired,
@ -19,7 +19,6 @@ interface StubCall {
init: RequestInit | undefined;
}
const API = "https://graph.facebook.com/v21.0";
const DIALOG = "https://www.facebook.com/v21.0/dialog/oauth";
const REDIRECT = "https://app.example.com/oauth/instagram/callback";
const IG_USER_ID = "17841400000000001";
@ -169,11 +168,12 @@ interface Harness {
credentials: InMemoryCredentials;
}
/** The Hub's Facebook app lives in the API environment, never in a connection. */
const APP_ENV = { INSTAGRAM_CLIENT_ID: "stub-client-id", INSTAGRAM_CLIENT_SECRET: "stub-client-secret" };
async function createHarness(seedToken = true): Promise<Harness> {
const stub = { calls: [], nextId: 100 };
const credentials = new InMemoryCredentials();
await credentials.put(connection, "client_id", "stub-client-id");
await credentials.put(connection, "client_secret", "stub-client-secret");
await credentials.put(connection, "ig_user_id", IG_USER_ID);
if (seedToken) await credentials.put(connection, "access_token", LONG_TOKEN);
const connector = createInstagramConnector({
@ -181,7 +181,7 @@ async function createHarness(seedToken = true): Promise<Harness> {
requestsPerSecond: 1000,
sleep: async () => Promise.resolve(),
credentials,
env: {},
env: APP_ENV,
});
return { stub, connector, credentials };
}
@ -190,8 +190,6 @@ const leg1: InstagramAuthorizationRequest = {
redirectUri: REDIRECT,
workspaceId: "ws-1",
state: "csrf-1",
// App credentials arrive as connection fields on the connect form.
fields: { clientId: "stub-client-id", clientSecret: "stub-client-secret" },
};
const post = (text: string, mediaAssetIds: string[] = [], providerMetadata: Record<string, unknown> = {}): NormalizedPost => ({
@ -250,9 +248,9 @@ describe("instagram connector: authorize + identity", () => {
expect(dialog.searchParams.get("scope")).toBe("instagram_basic,instagram_content_publish,pages_show_list");
expect(dialog.searchParams.get("state")).toBe("csrf-1");
expect(redirect.connectionId).toBe("instagram:ws-1");
// App credentials persist for leg 2.
expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).toBe("stub-client-id");
expect(await credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).toBe("stub-client-secret");
// App credentials stay in the environment: nothing app-level reaches the vault.
await expect(credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_id")).rejects.toThrow();
await expect(credentials.getSecret({ ...connection, id: redirect.connectionId }, "client_secret")).rejects.toThrow();
expect(stub.calls.filter((c) => pathOf(c) === "/oauth/access_token")).toHaveLength(0);
});
@ -299,7 +297,7 @@ describe("instagram connector: authorize + identity", () => {
});
it("fails loudly when no Page carries an instagram_business_account (personal account)", async () => {
const fetchImpl: typeof fetch = async (input, init) => {
const fetchImpl: typeof fetch = async (input) => {
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const url = new URL(href);
url.pathname = url.pathname.replace(/^\/v21\.0/, "");
@ -312,20 +310,56 @@ describe("instagram connector: authorize + identity", () => {
}
return graphError("no route", 100, 404);
};
const credentials = new InMemoryCredentials();
await credentials.put(connection, "client_id", "stub-client-id");
await credentials.put(connection, "client_secret", "stub-client-secret");
const connector = createInstagramConnector({
fetchImpl,
requestsPerSecond: 1000,
sleep: async () => undefined,
credentials,
env: {},
credentials: new InMemoryCredentials(),
env: APP_ENV,
});
const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
await expect(connector.authorize(request)).rejects.toThrow(/no Instagram professional account is linked/);
});
it("refuses to pick one when several Instagram accounts were granted", async () => {
const fetchImpl: typeof fetch = async (input) => {
const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
const url = new URL(href);
url.pathname = url.pathname.replace(/^\/v21\.0/, "");
if (url.pathname === "/oauth/access_token") {
return json({ access_token: url.searchParams.get("code") ? SHORT_TOKEN : LONG_TOKEN, token_type: "bearer" });
}
if (url.pathname === "/me") return json({ id: FB_USER_ID });
if (url.pathname === "/me/accounts") {
return json({
data: [
{ id: "page-a", instagram_business_account: { id: "17841400000000001", username: "acampada_a" } },
{ id: "page-b", instagram_business_account: { id: "17841400000000002", username: "acampada_b" } },
],
});
}
return graphError("no route", 100, 404);
};
const connector = createInstagramConnector({
fetchImpl,
requestsPerSecond: 1000,
sleep: async () => undefined,
credentials: new InMemoryCredentials(),
env: APP_ENV,
});
const request: InstagramAuthorizationRequest = { ...leg1, providerMetadata: { code: "good-code" } };
await expect(connector.authorize(request)).rejects.toThrow(/2 Instagram professional accounts were granted/);
});
it("is not connectable until the Hub's Facebook app is configured", async () => {
const connector = createInstagramConnector({ credentials: new InMemoryCredentials(), env: {} });
expect(connector.manifest().oauth?.connectEnabled).toBe(false);
expect(connector.manifest().connectionFields.map((field) => field.name)).toEqual(["igUserId"]);
await expect(connector.authorize(leg1)).rejects.toThrow(/INSTAGRAM_CLIENT_ID/);
const configured = createInstagramConnector({ credentials: new InMemoryCredentials(), env: APP_ENV });
expect(configured.manifest().oauth?.connectEnabled).toBe(true);
});
it("getIdentity re-resolves the stored IG account through /me + /me/accounts", async () => {
const { stub, connector } = await createHarness();
const identity = await connector.getIdentity(connection);
@ -374,8 +408,6 @@ describe("instagram connector: publish", () => {
it("uploads assets through loadAsset and captions the container", async () => {
const stub = { calls: [] as StubCall[], nextId: 100 };
const credentials = new InMemoryCredentials();
await credentials.put(connection, "client_id", "stub-client-id");
await credentials.put(connection, "client_secret", "stub-client-secret");
await credentials.put(connection, "ig_user_id", IG_USER_ID);
await credentials.put(connection, "access_token", LONG_TOKEN);
const connector = createInstagramConnector({
@ -415,8 +447,6 @@ describe("instagram connector: publish", () => {
const stub = { calls: [] as StubCall[], nextId: 100 };
const credentials = new InMemoryCredentials();
for (const [key, value] of [
["client_id", "stub-client-id"],
["client_secret", "stub-client-secret"],
["ig_user_id", IG_USER_ID],
["access_token", LONG_TOKEN],
] as const) {
@ -458,8 +488,6 @@ describe("instagram connector: publish", () => {
const stub = { calls: [] as StubCall[], nextId: 100 };
const credentials = new InMemoryCredentials();
for (const [key, value] of [
["client_id", "stub-client-id"],
["client_secret", "stub-client-secret"],
["ig_user_id", IG_USER_ID],
["access_token", LONG_TOKEN],
] as const) {
@ -499,7 +527,6 @@ describe("instagram connector: publish", () => {
it("rejects non-JPEG assets", async () => {
const { connector } = await createHarness();
(connector as unknown as { options?: unknown });
await expect(
connector.publish?.(connection, post("png", ["asset-png"], {
media: [],
@ -538,8 +565,6 @@ describe("instagram connector: deletePublication", () => {
const stub404Calls: StubCall[] = [];
const credentials = new InMemoryCredentials();
for (const [key, value] of [
["client_id", "stub-client-id"],
["client_secret", "stub-client-secret"],
["ig_user_id", IG_USER_ID],
["access_token", LONG_TOKEN],
] as const) {

View file

@ -10,7 +10,9 @@
* immediately exchanges it (grant_type=fb_exchange_token) for a long-lived
* (~60 day) token, then resolves the Instagram professional account through
* GET /me + GET /me/accounts (instagram_business_account). Stored credential
* keys: access_token, ig_user_id, client_id, client_secret.
* keys: access_token, ig_user_id. The Facebook app credentials identify the
* Hub, not a collective: they come only from INSTAGRAM_CLIENT_ID /
* INSTAGRAM_CLIENT_SECRET in the API environment and never enter the vault.
*
* Honest capability notes (verified against the Graph API):
* - Instagram has no text-only posts. publishText is declared only because
@ -100,8 +102,6 @@ const CAPABILITIES: ConnectorCapabilities = {
const SECRET = {
accessToken: "access_token",
igUserId: "ig_user_id",
clientId: "client_id",
clientSecret: "client_secret",
} as const;
export interface InstagramConnectorOptions {
@ -115,7 +115,7 @@ export interface InstagramConnectorOptions {
credentials?: CredentialAccessor;
/** Graph API version path segment. Default v21.0. */
apiVersion?: string;
/** Facebook app credentials fallback when not supplied per connection. */
/** INSTAGRAM_CLIENT_ID / INSTAGRAM_CLIENT_SECRET: the Hub's Facebook app. */
env?: Record<string, string | undefined>;
/** Resolve a media asset id to its metadata (needed by publish with media). */
loadAsset?: (assetId: string) => Promise<MediaAsset>;
@ -270,18 +270,6 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
displayName: "Instagram",
capabilities: CAPABILITIES,
connectionFields: [
{
name: "clientId",
label: "Facebook app ID (or INSTAGRAM_CLIENT_ID env)",
required: false,
secret: false,
},
{
name: "clientSecret",
label: "Facebook app secret (or INSTAGRAM_CLIENT_SECRET env)",
required: false,
secret: true,
},
{
name: "igUserId",
label: "Instagram professional account id (optional — resolved at connect)",
@ -296,14 +284,12 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
authorizeUrlTemplate: `${dialogBase}/dialog/oauth`,
scopes: SCOPES,
pkce: false,
connectEnabled: Boolean(str(env.INSTAGRAM_CLIENT_ID) && str(env.INSTAGRAM_CLIENT_SECRET)),
connectNote: "Instagram linking needs the Hub's Facebook app: set INSTAGRAM_CLIENT_ID and INSTAGRAM_CLIENT_SECRET on the API.",
},
docsUrl: "https://developers.facebook.com/docs/instagram-platform/content-publishing",
};
function igUserIdOf(connection: ConnectorConnection): string {
return str(connection.fields.igUserId) ?? `vault:${connection.id}`;
}
/** Vault first; the connection field is the dev/test fallback. */
async function tokenOf(connection: ConnectorConnection): Promise<string> {
try {
@ -333,24 +319,12 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
}
}
/** Facebook app credentials: vault first, then connection fields, then env. */
void secretOrUndefined;
async function clientCredentials(
connection: ConnectorConnection,
): Promise<{ clientId: string; clientSecret: string }> {
const clientId =
(await secretOrUndefined(connection, SECRET.clientId)) ??
str(connection.fields.clientId) ??
str(env.INSTAGRAM_CLIENT_ID);
const clientSecret =
(await secretOrUndefined(connection, SECRET.clientSecret)) ??
str(connection.fields.clientSecret) ??
str(env.INSTAGRAM_CLIENT_SECRET);
/** The Hub's Facebook app credentials — server environment only, never per connection. */
function clientCredentials(): { clientId: string; clientSecret: string } {
const clientId = str(env.INSTAGRAM_CLIENT_ID);
const clientSecret = str(env.INSTAGRAM_CLIENT_SECRET);
if (clientId === undefined || clientSecret === undefined) {
throw new ConnectorError(
`${PROVIDER}: no Facebook app credentials — set fields.clientId/clientSecret or INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`,
PROVIDER,
);
throw new ConnectorError(`${PROVIDER}: no Facebook app credentials — set INSTAGRAM_CLIENT_ID/INSTAGRAM_CLIENT_SECRET`, PROVIDER);
}
return { clientId, clientSecret };
}
@ -408,16 +382,27 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
/**
* GET /me validates the token (it is the Facebook user under Facebook
* Login), then GET /me/accounts resolves the first Page carrying an
* Login), then GET /me/accounts resolves the Page carrying an
* instagram_business_account — the IG professional account to publish as.
* Exactly one must be granted: picking one of several would publish as an
* account nobody chose, so the person re-links selecting a single one in
* Meta's dialog.
*/
async function fetchIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
const me = await graph<FacebookUser>(connection, "GET", "/me", { fields: "id,name" });
const { data } = await graph<GraphList<FacebookPage>>(connection, "GET", "/me/accounts", {
fields: PAGE_FIELDS,
});
const linked = data.find((page) => page.instagram_business_account !== undefined)
?.instagram_business_account;
const granted = data
.map((page) => page.instagram_business_account)
.filter((account): account is NonNullable<FacebookPage["instagram_business_account"]> => account !== undefined);
if (granted.length > 1) {
throw new ConnectorError(
`${PROVIDER}: ${granted.length} Instagram professional accounts were granted — select exactly one in the Facebook dialog`,
PROVIDER,
);
}
const linked = granted[0];
if (linked === undefined) {
throw new ConnectorError(
`${PROVIDER}: no Instagram professional account is linked to any Page of Facebook user ${me.id} — convert the account to business/creator and link a Page`,
@ -516,7 +501,7 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
provider: PROVIDER,
channelId: error.connectionId,
credentialId: { kind: "credential-ref", id: error.connectionId },
fields: input.fields?.clientId !== undefined ? { clientId: input.fields.clientId } : {},
fields: {},
createdAt: new Date(),
};
return { authorizationUrl: error.authorizationUrl, connection };
@ -531,7 +516,7 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
},
/**
* Leg 1 (no code): validate app credentials, store them in the vault and
* Leg 1 (no code): read the Hub's app credentials from the environment and
* throw AuthorizationRedirectRequired with the Facebook dialog URL.
* Leg 2 (providerMetadata.code): code → short-lived token → long-lived
* token (fb_exchange_token) → resolve the IG professional account via
@ -542,24 +527,17 @@ export function createInstagramConnector(options: InstagramConnectorOptions = {}
const code = str(meta.code);
const connectionId = str(input.connectionId) ?? str(meta.connectionId) ?? `${PROVIDER}:${input.workspaceId}`;
const credentialId: CredentialRef = { kind: "credential-ref", id: connectionId };
const provided: Record<string, string> = {};
const fieldClientId = str(input.fields?.clientId);
const fieldClientSecret = str(input.fields?.clientSecret);
if (fieldClientId !== undefined) provided.clientId = fieldClientId;
if (fieldClientSecret !== undefined) provided.clientSecret = fieldClientSecret;
const connection: ConnectorConnection = {
id: connectionId,
workspaceId: input.workspaceId,
provider: PROVIDER,
channelId: str(meta.channelId) ?? connectionId,
credentialId,
fields: { ...provided },
fields: {},
createdAt: new Date(),
};
const client = await clientCredentials({ ...connection, fields: provided });
await credentials.put(connection, SECRET.clientId, client.clientId);
await credentials.put(connection, SECRET.clientSecret, client.clientSecret);
const client = clientCredentials();
if (code === undefined) {
const url = new URL(`${dialogBase}/dialog/oauth`);

View file

@ -19,7 +19,9 @@
"@tactical-media-broadcast-hub/core": "workspace:*",
"pg": "^8.23.1",
"@tactical-media-broadcast-hub/connector-mastodon": "workspace:*",
"@tactical-media-broadcast-hub/connector-telegram": "workspace:*"
"@tactical-media-broadcast-hub/connector-telegram": "workspace:*",
"@tactical-media-broadcast-hub/connector-instagram": "workspace:*",
"@aws-sdk/client-s3": "^3.1146.0"
},
"devDependencies": {
"@types/node": "^22.20.5",

View file

@ -39,3 +39,10 @@ export function publishingContext(connection: ConnectorConnection): PublishingEx
return context;
}
export function executionSignal(): AbortSignal | undefined { return executionStorage.getStore()?.signal; }
/** The running execution's context; media access has no connection to fence on, only the tenant. */
export function currentPublishingContext(): PublishingExecutionContext {
const context = executionStorage.getStore();
if (!context) throw new Error("EXECUTION_CONTEXT_MISSING");
context.signal?.throwIfAborted();
return context;
}

View file

@ -37,6 +37,22 @@ export interface DispatchPreparation {
* drop author-supplied execution keys, inject the attempt's idempotency key
* and strip scheduledAt unless provider scheduling is explicitly allowed.
*/
/**
* The approved snapshot carries alt texts positionally (altTexts[i] describes
* mediaAssetIds[i]); connectors read them as providerMetadata.altText keyed by
* asset id. Pipeline-owned, so it replaces any author-set map.
*/
function altTextByAsset(post: NormalizedPost): { altText?: Record<string, string> } {
const altTexts = (post as NormalizedPost & { altTexts?: unknown }).altTexts;
if (!Array.isArray(altTexts) || altTexts.length !== post.mediaAssetIds.length) return {};
const map: Record<string, string> = {};
post.mediaAssetIds.forEach((id, index) => {
const text = altTexts[index];
if (typeof text === "string" && text.trim() !== "") map[id] = text;
});
return Object.keys(map).length === 0 ? {} : { altText: map };
}
export function preparePostForDispatch(post: NormalizedPost, prep: DispatchPreparation): NormalizedPost {
for (const key of FORBIDDEN_POST_METADATA_KEYS) {
if (Object.prototype.hasOwnProperty.call(post.providerMetadata, key)) throw new ExecutionMetadataError(key);
@ -45,7 +61,7 @@ export function preparePostForDispatch(post: NormalizedPost, prep: DispatchPrepa
const prepared: NormalizedPost = {
...stripped,
mediaAssetIds: [...post.mediaAssetIds],
providerMetadata: { ...stripped.providerMetadata, idempotencyKey: prep.idempotencyKey },
providerMetadata: { ...stripped.providerMetadata, ...altTextByAsset(post), idempotencyKey: prep.idempotencyKey },
};
if (prep.allowProviderScheduling !== true) delete prepared.scheduledAt;
return prepared;

View file

@ -7,3 +7,4 @@ export * from "./vault.js";
export * from "./provider-fetch.js";
export * from "./credentials.js";
export * from "./registry.js";
export * from "./media.js";

View file

@ -0,0 +1,86 @@
import { createCipheriv, createHash, randomBytes } from "node:crypto";
import type { Pool } from "pg";
import { describe, expect, it } from "vitest";
import type { MediaAsset, NormalizedPost } from "@tactical-media-broadcast-hub/core";
import { runWithPublishingContext } from "./execution-context.js";
import { preparePostForDispatch } from "./idempotency.js";
import { createPublishingMediaAccess, openSealedFrame } from "./media.js";
const KEK = randomBytes(32);
const WS = "0d9a1b40-6f0f-4b1e-8f0e-3f3c9e6b4a10";
const context = { workspaceId: WS, connectionId: "conn-1", credentialId: "cred-1", credentialGeneration: "1" };
/** The API's frame: magic | iv | AES-GCM(kek, cek) | AES-GCM(cek, body). */
function seal(plaintext: Buffer, kek = KEK): Buffer {
const cek = randomBytes(32);
const iv = randomBytes(12);
const wrap = createCipheriv("aes-256-gcm", kek, iv);
const sealedCek = Buffer.concat([wrap.update(cek), wrap.final(), wrap.getAuthTag()]);
const cipher = createCipheriv("aes-256-gcm", cek, iv);
const body = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
return Buffer.concat([Buffer.from("SPENC1"), iv, sealedCek, body]);
}
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4]);
const KEY = `sha256/${createHash("sha256").update(JPEG).digest("hex")}.jpg`;
const asset: MediaAsset = { id: "11111111-2222-4333-8444-555555555555", workspaceId: WS, storageKey: KEY, mimeType: "image/jpeg", sizeBytes: JPEG.length, createdAt: new Date() };
function fakePool(rows: unknown[]): { pool: Pool; queries: Array<{ sql: string; values?: unknown[] }> } {
const queries: Array<{ sql: string; values?: unknown[] }> = [];
const client = {
query: async (sql: string, values?: unknown[]) => { queries.push({ sql, values }); return { rows: sql.startsWith("SELECT id") ? rows : [] }; },
release: () => undefined,
};
return { pool: { connect: async () => client } as unknown as Pool, queries };
}
describe("openSealedFrame", () => {
it("opens the API frame and rejects tampering or a wrong key", () => {
const sealed = seal(JPEG);
expect(openSealedFrame(sealed, KEK).equals(JPEG)).toBe(true);
const flipped = Buffer.from(sealed); flipped[flipped.length - 20]! ^= 1;
expect(() => openSealedFrame(flipped, KEK)).toThrow();
expect(() => openSealedFrame(sealed, randomBytes(32))).toThrow();
expect(() => openSealedFrame(Buffer.from("plain"), KEK)).toThrow(/MEDIA_FRAME_INVALID/);
});
});
describe("createPublishingMediaAccess", () => {
it("loads the asset inside the execution's tenant", async () => {
const { pool, queries } = fakePool([{ id: asset.id, workspace_id: WS, storage_key: KEY, mime: "image/jpeg", size_bytes: "8", created_at: asset.createdAt }]);
const media = createPublishingMediaAccess(pool, { read: async () => seal(JPEG) }, KEK);
const loaded = await runWithPublishingContext(context, () => media.loadAsset(asset.id));
expect(loaded).toMatchObject({ id: asset.id, workspaceId: WS, storageKey: KEY, mimeType: "image/jpeg", sizeBytes: 8 });
expect(queries[1]).toEqual({ sql: "SELECT set_config('app.workspace_id', $1, true)", values: [WS] });
expect(queries[2]?.sql).toContain("workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid");
});
it("refuses outside a publishing execution and for unknown ids", async () => {
const { pool } = fakePool([]);
const media = createPublishingMediaAccess(pool, { read: async () => seal(JPEG) }, KEK);
await expect(media.loadAsset(asset.id)).rejects.toThrow(/EXECUTION_CONTEXT_MISSING/);
await expect(runWithPublishingContext(context, () => media.loadAsset(asset.id))).rejects.toThrow(/MEDIA_ASSET_NOT_FOUND/);
});
it("opens bytes, checks the content address and the tenant", async () => {
const media = createPublishingMediaAccess(fakePool([]).pool, { read: async () => seal(JPEG) }, KEK);
const opened = await runWithPublishingContext(context, () => media.openAsset(asset));
expect(Buffer.from(opened.bytes).equals(JPEG)).toBe(true);
expect(opened.filename).toBe(`${asset.id}.jpg`);
await expect(runWithPublishingContext(context, () => media.openAsset({ ...asset, workspaceId: "other" }))).rejects.toThrow(/MEDIA_TENANT_FENCE/);
const swapped = createPublishingMediaAccess(fakePool([]).pool, { read: async () => seal(Buffer.from("other bytes")) }, KEK);
await expect(runWithPublishingContext(context, () => swapped.openAsset(asset))).rejects.toThrow(/MEDIA_INTEGRITY_FAILURE/);
});
});
describe("alt text at dispatch", () => {
it("maps positional altTexts onto providerMetadata.altText by asset id", () => {
const post: NormalizedPost & { altTexts?: Array<string | null> } = { text: "x", mediaAssetIds: ["a", "b", "c"], altTexts: ["first", null, " "], visibility: "public", providerMetadata: { altText: { a: "author-set", b: "author-set" } } };
const prepared = preparePostForDispatch(post, { idempotencyKey: "k" });
expect(prepared.providerMetadata.altText).toEqual({ a: "first" });
expect(prepared.providerMetadata.idempotencyKey).toBe("k");
const withoutAltTexts: NormalizedPost = { text: "x", mediaAssetIds: ["a"], visibility: "public", providerMetadata: {} };
const none = preparePostForDispatch(withoutAltTexts, { idempotencyKey: "k" });
expect(none.providerMetadata.altText).toBeUndefined();
});
});

View file

@ -0,0 +1,137 @@
/**
* Publishing-side media access: the `loadAsset` / `openAsset` pair the
* connectors need to attach an approved post's media.
*
* - loadAsset reads the `media_assets` row inside the current execution's
* tenant (app.workspace_id set from the publishing context, RLS forced),
* so a job can only ever resolve its own workspace's assets.
* - openAsset downloads the sealed object and opens the SPENC1 frame with the
* media KEK, checking the content address. Read-only: publishing never
* writes to storage.
*
* Bytes travel worker → provider; they never pass through the API process.
*/
import { createDecipheriv, createHash } from "node:crypto";
import type { Pool } from "pg";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import type { MediaAsset } from "@tactical-media-broadcast-hub/core";
import { currentPublishingContext } from "./execution-context.js";
const FRAME_MAGIC = Buffer.from("SPENC1");
/** Same per-asset caps the API enforces at upload (media.service.ts). */
const MAX_ASSET_BYTES = 64 * 1024 * 1024;
export interface SealedObjectReader {
/** Returns the stored (sealed) object body. */
read(key: string): Promise<Buffer>;
}
export interface PublishingMediaAccess {
loadAsset(assetId: string): Promise<MediaAsset>;
openAsset(media: MediaAsset): Promise<{ bytes: Uint8Array; filename: string }>;
}
function aesGcmOpen(key: Buffer, iv: Buffer, sealed: Buffer): Buffer {
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(sealed.subarray(sealed.length - 16));
return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - 16)), decipher.final()]);
}
/** Open [magic | iv(12) | wrapped CEK(48) | body+tag], the API's storage frame. */
export function openSealedFrame(sealed: Buffer, kek: Buffer): Buffer {
if (!sealed.subarray(0, FRAME_MAGIC.length).equals(FRAME_MAGIC)) throw new Error("MEDIA_FRAME_INVALID");
const iv = sealed.subarray(6, 18);
const cek = aesGcmOpen(kek, iv, sealed.subarray(18, 66));
return aesGcmOpen(cek, iv, sealed.subarray(66));
}
export function mediaKekFromEnv(env: Record<string, string | undefined> = process.env): Buffer {
const raw = env.S3_KEK;
if (raw !== undefined && raw !== "") {
const kek = Buffer.from(raw, "base64");
if (kek.length !== 32) throw new Error("S3_KEK must be 32 bytes base64");
return kek;
}
if (env.NODE_ENV === "production") throw new Error("S3_KEK is required in production (media encryption is mandatory)");
// Dev mirror of the API's fallback so a local scheduler reads local uploads.
return createHash("sha256").update(`dev-media-kek:${env.HOSTNAME ?? "local"}`).digest();
}
export function s3ObjectReaderFromEnv(env: Record<string, string | undefined> = process.env): SealedObjectReader {
const bucket = env.S3_BUCKET ?? "tactical-media-broadcast-hub-media";
const client = new S3Client({
endpoint: env.S3_ENDPOINT,
region: env.S3_REGION ?? "us-east-1",
credentials: env.S3_ACCESS_KEY !== undefined && env.S3_SECRET_KEY !== undefined
? { accessKeyId: env.S3_ACCESS_KEY, secretAccessKey: env.S3_SECRET_KEY }
: undefined,
forcePathStyle: env.S3_FORCE_PATH_STYLE !== "false",
});
return {
async read(key) {
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
if (response.Body === undefined) throw new Error("MEDIA_OBJECT_EMPTY");
return Buffer.from(await response.Body.transformToByteArray());
},
};
}
const EXTENSIONS: Record<string, string> = {
"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "video/mp4": "mp4", "video/webm": "webm",
};
interface MediaRow {
id: string;
workspace_id: string;
storage_key: string;
mime: string;
size_bytes: string | number;
created_at: Date;
}
/** Caller owns pool lifecycle (same contract as the credential accessor). */
export function createPublishingMediaAccess(pool: Pool, reader: SealedObjectReader, kek: Buffer): PublishingMediaAccess {
async function loadAsset(assetId: string): Promise<MediaAsset> {
const context = currentPublishingContext();
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.workspace_id', $1, true)", [context.workspaceId]);
const result = await client.query<MediaRow>(
`SELECT id, workspace_id, storage_key, mime, size_bytes, created_at FROM media_assets
WHERE id = $1 AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid`,
[assetId],
);
await client.query("COMMIT");
const row = result.rows[0];
if (row === undefined) throw new Error("MEDIA_ASSET_NOT_FOUND");
return {
id: row.id,
workspaceId: row.workspace_id,
storageKey: row.storage_key,
mimeType: row.mime,
sizeBytes: Number(row.size_bytes),
createdAt: row.created_at,
};
} catch (error) {
await client.query("ROLLBACK").catch(() => undefined);
throw error;
} finally {
client.release();
}
}
async function openAsset(media: MediaAsset): Promise<{ bytes: Uint8Array; filename: string }> {
const context = currentPublishingContext();
if (media.workspaceId !== context.workspaceId) throw new Error("MEDIA_TENANT_FENCE");
if (media.sizeBytes > MAX_ASSET_BYTES) throw new Error("MEDIA_ASSET_TOO_LARGE");
const plaintext = openSealedFrame(await reader.read(media.storageKey), kek);
const expected = /^sha256\/([0-9a-f]{64})\./.exec(media.storageKey)?.[1];
if (expected === undefined || createHash("sha256").update(plaintext).digest("hex") !== expected) {
throw new Error("MEDIA_INTEGRITY_FAILURE");
}
return { bytes: plaintext, filename: `${media.id}.${EXTENSIONS[media.mimeType] ?? "bin"}` };
}
return { loadAsset, openAsset };
}

View file

@ -26,6 +26,8 @@ export const MASTODON_DEDUPE_WINDOW_MS = 30 * 60 * 1000;
export const PUBLISHING_POLICIES: Readonly<Record<string, PublishingPolicy>> = {
mastodon: { idempotency: "token", dedupeWindowMs: MASTODON_DEDUPE_WINDOW_MS },
telegram: { idempotency: "none" },
// Graph media_publish takes no idempotency key: an ambiguous send is never replayed.
instagram: { idempotency: "none" },
};
/** Unknown providers get the safe default: never replay an ambiguous send. */

View file

@ -6,10 +6,10 @@ import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
import { runWithPublishingContext } from "./execution-context.js";
const connection: ConnectorConnection = { id: "connection", workspaceId: "workspace", provider: "mastodon", channelId: "channel", fields: {}, createdAt: new Date(), credentialId: { kind: "credential-ref", id: "connection" } };
describe("production publishing registry", () => {
it("registers real Mastodon and Telegram connectors and rejects duplicates", () => {
it("registers real Mastodon, Telegram and Instagram connectors and rejects duplicates", () => {
const pool = {} as Pool;
const registry = createPublishingRegistry(pool);
expect(registry.list().map((c) => c.manifest().id).sort()).toEqual(["mastodon", "telegram"]);
expect(registry.list().map((c) => c.manifest().id).sort()).toEqual(["instagram", "mastodon", "telegram"]);
expect(registry.get("mastodon").publish).toBeTypeOf("function");
expect(() => registry.register(registry.get("mastodon"))).toThrow(/already registered/);
});

View file

@ -1,17 +1,22 @@
import type { Pool } from "pg";
import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
import { createInstagramConnector } from "@tactical-media-broadcast-hub/connector-instagram";
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
import { createPublishingCredentialAccessor } from "./credentials.js";
import { providerFetch } from "./provider-fetch.js";
import { executionSignal } from "./execution-context.js";
import { createPublishingMediaAccess, mediaKekFromEnv, s3ObjectReaderFromEnv, type PublishingMediaAccess } from "./media.js";
/** Production composition registers real connectors, once per worker process. */
export function createPublishingRegistry(pool: Pool): ConnectorRegistry {
export function createPublishingRegistry(pool: Pool, media?: PublishingMediaAccess): ConnectorRegistry {
const registry = new ConnectorRegistry();
const fetchImpl: typeof fetch = (input, init) => providerFetch(input, { ...init, signal: executionSignal() ?? init?.signal });
const options = { credentials: createPublishingCredentialAccessor(pool), fetchImpl, env: process.env };
const assets = media ?? createPublishingMediaAccess(pool, s3ObjectReaderFromEnv(), mediaKekFromEnv());
const options = { credentials: createPublishingCredentialAccessor(pool), fetchImpl, env: process.env, loadAsset: assets.loadAsset, openAsset: assets.openAsset };
registry.register(createMastodonConnector(options));
registry.register(createTelegramConnector(options));
// Connects and refuses text-only posts; image publishing needs a public media URL (not provided yet).
registry.register(createInstagramConnector({ credentials: options.credentials, fetchImpl, env: process.env, loadAsset: assets.loadAsset }));
return registry;
}
export { verifiedDedupeOrigin } from "./policy.js";

9
pnpm-lock.yaml generated
View file

@ -50,6 +50,9 @@ importers:
'@opentelemetry/sdk-node':
specifier: ^0.222.0
version: 0.222.0(@opentelemetry/api@1.9.1)
'@tactical-media-broadcast-hub/connector-instagram':
specifier: workspace:*
version: link:../../packages/connector-instagram
'@tactical-media-broadcast-hub/connector-mastodon':
specifier: workspace:*
version: link:../../packages/connector-mastodon
@ -499,6 +502,12 @@ importers:
packages/connector-runtime:
dependencies:
'@aws-sdk/client-s3':
specifier: ^3.1146.0
version: 3.1146.0
'@tactical-media-broadcast-hub/connector-instagram':
specifier: workspace:*
version: link:../connector-instagram
'@tactical-media-broadcast-hub/connector-mastodon':
specifier: workspace:*
version: link:../connector-mastodon