v1.1.0: session guard on all game routes + register + sign-in screen (ISS-107) #152
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
+10
-2
@@ -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<typeof setInterval> | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
const cleanup = () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<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 = 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 } });
|
||||
}
|
||||
@@ -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<Record<string, unknown>>;
|
||||
fuelPricePerL: number;
|
||||
sellFraction: number;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+47
-1
@@ -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<boolean | null>(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 (
|
||||
<div className="app-shell">
|
||||
<p data-testid="auth-checking" className="sign-in-loading">
|
||||
Loading…
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!authed) {
|
||||
return <SignInScreen onAuthed={() => setAuthed(true)} />;
|
||||
}
|
||||
|
||||
// The admin panel's Log out cleared the session — drop back to sign-in (ISS-107).
|
||||
return <AppShell onLogout={() => 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 */}
|
||||
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} />
|
||||
<Map state={state} hovered={hovered} applySnapshot={applySnapshot} onLogout={onLogout} />
|
||||
<RightPane state={state} loading={loading} applySnapshot={applySnapshot} onHover={setHovered} onError={onError} />
|
||||
<ToastStack toasts={toasts} onDismiss={dismiss} />
|
||||
</div>
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [me, setMe] = useState<Me | null>(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<string | null>(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);
|
||||
|
||||
+8
-3
@@ -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<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(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) {
|
||||
</header>
|
||||
<div ref={containerRef} data-testid="map" className="map-container" />
|
||||
<Modal open={adminOpen} onClose={() => setAdminOpen(false)} title="Admin">
|
||||
<AdminPanel />
|
||||
<AdminPanel onLogout={onLogout} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent<HTMLFormElement>, 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 (
|
||||
<div className="sign-in">
|
||||
<h1 data-testid="app-title">Flight Ops</h1>
|
||||
{error !== null && (
|
||||
<p data-testid="auth-error" role="alert" className="sign-in-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form data-testid="sign-in-form" onSubmit={(e) => void submit(e, '/api/login')}>
|
||||
<label htmlFor="sign-in-username">Username</label>
|
||||
<input id="sign-in-username" data-testid="sign-in-username" name="username" autoComplete="username" required />
|
||||
<label htmlFor="sign-in-password">Password</label>
|
||||
<input id="sign-in-password" data-testid="sign-in-password" name="password" type="password" autoComplete="current-password" required />
|
||||
<button type="submit" data-testid="sign-in-submit" className="pane-btn" disabled={busy}>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="sign-in-divider">or</p>
|
||||
|
||||
<form data-testid="create-account-form" onSubmit={(e) => void submit(e, '/api/register')}>
|
||||
<label htmlFor="register-username">Username (3–20 chars: a-z 0-9 _ -)</label>
|
||||
<input id="register-username" name="username" autoComplete="off" required />
|
||||
<label htmlFor="register-password">Password (at least 8 characters)</label>
|
||||
<input id="register-password" name="password" type="password" autoComplete="new-password" required minLength={8} />
|
||||
<button type="submit" data-testid="register-submit" className="pane-btn" disabled={busy}>
|
||||
Create account
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+24
-22
@@ -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<string> {
|
||||
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();
|
||||
});
|
||||
|
||||
+2
-1
@@ -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',
|
||||
]);
|
||||
|
||||
+31
@@ -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<string> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
const sessionId = await mintPlayerSession(page);
|
||||
await applySession(page, sessionId);
|
||||
return sessionId;
|
||||
}
|
||||
+8
-2
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+54
-6
@@ -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<string> {
|
||||
@@ -46,11 +56,8 @@ export async function createSession(inst: GameDb, userId: string): Promise<strin
|
||||
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();
|
||||
// Resolve a session id to a live (non-expired) user, or null.
|
||||
async function resolveSession(inst: GameDb, sessionId: string): Promise<AuthUser | null> {
|
||||
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<AuthUser | null> {
|
||||
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<AuthUser | null> {
|
||||
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<AuthGuard> {
|
||||
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<AuthGuard> {
|
||||
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<AuthGuard> {
|
||||
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.
|
||||
|
||||
@@ -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<string, string> {
|
||||
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
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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<unknown>): Promise<Response> {
|
||||
// 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<unknown>): Promise<Response> {
|
||||
const auth = await requireAuth(req);
|
||||
if (!auth.ok) return auth.response;
|
||||
try {
|
||||
await runAction(fn);
|
||||
return Response.json(await getStateSnapshot());
|
||||
|
||||
+2
-1
@@ -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',
|
||||
]);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+7
-2
@@ -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<StateSnapshot> {
|
||||
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
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flight-ops",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
Reference in New Issue
Block a user