Repository navigation
feat(billing): enforce Free = 2 participants (host + 1) + 20 listeners - #147
Merged
Merged
Conversation
/pricing has always sold Free as "2 participants + up to 20 listeners", but join_session only capped SIGNED-IN joiners at 20 and never counted guests, so a Free room never filled and the PR #146 upgrade prompt never fired. Who takes which seat (seatUsage() in shared-types, mirrored in SQL): - participant: can be heard or take control. The host (one seat, always held for them), every signed-in joiner (their SFU token may publish), and every anonymous guest in a P2P room (direct connection to the host: mic, can be handed control). Free 2, Plus 100, Pro 500, Team 2000. - listener: an anonymous guest in an SFU room (subscribe-only token). Free 20, Plus 100, Pro 500, Team 2000. - agents keep their own limit (5) and take no seat. Enforcement (migration 20261017120000_free_two_participant_cap): - join_session reads the owner's plan live (user_effective_plan, orgs included), locks the room row, and raises "Session is full (participants|listeners)". The owner always gets in. Baked settings.maxParticipants/maxListeners are synced for display only. - a seat is held while its row was seen in the last 3 minutes. Rows were never refreshed (323 abandoned guest rows sat in open rooms), so counting left_at IS NULL would have locked most Free rooms for good. claim_session_seat refreshes it: POST /api/sessions/<id>/presence every 30 s from /view, /session and the desktop viewer; the signaling stream does it server-side for desktop/mobile P2P viewers. - nobody in a call is removed: a fresh row is let back in without a check (reloads, token refreshes); only a row that went quiet is re-checked. - LiveKit token mint uses claim_session_seat for non-owners (was a raw count of signed-in rows against the listener cap). Upgrade plumbing from #146 now fires at 2: refusals record capRefusedKind; HostCapPrompt and the desktop PlanCapBanner count seats the same way and say "Your Free plan allows 2 participants, you included ... Plus raises it to 100 participants for $1/month" (or the listener version at 20). Copy, all 19 locales: Free card drops "Unlimited free viewers on public lives" (no longer true); paid cards say "Up to N participants and N listeners per room"; both FAQs rewritten; dashboard plan line is the pricing string; the ignored "Maximum Viewers" picker on /host is gone; llms.txt pricing updated. P2P-vs-SFU per plan is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ThreatCrush Security Scan47 finding(s) HIGH/CRITICAL: 4 | MEDIUM: 27 | LOW: 16
Snippets are redacted; ThreatCrush never prints matched credential material. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Anthony, 2026-10-17: "yes pairux should enforce".
/pricinghas always sold Free: 2 participants + up to 20 listeners, but until nowjoin_sessiononly capped signed-in joiners, at 20, and anonymous guests never counted. So a Free room never filled up, and the upgrade prompt from #146 never fired.The counting rule
Why anonymous guests count: if an anonymous P2P guest didn't take a participant seat, anyone could get around "2 participants" by not signing in, and the cap would mean nothing. An anonymous SFU guest can't publish anything, so they are a listener and count against the listener cap.
Judgment call for Anthony: listeners are now capped (Free 20), which means the Free card's line "Unlimited free viewers on public lives" is gone. That line conflicted with "up to 20 listeners" on the same card, and only one of the two could be true. This reverses the 2026-07-12 "unlimited guest viewers" behaviour. In the last 60 days the peak was 7 concurrent guests in any room, so nobody is affected today. To undo it, skip the listener check for
is_publicrooms insession_seat_refusal.Enforcement (migration
20261017120000_free_two_participant_cap.sql)join_sessionreads the owner's plan live (user_effective_plan, org plans included). It locks the room row, so two joiners can't race for the last seat, and raisesSession is full (participants|listeners). The owner always gets in. An upgrade takes effect on the next join.settings.maxParticipants/maxListenersare only a display copy, synced on every join; this replaces feat(billing): upgrade prompt at the plan cap, one-click CoinPay checkout #146's "lift the stale cap and retry".left_at IS NULLwould have locked most Free rooms for good.claim_session_seatrefreshes a seat:POST /api/sessions/<id>/presence, every 30 s from/view,/sessionand the desktop viewerclaim_session_seatfor non-owners. Before, it counted signed-in rows against the listener cap.GET /api/sessions/join/<code>now reportsparticipant_count/listener_countthe way they are enforced.GET /api/sessions/<id>gives the ownercapacity(plan, caps, usage).Upgrade prompt (#146 plumbing) now fires at 2
capRefusedKind.HostCapPrompt(web) andPlanCapBanner(desktop) count seats with the sameseatUsage()and show: "Your Free plan allows 2 participants, you included, and every seat is taken. Plus raises it to 100 participants for $1/month." At 20 listeners they show the listener version.ROOM_FULL_MESSAGE(403).Copy (all 19 locales)
/host"Maximum Viewers 2/5/10" picker. The server always ignored it.llms.txtpricing summary: it still listed the old 5-viewer/credits tiers.Not changed (not approved)
/r/…, cap 16) and partner tokens: they have no plan owner, so nothing changes there.Checks run locally
--no-verifybecause it gets OOM-killed on this box. Instead I ran:pnpm format:check(repo-wide): cleanfull (participants); the owner always gets in.participants.full (listeners); signed-in user 2 is OK, user 3 is refused.The migration is applied by hand after merge (no CI step). I'll also apply the never-applied
20261015120000_session_media_connectedfrom #143:/connected400s in prod without it, and the host page's realtime participant list depends on it.🤖 Generated with Claude Code