From b89d351587a7512cdb50a68ed9acbf60e3046d0f Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 24 Sep 2026 09:24:31 +0000 Subject: [PATCH 1/2] =?UTF-8?q?v0.71.0:=20admin=20panel=20=E2=80=94=20user?= =?UTF-8?q?s=20table,=20login/sessions,=20generic=20DB=20CRUD=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 + .../admin/tables/[name]/rows/route.test.ts | 184 ++++++++ app/api/admin/tables/[name]/rows/route.ts | 219 +++++++++ app/api/admin/tables/route.test.ts | 80 ++++ app/api/admin/tables/route.ts | 14 + app/api/login/route.test.ts | 93 ++++ app/api/login/route.ts | 52 +++ app/api/logout/route.test.ts | 60 +++ app/api/logout/route.ts | 16 + app/api/me/route.test.ts | 72 +++ app/api/me/route.ts | 11 + app/api/test/admin-session/route.ts | 23 + app/globals.css | 139 ++++++ components/AdminPanel.tsx | 414 ++++++++++++++++++ components/Map.tsx | 12 + components/Modal.tsx | 33 ++ e2e/admin.spec.ts | 109 +++++ e2e/api.spec.ts | 10 +- lib/auth.ts | 74 ++++ lib/db/client.test.ts | 36 ++ lib/db/client.ts | 29 ++ lib/db/schema.ts | 20 + lib/openapi.test.ts | 56 ++- lib/openapi.ts | 327 +++++++++++++- package.json | 2 +- 25 files changed, 2089 insertions(+), 6 deletions(-) create mode 100644 app/api/admin/tables/[name]/rows/route.test.ts create mode 100644 app/api/admin/tables/[name]/rows/route.ts create mode 100644 app/api/admin/tables/route.test.ts create mode 100644 app/api/admin/tables/route.ts create mode 100644 app/api/login/route.test.ts create mode 100644 app/api/login/route.ts create mode 100644 app/api/logout/route.test.ts create mode 100644 app/api/logout/route.ts create mode 100644 app/api/me/route.test.ts create mode 100644 app/api/me/route.ts create mode 100644 app/api/test/admin-session/route.ts create mode 100644 components/AdminPanel.tsx create mode 100644 components/Modal.tsx create mode 100644 e2e/admin.spec.ts create mode 100644 lib/auth.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d79cbb6..c75a91f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 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). +- **Auth API.** `POST /api/login {username,password}` verifies with node:crypto `scryptSync` + `timingSafeEqual`; an unknown username still runs one full scrypt against a fixed dummy digest so timing stays uniform. Success mints a 7-day session (lazy cleanup of expired rows) and returns `{sessionId, user:{username,admin}}`. `POST /api/logout` deletes the session named by the new `X-Flight-Session` header (idempotent); `GET /api/me` resolves it to the current user or 401. The client stores the sessionId in localStorage (`flight.session`) and sends the header on every authenticated request. +- **Generic whole-database CRUD behind the admin flag.** `lib/auth.ts`'s `requireAdmin` guard (no/expired session → 401, non-admin → 403) runs before any input is touched on: `GET /api/admin/tables` (user tables, sqlite_% excluded) and `GET|PUT|POST|DELETE /api/admin/tables/{name}/rows` — rows carry their rowid as `__rowid` so the PK-less tables (account, sim_state) are editable like any other; PUT updates only changed cells, POST inserts, DELETE removes by rowid. SQL safety: table names quoted only after sqlite_master validation, column names validated against PRAGMA table_info, values ALWAYS bound as parameters — no user input is ever concatenated into SQL. +- **UI.** The Admin modal shows a login form (wrong credentials show an error line), a "not an admin" notice for authenticated non-admins, or the grid: table list → per-table editable rows (each cell an input; per-row Save/Delete, "+ Add row", Refresh) with logout in the header area. Deliberately plain — it's a dev tool surface, not a product feature. +- **Test hook.** `POST /api/test/admin-session` (FLIGHT_TEST_HOOKS=1 gate like its siblings) mints a session for the seeded admin server-side and returns `{sessionId}` — so the e2e suite can exercise the authenticated panel WITHOUT the real admin password ever appearing in test code. +- **OpenAPI.** Five new paths documented (`/api/login`, `/api/logout`, `/api/me`, `/api/admin/tables`, `/api/admin/tables/{name}/rows` with all four verbs) plus their request/response schemas; endpoint count twelve → seventeen everywhere it is asserted (unit + e2e). +- **Tests.** Unit: the idempotent admin seed (fresh DB, reopen, and a legacy pre-v0.71.0 file), login success/wrong-password/unknown-username/malformed bodies with an in-test scrypt hash for a self-created user (the real admin's plaintext is never referenced), logout idempotency, /api/me with missing/unknown/expired/live sessions, the guard ladder (401 no header, 401 expired, 403 non-admin) and the full CRUD validation matrix (rows incl. `__rowid` for account, PUT-verified-by-refetch, POST+DELETE round-trip, unknown table 404, unknown column/bad limit/missing rowid 400). E2E (`e2e/admin.spec.ts`, CI-only): open modal → login form → wrong creds error → hook-minted session via localStorage → table list incl. packages/users → edit a cell + Save verified by an independent re-fetch → add + delete a users row → logout back to the login form. + ## v0.70.0 — 2026-09-23 22:07 - **Default map view is now the whole world.** The initial camera in `components/Map.tsx` moves from the Europe overview `[10,52]` z4 to null island `[0,0]` (renamed constant `WORLD_CENTER`) at zoom 2 — at z2 the world is 2048 px wide and the Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator, so both continents are fully visible on a typical desktop map pane. `MIN_ZOOM` is lowered 3 → 0: when fully zoomed out the whole earth (512 px world) fits any viewport; `MAX_ZOOM = 11` and the flat-2D view are unchanged. - **E2E.** The new default view shows both Africa + Europe with overlapping markers — LFRS's nearest neighbour (LFRB Brest, 254 km) is only ~13–19 px away at z2, so every marker-click spec now frames its target first: `e2e/map.spec.ts` gains a shared `frameLfrs` helper (`jumpTo` z5 + wait until the marker renders), used by all three of its click tests; `e2e/map-interactions.spec.ts` and `e2e/relocation.spec.ts` frame LFRS at z5 before their popup clicks. The plane-marker test in `e2e/map-interactions.spec.ts` fits EDDF+LEMD into the viewport first — `queryRenderedFeatures` is viewport-scoped and EDDF (50°N) sits right at the edge of the new default view. `e2e/hover.spec.ts`: the antimeridian test's `setMinZoom(0)` workaround is deleted — minZoom is natively 0 now, so `fitToAirports` can reach the z≈1 that EDDF→NFTF needs without lifting any floor. New assertion in `e2e/map.spec.ts` pins the initial camera to [0,0] z2; the wheel-clamp spec's MIN_ZOOM mirror is updated 3 → 0 (its zoom-out side now clamps at 0). diff --git a/app/api/admin/tables/[name]/rows/route.test.ts b/app/api/admin/tables/[name]/rows/route.test.ts new file mode 100644 index 0000000..c6d3c63 --- /dev/null +++ b/app/api/admin/tables/[name]/rows/route.test.ts @@ -0,0 +1,184 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getGameDb } from '@/lib/db/client'; +import { createSession } from '@/lib/auth'; +import { DELETE, GET, POST, PUT } from './route'; + +// v0.71.0 — generic DB CRUD route tests: rows fetch (incl. __rowid for PK-less tables), +// parameterized update/insert/delete, and the validation ladder (404 unknown table, 400 bad input). + +let dir: string; +let adminSession = ''; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-admin-rows-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'rows.db'); +}); + +afterAll(async () => { + delete process.env.FLIGHT_DB_PATH; + await fs.rm(dir, { recursive: true, force: true }); +}); + +// adminSession is minted in beforeAll, so the header helpers read it at call time. +const authHeaders = () => ({ 'X-Flight-Session': adminSession }); + +// The route handlers take Next.js's dynamic-segment context; unit tests supply it directly. +const ctx = (name: string) => ({ params: Promise.resolve({ name }) }); + +const getRows = (name: string, query = '') => + GET(new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows${query}`, { headers: authHeaders() }), ctx(name)); +const putRow = (name: string, body: unknown) => + PUT( + new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows`, { + method: 'PUT', + headers: { ...authHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify(body), + }), + ctx(name), + ); +const postRow = (name: string, body: unknown) => + POST( + new Request(`http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows`, { + method: 'POST', + headers: { ...authHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify(body), + }), + ctx(name), + ); +const deleteRow = (name: string, rowid: number | null) => + DELETE( + new Request( + `http://localhost/api/admin/tables/${encodeURIComponent(name)}/rows${rowid === null ? '' : `?rowid=${rowid}`}`, + { method: 'DELETE', headers: authHeaders() }, + ), + ctx(name), + ); + +describe('admin rows CRUD', () => { + beforeAll(async () => { + adminSession = await createSession(await getGameDb(), 'admin-kamilkrzywda'); + }); + + it('GET returns columns + rows with __rowid for the PK-less account table', async () => { + const res = await getRows('account'); + expect(res.status).toBe(200); + const body = (await res.json()) as { columns: string[]; rows: Array> }; + expect(body.columns[0]).toBe('__rowid'); + for (const col of ['cash', 'reputation']) expect(body.columns).toContain(col); + expect(body.rows).toHaveLength(1); // the single seeded account row + expect(typeof body.rows[0].__rowid).toBe('number'); + expect(typeof body.rows[0].cash).toBe('number'); + }); + + it('PUT updates a cell and the change survives a re-fetch', async () => { + const before = await (await getRows('account')).json(); + const originalCash = (before as { rows: Array> }).rows[0].cash; + + const res = await putRow('account', { rowid: 1, values: { cash: 424_242 } }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true }); + + // Re-fetch through the same route — the persisted value is back. + const body = await (await getRows('account')).json(); + expect((body as { rows: Array> }).rows[0].cash).toBe(424_242); + + // Restore the original cash so later tests see a sane world. + const restore = await putRow('account', { rowid: 1, values: { cash: originalCash } }); + expect(restore.status).toBe(200); + }); + + it('PUT rejects unknown columns and empty values with 400', async () => { + const badCol = await putRow('account', { rowid: 1, values: { no_such_column: 1 } }); + expect(badCol.status).toBe(400); + expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i); + + const empty = await putRow('account', { rowid: 1, values: {} }); + expect(empty.status).toBe(400); + + const noRowid = await putRow('account', { values: { cash: 1 } }); + expect(noRowid.status).toBe(400); + }); + + it('POST inserts a row and DELETE removes it again (users table)', async () => { + const created = await postRow('users', { + values: { id: 'user-crud-test', username: 'crud-test-user', password_hash: 'aa$bb', created_at: Date.now() }, + }); + expect(created.status).toBe(200); + const body = (await created.json()) as { ok: boolean; rowid: number }; + expect(body.ok).toBe(true); + expect(Number.isInteger(body.rowid)).toBe(true); + + // Visible via GET… + let list = await (await getRows('users')).json(); + const rows = (list as { rows: Array> }).rows; + expect(rows.some((r) => r.username === 'crud-test-user')).toBe(true); + + // …and gone after DELETE. + const del = await deleteRow('users', body.rowid); + expect(del.status).toBe(200); + list = await (await getRows('users')).json(); + expect((list as { rows: Array> }).rows.some((r) => r.username === 'crud-test-user')).toBe(false); + + // DELETE is idempotent. + const again = await deleteRow('users', body.rowid); + expect(again.status).toBe(200); + }); + + it('POST rejects unknown columns and empty values with 400', async () => { + const badCol = await postRow('users', { values: { id: 'x', no_such_column: 1 } }); + expect(badCol.status).toBe(400); + expect(((await badCol.json()) as { error: string }).error).toMatch(/unknown column/i); + + const empty = await postRow('users', { values: {} }); + expect(empty.status).toBe(400); + }); + + it('DELETE requires an integer rowid query parameter', async () => { + const missing = await deleteRow('users', null); + expect(missing.status).toBe(400); + const bad = await DELETE( + new Request('http://localhost/api/admin/tables/users/rows?rowid=abc', { method: 'DELETE', headers: authHeaders() }), + ctx('users'), + ); + expect(bad.status).toBe(400); + }); + + it('returns 404 for an unknown table on every verb', async () => { + const g = await getRows('no_such_table'); + expect(g.status).toBe(404); + const p = await putRow('no_such_table', { rowid: 1, values: { x: 1 } }); + expect(p.status).toBe(404); + const i = await postRow('no_such_table', { values: { x: 1 } }); + expect(i.status).toBe(404); + const d = await deleteRow('no_such_table', 1); + expect(d.status).toBe(404); + }); + + it('rejects a non-integer limit with 400 and caps at 1000', async () => { + const bad = await getRows('users', '?limit=abc'); + expect(bad.status).toBe(400); + const capped = await getRows('users', '?limit=999999'); + expect(capped.status).toBe(200); + }); + + it('keeps the guard in front of everything: no header → 401, non-admin → 403', async () => { + const noHeader = await GET(new Request('http://localhost/api/admin/tables/users/rows'), ctx('users')); + expect(noHeader.status).toBe(401); + + const inst = await getGameDb(); + inst.raw.run( + `INSERT INTO users (id, username, password_hash, admin, created_at) + SELECT 'user-rows-nonadmin', 'rows-nonadmin', 'aa$bb', 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = 'user-rows-nonadmin')`, + [Date.now()], + ); + const nonAdminSession = await createSession(inst, 'user-rows-nonadmin'); + const forbidden = await GET( + new Request('http://localhost/api/admin/tables/users/rows', { headers: { 'X-Flight-Session': nonAdminSession } }), + ctx('users'), + ); + expect(forbidden.status).toBe(403); + }); +}); diff --git a/app/api/admin/tables/[name]/rows/route.ts b/app/api/admin/tables/[name]/rows/route.ts new file mode 100644 index 0000000..90ff439 --- /dev/null +++ b/app/api/admin/tables/[name]/rows/route.ts @@ -0,0 +1,219 @@ +import { persist } from '@/lib/db/client'; +import type { GameDb } from '@/lib/db/client'; +import { requireAdmin } from '@/lib/auth'; + +// v0.71.0 — generic whole-database CRUD for the Admin modal (admin only, guarded BEFORE any +// user input is touched). SQL safety: table names are quoted only after validation against +// sqlite_master, column names only after validation against PRAGMA table_info, and VALUES ARE +// ALWAYS BOUND AS PARAMETERS — no user input is ever concatenated into a SQL string. + +const DEFAULT_LIMIT = 500; +const MAX_LIMIT = 1000; + +function quoteIdent(name: string): string { + return '"' + name.replace(/"/g, '""') + '"'; +} + +// The app's own tables (sqlite_% internals excluded) — the only names ever accepted. +function tableNames(inst: GameDb): Set { + const res = inst.raw.exec("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"); + return new Set(res[0].values.map((r) => String(r[0]))); +} + +// Column names of a validated table (name already checked against sqlite_master). +function columnsOf(inst: GameDb, name: string): string[] { + const res = inst.raw.exec(`PRAGMA table_info('${name.replace(/'/g, "''")}')`); + return res[0].values.map((r) => String(r[1])); +} + +// Parameterized execute — the only way values reach SQL in this file. +function run(inst: GameDb, sql: string, values: unknown[]): void { + inst.raw.run(sql, values as never[]); +} + +async function readBody(req: Request): Promise | Response> { + let body: unknown; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : undefined; + } catch { + return Response.json({ error: 'invalid JSON body' }, { status: 400 }); + } + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); + } + const input = body as Record; + for (const key of Object.keys(input)) { + if (!ALLOWED_BODY_FIELDS.has(key)) { + return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); + } + } + return input; +} + +// Per-method top-level body fields (strict, like the /api/test/* hooks). +const ALLOWED_BODY_FIELDS = new Set(['rowid', 'values']); + +function valuesObject(input: Record): Record | Response { + const values = input.values; + if (typeof values !== 'object' || values === null || Array.isArray(values)) { + return Response.json({ error: 'values must be a JSON object of column → value' }, { status: 400 }); + } + return values as Record; +} + +// Constraint violations (NOT NULL / UNIQUE) are the expected failure mode of a dev-tool edit; +// anything else is a server error. +function sqlError(err: unknown): Response { + const msg = err instanceof Error ? err.message : String(err); + if (/NOT NULL|UNIQUE|constraint failed/i.test(msg)) { + return Response.json({ error: `database constraint: ${msg}` }, { status: 409 }); + } + console.error('admin rows route failed', err); + return Response.json({ error: 'database error' }, { status: 500 }); +} + +// GET /api/admin/tables/{name}/rows?limit=500 — column headers + up to `limit` rows (default +// 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (account, sim_state) are +// editable like any other. Unknown table → 404; bad limit → 400. +export async function GET(req: Request, ctx: { params: Promise<{ name: string }> }) { + const guard = await requireAdmin(req); + if (!guard.ok) return guard.response; + + const { name } = await ctx.params; + if (!tableNames(guard.inst).has(name)) { + return Response.json({ error: 'unknown table' }, { status: 404 }); + } + let limit = DEFAULT_LIMIT; + const limitParam = new URL(req.url).searchParams.get('limit'); + if (limitParam !== null) { + limit = Number(limitParam); + if (!Number.isInteger(limit) || limit < 1) { + return Response.json({ error: 'limit must be a positive integer' }, { status: 400 }); + } + limit = Math.min(limit, MAX_LIMIT); + } + + const cols = columnsOf(guard.inst, name); + const stmt = guard.inst.raw.prepare(`SELECT rowid AS __rowid, * FROM ${quoteIdent(name)} LIMIT ?`); + try { + stmt.bind([limit]); + const rows: Array> = []; + while (stmt.step()) rows.push(stmt.getAsObject() as Record); + return Response.json({ columns: ['__rowid', ...cols], rows }); + } catch (err) { + return sqlError(err); + } finally { + stmt.free(); + } +} + +// PUT /api/admin/tables/{name}/rows — body { rowid, values: { col: value } }. Updates only the +// given columns of that row. Unknown table → 404; unknown column or non-integer rowid or empty +// values → 400. +export async function PUT(req: Request, ctx: { params: Promise<{ name: string }> }) { + const guard = await requireAdmin(req); + if (!guard.ok) return guard.response; + + const input = await readBody(req); + if (input instanceof Response) return input; + const rowid = input.rowid; + if (!Number.isInteger(rowid)) { + return Response.json({ error: 'rowid is required and must be an integer' }, { status: 400 }); + } + const values = await valuesObject(input); + if (values instanceof Response) return values; + + const { name } = await ctx.params; + if (!tableNames(guard.inst).has(name)) { + return Response.json({ error: 'unknown table' }, { status: 404 }); + } + const cols = columnsOf(guard.inst, name); + for (const col of Object.keys(values)) { + if (!cols.includes(col)) { + return Response.json({ error: `unknown column "${col}"` }, { status: 400 }); + } + } + if (Object.keys(values).length === 0) { + return Response.json({ error: 'values must contain at least one column' }, { status: 400 }); + } + + const setClause = Object.keys(values).map((c) => `${quoteIdent(c)} = ?`).join(', '); + try { + run(guard.inst, `UPDATE ${quoteIdent(name)} SET ${setClause} WHERE rowid = ?`, [ + ...Object.values(values), + rowid, + ]); + await persist(guard.inst); + return Response.json({ ok: true }); + } catch (err) { + return sqlError(err); + } +} + +// POST /api/admin/tables/{name}/rows — body { values: { col: value } }. Inserts a new row with +// exactly the given columns (others take their defaults or NULL). Returns the new rowid. +export async function POST(req: Request, ctx: { params: Promise<{ name: string }> }) { + const guard = await requireAdmin(req); + if (!guard.ok) return guard.response; + + const input = await readBody(req); + if (input instanceof Response) return input; + const values = await valuesObject(input); + if (values instanceof Response) return values; + if (Object.keys(values).length === 0) { + return Response.json({ error: 'values must contain at least one column' }, { status: 400 }); + } + + const { name } = await ctx.params; + if (!tableNames(guard.inst).has(name)) { + return Response.json({ error: 'unknown table' }, { status: 404 }); + } + const cols = columnsOf(guard.inst, name); + for (const col of Object.keys(values)) { + if (!cols.includes(col)) { + return Response.json({ error: `unknown column "${col}"` }, { status: 400 }); + } + } + + const keys = Object.keys(values); + try { + run( + guard.inst, + `INSERT INTO ${quoteIdent(name)} (${keys.map(quoteIdent).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`, + Object.values(values), + ); + // Read the new rowid BEFORE persist(): sql.js's export() (inside persist) resets + // last_insert_rowid to 0 on this connection. + const res = guard.inst.raw.exec('SELECT last_insert_rowid()'); + await persist(guard.inst); + return Response.json({ ok: true, rowid: Number(res[0].values[0][0]) }); + } catch (err) { + return sqlError(err); + } +} + +// DELETE /api/admin/tables/{name}/rows?rowid=N — deletes that one row. Unknown table → 404; +// missing/non-integer rowid → 400. Deleting a non-existent rowid is still 200 (idempotent). +export async function DELETE(req: Request, ctx: { params: Promise<{ name: string }> }) { + const guard = await requireAdmin(req); + if (!guard.ok) return guard.response; + + const { name } = await ctx.params; + if (!tableNames(guard.inst).has(name)) { + return Response.json({ error: 'unknown table' }, { status: 404 }); + } + const rowidParam = new URL(req.url).searchParams.get('rowid'); + const rowid = Number(rowidParam); + if (rowidParam === null || !Number.isInteger(rowid)) { + return Response.json({ error: 'rowid query parameter is required and must be an integer' }, { status: 400 }); + } + + try { + run(guard.inst, `DELETE FROM ${quoteIdent(name)} WHERE rowid = ?`, [rowid]); + await persist(guard.inst); + return Response.json({ ok: true }); + } catch (err) { + return sqlError(err); + } +} diff --git a/app/api/admin/tables/route.test.ts b/app/api/admin/tables/route.test.ts new file mode 100644 index 0000000..ddc4a2b --- /dev/null +++ b/app/api/admin/tables/route.test.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getGameDb } from '@/lib/db/client'; +import { createSession } from '@/lib/auth'; +import { GET } from './route'; + +// v0.71.0 — admin guard + table list tests: no/expired session → 401, non-admin → 403, +// admin passes and sees every user table (sqlite_% excluded). + +let dir: string; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-admin-tables-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'tables.db'); +}); + +afterAll(async () => { + delete process.env.FLIGHT_DB_PATH; + await fs.rm(dir, { recursive: true, force: true }); +}); + +const get = (sessionId?: string) => + GET( + new Request('http://localhost/api/admin/tables', { + headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined, + }), + ); + +async function makeUser(username: string, admin: number): Promise { + const inst = await getGameDb(); + const id = `user-${username}`; + inst.raw.run( + `INSERT INTO users (id, username, password_hash, admin, created_at) + SELECT ?, ?, 'aa$bb', ?, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = ?)`, + [id, username, admin, Date.now(), id], + ); + return id; +} + +describe('GET /api/admin/tables', () => { + it('returns 401 without a session header', async () => { + const res = await get(); + expect(res.status).toBe(401); + }); + + it('returns 401 for an expired session', async () => { + const inst = await getGameDb(); + inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [ + 'expired-admin-session', + 'admin-kamilkrzywda', + Date.now() - 1000, + ]); + const res = await get('expired-admin-session'); + expect(res.status).toBe(401); + }); + + it('returns 403 for a valid non-admin session', async () => { + const userId = await makeUser('tables-nonadmin', 0); + const sessionId = await createSession(await getGameDb(), userId); + const res = await get(sessionId); + expect(res.status).toBe(403); + expect(((await res.json()) as { error: string }).error).toMatch(/admin/i); + }); + + it('lists every user table for an admin, excluding sqlite_% internals', async () => { + const sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda'); + const res = await get(sessionId); + expect(res.status).toBe(200); + const names = ((await res.json()) as Array<{ name: string }>).map((t) => t.name); + + for (const expected of ['account', 'packages', 'planes', 'sessions', 'sim_state', 'users']) { + expect(names, `missing ${expected}`).toContain(expected); + } + expect(names.some((n) => n.startsWith('sqlite_'))).toBe(false); + // Name-ordered. + expect([...names].sort()).toEqual(names); + }); +}); diff --git a/app/api/admin/tables/route.ts b/app/api/admin/tables/route.ts new file mode 100644 index 0000000..0e3f035 --- /dev/null +++ b/app/api/admin/tables/route.ts @@ -0,0 +1,14 @@ +import { requireAdmin } from '@/lib/auth'; + +// v0.71.0 — GET /api/admin/tables (admin only). Lists every user table as [{ name }] in name +// order, excluding SQLite internals (sqlite_%). Feeds the Admin modal's table picker. + +export async function GET(req: Request) { + const guard = await requireAdmin(req); + if (!guard.ok) return guard.response; + + const res = guard.inst.raw.exec( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ); + return Response.json(res[0].values.map((r) => ({ name: String(r[0]) }))); +} diff --git a/app/api/login/route.test.ts b/app/api/login/route.test.ts new file mode 100644 index 0000000..c249d8d --- /dev/null +++ b/app/api/login/route.test.ts @@ -0,0 +1,93 @@ +import { randomUUID, scryptSync } from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getGameDb } from '@/lib/db/client'; +import { SESSION_TTL_MS } from '@/lib/auth'; +import { POST } from './route'; + +// v0.71.0 — login route tests. The test user is created HERE with a hash computed in-test via +// scryptSync; the seeded admin's plaintext password must never appear in any test code, so it +// is deliberately not exercised here (the e2e suite gets an admin session via /api/test/admin-session). + +let dir: string; +const TEST_USER = 'unit-test-user'; +const TEST_PASSWORD = 'right-horse-wrong-street-42'; +const SALT_HEX = randomUUID().replace(/-/g, '').slice(0, 32); // 16-byte hex salt +const HASH = `${SALT_HEX}$${scryptSync(TEST_PASSWORD, SALT_HEX, 32).toString('hex')}`; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-login-api-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'login.db'); +}); + +afterAll(async () => { + delete process.env.FLIGHT_DB_PATH; + await fs.rm(dir, { recursive: true, force: true }); +}); + +const post = (body: unknown) => POST(new Request('http://localhost/api/login', { method: 'POST', body: JSON.stringify(body) })); + +async function createTestUser(): Promise { + const inst = await getGameDb(); + // Idempotent — two tests in this file both want the user. + inst.raw.run( + `INSERT INTO users (id, username, password_hash, admin, created_at) + SELECT 'user-unit-test', ?, ?, 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = ?)`, + [TEST_USER, HASH, Date.now(), TEST_USER], + ); +} + +describe('POST /api/login', () => { + it('rejects malformed bodies with 400', async () => { + for (const body of [{}, { username: 'x' }, { password: 'y' }, { username: '', password: 'p' }, { bogus: 1 }]) { + const res = await post(body); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBeTruthy(); + } + }); + + it('returns 401 for an unknown username (and still runs a scrypt)', async () => { + const res = await post({ username: 'no-such-user', password: TEST_PASSWORD }); + expect(res.status).toBe(401); + expect(((await res.json()) as { error: string }).error).toMatch(/invalid credentials/i); + }); + + it('returns 401 for a wrong password and mints no session', async () => { + await createTestUser(); + const before = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions')[0].values[0][0]; + + const res = await post({ username: TEST_USER, password: 'definitely-wrong' }); + expect(res.status).toBe(401); + + const after = Number((await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions')[0].values[0][0]); + expect(after).toBe(Number(before)); + }); + + it('mints a ~7-day session on success and returns the user', async () => { + await createTestUser(); + const t0 = Date.now(); + + const res = await post({ username: TEST_USER, password: TEST_PASSWORD }); + expect(res.status).toBe(200); + const body = (await res.json()) as { sessionId: string; user: { username: string; admin: boolean } }; + expect(body.user).toEqual({ username: TEST_USER, admin: false }); + expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/); + + const row = (await getGameDb()).raw.exec('SELECT user_id, expires_at FROM sessions WHERE id = ?', [body.sessionId])[0].values[0]; + expect(String(row[0])).toBe('user-unit-test'); + const ttl = Number(row[1]) - t0; + expect(ttl).toBeGreaterThan(SESSION_TTL_MS - 5_000); // ~7 days, minus test drift + expect(ttl).toBeLessThan(SESSION_TTL_MS + 5_000); + }); + + it('login works for the seeded admin row too (hash verified without its plaintext)', async () => { + // The seeded admin's hash is a fixed string in client.ts; we can only prove the route + // ACCEPTS exactly that stored digest by checking verification against a known pair — which + // would require the plaintext. So instead: assert the seeded row is what login looks up, by + // confirming an unknown-password attempt for 'kamilkrzywda' yields 401 (row found, hash mismatch). + const res = await post({ username: 'kamilkrzywda', password: 'wrong-on-purpose' }); + expect(res.status).toBe(401); + }); +}); diff --git a/app/api/login/route.ts b/app/api/login/route.ts new file mode 100644 index 0000000..5df9cb7 --- /dev/null +++ b/app/api/login/route.ts @@ -0,0 +1,52 @@ +import { getGameDb } from '@/lib/db/client'; +import { createSession, verifyPassword, DUMMY_HASH_FOR_UNKNOWN_USER } from '@/lib/auth'; + +// v0.71.0 — POST /api/login +// body { username: string, password: string } (both required non-empty; unknown fields 400). +// Success → 200 { sessionId, user: { username, admin } }; the session row lives 7 days and is +// sent back as X-Flight-Session on later requests. Failure → 401 { error }. An unknown +// username still runs one full scrypt against a fixed dummy digest so timing stays uniform. + +export async function POST(req: Request) { + let body: unknown; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : undefined; + } catch { + return Response.json({ error: 'invalid JSON body' }, { status: 400 }); + } + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); + } + const input = body as Record; + for (const key of Object.keys(input)) { + if (key !== 'username' && key !== 'password') { + return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); + } + } + const username = input.username; + const password = input.password; + if (typeof username !== 'string' || username.length === 0) { + return Response.json({ error: 'username is required and must be a non-empty string' }, { status: 400 }); + } + if (typeof password !== 'string' || password.length === 0) { + return Response.json({ error: 'password is required and must be a non-empty string' }, { status: 400 }); + } + + const inst = await getGameDb(); + const res = inst.raw.exec('SELECT id, password_hash, admin FROM users WHERE username = ?', [username]); + const row = res[0]?.values?.[0]; + if (!row) { + verifyPassword(password, DUMMY_HASH_FOR_UNKNOWN_USER); // uniform timing — result discarded + return Response.json({ error: 'invalid credentials' }, { status: 401 }); + } + if (!verifyPassword(password, String(row[1]))) { + return Response.json({ error: 'invalid credentials' }, { status: 401 }); + } + + const sessionId = await createSession(inst, String(row[0])); + return Response.json({ + sessionId, + user: { username, admin: Number(row[2]) === 1 }, + }); +} diff --git a/app/api/logout/route.test.ts b/app/api/logout/route.test.ts new file mode 100644 index 0000000..f983b48 --- /dev/null +++ b/app/api/logout/route.test.ts @@ -0,0 +1,60 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getGameDb } from '@/lib/db/client'; +import { createSession } from '@/lib/auth'; +import { POST } from './route'; + +// v0.71.0 — logout route tests: deletes the session named by X-Flight-Session, idempotently. + +let dir: string; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-logout-api-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'logout.db'); +}); + +afterAll(async () => { + delete process.env.FLIGHT_DB_PATH; + await fs.rm(dir, { recursive: true, force: true }); +}); + +const post = (sessionId?: string) => + POST( + new Request('http://localhost/api/logout', { + method: 'POST', + headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined, + }), + ); + +async function mintSession(): Promise { + const inst = await getGameDb(); + return createSession(inst, 'admin-kamilkrzywda'); // any user id works — logout only deletes the session row +} + +describe('POST /api/logout', () => { + it('returns 401 without a session header', async () => { + const res = await post(); + expect(res.status).toBe(401); + }); + + it('deletes the named session and is idempotent on repeat calls', async () => { + const sessionId = await mintSession(); + const count = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions WHERE id = ?', [sessionId])[0].values[0][0]; + expect(Number(count)).toBe(1); + + const first = await post(sessionId); + expect(first.status).toBe(200); + expect(await first.json()).toEqual({ ok: true }); + + // The row is gone… + const after = (await getGameDb()).raw.exec('SELECT COUNT(*) FROM sessions WHERE id = ?', [sessionId])[0].values[0][0]; + expect(Number(after)).toBe(0); + + // …and a second logout of the same (now unknown) session is still 200. + const second = await post(sessionId); + expect(second.status).toBe(200); + expect(await second.json()).toEqual({ ok: true }); + }); +}); diff --git a/app/api/logout/route.ts b/app/api/logout/route.ts new file mode 100644 index 0000000..2be77b5 --- /dev/null +++ b/app/api/logout/route.ts @@ -0,0 +1,16 @@ +import { getGameDb, persist } from '@/lib/db/client'; +import { SESSION_HEADER } from '@/lib/auth'; + +// v0.71.0 — POST /api/logout (no body). Deletes the session named by the X-Flight-Session +// header; idempotent — deleting an unknown or already-deleted session is still 200. A missing +// header has nothing to delete and gets 401 like every other unauthenticated request here. + +export async function POST(req: Request) { + const sessionId = req.headers.get(SESSION_HEADER); + if (!sessionId) return Response.json({ error: 'authentication required' }, { status: 401 }); + + const inst = await getGameDb(); + inst.raw.run('DELETE FROM sessions WHERE id = ?', [sessionId]); + await persist(inst); + return Response.json({ ok: true }); +} diff --git a/app/api/me/route.test.ts b/app/api/me/route.test.ts new file mode 100644 index 0000000..799c3e6 --- /dev/null +++ b/app/api/me/route.test.ts @@ -0,0 +1,72 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { getGameDb } from '@/lib/db/client'; +import { createSession } from '@/lib/auth'; +import { GET } from './route'; + +// v0.71.0 — /api/me tests: resolves X-Flight-Session to its user, 401 otherwise. + +let dir: string; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-me-api-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'me.db'); +}); + +afterAll(async () => { + delete process.env.FLIGHT_DB_PATH; + await fs.rm(dir, { recursive: true, force: true }); +}); + +const get = (sessionId?: string) => + GET( + new Request('http://localhost/api/me', { + headers: sessionId ? { 'X-Flight-Session': sessionId } : undefined, + }), + ); + +describe('GET /api/me', () => { + it('returns 401 without a session header', async () => { + const res = await get(); + expect(res.status).toBe(401); + }); + + it('returns 401 for an unknown session id', async () => { + const res = await get('00000000-0000-4000-8000-000000000000'); + expect(res.status).toBe(401); + }); + + it('returns 401 for an expired session', async () => { + const inst = await getGameDb(); + // A session whose expiry is already in the past. + inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [ + 'expired-session-id', + 'admin-kamilkrzywda', + Date.now() - 1000, + ]); + const res = await get('expired-session-id'); + expect(res.status).toBe(401); + }); + + it('returns the user for a live session (admin flag included)', async () => { + const sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda'); + const res = await get(sessionId); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ username: 'kamilkrzywda', admin: true }); + }); + + it('reports non-admin users as such', async () => { + const inst = await getGameDb(); + inst.raw.run( + `INSERT INTO users (id, username, password_hash, admin, created_at) + SELECT 'user-me-test', 'me-test-user', 'aa$bb', 0, ? WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = 'user-me-test')`, + [Date.now()], + ); + const sessionId = await createSession(inst, 'user-me-test'); + const res = await get(sessionId); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ username: 'me-test-user', admin: false }); + }); +}); diff --git a/app/api/me/route.ts b/app/api/me/route.ts new file mode 100644 index 0000000..15df2f4 --- /dev/null +++ b/app/api/me/route.ts @@ -0,0 +1,11 @@ +import { currentUser } from '@/lib/auth'; + +// v0.71.0 — GET /api/me (no body). Returns the user behind a valid X-Flight-Session header as +// { username, admin }, or 401 when the header is missing/unknown/expired. The client uses it to +// decide what the Admin modal shows on open. + +export async function GET(req: Request) { + const user = await currentUser(req); + if (!user) return Response.json({ error: 'authentication required' }, { status: 401 }); + return Response.json({ username: user.username, admin: user.admin }); +} diff --git a/app/api/test/admin-session/route.ts b/app/api/test/admin-session/route.ts new file mode 100644 index 0000000..bdbabb9 --- /dev/null +++ b/app/api/test/admin-session/route.ts @@ -0,0 +1,23 @@ +import { getGameDb } from '@/lib/db/client'; +import { createSession } from '@/lib/auth'; + +// TEST-ONLY hook (v0.71.0) — mints a live session for the SEEDED admin user server-side and +// returns its id, so the e2e suite can exercise the authenticated Admin panel WITHOUT the real +// admin password ever appearing in test code (the spec forbids it). No body. Disabled unless +// FLIGHT_TEST_HOOKS=1 (set in docker-compose.ci.yml for the CI stack only); every other +// deployment 404s here, like its /api/test/* siblings. + +export async function POST() { + if (process.env.FLIGHT_TEST_HOOKS !== '1') { + return Response.json({ error: 'test hooks disabled' }, { status: 404 }); + } + + const inst = await getGameDb(); + const res = inst.raw.exec("SELECT id FROM users WHERE username = 'kamilkrzywda'"); + const userId = res[0]?.values?.[0]?.[0]; + if (typeof userId !== 'string') { + return Response.json({ error: 'seeded admin user missing' }, { status: 500 }); + } + const sessionId = await createSession(inst, userId); + return Response.json({ sessionId }); +} diff --git a/app/globals.css b/app/globals.css index a7753bd..58cef87 100644 --- a/app/globals.css +++ b/app/globals.css @@ -588,3 +588,142 @@ body { font-weight: 600; color: #f1f5f9; } + +/* v0.71.0 — Admin modal: overlay sits above the z-50 toast stack; panel matches the dark slate theme. */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 60; /* above toasts (z-50) and everything else on screen */ + display: flex; + align-items: center; + justify-content: center; + background: rgba(2, 6, 23, 0.7); +} + +.modal-panel { + width: min(920px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + display: flex; + flex-direction: column; + background: rgba(15, 23, 42, 0.97); + color: #e2e8f0; + border: 1px solid rgba(148, 163, 184, 0.4); + border-radius: 8px; + box-shadow: 0 12px 40px rgba(2, 6, 23, 0.6); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px; + border-bottom: 1px solid rgba(148, 163, 184, 0.25); +} + +.modal-header h2 { + margin: 0; + font-size: 15px; + font-weight: 600; +} + +/* v0.71.0 — Admin panel content (login form + generic DB grid). */ +.admin-panel { + padding: 14px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; +} + +.admin-login { + max-width: 320px; + margin: 0 auto; + width: 100%; +} + +.admin-panel label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: #cbd5e1; +} + +.admin-panel input[type='text'], +.admin-panel input:not([type]), +.admin-login input { + font-size: 13px; + padding: 5px 8px; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.4); + background: #1e293b; + color: #e2e8f0; +} + +.admin-message { + margin: 0; + font-size: 13px; + color: #cbd5e1; +} + +.admin-error { + font-size: 12px; + color: #fca5a5; +} + +.admin-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.admin-table-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.admin-rows-wrap { + overflow-x: auto; + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 6px; +} + +.admin-rows { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.admin-rows th, +.admin-rows td { + padding: 4px 6px; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid rgba(148, 163, 184, 0.2); +} + +.admin-rows th { + color: #94a3b8; + font-weight: 600; +} + +.admin-cell { + width: 110px; + font-size: 12px; + padding: 2px 5px; + border-radius: 3px; + border: 1px solid rgba(148, 163, 184, 0.3); + background: #1e293b; + color: #e2e8f0; +} + +.admin-cell:disabled { + opacity: 0.55; +} + +.admin-row-actions { + white-space: nowrap; +} diff --git a/components/AdminPanel.tsx b/components/AdminPanel.tsx new file mode 100644 index 0000000..8349943 --- /dev/null +++ b/components/AdminPanel.tsx @@ -0,0 +1,414 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +// v0.71.0 — content of the Admin modal (opened by the top-bar "Admin" button). Three views: +// login form (no valid session), a "not an admin" notice, or the generic whole-database CRUD +// grid behind /api/admin/*. This is a dev tool surface — plain and functional on purpose. + +const SESSION_STORAGE_KEY = 'flight.session'; + +interface Me { + username: string; + admin: boolean; +} + +interface RowsData { + columns: string[]; + rows: Array>; +} + +function readSession(): string | null { + try { + return window.localStorage.getItem(SESSION_STORAGE_KEY); + } catch { + return null; + } +} + +export default function AdminPanel() { + const [session, setSession] = useState(null); + const [me, setMe] = useState(null); // null while checking /api/me on mount + const [checking, setChecking] = useState(true); + + // Login form state. + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [loginError, setLoginError] = useState(null); + + // Admin grid state. + const [tables, setTables] = useState([]); + const [table, setTable] = useState(null); + const [rowsData, setRowsData] = useState(null); + const [edits, setEdits] = useState>({}); // key `${rowIdx}:${col}` + const [newRow, setNewRow] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const authHeaders = useCallback( + (s: string) => ({ 'X-Flight-Session': s }), + [], + ); + + // On mount: a stored session decides between the grid and the login form. + useEffect(() => { + let cancelled = false; + const stored = readSession(); + if (!stored) { + setChecking(false); + return; + } + fetch('/api/me', { headers: authHeaders(stored) }) + .then(async (res) => { + if (!res.ok || cancelled) { + window.localStorage.removeItem(SESSION_STORAGE_KEY); + setSession(null); + return; + } + const body = (await res.json()) as Me; + setSession(stored); + setMe(body); + }) + .catch(() => { + if (!cancelled) { + window.localStorage.removeItem(SESSION_STORAGE_KEY); + setSession(null); + } + }) + .finally(() => { + if (!cancelled) setChecking(false); + }); + return () => { + cancelled = true; + }; + }, [authHeaders]); + + const loadTables = useCallback(async (s: string) => { + const res = await fetch('/api/admin/tables', { headers: authHeaders(s) }); + if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`); + setTables(((await res.json()) as Array<{ name: string }>).map((t) => t.name)); + }, [authHeaders]); + + const loadRows = useCallback( + async (s: string, name: string) => { + const res = await fetch(`/api/admin/tables/${encodeURIComponent(name)}/rows?limit=500`, { headers: authHeaders(s) }); + if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`); + setRowsData(await res.json()); + }, + [authHeaders], + ); + + // Enter the grid once /api/me confirms an admin. + useEffect(() => { + if (!session || !me?.admin) return; + let cancelled = false; + (async () => { + try { + await loadTables(session); + if (!cancelled) setBusy(false); + } catch (err) { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)); + } finally { + if (!cancelled) setBusy(false); + } + })(); + return () => { + cancelled = true; + }; + }, [session, me, loadTables]); + + const openTable = async (name: string) => { + if (!session) return; + setBusy(true); + setError(null); + try { + await loadRows(session, name); + setTable(name); + setEdits({}); + setNewRow(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const refresh = async () => { + if (!session || !table) return; + setBusy(true); + setError(null); + try { + await loadRows(session, table); + setEdits({}); + setNewRow(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const login = async () => { + if (!username || !password) return; + setLoginError(null); + const res = await fetch('/api/login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + setLoginError((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`); + return; + } + const body = (await res.json()) as { sessionId: string; user: Me }; + window.localStorage.setItem(SESSION_STORAGE_KEY, body.sessionId); + setSession(body.sessionId); + setMe(body.user); + setUsername(''); + setPassword(''); + }; + + const logout = async () => { + if (session) await fetch('/api/logout', { method: 'POST', headers: authHeaders(session) }).catch(() => undefined); + window.localStorage.removeItem(SESSION_STORAGE_KEY); + setSession(null); + setMe(null); + setTable(null); + setRowsData(null); + setTables([]); + setError(null); + }; + + if (checking) return
loading…
; + + // No valid session → login form. + if (!session || !me) { + return ( +
{ + e.preventDefault(); + void login(); + }} + > + + + {loginError && ( +
+ {loginError} +
+ )} + +
+ ); + } + + // Authenticated but not an admin. + if (!me.admin) { + return ( +
+

+ Logged in as {me.username} — this account is not an admin. +

+ +
+ ); + } + + const columns = rowsData?.columns ?? []; + // Displayed grid: server rows plus one trailing empty row when "+ Add row" is active. + const rowCount = (rowsData?.rows.length ?? 0) + (newRow ? 1 : 0); + const cellValue = (rowIdx: number, col: string): string => { + if (col === '__rowid') return String(rowsData?.rows[rowIdx]?.[col] ?? ''); + const key = `${rowIdx}:${col}`; + if (key in edits) return edits[key]; + const raw = rowsData?.rows[rowIdx]?.[col]; + return raw === null || raw === undefined ? '' : String(raw); + }; + + const saveRow = async (rowIdx: number) => { + if (!session || !table || !rowsData) return; + setBusy(true); + setError(null); + try { + if (newRow && rowIdx === rowsData.rows.length) { + // New row → POST the non-empty cells. + const values: Record = {}; + for (const col of columns) { + if (col === '__rowid') continue; + const v = cellValue(rowIdx, col); + if (v !== '') values[col] = v; + } + const res = await fetch(`/api/admin/tables/${encodeURIComponent(table)}/rows`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeaders(session) }, + body: JSON.stringify({ values }), + }); + if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`); + } else { + // Existing row → PUT only the changed cells (cleared cell = NULL). + const values: Record = {}; + for (const col of columns) { + if (col === '__rowid') continue; + const original = rowsData.rows[rowIdx]?.[col]; + const origStr = original === null || original === undefined ? '' : String(original); + const v = cellValue(rowIdx, col); + if (v !== origStr) values[col] = v === '' ? null : v; + } + if (Object.keys(values).length === 0) return; // nothing changed + const res = await fetch(`/api/admin/tables/${encodeURIComponent(table)}/rows`, { + method: 'PUT', + headers: { 'content-type': 'application/json', ...authHeaders(session) }, + body: JSON.stringify({ rowid: rowsData.rows[rowIdx]?.__rowid, values }), + }); + if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`); + } + await loadRows(session, table); // re-fetch so the grid shows server state (persistence check) + setEdits({}); + setNewRow(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const deleteRow = async (rowIdx: number) => { + if (!session || !table || !rowsData?.rows[rowIdx]) return; + setBusy(true); + setError(null); + try { + const rowid = rowsData.rows[rowIdx]?.__rowid; + const res = await fetch( + `/api/admin/tables/${encodeURIComponent(table)}/rows?rowid=${encodeURIComponent(String(rowid))}`, + { method: 'DELETE', headers: authHeaders(session) }, + ); + if (!res.ok) throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`); + await loadRows(session, table); + setEdits({}); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ + {me.username} (admin) + + +
+ + {!table && ( + <> +

Pick a table:

+
+ {tables.map((t) => ( + + ))} +
+ + )} + + {table && rowsData && ( + <> +
+ {table} + + + +
+ +
+ + + + {columns.map((c) => ( + + ))} + + + + {Array.from({ length: rowCount }, (_, i) => ( + + {columns.map((c) => ( + + ))} + + + ))} + +
{c} +
+ setEdits((prev) => ({ ...prev, [`${i}:${c}`]: e.target.value }))} + /> + + {' '} + {!(newRow && i === rowsData.rows.length) && ( + <> + {' '} + + )} +
+
+ + )} + + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/components/Map.tsx b/components/Map.tsx index 7d868f3..d03e0f0 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -11,6 +11,8 @@ import type { HoverTarget } from './hoverTypes'; import { hoverRouteGeoJson, hoverTraceGeoJson, planesGeoJson, queuedRoutesGeoJson, routesGeoJson, type AirportCoord, type PlaneFeatureProps } from '../lib/mapData'; import { weatherFor } from '../lib/weather'; import { approxLocalTime } from '../lib/localTime'; +import Modal from './Modal'; +import AdminPanel from './AdminPanel'; // v0.70.0 default view: null island [0,0] at zoom 2 — the world is 2048 px wide there and the // Africa+Europe bounding box (lat −35..+71, lon −18..+52) spans ~800 px vertically in Web Mercator, @@ -89,6 +91,9 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { const airportsIndexRef = useRef | null>(null); const [airportCount, setAirportCount] = useState(null); const [styleId, setStyleId] = useState(DEFAULT_STYLE_ID); + // v0.71.0: the Admin modal (top-bar button) — unmounted while closed so it re-checks the + // stored session on every open. + const [adminOpen, setAdminOpen] = useState(false); // Push the latest planes/routes GeoJSON into whatever sources currently exist. // No-op until the map (and for routes: the airport index) is ready — the mount @@ -579,8 +584,15 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { {airportCount === null ? 'loading airports…' : `${airportCount} world airports`} + {/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */} +
+ setAdminOpen(false)} title="Admin"> + +
); } diff --git a/components/Modal.tsx b/components/Modal.tsx new file mode 100644 index 0000000..ceecf7d --- /dev/null +++ b/components/Modal.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useEffect, type ReactNode } from 'react'; + +// v0.71.0 — reusable modal dialog: fixed overlay (z-index 60, above the z-50 toast stack), +// centered panel, closes on Escape and backdrop click (inner-panel clicks do not close it). +// Foundation for future modal-based features; keep this generic. +export default function Modal({ open, onClose, title, children }: { open: boolean; onClose: () => void; title: string; children: ReactNode }) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+ {children} +
+
+ ); +} diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts new file mode 100644 index 0000000..afe3c76 --- /dev/null +++ b/e2e/admin.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test'; + +// v0.71.0 — Admin panel e2e: top-bar button → modal with login form; wrong credentials show an +// error; a session minted by the /api/test/admin-session hook (FLIGHT_TEST_HOOKS=1) authenticates +// WITHOUT the real admin password ever appearing in test code; then the generic DB grid is +// exercised end-to-end: table list → edit + save a cell (verified by an independent re-fetch) → +// add a row → delete it → logout back to the login form. + +const SESSION_KEY = 'flight.session'; // must match components/AdminPanel.tsx + +async function mintAdminSession(page: import('@playwright/test').Page): Promise { + const res = await page.request.post('/api/test/admin-session'); + expect(res.status(), 'admin-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200); + return ((await res.json()) as { sessionId: string }).sessionId; +} + +test('admin panel: login form, wrong-creds error, session auth, DB grid CRUD, logout', async ({ page }) => { + await page.goto('/'); + const adminBtn = page.getByTestId('admin-btn'); + await expect(adminBtn).toBeVisible({ timeout: 30_000 }); + + // --- Open the modal: no stored session → login form. + await adminBtn.click(); + const dialog = page.locator('[role="dialog"]'); + await expect(dialog).toBeVisible(); + await expect(page.getByTestId('admin-username')).toBeVisible(); + await expect(page.getByTestId('admin-password')).toBeVisible(); + + // --- Wrong credentials → error line (the seeded admin's plaintext is never used here). + await page.getByTestId('admin-username').fill('kamilkrzywda'); + await page.getByTestId('admin-password').fill('wrong-password-on-purpose'); + await page.getByTestId('admin-login-submit').click(); + await expect(page.getByTestId('admin-error')).toBeVisible({ timeout: 10_000 }); + + // --- Authenticate via the test hook + localStorage, then reopen the panel. + const sessionId = await mintAdminSession(page); + await page.evaluate((key) => window.localStorage.setItem(key, sessionId), SESSION_KEY); + await page.reload(); + await expect(adminBtn).toBeVisible({ timeout: 30_000 }); + await adminBtn.click(); + + // --- Authenticated admin → table list including packages and users. + const tables = page.getByTestId('admin-tables'); + await expect(tables).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('admin-table-packages')).toBeVisible(); + await expect(page.getByTestId('admin-table-users')).toBeVisible(); + + // --- Open packages, edit one cell, Save; verify via an INDEPENDENT re-fetch of the same route. + await page.getByTestId('admin-table-packages').click(); + const grid = page.getByTestId('admin-rows'); + await expect(grid).toBeVisible({ timeout: 30_000 }); + + const rowidCell = page.getByTestId('admin-cell-0-__rowid'); + const originalRowid = Number(await rowidCell.inputValue()); + expect(Number.isInteger(originalRowid)).toBeTruthy(); + + const rewardCell = page.getByTestId('admin-cell-0-reward'); + await rewardCell.fill('987654'); + await page.getByTestId('admin-save-0').click(); + + // The grid re-fetches after save — the edited cell shows the new value… + await expect(rewardCell).toHaveValue('987654', { timeout: 15_000 }); + // …and an independent request (not the grid's) confirms it persisted server-side. + const check = await page.request.get('/api/admin/tables/packages/rows?limit=500', { + headers: { 'X-Flight-Session': sessionId }, + }); + expect(check.status()).toBe(200); + const fetched = (await check.json()) as { rows: Array> }; + const row = fetched.rows.find((r) => Number(r.__rowid) === originalRowid); + expect(row, 'edited packages row must still exist').toBeTruthy(); + expect(Number(row?.reward)).toBe(987654); + + // --- Add a row (users table — fewest required columns), then delete it again. + await page.getByTestId('admin-back').click(); + await expect(tables).toBeVisible(); + await page.getByTestId('admin-table-users').click(); + const usersGrid = page.getByTestId('admin-rows'); + await expect(usersGrid).toBeVisible({ timeout: 30_000 }); + + const existingRows = await page.getByTestId(/admin-save-\d+/).count(); + await page.getByTestId('admin-add-row').click(); + const n = existingRows; // the new empty row is appended last + await page.getByTestId(`admin-cell-${n}-id`).fill('user-e2e-admin-test'); + await page.getByTestId(`admin-cell-${n}-username`).fill('e2e-admin-user'); + await page.getByTestId(`admin-cell-${n}-password_hash`).fill('aa$bb'); // any string — dev-tool insert, not a real credential + await page.getByTestId(`admin-cell-${n}-created_at`).fill(String(Date.now())); + await page.getByTestId(`admin-save-${n}`).click(); + + // The inserted row is visible after the grid's post-save re-fetch. + const bodyRows = usersGrid.locator('tbody tr'); + const findUserRow = async (): Promise => { + const count = await bodyRows.count(); + for (let i = 0; i < count; i++) { + if ((await page.getByTestId(`admin-cell-${i}-username`).inputValue()) === 'e2e-admin-user') return i; + } + return -1; + }; + await expect.poll(findUserRow, { timeout: 15_000 }).toBeGreaterThanOrEqual(0); + const addedIdx = await findUserRow(); + + // Delete it again — gone after the post-delete re-fetch. + await page.getByTestId(`admin-delete-${addedIdx}`).click(); + await expect.poll(findUserRow, { timeout: 15_000 }).toBe(-1); + + // --- Logout returns to the login form. + await page.getByTestId('admin-logout').click(); + await expect(page.getByTestId('admin-username')).toBeVisible({ timeout: 10_000 }); + await expect(tables).toBeHidden(); +}); diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 7749bde..33abc79 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -13,19 +13,25 @@ test('GET /api/version returns the app version from package.json', async ({ page expect(body.version).toBe(pkg.version); }); -test('GET /api/openapi serves the spec with all twelve endpoints and the package.json version', async ({ page }) => { +test('GET /api/openapi serves the spec with all seventeen endpoints and the package.json version', async ({ page }) => { const res = await page.request.get('/api/openapi'); expect(res.status()).toBe(200); expect(res.headers()['content-type']).toContain('application/json'); const spec = (await res.json()) as { info: { title: string; version: string }; paths: Record }; expect(spec.info.title).toBe('Flight Ops API'); expect(spec.info.version).toBe(pkg.version); - // v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly. + // v0.12.0 swaps the contract accept/dispatch pair for package load/abandon + plane fly; + // v0.71.0 adds the admin panel (login/logout/me + guarded DB CRUD). expect(Object.keys(spec.paths).sort()).toEqual([ + '/api/admin/tables', // v0.71.0 — admin panel + '/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path '/api/airports', '/api/events', // v0.57.0 (ISS-129) — SSE state stream + '/api/login', // v0.71.0 — admin panel auth + '/api/logout', // v0.71.0 '/api/market/buy-plane', '/api/market/sell-plane', + '/api/me', // v0.71.0 '/api/packages/{id}/abandon', '/api/packages/{id}/load', '/api/planes/{planeId}/fly', diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..6cf8429 --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,74 @@ +import { randomUUID, scryptSync, timingSafeEqual } from 'node:crypto'; +import { getGameDb, persist, type GameDb } from '@/lib/db/client'; + +// v0.71.0 — session auth for the admin panel (and later multiplayer). Sessions are opaque +// UUIDs in a DB table; the client stores one in localStorage ('flight.session') and sends it +// as the X-Flight-Session header on every authenticated request. No JWT, no cookies: this is +// a single-origin dev tool surface, not a public auth system. + +export const SESSION_HEADER = 'x-flight-session'; +export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days from creation + +// Fixed digest compared against when the username is unknown, so every failed login runs one +// full scrypt and the timing shape stays uniform (no user-existence oracle). +export const DUMMY_HASH_FOR_UNKNOWN_USER = '0123456789abcdef0123456789abcdef$b9813a9a95c67878bd5b2c703bce6d57416b5d7e48496dfbf603bc4c6d59137f'; + +export interface AuthUser { + id: string; + username: string; + admin: boolean; +} + +// Verify a plaintext password against a stored "salt$hex" scrypt digest (16-byte hex salt, +// 32-byte hash). Malformed digests never match. +export function verifyPassword(password: string, storedHash: string): boolean { + const [saltHex, hashHex] = storedHash.split('$'); + if (!saltHex || !hashHex || hashHex.length !== 64) return false; + const computed = scryptSync(password, saltHex, 32).toString('hex'); + try { + return timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(hashHex, 'hex')); + } catch { + return false; + } +} + +// Mint a session row for the user and lazily prune expired sessions. Persists so a restart +// doesn't silently log everyone out mid-session. +export async function createSession(inst: GameDb, userId: string): Promise { + const id = randomUUID(); + inst.raw.run('DELETE FROM sessions WHERE expires_at < ?', [Date.now()]); + inst.raw.run('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)', [ + id, + userId, + Date.now() + SESSION_TTL_MS, + ]); + await persist(inst); + return id; +} + +// Resolve the X-Flight-Session header to a live (non-expired) user, or null. +export async function currentUser(req: Request): Promise { + const sessionId = req.headers.get(SESSION_HEADER); + if (!sessionId) return null; + const inst = await getGameDb(); + const res = inst.raw.exec( + 'SELECT u.id, u.username, u.admin FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > ?', + [sessionId, Date.now()], + ); + const row = res[0]?.values?.[0]; + if (!row) return null; + return { id: String(row[0]), username: String(row[1]), admin: Number(row[2]) === 1 }; +} + +export type AdminGuard = { ok: true; inst: GameDb; user: AuthUser } | { ok: false; response: Response }; + +// Guard for every /api/admin/* route — runs BEFORE any user input is touched. +// No/expired session → 401, valid non-admin session → 403. +export async function requireAdmin(req: Request): Promise { + const user = await currentUser(req); + if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) }; + if (!user.admin) { + return { ok: false, response: Response.json({ error: 'admin access required' }, { status: 403 }) }; + } + return { ok: true, inst: await getGameDb(), user }; +} diff --git a/lib/db/client.test.ts b/lib/db/client.test.ts index 0fa91f6..7c22e89 100644 --- a/lib/db/client.test.ts +++ b/lib/db/client.test.ts @@ -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(); diff --git a/lib/db/client.ts b/lib/db/client.ts index 8ba899f..211419b 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -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 { 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. diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 6ccc3d0..6bc03e0 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -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; diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 8c5edac..7c63f6a 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -3,12 +3,17 @@ import { describe, expect, it } from 'vitest'; import { openApiSpec } from './openapi'; describe('OpenAPI spec', () => { - it('documents exactly the twelve public endpoints (4 read + 8 player actions)', () => { + it('documents exactly the seventeen public endpoints (6 read + 8 player actions + 2 auth + 1 admin CRUD)', () => { expect(Object.keys(openApiSpec.paths).sort()).toEqual([ + '/api/admin/tables', // v0.71.0 — admin panel + '/api/admin/tables/{name}/rows', // v0.71.0 — GET+PUT+POST+DELETE on one path '/api/airports', '/api/events', // v0.57.0 (ISS-129) — SSE state stream + '/api/login', // v0.71.0 — admin panel auth + '/api/logout', // v0.71.0 '/api/market/buy-plane', '/api/market/sell-plane', + '/api/me', // v0.71.0 '/api/packages/{id}/abandon', '/api/packages/{id}/load', '/api/planes/{planeId}/fly', @@ -242,4 +247,53 @@ describe('OpenAPI spec', () => { expect(sell.required).toEqual(['planeId']); expect(sell.properties.planeId.type).toBe('string'); }); + + it('documents the v0.71.0 admin-panel endpoints: auth + guarded DB CRUD', () => { + const paths = openApiSpec.paths as Record; requestBody?: unknown; parameters?: Array<{ name: string }> }>>; + + // Error codes per endpoint (401/403 on every admin route — the guard runs before input). + const codes = (p: string, m: string) => Object.keys(paths[p][m].responses).sort(); + expect(codes('/api/login', 'post')).toEqual(['200', '400', '401']); + expect(codes('/api/logout', 'post')).toEqual(['200', '401']); + expect(codes('/api/me', 'get')).toEqual(['200', '401']); + expect(codes('/api/admin/tables', 'get')).toEqual(['200', '401', '403']); + for (const m of ['get', 'put', 'post', 'delete'] as const) { + expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('401'); + expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('403'); + expect(codes(`/api/admin/tables/{name}/rows`, m), `${m} rows`).toContain('404'); + } + + // Success shapes. + const ok = (p: string, m: string) => paths[p][m].responses['200'] as { content: Record }; + expect((ok('/api/login', 'post').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/LoginResponse'); + expect((ok('/api/me', 'get').content['application/json'].schema as { $ref: string }).$ref).toBe('#/components/schemas/SessionUser'); + const tables = ok('/api/admin/tables', 'get').content['application/json'].schema as { type: string; items: { $ref: string } }; + expect(tables.type).toBe('array'); + expect(tables.items.$ref).toBe('#/components/schemas/AdminTable'); + const rows = ok('/api/admin/tables/{name}/rows', 'get').content['application/json'].schema as { $ref: string }; + expect(rows.$ref).toBe('#/components/schemas/AdminRowsResponse'); + + // Request bodies + path parameter. + const bodyRef = (p: string, m: string) => + ((paths[p][m].requestBody ?? {}) as { content?: Record }).content?.['application/json'] + ?.schema.$ref; + expect(bodyRef('/api/login', 'post')).toBe('#/components/schemas/LoginRequest'); + expect(bodyRef('/api/admin/tables/{name}/rows', 'put')).toBe('#/components/schemas/UpdateRowRequest'); + expect(bodyRef('/api/admin/tables/{name}/rows', 'post')).toBe('#/components/schemas/InsertRowRequest'); + for (const m of ['get', 'put', 'post', 'delete'] as const) { + expect(paths['/api/admin/tables/{name}/rows'][m].parameters?.map((x) => x.name)).toContain('name'); + } + + // Request schema shapes. + const login = openApiSpec.components.schemas.LoginRequest; + expect(login.required).toEqual(['username', 'password']); + expect(login.properties.username.type).toBe('string'); + expect(login.properties.password.type).toBe('string'); + const sessionUser = openApiSpec.components.schemas.SessionUser; + expect(sessionUser.required.sort()).toEqual(['admin', 'username']); + expect(sessionUser.properties.admin.type).toBe('boolean'); + const updateRow = openApiSpec.components.schemas.UpdateRowRequest; + expect(updateRow.required).toEqual(['rowid', 'values']); + expect(updateRow.properties.rowid.type).toBe('integer'); + }); }); diff --git a/lib/openapi.ts b/lib/openapi.ts index 118b390..0677894 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -8,7 +8,7 @@ export const openApiSpec = { info: { title: 'Flight Ops API', description: - 'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.', + 'JSON API for Flight Ops — world airport data for the map, the live game-state snapshot, player actions (load / fly / abandon / buy / sell), admin-panel auth + whole-database tooling (v0.71.0), and app metadata. Every action endpoint ticks the sim clock first and returns the full state snapshot on success.', // Sourced from package.json so it stays in sync with every version bump. version: pkg.version, }, @@ -342,7 +342,244 @@ export const openApiSpec = { content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, 409: { - description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost', + description: 'Plane is not grounded (flying or already repairing), or cash is insufficient for the repair cost', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + }, + // v0.71.0 — admin panel: session auth + generic whole-database CRUD behind the admin flag. + '/api/login': { + post: { + summary: 'Log in', + description: + 'Verify username/password (scrypt) and mint a 7-day session. The client stores the returned sessionId in localStorage and sends it as the X-Flight-Session header on later requests. An unknown username still runs one full scrypt against a fixed dummy digest, so timing stays uniform.', + operationId: 'login', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/LoginRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Authenticated — new session id + the user', + content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } }, + }, + 400: { + description: 'Invalid body (not an object, unknown field, or missing/non-string username/password)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 401: { + description: 'Unknown username or wrong password', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + }, + '/api/logout': { + post: { + summary: 'Log out', + description: + 'Delete the session named by the X-Flight-Session header (no body). Idempotent — deleting an unknown or already-deleted session is still 200.', + operationId: 'logout', + responses: { + 200: { + description: 'Session deleted (or was already gone)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } }, + }, + 401: { + description: 'Missing X-Flight-Session header', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + }, + '/api/me': { + get: { + summary: 'Current user', + description: + 'Resolve the X-Flight-Session header to its user, or 401 when the session is missing/unknown/expired. The client uses it to decide what the Admin modal shows on open.', + operationId: 'getMe', + responses: { + 200: { + description: 'The authenticated user', + content: { 'application/json': { schema: { $ref: '#/components/schemas/SessionUser' } } }, + }, + 401: { + description: 'Missing, unknown or expired session', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + }, + '/api/admin/tables': { + get: { + summary: 'List database tables (admin)', + description: + 'Every user table as [{ name }] in name order, excluding SQLite internals. Requires a valid X-Flight-Session of an ADMIN user — 401 without a live session, 403 for non-admins.', + operationId: 'listAdminTables', + responses: { + 200: { + description: 'The table names', + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/AdminTable' } }, + }, + }, + }, + 401: { + description: 'Missing, unknown or expired session', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 403: { + description: 'Valid session but the user is not an admin', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + }, + '/api/admin/tables/{name}/rows': { + get: { + summary: 'Read table rows (admin)', + description: + 'Column headers + up to `limit` rows of the named table (default 500, cap 1000). rowid is exposed as __rowid so the PK-less tables (account, sim_state) are editable like any other. Table names are validated against sqlite_master and values are always bound as parameters.', + operationId: 'listAdminRows', + parameters: [ + { name: 'name', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'limit', in: 'query', required: false, schema: { type: 'integer', minimum: 1, maximum: 1000 } }, + ], + responses: { + 200: { + description: 'Column headers + rows (each row carries its __rowid)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/AdminRowsResponse' } } }, + }, + 400: { + description: 'limit is not a positive integer', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 401: { + description: 'Missing, unknown or expired session', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 403: { + description: 'Valid session but the user is not an admin', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 404: { + description: 'Unknown table name', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + put: { + summary: 'Update a row (admin)', + description: + 'Body { rowid, values }: update only the given columns of that row. Column names are validated against PRAGMA table_info; values are always bound as parameters.', + operationId: 'updateAdminRow', + parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/UpdateRowRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Updated', + content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } }, + }, + 400: { + description: 'Invalid body, non-integer rowid, unknown column, or empty values', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 401: { + description: 'Missing, unknown or expired session', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 403: { + description: 'Valid session but the user is not an admin', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 404: { + description: 'Unknown table name', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + post: { + summary: 'Insert a row (admin)', + description: + 'Body { values }: insert a new row with exactly the given columns (others take their defaults or NULL). Returns the new rowid.', + operationId: 'insertAdminRow', + parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/InsertRowRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Inserted — the new rowid', + content: { 'application/json': { schema: { $ref: '#/components/schemas/InsertRowResponse' } } }, + }, + 400: { + description: 'Invalid body, unknown column, or empty values', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 401: { + description: 'Missing, unknown or expired session', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 403: { + description: 'Valid session but the user is not an admin', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 404: { + description: 'Unknown table name', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 409: { + description: 'Database constraint violation (NOT NULL / UNIQUE)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + delete: { + summary: 'Delete a row (admin)', + description: + 'Query parameter rowid=N deletes that one row. Idempotent — deleting a non-existent rowid is still 200.', + operationId: 'deleteAdminRow', + parameters: [ + { name: 'name', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'rowid', in: 'query', required: true, schema: { type: 'integer' } }, + ], + responses: { + 200: { + description: 'Deleted (or was already gone)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/OkResponse' } } }, + }, + 400: { + description: 'Missing or non-integer rowid query parameter', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 401: { + description: 'Missing, unknown or expired session', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 403: { + description: 'Valid session but the user is not an admin', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 404: { + description: 'Unknown table name', content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, }, }, @@ -496,6 +733,92 @@ export const openApiSpec = { }, required: ['planeId'], }, + // v0.71.0 — admin panel auth + DB tooling schemas. + LoginRequest: { + type: 'object', + properties: { + username: { type: 'string', description: 'Account username (non-empty).' }, + password: { type: 'string', description: 'Plaintext password — verified server-side with scrypt, never stored.' }, + }, + required: ['username', 'password'], + }, + SessionUser: { + type: 'object', + properties: { + username: { type: 'string' }, + admin: { type: 'boolean', description: 'Whether this account may use the /api/admin/* endpoints.' }, + }, + required: ['username', 'admin'], + }, + LoginResponse: { + type: 'object', + properties: { + sessionId: { type: 'string', description: 'Opaque session id — store it and send it back as the X-Flight-Session header. Valid 7 days.' }, + user: { $ref: '#/components/schemas/SessionUser' }, + }, + required: ['sessionId', 'user'], + }, + OkResponse: { + type: 'object', + properties: { + ok: { const: true }, + }, + required: ['ok'], + }, + AdminTable: { + type: 'object', + properties: { + name: { type: 'string', description: 'SQLite table name (sqlite_% internals excluded).' }, + }, + required: ['name'], + }, + AdminRowsResponse: { + type: 'object', + properties: { + columns: { + type: 'array', + items: { type: 'string' }, + description: 'Column names; the first entry is always __rowid (the rowid pseudo-column).', + }, + rows: { + type: 'array', + items: { type: 'object', additionalProperties: true }, + description: 'Up to `limit` rows, each keyed by column name including __rowid.', + }, + }, + required: ['columns', 'rows'], + }, + UpdateRowRequest: { + type: 'object', + properties: { + rowid: { type: 'integer', description: 'The __rowid of the row to update.' }, + values: { + type: 'object', + additionalProperties: true, + description: 'Only the columns to change (validated against PRAGMA table_info); others are untouched.', + }, + }, + required: ['rowid', 'values'], + }, + InsertRowRequest: { + type: 'object', + properties: { + values: { + type: 'object', + additionalProperties: true, + description: 'Exactly the columns to set; others take their defaults or NULL.', + }, + }, + required: ['values'], + }, + InsertRowResponse: { + type: 'object', + properties: { + ok: { const: true }, + rowid: { type: 'integer', description: 'The __rowid of the inserted row.' }, + }, + required: ['ok', 'rowid'], + }, Error: { type: 'object', properties: { diff --git a/package.json b/package.json index ad41b7e..5d562fb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "0.70.0", + "version": "0.71.0", "private": true, "scripts": { "dev": "next dev -p 3000", -- 2.54.0 From e24a5788c06211d9d098921c7d388719b626b1db Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Thu, 24 Sep 2026 09:56:05 +0000 Subject: [PATCH 2/2] =?UTF-8?q?v0.71.0:=20fix=20admin=20e2e=20=E2=80=94=20?= =?UTF-8?q?pass=20sessionId=20into=20page.evaluate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/admin.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts index afe3c76..aff0f44 100644 --- a/e2e/admin.spec.ts +++ b/e2e/admin.spec.ts @@ -34,7 +34,9 @@ test('admin panel: login form, wrong-creds error, session auth, DB grid CRUD, lo // --- Authenticate via the test hook + localStorage, then reopen the panel. const sessionId = await mintAdminSession(page); - await page.evaluate((key) => window.localStorage.setItem(key, sessionId), SESSION_KEY); + // Both values must be passed as arguments — the function runs in the browser, where Node-side + // variables are not in scope. + await page.evaluate(([key, sid]) => window.localStorage.setItem(key, sid), [SESSION_KEY, sessionId]); await page.reload(); await expect(adminBtn).toBeVisible({ timeout: 30_000 }); await adminBtn.click(); -- 2.54.0