Step 10K.8: backup codes and restore on the server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-04 23:44:52 -04:00
parent 29653e2fdc
commit 69cc5ff3a7
17 changed files with 1845 additions and 7 deletions

View File

@ -1088,3 +1088,12 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Client and server share one name rule** (`companyNameSchema` from `@deadhead/schema`), so the inline message is the same check the server will apply.
- **The confirmation is case-sensitive**: the typed text must equal the company name exactly.
- **A new company clears dismissed tips** so the tutorial intro card greets the restart, as the CR's "Contracts with the intro card" asks.
## Step 10K.8
- **Restoring creates a new guest sign-in for the device** and links it to the player in `player_logins`; session lookup checks the player's own user, then those links. Better Auth's public API can't start a session for an existing user, and this way several devices share a player safely (decision 0.5.73). The device's previous guest company is left behind, as the CR warns.
- **Code format:** 20 characters from a 31-character alphabet without 0/O/1/I/L (`23456789ABCDEFGHJKMNPQRSTUVWXYZ`), shown `XXXXX-XXXXX-XXXXX-XXXXX`: about 99 bits, above the 80 the CR asks. Input ignores case, spaces and dashes.
- **Hashing:** SHA-256 of the normalized code, with a unique index; the code is random enough that a slow password hash adds nothing, and lookup stays one indexed query.
- **Endpoints:** `GET` says whether a code exists; `POST` makes a new one, shown once, and the old one stops working. The server never returns an existing code.
- **Errors:** a wrong code is a generic `409 restore-failed` (no hint whether it was close); too many failures are `429 rate-limited`. 409 rather than 401, because the client treats 401 as "signed out".
- **Rate limit:** 5 failed attempts per 15 minutes, counted per IP and per device id (the client sends `x-device-id`), in memory per server process, which is enough for the single-instance base game (Phase 13 can move it to Postgres).

View File

@ -0,0 +1,11 @@
CREATE TABLE "player_logins" (
"user_id" text PRIMARY KEY NOT NULL,
"player_id" text NOT NULL,
"created_at" bigint NOT NULL
);
--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "backup_code_hash" text;--> statement-breakpoint
ALTER TABLE "players" ADD COLUMN "backup_code_created_at" bigint;--> statement-breakpoint
ALTER TABLE "player_logins" ADD CONSTRAINT "player_logins_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "player_logins" ADD CONSTRAINT "player_logins_player_id_players_id_fk" FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "players" ADD CONSTRAINT "players_backup_code_hash_unique" UNIQUE("backup_code_hash");

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,13 @@
"when": 1791164145228,
"tag": "0007_cr010_veteran_pilot",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1791171500580,
"tag": "0008_cr011_backup_codes",
"breakpoints": true
}
]
}

View File

@ -38,6 +38,9 @@ export const players = pgTable("players", {
/** The one-time deal has been accepted (decision 0.5.30). */
dealAccepted: boolean("deal_accepted").notNull().default(false),
createdAt: time("created_at").notNull(),
/** SHA-256 of the player's backup code (decision 0.5.72); null until one is made. */
backupCodeHash: text("backup_code_hash").unique(),
backupCodeCreatedAt: time("backup_code_created_at"),
});
export const companies = pgTable("companies", {
@ -233,3 +236,17 @@ export const claimedGoals = pgTable(
},
(t) => [primaryKey({ columns: [t.playerId, t.goalId] })],
);
/**
* Extra sign-ins for a player (decisions 0.5.72, 0.5.73): each device that
* restores with a backup code gets its own guest user, linked here.
*/
export const playerLogins = pgTable("player_logins", {
userId: text("user_id")
.primaryKey()
.references(() => user.id, { onDelete: "cascade" }),
playerId: text("player_id")
.notNull()
.references(() => players.id, { onDelete: "cascade" }),
createdAt: time("created_at").notNull(),
});

View File

@ -0,0 +1,96 @@
import { createHash, randomInt } from "node:crypto";
import type { RealTime } from "@deadhead/sim";
import { eq } from "drizzle-orm";
import type { Queryable } from "../db/client";
import { players } from "../db/schema";
/**
* Backup codes (decision 0.5.72): 20 characters from 31 that can't be mistaken
* for each other (no 0/O, 1/I/L), shown as four groups of five: about 99 bits.
* Only a SHA-256 of the normalized code is stored; the code itself is random
* enough that a slow hash would add nothing.
*/
export const BACKUP_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
const GROUPS = 4;
const GROUP_LENGTH = 5;
export function generateBackupCode(): string {
const groups = Array.from({ length: GROUPS }, () =>
Array.from(
{ length: GROUP_LENGTH },
() => BACKUP_ALPHABET[randomInt(BACKUP_ALPHABET.length)],
).join(""),
);
return groups.join("-");
}
/** The code without spaces or dashes, in capitals: how players type it doesn't matter. */
export function normalizeBackupCode(code: string): string {
return code.toUpperCase().replace(/[\s-]/g, "");
}
export function hashBackupCode(code: string): string {
return createHash("sha256").update(normalizeBackupCode(code)).digest("hex");
}
/** Makes a new code for the player, replacing any old one; returns it once. */
export async function createBackupCode(
tx: Queryable,
playerId: string,
now: RealTime,
): Promise<{ code: string; createdAt: RealTime }> {
const code = generateBackupCode();
await tx
.update(players)
.set({ backupCodeHash: hashBackupCode(code), backupCodeCreatedAt: now })
.where(eq(players.id, playerId));
return { code, createdAt: now };
}
export async function backupCodeStatus(tx: Queryable, playerId: string) {
const [row] = await tx
.select({ hash: players.backupCodeHash, createdAt: players.backupCodeCreatedAt })
.from(players)
.where(eq(players.id, playerId));
return { hasCode: Boolean(row?.hash), createdAt: row?.hash ? (row.createdAt ?? null) : null };
}
/** The player a backup code belongs to, or null. */
export async function playerForBackupCode(tx: Queryable, code: string): Promise<string | null> {
if (normalizeBackupCode(code).length === 0) return null;
const [row] = await tx
.select({ id: players.id })
.from(players)
.where(eq(players.backupCodeHash, hashBackupCode(code)));
return row?.id ?? null;
}
/**
* Failed restore attempts per key (an IP, a device), in memory: at most
* `limit` failures per `windowMs` for any key (decision 0.5.72).
*/
export class RestoreLimiter {
private readonly failures = new Map<string, { count: number; resetAt: RealTime }>();
constructor(
readonly limit = 5,
readonly windowMs = 15 * 60_000,
) {}
/** Whether any of the keys has used up its attempts. */
blocked(keys: readonly string[], now: RealTime): boolean {
return keys.some((key) => {
const entry = this.failures.get(key);
return entry !== undefined && entry.resetAt > now && entry.count >= this.limit;
});
}
fail(keys: readonly string[], now: RealTime): void {
for (const key of keys) {
const entry = this.failures.get(key);
if (!entry || entry.resetAt <= now)
this.failures.set(key, { count: 1, resetAt: now + this.windowMs });
else entry.count++;
}
}
}

View File

@ -3,7 +3,7 @@ import { fromNodeHeaders } from "better-auth/node";
import type { FastifyRequest } from "fastify";
import type { Auth } from "../auth";
import type { Db } from "../db/client";
import { players } from "../db/schema";
import { playerLogins, players } from "../db/schema";
import { ApiError } from "./errors";
/** The signed-in player's id, or a 401 `unauthorized` error. */
@ -19,6 +19,12 @@ export async function requirePlayerId(
.select({ id: players.id })
.from(players)
.where(eq(players.userId, session.user.id));
if (!player) throw new ApiError(401, "unauthorized", "No player for this account.");
return player.id;
if (player) return player.id;
// A device signed in with a backup code (decision 0.5.72).
const [login] = await db
.select({ playerId: playerLogins.playerId })
.from(playerLogins)
.where(eq(playerLogins.userId, session.user.id));
if (!login) throw new ApiError(401, "unauthorized", "No player for this account.");
return login.playerId;
}

View File

@ -6,12 +6,19 @@ import { fromNodeHeaders } from "better-auth/node";
import type { FastifyInstance } from "fastify";
import { acceptDeal } from "../game/deal";
import { claimGoal } from "../game/goals";
import {
backupCodeStatus,
createBackupCode,
playerForBackupCode,
RestoreLimiter,
} from "../game/backup";
import { renameCompany } from "../game/company";
import { repayLoan } from "../game/loan";
import { newCompany } from "../game/new-company";
import { createPlayer } from "../game/new-player";
import { loadPlayerState } from "../game/player-state";
import { acknowledgeFinishedUpgrades, acknowledgeSummaries } from "../game/summaries";
import { playerLogins } from "../db/schema";
import { ApiError } from "../http/errors";
import { registerRoute } from "../http/route";
import type { AppContext } from "./context";
@ -64,6 +71,50 @@ export function registerPlayerRoutes(app: FastifyInstance, ctx: AppContext): voi
}),
);
registerRoute(app, routes.getBackupCode, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, (tx) => backupCodeStatus(tx, playerId)),
);
registerRoute(app, routes.createBackupCode, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, (tx, now) => createBackupCode(tx, playerId, now)),
);
// Restoring signs this device in as the code's player (decisions 0.5.72, 0.5.73).
const limiter = new RestoreLimiter();
registerRoute(app, routes.restoreCompany, options, async (input, { request, reply }) => {
const now = clock();
const device = request.headers["x-device-id"];
const keys = [`ip:${request.ip}`, ...(typeof device === "string" ? [`device:${device}`] : [])];
if (limiter.blocked(keys, now)) {
throw new ApiError(
429,
"rate-limited",
"Too many attempts. Wait a few minutes and try again.",
);
}
const playerId = await playerForBackupCode(db, input.code);
if (!playerId) {
limiter.fail(keys, now);
throw new ApiError(
409,
"restore-failed",
"That code didn't open a company. Check it and try again.",
);
}
// A fresh guest sign-in for this device (any old session is left behind),
// linked to the code's player.
const headers = fromNodeHeaders(request.headers);
headers.delete("cookie");
const signIn = await auth.api.signInAnonymous({ headers, returnHeaders: true });
if (!signIn.response)
throw new ApiError(500, "internal-error", "Could not sign this device in.");
await db
.insert(playerLogins)
.values({ userId: signIn.response.user.id, playerId, createdAt: now });
reply.header("set-cookie", signIn.headers.getSetCookie());
return { playerId, state: await loadPlayerState(db, playerId, now) };
});
registerRoute(app, routes.newCompany, options, async (_input, { playerId }) =>
withPlayer(ctx, playerId, async (tx, now) => {
await newCompany(tx, playerId, now);

View File

@ -0,0 +1,106 @@
import {
backupCodeResponseSchema,
createGuestResponseSchema,
errorResponseSchema,
} from "@deadhead/schema";
import { sql } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { BACKUP_ALPHABET, normalizeBackupCode } from "../src/game/backup";
import { setCredits, useTestDatabase } from "./db";
import { call, cookieFrom, getState, signUp, signUpOwner, useTestApp, type Guest } from "./game";
const database = useTestDatabase();
const testApp = useTestApp(database);
const makeCode = async (guest: Guest) =>
backupCodeResponseSchema.parse(
(await call(testApp().app, guest, "POST", "/api/backup-code", {})).json(),
).code;
/** Restores on a new device (no cookie); `device` sets the device-id header. */
const restore = (code: string, device = "device-a", ip = "10.0.0.1") =>
testApp().app.inject({
method: "POST",
url: "/api/restore",
headers: { "x-device-id": device },
remoteAddress: ip,
payload: { code },
});
describe("backup codes (Step 10K.8)", () => {
it("makes a 20-character code in four groups, without look-alike characters", async () => {
const guest = await signUp(testApp().app);
const code = await makeCode(guest);
expect(code).toMatch(/^[A-Z2-9]{5}-[A-Z2-9]{5}-[A-Z2-9]{5}-[A-Z2-9]{5}$/);
for (const ch of normalizeBackupCode(code)) expect(BACKUP_ALPHABET).toContain(ch);
expect(code).not.toMatch(/[01OIL]/);
expect((await call(testApp().app, guest, "GET", "/api/backup-code")).json()).toMatchObject({
hasCode: true,
});
});
it("restores the same player on a second device, and both see the same state", async () => {
const { app } = testApp();
const owner = await signUpOwner(app, database().db);
await setCredits(database().db, owner.playerId, 4321);
const code = await makeCode(owner);
const response = await restore(code.toLowerCase().replaceAll("-", " "));
expect(response.statusCode).toBe(200);
const body = createGuestResponseSchema.parse(response.json());
expect(body.playerId).toBe(owner.playerId);
const second: Guest = { ...owner, cookie: cookieFrom(response.headers["set-cookie"]) };
expect(second.cookie).not.toBe(owner.cookie);
await call(app, second, "POST", "/api/company/name", { name: "Second Device Co" });
expect((await getState(app, owner)).company).toMatchObject({
name: "Second Device Co",
credits: 4321,
});
});
it("stops the old code working when a new one is made", async () => {
const guest = await signUp(testApp().app);
const first = await makeCode(guest);
const second = await makeCode(guest);
expect(second).not.toBe(first);
expect((await restore(first)).statusCode).toBe(409);
expect((await restore(second)).statusCode).toBe(200);
});
it("fails a wrong code with a generic error, and limits attempts", async () => {
const wrong = await restore("ABCDE-FGHJK-MNPQR-STUVW", "device-b", "10.0.0.2");
expect(wrong.statusCode).toBe(409);
expect(errorResponseSchema.parse(wrong.json()).error.code).toBe("restore-failed");
for (let i = 0; i < 4; i++) await restore("NOPE", "device-b", "10.0.0.2");
const limited = await restore("NOPE", "device-b", "10.0.0.2");
expect(limited.statusCode).toBe(429);
expect(errorResponseSchema.parse(limited.json()).error.code).toBe("rate-limited");
// The same device from another network is still limited.
expect((await restore("NOPE", "device-b", "10.0.0.3")).statusCode).toBe(429);
});
it("never stores the plain code", async () => {
const guest = await signUp(testApp().app);
const code = await makeCode(guest);
const plain = normalizeBackupCode(code);
const rows = await database().db.execute(
sql`select row_to_json(p)::text as row from players p`,
);
for (const { row } of rows.rows as { row: string }[]) {
expect(row).not.toContain(code);
expect(row).not.toContain(plain);
}
});
it("still restores the player after they start a new company", async () => {
const owner = await signUpOwner(testApp().app, database().db);
const code = await makeCode(owner);
await call(testApp().app, owner, "POST", "/api/company/new", {});
const response = await restore(code, "device-c", "10.0.0.4");
expect(createGuestResponseSchema.parse(response.json())).toMatchObject({
playerId: owner.playerId,
state: { tutorial: { runsCompleted: 0 } },
});
});
});

View File

@ -50,7 +50,7 @@ export function useTestApp(database: () => Database): () => TestApp {
return () => current!;
}
function cookieFrom(setCookie: string | string[] | undefined): string {
export function cookieFrom(setCookie: string | string[] | undefined): string {
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
return values.map((c) => c.split(";")[0]).join("; ");
}

View File

@ -41,6 +41,7 @@ describe("database migrations", () => {
"discovered_locations",
"dispatches",
"pilots",
"player_logins",
"players",
"session",
"ship_upgrades",

View File

@ -62,6 +62,9 @@ The `routes` catalog is the single source for both server and client. Each entry
| `spendSkillPoint` | `POST /api/pilots/:pilotId/skills` | `pilotId`, `skillId` | `{ pilot }` | yes |
| `acknowledgeSummaries` | `POST /api/summaries/acknowledge` | `dispatchIds`, optional `shipIds` (finished upgrades seen; at least one id overall) | `{ acknowledged }` | yes |
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
| `getBackupCode` | `GET /api/backup-code` | — | `{ hasCode, createdAt }` | yes |
| `createBackupCode` | `POST /api/backup-code` | — | `{ code, createdAt }` (shown once; replaces any old code) | yes |
| `restoreCompany` | `POST /api/restore` | `code` (case, spaces and dashes ignored); header `x-device-id` for rate limiting | `{ playerId, state }` + session cookie | no |
| `newCompany` | `POST /api/company/new` | — | `PlayerState` (the fresh start) | yes |
| `renameCompany` | `POST /api/company/name` | `name` (trimmed, 3–32 letters, digits, spaces, `. , ' & ! ? -`) | `{ company }` | yes |
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |
@ -76,6 +79,7 @@ Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/dri
| `players` | Link to the Better Auth user, world seed, tutorial flag, loaner location, tutorial runs completed, deal accepted | pk `id`, unique `user_id` → `user.id` |
| `companies` | Name, credits and loan balance (the row locked for money changes) | unique `player_id` |
| `ships` | Owned ships: model, name, status, location (null in flight), in-shop-until and the latest shop job (upgrade id, level) until acknowledged (CR-009) | — |
| `player_logins` | Extra sign-ins per player: a guest user created by restoring with a backup code (CR-011) | PK `user_id` |
| `ship_upgrades` | Installed upgrade level per ship and upgrade (CR-009) | primary key (ship, upgrade) |
| `pilots` | XP, skills (jsonb), points spent, assignment, location (unassigned only), status, duty hours, rest-ends-at (CR-004), `is_veteran` (CR-010) | unique `assigned_ship_id` (one pilot per ship) |
| `bays` | Leased bays | unique (`player_id`, `location_id`) |

View File

@ -259,7 +259,7 @@ Spec: `memory-bank/change-requests/CR-011-settings-backup.md` (received as "CR-0
- [x] **Step 10K.5 — Server: rename company.** `companyNameSchema` (trimmed; 3–32 of letters, digits, spaces and `. , ' & ! ? -`) and route `renameCompany` (`POST /api/company/name`, `{ name }` → `{ company }`), run in `withPlayer`. New companies get a default name from their world seed (`companyName(seed)`, e.g. "Lodestar Haulage") instead of the fixed "Deadhead Freight". Player state already carried the name. Test: `company-name.test.ts`.
- [x] **Step 10K.6 — Server: start a new company.** Route `newCompany` (`POST /api/company/new`, `{}` → player state) runs `newCompany` in `withPlayer` (company row lock): deletes claimed goals, accepted contracts, dispatches, boards, discovered locations, bays, pilots, ships (upgrades cascade) and the company, resets the player's tutorial fields with a new world seed, then calls `createStartingState`, now shared with guest creation. Arrival jobs for deleted flights find nothing. Test: `new-company.test.ts` (a mid-game owner ends up identical to a fresh guest, new seed, same player id; the old flight's job is a no-op). The backup-code part of the test is in 10K.8.
- [x] **Step 10K.7 — Client: rename and new company flows.** `screens/CompanySettings.tsx`: the company name has "Rename", an in-place field checked as you type with the server's `companyNameSchema` (message under the field, Save disabled until valid; saves the trimmed name). "Start a new company" opens a confirmation explaining what is lost; "Start over" stays disabled until the exact company name is typed (case-sensitive), then calls `newCompany`, clears dismissed tips and goes to Contracts, where the intro card shows. Test: `company-settings.test.tsx`.
- [ ] Step 10K.8 — Server: backup code and restore
- [x] **Step 10K.8 — Server: backup code and restore.** Migration `0008_cr011_backup_codes`: `players.backup_code_hash` (unique) and `backup_code_created_at`; table `player_logins` (extra sign-ins per player). `game/backup.ts`: 20-character codes from `23456789ABCDEFGHJKMNPQRSTUVWXYZ` in four groups of five (≈ 99 bits), SHA-256 of the normalized code stored, `RestoreLimiter` (5 failures per 15 min per IP and per device id). Routes: `getBackupCode` (GET, whether one exists), `createBackupCode` (POST, new code shown once, old one invalidated), `restoreCompany` (POST `/api/restore`, no session: a fresh guest sign-in for the device linked to the code's player; `409 restore-failed` for a bad code, `429 rate-limited`). Session lookup falls back to `player_logins`. Test: `backup.test.ts` (format, restore on a second device with shared state, regenerate invalidates, wrong code and rate limit, no plain code in the database, still works after a new company).
- [ ] Step 10K.9 — Client: backup and restore flows
- [ ] Step 10K.10 — Client: About and debug info
- [ ] Step 10K.11 — Client and server: developer tools (non-production only)

View File

@ -35,6 +35,8 @@ export const SERVER_ERROR_CODES = [
"unknown-goal",
"goal-not-ready",
"goal-already-claimed",
"restore-failed",
"rate-limited",
] as const;
export type ErrorCode =

View File

@ -93,3 +93,16 @@ export const companyNameSchema = z
.max(32, "At most 32 characters")
.regex(/^[A-Za-z0-9 .,'&!?-]+$/, "Letters, digits, spaces and . , ' & ! ? - only");
export const renameCompanyRequestSchema = z.object({ name: companyNameSchema });
/** Whether the player has a backup code, and since when (CR-011). */
export const backupCodeStatusSchema = z.object({
hasCode: z.boolean(),
createdAt: timeSchema.nullable(),
});
/** A newly made backup code: shown once (decision 0.5.72). */
export const backupCodeResponseSchema = z.object({
code: z.string().min(1),
createdAt: timeSchema,
});
/** A backup code typed by the player; spaces, dashes and case don't matter. */
export const restoreRequestSchema = z.object({ code: z.string().trim().min(1).max(64) });

View File

@ -17,7 +17,10 @@ import {
hirePilotRequestSchema,
leaseBayRequestSchema,
pilotResponseSchema,
backupCodeResponseSchema,
backupCodeStatusSchema,
renameCompanyRequestSchema,
restoreRequestSchema,
repayLoanRequestSchema,
shipResponseSchema,
spendSkillPointRequestSchema,
@ -152,6 +155,27 @@ export const routes = {
response: playerStateSchema,
auth: true,
},
getBackupCode: {
method: "GET",
path: "/api/backup-code",
request: emptyRequestSchema,
response: backupCodeStatusSchema,
auth: true,
},
createBackupCode: {
method: "POST",
path: "/api/backup-code",
request: emptyRequestSchema,
response: backupCodeResponseSchema,
auth: true,
},
restoreCompany: {
method: "POST",
path: "/api/restore",
request: restoreRequestSchema,
response: createGuestResponseSchema,
auth: false,
},
newCompany: {
method: "POST",
path: "/api/company/new",

View File

@ -24,6 +24,9 @@ describe("route catalog", () => {
"spendSkillPoint",
"acknowledgeSummaries",
"acceptDeal",
"getBackupCode",
"createBackupCode",
"restoreCompany",
"newCompany",
"renameCompany",
"repayLoan",
@ -58,8 +61,9 @@ describe("route catalog", () => {
}
});
it("requires a session everywhere except server time and guest creation", () => {
it("requires a session everywhere except server time, guest creation and restore", () => {
const open = entries.filter(([, r]) => !r.auth).map(([name]) => name);
expect(open.sort()).toEqual(["createGuest", "getServerTime"]);
// Restoring signs a device in, so it runs without a session (CR-011).
expect(open.sort()).toEqual(["createGuest", "getServerTime", "restoreCompany"]);
});
});