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 { loadAirportsGeoJson } from '@/lib/airports'; import { buildStarterPlane, STARTING_CASH, STARTING_REPUTATION } from '@/lib/game/seed'; // v1.1.0 (ISS-107) — POST /api/register // body { username: string, password: string } (both required; unknown fields 400). // Username: 3–20 chars of [a-z0-9_-], case-insensitive input stored lowercase → else 400. // Password: min 8 chars → else 400. Creates the users row (scrypt via hashPassword — same // digest shape verifyPassword() checks) AND a players row with the starter economy (the exact // values seedEmpty() gives a fresh world). 409 when the username is taken. On success mints a // session immediately and returns { sessionId, user } like /api/login — no second round-trip. const USERNAME_RE = /^[a-z0-9_-]{3,20}$/; export async function POST(req: Request) { let body: unknown; try { const text = await req.text(); body = text ? JSON.parse(text) : undefined; } catch { return Response.json({ error: 'invalid JSON body' }, { status: 400 }); } if (typeof body !== 'object' || body === null || Array.isArray(body)) { return Response.json({ error: 'body must be a JSON object' }, { status: 400 }); } const input = body as Record; for (const key of Object.keys(input)) { if (key !== 'username' && key !== 'password') { return Response.json({ error: `unknown field "${key}"` }, { status: 400 }); } } const username = typeof input.username === 'string' ? input.username.toLowerCase() : ''; if (!USERNAME_RE.test(username)) { return Response.json( { error: 'username must be 3–20 characters of a-z, 0-9, _ or -' }, { status: 400 }, ); } const password = input.password; if (typeof password !== 'string' || password.length < 8) { return Response.json({ error: 'password must be at least 8 characters' }, { status: 400 }); } const inst = await getGameDb(); const taken = inst.raw.exec('SELECT 1 FROM users WHERE username = ?', [username]); if (taken[0]?.values?.length) { return Response.json({ error: 'username is already taken' }, { status: 409 }); } // One UUID for both rows — the account and its player share an id; requireAuth still joins on username. const id = randomUUID(); inst.raw.run('INSERT INTO users (id, username, password_hash, admin, created_at) VALUES (?, ?, ?, 0, ?)', [ id, username, hashPassword(password), Date.now(), ]); await inst.db.insert(schema.players).values({ id, provider: 'local', username, cash: STARTING_CASH, reputation: STARTING_REPUTATION, }); // v1.2.0 (ISS-108): onboarding — the new player's first login also grounds a starter plane at EDDF // for them, reusing buildSeed's starter builder (same values as the fresh-install seed). // ponytail: legacy players without planes (pre-v1.2.0 test mints) get none — out of scope here. const fc = await loadAirportsGeoJson(); const starter = buildStarterPlane( fc.features.map((f) => ({ icao: f.properties.icao, name: f.properties.name, lat: f.properties.lat, lon: f.properties.lon })), randomUUID(), ); const { queue, ...starterCols } = starter; await inst.db.insert(schema.planes).values({ ...starterCols, ownerId: id, queue: JSON.stringify(queue ?? []) }); await persist(inst); const sessionId = await createSession(inst, id); return Response.json({ sessionId, user: { username, admin: false } }); }