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:
parent
29653e2fdc
commit
69cc5ff3a7
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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("; ");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ describe("database migrations", () => {
|
|||
"discovered_locations",
|
||||
"dispatches",
|
||||
"pilots",
|
||||
"player_logins",
|
||||
"players",
|
||||
"session",
|
||||
"ship_upgrades",
|
||||
|
|
|
|||
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue