TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import { z } from "zod";2import { BET_LEVELS, DEFAULT_PROFANITY, RESERVED_USERNAMES } from "./constants";34export const USERNAME_RE = /^[a-z0-9][a-z0-9_-]{2,23}$/;56export function normalizeUsername(raw: string): string {7 return raw.trim().toLowerCase();8}910export interface UsernameCheck {11 ok: boolean;12 reason?: "length" | "charset" | "reserved" | "profanity";13}1415export function checkUsername(raw: string, extraProfanity: string[] = []): UsernameCheck {16 const u = normalizeUsername(raw);17 if (u.length < 3 || u.length > 24) return { ok: false, reason: "length" };18 if (!USERNAME_RE.test(u)) return { ok: false, reason: "charset" };19 if (RESERVED_USERNAMES.has(u)) return { ok: false, reason: "reserved" };20 const squashed = u.replace(/[_-]/g, "");21 for (const w of [...DEFAULT_PROFANITY, ...extraProfanity]) {22 if (w && squashed.includes(w.toLowerCase())) return { ok: false, reason: "profanity" };23 }24 return { ok: true };25}2627export const usernameSchema = z28 .string()29 .min(3)30 .max(24)31 .transform(normalizeUsername)32 .refine((u) => USERNAME_RE.test(u), "Use 3–24 lowercase letters, numbers, _ or -");3334export const passwordSchema = z.string().min(8, "At least 8 characters").max(256);3536export const registerSchema = z37 .object({38 username: usernameSchema,39 password: passwordSchema,40 confirmPassword: z.string(),41 ageConfirmed: z.literal(true, { error: "You must confirm you are 18 or older" }),42 })43 .refine((d) => d.password === d.confirmPassword, { message: "Passwords do not match", path: ["confirmPassword"] });4445export const loginSchema = z.object({46 username: usernameSchema,47 password: z.string().min(1).max(256),48});4950export const RECOVERY_RE = /^SPZ-[A-Z2-9]{4}-[A-Z2-9]{4}-[A-Z2-9]{4}$/;5152export const recoverSchema = z.object({53 username: usernameSchema,54 recoveryCode: z55 .string()56 .transform((s) => s.trim().toUpperCase().replace(/\s+/g, ""))57 .refine((s) => RECOVERY_RE.test(s), "Recovery code format: SPZ-XXXX-XXXX-XXXX"),58 newPassword: passwordSchema,59});6061export const spinSchema = z.object({62 bet: z.number().int().refine((b) => (BET_LEVELS as readonly number[]).includes(b), "Invalid bet"),63 clientRoundId: z.string().uuid(),64});6566export const settingsSchema = z.object({67 soundEnabled: z.boolean().optional(),68 musicVolume: z.number().min(0).max(1).optional(),69 effectsVolume: z.number().min(0).max(1).optional(),70 masterVolume: z.number().min(0).max(1).optional(),71 reduceMotion: z.boolean().optional(),72 animationIntensity: z.enum(["low", "medium", "high"]).optional(),73 sessionReminderMinutes: z.number().int().min(0).max(240).optional(),74 breakReminder: z.boolean().optional(),75 leaderboardOptIn: z.boolean().optional(),76});7778export type SettingsInput = z.infer<typeof settingsSchema>;79