PubTrivia is sold per venue, and the thing we are guarding against is one login being passed around: the plans are priced for a pub, not for a chain sharing a password. So each account has a cap on how many browsers can be signed in at once, and the caps are declared in utils/plan-limits.ts along with every other limit:
trial: {
maxConcurrentLogins: 2, // one venue device + one home device
},
pro: {
maxConcurrentLogins: 3, // backstop against credential sharing
},
ultimate: {
maxConcurrentLogins: 3, // per-account cap (each of the 5 seats gets 3)
},
Two, three, three. Hold onto those numbers, because the enforcement path makes one of them unreachable.
How the cap is enforced
The cap is a Redis key, read in the proxy on every dashboard request:
// Read the per-user cap from Redis (written by the Stripe webhook
// when a subscription activates). Fall back to 3 if not set.
const capKey = `${KEY_PREFIX}:cap:${user.id}`
const storedCap = redis ? await redis.get<number>(capKey) : null
const maxLogins = typeof storedCap === 'number' ? storedCap : 3
The sessions themselves are a sorted set, member per session, score per last-seen timestamp. The member is a SHA-256 hash of the JWT's jti claim, done with Web Crypto because this runs on the edge:
async function sha256Hex(input: string): Promise<string> {
const encoded = new TextEncoder().encode(input)
const hashBuffer = await crypto.subtle.digest('SHA-256', encoded)
return Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
One pipeline prunes anything older than the 30 day session TTL, then reads this session's score and the live count:
const pruned = redis.pipeline()
pruned.zremrangebyscore(sessionsKey, 0, cutoff)
pruned.zscore(sessionsKey, hash)
pruned.zcard(sessionsKey)
The sorted set replaced a plain set plus one key per session, and the comment in the file is blunt about why:
// A sorted set rather than a plain set, because the cap is unenforceable
// without knowing which session is oldest. The previous model used a set plus
// one key per session hash, which meant the code could count sessions but never
// order them, and the "am I allowed" check reduced to asking whether the
// session it had just added was present, which is always true. The cap never
// blocked anybody while still costing a set, an sadd, an smembers and one
// exists per tracked session on every dashboard request.
The decision itself is three lines, and it refuses the newcomer rather than evicting an incumbent:
if (!isEstablishedSession && liveCount >= maxSessions) {
return false
}
Everything around that is written to fail open. No Redis credentials returns true. A thrown error inside the helper returns true. The whole block sits in a try whose catch comment reads "Session tracking failed, fail open, don't block the user". A venue is standing in front of a room of people at eight on a Thursday, and the right answer when Redis is unavailable is always "let them in".
The write side is four lines, all of them in the webhook
setUserSessionCap writes the number from the plan with a 35 day expiry, chosen to outlive any monthly cycle:
const limits = getPlanLimits(planType)
const capKey = `${KEY_PREFIX}:cap:${userId}`
await redis.set(capKey, limits.maxConcurrentLogins, { ex: CAP_TTL_S })
It is called in exactly four places, and all four are in app/api/webhook/stripe/route.ts: on activation or change for the owner, then for each active invited host when the plan is Ultimate, and the same pair again on cancellation with 'trial' hardcoded.
Nothing calls it at signup. A new account gets a venue row with plan_type: 'trial' and no Stripe subscription, which means no Stripe event, which means no key in Redis, which means the fallback applies.
So line them up:
plan declared cap cap actually enforced
trial 2 3 (no key, fallback)
pro 3 3 (key written, same as fallback)
ultimate 3 3 (key written, same as fallback)
Every paying customer is enforced at the fallback value anyway. The only account in the system that ever sees a 2 is one whose subscription was cancelled, because the cancellation handler is the only caller that passes 'trial'. The number is reachable, it is just reachable exclusively by people who have stopped paying us.
There is a second way to land on the fallback. The key is a 35 day lease, and the handler listens to four event types only: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted and checkout.session.completed. Invoice events are among the ones it ignores, so the thing that renews the lease is the period advancing on the subscription itself, which arrives as an update. For a monthly plan that is comfortably inside 35 days. And if it ever were not, the key would expire into a fallback that holds the same number it was holding, which is the whole point of the table above: the write path is currently indistinguishable from not having one.
The sentence we show when the cap bites
When the check refuses, the proxy clears the sb- cookies and redirects to the login form with a reason. You can see the exact screen without an account, because it is driven entirely by the query string: pub-trivia.app/login?reason=session_limit.
It says:
You've been signed out because this account has reached its maximum number of
active sessions. Sign in again to continue. Older sessions will be removed
automatically.
The last sentence is false. Older sessions are specifically the ones that are never touched: isEstablishedSession short circuits before the count is compared, so an incumbent is never displaced, and signing in again from the refused device does not help, because the refused device is the one over the line. The copy describes an evict-oldest policy. The code implements refuse-newest. They are opposite answers to the same question, and the person reading the banner is the one who gets the wrong end of it: they will try again, get the same screen, and conclude the product is broken.
Refuse-newest is the right policy for a venue. If the quiz is running on the bar's laptop, the last thing a cap should do is sign that laptop out because somebody opened the dashboard on a phone. So the fix is the copy, not the behaviour: say that too many devices are already signed in, and that signing out on one of them frees a slot.
What this is worth fixing, in order
- The banner text, which is a one line change and the only part a customer ever sees.
- Write the cap at account creation, so the trial value in
plan-limits.tsmeans something. Both signup paths already insert a venue row; one more call makes the table above true. - Then, and only then, argue about whether trial should be 2 at all. Right now that argument is unresolvable, because no trial account has ever been held to it.
If you want to watch the harmless half of this, open the pricing page and start a free trial, which takes no card. The cap you get will be 3, and now you know why.













