feat(server): mint session-scoped member tokens, release 0.0.4 - #46
Merged
Conversation
Member JWTs carried full user-level access to the queue's Reactor account: any admitted browser could use its token to create sessions for any model, touch other sessions, and call non-session APIs. The Coordinator now supports session authorization_details on POST /tokens, where a scoped token may only create sessions for the matched model and act on the sessions its own grant created. Each slot now owns one scoped token (match: the configured model, max_sessions: 1). The slot's session is created *with* that token — the step that binds the session to the token's grant — and every member seated on the slot receives it, which keeps shared sessions (usersPerSession > 1) and the connection-cap spill path working: the token follows the slot, not the member. A spilled member gets the new slot's token re-sent before session_ready. A scoped token cannot be refreshed mid-session (a fresh mint starts an empty grant), so slot tokens are minted to cover the admission grace plus the full session budget; tokenTtlSeconds becomes a floor, and request_token re-delivers the stored token. The queue's own admin JWT stays unscoped for cross-slot polling and teardown, which same-user ownership still permits. Deployments using an acquireSession override, and slots persisted before this version, keep unscoped member tokens (their sessions are bound to no grant this server holds). Signed-off-by: Dere-Wah <derexcontact@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Patch release for the scoped member-token change. Signed-off-by: Dere-Wah <derexcontact@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
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.
Why
Member JWTs were unscoped: any admitted browser held a token with full user-level access to the queue's Reactor account — it could create sessions for any model, act on other sessions, and call non-session APIs. The Coordinator now supports session
authorization_detailsonPOST /tokens(reactor#3195–#3197, live on prod and dev): a scoped token may only create sessions for the matched model, up tomax_sessions, and act on the sessions its own grant created. Anything else returns 403, and grant state is enforced server-side.The constraint that shapes this change: a session is bound to the grant of the token that created it, at creation time only. A token minted later can never attach to an existing session. So the queue can't keep creating sessions with its own server JWT and hand members separate scoped tokens — the member's token has to be the one that created the session.
What Changed
The scoped token belongs to the slot, not the member. Each slot mints one scoped token (
match: [model],max_sessions: 1) the first time it needs one; the slot's session is created with that token — the binding step — and every member seated on the slot receives it. That keeps the whole placement model intact: shared sessions (usersPerSession > 1) work because members of a slot share its token, and the connection-cap spill path works because a member who lands on a different slot is re-sent that slot's token just beforesession_ready.Scoped tokens cannot be refreshed mid-session (a re-mint starts a new, empty grant), so slot tokens are minted to cover the admission grace plus the full session budget, with
tokenTtlSecondsacting as a floor;request_token/ thegetJwtresolver re-deliver the stored token instead of minting. The queue's own admin JWT stays unscoped — pollingGET .../runtimeandDELETE /sessions/{id}across slots rides on same-user ownership, which the enforcement deliberately leaves intact.Two paths intentionally keep the old unscoped member tokens: deployments with an
acquireSessionoverride (their sessions are bound to no grant this server holds), and slots persisted by a previous version whose session already exists without a stored token — so a rolling deploy over live rooms degrades gracefully instead of locking members out.On the wire nothing changes: clients still receive
{ type: "token", jwt, expiresAt }and attach withconnect({ sessionId, connectionId }). No client/react/admin surface changes.The package version moves to 0.0.4, so merging this releases it (release.yml keys off the version).
Verification
pnpm typecheck,pnpm test(126 tests, including new coverage: scope payload + TTL floor at mint, session/connection created with the slot token, token re-delivery without re-mint, shared-slot token reuse, spill re-send,acquireSessionfallback, legacy grant-less slot fallback),pnpm format:check,pnpm lint:license.Coordinator behavior backing the design was verified live against prod: scoped mint echoes canonical
authorization_details; scoped JWTs 403 on unmatched models, non-session APIs, and other tokens' sessions; unscoped same-key tokens retain ownership access to scoped-created sessions.Made with Cursor