v0.71.0: admin panel — users table, login/sessions, generic DB CRUD modal
CI / build + unit + e2e (pull_request) Failing after 13m35s
CI / build + unit + e2e (pull_request) Failing after 13m35s
This commit is contained in:
@@ -222,6 +222,42 @@ describe('openGameDb', () => {
|
||||
expect(cols.has('dest_icao')).toBe(true);
|
||||
});
|
||||
|
||||
// v0.71.0: the seeded admin row — hash-only, idempotent across every open().
|
||||
const ADMIN_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');
|
||||
const inst = await openGameDb(file);
|
||||
|
||||
let rows = inst.raw.exec("SELECT id, username, password_hash, admin FROM users WHERE username = 'kamilkrzywda'");
|
||||
expect(rows[0].values).toHaveLength(1);
|
||||
expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', ADMIN_HASH, 1]);
|
||||
|
||||
// Simulate a process restart: brand-new instance over the same file.
|
||||
const second = await openGameDb(file);
|
||||
rows = second.raw.exec('SELECT COUNT(*) FROM users');
|
||||
expect(Number(rows[0].values[0][0])).toBe(1);
|
||||
});
|
||||
|
||||
it('seeds the admin into a legacy DB that predates the users table', async () => {
|
||||
// A pre-v0.71.0 file: no users/sessions tables at all (the v0.6.0 DDL has none).
|
||||
const SQL = await initSqlJs();
|
||||
const raw = new SQL.Database();
|
||||
raw.exec(LEGACY_DDL);
|
||||
raw.exec(`INSERT INTO planes VALUES ('plane-starter', 'atr72', 'available', 6300, 6300, 'EDDF', NULL, 50.03, 8.57)`);
|
||||
raw.exec(`INSERT INTO account VALUES (20000, 10)`);
|
||||
raw.exec('INSERT INTO sim_state VALUES (1750000000000, 50, 0)');
|
||||
const file = path.join(dir, 'legacy-admin.db');
|
||||
await fs.writeFile(file, Buffer.from(raw.export()));
|
||||
|
||||
const inst = await openGameDb(file);
|
||||
const rows = inst.raw.exec("SELECT id, username, admin FROM users WHERE username = 'kamilkrzywda'");
|
||||
expect(rows[0].values).toHaveLength(1);
|
||||
expect(rows[0].values[0]).toEqual(['admin-kamilkrzywda', 'kamilkrzywda', 1]);
|
||||
// The game world is untouched by the seed.
|
||||
expect((await inst.db.select().from(schema.account))[0]?.cash).toBe(20000);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -62,8 +62,36 @@ CREATE TABLE IF NOT EXISTS sim_state (
|
||||
last_real_tick_ms INTEGER NOT NULL DEFAULT 0,
|
||||
last_selected_time_scale REAL NOT NULL DEFAULT 1
|
||||
);
|
||||
-- v0.71.0: accounts + login sessions for the admin panel (and later multiplayer auth).
|
||||
CREATE TABLE IF NOT EXISTS 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
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
// 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.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.
|
||||
function seedAdminUser(raw: RawSqlJsDatabase): void {
|
||||
raw.run(
|
||||
`INSERT INTO users (id, username, password_hash, admin, created_at)
|
||||
SELECT 'admin-kamilkrzywda', 'kamilkrzywda', ?, 1, ?
|
||||
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'kamilkrzywda')`,
|
||||
[ADMIN_SEED_HASH, Date.now()],
|
||||
);
|
||||
}
|
||||
|
||||
// v0.7.0: DBs created by earlier versions lack the new columns; CREATE TABLE IF
|
||||
// NOT EXISTS won't add them, so ALTER (guarded by PRAGMA table_info). Fresh DBs
|
||||
// already have everything from DDL above and this is a no-op for them.
|
||||
@@ -161,6 +189,7 @@ async function open(file: string): Promise<GameDb> {
|
||||
const raw = bytes ? new SQL.Database(new Uint8Array(bytes)) : new SQL.Database();
|
||||
raw.exec(DDL); // no-op for tables that already exist in an older file
|
||||
migrateColumns(raw);
|
||||
seedAdminUser(raw); // v0.71.0 — idempotent; safe on pre-existing production DBs
|
||||
|
||||
// v0.12.0: run BEFORE the seed check — a pre-v0.12.0 file has account rows, so
|
||||
// without this it would keep the dead contracts table forever.
|
||||
|
||||
@@ -84,7 +84,27 @@ export const simState = sqliteTable('sim_state', {
|
||||
lastSelectedTimeScale: real('last_selected_time_scale').notNull().default(1),
|
||||
});
|
||||
|
||||
// v0.71.0: accounts + login sessions for the admin panel (and later multiplayer auth).
|
||||
// The seeded admin row is inserted by lib/db/client.ts on every open() — hash-only, see there.
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
username: text('username').notNull().unique(),
|
||||
// scrypt digest as "salt$hex" (16-byte hex salt, 32-byte hex hash) — verified with node:crypto.
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
admin: integer('admin', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable('sessions', {
|
||||
id: text('id').primaryKey().notNull(),
|
||||
userId: text('user_id').notNull(),
|
||||
// Real-time (Date.now()) epoch ms — 7 days from creation; expired rows are pruned lazily on login.
|
||||
expiresAt: integer('expires_at').notNull(),
|
||||
});
|
||||
|
||||
export type PlaneRow = typeof planes.$inferSelect;
|
||||
export type PackageRow = typeof packages.$inferSelect;
|
||||
export type AccountRow = typeof account.$inferSelect;
|
||||
export type SimStateRow = typeof simState.$inferSelect;
|
||||
export type UserRow = typeof users.$inferSelect;
|
||||
export type SessionRow = typeof sessions.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user