Replace one-time captain links with reusable team access codes - #263
Replace one-time captain links with reusable team access codes#263diese-tech wants to merge 3 commits into
Conversation
Captains could not get into the draft room on draft day. Three defects in the one-time link flow combined to cause it: - Redemption deleted the token (consumeCaptainToken), so a link opened on a second device, or in a browser that dropped the session cookie, was dead with nothing to show for it. - captain_tokens has unique (draft_room_id, org_id), but the admin UI offered "Generate another link". The second insert violated the constraint and returned 500; the all-seat path threw unhandled. - The session cookie held a single roomId|orgId pair, so joining a second room silently evicted the first seat. Captains now type a short code (H7K2-QM4X) into the draft room itself: - Codes are reusable and re-readable. Any device, any number of times; a captain who loses their session just re-enters the same code. - Crockford base32 alphabet (no I/L/O/U) with lookalike folding, so "h7k2-qm4x" and "H7KZ QM4X" resolve the same way. - The draft board shows a join panel instead of a dead-end "Spectator mode", plus a Leave control for shared devices. - Admin panel lists every seat's live code with copy/copy-all, and Rotate invalidates the previous code instantly — the revocation path one-time links never actually had. - The seat cookie holds a map of room -> org, so multiple seats coexist. v1 cookies still verify and upgrade on next join. Backed by the existing captain_tokens table; no schema change, per supabase/migrations/README.md. Legacy ?token= links stay redeemable until they expire and are no longer consumed on first use. This reverses the one-time-use hardening in SEC-06. A leaked code is usable until rotated, mitigated by per-client rate limiting, audited join attempts, instant rotation, and per-room/per-org scoping. The trade-off is documented in docs/draft-platform-guide.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qa7rba4XGU3QuFqG1Tcjo
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
dependency-audit started failing on high severity: nanoid <3.3.18 can loop indefinitely in custom generators when size is zero. It reaches the tree transitively via @tailwindcss/postcss -> postcss, which this repo already pins through overrides, so the advisory fires without any dependency change on our side. Adds a nanoid override alongside the existing postcss/sharp/minimatch pins. npm audit reports 0 vulnerabilities for both the production and full trees afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qa7rba4XGU3QuFqG1Tcjo
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 52eccdca81
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const { error: deleteError } = await supabase | ||
| .from("captain_tokens") | ||
| .delete() | ||
| .eq("draft_room_id", draftRoomId) | ||
| .eq("org_id", orgId); | ||
| if (deleteError) throw deleteError; |
There was a problem hiding this comment.
Preserve the prior code when rotation insertion fails
When the delete succeeds but the subsequent insert fails—for example because of a transient database error or a generated-code primary-key collision—the team's previously valid credential has already been destroyed. The endpoint then returns 500 while the admin UI continues displaying the now-invalid old code, potentially locking the captain out during a live draft. Perform the replacement atomically, such as with a transaction or a safe upsert, so a failed rotation leaves the prior code valid.
Useful? React with 👍 / 👎.
| export function getCaptainSessionFromRequest(request: NextRequest, draftRoomId: string): CaptainSession | null { | ||
| const orgId = getCaptainSeatsFromRequest(request)[draftRoomId]; | ||
| return orgId ? { draftRoomId, orgId } : null; |
There was a problem hiding this comment.
Revoke existing sessions when rotating a leaked code
If someone redeems a leaked code before an admin rotates it, the resulting signed cookie continues authorizing picks and shortlist operations for up to 30 days because session validation checks only the cookie's room/org mapping and never checks the current credential. Rotation therefore prevents only future redemption and does not remove an intruder who already joined, despite being presented as the response to suspected sharing. Include a credential version or similarly revocable value in the session and validate it against the current seat credential.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and it means the guide I wrote overclaimed. Fixed the documentation in f27f5cd rather than the mechanism, and I want to be explicit about why.
Implementing this properly is a security-model change rather than a patch: the seat cookie needs a third payload version carrying a credential fingerprint, a policy decision on what happens to the v1/v2 sessions already in the wild (grandfather them, or force every captain to re-enter their code on deploy), and a live credential check on the authenticated routes. Worth noting the check only needs to sit on pick and shortlist — the draft board itself is public, so a stale session on the read path gains nothing a spectator does not already have. That keeps the 3-second poll free of an extra query, which matters on draft day.
I have deliberately not bundled that into this PR. It arrives on the back of a draft where captains could not get into the room at all, so the priority is the access path being reliable; adding per-request credential validation to the same change widens the blast radius of the thing meant to fix reliability. Raising it with the repo owner as follow-up.
In the meantime the guide no longer presents rotation as the answer to an active intruder. It now states that rotation stops future redemption only, and points at pausing the room with admin-made picks, or voiding and replacing it, as the actual remedy.
One scope note on the threat model: a code only reaches an attacker if a captain forwards it, and it is scoped to one org in one room. The realistic failure here is a captain sharing their code with a teammate, not a hostile takeover.
Generated by Claude Code
Two findings from review on the access-code work. Rotation was delete-then-insert. If the delete landed and the insert then failed, the seat was left with no credential at all and the captain locked out mid-draft — a worse version of the bug this feature exists to fix. Replaced with a single upsert on the seat's unique (draft_room_id, org_id) constraint, so a failed rotation leaves the previous code untouched and the admin can retry. The guide also claimed rotation was the answer to a suspected leak. Sessions are validated by signature alone and are never re-checked against the current credential, so rotation stops future redemption but does not evict anyone who already joined. Documented what rotation actually does, and what to do instead when someone is genuinely in a seat they should not have. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014qa7rba4XGU3QuFqG1Tcjo
e2e check is cancelled for an infrastructure reason, not a test failureThe
Verified the suite locally instead343 passed, 0 failed against this branch. One caveat on how that was obtained, since it is not a plain Everything else on this commit
Generated by Claude Code |
Why
Captains could not get into the draft room when the player draft was hosted. Three defects in the one-time link flow combined to cause it:
consumeCaptainTokendeleted the row on exchange. Open the link on a phone and then a laptop → dead. Worse, if the browser dropped the session cookie (Safari ITP, the in-app browsers in Discord/Slack), the token was burned with nothing to show for it and the captain was locked out permanently.captain_tokenshasunique (draft_room_id, org_id), but the admin UI offered "Generate another link". The second insert violated the constraint and returned500 Failed to generate access link. The all-seat path threw unhandled.roomId|orgIdpair, so joining a second room silently evicted the first, and two captains sharing a machine logged each other out.There was also no recovery path: nothing in the draft room let a captain get back in, and admins could not re-read a link once it scrolled away.
What changed
Captains now type a short code into the draft room itself instead of following a link.
Codes —
H7K2-QM4X, Crockford base32 (noI/L/O/U), with lookalike folding soh7k2-qm4xandH7KZ QM4Xresolve identically. Reusable and re-readable: any device, any number of times. A captain who loses their session just re-enters the same code, no admin action needed.Captain UI — the dead-end "Spectator mode" now carries a join panel, plus a Leave control in the header for shared or borrowed devices.
Admin UI — every seat's live code is listed with copy / copy-all (the latter produces a paste-ready block with the room URL). Rotate invalidates the previous code instantly — the revocation path one-time links never actually had.
Multi-seat cookie — the seat cookie now holds a
room → orgmap, so seats in several rooms coexist. v1 cookies still verify and upgrade to v2 on the next join, so sessions issued before this change survive.Notes
captain_tokenstable, whoseunique (draft_room_id, org_id)already models one credential per seat.supabase/migrations/README.mdforbids new shared-schema migrations in this repo;npm run check:db-contractstill verifies clean.?token=links keep working until they expire, and are no longer consumed on first use.writeAuditLogrethrows on DB error, and joining is the one draft-day path that must not fail for a reason unrelated to the captain's code.Security trade-off
This reverses the one-time-use hardening recorded as SEC-06. A leaked code stays usable until it expires or is rotated, so a code should be treated like a password. Mitigations:
Worth a look from whoever owns that decision — the trade-off is documented in
docs/draft-platform-guide.md, including guidance to rotate if a code is suspected to have spread beyond the intended captain and backup owner.Verification
npm test— 788 passed, 26 skipped, 0 failuresnpm run lint— 0 errors (12 warnings, all pre-existing)npx tsc --noEmit— cleannpm run check:db-contract— verifiesdb-v1.17.0npm run buildcompiles and typechecks; it then fails collecting page data for/teams/[teamId]because this container has no Supabase env. Confirmed identical on a cleanmainbuild — pre-existing, not from this change.New coverage: access-code normalize/format/generate, the join route (reuse across devices, wrong-room codes, throttling and its reset, audit resilience), multi-seat and legacy-v1 cookie behaviour, and the code issue/redeem/list data layer.
🤖 Generated with Claude Code
https://claude.ai/code/session_014qa7rba4XGU3QuFqG1Tcjo
Generated by Claude Code