v1.1.0: session guard on all game routes + register + sign-in screen (ISS-107)
CI / build + unit + e2e (pull_request) Failing after 26m20s
CI / build + unit + e2e (pull_request) Failing after 26m20s
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user