Skip to content

feat(terminal): server-resolved short codes for live session invites - #7

Merged
kipavy merged 10 commits into
mainfrom
feat/session-join-grants
Aug 17, 2026
Merged

feat(terminal): server-resolved short codes for live session invites#7
kipavy merged 10 commits into
mainfrom
feat/session-join-grants

Conversation

@kipavy

@kipavy kipavy commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Server half of VoltiusApp/voltius#65: a server-resolved, short-lived code that a guest can read aloud or type by hand to join a live terminal session.

Why

The invite artifact carries a session-lifetime invite_token, so wherever it lands — chat scrollback, clipboard managers, OS handler logs — it stays valid until the session ends. A short-TTL code leaves a dead credential in those places instead. Secondarily, a 69-character sessionId:token cannot be read down a phone line; K7M2-P9QX-3B can.

What changed

Join credentials move from the single terminal_sessions.invite_token column into a terminal_session_grants table (migration 037), with three kinds:

  • legacy_token — backfilled from the existing column, never expires. This is what already-deployed clients present.
  • short_code — 10 symbols of Crockford base32 (50 bits, grouped 4-4-2), 10-minute TTL, one live per session enforced by a partial unique index.
  • guest — minted per redemption, never expires, individually revocable.

Two endpoints:

Route Auth Behaviour
POST /v1/terminal-sessions/:id/code host only, invite_link sessions only Revokes the previous code and mints a new one in one transaction. Returns { code, expires_at } once.
POST /v1/terminal-sessions/redeem any authenticated user Resolves the code, mints a guest grant, returns { session_id, invite_token }.

The redeemed secret keeps the existing 32-hex shape, so my-key?invite_token= and the WebSocket upgrade are untouched. A single resolve_join_grant replaces the token check that was previously written out separately in get_my_session_key and is_authorized_participant.

Rate limits: 30 mints/hour per host, 20 redemptions/hour per user. Unknown, malformed, expired and revoked codes all return 404, so no response distinguishes a real code from a wrong one, and the submitted code is never logged.

Backward compatibility

terminal_sessions.invite_token is still written, just never read, so rolling back to the previous binary works. Migration 037 backfills every live invite-link session, and src/db.rs re-runs that backfill idempotently at startup so a rollback followed by a roll-forward cannot strand a session. Bare sessionId:token and voltius://join?s=…&t=… both keep working.

Notable fixes found during review

  • A short code worked as a join token. The resolver was originally kind-agnostic, so a guest could pass the normalized code straight to my-key and skip redemption — no guest grant written, nothing to revoke, no record of who joined, and the redeem limiter bypassed. Now excluded explicitly, with tests at both call sites.
  • Concurrent regenerate returned 500 under READ COMMITTED; rotation now takes SELECT … FOR UPDATE on the session row first.
  • The migration backfill is one-shot, hence the idempotent startup reconciliation described above.

Verification

  • 275 unit tests, 0 failures; cargo clippy -- -D warnings clean crate-wide.
  • Live gate against a throwaway server + Postgres, 8/8, on this exact head:
    • migrations apply to an empty database and on top of a dump of the live schema at 036, backfilling correctly;
    • end-to-end mint → redeem → fetch key with two real accounts;
    • real wall-clock TTL: redeemed fine at ~9 minutes, 404 at 10m43s — measured, not simulated;
    • the bypass above returns 403, and proper redemption returns 200;
    • two simultaneous mints both return 201 and leave exactly one live code row;
    • reconciliation recreates a deleted grant on restart and inserts nothing on the next one;
    • legacy invite_token join still works;
    • 21st redemption in an hour returns 429.

Deployment

Unlike #69, this is not live until the voltius-server image is rebuilt and redeployed. Redeploy needs --env-file .env.dockhand or secrets come out blank. Verify against the docker voltius-db container, not Neon:

SELECT to_regclass('public.terminal_session_grants');
SELECT kind, count(*) FROM terminal_session_grants GROUP BY kind;

Known gap

Nothing writes revoked_at except code rotation, so there is no API to revoke one guest. The table makes per-guest revoke possible, but a guest admitted inside the 10-minute window keeps access until the session ends. The TTL bounds who can acquire access, not who keeps it.

Three non-blocking minors for later: the startup reconciliation panics the boot on a transient DB error where a warn-and-continue would be safer; its anti-join full-scans terminal_sessions on every boot; and one comment cites host-facing tooling that does not exist yet.

The client half of #65 is not in this PR.

kipavy added 10 commits August 16, 2026 21:48
… edge cases

- Grant no longer carries id/kind: nothing read them, and both were only
  ever written, tripping clippy dead_code.
- SessionState.invite_token: its last reader was removed with legacy-token
  grants; dead in-memory state must not linger next to the real source of
  truth in terminal_session_grants.
- create_code: fetch_optional the visibility lookup so a session ending
  between the host check and this query 404s instead of 500ing.
- comment fixes: correct the redeem-route-ordering rationale (matchit
  prefers static segments regardless of order), note why the guest grant
  has no expiry.
require_active_session_host catches ended sessions before create_code's
fetch_optional visibility lookup runs; only the row-disappears-mid-request
race reaches that branch, and that isn't worth simulating.
… reconcile legacy grants

- resolve_join_grant excludes kind=short_code so a spoken code can't skip
  /redeem, bypassing the guest-grant audit trail and the redeem limiter
- rotate_short_code takes a FOR UPDATE lock on the session row first,
  serializing concurrent regenerate calls against idx_tsg_one_live_code
- server boot reconciles orphaned invite_token rows left by a
  rollback/roll-forward cycle past migration 037's one-shot backfill;
  ON CONFLICT DO NOTHING makes it safe under a rolling deploy
- rate_limit::check_user_budget is the one implementation of the
  warn-and-429 shape, used by both session_codes handlers and the
  existing per-user middleware
- redeemed guest grants are attributed to the session host, not the
  redeeming guest, so host-facing tooling can find them
- delete a vacuous migration test, tighten a FORBIDDEN assertion, add
  get_my_session_key status-code regression coverage
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