From 7dfd6b3048ad92a7abed78324fdeccf1e192e090 Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:28:14 +1000 Subject: [PATCH] Prototype voluntary seat handover with durable private retries Let a current owner issue, cancel or replace a private 24-hour one-time offer. Recipients accept the same faction and saved game while old browser sessions, recovery keys and entry receipts lose authority. Preserve exact claim retries across refresh, restart and later offers, with private tab-scoped continuation. Fence every claim mutation to its winning transaction. Add durable receipts and additive backfill for already-issued offers without changing game JSON or existing seat authority. Block competing navigation during unresolved owner operations; remove invalidated owner kits on confirmed recovery. Record the pending CHOAM Nexus after-victory secrecy/window decision without inventing the unresolved gameplay or opening mode/publication gates. Validation: types/lint and 4,364 offline tests passed; production build passed; 43 HTTP tests passed. Added 19 offline and 3 HTTP regressions. Independent privacy/persistence review passed, including deterministic CAS interleavings, rollback, legacy migration and exact receipt restoration. Browser room 2EMYG4CY retained private setup choices after handover/refresh and continued into Storm; follow-up recovery removed its invalid offer with cards/forces/spice unchanged. All 218 opening rooms preserved (291 after QA and both broad runs). The opening 456 seat, 49 recovery-key, 56 recovery-receipt and 106 entry-receipt rows were also verified unchanged. Server reused; no restart or recurring automation. Final private report: ~/.local/share/dune/checkpoints/20260913-seat-handover/ verification-final/report.json. Source fingerprint: 8949cdf58eb8bc8bd33f2657c6b21352b3ee5e17e2a556c684a89073015632f4. Check/build/HTTP duration: 140565/6689/24835 ms. The earlier broad run passed; a final recovery-UI review fix required the second source-bound run. Goal aggregate counter 64,301,976 -> 65,031,751 before Git (729,775) for this bounded capability and its concurrency/recovery review; full goal remains active. --- README.md | 2 + app/api/rooms/[code]/control/route.ts | 79 ++- app/page.tsx | 56 +- components/game-table.tsx | 3 + components/seat-handover.tsx | 488 ++++++++++++++++ components/seat-recovery.tsx | 6 + db/rooms.ts | 484 ++++++++++++++++ db/schema.ts | 39 ++ docs/CURRENT_STATUS.md | 4 +- docs/MULTIPLAYER_AUDIT.md | 4 +- docs/NEXUS_CHOAM_SECRET_ALLY.md | 38 ++ docs/RULE_DECISIONS.md | 1 + docs/SEAT_HANDOVER.md | 63 +++ drizzle/0004_flippant_archangel.sql | 15 + drizzle/0005_salty_alice.sql | 19 + drizzle/meta/0004_snapshot.json | 400 ++++++++++++++ drizzle/meta/0005_snapshot.json | 497 +++++++++++++++++ drizzle/meta/_journal.json | 16 +- game/reference.ts | 24 + lib/seat-handover.ts | 205 +++++++ tests/fixture-nexus-room-store.ts | 9 +- tests/seat-handover-client.test.ts | 174 ++++++ tests/seat-handover-http.test.ts | 200 +++++++ tests/seat-handover-store.test.ts | 768 ++++++++++++++++++++++++++ 24 files changed, 3570 insertions(+), 24 deletions(-) create mode 100644 components/seat-handover.tsx create mode 100644 docs/SEAT_HANDOVER.md create mode 100644 drizzle/0004_flippant_archangel.sql create mode 100644 drizzle/0005_salty_alice.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 lib/seat-handover.ts create mode 100644 tests/seat-handover-client.test.ts create mode 100644 tests/seat-handover-http.test.ts create mode 100644 tests/seat-handover-store.test.ts diff --git a/README.md b/README.md index a87219e5..222a1649 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ New create/join requests save a private, tab-scoped retry record before sending. An uncertain saved-kit recovery keeps its exact retry details in memory and prevents conflicting navigation. If that recovery has become obsolete, deliberate abandonment releases the controls after explaining that it cannot undo a completed recovery. Keep a valid saved kit before discarding retry details. +Use **Pass your seat to another player** for a voluntary one-time transfer. The private offer lasts 24 hours; acceptance preserves progress and revokes the old owner’s sessions and recovery kits. The recipient uses **Accept a seat handover** on the home page, then creates their own recovery kit. Keep the tab open until acceptance is confirmed; exact private retry details survive refresh in that tab. See [seat handover](docs/SEAT_HANDOVER.md) for cancellation, retry behavior and current limits. + ## Verify changes Use the [development guide](docs/DEVELOPMENT.md) for architecture, focused tests diff --git a/app/api/rooms/[code]/control/route.ts b/app/api/rooms/[code]/control/route.ts index a36004f5..2f09f1ff 100644 --- a/app/api/rooms/[code]/control/route.ts +++ b/app/api/rooms/[code]/control/route.ts @@ -1,5 +1,8 @@ import { authenticate, + createSeatHandover, + revokeSeatHandover, + claimSeatHandover, recoverSeat, SeatControlError, setRecoveryKey, @@ -37,36 +40,78 @@ export async function POST(req: Request) { 400, ); const input = body as Record; - if (input.type === 'setRecoveryKey') { - const token = - req.headers - .get('cookie') - ?.split(';') - .map((part) => part.trim()) - .find((part) => part.startsWith(`dune_${code}=`)) - ?.split('=')[1] ?? ''; + const token = + req.headers + .get('cookie') + ?.split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(`dune_${code}=`)) + ?.split('=')[1] ?? ''; + if ( + input.type === 'setRecoveryKey' || + input.type === 'createSeatHandover' || + input.type === 'revokeSeatHandover' + ) { const auth = await authenticate(code, token); + if (input.type === 'createSeatHandover') + return Response.json( + await createSeatHandover(code, auth, input.version, { + offerId: input.offerId, + handoverSecret: input.handoverSecret, + }), + { headers: noStore }, + ); + if (input.type === 'revokeSeatHandover') + return Response.json( + await revokeSeatHandover(code, auth, input.version, input.offerId), + { headers: noStore }, + ); return Response.json( await setRecoveryKey(code, auth, input.version, input.recoverySecret), { headers: noStore }, ); } - if (input.type !== 'recoverSeat') + if (input.type !== 'recoverSeat' && input.type !== 'claimSeatHandover') throw new SeatControlError( 'Choose a recovery operation.', 'INVALID_CONTROL_REQUEST', 400, ); - const result = await recoverSeat(code, { - playerId: input.playerId, - recoverySecret: input.recoverySecret, - operationId: input.operationId, - newSessionToken: input.newSessionToken, - }); + if (input.type === 'claimSeatHandover' && token) { + let existing; + try { + existing = await authenticate(code, token); + } catch (error) { + if (!(error instanceof RuleError)) throw error; + } + if (existing && existing.playerId !== input.playerId) + throw new SeatControlError( + 'This browser already controls another seat in this room. Use a separate browser profile to accept the handover.', + 'ALREADY_SEATED', + 409, + ); + } + const result = + input.type === 'claimSeatHandover' + ? await claimSeatHandover(code, { + playerId: input.playerId, + offerId: input.offerId, + handoverSecret: input.handoverSecret, + operationId: input.operationId, + newSessionToken: input.newSessionToken, + }) + : await recoverSeat(code, { + playerId: input.playerId, + recoverySecret: input.recoverySecret, + operationId: input.operationId, + newSessionToken: input.newSessionToken, + }); return Response.json( { view: result.view, - recovered: result.recovered, + ...('transferred' in result + ? { transferred: result.transferred } + : { recovered: result.recovered }), replayed: result.replayed, }, { @@ -84,7 +129,7 @@ export async function POST(req: Request) { ? error.message : error instanceof SyntaxError ? 'Invalid JSON.' - : 'The server could not complete seat recovery.', + : 'The server could not complete this seat request.', code: error instanceof SeatControlError ? error.code diff --git a/app/page.tsx b/app/page.tsx index e1b1f9bd..c7394e5c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,6 +2,11 @@ import Link from 'next/link'; import { useState, useEffect, useCallback, useRef } from 'react'; import { GameTable } from '@/components/game-table'; +import { + SeatHandoverSetup, + SeatHandoverClaim, +} from '@/components/seat-handover'; +import { HANDOVER_CLAIM_STORAGE_KEY } from '@/lib/seat-handover'; import { SeatRecoverySetup, SeatRecoveryClaim, @@ -73,6 +78,8 @@ export default function Home() { ); const [entryProblem, setEntryProblem] = useState(''); const [showEntryRecovery, setShowEntryRecovery] = useState(false); + const [showHandover, setShowHandover] = useState(false); + const [handoverPending, setHandoverPending] = useState(true); const [abandonAcknowledged, setAbandonAcknowledged] = useState(false); const f = faction(selected); const roomCode = game?.code; @@ -228,6 +235,19 @@ export default function Home() { ); useEffect(() => { + // A one-use handover needs its exact receipt before any other seat flow. + try { + if (window.sessionStorage.getItem(HANDOVER_CLAIM_STORAGE_KEY) !== null) { + // oxlint-disable-next-line react/react-compiler -- Restore private tab-scoped continuation after SSR. + setShowHandover(true); + setRestoring(false); + return; + } + } catch { + setShowHandover(true); + setRestoring(false); + return; + } // A saved entry always gets an explicit retry screen. A GET with another // seat's cookie cannot prove which create/join operation completed. try { @@ -348,6 +368,7 @@ export default function Home() { } activeRoom.current = data.code; activeSeat.current = data.me; + setHandoverPending(true); knownVersions.current.set(data.code, data.version); recovery.current = null; botAttempt.current = null; @@ -425,12 +446,14 @@ export default function Home() { function exitTable() { if ( + handoverPending || mutationPending.current || reconnectPending.current || seatClaimUncertain.current ) return; activeRoom.current = null; + setHandoverPending(true); activeSeat.current = null; ++epoch.current; recovery.current = null; @@ -566,6 +589,7 @@ export default function Home() { ) return; if (claim) { + setHandoverPending(true); activeRoom.current = view.code; activeSeat.current = view.me; ++epoch.current; @@ -623,6 +647,18 @@ export default function Home() { } catch {} return () => lifecycle.abort(); }, [roomCode, refresh]); + if (showHandover) + return ( +
+ window.location.reload()} + onRestored={(view) => { + restoredSeat(view, true); + setShowHandover(false); + }} + /> +
+ ); if (game) return ( <> @@ -631,6 +667,7 @@ export default function Home() { send={send} busy={busy || needsReconcile} onExit={exitTable} + exitDisabled={handoverPending} />
{(entryAttempt || entryProblem) && ( @@ -641,7 +678,7 @@ export default function Home() {

{(notice || connection || needsReconcile || botRecovery) && ( @@ -1019,6 +1064,15 @@ export default function Home() { onUncertain={controlUncertain} onRestored={(view) => restoredSeat(view, true)} /> + {claimUncertain && ( Seat recovery may have completed. Retry the current recovery diff --git a/components/game-table.tsx b/components/game-table.tsx index 8326a0db..a0ae2310 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -173,11 +173,13 @@ export function GameTable({ send, busy: transportBusy, onExit, + exitDisabled = false, }: { game: GameView; send: (a: Action) => Promise; busy: boolean; onExit: () => void; + exitDisabled?: boolean; }) { const me = g.players.find((p) => p.id === g.me)!; const shipmentAvailable = nexusGuildShipmentAvailable(g); @@ -802,6 +804,7 @@ export function GameTable({ + ) : ( +
+ {record.operation ? ( + <> +

+ {record.operation === 'create' + ? 'Offer creation is not confirmed. Confirm it before sharing the kit.' + : 'Cancellation is not confirmed. The kit may still be usable.'} +

+ + + ) : ( + <> + +