Files
Flight-Ops/app/api/register/route.ts
T
2026-09-26 14:40:56 +00:00

72 lines
2.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } });
}