Skip to content

feat(server): bind each member their own session-scoped token, release 0.0.5 - #48

Merged
Dere-Wah merged 3 commits into
mainfrom
dere/per-member-bound-tokens
Jul 31, 2026
Merged

feat(server): bind each member their own session-scoped token, release 0.0.5#48
Dere-Wah merged 3 commits into
mainfrom
dere/per-member-bound-tokens

Conversation

@Dere-Wah

@Dere-Wah Dere-Wah commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Why

Every member of a slot holds the same JWT. PR #46 chose that design because the platform bound a session to the token that created it, at creation time only. A token minted later could not reach a session that already existed.

Three costs follow:

  • A shared session (usersPerSession > 1) gives several browsers one identical credential.
  • A member token cannot refresh. A new mint starts an empty grant, and the existing session rejects it. So the queue must mint each token to outlive the whole turn, and tokenTtlSeconds became a floor, not a lifetime.
  • An acquireSession override receives no scope at all. Members of those deployments hold an unscoped, account-wide JWT.

POST /tokens now accepts resources.sessions.bind, which binds a token to a session that already exists. The session must be open. Its owner must be the user that owns the API key. The queue server holds that key, so this change removes all three costs.

What this changes

The slot no longer owns a token. mintConnectionOnSlot creates the session and the connection with the server JWT. CoordinatorClient.createSession and createConnection drop their per-call jwt parameter.

mintMemberToken(sessionId) mints one token per member. Each token names the configured model and binds to the one session assigned to that member. It omits max_sessions, so the limit resolves to the bound count. The grant arrives full, and the token cannot open a session of its own.

A token is no longer single-use, because the bind targets a live session. sendMemberToken mints a fresh token on every call. request_token and the client getJwt resolver now refresh for real. tokenTtlSeconds is a true 60 second lifetime again.

A token cannot exist before its session. A member who reserves a new slot receives the token at claim, just before session_ready. A member who joins a running slot receives it at once. Each token binds to the session that the member lands on, so a member moved to another slot needs no second delivery.

No unscoped path remains. The scopedTokens switch is gone, so an acquireSession override is scoped like every other deployment. Its session must come from RQ_REACTOR_API_KEY, or from another key of the same user. The platform checks ownership per user, not per account. The README said "same Reactor account", which is too weak, and this PR corrects it.

A session from a different user now fails visibly. The Coordinator refuses the mint. The member receives { type: "error", message: "token_mint_failed" }, and the admin log records the 403. A silent fallback would let an operator believe that members are scoped when they are not.

API surface

Nothing changes for clients. Members still receive { type: "token", jwt, expiresAt } and attach with connect({ sessionId, connectionId }). Only the request that the server sends is new:

POST /tokens
{
  "expires_after": 60,
  "authorization_details": [
    {
      "type": "session",
      "resources": {
        "models": { "match": ["helios"] },
        "sessions": { "bind": ["<the member's session id>"] }
      }
    }
  ]
}

RQ_TOKEN_TTL_SECONDS recovers its documented meaning. Set it back to a small value if you raised it because of the old floor.

Verification

pnpm typecheck, pnpm test (133 tests), pnpm build, pnpm format:check, and ./scripts/check-license.sh pass.

New tests cover:

  • the bind payload, and the token order around session_ready
  • distinct tokens on a shared session, and request_token re-mints
  • a member moved to another slot, and a slot that version 0.0.4 persisted
  • a bound acquireSession session, and a refused bind that raises an error

Two invariants also hold. No member token authorizes a session or connection call, and no member holds an unscoped token.

A live deployment confirmed the behaviour. Several tokens each took their own connection on one shared session, and the Coordinator applied a short expires_after to a bound mint. The refusals held too. A full grant cannot create a session, cannot reach another session, and cannot call a non-session API.

Members of one slot shared a single JWT. A session was bound to the
grant of the token that created it, at creation time only, so the queue
had to make the token that created the session the token every member
attached with. That token could not be refreshed either, since a re-mint
started an empty grant, so it was issued to outlive the whole turn and
tokenTtlSeconds degraded into a floor.

The Coordinator now binds sessions that already exist at mint
(resources.sessions.bind), on the sole condition that the session is open
and owned by the minting API key. The queue is that key's holder, so the
server goes back to creating sessions and minting connections with its
own JWT, and each member receives a token bound to the one session they
were seated on. Members sharing a slot hold distinct tokens, and a
spilled member's token follows them to the session they landed on.

Binding works against a live session, so a member's token is minted
fresh whenever it is needed: tokenTtlSeconds is a real lifetime again
(60s by default) and request_token re-mints rather than re-delivering.
The limit is left to resolve to the bound count, which makes a member's
grant full on arrival — it drives the session it was given and cannot
open another. A member in the admission grace has no session to bind to
and receives their token at claim.

The slot no longer carries a token, so a slot persisted by an earlier
version binds like any other. Deployments with an acquireSession override
keep unscoped tokens: the session they supply need not belong to this
server's API key.

Signed-off-by: Dere-Wah <derexcontact@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@Dere-Wah Dere-Wah changed the title feat(server): bind each member their own session-scoped token feat(server): bind each member their own session-scoped token, release 0.0.5 Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

Dere-Wah and others added 2 commits July 28, 2026 09:25
An acquireSession override still handed members fully unscoped JWTs:
account-wide tokens able to create sessions for any model and reach
anything else the API key can. That path predates binding, when a
session the queue had not created could not be scoped to at all.

Binding only asks that the session be open and owned by the same user
as the minting API key, which an override that customises how the
session is made — extra arguments, a pinned release, its own retry —
already satisfies. So the queue scopes every member token and keeps no
unscoped path at all.

An override that sources a session from a different user now fails
rather than degrading: the Coordinator refuses the mint, the member
receives token_mint_failed, and the admin log carries the 403. Falling
back to an unscoped token would leave an operator believing members
were scoped while they were not, which is worse than a loud failure.
Same account is not sufficient, and the README now says so.

Signed-off-by: Dere-Wah <derexcontact@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Patch release for session-bound member tokens.

Signed-off-by: Dere-Wah <derexcontact@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@Dere-Wah
Dere-Wah force-pushed the dere/per-member-bound-tokens branch from a6297e0 to d2e2e30 Compare July 28, 2026 07:26
@Dere-Wah
Dere-Wah merged commit e23c100 into main Jul 31, 2026
5 checks passed
@Dere-Wah
Dere-Wah deleted the dere/per-member-bound-tokens branch July 31, 2026 17:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant