From 4623ea634ad7624c3ab40bd0e60a55646a43061c Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 23 Apr 2026 10:40:34 +0200 Subject: [PATCH 1/6] updating documentatin --- documentation/backlog.md | 5 ++--- documentation/deployment.md | 8 ++------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/documentation/backlog.md b/documentation/backlog.md index 8515238..c1cd276 100644 --- a/documentation/backlog.md +++ b/documentation/backlog.md @@ -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. -- **Migrations in the deploy pipeline** `[infra]` `[debt]` - Run `drizzle migrate` as a step in the CI/CD pipeline before the API container is restarted. Deploying code before schema is applied causes crashes. See `deployment.md` — deploy order is currently documented but not enforced. - - **Rate limiting on API endpoints** `[security]` At minimum: auth endpoints (brute force prevention) and game endpoints (spam prevention). Consider `express-rate-limit`. @@ -58,6 +55,7 @@ Clearly planned work, not yet started. No hard ordering — sequence based on wh - **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. @@ -113,6 +111,7 @@ Directionally right, timing is unclear. Revisit when the next/now work is done. Shipped milestones, newest first. +- **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 diff --git a/documentation/deployment.md b/documentation/deployment.md index afc4d8b..de1d3a0 100644 --- a/documentation/deployment.md +++ b/documentation/deployment.md @@ -144,6 +144,7 @@ docker system prune -a # aggressive — removes all unused images ### 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. +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`) @@ -174,12 +175,7 @@ The seeding script (`packages/db/src/seeding-datafiles.ts`) uses `onConflictDoNo ### Schema Migrations -Schema changes are managed by Drizzle. Deploy order matters: - -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. +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. ## Backups From 1dfe391233cb8d8ad330cb991c239721ba2cfebd Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 23 Apr 2026 11:12:57 +0200 Subject: [PATCH 2/6] adding task --- documentation/backlog.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/documentation/backlog.md b/documentation/backlog.md index c1cd276..23bd2cc 100644 --- a/documentation/backlog.md +++ b/documentation/backlog.md @@ -32,6 +32,9 @@ Things that are actively in progress or should be picked up immediately. Mostly - **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]` + 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. + --- ## next From 9893ead689b5ae5bd1e4b2b5e08c5cee2697e7c2 Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 23 Apr 2026 11:13:11 +0200 Subject: [PATCH 3/6] 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 --- apps/api/package.json | 2 + apps/api/src/app.test.ts | 39 ++++ apps/api/src/app.ts | 8 +- apps/api/src/middleware/rateLimiters.test.ts | 179 +++++++++++++++++++ apps/api/src/middleware/rateLimiters.ts | 44 +++++ pnpm-lock.yaml | 29 +++ 6 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/app.test.ts create mode 100644 apps/api/src/middleware/rateLimiters.test.ts create mode 100644 apps/api/src/middleware/rateLimiters.ts diff --git a/apps/api/package.json b/apps/api/package.json index bfd2878..870a77d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,8 @@ "better-auth": "^1.6.2", "cors": "^2.8.6", "express": "^5.2.1", + "express-rate-limit": "^8.4.0", + "helmet": "^8.1.0", "ws": "^8.20.0" }, "devDependencies": { diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts new file mode 100644 index 0000000..f41d3f0 --- /dev/null +++ b/apps/api/src/app.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 93d1864..635a92a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,20 +1,26 @@ import express from "express"; import type { Express } from "express"; import { toNodeHandler } from "better-auth/node"; +import cors from "cors"; +import helmet from "helmet"; import { auth } from "./lib/auth.js"; import { apiRouter } from "./routes/apiRouter.js"; import { errorHandler } from "./middleware/errorHandler.js"; -import cors from "cors"; +import { authLimiter } from "./middleware/rateLimiters.js"; export function createApp() { const app: Express = express(); + app.set("trust proxy", 1); + app.use(helmet()); + app.use( cors({ origin: process.env["CORS_ORIGIN"] || "http://localhost:5173", credentials: true, }), ); + app.use("/api/auth", authLimiter); app.all("/api/auth/*splat", toNodeHandler(auth)); app.use(express.json()); app.use("/api/v1", apiRouter); diff --git a/apps/api/src/middleware/rateLimiters.test.ts b/apps/api/src/middleware/rateLimiters.test.ts new file mode 100644 index 0000000..29a219f --- /dev/null +++ b/apps/api/src/middleware/rateLimiters.test.ts @@ -0,0 +1,179 @@ +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"; + +// Minimal app to test the limiter in isolation +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; + + beforeEach(() => { + // Fresh app = fresh in-memory store = counters reset between tests + app = createTestApp(); + }); + + it("allows requests under the limit through", async () => { + const res = await request(app).post("/api/auth/sign-in"); + expect(res.status).toBe(200); + }); + + it("returns 429 after exceeding the limit", 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); + expect(res.body).toEqual({ + success: false, + error: "Too many requests, please try again later.", + }); + }); + + it("sets RateLimit headers on 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); + }); +}); diff --git a/apps/api/src/middleware/rateLimiters.ts b/apps/api/src/middleware/rateLimiters.ts new file mode 100644 index 0000000..479be03 --- /dev/null +++ b/apps/api/src/middleware/rateLimiters.ts @@ -0,0 +1,44 @@ +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, + 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.", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef2ffc4..15acc4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,12 @@ importers: express: specifier: ^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 ws: specifier: ^8.20.0 version: 8.20.0 @@ -2045,6 +2051,12 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} 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: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -2185,6 +2197,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -2227,6 +2243,10 @@ packages: ini@1.3.8: 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: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4821,6 +4841,11 @@ snapshots: 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: dependencies: accepts: 2.0.0 @@ -4982,6 +5007,8 @@ snapshots: dependencies: function-bind: 1.1.2 + helmet@8.1.0: {} + hermes-estree@0.25.1: {} hermes-parser@0.25.1: @@ -5020,6 +5047,8 @@ snapshots: ini@1.3.8: {} + ip-address@10.1.0: {} + ipaddr.js@1.9.1: {} is-binary-path@2.1.0: From e6f4a39dadecc079069a6d20272422190e75b53f Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 23 Apr 2026 20:32:16 +0200 Subject: [PATCH 4/6] adding task --- documentation/backlog.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/documentation/backlog.md b/documentation/backlog.md index 23bd2cc..557ef1f 100644 --- a/documentation/backlog.md +++ b/documentation/backlog.md @@ -108,6 +108,9 @@ Directionally right, timing is unclear. Revisit when the next/now work is done. - **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 From 9ab2bc3d0e67e89d423a992d3fd1aa60cee517cf Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 23 Apr 2026 20:36:36 +0200 Subject: [PATCH 5/6] 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. --- apps/api/src/routes/gameRouter.ts | 3 +++ apps/api/src/routes/lobbyRouter.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/apps/api/src/routes/gameRouter.ts b/apps/api/src/routes/gameRouter.ts index f65bfb6..850a146 100644 --- a/apps/api/src/routes/gameRouter.ts +++ b/apps/api/src/routes/gameRouter.ts @@ -2,9 +2,12 @@ import express from "express"; import type { Router } from "express"; import { createGame, submitAnswer } from "../controllers/gameController.js"; import { requireAuth } from "../middleware/authMiddleware.js"; +import { gameLimiter } from "../middleware/rateLimiters.js"; export const gameRouter: Router = express.Router(); gameRouter.use(requireAuth); +gameRouter.use(gameLimiter); + gameRouter.post("/start", createGame); gameRouter.post("/answer", submitAnswer); diff --git a/apps/api/src/routes/lobbyRouter.ts b/apps/api/src/routes/lobbyRouter.ts index 5bd82dd..5cc24c9 100644 --- a/apps/api/src/routes/lobbyRouter.ts +++ b/apps/api/src/routes/lobbyRouter.ts @@ -5,10 +5,12 @@ import { joinLobbyHandler, } from "../controllers/lobbyController.js"; import { requireAuth } from "../middleware/authMiddleware.js"; +import { lobbyLimiter } from "../middleware/rateLimiters.js"; export const lobbyRouter: Router = express.Router(); lobbyRouter.use(requireAuth); +lobbyRouter.use(lobbyLimiter); lobbyRouter.post("/", createLobbyHandler); lobbyRouter.post("/:code/join", joinLobbyHandler); From 76192667e0de26fe8c69fe0d129fd22c3eb61733 Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 23 Apr 2026 21:45:35 +0200 Subject: [PATCH 6/6] 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'. --- Caddyfile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Caddyfile b/Caddyfile index 5705a44..0f95af4 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,4 +1,11 @@ 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 }