From b5bb0d01ce68bc8f0dd79a2186b2a0eea3a3a93c Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 24 Sep 2026 11:45:06 +0000 Subject: [PATCH] =?UTF-8?q?v0.72.0:=20fix=20admin=20login=20=E2=80=94=20re?= =?UTF-8?q?-seed=20scrypt=20digest=20computed=20by=20verifyPassword()=20ca?= =?UTF-8?q?ll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 3 +++ lib/db/client.test.ts | 37 ++++++++++++++++++++++++++++++++++++- lib/db/client.ts | 12 +++++++++++- package.json | 2 +- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c75a91f..99b1d51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## v0.72.0 — 2026-09-24 11:44 +- **Admin login fixed.** The seeded admin's scrypt digest in `lib/db/client.ts` was computed with different parameters than the verifier (`verifyPassword()` passes the salt as a plain string to `scryptSync(pw, saltHex, 32)`), so real logins failed with 401 "invalid credentials" since v0.71.0. The seed now carries a digest produced by exactly that call (the plaintext still exists nowhere in code, tests or comments). Because the seed is `INSERT … WHERE NOT EXISTS`, pre-existing databases keep their old row — so every `open()` also runs an idempotent rotation (`UPDATE users SET password_hash = ? WHERE id = 'admin-kamilkrzywda' AND password_hash != ?`): a no-op once healed, it repairs legacy rows at boot and is durable on the next persist. Unit tests cover the fresh seed with the new digest plus the rotate-on-open path (legacy file heals to the current digest; second open is a no-op). + ## v0.71.0 — 2026-09-24 09:18 - **Admin panel.** A new "Admin" button on the right of the top bar opens a reusable modal (`components/Modal.tsx` — fixed overlay at z-index 60, above the z-50 toast stack; centered `role="dialog"` panel; closes on Escape and backdrop click) hosting `components/AdminPanel.tsx`. No new URL routes: everything lives behind that one button + modal. - **Users + sessions tables.** New `users` (id PK, unique username, scrypt `salt$hex` password_hash, admin flag, created_at) and `sessions` (UUID id, user_id, 7-day expires_at) tables in the raw DDL (`lib/db/client.ts`) and typed Drizzle entries (`lib/db/schema.ts`). An **idempotent seeded admin** is inserted on every `open()` — user id `admin-kamilkrzywda`, username `kamilkrzywda`, admin=1, hash-only (the plaintext exists nowhere in code, tests or comments) — guarded by `WHERE NOT EXISTS` so pre-existing production DBs gain exactly one row and never a duplicate. The users table is also the foundation for multiplayer auth (#105/#107). diff --git a/lib/db/client.test.ts b/lib/db/client.test.ts index 7c22e89..c0882cf 100644 --- a/lib/db/client.test.ts +++ b/lib/db/client.test.ts @@ -223,7 +223,10 @@ describe('openGameDb', () => { }); // v0.71.0: the seeded admin row — hash-only, idempotent across every open(). - const ADMIN_HASH = '8dbed630b49a15073e8be571367c1858$0e5207dae2e3c813a7172a5f610fd47a90e8ff17513180ab1e0a7779c3766ef0'; + const ADMIN_HASH = '339149a14282ff3a2ed94534f9722aa4$abaf94cf073c5aafc892b4a39dce97aaf53ab6f7c662a588815bda1bc1771caf'; + // The v0.71.0 digest — computed with different scrypt parameters than verifyPassword(), + // so it never verified; production DBs seeded by that version carry exactly this value. + const LEGACY_HASH = '8dbed630b49a15073e8be571367c1858$0e5207dae2e3c813a7172a5f610fd47a90e8ff17513180ab1e0a7779c3766ef0'; it('seeds exactly one admin user on first open and never duplicates on reopen', async () => { const file = path.join(dir, 'admin-seed.db'); @@ -258,6 +261,38 @@ describe('openGameDb', () => { expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000); }); + it('rotates a v0.71.0 legacy admin digest on open (idempotent)', async () => { + // A file exactly as v0.71.0 left production: an admin row carrying the old, incompatible digest. + const SQL = await initSqlJs(); + const raw = new SQL.Database(); + raw.exec(`CREATE TABLE users ( + id TEXT PRIMARY KEY NOT NULL, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + admin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL + )`); + raw.run('INSERT INTO users VALUES (?, ?, ?, 1, ?)', [ + 'admin-kamilkrzywda', + 'kamilkrzywda', + LEGACY_HASH, + Date.now(), + ]); + const file = path.join(dir, 'admin-rotate.db'); + await fs.writeFile(file, Buffer.from(raw.export())); + + // First open heals the row to the current digest… + const first = await openGameDb(file); + let rows = first.raw.exec("SELECT password_hash FROM users WHERE id = 'admin-kamilkrzywda'"); + expect(rows[0].values).toHaveLength(1); + expect(rows[0].values[0][0]).toBe(ADMIN_HASH); + + // …and a second open is a no-op (idempotent rotation). + const second = await openGameDb(file); + rows = second.raw.exec("SELECT password_hash FROM users WHERE id = 'admin-kamilkrzywda'"); + expect(rows[0].values[0][0]).toBe(ADMIN_HASH); + }); + it('does not re-run the v0.12 plane reset on later opens (migration is one-shot)', async () => { const file = path.join(dir, 'pre-v12-reopen.db'); const SQL = await initSqlJs(); diff --git a/lib/db/client.ts b/lib/db/client.ts index 211419b..6bab57c 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -79,7 +79,10 @@ CREATE TABLE IF NOT EXISTS sessions ( // v0.71.0: the seeded admin's scrypt hash (salt$hex). The plaintext exists only in the // operator's head — never in code, tests or comments; login verifies against this string. -const ADMIN_SEED_HASH = '8dbed630b49a15073e8be571367c1858$0e5207dae2e3c813a7172a5f610fd47a90e8ff17513180ab1e0a7779c3766ef0'; +// v0.72.0: recomputed with exactly the same call as verifyPassword() — salt passed as a +// plain string, scryptSync(pw, saltHex, 32) — because the v0.71.0 digest used different +// parameters and real logins failed with 401. +const ADMIN_SEED_HASH = '339149a14282ff3a2ed94534f9722aa4$abaf94cf073c5aafc892b4a39dce97aaf53ab6f7c662a588815bda1bc1771caf'; // v0.71.0: idempotent admin seed — runs on every open(), no-ops when the row exists, so // pre-existing production DBs gain exactly one admin row and never a duplicate. @@ -90,6 +93,13 @@ function seedAdminUser(raw: RawSqlJsDatabase): void { WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'kamilkrzywda')`, [ADMIN_SEED_HASH, Date.now()], ); + // v0.72.0: v0.71.0 seeded an incompatible digest into production DBs and the INSERT above + // can't overwrite them (WHERE NOT EXISTS), so rotate any legacy row to the current digest + // at boot — a no-op once healed, durable on the next persist(). + raw.run( + `UPDATE users SET password_hash = ? WHERE id = 'admin-kamilkrzywda' AND password_hash != ?`, + [ADMIN_SEED_HASH, ADMIN_SEED_HASH], + ); } // v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF diff --git a/package.json b/package.json index 5d562fb..aff8098 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.71.0", + "version": "0.72.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0