feat(stage): LiveKit SFU stage audio [operator-action] (LC-512) #492

Merged
longjacksonle merged 3 commits from feat/LC-512-livekit-stage-audio into main 2026-06-29 03:52:45 +02:00

LC-512 - Stage audio: LiveKit SFU integration [operator-action]

The media transport for the LC-494 stage. The full mesh caps at ~4-6 peers, so a large-audience stage needs an SFU; this routes stage audio through a self-hosted LiveKit server, wired to the existing stage control plane (roles + request-to-speak).

Why LiveKit

Self-hostable, the de-facto standard SFU, and it fits this app's "optional external service via env" pattern (LLM / STT / GIF). Server-side it needs only access-token minting - no Rust media SDK - and connect-src wss: is already allowed, so no CSP change.

Server (complete + unit-tested)

  • livekit::LiveKitConfig::from_env (LETS_CHAT_LIVEKIT_URL / _API_KEY / _API_SECRET), read on demand like the GIF picker (no AppState field -> no test churn).
  • livekit::mint_token builds a LiveKit JWT (HS256, signed with the API secret) with a video grant pinning the room (stage-{id}) and publish/subscribe rights.
  • GET /room/{id}/stage/token gates on configured + stage-on + room access + on-roster, and derives canPublish from the live stage roster: speakers/hosts publish, listeners subscribe-only. A promote/demote therefore re-mints with new rights.
  • Unit tests assert the grant claims for speaker vs listener and that a wrong-secret token is rejected.

Client

stage_media.js reconciles against the per-viewer stage panel (data-lc-stage-livekit/joined/speaker): on join it lazy-loads the same-origin LiveKit SDK, fetches a token, connects, publishes mic if permitted, and plays subscribed audio; on leave it disconnects; on promote/demote it re-fetches a token and reconnects with the new publish right. If the SDK or server config is absent it silently no-ops (control plane still works).

Build / vendoring

just vendor-js fetches the pinned livekit-client UMD into server/assets/vendor/ (gitignored, produced at build like tailwind-built.css), wired into build / build-saas. Served same-origin - no runtime CDN, no CSP relaxation.

Operator action

Run a self-hosted LiveKit server and set the three LETS_CHAT_LIVEKIT_* vars (documented in CLAUDE.md). Unset = the stage still has roles + request-to-speak, just no audio. The build/Docker pipeline must run just vendor-js (wired into just build).

Tests / verification

just check, just test, just test-saas all green; the token-minting claims are unit-tested. The actual media path (browser SDK <-> running LiveKit server) is environment-dependent and not exercised by the Rust suite - same caveat as all the WebRTC in this repo; it needs a live LiveKit server + two browsers to smoke-test.

🤖 Generated with Claude Code

## LC-512 - Stage audio: LiveKit SFU integration [operator-action] The media transport for the LC-494 stage. The full mesh caps at ~4-6 peers, so a large-audience stage needs an SFU; this routes stage audio through a self-hosted **LiveKit** server, wired to the existing stage control plane (roles + request-to-speak). ### Why LiveKit Self-hostable, the de-facto standard SFU, and it fits this app's "optional external service via env" pattern (LLM / STT / GIF). Server-side it needs only **access-token minting** - no Rust media SDK - and `connect-src wss:` is already allowed, so no CSP change. ### Server (complete + unit-tested) - `livekit::LiveKitConfig::from_env` (`LETS_CHAT_LIVEKIT_URL` / `_API_KEY` / `_API_SECRET`), read on demand like the GIF picker (no AppState field -> no test churn). - `livekit::mint_token` builds a LiveKit JWT (HS256, signed with the API secret) with a `video` grant pinning the room (`stage-{id}`) and publish/subscribe rights. - `GET /room/{id}/stage/token` gates on configured + stage-on + room access + on-roster, and derives `canPublish` from the **live stage roster**: speakers/hosts publish, listeners subscribe-only. A promote/demote therefore re-mints with new rights. - Unit tests assert the grant claims for speaker vs listener and that a wrong-secret token is rejected. ### Client `stage_media.js` reconciles against the per-viewer stage panel (`data-lc-stage-livekit/joined/speaker`): on join it lazy-loads the same-origin LiveKit SDK, fetches a token, connects, publishes mic if permitted, and plays subscribed audio; on leave it disconnects; on promote/demote it re-fetches a token and reconnects with the new publish right. If the SDK or server config is absent it silently no-ops (control plane still works). ### Build / vendoring `just vendor-js` fetches the pinned `livekit-client` UMD into `server/assets/vendor/` (gitignored, produced at build like `tailwind-built.css`), wired into `build` / `build-saas`. Served same-origin - no runtime CDN, no CSP relaxation. ### Operator action Run a self-hosted LiveKit server and set the three `LETS_CHAT_LIVEKIT_*` vars (documented in CLAUDE.md). Unset = the stage still has roles + request-to-speak, just no audio. The build/Docker pipeline must run `just vendor-js` (wired into `just build`). ### Tests / verification `just check`, `just test`, `just test-saas` all green; the token-minting claims are unit-tested. The actual media path (browser SDK <-> running LiveKit server) is environment-dependent and not exercised by the Rust suite - same caveat as all the WebRTC in this repo; it needs a live LiveKit server + two browsers to smoke-test. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Adds livekit::LiveKitConfig (LETS_CHAT_LIVEKIT_URL/_API_KEY/_API_SECRET, read on demand like the GIF picker) and JWT access-token minting with LiveKit video grants. GET /room/{id}/stage/token gates on configured + stage-on + access + on-roster and derives publish rights from the live stage roster (speaker/host = canPublish, listener = subscribe-only), so a promote/demote re-mints with new rights. No Rust media SDK. Unit tests assert the grant claims for speaker vs listener and signature rejection.

Operator-Action: To enable large-audience stage audio, run a self-hosted LiveKit server and set LETS_CHAT_LIVEKIT_URL (wss://...), LETS_CHAT_LIVEKIT_API_KEY, LETS_CHAT_LIVEKIT_API_SECRET. Unset = stage roles/request-to-speak still work, no audio. The build must run `just vendor-js` to fetch the browser SDK (already wired into `just build`/`build-saas`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
stage_media.js reconciles against the per-viewer stage panel state (data-lc-stage-livekit/joined/speaker): on join it lazy-loads the same-origin LiveKit SDK, fetches a token, connects, publishes mic if permitted and subscribes to remote audio; on leave it disconnects; on promote/demote it re-fetches a token and reconnects with the new publish right. The panel drops the "audio coming soon" note once LiveKit is configured. No CSP change (same-origin SDK; connect-src wss already allowed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
build(stage): vendor LiveKit SDK same-origin at build + env docs (LC-512)
All checks were successful
check-secrets / Nosey parker (push) Successful in 3s
check-secrets / Kingfisher (push) Successful in 18s
check-secrets / TruffleHog (push) Successful in 18s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 6s
check-secrets / Kingfisher (pull_request) Successful in 6s
Check / clippy + fmt + tests (pull_request) Successful in 4m15s
Create release / Create release from merged PR (pull_request) Has been skipped
68a54b6426
`just vendor-js` fetches the pinned livekit-client UMD into server/assets/vendor/ (gitignored, produced at build like tailwind-built.css; no CDN at runtime, no CSP change), wired into build/build-saas. CLAUDE.md documents the LETS_CHAT_LIVEKIT_* env row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-06-29 03:52:36 +02:00
longjacksonle deleted branch feat/LC-512-livekit-stage-audio 2026-06-29 03:52:46 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!492
No description provided.