Compare commits

...
Sign in to create a new pull request.

17 commits

Author SHA1 Message Date
lila
6539d3e346 fix: prevent deployment when quality checks fail
All checks were successful
Build and Deploy / quality (push) Successful in 1m47s
Build and Deploy / build-and-deploy (push) Successful in 1m0s
2026-05-02 13:22:35 +02:00
lila
531da98c24 fix: initialize Resend lazily to prevent test failures when API key is absent
All checks were successful
Build and Deploy / quality (push) Successful in 1m46s
Build and Deploy / build-and-deploy (push) Successful in 1m37s
2026-05-02 13:18:00 +02:00
lila
90b0890263 Merge branch 'dev'
Some checks failed
Build and Deploy / quality (push) Failing after 1m47s
Build and Deploy / build-and-deploy (push) Successful in 2m8s
2026-05-02 13:09:19 +02:00
lila
ccfd83d16c feat: email/password auth + email verification + password reset via Resend 2026-05-02 13:05:43 +02:00
lila
4ae2c568c6 fix: resolve ESLint config file ignores and project service coverage 2026-05-02 12:15:23 +02:00
lila
6ca6fc4e09 fix: correct dotenv path in packages/db/src/index.ts for compiled dist output 2026-05-02 11:23:10 +02:00
lila
e1c4fb5744 refactoring 2026-05-02 11:22:54 +02:00
lila
dc11213cb5 feat: replace login route with auth modal
- Add AuthModal to root layout driven by ?modal=auth search param
- Update multiplayer and play beforeLoad redirects to use modal
- Update NavAuth and Hero links to use modal
- Delete login route and NavLogin component
2026-04-30 19:46:45 +02:00
lila
32ee1edf80 feat: add AuthModal component with login, register and social tabs
- Add AuthModal with login/register tabs and social buttons
- Add forgot-password and reset-password routes
- Add Sonner toaster to root layout
- Add auth search schemas to @lila/shared
- Add ESLint overrides for TanStack Router generics
2026-04-30 19:38:43 +02:00
lila
6297dff399 feat: add email/password auth backend + forgot/reset password routes
- Configure Better Auth emailAndPassword plugin with Resend
- Add email verification and password reset email sending
- Create forgot-password and reset-password frontend routes
- Add auth schemas to @lila/shared
2026-04-30 18:30:20 +02:00
lila
690e1ab72e updating status
All checks were successful
Build and Deploy / quality (push) Successful in 1m41s
Build and Deploy / build-and-deploy (push) Successful in 1m1s
2026-04-30 02:17:35 +02:00
lila
349107fa6f revert: remove registry login from deploy step
All checks were successful
Build and Deploy / quality (push) Successful in 1m41s
Build and Deploy / build-and-deploy (push) Successful in 34s
2026-04-30 02:02:58 +02:00
lila
14d1837ee9 fix: login to registry in deploy step to bypass gpg passphrase prompt
Some checks failed
Build and Deploy / quality (push) Successful in 1m40s
Build and Deploy / build-and-deploy (push) Failing after 1m1s
2026-04-30 01:52:21 +02:00
lila
5f553930c2 chore: skip husky in production installs
Some checks failed
Build and Deploy / quality (push) Successful in 1m43s
Build and Deploy / build-and-deploy (push) Failing after 1m56s
2026-04-30 01:36:07 +02:00
lila
47a0becc6e chore: fix typecheck script to use per-package tsc --noEmit
Some checks failed
Build and Deploy / quality (push) Successful in 1m41s
Build and Deploy / build-and-deploy (push) Failing after 1m2s
2026-04-30 01:29:19 +02:00
lila
89e559a4db adding pnpm-store
Some checks failed
Build and Deploy / quality (push) Failing after 1m31s
Build and Deploy / build-and-deploy (push) Failing after 48s
2026-04-30 01:20:59 +02:00
lila
4f47e18ad9 formatting 2026-04-30 01:20:12 +02:00
34 changed files with 770 additions and 226 deletions

View file

@ -16,3 +16,6 @@ VITE_WS_URL=
UID=1000
GID=1000
RESEND_API_KEY=
EMAIL_FROM=mail@example.com

View file

@ -29,6 +29,7 @@ jobs:
build-and-deploy:
runs-on: docker
needs: quality
steps:
- name: Install tools
run: apt-get update && apt-get install -y docker.io openssh-client

View file

@ -18,3 +18,5 @@ coverage/
pnpm-lock.yaml
routeTree.gen.ts
.pnpm-store/

View file

@ -7,7 +7,8 @@
"dev": "pnpm --filter shared build && pnpm --filter db build && tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/src/server.js",
"test": "vitest"
"test": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@lila/db": "workspace:*",
@ -17,6 +18,7 @@
"express": "^5.2.1",
"express-rate-limit": "^8.4.0",
"helmet": "^8.1.0",
"resend": "^6.12.2",
"ws": "^8.20.0"
},
"devDependencies": {

View file

@ -27,7 +27,7 @@ export function createApp() {
const store = new InMemoryGameSessionStore();
app.use("/api/v1", createApiRouter(store));
app.use(errorHandler);
return app;

View file

@ -197,7 +197,7 @@ describe("POST /api/v1/game/answer", () => {
expect(body.success).toBe(false);
expect(body.error).toContain("Question already answered");
});
it("returns 400 when a field has an invalid value", async () => {
const res = await request(app)
.post("/api/v1/game/start")

View file

@ -30,4 +30,4 @@ export class UnprocessableEntityError extends AppError {
constructor(message: string) {
super(message, 422);
}
}
}

View file

@ -1,8 +1,11 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { Resend } from "resend";
import { db } from "@lila/db";
import * as schema from "@lila/db/schema";
const emailFrom = process.env["EMAIL_FROM"] ?? "noreply@lilastudy.com";
export const auth = betterAuth({
baseURL: process.env["BETTER_AUTH_URL"] || "http://localhost:3000",
advanced: {
@ -16,6 +19,44 @@ export const auth = betterAuth({
},
},
database: drizzleAdapter(db, { provider: "pg", schema }),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({
user,
url,
}: {
user: { email: string };
url: string;
}) => {
const resend = new Resend(process.env["RESEND_API_KEY"]);
await resend.emails.send({
from: emailFrom,
to: user.email,
subject: "Reset your lila password",
html: `<p>Click <a href="${url}">here</a> to reset your password. This link expires in 1 hour.</p>`,
});
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({
user,
url,
}: {
user: { email: string };
url: string;
}) => {
const resend = new Resend(process.env["RESEND_API_KEY"]);
await resend.emails.send({
from: emailFrom,
to: user.email,
subject: "Verify your lila account",
html: `<p>Click <a href="${url}">here</a> to verify your email address.</p>`,
});
},
},
trustedOrigins: [process.env["CORS_ORIGIN"] || "http://localhost:5173"],
socialProviders: {
google: {

View file

@ -14,6 +14,6 @@ export const createGameRouter = (store: GameSessionStore): Router => {
router.post("/start", controller.createGame as express.RequestHandler);
router.post("/answer", controller.submitAnswer as express.RequestHandler);
return router;
};

View file

@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@lila/shared": "workspace:*",
@ -16,6 +17,7 @@
"better-auth": "^1.6.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sonner": "^2.0.7",
"tailwindcss": "^4.2.2"
},
"devDependencies": {

View file

@ -0,0 +1,249 @@
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { authClient } from "../../lib/auth-client";
type Tab = "login" | "register";
type AuthModalProps = { onClose: () => void; onSuccess: () => void };
type LoginFormProps = { onSuccess: () => void };
const LoginForm = ({ onSuccess }: LoginFormProps) => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isPending, setIsPending] = useState(false);
const handleSubmit = async () => {
setIsPending(true);
await authClient.signIn.email(
{ email, password },
{
onSuccess: () => {
toast.success("Welcome back!");
onSuccess();
},
onError: (ctx) => {
toast.error(ctx.error.message ?? "Something went wrong.");
setIsPending(false);
},
},
);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
className="flex flex-col gap-3"
>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<div className="text-right">
<a
href="/forgot-password"
className="text-xs text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
>
Forgot password?
</a>
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
>
{isPending ? "Logging in..." : "Login"}
</button>
</form>
);
};
type RegisterFormProps = { onSuccess: () => void };
const RegisterForm = ({ onSuccess }: RegisterFormProps) => {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isPending, setIsPending] = useState(false);
const handleSubmit = async () => {
setIsPending(true);
await authClient.signUp.email(
{ name, email, password },
{
onSuccess: () => {
toast.success("Check your email to verify your account.");
onSuccess();
},
onError: (ctx) => {
toast.error(ctx.error.message ?? "Something went wrong.");
setIsPending(false);
},
},
);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
className="flex flex-col gap-3"
>
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<button
type="submit"
disabled={isPending}
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
>
{isPending ? "Creating account..." : "Register"}
</button>
</form>
);
};
type SocialButtonsProps = { onSuccess: () => void };
const SocialButtons = ({ onSuccess }: SocialButtonsProps) => {
const handleSocial = (provider: "google" | "github") => {
void authClient.signIn.social(
{ provider, callbackURL: window.location.origin },
{
onSuccess,
onError: (ctx) => {
toast.error(ctx.error.message ?? "Something went wrong.");
},
},
);
};
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-(--color-primary-light)" />
<span className="text-xs text-(--color-text-muted) font-medium">
or continue with
</span>
<div className="flex-1 h-px bg-(--color-primary-light)" />
</div>
<button
onClick={() => handleSocial("github")}
className="w-full rounded-2xl bg-(--color-text) px-4 py-3 text-white font-bold hover:opacity-90 shadow-sm hover:shadow-md transition-all"
>
Continue with GitHub
</button>
<button
onClick={() => handleSocial("google")}
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
>
Continue with Google
</button>
</div>
);
};
export const AuthModal = ({ onClose, onSuccess }: AuthModalProps) => {
const [tab, setTab] = useState<Tab>("login");
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative w-full max-w-sm rounded-3xl border border-(--color-primary-light) bg-white shadow-lg p-8 flex flex-col gap-6"
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
aria-label="Close"
>
</button>
{/* Header */}
<div className="text-center">
<h2 className="text-2xl font-black tracking-tight text-(--color-text)">
lila
</h2>
</div>
{/* Tabs */}
<div className="flex rounded-2xl border border-(--color-primary-light) overflow-hidden">
{(["login", "register"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-2 text-sm font-bold transition-colors capitalize ${
tab === t
? "bg-(--color-primary) text-white"
: "text-(--color-text-muted) hover:text-(--color-primary)"
}`}
>
{t}
</button>
))}
</div>
{tab === "login" ? (
<LoginForm onSuccess={onSuccess} />
) : (
<RegisterForm onSuccess={onClose} />
)}
{/* Social */}
<SocialButtons onSuccess={onSuccess} />
</div>
</div>
);
};

View file

@ -66,13 +66,15 @@ const Hero = () => {
) : (
<>
<Link
to="/login"
to="/"
search={{ modal: "auth" }}
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
>
Get started
</Link>
<Link
to="/login"
to="/"
search={{ modal: "auth" }}
className="px-7 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
>
Log in

View file

@ -24,13 +24,14 @@ const NavAuth = () => {
</button>
) : (
<Link
to="/login"
to="/"
search={{ modal: "auth" }}
className="text-sm font-medium px-4 py-1.5 rounded-full
text-white bg-(--color-primary)
hover:bg-(--color-primary-dark)
transition-colors duration-200"
>
Sign in
Login
</Link>
)}
</div>

View file

@ -1,17 +0,0 @@
import { Link } from "@tanstack/react-router";
const NavLogin = () => {
return (
<Link
to="/login"
className="text-sm font-medium px-4 py-1.5 rounded-full
text-white bg-(--color-primary)
hover:bg-(--color-primary-dark)
transition-colors duration-200"
>
Login
</Link>
);
};
export default NavLogin;

View file

@ -9,15 +9,21 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as PlayRouteImport } from './routes/play'
import { Route as MultiplayerRouteImport } from './routes/multiplayer'
import { Route as LoginRouteImport } from './routes/login'
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
import { Route as MultiplayerIndexRouteImport } from './routes/multiplayer/index'
import { Route as MultiplayerLobbyCodeRouteImport } from './routes/multiplayer/lobby.$code'
import { Route as MultiplayerGameCodeRouteImport } from './routes/multiplayer/game.$code'
const ResetPasswordRoute = ResetPasswordRouteImport.update({
id: '/reset-password',
path: '/reset-password',
getParentRoute: () => rootRouteImport,
} as any)
const PlayRoute = PlayRouteImport.update({
id: '/play',
path: '/play',
@ -28,9 +34,9 @@ const MultiplayerRoute = MultiplayerRouteImport.update({
path: '/multiplayer',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
id: '/forgot-password',
path: '/forgot-password',
getParentRoute: () => rootRouteImport,
} as any)
const AboutRoute = AboutRouteImport.update({
@ -62,9 +68,10 @@ const MultiplayerGameCodeRoute = MultiplayerGameCodeRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/forgot-password': typeof ForgotPasswordRoute
'/multiplayer': typeof MultiplayerRouteWithChildren
'/play': typeof PlayRoute
'/reset-password': typeof ResetPasswordRoute
'/multiplayer/': typeof MultiplayerIndexRoute
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
@ -72,8 +79,9 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/forgot-password': typeof ForgotPasswordRoute
'/play': typeof PlayRoute
'/reset-password': typeof ResetPasswordRoute
'/multiplayer': typeof MultiplayerIndexRoute
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
@ -82,9 +90,10 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/forgot-password': typeof ForgotPasswordRoute
'/multiplayer': typeof MultiplayerRouteWithChildren
'/play': typeof PlayRoute
'/reset-password': typeof ResetPasswordRoute
'/multiplayer/': typeof MultiplayerIndexRoute
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
@ -94,9 +103,10 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/about'
| '/login'
| '/forgot-password'
| '/multiplayer'
| '/play'
| '/reset-password'
| '/multiplayer/'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
@ -104,8 +114,9 @@ export interface FileRouteTypes {
to:
| '/'
| '/about'
| '/login'
| '/forgot-password'
| '/play'
| '/reset-password'
| '/multiplayer'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
@ -113,9 +124,10 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/about'
| '/login'
| '/forgot-password'
| '/multiplayer'
| '/play'
| '/reset-password'
| '/multiplayer/'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
@ -124,13 +136,21 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
LoginRoute: typeof LoginRoute
ForgotPasswordRoute: typeof ForgotPasswordRoute
MultiplayerRoute: typeof MultiplayerRouteWithChildren
PlayRoute: typeof PlayRoute
ResetPasswordRoute: typeof ResetPasswordRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/reset-password': {
id: '/reset-password'
path: '/reset-password'
fullPath: '/reset-password'
preLoaderRoute: typeof ResetPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/play': {
id: '/play'
path: '/play'
@ -145,11 +165,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MultiplayerRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
'/forgot-password': {
id: '/forgot-password'
path: '/forgot-password'
fullPath: '/forgot-password'
preLoaderRoute: typeof ForgotPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/about': {
@ -209,9 +229,10 @@ const MultiplayerRouteWithChildren = MultiplayerRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
LoginRoute: LoginRoute,
ForgotPasswordRoute: ForgotPasswordRoute,
MultiplayerRoute: MultiplayerRouteWithChildren,
PlayRoute: PlayRoute,
ResetPasswordRoute: ResetPasswordRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View file

@ -1,16 +1,39 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import {
createRootRoute,
Outlet,
useNavigate,
useSearch,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { Toaster } from "sonner";
import Navbar from "../components/navbar/NavBar";
import NotFound from "../components/NotFound";
import RootError from "../components/RootError";
import { AuthModal } from "../components/auth/AuthModal";
import { AuthModalSearchSchema } from "@lila/shared";
const RootLayout = () => {
const navigate = useNavigate();
const { modal, redirect } = useSearch({ from: "__root__" });
const handleClose = () => {
void navigate({ to: "/", search: {} });
};
const handleSuccess = () => {
void navigate({ to: (redirect as string) ?? "/", search: {} });
};
return (
<>
<Navbar />
<main className="max-w-5xl mx-auto px-6 py-8">
<Outlet />
</main>
{modal === "auth" && (
<AuthModal onClose={handleClose} onSuccess={handleSuccess} />
)}
<Toaster richColors position="top-center" />
<TanStackRouterDevtools />
</>
);
@ -20,4 +43,5 @@ export const Route = createRootRoute({
component: RootLayout,
notFoundComponent: NotFound,
errorComponent: RootError,
validateSearch: AuthModalSearchSchema,
});

View file

@ -0,0 +1,74 @@
import { useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { authClient } from "../lib/auth-client";
import { toast } from "sonner";
function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [isPending, setIsPending] = useState(false);
const handleSubmit = async () => {
setIsPending(true);
await authClient.requestPasswordReset(
{ email, redirectTo: `${window.location.origin}/reset-password` },
{
onSuccess: () => {
toast.success("Check your email for a reset link.");
setIsPending(false);
},
onError: (ctx) => {
toast.error(ctx.error.message ?? "Something went wrong.");
setIsPending(false);
},
},
);
};
return (
<div className="flex flex-col items-center justify-center gap-6 p-8 max-w-sm mx-auto">
<div className="w-full text-center">
<h1 className="text-2xl font-black tracking-tight text-(--color-text)">
Forgot password
</h1>
<p className="mt-1 text-sm text-(--color-text-muted)">
Enter your email and we'll send you a reset link.
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
className="w-full flex flex-col gap-3"
>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<button
type="submit"
disabled={isPending}
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
>
{isPending ? "Sending..." : "Send reset link"}
</button>
</form>
<Link
to="/"
className="text-sm text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
>
Back to home
</Link>
</div>
);
}
export const Route = createFileRoute("/forgot-password")({
component: ForgotPasswordPage,
});

View file

@ -1,46 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { signIn, useSession } from "../lib/auth-client";
const LoginPage = () => {
const { data: session, isPending } = useSession();
const navigate = useNavigate();
if (isPending) return <div className="p-4">Loading...</div>;
if (session) {
void navigate({ to: "/" });
return null;
}
return (
<div className="flex flex-col items-center justify-center gap-4 p-8">
<h1 className="text-2xl font-bold">sign in to lila</h1>
<button
className="w-64 rounded-2xl bg-(--color-text) px-4 py-3 text-white font-bold hover:opacity-90 shadow-sm hover:shadow-md transition-all"
onClick={() => {
void signIn
.social({ provider: "github", callbackURL: window.location.origin })
.catch((err) => {
console.error("GitHub sign in error:", err);
});
}}
>
Continue with GitHub
</button>
<button
className="w-64 rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
onClick={() => {
void signIn
.social({ provider: "google", callbackURL: window.location.origin })
.catch((err) => {
console.error("Google sign in error:", err);
});
}}
>
Continue with Google
</button>
</div>
);
};
export const Route = createFileRoute("/login")({ component: LoginPage });

View file

@ -14,7 +14,10 @@ export const Route = createFileRoute("/multiplayer")({
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) {
throw redirect({ to: "/login" });
throw redirect({
to: "/",
search: { modal: "auth", redirect: "/multiplayer" },
});
}
return { session };
},

View file

@ -132,7 +132,7 @@ export const Route = createFileRoute("/play")({
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) {
throw redirect({ to: "/login" });
throw redirect({ to: "/", search: { modal: "auth", redirect: "/play" } });
}
},
});

View file

@ -0,0 +1,91 @@
import { useState } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { authClient } from "../lib/auth-client";
import { toast } from "sonner";
import { ResetPasswordSearchSchema } from "@lila/shared";
function ResetPasswordPage() {
const { token } = Route.useSearch();
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [isPending, setIsPending] = useState(false);
if (!token) {
return (
<div className="flex flex-col items-center justify-center gap-4 p-8 max-w-sm mx-auto text-center">
<h1 className="text-2xl font-black tracking-tight text-(--color-text)">
Invalid link
</h1>
<p className="text-sm text-(--color-text-muted)">
This reset link is invalid or has expired.
</p>
<Link
to="/forgot-password"
className="text-sm text-(--color-primary) hover:underline"
>
Request a new one
</Link>
</div>
);
}
const handleSubmit = async () => {
setIsPending(true);
await authClient.resetPassword(
{ newPassword: password, token },
{
onSuccess: () => {
toast.success("Password updated. You can now sign in.");
void navigate({ to: "/" });
},
onError: (ctx) => {
toast.error(ctx.error.message ?? "Something went wrong.");
setIsPending(false);
},
},
);
};
return (
<div className="flex flex-col items-center justify-center gap-6 p-8 max-w-sm mx-auto">
<div className="w-full text-center">
<h1 className="text-2xl font-black tracking-tight text-(--color-text)">
Reset password
</h1>
<p className="mt-1 text-sm text-(--color-text-muted)">
Enter your new password below.
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
className="w-full flex flex-col gap-3"
>
<input
type="password"
placeholder="New password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<button
type="submit"
disabled={isPending}
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
>
{isPending ? "Updating..." : "Update password"}
</button>
</form>
</div>
);
}
export const Route = createFileRoute("/reset-password")({
component: ResetPasswordPage,
validateSearch: ResetPasswordSearchSchema,
});

View file

@ -90,9 +90,6 @@ Directionally right, timing is unclear. Revisit when the next/now work is done.
- **Resolve eslint peer dependency warning** `[debt]`
`eslint-plugin-react-hooks 7.0.1` expects `eslint ^3.0.0^9.0.0` but found `10.0.3`. Low impact but worth cleaning up when nearby.
- **husky + lint-staged** `[debt]`
Set up husky and lint-staged to run linting and formatting checks before every commit. Prevents CI failures from formatting or lint issues that slipped through locally.
- **OpenAPI documentation for REST endpoints** `[feature]`
Document the API surface using OpenAPI/Swagger. Covers all REST endpoints with request/response shapes. Useful groundwork for the admin dashboard and any future contributors.
@ -105,6 +102,7 @@ Directionally right, timing is unclear. Revisit when the next/now work is done.
Shipped milestones, newest first.
- **04 - 2026 - husky + lint-staged + CI quality gate** - Pre-commit formatting, pre-push tests, and CI lint/typecheck/test gate before every deploy.
- **04 - 2026 - t00001 - Docker credential helper**
- **04 - 2026 - Pin dependencies in package.json** - Unpinned deps in a CI/CD pipeline are a real risk.
- **04 - 2026 - React error boundaries** - Catch and display runtime errors gracefully instead of crashing the entire app.

View file

@ -1,11 +1,11 @@
# 🔥 GameService Roast: `apps/api/src/services/gameService.ts`
> *"It works on my machine" is not a scalability strategy.*
> _"It works on my machine" is not a scalability strategy._
**Project:** lila — Vocabulary Trainer
**File Roasted:** `gameService.ts`
**Date:** $(date)
**Roaster:** Qwen3.6
**Roaster:** Qwen3.6
---
@ -28,8 +28,8 @@
**Location:** `gameService.ts:45-58` + `InMemoryGameSessionStore.ts:update()`
// Current flow (VULNERABLE):
const session = await store.get(submission.sessionId); // READ
const updatedAnswers = new Map(session.answers); // MODIFY (local copy)
const session = await store.get(submission.sessionId); // READ
const updatedAnswers = new Map(session.answers); // MODIFY (local copy)
updatedAnswers.delete(submission.questionId);
await store.update(submission.sessionId, { answers: updatedAnswers }); // WRITE
@ -46,64 +46,55 @@ Fix Options:
// Option A: Add atomic operation to store interface
interface GameSessionStore {
deleteAnswer(sessionId: string, questionId: string): Promise<boolean>;
deleteAnswer(sessionId: string, questionId: string): Promise<boolean>;
}
// Option B: Use Valkey Lua script for atomic read-modify-write
// Option C: Optimistic locking with version numbers
Priority: 🔴 CRITICAL — Data integrity issue
2. N+1 Query: Database Performance Bomb
Priority: 🔴 CRITICAL — Data integrity issue 2. N+1 Query: Database Performance Bomb
Location: gameService.ts:24-26 + termModel.ts:getDistractors()
// For each of N terms, we call getDistractors():
const questions: GameQuestion[] = await Promise.all(
terms.map(async (term) => {
const distractorTexts = await getDistractors(term.termId, ...); // 🚩 N queries!
})
terms.map(async (term) => {
const distractorTexts = await getDistractors(term.termId, ...); // 🚩 N queries!
})
);
Impact Analysis:
Rounds
DB Queries
At 50 concurrent users
3
1 + 3 = 4
200 queries/min
10
1 + 10 = 11
550 queries/min
20
1 + 20 = 21
1,050 queries/min
Each getDistractors() runs:
SELECT text FROM terms
JOIN translations ON ...
WHERE pos = $1 AND difficulty = $2 AND term_id != $3 AND text != $4
SELECT text FROM terms
JOIN translations ON ...
WHERE pos = $1 AND difficulty = $2 AND term_id != $3 AND text != $4
ORDER BY RANDOM() LIMIT 6
Fix: Batch Fetch Distractors
// Fetch all distractors in ONE query
const allDistractors = await db
.select({ termId: terms.id, text: translations.text })
.from(terms)
.innerJoin(translations, /* ... */)
.where(and(
eq(terms.pos, pos),
eq(translations.difficulty, difficulty),
inArray(terms.id, termIds), // Batch!
))
.limit(DISTRACTOR_FETCH_COUNT * termIds.length);
.select({ termId: terms.id, text: translations.text })
.from(terms)
.innerJoin(translations, /_ ... _/)
.where(and(
eq(terms.pos, pos),
eq(translations.difficulty, difficulty),
inArray(terms.id, termIds), // Batch!
))
.limit(DISTRACTOR_FETCH_COUNT \* termIds.length);
// Group by termId in JS, then slice to 3 unique distractors per term
const distractorsByTerm = groupByTermId(allDistractors);
@ -111,10 +102,10 @@ const distractorsByTerm = groupByTermId(allDistractors);
Priority: 🔴 CRITICAL — Performance/scalability issue
3. Error Handling Inconsistency
Location: gameService.ts:33-36
Location: gameService.ts:33-36
if (uniqueDistractors.length < 3) {
throw new Error(`Not enough unique distractors for term: ${term.targetText}`); // 🚩
throw new Error(`Not enough unique distractors for term: ${term.targetText}`); // 🚩
}
Problem: Raw Error bypasses your errorHandler middleware:
@ -127,15 +118,14 @@ Fix:
import { UnprocessableEntityError } from "../errors/AppError.js";
if (uniqueDistractors.length < 3) {
logger.warn({ termId: term.termId, uniqueCount: uniqueDistractors.length },
"insufficient_distractors");
throw new UnprocessableEntityError(
`Not enough unique distractors for term: ${term.targetText}`
);
logger.warn({ termId: term.termId, uniqueCount: uniqueDistractors.length },
"insufficient_distractors");
throw new UnprocessableEntityError(
`Not enough unique distractors for term: ${term.targetText}`
);
}
Priority: 🟡 HIGH — Observability & UX issue
⚠️ High-Severity Smells
4. Code Duplication: Singleplayer vs Multiplayer
⚠️ High-Severity Smells 4. Code Duplication: Singleplayer vs Multiplayer
Compare: gameService.ts vs multiplayerGameService.ts
// gameService.ts
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
@ -157,30 +147,29 @@ Fix: Extract pure function to @lila/shared or new @lila/game-logic:
// packages/shared/src/game-logic.ts
export const buildQuestionOptions = (
correctAnswer: string,
distractors: string[],
optionCount: number = 4
correctAnswer: string,
distractors: string[],
optionCount: number = 4
): { options: AnswerOption[]; correctOptionId: number } => {
const uniqueDistractors = [...new Set(distractors.filter(d => d !== correctAnswer))];
const optionTexts = [correctAnswer, ...uniqueDistractors.slice(0, optionCount - 1)];
const shuffled = shuffleSecure(optionTexts);
const correctOptionId = shuffled.indexOf(correctAnswer);
return {
options: shuffled.map((text, idx) => ({ optionId: idx, text })),
correctOptionId
};
const uniqueDistractors = [...new Set(distractors.filter(d => d !== correctAnswer))];
const optionTexts = [correctAnswer, ...uniqueDistractors.slice(0, optionCount - 1)];
const shuffled = shuffleSecure(optionTexts);
const correctOptionId = shuffled.indexOf(correctAnswer);
return {
options: shuffled.map((text, idx) => ({ optionId: idx, text })),
correctOptionId
};
};
Priority: 🟡 HIGH — Maintainability issue
5. Shuffle Bias: Math.random() Trap
Priority: 🟡 HIGH — Maintainability issue 5. Shuffle Bias: Math.random() Trap
Location: utils.ts:shuffleArray() + multiplayerGameService.ts:shuffle()
export const shuffleArray = <T>(array: T[]): T[] => {
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); // 🚩 Modulo bias + non-crypto RNG
// ...
}
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() \* (i + 1)); // 🚩 Modulo bias + non-crypto RNG
// ...
}
};
The Math:
@ -199,65 +188,64 @@ Fix (if needed):
import { randomBytes } from "crypto";
const shuffleSecure = <T>(array: T[]): T[] => {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
// Use crypto.getRandomValues for better randomness
const rand = randomBytes(4).readUInt32LE(0);
const j = rand % (i + 1);
[result[i], result[j]] = [result[j], result[i]];
}
return result;
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
// Use crypto.getRandomValues for better randomness
const rand = randomBytes(4).readUInt32LE(0);
const j = rand % (i + 1);
[result[i], result[j]] = [result[j], result[i]];
}
return result;
};
Priority: 🟢 LOW — Document tradeoff and move on for now
6. Test Coverage Gaps
File: gameService.test.ts
✅ Well Tested:
6. Test Coverage Gaps
File: gameService.test.ts
✅ Well Tested:
Happy path: session creation, answer evaluation
Edge cases: duplicate distractors, empty results, invalid inputs
Error propagation from DB layer
Happy path: session creation, answer evaluation
Edge cases: duplicate distractors, empty results, invalid inputs
Error propagation from DB layer
❌ Missing Tests:
// 1. Concurrency test (race condition)
it("rejects duplicate answers for same question under concurrent load", async () => {
const session = await createGameSession(validRequest, store, "user-1");
const question = session.questions[0]!;
// Submit two answers simultaneously
const [result1, result2] = await Promise.allSettled([
evaluateAnswer({ sessionId, questionId, selectedOptionId: 0 }, store, "user-1"),
evaluateAnswer({ sessionId, questionId, selectedOptionId: 1 }, store, "user-1"),
]);
// Exactly one should succeed, one should throw ConflictError
expect([result1, result2].filter(r => r.status === "fulfilled")).toHaveLength(1);
const session = await createGameSession(validRequest, store, "user-1");
const question = session.questions[0]!;
// Submit two answers simultaneously
const [result1, result2] = await Promise.allSettled([
evaluateAnswer({ sessionId, questionId, selectedOptionId: 0 }, store, "user-1"),
evaluateAnswer({ sessionId, questionId, selectedOptionId: 1 }, store, "user-1"),
]);
// Exactly one should succeed, one should throw ConflictError
expect([result1, result2].filter(r => r.status === "fulfilled")).toHaveLength(1);
});
// 2. TTL expiration test
it("deletes session after TTL expires", async () => {
vi.useFakeTimers();
const session = await createGameSession(validRequest, store, "user-1");
vi.advanceTimersByTime(31 * 60 * 1000); // 31 minutes
await expect(store.get(session.sessionId)).resolves.toBeNull();
vi.useFakeTimers();
const session = await createGameSession(validRequest, store, "user-1");
vi.advanceTimersByTime(31 _ 60 _ 1000); // 31 minutes
await expect(store.get(session.sessionId)).resolves.toBeNull();
});
// 3. Distractor fallback strategy test
it("uses fallback when <3 unique distractors available", async () => {
mockGetDistractors.mockResolvedValue(["same", "same", "same", "same"]);
// Should either: (a) fetch from broader pool, or (b) reduce rounds gracefully
mockGetDistractors.mockResolvedValue(["same", "same", "same", "same"]);
// Should either: (a) fetch from broader pool, or (b) reduce rounds gracefully
});
Priority: 🟡 HIGH — Prevents regression on critical fixes
🧼 Code Quality Nitpicks
7. Magic Numbers
🧼 Code Quality Nitpicks 7. Magic Numbers
// gameService.ts:52
await store.create(sessionId, {...}, 30 * 60 * 1000); // What is this?
await store.create(sessionId, {...}, 30 _ 60 _ 1000); // What is this?
// termModel.ts:65
.limit(count); // count=6, but why?
@ -267,16 +255,16 @@ optionId: z.number().int().min(0).max(3), // Why 4 options?
Fix: Centralize in @lila/shared/constants.ts:
export const GAME_SESSION_TTL_MS = 30 * 60 * 1000;
export const GAME*SESSION_TTL_MS = 30 * 60 \_ 1000;
export const DISTRACTOR_FETCH_COUNT = 6;
export const GAME_OPTION_COUNT = 4;
export const MIN_UNIQUE_DISTRACTORS = 3;
8. Mutable Reference Leakage
Location: InMemoryGameSessionStore.ts:get()
Location: InMemoryGameSessionStore.ts:get()
get(sessionId: string): Promise<GameSessionData | null> {
return Promise.resolve(entry.data); // 🚩 Returns mutable reference to internal state
return Promise.resolve(entry.data); // 🚩 Returns mutable reference to internal state
}
Risk: Any code that does session.answers.delete(...) mutates the store's internal Map directly.
@ -291,37 +279,35 @@ return Promise.resolve(entry.data as Readonly<GameSessionData>);
// Option C: Use immutable data structures (overkill for now)
9. Zero Observability
Problem: No logging, no metrics. You're flying blind in production.
Minimal Fix (5 minutes):
Problem: No logging, no metrics. You're flying blind in production.
Minimal Fix (5 minutes):
// apps/api/src/lib/logger.ts
import pino from "pino";
export const logger = pino({
level: process.env.LOG_LEVEL || "info",
transport: process.env.NODE_ENV === "production"
? { target: "pino-pretty" }
: undefined
export const logger = pino({
level: process.env.LOG_LEVEL || "info",
transport: process.env.NODE_ENV === "production"
? { target: "pino-pretty" }
: undefined
});
// In gameService.ts:
import { logger } from "../lib/logger.js";
logger.info(
{ userId, sourceLang, targetLang, termCount: terms.length },
"game_session_created"
{ userId, sourceLang, targetLang, termCount: terms.length },
"game_session_created"
);
logger.debug(
{ sessionId, questionId, isCorrect, responseTimeMs },
"answer_evaluated"
{ sessionId, questionId, isCorrect, responseTimeMs },
"answer_evaluated"
);
Bonus: Export a Prometheus histogram for game_service_duration_seconds.
10. ORDER BY RANDOM() Time Bomb
Location: termModel.ts:getGameTerms() + getDistractors()
Location: termModel.ts:getGameTerms() + getDistractors()
.orderBy(sql`RANDOM()`) // 🚩 Fine for 10k rows, slow for 1M
@ -333,16 +319,16 @@ Reality Check: "Post-MVP" never comes without a ticket.
Fix Options:
-- Option A: Pre-computed random_seed column (updated nightly)
WHERE ... AND random_seed >= random()
ORDER BY random_seed
WHERE ... AND random_seed >= random()
ORDER BY random_seed
LIMIT $1
-- Option B: TABLESAMPLE for approximate sampling (Postgres 9.5+)
FROM terms TABLESAMPLE SYSTEM(10)
WHERE ...
FROM terms TABLESAMPLE SYSTEM(10)
WHERE ...
LIMIT $1
-- Option C: Random offset (simple, but still scans)
OFFSET floor(random() * (SELECT count(*) FROM terms WHERE ...))
OFFSET floor(random() _ (SELECT count(_) FROM terms WHERE ...))
Action: Add a ticket to documentation/tickets/t00009.md now.
Action: Add a ticket to documentation/tickets/t00009.md now.

View file

@ -84,7 +84,10 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
2. `GameSessionStore.ts` — add `userId` to `GameSessionData`:
```ts
export type GameSessionData = { answers: Map<string, number>; userId: string };
export type GameSessionData = {
answers: Map<string, number>;
userId: string;
};
```
3. `gameService.ts` — add `userId` to both function signatures:
@ -100,7 +103,11 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
Store it on create:
```ts
await store.create(sessionId, { answers: answerKey, userId }, 30 * 60 * 1000);
await store.create(
sessionId,
{ answers: answerKey, userId },
30 * 60 * 1000,
);
```
Assert on evaluate:
@ -114,7 +121,7 @@ Rejected because: for user-owned resources identified by opaque IDs, confirming
4. `gameController.ts` — extract from authenticated request:
```ts
req.session.user.id
req.session.user.id;
```
5. `gameRouter.ts` — cast at registration:

View file

@ -27,7 +27,9 @@ Not chosen for this ticket — the database query is in `@lila/db` and is a sepa
- Filter distractors against the correct answer before building options:
```ts
const uniqueDistractors = distractorTexts.filter((t) => t !== term.targetText);
const uniqueDistractors = distractorTexts.filter(
(t) => t !== term.targetText,
);
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
```

View file

@ -10,8 +10,6 @@ export default defineConfig([
globalIgnores([
"**/dist/**",
"node_modules/",
"eslint.config.mjs",
"**/*.config.ts",
"routeTree.gen.ts",
"scripts/**",
"data-pipeline/**/*",
@ -24,12 +22,19 @@ export default defineConfig([
{
languageOptions: {
parserOptions: {
projectService: true,
projectService: { allowDefaultProject: ["*.mjs", "*.ts"] },
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ["eslint.config.mjs"],
rules: {
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
},
},
{
files: ["apps/web/**/*.{ts,tsx}"],
extends: [
@ -43,6 +48,9 @@ export default defineConfig([
rules: {
"react-refresh/only-export-components": "off",
"@typescript-eslint/only-throw-error": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-call": "off",
},
},
{

View file

@ -6,13 +6,13 @@
"scripts": {
"build": "pnpm --filter @lila/shared build && pnpm --filter @lila/db build && pnpm --filter @lila/api build",
"dev": "concurrently --names \"api,web\" -c \"magenta.bold,green.bold\" \"pnpm --filter @lila/api dev\" \"pnpm --filter @lila/web dev\"",
"prepare": "husky",
"prepare": "husky || true",
"test": "vitest",
"test:run": "vitest run",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --build --noEmit"
"typecheck": "pnpm -r typecheck"
},
"lint-staged": {
"**/*.{ts,tsx}": [

View file

@ -6,7 +6,8 @@
"scripts": {
"build": "rm -rf dist && tsc",
"generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate"
"migrate": "drizzle-kit migrate",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@lila/shared": "workspace:*",

View file

@ -6,10 +6,13 @@ import { dirname } from "path";
import * as schema from "./db/schema.js";
config({
path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"),
path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../../.env"),
});
export const db = drizzle(process.env["DATABASE_URL"]!, { schema });
export const db = drizzle(
process.env["DATABASE_URL_LOCAL"] ?? process.env["DATABASE_URL"]!,
{ schema },
);
export * from "./models/termModel.js";
export * from "./models/lobbyModel.js";

View file

@ -10,6 +10,7 @@
"include": [
"src",
"vitest.config.ts",
"drizzle.config.ts",
"../../data-pipeline/archive/packages-db-src-old-seeding-scripts/data"
]
}

View file

@ -4,7 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"build": "tsc"
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"exports": {
".": "./dist/src/index.js"

View file

@ -1,3 +1,4 @@
export * from "./constants.js";
export * from "./schemas/game.js";
export * from "./schemas/lobby.js";
export * from "./schemas/auth.js";

View file

@ -0,0 +1,14 @@
import * as z from "zod";
export const ResetPasswordSearchSchema = z.object({
token: z.string().catch(""),
});
export type ResetPasswordSearch = z.infer<typeof ResetPasswordSearchSchema>;
export const AuthModalSearchSchema = z.object({
modal: z.enum(["auth"]).optional().catch(undefined),
redirect: z.string().optional().catch(undefined),
});
export type AuthModalSearch = z.infer<typeof AuthModalSearchSchema>;

69
pnpm-lock.yaml generated
View file

@ -74,6 +74,9 @@ importers:
helmet:
specifier: ^8.1.0
version: 8.1.0
resend:
specifier: ^6.12.2
version: 6.12.2
ws:
specifier: ^8.20.0
version: 8.20.0
@ -120,6 +123,9 @@ importers:
react-dom:
specifier: ^19.2.4
version: 19.2.4(react@19.2.4)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
tailwindcss:
specifier: ^4.2.2
version: 4.2.2
@ -1090,6 +1096,9 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@ -2116,6 +2125,9 @@ packages:
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -2703,6 +2715,9 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
postal-mime@2.7.4:
resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==}
postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
@ -2794,6 +2809,15 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resend@6.12.2:
resolution: {integrity: sha512-xwgmU4b0OqoabJsIoK/x0Whk0Fcs3bpbK4i/DEWPiE5hYJHyHl0TbB6QbI3gIr+bLdLUJ1GYm/fe41aVFuHXgw==}
engines: {node: '>=20'}
peerDependencies:
'@react-email/render': '*'
peerDependenciesMeta:
'@react-email/render':
optional: true
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@ -2914,6 +2938,12 @@ packages:
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
engines: {node: '>=20'}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@ -2940,6 +2970,9 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@ -2994,6 +3027,9 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
svix@1.90.0:
resolution: {integrity: sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@ -3131,6 +3167,11 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@ -3937,6 +3978,8 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.7': {}
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {}
'@tailwindcss/node@4.2.2':
@ -5035,6 +5078,8 @@ snapshots:
fast-safe-stringify@2.1.1: {}
fast-sha256@1.3.0: {}
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@ -5542,6 +5587,8 @@ snapshots:
picomatch@4.0.3: {}
postal-mime@2.7.4: {}
postcss@8.5.8:
dependencies:
nanoid: 3.3.11
@ -5638,6 +5685,11 @@ snapshots:
require-from-string@2.0.2: {}
resend@6.12.2:
dependencies:
postal-mime: 2.7.4
svix: 1.90.0
resolve-pkg-maps@1.0.0: {}
restore-cursor@5.1.0:
@ -5791,6 +5843,11 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
source-map-js@1.2.1: {}
source-map-support@0.5.21:
@ -5810,6 +5867,11 @@ snapshots:
stackback@0.0.2: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
statuses@2.0.2: {}
std-env@4.0.0: {}
@ -5877,6 +5939,11 @@ snapshots:
dependencies:
has-flag: 4.0.0
svix@1.90.0:
dependencies:
standardwebhooks: 1.0.0
uuid: 10.0.0
symbol-tree@3.2.4: {}
tailwindcss@4.2.2: {}
@ -6007,6 +6074,8 @@ snapshots:
util-deprecate@1.0.2: {}
uuid@10.0.0: {}
vary@1.1.2: {}
vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3):