diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index 7c4d563..cfbe065 100644 --- a/apps/api/src/controllers/gameController.test.ts +++ b/apps/api/src/controllers/gameController.test.ts @@ -60,7 +60,7 @@ const validBody = { target_language: "it", pos: "noun", difficulty: "easy", - rounds: "3", + rounds: 3, }; const fakeTerms = [ @@ -177,4 +177,22 @@ describe("POST /api/v1/game/answer", () => { expect(body.success).toBe(false); expect(body.error).toContain("Question not found"); }); + + 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); + }); }); diff --git a/apps/api/src/services/gameService.test.ts b/apps/api/src/services/gameService.test.ts index 66b7470..2c7daf8 100644 --- a/apps/api/src/services/gameService.test.ts +++ b/apps/api/src/services/gameService.test.ts @@ -14,7 +14,7 @@ const validRequest: GameRequest = { target_language: "it", pos: "noun", difficulty: "easy", - rounds: "3", + rounds: 3, }; const fakeTerms = [ diff --git a/apps/api/src/services/gameService.ts b/apps/api/src/services/gameService.ts index b015271..d0f0781 100644 --- a/apps/api/src/services/gameService.ts +++ b/apps/api/src/services/gameService.ts @@ -21,7 +21,7 @@ export const createGameSession = async ( request.target_language, request.pos, request.difficulty, - Number(request.rounds), + request.rounds, ); const answerKey = new Map(); diff --git a/apps/web/src/components/game/GameSetup.tsx b/apps/web/src/components/game/GameSetup.tsx index 89c9f17..269818c 100644 --- a/apps/web/src/components/game/GameSetup.tsx +++ b/apps/web/src/components/game/GameSetup.tsx @@ -24,19 +24,19 @@ const LABELS: Record = { type GameSetupProps = { onStart: (settings: GameRequest) => void }; -type SettingGroupProps = { +type SettingGroupProps = { label: string; - options: readonly string[]; - selected: string; - onSelect: (value: string) => void; + options: readonly T[]; + selected: T; + onSelect: (value: T) => void; }; -const SettingGroup = ({ +const SettingGroup = ({ label, options, selected, onSelect, -}: SettingGroupProps) => ( +}: SettingGroupProps) => (

{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" }`} > - {LABELS[option] ?? option} + {LABELS[String(option)] ?? option} ))}

@@ -68,7 +68,7 @@ export const GameSetup = ({ onStart }: GameSetupProps) => { ); const [pos, setPos] = useState(SUPPORTED_POS[0]); const [difficulty, setDifficulty] = useState(DIFFICULTY_LEVELS[0]); - const [rounds, setRounds] = useState(GAME_ROUNDS[0]); + const [rounds, setRounds] = useState(GAME_ROUNDS[0]); const handleSourceLanguage = (value: string) => { if (value === targetLanguage) { diff --git a/documentation/roasts/gameService.md b/documentation/roasts/gameService.md index 871ff7a..e5663b5 100644 --- a/documentation/roasts/gameService.md +++ b/documentation/roasts/gameService.md @@ -151,7 +151,7 @@ export const evaluateAnswer = async (...) => { ... }; --- -## 4. `rounds` is typed as a string + **Problem** diff --git a/documentation/tickets/t00001.md b/documentation/tickets/t00001.md index d15f242..4fffaec 100644 --- a/documentation/tickets/t00001.md +++ b/documentation/tickets/t00001.md @@ -1,49 +1,52 @@ # 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. + +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. + +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. + +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. + +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 +- 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 -- https://docs.docker.com/reference/cli/docker/login/#credential-stores -- https://www.passwordstore.org/ + +- [docker docs](https://docs.docker.com/reference/cli/docker/login/#credential-stores) +- [pass docs](https://www.passwordstore.org/) --- @@ -52,29 +55,37 @@ Same limitations as Option B. 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 ``` ### 5. Update `~/.docker/config.json` + Replace the entire file contents with: + ```json { "credsStore": "pass" @@ -82,6 +93,7 @@ Replace the entire file contents with: ``` ### 6. Re-login to registries + ```bash docker login git.lilastudy.com # dev laptop only: @@ -89,7 +101,9 @@ docker login dhi.io ``` ### 7. Verify + ```bash cat ~/.docker/config.json ``` + Should show only `"credsStore": "pass"` with no `auths` block. diff --git a/documentation/tickets/t00002.md b/documentation/tickets/t00002.md new file mode 100644 index 0000000..dc93605 --- /dev/null +++ b/documentation/tickets/t00002.md @@ -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` for rounds changed to `useState` +- 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` to `useState` diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index ebe90ce..252bfff 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -4,7 +4,7 @@ export type SupportedLanguageCode = (typeof SUPPORTED_LANGUAGE_CODES)[number]; export const SUPPORTED_POS = ["noun", "verb", "adjective", "adverb"] as const; 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 const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] as const; diff --git a/packages/shared/src/schemas/game.ts b/packages/shared/src/schemas/game.ts index 2a32a24..51f1cc2 100644 --- a/packages/shared/src/schemas/game.ts +++ b/packages/shared/src/schemas/game.ts @@ -12,7 +12,7 @@ export const GameRequestSchema = z.object({ target_language: z.enum(SUPPORTED_LANGUAGE_CODES), pos: z.enum(SUPPORTED_POS), difficulty: z.enum(DIFFICULTY_LEVELS), - rounds: z.enum(GAME_ROUNDS), + rounds: z.literal(GAME_ROUNDS), }); export type GameRequest = z.infer;