From the hacklab/T-HUB fork (diverged at a6843e9):
- 046_file_folders: folder trees (group + personal scope, RLS forced,
workspace-immutability trigger) + created_by on chat_media_archive
- Instagram connector wired into connect + publishing
- outreach contacts: per-collective DM tracking (todo/sent/replied/declined),
RLS forced, role-scoped permissions, audit without message text,
migration renumbered 041→047
- FORCE row-level security on media_assets
The J1 api agent's folder service/controller work continues separately.
The dropzone rejected >2 GiB client-side while the chunked ingest allows
100 GiB server-side. Note: chunked reassembly + seal holds ~2x the file in
api RAM, so the practical ceiling on the current box is ~8-10 GiB — the
multipart-direct build removes that ceiling.
Legacy/edge-case video_assets rows without a processing job threw 'has no
processing job' from getVideoStatus — the library detail click 500ed.
VideoStatus.processingJob is now nullable end to end (api type, DTO, web
consumers null-guarded); a jobless asset renders as queued.
The sliders rendered dead when the video's duration hadn't loaded into
state (max=0). Duration discovery now polls loadedmetadata/durationchange
until it lands, the sliders stay disabled with a reading note until then,
and dragging either handle seeks the preview. Cuts are auto-versioned
(v2, v3… after the original v1) by counting same-source names in the
library — a cut always creates a new clip, never touches the original.
- MediaViewer branches by type: pdf (native iframe), text/json (viewer +
copy), html (sandboxed allow-scripts run + source toggle), image (zoom/
pan/rotate lightbox + Edit), video (brand player + Trim)
- ImageEditor: canvas crop (drag + aspect presets), rotate 90s, flip H/V,
brightness/contrast/saturation; Save as copy uploads a NEW library file
via the direct path (server fallback)
- VideoTrim: in/out over the duration; Cut in Video Studio registers the
same R2 object as a video_asset, creates a one-clip EDL at the trim, and
queues the assemble render — the cut lands in the Video library, the
original file is never modified
- invalid type syntax fixed across the new components (prop callbacks use
arrow types)
SOTA lightbox patterns adapted to the hub: images zoom (wheel/double-click)
pan rotate with a filmstrip + counter + focus trap; videos play in a
brand-skinned player (scrubber with buffered range, volume, speed, PiP,
fullscreen, keyboard: space/k, arrows ±5s, j/l ±10s, m, f, p, up/down).
Payloads are E2E-encrypted, so thumbnails and playback decrypt client-side
through the session cache (direct objects via presigned GET + CEK; others
via the streaming route) — the grid shows real video stills captured in the
browser. List view stays for documents; Media/Grid is the default for
image/video items. FileRow gains a View action for media rows.
P0-1 compose: inline 'Connect a channel to publish' beside the disabled
Publish (desktop) and above the mobile bar — the silent-disabled state is gone.
P0-2 first-run home reads the landing's tmbh.manual.lang preference instead
of hard-coding Spanish.
P1-3 sidebar: 16 flat items → 5 labeled sections (Publish/Content/Operations/
Admin); dead Onboarding item removed; Groups surfaces in Operations.
P1-4 More menu: Groups row added; Onboarding row removed.
P1-6 duplicate Security & sessions row removed.
P1-7 mobile Post → Publish (label drift); More menu labels unified
(Video library / Files / Connections / Audit trail).
P2-10 username pattern regex fixed ([a-z0-9._-] with the dash last).
POST /video/direct/init|complete: the browser seals (SPENC2) and PUTs
straight to object storage; the api HEAD-checks, registers the unprobed
asset and queues a probe job — video bytes never cross the api box.
Worker side: produce_job_claim widened to probe jobs (042), the runner
gains a probe lane (sealed fetch → SPENC1/SPENC2 open by magic → scratch →
ffprobe → metadata write-back → complete 100), worker storage reads SPENC2
frames via DIRECT_EC_PRIVATE_KEY (inherited through compose appenv), and a
ported ffprobe util. Probe rows carrying input_json fail instead of
stranding; the claim guard no longer strands probe rows as invalid.
For scale: payload bytes go browser → R2 → browser; the api handles keys,
authz and metadata only, so a cheap box can serve thousands of users.
- SPENC2 frame (browser-sealed, P-256 ECDH → HKDF wrap of a per-file CEK,
10 MiG AES-GCM segments): storage.ts gains the reader (magic-branched
getObject), presignPut/headObject, openDirectHeader; sealForDirect is the
reference sealer for cross-implementation tests
- Files API: GET /files/direct/config, POST /files/direct/init (presigned
PUT), POST /files/direct/complete (HEAD-gated commit), POST
/files/:id/direct-download (presigned GET + CEK ticket)
- web: lib/direct-storage (WebCrypto seal/open), lib/files-direct (flows),
dropzone rewritten with a multi-file sequential queue (per-file status,
direct-first with server fallback), file download decrypts in-browser for
direct objects (falls back to streaming)
- video dropzone wired to the chunked ingest API (>10 MiB → init/chunk/
complete), fixing the Cloudflare ~100 MB body cap for large videos
- DIRECT_EC_PRIVATE_KEY (P-256, PKCS8 b64) wired through compose; cross-impl
roundtrip tests prove web-seal ↔ server-open byte compatibility
Files payloads moved to R2 in 041; this removes the remaining oCIS coupling:
- delete the oCIS WebDAV client; verifyWhileStreaming moves to its own
storage-agnostic module (verify-stream.ts)
- group-files: storage_key is the only payload source; legacy rows answer
'retired storage' instead of touching oCIS
- collective share: the ocis-resource item kind is gone (peertube links stay);
share-into-library was R2-backed since the previous commit
- import-drive endpoint removed (no web caller); import-chat (homeserver) stays
- web: Drive tab + embedded oCIS page removed; Library is the Files view
Legacy e2e rows (2) deleted; every remaining archive row is R2-backed.
The Files tab dropzone still enforced the old 20 MiB cap client- and
server-side while every other path had moved to 2 GiB. Also corrects the
stale '100 MiB' message on the video upload rejection.
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.
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.
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.
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).
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.
Hero strip (ES/EN): 'Not a platform. A process.' / 'Decide together.
Publish anywhere. Forget nothing.' / 'What they delete, we archive. What
they silence, we broadcast.' Footer line: 'Not a product. A process.
Built by collectives, for collectives.'
From the brand review: the sidebar truncated 'Tactical Media Broadcast Hub'
on eleven desktop routes; unknown routes rendered a stock white Next.js 404;
solo users saw the raw 'groupid must be a UUID' validation text on Files;
Element's config brand string still read 'OccupyMedia Hub'.
page.tsx now calls the real backend: GET /files with groupId/name/mimeClass/
cursor/limit (origin filter dropped — the api has no origin param), shares
tracked as ShareCreated (revoke by id), retention mapped to the orthogonal
legalHold model, downloads by item.name, samples reshaped to the wire
contract.
GroupFile gains mimeClass (the indexed coarse class the row icon and the
filter depend on): core contract, service row mapper, parseGroupFile guard,
test fixture, UI types — one vocabulary end to end. parseGroupFile's roomId
becomes null-tolerant to match direct-upload rows (mapping input stays
strict).
Verified: core build clean, api typecheck 0 errors + group-files 22/22,
web typecheck 0 errors.
Wires the three parallel group-cloud tracks to one surface and fixes the
seams between them:
- chat_media_archive.room_id becomes nullable (direct web uploads have no
Matrix room yet — the contract's null-event pattern extends to the room);
core GroupFile.roomId and ArchiveEntryInput.roomId follow.
- mime_class gains 'archive' (zip) across the 029 CHECK, validation
MIME_CLASSES and the bot classifier — one vocabulary everywhere.
- registerArchiveEntry claim keys are null-safe for room-less uploads.
- New: POST /files/groups/:groupId/upload — FileInterceptor (20 MiB) streams
through magic-byte sniffing, library-safe naming, oCIS upload and the
metadata index, behind group-membership ACL (Owner/Admin bypass).
- safeFileName/sniffMime promoted to packages/core (file-names.ts,
file-mime.ts) so the bot and the api share one implementation; the bot's
local modules become re-export shims; @types/node added to core.
Verified: api typecheck clean, group-files 22/22, bot 74/74, web typecheck
clean; 029 re-applied clean on a fresh scratch DB (room_id nullable,
archive class, claim_key unique all confirmed in the catalog).
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.
MIT licensed. See README.md and docs/.