Compare commits

..
Author SHA1 Message Date
kamilkrzywda 837aa60a7d Merge pull request 'v0.73.0: remove new-package toast notifications' (#150) from chore/remove-new-job-toasts into master
CI / build + unit + e2e (push) Successful in 13m46s
Merge pull request 'v0.73.0 — remove new-package toast notifications' (#150) from chore/remove-new-job-toasts into master
2026-09-24 14:39:29 +02:00
kamilkrzywda 7b6a942e0c v0.73.0: remove new-package toast notifications
CI / build + unit + e2e (pull_request) Successful in 13m31s
2026-09-24 12:25:27 +00:00
kamilkrzywda 1a1834abc7 Merge pull request 'v0.72.0: fix admin login — re-seed scrypt digest computed by verifyPassword() call' (#149) from fix/admin-login-hash into master
CI / build + unit + e2e (push) Successful in 13m14s
v0.72.0: fix admin login — re-seed scrypt digest computed by verifyPassword() call
2026-09-24 13:59:28 +02:00
kamilkrzywda b5bb0d01ce v0.72.0: fix admin login — re-seed scrypt digest computed by verifyPassword() call
CI / build + unit + e2e (pull_request) Successful in 14m7s
2026-09-24 11:45:06 +00:00
kamilkrzywda fe23c9ff0f Merge pull request 'v0.71.0 Admin panel — users table, login/sessions, generic DB CRUD modal' (#148) from feat/admin-panel into master
CI / build + unit + e2e (push) Successful in 13m21s
2026-09-24 12:11:46 +02:00
6 changed files with 59 additions and 21 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## v0.73.0 — 2026-09-24 12:24
- **New-package toasts removed.** The client-side snapshot diff in `lib/deriveEvents.ts` no longer emits "New package available" info toasts when a contract appears (the user was spammed by them); the first-snapshot guard and every other event type — delivered, expired, landed, bought, sold — are unchanged.
## 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 ## 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. - **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). - **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(). // 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 () => { it('seeds exactly one admin user on first open and never duplicates on reopen', async () => {
const file = path.join(dir, 'admin-seed.db'); 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); 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 () => { 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 file = path.join(dir, 'pre-v12-reopen.db');
const SQL = await initSqlJs(); 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 // 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. // 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 // 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. // 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')`, WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'kamilkrzywda')`,
[ADMIN_SEED_HASH, Date.now()], [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 // v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
+1 -9
View File
@@ -64,7 +64,7 @@ describe('diffSnapshots', () => {
expect(diffSnapshots(prev, next)).toEqual([]); expect(diffSnapshots(prev, next)).toEqual([]);
}); });
it('returns [] for the first snapshot (no baseline — no "new package" spam on page load)', () => { it('returns [] for the first snapshot (no baseline to diff against)', () => {
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] }); const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
expect(diffSnapshots(null, next)).toEqual([]); expect(diffSnapshots(null, next)).toEqual([]);
}); });
@@ -113,14 +113,6 @@ describe('diffSnapshots', () => {
expect(fromLoaded).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]); expect(fromLoaded).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]);
}); });
it('emits a new-package event for ids only in the next snapshot', () => {
const prev = snapshot({ packages: [basePackage('pkg-1')] });
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
expect(diffSnapshots(prev, next)).toEqual([
{ kind: 'info', message: 'New package available: Frankfurt → Madrid–Barajas' },
]);
});
it('emits bought/sold events when the plane id set changes (with the model name)', () => { it('emits bought/sold events when the plane id set changes (with the model name)', () => {
const bought = diffSnapshots( const bought = diffSnapshots(
snapshot(), snapshot(),
+4 -9
View File
@@ -15,8 +15,7 @@ export interface GameEvent {
const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model; const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model;
export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] { export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] {
// No baseline yet (first snapshot after load) — nothing to compare against. Emitting a // No baseline yet (first snapshot after load) — nothing to diff against, so no events.
// "new package" event for every existing row would spam the stack on page load.
if (prev === null) return []; if (prev === null) return [];
const events: GameEvent[] = []; const events: GameEvent[] = [];
@@ -40,16 +39,12 @@ export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot):
} }
} }
// Packages: delivered (loaded → delivered, reward paid out in the same tick), expired // Packages: delivered (loaded → delivered, reward paid out in the same tick) and expired
// (open|loaded → expired), newly appeared (id only in next — terminal packages are never // (open|loaded → expired). Ids that appear for the first time emit nothing.
// deleted, so an id appearing is always a genuinely new one).
const prevPackages = new Map(prev.packages.map((p) => [p.id, p])); const prevPackages = new Map(prev.packages.map((p) => [p.id, p]));
for (const pkg of next.packages) { for (const pkg of next.packages) {
const before = prevPackages.get(pkg.id); const before = prevPackages.get(pkg.id);
if (!before) { if (!before) continue;
events.push({ kind: 'info', message: `New package available: ${pkg.originName} → ${pkg.destName}` });
continue;
}
if (before.status === 'loaded' && pkg.status === 'delivered') { if (before.status === 'loaded' && pkg.status === 'delivered') {
events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` }); events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` });
} else if ((before.status === 'open' || before.status === 'loaded') && pkg.status === 'expired') { } else if ((before.status === 'open' || before.status === 'loaded') && pkg.status === 'expired') {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.71.0", "version": "0.73.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",