diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index cfbe065..7c4d563 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,22 +177,4 @@ 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 2c7daf8..66b7470 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 d0f0781..b015271 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, - request.rounds, + Number(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 269818c..89c9f17 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 T[]; - selected: T; - onSelect: (value: T) => void; + options: readonly string[]; + selected: string; + onSelect: (value: string) => 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[String(option)] ?? option} + {LABELS[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 e5663b5..871ff7a 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 4fffaec..d15f242 100644 --- a/documentation/tickets/t00001.md +++ b/documentation/tickets/t00001.md @@ -1,52 +1,49 @@ # 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 - -- [docker docs](https://docs.docker.com/reference/cli/docker/login/#credential-stores) -- [pass docs](https://www.passwordstore.org/) +- https://docs.docker.com/reference/cli/docker/login/#credential-stores +- https://www.passwordstore.org/ --- @@ -55,37 +52,29 @@ 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" @@ -93,7 +82,6 @@ Replace the entire file contents with: ``` ### 6. Re-login to registries - ```bash docker login git.lilastudy.com # dev laptop only: @@ -101,9 +89,7 @@ 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 deleted file mode 100644 index dc93605..0000000 --- a/documentation/tickets/t00002.md +++ /dev/null @@ -1,149 +0,0 @@ -# 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 252bfff..ebe90ce 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 51f1cc2..2a32a24 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.literal(GAME_ROUNDS), + rounds: z.enum(GAME_ROUNDS), }); export type GameRequest = z.infer;