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.
- 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.
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.
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.
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.
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.
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.
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).
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.
The produce worker crashed its claim cycle at the scratch mkdtemp (ENOENT:
the parent dir never existed on a fresh volume) — bootstrap MEDIA_SCRATCH_DIR
recursively at startup. The api image lacked ffprobe entirely, so every
upload's probe job failed — install ffmpeg in the api runtime image.
The store's hand-built claim interpolated the lane predicate into the SQL
text while still passing a bind parameter — node-postgres sent 1 param for
a 0-param statement (08P01) on every poll, so the live produce worker
store-errored every ~2s and never claimed. Migration 040 replaces the
inline claim with produce_job_claim(p_lane, p_stale_after): SECURITY
DEFINER (the 035/oidc_refresh_lookup pattern — the poller is lane-global,
FORCE RLS hides every row without a tenant context), atomic FOR UPDATE
SKIP LOCKED with the queued→transcoding bump as the lease, and stale-
transcoding reclaim so a killed worker's rows return to the queue.
produce_job_workspace(p_job_id) discovers the owning tenant for the
follow-up writes, which stay RLS-bound as sp_app. pg-store rewired; store
errors now surface err.message instead of err.name; pg-store tests added.
38/38 producer tests green.
Deployment (t4g2): media-worker gains font-dejavu (drawtext lower-thirds)
and a dedicated produce-worker compose service running src/produce.ts —
claims video_processing_jobs compose|mix by lane, renders assembled/
overlaid/voiced cuts, registers derived library assets. Deliberately its
own process so a remote-lane box needs only Postgres + S3 + ffmpeg.
Web: compose media attachments UI (t2), Producer panel in the video
drawer (t5), collective creation + branding settings + shell branding
(brandweb), mobile CSS (mcss: touch targets, icon sizing, safe areas).
Compose: upload picked files to /media with progress, thumbnails via the
auth-scoped serve route, per-attachment alt text, remove, ≤4 cap with the
Mastodon norm hint, in-flight blocking. Video drawer: Producer section —
lower-third editor, logo key field, MediaRecorder voiceover (record/
stop/review/re-take/upload), produce submission with lane + label, job
progress, derived-asset link. Local types for both contracts.
validateBrandingPatch validated storage-key values but never assigned
them into the output — PUT /workspace/branding with a logo/background
silently did nothing, so admins could never set them. Also ACCENT_HEX
gains /i (uppercase hex accepted, canonicalized to lowercase on read).
Tests unpinned from the buggy behavior; 41/41 green.
Until now collectives only existed at registration; an account with a
personal workspace had no path to start one. The creator becomes Owner
and gets a session scoped to the new workspace.
POST /video/import-drive {resourceId} pulls through the oCIS WebDAV
service-account client; POST /video/import-chat {mxcUri} pulls from the
Matrix content repository (MATRIX_MEDIA_TOKEN, homeserver falls back to
ARCHIVE_PROVISIONER_HOMESERVER_URL). Both cap at 256 MiB (buffered v1,
streaming follow-up), store through the same encrypted content-
addressed path as uploads, probe, and audit video.imported with
provenance.
WS0 of the video-producer plan: every group can open a public,
accountless upload box (random token, rotatable, disable switch).
Submitters tick the Creative Commons qualifier; credit name and a
licensing contact are optional free text — contact is visible only in
the moderation surface and never copied onto assets. Intake sniffs
magic bytes (mp4/webm/jpeg/png), caps video 200 MiB / photo 50 MiB,
rate-limits 5/hour per salted IP hash (hash never rendered), dedups by
sha256, caps 5 pending per box, and quarantines everything as pending.
Group managers approve (video → video library via the register+probe
path, photo → group asset) or reject with a note; every step audited.
Migration 035 (RLS + submission_box_lookup/submission_rate_count
definer fns). 4 unit + 4 live-DB tests.
oCIS web's index.html carries ownCloud branding before the app mounts
(title 'ownCloud', blue theme-color, white/blue splash spinner). The
files vhosts (tunnel + TLS) now rewrite those at the edge: Hub title,
dark theme-color, dark splash with warm text and the red spinner.
The engineering guide still called the control room 'planned' while it
ships: multiview grid (2x1-4x4/custom, 4 live HLS tiles, tap-to-focus,
adaptive preview polling), broadcast switcher (PST/TAKE/CUT semantics,
lock, override modes), per-slot + master audio, overlay editor
(lower_third/chyron/logo/clock, layers, rendered revisions), signed
previews and the transactional director behind it. User manual gets the
operator walkthrough (§7.1); engineering guide documents the media-
control architecture and corrects the Level-2 status section; admin
guide gets permissions, PUBLIC_ORIGIN, capacity and tally notes.
Hub sessions gain an optional sid backed by auth_sessions (login/reissue
write the row; POST /auth/logout revokes it; pre-phase2 tokens are
refused at reissue so revocation applies from the next epoch bump).
The OIDC provider issues rotating refresh tokens (oidc_refresh_tokens,
SHA-256 at rest, family-wide revocation on reuse detection) that live
and die with the consenting Hub session — an oCIS session can no longer
outlive the sign-in that minted it, and Hub logout propagates at the
next refresh instead of the next silent-authorize. Discovery advertises
refresh_token. Migration 034; RLS via the audit_append definer pattern
for the unauthenticated token-endpoint lookup.
A boost to a gated connection (admin1/admin_majority/admin_consensus)
now holds with awaiting_publish_approval on the same publish-phase poll
the publish route uses, instead of executing the reblog. The holder
re-invokes amplify after the pass (hold-and-retry: no durable amplify
queue exists and the poll-close transaction must not do network I/O).
Unset/auto connections behave exactly as before; the route answers 202
on hold, 200 on execute. Adds amplify-governance IT suite (3 live-DB
tests).
Per the owner's spec: each social connection gets publishGovernance in its
fields — unset = manual publish (today), auto = dispatch on approval,
admin1/admin_majority/admin_consensus = a publish-phase chatgov poll
(subject draft_publish, its own open-poll slot, no collision with review
gates). Dispatch holds until the publish poll passes (E3 pre-dispatch
check); auto dispatches on approval. Includes migration 033, the PATCH
/connections/:id/publish-governance route (chatgov.manage), and 19+31 new
tests. Implementation by the branding-panel process (claude/opus), lint
and integration fixes here.
The header double-rendered 'Tactical Media Broadcast Hub': white via the
Logo lockup's wordmark, then again in red via the accent span. The mark
alone + the red span is the whole lockup the palette calls for.
OKLCH-based: any computed color with hue 200–300° and chroma > 0.006 on
/chat or /files/drive (both iframes included) fails the check. Run after
Element/oCIS image bumps; PLAYWRIGHT_PATH can point at an existing
node_modules. Found by the branding panel; threshold from the brand spec.
S9: boost draft (providerMetadata.sourceRemoteRef) → immediate-rule approve
→ POST /publishing/drafts/:id/amplify { connectionId, kind: repost } →
verify the source status reblogged on Mastodon. ONLY=S9 runs it standalone.
Gap list updated: boost + Temporal-queue + status-read gaps closed
(auto-publish remains a governance-model decision, not a missing route).
The oCIS web fetches the OIDC discovery document from the Hub issuer in the
browser — connect-src only allowed same-origin + blob, so the fetch was
blocked and the web fell back to its own login. With the CSP fix + the
external issuer flip, the full SSO chain works: Hub login -> authorize ->
consent SPA -> code -> autoprovisioned personal drive.
createPublishingCredentialAccessor is execution-fenced (Temporal contexts
only) — the API-process amplify route threw EXECUTION_CREDENTIAL_FENCE.
Use the established PgConnectionStore+vault accessor instead (same trust
level as the archive amplify route's bot-token path).
POST /publishing/drafts/:draftId/amplify { connectionId, kind?: repost|quote }:
the governance gate is the draft state (only approved drafts amplify), the
target is providerMetadata.sourceRemoteRef recorded at creation — never a
caller-supplied id. Connectors resolve from a process-level publishing
registry (vault-backed credentials); capability-checked per kind. Closes
the gap the governance sim documented (boosts fell back to replies).
Panel consensus (the branding panel): don't mount full-app iframes on
mobile — nested scrolling, keyboard collapse and double chrome break both
Element and oCIS under the bottom tab bar. Chat and Drive render a gateway
card (same-tab handoff, matrix: deep link, signed-in-as) below 1024px; the
iframe stays on desktop. hub-sso.html pins mx_local_settings.theme to
custom-OccupyHub Dark once (version-flagged) — a device's earlier picker
choice would otherwise beat config.json's default_theme.
oCIS compresses the idp sign-in HTML, and sub_filter cannot rewrite
compressed bodies. Works on the grey/failover path where the edge serves
files; in tunnel mode files bypasses the edge entirely (tunnel ingress
points straight at oCIS), so the permanent brand fix remains the Hub OIDC
issuer (docs/design/2026-10-13-files-oidc-sso.md).
The sign-in page is served by oCIS's idp service, which the web theme.json
cannot touch. Inject hub-signin.css via sub_filter on /signin/ until oCIS
sits behind the Hub OIDC issuer (see docs/design/2026-10-13-files-oidc-sso.md).
Phase 1 kills the navy login wall (provider + PKCE + auto-consent for the
first-party oCIS web client); phases 2-3 move uploads into personal drives
and share collective folders into them. Mirrors the Matrix JWT broker: the
Hub is the identity, oCIS is a relying party.