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.'} +

+ + + ) : ( + <> + +