Files
Dune/lib/seat-handover.ts
T

207 lines
6.1 KiB
TypeScript

import { randomId } from './random-id';
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: randomId(),
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: randomId(),
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
);
}