v0.72.0: fix admin login — re-seed scrypt digest computed by verifyPassword() call #149

Merged
kamilkrzywda merged 1 commits from fix/admin-login-hash into master 2026-09-24 13:59:28 +02:00
4 changed files with 51 additions and 3 deletions
Showing only changes of commit b5bb0d01ce - Show all commits
+3
View File
@@ -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).
+36 -1
View File
@@ -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();
+11 -1
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.71.0",
"version": "0.72.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",