From b0f1bd8b67e05337682d155887223ff46ee995f7 Mon Sep 17 00:00:00 2001 From: Kamil Krzywda Date: Sat, 26 Sep 2026 14:40:56 +0000 Subject: [PATCH] v1.1.0: session guard on all game routes + register + sign-in screen (ISS-107) --- CHANGELOG.md | 7 ++ app/api/events/route.test.ts | 17 +++- app/api/events/route.ts | 12 ++- app/api/market/buy-plane/route.test.ts | 13 ++- app/api/market/buy-plane/route.ts | 2 +- app/api/market/sell-plane/route.test.ts | 13 ++- app/api/market/sell-plane/route.ts | 2 +- app/api/packages/[id]/abandon/route.test.ts | 12 ++- app/api/packages/[id]/abandon/route.ts | 4 +- app/api/packages/[id]/load/route.test.ts | 15 +++- app/api/packages/[id]/load/route.ts | 2 +- app/api/planes/[planeId]/fly/route.test.ts | 15 +++- app/api/planes/[planeId]/fly/route.ts | 2 +- .../planes/[planeId]/queue/cancel/route.ts | 2 +- app/api/planes/[planeId]/queue/route.ts | 2 +- app/api/planes/[planeId]/repair/route.ts | 4 +- app/api/register/route.test.ts | 70 +++++++++++++++ app/api/register/route.ts | 71 +++++++++++++++ app/api/state/route.test.ts | 17 +++- app/api/state/route.ts | 7 +- .../test/mint-player-session/route.test.ts | 75 ++++++++++++++++ app/api/test/mint-player-session/route.ts | 77 ++++++++++++++++ app/globals.css | 71 +++++++++++++++ app/page.tsx | 48 +++++++++- components/AdminPanel.tsx | 29 ++----- components/Map.tsx | 11 ++- components/SignInScreen.tsx | 78 +++++++++++++++++ e2e/actions.spec.ts | 5 ++ e2e/admin.spec.ts | 46 +++++----- e2e/api.spec.ts | 3 +- e2e/auth.ts | 31 +++++++ e2e/events.spec.ts | 10 ++- e2e/hover.spec.ts | 5 ++ e2e/maintenance.spec.ts | 5 ++ e2e/map-interactions.spec.ts | 5 ++ e2e/map.spec.ts | 5 ++ e2e/panes.spec.ts | 5 ++ e2e/queue.spec.ts | 5 ++ e2e/relocation.spec.ts | 5 ++ e2e/right-pane.spec.ts | 5 ++ e2e/sim.spec.ts | 5 ++ e2e/state.spec.ts | 5 ++ e2e/toasts.spec.ts | 5 ++ e2e/two-planes.spec.ts | 5 ++ lib/auth.test.ts | 87 +++++++++++++++++++ lib/auth.ts | 60 +++++++++++-- lib/authClient.ts | 34 ++++++++ lib/game/actions.ts | 7 +- lib/openapi.test.ts | 3 +- lib/openapi.ts | 40 +++++++++ lib/useGameAction.ts | 4 +- lib/useGameState.test.ts | 14 ++- lib/useGameState.ts | 9 +- package.json | 2 +- 54 files changed, 1002 insertions(+), 91 deletions(-) create mode 100644 app/api/register/route.test.ts create mode 100644 app/api/register/route.ts create mode 100644 app/api/test/mint-player-session/route.test.ts create mode 100644 app/api/test/mint-player-session/route.ts create mode 100644 components/SignInScreen.tsx create mode 100644 e2e/auth.ts create mode 100644 lib/auth.test.ts create mode 100644 lib/authClient.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bba0fc..1fa56d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v1.1.0 — 2026-09-26 14:39 +- **Multiplayer step 3/12 (ISS-107): every game route is session-guarded; new accounts can register.** `lib/auth.ts` grows a shared guard on top of the v0.71.0 session plumbing: `requireAuth(req)` (X-Flight-Session header) and `requireAuthSse(req)` (header OR `?session=` query — EventSource cannot set headers), both returning either `{ok, inst, user, player}` or a ready-made 401/500 response. The guard joins `players` on the account's username, so the seeded admin resolves to the v1.0.0 local player row; an account with no players row is a data error → 500. Coverage: `/api/state`, all eight action routes (one `performAction(req, fn)` call site in `lib/game/actions.ts` guards them all), and the SSE stream (`/api/events`, validated at connect — ponytail comment names the mid-stream ceiling). Exempt by design: `/api/version`, `/api/openapi`, `/api/airports`, `/api/login`, `/api/register`, `/api/logout`, `/api/me`, the admin CRUD (own guard), and every `/api/test/*` hook. +- **POST /api/register.** Strict body validation (exactly `username` + `password`; unknown fields 400); username lowercased, then 3–20 chars of `[a-z0-9_-]` else 400; password ≥8 chars else 400; 409 when taken. Creates the users row (scrypt via the same `hashPassword()` login verifies) AND a players row with the starter economy (`STARTING_CASH`/`STARTING_REPUTATION`) — one randomUUID shared by both rows — then mints a session immediately and returns `{sessionId, user}` like `/api/login`, so sign-up needs no second round-trip. +- **Sign-in screen replaces the shell until authenticated.** New `components/SignInScreen.tsx`: full-screen dark panel with two forms (sign in / create account) sharing one submit path; errors surface in a single `role=alert` line. `app/page.tsx` gates on it: stored session → `/api/me` check on mount (stale id cleared, network error falls through to sign-in), then the game shell mounts only when authenticated — so an unauthenticated browser never polls `/api/state`. New client module `lib/authClient.ts` owns the localStorage key (`flight.session`) and builds the `X-Flight-Session` header; every app fetch (state poll, SSE URL, action POSTs, fly/queue in Map) now carries it. Admin panel logout drops back to the sign-in screen via a new `onLogout` prop (page → Map → AdminPanel). +- **E2E session hook + spec updates.** New test-only `POST /api/test/mint-player-session` (FLIGHT_TEST_HOOKS=1, 404 elsewhere — like its `/api/test/*` siblings): creates the users+players rows for a username if missing and mints a session, idempotent across container restarts since the CI DB volume persists. `e2e/auth.ts` mints + applies it per test (context extraHTTPHeaders for spec-side requests, localStorage init script for the app's own fetches); every guarded-route spec authenticates in a beforeEach; events.spec uses the real client path (`?session=`); api.spec/openapi tests track the new eighteenth endpoint. Admin spec reworked: unauthenticated visitors see the sign-in screen (no shell), wrong credentials error there, and logout returns to it. +- **Tests.** Unit: new `lib/auth.test.ts` (hash round-trip; 401 on missing/unknown/expired session; admin→local player join; 500 on missing players row), register route tests (validation matrix, shared-id rows + starter economy + minted session, case-insensitive 409) and mint-hook tests (gate off → 404 even with CI's FLIGHT_TEST_HOOKS=1 env — stubbed explicitly, default-username creation, idempotent re-mint, bad usernames). All existing state/events/action route tests now mint a session for the seeded admin user in beforeAll. OpenAPI: `/api/register` documented (RegisterRequest schema; 200/400/409) — spec is now eighteen paths. + ## v1.0.0 — 2026-09-24 14:20 - **Multiplayer step 1/12 (ISS-105): `players` table replaces the singleton `account`.** New `players` table (`id TEXT PK`, `provider`, `username`, `cash REAL`, `reputation REAL`) in the raw DDL (`lib/db/client.ts`) and a matching Drizzle entry (`lib/db/schema.ts`; `AccountRow` → `PlayerRow`). **One-time migration**: on every `open()`, if the legacy `account` table still exists (sqlite_master check, same guard style as the v0.12.0 contracts migration) AND `players` is empty, its single row is copied into a seeded local player — id `local`, provider `local`, username `kamilkrzywda` (so a later step can join players↔users on username) with cash/reputation carried over — and `account` is dropped. Idempotent: fresh DBs never have `account`, already-migrated files no longer do either, so it runs exactly once per legacy file. **Fresh worlds**: the DDL no longer creates `account`; first-run seeding (empty-check flipped from `COUNT(*) FROM account` to `players`) inserts the local player row directly with the same starting cash/reputation as before. - **No behavior change — temporary single-player helper.** Every pre-multiplayer read/write of the singleton economy now goes through ONE small module, `lib/db/localPlayer.ts`: `getLocalPlayer(inst)` (resolves the `players` row where id = 'local', throws if missing) and `updateLocalPlayerEconomy(inst, {cash?, reputation?})`. Call sites converted: snapshot (`lib/game/state.ts`), all five actions (`flyPlane` refuel, abandon penalty, buy/sell plane, repair — `lib/game/actions.ts`), the engine's delivery awards + chain top-up + spawn reputation read (`lib/sim/engine.ts`), and the three test hooks (add-cash / set-cash / create-package). The `/api/state` snapshot keeps its exact `account: {cash, reputation}` field name and values, so OpenAPI is untouched — 17 paths, same schemas. diff --git a/app/api/events/route.test.ts b/app/api/events/route.test.ts index 3392ffa..a935520 100644 --- a/app/api/events/route.test.ts +++ b/app/api/events/route.test.ts @@ -2,9 +2,12 @@ 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 { createSession } from '@/lib/auth'; +import { getGameDb } from '@/lib/db/client'; import { GET } from './route'; let dir: string; +let sessionId: string; beforeAll(async () => { // Same pattern as app/api/state/route.test.ts: point the DB singleton at a throwaway file so @@ -12,6 +15,8 @@ beforeAll(async () => { // vitest gives each test file its own module registry (fresh singleton). dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-events-')); process.env.FLIGHT_DB_PATH = path.join(dir, 'events.db'); + // v1.1.0 (ISS-107): /api/events is session-guarded — mint a session for the seeded admin user. + sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda'); }); afterAll(async () => { @@ -20,8 +25,18 @@ afterAll(async () => { }); describe('GET /api/events', () => { + // v1.1.0 (ISS-107): EventSource cannot set headers, so the client passes the session as a query + // param — this test exercises exactly that path (requireAuthSse). + const req = () => new Request(`http://localhost/api/events?session=${sessionId}`); + + it('rejects an unauthenticated request with 401', async () => { + const res = await GET(new Request('http://localhost/api/events')); + expect(res.status).toBe(401); + expect(((await res.json()) as { error: string }).error).toMatch(/authentication required/i); + }); + it('streams SSE with the current snapshot as the first event', async () => { - const res = await GET(); + const res = await GET(req()); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('text/event-stream'); expect(res.headers.get('cache-control')).toBe('no-cache'); diff --git a/app/api/events/route.ts b/app/api/events/route.ts index ad2a383..d78283a 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,3 +1,4 @@ +import { requireAuthSse } from '@/lib/auth'; import { getStateSnapshot, sseFrame } from '@/lib/game/state'; import { subscribe } from '@/lib/sse'; @@ -5,12 +6,19 @@ import { subscribe } from '@/lib/sse'; // then one after every sim tick (broadcast from lib/sim/engine.ts). One-way push; player actions // stay REST POSTs. Heartbeats keep idle proxies from dropping the connection. ISS-133 (v0.59.0): // frames carry the sseFrame projection (terminal package history dropped) — same shape as the -// pushed frames, so a connect never costs one full-history frame. +// pushed frames, so a connect never costs one full-history frame. v1.1.0 (ISS-107): guarded — +// EventSource cannot set headers, so the session comes from X-Flight-Session OR ?session= +// (requireAuthSse). ponytail: validated at CONNECT only — a session deleted/expired mid-stream keeps +// receiving pushes until it reconnects (private infra, 7-day sessions; per-push re-validation would +// be one indexed lookup per tick if that ever matters). const encoder = new TextEncoder(); const HEARTBEAT_MS = 15_000; -export async function GET() { +export async function GET(req: Request) { + const auth = await requireAuthSse(req); + if (!auth.ok) return auth.response; + let heartbeat: ReturnType | null = null; let unsubscribe: (() => void) | null = null; const cleanup = () => { diff --git a/app/api/market/buy-plane/route.test.ts b/app/api/market/buy-plane/route.test.ts index de9eeee..e8595d2 100644 --- a/app/api/market/buy-plane/route.test.ts +++ b/app/api/market/buy-plane/route.test.ts @@ -4,16 +4,20 @@ import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { getGameDb } from '@/lib/db/client'; +import { createSession } from '@/lib/auth'; import { PLANE_TYPES } from '@/lib/game/catalog'; import { POST } from './route'; let dir: string; +let sessionId: string; beforeAll(async () => { // Same pattern as app/api/state/route.test.ts: point the DB singleton at a // throwaway file (fresh module registry per test file → fresh singleton). dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-buy-api-')); process.env.FLIGHT_DB_PATH = path.join(dir, 'buy.db'); + // v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user. + sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda'); }); afterAll(async () => { @@ -21,7 +25,14 @@ afterAll(async () => { await fs.rm(dir, { recursive: true, force: true }); }); -const post = (body: unknown) => POST(new Request('http://localhost/api/market/buy-plane', { method: 'POST', body: JSON.stringify(body) })); +const post = (body: unknown) => + POST( + new Request('http://localhost/api/market/buy-plane', { + method: 'POST', + headers: { 'X-Flight-Session': sessionId }, + body: JSON.stringify(body), + }), + ); describe('POST /api/market/buy-plane', () => { it('rejects a missing model, unknown fields and unknown models with 400', async () => { diff --git a/app/api/market/buy-plane/route.ts b/app/api/market/buy-plane/route.ts index 0715d92..45d78f5 100644 --- a/app/api/market/buy-plane/route.ts +++ b/app/api/market/buy-plane/route.ts @@ -27,5 +27,5 @@ export async function POST(req: Request) { } const model = input.model; - return performAction((inst) => buyPlane(inst, model)); + return performAction(req, (inst) => buyPlane(inst, model)); } diff --git a/app/api/market/sell-plane/route.test.ts b/app/api/market/sell-plane/route.test.ts index e7ece28..5ddc456 100644 --- a/app/api/market/sell-plane/route.test.ts +++ b/app/api/market/sell-plane/route.test.ts @@ -6,16 +6,20 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as schema from '@/lib/db/schema'; import { getLocalPlayer } from '@/lib/db/localPlayer'; import { getGameDb } from '@/lib/db/client'; +import { createSession } from '@/lib/auth'; import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { POST } from './route'; let dir: string; +let sessionId: string; beforeAll(async () => { // Same pattern as app/api/state/route.test.ts: point the DB singleton at a // throwaway file (fresh module registry per test file → fresh singleton). dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-sell-api-')); process.env.FLIGHT_DB_PATH = path.join(dir, 'sell.db'); + // v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user. + sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda'); }); afterAll(async () => { @@ -23,7 +27,14 @@ afterAll(async () => { await fs.rm(dir, { recursive: true, force: true }); }); -const post = (body: unknown) => POST(new Request('http://localhost/api/market/sell-plane', { method: 'POST', body: JSON.stringify(body) })); +const post = (body: unknown) => + POST( + new Request('http://localhost/api/market/sell-plane', { + method: 'POST', + headers: { 'X-Flight-Session': sessionId }, + body: JSON.stringify(body), + }), + ); describe('POST /api/market/sell-plane', () => { it('rejects a missing planeId and unknown fields with 400', async () => { diff --git a/app/api/market/sell-plane/route.ts b/app/api/market/sell-plane/route.ts index 0ae1d06..df1c3ca 100644 --- a/app/api/market/sell-plane/route.ts +++ b/app/api/market/sell-plane/route.ts @@ -27,5 +27,5 @@ export async function POST(req: Request) { } const planeId = input.planeId; - return performAction((inst) => sellPlane(inst, planeId)); + return performAction(req, (inst) => sellPlane(inst, planeId)); } diff --git a/app/api/packages/[id]/abandon/route.test.ts b/app/api/packages/[id]/abandon/route.test.ts index 3b1c155..7822825 100644 --- a/app/api/packages/[id]/abandon/route.test.ts +++ b/app/api/packages/[id]/abandon/route.test.ts @@ -6,15 +6,19 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as schema from '@/lib/db/schema'; import { getLocalPlayer } from '@/lib/db/localPlayer'; import { getGameDb } from '@/lib/db/client'; +import { createSession } from '@/lib/auth'; import { POST } from './route'; let dir: string; +let sessionId: string; beforeAll(async () => { // Same pattern as app/api/state/route.test.ts: point the DB singleton at a // throwaway file (fresh module registry per test file → fresh singleton). dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-abandon-api-')); process.env.FLIGHT_DB_PATH = path.join(dir, 'abandon.db'); + // v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user. + sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda'); }); afterAll(async () => { @@ -22,9 +26,11 @@ afterAll(async () => { await fs.rm(dir, { recursive: true, force: true }); }); -const post = (id: string) => POST(new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST' }), { - params: Promise.resolve({ id }), -}); +const post = (id: string) => + POST( + new Request(`http://localhost/api/packages/${id}/abandon`, { method: 'POST', headers: { 'X-Flight-Session': sessionId } }), + { params: Promise.resolve({ id }) }, + ); // Insert a LOADED package bound to the starter plane with a long deadline. Returns // the exact created/deadline times so tests can compute the penalty from the diff --git a/app/api/packages/[id]/abandon/route.ts b/app/api/packages/[id]/abandon/route.ts index b2d96f1..cfccaab 100644 --- a/app/api/packages/[id]/abandon/route.ts +++ b/app/api/packages/[id]/abandon/route.ts @@ -7,7 +7,7 @@ import { abandonPackage, performAction } from '@/lib/game/actions'; // id; 409 wrong status or the plane is in flight. On success returns the full // state snapshot. -export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) { +export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) { const { id } = await ctx.params; - return performAction((inst) => abandonPackage(inst, id)); + return performAction(req, (inst) => abandonPackage(inst, id)); } diff --git a/app/api/packages/[id]/load/route.test.ts b/app/api/packages/[id]/load/route.test.ts index faccd1c..cbc88ee 100644 --- a/app/api/packages/[id]/load/route.test.ts +++ b/app/api/packages/[id]/load/route.test.ts @@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as schema from '@/lib/db/schema'; import { getGameDb } from '@/lib/db/client'; +import { createSession } from '@/lib/auth'; import { POST } from './route'; let dir: string; +let sessionId: string; beforeAll(async () => { // Same pattern as app/api/state/route.test.ts: point the DB singleton at a // throwaway file (fresh module registry per test file → fresh singleton). dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-load-api-')); process.env.FLIGHT_DB_PATH = path.join(dir, 'load.db'); + // v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user. + sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda'); }); afterAll(async () => { @@ -22,9 +26,14 @@ afterAll(async () => { }); const post = (id: string, body: unknown) => - POST(new Request(`http://localhost/api/packages/${id}/load`, { method: 'POST', body: JSON.stringify(body) }), { - params: Promise.resolve({ id }), - }); + POST( + new Request(`http://localhost/api/packages/${id}/load`, { + method: 'POST', + headers: { 'X-Flight-Session': sessionId }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id }) }, + ); describe('POST /api/packages/:id/load', () => { it('rejects a missing/non-string planeId and unknown fields with 400', async () => { diff --git a/app/api/packages/[id]/load/route.ts b/app/api/packages/[id]/load/route.ts index 79e2d17..c4cbfd9 100644 --- a/app/api/packages/[id]/load/route.ts +++ b/app/api/packages/[id]/load/route.ts @@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }> } const planeId = input.planeId; - return performAction((inst) => loadPackage(inst, id, planeId)); + return performAction(req, (inst) => loadPackage(inst, id, planeId)); } diff --git a/app/api/planes/[planeId]/fly/route.test.ts b/app/api/planes/[planeId]/fly/route.test.ts index bc22a77..d1fa2a8 100644 --- a/app/api/planes/[planeId]/fly/route.test.ts +++ b/app/api/planes/[planeId]/fly/route.test.ts @@ -5,15 +5,19 @@ import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import * as schema from '@/lib/db/schema'; import { getGameDb } from '@/lib/db/client'; +import { createSession } from '@/lib/auth'; import { POST } from './route'; let dir: string; +let sessionId: string; beforeAll(async () => { // Same pattern as app/api/state/route.test.ts: point the DB singleton at a // throwaway file (fresh module registry per test file → fresh singleton). dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-fly-api-')); process.env.FLIGHT_DB_PATH = path.join(dir, 'fly.db'); + // v1.1.0 (ISS-107): action routes are session-guarded — mint a session for the seeded admin user. + sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda'); }); afterAll(async () => { @@ -22,9 +26,14 @@ afterAll(async () => { }); const post = (planeId: string, body: unknown) => - POST(new Request(`http://localhost/api/planes/${planeId}/fly`, { method: 'POST', body: JSON.stringify(body) }), { - params: Promise.resolve({ planeId }), - }); + POST( + new Request(`http://localhost/api/planes/${planeId}/fly`, { + method: 'POST', + headers: { 'X-Flight-Session': sessionId }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ planeId }) }, + ); describe('POST /api/planes/:planeId/fly', () => { it('rejects a missing/non-string destIcao and unknown fields with 400', async () => { diff --git a/app/api/planes/[planeId]/fly/route.ts b/app/api/planes/[planeId]/fly/route.ts index db09725..cfd0926 100644 --- a/app/api/planes/[planeId]/fly/route.ts +++ b/app/api/planes/[planeId]/fly/route.ts @@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin } const destIcao = input.destIcao; - return performAction((inst) => flyPlane(inst, planeId, destIcao)); + return performAction(req, (inst) => flyPlane(inst, planeId, destIcao)); } diff --git a/app/api/planes/[planeId]/queue/cancel/route.ts b/app/api/planes/[planeId]/queue/cancel/route.ts index 6290ca7..cd9b454 100644 --- a/app/api/planes/[planeId]/queue/cancel/route.ts +++ b/app/api/planes/[planeId]/queue/cancel/route.ts @@ -30,5 +30,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin } const index = input.index as number; - return performAction((inst) => cancelQueuedLeg(inst, planeId, index)); + return performAction(req, (inst) => cancelQueuedLeg(inst, planeId, index)); } diff --git a/app/api/planes/[planeId]/queue/route.ts b/app/api/planes/[planeId]/queue/route.ts index 987cc3e..e3e9099 100644 --- a/app/api/planes/[planeId]/queue/route.ts +++ b/app/api/planes/[planeId]/queue/route.ts @@ -31,5 +31,5 @@ export async function POST(req: Request, ctx: { params: Promise<{ planeId: strin } const destIcao = input.destIcao; - return performAction((inst) => queueLeg(inst, planeId, destIcao)); + return performAction(req, (inst) => queueLeg(inst, planeId, destIcao)); } diff --git a/app/api/planes/[planeId]/repair/route.ts b/app/api/planes/[planeId]/repair/route.ts index 07f3506..5c9edbf 100644 --- a/app/api/planes/[planeId]/repair/route.ts +++ b/app/api/planes/[planeId]/repair/route.ts @@ -6,7 +6,7 @@ import { performAction, repairPlane } from '@/lib/game/actions'; // repair is a free no-op (cost 0). 404 unknown id; 409 plane not grounded or insufficient funds. // On success returns the full state snapshot. -export async function POST(_req: Request, ctx: { params: Promise<{ planeId: string }> }) { +export async function POST(req: Request, ctx: { params: Promise<{ planeId: string }> }) { const { planeId } = await ctx.params; - return performAction((inst) => repairPlane(inst, planeId)); + return performAction(req, (inst) => repairPlane(inst, planeId)); } diff --git a/app/api/register/route.test.ts b/app/api/register/route.test.ts new file mode 100644 index 0000000..130fe95 --- /dev/null +++ b/app/api/register/route.test.ts @@ -0,0 +1,70 @@ +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 { verifyPassword } from '@/lib/auth'; +import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed'; +import { POST } from './route'; + +// v1.1.0 (ISS-107) — register route tests. Same temp-DB pattern as the login route tests: the DB +// singleton points at a throwaway file, and every created user is verified through verifyPassword(). + +let dir: string; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-register-api-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'register.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/register', { method: 'POST', body: JSON.stringify(body) })); + +describe('POST /api/register', () => { + it('rejects malformed bodies and bad credentials with 400', async () => { + for (const body of [ + {}, // missing both fields + { username: 'ab', password: 'longenough' }, // too short + { username: 'a'.repeat(21), password: 'longenough' }, // too long + { username: 'bad user!', password: 'longenough' }, // bad charset (space + !) + { username: 'validuser', password: 'short' }, // password < 8 chars + { username: 'validuser', password: 'longenough', bogus: 1 }, // unknown field + ]) { + const res = await post(body); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBeTruthy(); + } + }); + + it('creates users + players rows sharing one id, with the starter economy, and mints a session', async () => { + const res = await post({ username: 'NewUser42', password: 'correct-horse-1' }); + expect(res.status).toBe(200); + const body = (await res.json()) as { sessionId: string; user: { username: string; admin: boolean } }; + expect(body.user).toEqual({ username: 'newuser42', admin: false }); // input lowercased + expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/); + + const inst = await getGameDb(); + const userRow = inst.raw.exec('SELECT id, password_hash FROM users WHERE username = ?', ['newuser42'])[0].values[0]; + const playerRow = inst.raw.exec('SELECT id, provider, cash, reputation FROM players WHERE username = ?', ['newuser42'])[0].values[0]; + expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows + expect(verifyPassword('correct-horse-1', String(userRow[1]))).toBe(true); + expect(String(playerRow[1])).toBe('local'); + expect(Number(playerRow[2])).toBe(STARTING_CASH); + expect(Number(playerRow[3])).toBe(STARTING_REPUTATION); + + // The minted session resolves to the new user. + const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0]; + expect(String(sess[0])).toBe(String(userRow[0])); + }); + + it('returns 409 when the username is taken (case-insensitively)', async () => { + await post({ username: 'taken-user', password: 'correct-horse-1' }); + const res = await post({ username: 'Taken-User', password: 'different-pass-2' }); + expect(res.status).toBe(409); + expect(((await res.json()) as { error: string }).error).toMatch(/already taken/i); + }); +}); diff --git a/app/api/register/route.ts b/app/api/register/route.ts new file mode 100644 index 0000000..d673f50 --- /dev/null +++ b/app/api/register/route.ts @@ -0,0 +1,71 @@ +import { randomUUID } from 'node:crypto'; +import * as schema from '@/lib/db/schema'; +import { getGameDb, persist } from '@/lib/db/client'; +import { createSession, hashPassword } from '@/lib/auth'; +import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed'; + +// v1.1.0 (ISS-107) — POST /api/register +// body { username: string, password: string } (both required; unknown fields 400). +// Username: 3–20 chars of [a-z0-9_-], case-insensitive input stored lowercase → else 400. +// Password: min 8 chars → else 400. Creates the users row (scrypt via hashPassword — same +// digest shape verifyPassword() checks) AND a players row with the starter economy (the exact +// values seedEmpty() gives a fresh world). 409 when the username is taken. On success mints a +// session immediately and returns { sessionId, user } like /api/login — no second round-trip. + +const USERNAME_RE = /^[a-z0-9_-]{3,20}$/; + +export async function POST(req: Request) { + let body: unknown; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : undefined; + } catch { + return Response.json({ error: 'invalid JSON body' }, { status: 400 }); + } + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); + } + const input = body as Record; + for (const key of Object.keys(input)) { + if (key !== 'username' && key !== 'password') { + return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); + } + } + const username = typeof input.username === 'string' ? input.username.toLowerCase() : ''; + if (!USERNAME_RE.test(username)) { + return Response.json( + { error: 'username must be 3–20 characters of a-z, 0-9, _ or -' }, + { status: 400 }, + ); + } + const password = input.password; + if (typeof password !== 'string' || password.length < 8) { + return Response.json({ error: 'password must be at least 8 characters' }, { status: 400 }); + } + + const inst = await getGameDb(); + const taken = inst.raw.exec('SELECT 1 FROM users WHERE username = ?', [username]); + if (taken[0]?.values?.length) { + return Response.json({ error: 'username is already taken' }, { status: 409 }); + } + + // One UUID for both rows — the account and its player share an id; requireAuth still joins on username. + const id = randomUUID(); + inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [ + id, + username, + hashPassword(password), + Date.now(), + ]); + await inst.db.insert(schema.players).values({ + id, + provider: 'local', + username, + cash: STARTING_CASH, + reputation: STARTING_REPUTATION, + }); + await persist(inst); + + const sessionId = await createSession(inst, id); + return Response.json({ sessionId, user: { username, admin: false } }); +} diff --git a/app/api/state/route.test.ts b/app/api/state/route.test.ts index 82ff544..84b483d 100644 --- a/app/api/state/route.test.ts +++ b/app/api/state/route.test.ts @@ -5,9 +5,12 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { loadAirportsGeoJson } from '@/lib/airports'; import { haversineKm } from '@/lib/geo'; import { FUEL_PRICE_PER_L, PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; +import { createSession } from '@/lib/auth'; +import { getGameDb } from '@/lib/db/client'; import { GET } from './route'; let dir: string; +let sessionId: string; beforeAll(async () => { // Point the app's DB singleton at a throwaway file so this test never @@ -15,8 +18,14 @@ beforeAll(async () => { // vitest gives each test file its own module registry (fresh singleton). dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-state-')); process.env.FLIGHT_DB_PATH = path.join(dir, 'state.db'); + // v1.1.0 (ISS-107): /api/state is session-guarded — mint a session for the seeded admin user + // (no password needed: createSession only inserts into sessions; the guard joins players on + // username → the 'local' row). + sessionId = await createSession(await getGameDb(), 'admin-kamilkrzywda'); }); +const req = () => new Request('http://localhost/api/state', { headers: { 'X-Flight-Session': sessionId } }); + afterAll(async () => { delete process.env.FLIGHT_DB_PATH; await fs.rm(dir, { recursive: true, force: true }); @@ -24,7 +33,7 @@ afterAll(async () => { describe('GET /api/state', () => { it('returns 200 with the full snapshot shape', async () => { - const res = await GET(); + const res = await GET(req()); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('application/json'); @@ -75,13 +84,13 @@ describe('GET /api/state', () => { }); it('is stable across repeated calls (same seeded DB)', async () => { - const a = ((await GET()).json()) as Promise<{ account: { cash: number } }>; - const b = ((await GET()).json()) as Promise<{ account: { cash: number } }>; + const a = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>; + const b = ((await GET(req())).json()) as Promise<{ account: { cash: number } }>; expect((await b).account.cash).toBe((await a).account.cash); }); it('exposes the catalog + economy constants matching lib/game/catalog.ts (v0.10.0)', async () => { - const body = ((await GET()).json()) as Promise<{ + const body = ((await GET(req())).json()) as Promise<{ catalog: Array>; fuelPricePerL: number; sellFraction: number; diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 3010458..0595342 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -1,7 +1,12 @@ +import { requireAuth } from '@/lib/auth'; import { tickNow } from '@/lib/sim/engine'; import { getStateSnapshot } from '@/lib/game/state'; -export async function GET() { +// v1.1.0 (ISS-107): guarded — 401 without a live X-Flight-Session. The snapshot shape is +// unchanged; per-player snapshots are a later step. +export async function GET(req: Request) { + const auth = await requireAuth(req); + if (!auth.ok) return auth.response; try { // Tick-on-read (v0.7.0): advance the sim clock before snapshotting so every // read reflects current game time (expiry/finalize/spawn run inside the tick). diff --git a/app/api/test/mint-player-session/route.test.ts b/app/api/test/mint-player-session/route.test.ts new file mode 100644 index 0000000..15d1b63 --- /dev/null +++ b/app/api/test/mint-player-session/route.test.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { getGameDb } from '@/lib/db/client'; +import { POST } from './route'; + +// v1.1.0 (ISS-107) — the e2e session-mint hook. CI runs unit tests with FLIGHT_TEST_HOOKS=1 set +// (docker-compose.ci.yml), so the gate test stubs the env explicitly instead of relying on absence. + +let dir: string; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-mint-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'mint.db'); +}); + +afterAll(async () => { + vi.unstubAllEnvs(); + delete process.env.FLIGHT_DB_PATH; + await fs.rm(dir, { recursive: true, force: true }); +}); + +const post = (body?: unknown) => + POST( + new Request('http://localhost/api/test/mint-player-session', { + method: 'POST', + body: body === undefined ? undefined : JSON.stringify(body), + }), + ); + +describe('POST /api/test/mint-player-session', () => { + it('404s when FLIGHT_TEST_HOOKS is not "1" (like its /api/test/* siblings)', async () => { + vi.stubEnv('FLIGHT_TEST_HOOKS', ''); + const res = await post(); + expect(res.status).toBe(404); + }); + + it('creates the default test account + player row and mints a session when enabled', async () => { + vi.stubEnv('FLIGHT_TEST_HOOKS', '1'); + const res = await post(); // no body → DEFAULT_USERNAME + expect(res.status).toBe(200); + const body = (await res.json()) as { sessionId: string }; + expect(body.sessionId).toMatch(/^[0-9a-f-]{36}$/); + + const inst = await getGameDb(); + const userRow = inst.raw.exec("SELECT id FROM users WHERE username = 'e2e-tester'")[0].values[0]; + expect(userRow.length).toBe(1); + const playerRow = inst.raw.exec("SELECT id, cash FROM players WHERE username = 'e2e-tester'")[0].values[0]; + expect(String(playerRow[0])).toBe(String(userRow[0])); // one UUID for both rows + + // The minted session resolves to the new user. + const sess = inst.raw.exec('SELECT user_id FROM sessions WHERE id = ?', [body.sessionId])[0].values[0]; + expect(String(sess[0])).toBe(String(userRow[0])); + }); + + it('is idempotent — re-minting an existing username mints another session, no duplicate rows', async () => { + vi.stubEnv('FLIGHT_TEST_HOOKS', '1'); + const count = async () => Number((await getGameDb()).raw.exec("SELECT COUNT(*) FROM users WHERE username = 'e2e-tester'")[0].values[0][0]); + expect(await count()).toBe(1); + + const res = await post(); + expect(res.status).toBe(200); + expect(await count()).toBe(1); // still one account… + }); + + it('rejects bad usernames and unknown fields with 400', async () => { + vi.stubEnv('FLIGHT_TEST_HOOKS', '1'); + for (const body of [{ username: 'x' }, { username: 'BAD USER!' }, { bogus: 1 }]) { + const res = await post(body); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBeTruthy(); + } + }); +}); diff --git a/app/api/test/mint-player-session/route.ts b/app/api/test/mint-player-session/route.ts new file mode 100644 index 0000000..db34b4e --- /dev/null +++ b/app/api/test/mint-player-session/route.ts @@ -0,0 +1,77 @@ +import { randomUUID } from 'node:crypto'; +import * as schema from '@/lib/db/schema'; +import { getGameDb, persist } from '@/lib/db/client'; +import { createSession, hashPassword } from '@/lib/auth'; +import { STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed'; + +// TEST-ONLY hook (v1.1.0, ISS-107) — mints a session for a test player so the e2e suite can hit +// the guarded game routes WITHOUT any real credentials in test code (same reason as its sibling +// /api/test/admin-session). Creates the users + players rows if missing — idempotent across +// container restarts, since the CI DB volume persists. Disabled unless FLIGHT_TEST_HOOKS=1; every +// other deployment 404s here, like its /api/test/* siblings. +// +// POST /api/test/mint-player-session +// body { username?: string } — defaults to 'e2e-tester'; must match the register rules when given. +// Returns { sessionId }. + +const DEFAULT_USERNAME = 'e2e-tester'; +// Test-only credential for the minted users row — e2e never logs in with it (sessions are minted +// server-side); a fixed value keeps the row debuggable from the admin panel. +const TEST_PASSWORD = 'e2e-mint-password-42'; + +export async function POST(req: Request) { + if (process.env.FLIGHT_TEST_HOOKS !== '1') { + return Response.json({ error: 'test hooks disabled' }, { status: 404 }); + } + + let body: unknown = {}; + try { + const text = await req.text(); + body = text ? JSON.parse(text) : {}; + } catch { + return Response.json({ error: 'invalid JSON body' }, { status: 400 }); + } + if (typeof body !== 'object' || body === null || Array.isArray(body)) { + return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); + } + const input = body as Record; + for (const key of Object.keys(input)) { + if (key !== 'username') { + return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); + } + } + let username = DEFAULT_USERNAME; + if (input.username !== undefined) { + if (typeof input.username !== 'string' || !/^[a-z0-9_-]{3,20}$/.test(input.username)) { + return Response.json( + { error: 'username must be 3–20 characters of a-z, 0-9, _ or -' }, + { status: 400 }, + ); + } + username = input.username; + } + + const inst = await getGameDb(); + let res = inst.raw.exec('SELECT id FROM users WHERE username = ?', [username]); + let userId = res[0]?.values?.[0]?.[0]; + if (typeof userId !== 'string') { + // New test account — one UUID for both rows, starter economy like a fresh world. + const id = randomUUID(); + inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [ + id, + username, + hashPassword(TEST_PASSWORD), + Date.now(), + ]); + await inst.db.insert(schema.players).values({ + id, + provider: 'local', + username, + cash: STARTING_CASH, + reputation: STARTING_REPUTATION, + }); + userId = id; + } + const sessionId = await createSession(inst, userId); // persists the rows + session together + return Response.json({ sessionId }); +} diff --git a/app/globals.css b/app/globals.css index 58cef87..71abb1f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -727,3 +727,74 @@ body { .admin-row-actions { white-space: nowrap; } + +/* v1.1.0 (ISS-107) — full-screen sign-in / create-account screen, same dark slate theme as the panes. */ +.sign-in { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + background: rgba(15, 23, 42, 0.97); + color: #f8fafc; +} + +.sign-in h1 { + margin: 0; + font-size: 22px; + letter-spacing: 0.06em; +} + +.sign-in form { + display: flex; + flex-direction: column; + gap: 6px; + width: 300px; + padding: 14px; + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 6px; + background: rgba(30, 41, 59, 0.6); +} + +.sign-in label { + font-size: 12px; + color: #cbd5e1; +} + +.sign-in input { + font-size: 13px; + padding: 5px 8px; + border-radius: 4px; + border: 1px solid rgba(148, 163, 184, 0.4); + background: rgba(15, 23, 42, 0.9); + color: #f8fafc; +} + +.sign-in .pane-btn { + margin-top: 6px; + padding: 5px 10px; +} + +.sign-in-divider { + margin: 0; + font-size: 12px; + color: #94a3b8; +} + +.sign-in-error { + margin: 0; + font-size: 13px; + color: #fca5a5; +} + +.sign-in-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + color: #cbd5e1; +} diff --git a/app/page.tsx b/app/page.tsx index 81b8bb3..b2cde0c 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,8 +4,10 @@ import dynamic from 'next/dynamic'; import { useEffect, useRef, useState } from 'react'; import LeftPane from '@/components/LeftPane'; import RightPane from '@/components/RightPane'; +import SignInScreen from '@/components/SignInScreen'; import ToastStack from '@/components/Toasts'; import type { HoverTarget } from '@/components/hoverTypes'; +import { clearStoredSession, sessionHeaders, storedSession } from '@/lib/authClient'; import { useGameState } from '@/lib/useGameState'; import { useToasts } from '@/lib/useToasts'; import { diffSnapshots } from '@/lib/deriveEvents'; @@ -13,6 +15,50 @@ import { diffSnapshots } from '@/lib/deriveEvents'; const Map = dynamic(() => import('@/components/Map'), { ssr: false }); export default function Home() { + // v1.1.0 (ISS-107): auth gate — null while checking the stored session on mount, then either + // the sign-in screen or the game shell. The shell only mounts once authenticated, so its state + // feed never polls an unauthenticated /api/state. + const [authed, setAuthed] = useState(null); + + useEffect(() => { + const sid = storedSession(); + if (!sid) { + setAuthed(false); + return; + } + let cancelled = false; + fetch('/api/me', { headers: sessionHeaders(sid), cache: 'no-store' }) + .then((res) => { + if (cancelled) return; + if (!res.ok) clearStoredSession(); // stale/expired id — drop it and show sign-in + setAuthed(res.ok); + }) + .catch(() => { + if (!cancelled) setAuthed(false); // network error → let the user try to sign in + }); + return () => { + cancelled = true; + }; + }, []); + + if (authed === null) { + return ( +
+

+ Loading… +

+
+ ); + } + if (!authed) { + return setAuthed(true)} />; + } + + // The admin panel's Log out cleared the session — drop back to sign-in (ISS-107). + return setAuthed(false)} />; +} + +function AppShell({ onLogout }: { onLogout: () => void }) { // Single polling source for the whole shell (v0.9.0): one /api/state loop, snapshot passed // down as props so later panes can share it without extra poll loops. applySnapshot (v0.10.0) // lets a pane push an action endpoint's full response snapshot in immediately. @@ -49,7 +95,7 @@ export default function Home() { {/* v0.11.0 — the same polled snapshot drives the map's plane markers + route lines; ISS-010 (v0.18.0) — applySnapshot lets the airport popup's Relocate row push its action response into the shared state, exactly like RightPane does */} - + diff --git a/components/AdminPanel.tsx b/components/AdminPanel.tsx index 8349943..a4be7b9 100644 --- a/components/AdminPanel.tsx +++ b/components/AdminPanel.tsx @@ -1,13 +1,12 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; +import { clearStoredSession, sessionHeaders, storedSession, storeSession } from '../lib/authClient'; // 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; @@ -18,15 +17,7 @@ interface RowsData { rows: Array>; } -function readSession(): string | null { - try { - return window.localStorage.getItem(SESSION_STORAGE_KEY); - } catch { - return null; - } -} - -export default function AdminPanel() { +export default function AdminPanel({ onLogout }: { onLogout?: () => void }) { const [session, setSession] = useState(null); const [me, setMe] = useState(null); // null while checking /api/me on mount const [checking, setChecking] = useState(true); @@ -45,15 +36,12 @@ export default function AdminPanel() { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - const authHeaders = useCallback( - (s: string) => ({ 'X-Flight-Session': s }), - [], - ); + const authHeaders = useCallback((s: string) => sessionHeaders(s), []); // On mount: a stored session decides between the grid and the login form. useEffect(() => { let cancelled = false; - const stored = readSession(); + const stored = storedSession(); if (!stored) { setChecking(false); return; @@ -61,7 +49,7 @@ export default function AdminPanel() { fetch('/api/me', { headers: authHeaders(stored) }) .then(async (res) => { if (!res.ok || cancelled) { - window.localStorage.removeItem(SESSION_STORAGE_KEY); + clearStoredSession(); setSession(null); return; } @@ -71,7 +59,7 @@ export default function AdminPanel() { }) .catch(() => { if (!cancelled) { - window.localStorage.removeItem(SESSION_STORAGE_KEY); + clearStoredSession(); setSession(null); } }) @@ -161,7 +149,7 @@ export default function AdminPanel() { return; } const body = (await res.json()) as { sessionId: string; user: Me }; - window.localStorage.setItem(SESSION_STORAGE_KEY, body.sessionId); + storeSession(body.sessionId); setSession(body.sessionId); setMe(body.user); setUsername(''); @@ -170,7 +158,8 @@ export default function AdminPanel() { const logout = async () => { if (session) await fetch('/api/logout', { method: 'POST', headers: authHeaders(session) }).catch(() => undefined); - window.localStorage.removeItem(SESSION_STORAGE_KEY); + clearStoredSession(); + onLogout?.(); // v1.1.0 (ISS-107): session gone → shell drops back to the sign-in screen setSession(null); setMe(null); setTable(null); diff --git a/components/Map.tsx b/components/Map.tsx index d03e0f0..b884d4b 100644 --- a/components/Map.tsx +++ b/components/Map.tsx @@ -13,6 +13,7 @@ import { weatherFor } from '../lib/weather'; import { approxLocalTime } from '../lib/localTime'; import Modal from './Modal'; import AdminPanel from './AdminPanel'; +import { sessionHeaders } from '../lib/authClient'; // 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, @@ -69,11 +70,14 @@ interface MapProps { // ISS-010 (v0.18.0): push an action endpoint's full response snapshot into the shared // shell state — same callback RightPane receives, used by the airport popup's Relocate row. applySnapshot: (snapshot: StateSnapshot) => void; + // v1.1.0 (ISS-107): the admin panel's Log out cleared the session — tell the shell to drop back + // to the sign-in screen instead of leaving a signed-out user staring at a live dashboard. + onLogout?: () => void; } // Named FlightMap (not Map): a component called `Map` would shadow the global // Map constructor inside its own body, breaking `new Map(...)` for the airport index. -export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { +export default function FlightMap({ state, hovered, applySnapshot, onLogout }: MapProps) { const containerRef = useRef(null); const mapRef = useRef(null); // /api/airports is fetched at most once per page life: every styledata event @@ -295,7 +299,8 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) { const action = planeStatus === 'available' ? 'fly' : 'queue'; const res = await fetch(`/api/planes/${planeId}/${action}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + // v1.1.0 (ISS-107): fly/queue are session-guarded — echo the stored id. + headers: { 'Content-Type': 'application/json', ...sessionHeaders() }, body: JSON.stringify({ destIcao: p.icao }), cache: 'no-store', }); @@ -591,7 +596,7 @@ export default function FlightMap({ state, hovered, applySnapshot }: MapProps) {
setAdminOpen(false)} title="Admin"> - +
); diff --git a/components/SignInScreen.tsx b/components/SignInScreen.tsx new file mode 100644 index 0000000..fa8df8e --- /dev/null +++ b/components/SignInScreen.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useState } from 'react'; +import { storeSession } from '@/lib/authClient'; + +// v1.1.0 (ISS-107) — full-screen sign-in / create-account screen shown when no valid session is +// stored. Both forms POST the same shape ({username, password}); login mints a session for an +// existing account, register creates one first and then mints it. On success the sessionId goes +// to localStorage (via storeSession) and onAuthed() flips the shell back in. + +interface SignInScreenProps { + onAuthed: () => void; +} + +export default function SignInScreen({ onAuthed }: SignInScreenProps) { + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent, path: '/api/login' | '/api/register') { + e.preventDefault(); + if (busy) return; + setBusy(true); + setError(null); + try { + const form = new FormData(e.currentTarget); + const res = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: String(form.get('username') ?? ''), + password: String(form.get('password') ?? ''), + }), + cache: 'no-store', + }); + const data = (await res.json()) as { sessionId?: string; error?: string }; + if (!res.ok || !data.sessionId) throw new Error(data.error ?? `HTTP ${res.status}`); + storeSession(data.sessionId); + onAuthed(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( +
+

Flight Ops

+ {error !== null && ( +

+ {error} +

+ )} + +
void submit(e, '/api/login')}> + + + + + +
+ +

or

+ +
void submit(e, '/api/register')}> + + + + + +
+
+ ); +} diff --git a/e2e/actions.spec.ts b/e2e/actions.spec.ts index 3d9857a..867d5be 100644 --- a/e2e/actions.spec.ts +++ b/e2e/actions.spec.ts @@ -6,6 +6,11 @@ import { initialBearingDeg } from '../lib/geo'; import { weatherFor } from '../lib/weather'; import { windBurnFactor } from '../lib/sim/movement'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + // v0.12.0 package model against the live origin (https://flight.dev.kkhost.pl): // the full core loop through the UI — hook creates a short package at EDDF → Load // via the right pane → click its destination in the on-board list (left pane since diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts index aff0f44..49580ee 100644 --- a/e2e/admin.spec.ts +++ b/e2e/admin.spec.ts @@ -1,12 +1,13 @@ 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. +// v0.71.0 — Admin panel e2e: top-bar button → modal; 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. v1.1.0 (ISS-107): the whole app now sits behind the sign-in screen — unauthenticated +// visitors see IT instead of the shell, and logout drops back to it. -const SESSION_KEY = 'flight.session'; // must match components/AdminPanel.tsx +const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts async function mintAdminSession(page: import('@playwright/test').Page): Promise { const res = await page.request.post('/api/test/admin-session'); @@ -14,31 +15,32 @@ async function mintAdminSession(page: import('@playwright/test').Page): Promise< return ((await res.json()) as { sessionId: string }).sessionId; } -test('admin panel: login form, wrong-creds error, session auth, DB grid CRUD, logout', async ({ page }) => { +test('admin panel: sign-in gate, 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(); + // --- Unauthenticated → the sign-in screen replaces the whole shell (v1.1.0, ISS-107). + const signIn = page.getByTestId('sign-in-form'); + await expect(signIn).toBeVisible({ timeout: 30_000 }); + await expect(page.getByTestId('app-header')).toHaveCount(0); // --- 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 }); + await page.getByTestId('sign-in-username').fill('kamilkrzywda'); + await page.getByTestId('sign-in-password').fill('wrong-password-on-purpose'); + await page.getByTestId('sign-in-submit').click(); + await expect(page.getByTestId('auth-error')).toBeVisible({ timeout: 10_000 }); - // --- Authenticate via the test hook + localStorage, then reopen the panel. + // --- Authenticate via the test hook + localStorage, then reload into the shell. 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]); + // The independent re-fetch below goes through page.request → needs the header at context level. + await page.context().setExtraHTTPHeaders({ 'X-Flight-Session': sessionId }); await page.reload(); + const adminBtn = page.getByTestId('admin-btn'); await expect(adminBtn).toBeVisible({ timeout: 30_000 }); + + // --- Open the modal: stored session is valid → straight to the table list. await adminBtn.click(); // --- Authenticated admin → table list including packages and users. @@ -104,8 +106,8 @@ test('admin panel: login form, wrong-creds error, session auth, DB grid CRUD, lo await page.getByTestId(`admin-delete-${addedIdx}`).click(); await expect.poll(findUserRow, { timeout: 15_000 }).toBe(-1); - // --- Logout returns to the login form. + // --- Logout drops back to the sign-in screen (v1.1.0, ISS-107 — onLogout unmounts the shell). await page.getByTestId('admin-logout').click(); - await expect(page.getByTestId('admin-username')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('sign-in-form')).toBeVisible({ timeout: 10_000 }); await expect(tables).toBeHidden(); }); diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 33abc79..d59b846 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -13,7 +13,7 @@ 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 seventeen endpoints and the package.json version', async ({ page }) => { +test('GET /api/openapi serves the spec with all eighteen 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'); @@ -38,6 +38,7 @@ test('GET /api/openapi serves the spec with all seventeen endpoints and the pack '/api/planes/{planeId}/queue', '/api/planes/{planeId}/queue/cancel', '/api/planes/{planeId}/repair', // v0.39.0 (ISS-069) + '/api/register', // v1.1.0 (ISS-107) — self-service account creation '/api/state', '/api/version', ]); diff --git a/e2e/auth.ts b/e2e/auth.ts new file mode 100644 index 0000000..d8a49f8 --- /dev/null +++ b/e2e/auth.ts @@ -0,0 +1,31 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +// v1.1.0 (ISS-107) — session helpers for the e2e suite. Every game route is now session-guarded, so +// each spec authenticates in a beforeEach: mint a player session server-side via the test hook +// (FLIGHT_TEST_HOOKS=1; no real credentials in test code), then apply it BOTH ways the client uses — +// localStorage for the app's own fetches (lib/authClient.ts reads it) and context extraHTTPHeaders +// so page.request.* calls in specs carry X-Flight-Session too. + +export const SESSION_KEY = 'flight.session'; // must match lib/authClient.ts + +export async function mintPlayerSession(page: Page): Promise { + const res = await page.request.post('/api/test/mint-player-session'); + expect(res.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200); + return ((await res.json()) as { sessionId: string }).sessionId; +} + +export async function applySession(page: Page, sessionId: string): Promise { + await page.context().setExtraHTTPHeaders({ 'X-Flight-Session': sessionId }); + // Runs before every navigation's scripts — the app reads the session from localStorage on load. + await page.addInitScript( + ([key, sid]) => window.localStorage.setItem(key, sid), + [SESSION_KEY, sessionId], + ); +} + +export async function authenticate(page: Page): Promise { + const sessionId = await mintPlayerSession(page); + await applySession(page, sessionId); + return sessionId; +} diff --git a/e2e/events.spec.ts b/e2e/events.spec.ts index 787d3a2..d42a208 100644 --- a/e2e/events.spec.ts +++ b/e2e/events.spec.ts @@ -15,8 +15,14 @@ interface StateBody { // (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 () => { - const res = await fetch(`${baseUrl}/api/events`, { headers: { Accept: 'text/event-stream' } }); +test('GET /api/events streams state snapshots over SSE', async ({ request }) => { + // v1.1.0 (ISS-107): the stream is session-guarded — EventSource cannot set headers, so the client + // passes ?session= (requireAuthSse). Mint one via the test hook and use exactly that path. + const mint = await request.post('/api/test/mint-player-session'); + expect(mint.status(), 'mint-player-session hook should be enabled in the CI stack (FLIGHT_TEST_HOOKS=1)').toBe(200); + const { sessionId } = (await mint.json()) as { sessionId: string }; + + const res = await fetch(`${baseUrl}/api/events?session=${sessionId}`, { headers: { Accept: 'text/event-stream' } }); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toContain('text/event-stream'); expect(res.body).not.toBeNull(); diff --git a/e2e/hover.spec.ts b/e2e/hover.spec.ts index 3c9efe3..0b4e438 100644 --- a/e2e/hover.spec.ts +++ b/e2e/hover.spec.ts @@ -1,5 +1,10 @@ import { test, expect, type Page } from '@playwright/test'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + // v0.15.0 — ISS-002: hovering a plane or package in either side panel highlights it on the map. // Fleet rows + on-board blocks highlight the plane marker (and its route leg while flying); // open-package rows highlight both endpoint airports. The map exposes three top-most diff --git a/e2e/maintenance.spec.ts b/e2e/maintenance.spec.ts index c16bc03..597c5eb 100644 --- a/e2e/maintenance.spec.ts +++ b/e2e/maintenance.spec.ts @@ -3,6 +3,11 @@ import { expect, test } from '@playwright/test'; // v0.39.0 (ISS-069) — maintenance: expectations use the real balance constants. import { REPAIR_COST } from '../lib/game/catalog'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + // v0.39.0 (ISS-069), reworked in v0.45.0 (ISS-089) — maintenance visible flow against the live // origin: health is shown per plane in the fleet list; forcing it to 0 via the set-health hook shows // the "broken" pill and blocks dispatch with a 409 that mutates nothing; clicking Repair deducts diff --git a/e2e/map-interactions.spec.ts b/e2e/map-interactions.spec.ts index 45126d2..3b20e3d 100644 --- a/e2e/map-interactions.spec.ts +++ b/e2e/map-interactions.spec.ts @@ -1,5 +1,10 @@ import { test, expect, type Page } from '@playwright/test'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + // v0.12.0 — map interactions against the live origin (PLAN.md "Middle — Map"): // expanded airport popup (name + ICAO kept, plus approximate local time and a // deterministic generated weather), plane markers whose positions come from the diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index b4df140..6f82a3e 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -2,6 +2,11 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { test, expect, type Page } from '@playwright/test'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + const APP_URL = '/'; // Read at test time so the assertion tracks package.json automatically. diff --git a/e2e/panes.spec.ts b/e2e/panes.spec.ts index 1c928b7..80d0185 100644 --- a/e2e/panes.spec.ts +++ b/e2e/panes.spec.ts @@ -1,5 +1,10 @@ import { test, expect, type Page } from '@playwright/test'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + // v0.9.0 — single-screen 3-pane shell + left Assets pane against the live origin. // The panes render from the shell's single /api/state poll (lib/useGameState.ts). diff --git a/e2e/queue.spec.ts b/e2e/queue.spec.ts index 0f43891..152cb84 100644 --- a/e2e/queue.spec.ts +++ b/e2e/queue.spec.ts @@ -7,6 +7,11 @@ import { haversineKm, initialBearingDeg } from '../lib/geo'; import { weatherFor } from '../lib/weather'; import { fuelAtWeighted, requiredFuelFor, windBurnFactor } from '../lib/sim/movement'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + // v0.26.0 — ISS-043 part 2 (UI): the flight queue. A plane's not-yet-started legs show up in a // Queue sub-section of its on-board block (cancel per leg; adding happens via fly-to / Relocate // while flying, ISS-053), and diff --git a/e2e/relocation.spec.ts b/e2e/relocation.spec.ts index 54686b9..5e00783 100644 --- a/e2e/relocation.spec.ts +++ b/e2e/relocation.spec.ts @@ -1,6 +1,11 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + // v0.18.0 — ISS-010: relocating a GROUNDED (empty) plane to any airport against the live origin. // Two UI surfaces drive the EXISTING POST /api/planes/{id}/fly endpoint (pure repositioning has // been allowed since v0.12.0 — no API change): the left-pane Relocate section (ISS-023, v0.21.0) diff --git a/e2e/right-pane.spec.ts b/e2e/right-pane.spec.ts index 621ffc9..d8b2dba 100644 --- a/e2e/right-pane.spec.ts +++ b/e2e/right-pane.spec.ts @@ -9,6 +9,11 @@ import { fuelAtWeighted, guaranteedRangeKm, windBurnFactor } from '../lib/sim/mo import { FUEL_DISCOUNT_PER_REP, MAX_FUEL_DISCOUNT, MAX_PLANE_DISCOUNT, PLANE_DISCOUNT_PER_REP, repRate } from '../lib/game/catalog'; import { STARTING_REPUTATION } from '../lib/game/seed'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + // v0.12.0 — right Jobs & Market pane against the live origin: open packages render with // names/reward/deadline and Load (UI click) binds one to the starter; the on-board list // (moved to the left pane in v0.20.0, ISS-021) shows loaded packages with fly-to-destination + diff --git a/e2e/sim.spec.ts b/e2e/sim.spec.ts index b0e8a66..a43516f 100644 --- a/e2e/sim.spec.ts +++ b/e2e/sim.spec.ts @@ -4,6 +4,11 @@ import { test, expect, type Page } from '@playwright/test'; // pool, at EDDF. import { POOL_TARGET_PER_AIRPORT as POOL_TARGET } from '../lib/game/seed'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + // v0.52.0 (ISS-117), finalized in #135: fixed world clock against the live origin — a fully // grounded fleet freezes the clock, background-tick advance at a raised rate while something is // active, package expiry via the FLIGHT_TEST_HOOKS hooks (enabled in docker-compose.yml), and diff --git a/e2e/state.spec.ts b/e2e/state.spec.ts index 4651c82..ccb1149 100644 --- a/e2e/state.spec.ts +++ b/e2e/state.spec.ts @@ -2,6 +2,11 @@ import { execSync } from 'node:child_process'; import path from 'node:path'; import { test, expect } from '@playwright/test'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + // Repo root — docker-compose.yml lives here; `docker compose restart` runs on // this host (the test runner), browsers stay remote. CI points E2E_COMPOSE_ARGS at the // isolated stack file + project (-f docker-compose.ci.yml -p flight-ci) instead of the default one. diff --git a/e2e/toasts.spec.ts b/e2e/toasts.spec.ts index e7773e0..1e51f5f 100644 --- a/e2e/toasts.spec.ts +++ b/e2e/toasts.spec.ts @@ -1,6 +1,11 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + // v0.17.0 — ISS-008: action errors surface as toasts at the map's right edge (clear of the // right pane, in addition to the inline pane error) and game events appear as info toasts via // snapshot diffing; the left-pane Time section offers an explicit "Enable notifications" opt-in diff --git a/e2e/two-planes.spec.ts b/e2e/two-planes.spec.ts index e3c2057..2c93346 100644 --- a/e2e/two-planes.spec.ts +++ b/e2e/two-planes.spec.ts @@ -6,6 +6,11 @@ import { initialBearingDeg } from '../lib/geo'; import { weatherFor } from '../lib/weather'; import { windBurnFactor } from '../lib/sim/movement'; +import { authenticate } from './auth'; + +// v1.1.0 (ISS-107): game routes are session-guarded — mint + apply a player session before every test. +test.beforeEach(async ({ page }) => { await authenticate(page); }); + // v0.31.0 — ISS-055: gameplay with TWO planes in the fleet. The reported bug ("can't load cargo // if both planes are on the same airport") was UX-only: LoadControls' multi-plane path rendered a // greyed-out Load button until a plane was picked from the dropdown, while the single-plane path diff --git a/lib/auth.test.ts b/lib/auth.test.ts new file mode 100644 index 0000000..a7249e0 --- /dev/null +++ b/lib/auth.test.ts @@ -0,0 +1,87 @@ +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, hashPassword, requireAuth, verifyPassword } from './auth'; + +// v1.1.0 (ISS-107) — the session guard every game route funnels through. Same temp-DB pattern as +// the other API tests: FLIGHT_DB_PATH points at a throwaway file, fresh singleton per test file. + +let dir: string; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'flight-auth-')); + process.env.FLIGHT_DB_PATH = path.join(dir, 'auth.db'); +}); + +afterAll(async () => { + delete process.env.FLIGHT_DB_PATH; + await fs.rm(dir, { recursive: true, force: true }); +}); + +const reqWith = (sessionId?: string) => + new Request('http://localhost/api/state', { headers: sessionId ? { 'X-Flight-Session': sessionId } : {} }); + +describe('hashPassword / verifyPassword', () => { + it('round-trips a password through the stored digest', () => { + const hash = hashPassword('correct-horse-battery-staple'); + expect(hash).toMatch(/^[0-9a-f]{32}\$/); // 16-byte hex salt + 32-byte scrypt, like the seeded admin's + expect(verifyPassword('correct-horse-battery-staple', hash)).toBe(true); + }); + + it('rejects a wrong password and malformed digests', () => { + const hash = hashPassword('some-password'); + expect(verifyPassword('other-password', hash)).toBe(false); + expect(verifyPassword('x', 'not-a-digest')).toBe(false); + expect(verifyPassword('x', '')).toBe(false); + }); +}); + +describe('requireAuth', () => { + it('401s when the header is missing or the session id is unknown', async () => { + for (const req of [reqWith(), reqWith('no-such-session')]) { + const guard = await requireAuth(req); + expect(guard.ok).toBe(false); + if (!guard.ok) { + expect(guard.response.status).toBe(401); + expect(((await guard.response.json()) as { error: string }).error).toMatch(/authentication required/i); + } + } + }); + + it('401s an expired session', async () => { + const inst = await getGameDb(); + inst.raw.run("INSERT INTO sessions (id, user_id, expires_at) VALUES ('expired-sess', 'admin-kamilkrzywda', ?)", [Date.now() - 1]); + const guard = await requireAuth(reqWith('expired-sess')); + expect(guard.ok).toBe(false); + if (!guard.ok) expect(guard.response.status).toBe(401); + }); + + it('resolves the seeded admin session to the local player row (joined on username)', async () => { + const inst = await getGameDb(); + const sessionId = await createSession(inst, 'admin-kamilkrzywda'); + const guard = await requireAuth(reqWith(sessionId)); + expect(guard.ok).toBe(true); + if (guard.ok) { + expect(guard.user.username).toBe('kamilkrzywda'); + expect(guard.player.id).toBe('local'); // the v1.0.0 migration's local player row + expect(guard.player.cash).toBe(20_000); // starter economy from the seed + } + }); + + it('500s when the account has no players row (broken data, not a client error)', async () => { + const inst = await getGameDb(); + inst.raw.run("INSERT INTO users (id, username, password_hash, admin, created_at) VALUES ('user-no-player', 'no-player-user', ?, 0, ?)", [ + hashPassword('whatever-123'), + Date.now(), + ]); + const sessionId = await createSession(inst, 'user-no-player'); + const guard = await requireAuth(reqWith(sessionId)); + expect(guard.ok).toBe(false); + if (!guard.ok) { + expect(guard.response.status).toBe(500); + expect(((await guard.response.json()) as { error: string }).error).toMatch(/player row missing/i); + } + }); +}); diff --git a/lib/auth.ts b/lib/auth.ts index 6cf8429..0e8b7f7 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,5 +1,6 @@ -import { randomUUID, scryptSync, timingSafeEqual } from 'node:crypto'; +import { randomBytes, randomUUID, scryptSync, timingSafeEqual } from 'node:crypto'; import { getGameDb, persist, type GameDb } from '@/lib/db/client'; +import type { PlayerRow } from './db/schema'; // 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 @@ -32,6 +33,15 @@ export function verifyPassword(password: string, storedHash: string): boolean { } } +// Hash a plaintext password into the "salt$hex" scrypt digest verifyPassword() checks — the same +// call shape as the seeded admin's digest (v0.72.0): 16-byte hex salt passed to scryptSync as a +// plain string, 32-byte hash. Used by /api/register and the test mint hook so every stored hash +// verifies through verifyPassword(). +export function hashPassword(password: string): string { + const salt = randomBytes(16).toString('hex'); + return `${salt}$${scryptSync(password, salt, 32).toString('hex')}`; +} + // 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 { @@ -46,11 +56,8 @@ export async function createSession(inst: GameDb, userId: string): Promise { - const sessionId = req.headers.get(SESSION_HEADER); - if (!sessionId) return null; - const inst = await getGameDb(); +// Resolve a session id to a live (non-expired) user, or null. +async function resolveSession(inst: GameDb, sessionId: string): Promise { 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()], @@ -60,6 +67,47 @@ export async function currentUser(req: Request): Promise { return { id: String(row[0]), username: String(row[1]), admin: Number(row[2]) === 1 }; } +// Resolve the X-Flight-Session header to a live (non-expired) user, or null. +export async function currentUser(req: Request): Promise { + const sessionId = req.headers.get(SESSION_HEADER); + if (!sessionId) return null; + return resolveSession(await getGameDb(), sessionId); +} + +export type AuthGuard = + | { ok: true; inst: GameDb; user: AuthUser; player: PlayerRow } + | { ok: false; response: Response }; + +// v1.1.0 (ISS-107): guard for every game route — resolves the session to its user AND that +// user's players row (joined on username, so the seeded kamilkrzywda maps to id 'local'). +// 401 when the session is missing/unknown/expired; 500 if the account has no player row +// (register and the mint hook always create both together — a miss means broken data). +async function finishAuth(user: AuthUser | null): Promise { + if (!user) return { ok: false, response: Response.json({ error: 'authentication required' }, { status: 401 }) }; + const inst = await getGameDb(); + const res = inst.raw.exec('SELECT id, provider, username, cash, reputation FROM players WHERE username = ?', [user.username]); + const row = res[0]?.values?.[0]; + if (!row) return { ok: false, response: Response.json({ error: 'player row missing for this account' }, { status: 500 }) }; + return { + ok: true, + inst, + user, + player: { id: String(row[0]), provider: String(row[1]), username: String(row[2]), cash: Number(row[3]), reputation: Number(row[4]) }, + }; +} + +export async function requireAuth(req: Request): Promise { + return finishAuth(await currentUser(req)); +} + +// /api/events only — EventSource cannot set headers, so the SSE route also accepts the session as a +// ?session= query parameter (private infra; every other game route is header-only). +export async function requireAuthSse(req: Request): Promise { + const sessionId = req.headers.get(SESSION_HEADER) ?? new URL(req.url).searchParams.get('session'); + if (!sessionId) return finishAuth(null); + return finishAuth(await resolveSession(await getGameDb(), sessionId)); +} + 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. diff --git a/lib/authClient.ts b/lib/authClient.ts new file mode 100644 index 0000000..5beef76 --- /dev/null +++ b/lib/authClient.ts @@ -0,0 +1,34 @@ +'use client'; + +// v1.1.0 (ISS-107) — client-side session handling shared by every authenticated fetcher, the +// sign-in screen and the admin panel. The server mints sessions at /api/login + /api/register; +// we only store the id in localStorage and echo it back as X-Flight-Session. + +export const SESSION_STORAGE_KEY = 'flight.session'; + +export function storedSession(): string | null { + if (typeof window === 'undefined') return null; + try { + return window.localStorage.getItem(SESSION_STORAGE_KEY); + } catch { + return null; // storage blocked (private mode) — treat as signed out + } +} + +// Headers for an authenticated fetch. Empty object when no session is stored, so callers can +// spread it into their existing header objects without branching. +export function sessionHeaders(session: string | null = storedSession()): Record { + return session ? { 'X-Flight-Session': session } : {}; +} + +export function storeSession(sessionId: string): void { + window.localStorage.setItem(SESSION_STORAGE_KEY, sessionId); +} + +export function clearStoredSession(): void { + try { + window.localStorage.removeItem(SESSION_STORAGE_KEY); + } catch { + // storage blocked — nothing to clear + } +} diff --git a/lib/game/actions.ts b/lib/game/actions.ts index 14cde42..37d8e7b 100644 --- a/lib/game/actions.ts +++ b/lib/game/actions.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { and, eq, inArray } from 'drizzle-orm'; +import { requireAuth } from '@/lib/auth'; import * as schema from '@/lib/db/schema'; import { getLocalPlayer, updateLocalPlayerEconomy } from '@/lib/db/localPlayer'; import { persist, type GameDb } from '@/lib/db/client'; @@ -381,7 +382,11 @@ export async function repairPlane(inst: GameDb, planeId: string): Promise<{ cost // game time — then respond with the FULL current state snapshot (same shape as // GET /api/state), so clients never need a follow-up poll. GameActionError maps to // its own status + {error}; anything unexpected is a 500. -export async function performAction(fn: (inst: GameDb) => Promise): Promise { +// v1.1.0 (ISS-107): every action route funnels through here, so the requireAuth guard lives in ONE +// place — 401 before any game mutation when the X-Flight-Session header is missing/unknown/expired. +export async function performAction(req: Request, fn: (inst: GameDb) => Promise): Promise { + const auth = await requireAuth(req); + if (!auth.ok) return auth.response; try { await runAction(fn); return Response.json(await getStateSnapshot()); diff --git a/lib/openapi.test.ts b/lib/openapi.test.ts index 7c63f6a..22f7683 100644 --- a/lib/openapi.test.ts +++ b/lib/openapi.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { openApiSpec } from './openapi'; describe('OpenAPI spec', () => { - it('documents exactly the seventeen public endpoints (6 read + 8 player actions + 2 auth + 1 admin CRUD)', () => { + it('documents exactly the eighteen public endpoints (4 read + 8 player actions + 4 auth + 2 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 @@ -20,6 +20,7 @@ describe('OpenAPI spec', () => { '/api/planes/{planeId}/queue', // v0.25.0 (ISS-043) '/api/planes/{planeId}/queue/cancel', // v0.25.0 (ISS-043) '/api/planes/{planeId}/repair', // v0.39.0 (ISS-069) + '/api/register', // v1.1.0 (ISS-107) — self-service account creation '/api/state', '/api/version', ]); diff --git a/lib/openapi.ts b/lib/openapi.ts index eeedae6..2d78798 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -348,6 +348,37 @@ export const openApiSpec = { }, }, }, + // v1.1.0 — multiplayer step 3 (ISS-107): self-service account creation. + '/api/register': { + post: { + summary: 'Create an account', + description: + 'Register a new local account (v1.1.0, ISS-107): username 3–20 chars of [a-z0-9_-] (case-insensitive input, stored lowercase), password at least 8 chars — else 400; 409 when the username is taken. Creates the users row (scrypt) AND a players row with the starter economy (the exact values a fresh world seeds: €20,000 cash, reputation 10). Mints a session immediately and returns it, so no second round-trip is needed.', + operationId: 'register', + requestBody: { + required: true, + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/RegisterRequest' }, + }, + }, + }, + responses: { + 200: { + description: 'Registered — new session id + the user (same shape as login)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } }, + }, + 400: { + description: 'Invalid body (not an object, unknown field, bad username charset/length, or password shorter than 8 chars)', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 409: { + description: 'Username is already taken', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + }, + }, + }, // v0.71.0 — admin panel: session auth + generic whole-database CRUD behind the admin flag. '/api/login': { post: { @@ -733,6 +764,15 @@ export const openApiSpec = { }, required: ['planeId'], }, + // v1.1.0 — multiplayer step 3 (ISS-107). + RegisterRequest: { + type: 'object', + properties: { + username: { type: 'string', description: '3–20 chars of [a-z0-9_-]; input is lowercased before validation.' }, + password: { type: 'string', description: 'Plaintext, at least 8 chars — stored as a scrypt digest server-side.' }, + }, + required: ['username', 'password'], + }, // v0.71.0 — admin panel auth + DB tooling schemas. LoginRequest: { type: 'object', diff --git a/lib/useGameAction.ts b/lib/useGameAction.ts index 9af3091..92347e1 100644 --- a/lib/useGameAction.ts +++ b/lib/useGameAction.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { StateSnapshot } from '@/lib/game/state'; +import { sessionHeaders } from './authClient'; // ISS-021 (v0.20.0) — shared action runner for the side panes (extracted verbatim from // RightPane.runAction when the On board section moved to LeftPane). POSTs an action endpoint; @@ -30,7 +31,8 @@ export function useGameAction( try { const res = await fetch(path, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + // v1.1.0 (ISS-107): action endpoints are session-guarded — echo the stored id. + headers: { 'Content-Type': 'application/json', ...sessionHeaders() }, body: body === undefined ? undefined : JSON.stringify(body), cache: 'no-store', }); diff --git a/lib/useGameState.test.ts b/lib/useGameState.test.ts index 2fe1a79..9e5fe0a 100644 --- a/lib/useGameState.test.ts +++ b/lib/useGameState.test.ts @@ -22,7 +22,19 @@ describe('fetchState', () => { await expect(fetchState()).resolves.toEqual(SNAPSHOT); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store' }); + // No stored session in node → empty headers object (v1.1.0, ISS-107). + expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store', headers: {} }); + }); + + it('sends the stored session as X-Flight-Session when one exists (v1.1.0)', async () => { + vi.stubGlobal('window', { + localStorage: { getItem: (k: string) => (k === 'flight.session' ? 'sid-123' : null), removeItem: () => {} }, + }); + const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => SNAPSHOT })); + vi.stubGlobal('fetch', fetchMock); + + await fetchState(); + expect(fetchMock).toHaveBeenCalledWith('/api/state', { cache: 'no-store', headers: { 'X-Flight-Session': 'sid-123' } }); }); it('throws with the HTTP status on a non-2xx response', async () => { diff --git a/lib/useGameState.ts b/lib/useGameState.ts index f67d780..105db53 100644 --- a/lib/useGameState.ts +++ b/lib/useGameState.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { StateSnapshot } from './game/state'; +import { sessionHeaders, storedSession } from './authClient'; export interface GameStateResult { state: StateSnapshot | null; @@ -19,7 +20,8 @@ export interface GameStateResult { // Single fetch of the full game-state snapshot. Exported separately from the hook so it can be unit-tested with a mocked fetch. export async function fetchState(): Promise { - const res = await fetch('/api/state', { cache: 'no-store' }); + // v1.1.0 (ISS-107): /api/state is session-guarded — the stored id rides along as X-Flight-Session. + const res = await fetch('/api/state', { cache: 'no-store', headers: sessionHeaders() }); if (!res.ok) throw new Error(`GET /api/state → HTTP ${res.status}`); const snapshot = (await res.json()) as StateSnapshot; // Dev/e2e hook (v0.15.0): the last successfully polled snapshot, so tests can compare what @@ -93,7 +95,10 @@ export function useGameState(intervalMs = 1000): GameStateResult { let source: EventSource | null = null; try { - source = new EventSource(SSE_URL); + // v1.1.0 (ISS-107): EventSource cannot set custom headers, so the session id rides along as a + // query param — /api/events accepts ?session= exactly for this (requireAuthSse). + const sid = storedSession(); + source = new EventSource(sid ? `${SSE_URL}?session=${encodeURIComponent(sid)}` : SSE_URL); } catch { source = null; // no EventSource support — pure polling below } diff --git a/package.json b/package.json index 8dc1781..f01707a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flight-ops", - "version": "1.0.0", + "version": "1.1.0", "private": true, "scripts": { "dev": "next dev -p 3000",