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,71 @@
|
||||
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 } });
|
||||
}
|
||||
Reference in New Issue
Block a user