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.
|
- **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.
|
- **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.
|
- **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,
|
"when": 1791164145228,
|
||||||
"tag": "0007_cr010_veteran_pilot",
|
"tag": "0007_cr010_veteran_pilot",
|
||||||
"breakpoints": true
|
"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). */
|
/** The one-time deal has been accepted (decision 0.5.30). */
|
||||||
dealAccepted: boolean("deal_accepted").notNull().default(false),
|
dealAccepted: boolean("deal_accepted").notNull().default(false),
|
||||||
createdAt: time("created_at").notNull(),
|
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", {
|
export const companies = pgTable("companies", {
|
||||||
|
|
@ -233,3 +236,17 @@ export const claimedGoals = pgTable(
|
||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.playerId, t.goalId] })],
|
(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 { FastifyRequest } from "fastify";
|
||||||
import type { Auth } from "../auth";
|
import type { Auth } from "../auth";
|
||||||
import type { Db } from "../db/client";
|
import type { Db } from "../db/client";
|
||||||
import { players } from "../db/schema";
|
import { playerLogins, players } from "../db/schema";
|
||||||
import { ApiError } from "./errors";
|
import { ApiError } from "./errors";
|
||||||
|
|
||||||
/** The signed-in player's id, or a 401 `unauthorized` error. */
|
/** The signed-in player's id, or a 401 `unauthorized` error. */
|
||||||
|
|
@ -19,6 +19,12 @@ export async function requirePlayerId(
|
||||||
.select({ id: players.id })
|
.select({ id: players.id })
|
||||||
.from(players)
|
.from(players)
|
||||||
.where(eq(players.userId, session.user.id));
|
.where(eq(players.userId, session.user.id));
|
||||||
if (!player) throw new ApiError(401, "unauthorized", "No player for this account.");
|
if (player) return player.id;
|
||||||
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 type { FastifyInstance } from "fastify";
|
||||||
import { acceptDeal } from "../game/deal";
|
import { acceptDeal } from "../game/deal";
|
||||||
import { claimGoal } from "../game/goals";
|
import { claimGoal } from "../game/goals";
|
||||||
|
import {
|
||||||
|
backupCodeStatus,
|
||||||
|
createBackupCode,
|
||||||
|
playerForBackupCode,
|
||||||
|
RestoreLimiter,
|
||||||
|
} from "../game/backup";
|
||||||
import { renameCompany } from "../game/company";
|
import { renameCompany } from "../game/company";
|
||||||
import { repayLoan } from "../game/loan";
|
import { repayLoan } from "../game/loan";
|
||||||
import { newCompany } from "../game/new-company";
|
import { newCompany } from "../game/new-company";
|
||||||
import { createPlayer } from "../game/new-player";
|
import { createPlayer } from "../game/new-player";
|
||||||
import { loadPlayerState } from "../game/player-state";
|
import { loadPlayerState } from "../game/player-state";
|
||||||
import { acknowledgeFinishedUpgrades, acknowledgeSummaries } from "../game/summaries";
|
import { acknowledgeFinishedUpgrades, acknowledgeSummaries } from "../game/summaries";
|
||||||
|
import { playerLogins } from "../db/schema";
|
||||||
import { ApiError } from "../http/errors";
|
import { ApiError } from "../http/errors";
|
||||||
import { registerRoute } from "../http/route";
|
import { registerRoute } from "../http/route";
|
||||||
import type { AppContext } from "./context";
|
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 }) =>
|
registerRoute(app, routes.newCompany, options, async (_input, { playerId }) =>
|
||||||
withPlayer(ctx, playerId, async (tx, now) => {
|
withPlayer(ctx, playerId, async (tx, now) => {
|
||||||
await newCompany(tx, playerId, 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!;
|
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] : [];
|
const values = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
|
||||||
return values.map((c) => c.split(";")[0]).join("; ");
|
return values.map((c) => c.split(";")[0]).join("; ");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ describe("database migrations", () => {
|
||||||
"discovered_locations",
|
"discovered_locations",
|
||||||
"dispatches",
|
"dispatches",
|
||||||
"pilots",
|
"pilots",
|
||||||
|
"player_logins",
|
||||||
"players",
|
"players",
|
||||||
"session",
|
"session",
|
||||||
"ship_upgrades",
|
"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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `newCompany` | `POST /api/company/new` | — | `PlayerState` (the fresh start) | yes |
|
||||||
| `renameCompany` | `POST /api/company/name` | `name` (trimmed, 3–32 letters, digits, spaces, `. , ' & ! ? -`) | `{ company }` | yes |
|
| `renameCompany` | `POST /api/company/name` | `name` (trimmed, 3–32 letters, digits, spaces, `. , ' & ! ? -`) | `{ company }` | yes |
|
||||||
| `repayLoan` | `POST /api/loan/repay` | `amount` | `{ 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` |
|
| `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` |
|
| `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) | — |
|
| `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) |
|
| `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) |
|
| `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`) |
|
| `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.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.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`.
|
- [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.9 — Client: backup and restore flows
|
||||||
- [ ] Step 10K.10 — Client: About and debug info
|
- [ ] Step 10K.10 — Client: About and debug info
|
||||||
- [ ] Step 10K.11 — Client and server: developer tools (non-production only)
|
- [ ] Step 10K.11 — Client and server: developer tools (non-production only)
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ export const SERVER_ERROR_CODES = [
|
||||||
"unknown-goal",
|
"unknown-goal",
|
||||||
"goal-not-ready",
|
"goal-not-ready",
|
||||||
"goal-already-claimed",
|
"goal-already-claimed",
|
||||||
|
"restore-failed",
|
||||||
|
"rate-limited",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ErrorCode =
|
export type ErrorCode =
|
||||||
|
|
|
||||||
|
|
@ -93,3 +93,16 @@ export const companyNameSchema = z
|
||||||
.max(32, "At most 32 characters")
|
.max(32, "At most 32 characters")
|
||||||
.regex(/^[A-Za-z0-9 .,'&!?-]+$/, "Letters, digits, spaces and . , ' & ! ? - only");
|
.regex(/^[A-Za-z0-9 .,'&!?-]+$/, "Letters, digits, spaces and . , ' & ! ? - only");
|
||||||
export const renameCompanyRequestSchema = z.object({ name: companyNameSchema });
|
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,
|
hirePilotRequestSchema,
|
||||||
leaseBayRequestSchema,
|
leaseBayRequestSchema,
|
||||||
pilotResponseSchema,
|
pilotResponseSchema,
|
||||||
|
backupCodeResponseSchema,
|
||||||
|
backupCodeStatusSchema,
|
||||||
renameCompanyRequestSchema,
|
renameCompanyRequestSchema,
|
||||||
|
restoreRequestSchema,
|
||||||
repayLoanRequestSchema,
|
repayLoanRequestSchema,
|
||||||
shipResponseSchema,
|
shipResponseSchema,
|
||||||
spendSkillPointRequestSchema,
|
spendSkillPointRequestSchema,
|
||||||
|
|
@ -152,6 +155,27 @@ export const routes = {
|
||||||
response: playerStateSchema,
|
response: playerStateSchema,
|
||||||
auth: true,
|
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: {
|
newCompany: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/api/company/new",
|
path: "/api/company/new",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ describe("route catalog", () => {
|
||||||
"spendSkillPoint",
|
"spendSkillPoint",
|
||||||
"acknowledgeSummaries",
|
"acknowledgeSummaries",
|
||||||
"acceptDeal",
|
"acceptDeal",
|
||||||
|
"getBackupCode",
|
||||||
|
"createBackupCode",
|
||||||
|
"restoreCompany",
|
||||||
"newCompany",
|
"newCompany",
|
||||||
"renameCompany",
|
"renameCompany",
|
||||||
"repayLoan",
|
"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);
|
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