TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import type { FastifyInstance } from "fastify";2import { db, eq, recoveryCodes, userSettings, users, wallets, dailyRewards, sql } from "@spinza/database";3import { STARTING_BALANCE, checkUsername, loginSchema, recoverSchema, registerSchema, passwordSchema } from "@spinza/shared";4import { errors } from "../lib/errors";5import { generateRecoveryCode, hashPassword, hashRecoveryCode, verifyPassword, verifyRecoveryCode } from "../lib/crypto";6import { rateLimit } from "../lib/redis";7import { createSession, destroyAllSessions, destroySession, requireUser } from "../plugins/auth";8import { clientIp, logSecurity } from "../lib/security";9import { flag, profanityWords, maintenance } from "../lib/settings";10import { applyCredit, lockWallet, saveWallet } from "../services/wallet";11import { z } from "zod";12import { PG_UNIQUE_VIOLATION, pgCode } from "../lib/pg";1314async function limit(req: Parameters<typeof clientIp>[0], scope: string, max: number, windowSec: number) {15 const retry = await rateLimit(`${scope}:${clientIp(req)}`, max, windowSec);16 if (retry) {17 await logSecurity(req, "rate_limit", { meta: { scope } });18 throw errors.rateLimited(retry);19 }20}2122export async function authRoutes(app: FastifyInstance) {23 app.post("/api/auth/check-username", async (req) => {24 await limit(req, "check-username", 60, 60);25 const body = z.object({ username: z.string().max(64) }).parse(req.body);26 const check = checkUsername(body.username, profanityWords());27 if (!check.ok) return { available: false, reason: check.reason };28 const normalized = body.username.trim().toLowerCase();29 const exists = await db.query.users.findFirst({ where: eq(users.usernameNormalized, normalized), columns: { id: true } });30 return { available: !exists, reason: exists ? "taken" : undefined };31 });3233 app.post("/api/auth/register", async (req, reply) => {34 await limit(req, "register", 10, 600);35 if (!flag("registration.enabled")) throw errors.forbidden("Registration is temporarily closed.");36 const m = maintenance();37 if (m.enabled) throw errors.maintenance(m.message);38 const parsed = registerSchema.safeParse(req.body);39 if (!parsed.success) throw errors.badRequest("Please check the form.", parsed.error.flatten());40 const { username, password } = parsed.data;41 const check = checkUsername(username, profanityWords());42 if (!check.ok) {43 const msg = { length: "Username must be 3–24 characters.", charset: "Use lowercase letters, numbers, _ or -.", reserved: "This username is reserved.", profanity: "This username is not allowed." }[check.reason!];44 throw errors.badRequest(msg, { field: "username" });45 }46 const passwordHash = await hashPassword(password);47 const recoveryCode = generateRecoveryCode();48 const codeHash = await hashRecoveryCode(recoveryCode);4950 let userId: string;51 try {52 userId = await db.transaction(async (tx) => {53 const [u] = await tx54 .insert(users)55 .values({ username, usernameNormalized: username, passwordHash, ageConfirmedAt: new Date(), lastLoginAt: new Date() })56 .returning({ id: users.id });57 await tx.insert(wallets).values({ userId: u.id, balance: 0 });58 await tx.insert(userSettings).values({ userId: u.id });59 await tx.insert(recoveryCodes).values({ userId: u.id, codeHash });60 await tx.insert(dailyRewards).values({ userId: u.id });61 const w = await lockWallet(tx, u.id);62 await applyCredit(tx, w, "INITIAL_GRANT", STARTING_BALANCE, "welcome", { reason: "Welcome to Spinza" });63 await saveWallet(tx, w);64 return u.id;65 });66 } catch (e) {67 if (pgCode(e) === PG_UNIQUE_VIOLATION) throw errors.conflict("USERNAME_TAKEN", "That username is already taken.");68 throw e;69 }70 await createSession(reply, req, userId);71 await logSecurity(req, "register", { userId });72 return {73 user: { id: userId, username, isNew: true },74 balance: STARTING_BALANCE,75 recoveryCode,76 notice: "Save this recovery code. Spinza does not collect your email address. If you lose your password and recovery code, your account cannot be recovered.",77 };78 });7980 app.post("/api/auth/login", async (req, reply) => {81 await limit(req, "login", 20, 300);82 const parsed = loginSchema.safeParse(req.body);83 if (!parsed.success) throw errors.unauthorized("Invalid username or password.");84 const { username, password } = parsed.data;85 const perUser = await rateLimit(`login-user:${username}`, 10, 300);86 if (perUser) throw errors.rateLimited(perUser);87 const u = await db.query.users.findFirst({ where: eq(users.usernameNormalized, username) });88 const ok = u ? await verifyPassword(u.passwordHash, password) : await verifyPassword("$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", password);89 if (!u || !ok) {90 await logSecurity(req, "login.failed", { userId: u?.id ?? null, meta: { username } });91 throw errors.unauthorized("Invalid username or password.");92 }93 if (u.status !== "active") throw errors.forbidden("This account is suspended.");94 await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, u.id));95 await createSession(reply, req, u.id);96 await logSecurity(req, "login.success", { userId: u.id });97 return { user: { id: u.id, username: u.username } };98 });99100 app.post("/api/auth/logout", async (req, reply) => {101 const uid = req.user?.id;102 await destroySession(reply, req);103 if (uid) await logSecurity(req, "logout", { userId: uid });104 return { ok: true };105 });106107 app.post("/api/auth/recover", async (req, reply) => {108 await limit(req, "recover", 8, 900);109 const parsed = recoverSchema.safeParse(req.body);110 if (!parsed.success) throw errors.badRequest("Please check the form.", parsed.error.flatten());111 const { username, recoveryCode, newPassword } = parsed.data;112 const u = await db.query.users.findFirst({ where: eq(users.usernameNormalized, username) });113 const rc = u ? await db.query.recoveryCodes.findFirst({ where: eq(recoveryCodes.userId, u.id) }) : null;114 const ok = rc ? await verifyRecoveryCode(rc.codeHash, recoveryCode) : false;115 if (!u || !rc || !ok) {116 await logSecurity(req, "recovery.failed", { userId: u?.id ?? null, severity: "warn", meta: { username } });117 throw errors.unauthorized("Invalid username or recovery code.");118 }119 const nextCode = generateRecoveryCode();120 const passwordHash = await hashPassword(newPassword);121 await db.transaction(async (tx) => {122 await tx.update(users).set({ passwordHash }).where(eq(users.id, u.id));123 await tx.update(recoveryCodes).set({ codeHash: await hashRecoveryCode(nextCode), rotatedAt: new Date(), usedAt: new Date(), useCount: sql`${recoveryCodes.useCount} + 1` }).where(eq(recoveryCodes.userId, u.id));124 });125 await destroyAllSessions(u.id);126 await createSession(reply, req, u.id);127 await logSecurity(req, "recovery.used", { userId: u.id, severity: "warn" });128 return { user: { id: u.id, username: u.username }, recoveryCode: nextCode, notice: "Your recovery code has been rotated. Save the new one." };129 });130131 app.post("/api/auth/password", async (req) => {132 const user = requireUser(req);133 const body = z.object({ currentPassword: z.string().min(1), newPassword: passwordSchema }).parse(req.body);134 const u = await db.query.users.findFirst({ where: eq(users.id, user.id) });135 if (!u || !(await verifyPassword(u.passwordHash, body.currentPassword))) throw errors.unauthorized("Current password is incorrect.");136 await db.update(users).set({ passwordHash: await hashPassword(body.newPassword) }).where(eq(users.id, u.id));137 await logSecurity(req, "password.changed", { userId: u.id });138 return { ok: true };139 });140141 /** Rotate the recovery code (requires password). */142 app.post("/api/auth/recovery-code/rotate", async (req) => {143 const user = requireUser(req);144 const body = z.object({ password: z.string().min(1) }).parse(req.body);145 const u = await db.query.users.findFirst({ where: eq(users.id, user.id) });146 if (!u || !(await verifyPassword(u.passwordHash, body.password))) throw errors.unauthorized("Password is incorrect.");147 const code = generateRecoveryCode();148 await db.insert(recoveryCodes).values({ userId: u.id, codeHash: await hashRecoveryCode(code) }).onConflictDoUpdate({ target: recoveryCodes.userId, set: { codeHash: await hashRecoveryCode(code), rotatedAt: new Date() } });149 return { recoveryCode: code };150 });151}152