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.
This commit is contained in:
1 parent
f0d3d9c464
commit
7dfd6b3048
24 files changed
+3570
-24
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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
|
||||
|
||||
+55
-1
@@ -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 (
|
||||
<main className="table-shell">
|
||||
<SeatHandoverClaim
|
||||
onCancel={() => window.location.reload()}
|
||||
onRestored={(view) => {
|
||||
restoredSeat(view, true);
|
||||
setShowHandover(false);
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
if (game)
|
||||
return (
|
||||
<>
|
||||
@@ -631,6 +667,7 @@ export default function Home() {
|
||||
send={send}
|
||||
busy={busy || needsReconcile}
|
||||
onExit={exitTable}
|
||||
exitDisabled={handoverPending}
|
||||
/>
|
||||
<div className="table-shell">
|
||||
{(entryAttempt || entryProblem) && (
|
||||
@@ -641,7 +678,7 @@ export default function Home() {
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || checkingConnection}
|
||||
disabled={busy || checkingConnection || handoverPending}
|
||||
onClick={exitTable}
|
||||
>
|
||||
Review saved room request
|
||||
@@ -655,6 +692,14 @@ export default function Home() {
|
||||
onPending={controlPending}
|
||||
onRestored={(view) => restoredSeat(view)}
|
||||
/>
|
||||
<SeatHandoverSetup
|
||||
key={`handover:${game.code}:${game.me}`}
|
||||
game={game}
|
||||
disabled={busy || needsReconcile || checkingConnection}
|
||||
onPending={controlPending}
|
||||
onUncertain={setHandoverPending}
|
||||
onRestored={(view) => restoredSeat(view)}
|
||||
/>
|
||||
</div>
|
||||
{(notice || connection || needsReconcile || botRecovery) && (
|
||||
<output className="floating-notice">
|
||||
@@ -1019,6 +1064,15 @@ export default function Home() {
|
||||
onUncertain={controlUncertain}
|
||||
onRestored={(view) => restoredSeat(view, true)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
busy || claimUncertain || needsReconcile || checkingConnection
|
||||
}
|
||||
onClick={() => setShowHandover(true)}
|
||||
>
|
||||
Accept a seat handover
|
||||
</Button>
|
||||
{claimUncertain && (
|
||||
<output className="notice">
|
||||
Seat recovery may have completed. Retry the current recovery
|
||||
|
||||
@@ -173,11 +173,13 @@ export function GameTable({
|
||||
send,
|
||||
busy: transportBusy,
|
||||
onExit,
|
||||
exitDisabled = false,
|
||||
}: {
|
||||
game: GameView;
|
||||
send: (a: Action) => Promise<void>;
|
||||
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({
|
||||
<button
|
||||
className="wordmark wordmark-button"
|
||||
onClick={onExit}
|
||||
disabled={transportBusy || exitDisabled}
|
||||
aria-label="Return to lobby"
|
||||
>
|
||||
DUNE<span>ARRAKIS TABLE</span>
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { requestJson } from '@/lib/client-request';
|
||||
import {
|
||||
HANDOVER_CLAIM_STORAGE_KEY,
|
||||
clearHandoverClaim,
|
||||
createHandoverClaim,
|
||||
createHandoverKit,
|
||||
handoverClaimConfirmed,
|
||||
handoverOwnerStorageKey,
|
||||
parseHandoverClaim,
|
||||
parseHandoverKit,
|
||||
saveHandoverClaim,
|
||||
serializeHandoverKit,
|
||||
type SavedHandoverClaim,
|
||||
type SeatHandoverKit,
|
||||
} from '@/lib/seat-handover';
|
||||
|
||||
type OwnerRecord = {
|
||||
kit: SeatHandoverKit;
|
||||
expiresAt: number | null;
|
||||
operation: 'create' | 'revoke' | null;
|
||||
};
|
||||
type ViewCallback = (view: GameView) => void;
|
||||
|
||||
/** Mount by room + seat, so a browser never carries one owner's kit into another seat. */
|
||||
export function SeatHandoverSetup({
|
||||
game,
|
||||
disabled,
|
||||
onPending,
|
||||
onUncertain,
|
||||
onRestored,
|
||||
}: {
|
||||
game: GameView;
|
||||
disabled: boolean;
|
||||
onPending: (value: boolean) => void;
|
||||
onUncertain: (value: boolean) => void;
|
||||
onRestored: ViewCallback;
|
||||
}) {
|
||||
const id = useId(),
|
||||
pending = useRef(false);
|
||||
const storageKey = handoverOwnerStorageKey(game.code, game.me);
|
||||
const [record, setRecord] = useState<OwnerRecord | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [replaceAcknowledged, setReplaceAcknowledged] = useState(false);
|
||||
const own = game.players.find((player) => player.id === game.me);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(storageKey);
|
||||
if (saved !== null) {
|
||||
const value: unknown = JSON.parse(saved);
|
||||
if (!value || typeof value !== 'object') throw new Error();
|
||||
const stored = value as OwnerRecord,
|
||||
kit = parseHandoverKit(JSON.stringify(stored.kit));
|
||||
if (
|
||||
kit.roomCode !== game.code ||
|
||||
kit.playerId !== game.me ||
|
||||
(stored.expiresAt !== null &&
|
||||
(!Number.isSafeInteger(stored.expiresAt) ||
|
||||
stored.expiresAt < 0)) ||
|
||||
![null, 'create', 'revoke'].includes(stored.operation)
|
||||
)
|
||||
throw new Error();
|
||||
// oxlint-disable-next-line react/react-compiler -- Restore this seat's private tab-scoped offer after SSR.
|
||||
setRecord({
|
||||
kit,
|
||||
expiresAt: stored.expiresAt,
|
||||
operation: stored.operation,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setMessage(
|
||||
'The saved offer could not be read. Creating a replacement will invalidate any previous offer when confirmed.',
|
||||
);
|
||||
}
|
||||
setLoaded(true);
|
||||
}, [storageKey, game.code, game.me]);
|
||||
useEffect(() => {
|
||||
onUncertain(!loaded || !!record?.operation);
|
||||
}, [loaded, record, onUncertain]);
|
||||
if (!own) return null;
|
||||
|
||||
function store(next: OwnerRecord | null) {
|
||||
const text = next ? JSON.stringify(next) : null;
|
||||
if (text === null) sessionStorage.removeItem(storageKey);
|
||||
else sessionStorage.setItem(storageKey, text);
|
||||
if (sessionStorage.getItem(storageKey) !== text)
|
||||
throw new Error('The offer details could not be saved in this tab.');
|
||||
setRecord(next);
|
||||
}
|
||||
async function submit(operation: 'create' | 'revoke', kit: SeatHandoverKit) {
|
||||
if (disabled || pending.current) return;
|
||||
pending.current = true;
|
||||
setReplaceAcknowledged(false);
|
||||
setBusy(true);
|
||||
onPending(true);
|
||||
try {
|
||||
store({ kit, expiresAt: record?.expiresAt ?? null, operation });
|
||||
const result = await requestJson<{
|
||||
view: GameView;
|
||||
expiresAt?: number;
|
||||
revoked?: true;
|
||||
}>(`/api/rooms/${game.code}/control`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(
|
||||
operation === 'create'
|
||||
? {
|
||||
type: 'createSeatHandover',
|
||||
version: game.version,
|
||||
offerId: kit.offerId,
|
||||
handoverSecret: kit.handoverSecret,
|
||||
}
|
||||
: {
|
||||
type: 'revokeSeatHandover',
|
||||
version: game.version,
|
||||
offerId: kit.offerId,
|
||||
},
|
||||
),
|
||||
});
|
||||
if (
|
||||
result.view?.code !== kit.roomCode ||
|
||||
result.view?.me !== kit.playerId ||
|
||||
(operation === 'create'
|
||||
? !Number.isSafeInteger(result.expiresAt)
|
||||
: result.revoked !== true)
|
||||
)
|
||||
throw new Error('The server did not confirm the offer.');
|
||||
store(
|
||||
operation === 'create'
|
||||
? { kit, expiresAt: result.expiresAt!, operation: null }
|
||||
: null,
|
||||
);
|
||||
onRestored(result.view);
|
||||
setMessage(
|
||||
operation === 'create'
|
||||
? 'Handover ready. Share this complete kit privately with the intended new player.'
|
||||
: 'Handover cancelled. This kit can no longer transfer your seat.',
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
`${(error as Error).message} Keep this tab open. Retry the same operation to confirm its outcome; no retry is automatic.`,
|
||||
);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
onPending(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<details className="notice">
|
||||
<summary className="cursor-pointer text-base font-semibold">
|
||||
Pass your seat to another player
|
||||
</summary>
|
||||
<p>
|
||||
Create a private, one-time handover for {own.name}. The recipient keeps
|
||||
this faction, its private hand and all progress. Acceptance revokes your
|
||||
browser sessions and recovery kits for this seat. The new owner should
|
||||
protect their saved seat with a new recovery kit.
|
||||
</p>
|
||||
<p className="fine">
|
||||
The offer expires after 24 hours. You can keep playing until it is
|
||||
accepted. Recovering your seat invalidates its pending handover. Only
|
||||
share the kit with the intended recipient.
|
||||
</p>
|
||||
{!record ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || busy || !loaded}
|
||||
onClick={() => {
|
||||
try {
|
||||
void submit('create', createHandoverKit(game.code, game.me));
|
||||
} catch (error) {
|
||||
setMessage((error as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Create private handover
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{record.operation ? (
|
||||
<>
|
||||
<p>
|
||||
{record.operation === 'create'
|
||||
? 'Offer creation is not confirmed. Confirm it before sharing the kit.'
|
||||
: 'Cancellation is not confirmed. The kit may still be usable.'}
|
||||
</p>
|
||||
<Button
|
||||
disabled={disabled || busy}
|
||||
onClick={() => void submit(record.operation!, record.kit)}
|
||||
>
|
||||
Retry same{' '}
|
||||
{record.operation === 'create' ? 'offer' : 'cancellation'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label htmlFor={`${id}-kit`}>
|
||||
Private handover kit for room {game.code}
|
||||
</label>
|
||||
<Textarea
|
||||
id={`${id}-kit`}
|
||||
readOnly
|
||||
value={serializeHandoverKit(record.kit)}
|
||||
rows={9}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="fine">
|
||||
Expires: {new Date(record.expiresAt!).toLocaleString()}. Send
|
||||
the complete kit privately. The recipient can choose “Accept a
|
||||
seat handover” on the home page.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void navigator.clipboard
|
||||
.writeText(serializeHandoverKit(record.kit))
|
||||
.then(() => setMessage('Private handover kit copied.'))
|
||||
.catch(() =>
|
||||
setMessage(
|
||||
'Select and copy the complete kit manually.',
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Copy handover kit
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || busy}
|
||||
onClick={() => void submit('revoke', record.kit)}
|
||||
>
|
||||
Cancel handover
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{record.operation && (
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={replaceAcknowledged}
|
||||
disabled={disabled || busy}
|
||||
onChange={(event) =>
|
||||
setReplaceAcknowledged(event.target.checked)
|
||||
}
|
||||
/>
|
||||
I understand a confirmed replacement invalidates the previous kit.
|
||||
It cannot undo a handover already accepted.
|
||||
</label>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
disabled || busy || (!!record.operation && !replaceAcknowledged)
|
||||
}
|
||||
onClick={() => {
|
||||
try {
|
||||
void submit('create', createHandoverKit(game.code, game.me));
|
||||
} catch (error) {
|
||||
setMessage((error as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Replace with a new handover
|
||||
</Button>
|
||||
<p className="fine">
|
||||
A confirmed replacement invalidates the older kit. This tab keeps
|
||||
the private offer details across refresh; closing the tab can remove
|
||||
them.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<output className="block text-sm" aria-live="polite">
|
||||
{message}
|
||||
</output>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** A dedicated screen prevents room entry/recovery from competing with a saved claim. */
|
||||
export function SeatHandoverClaim({
|
||||
onRestored,
|
||||
onCancel,
|
||||
}: {
|
||||
onRestored: ViewCallback;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const id = useId(),
|
||||
pending = useRef(false);
|
||||
const [kitText, setKitText] = useState('');
|
||||
const [claim, setClaim] = useState<SavedHandoverClaim | null>(null);
|
||||
const [stored, setStored] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [acknowledged, setAcknowledged] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(HANDOVER_CLAIM_STORAGE_KEY);
|
||||
if (saved !== null) {
|
||||
// oxlint-disable-next-line react/react-compiler -- Hydrate exact private retry proof after SSR.
|
||||
setStored(true);
|
||||
setClaim(parseHandoverClaim(saved));
|
||||
setMessage(
|
||||
'A saved handover request is waiting. Retry it with the same details to confirm the result.',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setStored(true);
|
||||
setMessage(
|
||||
'The saved handover request could not be read. It has not been sent or deleted. Restore access to this tab’s storage and reload, or deliberately abandon it below.',
|
||||
);
|
||||
}
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
async function accept() {
|
||||
if (!claim || pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
saveHandoverClaim(sessionStorage, claim);
|
||||
setStored(true);
|
||||
const response = await requestJson<unknown>(
|
||||
`/api/rooms/${claim.kit.roomCode}/control`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(claim.attempt),
|
||||
},
|
||||
);
|
||||
const view = await requestJson<unknown>(
|
||||
`/api/rooms/${claim.kit.roomCode}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (!handoverClaimConfirmed(claim, response, view))
|
||||
throw new Error(
|
||||
'The browser session did not confirm the transferred seat.',
|
||||
);
|
||||
// Retain the room locator before deleting the only durable retry record.
|
||||
// If storage cleanup fails, the same request remains available on reload.
|
||||
window.history.replaceState(null, '', `/?room=${view.code}`);
|
||||
clearHandoverClaim(sessionStorage);
|
||||
setStored(false);
|
||||
setClaim(null);
|
||||
setKitText('');
|
||||
onRestored(view);
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
`${(error as Error).message} Acceptance may already have completed. Keep this tab open and retry this same request. Refresh retains its private retry details when tab storage is available.`,
|
||||
);
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="notice space-y-4" aria-label="Accept a seat handover">
|
||||
<h1>Accept a seat handover</h1>
|
||||
<p>
|
||||
Use the private kit given to you by the current owner. Accepting takes
|
||||
over their faction, including private information and existing
|
||||
decisions, and revokes their access. Keep this tab open until acceptance
|
||||
is confirmed.
|
||||
</p>
|
||||
{!claim && !stored && (
|
||||
<>
|
||||
<label htmlFor={`${id}-kit`}>Paste your private handover kit</label>
|
||||
<Input
|
||||
id={`${id}-kit`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
maxLength={4096}
|
||||
disabled={busy || !loaded}
|
||||
value={kitText}
|
||||
onChange={(event) => setKitText(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!kitText.trim() || busy || !loaded}
|
||||
onClick={() => {
|
||||
try {
|
||||
setClaim(createHandoverClaim(parseHandoverKit(kitText)));
|
||||
setKitText('');
|
||||
setMessage(
|
||||
'Kit read. Review the room and confirm you intend to take this seat.',
|
||||
);
|
||||
} catch (error) {
|
||||
setMessage((error as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Read handover kit
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{claim && (
|
||||
<>
|
||||
<p>
|
||||
Room: <strong>{claim.kit.roomCode}</strong>
|
||||
</p>
|
||||
<p className="break-all text-sm">Seat: {claim.kit.playerId}</p>
|
||||
<Button disabled={busy || !loaded} onClick={() => void accept()}>
|
||||
{busy
|
||||
? 'Confirming handover…'
|
||||
: stored
|
||||
? 'Retry same handover request'
|
||||
: 'Accept this seat'}
|
||||
</Button>
|
||||
{!stored && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => setClaim(null)}
|
||||
>
|
||||
Use a different kit
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{stored && (
|
||||
<>
|
||||
<p>
|
||||
Abandoning removes this tab’s exact retry proof. It cannot undo a
|
||||
completed handover. If its successful response was lost, you may
|
||||
lose access to the seat because the one-time kit has already been
|
||||
used.
|
||||
</p>
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={busy}
|
||||
checked={acknowledged}
|
||||
onChange={(event) => setAcknowledged(event.target.checked)}
|
||||
/>
|
||||
I understand abandoning may lose access and does not cancel the
|
||||
handover.
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || !acknowledged}
|
||||
onClick={() => {
|
||||
try {
|
||||
clearHandoverClaim(sessionStorage);
|
||||
setStored(false);
|
||||
setClaim(null);
|
||||
onCancel();
|
||||
} catch (error) {
|
||||
setMessage((error as Error).message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Abandon handover request
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!stored && (
|
||||
<Button variant="outline" disabled={busy || !loaded} onClick={onCancel}>
|
||||
Back to home
|
||||
</Button>
|
||||
)}
|
||||
<p className="fine">
|
||||
Private retry details are saved only in this tab before sending. They
|
||||
are removed after the handover and browser session are confirmed. Once
|
||||
seated, create your own recovery kit.
|
||||
</p>
|
||||
{message && (
|
||||
<output className="block" aria-live="polite">
|
||||
{message}
|
||||
</output>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { clearHandoverOwner } from '@/lib/seat-handover';
|
||||
import {
|
||||
createRecoveryAttempt,
|
||||
createRecoveryKit,
|
||||
@@ -284,6 +285,11 @@ export function SeatRecoveryClaim({
|
||||
result.view.me !== prepared.attempt.playerId
|
||||
)
|
||||
throw new Error('The server did not confirm the recovered seat.');
|
||||
clearHandoverOwner(
|
||||
sessionStorage,
|
||||
prepared.room,
|
||||
prepared.attempt.playerId,
|
||||
);
|
||||
setKitText('');
|
||||
setPrepared(null);
|
||||
setShowKit(false);
|
||||
|
||||
+484
@@ -699,3 +699,487 @@ export async function recoverSeat(
|
||||
replayed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const SEAT_HANDOVER_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const controlUuid = (value: unknown, message: string) => {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||
value,
|
||||
)
|
||||
)
|
||||
throw new SeatControlError(message, 'INVALID_HANDOVER_REQUEST', 400);
|
||||
return value;
|
||||
};
|
||||
type HandoverClock = Pick<RoomsClock, 'now'>;
|
||||
type HandoverOfferRow = Readonly<{
|
||||
offer_hash: string;
|
||||
secret_hash: string;
|
||||
issuer_session_hash: string;
|
||||
expires_at: number;
|
||||
claim_operation_hash: string | null;
|
||||
session_hash: string | null;
|
||||
claimed_at: number | null;
|
||||
}>;
|
||||
async function handoverOffer(code: string, playerId: string) {
|
||||
return db()
|
||||
.prepare(
|
||||
'SELECT offer_hash,secret_hash,issuer_session_hash,expires_at,claim_operation_hash,session_hash,claimed_at FROM seat_handover_offers WHERE room_code = ? AND player_id = ?',
|
||||
)
|
||||
.bind(code, playerId)
|
||||
.first<HandoverOfferRow>();
|
||||
}
|
||||
async function replayCreatedHandover(
|
||||
code: string,
|
||||
auth: SeatAuth,
|
||||
offerHash: string,
|
||||
secretHashValue: string,
|
||||
now: number,
|
||||
) {
|
||||
const row = await db()
|
||||
.prepare(
|
||||
'SELECT offers.expires_at FROM seat_handover_offers AS offers JOIN seats ON seats.room_code = offers.room_code AND seats.player_id = offers.player_id AND seats.token_hash = offers.issuer_session_hash WHERE offers.room_code = ? AND offers.player_id = ? AND offers.offer_hash = ? AND offers.secret_hash = ? AND offers.issuer_session_hash = ? AND offers.claim_operation_hash IS NULL AND seats.revoked = 0',
|
||||
)
|
||||
.bind(code, auth.playerId, offerHash, secretHashValue, auth.tokenHash)
|
||||
.first<{ expires_at: number }>();
|
||||
if (!row) return null;
|
||||
if (row.expires_at <= now)
|
||||
throw new SeatControlError(
|
||||
'This handover offer has expired. Create a new private offer.',
|
||||
'HANDOVER_EXPIRED',
|
||||
410,
|
||||
);
|
||||
return {
|
||||
view: await readSeatView(code, auth),
|
||||
expiresAt: row.expires_at,
|
||||
replayed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSeatHandover(
|
||||
code: string,
|
||||
auth: SeatAuth,
|
||||
version: unknown,
|
||||
input: { offerId: unknown; handoverSecret: unknown },
|
||||
clock: HandoverClock = roomsClock,
|
||||
) {
|
||||
controlVersion(version);
|
||||
const offerId = controlUuid(
|
||||
input.offerId,
|
||||
'Generate a new handover offer identifier.',
|
||||
);
|
||||
const [offerHash, handoverHash] = await Promise.all([
|
||||
hash(offerId),
|
||||
secretHash(input.handoverSecret),
|
||||
]);
|
||||
if (handoverHash === auth.tokenHash)
|
||||
throw new SeatControlError(
|
||||
'The handover secret must differ from the current session credential.',
|
||||
'INVALID_HANDOVER_REQUEST',
|
||||
400,
|
||||
);
|
||||
const now = clock.now();
|
||||
const replay = await replayCreatedHandover(
|
||||
code,
|
||||
auth,
|
||||
offerHash,
|
||||
handoverHash,
|
||||
now,
|
||||
);
|
||||
if (replay) return replay;
|
||||
const expiresAt = now + SEAT_HANDOVER_TTL_MS;
|
||||
try {
|
||||
const results = await db().batch([
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seat_handover_offers WHERE offer_hash = ? AND (room_code != ? OR player_id != ?))',
|
||||
)
|
||||
.bind(
|
||||
now,
|
||||
code,
|
||||
version,
|
||||
code,
|
||||
auth.playerId,
|
||||
auth.tokenHash,
|
||||
offerHash,
|
||||
code,
|
||||
auth.playerId,
|
||||
),
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO seat_handover_offers(room_code,player_id,offer_hash,secret_hash,issuer_session_hash,expires_at,claim_operation_hash,session_hash,claimed_at) SELECT ?,?,?,?,?,?,NULL,NULL,NULL WHERE changes() = 1 ON CONFLICT(room_code,player_id) DO UPDATE SET offer_hash = excluded.offer_hash, secret_hash = excluded.secret_hash, issuer_session_hash = excluded.issuer_session_hash, expires_at = excluded.expires_at, claim_operation_hash = NULL, session_hash = NULL, claimed_at = NULL',
|
||||
)
|
||||
.bind(
|
||||
code,
|
||||
auth.playerId,
|
||||
offerHash,
|
||||
handoverHash,
|
||||
auth.tokenHash,
|
||||
expiresAt,
|
||||
),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1) {
|
||||
const won = await replayCreatedHandover(
|
||||
code,
|
||||
auth,
|
||||
offerHash,
|
||||
handoverHash,
|
||||
now,
|
||||
);
|
||||
if (won) return won;
|
||||
throw new SeatControlError(
|
||||
'The table, seat, or handover offer changed. Reconnect before creating the offer.',
|
||||
'STALE_VERSION',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const won = await replayCreatedHandover(
|
||||
code,
|
||||
auth,
|
||||
offerHash,
|
||||
handoverHash,
|
||||
now,
|
||||
);
|
||||
if (won) return won;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
view: await readSeatView(code, auth),
|
||||
expiresAt,
|
||||
replayed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeSeatHandover(
|
||||
code: string,
|
||||
auth: SeatAuth,
|
||||
version: unknown,
|
||||
offerIdValue: unknown,
|
||||
clock: HandoverClock = roomsClock,
|
||||
) {
|
||||
controlVersion(version);
|
||||
const offerHash = await hash(
|
||||
controlUuid(offerIdValue, 'Choose the handover offer to revoke.'),
|
||||
);
|
||||
const existing = await handoverOffer(code, auth.playerId);
|
||||
if (!existing || existing.claim_operation_hash !== null) {
|
||||
return {
|
||||
view: await readSeatView(code, auth),
|
||||
revoked: true as const,
|
||||
replayed: true,
|
||||
};
|
||||
}
|
||||
if (existing.offer_hash !== offerHash)
|
||||
throw new SeatControlError(
|
||||
'A different handover offer is now current for this seat.',
|
||||
'HANDOVER_CHANGED',
|
||||
);
|
||||
const results = await db().batch([
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND claim_operation_hash IS NULL)',
|
||||
)
|
||||
.bind(
|
||||
clock.now(),
|
||||
code,
|
||||
version,
|
||||
code,
|
||||
auth.playerId,
|
||||
auth.tokenHash,
|
||||
code,
|
||||
auth.playerId,
|
||||
offerHash,
|
||||
),
|
||||
db()
|
||||
.prepare(
|
||||
'DELETE FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND claim_operation_hash IS NULL AND changes() = 1',
|
||||
)
|
||||
.bind(code, auth.playerId, offerHash),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1) {
|
||||
const current = await handoverOffer(code, auth.playerId);
|
||||
if (!current)
|
||||
return {
|
||||
view: await readSeatView(code, auth),
|
||||
revoked: true as const,
|
||||
replayed: true,
|
||||
};
|
||||
throw new SeatControlError(
|
||||
'The table, seat, or handover offer changed. Reconnect before revoking it.',
|
||||
current.offer_hash === offerHash ? 'STALE_VERSION' : 'HANDOVER_CHANGED',
|
||||
);
|
||||
}
|
||||
return {
|
||||
view: await readSeatView(code, auth),
|
||||
revoked: true as const,
|
||||
replayed: false,
|
||||
};
|
||||
}
|
||||
|
||||
type HandoverClaimReceiptRow = Readonly<{
|
||||
offer_hash: string;
|
||||
secret_hash: string;
|
||||
session_hash: string;
|
||||
}>;
|
||||
async function handoverClaimReceipt(
|
||||
code: string,
|
||||
playerId: string,
|
||||
operationHash: string,
|
||||
) {
|
||||
return db()
|
||||
.prepare(
|
||||
'SELECT offer_hash,secret_hash,session_hash FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?',
|
||||
)
|
||||
.bind(code, playerId, operationHash)
|
||||
.first<HandoverClaimReceiptRow>();
|
||||
}
|
||||
async function replayClaimedHandover(
|
||||
code: string,
|
||||
playerId: string,
|
||||
offerHash: string,
|
||||
secretHashValue: string,
|
||||
operationHash: string,
|
||||
sessionHash: string,
|
||||
newSessionToken: unknown,
|
||||
) {
|
||||
const row = await db()
|
||||
.prepare(
|
||||
'SELECT rooms.state,rooms.version FROM rooms JOIN seat_handover_claim_receipts AS receipts ON receipts.room_code = rooms.code JOIN seats ON seats.room_code = receipts.room_code AND seats.player_id = receipts.player_id WHERE rooms.code = ? AND receipts.player_id = ? AND receipts.offer_hash = ? AND receipts.secret_hash = ? AND receipts.operation_hash = ? AND receipts.session_hash = ? AND seats.token_hash = ? AND seats.revoked = 0',
|
||||
)
|
||||
.bind(
|
||||
code,
|
||||
playerId,
|
||||
offerHash,
|
||||
secretHashValue,
|
||||
operationHash,
|
||||
sessionHash,
|
||||
sessionHash,
|
||||
)
|
||||
.first<{ state: string; version: number }>();
|
||||
if (!row) return null;
|
||||
const game: Game = JSON.parse(row.state);
|
||||
game.version = row.version;
|
||||
return {
|
||||
view: viewGame(game, playerId),
|
||||
token: newSessionToken as string,
|
||||
transferred: true as const,
|
||||
replayed: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function claimSeatHandover(
|
||||
code: string,
|
||||
input: {
|
||||
playerId: unknown;
|
||||
offerId: unknown;
|
||||
handoverSecret: unknown;
|
||||
operationId: unknown;
|
||||
newSessionToken: unknown;
|
||||
},
|
||||
clock: HandoverClock = roomsClock,
|
||||
) {
|
||||
if (
|
||||
typeof input.playerId !== 'string' ||
|
||||
!input.playerId ||
|
||||
input.playerId.length > 100
|
||||
)
|
||||
throw new SeatControlError(
|
||||
'Provide a valid private handover kit.',
|
||||
'INVALID_HANDOVER_REQUEST',
|
||||
400,
|
||||
);
|
||||
const playerId = input.playerId;
|
||||
const offerId = controlUuid(
|
||||
input.offerId,
|
||||
'Provide a valid private handover kit.',
|
||||
);
|
||||
const operationId = controlUuid(
|
||||
input.operationId,
|
||||
'Generate a handover claim operation identifier.',
|
||||
);
|
||||
const [offerHash, handoverHash, operationHash, sessionHash] =
|
||||
await Promise.all([
|
||||
hash(offerId),
|
||||
secretHash(input.handoverSecret),
|
||||
hash(operationId),
|
||||
secretHash(input.newSessionToken),
|
||||
]);
|
||||
if (sessionHash === handoverHash)
|
||||
throw new SeatControlError(
|
||||
'The recipient session credential must differ from the handover secret.',
|
||||
'INVALID_SESSION_TOKEN',
|
||||
400,
|
||||
);
|
||||
const replay = await replayClaimedHandover(
|
||||
code,
|
||||
playerId,
|
||||
offerHash,
|
||||
handoverHash,
|
||||
operationHash,
|
||||
sessionHash,
|
||||
input.newSessionToken,
|
||||
);
|
||||
if (replay) return replay;
|
||||
if (await handoverClaimReceipt(code, playerId, operationHash))
|
||||
throw new SeatControlError(
|
||||
'This handover claim operation is no longer active or was reused with different details.',
|
||||
'HANDOVER_RECEIPT_INVALID',
|
||||
);
|
||||
const offer = await handoverOffer(code, playerId);
|
||||
if (
|
||||
!offer ||
|
||||
offer.offer_hash !== offerHash ||
|
||||
offer.secret_hash !== handoverHash
|
||||
) {
|
||||
const claimed = await db()
|
||||
.prepare(
|
||||
'SELECT 1 AS present FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ?',
|
||||
)
|
||||
.bind(code, playerId, offerHash, handoverHash)
|
||||
.first();
|
||||
throw new SeatControlError(
|
||||
claimed
|
||||
? 'This handover offer has already been claimed.'
|
||||
: 'The handover proof could not be verified.',
|
||||
claimed ? 'HANDOVER_ALREADY_CLAIMED' : 'INVALID_HANDOVER_PROOF',
|
||||
);
|
||||
}
|
||||
if (offer.claim_operation_hash !== null)
|
||||
throw new SeatControlError(
|
||||
'This handover offer has already been claimed.',
|
||||
'HANDOVER_ALREADY_CLAIMED',
|
||||
);
|
||||
const now = clock.now();
|
||||
if (offer.expires_at <= now)
|
||||
throw new SeatControlError(
|
||||
'This handover offer has expired.',
|
||||
'HANDOVER_EXPIRED',
|
||||
410,
|
||||
);
|
||||
const issuer = await db()
|
||||
.prepare(
|
||||
'SELECT 1 AS present FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0',
|
||||
)
|
||||
.bind(code, playerId, offer.issuer_session_hash)
|
||||
.first();
|
||||
if (!issuer)
|
||||
throw new SeatControlError(
|
||||
'The handover proof could not be verified.',
|
||||
'INVALID_HANDOVER_PROOF',
|
||||
);
|
||||
if (
|
||||
await db()
|
||||
.prepare('SELECT 1 AS present FROM seats WHERE token_hash = ?')
|
||||
.bind(sessionHash)
|
||||
.first()
|
||||
)
|
||||
throw new SeatControlError(
|
||||
'Generate a new recipient session credential; this token has already been used.',
|
||||
'INVALID_SESSION_TOKEN',
|
||||
);
|
||||
const current = await readRoom(code);
|
||||
const claimFence = await hash(crypto.randomUUID());
|
||||
const results = await db().batch([
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ? AND EXISTS (SELECT 1 FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ? AND issuer_session_hash = ? AND expires_at > ? AND claim_operation_hash IS NULL) AND EXISTS (SELECT 1 FROM seats WHERE room_code = ? AND player_id = ? AND token_hash = ? AND revoked = 0) AND NOT EXISTS (SELECT 1 FROM seats WHERE token_hash = ?) AND NOT EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND operation_hash = ?)',
|
||||
)
|
||||
.bind(
|
||||
now,
|
||||
code,
|
||||
current.version,
|
||||
code,
|
||||
playerId,
|
||||
offerHash,
|
||||
handoverHash,
|
||||
offer.issuer_session_hash,
|
||||
now,
|
||||
code,
|
||||
playerId,
|
||||
offer.issuer_session_hash,
|
||||
sessionHash,
|
||||
code,
|
||||
playerId,
|
||||
operationHash,
|
||||
),
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO seat_handover_claim_receipts(room_code,player_id,operation_hash,offer_hash,secret_hash,session_hash,claim_fence,claimed_at) SELECT ?,?,?,?,?,?,?,? WHERE changes() = 1',
|
||||
)
|
||||
.bind(
|
||||
code,
|
||||
playerId,
|
||||
operationHash,
|
||||
offerHash,
|
||||
handoverHash,
|
||||
sessionHash,
|
||||
claimFence,
|
||||
now,
|
||||
),
|
||||
db()
|
||||
.prepare(
|
||||
'DELETE FROM seat_handover_offers WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ? AND EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND claim_fence = ?)',
|
||||
)
|
||||
.bind(
|
||||
code,
|
||||
playerId,
|
||||
offerHash,
|
||||
handoverHash,
|
||||
code,
|
||||
playerId,
|
||||
claimFence,
|
||||
),
|
||||
db()
|
||||
.prepare(
|
||||
'UPDATE seats SET revoked = 1 WHERE room_code = ? AND player_id = ? AND revoked = 0 AND EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND claim_fence = ?)',
|
||||
)
|
||||
.bind(code, playerId, code, playerId, claimFence),
|
||||
db()
|
||||
.prepare(
|
||||
'INSERT INTO seats(token_hash,room_code,player_id) SELECT ?,?,? WHERE EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND claim_fence = ?)',
|
||||
)
|
||||
.bind(sessionHash, code, playerId, code, playerId, claimFence),
|
||||
db()
|
||||
.prepare(
|
||||
'DELETE FROM seat_recovery_receipts WHERE room_code = ? AND player_id = ? AND EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND claim_fence = ?)',
|
||||
)
|
||||
.bind(code, playerId, code, playerId, claimFence),
|
||||
db()
|
||||
.prepare(
|
||||
'DELETE FROM seat_recovery_keys WHERE room_code = ? AND player_id = ? AND EXISTS (SELECT 1 FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND claim_fence = ?)',
|
||||
)
|
||||
.bind(code, playerId, code, playerId, claimFence),
|
||||
]);
|
||||
if (results[0].meta.changes !== 1) {
|
||||
const won = await replayClaimedHandover(
|
||||
code,
|
||||
playerId,
|
||||
offerHash,
|
||||
handoverHash,
|
||||
operationHash,
|
||||
sessionHash,
|
||||
input.newSessionToken,
|
||||
);
|
||||
if (won) return won;
|
||||
const claimed = await db()
|
||||
.prepare(
|
||||
'SELECT 1 AS present FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ? AND offer_hash = ? AND secret_hash = ?',
|
||||
)
|
||||
.bind(code, playerId, offerHash, handoverHash)
|
||||
.first();
|
||||
throw new SeatControlError(
|
||||
claimed
|
||||
? 'This handover offer has already been claimed.'
|
||||
: 'The table or handover offer changed. Retry the exact claim request.',
|
||||
claimed
|
||||
? 'HANDOVER_ALREADY_CLAIMED'
|
||||
: 'STALE_VERSION',
|
||||
);
|
||||
}
|
||||
return {
|
||||
view: await readSeatView(code, { playerId, tokenHash: sessionHash }),
|
||||
token: input.newSessionToken as string,
|
||||
transferred: true as const,
|
||||
replayed: false,
|
||||
};
|
||||
}
|
||||
@@ -49,6 +49,45 @@ export const seatRecoveryReceipts = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const seatHandoverOffers = sqliteTable(
|
||||
'seat_handover_offers',
|
||||
{
|
||||
roomCode: text('room_code')
|
||||
.notNull()
|
||||
.references(() => rooms.code, { onDelete: 'cascade' }),
|
||||
playerId: text('player_id').notNull(),
|
||||
offerHash: text('offer_hash').notNull().unique(),
|
||||
secretHash: text('secret_hash').notNull(),
|
||||
issuerSessionHash: text('issuer_session_hash').notNull(),
|
||||
expiresAt: integer('expires_at').notNull(),
|
||||
claimOperationHash: text('claim_operation_hash'),
|
||||
sessionHash: text('session_hash'),
|
||||
claimedAt: integer('claimed_at'),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.roomCode, table.playerId] })],
|
||||
);
|
||||
|
||||
export const seatHandoverClaimReceipts = sqliteTable(
|
||||
'seat_handover_claim_receipts',
|
||||
{
|
||||
roomCode: text('room_code')
|
||||
.notNull()
|
||||
.references(() => rooms.code, { onDelete: 'cascade' }),
|
||||
playerId: text('player_id').notNull(),
|
||||
operationHash: text('operation_hash').notNull(),
|
||||
offerHash: text('offer_hash').notNull(),
|
||||
secretHash: text('secret_hash').notNull(),
|
||||
sessionHash: text('session_hash').notNull(),
|
||||
claimFence: text('claim_fence').notNull().unique(),
|
||||
claimedAt: integer('claimed_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.roomCode, table.playerId, table.operationHash],
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
export const roomEntryReceipts = sqliteTable('room_entry_receipts', {
|
||||
operationHash: text('operation_hash').primaryKey(),
|
||||
requestHash: text('request_hash').notNull(),
|
||||
|
||||
@@ -57,13 +57,15 @@ restoration after necessary restarts. No recurring automation is installed.
|
||||
| Six-faction Advanced | Genuine offline setup across 456 configurations; four-profile full-game simulations and JSON continuations. | Base gaps plus provisional special-Karama outcomes, remaining timing audits and authentic multiplayer/browser acceptance. Public starts remain disabled. |
|
||||
| Twelve factions and optional modules | Many bounded faction, card, Homeworld and Nexus slices have controls, bots and recovery evidence; Discovery includes free entry, Cistern, carried flight and bounded Jacurutu, Testing Station and Shrine effects. | Every unfinished effect and interaction, occupation entitlement, Orgiz, mixed physical dial allocation and contested Discovery benefits and complete combined games. Inventory or fixture coverage does not enable a module. |
|
||||
| AI difficulty | Four profiles consume private legal views; recorded Basic and Advanced studies complete. | Demonstrate a consistent Medium → Hard → Brutal strength ordering, improve strategic gaps, and repeat targeted studies after relevant policy/rule changes. |
|
||||
| Multiplayer | Versioned writes, saved seats, owner recovery kits, uncertain-request retry and voluntary own-seat AI control. | Intentional transfer, unprepared abandoned-seat recovery, unattended continuation and remaining network-failure acceptance. Preserve privacy and existing games. |
|
||||
| Multiplayer | Versioned writes, saved seats, owner recovery kits, uncertain-request retry, voluntary own-seat AI control and a [voluntary seat handover prototype](SEAT_HANDOVER.md). | Broader handover/disconnection acceptance, unprepared abandoned-seat recovery, unattended continuation and remaining network-failure acceptance. Preserve privacy and existing games. |
|
||||
| Player experience | Internal component inspectors and many desktop/phone feature journeys verified. | Complete end-to-end games, accessible controls, responsive layout, clear rules/errors and recovery across all supported combinations. |
|
||||
|
||||
The [base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md) includes historical missing-feature rows. Its follow-ups supersede the Fremen cancellation and BG setup findings; [genuine setup](ADVANCED_SETUP_TEST_SEAM.md) and the [Advanced study](AI_ADVANCED_SETUP_CALIBRATION_20260907.md) supersede the old Basic-then-flip testing concern. They do not settle the remaining rules.
|
||||
|
||||
Likewise, the [Truthtrance readiness audit](TRUTHTRANCE_NONBATTLE_READINESS_20260907.md) predates [spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card-count facts](TRUTHTRANCE_CARD_COUNT.md) and [Basic shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md). Current `TruthFact` already includes these facts; do not rebuild them. Typed preparation and BG conversion now support the active reserve-shipment scope in base Advanced without Guild or optional modules. Broader facts, earlier-phase promises, remaining Advanced/expansion commitment scope and freeform interpretation remain incomplete.
|
||||
|
||||
The voluntary [seat handover prototype](SEAT_HANDOVER.md) connects private one-time offers, recipient controls, exact saved retry proof and active-token custody fencing. It preserves existing game state and revokes previous owner recovery authority. Full disconnected-seat acceptance remains open. CHOAM Nexus after-victory inspection awaits the [private-window decision](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit); development continues independently.
|
||||
|
||||
## Next milestone: rapid functional coverage
|
||||
|
||||
The first connected batch is the [Ixians & Tleilaxu prototype](IX_PROTOTYPE.md):
|
||||
|
||||
@@ -59,9 +59,9 @@ Normal waiting for a temporarily disconnected player is legitimate; it should no
|
||||
- Seven passing persisted tests cover the scenarios above, not all valid player counts, official module combinations, every sealed interaction, or full release readiness.
|
||||
- No server, engine, or existing test file was modified during this audit. The new test must be included in the coordinating agent's multiplayer test command.
|
||||
|
||||
## Proposed disconnected-seat contract (design only)
|
||||
## Proposed disconnected-seat contract (historical design)
|
||||
|
||||
This proposal changes operational control of a seat, not board-game rules. It is not implemented or included in the passing test count. Preserve the same player ID, faction, resources, hand, traitors, promises, submitted plans, decision ownership and history throughout every transition. Never restart setup or reveal a sealed plan merely because its controller changes.
|
||||
This broader proposal changes operational control of a seat, not board-game rules. Owner recovery and [voluntary handover](SEAT_HANDOVER.md) now have bounded implementations using exact active-token fencing. The separate generation/delegation schema below is historical design and has not been adopted; unattended or host-driven substitution remains unfinished. The passing test count above predates these additions. Preserve the same player ID, faction, resources, hand, traitors, promises, submitted plans, decision ownership and history throughout every transition. Never restart setup or reveal a sealed plan merely because its controller changes.
|
||||
|
||||
### Distinguish three cases
|
||||
|
||||
|
||||
@@ -40,6 +40,44 @@ combinations remain part of the later integration pass.
|
||||
server restart is required. Recovery validates the already-paid outcome and
|
||||
retires the discard; it never credits spice a second time.
|
||||
|
||||
## After-victory inspection audit
|
||||
|
||||
The other printed Secret Ally choice applies only after its holder wins a
|
||||
battle. It inspects exactly one random Treachery Card held by the opposing
|
||||
combatant that the opponent did not use in that battle. Normal and traitor
|
||||
wins qualify. Mutual-traitor and Lasgun-shield explosion results have no winner
|
||||
and do not. “Used” is the physical set already produced by the battle quote for
|
||||
the opponent, including a Cheap Hero, cards played in weapon or defense slots
|
||||
and a late Portable Snooper. A used card remains excluded if another effect
|
||||
later retains it. The effect neither transfers nor discards the inspected card.
|
||||
|
||||
The battle engine must preserve the opposing used-card IDs with the resolved
|
||||
battle event before mandatory loser disposal. The existing Auditor candidate
|
||||
filter is reusable because it removes exact physical used IDs from the current
|
||||
opposing hand. The Nexus effect still needs its own one-card sampler and receipt;
|
||||
it must not borrow Auditor survival, payment or cancellation behavior. A
|
||||
successful use discards the held CHOAM Nexus card once, records the sampled
|
||||
snapshot once and projects the face only to its holder. The public chronicle may
|
||||
name the use, winner and opponent, but not the inspected face.
|
||||
|
||||
The safe continuation point is the existing final `finishBattle` board boundary:
|
||||
after mandatory and optional battle-card cleanup, Moritani retention, battle
|
||||
income, technology, capture, Auditor, Face Dance and Caladan reinforcement, and
|
||||
before another battle or phase begins. No existing choice occurs at that point
|
||||
for every possible winner. Opening a pause only when the winner secretly holds
|
||||
the CHOAM Nexus card would reveal that hidden identity. Precommitting a
|
||||
conditional use before the result would remove the printed after-victory choice.
|
||||
A universal use-or-pass window would preserve secrecy and the full choice, but
|
||||
would also ask winners without the card to pass. The user has been asked which
|
||||
product exception to adopt; no timing policy is implemented while that answer
|
||||
is pending.
|
||||
|
||||
The retrieved Nexus sources neither establish an ordinary Karama response to
|
||||
this Secret Ally play nor grant blanket Nexus immunity. The Collection trade is
|
||||
currently direct and this inspection must not inherit the native Auditor's
|
||||
Karama/payment window by analogy. Cancellation remains an explicit later audit
|
||||
boundary rather than an invented prototype rule.
|
||||
|
||||
## Focused evidence and remaining scope
|
||||
|
||||
Run `npm test -- nexus-choam-trade`. Focused cases cover Basic/Advanced payment,
|
||||
|
||||
@@ -34,6 +34,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
|
||||
| Orgiz collection | User question pending: take one spice per positive board pile collected, or leave the effect pending? The publisher does not clarify stacked Spice Blows. The draft quote remains opt-in and is not enabled by the engine. Contested Cistern income also remains outside the ordinary sole-occupant prototype. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
|
||||
| Jacurutu physical dial | The user question asks whether the losing player should choose the physical normal/elite forces represented by its dial. When legal Advanced allocations produce different rewards, income remains explicitly unpaid; numeric wheel subtraction is not a ruling. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
|
||||
| Testing Station contest/timing | Only sole non-advisor occupation is prototyped. Two-occupant priority/stacking remains unresolved; opening after the ordinary storm-card window is an explicit source-timing inference. Weather Control is excluded by the printed text. | [Discovery interpretations](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
|
||||
| CHOAM Secret Ally battle timing | The printed after-victory inspection is optional and the Nexus identity stays secret. No existing postbattle choice covers every winner: a pause only for a holder leaks ownership, precommitment removes the after-win choice, and a universal window makes nonholders pass. The product-exception question has been sent; no timing policy is implemented pending the answer. Ordinary Karama cancellation is also not established by the retrieved sources. | [After-victory inspection audit](NEXUS_CHOAM_SECRET_ALLY.md#after-victory-inspection-audit) |
|
||||
| Discovery prototype inferences | Free entry uses storm order and ordinary source-storm restrictions; these are general-rule inferences. Great Maker currently follows ordinary first-turn worm skipping and Sandtrout suppression; an exhausted token type creates no replacement token. These preserve current source interpretation and physical inventory but are not separately confirmed rulings or complete combined-module certification. | [Interpretations for refinement](DISCOVERY_PROTOTYPE.md#interpretations-retained-for-refinement) |
|
||||
| Other Homeworld combinations | Population/force splitting, removal custody, replacement shortages, income accounting and placement ordering remain local to their detailed contracts. | [Homeworld rules](HOMEWORLD_RULES.md), [occupation](HOMEWORLD_OCCUPATION_RULES.md) and the effect documents above |
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Voluntary seat handover prototype
|
||||
|
||||
13 September 2026. This is an operational multiplayer feature, independent of
|
||||
board-game rules and mode gates. The current human owner can hand their own seat
|
||||
to another browser with a private one-time kit. No host takeover, inactivity
|
||||
inference or delegation by another player is authorized by this feature.
|
||||
|
||||
## Player flow
|
||||
|
||||
- In the table, **Pass your seat to another player** creates a private offer.
|
||||
Share its complete kit only with the intended recipient. It expires 24 hours
|
||||
after server creation. Ordinary play can continue until acceptance.
|
||||
- **Cancel handover** invalidates the current offer. A confirmed replacement
|
||||
invalidates an older kit. If an owner operation is uncertain, retry it exactly;
|
||||
replacing uncertain work requires an explicit acknowledgement.
|
||||
- The recipient chooses **Accept a seat handover** on the home page, reads the
|
||||
kit and confirms the target room and seat. An active cookie for another seat
|
||||
in that room is rejected; use a separate browser profile.
|
||||
- Acceptance preserves the player ID, faction, name, cards, resources, private
|
||||
commitments, AI-control setting and game history. It revokes all previous
|
||||
browser sessions and recovery authority for that seat. The new owner should
|
||||
create their own recovery kit after acceptance.
|
||||
|
||||
Offers and exact recipient retry proof remain in private tab-scoped browser
|
||||
storage across refresh. Claim proof is written and read back before any request
|
||||
is sent. A dedicated screen retains it while the outcome is unknown and excludes
|
||||
competing create/join/recovery controls. An exact receipt response and a matching
|
||||
cookie-authorized read must both confirm the seat. The room URL is anchored before
|
||||
the proof is removed. Closing the tab or clearing its storage can lose this proof;
|
||||
explicit abandonment explains that it cannot undo a completed claim.
|
||||
|
||||
## Server boundary
|
||||
|
||||
The existing `/api/rooms/:code/control` endpoint supports `createSeatHandover`,
|
||||
`revokeSeatHandover` and `claimSeatHandover`. Only an authenticated seat may issue
|
||||
or revoke its own offer. The server uses 256-bit client secrets, hashed storage,
|
||||
a server expiry and room-version comparisons with active-session checks inside
|
||||
the atomic write. Recovery invalidates an unclaimed offer by revoking its issuing
|
||||
session. Plaintext secrets never enter game JSON, projected views, URLs or history.
|
||||
|
||||
The additive offer and receipt tables preserve existing games and credentials.
|
||||
The second additive migration also copies existing claimed-offer receipts without changing game or seat authority. Claims alter custody and room version while keeping stored game JSON byte-identical.
|
||||
Exact claim retries reissue the same session; creating a later offer does not
|
||||
consume that receipt. A later successful claim or recovery revokes the session
|
||||
and prevents replay. Invalid proofs reveal no private view. Transport retains
|
||||
origin checks, bounded bodies, no-store and room-scoped HttpOnly cookies.
|
||||
|
||||
## Evidence and remaining acceptance
|
||||
|
||||
`tests/seat-handover-client.test.ts` covers kit/proof validation, refresh identity,
|
||||
storage failures and receipt/read confirmation. `tests/seat-handover-store.test.ts`
|
||||
executes production SQL against in-memory SQLite for custody, private saved state,
|
||||
rollback and concurrent ownership changes. `tests/seat-handover-http.test.ts`
|
||||
exercises the real HTTP boundary, exact retries, revoked cookies/recovery,
|
||||
cross-origin rejection and competing-seat protection.
|
||||
|
||||
Required checkpoint results belong in the verified commit and compact report.
|
||||
Browser QA in new room `2EMYG4CY` confirmed offer restoration, acceptance with the same four private traitor choices, refresh, and continued play through starting placement and the initial card deal. The recipient reached Storm with Crysknife, Lady Jessica, ten forces in Arrakeen and ten spice. A follow-up created a recovery key and new offer, recovered the same seat, and verified that the invalidated offer was removed from the owner controls while cards, forces and spice stayed intact. This was a bounded desktop journey; existing human game authority was not used. All 218 opening rooms and their 456 seat, 49 recovery-key, 56 recovery-receipt and 106 entry-receipt records remained unchanged after migration and targeted QA.
|
||||
Full disconnection acceptance across all decisions, unattended continuation,
|
||||
unprepared abandoned-seat recovery and comprehensive hosted acceptance remain
|
||||
unfinished. The historical generation/delegation design in
|
||||
[MULTIPLAYER_AUDIT](MULTIPLAYER_AUDIT.md) remains a broader proposal; current
|
||||
handover uses exact active-token fencing instead of a separate generation field.
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE `seat_handover_offers` (
|
||||
`room_code` text NOT NULL,
|
||||
`player_id` text NOT NULL,
|
||||
`offer_hash` text NOT NULL,
|
||||
`secret_hash` text NOT NULL,
|
||||
`issuer_session_hash` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`claim_operation_hash` text,
|
||||
`session_hash` text,
|
||||
`claimed_at` integer,
|
||||
PRIMARY KEY(`room_code`, `player_id`),
|
||||
FOREIGN KEY (`room_code`) REFERENCES `rooms`(`code`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `seat_handover_offers_offer_hash_unique` ON `seat_handover_offers` (`offer_hash`);
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE `seat_handover_claim_receipts` (
|
||||
`room_code` text NOT NULL,
|
||||
`player_id` text NOT NULL,
|
||||
`operation_hash` text NOT NULL,
|
||||
`offer_hash` text NOT NULL,
|
||||
`secret_hash` text NOT NULL,
|
||||
`session_hash` text NOT NULL,
|
||||
`claim_fence` text NOT NULL,
|
||||
`claimed_at` integer NOT NULL,
|
||||
PRIMARY KEY(`room_code`, `player_id`, `operation_hash`),
|
||||
FOREIGN KEY (`room_code`) REFERENCES `rooms`(`code`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `seat_handover_claim_receipts_claim_fence_unique` ON `seat_handover_claim_receipts` (`claim_fence`);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `seat_handover_claim_receipts` (`room_code`,`player_id`,`operation_hash`,`offer_hash`,`secret_hash`,`session_hash`,`claim_fence`,`claimed_at`)
|
||||
SELECT `room_code`,`player_id`,`claim_operation_hash`,`offer_hash`,`secret_hash`,`session_hash`,'legacy:' || length(`room_code`) || ':' || `room_code` || ':' || length(`player_id`) || ':' || `player_id` || ':' || `claim_operation_hash`,`claimed_at`
|
||||
FROM `seat_handover_offers`
|
||||
WHERE `claim_operation_hash` IS NOT NULL AND `session_hash` IS NOT NULL AND `claimed_at` IS NOT NULL;
|
||||
@@ -0,0 +1,400 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "dcf803b3-13d9-4ec9-8c11-e96778ccad59",
|
||||
"prevId": "8a551145-9fb9-42a4-9273-3cad1043504c",
|
||||
"tables": {
|
||||
"room_entry_receipts": {
|
||||
"name": "room_entry_receipts",
|
||||
"columns": {
|
||||
"operation_hash": {
|
||||
"name": "operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"request_hash": {
|
||||
"name": "request_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_hash": {
|
||||
"name": "session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"room_entry_receipts_room_code_rooms_code_fk": {
|
||||
"name": "room_entry_receipts_room_code_rooms_code_fk",
|
||||
"tableFrom": "room_entry_receipts",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"rooms": {
|
||||
"name": "rooms",
|
||||
"columns": {
|
||||
"code": {
|
||||
"name": "code",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_handover_offers": {
|
||||
"name": "seat_handover_offers",
|
||||
"columns": {
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"offer_hash": {
|
||||
"name": "offer_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"secret_hash": {
|
||||
"name": "secret_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"issuer_session_hash": {
|
||||
"name": "issuer_session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"claim_operation_hash": {
|
||||
"name": "claim_operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_hash": {
|
||||
"name": "session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"claimed_at": {
|
||||
"name": "claimed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"seat_handover_offers_offer_hash_unique": {
|
||||
"name": "seat_handover_offers_offer_hash_unique",
|
||||
"columns": [
|
||||
"offer_hash"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"seat_handover_offers_room_code_rooms_code_fk": {
|
||||
"name": "seat_handover_offers_room_code_rooms_code_fk",
|
||||
"tableFrom": "seat_handover_offers",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"seat_handover_offers_room_code_player_id_pk": {
|
||||
"columns": [
|
||||
"room_code",
|
||||
"player_id"
|
||||
],
|
||||
"name": "seat_handover_offers_room_code_player_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_recovery_keys": {
|
||||
"name": "seat_recovery_keys",
|
||||
"columns": {
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recovery_hash": {
|
||||
"name": "recovery_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"current_operation_hash": {
|
||||
"name": "current_operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"seat_recovery_keys_room_code_rooms_code_fk": {
|
||||
"name": "seat_recovery_keys_room_code_rooms_code_fk",
|
||||
"tableFrom": "seat_recovery_keys",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"seat_recovery_keys_room_code_player_id_pk": {
|
||||
"columns": [
|
||||
"room_code",
|
||||
"player_id"
|
||||
],
|
||||
"name": "seat_recovery_keys_room_code_player_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_recovery_receipts": {
|
||||
"name": "seat_recovery_receipts",
|
||||
"columns": {
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operation_hash": {
|
||||
"name": "operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recovery_hash": {
|
||||
"name": "recovery_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_hash": {
|
||||
"name": "session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"seat_recovery_receipts_room_code_rooms_code_fk": {
|
||||
"name": "seat_recovery_receipts_room_code_rooms_code_fk",
|
||||
"tableFrom": "seat_recovery_receipts",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"seat_recovery_receipts_room_code_player_id_operation_hash_pk": {
|
||||
"columns": [
|
||||
"room_code",
|
||||
"player_id",
|
||||
"operation_hash"
|
||||
],
|
||||
"name": "seat_recovery_receipts_room_code_player_id_operation_hash_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seats": {
|
||||
"name": "seats",
|
||||
"columns": {
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"revoked": {
|
||||
"name": "revoked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"seats_room_code_rooms_code_fk": {
|
||||
"name": "seats_room_code_rooms_code_fk",
|
||||
"tableFrom": "seats",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "db4830ec-5bf2-4dad-b47a-45ebd3bb63b3",
|
||||
"prevId": "dcf803b3-13d9-4ec9-8c11-e96778ccad59",
|
||||
"tables": {
|
||||
"room_entry_receipts": {
|
||||
"name": "room_entry_receipts",
|
||||
"columns": {
|
||||
"operation_hash": {
|
||||
"name": "operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"request_hash": {
|
||||
"name": "request_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_hash": {
|
||||
"name": "session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"room_entry_receipts_room_code_rooms_code_fk": {
|
||||
"name": "room_entry_receipts_room_code_rooms_code_fk",
|
||||
"tableFrom": "room_entry_receipts",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"rooms": {
|
||||
"name": "rooms",
|
||||
"columns": {
|
||||
"code": {
|
||||
"name": "code",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"version": {
|
||||
"name": "version",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_handover_claim_receipts": {
|
||||
"name": "seat_handover_claim_receipts",
|
||||
"columns": {
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operation_hash": {
|
||||
"name": "operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"offer_hash": {
|
||||
"name": "offer_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"secret_hash": {
|
||||
"name": "secret_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_hash": {
|
||||
"name": "session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"claim_fence": {
|
||||
"name": "claim_fence",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"claimed_at": {
|
||||
"name": "claimed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"seat_handover_claim_receipts_claim_fence_unique": {
|
||||
"name": "seat_handover_claim_receipts_claim_fence_unique",
|
||||
"columns": [
|
||||
"claim_fence"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"seat_handover_claim_receipts_room_code_rooms_code_fk": {
|
||||
"name": "seat_handover_claim_receipts_room_code_rooms_code_fk",
|
||||
"tableFrom": "seat_handover_claim_receipts",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"seat_handover_claim_receipts_room_code_player_id_operation_hash_pk": {
|
||||
"columns": [
|
||||
"room_code",
|
||||
"player_id",
|
||||
"operation_hash"
|
||||
],
|
||||
"name": "seat_handover_claim_receipts_room_code_player_id_operation_hash_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_handover_offers": {
|
||||
"name": "seat_handover_offers",
|
||||
"columns": {
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"offer_hash": {
|
||||
"name": "offer_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"secret_hash": {
|
||||
"name": "secret_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"issuer_session_hash": {
|
||||
"name": "issuer_session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"claim_operation_hash": {
|
||||
"name": "claim_operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_hash": {
|
||||
"name": "session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"claimed_at": {
|
||||
"name": "claimed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"seat_handover_offers_offer_hash_unique": {
|
||||
"name": "seat_handover_offers_offer_hash_unique",
|
||||
"columns": [
|
||||
"offer_hash"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"seat_handover_offers_room_code_rooms_code_fk": {
|
||||
"name": "seat_handover_offers_room_code_rooms_code_fk",
|
||||
"tableFrom": "seat_handover_offers",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"seat_handover_offers_room_code_player_id_pk": {
|
||||
"columns": [
|
||||
"room_code",
|
||||
"player_id"
|
||||
],
|
||||
"name": "seat_handover_offers_room_code_player_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_recovery_keys": {
|
||||
"name": "seat_recovery_keys",
|
||||
"columns": {
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recovery_hash": {
|
||||
"name": "recovery_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"current_operation_hash": {
|
||||
"name": "current_operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"seat_recovery_keys_room_code_rooms_code_fk": {
|
||||
"name": "seat_recovery_keys_room_code_rooms_code_fk",
|
||||
"tableFrom": "seat_recovery_keys",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"seat_recovery_keys_room_code_player_id_pk": {
|
||||
"columns": [
|
||||
"room_code",
|
||||
"player_id"
|
||||
],
|
||||
"name": "seat_recovery_keys_room_code_player_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_recovery_receipts": {
|
||||
"name": "seat_recovery_receipts",
|
||||
"columns": {
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"operation_hash": {
|
||||
"name": "operation_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"recovery_hash": {
|
||||
"name": "recovery_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_hash": {
|
||||
"name": "session_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"seat_recovery_receipts_room_code_rooms_code_fk": {
|
||||
"name": "seat_recovery_receipts_room_code_rooms_code_fk",
|
||||
"tableFrom": "seat_recovery_receipts",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"seat_recovery_receipts_room_code_player_id_operation_hash_pk": {
|
||||
"columns": [
|
||||
"room_code",
|
||||
"player_id",
|
||||
"operation_hash"
|
||||
],
|
||||
"name": "seat_recovery_receipts_room_code_player_id_operation_hash_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seats": {
|
||||
"name": "seats",
|
||||
"columns": {
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"revoked": {
|
||||
"name": "revoked",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"room_code": {
|
||||
"name": "room_code",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"player_id": {
|
||||
"name": "player_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"seats_room_code_rooms_code_fk": {
|
||||
"name": "seats_room_code_rooms_code_fk",
|
||||
"tableFrom": "seats",
|
||||
"tableTo": "rooms",
|
||||
"columnsFrom": [
|
||||
"room_code"
|
||||
],
|
||||
"columnsTo": [
|
||||
"code"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,20 @@
|
||||
"when": 1788654062433,
|
||||
"tag": "0003_magical_hawkeye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1789276100598,
|
||||
"tag": "0004_flippant_archangel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1789276284776,
|
||||
"tag": "0005_salty_alice",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -213,12 +213,36 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'automatic-responses',
|
||||
'automatic-decisions',
|
||||
'ai-pacing',
|
||||
'seat-handover',
|
||||
'ecaz-modules',
|
||||
'discoveries',
|
||||
'homeworlds',
|
||||
'advanced-combat',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'seat-handover',
|
||||
title: 'Pass a seat to another player',
|
||||
category: 'Getting started',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'A current human owner can offer their own seat through a private one-time kit without changing the faction or game progress.',
|
||||
steps: [
|
||||
'Open Pass your seat to another player below the table and create a private handover. Send the complete kit only to your intended recipient. It expires after 24 hours; play can continue until acceptance.',
|
||||
'The recipient chooses Accept a seat handover on the home page and confirms the room and seat. Acceptance preserves private cards, resources, decisions and AI-control settings. It revokes the previous owner’s browser sessions and recovery kits. The recipient should protect their seat with a new recovery kit.',
|
||||
'An owner may cancel or replace an offer. A confirmed replacement invalidates the old kit. Recovering the issuing seat also invalidates its pending offer. A browser already controlling another seat in that room must use a separate browser profile.',
|
||||
'Keep the tab open after an uncertain result and retry the same request. Private retry details survive refresh in that tab. They are removed after both acceptance and the new browser session are confirmed. Closing the tab or abandoning its details can lose access to a completed transfer.',
|
||||
'Host status and inactivity do not authorize taking another player’s seat. Broader disconnected-seat and hosted acceptance remains unfinished.',
|
||||
],
|
||||
related: ['privacy', 'ai-players', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Implemented', detail: 'Bounded voluntary owner issue/cancel/replace and one-time recipient claim with persistent private custody and exact retry receipts.' },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'Private owner kit controls and a separate recipient acceptance/retry screen, including refresh continuation and deliberate abandonment.' },
|
||||
{ area: 'AI', status: 'Implemented', detail: 'Transfer preserves the human seat’s existing autopilot setting; bots cannot issue or claim someone else’s handover.' },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'This guide explains expiry, revoked access, retry proof and the need for a new owner recovery kit.' },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused private state, custody, concurrency, HTTP and browser checks. Complete network-failure acceptance remains open.', evidence: ['tests/seat-handover-client.test.ts', 'tests/seat-handover-store.test.ts', 'tests/seat-handover-http.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'richese-cards',
|
||||
title: 'Richese card collection',
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { GameView } from '../game/engine';
|
||||
|
||||
export const HANDOVER_CLAIM_STORAGE_KEY = 'dune.pending-seat-handover.v1';
|
||||
export const handoverOwnerStorageKey = (room: string, player: string) =>
|
||||
`dune.handover-offer.v1.${room}.${player}`;
|
||||
/** Confirmed owner recovery revokes the issuer; its old kit must not remain advertised. */
|
||||
export function clearHandoverOwner(
|
||||
storage: Pick<Storage, 'getItem' | 'removeItem'>,
|
||||
room: string,
|
||||
player: string,
|
||||
) {
|
||||
const key = handoverOwnerStorageKey(room, player);
|
||||
storage.removeItem(key);
|
||||
if (storage.getItem(key) !== null)
|
||||
throw new Error(
|
||||
'The invalidated handover kit could not be removed from this tab. Retry recovery to confirm cleanup.',
|
||||
);
|
||||
}
|
||||
const ROOM = /^[A-Z2-9]{8}$/;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const SECRET = /^[0-9a-f]{64}$/;
|
||||
const invalid = () =>
|
||||
new Error(
|
||||
'Paste the complete original Dune handover kit. Its format or identifiers are invalid.',
|
||||
);
|
||||
const secret = () =>
|
||||
Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) =>
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
!!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const keys = (value: Record<string, unknown>, names: string[]) =>
|
||||
Object.keys(value).length === names.length &&
|
||||
names.every((name) => Object.hasOwn(value, name));
|
||||
|
||||
export type SeatHandoverKit = Readonly<{
|
||||
format: 'dune-seat-handover';
|
||||
version: 1;
|
||||
roomCode: string;
|
||||
playerId: string;
|
||||
offerId: string;
|
||||
handoverSecret: string;
|
||||
}>;
|
||||
export type SeatHandoverAttempt = Readonly<{
|
||||
type: 'claimSeatHandover';
|
||||
playerId: string;
|
||||
offerId: string;
|
||||
handoverSecret: string;
|
||||
operationId: string;
|
||||
newSessionToken: string;
|
||||
}>;
|
||||
export type SavedHandoverClaim = Readonly<{
|
||||
kit: SeatHandoverKit;
|
||||
attempt: SeatHandoverAttempt;
|
||||
}>;
|
||||
|
||||
export function parseHandoverKit(text: string): SeatHandoverKit {
|
||||
if (text.length > 4096) throw invalid();
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
throw invalid();
|
||||
}
|
||||
if (
|
||||
!object(value) ||
|
||||
!keys(value, [
|
||||
'format',
|
||||
'version',
|
||||
'roomCode',
|
||||
'playerId',
|
||||
'offerId',
|
||||
'handoverSecret',
|
||||
]) ||
|
||||
value.format !== 'dune-seat-handover' ||
|
||||
value.version !== 1 ||
|
||||
typeof value.roomCode !== 'string' ||
|
||||
!ROOM.test(value.roomCode) ||
|
||||
typeof value.playerId !== 'string' ||
|
||||
!UUID.test(value.playerId) ||
|
||||
typeof value.offerId !== 'string' ||
|
||||
!UUID.test(value.offerId) ||
|
||||
typeof value.handoverSecret !== 'string' ||
|
||||
!SECRET.test(value.handoverSecret)
|
||||
)
|
||||
throw invalid();
|
||||
return Object.freeze(value as SeatHandoverKit);
|
||||
}
|
||||
export const serializeHandoverKit = (kit: SeatHandoverKit) =>
|
||||
JSON.stringify(kit, null, 2);
|
||||
export function createHandoverKit(
|
||||
roomCode: string,
|
||||
playerId: string,
|
||||
): SeatHandoverKit {
|
||||
return parseHandoverKit(
|
||||
JSON.stringify({
|
||||
format: 'dune-seat-handover',
|
||||
version: 1,
|
||||
roomCode,
|
||||
playerId,
|
||||
offerId: crypto.randomUUID(),
|
||||
handoverSecret: secret(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
export function createHandoverClaim(kit: SeatHandoverKit): SavedHandoverClaim {
|
||||
return Object.freeze({
|
||||
kit,
|
||||
attempt: Object.freeze({
|
||||
type: 'claimSeatHandover',
|
||||
playerId: kit.playerId,
|
||||
offerId: kit.offerId,
|
||||
handoverSecret: kit.handoverSecret,
|
||||
operationId: crypto.randomUUID(),
|
||||
newSessionToken: secret(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
export function parseHandoverClaim(text: string): SavedHandoverClaim {
|
||||
if (text.length > 4096) throw invalid();
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
throw invalid();
|
||||
}
|
||||
if (!object(value) || !keys(value, ['kit', 'attempt'])) throw invalid();
|
||||
const kit = parseHandoverKit(JSON.stringify(value.kit));
|
||||
const a = value.attempt;
|
||||
if (
|
||||
!object(a) ||
|
||||
!keys(a, [
|
||||
'type',
|
||||
'playerId',
|
||||
'offerId',
|
||||
'handoverSecret',
|
||||
'operationId',
|
||||
'newSessionToken',
|
||||
]) ||
|
||||
a.type !== 'claimSeatHandover' ||
|
||||
a.playerId !== kit.playerId ||
|
||||
a.offerId !== kit.offerId ||
|
||||
a.handoverSecret !== kit.handoverSecret ||
|
||||
typeof a.operationId !== 'string' ||
|
||||
!UUID.test(a.operationId) ||
|
||||
typeof a.newSessionToken !== 'string' ||
|
||||
!SECRET.test(a.newSessionToken) ||
|
||||
a.newSessionToken === kit.handoverSecret
|
||||
)
|
||||
throw invalid();
|
||||
return Object.freeze({
|
||||
kit,
|
||||
attempt: Object.freeze(a as SeatHandoverAttempt),
|
||||
});
|
||||
}
|
||||
export function saveHandoverClaim(
|
||||
storage: Pick<Storage, 'getItem' | 'setItem'>,
|
||||
claim: SavedHandoverClaim,
|
||||
) {
|
||||
const text = JSON.stringify(claim),
|
||||
existing = storage.getItem(HANDOVER_CLAIM_STORAGE_KEY);
|
||||
if (existing !== null && existing !== text)
|
||||
throw new Error(
|
||||
'Resolve the saved handover request before starting another.',
|
||||
);
|
||||
storage.setItem(HANDOVER_CLAIM_STORAGE_KEY, text);
|
||||
if (storage.getItem(HANDOVER_CLAIM_STORAGE_KEY) !== text)
|
||||
throw new Error(
|
||||
'The retry details could not be saved. No handover request was sent.',
|
||||
);
|
||||
}
|
||||
export function clearHandoverClaim(
|
||||
storage: Pick<Storage, 'getItem' | 'removeItem'>,
|
||||
) {
|
||||
storage.removeItem(HANDOVER_CLAIM_STORAGE_KEY);
|
||||
if (storage.getItem(HANDOVER_CLAIM_STORAGE_KEY) !== null)
|
||||
throw new Error(
|
||||
'The saved handover details could not be removed. Keep this tab open.',
|
||||
);
|
||||
}
|
||||
/** Confirm both the claim receipt and the cookie-authorized follow-up read. */
|
||||
export function handoverClaimConfirmed(
|
||||
claim: SavedHandoverClaim,
|
||||
response: unknown,
|
||||
read: unknown,
|
||||
): read is GameView {
|
||||
const seat = (value: unknown) =>
|
||||
object(value) &&
|
||||
value.code === claim.kit.roomCode &&
|
||||
value.me === claim.kit.playerId &&
|
||||
Number.isSafeInteger(value.version) &&
|
||||
(value.version as number) >= 0 &&
|
||||
Array.isArray(value.players) &&
|
||||
value.players.some(
|
||||
(player) => object(player) && player.id === claim.kit.playerId,
|
||||
);
|
||||
return (
|
||||
object(response) &&
|
||||
response.transferred === true &&
|
||||
typeof response.replayed === 'boolean' &&
|
||||
seat(response.view) &&
|
||||
seat(read) &&
|
||||
(read as GameView).version >= (response.view as GameView).version
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,16 @@ import * as bots from '../game/bots';
|
||||
import type * as Rooms from '../db/rooms';
|
||||
|
||||
/** Execute the production room module and SQL, with a hook immediately before its CAS. */
|
||||
export function unitStore(runBots = bots.runBots) {
|
||||
export function unitStore(runBots = bots.runBots, migrationThrough?: string) {
|
||||
const sqlite = new DatabaseSync(':memory:');
|
||||
const hooks: { beforeWrite?: () => Promise<void> } = {};
|
||||
const hooks: {
|
||||
beforeWrite?: () => Promise<void>;
|
||||
beforeBatch?: () => Promise<void>;
|
||||
} = {};
|
||||
const writes: { expected: number; changes: number }[] = [];
|
||||
for (const file of readdirSync(new URL('../drizzle/', import.meta.url))
|
||||
.filter((f) => f.endsWith('.sql'))
|
||||
.filter((f) => !migrationThrough || f <= migrationThrough)
|
||||
.sort())
|
||||
sqlite.exec(
|
||||
readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'),
|
||||
@@ -42,6 +46,7 @@ export function unitStore(runBots = bots.runBots) {
|
||||
const database = {
|
||||
prepare: (sql: string) => new Statement(sql),
|
||||
batch: async (statements: Statement[]) => {
|
||||
await hooks.beforeBatch?.();
|
||||
sqlite.exec('BEGIN');
|
||||
try {
|
||||
const results = [];
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
HANDOVER_CLAIM_STORAGE_KEY,
|
||||
clearHandoverClaim,
|
||||
clearHandoverOwner,
|
||||
createHandoverClaim,
|
||||
createHandoverKit,
|
||||
handoverClaimConfirmed,
|
||||
handoverOwnerStorageKey,
|
||||
parseHandoverClaim,
|
||||
parseHandoverKit,
|
||||
saveHandoverClaim,
|
||||
serializeHandoverKit,
|
||||
} from '../lib/seat-handover';
|
||||
const player = '93c66e7c-2d3b-4845-aedf-5d1f06cf3371';
|
||||
const kit = () => createHandoverKit('ABCD2345', player);
|
||||
const storage = () => {
|
||||
const entries = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => entries.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
entries.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
entries.delete(key);
|
||||
},
|
||||
};
|
||||
};
|
||||
void test('confirmed recovery removes only the invalidated owner offer and verifies cleanup before restoring the UI', () => {
|
||||
const saved = storage(),
|
||||
key = handoverOwnerStorageKey('ABCD2345', player);
|
||||
const other = handoverOwnerStorageKey('BCDE2345', player);
|
||||
saved.setItem(key, 'old private offer');
|
||||
saved.setItem(other, 'another room offer');
|
||||
saved.setItem(HANDOVER_CLAIM_STORAGE_KEY, 'unrelated pending claim');
|
||||
clearHandoverOwner(saved, 'ABCD2345', player);
|
||||
assert.equal(saved.getItem(key), null);
|
||||
assert.equal(saved.getItem(other), 'another room offer');
|
||||
assert.equal(
|
||||
saved.getItem(HANDOVER_CLAIM_STORAGE_KEY),
|
||||
'unrelated pending claim',
|
||||
);
|
||||
assert.throws(() =>
|
||||
clearHandoverOwner(
|
||||
{ getItem: () => 'old private offer', removeItem() {} },
|
||||
'ABCD2345',
|
||||
player,
|
||||
),
|
||||
);
|
||||
});
|
||||
void test('handover kits have fresh secure one-time offer identities and strict round-trip format', () => {
|
||||
const a = kit(),
|
||||
b = kit();
|
||||
assert.deepEqual(parseHandoverKit(serializeHandoverKit(a)), a);
|
||||
assert.notEqual(a.offerId, b.offerId);
|
||||
assert.notEqual(a.handoverSecret, b.handoverSecret);
|
||||
assert.match(a.handoverSecret, /^[0-9a-f]{64}$/);
|
||||
assert.ok(Object.isFrozen(a));
|
||||
});
|
||||
void test('kits reject substituted recovery formats, secret-bearing URLs and injected fields without echoing inputs', () => {
|
||||
const a = kit();
|
||||
for (const text of [
|
||||
'null',
|
||||
'[]',
|
||||
'{',
|
||||
'x'.repeat(4097),
|
||||
...[
|
||||
{ ...a, format: 'dune-seat-recovery' },
|
||||
{ ...a, roomCode: 'https://evil.test' },
|
||||
{ ...a, playerId: 'host' },
|
||||
{ ...a, handoverSecret: 'secret-to-not-echo' },
|
||||
{ ...a, newSessionToken: 'ff'.repeat(32) },
|
||||
{ ...a, version: 2 },
|
||||
].map((value) => JSON.stringify(value)),
|
||||
]) {
|
||||
assert.throws(
|
||||
() => parseHandoverKit(text),
|
||||
(error) =>
|
||||
error instanceof Error && !error.message.includes('secret-to-not-echo'),
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('refresh restores exactly the same claim proof and prevents a second claim from replacing it', () => {
|
||||
const a = createHandoverClaim(kit()),
|
||||
saved = storage();
|
||||
saveHandoverClaim(saved, a);
|
||||
const restored = parseHandoverClaim(
|
||||
saved.getItem(HANDOVER_CLAIM_STORAGE_KEY)!,
|
||||
);
|
||||
assert.deepEqual(restored, a);
|
||||
assert.equal(JSON.stringify(restored.attempt), JSON.stringify(a.attempt));
|
||||
assert.ok(Object.isFrozen(restored.attempt));
|
||||
assert.notEqual(a.attempt.newSessionToken, a.kit.handoverSecret);
|
||||
saveHandoverClaim(saved, restored);
|
||||
assert.throws(() => saveHandoverClaim(saved, createHandoverClaim(a.kit)));
|
||||
assert.deepEqual(
|
||||
parseHandoverClaim(saved.getItem(HANDOVER_CLAIM_STORAGE_KEY)!),
|
||||
a,
|
||||
);
|
||||
clearHandoverClaim(saved);
|
||||
assert.equal(saved.getItem(HANDOVER_CLAIM_STORAGE_KEY), null);
|
||||
});
|
||||
void test('saved claim validation rejects altered bindings, reused secrets and unexpected authority fields', () => {
|
||||
const a = createHandoverClaim(kit());
|
||||
for (const value of [
|
||||
{ ...a, extra: true },
|
||||
{ ...a, attempt: { ...a.attempt, playerId: crypto.randomUUID() } },
|
||||
{ ...a, attempt: { ...a.attempt, offerId: crypto.randomUUID() } },
|
||||
{ ...a, attempt: { ...a.attempt, newSessionToken: a.kit.handoverSecret } },
|
||||
{ ...a, attempt: { ...a.attempt, operationId: 'again' } },
|
||||
{ ...a, attempt: { ...a.attempt, host: true } },
|
||||
])
|
||||
assert.throws(() => parseHandoverClaim(JSON.stringify(value)));
|
||||
});
|
||||
void test('unavailable or silently dropping browser storage cannot confirm saved or removed retry proof', () => {
|
||||
const a = createHandoverClaim(kit());
|
||||
assert.throws(() =>
|
||||
saveHandoverClaim({ getItem: () => null, setItem() {} }, a),
|
||||
);
|
||||
assert.throws(() =>
|
||||
saveHandoverClaim(
|
||||
{
|
||||
getItem: () => null,
|
||||
setItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
},
|
||||
a,
|
||||
),
|
||||
);
|
||||
assert.throws(() =>
|
||||
clearHandoverClaim({ getItem: () => JSON.stringify(a), removeItem() {} }),
|
||||
);
|
||||
});
|
||||
void test('a claimed seat is confirmed only after receipt and cookie-authorized read both identify the intended seat', () => {
|
||||
const a = createHandoverClaim(kit());
|
||||
const view = {
|
||||
code: a.kit.roomCode,
|
||||
me: player,
|
||||
version: 4,
|
||||
players: [{ id: player }],
|
||||
};
|
||||
const response = { view, transferred: true, replayed: false };
|
||||
assert.ok(handoverClaimConfirmed(a, response, view));
|
||||
assert.ok(
|
||||
handoverClaimConfirmed(
|
||||
a,
|
||||
{ ...response, replayed: true },
|
||||
{ ...view, version: 9 },
|
||||
),
|
||||
);
|
||||
assert.equal(handoverClaimConfirmed(a, { view }, view), false);
|
||||
assert.equal(
|
||||
handoverClaimConfirmed(a, response, { ...view, me: crypto.randomUUID() }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
handoverClaimConfirmed(
|
||||
a,
|
||||
{ ...response, view: { ...view, code: 'BCDE2345' } },
|
||||
view,
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
handoverClaimConfirmed(a, response, { ...view, players: [] }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
handoverClaimConfirmed(a, response, { ...view, version: 3 }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
// @dune-suite integration
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { GameView } from '../game/engine';
|
||||
import { createHandoverKit, createHandoverClaim } from '../lib/seat-handover';
|
||||
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||
const secret = () =>
|
||||
crypto.randomUUID().replaceAll('-', '') +
|
||||
crypto.randomUUID().replaceAll('-', '');
|
||||
async function request(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
cookie?: string,
|
||||
origin?: string,
|
||||
) {
|
||||
const response = await fetch(base + path, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: {
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(origin ? { origin } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
const raw = await response.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = { error: 'Non-JSON response' };
|
||||
}
|
||||
const data = parsed as GameView & {
|
||||
view: GameView;
|
||||
error?: string;
|
||||
code: string;
|
||||
replayed?: boolean;
|
||||
transferred?: boolean;
|
||||
expiresAt?: number;
|
||||
revoked?: boolean;
|
||||
};
|
||||
return {
|
||||
status: response.status,
|
||||
data,
|
||||
cookie: response.headers.get('set-cookie')?.split(';')[0],
|
||||
cookieHeader: response.headers.get('set-cookie'),
|
||||
cache: response.headers.get('cache-control'),
|
||||
};
|
||||
}
|
||||
async function create() {
|
||||
const result = await request('/api/rooms', {
|
||||
name: 'Handover HTTP QA',
|
||||
faction: 'atreides',
|
||||
expansions: [],
|
||||
advanced: false,
|
||||
});
|
||||
assert.equal(result.status, 201);
|
||||
assert.ok(result.cookie);
|
||||
return { ...result, path: `/api/rooms/${result.data.code}` };
|
||||
}
|
||||
const sameGame = (a: GameView, b: GameView) =>
|
||||
assert.deepEqual({ ...a, version: 0 }, { ...b, version: 0 });
|
||||
void test('HTTP owner handover preserves seat, invalidates old browser/recovery and confirms exact claim retry', async () => {
|
||||
const a = await create(),
|
||||
recoverySecret = secret();
|
||||
const saved = await request(
|
||||
a.path + '/control',
|
||||
{ type: 'setRecoveryKey', version: a.data.version, recoverySecret },
|
||||
a.cookie,
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
const kit = createHandoverKit(a.data.code, a.data.me);
|
||||
const body = {
|
||||
type: 'createSeatHandover',
|
||||
version: saved.data.view.version,
|
||||
offerId: kit.offerId,
|
||||
handoverSecret: kit.handoverSecret,
|
||||
};
|
||||
const offered = await request(a.path + '/control', body, a.cookie);
|
||||
assert.equal(offered.status, 200);
|
||||
assert.ok(offered.data.expiresAt! > Date.now());
|
||||
sameGame(offered.data.view, saved.data.view);
|
||||
const retriedOffer = await request(a.path + '/control', body, a.cookie);
|
||||
assert.equal(retriedOffer.status, 200);
|
||||
assert.equal(retriedOffer.data.replayed, true);
|
||||
assert.equal(retriedOffer.data.view.version, offered.data.view.version);
|
||||
const attempt = createHandoverClaim(kit).attempt;
|
||||
const claimed = await request(a.path + '/control', attempt);
|
||||
assert.equal(claimed.status, 200);
|
||||
assert.equal(claimed.data.transferred, true);
|
||||
assert.ok(claimed.cookieHeader?.includes('HttpOnly'));
|
||||
assert.ok(claimed.cookieHeader?.includes('SameSite=Strict'));
|
||||
assert.ok(claimed.cookieHeader?.includes(`Path=${a.path}`));
|
||||
assert.equal(claimed.cache, 'no-store');
|
||||
sameGame(claimed.data.view, offered.data.view);
|
||||
assert.equal((await request(a.path, undefined, a.cookie)).status, 409);
|
||||
assert.equal(
|
||||
(
|
||||
await request(a.path + '/control', {
|
||||
type: 'recoverSeat',
|
||||
playerId: a.data.me,
|
||||
recoverySecret,
|
||||
operationId: crypto.randomUUID(),
|
||||
newSessionToken: secret(),
|
||||
})
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
const replay = await request(a.path + '/control', attempt);
|
||||
assert.equal(replay.status, 200);
|
||||
assert.equal(replay.cookie, claimed.cookie);
|
||||
assert.equal(replay.data.replayed, true);
|
||||
assert.equal(replay.data.view.version, claimed.data.view.version);
|
||||
const read = await request(a.path, undefined, replay.cookie);
|
||||
assert.equal(read.status, 200);
|
||||
sameGame(read.data, claimed.data.view);
|
||||
assert.equal(JSON.stringify(read.data).includes(kit.handoverSecret), false);
|
||||
assert.equal(JSON.stringify(read.data).includes(kit.offerId), false);
|
||||
assert.equal(
|
||||
(await request(a.path + '/control', createHandoverClaim(kit).attempt))
|
||||
.status,
|
||||
409,
|
||||
);
|
||||
});
|
||||
void test('HTTP handover rejects another active seat cookie, wrong secrets and cross-origin writes without changing ownership', async () => {
|
||||
const a = await create();
|
||||
const other = await request(a.path, {
|
||||
type: 'join',
|
||||
name: 'Other handover seat',
|
||||
faction: 'harkonnen',
|
||||
});
|
||||
assert.equal(other.status, 200);
|
||||
const kit = createHandoverKit(a.data.code, a.data.me);
|
||||
const offer = {
|
||||
type: 'createSeatHandover',
|
||||
version: other.data.version,
|
||||
offerId: kit.offerId,
|
||||
handoverSecret: kit.handoverSecret,
|
||||
};
|
||||
assert.equal((await request(a.path + '/control', offer)).status, 409);
|
||||
assert.equal(
|
||||
(await request(a.path + '/control', offer, a.cookie, 'https://other.test'))
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
const made = await request(a.path + '/control', offer, a.cookie);
|
||||
assert.equal(made.status, 200);
|
||||
const attempt = createHandoverClaim(kit).attempt;
|
||||
const wrongSeat = await request(a.path + '/control', attempt, other.cookie);
|
||||
assert.equal(wrongSeat.status, 409);
|
||||
assert.equal(wrongSeat.data.code, 'ALREADY_SEATED');
|
||||
const bad = await request(a.path + '/control', {
|
||||
...attempt,
|
||||
handoverSecret: secret(),
|
||||
});
|
||||
assert.equal(bad.status, 409);
|
||||
assert.equal(bad.data.view, undefined);
|
||||
assert.equal((await request(a.path, undefined, a.cookie)).status, 200);
|
||||
const cancelled = await request(
|
||||
a.path + '/control',
|
||||
{
|
||||
type: 'revokeSeatHandover',
|
||||
version: made.data.view.version,
|
||||
offerId: kit.offerId,
|
||||
},
|
||||
a.cookie,
|
||||
);
|
||||
assert.equal(cancelled.status, 200);
|
||||
assert.equal(cancelled.data.revoked, true);
|
||||
assert.equal((await request(a.path + '/control', attempt)).status, 409);
|
||||
});
|
||||
void test('HTTP simultaneous identical handover retries establish one credential and one ownership change', async () => {
|
||||
const a = await create(),
|
||||
kit = createHandoverKit(a.data.code, a.data.me);
|
||||
const offered = await request(
|
||||
a.path + '/control',
|
||||
{
|
||||
type: 'createSeatHandover',
|
||||
version: a.data.version,
|
||||
offerId: kit.offerId,
|
||||
handoverSecret: kit.handoverSecret,
|
||||
},
|
||||
a.cookie,
|
||||
);
|
||||
assert.equal(offered.status, 200);
|
||||
const attempt = createHandoverClaim(kit).attempt;
|
||||
const results = await Promise.all([
|
||||
request(a.path + '/control', attempt),
|
||||
request(a.path + '/control', attempt),
|
||||
]);
|
||||
assert.ok(results.some((result) => result.status === 200));
|
||||
const confirmed = await request(a.path + '/control', attempt);
|
||||
assert.equal(confirmed.status, 200);
|
||||
assert.equal(confirmed.data.view.version, offered.data.view.version + 1);
|
||||
for (const result of results.filter((value) => value.status === 200))
|
||||
assert.equal(result.cookie, confirmed.cookie);
|
||||
assert.equal(
|
||||
(await request(a.path, undefined, confirmed.cookie)).status,
|
||||
200,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,768 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const UUID = {
|
||||
entry: '10000000-0000-4000-8000-000000000001',
|
||||
recovery: '10000000-0000-4000-8000-000000000002',
|
||||
offer: '10000000-0000-4000-8000-000000000003',
|
||||
replacement: '10000000-0000-4000-8000-000000000004',
|
||||
claim: '10000000-0000-4000-8000-000000000005',
|
||||
rival: '10000000-0000-4000-8000-000000000006',
|
||||
};
|
||||
const secret = (pair: string) => pair.repeat(32);
|
||||
const digest = (value: string) =>
|
||||
createHash('sha256').update(value).digest('hex');
|
||||
const clock = (now: number) => ({ now: () => now });
|
||||
|
||||
async function fixture(
|
||||
withRecoveryRotation = false,
|
||||
migrationThrough?: string,
|
||||
) {
|
||||
const store = unitStore(undefined, migrationThrough);
|
||||
const entry = {
|
||||
operationId: UUID.entry,
|
||||
sessionToken: secret('10'),
|
||||
};
|
||||
const created = await store.rooms.createRoom(
|
||||
'Original owner',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
entry,
|
||||
);
|
||||
const code = created.view.code;
|
||||
let auth = await store.rooms.authenticate(code, created.token);
|
||||
const game = await store.rooms.readRoom(code);
|
||||
game.players[0].hand = [baseDeck()[0]];
|
||||
game.players[0].traitors = ['emperor-0'];
|
||||
store.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), code);
|
||||
let view = await store.rooms.readSeatView(code, auth);
|
||||
const recoverySecret = secret('20');
|
||||
let recoveryInput:
|
||||
| {
|
||||
playerId: string;
|
||||
recoverySecret: string;
|
||||
operationId: string;
|
||||
newSessionToken: string;
|
||||
}
|
||||
| undefined;
|
||||
if (withRecoveryRotation) {
|
||||
view = (
|
||||
await store.rooms.setRecoveryKey(
|
||||
code,
|
||||
auth,
|
||||
view.version,
|
||||
recoverySecret,
|
||||
)
|
||||
).view;
|
||||
recoveryInput = {
|
||||
playerId: created.view.me,
|
||||
recoverySecret,
|
||||
operationId: UUID.recovery,
|
||||
newSessionToken: secret('30'),
|
||||
};
|
||||
const recovered = await store.rooms.recoverSeat(code, recoveryInput);
|
||||
view = recovered.view;
|
||||
auth = await store.rooms.authenticate(code, recoveryInput.newSessionToken);
|
||||
}
|
||||
return {
|
||||
...store,
|
||||
code,
|
||||
created,
|
||||
entry,
|
||||
auth,
|
||||
view,
|
||||
recoverySecret,
|
||||
recoveryInput,
|
||||
};
|
||||
}
|
||||
|
||||
const offer = (id = UUID.offer, handoverSecret = secret('40')) => ({
|
||||
offerId: id,
|
||||
handoverSecret,
|
||||
});
|
||||
const claim = (
|
||||
playerId: string,
|
||||
operationId = UUID.claim,
|
||||
newSessionToken = secret('50'),
|
||||
handover = offer(),
|
||||
) => ({
|
||||
playerId,
|
||||
...handover,
|
||||
operationId,
|
||||
newSessionToken,
|
||||
});
|
||||
|
||||
function custody(sqlite: DatabaseSync) {
|
||||
return JSON.stringify(
|
||||
[
|
||||
'rooms',
|
||||
'seats',
|
||||
'seat_recovery_keys',
|
||||
'seat_recovery_receipts',
|
||||
'room_entry_receipts',
|
||||
'seat_handover_offers',
|
||||
'seat_handover_claim_receipts',
|
||||
].map((table) =>
|
||||
sqlite.prepare(`SELECT * FROM ${table} ORDER BY rowid`).all(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function batchBarrier(hooks: { beforeBatch?: () => Promise<void> }) {
|
||||
let entered!: () => void;
|
||||
let release!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
hooks.beforeBatch = async () => {
|
||||
delete hooks.beforeBatch;
|
||||
entered();
|
||||
await gate;
|
||||
};
|
||||
return { waiting, release };
|
||||
}
|
||||
|
||||
void test('voluntary claim preserves exact game state and private seat while revoking every old authority', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
const handover = offer();
|
||||
const created = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
handover,
|
||||
clock(1000),
|
||||
);
|
||||
assert.equal(created.expiresAt, 1000 + f.rooms.SEAT_HANDOVER_TTL_MS);
|
||||
assert.equal(created.replayed, false);
|
||||
const state = f.sqlite
|
||||
.prepare('SELECT state FROM rooms WHERE code = ?')
|
||||
.get(f.code)!.state;
|
||||
const body = claim(f.created.view.me);
|
||||
const transferred = await f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
body,
|
||||
clock(2000),
|
||||
);
|
||||
assert.equal(transferred.transferred, true);
|
||||
assert.equal(transferred.replayed, false);
|
||||
assert.equal(transferred.view.me, f.created.view.me);
|
||||
assert.equal(transferred.view.players[0].faction, 'atreides');
|
||||
assert.equal(transferred.view.players[0].hand![0].id, baseDeck()[0].id);
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT state FROM rooms WHERE code = ?').get(f.code)!
|
||||
.state,
|
||||
state,
|
||||
);
|
||||
await assert.rejects(f.rooms.readSeatView(f.code, f.auth), /verified/);
|
||||
await assert.rejects(
|
||||
f.rooms.recoverSeat(f.code, f.recoveryInput!),
|
||||
/could not be verified|no longer active/,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.createRoom(
|
||||
'Original owner',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
f.entry,
|
||||
),
|
||||
/no longer active/,
|
||||
);
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT count(*) AS n FROM seat_recovery_keys').get()!
|
||||
.n,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
f.sqlite
|
||||
.prepare('SELECT count(*) AS n FROM seat_recovery_receipts')
|
||||
.get()!.n,
|
||||
0,
|
||||
);
|
||||
const recipientAuth = await f.rooms.authenticate(
|
||||
f.code,
|
||||
body.newSessionToken,
|
||||
);
|
||||
const acted = await f.rooms.act(
|
||||
f.code,
|
||||
recipientAuth,
|
||||
transferred.view.version,
|
||||
{ type: 'ready' },
|
||||
);
|
||||
assert.equal(acted.players[0].ready, true);
|
||||
const restarted = f.restart();
|
||||
const replay = await restarted.claimSeatHandover(
|
||||
f.code,
|
||||
body,
|
||||
clock(1000 + f.rooms.SEAT_HANDOVER_TTL_MS + 1),
|
||||
);
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.token, body.newSessionToken);
|
||||
assert.equal(replay.view.version, acted.version);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('recovering the issuer invalidates its still-unclaimed handover offer', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const recoverySecret = secret('21');
|
||||
const saved = await f.rooms.setRecoveryKey(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
recoverySecret,
|
||||
);
|
||||
const handover = offer();
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
saved.view.version,
|
||||
handover,
|
||||
clock(1000),
|
||||
);
|
||||
const recovery = {
|
||||
playerId: f.created.view.me,
|
||||
recoverySecret,
|
||||
operationId: UUID.recovery,
|
||||
newSessionToken: secret('31'),
|
||||
};
|
||||
const recovered = await f.rooms.recoverSeat(f.code, recovery);
|
||||
await assert.rejects(
|
||||
f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
),
|
||||
/could not be verified/,
|
||||
);
|
||||
const newAuth = await f.rooms.authenticate(
|
||||
f.code,
|
||||
recovery.newSessionToken,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await f.rooms.readSeatView(f.code, newAuth),
|
||||
recovered.view,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.rooms.readRoom(f.code)).version,
|
||||
offered.view.version + 1,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('claim invalidates the issuer entry receipt that still controlled the seat', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
await f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.createRoom(
|
||||
'Original owner',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
f.entry,
|
||||
),
|
||||
/no longer active/,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.rooms.readRoom(f.code)).version,
|
||||
offered.view.version + 1,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('0005 backfills an active 0004 claim for exact replay after upgrade', async () => {
|
||||
const f = await fixture(false, '0004_flippant_archangel.sql');
|
||||
try {
|
||||
const body = claim(f.created.view.me);
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const operationHash = digest(body.operationId);
|
||||
const sessionHash = digest(body.newSessionToken);
|
||||
f.sqlite.exec('BEGIN');
|
||||
try {
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'UPDATE rooms SET version = version + 1, updated_at = ? WHERE code = ? AND version = ?',
|
||||
)
|
||||
.run(2000, f.code, offered.view.version);
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'UPDATE seat_handover_offers SET claim_operation_hash = ?, session_hash = ?, claimed_at = ? WHERE room_code = ? AND player_id = ?',
|
||||
)
|
||||
.run(operationHash, sessionHash, 2000, f.code, f.created.view.me);
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'UPDATE seats SET revoked = 1 WHERE room_code = ? AND player_id = ? AND revoked = 0',
|
||||
)
|
||||
.run(f.code, f.created.view.me);
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seats(token_hash,room_code,player_id) VALUES (?,?,?)',
|
||||
)
|
||||
.run(sessionHash, f.code, f.created.view.me);
|
||||
f.sqlite.exec('COMMIT');
|
||||
} catch (error) {
|
||||
f.sqlite.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
const stateBeforeUpgrade = f.sqlite
|
||||
.prepare('SELECT state FROM rooms WHERE code = ?')
|
||||
.get(f.code)!.state;
|
||||
f.sqlite.exec(
|
||||
readFileSync(
|
||||
new URL('../drizzle/0005_salty_alice.sql', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const receipt = f.sqlite
|
||||
.prepare(
|
||||
'SELECT operation_hash,session_hash,claim_fence FROM seat_handover_claim_receipts WHERE room_code = ? AND player_id = ?',
|
||||
)
|
||||
.get(f.code, f.created.view.me)!;
|
||||
assert.equal(receipt.operation_hash, operationHash);
|
||||
assert.equal(receipt.session_hash, sessionHash);
|
||||
assert.match(receipt.claim_fence as string, /^legacy:/);
|
||||
assert.equal(
|
||||
(receipt.claim_fence as string).includes(body.newSessionToken),
|
||||
false,
|
||||
);
|
||||
const replay = await f.restart().claimSeatHandover(
|
||||
f.code,
|
||||
body,
|
||||
clock(3000),
|
||||
);
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.token, body.newSessionToken);
|
||||
assert.equal(replay.view.me, f.created.view.me);
|
||||
assert.equal(replay.view.players[0].hand![0].id, baseDeck()[0].id);
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT state FROM rooms WHERE code = ?').get(f.code)!
|
||||
.state,
|
||||
stateBeforeUpgrade,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('exact claim replay survives a later offer created by the recipient', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const body = claim(f.created.view.me);
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const transferred = await f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
body,
|
||||
clock(2000),
|
||||
);
|
||||
const recipientAuth = await f.rooms.authenticate(
|
||||
f.code,
|
||||
body.newSessionToken,
|
||||
);
|
||||
const later = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
recipientAuth,
|
||||
transferred.view.version,
|
||||
offer(UUID.replacement, secret('41')),
|
||||
clock(3000),
|
||||
);
|
||||
const replay = await f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
body,
|
||||
clock(4000),
|
||||
);
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.token, body.newSessionToken);
|
||||
assert.equal(replay.view.version, later.view.version);
|
||||
assert.equal(offered.view.version + 2, later.view.version);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('losing exact claim cannot erase recovery configured after the winner', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const body = claim(f.created.view.me);
|
||||
await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const barrier = batchBarrier(f.hooks);
|
||||
const loser = f.rooms.claimSeatHandover(f.code, body, clock(2000));
|
||||
await barrier.waiting;
|
||||
const winner = await f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
body,
|
||||
clock(2000),
|
||||
);
|
||||
const recipientAuth = await f.rooms.authenticate(
|
||||
f.code,
|
||||
body.newSessionToken,
|
||||
);
|
||||
const recoverySecret = secret('22');
|
||||
const configured = await f.rooms.setRecoveryKey(
|
||||
f.code,
|
||||
recipientAuth,
|
||||
winner.view.version,
|
||||
recoverySecret,
|
||||
);
|
||||
barrier.release();
|
||||
const replay = await loser;
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.view.version, configured.view.version);
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT count(*) AS n FROM seat_recovery_keys').get()!
|
||||
.n,
|
||||
1,
|
||||
);
|
||||
const recovered = await f.rooms.recoverSeat(f.code, {
|
||||
playerId: f.created.view.me,
|
||||
recoverySecret,
|
||||
operationId: UUID.recovery,
|
||||
newSessionToken: secret('32'),
|
||||
});
|
||||
assert.equal(recovered.recovered, true);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a concurrent game action wins cleanly over a paused claim', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const barrier = batchBarrier(f.hooks);
|
||||
const pending = f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
);
|
||||
await barrier.waiting;
|
||||
const acted = await f.rooms.act(f.code, f.auth, offered.view.version, {
|
||||
type: 'ready',
|
||||
});
|
||||
barrier.release();
|
||||
await assert.rejects(pending, /changed/);
|
||||
assert.equal(acted.players[0].ready, true);
|
||||
await f.rooms.readSeatView(f.code, f.auth);
|
||||
assert.equal(
|
||||
f.sqlite
|
||||
.prepare('SELECT count(*) AS n FROM seat_handover_claim_receipts')
|
||||
.get()!.n,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('issuer recovery wins cleanly over a paused claim', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const recoverySecret = secret('23');
|
||||
const saved = await f.rooms.setRecoveryKey(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
recoverySecret,
|
||||
);
|
||||
await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
saved.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const barrier = batchBarrier(f.hooks);
|
||||
const pending = f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
);
|
||||
await barrier.waiting;
|
||||
const recovery = {
|
||||
playerId: f.created.view.me,
|
||||
recoverySecret,
|
||||
operationId: UUID.recovery,
|
||||
newSessionToken: secret('33'),
|
||||
};
|
||||
const recovered = await f.rooms.recoverSeat(f.code, recovery);
|
||||
barrier.release();
|
||||
await assert.rejects(pending, /changed/);
|
||||
const recoveredAuth = await f.rooms.authenticate(
|
||||
f.code,
|
||||
recovery.newSessionToken,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await f.rooms.readSeatView(f.code, recoveredAuth),
|
||||
recovered.view,
|
||||
);
|
||||
assert.equal(
|
||||
f.sqlite
|
||||
.prepare('SELECT count(*) AS n FROM seat_handover_claim_receipts')
|
||||
.get()!.n,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('owner revocation wins cleanly over a paused claim', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const barrier = batchBarrier(f.hooks);
|
||||
const pending = f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
);
|
||||
await barrier.waiting;
|
||||
const revoked = await f.rooms.revokeSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
offered.view.version,
|
||||
UUID.offer,
|
||||
clock(2000),
|
||||
);
|
||||
barrier.release();
|
||||
await assert.rejects(pending, /changed/);
|
||||
assert.equal(revoked.revoked, true);
|
||||
await f.rooms.readSeatView(f.code, f.auth);
|
||||
assert.equal(
|
||||
f.sqlite
|
||||
.prepare('SELECT count(*) AS n FROM seat_handover_claim_receipts')
|
||||
.get()!.n,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('exact concurrent claims replay one rotation while different claims have one winner', async () => {
|
||||
for (const same of [true, false]) {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const offered = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const first = claim(f.created.view.me);
|
||||
const second = same
|
||||
? first
|
||||
: claim(f.created.view.me, UUID.rival, secret('60'));
|
||||
const results = await Promise.allSettled([
|
||||
f.rooms.claimSeatHandover(f.code, first, clock(2000)),
|
||||
f.rooms.claimSeatHandover(f.code, second, clock(2000)),
|
||||
]);
|
||||
assert.equal(
|
||||
results.filter((result) => result.status === 'fulfilled').length,
|
||||
same ? 2 : 1,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.rooms.readRoom(f.code)).version,
|
||||
offered.view.version + 1,
|
||||
);
|
||||
assert.equal(
|
||||
f.sqlite
|
||||
.prepare('SELECT count(*) AS n FROM seats WHERE revoked = 0')
|
||||
.get()!.n,
|
||||
1,
|
||||
);
|
||||
if (same)
|
||||
assert.ok(
|
||||
results.every(
|
||||
(result) =>
|
||||
result.status === 'fulfilled' &&
|
||||
result.value.token === first.newSessionToken,
|
||||
),
|
||||
);
|
||||
else
|
||||
await assert.rejects(
|
||||
f.rooms.claimSeatHandover(f.code, second, clock(2000)),
|
||||
/already been claimed/,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('offer replacement, revocation and expiry are version-fenced and retry safe', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const first = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const replay = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1001),
|
||||
);
|
||||
assert.equal(replay.replayed, true);
|
||||
assert.equal(replay.view.version, first.view.version);
|
||||
const replacement = offer(UUID.replacement, secret('41'));
|
||||
const replaced = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
first.view.version,
|
||||
replacement,
|
||||
clock(2000),
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(3000),
|
||||
),
|
||||
/could not be verified/,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.revokeSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
replaced.view.version,
|
||||
UUID.offer,
|
||||
),
|
||||
/different handover offer/,
|
||||
);
|
||||
const revoked = await f.rooms.revokeSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
replaced.view.version,
|
||||
UUID.replacement,
|
||||
clock(3000),
|
||||
);
|
||||
assert.equal(revoked.replayed, false);
|
||||
const retried = await f.rooms.revokeSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
replaced.view.version,
|
||||
UUID.replacement,
|
||||
clock(3001),
|
||||
);
|
||||
assert.equal(retried.replayed, true);
|
||||
|
||||
const expiring = await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
retried.view.version,
|
||||
offer(),
|
||||
clock(4000),
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(expiring.expiresAt),
|
||||
),
|
||||
/expired/,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('failed recipient credential insertion rolls back claim custody atomically', async () => {
|
||||
const f = await fixture(true);
|
||||
try {
|
||||
await f.rooms.createSeatHandover(
|
||||
f.code,
|
||||
f.auth,
|
||||
f.view.version,
|
||||
offer(),
|
||||
clock(1000),
|
||||
);
|
||||
const before = custody(f.sqlite);
|
||||
f.sqlite.exec(
|
||||
"CREATE TRIGGER reject_handover_session BEFORE INSERT ON seats BEGIN SELECT RAISE(ABORT, 'injected handover insertion failure'); END;",
|
||||
);
|
||||
await assert.rejects(
|
||||
f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
),
|
||||
/injected handover insertion failure/,
|
||||
);
|
||||
assert.equal(custody(f.sqlite), before);
|
||||
await f.rooms.readSeatView(f.code, f.auth);
|
||||
f.sqlite.exec('DROP TRIGGER reject_handover_session');
|
||||
const done = await f.rooms.claimSeatHandover(
|
||||
f.code,
|
||||
claim(f.created.view.me),
|
||||
clock(2000),
|
||||
);
|
||||
assert.equal(done.transferred, true);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user