From 69cc5ff3a7d547a4a60f864d1a1ba0ef77f8bac3 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 23:44:52 -0400 Subject: [PATCH] Step 10K.8: backup codes and restore on the server Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 9 + .../drizzle/0008_cr011_backup_codes.sql | 11 + apps/server/drizzle/meta/0008_snapshot.json | 1487 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 7 + apps/server/src/db/schema.ts | 17 + apps/server/src/game/backup.ts | 96 ++ apps/server/src/http/session.ts | 12 +- apps/server/src/routes/player.ts | 51 + apps/server/test/backup.test.ts | 106 ++ apps/server/test/game.ts | 2 +- apps/server/test/migrations.test.ts | 1 + memory-bank/architecture.md | 4 + memory-bank/progress.md | 2 +- packages/schema/src/errors.ts | 2 + packages/schema/src/requests.ts | 13 + packages/schema/src/routes.ts | 24 + packages/schema/test/routes.test.ts | 8 +- 17 files changed, 1845 insertions(+), 7 deletions(-) create mode 100644 apps/server/drizzle/0008_cr011_backup_codes.sql create mode 100644 apps/server/drizzle/meta/0008_snapshot.json create mode 100644 apps/server/src/game/backup.ts create mode 100644 apps/server/test/backup.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 3287550..551f92a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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). diff --git a/apps/server/drizzle/0008_cr011_backup_codes.sql b/apps/server/drizzle/0008_cr011_backup_codes.sql new file mode 100644 index 0000000..9e45c7c --- /dev/null +++ b/apps/server/drizzle/0008_cr011_backup_codes.sql @@ -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"); \ No newline at end of file diff --git a/apps/server/drizzle/meta/0008_snapshot.json b/apps/server/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..0f136d0 --- /dev/null +++ b/apps/server/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1487 @@ +{ + "id": "9c84497a-e6c5-4313-a339-71cc45364eb4", + "prevId": "4d1e7e7c-f079-46db-8c00-bfe58ba49f73", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accepted_contracts": { + "name": "accepted_contracts", + "schema": "", + "columns": { + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_index": { + "name": "period_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "accepted_contracts_player_id_players_id_fk": { + "name": "accepted_contracts_player_id_players_id_fk", + "tableFrom": "accepted_contracts", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "accepted_contracts_dispatch_id_dispatches_id_fk": { + "name": "accepted_contracts_dispatch_id_dispatches_id_fk", + "tableFrom": "accepted_contracts", + "tableTo": "dispatches", + "columnsFrom": [ + "dispatch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "accepted_contracts_player_id_contract_id_pk": { + "name": "accepted_contracts_player_id_contract_id_pk", + "columns": [ + "player_id", + "contract_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bays": { + "name": "bays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "bays_player_location_idx": { + "name": "bays_player_location_idx", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bays_player_id_players_id_fk": { + "name": "bays_player_id_players_id_fk", + "tableFrom": "bays", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.claimed_goals": { + "name": "claimed_goals", + "schema": "", + "columns": { + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "claimed_goals_player_id_players_id_fk": { + "name": "claimed_goals_player_id_players_id_fk", + "tableFrom": "claimed_goals", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "claimed_goals_player_id_goal_id_pk": { + "name": "claimed_goals_player_id_goal_id_pk", + "columns": [ + "player_id", + "goal_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "loan_balance": { + "name": "loan_balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "companies_player_id_players_id_fk": { + "name": "companies_player_id_players_id_fk", + "tableFrom": "companies", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "companies_player_id_unique": { + "name": "companies_player_id_unique", + "nullsNotDistinct": false, + "columns": [ + "player_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contract_boards": { + "name": "contract_boards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "board_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_index": { + "name": "period_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tutorial_run": { + "name": "tutorial_run", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "contracts": { + "name": "contracts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "contract_boards_unique_idx": { + "name": "contract_boards_unique_idx", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "location_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tutorial_run", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contract_boards_player_id_players_id_fk": { + "name": "contract_boards_player_id_players_id_fk", + "tableFrom": "contract_boards", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discovered_locations": { + "name": "discovered_locations", + "schema": "", + "columns": { + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discovered_at": { + "name": "discovered_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "discovered_locations_player_id_players_id_fk": { + "name": "discovered_locations_player_id_players_id_fk", + "tableFrom": "discovered_locations", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "discovered_locations_player_id_location_id_pk": { + "name": "discovered_locations_player_id_location_id_pk", + "columns": [ + "player_id", + "location_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dispatches": { + "name": "dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ship_id": { + "name": "ship_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pilot_id": { + "name": "pilot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tutorial": { + "name": "tutorial", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "arrival_at": { + "name": "arrival_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "dispatch_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'in_flight'" + }, + "resolution": { + "name": "resolution", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "dispatches_due_idx": { + "name": "dispatches_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "arrival_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "dispatches_player_status_idx": { + "name": "dispatches_player_status_idx", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dispatches_player_id_players_id_fk": { + "name": "dispatches_player_id_players_id_fk", + "tableFrom": "dispatches", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dispatches_ship_id_ships_id_fk": { + "name": "dispatches_ship_id_ships_id_fk", + "tableFrom": "dispatches", + "tableTo": "ships", + "columnsFrom": [ + "ship_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "dispatches_pilot_id_pilots_id_fk": { + "name": "dispatches_pilot_id_pilots_id_fk", + "tableFrom": "dispatches", + "tableTo": "pilots", + "columnsFrom": [ + "pilot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pilots": { + "name": "pilots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_avatar": { + "name": "is_avatar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_veteran": { + "name": "is_veteran", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "xp": { + "name": "xp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "skill_points_spent": { + "name": "skill_points_spent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "assigned_ship_id": { + "name": "assigned_ship_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "activity_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "duty_hours": { + "name": "duty_hours", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rest_ends_at": { + "name": "rest_ends_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "pilots_player_idx": { + "name": "pilots_player_idx", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pilots_player_id_players_id_fk": { + "name": "pilots_player_id_players_id_fk", + "tableFrom": "pilots", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pilots_assigned_ship_id_ships_id_fk": { + "name": "pilots_assigned_ship_id_ships_id_fk", + "tableFrom": "pilots", + "tableTo": "ships", + "columnsFrom": [ + "assigned_ship_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pilots_assigned_ship_id_unique": { + "name": "pilots_assigned_ship_id_unique", + "nullsNotDistinct": false, + "columns": [ + "assigned_ship_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_logins": { + "name": "player_logins", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "player_logins_user_id_user_id_fk": { + "name": "player_logins_user_id_user_id_fk", + "tableFrom": "player_logins", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "player_logins_player_id_players_id_fk": { + "name": "player_logins_player_id_players_id_fk", + "tableFrom": "player_logins", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.players": { + "name": "players", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "world_seed": { + "name": "world_seed", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tutorial_active": { + "name": "tutorial_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "loaner_location_id": { + "name": "loaner_location_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tutorial_runs_completed": { + "name": "tutorial_runs_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deal_accepted": { + "name": "deal_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "backup_code_hash": { + "name": "backup_code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backup_code_created_at": { + "name": "backup_code_created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "players_user_id_user_id_fk": { + "name": "players_user_id_user_id_fk", + "tableFrom": "players", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "players_user_id_unique": { + "name": "players_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "players_backup_code_hash_unique": { + "name": "players_backup_code_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "backup_code_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ship_upgrades": { + "name": "ship_upgrades", + "schema": "", + "columns": { + "ship_id": { + "name": "ship_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upgrade_id": { + "name": "upgrade_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ship_upgrades_ship_id_ships_id_fk": { + "name": "ship_upgrades_ship_id_ships_id_fk", + "tableFrom": "ship_upgrades", + "tableTo": "ships", + "columnsFrom": [ + "ship_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ship_upgrades_ship_id_upgrade_id_pk": { + "name": "ship_upgrades_ship_id_upgrade_id_pk", + "columns": [ + "ship_id", + "upgrade_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ships": { + "name": "ships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "activity_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_shop_until": { + "name": "in_shop_until", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "shop_upgrade_id": { + "name": "shop_upgrade_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shop_level": { + "name": "shop_level", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ships_player_idx": { + "name": "ships_player_idx", + "columns": [ + { + "expression": "player_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ships_player_id_players_id_fk": { + "name": "ships_player_id_players_id_fk", + "tableFrom": "ships", + "tableTo": "players", + "columnsFrom": [ + "player_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.activity_status": { + "name": "activity_status", + "schema": "public", + "values": [ + "idle", + "in_flight" + ] + }, + "public.board_kind": { + "name": "board_kind", + "schema": "public", + "values": [ + "location", + "tutorial" + ] + }, + "public.dispatch_status": { + "name": "dispatch_status", + "schema": "public", + "values": [ + "in_flight", + "resolved" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 0c382ac..ee3d04b 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 8178713..9bade69 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -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(), +}); diff --git a/apps/server/src/game/backup.ts b/apps/server/src/game/backup.ts new file mode 100644 index 0000000..64945fe --- /dev/null +++ b/apps/server/src/game/backup.ts @@ -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 { + 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(); + + 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++; + } + } +} diff --git a/apps/server/src/http/session.ts b/apps/server/src/http/session.ts index 2af16d5..3deb7bc 100644 --- a/apps/server/src/http/session.ts +++ b/apps/server/src/http/session.ts @@ -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; } diff --git a/apps/server/src/routes/player.ts b/apps/server/src/routes/player.ts index 9278d5d..5f0b174 100644 --- a/apps/server/src/routes/player.ts +++ b/apps/server/src/routes/player.ts @@ -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); diff --git a/apps/server/test/backup.test.ts b/apps/server/test/backup.test.ts new file mode 100644 index 0000000..5b40917 --- /dev/null +++ b/apps/server/test/backup.test.ts @@ -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 } }, + }); + }); +}); diff --git a/apps/server/test/game.ts b/apps/server/test/game.ts index 805c73c..f9e9940 100644 --- a/apps/server/test/game.ts +++ b/apps/server/test/game.ts @@ -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("; "); } diff --git a/apps/server/test/migrations.test.ts b/apps/server/test/migrations.test.ts index c12ffea..6f95a2a 100644 --- a/apps/server/test/migrations.test.ts +++ b/apps/server/test/migrations.test.ts @@ -41,6 +41,7 @@ describe("database migrations", () => { "discovered_locations", "dispatches", "pilots", + "player_logins", "players", "session", "ship_upgrades", diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index daf7289..05971f8 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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`) | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 338c2d8..a596890 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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) diff --git a/packages/schema/src/errors.ts b/packages/schema/src/errors.ts index 977cba4..e2f37cd 100644 --- a/packages/schema/src/errors.ts +++ b/packages/schema/src/errors.ts @@ -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 = diff --git a/packages/schema/src/requests.ts b/packages/schema/src/requests.ts index bc5a038..59ecba8 100644 --- a/packages/schema/src/requests.ts +++ b/packages/schema/src/requests.ts @@ -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) }); diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 8893842..967897d 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -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", diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index edee38f..cdedc48 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -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"]); }); });