Commit graph

89 commits

Author SHA1 Message Date
74f0802f1a fix: oidc hardening — sid-less consent refused, rotation race closed, iss on errors, logout honesty
From the logic audit:
- consent rejects sid-less bearers and requires a LIVE hub session
  (sessionAlive) before minting codes — a logged-out bearer can no longer
  mint downstream tokens inside its TTL
- refresh rotation race closed: consume returns whether THIS call won;
  the loser revokes the family (reuse detection now holds under
  concurrency)
- error redirects carry iss (RFC 9207) on error responses too
- logout awaits the revocation and no longer returns ok for missing or
  forged bearers
2026-10-18 11:54:59 +00:00
8f01765df3 feat: adapt T-HUB features — folders schema, Instagram connector, outreach contacts, media RLS force
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.
2026-10-18 10:40:45 +00:00
57762f543c 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-18 10:35:33 +00:00
4f0b69f5aa 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-18 10:35:33 +00:00
0ff1484a6e 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-18 10:35:33 +00:00
b24ebc5862 fix: logic-audit P0/P1 wave — CORS PUT, chunked cap, presign checksums, logout await, refresh rotation race
From the logic audit (HEAD 04f5f7e):
- P0 CORS: enableCors methods lacked PUT — browsers could not chunk-upload
  at all (cross-origin preflight refused)
- P0 chunked: validateUpload enforced the 2 GiB whole-body cap on chunked
  completes, so every >2 GiB chunked upload failed after full upload
- P1 presignPut: the SDK's flexible-checksum default added an
  x-amz-checksum-crc32 header browsers cannot reproduce; S3Client now uses
  requestChecksumCalculation WHEN_REQUIRED
- P1 refresh rotation race: concurrent refreshes both succeeded (consume
  ignored rowCount) forking the token family; consume now returns whether
  it won and the loser revokes the chain
- P2 logout: awaits the revocation and ignores missing/forged bearers
honestly
2026-10-18 10:18:02 +00:00
04f5f7e3aa feat: video uploads up to 20 GiB via the chunked path
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.
2026-10-18 08:31:44 +00:00
31c97a0b0a fix: jobless video assets no longer 500 the library click
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.
2026-10-18 08:19:42 +00:00
215952fe5e fix: trim sliders now live + auto-versioned cuts (v2, v3…)
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.
2026-10-18 07:35:54 +00:00
fe483a0822 feat: Files media suite — viewer modes for pdf/text/html, image editor, video trim to studio
- 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)
2026-10-17 13:32:21 +00:00
0bdf13816d feat: Files media viewer — brand video player, image lightbox, media grid
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.
2026-10-17 12:58:00 +00:00
98cf36fc51 fix: UX audit P0/P1 pass — publish reason inline, language pref on first-run, grouped sidebar, nav cleanup
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).
2026-10-17 07:04:04 +00:00
f3631307aa fix: bound the audio-less clip bed with atrim (the card bed was fixed; this branch was missed by a silent no-op replace) 2026-10-16 22:27:09 +00:00
8401aa3d37 fix: bound synthetic silence beds with atrim — unbounded anullsrc deadlocked concat
Card and audio-less-clip anullsrc sources are infinite; concat never
terminates an infinite segment, so the encode hung forever at 30%. atrim
pins each bed to its item's span (card duration / clip trim).
2026-10-16 22:16:53 +00:00
6373715ffe fix: allow .bin scratch names in the assemble builder
Direct-object sources download as <sha>.bin (scratchAssetName's fallback for
non-sha256 keys); the allowlist rejected them before spawn.
2026-10-16 22:01:16 +00:00
2a813eebe6 fix: install sharp in the media-worker for card/overlay PNG rasterization
The assemble lane's card pass resolves sharp (SHARP_MODULE_PATH or
require.resolve) like the api does; the worker image never had it, so every
assemble job failed 'sharp not found'.
2026-10-16 21:55:50 +00:00
78d0d0c15e fix: assemble jobs pin the project's first clip as their source asset
The claim path resolves the row's video_asset_id; NULL stranded every
assemble job as 'produce asset unavailable'.
2026-10-16 21:42:32 +00:00
d8de9f5d82 feat: editor-v2 S1 — EDL schema, strict validation, /video/edl API, edl_projects store
apps/api/src/media-control/edl.ts: the EdlProject schema (closed enums,
integer ms, 3dB steps, <=50 items, <=15 min, filtergraph-metacharacter
blocklist on card text) + validateEdlProject — the mirror-assert owner for
the worker's edl-plan port. 043_edl_projects: RLS tenant table. EdlController:
POST /video/edl, PATCH autosave, GET (project + latest assemble job),
POST :id/render (queues kind 'assemble' with the validated project).
Tests 130 passing across media-control.
2026-10-16 21:22:00 +00:00
10dfda3b7c feat: editor-v2 assemble lane — multi-clip/card timeline rendering in the worker
Per the adopted qwen38 plan §2 (+ glm53 amendments): video_processing_jobs
kind 'assemble' (claim widened via 044), EdlPlan mirror validation
(lenient-fail), the filter_complex builder (interleaved concat pairs, fit
cover/contain chains, absolute-timeline PNG overlays with enable windows,
VO adelay/sidechain duck, hard-breaking word wrap for card PNGs), and the
runner's assemble path with pre-spawn trim gates, attempt backoff and 15-min
cap. Sources open by magic (SPENC1 kek / SPENC2 direct key) so direct-to-R2
uploads assemble without the api ever seeing bytes. Tests: builder 18,
edl-plan 12, overlay-png 3, runner fakes extended.
2026-10-16 21:12:55 +00:00
df62aa88d4 fix: probe lane ffprobes the OPENED source, not the sealed frame
downloadSealed fetched raw bytes (getSealedObject) and handed ciphertext to
ffprobe — every direct upload failed 'Invalid data found'. The lane now uses
getObject, whose magic branch opens SPENC1 and SPENC2 alike.
2026-10-16 11:21:08 +00:00
11b7619653 fix: video direct init binds the object key to the upload id
init minted two random uuids (uploadId + objectKey), so /complete's
direct/<uploadId>.enc binding check could never pass.
2026-10-16 11:06:09 +00:00
1429e4e9af feat: video direct-to-R2 ingest — browser seals, worker probes off R2
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.
2026-10-16 10:52:47 +00:00
a7c3f26a4f fix: direct-download reads the sealed object for its CEK header
getObject decrypts by magic, so feeding its plaintext into openDirectHeader
always failed. getSealedObject exposes raw bytes for the header read;
DirectStorageMembers and the test fakes carry the new member.
2026-10-16 08:30:25 +00:00
0a1c3b392b feat: direct-to-R2 Files uploads (client-sealed SPENC2) + chunked video dropzone + multi-file queue
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
2026-10-16 08:12:11 +00:00
2d881f5e41 fix: deliver alt texts to providers at publish time
Intake stores alt texts positionally (aligned with mediaAssetIds) but the
Mastodon upload reads an asset-id-keyed providerMetadata.altText map, so
descriptions were silently dropped. postOf translates the array into the map.
2026-10-16 01:52:35 +00:00
e7bf870de9 feat: retire oCIS — the hub runs entirely on R2 object storage
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.
2026-10-16 01:32:56 +00:00
4cbb63920a fix: mark ChunkedUploadService constructor params @Optional
Nest tried to resolve String/Number/Function as DI tokens and the api
crashed at bootstrap.
2026-10-15 22:48:40 +00:00
93edeab5ca feat: chunked video ingest API (init / chunk / complete)
Cloudflare's proxy plan rejects bodies above ~100 MB, so whole-body video
uploads cannot exceed it no matter the origin limits. Clients now slice into
10 MB chunks: POST /video/upload/init, PUT /video/upload/:id/chunk/:index,
POST /video/upload/:id/complete reassembles to disk, seals, stores to R2 and
runs the normal probe + job pipeline. Max chunked upload: 100 GiB. Tests 17/17.
2026-10-15 22:28:45 +00:00
18afff5e49 feat: Files payloads move from oCIS WebDAV to R2 object storage
The Files tab was the only payload path not on object storage, violating the
2026-10-06 directive (object storage is the ONLY media I/O path) — and oCIS's
WebDAV surface is currently 500ing server-side, which made every upload fail.

Uploads now seal + dedupe into R2 via StorageService (SPENC1, content-
addressed); downloads read R2 when storage_key is set, falling back to oCIS
for legacy rows. Share-into-library copies oCIS Drive sources into R2 and
records the real sha256 instead of a placeholder. Migration 041 adds
chat_media_archive.storage_key.
2026-10-15 22:11:07 +00:00
5808e89dde fix: synth silence bed for audio-less produce sources
Audio-less inputs no longer emit [0:a] maps that ffmpeg rejects; the worker
branches to an anullsrc bed configured at the output preset's rate/layout.
Producer tests 42/42.
2026-10-15 21:27:09 +00:00
613aba236b fix: raise Files group upload cap 20 MiB -> 2 GiB (api + web)
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.
2026-10-15 20:49:46 +00:00
b8115eec09 fix: wire scheduler media resolver into publishing registry
The registry accepted media hooks but nothing supplied them, so Mastodon
media posts failed uploadAll (INVALID_POST attempts). PublishingMediaResolver
in the scheduler resolves workspace-scoped media_assets rows via RLS tenant
context and reads plaintext bytes from S3 with SPENC1 open (S3_KEK), matching
the api's S3Storage frame format.
2026-10-15 18:44:18 +00:00
9e8653aca5 feat: publishing registry media seam (loadAsset/openAsset) + SPENC1 decrypt + multipart transport
The Mastodon connector's uploadAll resolves asset ids via loadAsset/openAsset.
The registry wires these hooks from a mediaResolver that the scheduler injects:
media_assets lookup (workspace-scoped via SET LOCAL) + R2 download + SPENC1
decrypt (extracted from the api's storage into connector-runtime/media.ts).
provider-fetch gains serializeBody for multipart media uploads through the
pinned transport channel.
2026-10-15 16:48:57 +00:00
ad1f4a3bc3 fix: raise upload limits — web dropzone 2 GiB, nginx edge 2g on all vhosts 2026-10-15 09:45:09 +00:00
1ddaf32edf fix: raise upload limits — api 2 GiB, nginx 2g (was 100 MiB api / 20m-512m nginx)
Cloudflare free caps at 100 MB on the proxied route regardless; larger
files need the domain set to DNS-only or a chunked upload path.
2026-10-15 08:46:50 +00:00
d95cee8878 chore: MEDIA_DEBUG_STDERR=1 routes produce ffmpeg stderr to /tmp/ffmpeg-debug.log (debug aid; default stays redacted) 2026-10-14 23:05:10 +00:00
26f3a27eee fix: produce worker boot — scratch dir bootstrap + api ffprobe
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.
2026-10-14 22:19:59 +00:00
40e0c49f7c fix: drop unused tmpdir import (lint) 2026-10-14 22:00:15 +00:00
ee8d5c6e76 fix: produce worker claim — SECURITY DEFINER fn fixes 08P01 poll crash + stale-lease reclaim (040)
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.
2026-10-14 21:55:41 +00:00
d9e8cdf789 feat: produce worker deployment + web producer/collective/branding UI + mobile CSS (wave-2c)
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).
2026-10-14 20:40:49 +00:00
1183d69849 feat: compose media attachments UI + video producer panel (t2+t5)
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.
2026-10-14 20:14:20 +00:00
71db0a08b2 feat: media attachments backend + producer job intake (t1+t3)
Rich media (t1): media_assets (migration 038) + /media module — multipart
upload with magic-byte sniffing, size caps, auth-scoped serve with
ETag/same-origin; the publishing 'text only' gate lifted to accept
0..4 validated mediaAssetIds with optional positional altTexts.

Producer intake (t3): video_processing_jobs gains kind compose|mix +
input_json + produced_asset_id (migration 039), video_assets.derived_from;
POST /video/:id/produce validates overlays/voiceover strictly and queues
the job for the media-worker runner (landing separately).
2026-10-14 20:00:31 +00:00
94318de519 fix: branding patch drops asset keys + rejects uppercase hex
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.
2026-10-14 18:57:00 +00:00
4b249e3811 fix: reconcile branding tests + chat-display types (integration of wave agents) 2026-10-14 18:07:26 +00:00
cbdb8c0f81 feat: POST /auth/workspaces — create an additional collective from an existing account
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.
2026-10-14 17:16:17 +00:00
177ca6eb2b feat: video library imports from Drive and chat media (WS2 backend)
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.
2026-10-14 14:16:19 +00:00
d87c4e99e6 fix: provisioner matrix login retries through M_LIMIT_EXCEEDED instead of failing boot
Rapid deploy restarts exhausted the homeserver's per-account login rate
limit; bootstrap now backs off 5s/15s/45s/90s on 429 before giving up.
2026-10-14 13:55:05 +00:00
71ccc1db75 fix: submissions module constructs its own VideoService (root-module provider not importable) 2026-10-14 13:45:15 +00:00
b4ff73ffa8 fix: submissions IT — stray type name under build tsconfig 2026-10-14 13:37:54 +00:00
599a68cc41 feat: anonymous group submission boxes — public intake, CC licence, moderation
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.
2026-10-14 13:32:00 +00:00