Mint SFU tokens for huddles, and stop stages colliding with them (LC-596) #569

Merged
longjacksonle merged 1 commit from feat/lc596-sfu-huddle-tokens into main 2026-07-20 21:21:24 +02:00

First slice of LC-596. Does not close it - LC-596 is an epic and this is the SFU-backed-huddle workstream, server side. See the scope note at the bottom.

A latent bug this had to fix first

livekit::room_name hardcoded format!("stage-{room_id}"). A lets-chat room can host a Stage and a huddle simultaneously - they are independent features, one gated by stage_enabled and the other by live mesh membership, and nothing prevents both being active. Naming both stage-{id} would have dropped both sets of participants into a single LiveKit room, so a huddle would have leaked into the Stage broadcast and vice versa.

Surface is now part of the name (stage-42 vs huddle-42). This predates the ticket and would have bitten the moment huddles reached the SFU.

The endpoint

GET /room/{id}/huddle/token, deliberately separate from the stage token rather than a mode flag on it. The two surfaces have different gates (mesh membership vs the stage roster), different publish rules (symmetric vs a granted floor), and different LiveKit rooms - one handler would have branched on all three.

The gate mirrors require_participant in routes/transcripts.rs, which is the established precedent for this exact two-surface question: being able to see the room is not being in the call, so the caller must appear in voice_room_users. Every huddle participant publishes, because a huddle has no listener role.

Why below-threshold is a refusal, not a fallback flag

Below SFU_MIN_PARTICIPANTS the endpoint returns 400. Issuing a token there would split one call across two transports - some participants on the SFU, some still on the mesh, hearing nobody - which is strictly worse than staying on the mesh.

It also makes the client simple: the unconfigured case and the below-threshold case are the same signal, so mesh fallback is one code path rather than two.

The threshold is 3, living in livekit.rs as one named constant rather than a number duplicated across client and server. Two peers is a single RTCPeerConnection, where the mesh is cheaper and lower-latency than a server hop; the N-squared cost is what makes the SFU worth it from three up. The ticket flagged this as an assumption to revise once measured, so it is one edit when that happens.

Tests

huddle_sfu_token_gate walks the four states reachable without a LiveKit server: unconfigured, configured but not in the call, in the call but below threshold, and at threshold (a real token, can_publish: true, correct URL). Plus two unit tests: stage_and_huddle_in_one_room_do_not_collide and huddle_participant_publishes.

Confirmed load-bearing by deleting the threshold guard - the below-threshold case then returns 200 with a valid huddle-1 token:

assertion `left == right` failed: below the threshold the mesh keeps the call
  left: 200
 right: 400

Full suite green, 181 test binaries. just check clean.

One note on the test: it creates distinct extra users rather than extra connections, because voice_room_users dedupes by user id - more connections for the same user would not move the count, and the test would have passed for the wrong reason.

Scope

LC-596 is four workstreams: group ring/invite, sidebar discovery, SFU-backed huddles, and converging call.js with voice.js. This PR is the server half of the third.

The browser media path is not here and needs its own ticket. It is not a small addition: stage_media.js is 129 lines of audio-only subscribe-and-attach, whereas a symmetric group call needs camera and mic publishing, N remote participants, tile rendering, and mute/screen-share parity with the existing mesh UI. That is comparable in size to voice.js itself, and bolting it onto this PR would make the whole thing unreviewable. The endpoint is independently correct and tested; nothing calls it yet.

Structural blockers found while scoping the other three, recorded so they are not rediscovered: RingingSlot is keyed by room_id and holds a single caller, #lc-call-root is single-peer by construction, VoiceJoined only reaches sockets with the room already open, and the call/ring/huddle paths have essentially no test coverage today.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5

First slice of LC-596. Does **not** close it - LC-596 is an epic and this is the SFU-backed-huddle workstream, server side. See the scope note at the bottom. ## A latent bug this had to fix first `livekit::room_name` hardcoded `format!("stage-{room_id}")`. A lets-chat room can host a Stage and a huddle simultaneously - they are independent features, one gated by `stage_enabled` and the other by live mesh membership, and nothing prevents both being active. Naming both `stage-{id}` would have dropped both sets of participants into a single LiveKit room, so a huddle would have leaked into the Stage broadcast and vice versa. `Surface` is now part of the name (`stage-42` vs `huddle-42`). This predates the ticket and would have bitten the moment huddles reached the SFU. ## The endpoint `GET /room/{id}/huddle/token`, deliberately separate from the stage token rather than a mode flag on it. The two surfaces have different gates (mesh membership vs the stage roster), different publish rules (symmetric vs a granted floor), and different LiveKit rooms - one handler would have branched on all three. The gate mirrors `require_participant` in `routes/transcripts.rs`, which is the established precedent for this exact two-surface question: being able to see the room is not being in the call, so the caller must appear in `voice_room_users`. Every huddle participant publishes, because a huddle has no listener role. ## Why below-threshold is a refusal, not a fallback flag Below `SFU_MIN_PARTICIPANTS` the endpoint returns 400. Issuing a token there would split one call across two transports - some participants on the SFU, some still on the mesh, hearing nobody - which is strictly worse than staying on the mesh. It also makes the client simple: the unconfigured case and the below-threshold case are the same signal, so mesh fallback is one code path rather than two. The threshold is 3, living in `livekit.rs` as one named constant rather than a number duplicated across client and server. Two peers is a single `RTCPeerConnection`, where the mesh is cheaper and lower-latency than a server hop; the N-squared cost is what makes the SFU worth it from three up. The ticket flagged this as an assumption to revise once measured, so it is one edit when that happens. ## Tests `huddle_sfu_token_gate` walks the four states reachable without a LiveKit server: unconfigured, configured but not in the call, in the call but below threshold, and at threshold (a real token, `can_publish: true`, correct URL). Plus two unit tests: `stage_and_huddle_in_one_room_do_not_collide` and `huddle_participant_publishes`. Confirmed load-bearing by deleting the threshold guard - the below-threshold case then returns 200 with a valid `huddle-1` token: ``` assertion `left == right` failed: below the threshold the mesh keeps the call left: 200 right: 400 ``` Full suite green, 181 test binaries. `just check` clean. One note on the test: it creates distinct extra users rather than extra connections, because `voice_room_users` dedupes by user id - more connections for the same user would not move the count, and the test would have passed for the wrong reason. ## Scope LC-596 is four workstreams: group ring/invite, sidebar discovery, SFU-backed huddles, and converging `call.js` with `voice.js`. This PR is the server half of the third. The **browser media path is not here** and needs its own ticket. It is not a small addition: `stage_media.js` is 129 lines of audio-only subscribe-and-attach, whereas a symmetric group call needs camera and mic publishing, N remote participants, tile rendering, and mute/screen-share parity with the existing mesh UI. That is comparable in size to `voice.js` itself, and bolting it onto this PR would make the whole thing unreviewable. The endpoint is independently correct and tested; nothing calls it yet. Structural blockers found while scoping the other three, recorded so they are not rediscovered: `RingingSlot` is keyed by `room_id` and holds a single caller, `#lc-call-root` is single-peer by construction, `VoiceJoined` only reaches sockets with the room already open, and the call/ring/huddle paths have essentially no test coverage today. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5
feat(huddle): mint SFU tokens for huddles, and stop stages colliding with them (LC-596)
All checks were successful
check-secrets / TruffleHog (push) Successful in 12s
check-secrets / Kingfisher (push) Successful in 12s
check-secrets / Nosey parker (push) Successful in 11s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / Kingfisher (pull_request) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 6s
Check / clippy + fmt + tests (pull_request) Successful in 5m10s
Create release / Create release from merged PR (pull_request) Has been skipped
ccd9649b2b
First slice of LC-596, the SFU-backed group call. Server side only: the token endpoint and the room-naming fix it depends on. The browser media path is a separate ticket, see below.

`livekit::room_name` hardcoded `stage-{room_id}`, so a huddle and a Stage in the same lets-chat room would have resolved to one LiveKit room and their participants would have heard each other. The two surfaces are independent - one gated by `stage_enabled`, the other by live mesh membership - and nothing stops both running at once. `Surface` is now part of the name, and `stage_and_huddle_in_one_room_do_not_collide` pins it.

`GET /room/{id}/huddle/token` is a separate route from the stage token rather than a mode flag on it. The two have different gates (mesh membership vs the stage roster), different publish rules (symmetric vs a granted floor), and different LiveKit rooms; one handler would have branched on all three.

The gate mirrors `require_participant` in `routes/transcripts.rs`: room access is not membership in the call, so the caller must be in `voice_room_users`. Every huddle participant publishes, because a huddle has no listener role.

Below `SFU_MIN_PARTICIPANTS` the endpoint refuses. Handing out a token there would split one call across two transports - some participants on the SFU, some on the mesh, hearing nobody - which is worse than staying on the mesh. Refusing is also exactly the signal the client needs for mesh fallback, so the unconfigured case and the below-threshold case behave the same way from the browser's point of view.

The threshold is 3, and it is one named constant in `livekit.rs` rather than a number spread across client and server. Two peers is a single `RTCPeerConnection` and the mesh is cheaper and lower-latency there; the N-squared cost is what makes the SFU worth a server hop from three up. The ticket called this an assumption to revise once measured.

Tests: the four gate states (LiveKit unconfigured, not in the call, below threshold, at threshold), plus surface collision and huddle publish rights. Confirmed load-bearing by deleting the threshold guard, which turns the below-threshold case into a 200 with a real token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-20 21:20:01 +02:00
longjacksonle deleted branch feat/lc596-sfu-huddle-tokens 2026-07-20 21:21:25 +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!569
No description provided.