Compare commits
11 commits
76192667e0
...
3971642848
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3971642848 | ||
|
|
ee719aaa58 | ||
|
|
4ece995385 | ||
|
|
762cf91f86 | ||
|
|
5b266d7435 | ||
|
|
ec84f76fb2 | ||
|
|
59049002fc | ||
|
|
c57fc5a98b | ||
|
|
cc0d2c7f8f | ||
|
|
d67263e44a | ||
|
|
2328ad445d |
12 changed files with 1400 additions and 20 deletions
|
|
@ -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");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
125
apps/api/src/services/multiplayerGameService.test.ts
Normal file
125
apps/api/src/services/multiplayerGameService.test.ts
Normal 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
1
packages/db/drizzle/0010_thankful_reaper.sql
Normal file
1
packages/db/drizzle/0010_thankful_reaper.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE "dummy" CASCADE;
|
||||||
1184
packages/db/drizzle/meta/0010_snapshot.json
Normal file
1184
packages/db/drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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() });
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue