83 lines
3.6 KiB
TypeScript
83 lines
3.6 KiB
TypeScript
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<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,
|
||
});
|
||
// 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 } });
|
||
}
|