Compare commits

..
8 Commits
Author SHA1 Message Date
kamilkrzywda fe23c9ff0f Merge pull request 'v0.71.0 Admin panel — users table, login/sessions, generic DB CRUD modal' (#148) from feat/admin-panel into master
CI / build + unit + e2e (push) Successful in 13m21s
2026-09-24 12:11:46 +02:00
kamilkrzywda e24a5788c0 v0.71.0: fix admin e2e — pass sessionId into page.evaluate
CI / build + unit + e2e (pull_request) Successful in 15m37s
2026-09-24 09:56:05 +00:00
kamilkrzywda b89d351587 v0.71.0: admin panel — users table, login/sessions, generic DB CRUD modal
CI / build + unit + e2e (pull_request) Failing after 13m35s
2026-09-24 09:24:31 +00:00
kamilkrzywda 034d7a8d94 Merge pull request 'v0.70.0 Default map view: whole world at [0,0]' (#147) from chore/map-default-view into master
CI / build + unit + e2e (push) Successful in 13m59s
v0.70.0 — Default map view: whole world at [0,0] (#147)
2026-09-24 01:49:27 +02:00
kamilkrzywda 0aca4d63aa v0.70.0: e2e wheel-clamp test asserts MapLibre height fit floor
CI / build + unit + e2e (pull_request) Successful in 14m5s
2026-09-23 23:35:22 +00:00
kamilkrzywda fb369a1afb v0.70.0: default map view — world at [0,0] zoom 2, minZoom 0
CI / build + unit + e2e (pull_request) Failing after 14m13s
2026-09-23 22:53:21 +00:00
kamilkrzywda 78961fdb36 Merge pull request 'v0.69.0 Remove job ignore feature' (#146) from chore/remove-job-ignore into master
CI / build + unit + e2e (push) Successful in 14m3s
v0.69.0 — Remove job ignore feature (#146)
2026-09-24 00:16:58 +02:00
kamilkrzywda d2defe4cd3 v0.69.0: remove job ignore feature
CI / build + unit + e2e (pull_request) Successful in 14m31s
2026-09-23 22:01:39 +00:00
40 changed files with 2146 additions and 178 deletions
+15
View File
@@ -1,9 +1,24 @@
# 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).
## v0.69.0 — 2026-09-23 21:54
- **Job ignore removed end-to-end.** The "Ignore" button on open jobs (v0.29.0, ISS-047 part B) is gone with everything behind it: the `POST /api/packages/{id}/ignore` route, the `ignorePackage` action, and `'ignored'` from the package status enum (schema, snapshot type, OpenAPI — now twelve endpoints). Packages can only be loaded, delivered, expired, or abandoned.
- **Legacy 'ignored' rows self-heal.** Production databases already contain rows with `status='ignored'`; nothing ever cleaned them up (`expireOpenPackages` only selected `'open'`). The sweep now selects `IN ('open','ignored')` and flips any row past `createdRealMs + JOB_LIFETIME_MS` to `'expired'`, so every legacy row heals on the first tick after its lifetime lapses (in practice: the first tick after deploy) and no new ones can ever be created. Reads before that first tick are safe: Drizzle does no runtime validation, nothing renders a non-open/loaded/expired package, and `GET /api/state` ticks first anyway.
- **Tests.** Unit: the `ignorePackage` suite is deleted; terminal-status fixtures drop their redundant `'ignored'` rows (delivered/abandoned already cover "terminal never flips"); the frozen-top-up drain now flips to `'expired'` mirroring `expireOpenPackages`; the taken-slot cooldown ladder keeps two stamped slots, both `'loaded'`. E2E: the Ignore UI test is deleted; `sim.spec.ts`'s soft-lock regression drains via the existing `/api/test/expire-package` hook (loading can't drain a pool — the starter's payload fits only the lightest band) and `expiry.spec.ts` cleans up its created packages through the same hook. OpenAPI endpoint count thirteen → twelve everywhere it is asserted.
## v0.68.0 — 2026-09-23 20:32
- **Special remote airports re-curated per #144.** The old remote pass kept EVERY non-large fixed-wing row with no large airport within 1000 km, so regions without big cities (central Australia, Canada edges) got filled with dozens of tiny strips. `scripts/build-world-airports.mjs` now greedily thins the same candidate pool to one special per ~1000 km region: candidates are sorted by priority (scheduled service first, then type rank medium < small < seaplane, then numeric id) and a candidate is accepted only if it is at least `REMOTE_RADIUS_KM` from EVERY already-accepted special — no special airport within 1000 km of another. This subsumes the old 40 km remote clustering (deleted). Regenerated `data/airports_world.csv`: 1483 → 1161 rows (1119 large unchanged, specials 364 → 42; min pairwise special distance 1001.6 km).
- **Tests.** New committed-dataset invariant in `lib/airports.test.ts` (same style as the existing remote-rule test): every pair of non-large rows is at least `REMOTE_RADIUS_KM` apart (pairwise haversine, O(n²) — n≈42). The existing "no large airport within 1000 km" invariant stays and still holds. No runtime code changes in `lib/airports.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<Record<string, unknown>> };
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<Record<string, unknown>> }).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<Record<string, unknown>> }).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<Record<string, unknown>> }).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<Record<string, unknown>> }).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);
});
});
+219
View File
@@ -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<string> {
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<Record<string, unknown> | 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<string, unknown>;
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<string, unknown>): Record<string, unknown> | 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<string, unknown>;
}
// 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<Record<string, unknown>> = [];
while (stmt.step()) rows.push(stmt.getAsObject() as Record<string, unknown>);
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);
}
}
+80
View File
@@ -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<string> {
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);
});
});
+14
View File
@@ -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]) })));
}
+93
View File
@@ -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<void> {
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);
});
});
+52
View File
@@ -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<string, unknown>;
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 },
});
}
+60
View File
@@ -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<string> {
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 });
});
});
+16
View File
@@ -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 });
}
+72
View File
@@ -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 });
});
});
+11
View File
@@ -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 });
}
-10
View File
@@ -1,10 +0,0 @@
import { ignorePackage, performAction } from '@/lib/game/actions';
// POST /api/packages/:id/ignore (v0.29.0, ISS-047 part B) — no body. Dismisses an OPEN job so it
// leaves the market permanently; the top-up logic spawns a replacement on the next tick. No penalty,
// nothing else changes. 404 unknown id; 409 unless open. On success returns the full state snapshot.
export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
return performAction((inst) => ignorePackage(inst, id));
}
+23
View File
@@ -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 });
}
+139
View File
@@ -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;
}
+414
View File
@@ -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<Record<string, unknown>>;
}
function readSession(): string | null {
try {
return window.localStorage.getItem(SESSION_STORAGE_KEY);
} catch {
return null;
}
}
export default function AdminPanel() {
const [session, setSession] = useState<string | null>(null);
const [me, setMe] = useState<Me | null>(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<string | null>(null);
// Admin grid state.
const [tables, setTables] = useState<string[]>([]);
const [table, setTable] = useState<string | null>(null);
const [rowsData, setRowsData] = useState<RowsData | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({}); // key `${rowIdx}:${col}`
const [newRow, setNewRow] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 <div className="admin-panel">loading…</div>;
// No valid session → login form.
if (!session || !me) {
return (
<form
className="admin-panel admin-login"
onSubmit={(e) => {
e.preventDefault();
void login();
}}
>
<label>
Username
<input data-testid="admin-username" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</label>
<label>
Password
<input
data-testid="admin-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</label>
{loginError && (
<div data-testid="admin-error" className="admin-error">
{loginError}
</div>
)}
<button type="submit" data-testid="admin-login-submit" className="pane-btn">
Log in
</button>
</form>
);
}
// Authenticated but not an admin.
if (!me.admin) {
return (
<div className="admin-panel">
<p data-testid="admin-not-admin" className="admin-message">
Logged in as <strong>{me.username}</strong> — this account is not an admin.
</p>
<button type="button" data-testid="admin-logout" className="pane-btn" onClick={() => void logout()}>
Log out
</button>
</div>
);
}
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<string, string> = {};
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<string, string | null> = {};
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 (
<div className="admin-panel">
<div className="admin-toolbar">
<span data-testid="admin-user" className="admin-message">
{me.username} (admin)
</span>
<button type="button" data-testid="admin-logout" className="pane-btn" onClick={() => void logout()}>
Log out
</button>
</div>
{!table && (
<>
<p className="admin-message">Pick a table:</p>
<div data-testid="admin-tables" className="admin-table-list">
{tables.map((t) => (
<button key={t} type="button" data-testid={`admin-table-${t}`} className="pane-btn" onClick={() => void openTable(t)}>
{t}
</button>
))}
</div>
</>
)}
{table && rowsData && (
<>
<div className="admin-toolbar">
<strong data-testid="admin-current-table">{table}</strong>
<button type="button" data-testid="admin-back" className="pane-btn" onClick={() => setTable(null)}>
← tables
</button>
<button type="button" data-testid="admin-refresh" className="pane-btn" disabled={busy} onClick={() => void refresh()}>
Refresh
</button>
<button
type="button"
data-testid="admin-add-row"
className="pane-btn"
disabled={busy || newRow}
onClick={() => setNewRow(true)}
>
+ Add row
</button>
</div>
<div className="admin-rows-wrap">
<table data-testid="admin-rows" className="admin-rows">
<thead>
<tr>
{columns.map((c) => (
<th key={c}>{c}</th>
))}
<th />
</tr>
</thead>
<tbody>
{Array.from({ length: rowCount }, (_, i) => (
<tr key={i}>
{columns.map((c) => (
<td key={c}>
<input
data-testid={`admin-cell-${i}-${c}`}
className="admin-cell"
value={cellValue(i, c)}
disabled={busy || c === '__rowid'}
onChange={(e) => setEdits((prev) => ({ ...prev, [`${i}:${c}`]: e.target.value }))}
/>
</td>
))}
<td className="admin-row-actions">
<button type="button" data-testid={`admin-save-${i}`} className="pane-btn" disabled={busy} onClick={() => void saveRow(i)}>
Save
</button>{' '}
{!(newRow && i === rowsData.rows.length) && (
<>
<button
type="button"
data-testid={`admin-delete-${i}`}
className="pane-btn"
disabled={busy}
onClick={() => void deleteRow(i)}
>
Delete
</button>{' '}
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{error && (
<div data-testid="admin-error" className="admin-error">
{error}
</div>
)}
</div>
);
}
+12
View File
@@ -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<Map<string, AirportCoord> | null>(null);
const [airportCount, setAirportCount] = useState<number | null>(null);
const [styleId, setStyleId] = useState<string>(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) {
<span data-testid="airport-count">
{airportCount === null ? 'loading airports…' : `${airportCount} world airports`}
</span>
{/* v0.71.0: admin panel entry point — right side of the top bar, opens a modal (no URL). */}
<button type="button" data-testid="admin-btn" className="pane-btn" onClick={() => setAdminOpen(true)}>
Admin
</button>
</header>
<div ref={containerRef} data-testid="map" className="map-container" />
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
<AdminPanel />
</Modal>
</div>
);
}
+33
View File
@@ -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 (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-panel" role="dialog" aria-modal="true" aria-label={title} onClick={(e) => e.stopPropagation()}>
<header className="modal-header">
<h2>{title}</h2>
<button type="button" data-testid="modal-close" className="pane-btn" onClick={onClose} aria-label="Close dialog">
×
</button>
</header>
{children}
</div>
</div>
);
}
-10
View File
@@ -221,16 +221,6 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
Load
</button>
)}
{/* ISS-047 part B (v0.29.0): dismiss the job — it leaves the market permanently, no penalty. */}
<button
type="button"
data-testid={`ignore-${pkg.id}`}
className="pane-btn"
disabled={busy}
onClick={() => void runAction(`/api/packages/${pkg.id}/ignore`)}
>
Ignore
</button>
</div>
<div className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
+111
View File
@@ -0,0 +1,111 @@
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<string> {
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);
// 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();
// --- 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<Record<string, unknown>> };
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<number> => {
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();
});
+8 -3
View File
@@ -13,21 +13,26 @@ 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 thirteen 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<string, unknown> };
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}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load',
'/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue',
+1 -1
View File
@@ -12,7 +12,7 @@ interface StateBody {
}
// ISS-133 (v0.59.0): frames carry only the package statuses the client renders — terminal history
// (delivered/abandoned/ignored) is unbounded in the DB and must never leak into a pushed frame.
// (delivered/abandoned) is unbounded in the DB and must never leak into a pushed frame.
const ALLOWED_PACKAGE_STATUSES = new Set(['open', 'loaded', 'expired']);
test('GET /api/events streams state snapshots over SSE', async () => {
+3 -3
View File
@@ -63,10 +63,10 @@ test('long-leg priority packages always expire after travel time at max speed +
created.push(pkg);
}
} finally {
// Cleanup: dismiss every package this test created so it leaves no trace on the shared board.
// Cleanup: age out every package this test created so it leaves no trace on the shared board.
for (const pkg of created) {
const r = await page.request.post(`/api/packages/${pkg.id}/ignore`);
expect(r.ok(), `ignoring ${pkg.id}`).toBeTruthy();
const r = await page.request.post('/api/test/expire-package', { data: { packageId: pkg.id } });
expect(r.ok(), `expiring ${pkg.id}`).toBeTruthy();
}
}
});
+10 -4
View File
@@ -168,12 +168,18 @@ test('wheel zoom is clamped between min and max zoom', async ({ page }) => {
await waitForStableZoom();
expect(await getZoom()).toBeCloseTo(MAX_ZOOM);
// Zoom out well below MIN_ZOOM with several large deltas.
await jumpTo(MIN_ZOOM + 1);
await wheelToLimit(600, (z) => z <= MIN_ZOOM + 1e-9);
// Zoom out to the effective minimum. MapLibre won't zoom out past the point where
// the world no longer covers the viewport height (fit floor = log2(h/512)), so with
// MIN_ZOOM=0 the reachable limit is max(MIN_ZOOM, fitFloor) — "just far enough to see
// the whole Earth", which is exactly what min zoom should give.
const mapBox = await page.getByTestId('map').boundingBox();
if (!mapBox) throw new Error('map bounding box unavailable');
const minLimit = Math.max(MIN_ZOOM, Math.log2(mapBox.height / 512));
await jumpTo(minLimit + 1);
await wheelToLimit(600, (z) => z <= minLimit + 1e-9);
await waitForStableZoom();
const zoom = await getZoom();
expect(zoom).toBeCloseTo(MIN_ZOOM);
expect(zoom).toBeCloseTo(minLimit);
expect(zoom).toBeGreaterThanOrEqual(MIN_ZOOM);
expect(zoom).toBeLessThanOrEqual(MAX_ZOOM);
});
+1 -33
View File
@@ -40,7 +40,7 @@ interface PackageState {
originName: string;
destName: string;
reward: number;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored'; // v0.29.0 (ISS-047 part B)
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
createdSimTime: number;
deadlineSimTime: number;
@@ -447,38 +447,6 @@ test('dispatch auto-refuels: a low tank tops up to 150% of the leg requirement a
expect((await page.request.post('/api/test/set-fuel', { data: { planeId: 'plane-starter', fuel: entry.maxFuel } })).status()).toBe(200);
});
test('Ignore removes an open job from the list permanently (ISS-047 part B)', async ({ page }) => {
test.setTimeout(300_000); // self-heal can take minutes after an interrupted run
await flyHomeViaApi(page); // deterministic: the starter sits at EDDF
const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao, 24 * 3_600_000);
await page.goto('/');
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// Ignore through the UI — the post-action snapshot lands immediately (no poll wait).
const ignoreBtn = page.getByTestId(`ignore-${pkg.id}`);
await expect(ignoreBtn, 'the row has an Ignore button').toBeEnabled();
await ignoreBtn.click();
// Server truth: the package is 'ignored'…
await expect
.poll(async () => (await getState(page)).packages.find((p) => p.id === pkg.id)?.status, {
timeout: 15_000,
message: 'waiting for the ignored status in /api/state',
})
.toBe('ignored');
// …and the row is gone from the open list (the section filters status==='open').
await expect(row).not.toBeVisible({ timeout: 15_000 });
// A second ignore attempt on the same id is a 409 — 'ignored' is terminal.
const again = await page.request.post(`/api/packages/${pkg.id}/ignore`);
expect(again.status()).toBe(409);
});
test('Planes market tab lists all plane types with tech specs', async ({ page }) => {
await page.goto('/');
const body = await getState(page); // authoritative catalog — v0.28.0 ships six types
+11 -10
View File
@@ -84,7 +84,7 @@ test('a fully grounded fleet freezes the world clock (#135)', async ({ page }) =
expect(after.simState.clockMs - c0, 'the grounded clock must stay frozen').toBe(0);
});
test('a grounded fleet never starves: ignoring every open package cannot empty the market (#135 follow-up)', async ({ page }) => {
test('a grounded fleet never starves: draining every open package cannot empty the market (#135 follow-up)', async ({ page }) => {
// Self-heal to a fully grounded fleet first — that is what freezes the world clock (#135), and
// the freeze is exactly what used to let the drain stick (no frozen tick spawned replacements).
await expect
@@ -100,23 +100,24 @@ test('a grounded fleet never starves: ignoring every open package cannot empty t
expect(before.planes.every((p) => p.status === 'available'), 'fleet should be grounded at spec start').toBe(true);
const clockBefore = before.simState.clockMs;
// Drain: ignore every open package, pass after pass. Pre-fix (v0.62.0) a frozen tick never
// spawned replacements, so this reached zero and stayed there — the soft-lock. Post-fix every
// request's tick re-tops-up BEFORE its removal, so the market can never empty; cap the passes to
// bound runtime either way (a zero-open snapshot would mean the bug is back). v0.65.0: ignoring
// starts each slot's refill cooldown (seconds in CI via FLIGHT_JOB_*_REFILL_MS), so the board
// comes back a few seconds after the drain, not instantly.
// Drain: expire every open package via the test hook (FLIGHT_TEST_HOOKS=1), pass after pass —
// ignore was removed in v0.69.0 and loading can't drain a pool either (the starter's payload fits
// only the lightest band). Pre-fix (v0.62.0) a frozen tick never spawned replacements, so this
// reached zero and stayed there — the soft-lock. Post-fix every request's tick re-tops-up around
// its removal, so the market can never empty; cap the passes to bound runtime either way (a
// zero-open snapshot would mean the bug is back). Expired slots carry no refill cooldown, so the
// board comes back on the very next tick.
for (let pass = 0; pass < 3; pass++) {
const s = await getState(page);
const open = s.packages.filter((p) => p.status === 'open');
if (open.length === 0) break; // drained — the pre-fix soft-lock state
for (const p of open) {
expect((await page.request.post(`/api/packages/${p.id}/ignore`)).status()).toBe(200);
expect((await page.request.post('/api/test/expire-package', { data: { packageId: p.id } })).status()).toBe(200);
}
}
// The ~1 s background tick must refill the pool once the cooldowns lapse — within ~15 s of real
// time (CI's heavy-job refill delay is 5 s, so allow headroom for dind slowness)…
// Expired slots respawn immediately (no cooldown), so the ~1 s background tick must refill the
// pool right away — within ~15 s of real time, with headroom for dind slowness…
let topped: StateBody | undefined;
await expect
.poll(async () => {
+1 -1
View File
@@ -38,7 +38,7 @@ interface PackageState {
id: string;
originIcao: string;
destIcao: string;
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
}
+74
View File
@@ -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<string> {
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<AuthUser | null> {
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<AdminGuard> {
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 };
}
+36
View File
@@ -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();
+29
View File
@@ -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.
+22 -3
View File
@@ -53,15 +53,14 @@ export const packages = sqliteTable('packages', {
deadlineSimTime: integer('deadline_sim_time').notNull(),
// 'expired' = an open OR loaded package whose deadline passed on a tick. Loaded
// expired packages keep occupying their slot, deliver no reward, and abandon free.
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] }).notNull(),
status: text('status', { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] }).notNull(),
planeId: text('plane_id'),
createdSimTime: integer('created_sim_time').notNull(),
// Real-time (Date.now()) epoch ms of creation (v0.65.0) — open jobs live on the WALL clock, not the sim
// clock: they expire JOB_LIFETIME_MS after this regardless of time scale or fleet state. Legacy rows
// backfill to seed time so they get a fresh full lifetime instead of expiring on the first tick.
createdRealMs: integer('created_real_ms').notNull().default(0),
// Real-time epoch ms when this (origin, band) slot was last taken — loaded or ignored (v0.65.0). The pool
// Real-time epoch ms when this (origin, band) slot was last taken — loaded (v0.65.0). The pool
// spawner holds that slot for the band's refill delay after; NULL = never taken / no cooldown. Expired open
// jobs set nothing: an untaken job's slot respawns immediately.
slotFreeAtRealMs: integer('slot_free_at_real_ms'),
@@ -85,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;
-31
View File
@@ -28,7 +28,6 @@ import {
cancelQueuedLeg,
flyPlane,
GameActionError,
ignorePackage,
loadPackage,
queueLeg,
repairPlane,
@@ -530,36 +529,6 @@ describe('abandonPackage', () => {
});
});
describe('ignorePackage', () => {
it('flips an open package to ignored — no penalty, nothing else changes', async () => {
const inst = await freshDb('ig-open.db');
await insertOpenPackage(inst, 'p-ig');
const cashBefore = (await inst.db.select().from(schema.account))[0].cash;
await ignorePackage(inst, 'p-ig');
const row = (await inst.db.select().from(schema.packages).where(eq(schema.packages.id, 'p-ig')))[0];
expect(row.status).toBe('ignored');
expect(row.planeId).toBeNull(); // was never bound
expect((await inst.db.select().from(schema.account))[0].cash).toBe(cashBefore); // no penalty
});
it.each(['loaded', 'delivered', 'expired', 'abandoned', 'ignored'] as const)(
'rejects a %s package with 409 (must be open)',
async (status) => {
const inst = await freshDb(`ig-${status}.db`);
await insertOpenPackage(inst, 'p-x', { status });
await expectActionError(ignorePackage(inst, 'p-x'), 409, new RegExp(status));
},
);
it('rejects an unknown id with 404', async () => {
const inst = await freshDb('ig-unknown.db');
await expectActionError(ignorePackage(inst, 'nope'), 404);
});
});
describe('buyPlane', () => {
it('deducts the full price and delivers a full-tank plane at the home base', async () => {
const inst = await freshDb('buy-ok.db');
-17
View File
@@ -263,23 +263,6 @@ export async function abandonPackage(inst: GameDb, packageId: string): Promise<{
return { penalty };
}
// --- Ignore (POST /api/packages/:id/ignore) — ISS-047 part B ---
// Dismiss an OPEN job so it leaves the market permanently; no penalty, nothing else
// changes. The pool spawner refills this (origin, band) slot after its refill delay —
// ignoring starts that cooldown via slotFreeAtRealMs (v0.65.0). 404 unknown id; 409 unless open.
export async function ignorePackage(inst: GameDb, packageId: string): Promise<void> {
const pkgRows = await inst.db.select().from(schema.packages).where(eq(schema.packages.id, packageId));
const pkg = pkgRows[0];
if (!pkg) throw new GameActionError(404, `package ${packageId} not found`);
if (pkg.status !== 'open') {
throw new GameActionError(409, `package is ${pkg.status}, only open packages can be ignored`);
}
await inst.db.update(schema.packages).set({ status: 'ignored', slotFreeAtRealMs: Date.now() }).where(eq(schema.packages.id, packageId));
await persist(inst);
}
// --- Market (POST /api/market/buy-plane {model}) ---
// Buy a catalog model at its reputation-scaled price (v0.38.0, ISS-072 — discount grows with
+1 -1
View File
@@ -122,7 +122,7 @@ function envMs(name: string, fallback: number): number {
}
export const JOB_LIFETIME_MS = envMs('FLIGHT_JOB_LIFETIME_MS', 3_600_000); // 1 h wall clock
// Refill cooldown after a job is TAKEN (loaded or ignored): the slot stays empty this long so the
// Refill cooldown after a job is TAKEN (loaded): the slot stays empty this long so the
// board visibly refreshes instead of instantly refilling. Expired/delivered/abandoned never block —
// an untaken job's slot respawns immediately. Small jobs (<4 t) refill faster; they're common.
export const REFILL_DELAY_MS = envMs('FLIGHT_JOB_REFILL_MS', 15 * 60_000); // 15 min wall clock
-1
View File
@@ -40,7 +40,6 @@ describe('sseFrame', () => {
pkg('loaded', 'p-loaded'),
pkg('abandoned', 'p-abandoned'),
pkg('expired', 'p-expired'),
pkg('ignored', 'p-ignored'),
];
const frame = sseFrame(snapshot(all));
expect(frame.packages.map((p) => p.id)).toEqual(['p-open', 'p-loaded', 'p-expired']);
+2 -3
View File
@@ -81,8 +81,7 @@ export interface PackageState {
// Great-circle leg distance in km (v0.48.0, ISS-095) — computed from airport coords, not stored.
distanceKm: number;
deadlineSimTime: number;
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned' | 'ignored';
status: 'open' | 'loaded' | 'delivered' | 'expired' | 'abandoned';
planeId: string | null;
createdSimTime: number;
// Wall-clock expiry of an OPEN job (v0.65.0) — createdRealMs + JOB_LIFETIME_MS, shown as a live
@@ -106,7 +105,7 @@ export interface StateSnapshot {
// ISS-133 (v0.59.0): the SSE projection of a snapshot — only the package statuses any component
// renders: open (market list), loaded + expired (on-board cargo, which keeps its payload slot).
// Terminal history (delivered/abandoned/ignored) is unbounded in the DB and never read by the
// Terminal history (delivered/abandoned) is unbounded in the DB and never read by the
// client, so it stays out of every pushed frame; /api/state and action responses keep the full set.
export const SSE_PACKAGE_STATUSES = ['open', 'loaded', 'expired'] as const;
+56 -3
View File
@@ -3,14 +3,18 @@ import { describe, expect, it } from 'vitest';
import { openApiSpec } from './openapi';
describe('OpenAPI spec', () => {
it('documents exactly the thirteen public endpoints (4 read + 9 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}/ignore', // v0.29.0 (ISS-047 part B)
'/api/packages/{id}/load',
'/api/planes/{planeId}/fly',
'/api/planes/{planeId}/queue', // v0.25.0 (ISS-043)
@@ -153,7 +157,7 @@ describe('OpenAPI spec', () => {
expect(Object.keys(sim).sort()).toEqual(['clockMs']);
const packageStatus = openApiSpec.components.schemas.PackageState.properties.status;
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'ignored', 'loaded', 'open']); // v0.29.0 (ISS-047 part B)
expect(packageStatus.enum.sort()).toEqual(['abandoned', 'delivered', 'expired', 'loaded', 'open']);
});
it('documents the eight action endpoints: snapshot on 200, Error on their 4xx paths', () => {
@@ -243,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<string, Record<string, { responses: Record<string, unknown>; 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<string, { schema: unknown }> };
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<string, { schema: { $ref: string } }> }).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');
});
});
+325 -28
View File
@@ -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,
},
@@ -147,31 +147,6 @@ export const openApiSpec = {
},
},
},
'/api/packages/{id}/ignore': {
post: {
summary: 'Ignore an open job',
description:
'Dismiss an OPEN job so it leaves the market permanently (v0.29.0, ISS-047 part B). No penalty and nothing else changes — the top-up logic spawns a replacement on the next tick. No body.',
operationId: 'ignorePackage',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: {
description: 'Ignored — full state snapshot (package "ignored", gone from the open list)',
content: { 'application/json': { schema: { $ref: '#/components/schemas/StateSnapshot' } } },
},
404: {
description: 'Unknown package id',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
409: {
description: 'Package not "open"',
content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
},
},
},
},
'/api/planes/{planeId}/fly': {
post: {
summary: 'Fly a plane to an airport',
@@ -373,6 +348,243 @@ export const openApiSpec = {
},
},
},
// 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' } } },
},
},
},
},
},
components: {
schemas: {
@@ -521,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: {
@@ -604,8 +902,7 @@ export const openApiSpec = {
deadlineSimTime: { type: 'integer', description: 'Sim-clock epoch ms (game time)' },
// 'expired' = an open OR loaded package whose deadline passed on a tick;
// loaded expired packages keep their slot, deliver no reward, abandon free.
// 'ignored' (v0.29.0, ISS-047 part B) = a dismissed open job — terminal, leaves the market.
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned', 'ignored'] },
status: { enum: ['open', 'loaded', 'delivered', 'expired', 'abandoned'] },
planeId: { type: ['string', 'null'] },
createdSimTime: { type: 'integer' },
expiresRealMs: { type: ['integer', 'null'], description: 'Wall-clock epoch ms when an OPEN job expires (v0.65.0) — createdRealMs + lifetime; null for every non-open status.' },
+5 -7
View File
@@ -152,7 +152,6 @@ describe('expireLoadedPackages (sim clock)', () => {
{ id: 'p-loaded-future', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock + 1, status: 'loaded', planeId: 'plane-starter', createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-delivered-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'delivered', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-abandoned-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'abandoned', planeId: null, createdSimTime: STARTING_CLOCK_MS },
{ id: 'p-ignored-past', originIcao: 'EDDF', destIcao: 'LEMD', reward: 1, cargoType: 'Mail', cargoCount: 1, deadlineSimTime: clock - 1, status: 'ignored', planeId: null, createdSimTime: STARTING_CLOCK_MS },
]);
const n = await expireLoadedPackages(inst, clock);
@@ -163,7 +162,6 @@ describe('expireLoadedPackages (sim clock)', () => {
expect(byId.get('p-loaded-future')).toBe('loaded'); // deadline not reached yet
expect(byId.get('p-delivered-past')).toBe('delivered'); // terminal — never flips
expect(byId.get('p-abandoned-past')).toBe('abandoned'); // terminal — never flips
expect(byId.get('p-ignored-past')).toBe('ignored'); // v0.29.0 (ISS-047 part B) — terminal, never flips
});
it('a deadline exactly at the clock counts as expired', async () => {
@@ -673,12 +671,12 @@ describe('grounded fleet freezes the world clock (#135)', () => {
it('a frozen tick still refills the pool so a grounded fleet never starves (#135 follow-up)', async () => {
const inst = await freshDb('frozen-topup.db'); // seeded starter is on the ground → frozen
// Drain the open market to zero the way repeated ignores would (removal paths run while frozen).
// Direct status flip WITHOUT slotFreeAtRealMs: models "slots are empty", not "taken" — a real
// ignore stamps the cooldown and would block this refill for REFILL_DELAY_MS.
// Drain the open market the way wall-clock expiry would (removal paths run while frozen).
// Direct status flip WITHOUT slotFreeAtRealMs, mirroring expireOpenPackages: models "slots are
// empty", not "taken" — a real load stamps the cooldown and would block this refill for REFILL_DELAY_MS.
for (const p of await inst.db.select().from(schema.packages)) {
if (p.status === 'open') {
await inst.db.update(schema.packages).set({ status: 'ignored' }).where(eq(schema.packages.id, p.id));
await inst.db.update(schema.packages).set({ status: 'expired' }).where(eq(schema.packages.id, p.id));
}
}
@@ -807,7 +805,7 @@ describe('spawnPoolPackages (v0.65.0 per-airport pools)', () => {
const small = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) === 0)!;
const heavy = all.find((p) => p.status === 'open' && poolBandOf(p.weightKg) >= 2)!;
await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, small.id));
await inst.db.update(schema.packages).set({ status: 'ignored', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, heavy.id));
await inst.db.update(schema.packages).set({ status: 'loaded', planeId: 'plane-starter', slotFreeAtRealMs: T0 }).where(eq(schema.packages.id, heavy.id));
// While the cooldown runs (real = T0), nothing refills — even though two slots are empty.
expect(await spawnPoolPackages(inst, T0 + 5_000, mulberry32(7), T0)).toBe(0);
+11 -7
View File
@@ -1,5 +1,5 @@
import { randomInt, randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { eq, sql } from 'drizzle-orm';
import { getGameDb, persist, type GameDb } from '@/lib/db/client';
import * as schema from '@/lib/db/schema';
import { loadAirportsGeoJson } from '@/lib/airports';
@@ -111,7 +111,7 @@ export async function tickOnce(inst: GameDb): Promise<schema.SimStateRow> {
await finishRepairs(inst, clockMs);
}
// v0.65.0: market refresh runs on EVERY tick — frozen ones included (#135 follow-up carried over) —
// because while grounded nothing else creates packages but removal paths (ignore) still run; without
// because while grounded nothing else creates packages but removal paths (open-job expiry) still run; without
// this a grounded fleet could drain its pools to zero and soft-lock forever. Open jobs age on the
// WALL clock (JOB_LIFETIME_MS after createdRealMs), so even a fully paused world refreshes its board;
// both passes are idempotent no-ops when satisfied, so advancing ticks behave exactly as before.
@@ -147,9 +147,13 @@ export async function expireLoadedPackages(inst: GameDb, clockMs: number): Promi
// v0.65.0: open jobs live on the WALL clock — JOB_LIFETIME_MS after createdRealMs they expire
// regardless of time scale or fleet state, so a paused/frozen world still refreshes its board. Runs
// every tick (~1 s), outside the advance gate. An expired job's slot respawns immediately (no
// slotFreeAtRealMs is set — only TAKEN jobs start a refill cooldown). Returns the count flipped.
// slotFreeAtRealMs is set — only TAKEN jobs start a refill cooldown). v0.69.0: legacy 'ignored' rows
// (pre-v0.69.0 dismissals) are swept in too — each self-heals to 'expired' on the first tick past its
// lifetime, and no new ones can ever be created. Returns the count flipped.
export async function expireOpenPackages(inst: GameDb, nowMs: number): Promise<number> {
const rows = await inst.db.select().from(schema.packages).where(eq(schema.packages.status, 'open'));
// Raw SQL on purpose: 'ignored' is no longer in the status enum (v0.69.0) — it only exists as a
// legacy value in pre-v0.69.0 databases, so the typed column can't express the query.
const rows = await inst.db.select().from(schema.packages).where(sql`${schema.packages.status} IN ('open', 'ignored')`);
let n = 0;
for (const p of rows) {
if (p.createdRealMs + JOB_LIFETIME_MS <= nowMs) {
@@ -352,7 +356,7 @@ export async function deliverPackages(inst: GameDb, clockMs: number): Promise<nu
// (v0.8.0). Every airport where a plane can take work — grounded there, flying TO it, or queued for
// it (poolAirports in lib/game/state.ts) — keeps the POOL_BANDS composition of open packages: each
// band is topped back up to its count using generatePoolPackage, so seeded and spawned packages are
// indistinguishable. A TAKEN slot (loaded/ignored) stays empty for the band's refill delay after
// indistinguishable. A TAKEN slot (loaded) stays empty for the band's refill delay after
// slotFreeAtRealMs; expired/delivered/abandoned never block, so an untaken job's slot respawns on
// the next tick. Orphaned pools (plane left without taking a job) simply expire out — no cleanup.
// RNG and wall-clock now are injectable for deterministic tests. Returns the number created.
@@ -385,8 +389,8 @@ export async function spawnPoolPackages(
let n = 0;
for (const originIcao of pool) {
// Open counts and refill cooldowns per band at this airport. Only open/loaded/ignored rows matter:
// terminal statuses never block a slot, and loaded/ignored carry the slotFreeAtRealMs stamp.
// Open counts and refill cooldowns per band at this airport. Only open/loaded rows matter:
// terminal statuses never block a slot, and loaded carries the slotFreeAtRealMs stamp.
const openByBand = [0, 0, 0, 0];
let blockedUntil: number | null = null;
for (const p of packageRows) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "flight-ops",
"version": "0.70.0",
"version": "0.71.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",