v1.1.0: session guard on all game routes + register + sign-in screen (ISS-107)
CI / build + unit + e2e (pull_request) Failing after 26m20s

This commit is contained in:
2026-09-26 14:40:56 +00:00
parent 21dd37f88c
commit b0f1bd8b67
54 changed files with 1002 additions and 91 deletions
+87
View File
@@ -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
View File
@@ -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.
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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',
]);
+40
View File
@@ -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',
+3 -1
View File
@@ -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',
});
+13 -1
View File
@@ -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
View File
@@ -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
}