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
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user