Compare commits

..

68 commits

Author SHA1 Message Date
lila
6539d3e346 fix: prevent deployment when quality checks fail
All checks were successful
Build and Deploy / quality (push) Successful in 1m47s
Build and Deploy / build-and-deploy (push) Successful in 1m0s
2026-05-02 13:22:35 +02:00
lila
531da98c24 fix: initialize Resend lazily to prevent test failures when API key is absent
All checks were successful
Build and Deploy / quality (push) Successful in 1m46s
Build and Deploy / build-and-deploy (push) Successful in 1m37s
2026-05-02 13:18:00 +02:00
lila
90b0890263 Merge branch 'dev'
Some checks failed
Build and Deploy / quality (push) Failing after 1m47s
Build and Deploy / build-and-deploy (push) Successful in 2m8s
2026-05-02 13:09:19 +02:00
lila
ccfd83d16c feat: email/password auth + email verification + password reset via Resend 2026-05-02 13:05:43 +02:00
lila
4ae2c568c6 fix: resolve ESLint config file ignores and project service coverage 2026-05-02 12:15:23 +02:00
lila
6ca6fc4e09 fix: correct dotenv path in packages/db/src/index.ts for compiled dist output 2026-05-02 11:23:10 +02:00
lila
e1c4fb5744 refactoring 2026-05-02 11:22:54 +02:00
lila
dc11213cb5 feat: replace login route with auth modal
- Add AuthModal to root layout driven by ?modal=auth search param
- Update multiplayer and play beforeLoad redirects to use modal
- Update NavAuth and Hero links to use modal
- Delete login route and NavLogin component
2026-04-30 19:46:45 +02:00
lila
32ee1edf80 feat: add AuthModal component with login, register and social tabs
- Add AuthModal with login/register tabs and social buttons
- Add forgot-password and reset-password routes
- Add Sonner toaster to root layout
- Add auth search schemas to @lila/shared
- Add ESLint overrides for TanStack Router generics
2026-04-30 19:38:43 +02:00
lila
6297dff399 feat: add email/password auth backend + forgot/reset password routes
- Configure Better Auth emailAndPassword plugin with Resend
- Add email verification and password reset email sending
- Create forgot-password and reset-password frontend routes
- Add auth schemas to @lila/shared
2026-04-30 18:30:20 +02:00
lila
690e1ab72e updating status
All checks were successful
Build and Deploy / quality (push) Successful in 1m41s
Build and Deploy / build-and-deploy (push) Successful in 1m1s
2026-04-30 02:17:35 +02:00
lila
349107fa6f revert: remove registry login from deploy step
All checks were successful
Build and Deploy / quality (push) Successful in 1m41s
Build and Deploy / build-and-deploy (push) Successful in 34s
2026-04-30 02:02:58 +02:00
lila
14d1837ee9 fix: login to registry in deploy step to bypass gpg passphrase prompt
Some checks failed
Build and Deploy / quality (push) Successful in 1m40s
Build and Deploy / build-and-deploy (push) Failing after 1m1s
2026-04-30 01:52:21 +02:00
lila
5f553930c2 chore: skip husky in production installs
Some checks failed
Build and Deploy / quality (push) Successful in 1m43s
Build and Deploy / build-and-deploy (push) Failing after 1m56s
2026-04-30 01:36:07 +02:00
lila
47a0becc6e chore: fix typecheck script to use per-package tsc --noEmit
Some checks failed
Build and Deploy / quality (push) Successful in 1m41s
Build and Deploy / build-and-deploy (push) Failing after 1m2s
2026-04-30 01:29:19 +02:00
lila
89e559a4db adding pnpm-store
Some checks failed
Build and Deploy / quality (push) Failing after 1m31s
Build and Deploy / build-and-deploy (push) Failing after 48s
2026-04-30 01:20:59 +02:00
lila
4f47e18ad9 formatting 2026-04-30 01:20:12 +02:00
lila
35e54014b3 chore: add husky pre-commit and pre-push hooks
Some checks failed
Build and Deploy / quality (push) Failing after 1m53s
Build and Deploy / build-and-deploy (push) Failing after 1m3s
2026-04-30 01:15:14 +02:00
lila
4d64d50598 removing comment that tested pre-commit/pre-push hook 2026-04-30 01:14:53 +02:00
lila
1bfc0606c3 test: verify pre-commit hook 2026-04-30 01:13:01 +02:00
lila
8a121442a3 adding missing variables 2026-04-30 00:38:08 +02:00
lila
57d2190549 adding task to prompts 2026-04-30 00:36:28 +02:00
lila
fd9667c1fd updating documentation
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 1m27s
2026-04-28 17:26:01 +02:00
lila
98c59f33c5 formatting + adding issues 2026-04-28 16:39:36 +02:00
lila
648c5d2979 fix: improve error semantics, clarify answer key type 2026-04-28 16:07:19 +02:00
lila
6eaf282651 fix: sanitise Zod validation error messages in game controller 2026-04-28 15:51:57 +02:00
lila
c081e632cf fix: explicit store update in evaluateAnswer, remove mutation through reference 2026-04-28 15:47:53 +02:00
lila
a02d3b3335 fix: deduplicate distractors against each other, guard thin distractor pool 2026-04-28 15:44:29 +02:00
lila
a02f3b139d fix: deduplicate distractors, replace tautological test, add distractor failure test 2026-04-28 15:17:31 +02:00
lila
3d16ab0fff feat: guard against empty terms in createGameSession 2026-04-28 15:08:06 +02:00
lila
1e30f04e81 feat: add ownership check to evaluateAnswer, AuthenticatedRequest type 2026-04-28 14:39:13 +02:00
lila
fdeb769640 feat: add TTL to GameSessionStore, replay protection and session cleanup to evaluateAnswer 2026-04-28 14:03:15 +02:00
lila
54705943fa adding ticket for refactor: dependency injection for GameSessionStore via composition root 2026-04-28 13:50:56 +02:00
lila
a4a4bfff57 refactor: dependency injection for GameSessionStore via composition root 2026-04-28 13:48:50 +02:00
lila
4f59f3bc14 formatting 2026-04-28 13:18:18 +02:00
lila
2ff7d1759e refactor: extract shuffleArray to lib/utils, rename correctAnswers to terms 2026-04-28 13:17:24 +02:00
lila
c46729f365 formatting 2026-04-28 12:32:44 +02:00
lila
02ccc88d24 fix: change GAME_ROUNDS from strings to numbers 2026-04-28 12:29:46 +02:00
lila
7d3c456efe formatting 2026-04-28 12:29:32 +02:00
lila
6b6a8aac3e adding roasts of gameservice 2026-04-27 17:47:05 +02:00
lila
fbb4e59274 adding ticket structure, finishing docker credential helper setup 2026-04-26 10:51:45 +02:00
lila
768ca24eb2 fix: remove unfrozen pnpm install from builder stages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
2026-04-26 09:03:23 +02:00
lila
091495c1db updating tasks 2026-04-26 08:46:54 +02:00
lila
0da8397940 feat: add root and route-level error boundaries 2026-04-26 08:45:18 +02:00
lila
e3d28e4127 updating issues 2026-04-24 18:34:43 +02:00
lila
4de2c40482 feat: add 404 catch-all route and NotFound page 2026-04-24 18:28:22 +02:00
lila
4fabde57bd adding prompt 2026-04-24 10:27:54 +02:00
lila
e9ba8d292d updating tasks 2026-04-24 10:21:06 +02:00
lila
3971642848 Merge branch 'dev'
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m51s
2026-04-24 10:15:23 +02:00
lila
ee719aaa58 test: add test file for multiplayerGameService
Covers generateMultiplayerQuestions: question count, option structure,
correct answer inclusion, correctOptionId integrity, prompt/gloss
passthrough, DB call arguments, and error propagation.
2026-04-24 10:14:28 +02:00
lila
4ece995385 test: fill coverage gaps in lobbyService and gameService
- joinLobby: addPlayer returns falsy (race condition fallback)
- joinLobby: lobby disappears between addPlayer and final fetch
- createLobby: non-unique-violation errors re-thrown immediately
- createGameSession: unexpected DB errors propagate correctly
2026-04-24 10:11:36 +02:00
lila
762cf91f86 updating tasks 2026-04-24 09:30:20 +02:00
lila
5b266d7435 adding task to test gameservice
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m13s
2026-04-24 09:15:59 +02:00
lila
ec84f76fb2 updating backlog
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
2026-04-23 23:32:30 +02:00
lila
59049002fc fix(api): skip rate limiting for non-sensitive auth endpoints
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m50s
The authLimiter was blocking legitimate users because Better Auth's
client polls /get-session frequently (on mount, route changes, focus),
and /sign-out was also getting blocked after repeated session polls.

Skip rate limiting for:
- /get-session — read-only, requires valid cookie, no attack surface
- /sign-out — no attack value in blocking logout
- /callback/* — OAuth callbacks from providers

Brute force protection remains on /sign-in, /sign-up, and other
sensitive endpoints.
2026-04-23 22:12:38 +02:00
lila
c57fc5a98b Merge branch 'dev'
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m31s
2026-04-23 21:46:01 +02:00
lila
76192667e0 feat(caddy): add security headers for frontend
Adds HSTS, CSP, X-Frame-Options, X-Content-Type-Options,
and Referrer-Policy to lilastudy.com responses.

CSP allows connect-src to api.lilastudy.com over HTTPS and
wss:// for WebSocket multiplayer. Tailwind's inline styles
require style-src 'unsafe-inline'.
2026-04-23 21:45:35 +02:00
lila
9ab2bc3d0e feat(api): apply rate limiters to game and lobby routes
Wire gameLimiter into gameRouter and lobbyLimiter into lobbyRouter.
Both run after requireAuth since they key by req.session.user.id.
2026-04-23 20:36:36 +02:00
lila
e6f4a39dad adding task 2026-04-23 20:32:16 +02:00
lila
9893ead689 feat(api): add helmet security headers and rate limiting
- Add helmet middleware for secure HTTP response headers
- Add express-rate-limit with three limiters:
  - authLimiter: per-IP, 20 req/15min on /api/auth/*
  - gameLimiter: per-user, 150 req/15min (not yet wired)
  - lobbyLimiter: per-user, 20 req/15min (not yet wired)
- Set trust proxy for correct client IP behind Caddy
- Add tests for all three limiters and helmet headers
2026-04-23 11:13:11 +02:00
lila
1dfe391233 adding task 2026-04-23 11:12:57 +02:00
lila
4623ea634a updating documentatin 2026-04-23 10:40:34 +02:00
lila
cc0d2c7f8f removing dummy table for db migration pipeline test
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m48s
2026-04-23 09:39:18 +02:00
lila
d67263e44a updating file path
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m11s
2026-04-23 09:33:11 +02:00
lila
2328ad445d updating pnpm 2026-04-23 09:32:27 +02:00
lila
1a50f73c74 updated docker pipeline to include database migrations, added dummy table to verify the pipeline works
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m52s
2026-04-23 09:19:57 +02:00
lila
66eddb9a2a creating backlog with issues 2026-04-22 21:09:24 +02:00
lila
9a3376cdcc updating docs 2026-04-21 15:40:26 +02:00
89 changed files with 6570 additions and 3368 deletions

View file

@ -1,4 +1,5 @@
DATABASE_URL=postgres://postgres:mypassword@db-host:5432/databasename DATABASE_URL=postgres://postgres:mypassword@db-host:5432/databasename
DATABASE_URL_LOCAL=postgres://postgres:mypassword@localhost:5432/databasename
POSTGRES_USER=postgres POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
@ -10,3 +11,11 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID= GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET= GITHUB_CLIENT_SECRET=
VITE_WS_URL=
UID=1000
GID=1000
RESEND_API_KEY=
EMAIL_FROM=mail@example.com

View file

@ -5,8 +5,31 @@ on:
branches: [main] branches: [main]
jobs: 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: build-and-deploy:
runs-on: docker runs-on: docker
needs: quality
steps: steps:
- name: Install tools - name: Install tools
run: apt-get update && apt-get install -y docker.io openssh-client run: apt-get update && apt-get install -y docker.io openssh-client

1
.husky/pre-commit Executable file
View file

@ -0,0 +1 @@
pnpm lint-staged

1
.husky/pre-push Executable file
View file

@ -0,0 +1 @@
pnpm test:run

View file

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

View file

@ -1,4 +1,11 @@
lilastudy.com { lilastudy.com {
header {
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Strict-Transport-Security "max-age=31536000; includeSubDomains"
Content-Security-Policy "default-src 'self'; connect-src 'self' https://api.lilastudy.com wss://api.lilastudy.com; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'"
}
reverse_proxy web:80 reverse_proxy web:80
} }

View file

@ -11,7 +11,7 @@ Live at [lilastudy.com](https://lilastudy.com).
## Stack ## Stack
| Layer | Technology | | Layer | Technology |
|---|---| | ------------ | ---------------------------------- |
| Monorepo | pnpm workspaces | | Monorepo | pnpm workspaces |
| Frontend | React 18, Vite, TypeScript | | Frontend | React 18, Vite, TypeScript |
| Routing | TanStack Router | | Routing | TanStack Router |
@ -30,7 +30,7 @@ Live at [lilastudy.com](https://lilastudy.com).
## Repository Structure ## Repository Structure
``` ```tree
lila/ lila/
├── apps/ ├── apps/
│ ├── api/ — Express backend │ ├── api/ — Express backend
@ -50,7 +50,7 @@ lila/
Requests flow through a strict layered architecture: Requests flow through a strict layered architecture:
``` ```text
HTTP Request → Router → Controller → Service → Model → Database HTTP Request → Router → Controller → Service → Model → Database
``` ```
@ -71,7 +71,7 @@ Vocabulary data is sourced from WordNet and the Open Multilingual Wordnet (OMW).
## API ## API
``` ```text
POST /api/v1/game/start — start a quiz session (auth required) POST /api/v1/game/start — start a quiz session (auth required)
POST /api/v1/game/answer — submit an answer (auth required) POST /api/v1/game/answer — submit an answer (auth required)
GET /api/v1/health — health check (public) GET /api/v1/health — health check (public)
@ -90,7 +90,7 @@ Rooms are created via REST, then managed over WebSockets. Messages are typed via
## Infrastructure ## Infrastructure
``` ```tree
Internet → Caddy (HTTPS) Internet → Caddy (HTTPS)
├── lilastudy.com → web (nginx, static files) ├── lilastudy.com → web (nginx, static files)
├── api.lilastudy.com → api (Express) ├── api.lilastudy.com → api (Express)
@ -157,7 +157,7 @@ pnpm --filter web test
## Roadmap ## Roadmap
| Phase | Description | Status | | Phase | Description | Status |
|---|---|---| | ----- | ---------------------------------------------------------------------- | ------ |
| 0 | Foundation — monorepo, tooling, dev environment | ✅ | | 0 | Foundation — monorepo, tooling, dev environment | ✅ |
| 1 | Vocabulary data pipeline + REST API | ✅ | | 1 | Vocabulary data pipeline + REST API | ✅ |
| 2 | Singleplayer quiz UI | ✅ | | 2 | Singleplayer quiz UI | ✅ |

View file

@ -22,9 +22,12 @@ CMD ["pnpm", "--filter", "api", "dev"]
# 4. build # 4. build
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY packages/shared/package.json ./packages/shared/
COPY packages/db/package.json ./packages/db/
RUN pnpm install --frozen-lockfile
COPY . . COPY . .
RUN pnpm install
RUN pnpm --filter shared build RUN pnpm --filter shared build
RUN pnpm --filter db build RUN pnpm --filter db build
RUN pnpm --filter api build RUN pnpm --filter api build
@ -39,6 +42,7 @@ COPY packages/db/package.json ./packages/db/
COPY --from=builder /app/apps/api/dist ./apps/api/dist COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/packages/db/dist ./packages/db/dist COPY --from=builder /app/packages/db/dist ./packages/db/dist
COPY --from=builder /app/packages/db/drizzle ./packages/db/drizzle
RUN pnpm install --frozen-lockfile --prod RUN pnpm install --frozen-lockfile --prod
EXPOSE 3000 EXPOSE 3000
CMD ["node", "apps/api/dist/src/server.js"] CMD ["sh", "-c", "node packages/db/dist/src/migrate.js && node apps/api/dist/src/server.js"]

View file

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

39
apps/api/src/app.test.ts Normal file
View file

@ -0,0 +1,39 @@
import request from "supertest";
import { describe, it, expect } from "vitest";
import { createApp } from "./app.js";
const app = createApp();
describe("security headers (helmet)", () => {
it("sets X-Content-Type-Options to nosniff", async () => {
const res = await request(app).get("/api/v1/health");
expect(res.headers["x-content-type-options"]).toBe("nosniff");
});
it("sets X-Frame-Options to SAMEORIGIN", async () => {
const res = await request(app).get("/api/v1/health");
expect(res.headers["x-frame-options"]).toBe("SAMEORIGIN");
});
it("removes X-Powered-By header", async () => {
const res = await request(app).get("/api/v1/health");
expect(res.headers).not.toHaveProperty("x-powered-by");
});
it("sets Content-Security-Policy", async () => {
const res = await request(app).get("/api/v1/health");
expect(res.headers).toHaveProperty("content-security-policy");
});
});
describe("auth rate limiting", () => {
it("returns 429 after exceeding the auth limit", async () => {
const testApp = createApp();
const limit = 20;
for (let i = 0; i < limit; i++) {
await request(testApp).post("/api/auth/sign-in");
}
const res = await request(testApp).post("/api/auth/sign-in");
expect(res.status).toBe(429);
});
});

View file

@ -1,23 +1,33 @@
import express from "express"; import express from "express";
import type { Express } from "express"; import type { Express } from "express";
import { toNodeHandler } from "better-auth/node"; import { toNodeHandler } from "better-auth/node";
import { auth } from "./lib/auth.js";
import { apiRouter } from "./routes/apiRouter.js";
import { errorHandler } from "./middleware/errorHandler.js";
import cors from "cors"; import cors from "cors";
import helmet from "helmet";
import { auth } from "./lib/auth.js";
import { createApiRouter } from "./routes/apiRouter.js";
import { InMemoryGameSessionStore } from "./gameSessionStore/index.js";
import { errorHandler } from "./middleware/errorHandler.js";
import { authLimiter } from "./middleware/rateLimiters.js";
export function createApp() { export function createApp() {
const app: Express = express(); const app: Express = express();
app.set("trust proxy", 1);
app.use(helmet());
app.use( app.use(
cors({ cors({
origin: process.env["CORS_ORIGIN"] || "http://localhost:5173", origin: process.env["CORS_ORIGIN"] || "http://localhost:5173",
credentials: true, credentials: true,
}), }),
); );
app.use("/api/auth", authLimiter);
app.all("/api/auth/*splat", toNodeHandler(auth)); app.all("/api/auth/*splat", toNodeHandler(auth));
app.use(express.json()); app.use(express.json());
app.use("/api/v1", apiRouter);
const store = new InMemoryGameSessionStore();
app.use("/api/v1", createApiRouter(store));
app.use(errorHandler); app.use(errorHandler);
return app; return app;

View file

@ -60,7 +60,7 @@ const validBody = {
target_language: "it", target_language: "it",
pos: "noun", pos: "noun",
difficulty: "easy", difficulty: "easy",
rounds: "3", rounds: 3,
}; };
const fakeTerms = [ const fakeTerms = [
@ -110,6 +110,26 @@ describe("POST /api/v1/game/start", () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
it("returns 422 when no terms are found for the given filters", async () => {
mockGetGameTerms.mockResolvedValue([]);
const res = await request(app).post("/api/v1/game/start").send(validBody);
const body = res.body as ErrorResponse;
expect(res.status).toBe(422);
expect(body.success).toBe(false);
});
it("returns a sanitised error message when the body is invalid", async () => {
const res = await request(app)
.post("/api/v1/game/start")
.send({ ...validBody, difficulty: "impossible" });
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(body.error).toBe("Invalid game settings");
expect(body.error).not.toContain("Invalid literal value");
expect(body.error).not.toContain("Invalid enum value");
});
}); });
describe("POST /api/v1/game/answer", () => { describe("POST /api/v1/game/answer", () => {
@ -158,7 +178,7 @@ describe("POST /api/v1/game/answer", () => {
expect(body.error).toContain("Game session not found"); expect(body.error).toContain("Game session not found");
}); });
it("returns 404 when the question does not exist in the session", async () => { it("returns 409 when the question does not exist in the session", async () => {
const startRes = await request(app) const startRes = await request(app)
.post("/api/v1/game/start") .post("/api/v1/game/start")
.send(validBody); .send(validBody);
@ -173,8 +193,26 @@ describe("POST /api/v1/game/answer", () => {
selectedOptionId: 0, selectedOptionId: 0,
}); });
const body = res.body as ErrorResponse; const body = res.body as ErrorResponse;
expect(res.status).toBe(404); expect(res.status).toBe(409);
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")
.send({ ...validBody, difficulty: "impossible" });
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(body.success).toBe(false);
});
it("returns 400 when rounds has an invalid value", async () => {
const res = await request(app)
.post("/api/v1/game/start")
.send({ ...validBody, rounds: "invalid" });
const body = res.body as ErrorResponse;
expect(res.status).toBe(400);
expect(body.success).toBe(false); expect(body.success).toBe(false);
expect(body.error).toContain("Question not found");
}); });
}); });

View file

@ -1,42 +1,50 @@
import type { Request, Response, NextFunction } from "express"; import type { Response, NextFunction } from "express";
import type { AuthenticatedRequest } from "../types/express.js";
import { GameRequestSchema, AnswerSubmissionSchema } from "@lila/shared"; import { GameRequestSchema, AnswerSubmissionSchema } from "@lila/shared";
import { createGameSession, evaluateAnswer } from "../services/gameService.js"; import { createGameSession, evaluateAnswer } from "../services/gameService.js";
import { ValidationError } from "../errors/AppError.js"; import { ValidationError } from "../errors/AppError.js";
import type { GameSessionStore } from "../gameSessionStore/index.js";
export const createGame = async ( export const createGameController = (store: GameSessionStore) => ({
req: Request, createGame: async (
req: AuthenticatedRequest,
res: Response, res: Response,
next: NextFunction, next: NextFunction,
) => { ) => {
try { try {
const gameSettings = GameRequestSchema.safeParse(req.body); const gameSettings = GameRequestSchema.safeParse(req.body);
if (!gameSettings.success) { if (!gameSettings.success) {
throw new ValidationError(gameSettings.error.message); throw new ValidationError("Invalid game settings");
} }
const gameQuestions = await createGameSession(
const gameQuestions = await createGameSession(gameSettings.data); gameSettings.data,
store,
req.session.user.id,
);
res.json({ success: true, data: gameQuestions }); res.json({ success: true, data: gameQuestions });
} catch (error) { } catch (error) {
next(error); next(error);
} }
}; },
export const submitAnswer = async ( submitAnswer: async (
req: Request, req: AuthenticatedRequest,
res: Response, res: Response,
next: NextFunction, next: NextFunction,
) => { ) => {
try { try {
const submission = AnswerSubmissionSchema.safeParse(req.body); const submission = AnswerSubmissionSchema.safeParse(req.body);
if (!submission.success) { if (!submission.success) {
throw new ValidationError(submission.error.message); throw new ValidationError("Invalid answer submission");
} }
const result = await evaluateAnswer(
const result = await evaluateAnswer(submission.data); submission.data,
store,
req.session.user.id,
);
res.json({ success: true, data: result }); res.json({ success: true, data: result });
} catch (error) { } catch (error) {
next(error); next(error);
} }
}; },
});

View file

@ -25,3 +25,9 @@ export class ConflictError extends AppError {
super(message, 409); super(message, 409);
} }
} }
export class UnprocessableEntityError extends AppError {
constructor(message: string) {
super(message, 422);
}
}

View file

@ -1,7 +1,15 @@
export type GameSessionData = { answers: Map<string, number> }; export type GameSessionData = {
answers: Map<string, { correctOptionId: number }>;
userId: string;
};
export interface GameSessionStore { export interface GameSessionStore {
create(sessionId: string, data: GameSessionData): Promise<void>; create(
sessionId: string,
data: GameSessionData,
ttlMs: number,
): Promise<void>;
get(sessionId: string): Promise<GameSessionData | null>; get(sessionId: string): Promise<GameSessionData | null>;
update(sessionId: string, data: GameSessionData): Promise<void>;
delete(sessionId: string): Promise<void>; delete(sessionId: string): Promise<void>;
} }

View file

@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach } from "vitest";
import { InMemoryGameSessionStore } from "./InMemoryGameSessionStore.js";
describe("InMemoryGameSessionStore", () => {
let store: InMemoryGameSessionStore;
beforeEach(() => {
store = new InMemoryGameSessionStore();
});
it("returns null for a non-existent session", async () => {
const result = await store.get("00000000-0000-0000-0000-000000000000");
expect(result).toBeNull();
});
it("returns session data after creation", async () => {
const data = {
answers: new Map([["q1", { correctOptionId: 2 }]]),
userId: "user-1",
};
await store.create("session-1", data, 60_000);
const result = await store.get("session-1");
expect(result).toEqual(data);
});
it("returns null after the session is deleted", async () => {
const data = {
answers: new Map([["q1", { correctOptionId: 2 }]]),
userId: "user-1",
};
await store.create("session-1", data, 60_000);
await store.delete("session-1");
const result = await store.get("session-1");
expect(result).toBeNull();
});
it("returns null after TTL expires", async () => {
const data = {
answers: new Map([["q1", { correctOptionId: 2 }]]),
userId: "user-1",
};
await store.create("session-1", data, 1);
await new Promise((resolve) => setTimeout(resolve, 10));
const result = await store.get("session-1");
expect(result).toBeNull();
});
it("returns session data before TTL expires", async () => {
const data = {
answers: new Map([["q1", { correctOptionId: 2 }]]),
userId: "user-1",
};
await store.create("session-1", data, 60_000);
const result = await store.get("session-1");
expect(result).not.toBeNull();
});
it("update persists modified session data", async () => {
const data = {
answers: new Map([["q1", { correctOptionId: 2 }]]),
userId: "user-1",
};
await store.create("session-1", data, 60_000);
const updated = {
answers: new Map([["q2", { correctOptionId: 1 }]]),
userId: "user-1",
};
await store.update("session-1", updated);
const result = await store.get("session-1");
expect(result).toEqual(updated);
});
});

View file

@ -1,15 +1,34 @@
import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js"; import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js";
export class InMemoryGameSessionStore implements GameSessionStore { type SessionEntry = { data: GameSessionData; expiresAt: number };
private sessions = new Map<string, GameSessionData>();
create(sessionId: string, data: GameSessionData): Promise<void> { export class InMemoryGameSessionStore implements GameSessionStore {
this.sessions.set(sessionId, data); private sessions = new Map<string, SessionEntry>();
create(
sessionId: string,
data: GameSessionData,
ttlMs: number,
): Promise<void> {
this.sessions.set(sessionId, { data, expiresAt: Date.now() + ttlMs });
return Promise.resolve(); return Promise.resolve();
} }
get(sessionId: string): Promise<GameSessionData | null> { get(sessionId: string): Promise<GameSessionData | null> {
return Promise.resolve(this.sessions.get(sessionId) ?? null); const entry = this.sessions.get(sessionId);
if (!entry) return Promise.resolve(null);
if (Date.now() > entry.expiresAt) {
this.sessions.delete(sessionId);
return Promise.resolve(null);
}
return Promise.resolve(entry.data);
}
update(sessionId: string, data: GameSessionData): Promise<void> {
const entry = this.sessions.get(sessionId);
if (!entry) return Promise.resolve();
this.sessions.set(sessionId, { data, expiresAt: entry.expiresAt });
return Promise.resolve();
} }
delete(sessionId: string): Promise<void> { delete(sessionId: string): Promise<void> {

View file

@ -1,8 +1,11 @@
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { Resend } from "resend";
import { db } from "@lila/db"; import { db } from "@lila/db";
import * as schema from "@lila/db/schema"; import * as schema from "@lila/db/schema";
const emailFrom = process.env["EMAIL_FROM"] ?? "noreply@lilastudy.com";
export const auth = betterAuth({ export const auth = betterAuth({
baseURL: process.env["BETTER_AUTH_URL"] || "http://localhost:3000", baseURL: process.env["BETTER_AUTH_URL"] || "http://localhost:3000",
advanced: { advanced: {
@ -16,6 +19,44 @@ export const auth = betterAuth({
}, },
}, },
database: drizzleAdapter(db, { provider: "pg", schema }), 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"], trustedOrigins: [process.env["CORS_ORIGIN"] || "http://localhost:5173"],
socialProviders: { socialProviders: {
google: { google: {

10
apps/api/src/lib/utils.ts Normal file
View file

@ -0,0 +1,10 @@
export const shuffleArray = <T>(array: T[]): T[] => {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const temp = result[i]!;
result[i] = result[j]!;
result[j] = temp;
}
return result;
};

View file

@ -0,0 +1,200 @@
import express from "express";
import request from "supertest";
import { describe, it, expect, beforeEach } from "vitest";
import { authLimiter, gameLimiter, lobbyLimiter } from "./rateLimiters.js";
import type { Session, User } from "better-auth";
function createTestApp() {
const app = express();
app.set("trust proxy", 1);
app.use("/api/auth", authLimiter);
app.all("/api/auth/*splat", (_req, res) => {
res.status(200).json({ success: true });
});
return app;
}
describe("authLimiter", () => {
let app: ReturnType<typeof createTestApp>;
beforeEach(() => {
app = createTestApp();
});
it("allows requests under the limit through on sensitive endpoints", async () => {
const res = await request(app).post("/api/auth/sign-in");
expect(res.status).toBe(200);
});
it("returns 429 after exceeding the limit on sensitive endpoints", async () => {
const limit = 20;
for (let i = 0; i < limit; i++) {
await request(app).post("/api/auth/sign-in");
}
const res = await request(app).post("/api/auth/sign-in");
expect(res.status).toBe(429);
});
it("does not rate limit /get-session", async () => {
const limit = 20;
for (let i = 0; i < limit + 5; i++) {
await request(app).get("/api/auth/get-session");
}
const res = await request(app).get("/api/auth/get-session");
expect(res.status).toBe(200);
});
it("does not rate limit /sign-out", async () => {
const limit = 20;
for (let i = 0; i < limit + 5; i++) {
await request(app).post("/api/auth/sign-out");
}
const res = await request(app).post("/api/auth/sign-out");
expect(res.status).toBe(200);
});
it("does not rate limit OAuth callbacks", async () => {
const limit = 20;
for (let i = 0; i < limit + 5; i++) {
await request(app).get("/api/auth/callback/google");
}
const res = await request(app).get("/api/auth/callback/google");
expect(res.status).toBe(200);
});
it("sets RateLimit headers on sensitive responses", async () => {
const res = await request(app).post("/api/auth/sign-in");
expect(res.headers).toHaveProperty("ratelimit");
});
});
function fakeAuth(userId: string) {
return (
req: express.Request,
_res: express.Response,
next: express.NextFunction,
) => {
req.session = { session: {} as Session, user: { id: userId } as User };
next();
};
}
function createGameTestApp(userId = "user-1") {
const app = express();
app.set("trust proxy", 1);
app.use(fakeAuth(userId));
app.use(gameLimiter);
app.post("/game/start", (_req, res) =>
res.status(200).json({ success: true }),
);
app.post("/game/answer", (_req, res) =>
res.status(200).json({ success: true }),
);
return app;
}
describe("gameLimiter", () => {
it("allows requests under the limit through", async () => {
const app = createGameTestApp();
const res = await request(app).post("/game/start");
expect(res.status).toBe(200);
});
it("returns 429 after exceeding the limit", async () => {
const app = createGameTestApp();
const limit = 150;
for (let i = 0; i < limit; i++) {
await request(app).post("/game/answer");
}
const res = await request(app).post("/game/answer");
expect(res.status).toBe(429);
expect(res.body).toEqual({
success: false,
error: "Too many requests, please try again later.",
});
});
it("tracks limits per user, not per IP", async () => {
const app = express();
app.set("trust proxy", 1);
// Two routes, same limiter, different users
app.use("/user1", fakeAuth("user-1"), gameLimiter, (_req, res) =>
res.status(200).json({ success: true }),
);
app.use("/user2", fakeAuth("user-2"), gameLimiter, (_req, res) =>
res.status(200).json({ success: true }),
);
const limit = 150;
for (let i = 0; i < limit; i++) {
await request(app).post("/user1");
}
// user-1 is exhausted
const blocked = await request(app).post("/user1");
expect(blocked.status).toBe(429);
// user-2 is unaffected
const allowed = await request(app).post("/user2");
expect(allowed.status).toBe(200);
});
});
function createLobbyTestApp(userId = "user-1") {
const app = express();
app.set("trust proxy", 1);
app.use(fakeAuth(userId));
app.use(lobbyLimiter);
app.post("/lobbies", (_req, res) => res.status(200).json({ success: true }));
app.post("/lobbies/:code/join", (_req, res) =>
res.status(200).json({ success: true }),
);
return app;
}
describe("lobbyLimiter", () => {
it("allows requests under the limit through", async () => {
const app = createLobbyTestApp();
const res = await request(app).post("/lobbies");
expect(res.status).toBe(200);
});
it("returns 429 after exceeding the limit", async () => {
const app = createLobbyTestApp();
const limit = 20;
for (let i = 0; i < limit; i++) {
await request(app).post("/lobbies");
}
const res = await request(app).post("/lobbies");
expect(res.status).toBe(429);
expect(res.body).toEqual({
success: false,
error: "Too many requests, please try again later.",
});
});
it("tracks limits per user, not per IP", async () => {
const app = express();
app.set("trust proxy", 1);
app.use("/user1", fakeAuth("user-1"), lobbyLimiter, (_req, res) =>
res.status(200).json({ success: true }),
);
app.use("/user2", fakeAuth("user-2"), lobbyLimiter, (_req, res) =>
res.status(200).json({ success: true }),
);
const limit = 20;
for (let i = 0; i < limit; i++) {
await request(app).post("/user1");
}
const blocked = await request(app).post("/user1");
expect(blocked.status).toBe(429);
const allowed = await request(app).post("/user2");
expect(allowed.status).toBe(200);
});
});

View file

@ -0,0 +1,53 @@
import rateLimit from "express-rate-limit";
import type { Request } from "express";
// TODO: When Valkey is wired up, swap the default in-memory store for
// rate-limit-redis to persist limits across restarts:
//
// import { RedisStore } from "rate-limit-redis";
// import { valkey } from "../lib/valkey.js";
// Then add to each limiter: store: new RedisStore({ sendCommand: (...args) => valkey.call(...args) })
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 20,
standardHeaders: "draft-8",
legacyHeaders: false,
skip: (req) => {
const path = req.path;
return (
path.includes("/get-session") ||
path.includes("/sign-out") ||
path.startsWith("/callback/") ||
path.includes("/callback/")
);
},
message: {
success: false,
error: "Too many requests, please try again later.",
},
});
export const gameLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 150,
standardHeaders: "draft-8",
legacyHeaders: false,
keyGenerator: (req: Request) => req.session!.user.id,
message: {
success: false,
error: "Too many requests, please try again later.",
},
});
export const lobbyLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 20,
standardHeaders: "draft-8",
legacyHeaders: false,
keyGenerator: (req: Request) => req.session!.user.id,
message: {
success: false,
error: "Too many requests, please try again later.",
},
});

View file

@ -1,11 +1,16 @@
import express from "express"; import express from "express";
import { Router } from "express"; import type { Router } from "express";
import { healthRouter } from "./healthRouter.js"; import { healthRouter } from "./healthRouter.js";
import { gameRouter } from "./gameRouter.js"; import { createGameRouter } from "./gameRouter.js";
import { lobbyRouter } from "./lobbyRouter.js"; import { lobbyRouter } from "./lobbyRouter.js";
import type { GameSessionStore } from "../gameSessionStore/index.js";
export const apiRouter: Router = express.Router(); export const createApiRouter = (store: GameSessionStore): Router => {
const router = express.Router();
apiRouter.use("/health", healthRouter); router.use("/health", healthRouter);
apiRouter.use("/game", gameRouter); router.use("/game", createGameRouter(store));
apiRouter.use("/lobbies", lobbyRouter); router.use("/lobbies", lobbyRouter);
return router;
};

View file

@ -1,10 +1,19 @@
import express from "express"; import express from "express";
import type { Router } from "express"; import type { Router } from "express";
import { createGame, submitAnswer } from "../controllers/gameController.js"; import { createGameController } from "../controllers/gameController.js";
import { requireAuth } from "../middleware/authMiddleware.js"; import { requireAuth } from "../middleware/authMiddleware.js";
import { gameLimiter } from "../middleware/rateLimiters.js";
import type { GameSessionStore } from "../gameSessionStore/index.js";
export const gameRouter: Router = express.Router(); export const createGameRouter = (store: GameSessionStore): Router => {
const router = express.Router();
const controller = createGameController(store);
gameRouter.use(requireAuth); router.use(requireAuth);
gameRouter.post("/start", createGame); router.use(gameLimiter);
gameRouter.post("/answer", submitAnswer);
router.post("/start", controller.createGame as express.RequestHandler);
router.post("/answer", controller.submitAnswer as express.RequestHandler);
return router;
};

View file

@ -5,10 +5,12 @@ import {
joinLobbyHandler, joinLobbyHandler,
} from "../controllers/lobbyController.js"; } from "../controllers/lobbyController.js";
import { requireAuth } from "../middleware/authMiddleware.js"; import { requireAuth } from "../middleware/authMiddleware.js";
import { lobbyLimiter } from "../middleware/rateLimiters.js";
export const lobbyRouter: Router = express.Router(); export const lobbyRouter: Router = express.Router();
lobbyRouter.use(requireAuth); lobbyRouter.use(requireAuth);
lobbyRouter.use(lobbyLimiter);
lobbyRouter.post("/", createLobbyHandler); lobbyRouter.post("/", createLobbyHandler);
lobbyRouter.post("/:code/join", joinLobbyHandler); lobbyRouter.post("/:code/join", joinLobbyHandler);

View file

@ -5,6 +5,7 @@ vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
import { getGameTerms, getDistractors } from "@lila/db"; import { getGameTerms, getDistractors } from "@lila/db";
import { createGameSession, evaluateAnswer } from "./gameService.js"; import { createGameSession, evaluateAnswer } from "./gameService.js";
import { InMemoryGameSessionStore } from "../gameSessionStore/index.js";
const mockGetGameTerms = vi.mocked(getGameTerms); const mockGetGameTerms = vi.mocked(getGameTerms);
const mockGetDistractors = vi.mocked(getDistractors); const mockGetDistractors = vi.mocked(getDistractors);
@ -14,7 +15,7 @@ const validRequest: GameRequest = {
target_language: "it", target_language: "it",
pos: "noun", pos: "noun",
difficulty: "easy", difficulty: "easy",
rounds: "3", rounds: 3,
}; };
const fakeTerms = [ const fakeTerms = [
@ -31,19 +32,32 @@ const fakeTerms = [
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockGetGameTerms.mockResolvedValue(fakeTerms); mockGetGameTerms.mockResolvedValue(fakeTerms);
mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]); mockGetDistractors.mockResolvedValue([
"wrong1",
"wrong2",
"wrong3",
"wrong4",
"wrong5",
"wrong6",
]);
}); });
describe("createGameSession", () => { describe("createGameSession", () => {
let store: InMemoryGameSessionStore;
beforeEach(() => {
store = new InMemoryGameSessionStore();
});
it("returns a session with the correct number of questions", async () => { it("returns a session with the correct number of questions", async () => {
const session = await createGameSession(validRequest); const session = await createGameSession(validRequest, store, "user-1");
expect(session.sessionId).toBeDefined(); expect(session.sessionId).toBeDefined();
expect(session.questions).toHaveLength(3); expect(session.questions).toHaveLength(3);
}); });
it("each question has exactly 4 options", async () => { it("each question has exactly 4 options", async () => {
const session = await createGameSession(validRequest); const session = await createGameSession(validRequest, store, "user-1");
for (const question of session.questions) { for (const question of session.questions) {
expect(question.options).toHaveLength(4); expect(question.options).toHaveLength(4);
@ -51,14 +65,14 @@ describe("createGameSession", () => {
}); });
it("each question has a unique questionId", async () => { it("each question has a unique questionId", async () => {
const session = await createGameSession(validRequest); const session = await createGameSession(validRequest, store, "user-1");
const ids = session.questions.map((q) => q.questionId); const ids = session.questions.map((q) => q.questionId);
expect(new Set(ids).size).toBe(ids.length); expect(new Set(ids).size).toBe(ids.length);
}); });
it("options have sequential optionIds 0-3", async () => { it("options have sequential optionIds 0-3", async () => {
const session = await createGameSession(validRequest); const session = await createGameSession(validRequest, store, "user-1");
for (const question of session.questions) { for (const question of session.questions) {
const optionIds = question.options.map((o) => o.optionId); const optionIds = question.options.map((o) => o.optionId);
@ -67,7 +81,7 @@ describe("createGameSession", () => {
}); });
it("the correct answer is always among the options", async () => { it("the correct answer is always among the options", async () => {
const session = await createGameSession(validRequest); const session = await createGameSession(validRequest, store, "user-1");
for (let i = 0; i < session.questions.length; i++) { for (let i = 0; i < session.questions.length; i++) {
const question = session.questions[i]!; const question = session.questions[i]!;
@ -78,24 +92,26 @@ describe("createGameSession", () => {
} }
}); });
it("distractors are never the correct answer", async () => { it("correct answer appears exactly once even if getDistractors returns a duplicate", async () => {
const session = await createGameSession(validRequest); mockGetDistractors.mockResolvedValueOnce([
"cane",
"wrong2",
"wrong3",
"wrong4",
"wrong5",
"wrong6",
]);
for (let i = 0; i < session.questions.length; i++) { const session = await createGameSession(validRequest, store, "user-1");
const question = session.questions[i]!; const question = session.questions[0]!;
const correctText = fakeTerms[i]!.targetText; const optionTexts = question.options.map((o) => o.text);
const distractorTexts = question.options
.map((o) => o.text)
.filter((t) => t !== correctText);
for (const text of distractorTexts) { expect(optionTexts.filter((t) => t === "cane")).toHaveLength(1);
expect(text).not.toBe(correctText); expect(question.options).toHaveLength(4);
}
}
}); });
it("sets the prompt from the source text", async () => { it("sets the prompt from the source text", async () => {
const session = await createGameSession(validRequest); const session = await createGameSession(validRequest, store, "user-1");
expect(session.questions[0]!.prompt).toBe("dog"); expect(session.questions[0]!.prompt).toBe("dog");
expect(session.questions[1]!.prompt).toBe("cat"); expect(session.questions[1]!.prompt).toBe("cat");
@ -103,14 +119,14 @@ describe("createGameSession", () => {
}); });
it("passes gloss through (null or string)", async () => { it("passes gloss through (null or string)", async () => {
const session = await createGameSession(validRequest); const session = await createGameSession(validRequest, store, "user-1");
expect(session.questions[0]!.gloss).toBeNull(); expect(session.questions[0]!.gloss).toBeNull();
expect(session.questions[2]!.gloss).toBe("a building for living in"); expect(session.questions[2]!.gloss).toBe("a building for living in");
}); });
it("calls getGameTerms with the correct arguments", async () => { it("calls getGameTerms with the correct arguments", async () => {
await createGameSession(validRequest); await createGameSession(validRequest, store, "user-1");
expect(mockGetGameTerms).toHaveBeenCalledWith( expect(mockGetGameTerms).toHaveBeenCalledWith(
"en", "en",
@ -122,24 +138,83 @@ describe("createGameSession", () => {
}); });
it("calls getDistractors once per question", async () => { it("calls getDistractors once per question", async () => {
await createGameSession(validRequest); await createGameSession(validRequest, store, "user-1");
expect(mockGetDistractors).toHaveBeenCalledTimes(3); expect(mockGetDistractors).toHaveBeenCalledTimes(3);
}); });
it("propagates unexpected errors from getGameTerms", async () => {
mockGetGameTerms.mockRejectedValue(new Error("connection refused"));
await expect(
createGameSession(validRequest, store, "user-1"),
).rejects.toThrow("connection refused");
});
it("propagates getDistractors failure", async () => {
mockGetDistractors.mockRejectedValue(new Error("db timeout"));
await expect(
createGameSession(validRequest, store, "user-1"),
).rejects.toThrow("db timeout");
});
it("throws when fewer than 3 unique distractors remain after deduplication", async () => {
mockGetDistractors.mockResolvedValueOnce([
"cane",
"cane",
"cane",
"cane",
"cane",
"cane",
]);
await expect(
createGameSession(validRequest, store, "user-1"),
).rejects.toThrow("Not enough unique distractors");
});
it("duplicate distractors are deduplicated against each other", async () => {
mockGetDistractors.mockResolvedValueOnce([
"wrong1",
"wrong1",
"wrong1",
"wrong2",
"wrong3",
"wrong4",
]);
const session = await createGameSession(validRequest, store, "user-1");
const question = session.questions[0]!;
const optionTexts = question.options.map((o) => o.text);
expect(new Set(optionTexts).size).toBe(4);
expect(question.options).toHaveLength(4);
});
}); });
describe("evaluateAnswer", () => { describe("evaluateAnswer", () => {
let store: InMemoryGameSessionStore;
beforeEach(() => {
store = new InMemoryGameSessionStore();
});
it("returns isCorrect: true when the correct option is selected", async () => { it("returns isCorrect: true when the correct option is selected", async () => {
const session = await createGameSession(validRequest); const session = await createGameSession(validRequest, store, "user-1");
const question = session.questions[0]!; const question = session.questions[0]!;
const correctText = fakeTerms[0]!.targetText; const correctText = fakeTerms[0]!.targetText;
const correctOption = question.options.find((o) => o.text === correctText)!; const correctOption = question.options.find((o) => o.text === correctText)!;
const result = await evaluateAnswer({ const result = await evaluateAnswer(
{
sessionId: session.sessionId, sessionId: session.sessionId,
questionId: question.questionId, questionId: question.questionId,
selectedOptionId: correctOption.optionId, selectedOptionId: correctOption.optionId,
}); },
store,
"user-1",
);
expect(result.isCorrect).toBe(true); expect(result.isCorrect).toBe(true);
expect(result.correctOptionId).toBe(correctOption.optionId); expect(result.correctOptionId).toBe(correctOption.optionId);
@ -147,17 +222,21 @@ describe("evaluateAnswer", () => {
}); });
it("returns isCorrect: false when a wrong option is selected", async () => { it("returns isCorrect: false when a wrong option is selected", async () => {
const session = await createGameSession(validRequest); const session = await createGameSession(validRequest, store, "user-1");
const question = session.questions[0]!; const question = session.questions[0]!;
const correctText = fakeTerms[0]!.targetText; const correctText = fakeTerms[0]!.targetText;
const correctOption = question.options.find((o) => o.text === correctText)!; const correctOption = question.options.find((o) => o.text === correctText)!;
const wrongOption = question.options.find((o) => o.text !== correctText)!; const wrongOption = question.options.find((o) => o.text !== correctText)!;
const result = await evaluateAnswer({ const result = await evaluateAnswer(
{
sessionId: session.sessionId, sessionId: session.sessionId,
questionId: question.questionId, questionId: question.questionId,
selectedOptionId: wrongOption.optionId, selectedOptionId: wrongOption.optionId,
}); },
store,
"user-1",
);
expect(result.isCorrect).toBe(false); expect(result.isCorrect).toBe(false);
expect(result.correctOptionId).toBe(correctOption.optionId); expect(result.correctOptionId).toBe(correctOption.optionId);
@ -171,13 +250,13 @@ describe("evaluateAnswer", () => {
selectedOptionId: 0, selectedOptionId: 0,
}; };
await expect(evaluateAnswer(submission)).rejects.toThrow( await expect(evaluateAnswer(submission, store, "user-1")).rejects.toThrow(
"Game session not found", "Game session not found",
); );
}); });
it("throws NotFoundError for a non-existent question", async () => { it("throws ConflictError for a non-existent question", async () => {
const session = await createGameSession(validRequest); const session = await createGameSession(validRequest, store, "user-1");
const submission: AnswerSubmission = { const submission: AnswerSubmission = {
sessionId: session.sessionId, sessionId: session.sessionId,
@ -185,8 +264,71 @@ describe("evaluateAnswer", () => {
selectedOptionId: 0, selectedOptionId: 0,
}; };
await expect(evaluateAnswer(submission)).rejects.toThrow( await expect(
"Question not found", evaluateAnswer(submission, store, "user-1"),
).rejects.toMatchObject({ statusCode: 409 });
});
it("throws ConflictError when the same question is submitted twice", async () => {
const session = await createGameSession(validRequest, store, "user-1");
const question = session.questions[0]!;
await evaluateAnswer(
{
sessionId: session.sessionId,
questionId: question.questionId,
selectedOptionId: 0,
},
store,
"user-1",
); );
await expect(
evaluateAnswer(
{
sessionId: session.sessionId,
questionId: question.questionId,
selectedOptionId: 0,
},
store,
"user-1",
),
).rejects.toMatchObject({ statusCode: 409 });
});
it("deletes the session after the last question is answered", async () => {
const session = await createGameSession(validRequest, store, "user-1");
for (const question of session.questions) {
await evaluateAnswer(
{
sessionId: session.sessionId,
questionId: question.questionId,
selectedOptionId: 0,
},
store,
"user-1",
);
}
await expect(
evaluateAnswer(
{
sessionId: session.sessionId,
questionId: session.questions[0]!.questionId,
selectedOptionId: 0,
},
store,
"user-1",
),
).rejects.toThrow("Game session not found");
});
it("throws UnprocessableEntityError when getGameTerms returns no terms", async () => {
mockGetGameTerms.mockResolvedValue([]);
await expect(
createGameSession(validRequest, store, "user-1"),
).rejects.toMatchObject({ statusCode: 422 });
}); });
}); });

View file

@ -8,38 +8,57 @@ import type {
AnswerSubmission, AnswerSubmission,
AnswerResult, AnswerResult,
} from "@lila/shared"; } from "@lila/shared";
import { InMemoryGameSessionStore } from "../gameSessionStore/index.js"; import type { GameSessionStore } from "../gameSessionStore/index.js";
import { NotFoundError } from "../errors/AppError.js"; import {
NotFoundError,
const gameSessionStore = new InMemoryGameSessionStore(); ConflictError,
UnprocessableEntityError,
} from "../errors/AppError.js";
import { shuffleArray } from "../lib/utils.js";
export const createGameSession = async ( export const createGameSession = async (
request: GameRequest, request: GameRequest,
store: GameSessionStore,
userId: string,
): Promise<GameSession> => { ): Promise<GameSession> => {
const correctAnswers = await getGameTerms( const terms = await getGameTerms(
request.source_language, request.source_language,
request.target_language, request.target_language,
request.pos, request.pos,
request.difficulty, request.difficulty,
Number(request.rounds), request.rounds,
); );
const answerKey = new Map<string, number>(); if (terms.length === 0) {
throw new UnprocessableEntityError("No terms found for the given filters");
}
const answerKey = new Map<string, { correctOptionId: number }>();
const questions: GameQuestion[] = await Promise.all( const questions: GameQuestion[] = await Promise.all(
correctAnswers.map(async (correctAnswer) => { terms.map(async (term) => {
const distractorTexts = await getDistractors( const distractorTexts = await getDistractors(
correctAnswer.termId, term.termId,
correctAnswer.targetText, term.targetText,
request.target_language, request.target_language,
request.pos, request.pos,
request.difficulty, request.difficulty,
3, 6,
); );
const optionTexts = [correctAnswer.targetText, ...distractorTexts]; const uniqueDistractors = [
const shuffledTexts = shuffle(optionTexts); ...new Set(distractorTexts.filter((t) => t !== term.targetText)),
const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText); ];
if (uniqueDistractors.length < 3) {
throw new Error(
`Not enough unique distractors for term: ${term.targetText}`,
);
}
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
const shuffledTexts = shuffleArray(optionTexts);
const correctOptionId = shuffledTexts.indexOf(term.targetText);
const options: AnswerOption[] = shuffledTexts.map((text, index) => ({ const options: AnswerOption[] = shuffledTexts.map((text, index) => ({
optionId: index, optionId: index,
@ -47,53 +66,58 @@ export const createGameSession = async (
})); }));
const questionId = randomUUID(); const questionId = randomUUID();
answerKey.set(questionId, correctOptionId); answerKey.set(questionId, { correctOptionId });
return { return {
questionId, questionId,
prompt: correctAnswer.sourceText, prompt: term.sourceText,
gloss: correctAnswer.sourceGloss, gloss: term.sourceGloss,
options, options,
}; };
}), }),
); );
const sessionId = randomUUID(); const sessionId = randomUUID();
await gameSessionStore.create(sessionId, { answers: answerKey }); await store.create(sessionId, { answers: answerKey, userId }, 30 * 60 * 1000);
return { sessionId, questions }; return { sessionId, questions };
}; };
const shuffle = <T>(array: T[]): T[] => {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const temp = result[i]!;
result[i] = result[j]!;
result[j] = temp;
}
return result;
};
export const evaluateAnswer = async ( export const evaluateAnswer = async (
submission: AnswerSubmission, submission: AnswerSubmission,
store: GameSessionStore,
userId: string,
): Promise<AnswerResult> => { ): Promise<AnswerResult> => {
const session = await gameSessionStore.get(submission.sessionId); const session = await store.get(submission.sessionId);
if (!session) { if (!session || session.userId !== userId) {
throw new NotFoundError(`Game session not found: ${submission.sessionId}`); throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
} }
const correctOptionId = session.answers.get(submission.questionId); const answer = session.answers.get(submission.questionId);
if (correctOptionId === undefined) { if (answer === undefined) {
throw new NotFoundError(`Question not found: ${submission.questionId}`); throw new ConflictError(
`Question already answered: ${submission.questionId}`,
);
}
const updatedAnswers = new Map(session.answers);
updatedAnswers.delete(submission.questionId);
if (updatedAnswers.size === 0) {
await store.delete(submission.sessionId);
} else {
await store.update(submission.sessionId, {
answers: updatedAnswers,
userId: session.userId,
});
} }
return { return {
questionId: submission.questionId, questionId: submission.questionId,
isCorrect: submission.selectedOptionId === correctOptionId, isCorrect: submission.selectedOptionId === answer.correctOptionId,
correctOptionId, correctOptionId: answer.correctOptionId,
selectedOptionId: submission.selectedOptionId, selectedOptionId: submission.selectedOptionId,
}; };
}; };

View file

@ -87,6 +87,14 @@ describe("createLobby", () => {
"Could not generate a unique lobby code", "Could not generate a unique lobby code",
); );
}); });
it("re-throws non-unique-violation errors immediately", async () => {
const dbError = new Error("connection refused");
mockCreateLobby.mockRejectedValue(dbError);
await expect(createLobby("user-1")).rejects.toThrow("connection refused");
expect(mockCreateLobby).toHaveBeenCalledTimes(1);
});
}); });
describe("joinLobby", () => { describe("joinLobby", () => {
@ -173,4 +181,22 @@ describe("joinLobby", () => {
"Lobby is full", "Lobby is full",
); );
}); });
it("throws ConflictError when addPlayer returns falsy (race condition)", async () => {
mockAddPlayer.mockResolvedValue(undefined);
await expect(joinLobby("ABC123", "user-2")).rejects.toThrow(
"Lobby is no longer available",
);
});
it("throws AppError when lobby disappears after addPlayer succeeds", async () => {
mockGetLobbyByCodeWithPlayers
.mockResolvedValueOnce(fakeLobbyWithPlayers)
.mockResolvedValueOnce(undefined);
await expect(joinLobby("ABC123", "user-2")).rejects.toThrow(
"Lobby disappeared during join",
);
});
}); });

View file

@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
import { getGameTerms, getDistractors } from "@lila/db";
import { generateMultiplayerQuestions } from "./multiplayerGameService.js";
const mockGetGameTerms = vi.mocked(getGameTerms);
const mockGetDistractors = vi.mocked(getDistractors);
const fakeTerms = [
{ termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
{ termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
{
termId: "t3",
sourceText: "house",
targetText: "casa",
sourceGloss: "a building for living in",
},
];
beforeEach(() => {
vi.clearAllMocks();
mockGetGameTerms.mockResolvedValue(fakeTerms);
mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]);
});
describe("generateMultiplayerQuestions", () => {
it("returns the correct number of questions", async () => {
const questions = await generateMultiplayerQuestions();
expect(questions).toHaveLength(3);
});
it("each question has exactly 4 options", async () => {
const questions = await generateMultiplayerQuestions();
for (const question of questions) {
expect(question.options).toHaveLength(4);
}
});
it("each question has a unique questionId", async () => {
const questions = await generateMultiplayerQuestions();
const ids = questions.map((q) => q.questionId);
expect(new Set(ids).size).toBe(ids.length);
});
it("options have sequential optionIds 0-3", async () => {
const questions = await generateMultiplayerQuestions();
for (const question of questions) {
const optionIds = question.options.map((o) => o.optionId);
expect(optionIds).toEqual([0, 1, 2, 3]);
}
});
it("the correct answer is always among the options", async () => {
const questions = await generateMultiplayerQuestions();
for (let i = 0; i < questions.length; i++) {
const question = questions[i]!;
const correctText = fakeTerms[i]!.targetText;
const optionTexts = question.options.map((o) => o.text);
expect(optionTexts).toContain(correctText);
}
});
it("correctOptionId points to the option whose text matches the correct answer", async () => {
const questions = await generateMultiplayerQuestions();
for (let i = 0; i < questions.length; i++) {
const question = questions[i]!;
const correctText = fakeTerms[i]!.targetText;
const correctOption = question.options.find(
(o) => o.optionId === question.correctOptionId,
);
expect(correctOption?.text).toBe(correctText);
}
});
it("sets the prompt from the source text", async () => {
const questions = await generateMultiplayerQuestions();
expect(questions[0]!.prompt).toBe("dog");
expect(questions[1]!.prompt).toBe("cat");
expect(questions[2]!.prompt).toBe("house");
});
it("passes gloss through (null or string)", async () => {
const questions = await generateMultiplayerQuestions();
expect(questions[0]!.gloss).toBeNull();
expect(questions[2]!.gloss).toBe("a building for living in");
});
it("calls getGameTerms with the multiplayer defaults", async () => {
await generateMultiplayerQuestions();
expect(mockGetGameTerms).toHaveBeenCalledWith(
"en",
"it",
"noun",
"easy",
3,
);
});
it("calls getDistractors once per question", async () => {
await generateMultiplayerQuestions();
expect(mockGetDistractors).toHaveBeenCalledTimes(3);
});
it("propagates unexpected errors from getGameTerms", async () => {
mockGetGameTerms.mockRejectedValue(new Error("connection refused"));
await expect(generateMultiplayerQuestions()).rejects.toThrow(
"connection refused",
);
});
});

View file

@ -1,3 +1,4 @@
import type { Request } from "express";
import type { Session, User } from "better-auth"; import type { Session, User } from "better-auth";
declare global { declare global {
@ -14,4 +15,6 @@ declare module "ws" {
} }
} }
export {}; export type AuthenticatedRequest = Request & {
session: { session: Session; user: User };
};

View file

@ -21,9 +21,11 @@ CMD ["pnpm", "--filter", "web", "dev", "--host"]
# 4. Build # 4. Build
FROM base AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
RUN pnpm install --frozen-lockfile
COPY . . COPY . .
RUN pnpm install
ARG VITE_API_URL ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
RUN pnpm --filter shared build RUN pnpm --filter shared build

View file

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

View file

@ -0,0 +1,43 @@
import { Link } from "@tanstack/react-router";
export default function NotFound() {
return (
<section className="relative flex flex-col items-center justify-center py-24 text-center overflow-hidden">
<div className="absolute inset-0 -z-10">
<div className="absolute top-0 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-primary) opacity-[0.10] blur-3xl" />
<div className="absolute top-10 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
</div>
<div className="-rotate-1 mb-4">
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) px-4 py-1 text-xs font-semibold tracking-widest uppercase text-(--color-accent) border border-(--color-primary-light)">
lost in translation
</span>
</div>
<h1 className="text-8xl font-black tracking-tight text-(--color-text) leading-none">
4
<span className="inline-block rotate-1 px-3 py-1 bg-(--color-primary) text-white rounded-xl">
0
</span>
4
</h1>
<p className="mt-6 text-lg font-medium text-(--color-text-muted) max-w-sm">
This page doesn't exist. Maybe it never did - or maybe you{" "}
<span className="text-(--color-accent) font-bold">
just guessed wrong
</span>
.
</p>
<div className="mt-8">
<Link
to="/"
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
>
Back to home
</Link>
</div>
</section>
);
}

View file

@ -0,0 +1,56 @@
import { Link } from "@tanstack/react-router";
interface RootErrorProps {
error: Error;
reset: () => void;
}
export default function RootError({ error, reset }: RootErrorProps) {
return (
<section className="relative flex flex-col items-center justify-center min-h-screen text-center overflow-hidden px-6">
<div className="absolute inset-0 -z-10">
<div className="absolute top-0 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-primary) opacity-[0.10] blur-3xl" />
<div className="absolute top-10 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
</div>
<div className="-rotate-1 mb-4">
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) px-4 py-1 text-xs font-semibold tracking-widest uppercase text-(--color-accent) border border-(--color-primary-light)">
something went wrong
</span>
</div>
<h1 className="text-5xl md:text-6xl font-black tracking-tight text-(--color-text) leading-[1.05]">
Unexpected{" "}
<span className="inline-block rotate-1 px-3 py-1 bg-(--color-primary) text-white rounded-xl">
error
</span>
</h1>
<p className="mt-6 text-lg font-medium text-(--color-text-muted) max-w-sm">
Something crashed. This has been noted {" "}
<span className="text-(--color-accent) font-bold">it's not you</span>.
</p>
{import.meta.env.DEV && (
<pre className="mt-4 max-w-xl w-full text-left text-xs bg-(--color-surface) border border-(--color-primary-light) rounded-2xl p-4 overflow-auto text-(--color-text-muted)">
{error.message}
</pre>
)}
<div className="mt-8 flex gap-3 flex-wrap justify-center">
<button
onClick={reset}
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
>
Try again
</button>
<Link
to="/"
className="px-7 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
>
Back to home
</Link>
</div>
</section>
);
}

View file

@ -0,0 +1,49 @@
interface RouteErrorProps {
error: Error;
reset: () => void;
}
export default function RouteError({ error, reset }: RouteErrorProps) {
return (
<section className="relative flex flex-col items-center justify-center py-24 text-center overflow-hidden">
<div className="absolute inset-0 -z-10">
<div className="absolute top-0 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-primary) opacity-[0.10] blur-3xl" />
<div className="absolute top-10 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
</div>
<div className="-rotate-1 mb-4">
<span className="inline-flex items-center gap-2 rounded-full bg-(--color-surface) px-4 py-1 text-xs font-semibold tracking-widest uppercase text-(--color-accent) border border-(--color-primary-light)">
something went wrong
</span>
</div>
<h1 className="text-5xl font-black tracking-tight text-(--color-text) leading-[1.05]">
This page{" "}
<span className="inline-block rotate-1 px-3 py-1 bg-(--color-primary) text-white rounded-xl">
crashed
</span>
</h1>
<p className="mt-6 text-lg font-medium text-(--color-text-muted) max-w-sm">
Something went wrong loading this page.{" "}
<span className="text-(--color-accent) font-bold">Try again</span> or
head back home.
</p>
{import.meta.env.DEV && (
<pre className="mt-4 max-w-xl w-full text-left text-xs bg-(--color-surface) border border-(--color-primary-light) rounded-2xl p-4 overflow-auto text-(--color-text-muted)">
{error.message}
</pre>
)}
<div className="mt-8 flex gap-3 flex-wrap justify-center">
<button
onClick={reset}
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
>
Try again
</button>
</div>
</section>
);
}

View file

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

View file

@ -24,19 +24,19 @@ const LABELS: Record<string, string> = {
type GameSetupProps = { onStart: (settings: GameRequest) => void }; type GameSetupProps = { onStart: (settings: GameRequest) => void };
type SettingGroupProps = { type SettingGroupProps<T extends string | number> = {
label: string; label: string;
options: readonly string[]; options: readonly T[];
selected: string; selected: T;
onSelect: (value: string) => void; onSelect: (value: T) => void;
}; };
const SettingGroup = ({ const SettingGroup = <T extends string | number>({
label, label,
options, options,
selected, selected,
onSelect, onSelect,
}: SettingGroupProps) => ( }: SettingGroupProps<T>) => (
<div className="w-full"> <div className="w-full">
<p className="text-xs font-bold tracking-widest uppercase text-(--color-primary) mb-2"> <p className="text-xs font-bold tracking-widest uppercase text-(--color-primary) mb-2">
{label} {label}
@ -52,7 +52,7 @@ const SettingGroup = ({
: "bg-white text-(--color-primary-dark) border-(--color-primary-light) hover:bg-(--color-surface) hover:-translate-y-0.5 active:translate-y-0" : "bg-white text-(--color-primary-dark) border-(--color-primary-light) hover:bg-(--color-surface) hover:-translate-y-0.5 active:translate-y-0"
}`} }`}
> >
{LABELS[option] ?? option} {LABELS[String(option)] ?? option}
</button> </button>
))} ))}
</div> </div>
@ -68,7 +68,7 @@ export const GameSetup = ({ onStart }: GameSetupProps) => {
); );
const [pos, setPos] = useState<string>(SUPPORTED_POS[0]); const [pos, setPos] = useState<string>(SUPPORTED_POS[0]);
const [difficulty, setDifficulty] = useState<string>(DIFFICULTY_LEVELS[0]); const [difficulty, setDifficulty] = useState<string>(DIFFICULTY_LEVELS[0]);
const [rounds, setRounds] = useState<string>(GAME_ROUNDS[0]); const [rounds, setRounds] = useState<number>(GAME_ROUNDS[0]);
const handleSourceLanguage = (value: string) => { const handleSourceLanguage = (value: string) => {
if (value === targetLanguage) { if (value === targetLanguage) {

View file

@ -53,7 +53,11 @@ export const QuestionCard = ({
Round {questionNumber}/{totalQuestions} Round {questionNumber}/{totalQuestions}
</div> </div>
<div className="text-xs font-semibold text-(--color-text-muted)"> <div className="text-xs font-semibold text-(--color-text-muted)">
{currentResult ? "Checked" : selectedOptionId !== null ? "Ready" : "Pick one"} {currentResult
? "Checked"
: selectedOptionId !== null
? "Ready"
: "Pick one"}
</div> </div>
</div> </div>

View file

@ -7,8 +7,8 @@ const Hero = () => {
return ( return (
<section className="relative pt-10 md:pt-16 pb-10 md:pb-14"> <section className="relative pt-10 md:pt-16 pb-10 md:pb-14">
<div className="absolute inset-0 -z-10"> <div className="absolute inset-0 -z-10">
<div className="absolute -top-24 left-1/2 h-72 w-[46rem] -translate-x-1/2 rounded-full bg-(--color-primary) opacity-[0.10] blur-3xl" /> <div className="absolute -top-24 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-primary) opacity-[0.10] blur-3xl" />
<div className="absolute -top-10 left-1/2 h-72 w-[46rem] -translate-x-1/2 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" /> <div className="absolute -top-10 left-1/2 h-72 w-184 -translate-x-1/2 rounded-full bg-(--color-accent) opacity-[0.10] blur-3xl" />
</div> </div>
<div className="grid items-center gap-10 md:grid-cols-2"> <div className="grid items-center gap-10 md:grid-cols-2">
@ -28,9 +28,11 @@ const Hero = () => {
</h1> </h1>
<p className="mt-5 text-lg md:text-xl font-medium text-(--color-text-muted) max-w-xl mx-auto md:mx-0"> <p className="mt-5 text-lg md:text-xl font-medium text-(--color-text-muted) max-w-xl mx-auto md:mx-0">
A word appears. You pick the translation. You score points. A word appears. You pick the translation. You score points. Then you
Then you queue up a room and{" "} queue up a room and{" "}
<span className="text-(--color-accent) font-bold">beat friends</span>{" "} <span className="text-(--color-accent) font-bold">
beat friends
</span>{" "}
in real time. in real time.
</p> </p>
@ -64,13 +66,15 @@ const Hero = () => {
) : ( ) : (
<> <>
<Link <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)" className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
> >
Get started Get started
</Link> </Link>
<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)" 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 Log in

View file

@ -69,7 +69,9 @@ export const MultiplayerScoreScreen = ({
</span> </span>
<span <span
className={`text-sm font-semibold ${ className={`text-sm font-semibold ${
isCurrentUser ? "text-(--color-text)" : "text-(--color-text)" isCurrentUser
? "text-(--color-text)"
: "text-(--color-text)"
}`} }`}
> >
{player.user.name} {player.user.name}

View file

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

View file

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

View file

@ -6,10 +6,7 @@ type ConfettiBurstProps = {
count?: number; count?: number;
}; };
type Piece = { type Piece = { id: number; style: React.CSSProperties & ConfettiVars };
id: number;
style: React.CSSProperties & ConfettiVars;
};
type ConfettiVars = { type ConfettiVars = {
["--x0"]: string; ["--x0"]: string;
@ -56,7 +53,9 @@ export const ConfettiBurst = ({
}, []); }, []);
const pieces = useMemo<Piece[]>(() => { const pieces = useMemo<Piece[]>(() => {
const seed = hashStringToUint32(`${instanceId}:${count}:${colors.join(",")}`); const seed = hashStringToUint32(
`${instanceId}:${count}:${colors.join(",")}`,
);
const rand = mulberry32(seed); const rand = mulberry32(seed);
const rnd = (min: number, max: number) => min + rand() * (max - min); const rnd = (min: number, max: number) => min + rand() * (max - min);
@ -100,4 +99,3 @@ export const ConfettiBurst = ({
</div> </div>
); );
}; };

View file

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

View file

@ -1,17 +1,47 @@
import { createRootRoute, Outlet } from "@tanstack/react-router"; import {
createRootRoute,
Outlet,
useNavigate,
useSearch,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { Toaster } from "sonner";
import Navbar from "../components/navbar/NavBar"; 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 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 ( return (
<> <>
<Navbar /> <Navbar />
<main className="max-w-5xl mx-auto px-6 py-8"> <main className="max-w-5xl mx-auto px-6 py-8">
<Outlet /> <Outlet />
</main> </main>
{modal === "auth" && (
<AuthModal onClose={handleClose} onSuccess={handleSuccess} />
)}
<Toaster richColors position="top-center" />
<TanStackRouterDevtools /> <TanStackRouterDevtools />
</> </>
); );
}; };
export const Route = createRootRoute({ component: RootLayout }); export const Route = createRootRoute({
component: RootLayout,
notFoundComponent: NotFound,
errorComponent: RootError,
validateSearch: AuthModalSearchSchema,
});

View file

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

View file

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

View file

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

View file

@ -108,7 +108,9 @@ function MultiplayerPage() {
{/* Join lobby */} {/* Join lobby */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h2 className="text-lg font-bold text-(--color-text)">Join a lobby</h2> <h2 className="text-lg font-bold text-(--color-text)">
Join a lobby
</h2>
<p className="text-sm text-(--color-text-muted)"> <p className="text-sm text-(--color-text-muted)">
Enter the code shared by your host. Enter the code shared by your host.
</p> </p>

View file

@ -4,6 +4,7 @@ import type { GameSession, GameRequest, AnswerResult } from "@lila/shared";
import { QuestionCard } from "../components/game/QuestionCard"; import { QuestionCard } from "../components/game/QuestionCard";
import { ScoreScreen } from "../components/game/ScoreScreen"; import { ScoreScreen } from "../components/game/ScoreScreen";
import { GameSetup } from "../components/game/GameSetup"; import { GameSetup } from "../components/game/GameSetup";
import RouteError from "../components/RouteError";
import { authClient } from "../lib/auth-client"; import { authClient } from "../lib/auth-client";
type GameStartResponse = { success: true; data: GameSession }; type GameStartResponse = { success: true; data: GameSession };
@ -127,10 +128,11 @@ function Play() {
export const Route = createFileRoute("/play")({ export const Route = createFileRoute("/play")({
component: Play, component: Play,
errorComponent: RouteError,
beforeLoad: async () => { beforeLoad: async () => {
const { data: session } = await authClient.getSession(); const { data: session } = await authClient.getSession();
if (!session) { if (!session) {
throw redirect({ to: "/login" }); throw redirect({ to: "/", search: { modal: "auth", redirect: "/play" } });
} }
}, },
}); });

View file

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

File diff suppressed because it is too large Load diff

View file

@ -5,8 +5,8 @@
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"outDir": "dist", "outDir": "dist",
"rootDir": ".", "rootDir": ".",
"types": ["node"], "types": ["node"]
}, },
"references": [{ "path": "../packages/shared" }], "references": [{ "path": "../packages/shared" }],
"include": ["./**/*"], "include": ["./**/*"]
} }

View file

@ -31,6 +31,7 @@ services:
api: api:
container_name: lila-api container_name: lila-api
user: "${UID}:${GID}"
build: build:
context: . context: .
dockerfile: ./apps/api/Dockerfile dockerfile: ./apps/api/Dockerfile
@ -59,6 +60,7 @@ services:
web: web:
container_name: lila-web container_name: lila-web
user: "${UID}:${GID}"
build: build:
context: . context: .
dockerfile: ./apps/web/Dockerfile dockerfile: ./apps/web/Dockerfile

120
documentation/backlog.md Normal file
View file

@ -0,0 +1,120 @@
# lila — backlog
Labels: `[feature]` `[infra]` `[security]` `[ux]` `[debt]`
---
## now
Things that are actively in progress or should be picked up immediately. Mostly operational risk and the remaining phase 7 hardening work.
- **Hetzner domain migration check** `[infra]`
Verify whether the lilastudy.com domain needs to be migrated following a Hetzner DNS change. Check Hetzner dashboard for any pending migration notice.
---
## next
Clearly planned work, not yet started. No hard ordering — sequence based on what unblocks real users first.
- **Batch distractor queries to eliminate N+1** `[debt]`
createGameSession calls getDistractors once per term in parallel — 3 queries for 3 rounds, 10 for 10. Each query does ORDER BY RANDOM() which can't use an index and gets slower as the translations table grows. Fix: add a getDistractorsForTerms(termIds[], ...) function to @lila/db that batches all distractor fetches into a single query and returns results grouped by term. The service distributes the results per question. Prerequisite: none. Blocked by: nothing, but coordinate with any ongoing @lila/db changes.
- **Atomic session creation** `[debt]`
createGameSession reads from Postgres (getGameTerms, getDistractors) then writes to the session store (in-memory/Valkey). A crash between the two leaves the terms consumed with no session created — the user gets an error and retries, no data is corrupted, but the work is wasted. A true transaction boundary isn't achievable across two different systems (Postgres + Valkey have no shared coordinator). Options when revisiting: store sessions in Postgres instead of Valkey (full transactionality, higher latency), or accept the current behaviour and add retry logic on the client. Revisit after Valkey is in production and actual failure rates are observable.
- **Guest / try-now flow** `[feature]`
Allow users to play a quiz without signing in so they can see what the app offers before creating an account. Make auth middleware optional on game routes, add a "Try without account" button on the landing/login page.
- **Favicon, page titles, Open Graph meta** `[ux]`
Add favicon, set proper per-route page titles, add Open Graph meta tags for link previews.
- **Accessibility pass** `[ux]`
Keyboard navigation for quiz buttons, ARIA labels on interactive elements, focus management during quiz flow.
- **Monitoring and logging** `[infra]`
Uptime monitoring and centralized logging on the VPS. Options: `chkrootkit`/`rkhunter` for security, `logwatch`/`monit` for daily summaries.
- **Offsite backup storage** `[infra]`
Database backups currently live on the same VPS. Add offsite copies to Hetzner Object Storage or an S3-compatible service to protect against VPS failure.
- **Valkey for game session store** `[infra]`
Add Valkey to the production Docker stack. Implement `ValkeyGameSessionStore` against the existing `GameSessionStore` interface. Required before multiplayer scales.
NOTE: the rate limiting middleware needs to be adjusted for valkey, see todo comment
- **User stats endpoint + profile page** `[feature]`
`GET /users/me/stats` returning games played, score history, etc. Frontend profile page displaying the stats.
- **Admin dashboard** `[feature]`
User management, overview of words and languages, and per-term stats. Not urgent but has real operational value once real users are present.
- **Email + password login** `[feature]`
Traditional email/password auth as an alternative to social login. Configure via Better Auth.
- **Apple login** `[feature]`
Add Apple as a social login option via Better Auth. Requires Apple Developer account and Sign in with Apple configuration.
- **Graceful WS reconnect** `[infra]`
Handle WebSocket disconnections gracefully. Reconnect with exponential back-off. Restore game state on reconnection if a game is still in progress.
- **Configurable game settings in multiplayer lobby** `[feature]`
Game settings (mode, round count, timer duration, target score) are currently hardcoded. The host should be able to configure these when creating a lobby. Settings should be stored in the settings jsonb column on the lobbies table and passed through to the game service at start.
- **Tighten CSP to remove unsafe-inline** `[security]`
Current script-src uses 'unsafe-inline' to accommodate framework-injected inline scripts (likely TanStack Router hydration). Tightening this would require nonce-based CSP, which needs server-rendered HTML or a Caddy layer that injects per-request nonces. Not urgent — pragmatic CSP with 'unsafe-inline' is mainstream for SPAs at this scale. Revisit if the app handles more sensitive data or grows a meaningful user base
- **Publish Google OAuth consent screen** `[infra]`
App is currently in testing mode, which caps OAuth sign-ins at 100 users. Before hitting that limit, publish the consent screen in Google Cloud Console. Basic scopes (email, profile, openid) require no Google review — just fill in branding fields (app name, logo, support email, privacy policy URL) and click publish. Trigger: do this before reaching 80 users.
---
## later
Directionally right, timing is unclear. Revisit when the next/now work is done.
- **Game modes** `[feature]`
Five modes are designed in `game_modes.md` — TV Quiz Show, Race to the Top, Chain Link, Elimination Round, Cooperative Challenge. The lobby infrastructure is mode-agnostic; each mode adds game logic only. First mode to implement is TBD. This is effectively a new phase.
- **Single Player Extended** `[feature]`
Expanded singleplayer flow. Possible directions: longer sessions with increasing difficulty, streak bonuses, mixed POS/language rounds, progress tracking across sessions, timed challenge mode.
- **Users in a separate database** `[infra]`
Architectural separation of auth/user data from vocabulary and game data. No immediate benefit — revisit after hardening is complete and user growth justifies the complexity.
- **Modern env management** `[debt]`
Replace `.env` files with a more robust approach (e.g. `dotenvx`, `infisical`). Current setup works but is error-prone and not versioned.
- **Reorganize datafiles and wordlists** `[debt]`
The current layout of `data-sources/`, `scripts/datafiles/`, `scripts/data-sources/`, and `packages/db/src/data/` is confusing with overlapping content. Consolidate into a clear structure.
- **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.
- **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.
- **Frontend tests** `[debt]`
component tests for QuestionCard, OptionButton, ScoreScreen; consider Playwright or Vitest browser mode for e2e
---
## changelog
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.
- **04 - 2026 - 404 and redirect handling** - Unknown routes return raw errors. Add a catch-all route on the frontend for client-side 404s.
- **04 - 2026 - Multiplayer GameService unit tests** - round evaluation, scoring, tie-breaking, timeout handling
- **04 - 2026 - Security headers with helmet** - Add helmet middleware to set secure HTTP response headers.
- **04 - 2026 - Rate limiting on API endpoints** - At minimum: auth endpoints (brute force prevention) and game endpoints (spam prevention)
- **04 - 2026 — Migrations in deploy pipeline** — Drizzle migrate runs as a CI/CD step before the API container restarts
- **04 - 2026 — Phase 6: Production deployment** — Hetzner VPS, Caddy HTTPS, Forgejo CI/CD, daily DB backups, cross-subdomain auth
- **04 - 2026 — Phase 5: Multiplayer game** — real-time simultaneous play, 15s server timer, live scoring, winner screen
- **04 - 2026 — Phase 4: Multiplayer lobby** — WebSocket server, lobby create/join, real-time player list
- **04 - 2026 — Phase 3: Auth** — Better Auth, Google + GitHub social login, session middleware, auth guard
- **04 - 2026 — Phase 2: Singleplayer UI** — full quiz loop in browser, game setup, question card, score screen
- **04 - 2026 — Phase 1: Vocabulary data + API** — WordNet/OMW data pipeline, CEFR enrichment, game session endpoints
- **04 - 2026 — Phase 0: Foundation** — pnpm monorepo, TypeScript, ESLint, Vitest, Drizzle, Docker Compose

View file

@ -56,7 +56,7 @@ See **Setup** for download instructions.
Per-language JSON files in `sources/cefr/` provide the initial CEFR level annotations. These files do not cover the full vocabulary extracted from OMW — coverage varies by language. Gaps and disagreements are handled by the enrich stage. Per-language JSON files in `sources/cefr/` provide the initial CEFR level annotations. These files do not cover the full vocabulary extracted from OMW — coverage varies by language. Gaps and disagreements are handled by the enrich stage.
| Language | File | | Language | File |
|---|---| | -------- | ---------------------- |
| English | `sources/cefr/en.json` | | English | `sources/cefr/en.json` |
| Italian | `sources/cefr/it.json` | | Italian | `sources/cefr/it.json` |
| Spanish | `sources/cefr/es.json` | | Spanish | `sources/cefr/es.json` |
@ -103,7 +103,7 @@ See `LLM-SETUP.md`.
The pipeline runs in five stages. Each stage is independent and can be re-run without affecting the others. The pipeline runs in five stages. Each stage is independent and can be re-run without affecting the others.
| Stage | What it does | | Stage | What it does |
|---|---| | ----------- | -------------------------------------------------------------------- |
| 1. Extract | Reads OMW SQLite database, outputs normalized JSON per language | | 1. Extract | Reads OMW SQLite database, outputs normalized JSON per language |
| 2. Annotate | Merges CEFR source files into extracted data, adds source file votes | | 2. Annotate | Merges CEFR source files into extracted data, adds source file votes |
| 3. Enrich | Runs local LLMs in two rounds — generation then voting | | 3. Enrich | Runs local LLMs in two rounds — generation then voting |
@ -137,11 +137,11 @@ Each record in the output looks like this:
"fr": ["comptable"] "fr": ["comptable"]
}, },
"glosses": { "glosses": {
"en": ["(usually followed by 'to') having the necessary means or skill or know-how or authority to do something"] "en": [
"(usually followed by 'to') having the necessary means or skill or know-how or authority to do something"
]
}, },
"examples": { "examples": { "en": ["able to swim", "she was able to program her computer"] }
"en": ["able to swim", "she was able to program her computer"]
}
} }
``` ```
@ -158,6 +158,7 @@ Words appearing in the CEFR source file multiple times with different CEFR level
**Input:** `stage-1-extract/output/omw.json` + `stage-2-annotate/sources/cefr/{lang}.json` **Input:** `stage-1-extract/output/omw.json` + `stage-2-annotate/sources/cefr/{lang}.json`
**Output:** **Output:**
- `stage-2-annotate/output/{lang}.json` — one per language - `stage-2-annotate/output/{lang}.json` — one per language
- `stage-2-annotate/output/conflicts.json` — cross-language conflicts for review - `stage-2-annotate/output/conflicts.json` — cross-language conflicts for review
@ -177,20 +178,14 @@ Each record in the output extends the OMW record with a `votes` field and any ad
"es": ["capaz"], "es": ["capaz"],
"fr": ["comptable"] "fr": ["comptable"]
}, },
"glosses": { "glosses": { "en": ["having the necessary means or skill to do something"] },
"en": ["having the necessary means or skill to do something"]
},
"examples": { "examples": {
"en": [ "en": [
{ "text": "able to swim", "source": "omw" }, { "text": "able to swim", "source": "omw" },
{ "text": "She was able to finish the task.", "source": "cefr" } { "text": "She was able to finish the task.", "source": "cefr" }
] ]
}, },
"votes": { "votes": { "en": { "able": { "cefr_source": "B1" } } }
"en": {
"able": { "cefr_source": "B1" }
}
}
} }
``` ```
@ -297,9 +292,7 @@ Each record in the votes file looks like this:
} }
}, },
"examples": { "examples": {
"en": [ "en": [{ "text": "the dog barked at the stranger", "source": "omw" }],
{ "text": "the dog barked at the stranger", "source": "omw" }
],
"fr": { "fr": {
"candidates": [ "candidates": [
{ "text": "le chien a aboyé", "source": "model_1" }, { "text": "le chien a aboyé", "source": "model_1" },
@ -311,8 +304,14 @@ Each record in the votes file looks like this:
"descriptions": { "descriptions": {
"en": { "en": {
"candidates": [ "candidates": [
{ "text": "a common household pet known for loyalty", "source": "model_1" }, {
{ "text": "a domesticated animal and loyal companion", "source": "model_2" } "text": "a common household pet known for loyalty",
"source": "model_1"
},
{
"text": "a domesticated animal and loyal companion",
"source": "model_2"
}
], ],
"votes": { "model_1": 2, "model_2": 1 } "votes": { "model_1": 2, "model_2": 1 }
} }
@ -335,13 +334,14 @@ Reads the votes file per language and resolves the final value for every field.
**Difficulty mapping:** **Difficulty mapping:**
| CEFR | Difficulty | | CEFR | Difficulty |
|---|---| | ------ | ------------ |
| A1, A2 | easy | | A1, A2 | easy |
| B1, B2 | intermediate | | B1, B2 | intermediate |
| C1, C2 | hard | | C1, C2 | hard |
**Input:** `stage-3-enrich/output/votes/{lang}_votes.json` **Input:** `stage-3-enrich/output/votes/{lang}_votes.json`
**Output:** **Output:**
- `stage-4-merge/output/final/{lang}.json` — fully resolved, ready for seeding - `stage-4-merge/output/final/{lang}.json` — fully resolved, ready for seeding
- `stage-4-merge/output/flagged/{lang}.json` — CEFR majority not reached, needs manual review before seeding - `stage-4-merge/output/flagged/{lang}.json` — CEFR majority not reached, needs manual review before seeding
@ -360,21 +360,15 @@ Each record in `final/{lang}.json` looks like this:
{ "text": "dog", "cefr_level": "A1", "difficulty": "easy" }, { "text": "dog", "cefr_level": "A1", "difficulty": "easy" },
{ "text": "canine", "cefr_level": "B2", "difficulty": "intermediate" } { "text": "canine", "cefr_level": "B2", "difficulty": "intermediate" }
], ],
"it": [ "it": [{ "text": "cane", "cefr_level": "A1", "difficulty": "easy" }]
{ "text": "cane", "cefr_level": "A1", "difficulty": "easy" }
]
}, },
"glosses": { "glosses": {
"en": { "text": "a domesticated carnivorous mammal", "source": "omw" }, "en": { "text": "a domesticated carnivorous mammal", "source": "omw" },
"fr": { "text": "un mammifère carnivore domestiqué", "source": "model_1" } "fr": { "text": "un mammifère carnivore domestiqué", "source": "model_1" }
}, },
"examples": { "examples": {
"en": [ "en": [{ "text": "the dog barked at the stranger", "source": "omw" }],
{ "text": "the dog barked at the stranger", "source": "omw" } "fr": [{ "text": "le chien a aboyé", "source": "model_1" }]
],
"fr": [
{ "text": "le chien a aboyé", "source": "model_1" }
]
}, },
"descriptions": { "descriptions": {
"en": { "en": {
@ -400,6 +394,7 @@ output quality per language. Run this after merge to verify output before
seeding the database. seeding the database.
**Input:** **Input:**
- `stage-4-merge/output/final/{lang}.json` - `stage-4-merge/output/final/{lang}.json`
- `stage-4-merge/output/flagged/{lang}.json` - `stage-4-merge/output/flagged/{lang}.json`
@ -437,7 +432,7 @@ pnpm --filter @lila/pipeline compare
These values are defined in `packages/shared/src/constants.ts` and enforced by database check constraints. The pipeline filters out any entries that violate them. These values are defined in `packages/shared/src/constants.ts` and enforced by database check constraints. The pipeline filters out any entries that violate them.
| Constant | Values | | Constant | Values |
|---|---| | --------------- | ------------------------------------- |
| Languages | `en`, `it`, `de`, `es`, `fr` | | Languages | `en`, `it`, `de`, `es`, `fr` |
| Parts of speech | `noun`, `verb`, `adjective`, `adverb` | | Parts of speech | `noun`, `verb`, `adjective`, `adverb` |
| CEFR levels | `A1`, `A2`, `B1`, `B2`, `C1`, `C2` | | CEFR levels | `A1`, `A2`, `B1`, `B2`, `C1`, `C2` |

View file

@ -144,6 +144,7 @@ docker system prune -a # aggressive — removes all unused images
### API (`apps/api/Dockerfile`) ### API (`apps/api/Dockerfile`)
Multi-stage build: base → deps → dev → builder → runner. The `runner` stage does a fresh `pnpm install --prod` to get correct symlinks. Output is at `apps/api/dist/src/server.js` due to monorepo rootDir configuration. Multi-stage build: base → deps → dev → builder → runner. The `runner` stage does a fresh `pnpm install --prod` to get correct symlinks. Output is at `apps/api/dist/src/server.js` due to monorepo rootDir configuration.
The runner stage copies compiled migration files from the builder (packages/db/drizzle) alongside the application code. The container entrypoint runs migrate.js first, then starts server.js, ensuring schema and code are always in sync on every deploy.
### Frontend (`apps/web/Dockerfile`) ### Frontend (`apps/web/Dockerfile`)
@ -174,12 +175,7 @@ The seeding script (`packages/db/src/seeding-datafiles.ts`) uses `onConflictDoNo
### Schema Migrations ### Schema Migrations
Schema changes are managed by Drizzle. Deploy order matters: Migrations are run automatically on container startup via the CMD in the API Dockerfile. The entrypoint runs migrate.js before starting the server, so the schema is always up to date before the API begins accepting requests. The correct deploy order is enforced automatically.
1. Run migration first (database gets new structure)
2. Deploy new API image (code uses new structure)
Reversing this order causes the API to crash on missing columns/tables.
## Backups ## Backups
@ -248,7 +244,7 @@ Automated build and deploy via Forgejo Actions. On every push to `main`, the pip
### Secrets (stored in Forgejo repo settings → Actions → Secrets) ### Secrets (stored in Forgejo repo settings → Actions → Secrets)
| Secret | Value | | Secret | Value |
|---|---| | ----------------- | ----------------------------------------- |
| REGISTRY_USER | Forgejo username | | REGISTRY_USER | Forgejo username |
| REGISTRY_PASSWORD | Forgejo password | | REGISTRY_PASSWORD | Forgejo password |
| SSH_PRIVATE_KEY | Contents of `~/.ssh/ci-runner` on the VPS | | SSH_PRIVATE_KEY | Contents of `~/.ssh/ci-runner` on the VPS |

View file

@ -10,7 +10,7 @@ and production scripts.
## Hardware (dev machine) ## Hardware (dev machine)
| Component | Spec | | Component | Spec |
|---|---| | --------- | --------------------------------------------------------------- |
| CPU | Intel Core i7-6500U (2 cores / 4 threads @ 3.10 GHz) | | CPU | Intel Core i7-6500U (2 cores / 4 threads @ 3.10 GHz) |
| RAM | 8 GB | | RAM | 8 GB |
| GPU | NVIDIA GeForce GTX 950M — 4 GB VRAM (Maxwell, CUDA compute 5.0) | | GPU | NVIDIA GeForce GTX 950M — 4 GB VRAM (Maxwell, CUDA compute 5.0) |
@ -29,7 +29,7 @@ except Anthropic expose an OpenAI-compatible API, so the same client code
works across all of them — only `baseURL`, `apiKey`, and `model` change. works across all of them — only `baseURL`, `apiKey`, and `model` change.
| Provider | Use case | Cost | Rate limits | | Provider | Use case | Cost | Rate limits |
|---|---|---|---| | ---------------------- | --------------------------------------------- | ------------------ | ---------------------- |
| llama.cpp (local) | Quality testing, overnight dev runs | Free (electricity) | None | | llama.cpp (local) | Quality testing, overnight dev runs | Free (electricity) | None |
| OpenRouter (free tier) | Quality comparison, multi-model evaluation | Free | 50 req/day, 20 req/min | | OpenRouter (free tier) | Quality comparison, multi-model evaluation | Free | 50 req/day, 20 req/min |
| OpenRouter (paid) | Production runs if local quality insufficient | Pay-per-token | None | | OpenRouter (paid) | Production runs if local quality insufficient | Pay-per-token | None |
@ -59,7 +59,7 @@ in hybrid mode, slower than full-GPU but much faster than pure CPU.
Practical estimates for this hardware (~3.5 GB VRAM usable after drivers): Practical estimates for this hardware (~3.5 GB VRAM usable after drivers):
| Model size | Q4 VRAM | Mode | Est. speed | | Model size | Q4 VRAM | Mode | Est. speed |
|---|---|---|---| | ---------- | ------- | ----------------------------- | ------------ |
| 3B | ~2.0 GB | Full GPU | ~1520 tok/s | | 3B | ~2.0 GB | Full GPU | ~1520 tok/s |
| 4B | ~2.5 GB | Full GPU | ~1218 tok/s | | 4B | ~2.5 GB | Full GPU | ~1218 tok/s |
| 7B | ~4.5 GB | Hybrid (~26/32 layers on GPU) | ~812 tok/s | | 7B | ~4.5 GB | Hybrid (~26/32 layers on GPU) | ~812 tok/s |
@ -71,6 +71,7 @@ Two candidates worth testing, covering different points on the size/quality
tradeoff: tradeoff:
**Gemma 4 E4B Instruct (Q4 / UD-Q4_K_XL)** **Gemma 4 E4B Instruct (Q4 / UD-Q4_K_XL)**
- GGUF file: `gemma-4-E4B-it-UD-Q4_K_XL.gguf` (~2.5 GB) - GGUF file: `gemma-4-E4B-it-UD-Q4_K_XL.gguf` (~2.5 GB)
- Source: https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF - Source: https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF
- Runs fully on GPU. Brand new (April 2025), built for edge hardware, 140+ - Runs fully on GPU. Brand new (April 2025), built for edge hardware, 140+
@ -78,6 +79,7 @@ tradeoff:
to test. to test.
**Qwen2.5 7B Instruct (Q4_K_M)** **Qwen2.5 7B Instruct (Q4_K_M)**
- GGUF file: `Qwen2.5-7B-Instruct-Q4_K_M.gguf` (~4.5 GB) - GGUF file: `Qwen2.5-7B-Instruct-Q4_K_M.gguf` (~4.5 GB)
- Source: https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF - Source: https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF
- Runs in hybrid mode (~26 of 32 layers on GPU, rest on CPU), ~812 tok/s. - Runs in hybrid mode (~26 of 32 layers on GPU, rest on CPU), ~812 tok/s.
@ -107,6 +109,7 @@ wget -O models/qwen2.5-3b-instruct-q4_k_m.gguf \
### Starting the server ### Starting the server
**Gemma 4 E4B** (full GPU): **Gemma 4 E4B** (full GPU):
```bash ```bash
./build/bin/llama-server \ ./build/bin/llama-server \
--model models/gemma-4-e4b-it-ud-q4_k_xl.gguf \ --model models/gemma-4-e4b-it-ud-q4_k_xl.gguf \
@ -117,6 +120,7 @@ wget -O models/qwen2.5-3b-instruct-q4_k_m.gguf \
``` ```
**Qwen2.5 7B** (hybrid — tune `--n-gpu-layers` to fit your VRAM): **Qwen2.5 7B** (hybrid — tune `--n-gpu-layers` to fit your VRAM):
```bash ```bash
./build/bin/llama-server \ ./build/bin/llama-server \
--model models/qwen2.5-7b-instruct-q4_k_m.gguf \ --model models/qwen2.5-7b-instruct-q4_k_m.gguf \
@ -164,7 +168,7 @@ object changes.
Ranked by expected multilingual generation quality for en/it/de/fr/es: Ranked by expected multilingual generation quality for en/it/de/fr/es:
| Model ID | Params | Notes | | Model ID | Params | Notes |
|---|---|---| | ---------------------------------------- | --------------------- | ------------------------------------------------------------------------------------ |
| `qwen/qwen3-coder:free` | 480B MoE (35B active) | Best free option. Strong multilingual despite "coder" label. Use as quality ceiling. | | `qwen/qwen3-coder:free` | 480B MoE (35B active) | Best free option. Strong multilingual despite "coder" label. Use as quality ceiling. |
| `qwen/qwen3-next-80b-a3b-instruct:free` | 80B MoE (3B active) | Smaller Qwen, useful comparison point. | | `qwen/qwen3-next-80b-a3b-instruct:free` | 80B MoE (3B active) | Smaller Qwen, useful comparison point. |
| `nvidia/nemotron-3-super-120b-a12b:free` | 120B MoE (12B active) | 262K context, supports structured output. | | `nvidia/nemotron-3-super-120b-a12b:free` | 120B MoE (12B active) | 262K context, supports structured output. |
@ -172,6 +176,7 @@ Ranked by expected multilingual generation quality for en/it/de/fr/es:
| `zhipuai/glm-4.5-air:free` | MoE | Multilingual-focused. | | `zhipuai/glm-4.5-air:free` | MoE | Multilingual-focused. |
**Skip for this pipeline:** **Skip for this pipeline:**
- Llama models — weaker European language generation than Qwen/Gemma - Llama models — weaker European language generation than Qwen/Gemma
- Mistral free tier — requests may be used for model training - Mistral free tier — requests may be used for model training
@ -239,6 +244,7 @@ export const ANTHROPIC_SONNET: ProviderConfig = {
``` ```
Output from each run lands in: Output from each run lands in:
``` ```
stage-3-enrich/test/output/{provider.name}/results.json stage-3-enrich/test/output/{provider.name}/results.json
stage-3-enrich/test/output/{provider.name}/metrics.json stage-3-enrich/test/output/{provider.name}/metrics.json
@ -253,7 +259,7 @@ The evaluate script compares all `metrics.json` files side by side.
The test script measures the following per provider run: The test script measures the following per provider run:
| Metric | What it measures | | Metric | What it measures |
|---|---| | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **JSON parse rate** | % of responses that are valid, schema-compliant JSON. Critical — a failed parse is a wasted call. Target: >97% | | **JSON parse rate** | % of responses that are valid, schema-compliant JSON. Critical — a failed parse is a wasted call. Target: >97% |
| **Field coverage** | % of records where all required fields are present (cefr votes for all translations, descriptions for all languages, glosses/examples for fr/es) | | **Field coverage** | % of records where all required fields are present (cefr votes for all translations, descriptions for all languages, glosses/examples for fr/es) |
| **CEFR agreement** | For records that have a `cefr_source` vote, % where the model agrees. Measures calibration. | | **CEFR agreement** | For records that have a `cefr_source` vote, % where the model agrees. Measures calibration. |
@ -263,7 +269,7 @@ The test script measures the following per provider run:
### Decision thresholds ### Decision thresholds
| Metric | Threshold | Action if below | | Metric | Threshold | Action if below |
|---|---|---| | --------------- | --------- | ---------------------------------------------- |
| JSON parse rate | < 97% | Do not use this model for production | | JSON parse rate | < 97% | Do not use this model for production |
| Field coverage | < 95% | Prompt needs revision before production | | Field coverage | < 95% | Prompt needs revision before production |
| CEFR agreement | < 70% | Model lacks vocabulary knowledge for this task | | CEFR agreement | < 70% | Model lacks vocabulary knowledge for this task |

View file

@ -1,7 +1,23 @@
# notes # notes
## prompt
ive attached the readme of my project. this is my current task:
task description.
1. tell me which files you need to see to get the full context of the problem
2. walk me text-only through the problem and the solution
3. if we need to update multiple files: lets go through them one by one, no matter how many files
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. 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 ## tasks
- **IMPORTANT** db migrations have to be part of the deployment pipeline!!!!!!!!!!!!!!!!!!
- put users in separate db - put users in separate db
- pinning dependencies in package.json files - pinning dependencies in package.json files
- rethink organisation of datafiles and wordlists - rethink organisation of datafiles and wordlists
@ -30,7 +46,7 @@ laptop: verify if docker containers run on startup (they shouldnt)
### vps setup ### vps setup
- monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary) - monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary)
<<<<<<< HEAD <<<<<<< HEAD
- ~~keep the vps clean (e.g. old docker images/containers)~~ ✅ CI/CD pipeline runs `docker image prune -f` after deploy - ~~keep the vps clean (e.g. old docker images/containers)~~ ✅ CI/CD pipeline runs `docker image prune -f` after deploy
### ~~cd/ci pipeline~~ ✅ RESOLVED ### ~~cd/ci pipeline~~ ✅ RESOLVED
@ -39,9 +55,9 @@ Forgejo Actions with runner on VPS, Forgejo built-in container registry. See `de
### ~~postgres backups~~ ✅ RESOLVED ### ~~postgres backups~~ ✅ RESOLVED
Daily pg_dump cron job, 7-day retention, dev laptop auto-sync via rsync. See `deployment.md`. # Daily pg_dump cron job, 7-day retention, dev laptop auto-sync via rsync. See `deployment.md`.
=======
>>>>>>> dev > > > > > > > dev
### try now option ### try now option

View file

@ -0,0 +1,334 @@
# 🔥 GameService Roast: `apps/api/src/services/gameService.ts`
> _"It works on my machine" is not a scalability strategy._
**Project:** lila — Vocabulary Trainer
**File Roasted:** `gameService.ts`
**Date:** $(date)
**Roaster:** Qwen3.6
---
## 📋 Executive Summary
| Metric | Score | Notes |
| ------------- | -------- | ---------------------------------------------------- |
| Code Quality | 8/10 | Clean layering, good types, consistent style |
| Correctness | 6/10 | Race condition + N+1 query are critical |
| Test Coverage | 7/10 | Good happy-path tests, missing concurrency tests |
| Scalability | 5/10 | Will choke at ~100 concurrent users without fixes |
| **Overall** | **7/10** | Solid foundation, but fix the footguns before launch |
---
## 🚨 Critical Issues (Fix Before Production)
### 1. Race Condition: Lost Update in `evaluateAnswer`
**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)
updatedAnswers.delete(submission.questionId);
await store.update(submission.sessionId, { answers: updatedAnswers }); // WRITE
The Attack:
Client submits answer A and answer B for the same question (network retry, bug, or malice)
Both requests read the same session.answers Map (question still present)
Both delete the question from their local copy
Both write back → second write overwrites first
Result: One answer is silently lost, session state desyncs
Why Tests Missed It: Vitest runs tests synchronously. Race conditions require deliberate concurrency testing.
Fix Options:
// Option A: Add atomic operation to store interface
interface GameSessionStore {
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
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!
})
);
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
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);
// Group by termId in JS, then slice to 3 unique distractors per term
const distractorsByTerm = groupByTermId(allDistractors);
Priority: 🔴 CRITICAL — Performance/scalability issue
3. Error Handling Inconsistency
Location: gameService.ts:33-36
if (uniqueDistractors.length < 3) {
throw new Error(`Not enough unique distractors for term: ${term.targetText}`); // 🚩
}
Problem: Raw Error bypasses your errorHandler middleware:
No HTTP status mapping (defaults to 500)
No structured logging
Inconsistent API responses
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}`
);
}
Priority: 🟡 HIGH — Observability & UX issue
⚠️ 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)];
const shuffledTexts = shuffleArray(optionTexts);
const correctOptionId = shuffledTexts.indexOf(term.targetText);
// multiplayerGameService.ts (lines 35-45)
const optionTexts = [correctAnswer.targetText, ...distractorTexts];
const shuffledTexts = shuffle(optionTexts); // Different function, same logic!
const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText);
Risks:
Fix shuffle bias in one place, forget the other
Add new option type (e.g., etymology hint), update one service only
Harder to test core game logic in isolation
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
): { 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
};
};
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
// ...
}
};
The Math:
Math.random() has ~53 bits of entropy (fine for vocab)
Math.floor(rand * n) has modulo bias when n isn't a power of 2
For n=4: bias is ~0.01% (tiny, but non-zero)
When It Matters:
Competitive leaderboards ("option 0 is correct 26% of the time")
Achievement systems based on answer patterns
Security-sensitive features (not applicable here, but principle matters)
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;
};
Priority: 🟢 LOW — Document tradeoff and move on for now
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
❌ 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);
});
// 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();
});
// 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
});
Priority: 🟡 HIGH — Prevents regression on critical fixes
🧼 Code Quality Nitpicks 7. Magic Numbers
// gameService.ts:52
await store.create(sessionId, {...}, 30 _ 60 _ 1000); // What is this?
// termModel.ts:65
.limit(count); // count=6, but why?
// shared/schemas/game.ts:15
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 DISTRACTOR_FETCH_COUNT = 6;
export const GAME_OPTION_COUNT = 4;
export const MIN_UNIQUE_DISTRACTORS = 3;
8. Mutable Reference Leakage
Location: InMemoryGameSessionStore.ts:get()
get(sessionId: string): Promise<GameSessionData | null> {
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.
Fix:
// Option A: Deep clone (simple, works for this data shape)
return Promise.resolve(structuredClone(entry.data));
// Option B: Return readonly view (TypeScript-only protection)
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):
// 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
});
// In gameService.ts:
import { logger } from "../lib/logger.js";
logger.info(
{ userId, sourceLang, targetLang, termCount: terms.length },
"game_session_created"
);
logger.debug(
{ 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()
.orderBy(sql`RANDOM()`) // 🚩 Fine for 10k rows, slow for 1M
The Comment Admits It:
// TODO(post-mvp): ORDER BY RANDOM() sorts the entire filtered result set...
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
LIMIT $1
-- Option B: TABLESAMPLE for approximate sampling (Postgres 9.5+)
FROM terms TABLESAMPLE SYSTEM(10)
WHERE ...
LIMIT $1
-- Option C: Random offset (simple, but still scans)
OFFSET floor(random() _ (SELECT count(_) FROM terms WHERE ...))
Action: Add a ticket to documentation/tickets/t00009.md now.

View file

@ -52,7 +52,7 @@ This is the full vision. The current implementation already covers most of it; r
### What is CUT from the MVP ### What is CUT from the MVP
| Feature | Why cut | | Feature | Why cut |
| ------------------------------- | -------------------------------------- | | --------------------- | ---------- |
| User stats / profiles | Needs auth | | User stats / profiles | Needs auth |
These are not deleted from the plan — they are deferred. The architecture is already designed to support them. See Section 11 (Post-MVP Ladder). These are not deleted from the plan — they are deferred. The architecture is already designed to support them. See Section 11 (Post-MVP Ladder).
@ -64,7 +64,7 @@ These are not deleted from the plan — they are deferred. The architecture is a
The monorepo structure and tooling are already set up. This is the full stack. The monorepo structure and tooling are already set up. This is the full stack.
| Layer | Technology | Status | | Layer | Technology | Status |
| ------------ | ------------------------------ | ----------- | | ------------ | ------------------------------ | ------------------------------------------------------ |
| Monorepo | pnpm workspaces | ✅ | | Monorepo | pnpm workspaces | ✅ |
| Frontend | React 18, Vite, TypeScript | ✅ | | Frontend | React 18, Vite, TypeScript | ✅ |
| Routing | TanStack Router | ✅ | | Routing | TanStack Router | ✅ |
@ -307,7 +307,8 @@ After completing a task: share the code, ask what to refactor and why. The LLM s
| Multiplayer Lobby | Room creation, join by code, WebSocket connection | ✅ | | Multiplayer Lobby | Room creation, join by code, WebSocket connection | ✅ |
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ✅ | | Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen | ✅ |
| Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ | | Hardening (rest) | Rate limiting, error boundaries, monitoring, accessibility | ❌ |
>>>>>>> dev
> > > > > > > dev
### Future Data Model Extensions (deferred, additive) ### Future Data Model Extensions (deferred, additive)

View file

@ -0,0 +1,95 @@
# Ticket Blueprint
Two formats depending on task type. Choose based on whether a meaningful
decision between options was made.
---
## Format A — ADR (architectural/infrastructural decisions)
Use when: you chose between options with long-term consequences.
Prefix: `adr-`
---
# ADR: <title>
## Status
Accepted | Superseded by | Deprecated
## Date
YYYY-MM-DD
## Context
What is the problem? Why does it need to be solved?
## Decision
What was chosen and why in one or two sentences.
## Options considered
### Option A — <name>
Description. Why it was chosen.
### Option B — <name>
Description. Why it was rejected.
## Consequences
- What gets better
- What gets worse or more complex
- Operational implications
- What breaks if this needs to be redone
## Affected files / machines
- List files, servers, or systems touched
## References
- Links to relevant docs
---
## Setup guide / implementation notes
Step-by-step of what was actually done.
---
## Format B — Task (features, fixes, chores)
Use when: routine task with a clear solution.
Prefix: `feat-` / `fix-` / `chore-`
---
# <prefix>: <title>
## Problem
What was wrong or missing?
## Options considered
### Option A — <name>
### Option B — <name>
## Solution
What was done and why.
## Files changed
- `path/to/file.ts`
## Commit
`<type>: <message>`

View file

@ -0,0 +1,107 @@
# ADR: Docker Credential Helper Setup
## Status
Accepted
## Date
2026-04-26
## Context
Docker credentials for `git.lilastudy.com` and `dhi.io` were stored as base64-encoded strings in `~/.docker/config.json` on both the dev laptop and the VPS. Base64 is not encryption — anyone with read access to the file can decode the credentials instantly.
## Decision
Use `pass` (GPG-backed password store) as the Docker credential helper on both machines.
## Options considered
### Option A — `pass` (GPG-backed) ✅
Stores credentials encrypted with a GPG key. Works on headless servers and desktops without GNOME. Industry standard for Linux servers.
### Option B — `secretservice` (GNOME keyring)
Uses the desktop keyring daemon. Not suitable for a headless VPS, and not suitable for an i3 desktop without running `gnome-keyring-daemon` manually.
### Option C — `gnome-libsecret`
Same limitations as Option B.
## Consequences
- Credentials are now GPG-encrypted at rest on both machines
- Requires GPG passphrase entry when Docker needs to pull credentials
in a new session
- Must be set up manually on each machine — not reproducible via the repo
- VPS setup must be repeated if the server is reprovisioned
## Affected machines
- Dev laptop (Debian 13, i3)
- VPS (Debian 13, ARM64, headless)
## References
- [docker docs](https://docs.docker.com/reference/cli/docker/login/#credential-stores)
- [pass docs](https://www.passwordstore.org/)
---
## Setup guide
Repeat these steps on each machine.
### 1. Install dependencies
```bash
sudo apt-get install -y pass gnupg2 golang-docker-credential-helpers
```
### 2. Generate a GPG key
```bash
gpg --full-generate-key
```
Choose RSA, 4096 bits, no expiry. Set a strong passphrase.
### 3. Get the key ID
```bash
gpg --list-secret-keys --keyid-format LONG
```
Copy the hex string after the `/` on the `sec` line.
### 4. Initialise pass
```bash
pass init <your-key-id>
```
### 5. Update `~/.docker/config.json`
Replace the entire file contents with:
```json
{ "credsStore": "pass" }
```
### 6. Re-login to registries
```bash
docker login git.lilastudy.com
# dev laptop only:
docker login dhi.io
```
### 7. Verify
```bash
cat ~/.docker/config.json
```
Should show only `"credsStore": "pass"` with no `auths` block.

View file

@ -0,0 +1,149 @@
# ADR: Change GAME_ROUNDS from strings to numbers
## Status
Accepted
## Date
2026-04-28
## Context
`GAME_ROUNDS` in `packages/shared/src/constants.ts` was typed as `["3", "10"] as const`, making `GameRounds` a string union (`"3" | "10"`). This meant `gameService.ts` had to cast the value with `Number(request.rounds)` deep in business logic — a type conversion happening far from the boundary where data enters the system. The type system was lying: `rounds` was described as a string everywhere but used as a number where it mattered.
## Decision
Change `GAME_ROUNDS` to `[3, 10] as const` and update the Zod schema to use `z.literal(GAME_ROUNDS)` instead of `z.enum(GAME_ROUNDS)`. The single source of truth remains `constants.ts` — adding a new round count (e.g. `20`) requires only editing that file.
## Options considered
### Option A — Numbers everywhere ✅
Change `GAME_ROUNDS` to `[3, 10] as const`. Use `z.literal(GAME_ROUNDS)` in the schema. Update the frontend component state and `SettingGroup` props. Drop `Number()` cast in the service.
Chosen because: JSON carries numbers natively, both ends of the wire are owned by this codebase, and type conversions belong at the boundary — not inside business logic.
### Option B — Keep strings, accept the cast
Leave `GAME_ROUNDS` as `["3", "10"]`. The `Number()` cast stays in `gameService.ts`.
Rejected because: it pushes type conversion into business logic and makes the inferred `GameRequest` type misleading. The cast has to live somewhere — the schema boundary is the right place.
### Option C — Coerce at the schema boundary
Keep `GAME_ROUNDS` as numbers but use `z.coerce.number().pipe(z.literal(GAME_ROUNDS))` so the frontend can keep sending strings.
Rejected because: coercion is for untrusted or uncontrolled inputs (form fields, query params, third-party clients). We control both ends of the wire. Coercing a self-inflicted type mismatch is treating a wound we gave ourselves.
## Consequences
- `GameRounds` is now `3 | 10` instead of `"3" | "10"`
- `Number(request.rounds)` cast removed from `gameService.ts`
- `SettingGroup` in `GameSetup.tsx` now accepts `string | number` options
- `useState<string>` for rounds changed to `useState<number>`
- Adding a new round count requires only editing `GAME_ROUNDS` in `constants.ts`
- `z.enum` cannot be used for number literals — `z.literal` must be used instead (this is a Zod constraint, not a project convention)
## Affected files
- `packages/shared/src/constants.ts`
- `packages/shared/src/schemas/game.ts`
- `apps/api/src/services/gameService.ts`
- `apps/api/src/services/gameService.test.ts`
- `apps/api/src/controllers/gameController.test.ts`
- `apps/web/src/components/game/GameSetup.tsx`
## References
- [Zod literals](https://zod.dev/?id=literals)
---
## Setup guide / implementation notes
1. In `packages/shared/src/constants.ts`, change:
```ts
export const GAME_ROUNDS = ["3", "10"] as const;
```
to:
```ts
export const GAME_ROUNDS = [3, 10] as const;
```
2. In `packages/shared/src/schemas/game.ts`, change:
```ts
rounds: z.enum(GAME_ROUNDS),
```
to:
```ts
rounds: z.literal(GAME_ROUNDS),
```
3. In `apps/api/src/services/gameService.ts`, change:
```ts
Number(request.rounds),
```
to:
```ts
request.rounds,
```
4. In `apps/api/src/services/gameService.test.ts`, change:
```ts
rounds: "3",
```
to:
```ts
rounds: 3,
```
5. In `apps/api/src/controllers/gameController.test.ts`, change:
```ts
rounds: "3",
```
to:
```ts
rounds: 3,
```
Also add a pinning test before the refactor:
```ts
it("returns 400 when rounds has an invalid value", async () => {
const res = await request(app)
.post("/api/v1/game/start")
.send({ ...validBody, rounds: "invalid" });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
```
6. In `apps/web/src/components/game/GameSetup.tsx`:
- Update `SettingGroup` props to accept `string | number`:
```ts
type SettingGroupProps = {
options: readonly (string | number)[];
selected: string | number;
onSelect: (value: string | number) => void;
};
```
- Update `LABELS` lookup to `LABELS[String(option)]`
- Change rounds state from `useState<string>` to `useState<number>`

View file

@ -0,0 +1,37 @@
# refactor: extract shuffleArray to lib/utils, rename correctAnswers to terms
## Problem
Two readability issues in `gameService.ts`:
1. `shuffle` was defined as a private function at the bottom of `gameService.ts`, after the function that calls it. It is a pure generic utility with no dependency on game domain logic, so it had no business living there.
2. The variable holding terms fetched from the database was named `correctAnswers`. These are word pairs — they only become "correct answers" once options are built around them. The name was premature and misleading.
## Options considered
### Option A — Move `shuffle` up in the same file
Simple, no new files. Fixes the ordering issue but keeps a generic utility buried in domain code.
### Option B — Extract to `lib/utils.ts`
Move `shuffle` (renamed `shuffleArray`) to `apps/api/src/lib/utils.ts` and import it. Cleaner separation: domain logic stays in services, generic utilities live in `lib/`.
Chosen because `lib/` already exists, the function is reusable, and it gives future utilities a home.
## Solution
- Created `apps/api/src/lib/utils.ts` with `shuffleArray`
- Renamed `shuffle``shuffleArray` for clarity at the call site
- Removed the inline `shuffle` from `gameService.ts` and imported from `lib/utils.ts`
- Renamed `correctAnswers``terms` and `correctAnswer``term` throughout `gameService.ts`
## Files changed
- `apps/api/src/lib/utils.ts` — created
- `apps/api/src/services/gameService.ts` — removed `shuffle`, updated import, renamed variables
## Commit
`refactor: extract shuffleArray to lib/utils, rename correctAnswers to terms`

View file

@ -0,0 +1,110 @@
# ADR: Dependency injection for GameSessionStore via composition root
## Status
Accepted
## Date
2026-04-28
## Context
`gameService.ts` had a module-level singleton:
```ts
const gameSessionStore = new InMemoryGameSessionStore();
```
This made the store invisible to anything outside the file. The `GameSessionStore` interface existed to make the store swappable — but the singleton made that impossible without editing the service itself. Tests shared the same instance across every test run, creating the potential for ghost sessions leaking between tests. The controller also briefly owned the singleton during an intermediate step, which violated the principle that controllers should only handle HTTP concerns.
## Decision
Adopt a composition root pattern. The store is created once in `createApp()` and passed down through factory functions: `createApiRouter(store)``createGameRouter(store)``createGameController(store)` → service calls. Neither the controller nor the service knows which implementation they're working with — they both see `GameSessionStore`.
## Options considered
### Option A — Composition root ✅
Convert routers and controllers to factory functions. Create the store in `createApp()` and pass it down. The store is created once, at the top, and injected through the call chain.
Chosen because: clean separation of concerns, no layer below `createApp()` needs to know the concrete implementation, swapping to `ValKeyGameSessionStore` is a one-line change in `app.ts`, and tests get fresh isolated store instances.
### Option B — Keep singleton in controller
Leave the store as a module-level singleton in `gameController.ts`. Controllers own the store lifetime.
Rejected because: controllers should only handle HTTP concerns. Owning infrastructure lifetime is not an HTTP concern.
### Option C — DI framework (tsyringe, inversify)
Use a proper dependency injection container.
Rejected because: overkill for the current scale. The composition root pattern achieves the same result with zero dependencies and no magic.
## Consequences
- Swapping `InMemoryGameSessionStore` for `ValKeyGameSessionStore` requires editing one line in `app.ts`
- Tests create fresh `InMemoryGameSessionStore` instances per test — no shared state, no ghost sessions
- Routers and controllers are now factory functions instead of module-level singletons — slightly more verbose but explicitly testable
- `gameController.test.ts` uses `createApp()` which owns the store — controller tests remain integration-style and unaffected
- All layers below `createApp()` depend only on the `GameSessionStore` interface, never the concrete implementation
## Affected files
- `apps/api/src/app.ts` — creates the store, passes to `createApiRouter`
- `apps/api/src/routes/apiRouter.ts` — converted to `createApiRouter(store)` factory
- `apps/api/src/routes/gameRouter.ts` — converted to `createGameRouter(store)` factory
- `apps/api/src/controllers/gameController.ts` — converted to `createGameController(store)` factory
- `apps/api/src/services/gameService.ts``store` parameter added to both functions, singleton removed
- `apps/api/src/services/gameService.test.ts` — fresh store per describe block via `beforeEach`
## References
- [Composition root pattern](https://blog.ploeh.dk/2011/07/28/CompositionRoot/)
---
## Setup guide / implementation notes
1. `gameService.ts` — remove module-level singleton, add `store: GameSessionStore` parameter to `createGameSession` and `evaluateAnswer`
2. `gameController.ts` — convert exported functions to a factory:
```ts
export const createGameController = (store: GameSessionStore) => ({
createGame: async (req, res, next) => { ... },
submitAnswer: async (req, res, next) => { ... },
});
```
3. `gameRouter.ts` — convert to factory:
```ts
export const createGameRouter = (store: GameSessionStore): Router => {
const router = express.Router();
const controller = createGameController(store);
router.post("/start", controller.createGame);
router.post("/answer", controller.submitAnswer);
return router;
};
```
4. `apiRouter.ts` — convert to factory:
```ts
export const createApiRouter = (store: GameSessionStore): Router => {
const router = express.Router();
router.use("/game", createGameRouter(store));
return router;
};
```
5. `app.ts` — create the store at the composition root:
```ts
const store = new InMemoryGameSessionStore();
app.use("/api/v1", createApiRouter(store));
```
6. `gameService.test.ts` — add `let store: InMemoryGameSessionStore` to each `describe` block, reset in `beforeEach`, pass to every service call

View file

@ -0,0 +1,93 @@
# ADR: Session lifecycle — TTL and replay protection
## Status
Accepted
## Date
2026-04-28
## Context
`InMemoryGameSessionStore` had no TTL and no cleanup mechanism. Every session created stayed in memory until the process restarted. Additionally, `evaluateAnswer` never removed a question from the answer key after evaluating it, meaning the same question could be submitted multiple times and receive a valid result each time — a potential exploit in multiplayer and a correctness bug in singleplayer.
## Decision
Add a `ttlMs` parameter to `GameSessionStore.create()` so both the in-memory and future Valkey implementations handle expiry consistently. Delete questions from the answer key after evaluation. Delete the session when the last question is answered.
## Options considered
### Option A — Delete on last answer only
Simple. Covers replay protection and normal session completion. Abandoned sessions (player starts game, never finishes) still leak memory.
### Option B — Delete on last answer + TTL on the interface ✅
Delete on answer covers normal flow. TTL covers abandoned sessions. TTL on the interface means `ValKeyGameSessionStore` can use Redis-native `EXPIRE` without any interface changes during migration.
Chosen because it closes the memory leak entirely and makes the Valkey migration a zero-interface-change operation.
### Option C — TTL hardcoded inside InMemoryGameSessionStore only
Simpler short-term. But the interface wouldn't carry the TTL parameter, so `ValKeyGameSessionStore` would need a different mechanism — inconsistency between implementations.
## Consequences
- Sessions expire after 30 minutes of inactivity regardless of completion state
- Submitting the same question twice throws `NotFoundError` on the second attempt
- Sessions are deleted automatically when the last question is answered
- `GameSessionStore.create()` now requires a `ttlMs` argument — any future implementation must honour it
- `ValKeyGameSessionStore` can implement TTL via Redis `EXPIRE` with no interface changes
- `InMemoryGameSessionStore` stores `{ data, expiresAt }` entries instead of raw `GameSessionData` — expiry is checked lazily on `get()`
## Affected files
- `apps/api/src/gameSessionStore/GameSessionStore.ts``ttlMs` added to `create`
- `apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts` — TTL implementation
- `apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts` — new test file
- `apps/api/src/services/gameService.ts` — passes TTL to `store.create`, deletes question after evaluation, deletes session when empty
- `apps/api/src/services/gameService.test.ts` — replay protection and session cleanup tests added
## References
- [Redis EXPIRE command](https://redis.io/commands/expire/)
---
## Setup guide / implementation notes
1. `GameSessionStore.ts` — add `ttlMs` to `create`:
```ts
create(sessionId: string, data: GameSessionData, ttlMs: number): Promise<void>;
```
2. `InMemoryGameSessionStore.ts` — wrap stored data with expiry:
```ts
type SessionEntry = { data: GameSessionData; expiresAt: number };
```
Check expiry on `get()`, delete expired entries lazily.
3. `gameService.ts` — pass TTL when creating session:
```ts
await store.create(sessionId, { answers: answerKey }, 30 * 60 * 1000);
```
After evaluating an answer:
```ts
session.answers.delete(submission.questionId);
if (session.answers.size === 0) {
await store.delete(submission.sessionId);
}
```
4. When implementing `ValKeyGameSessionStore`, pass `ttlMs` to Redis `EXPIRE`:
```ts
await valkey.set(sessionId, serialize(data), "EX", Math.ceil(ttlMs / 1000));
```

View file

@ -0,0 +1,132 @@
# ADR: Session ownership check and AuthenticatedRequest type
## Status
Accepted
## Date
2026-04-28
## Context
`evaluateAnswer` accepted any `sessionId` without verifying it belonged to the requesting user. The only protection was the unguessability of a UUID — security through obscurity. If a user intercepted or guessed another user's `sessionId`, they could submit answers on their behalf.
Additionally, protected controller handlers typed their `req` parameter as `Request`, making `session` optional even though `requireAuth` middleware guarantees it is present. This required non-null assertions (`req.session!`) in business logic — a type assertion that could cause a runtime crash if middleware ordering ever changed.
## Decision
Store `userId` in `GameSessionData`. Pass `userId` from the controller into both `createGameSession` and `evaluateAnswer`. Assert ownership on evaluation — if the session's `userId` doesn't match the requesting user's ID, throw `NotFoundError`. Introduce `AuthenticatedRequest` to eliminate non-null assertions in protected handlers.
## Options considered
### Option A — AuthenticatedRequest type ✅
Define `AuthenticatedRequest = Request & { session: { session: Session; user: User } }` in `types/express.d.ts`. Use it in protected controller handlers instead of `Request`. Requires a single `as express.RequestHandler` cast at route registration due to Express's type limitations.
Chosen because: eliminates dangerous non-null assertions in business logic. The cast at route registration is a necessary cast caused by a third-party library limitation, not uncertain logic.
### Option B — Non-null assertion (`req.session!`)
Keep `Request` on all handlers. Assert `req.session!` at every usage.
Rejected because: non-null assertions in business logic are dangerous — if middleware ordering ever changes, the assertion silently passes and crashes at runtime.
---
### Option C — NotFoundError (404) on ownership failure ✅
When a session exists but belongs to a different user, throw `NotFoundError` with the same message as a missing session.
Chosen because: session IDs are opaque secrets. Returning 403 would confirm to the caller that the session ID is valid and belongs to someone else — information they shouldn't have. This pattern is used by GitHub, AWS, and most security-conscious APIs.
### Option D — ForbiddenError (403) on ownership failure
Explicit error that distinguishes "not found" from "not allowed".
Rejected because: for user-owned resources identified by opaque IDs, confirming existence to an unauthorised caller is an information leak. 404 is the industry standard for this case.
## Consequences
- Alice cannot submit answers for Bob's session — ownership is verified at the service layer
- `req.session.user.id` is accessible without non-null assertions in protected handlers
- `GameSessionData` now carries `userId` — any future `GameSessionStore` implementation must store and return it
- Route registration requires `as express.RequestHandler` cast for protected handlers — one cast per route, in wiring code only
- `ValKeyGameSessionStore` must serialise and deserialise `userId` alongside `answers`
## Affected files
- `apps/api/src/types/express.d.ts``AuthenticatedRequest` type added
- `apps/api/src/gameSessionStore/GameSessionStore.ts``userId` added to `GameSessionData`
- `apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts` — updated data fixtures
- `apps/api/src/services/gameService.ts``userId` parameter added to both functions, ownership assertion in `evaluateAnswer`
- `apps/api/src/services/gameService.test.ts` — updated all calls, ownership test added
- `apps/api/src/controllers/gameController.ts` — extracts `userId` from `req.session.user.id`, passes to service calls
- `apps/api/src/routes/gameRouter.ts``as express.RequestHandler` cast at route registration
## References
- [OWASP: Insecure Direct Object Reference](https://owasp.org/www-community/attacks/Insecure_Direct_Object_Reference)
- [HTTP 403 vs 404 for authorization failures](https://stackoverflow.com/questions/3297048/403-forbidden-vs-401-unauthorized-http-responses)
---
## Setup guide / implementation notes
1. `types/express.d.ts` — add:
```ts
export type AuthenticatedRequest = Request & {
session: { session: Session; user: User };
};
```
2. `GameSessionStore.ts` — add `userId` to `GameSessionData`:
```ts
export type GameSessionData = {
answers: Map<string, number>;
userId: string;
};
```
3. `gameService.ts` — add `userId` to both function signatures:
```ts
export const createGameSession = async (
request: GameRequest,
store: GameSessionStore,
userId: string,
): Promise<GameSession>
```
Store it on create:
```ts
await store.create(
sessionId,
{ answers: answerKey, userId },
30 * 60 * 1000,
);
```
Assert on evaluate:
```ts
if (!session || session.userId !== userId) {
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
}
```
4. `gameController.ts` — extract from authenticated request:
```ts
req.session.user.id;
```
5. `gameRouter.ts` — cast at registration:
```ts
router.post("/start", controller.createGame as express.RequestHandler);
router.post("/answer", controller.submitAnswer as express.RequestHandler);
```

View file

@ -0,0 +1,41 @@
# feat: guard against empty terms in createGameSession
## Problem
If `getGameTerms` returned an empty array — no vocabulary data matched the requested language, difficulty, and part of speech combination — `createGameSession` would create a session with zero questions and return it. The frontend would receive an empty `questions` array, attempt to render the first question, find nothing, and crash with no useful error message shown to the user.
## Options considered
### Option A — `NotFoundError` (404) ✅
Throw when `terms.length === 0` before any session is created. The combination of filters yielded no data — that's a "not found" situation.
Chosen because: the request is technically valid (all filter values are recognised), but the combination has no matching data. 404 is the correct semantic response.
### Option B — `ValidationError` (400)
Treat empty results as a bad request.
Rejected because: the client sent valid input. The problem is missing data, not invalid input. 400 would be misleading.
## Solution
Added a guard in `createGameSession` immediately after `getGameTerms`:
```ts
if (terms.length === 0) {
throw new NotFoundError("No terms found for the given filters");
}
```
The error propagates through the controller's `try/catch` to the error handler, which returns a clean 404 response. No session is created.
## Files changed
- `apps/api/src/services/gameService.ts` — empty terms guard added
- `apps/api/src/services/gameService.test.ts` — pinning test added
- `apps/api/src/controllers/gameController.test.ts` — pinning test added at HTTP layer
## Commit
`feat: guard against empty terms in createGameSession`

View file

@ -0,0 +1,56 @@
# fix: deduplicate distractors, replace tautological test
## Problem
Two issues in `createGameSession` and its test suite:
1. If `getDistractors` returned the correct answer as one of the distractors, `createGameSession` would include it in the options array without filtering it out. `indexOf` would then find the first occurrence, which might not be the one intended as the correct answer — producing a question where the correct answer appears twice and the stored `correctOptionId` is wrong.
2. The test `"distractors are never the correct answer"` was tautological — it filtered the correct answer out of the options array, then asserted the remaining items were not the correct answer. It was testing that `Array.filter()` works. It could never fail.
## Options considered
### Option A — Filter duplicates after fetching, request extra distractors as buffer ✅
Filter out any distractor that matches the correct answer after fetching. Request 6 distractors instead of 3 to ensure enough remain after deduplication. Take the first 3 valid ones with `slice(0, 3)`.
Chosen because: deduplication at the service layer is the right place — `getDistractors` shouldn't need to know what the correct answer is. Requesting extra provides a buffer against collisions.
### Option B — Fix `getDistractors` to never return the correct answer
Add a NOT filter in the database query.
Not chosen for this ticket — the database query is in `@lila/db` and is a separate concern. The service layer should be defensive regardless of what the model layer returns.
## Solution
- Filter distractors against the correct answer before building options:
```ts
const uniqueDistractors = distractorTexts.filter(
(t) => t !== term.targetText,
);
const optionTexts = [term.targetText, ...uniqueDistractors.slice(0, 3)];
```
- Request 6 distractors instead of 3 to account for potential duplicates
- Replaced tautological test with a test that actually exercises the duplicate case:
```ts
it("correct answer appears exactly once even if getDistractors returns a duplicate", ...)
```
- Added distractor failure propagation test:
```ts
it("propagates getDistractors failure", ...)
```
## Files changed
- `apps/api/src/services/gameService.ts` — deduplication logic, distractor count increased to 6
- `apps/api/src/services/gameService.test.ts` — tautological test replaced, failure test added
## Commit
`fix: deduplicate distractors, replace tautological test, add distractor failure test`

View file

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

View file

@ -6,13 +6,24 @@
"scripts": { "scripts": {
"build": "pnpm --filter @lila/shared build && pnpm --filter @lila/db build && pnpm --filter @lila/api build", "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\"", "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": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check ." "format:check": "prettier --check .",
"typecheck": "pnpm -r typecheck"
}, },
"packageManager": "pnpm@10.33.0", "lint-staged": {
"**/*.{ts,tsx}": [
"prettier --write",
"eslint --fix"
],
"**/*.{js,mjs,json,md,css,html}": [
"prettier --write"
]
},
"packageManager": "pnpm@10.33.1",
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tanstack/eslint-plugin-router": "^1.161.6", "@tanstack/eslint-plugin-router": "^1.161.6",
@ -22,6 +33,8 @@
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.57.1", "typescript-eslint": "^8.57.1",

View file

@ -0,0 +1,3 @@
CREATE TABLE "dummy" (
"id" serial PRIMARY KEY NOT NULL
);

View file

@ -0,0 +1 @@
DROP TABLE "dummy" CASCADE;

View file

@ -110,12 +110,8 @@
"name": "account_user_id_user_id_fk", "name": "account_user_id_user_id_fk",
"tableFrom": "account", "tableFrom": "account",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -149,12 +145,8 @@
"name": "deck_terms_deck_id_decks_id_fk", "name": "deck_terms_deck_id_decks_id_fk",
"tableFrom": "deck_terms", "tableFrom": "deck_terms",
"tableTo": "decks", "tableTo": "decks",
"columnsFrom": [ "columnsFrom": ["deck_id"],
"deck_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@ -162,12 +154,8 @@
"name": "deck_terms_term_id_terms_id_fk", "name": "deck_terms_term_id_terms_id_fk",
"tableFrom": "deck_terms", "tableFrom": "deck_terms",
"tableTo": "terms", "tableTo": "terms",
"columnsFrom": [ "columnsFrom": ["term_id"],
"term_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -175,10 +163,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"deck_terms_deck_id_term_id_pk": { "deck_terms_deck_id_term_id_pk": {
"name": "deck_terms_deck_id_term_id_pk", "name": "deck_terms_deck_id_term_id_pk",
"columns": [ "columns": ["deck_id", "term_id"]
"deck_id",
"term_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@ -265,10 +250,7 @@
"unique_deck_name": { "unique_deck_name": {
"name": "unique_deck_name", "name": "unique_deck_name",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["name", "source_language"]
"name",
"source_language"
]
} }
}, },
"policies": {}, "policies": {},
@ -336,12 +318,8 @@
"name": "lobbies_host_user_id_user_id_fk", "name": "lobbies_host_user_id_user_id_fk",
"tableFrom": "lobbies", "tableFrom": "lobbies",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["host_user_id"],
"host_user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -351,9 +329,7 @@
"lobbies_code_unique": { "lobbies_code_unique": {
"name": "lobbies_code_unique", "name": "lobbies_code_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["code"]
"code"
]
} }
}, },
"policies": {}, "policies": {},
@ -402,12 +378,8 @@
"name": "lobby_players_lobby_id_lobbies_id_fk", "name": "lobby_players_lobby_id_lobbies_id_fk",
"tableFrom": "lobby_players", "tableFrom": "lobby_players",
"tableTo": "lobbies", "tableTo": "lobbies",
"columnsFrom": [ "columnsFrom": ["lobby_id"],
"lobby_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@ -415,12 +387,8 @@
"name": "lobby_players_user_id_user_id_fk", "name": "lobby_players_user_id_user_id_fk",
"tableFrom": "lobby_players", "tableFrom": "lobby_players",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -428,10 +396,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"lobby_players_lobby_id_user_id_pk": { "lobby_players_lobby_id_user_id_pk": {
"name": "lobby_players_lobby_id_user_id_pk", "name": "lobby_players_lobby_id_user_id_pk",
"columns": [ "columns": ["lobby_id", "user_id"]
"lobby_id",
"user_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@ -515,12 +480,8 @@
"name": "session_user_id_user_id_fk", "name": "session_user_id_user_id_fk",
"tableFrom": "session", "tableFrom": "session",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -530,9 +491,7 @@
"session_token_unique": { "session_token_unique": {
"name": "session_token_unique", "name": "session_token_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["token"]
"token"
]
} }
}, },
"policies": {}, "policies": {},
@ -588,12 +547,8 @@
"name": "term_glosses_term_id_terms_id_fk", "name": "term_glosses_term_id_terms_id_fk",
"tableFrom": "term_glosses", "tableFrom": "term_glosses",
"tableTo": "terms", "tableTo": "terms",
"columnsFrom": [ "columnsFrom": ["term_id"],
"term_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -603,10 +558,7 @@
"unique_term_gloss": { "unique_term_gloss": {
"name": "unique_term_gloss", "name": "unique_term_gloss",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["term_id", "language_code"]
"term_id",
"language_code"
]
} }
}, },
"policies": {}, "policies": {},
@ -641,12 +593,8 @@
"name": "term_topics_term_id_terms_id_fk", "name": "term_topics_term_id_terms_id_fk",
"tableFrom": "term_topics", "tableFrom": "term_topics",
"tableTo": "terms", "tableTo": "terms",
"columnsFrom": [ "columnsFrom": ["term_id"],
"term_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@ -654,12 +602,8 @@
"name": "term_topics_topic_id_topics_id_fk", "name": "term_topics_topic_id_topics_id_fk",
"tableFrom": "term_topics", "tableFrom": "term_topics",
"tableTo": "topics", "tableTo": "topics",
"columnsFrom": [ "columnsFrom": ["topic_id"],
"topic_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -667,10 +611,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"term_topics_term_id_topic_id_pk": { "term_topics_term_id_topic_id_pk": {
"name": "term_topics_term_id_topic_id_pk", "name": "term_topics_term_id_topic_id_pk",
"columns": [ "columns": ["term_id", "topic_id"]
"term_id",
"topic_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@ -744,10 +685,7 @@
"unique_source_id": { "unique_source_id": {
"name": "unique_source_id", "name": "unique_source_id",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["source", "source_id"]
"source",
"source_id"
]
} }
}, },
"policies": {}, "policies": {},
@ -803,9 +741,7 @@
"topics_slug_unique": { "topics_slug_unique": {
"name": "topics_slug_unique", "name": "topics_slug_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["slug"]
"slug"
]
} }
}, },
"policies": {}, "policies": {},
@ -901,12 +837,8 @@
"name": "translations_term_id_terms_id_fk", "name": "translations_term_id_terms_id_fk",
"tableFrom": "translations", "tableFrom": "translations",
"tableTo": "terms", "tableTo": "terms",
"columnsFrom": [ "columnsFrom": ["term_id"],
"term_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -916,11 +848,7 @@
"unique_translations": { "unique_translations": {
"name": "unique_translations", "name": "unique_translations",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["term_id", "language_code", "text"]
"term_id",
"language_code",
"text"
]
} }
}, },
"policies": {}, "policies": {},
@ -997,9 +925,7 @@
"user_email_unique": { "user_email_unique": {
"name": "user_email_unique", "name": "user_email_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["email"]
"email"
]
} }
}, },
"policies": {}, "policies": {},
@ -1080,9 +1006,5 @@
"roles": {}, "roles": {},
"policies": {}, "policies": {},
"views": {}, "views": {},
"_meta": { "_meta": { "columns": {}, "schemas": {}, "tables": {} }
"columns": {},
"schemas": {},
"tables": {}
}
} }

View file

@ -110,12 +110,8 @@
"name": "account_user_id_user_id_fk", "name": "account_user_id_user_id_fk",
"tableFrom": "account", "tableFrom": "account",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -149,12 +145,8 @@
"name": "deck_terms_deck_id_decks_id_fk", "name": "deck_terms_deck_id_decks_id_fk",
"tableFrom": "deck_terms", "tableFrom": "deck_terms",
"tableTo": "decks", "tableTo": "decks",
"columnsFrom": [ "columnsFrom": ["deck_id"],
"deck_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@ -162,12 +154,8 @@
"name": "deck_terms_term_id_terms_id_fk", "name": "deck_terms_term_id_terms_id_fk",
"tableFrom": "deck_terms", "tableFrom": "deck_terms",
"tableTo": "terms", "tableTo": "terms",
"columnsFrom": [ "columnsFrom": ["term_id"],
"term_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -175,10 +163,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"deck_terms_deck_id_term_id_pk": { "deck_terms_deck_id_term_id_pk": {
"name": "deck_terms_deck_id_term_id_pk", "name": "deck_terms_deck_id_term_id_pk",
"columns": [ "columns": ["deck_id", "term_id"]
"deck_id",
"term_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@ -265,10 +250,7 @@
"unique_deck_name": { "unique_deck_name": {
"name": "unique_deck_name", "name": "unique_deck_name",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["name", "source_language"]
"name",
"source_language"
]
} }
}, },
"policies": {}, "policies": {},
@ -336,12 +318,8 @@
"name": "lobbies_host_user_id_user_id_fk", "name": "lobbies_host_user_id_user_id_fk",
"tableFrom": "lobbies", "tableFrom": "lobbies",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["host_user_id"],
"host_user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -351,9 +329,7 @@
"lobbies_code_unique": { "lobbies_code_unique": {
"name": "lobbies_code_unique", "name": "lobbies_code_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["code"]
"code"
]
} }
}, },
"policies": {}, "policies": {},
@ -402,12 +378,8 @@
"name": "lobby_players_lobby_id_lobbies_id_fk", "name": "lobby_players_lobby_id_lobbies_id_fk",
"tableFrom": "lobby_players", "tableFrom": "lobby_players",
"tableTo": "lobbies", "tableTo": "lobbies",
"columnsFrom": [ "columnsFrom": ["lobby_id"],
"lobby_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@ -415,12 +387,8 @@
"name": "lobby_players_user_id_user_id_fk", "name": "lobby_players_user_id_user_id_fk",
"tableFrom": "lobby_players", "tableFrom": "lobby_players",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -428,10 +396,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"lobby_players_lobby_id_user_id_pk": { "lobby_players_lobby_id_user_id_pk": {
"name": "lobby_players_lobby_id_user_id_pk", "name": "lobby_players_lobby_id_user_id_pk",
"columns": [ "columns": ["lobby_id", "user_id"]
"lobby_id",
"user_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@ -515,12 +480,8 @@
"name": "session_user_id_user_id_fk", "name": "session_user_id_user_id_fk",
"tableFrom": "session", "tableFrom": "session",
"tableTo": "user", "tableTo": "user",
"columnsFrom": [ "columnsFrom": ["user_id"],
"user_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -530,9 +491,7 @@
"session_token_unique": { "session_token_unique": {
"name": "session_token_unique", "name": "session_token_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["token"]
"token"
]
} }
}, },
"policies": {}, "policies": {},
@ -604,12 +563,8 @@
"name": "term_examples_term_id_terms_id_fk", "name": "term_examples_term_id_terms_id_fk",
"tableFrom": "term_examples", "tableFrom": "term_examples",
"tableTo": "terms", "tableTo": "terms",
"columnsFrom": [ "columnsFrom": ["term_id"],
"term_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -619,11 +574,7 @@
"unique_term_example": { "unique_term_example": {
"name": "unique_term_example", "name": "unique_term_example",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["term_id", "language_code", "text"]
"term_id",
"language_code",
"text"
]
} }
}, },
"policies": {}, "policies": {},
@ -684,12 +635,8 @@
"name": "term_glosses_term_id_terms_id_fk", "name": "term_glosses_term_id_terms_id_fk",
"tableFrom": "term_glosses", "tableFrom": "term_glosses",
"tableTo": "terms", "tableTo": "terms",
"columnsFrom": [ "columnsFrom": ["term_id"],
"term_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -699,10 +646,7 @@
"unique_term_gloss": { "unique_term_gloss": {
"name": "unique_term_gloss", "name": "unique_term_gloss",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["term_id", "language_code"]
"term_id",
"language_code"
]
} }
}, },
"policies": {}, "policies": {},
@ -737,12 +681,8 @@
"name": "term_topics_term_id_terms_id_fk", "name": "term_topics_term_id_terms_id_fk",
"tableFrom": "term_topics", "tableFrom": "term_topics",
"tableTo": "terms", "tableTo": "terms",
"columnsFrom": [ "columnsFrom": ["term_id"],
"term_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
}, },
@ -750,12 +690,8 @@
"name": "term_topics_topic_id_topics_id_fk", "name": "term_topics_topic_id_topics_id_fk",
"tableFrom": "term_topics", "tableFrom": "term_topics",
"tableTo": "topics", "tableTo": "topics",
"columnsFrom": [ "columnsFrom": ["topic_id"],
"topic_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -763,10 +699,7 @@
"compositePrimaryKeys": { "compositePrimaryKeys": {
"term_topics_term_id_topic_id_pk": { "term_topics_term_id_topic_id_pk": {
"name": "term_topics_term_id_topic_id_pk", "name": "term_topics_term_id_topic_id_pk",
"columns": [ "columns": ["term_id", "topic_id"]
"term_id",
"topic_id"
]
} }
}, },
"uniqueConstraints": {}, "uniqueConstraints": {},
@ -840,10 +773,7 @@
"unique_source_id": { "unique_source_id": {
"name": "unique_source_id", "name": "unique_source_id",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["source", "source_id"]
"source",
"source_id"
]
} }
}, },
"policies": {}, "policies": {},
@ -899,9 +829,7 @@
"topics_slug_unique": { "topics_slug_unique": {
"name": "topics_slug_unique", "name": "topics_slug_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["slug"]
"slug"
]
} }
}, },
"policies": {}, "policies": {},
@ -997,12 +925,8 @@
"name": "translations_term_id_terms_id_fk", "name": "translations_term_id_terms_id_fk",
"tableFrom": "translations", "tableFrom": "translations",
"tableTo": "terms", "tableTo": "terms",
"columnsFrom": [ "columnsFrom": ["term_id"],
"term_id" "columnsTo": ["id"],
],
"columnsTo": [
"id"
],
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "onUpdate": "no action"
} }
@ -1012,11 +936,7 @@
"unique_translations": { "unique_translations": {
"name": "unique_translations", "name": "unique_translations",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["term_id", "language_code", "text"]
"term_id",
"language_code",
"text"
]
} }
}, },
"policies": {}, "policies": {},
@ -1093,9 +1013,7 @@
"user_email_unique": { "user_email_unique": {
"name": "user_email_unique", "name": "user_email_unique",
"nullsNotDistinct": false, "nullsNotDistinct": false,
"columns": [ "columns": ["email"]
"email"
]
} }
}, },
"policies": {}, "policies": {},
@ -1176,9 +1094,5 @@
"roles": {}, "roles": {},
"policies": {}, "policies": {},
"views": {}, "views": {},
"_meta": { "_meta": { "columns": {}, "schemas": {}, "tables": {} }
"columns": {},
"schemas": {},
"tables": {}
}
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -64,6 +64,20 @@
"when": 1776695279870, "when": 1776695279870,
"tag": "0008_far_energizer", "tag": "0008_far_energizer",
"breakpoints": true "breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1776928720684,
"tag": "0009_rapid_cobalt_man",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1776929932845,
"tag": "0010_thankful_reaper",
"breakpoints": true
} }
] ]
} }

View file

@ -4,9 +4,10 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc", "build": "rm -rf dist && tsc",
"generate": "drizzle-kit generate", "generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate" "migrate": "drizzle-kit migrate",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@lila/shared": "workspace:*", "@lila/shared": "workspace:*",

View file

@ -6,10 +6,13 @@ import { dirname } from "path";
import * as schema from "./db/schema.js"; import * as schema from "./db/schema.js";
config({ 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/termModel.js";
export * from "./models/lobbyModel.js"; export * from "./models/lobbyModel.js";

View file

@ -0,0 +1,25 @@
import { config } from "dotenv";
import { drizzle } from "drizzle-orm/node-postgres";
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { Pool } from "pg";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
config({
path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"),
});
const pool = new Pool({ connectionString: process.env["DATABASE_URL"]! });
const db = drizzle(pool);
console.log("starting database migrations...");
await migrate(db, {
migrationsFolder: resolve(
dirname(fileURLToPath(import.meta.url)),
"../../drizzle",
),
});
await pool.end();
console.log("database migrations complete.");

View file

@ -5,11 +5,12 @@
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"outDir": "./dist", "outDir": "./dist",
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["vitest/globals"], "types": ["vitest/globals"]
}, },
"include": [ "include": [
"src", "src",
"vitest.config.ts", "vitest.config.ts",
"../../data-pipeline/archive/packages-db-src-old-seeding-scripts/data", "drizzle.config.ts",
], "../../data-pipeline/archive/packages-db-src-old-seeding-scripts/data"
]
} }

View file

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

View file

@ -4,7 +4,7 @@ export type SupportedLanguageCode = (typeof SUPPORTED_LANGUAGE_CODES)[number];
export const SUPPORTED_POS = ["noun", "verb", "adjective", "adverb"] as const; export const SUPPORTED_POS = ["noun", "verb", "adjective", "adverb"] as const;
export type SupportedPos = (typeof SUPPORTED_POS)[number]; export type SupportedPos = (typeof SUPPORTED_POS)[number];
export const GAME_ROUNDS = ["3", "10"] as const; export const GAME_ROUNDS = [3, 10] as const;
export type GameRounds = (typeof GAME_ROUNDS)[number]; export type GameRounds = (typeof GAME_ROUNDS)[number];
export const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] as const; export const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] as const;

View file

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

View file

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

View file

@ -12,7 +12,7 @@ export const GameRequestSchema = z.object({
target_language: z.enum(SUPPORTED_LANGUAGE_CODES), target_language: z.enum(SUPPORTED_LANGUAGE_CODES),
pos: z.enum(SUPPORTED_POS), pos: z.enum(SUPPORTED_POS),
difficulty: z.enum(DIFFICULTY_LEVELS), difficulty: z.enum(DIFFICULTY_LEVELS),
rounds: z.enum(GAME_ROUNDS), rounds: z.literal(GAME_ROUNDS),
}); });
export type GameRequest = z.infer<typeof GameRequestSchema>; export type GameRequest = z.infer<typeof GameRequestSchema>;

396
pnpm-lock.yaml generated
View file

@ -16,7 +16,7 @@ importers:
version: 1.161.6(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3) version: 1.161.6(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^4.1.0 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: concurrently:
specifier: ^9.2.1 specifier: ^9.2.1
version: 9.2.1 version: 9.2.1
@ -32,6 +32,12 @@ importers:
eslint-plugin-react-refresh: eslint-plugin-react-refresh:
specifier: ^0.5.2 specifier: ^0.5.2
version: 0.5.2(eslint@10.0.3(jiti@2.6.1)) 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: prettier:
specifier: ^3.8.1 specifier: ^3.8.1
version: 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) version: 8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
vitest: vitest:
specifier: ^4.1.0 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: apps/api:
dependencies: dependencies:
@ -55,13 +61,22 @@ importers:
version: link:../../packages/shared version: link:../../packages/shared
better-auth: better-auth:
specifier: ^1.6.2 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: cors:
specifier: ^2.8.6 specifier: ^2.8.6
version: 2.8.6 version: 2.8.6
express: express:
specifier: ^5.2.1 specifier: ^5.2.1
version: 5.2.1 version: 5.2.1
express-rate-limit:
specifier: ^8.4.0
version: 8.4.0(express@5.2.1)
helmet:
specifier: ^8.1.0
version: 8.1.0
resend:
specifier: ^6.12.2
version: 6.12.2
ws: ws:
specifier: ^8.20.0 specifier: ^8.20.0
version: 8.20.0 version: 8.20.0
@ -92,7 +107,7 @@ importers:
version: link:../../packages/shared version: link:../../packages/shared
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.2 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': '@tanstack/react-router':
specifier: ^1.168.1 specifier: ^1.168.1
version: 1.168.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 1.168.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -101,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) 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: better-auth:
specifier: ^1.6.2 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: react:
specifier: ^19.2.4 specifier: ^19.2.4
version: 19.2.4 version: 19.2.4
react-dom: react-dom:
specifier: ^19.2.4 specifier: ^19.2.4
version: 19.2.4(react@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: tailwindcss:
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2 version: 4.2.2
devDependencies: devDependencies:
'@tanstack/router-plugin': '@tanstack/router-plugin':
specifier: ^1.167.2 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': '@types/node':
specifier: ^24.12.0 specifier: ^24.12.0
version: 24.12.0 version: 24.12.0
@ -126,13 +144,13 @@ importers:
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^6.0.1 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: jsdom:
specifier: ^29.0.1 specifier: ^29.0.1
version: 29.0.1(@noble/hashes@2.2.0) version: 29.0.1(@noble/hashes@2.2.0)
vite: vite:
specifier: ^8.0.1 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: data-pipeline:
dependencies: dependencies:
@ -1078,6 +1096,9 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.7': '@rolldown/pluginutils@1.0.0-rc.7':
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@ -1476,14 +1497,26 @@ packages:
ajv@6.14.0: ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
ansi-escapes@7.3.0:
resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
engines: {node: '>=18'}
ansi-regex@5.0.1: ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@4.3.0: ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'} engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
ansis@4.2.0: ansis@4.2.0:
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -1668,6 +1701,14 @@ packages:
chownr@1.1.4: chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} 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: cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -1687,10 +1728,17 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
combined-stream@1.0.8: combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
component-emitter@1.3.1: component-emitter@1.3.1:
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
@ -1903,6 +1951,9 @@ packages:
electron-to-chromium@1.5.321: electron-to-chromium@1.5.321:
resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
emoji-regex@8.0.0: emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@ -1921,6 +1972,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
environment@1.1.0:
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
engines: {node: '>=18'}
es-define-property@1.0.1: es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2037,6 +2092,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
expand-template@2.0.3: expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -2045,6 +2103,12 @@ packages:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
express-rate-limit@8.4.0:
resolution: {integrity: sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express@5.2.1: express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@ -2061,6 +2125,9 @@ packages:
fast-safe-stringify@2.1.1: fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} 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: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -2135,6 +2202,10 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*} 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: get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2185,6 +2256,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
helmet@8.1.0:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'}
hermes-estree@0.25.1: hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@ -2202,6 +2277,11 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} 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: iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2227,6 +2307,10 @@ packages:
ini@1.3.8: ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -2243,6 +2327,10 @@ packages:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'} engines: {node: '>=8'}
is-fullwidth-code-point@5.1.0:
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
engines: {node: '>=18'}
is-glob@4.0.3: is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2402,10 +2490,23 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'} 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: locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
log-update@6.1.0:
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
engines: {node: '>=18'}
lru-cache@11.2.7: lru-cache@11.2.7:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@ -2463,6 +2564,10 @@ packages:
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
hasBin: true hasBin: true
mimic-function@5.0.1:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
mimic-response@3.1.0: mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2528,6 +2633,10 @@ packages:
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} 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: optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -2606,6 +2715,9 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'} engines: {node: '>=12'}
postal-mime@2.7.4:
resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==}
postcss@8.5.8: postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@ -2697,9 +2809,25 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} 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: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} 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: rolldown@1.0.0-rc.10:
resolution: {integrity: sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==} resolution: {integrity: sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -2792,12 +2920,30 @@ packages:
siginfo@2.0.0: siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-concat@1.0.1: simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1: simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} 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: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2824,6 +2970,9 @@ packages:
stackback@0.0.2: stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
statuses@2.0.2: statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -2831,10 +2980,22 @@ packages:
std-env@4.0.0: std-env@4.0.0:
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} 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: string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} 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: string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@ -2842,6 +3003,10 @@ packages:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'} engines: {node: '>=8'}
strip-ansi@7.2.0:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
strip-json-comments@2.0.1: strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -2862,6 +3027,9 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
svix@1.90.0:
resolution: {integrity: sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==}
symbol-tree@3.2.4: symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@ -2999,6 +3167,11 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} 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: vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -3126,6 +3299,10 @@ packages:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@ -3164,6 +3341,11 @@ packages:
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} 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: yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@ -3796,6 +3978,8 @@ snapshots:
'@rolldown/pluginutils@1.0.0-rc.7': {} '@rolldown/pluginutils@1.0.0-rc.7': {}
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@tailwindcss/node@4.2.2': '@tailwindcss/node@4.2.2':
@ -3859,12 +4043,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
'@tailwindcss/oxide-win32-x64-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: dependencies:
'@tailwindcss/node': 4.2.2 '@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2 '@tailwindcss/oxide': 4.2.2
tailwindcss: 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)': '@tanstack/eslint-plugin-router@1.161.6(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
@ -3936,7 +4120,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0)
@ -3953,7 +4137,7 @@ snapshots:
zod: 3.25.76 zod: 3.25.76
optionalDependencies: optionalDependencies:
'@tanstack/react-router': 1.168.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@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: transitivePeerDependencies:
- supports-color - supports-color
@ -4172,12 +4356,12 @@ snapshots:
'@typescript-eslint/types': 8.57.1 '@typescript-eslint/types': 8.57.1
eslint-visitor-keys: 5.0.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: dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7 '@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: dependencies:
'@bcoe/v8-coverage': 1.0.2 '@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.1.0 '@vitest/utils': 4.1.0
@ -4189,7 +4373,7 @@ snapshots:
obug: 2.1.1 obug: 2.1.1
std-env: 4.0.0 std-env: 4.0.0
tinyrainbow: 3.1.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': '@vitest/expect@4.1.0':
dependencies: dependencies:
@ -4200,22 +4384,22 @@ snapshots:
chai: 6.2.2 chai: 6.2.2
tinyrainbow: 3.1.0 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: dependencies:
'@vitest/spy': 4.1.0 '@vitest/spy': 4.1.0
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.21 magic-string: 0.30.21
optionalDependencies: 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 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: dependencies:
'@vitest/spy': 4.1.0 '@vitest/spy': 4.1.0
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.21 magic-string: 0.30.21
optionalDependencies: 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': '@vitest/pretty-format@4.1.0':
dependencies: dependencies:
@ -4261,12 +4445,20 @@ snapshots:
json-schema-traverse: 0.4.1 json-schema-traverse: 0.4.1
uri-js: 4.4.1 uri-js: 4.4.1
ansi-escapes@7.3.0:
dependencies:
environment: 1.1.0
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@4.3.0: ansi-styles@4.3.0:
dependencies: dependencies:
color-convert: 2.0.1 color-convert: 2.0.1
ansi-styles@6.2.3: {}
ansis@4.2.0: {} ansis@4.2.0: {}
anymatch@3.1.3: anymatch@3.1.3:
@ -4305,7 +4497,7 @@ snapshots:
baseline-browser-mapping@2.10.9: {} 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: 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/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)) '@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))
@ -4331,12 +4523,12 @@ snapshots:
pg: 8.20.0 pg: 8.20.0
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(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: transitivePeerDependencies:
- '@cloudflare/workers-types' - '@cloudflare/workers-types'
- '@opentelemetry/api' - '@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: 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/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)) '@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))
@ -4362,7 +4554,7 @@ snapshots:
pg: 8.20.0 pg: 8.20.0
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(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: transitivePeerDependencies:
- '@cloudflare/workers-types' - '@cloudflare/workers-types'
- '@opentelemetry/api' - '@opentelemetry/api'
@ -4474,6 +4666,15 @@ snapshots:
chownr@1.1.4: {} 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: cliui@8.0.1:
dependencies: dependencies:
string-width: 4.2.3 string-width: 4.2.3
@ -4490,10 +4691,14 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
colorette@2.0.20: {}
combined-stream@1.0.8: combined-stream@1.0.8:
dependencies: dependencies:
delayed-stream: 1.0.0 delayed-stream: 1.0.0
commander@14.0.3: {}
component-emitter@1.3.1: {} component-emitter@1.3.1: {}
concurrently@9.2.1: concurrently@9.2.1:
@ -4603,6 +4808,8 @@ snapshots:
electron-to-chromium@1.5.321: {} electron-to-chromium@1.5.321: {}
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
encodeurl@2.0.0: {} encodeurl@2.0.0: {}
@ -4618,6 +4825,8 @@ snapshots:
entities@6.0.1: {} entities@6.0.1: {}
environment@1.1.0: {}
es-define-property@1.0.1: {} es-define-property@1.0.1: {}
es-errors@1.3.0: {} es-errors@1.3.0: {}
@ -4817,10 +5026,17 @@ snapshots:
etag@1.8.1: {} etag@1.8.1: {}
eventemitter3@5.0.4: {}
expand-template@2.0.3: {} expand-template@2.0.3: {}
expect-type@1.3.0: {} expect-type@1.3.0: {}
express-rate-limit@8.4.0(express@5.2.1):
dependencies:
express: 5.2.1
ip-address: 10.1.0
express@5.2.1: express@5.2.1:
dependencies: dependencies:
accepts: 2.0.0 accepts: 2.0.0
@ -4862,6 +5078,8 @@ snapshots:
fast-safe-stringify@2.1.1: {} fast-safe-stringify@2.1.1: {}
fast-sha256@1.3.0: {}
fdir@6.5.0(picomatch@4.0.3): fdir@6.5.0(picomatch@4.0.3):
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
@ -4930,6 +5148,8 @@ snapshots:
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}
get-east-asian-width@1.5.0: {}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@ -4982,6 +5202,8 @@ snapshots:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
helmet@8.1.0: {}
hermes-estree@0.25.1: {} hermes-estree@0.25.1: {}
hermes-parser@0.25.1: hermes-parser@0.25.1:
@ -5004,6 +5226,8 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 toidentifier: 1.0.1
husky@9.1.7: {}
iconv-lite@0.7.2: iconv-lite@0.7.2:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
@ -5020,6 +5244,8 @@ snapshots:
ini@1.3.8: {} ini@1.3.8: {}
ip-address@10.1.0: {}
ipaddr.js@1.9.1: {} ipaddr.js@1.9.1: {}
is-binary-path@2.1.0: is-binary-path@2.1.0:
@ -5030,6 +5256,10 @@ snapshots:
is-fullwidth-code-point@3.0.0: {} 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: is-glob@4.0.3:
dependencies: dependencies:
is-extglob: 2.1.1 is-extglob: 2.1.1
@ -5161,10 +5391,36 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-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: locate-path@6.0.0:
dependencies: dependencies:
p-locate: 5.0.0 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@11.2.7: {}
lru-cache@5.1.1: lru-cache@5.1.1:
@ -5209,6 +5465,8 @@ snapshots:
mime@2.6.0: {} mime@2.6.0: {}
mimic-function@5.0.1: {}
mimic-response@3.1.0: {} mimic-response@3.1.0: {}
minimatch@10.2.4: minimatch@10.2.4:
@ -5253,6 +5511,10 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
optionator@0.9.4: optionator@0.9.4:
dependencies: dependencies:
deep-is: 0.1.4 deep-is: 0.1.4
@ -5325,6 +5587,8 @@ snapshots:
picomatch@4.0.3: {} picomatch@4.0.3: {}
postal-mime@2.7.4: {}
postcss@8.5.8: postcss@8.5.8:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
@ -5421,8 +5685,20 @@ snapshots:
require-from-string@2.0.2: {} 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: {} 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: rolldown@1.0.0-rc.10:
dependencies: dependencies:
'@oxc-project/types': 0.120.0 '@oxc-project/types': 0.120.0
@ -5547,6 +5823,8 @@ snapshots:
siginfo@2.0.0: {} siginfo@2.0.0: {}
signal-exit@4.1.0: {}
simple-concat@1.0.1: {} simple-concat@1.0.1: {}
simple-get@4.0.1: simple-get@4.0.1:
@ -5555,6 +5833,21 @@ snapshots:
once: 1.4.0 once: 1.4.0
simple-concat: 1.0.1 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-js@1.2.1: {}
source-map-support@0.5.21: source-map-support@0.5.21:
@ -5574,16 +5867,34 @@ snapshots:
stackback@0.0.2: {} stackback@0.0.2: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
statuses@2.0.2: {} statuses@2.0.2: {}
std-env@4.0.0: {} std-env@4.0.0: {}
string-argv@0.3.2: {}
string-width@4.2.3: string-width@4.2.3:
dependencies: dependencies:
emoji-regex: 8.0.0 emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0 is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1 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: string_decoder@1.3.0:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
@ -5592,6 +5903,10 @@ snapshots:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1
strip-ansi@7.2.0:
dependencies:
ansi-regex: 6.2.2
strip-json-comments@2.0.1: {} strip-json-comments@2.0.1: {}
superagent@10.3.0: superagent@10.3.0:
@ -5624,6 +5939,11 @@ snapshots:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
svix@1.90.0:
dependencies:
standardwebhooks: 1.0.0
uuid: 10.0.0
symbol-tree@3.2.4: {} symbol-tree@3.2.4: {}
tailwindcss@4.2.2: {} tailwindcss@4.2.2: {}
@ -5754,9 +6074,11 @@ snapshots:
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
uuid@10.0.0: {}
vary@1.1.2: {} 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: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.3 picomatch: 4.0.3
@ -5769,8 +6091,9 @@ snapshots:
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.6.1 jiti: 2.6.1
tsx: 4.21.0 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: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.3 picomatch: 4.0.3
@ -5783,11 +6106,12 @@ snapshots:
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.6.1 jiti: 2.6.1
tsx: 4.21.0 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: dependencies:
'@vitest/expect': 4.1.0 '@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/pretty-format': 4.1.0
'@vitest/runner': 4.1.0 '@vitest/runner': 4.1.0
'@vitest/snapshot': 4.1.0 '@vitest/snapshot': 4.1.0
@ -5804,7 +6128,7 @@ snapshots:
tinyexec: 1.0.4 tinyexec: 1.0.4
tinyglobby: 0.2.15 tinyglobby: 0.2.15
tinyrainbow: 3.1.0 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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
@ -5814,10 +6138,10 @@ snapshots:
- msw - msw
optional: true 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: dependencies:
'@vitest/expect': 4.1.0 '@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/pretty-format': 4.1.0
'@vitest/runner': 4.1.0 '@vitest/runner': 4.1.0
'@vitest/snapshot': 4.1.0 '@vitest/snapshot': 4.1.0
@ -5834,7 +6158,7 @@ snapshots:
tinyexec: 1.0.4 tinyexec: 1.0.4
tinyglobby: 0.2.15 tinyglobby: 0.2.15
tinyrainbow: 3.1.0 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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
@ -5882,6 +6206,12 @@ snapshots:
string-width: 4.2.3 string-width: 4.2.3
strip-ansi: 6.0.1 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: {} wrappy@1.0.2: {}
ws@8.20.0: {} ws@8.20.0: {}
@ -5906,6 +6236,8 @@ snapshots:
yallist@3.1.1: {} yallist@3.1.1: {}
yaml@2.8.3: {}
yargs-parser@21.1.1: {} yargs-parser@21.1.1: {}
yargs@17.7.2: yargs@17.7.2:

View file

@ -4,7 +4,7 @@
{ "path": "./packages/db" }, { "path": "./packages/db" },
{ "path": "./apps/web" }, { "path": "./apps/web" },
{ "path": "./apps/api" }, { "path": "./apps/api" },
{ "path": "./data-pipeline" }, { "path": "./data-pipeline" }
], ],
"files": [], "files": []
} }