Compare commits
22 commits
hardening/
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6539d3e346 | ||
|
|
531da98c24 | ||
|
|
90b0890263 | ||
|
|
ccfd83d16c | ||
|
|
4ae2c568c6 | ||
|
|
6ca6fc4e09 | ||
|
|
e1c4fb5744 | ||
|
|
dc11213cb5 | ||
|
|
32ee1edf80 | ||
|
|
6297dff399 | ||
|
|
690e1ab72e | ||
|
|
349107fa6f | ||
|
|
14d1837ee9 | ||
|
|
5f553930c2 | ||
|
|
47a0becc6e | ||
|
|
89e559a4db | ||
|
|
4f47e18ad9 | ||
|
|
35e54014b3 | ||
|
|
4d64d50598 | ||
|
|
1bfc0606c3 | ||
|
|
8a121442a3 | ||
|
|
57d2190549 |
37 changed files with 1080 additions and 258 deletions
|
|
@ -1,4 +1,5 @@
|
|||
DATABASE_URL=postgres://postgres:mypassword@db-host:5432/databasename
|
||||
DATABASE_URL_LOCAL=postgres://postgres:mypassword@localhost:5432/databasename
|
||||
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
|
|
@ -10,3 +11,11 @@ GOOGLE_CLIENT_ID=
|
|||
GOOGLE_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
||||
VITE_WS_URL=
|
||||
|
||||
UID=1000
|
||||
GID=1000
|
||||
|
||||
RESEND_API_KEY=
|
||||
EMAIL_FROM=mail@example.com
|
||||
|
|
|
|||
|
|
@ -5,8 +5,31 @@ on:
|
|||
branches: [main]
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Install tools
|
||||
run: apt-get update && apt-get install -y nodejs
|
||||
- name: Checkout code
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Build shared packages
|
||||
run: pnpm --filter @lila/shared build && pnpm --filter @lila/db build
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Type-check
|
||||
run: pnpm typecheck
|
||||
- name: Test
|
||||
run: pnpm test:run
|
||||
|
||||
build-and-deploy:
|
||||
runs-on: docker
|
||||
needs: quality
|
||||
steps:
|
||||
- name: Install tools
|
||||
run: apt-get update && apt-get install -y docker.io openssh-client
|
||||
|
|
|
|||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
|
|
@ -0,0 +1 @@
|
|||
pnpm lint-staged
|
||||
1
.husky/pre-push
Executable file
1
.husky/pre-push
Executable file
|
|
@ -0,0 +1 @@
|
|||
pnpm test:run
|
||||
|
|
@ -18,3 +18,5 @@ coverage/
|
|||
|
||||
pnpm-lock.yaml
|
||||
routeTree.gen.ts
|
||||
|
||||
.pnpm-store/
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function createApp() {
|
|||
|
||||
const store = new InMemoryGameSessionStore();
|
||||
app.use("/api/v1", createApiRouter(store));
|
||||
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -30,4 +30,4 @@ export class UnprocessableEntityError extends AppError {
|
|||
constructor(message: string) {
|
||||
super(message, 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
249
apps/web/src/components/auth/AuthModal.tsx
Normal file
249
apps/web/src/components/auth/AuthModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
74
apps/web/src/routes/forgot-password.tsx
Normal file
74
apps/web/src/routes/forgot-password.tsx
Normal 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,
|
||||
});
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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 };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
91
apps/web/src/routes/reset-password.tsx
Normal file
91
apps/web/src/routes/reset-password.tsx
Normal 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,
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ task description.
|
|||
4. if we go through a file, we'll do it slowly section by section, no matter how many sections
|
||||
5. how to name the current feature branch? also tell me when its time to git commit and provide a commit message
|
||||
6. if we have multiple options to do something, also always provide options that reflect current industry standards and best practices
|
||||
7. For every completed task, produce a ticket file in documentation/tickets/. Use ADR format (adr-) for decisions between options with long-term consequences. Use feat-/fix-/chore- for routine tasks. Always include a setup guide or summary of what was done. Suggest the filename.
|
||||
7. never assume anything! always ask for clarification!
|
||||
8. For every completed task, produce a ticket file in documentation/tickets/. Use ADR format (adr-) for decisions between options with long-term consequences. Use feat-/fix-/chore- for routine tasks. Always include a setup guide or summary of what was done. Suggest the filename.
|
||||
|
||||
## tasks
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
15
package.json
15
package.json
|
|
@ -6,11 +6,22 @@
|
|||
"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 || true",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
"format:check": "prettier --check .",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
},
|
||||
"lint-staged": {
|
||||
"**/*.{ts,tsx}": [
|
||||
"prettier --write",
|
||||
"eslint --fix"
|
||||
],
|
||||
"**/*.{js,mjs,json,md,css,html}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.33.1",
|
||||
"devDependencies": {
|
||||
|
|
@ -22,6 +33,8 @@
|
|||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.57.1",
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"include": [
|
||||
"src",
|
||||
"vitest.config.ts",
|
||||
"drizzle.config.ts",
|
||||
"../../data-pipeline/archive/packages-db-src-old-seeding-scripts/data"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/src/index.js"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./constants.js";
|
||||
export * from "./schemas/game.js";
|
||||
export * from "./schemas/lobby.js";
|
||||
export * from "./schemas/auth.js";
|
||||
|
|
|
|||
14
packages/shared/src/schemas/auth.ts
Normal file
14
packages/shared/src/schemas/auth.ts
Normal 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>;
|
||||
367
pnpm-lock.yaml
generated
367
pnpm-lock.yaml
generated
|
|
@ -16,7 +16,7 @@ importers:
|
|||
version: 1.161.6(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))
|
||||
version: 4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))
|
||||
concurrently:
|
||||
specifier: ^9.2.1
|
||||
version: 9.2.1
|
||||
|
|
@ -32,6 +32,12 @@ importers:
|
|||
eslint-plugin-react-refresh:
|
||||
specifier: ^0.5.2
|
||||
version: 0.5.2(eslint@10.0.3(jiti@2.6.1))
|
||||
husky:
|
||||
specifier: ^9.1.7
|
||||
version: 9.1.7
|
||||
lint-staged:
|
||||
specifier: ^16.4.0
|
||||
version: 16.4.0
|
||||
prettier:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1
|
||||
|
|
@ -43,7 +49,7 @@ importers:
|
|||
version: 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
|
||||
vitest:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
|
||||
version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
apps/api:
|
||||
dependencies:
|
||||
|
|
@ -55,7 +61,7 @@ importers:
|
|||
version: link:../../packages/shared
|
||||
better-auth:
|
||||
specifier: ^1.6.2
|
||||
version: 1.6.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.9.0)(kysely@0.28.16)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))
|
||||
version: 1.6.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.9.0)(kysely@0.28.16)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))
|
||||
cors:
|
||||
specifier: ^2.8.6
|
||||
version: 2.8.6
|
||||
|
|
@ -68,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
|
||||
|
|
@ -98,7 +107,7 @@ importers:
|
|||
version: link:../../packages/shared
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
|
||||
version: 4.2.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))
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.168.1
|
||||
version: 1.168.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -107,20 +116,23 @@ importers:
|
|||
version: 1.166.10(@tanstack/react-router@1.168.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.168.1)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
better-auth:
|
||||
specifier: ^1.6.2
|
||||
version: 1.6.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.9.0)(kysely@0.28.16)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))
|
||||
version: 1.6.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.9.0)(kysely@0.28.16)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(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)))
|
||||
react:
|
||||
specifier: ^19.2.4
|
||||
version: 19.2.4
|
||||
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
|
||||
devDependencies:
|
||||
'@tanstack/router-plugin':
|
||||
specifier: ^1.167.2
|
||||
version: 1.167.2(@tanstack/react-router@1.168.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
|
||||
version: 1.167.2(@tanstack/react-router@1.168.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(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))
|
||||
'@types/node':
|
||||
specifier: ^24.12.0
|
||||
version: 24.12.0
|
||||
|
|
@ -132,13 +144,13 @@ importers:
|
|||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
|
||||
version: 6.0.1(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))
|
||||
jsdom:
|
||||
specifier: ^29.0.1
|
||||
version: 29.0.1(@noble/hashes@2.2.0)
|
||||
vite:
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
|
||||
version: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
||||
data-pipeline:
|
||||
dependencies:
|
||||
|
|
@ -1084,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==}
|
||||
|
||||
|
|
@ -1482,14 +1497,26 @@ packages:
|
|||
ajv@6.14.0:
|
||||
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
|
||||
|
||||
ansi-escapes@7.3.0:
|
||||
resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-regex@6.2.2:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansis@4.2.0:
|
||||
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
|
||||
engines: {node: '>=14'}
|
||||
|
|
@ -1674,6 +1701,14 @@ packages:
|
|||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cli-truncate@5.2.0:
|
||||
resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
cliui@8.0.1:
|
||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -1693,10 +1728,17 @@ packages:
|
|||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
commander@14.0.3:
|
||||
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
component-emitter@1.3.1:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
|
||||
|
|
@ -1909,6 +1951,9 @@ packages:
|
|||
electron-to-chromium@1.5.321:
|
||||
resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==}
|
||||
|
||||
emoji-regex@10.6.0:
|
||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
|
|
@ -1927,6 +1972,10 @@ packages:
|
|||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
environment@1.1.0:
|
||||
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -2043,6 +2092,9 @@ packages:
|
|||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -2073,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'}
|
||||
|
|
@ -2147,6 +2202,10 @@ packages:
|
|||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-east-asian-width@1.5.0:
|
||||
resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -2218,6 +2277,11 @@ packages:
|
|||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
husky@9.1.7:
|
||||
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -2263,6 +2327,10 @@ packages:
|
|||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-fullwidth-code-point@5.1.0:
|
||||
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
is-glob@4.0.3:
|
||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -2422,10 +2490,23 @@ packages:
|
|||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
lint-staged@16.4.0:
|
||||
resolution: {integrity: sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==}
|
||||
engines: {node: '>=20.17'}
|
||||
hasBin: true
|
||||
|
||||
listr2@9.0.5:
|
||||
resolution: {integrity: sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
log-update@6.1.0:
|
||||
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
lru-cache@11.2.7:
|
||||
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
|
@ -2483,6 +2564,10 @@ packages:
|
|||
engines: {node: '>=4.0.0'}
|
||||
hasBin: true
|
||||
|
||||
mimic-function@5.0.1:
|
||||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
mimic-response@3.1.0:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -2548,6 +2633,10 @@ packages:
|
|||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
onetime@7.0.0:
|
||||
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -2626,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}
|
||||
|
|
@ -2717,9 +2809,25 @@ 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==}
|
||||
|
||||
restore-cursor@5.1.0:
|
||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rolldown@1.0.0-rc.10:
|
||||
resolution: {integrity: sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -2812,12 +2920,30 @@ packages:
|
|||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
signal-exit@4.1.0:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
simple-concat@1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
|
||||
simple-get@4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
slice-ansi@8.0.0:
|
||||
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'}
|
||||
|
|
@ -2844,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'}
|
||||
|
|
@ -2851,10 +2980,22 @@ packages:
|
|||
std-env@4.0.0:
|
||||
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
|
||||
|
||||
string-argv@0.3.2:
|
||||
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
|
||||
engines: {node: '>=0.6.19'}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string-width@7.2.0:
|
||||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
string-width@8.2.1:
|
||||
resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
|
|
@ -2862,6 +3003,10 @@ packages:
|
|||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-json-comments@2.0.1:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -2882,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==}
|
||||
|
||||
|
|
@ -3019,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'}
|
||||
|
|
@ -3146,6 +3299,10 @@ packages:
|
|||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
|
|
@ -3184,6 +3341,11 @@ packages:
|
|||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yaml@2.8.3:
|
||||
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
|
||||
engines: {node: '>= 14.6'}
|
||||
hasBin: true
|
||||
|
||||
yargs-parser@21.1.1:
|
||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -3816,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':
|
||||
|
|
@ -3879,12 +4043,12 @@ snapshots:
|
|||
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2
|
||||
|
||||
'@tailwindcss/vite@4.2.2(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
|
||||
'@tailwindcss/vite@4.2.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))':
|
||||
dependencies:
|
||||
'@tailwindcss/node': 4.2.2
|
||||
'@tailwindcss/oxide': 4.2.2
|
||||
tailwindcss: 4.2.2
|
||||
vite: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
|
||||
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)
|
||||
|
||||
'@tanstack/eslint-plugin-router@1.161.6(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
|
|
@ -3956,7 +4120,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@tanstack/router-plugin@1.167.2(@tanstack/react-router@1.168.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
|
||||
'@tanstack/router-plugin@1.167.2(@tanstack/react-router@1.168.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(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))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
|
||||
|
|
@ -3973,7 +4137,7 @@ snapshots:
|
|||
zod: 3.25.76
|
||||
optionalDependencies:
|
||||
'@tanstack/react-router': 1.168.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
vite: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
|
||||
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)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -4192,12 +4356,12 @@ snapshots:
|
|||
'@typescript-eslint/types': 8.57.1
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@vitejs/plugin-react@6.0.1(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
|
||||
'@vitejs/plugin-react@6.0.1(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))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.7
|
||||
vite: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
|
||||
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)
|
||||
|
||||
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)))':
|
||||
'@vitest/coverage-v8@4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.1.0
|
||||
|
|
@ -4209,7 +4373,7 @@ snapshots:
|
|||
obug: 2.1.1
|
||||
std-env: 4.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
|
||||
vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
'@vitest/expect@4.1.0':
|
||||
dependencies:
|
||||
|
|
@ -4220,22 +4384,22 @@ snapshots:
|
|||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.0(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
|
||||
'@vitest/mocker@4.1.0(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))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.0
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
|
||||
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)
|
||||
optional: true
|
||||
|
||||
'@vitest/mocker@4.1.0(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))':
|
||||
'@vitest/mocker@4.1.0(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.0
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
|
||||
vite: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
|
||||
'@vitest/pretty-format@4.1.0':
|
||||
dependencies:
|
||||
|
|
@ -4281,12 +4445,20 @@ snapshots:
|
|||
json-schema-traverse: 0.4.1
|
||||
uri-js: 4.4.1
|
||||
|
||||
ansi-escapes@7.3.0:
|
||||
dependencies:
|
||||
environment: 1.1.0
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
ansis@4.2.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
|
|
@ -4325,7 +4497,7 @@ snapshots:
|
|||
|
||||
baseline-browser-mapping@2.10.9: {}
|
||||
|
||||
better-auth@1.6.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.9.0)(kysely@0.28.16)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))):
|
||||
better-auth@1.6.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.9.0)(kysely@0.28.16)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(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))):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.2.0)
|
||||
'@better-auth/drizzle-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.9.0)(kysely@0.28.16)(pg@8.20.0))
|
||||
|
|
@ -4351,12 +4523,12 @@ snapshots:
|
|||
pg: 8.20.0
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
|
||||
vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(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))
|
||||
transitivePeerDependencies:
|
||||
- '@cloudflare/workers-types'
|
||||
- '@opentelemetry/api'
|
||||
|
||||
better-auth@1.6.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.9.0)(kysely@0.28.16)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))):
|
||||
better-auth@1.6.2(@opentelemetry/api@1.9.1)(better-sqlite3@12.9.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.9.0)(kysely@0.28.16)(pg@8.20.0))(pg@8.20.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))):
|
||||
dependencies:
|
||||
'@better-auth/core': 1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.2.0)
|
||||
'@better-auth/drizzle-adapter': 1.6.2(@better-auth/core@1.6.2(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.3.6))(jose@6.2.2)(kysely@0.28.16)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.9.0)(kysely@0.28.16)(pg@8.20.0))
|
||||
|
|
@ -4382,7 +4554,7 @@ snapshots:
|
|||
pg: 8.20.0
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
|
||||
vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
transitivePeerDependencies:
|
||||
- '@cloudflare/workers-types'
|
||||
- '@opentelemetry/api'
|
||||
|
|
@ -4494,6 +4666,15 @@ snapshots:
|
|||
|
||||
chownr@1.1.4: {}
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
dependencies:
|
||||
restore-cursor: 5.1.0
|
||||
|
||||
cli-truncate@5.2.0:
|
||||
dependencies:
|
||||
slice-ansi: 8.0.0
|
||||
string-width: 8.2.1
|
||||
|
||||
cliui@8.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
|
|
@ -4510,10 +4691,14 @@ snapshots:
|
|||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
commander@14.0.3: {}
|
||||
|
||||
component-emitter@1.3.1: {}
|
||||
|
||||
concurrently@9.2.1:
|
||||
|
|
@ -4623,6 +4808,8 @@ snapshots:
|
|||
|
||||
electron-to-chromium@1.5.321: {}
|
||||
|
||||
emoji-regex@10.6.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
encodeurl@2.0.0: {}
|
||||
|
|
@ -4638,6 +4825,8 @@ snapshots:
|
|||
|
||||
entities@6.0.1: {}
|
||||
|
||||
environment@1.1.0: {}
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
|
@ -4837,6 +5026,8 @@ snapshots:
|
|||
|
||||
etag@1.8.1: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
|
@ -4887,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
|
||||
|
|
@ -4955,6 +5148,8 @@ snapshots:
|
|||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-east-asian-width@1.5.0: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
|
|
@ -5031,6 +5226,8 @@ snapshots:
|
|||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
iconv-lite@0.7.2:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
|
@ -5059,6 +5256,10 @@ snapshots:
|
|||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-fullwidth-code-point@5.1.0:
|
||||
dependencies:
|
||||
get-east-asian-width: 1.5.0
|
||||
|
||||
is-glob@4.0.3:
|
||||
dependencies:
|
||||
is-extglob: 2.1.1
|
||||
|
|
@ -5190,10 +5391,36 @@ snapshots:
|
|||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
|
||||
lint-staged@16.4.0:
|
||||
dependencies:
|
||||
commander: 14.0.3
|
||||
listr2: 9.0.5
|
||||
picomatch: 4.0.3
|
||||
string-argv: 0.3.2
|
||||
tinyexec: 1.0.4
|
||||
yaml: 2.8.3
|
||||
|
||||
listr2@9.0.5:
|
||||
dependencies:
|
||||
cli-truncate: 5.2.0
|
||||
colorette: 2.0.20
|
||||
eventemitter3: 5.0.4
|
||||
log-update: 6.1.0
|
||||
rfdc: 1.4.1
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
|
||||
log-update@6.1.0:
|
||||
dependencies:
|
||||
ansi-escapes: 7.3.0
|
||||
cli-cursor: 5.0.0
|
||||
slice-ansi: 7.1.2
|
||||
strip-ansi: 7.2.0
|
||||
wrap-ansi: 9.0.2
|
||||
|
||||
lru-cache@11.2.7: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
|
|
@ -5238,6 +5465,8 @@ snapshots:
|
|||
|
||||
mime@2.6.0: {}
|
||||
|
||||
mimic-function@5.0.1: {}
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
||||
minimatch@10.2.4:
|
||||
|
|
@ -5282,6 +5511,10 @@ snapshots:
|
|||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
onetime@7.0.0:
|
||||
dependencies:
|
||||
mimic-function: 5.0.1
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
|
@ -5354,6 +5587,8 @@ snapshots:
|
|||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
postal-mime@2.7.4: {}
|
||||
|
||||
postcss@8.5.8:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
|
|
@ -5450,8 +5685,20 @@ 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:
|
||||
dependencies:
|
||||
onetime: 7.0.0
|
||||
signal-exit: 4.1.0
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rolldown@1.0.0-rc.10:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.120.0
|
||||
|
|
@ -5576,6 +5823,8 @@ snapshots:
|
|||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-concat@1.0.1: {}
|
||||
|
||||
simple-get@4.0.1:
|
||||
|
|
@ -5584,6 +5833,21 @@ snapshots:
|
|||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
is-fullwidth-code-point: 5.1.0
|
||||
|
||||
slice-ansi@8.0.0:
|
||||
dependencies:
|
||||
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:
|
||||
|
|
@ -5603,16 +5867,34 @@ 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: {}
|
||||
|
||||
string-argv@0.3.2: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string-width@7.2.0:
|
||||
dependencies:
|
||||
emoji-regex: 10.6.0
|
||||
get-east-asian-width: 1.5.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
string-width@8.2.1:
|
||||
dependencies:
|
||||
get-east-asian-width: 1.5.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
|
@ -5621,6 +5903,10 @@ snapshots:
|
|||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
superagent@10.3.0:
|
||||
|
|
@ -5653,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: {}
|
||||
|
|
@ -5783,9 +6074,11 @@ 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):
|
||||
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):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.3
|
||||
|
|
@ -5798,8 +6091,9 @@ snapshots:
|
|||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.3
|
||||
|
||||
vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0):
|
||||
vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.3
|
||||
|
|
@ -5812,11 +6106,12 @@ snapshots:
|
|||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.3
|
||||
|
||||
vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)):
|
||||
vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(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)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.0
|
||||
'@vitest/mocker': 4.1.0(vite@8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
|
||||
'@vitest/mocker': 4.1.0(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))
|
||||
'@vitest/pretty-format': 4.1.0
|
||||
'@vitest/runner': 4.1.0
|
||||
'@vitest/snapshot': 4.1.0
|
||||
|
|
@ -5833,7 +6128,7 @@ snapshots:
|
|||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.0.1(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
|
||||
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)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
|
@ -5843,10 +6138,10 @@ snapshots:
|
|||
- msw
|
||||
optional: true
|
||||
|
||||
vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)):
|
||||
vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.2.0))(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.0
|
||||
'@vitest/mocker': 4.1.0(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0))
|
||||
'@vitest/mocker': 4.1.0(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
'@vitest/pretty-format': 4.1.0
|
||||
'@vitest/runner': 4.1.0
|
||||
'@vitest/snapshot': 4.1.0
|
||||
|
|
@ -5863,7 +6158,7 @@ snapshots:
|
|||
tinyexec: 1.0.4
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)
|
||||
vite: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
|
@ -5911,6 +6206,12 @@ snapshots:
|
|||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrap-ansi@9.0.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.20.0: {}
|
||||
|
|
@ -5935,6 +6236,8 @@ snapshots:
|
|||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yaml@2.8.3: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
yargs@17.7.2:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue