2026-04-07 22:05:41 -04:00
import express from 'express' ;
import http from 'http' ;
2026-05-28 23:20:02 -04:00
import fs from 'fs' ;
2026-05-22 17:22:30 -04:00
import net from 'net' ;
import dgram from 'dgram' ;
2026-04-07 22:05:41 -04:00
import pool from '../db/pool.js' ;
2026-05-26 10:10:44 -04:00
import { getS3Bucket } from '../s3/client.js' ;
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
import { validateUuid } from '../middleware/errors.js' ;
feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.
- recorders: param('id') resolves project + view baseline, requireRecorderEdit
on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
to write); also fixes the author bug (req.session.userId -> req.user.id, which
was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
registration) — TODO replaced with a rationale note
Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
asset into a project the caller lacks edit on — now asserts edit on the
override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
could pull in (and via GET /:id leak signed proxy URLs for) assets from a
project they can't access — now every clip asset must belong to the
sequence's project; pre-transaction queries moved inside try/catch so a DB
error returns 500 instead of hanging the request
- tests: recorders-access, sequences-access (incl. cross-project clip guard),
comments-access (incl. author-id regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:48:02 -04:00
import { assertProjectAccess , accessibleProjectIds } from '../auth/authz.js' ;
2026-04-07 22:05:41 -04:00
import { v4 as uuidv4 } from 'uuid' ;
const router = express . Router ( ) ;
feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.
- recorders: param('id') resolves project + view baseline, requireRecorderEdit
on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
to write); also fixes the author bug (req.session.userId -> req.user.id, which
was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
registration) — TODO replaced with a rationale note
Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
asset into a project the caller lacks edit on — now asserts edit on the
override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
could pull in (and via GET /:id leak signed proxy URLs for) assets from a
project they can't access — now every clip asset must belong to the
sequence's project; pre-transaction queries moved inside try/catch so a DB
error returns 500 instead of hanging the request
- tests: recorders-access, sequences-access (incl. cross-project clip guard),
comments-access (incl. author-id regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:48:02 -04:00
// Every /:id recorder route is scoped to the recorder's project. The param
// handler validates the UUID, resolves the owning project_id, and asserts the
// 'view' baseline; mutating routes escalate to 'edit' via requireRecorderEdit.
// A recorder with a NULL project_id resolves to admin-only (assertProjectAccess
// throws 403 for non-admins on a null project).
router . param ( 'id' , async ( req , res , next ) => {
validateUuid ( 'id' ) ( req , res , ( ) => { } ) ;
if ( res . headersSent ) return ;
try {
const { rows } = await pool . query ( 'SELECT project_id FROM recorders WHERE id = $1' , [ req . params . id ] ) ;
if ( rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Recorder not found' } ) ;
req . recorderProjectId = rows [ 0 ] . project _id ;
await assertProjectAccess ( req . user , req . recorderProjectId , 'view' ) ;
next ( ) ;
} catch ( err ) { next ( err ) ; }
} ) ;
async function requireRecorderEdit ( req , res , next ) {
try {
await assertProjectAccess ( req . user , req . recorderProjectId , 'edit' ) ;
next ( ) ;
} catch ( err ) { next ( err ) ; }
}
2026-04-07 22:05:41 -04:00
2026-05-21 18:51:10 -04:00
// Base port for on-demand SDI sidecar containers on remote worker nodes.
// Device index 0 → 7438, index 1 → 7439, etc.
const SIDECAR _PORT _BASE = 7438 ;
2026-04-07 22:05:41 -04:00
// Docker API helper function
function dockerApi ( method , path , body = null ) {
return new Promise ( ( resolve , reject ) => {
const options = {
socketPath : '/var/run/docker.sock' ,
path : ` /v1.43 ${ path } ` ,
method ,
headers : { 'Content-Type' : 'application/json' } ,
} ;
const req = http . request ( options , ( res ) => {
let data = '' ;
res . on ( 'data' , chunk => data += chunk ) ;
res . on ( 'end' , ( ) => {
try {
resolve ( { status : res . statusCode , data : data ? JSON . parse ( data ) : { } } ) ;
} catch {
resolve ( { status : res . statusCode , data } ) ;
}
} ) ;
} ) ;
req . on ( 'error' , reject ) ;
2026-05-26 10:10:44 -04:00
// Add 10-second timeout to prevent indefinite hangs if Docker daemon is unresponsive
req . setTimeout ( 10000 , ( ) => {
req . destroy ( new Error ( 'Docker API timeout after 10s' ) ) ;
} ) ;
2026-04-07 22:05:41 -04:00
if ( body ) req . write ( JSON . stringify ( body ) ) ;
req . end ( ) ;
} ) ;
}
2026-05-21 18:51:10 -04:00
// Look up the cluster node for a recorder and decide if it is remote.
// Returns { remote: false } when the node is local or unset;
// { remote: true, apiUrl, ip } when it is a different host.
async function resolveNodeTarget ( nodeId ) {
if ( ! nodeId ) return { remote : false } ;
const r = await pool . query (
'SELECT hostname, ip_address, api_url FROM cluster_nodes WHERE id = $1' ,
[ nodeId ]
) ;
if ( r . rows . length === 0 ) return { remote : false } ;
const node = r . rows [ 0 ] ;
const localHostname = process . env . NODE _HOSTNAME || '' ;
if ( ! node . api _url || node . hostname === localHostname ) return { remote : false } ;
return { remote : true , apiUrl : node . api _url , ip : node . ip _address } ;
}
2026-04-07 22:05:41 -04:00
// Helper function to generate clip name with timestamp
function generateClipName ( recorderName ) {
const now = new Date ( ) ;
const year = now . getFullYear ( ) ;
const month = String ( now . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) ;
const day = String ( now . getDate ( ) ) . padStart ( 2 , '0' ) ;
const hours = String ( now . getHours ( ) ) . padStart ( 2 , '0' ) ;
const minutes = String ( now . getMinutes ( ) ) . padStart ( 2 , '0' ) ;
const seconds = String ( now . getSeconds ( ) ) . padStart ( 2 , '0' ) ;
2026-05-23 00:12:42 -04:00
// Strip filesystem-hostile characters out of the recorder name (spaces
// become underscores, anything outside [A-Za-z0-9._-] is dropped) so the
// clipName flows cleanly through S3 keys, SMB paths, and ffmpeg args.
const safe = String ( recorderName || 'rec' )
. replace ( /\s+/g , '_' )
. replace ( /[^A-Za-z0-9._-]/g , '' )
. slice ( 0 , 40 ) || 'rec' ;
return ` ${ safe } _ ${ year } ${ month } ${ day } _ ${ hours } ${ minutes } ${ seconds } ` ;
2026-04-07 22:05:41 -04:00
}
2026-05-22 23:41:03 -04:00
// Sanitize an operator-provided clip name so it's safe as both an S3 key
// segment and an SMB/POSIX filename. Allow letters, digits, dot, dash,
// underscore, and spaces; collapse runs of whitespace; cap at 80 chars.
function sanitizeClipName ( raw ) {
if ( typeof raw !== 'string' ) return null ;
const cleaned = raw
. replace ( /[^A-Za-z0-9._\- ]+/g , '' )
. replace ( /\s+/g , ' ' )
. trim ( )
. slice ( 0 , 80 ) ;
return cleaned . length > 0 ? cleaned : null ;
}
2026-05-16 08:21:03 -04:00
/ * *
* Build Docker PortBindings and ExposedPorts for listener - mode recorders .
* /
function buildPortConfig ( sourceType , sourceConfig ) {
const portBindings = { } ;
const exposedPorts = { } ;
if ( sourceConfig && sourceConfig . mode === 'listener' ) {
if ( sourceType === 'srt' ) {
const port = String ( sourceConfig . listen _port || 9000 ) ;
const proto = ` ${ port } /udp ` ;
portBindings [ proto ] = [ { HostPort : port } ] ;
exposedPorts [ proto ] = { } ;
} else if ( sourceType === 'rtmp' ) {
const port = String ( sourceConfig . listen _port || 1935 ) ;
const proto = ` ${ port } /tcp ` ;
portBindings [ proto ] = [ { HostPort : port } ] ;
exposedPorts [ proto ] = { } ;
}
}
return { portBindings , exposedPorts } ;
}
2026-05-21 00:17:45 -04:00
// Whitelist of recorder columns the API accepts on POST/PATCH. Keeping it
// explicit prevents accidental writes to status / container_id / timestamps.
const RECORDER _FIELDS = [
'name' , 'source_type' , 'source_config' ,
'recording_codec' , 'recording_resolution' ,
'recording_video_bitrate' , 'recording_framerate' ,
'recording_audio_codec' , 'recording_audio_bitrate' , 'recording_audio_channels' ,
'recording_container' ,
'proxy_enabled' , 'proxy_codec' , 'proxy_resolution' ,
'proxy_video_bitrate' , 'proxy_framerate' ,
'proxy_audio_codec' , 'proxy_audio_bitrate' , 'proxy_audio_channels' ,
'proxy_container' ,
'project_id' , 'node_id' , 'device_index' ,
2026-05-31 14:50:31 -04:00
'growing_enabled' ,
2026-05-21 00:17:45 -04:00
] ;
function pickRecorderFields ( body ) {
const out = { } ;
for ( const k of RECORDER _FIELDS ) {
if ( body [ k ] !== undefined ) out [ k ] = body [ k ] ;
}
return out ;
}
2026-05-31 18:34:36 -04:00
// Codecs that require an NVIDIA GPU on the target node.
const GPU _CODECS = [ 'hevc_nvenc' , 'h264_nvenc' ] ;
// Issue #163 — codec/container/audio compatibility guard. Returns null when the
// config is valid, otherwise a descriptive error string naming the bad combo.
// `nodeHasGpu` is tri-state: true (GPU present), false (no GPU), or null
// (unknown — node not resolvable at this point, so GPU is only a soft check).
//
// Rules:
// - PCM audio is only valid in MOV/MXF containers, never MP4 (an MP4 with a
// PCM track produces a corrupt/unplayable master — also part of #162).
// - HEVC is not valid in MXF in this build.
// - NVENC codecs require the target node to have a GPU.
function validateRecorderConfig ( cfg , nodeHasGpu = null ) {
if ( ! cfg ) return null ;
const container = String ( cfg . recording _container || '' ) . toLowerCase ( ) ;
const codec = String ( cfg . recording _codec || '' ) . toLowerCase ( ) ;
const audio = String ( cfg . recording _audio _codec || '' ) . toLowerCase ( ) ;
// PCM audio + MP4 → reject.
if ( container === 'mp4' && audio . startsWith ( 'pcm' ) ) {
return ` Invalid combo: PCM audio ( ${ cfg . recording _audio _codec } ) is not supported in an MP4 container. Use a MOV or MXF container, or switch the audio codec to AAC. ` ;
}
// HEVC in MXF → reject.
if ( container === 'mxf' && ( codec === 'hevc' || codec === 'hevc_nvenc' ) ) {
return ` Invalid combo: HEVC ( ${ cfg . recording _codec } ) is not supported in an MXF container in this build. Use a MOV/MP4 container, or pick a DNxHR/ProRes codec for MXF. ` ;
}
// NVENC requires a GPU on the target node. Only a hard error when we know the
// node lacks one; unknown capability is left as a soft pass.
if ( GPU _CODECS . includes ( codec ) && nodeHasGpu === false ) {
return ` Invalid combo: codec ${ cfg . recording _codec } requires an NVIDIA GPU, but the target node reports no GPU. Choose a software codec (e.g. prores_hq, dnxhr_hq, h264) or assign a GPU node. ` ;
}
return null ;
}
// Resolve whether a recorder's target node has a GPU. Returns true/false when
// the node's heartbeat capability is known, or null when it can't be resolved
// (no node assigned / no capability reported) — callers treat null as a soft
// check per validateRecorderConfig.
async function nodeHasGpuCapability ( nodeId ) {
if ( ! nodeId ) return null ;
try {
const r = await pool . query (
'SELECT capabilities FROM cluster_nodes WHERE id = $1' ,
[ nodeId ]
) ;
if ( r . rows . length === 0 ) return null ;
const caps = r . rows [ 0 ] . capabilities ;
const gpus = caps && caps . gpus ;
if ( ! Array . isArray ( gpus ) ) return null ;
return gpus . length > 0 ;
} catch ( _ ) {
return null ;
}
}
const sleep = ( ms ) => new Promise ( r => setTimeout ( r , ms ) ) ;
// Issue #162 — after a local-spawn stop, wait for the capture container to
// finalize its master. The asset row was pre-created at start with
// status='live' (display_name = current_session_id); the ingest/finalize step
// flips it to ready/processing once the MOV/MP4 trailer is written. We poll
// until the asset leaves 'live' (or disappears) or we hit the timeout, so we
// don't DELETE the container — and SIGKILL ffmpeg — before the trailer lands.
async function waitForFinalize ( recorder , { timeoutMs = 180000 , intervalMs = 3000 } = { } ) {
if ( ! recorder . current _session _id ) return ;
const deadline = Date . now ( ) + timeoutMs ;
while ( Date . now ( ) < deadline ) {
try {
const r = await pool . query (
` SELECT 1 FROM assets
WHERE project _id = $1
AND display _name = $2
AND status = 'live'
LIMIT 1 ` ,
[ recorder . project _id , recorder . current _session _id ]
) ;
// No live asset row left → finalize is done (or there was none to wait on).
if ( r . rows . length === 0 ) return ;
} catch ( _ ) { /* transient DB error — keep polling until timeout */ }
await sleep ( intervalMs ) ;
}
}
2026-04-07 22:05:41 -04:00
// GET / - List all recorders
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
//
// Issue #121 — previous version fired N PG queries + N Docker inspects per
// list call. Now we resolve `live_asset_id` for every recording row in a
// single LATERAL JOIN, and the Docker `started_at` lookups are bounded by
// the number of currently-recording rows (typically <10) and run in
// parallel with a per-call timeout from `dockerApi`.
2026-04-07 22:05:41 -04:00
router . get ( '/' , async ( req , res , next ) => {
try {
feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.
- recorders: param('id') resolves project + view baseline, requireRecorderEdit
on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
to write); also fixes the author bug (req.session.userId -> req.user.id, which
was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
registration) — TODO replaced with a rationale note
Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
asset into a project the caller lacks edit on — now asserts edit on the
override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
could pull in (and via GET /:id leak signed proxy URLs for) assets from a
project they can't access — now every clip asset must belong to the
sequence's project; pre-transaction queries moved inside try/catch so a DB
error returns 500 instead of hanging the request
- tests: recorders-access, sequences-access (incl. cross-project clip guard),
comments-access (incl. author-id regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:48:02 -04:00
// Scope to recorders in projects the caller can access (admins unfiltered).
// Recorders with a NULL project are admin-only and never appear for scoped
// users (accessibleProjectIds never yields a null id).
const access = await accessibleProjectIds ( req . user ) ;
let scopeClause = '' ;
const params = [ ] ;
if ( ! access . all ) {
if ( access . ids . size === 0 ) return res . json ( [ ] ) ;
scopeClause = 'WHERE r.project_id = ANY($1::uuid[])' ;
params . push ( [ ... access . ids ] ) ;
}
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
const result = await pool . query ( `
SELECT r . * , la . live _asset _id
FROM recorders r
LEFT JOIN LATERAL (
SELECT a . id AS live _asset _id
FROM assets a
WHERE r . status = 'recording'
AND a . project _id = r . project _id
AND a . display _name = r . current _session _id
AND a . status = 'live'
ORDER BY a . created _at DESC
LIMIT 1
) la ON TRUE
feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.
- recorders: param('id') resolves project + view baseline, requireRecorderEdit
on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
to write); also fixes the author bug (req.session.userId -> req.user.id, which
was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
registration) — TODO replaced with a rationale note
Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
asset into a project the caller lacks edit on — now asserts edit on the
override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
could pull in (and via GET /:id leak signed proxy URLs for) assets from a
project they can't access — now every clip asset must belong to the
sequence's project; pre-transaction queries moved inside try/catch so a DB
error returns 500 instead of hanging the request
- tests: recorders-access, sequences-access (incl. cross-project clip guard),
comments-access (incl. author-id regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:48:02 -04:00
$ { scopeClause }
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
ORDER BY r . created _at DESC
feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.
- recorders: param('id') resolves project + view baseline, requireRecorderEdit
on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
to write); also fixes the author bug (req.session.userId -> req.user.id, which
was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
registration) — TODO replaced with a rationale note
Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
asset into a project the caller lacks edit on — now asserts edit on the
override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
could pull in (and via GET /:id leak signed proxy URLs for) assets from a
project they can't access — now every clip asset must belong to the
sequence's project; pre-transaction queries moved inside try/catch so a DB
error returns 500 instead of hanging the request
- tests: recorders-access, sequences-access (incl. cross-project clip guard),
comments-access (incl. author-id regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:48:02 -04:00
` , params);
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
const rows = result . rows ;
// Only inspect containers for recorders that actually claim to be recording.
const inspectable = rows . filter ( r => r . status === 'recording' && r . container _id ) ;
await Promise . all ( inspectable . map ( async ( r ) => {
try {
const insp = await dockerApi ( 'GET' , ` /containers/ ${ r . container _id } /json ` ) ;
if ( insp . status === 200 && insp . data && insp . data . State ) {
r . started _at = insp . data . State . StartedAt ;
}
} catch ( _ ) { /* leave started_at undefined */ }
2026-05-18 09:40:42 -04:00
} ) ) ;
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
2026-05-18 09:40:42 -04:00
res . json ( rows ) ;
2026-04-07 22:05:41 -04:00
} catch ( err ) {
next ( err ) ;
}
} ) ;
// POST / - Create a new recorder
router . post ( '/' , async ( req , res , next ) => {
try {
2026-05-21 00:17:45 -04:00
const fields = pickRecorderFields ( req . body ) ;
if ( ! fields . name || ! fields . source _type ) {
2026-04-07 22:05:41 -04:00
return res
. status ( 400 )
. json ( { error : 'Name and source_type are required' } ) ;
}
feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.
- recorders: param('id') resolves project + view baseline, requireRecorderEdit
on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
to write); also fixes the author bug (req.session.userId -> req.user.id, which
was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
registration) — TODO replaced with a rationale note
Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
asset into a project the caller lacks edit on — now asserts edit on the
override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
could pull in (and via GET /:id leak signed proxy URLs for) assets from a
project they can't access — now every clip asset must belong to the
sequence's project; pre-transaction queries moved inside try/catch so a DB
error returns 500 instead of hanging the request
- tests: recorders-access, sequences-access (incl. cross-project clip guard),
comments-access (incl. author-id regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:48:02 -04:00
// Creating a recorder writes into a project — require edit there. A recorder
// with no project_id is admin-only (assertProjectAccess denies non-admins on
// a null project).
await assertProjectAccess ( req . user , fields . project _id ? ? null , 'edit' ) ;
2026-05-21 00:17:45 -04:00
// Defaults — written on insert so the DB row is always self-contained.
const defaults = {
source _config : { } ,
2026-05-29 17:04:00 -04:00
recording _codec : 'hevc_nvenc' ,
2026-05-21 00:17:45 -04:00
recording _resolution : 'native' ,
recording _audio _codec : 'pcm_s24le' ,
recording _audio _channels : 2 ,
recording _container : 'mov' ,
proxy _enabled : true ,
proxy _codec : 'h264' ,
proxy _resolution : '1920x1080' ,
2026-05-22 17:22:30 -04:00
proxy _video _bitrate : '2M' ,
2026-05-21 00:17:45 -04:00
proxy _audio _codec : 'aac' ,
2026-05-22 17:22:30 -04:00
proxy _audio _bitrate : '128k' ,
2026-05-21 00:17:45 -04:00
proxy _audio _channels : 2 ,
proxy _container : 'mp4' ,
} ;
const row = { id : uuidv4 ( ) , status : 'stopped' , ... defaults , ... fields } ;
2026-05-31 18:34:36 -04:00
// Issue #163 — reject invalid codec/container/audio combos before insert.
const createGpu = await nodeHasGpuCapability ( row . node _id ) ;
const createErr = validateRecorderConfig ( row , createGpu ) ;
if ( createErr ) {
return res . status ( 400 ) . json ( { error : createErr } ) ;
}
2026-05-21 00:17:45 -04:00
// Build INSERT dynamically so adding columns later means one place to update.
const cols = Object . keys ( row ) ;
const placeholders = cols . map ( ( _ , i ) => ` $ ${ i + 1 } ` ) . join ( ', ' ) ;
const values = cols . map ( k => {
const v = row [ k ] ;
if ( k === 'source_config' ) return v && typeof v === 'object' ? v : { } ;
return v ;
} ) ;
2026-04-07 22:05:41 -04:00
const result = await pool . query (
2026-05-21 00:17:45 -04:00
` INSERT INTO recorders ( ${ cols . join ( ', ' ) } , created_at, updated_at)
VALUES ( $ { placeholders } , NOW ( ) , NOW ( ) )
RETURNING * ` ,
values
2026-04-07 22:05:41 -04:00
) ;
res . status ( 201 ) . json ( result . rows [ 0 ] ) ;
} catch ( err ) {
next ( err ) ;
}
} ) ;
// GET /:id - Get single recorder
router . get ( '/:id' , async ( req , res , next ) => {
try {
const { id } = req . params ;
const result = await pool . query (
'SELECT * FROM recorders WHERE id = $1' ,
[ id ]
) ;
if ( result . rows . length === 0 ) {
return res . status ( 404 ) . json ( { error : 'Recorder not found' } ) ;
}
res . json ( result . rows [ 0 ] ) ;
} catch ( err ) {
next ( err ) ;
}
} ) ;
2026-05-18 23:24:27 -04:00
// PATCH /:id - Edit recorder settings
// Blocked while recorder is actively recording to prevent config drift.
feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.
- recorders: param('id') resolves project + view baseline, requireRecorderEdit
on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
to write); also fixes the author bug (req.session.userId -> req.user.id, which
was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
registration) — TODO replaced with a rationale note
Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
asset into a project the caller lacks edit on — now asserts edit on the
override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
could pull in (and via GET /:id leak signed proxy URLs for) assets from a
project they can't access — now every clip asset must belong to the
sequence's project; pre-transaction queries moved inside try/catch so a DB
error returns 500 instead of hanging the request
- tests: recorders-access, sequences-access (incl. cross-project clip guard),
comments-access (incl. author-id regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:48:02 -04:00
router . patch ( '/:id' , requireRecorderEdit , async ( req , res , next ) => {
2026-05-18 23:24:27 -04:00
try {
const { id } = req . params ;
const recorderResult = await pool . query (
'SELECT * FROM recorders WHERE id = $1' ,
[ id ]
) ;
if ( recorderResult . rows . length === 0 ) {
return res . status ( 404 ) . json ( { error : 'Recorder not found' } ) ;
}
const recorder = recorderResult . rows [ 0 ] ;
if ( recorder . status === 'recording' ) {
return res . status ( 409 ) . json ( { error : 'Cannot edit a recorder while it is recording — stop it first' } ) ;
}
2026-05-21 00:17:45 -04:00
const fields = pickRecorderFields ( req . body ) ;
const cols = Object . keys ( fields ) ;
if ( cols . length === 0 ) {
2026-05-18 23:24:27 -04:00
return res . status ( 400 ) . json ( { error : 'No fields to update' } ) ;
}
2026-05-31 18:34:36 -04:00
// Issue #163 — validate the resulting config (existing row overlaid with the
// incoming changes) so a PATCH can't introduce an invalid combo either.
const merged = { ... recorder , ... fields } ;
const patchGpu = await nodeHasGpuCapability ( merged . node _id ) ;
const patchErr = validateRecorderConfig ( merged , patchGpu ) ;
if ( patchErr ) {
return res . status ( 400 ) . json ( { error : patchErr } ) ;
}
2026-05-21 00:17:45 -04:00
const setClause = cols . map ( ( k , i ) => ` ${ k } = $ ${ i + 1 } ` ) . join ( ', ' ) ;
const params = cols . map ( k => fields [ k ] ) ;
2026-05-18 23:24:27 -04:00
params . push ( id ) ;
const result = await pool . query (
2026-05-21 00:17:45 -04:00
` UPDATE recorders SET ${ setClause } , updated_at = NOW() WHERE id = $ ${ params . length } RETURNING * ` ,
2026-05-18 23:24:27 -04:00
params
) ;
res . json ( result . rows [ 0 ] ) ;
} catch ( err ) {
next ( err ) ;
}
} ) ;
2026-04-07 22:05:41 -04:00
// POST /:id/start - Start recording
feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.
- recorders: param('id') resolves project + view baseline, requireRecorderEdit
on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
to write); also fixes the author bug (req.session.userId -> req.user.id, which
was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
registration) — TODO replaced with a rationale note
Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
asset into a project the caller lacks edit on — now asserts edit on the
override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
could pull in (and via GET /:id leak signed proxy URLs for) assets from a
project they can't access — now every clip asset must belong to the
sequence's project; pre-transaction queries moved inside try/catch so a DB
error returns 500 instead of hanging the request
- tests: recorders-access, sequences-access (incl. cross-project clip guard),
comments-access (incl. author-id regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:48:02 -04:00
router . post ( '/:id/start' , requireRecorderEdit , async ( req , res , next ) => {
2026-04-07 22:05:41 -04:00
try {
const { id } = req . params ;
const recorderResult = await pool . query (
'SELECT * FROM recorders WHERE id = $1' ,
[ id ]
) ;
if ( recorderResult . rows . length === 0 ) {
return res . status ( 404 ) . json ( { error : 'Recorder not found' } ) ;
}
const recorder = recorderResult . rows [ 0 ] ;
if ( recorder . status === 'recording' ) {
return res . status ( 400 ) . json ( { error : 'Recorder is already recording' } ) ;
}
const s3Endpoint = process . env . S3 _ENDPOINT ;
2026-05-26 10:10:44 -04:00
const s3Bucket = getS3Bucket ( ) ; // Use live config, not stale env snapshot (#61)
2026-04-07 22:05:41 -04:00
const s3AccessKey = process . env . S3 _ACCESS _KEY ;
const s3SecretKey = process . env . S3 _SECRET _KEY ;
const mamApiUrl = process . env . MAM _API _URL || 'http://mam-api:3000' ;
2026-05-28 21:04:24 -04:00
const externalMamApiUrl = ` http:// ${ process . env . NODE _IP || '172.18.91.200' } : ${ process . env . PORT _MAM _API || 47432 } ` ;
2026-04-07 22:05:41 -04:00
const dockerNetwork = process . env . DOCKER _NETWORK || 'wild-dragon_wild-dragon' ;
2026-05-31 14:50:31 -04:00
// Growing-files mode is a PER-RECORDER setting (recorders.growing_enabled).
// When on, the capture container writes the master to its /growing/ mount
// instead of streaming it to S3 — editors can mount the SMB share and cut it
// live. The SMB share itself (mount source + credentials) is shared
// infrastructure configured globally in Settings → Storage.
const growingEnabled = recorder . growing _enabled === true ;
// Shared growing-files SMB infrastructure (global settings). Used to mount
// the CIFS share inside the capture container (services/capture mounts it
// with these credentials when GROWING_SMB_MOUNT is set).
const growingInfra = { } ;
{
const r = await pool . query (
` SELECT key, value FROM settings WHERE key = ANY( $ 1) ` ,
[ [ 'growing_smb_mount' , 'growing_smb_username' , 'growing_smb_password' , 'growing_smb_vers' ] ]
) ;
for ( const { key , value } of r . rows ) growingInfra [ key ] = value ;
}
const smbMount = growingEnabled ? ( growingInfra . growing _smb _mount || '' ) : '' ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
2026-05-22 23:41:03 -04:00
// Operator-supplied clip name wins over the auto-timestamped fallback.
// The Recorders UI passes this on the start request when the user types
// something into the "Clip name" field; otherwise it's blank and we
// generate `<recorder>_<timestamp>` as before.
const customClipName = sanitizeClipName ( req . body && req . body . clipName ) ;
const clipName = customClipName || generateClipName ( recorder . name ) ;
2026-04-07 22:05:41 -04:00
2026-05-28 18:25:56 -04:00
// Per-take project override: the Recorders UI can pass projectId on the
// start request to send clips to a different project than the recorder's
// default. Falls back to the recorder's configured project_id.
const takeProjectId = ( req . body && req . body . projectId && typeof req . body . projectId === 'string' )
? req . body . projectId
: recorder . project _id ;
feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.
- recorders: param('id') resolves project + view baseline, requireRecorderEdit
on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
to write); also fixes the author bug (req.session.userId -> req.user.id, which
was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
registration) — TODO replaced with a rationale note
Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
asset into a project the caller lacks edit on — now asserts edit on the
override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
could pull in (and via GET /:id leak signed proxy URLs for) assets from a
project they can't access — now every clip asset must belong to the
sequence's project; pre-transaction queries moved inside try/catch so a DB
error returns 500 instead of hanging the request
- tests: recorders-access, sequences-access (incl. cross-project clip guard),
comments-access (incl. author-id regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:48:02 -04:00
// requireRecorderEdit only covered the recorder's own project. If this take
// is being routed into a DIFFERENT project, the caller must have edit there
// too — otherwise edit on recorder A's project would let them write live
// assets into any project B.
if ( takeProjectId !== recorder . project _id ) {
await assertProjectAccess ( req . user , takeProjectId , 'edit' ) ;
}
2026-05-21 00:17:45 -04:00
// live-asset: create the asset row right now (status='live') so the
// library shows the recording while it is happening.
2026-05-18 23:56:00 -04:00
const assetIdLive = uuidv4 ( ) ;
2026-05-18 07:29:50 -04:00
try {
2026-05-21 00:17:45 -04:00
const ext = recorder . recording _container || 'mov' ;
2026-05-18 07:29:50 -04:00
await pool . query (
` INSERT INTO assets (
id , project _id , bin _id , filename , display _name , status , media _type ,
original _s3 _key , created _at , updated _at
) VALUES ( $1 , $2 , NULL , $3 , $3 , 'live' , 'video' , $4 , NOW ( ) , NOW ( ) ) ` ,
2026-05-28 18:25:56 -04:00
[ assetIdLive , takeProjectId , clipName , ` projects/ ${ takeProjectId } /masters/ ${ clipName } . ${ ext } ` ]
2026-05-18 07:29:50 -04:00
) ;
} catch ( e ) {
console . warn ( '[recorders] could not pre-create live asset:' , e . message ) ;
}
2026-05-16 08:21:03 -04:00
const sourceConfig = recorder . source _config || { } ;
const isListener = sourceConfig . mode === 'listener' ;
const sourceType = recorder . source _type ;
2026-05-21 00:17:45 -04:00
const deviceIndex = recorder . device _index ? ? sourceConfig . device ? ? 0 ;
2026-05-16 08:21:03 -04:00
2026-05-21 00:17:45 -04:00
// Build container env — all codec controls flow through here.
2026-05-16 08:21:03 -04:00
const env = [
` S3_ENDPOINT= ${ s3Endpoint } ` ,
` S3_BUCKET= ${ s3Bucket } ` ,
` S3_ACCESS_KEY= ${ s3AccessKey } ` ,
` S3_SECRET_KEY= ${ s3SecretKey } ` ,
` S3_REGION= ${ process . env . S3 _REGION || 'us-east-1' } ` ,
` MAM_API_URL= ${ mamApiUrl } ` ,
` RECORDER_ID= ${ id } ` ,
` SOURCE_TYPE= ${ sourceType } ` ,
` SOURCE_CONFIG= ${ JSON . stringify ( sourceConfig ) } ` ,
2026-05-21 00:17:45 -04:00
` DEVICE_INDEX= ${ deviceIndex } ` ,
// Recording codec controls
` RECORDING_CODEC= ${ recorder . recording _codec || 'prores_hq' } ` ,
` RECORDING_RESOLUTION= ${ recorder . recording _resolution || 'native' } ` ,
` RECORDING_VIDEO_BITRATE= ${ recorder . recording _video _bitrate || '' } ` ,
` RECORDING_FRAMERATE= ${ recorder . recording _framerate || '' } ` ,
` RECORDING_AUDIO_CODEC= ${ recorder . recording _audio _codec || 'pcm_s24le' } ` ,
` RECORDING_AUDIO_BITRATE= ${ recorder . recording _audio _bitrate || '' } ` ,
` RECORDING_AUDIO_CHANNELS= ${ recorder . recording _audio _channels ? ? 2 } ` ,
` RECORDING_CONTAINER= ${ recorder . recording _container || 'mov' } ` ,
// Proxy codec controls
` PROXY_ENABLED= ${ recorder . proxy _enabled !== false ? 'true' : 'false' } ` ,
` PROXY_CODEC= ${ recorder . proxy _codec || 'h264' } ` ,
` PROXY_RESOLUTION= ${ recorder . proxy _resolution || '1920x1080' } ` ,
2026-05-22 17:22:30 -04:00
` PROXY_VIDEO_BITRATE= ${ recorder . proxy _video _bitrate || '2M' } ` ,
2026-05-21 00:17:45 -04:00
` PROXY_FRAMERATE= ${ recorder . proxy _framerate || '' } ` ,
` PROXY_AUDIO_CODEC= ${ recorder . proxy _audio _codec || 'aac' } ` ,
2026-05-22 17:22:30 -04:00
` PROXY_AUDIO_BITRATE= ${ recorder . proxy _audio _bitrate || '128k' } ` ,
2026-05-21 00:17:45 -04:00
` PROXY_AUDIO_CHANNELS= ${ recorder . proxy _audio _channels ? ? 2 } ` ,
` PROXY_CONTAINER= ${ recorder . proxy _container || 'mp4' } ` ,
2026-05-28 18:25:56 -04:00
` PROJECT_ID= ${ takeProjectId } ` ,
2026-05-16 08:21:03 -04:00
` CLIP_NAME= ${ clipName } ` ,
2026-05-18 07:29:50 -04:00
` ASSET_ID= ${ assetIdLive } ` ,
2026-05-28 21:57:39 -04:00
` MAM_API_TOKEN= ${ process . env . CAPTURE _TOKEN || '' } ` ,
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
` GROWING_ENABLED= ${ growingEnabled ? 'true' : 'false' } ` ,
` GROWING_PATH=/growing ` ,
2026-05-31 14:50:31 -04:00
// SMB mount details for the in-container CIFS mount (Approach A). Empty
// GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume
// (or to S3 streaming if growing isn't enabled).
` GROWING_SMB_MOUNT= ${ smbMount } ` ,
` GROWING_SMB_USERNAME= ${ growingInfra . growing _smb _username || '' } ` ,
` GROWING_SMB_PASSWORD= ${ growingInfra . growing _smb _password || '' } ` ,
` GROWING_SMB_VERS= ${ growingInfra . growing _smb _vers || '3.0' } ` ,
2026-05-16 08:21:03 -04:00
] ;
2026-05-28 19:12:40 -04:00
// Deltacast: pass port count so the capture container can enumerate
// test-card slots even without physical /dev/deltacast* nodes.
if ( sourceType === 'deltacast' ) {
const dcCount = process . env . DELTACAST _PORT _COUNT || sourceConfig . port _count || '' ;
if ( dcCount ) env . push ( ` DELTACAST_PORT_COUNT= ${ dcCount } ` ) ;
}
2026-05-16 08:21:03 -04:00
if ( sourceType === 'srt' || sourceType === 'rtmp' ) {
env . push ( ` LISTEN= ${ isListener ? '1' : '0' } ` ) ;
if ( isListener ) {
env . push ( ` LISTEN_PORT= ${ sourceConfig . listen _port || ( sourceType === 'srt' ? 9000 : 1935 ) } ` ) ;
if ( sourceType === 'rtmp' && sourceConfig . stream _key ) {
env . push ( ` STREAM_KEY= ${ sourceConfig . stream _key } ` ) ;
}
} else if ( sourceConfig . url ) {
env . push ( ` SOURCE_URL= ${ sourceConfig . url } ` ) ;
}
}
2026-05-29 12:30:01 -04:00
// GPU-accelerated codecs require the NVIDIA container runtime on the node.
2026-05-31 18:34:36 -04:00
// hevc_nvenc / h264_nvenc are the only two we currently support (see the
// module-level GPU_CODECS list); extend it if av1_nvenc or others are added.
2026-05-29 12:30:01 -04:00
const useGpu = GPU _CODECS . includes ( recorder . recording _codec ) ;
2026-05-21 18:51:10 -04:00
// Determine whether to spawn locally or via a remote node-agent.
const { remote : isRemote , apiUrl : targetNodeApiUrl } = await resolveNodeTarget ( recorder . node _id ) ;
2026-05-28 21:04:24 -04:00
// For remote sidecars, the capture container runs on the worker host network and cannot
// resolve the Docker-internal mam-api hostname — replace with the external URL.
if ( isRemote ) {
const idx = env . findIndex ( e => e . startsWith ( 'MAM_API_URL=' ) ) ;
if ( idx !== - 1 ) env [ idx ] = ` MAM_API_URL= ${ externalMamApiUrl } ` ;
}
2026-04-07 22:05:41 -04:00
2026-05-21 18:51:10 -04:00
let containerId ;
2026-04-07 22:05:41 -04:00
2026-05-21 18:51:10 -04:00
if ( isRemote ) {
// Remote node: delegate container lifecycle to that node's agent.
const capturePort = SIDECAR _PORT _BASE + ( deviceIndex || 0 ) ;
const sidecarRes = await fetch ( ` ${ targetNodeApiUrl } /sidecar/start ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
2026-05-29 12:30:01 -04:00
body : JSON . stringify ( { image : 'wild-dragon-capture:latest' , env , capturePort , sourceType , useGpu } ) ,
2026-05-21 18:51:10 -04:00
signal : AbortSignal . timeout ( 15000 ) ,
2026-04-07 22:05:41 -04:00
} ) ;
2026-05-21 18:51:10 -04:00
if ( ! sidecarRes . ok ) {
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
// #105 — never proxy the remote node's raw response back to the
// browser; it could contain echoed env vars on bad-request paths.
2026-05-21 18:51:10 -04:00
const details = await sidecarRes . json ( ) . catch ( ( ) => ( { } ) ) ;
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
console . error ( '[recorders] remote sidecar start failed:' , JSON . stringify ( details ) ) ;
return res . status ( 502 ) . json ( {
error : 'Remote node failed to start sidecar' ,
details : ( details && details . message ) || 'see server logs' ,
} ) ;
2026-05-21 18:51:10 -04:00
}
const sidecarData = await sidecarRes . json ( ) ;
containerId = sidecarData . containerId ;
} else {
// Local spawn via Docker socket.
const { portBindings , exposedPorts } = buildPortConfig ( sourceType , sourceConfig ) ;
const alias = ` recorder- ${ id } ` ;
const hostBinds = [ '/mnt/NVME/MAM/wild-dragon-live:/live' ] ;
if ( sourceType === 'sdi' ) hostBinds . push ( '/dev/blackmagic:/dev/blackmagic' ) ;
2026-05-28 19:12:40 -04:00
if ( sourceType === 'deltacast' ) {
// Bind each /dev/deltacast* device node the host has into the container.
// The capture service falls back to test-card if none are present.
try {
const { readdirSync } = await import ( 'node:fs' ) ;
const dcEntries = readdirSync ( '/dev' ) . filter ( n => / ^ deltacast \ d + $ / . test ( n ) ) ;
for ( const d of dcEntries ) hostBinds . push ( ` /dev/ ${ d } :/dev/ ${ d } ` ) ;
} catch ( _ ) { /* no /dev/deltacast* nodes on this host */ }
}
2026-05-31 14:50:31 -04:00
// /growing handling:
// - SMB mount configured → DON'T host-bind; the capture container mounts
// the CIFS share at /growing itself (Approach A). A bind-mount here
// would shadow the in-container mount.
// - growing on but no SMB mount → legacy host bind-mount fallback.
// - growing off → no /growing mount at all.
if ( growingEnabled && ! smbMount ) {
hostBinds . push ( '/mnt/NVME/MAM/wild-dragon-growing:/growing' ) ;
}
2026-05-21 18:51:10 -04:00
2026-05-29 12:30:01 -04:00
const localEnv = [ ... env ] ;
if ( useGpu ) {
localEnv . push ( 'NVIDIA_VISIBLE_DEVICES=all' ) ;
localEnv . push ( 'NVIDIA_DRIVER_CAPABILITIES=video,compute,utility' ) ;
}
const localHostConfig = {
Privileged : true ,
NetworkMode : dockerNetwork ,
PortBindings : Object . keys ( portBindings ) . length > 0 ? portBindings : undefined ,
Binds : hostBinds ,
... ( useGpu && {
Runtime : 'nvidia' ,
DeviceRequests : [ { Driver : 'nvidia' , Count : - 1 , Capabilities : [ [ 'gpu' ] ] } ] ,
} ) ,
} ;
2026-05-21 18:51:10 -04:00
const containerConfig = {
Image : 'wild-dragon-capture:latest' ,
2026-05-29 12:30:01 -04:00
Env : localEnv ,
2026-05-21 18:51:10 -04:00
ExposedPorts : Object . keys ( exposedPorts ) . length > 0 ? exposedPorts : undefined ,
2026-05-29 12:30:01 -04:00
HostConfig : localHostConfig ,
2026-05-21 18:51:10 -04:00
NetworkingConfig : {
EndpointsConfig : {
[ dockerNetwork ] : { Aliases : [ alias ] } ,
} ,
} ,
Hostname : alias ,
} ;
const createRes = await dockerApi ( 'POST' , '/containers/create' , containerConfig ) ;
if ( createRes . status !== 201 ) {
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
// Issue #105 — log the full Docker error server-side, but never echo
// the create payload (which contains S3_ACCESS_KEY / STREAM_KEY in
// Env) back to the client. Send a short, generic message.
console . error ( '[recorders] container create failed:' , JSON . stringify ( createRes . data ) ) ;
2026-05-21 18:51:10 -04:00
return res . status ( 500 ) . json ( {
error : 'Failed to create container' ,
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
details : ( createRes . data && createRes . data . message ) || 'see server logs' ,
2026-05-21 18:51:10 -04:00
} ) ;
}
2026-04-07 22:05:41 -04:00
2026-05-21 18:51:10 -04:00
containerId = createRes . data . Id ;
const startRes = await dockerApi ( 'POST' , ` /containers/ ${ containerId } /start ` ) ;
if ( startRes . status !== 204 ) {
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
console . error ( '[recorders] container start failed:' , JSON . stringify ( startRes . data ) ) ;
2026-05-21 18:51:10 -04:00
return res . status ( 500 ) . json ( {
error : 'Failed to start container' ,
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
details : ( startRes . data && startRes . data . message ) || 'see server logs' ,
2026-05-21 18:51:10 -04:00
} ) ;
}
2026-04-07 22:05:41 -04:00
}
const updateResult = await pool . query (
` UPDATE recorders
SET container _id = $1 , status = $2 , current _session _id = $3 , updated _at = NOW ( )
WHERE id = $4
RETURNING * ` ,
[ containerId , 'recording' , clipName , id ]
) ;
res . json ( updateResult . rows [ 0 ] ) ;
} catch ( err ) {
next ( err ) ;
}
} ) ;
// POST /:id/stop - Stop recording
feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.
- recorders: param('id') resolves project + view baseline, requireRecorderEdit
on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
to write); also fixes the author bug (req.session.userId -> req.user.id, which
was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
registration) — TODO replaced with a rationale note
Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
asset into a project the caller lacks edit on — now asserts edit on the
override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
could pull in (and via GET /:id leak signed proxy URLs for) assets from a
project they can't access — now every clip asset must belong to the
sequence's project; pre-transaction queries moved inside try/catch so a DB
error returns 500 instead of hanging the request
- tests: recorders-access, sequences-access (incl. cross-project clip guard),
comments-access (incl. author-id regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:48:02 -04:00
router . post ( '/:id/stop' , requireRecorderEdit , async ( req , res , next ) => {
2026-04-07 22:05:41 -04:00
try {
const { id } = req . params ;
const recorderResult = await pool . query (
'SELECT * FROM recorders WHERE id = $1' ,
[ id ]
) ;
if ( recorderResult . rows . length === 0 ) {
return res . status ( 404 ) . json ( { error : 'Recorder not found' } ) ;
}
const recorder = recorderResult . rows [ 0 ] ;
if ( ! recorder . container _id ) {
2026-05-22 11:32:44 -04:00
// No container tracked — reset stuck status gracefully.
const result = await pool . query (
` UPDATE recorders SET container_id = NULL, status = 'stopped', updated_at = NOW()
WHERE id = $1 RETURNING * ` ,
[ id ]
) ;
return res . json ( result . rows [ 0 ] ) ;
2026-04-07 22:05:41 -04:00
}
2026-05-21 18:51:10 -04:00
const { remote : isRemote , apiUrl : targetNodeApiUrl } = await resolveNodeTarget ( recorder . node _id ) ;
2026-04-07 22:05:41 -04:00
2026-05-21 18:51:10 -04:00
if ( isRemote ) {
const stopRes = await fetch ( ` ${ targetNodeApiUrl } /sidecar/ ${ recorder . container _id } ` , {
method : 'DELETE' ,
signal : AbortSignal . timeout ( 15000 ) ,
2026-04-07 22:05:41 -04:00
} ) ;
2026-05-21 18:51:10 -04:00
if ( ! stopRes . ok && stopRes . status !== 404 ) {
return res . status ( 502 ) . json ( { error : 'Remote node failed to stop sidecar' } ) ;
}
} else {
2026-05-31 18:34:36 -04:00
// Issue #162 — stop WITH a grace period (t=180). Docker sends SIGTERM and
// waits up to 180s for ffmpeg to flush and write the MOV/MP4 trailer before
// it SIGKILLs. Without this the master is truncated/corrupt and the
// pre-created asset can get stuck in 'live'.
2026-05-21 18:51:10 -04:00
const stopRes = await dockerApi (
'POST' ,
2026-05-31 18:34:36 -04:00
` /containers/ ${ recorder . container _id } /stop?t=180 `
2026-05-21 18:51:10 -04:00
) ;
2026-04-07 22:05:41 -04:00
2026-05-22 11:32:44 -04:00
// 204 = stopped, 304 = already stopped, 404 = container gone — all acceptable.
if ( stopRes . status !== 204 && stopRes . status !== 304 && stopRes . status !== 404 ) {
2026-05-21 18:51:10 -04:00
return res . status ( 500 ) . json ( {
error : 'Failed to stop container' ,
details : stopRes . data ,
} ) ;
}
2026-04-07 22:05:41 -04:00
2026-05-22 11:32:44 -04:00
// Only attempt remove if the container existed (not 404).
if ( stopRes . status !== 404 ) {
2026-05-31 18:34:36 -04:00
// Issue #162 — before removing the container, wait for the master to
// finalize (asset leaves 'live'), mirroring the remote path's reliance on
// the node-agent's clean teardown. This guards against deleting the
// container — and its lingering finalize work — too early.
await waitForFinalize ( recorder ) ;
2026-05-22 11:32:44 -04:00
const removeRes = await dockerApi (
'DELETE' ,
` /containers/ ${ recorder . container _id } `
) ;
if ( removeRes . status !== 204 && removeRes . status !== 404 ) {
return res . status ( 500 ) . json ( {
error : 'Failed to remove container' ,
details : removeRes . data ,
} ) ;
}
2026-05-21 18:51:10 -04:00
}
2026-04-07 22:05:41 -04:00
}
const updateResult = await pool . query (
` UPDATE recorders
SET container _id = NULL , status = $1 , updated _at = NOW ( )
WHERE id = $2
RETURNING * ` ,
[ 'stopped' , id ]
) ;
res . json ( updateResult . rows [ 0 ] ) ;
} catch ( err ) {
next ( err ) ;
}
} ) ;
// GET /:id/status - Get live status
router . get ( '/:id/status' , async ( req , res , next ) => {
try {
const { id } = req . params ;
const recorderResult = await pool . query (
'SELECT * FROM recorders WHERE id = $1' ,
[ id ]
) ;
if ( recorderResult . rows . length === 0 ) {
return res . status ( 404 ) . json ( { error : 'Recorder not found' } ) ;
}
const recorder = recorderResult . rows [ 0 ] ;
if ( ! recorder . container _id ) {
return res . json ( {
status : recorder . status ,
duration : 0 ,
containerId : null ,
} ) ;
}
2026-05-21 18:51:10 -04:00
const deviceIndex = recorder . device _index ? ? ( recorder . source _config ? . device ? ? 0 ) ;
const { remote : isRemote , apiUrl : targetNodeApiUrl } = await resolveNodeTarget ( recorder . node _id ) ;
2026-04-07 22:05:41 -04:00
2026-05-21 18:51:10 -04:00
let isRunning = false ;
let duration = 0 ;
let signal = 'connecting' ;
2026-05-18 09:40:42 -04:00
let signalKnown = false ;
2026-05-17 07:39:19 -04:00
let live = null ;
2026-05-21 18:51:10 -04:00
if ( isRemote ) {
try {
const statusRes = await fetch ( ` ${ targetNodeApiUrl } /sidecar/ ${ recorder . container _id } /status ` , {
signal : AbortSignal . timeout ( 4000 ) ,
} ) ;
if ( statusRes . ok ) {
const data = await statusRes . json ( ) ;
isRunning = data . running ;
if ( data . startedAt ) {
duration = Math . floor ( ( Date . now ( ) - new Date ( data . startedAt ) . getTime ( ) ) / 1000 ) ;
}
live = data . live ;
}
} catch ( _ ) { /* node unreachable */ }
} else {
const inspectRes = await dockerApi (
'GET' ,
` /containers/ ${ recorder . container _id } /json `
) ;
if ( inspectRes . status !== 200 ) {
return res . json ( {
status : 'unknown' ,
duration : 0 ,
containerId : recorder . container _id ,
} ) ;
2026-05-17 07:39:19 -04:00
}
2026-05-21 18:51:10 -04:00
const container = inspectRes . data ;
isRunning = container . State . Running ;
duration = Math . floor ( ( Date . now ( ) - new Date ( container . State . StartedAt ) . getTime ( ) ) / 1000 ) ;
try {
const captureRes = await fetch ( ` http://recorder- ${ id } :3001/capture/status ` , { signal : AbortSignal . timeout ( 2000 ) } ) ;
if ( captureRes . ok ) live = await captureRes . json ( ) ;
} catch ( _ ) { /* not ready yet */ }
}
if ( isRunning ) signal = 'receiving' ;
if ( ! isRunning ) signal = 'stopped' ;
if ( live && live . signal ) { signal = live . signal ; signalKnown = true ; }
2026-05-17 07:39:19 -04:00
2026-04-07 22:05:41 -04:00
res . json ( {
2026-05-21 18:51:10 -04:00
status : isRunning ? 'recording' : 'stopped' ,
2026-04-07 22:05:41 -04:00
duration ,
containerId : recorder . container _id ,
2026-05-17 07:39:19 -04:00
signal ,
2026-05-18 09:40:42 -04:00
signalKnown ,
2026-05-17 07:39:19 -04:00
framesReceived : live ? live . framesReceived : null ,
2026-05-21 00:17:45 -04:00
currentFps : live ? live . currentFps : null ,
lastFrameAt : live ? live . lastFrameAt : null ,
lastError : live ? live . lastError : null ,
2026-04-07 22:05:41 -04:00
} ) ;
} catch ( err ) {
next ( err ) ;
}
} ) ;
// DELETE /:id - Delete recorder
feat(mam-api): finish per-project authz on the deferred routers
Phase 1 scoped only projects/assets/bins and left recorders, sequences,
imports, comments carrying TODO(authz) markers. A scoped editor/viewer could
still read and mutate those across every project. This closes the gap using
the existing authz.js helpers — no open TODO(authz) markers remain.
- recorders: param('id') resolves project + view baseline, requireRecorderEdit
on PATCH/DELETE/start/stop, GET / filtered by accessibleProjectIds, POST /
asserts edit on the target project (null project = admin-only)
- sequences: same param pattern + requireSequenceEdit on PUT/:id,/clips,conform
and DELETE; GET//POST/ assert on the query/body project
- imports: POST /youtube asserts edit on the body projectId
- comments: router.use guard resolves project via the asset (view to read, edit
to write); also fixes the author bug (req.session.userId -> req.user.id, which
was always NULL so comments had no recorded author)
- capture: intentionally any-logged-in (shared hardware, asset scoped on
registration) — TODO replaced with a rationale note
Security fixes from review of this change:
- recorders POST /:id/start: a per-take projectId override could route a live
asset into a project the caller lacks edit on — now asserts edit on the
override target
- sequences PUT /:id/clips: spliced asset_ids weren't checked, so an editor
could pull in (and via GET /:id leak signed proxy URLs for) assets from a
project they can't access — now every clip asset must belong to the
sequence's project; pre-transaction queries moved inside try/catch so a DB
error returns 500 instead of hanging the request
- tests: recorders-access, sequences-access (incl. cross-project clip guard),
comments-access (incl. author-id regression)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:48:02 -04:00
router . delete ( '/:id' , requireRecorderEdit , async ( req , res , next ) => {
2026-04-07 22:05:41 -04:00
try {
const { id } = req . params ;
const recorderResult = await pool . query (
'SELECT * FROM recorders WHERE id = $1' ,
[ id ]
) ;
if ( recorderResult . rows . length === 0 ) {
return res . status ( 404 ) . json ( { error : 'Recorder not found' } ) ;
}
const recorder = recorderResult . rows [ 0 ] ;
if ( recorder . container _id ) {
2026-05-21 18:51:10 -04:00
const { remote : isRemote , apiUrl : targetNodeApiUrl } = await resolveNodeTarget ( recorder . node _id ) ;
2026-04-07 22:05:41 -04:00
try {
2026-05-21 18:51:10 -04:00
if ( isRemote ) {
await fetch ( ` ${ targetNodeApiUrl } /sidecar/ ${ recorder . container _id } ` , {
method : 'DELETE' ,
signal : AbortSignal . timeout ( 10000 ) ,
} ) ;
} else {
await dockerApi ( 'POST' , ` /containers/ ${ recorder . container _id } /stop ` ) ;
await dockerApi ( 'DELETE' , ` /containers/ ${ recorder . container _id } ` ) ;
}
2026-04-07 22:05:41 -04:00
} catch ( err ) {
console . error ( 'Error stopping container during delete:' , err ) ;
}
}
const deleteResult = await pool . query (
'DELETE FROM recorders WHERE id = $1 RETURNING *' ,
[ id ]
) ;
res . json ( { message : 'Recorder deleted' , recorder : deleteResult . rows [ 0 ] } ) ;
} catch ( err ) {
next ( err ) ;
}
} ) ;
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
// Issue #104 — limit probe targets so an authed user can't scan the cluster's
// internal services (Docker socket, DB, metadata endpoints).
const ALLOWED _PROBE _SCHEMES = new Set ( [ 'srt' , 'rtmp' , 'rtmps' , 'rtsp' , 'udp' , 'rtp' ] ) ;
const BLOCKED _PROBE _PORTS = new Set ( [ 22 , 25 , 53 , 80 , 443 , 5432 , 6379 , 9000 , 9100 , 9229 ] ) ;
function isPrivateOrLoopback ( host ) {
if ( ! host ) return true ;
const h = host . toLowerCase ( ) ;
if ( h === 'localhost' || h . endsWith ( '.local' ) || h . endsWith ( '.internal' ) ) return true ;
// Hostname lookups happen later by the socket; here we just bail on the
// obvious cases. IPv4 private ranges + IPv6 link-local + AWS metadata IP.
if ( /^127\./ . test ( h ) ) return true ;
if ( /^10\./ . test ( h ) ) return true ;
if ( /^192\.168\./ . test ( h ) ) return true ;
if ( /^172\.(1[6-9]|2[0-9]|3[01])\./ . test ( h ) ) return true ;
if ( /^169\.254\./ . test ( h ) ) return true ; // link-local / AWS metadata
if ( /^100\.6[4-9]\./ . test ( h ) || /^100\.[7-9]\d\./ . test ( h ) || /^100\.1[0-1]\d\./ . test ( h ) || /^100\.12[0-7]\./ . test ( h ) ) return true ;
if ( /^0\./ . test ( h ) || /^::1$/ . test ( h ) || /^fe80:/ . test ( h ) || /^fc/ . test ( h ) || /^fd/ . test ( h ) ) return true ;
return false ;
}
function isAdmin ( req ) {
if ( process . env . AUTH _ENABLED !== 'true' ) return true ;
return req . user ? . role === 'admin' ;
}
2026-05-22 17:26:26 -04:00
// POST /probe - Probe a source URL for reachability.
// Tries the capture service first; falls back to basic TCP/UDP connectivity
// check when capture is not running.
2026-05-22 17:22:30 -04:00
router . post ( '/probe' , async ( req , res ) => {
const { source _type , url } = req . body || { } ;
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
// Validate URL up-front so we don't even let the capture service see junk.
let parsed = null ;
if ( url ) {
try { parsed = new URL ( url ) ; }
catch { return res . status ( 400 ) . json ( { error : 'Invalid URL' } ) ; }
const proto = ( parsed . protocol || '' ) . replace ( ':' , '' ) . toLowerCase ( ) ;
if ( ! ALLOWED _PROBE _SCHEMES . has ( proto ) ) {
return res . status ( 400 ) . json ( { error : ` Scheme " ${ proto } " is not permitted for probe (#104) ` } ) ;
}
// Non-admin users can only probe public hostnames. Admins may probe LAN.
if ( ! isAdmin ( req ) && isPrivateOrLoopback ( parsed . hostname ) ) {
return res . status ( 403 ) . json ( { error : 'Probe target must be a public host (#104)' } ) ;
}
}
2026-05-22 17:26:26 -04:00
// Try the capture service first (5s timeout)
2026-05-17 18:39:09 -04:00
try {
const r = await fetch ( 'http://capture:3001/capture/probe' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( req . body || { } ) ,
2026-05-22 17:22:30 -04:00
signal : AbortSignal . timeout ( 5000 ) ,
2026-05-17 18:39:09 -04:00
} ) ;
const data = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
2026-05-22 17:22:30 -04:00
return res . status ( r . status ) . json ( data ) ;
} catch ( _ ) {
// capture service not running — fall through to basic connectivity probe
2026-05-17 18:39:09 -04:00
}
2026-05-22 17:22:30 -04:00
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
if ( ! parsed ) {
2026-05-22 17:22:30 -04:00
return res . json ( {
reachable : false ,
mode : 'basic' ,
note : 'Capture service offline. Provide a URL for connectivity check.' ,
} ) ;
}
const host = parsed . hostname ;
const proto = ( parsed . protocol || '' ) . replace ( ':' , '' ) . toLowerCase ( ) ;
const isUdp = proto === 'srt' || source _type === 'srt' ;
const port = parseInt ( parsed . port , 10 ) || ( isUdp ? 9000 : 1935 ) ;
chore: 1.2 ship-prep sweep — close 38 issues
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
2026-05-26 22:06:14 -04:00
if ( BLOCKED _PROBE _PORTS . has ( port ) && ! isAdmin ( req ) ) {
return res . status ( 403 ) . json ( { error : ` Port ${ port } is not permitted for probe (#104) ` } ) ;
}
2026-05-22 17:22:30 -04:00
const reachable = await ( isUdp ? probeUdp ( host , port ) : probeTcp ( host , port ) ) ;
2026-05-22 17:26:26 -04:00
2026-05-22 17:22:30 -04:00
return res . json ( {
reachable ,
mode : 'basic' ,
note : ` Capture service offline · ${ isUdp ? 'UDP' : 'TCP' } connectivity check only ` ,
2026-05-22 17:26:26 -04:00
... ( reachable
? { source : ` ${ host } : ${ port } ` }
: { error : ` ${ host } : ${ port } did not respond ` }
) ,
2026-05-22 17:22:30 -04:00
} ) ;
2026-05-17 18:39:09 -04:00
} ) ;
2026-05-22 17:22:30 -04:00
function probeTcp ( host , port ) {
return new Promise ( ( resolve ) => {
const sock = new net . Socket ( ) ;
let done = false ;
const finish = ( ok ) => { if ( ! done ) { done = true ; sock . destroy ( ) ; resolve ( ok ) ; } } ;
sock . setTimeout ( 4000 ) ;
sock . connect ( port , host , ( ) => finish ( true ) ) ;
sock . on ( 'error' , ( ) => finish ( false ) ) ;
sock . on ( 'timeout' , ( ) => finish ( false ) ) ;
} ) ;
}
function probeUdp ( host , port ) {
return new Promise ( ( resolve ) => {
const sock = dgram . createSocket ( 'udp4' ) ;
let done = false ;
const finish = ( ok ) => {
if ( done ) return ;
done = true ;
try { sock . close ( ) ; } catch ( _ ) { }
resolve ( ok ) ;
} ;
2026-05-22 17:26:26 -04:00
// ICMP port-unreachable will fire sock.on('error') within ~100ms if nothing is listening
2026-05-22 17:22:30 -04:00
sock . on ( 'error' , ( ) => finish ( false ) ) ;
sock . send ( Buffer . alloc ( 16 , 0 ) , 0 , 16 , port , host , ( err ) => {
if ( err ) return finish ( false ) ;
2026-05-22 17:26:26 -04:00
// No ICMP error after 2.5s → assume something is listening
2026-05-22 17:22:30 -04:00
setTimeout ( ( ) => finish ( true ) , 2500 ) ;
} ) ;
setTimeout ( ( ) => finish ( false ) , 5000 ) ;
} ) ;
}
2026-05-28 23:20:02 -04:00
// GET /:id/live/* — reverse-proxy the live HLS preview from the recorder's node.
// Remote recorders: segments live on the worker node, served by its node-agent
// (/live/...). Local recorders: served from this host's /live mount. Browser
// media requests carry the session cookie (same-origin) so auth passes.
router . get ( '/:id/live/:rest(*)' , async ( req , res , next ) => {
try {
const { id } = req . params ;
const rest = req . params . rest ;
if ( ! rest || rest . includes ( '..' ) ) return res . status ( 400 ) . end ( ) ;
const rec = await pool . query ( 'SELECT node_id FROM recorders WHERE id = $1' , [ id ] ) ;
if ( rec . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Recorder not found' } ) ;
const ct = rest . endsWith ( '.m3u8' ) ? 'application/vnd.apple.mpegurl'
: rest . endsWith ( '.ts' ) ? 'video/mp2t'
: 'application/octet-stream' ;
res . set ( 'Cache-Control' , 'no-cache' ) ;
res . set ( 'Content-Type' , ct ) ;
const target = await resolveNodeTarget ( rec . rows [ 0 ] . node _id ) ;
if ( ! target . remote ) {
return fs . readFile ( '/live/' + rest , ( err , data ) => {
if ( err ) return res . status ( 404 ) . end ( ) ;
res . end ( data ) ;
} ) ;
}
const base = String ( target . apiUrl ) . replace ( /\/$/ , '' ) ;
const upstream = await fetch ( ` ${ base } /live/ ${ rest } ` ) . catch ( ( ) => null ) ;
if ( ! upstream || ! upstream . ok ) return res . status ( upstream ? upstream . status : 502 ) . end ( ) ;
res . end ( Buffer . from ( await upstream . arrayBuffer ( ) ) ) ;
} catch ( err ) { next ( err ) ; }
} ) ;
2026-04-07 22:05:41 -04:00
export default router ;