Compare commits

...

11 commits

Author SHA1 Message Date
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
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
12 changed files with 1400 additions and 20 deletions

View file

@ -5,7 +5,6 @@ import { authLimiter, gameLimiter, lobbyLimiter } from "./rateLimiters.js";
import type { Session, User } from "better-auth"; import type { Session, User } from "better-auth";
// Minimal app to test the limiter in isolation
function createTestApp() { function createTestApp() {
const app = express(); const app = express();
app.set("trust proxy", 1); app.set("trust proxy", 1);
@ -20,29 +19,51 @@ describe("authLimiter", () => {
let app: ReturnType<typeof createTestApp>; let app: ReturnType<typeof createTestApp>;
beforeEach(() => { beforeEach(() => {
// Fresh app = fresh in-memory store = counters reset between tests
app = createTestApp(); app = createTestApp();
}); });
it("allows requests under the limit through", async () => { it("allows requests under the limit through on sensitive endpoints", async () => {
const res = await request(app).post("/api/auth/sign-in"); const res = await request(app).post("/api/auth/sign-in");
expect(res.status).toBe(200); expect(res.status).toBe(200);
}); });
it("returns 429 after exceeding the limit", async () => { it("returns 429 after exceeding the limit on sensitive endpoints", async () => {
const limit = 20; const limit = 20;
for (let i = 0; i < limit; i++) { for (let i = 0; i < limit; i++) {
await request(app).post("/api/auth/sign-in"); await request(app).post("/api/auth/sign-in");
} }
const res = await request(app).post("/api/auth/sign-in"); const res = await request(app).post("/api/auth/sign-in");
expect(res.status).toBe(429); expect(res.status).toBe(429);
expect(res.body).toEqual({
success: false,
error: "Too many requests, please try again later.",
});
}); });
it("sets RateLimit headers on responses", async () => { 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"); const res = await request(app).post("/api/auth/sign-in");
expect(res.headers).toHaveProperty("ratelimit"); expect(res.headers).toHaveProperty("ratelimit");
}); });

View file

@ -13,6 +13,15 @@ export const authLimiter = rateLimit({
limit: 20, limit: 20,
standardHeaders: "draft-8", standardHeaders: "draft-8",
legacyHeaders: false, legacyHeaders: false,
skip: (req) => {
const path = req.path;
return (
path.includes("/get-session") ||
path.includes("/sign-out") ||
path.startsWith("/callback/") ||
path.includes("/callback/")
);
},
message: { message: {
success: false, success: false,
error: "Too many requests, please try again later.", error: "Too many requests, please try again later.",

View file

@ -126,6 +126,14 @@ describe("createGameSession", () => {
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)).rejects.toThrow(
"connection refused",
);
});
}); });
describe("evaluateAnswer", () => { describe("evaluateAnswer", () => {

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

@ -8,9 +8,6 @@ Labels: `[feature]` `[infra]` `[security]` `[ux]` `[debt]`
Things that are actively in progress or should be picked up immediately. Mostly operational risk and the remaining phase 7 hardening work. Things that are actively in progress or should be picked up immediately. Mostly operational risk and the remaining phase 7 hardening work.
- **Rate limiting on API endpoints** `[security]`
At minimum: auth endpoints (brute force prevention) and game endpoints (spam prevention). Consider `express-rate-limit`.
- **404 and redirect handling** `[ux]` - **404 and redirect handling** `[ux]`
Unknown routes return raw errors. Add a catch-all route on the frontend for client-side 404s. Consider a Caddy fallback for unrecognized subdomains. Unknown routes return raw errors. Add a catch-all route on the frontend for client-side 404s. Consider a Caddy fallback for unrecognized subdomains.
@ -29,12 +26,12 @@ Things that are actively in progress or should be picked up immediately. Mostly
- **Hetzner domain migration check** `[infra]` - **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. Verify whether the lilastudy.com domain needs to be migrated following a Hetzner DNS change. Check Hetzner dashboard for any pending migration notice.
- **Security headers with helmet** `[security]`
Add helmet middleware to set secure HTTP response headers. One-liner: app.use(helmet()). Covers headers like X-Content-Type-Options, X-Frame-Options, and Content-Security-Policy.
- **Conditionally register OAuth providers** `[debt]` - **Conditionally register OAuth providers** `[debt]`
Better Auth logs warnings when social providers are registered without credentials (`Social provider google is missing clientId or clientSecret`). Instead of registering all providers unconditionally, only add a provider to the config when its credentials are present in the environment. Keeps local dev clean for contributors who don't have OAuth apps set up. Better Auth logs warnings when social providers are registered without credentials (`Social provider google is missing clientId or clientSecret`). Instead of registering all providers unconditionally, only add a provider to the config when its credentials are present in the environment. Keeps local dev clean for contributors who don't have OAuth apps set up.
- **Multiplayer GameService unit tests** `[debt]`
round evaluation, scoring, tie-breaking, timeout handling
--- ---
## next ## next
@ -78,6 +75,9 @@ Clearly planned work, not yet started. No hard ordering — sequence based on wh
- **Configurable game settings in multiplayer lobby** `[feature]` - **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. 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
--- ---
## later ## later
@ -117,6 +117,8 @@ Directionally right, timing is unclear. Revisit when the next/now work is done.
Shipped milestones, newest first. Shipped milestones, newest first.
- **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 — 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 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 5: Multiplayer game** — real-time simultaneous play, 15s server timer, live scoring, winner screen

View file

@ -12,7 +12,7 @@
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check ." "format:check": "prettier --check ."
}, },
"packageManager": "pnpm@10.33.0", "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",

View file

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

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,13 @@
"when": 1776928720684, "when": 1776928720684,
"tag": "0009_rapid_cobalt_man", "tag": "0009_rapid_cobalt_man",
"breakpoints": true "breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1776929932845,
"tag": "0010_thankful_reaper",
"breakpoints": true
} }
] ]
} }

View file

@ -10,7 +10,6 @@ import {
index, index,
boolean, boolean,
integer, integer,
serial,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { sql, relations } from "drizzle-orm"; import { sql, relations } from "drizzle-orm";
@ -331,5 +330,3 @@ export const lobbyPlayersRelations = relations(lobby_players, ({ one }) => ({
}), }),
user: one(user, { fields: [lobby_players.userId], references: [user.id] }), user: one(user, { fields: [lobby_players.userId], references: [user.id] }),
})); }));
export const dummy = pgTable("dummy", { id: serial("id").primaryKey() });

View file

@ -17,7 +17,7 @@ console.log("starting database migrations...");
await migrate(db, { await migrate(db, {
migrationsFolder: resolve( migrationsFolder: resolve(
dirname(fileURLToPath(import.meta.url)), dirname(fileURLToPath(import.meta.url)),
"../drizzle", "../../drizzle",
), ),
}); });