Files
Dune/lib/seat-recovery.ts
T

114 lines
3.2 KiB
TypeScript

import { randomId } from './random-id';
export type SeatRecoveryKit = Readonly<{
format: 'dune-seat-recovery';
version: 1;
roomCode: string;
playerId: string;
recoverySecret: string;
}>;
export type SeatRecoveryAttempt = Readonly<{
type: 'recoverSeat';
playerId: string;
recoverySecret: string;
operationId: string;
newSessionToken: string;
}>;
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}$/;
/** Secrets remain in caller memory. This module has no storage or network access. */
function secret256(): string {
return Array.from(
globalThis.crypto.getRandomValues(new Uint8Array(32)),
(byte) => byte.toString(16).padStart(2, '0'),
).join('');
}
export function createRecoveryKit(
roomCode: string,
playerId: string,
): SeatRecoveryKit {
if (!ROOM.test(roomCode) || !UUID.test(playerId))
throw new Error(
'The current room or seat identifier is invalid. Refresh the table before creating a recovery kit.',
);
return Object.freeze({
format: 'dune-seat-recovery',
version: 1,
roomCode,
playerId,
recoverySecret: secret256(),
});
}
export function serializeRecoveryKit(kit: SeatRecoveryKit): string {
return JSON.stringify(kit, null, 2);
}
export function parseRecoveryKit(text: string): SeatRecoveryKit {
if (text.length > 4096)
throw new Error(
'This recovery kit is too large. Paste only the saved kit.',
);
let data: unknown;
try {
data = JSON.parse(text.trim());
} catch {
throw new Error(
'Paste the complete saved recovery kit, including its opening and closing braces.',
);
}
if (!data || typeof data !== 'object' || Array.isArray(data))
throw new Error('This is not a Dune seat recovery kit.');
const candidate = data as Record<string, unknown>;
if (candidate.format !== 'dune-seat-recovery' || candidate.version !== 1)
throw new Error('This recovery kit format is not supported.');
if (
Object.keys(candidate).some(
(key) =>
![
'format',
'version',
'roomCode',
'playerId',
'recoverySecret',
].includes(key),
)
)
throw new Error(
'The recovery kit contains unexpected fields. Paste only the original saved kit.',
);
if (
typeof candidate.roomCode !== 'string' ||
!ROOM.test(candidate.roomCode) ||
typeof candidate.playerId !== 'string' ||
!UUID.test(candidate.playerId) ||
typeof candidate.recoverySecret !== 'string' ||
!SECRET.test(candidate.recoverySecret)
)
throw new Error(
'The recovery kit has an invalid room, seat identifier or recovery key.',
);
return Object.freeze({
format: 'dune-seat-recovery',
version: 1,
roomCode: candidate.roomCode,
playerId: candidate.playerId,
recoverySecret: candidate.recoverySecret,
});
}
/** Generate once per explicit attempt. Reuse this exact object for every retry. */
export function createRecoveryAttempt(
kit: SeatRecoveryKit,
): SeatRecoveryAttempt {
return Object.freeze({
type: 'recoverSeat',
playerId: kit.playerId,
recoverySecret: kit.recoverySecret,
operationId: randomId(),
newSessionToken: secret256(),
});
}