Compare commits

...

2 commits

Author SHA1 Message Date
lila
02ccc88d24 fix: change GAME_ROUNDS from strings to numbers 2026-04-28 12:29:46 +02:00
lila
7d3c456efe formatting 2026-04-28 12:29:32 +02:00
9 changed files with 209 additions and 28 deletions

View file

@ -60,7 +60,7 @@ const validBody = {
target_language: "it", target_language: "it",
pos: "noun", pos: "noun",
difficulty: "easy", difficulty: "easy",
rounds: "3", rounds: 3,
}; };
const fakeTerms = [ const fakeTerms = [
@ -177,4 +177,22 @@ describe("POST /api/v1/game/answer", () => {
expect(body.success).toBe(false); expect(body.success).toBe(false);
expect(body.error).toContain("Question not found"); 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);
});
}); });

View file

@ -14,7 +14,7 @@ const validRequest: GameRequest = {
target_language: "it", target_language: "it",
pos: "noun", pos: "noun",
difficulty: "easy", difficulty: "easy",
rounds: "3", rounds: 3,
}; };
const fakeTerms = [ const fakeTerms = [

View file

@ -21,7 +21,7 @@ export const createGameSession = async (
request.target_language, request.target_language,
request.pos, request.pos,
request.difficulty, request.difficulty,
Number(request.rounds), request.rounds,
); );
const answerKey = new Map<string, number>(); const answerKey = new Map<string, number>();

View file

@ -24,19 +24,19 @@ const LABELS: Record<string, string> = {
type GameSetupProps = { onStart: (settings: GameRequest) => void }; type GameSetupProps = { onStart: (settings: GameRequest) => void };
type SettingGroupProps = { type SettingGroupProps<T extends string | number> = {
label: string; label: string;
options: readonly string[]; options: readonly T[];
selected: string; selected: T;
onSelect: (value: string) => void; onSelect: (value: T) => void;
}; };
const SettingGroup = ({ const SettingGroup = <T extends string | number>({
label, label,
options, options,
selected, selected,
onSelect, onSelect,
}: SettingGroupProps) => ( }: SettingGroupProps<T>) => (
<div className="w-full"> <div className="w-full">
<p className="text-xs font-bold tracking-widest uppercase text-(--color-primary) mb-2"> <p className="text-xs font-bold tracking-widest uppercase text-(--color-primary) mb-2">
{label} {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" : "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}
</button> </button>
))} ))}
</div> </div>
@ -68,7 +68,7 @@ export const GameSetup = ({ onStart }: GameSetupProps) => {
); );
const [pos, setPos] = useState<string>(SUPPORTED_POS[0]); const [pos, setPos] = useState<string>(SUPPORTED_POS[0]);
const [difficulty, setDifficulty] = useState<string>(DIFFICULTY_LEVELS[0]); const [difficulty, setDifficulty] = useState<string>(DIFFICULTY_LEVELS[0]);
const [rounds, setRounds] = useState<string>(GAME_ROUNDS[0]); const [rounds, setRounds] = useState<number>(GAME_ROUNDS[0]);
const handleSourceLanguage = (value: string) => { const handleSourceLanguage = (value: string) => {
if (value === targetLanguage) { if (value === targetLanguage) {

View file

@ -151,7 +151,7 @@ export const evaluateAnswer = async (...) => { ... };
--- ---
## 4. `rounds` is typed as a string
**Problem** **Problem**

View file

@ -1,49 +1,52 @@
# ADR: Docker Credential Helper Setup # ADR: Docker Credential Helper Setup
## Status ## Status
Accepted Accepted
## Date ## Date
2026-04-26 2026-04-26
## Context ## 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 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.
and the VPS. Base64 is not encryption — anyone with read access to the
file can decode the credentials instantly.
## Decision ## 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 ## Options considered
### Option A — `pass` (GPG-backed) ✅ ### 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) ### 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` Uses the desktop keyring daemon. Not suitable for a headless VPS, and not suitable for an i3 desktop without running `gnome-keyring-daemon` manually.
manually.
### Option C — `gnome-libsecret` ### Option C — `gnome-libsecret`
Same limitations as Option B. Same limitations as Option B.
## Consequences ## Consequences
- Credentials are now GPG-encrypted at rest on both machines - 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 in a new session
- Must be set up manually on each machine — not reproducible via the repo - Must be set up manually on each machine — not reproducible via the repo
- VPS setup must be repeated if the server is reprovisioned - VPS setup must be repeated if the server is reprovisioned
## Affected machines ## Affected machines
- Dev laptop (Debian 13, i3) - Dev laptop (Debian 13, i3)
- VPS (Debian 13, ARM64, headless) - VPS (Debian 13, ARM64, headless)
## References ## 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. Repeat these steps on each machine.
### 1. Install dependencies ### 1. Install dependencies
```bash ```bash
sudo apt-get install -y pass gnupg2 golang-docker-credential-helpers sudo apt-get install -y pass gnupg2 golang-docker-credential-helpers
``` ```
### 2. Generate a GPG key ### 2. Generate a GPG key
```bash ```bash
gpg --full-generate-key gpg --full-generate-key
``` ```
Choose RSA, 4096 bits, no expiry. Set a strong passphrase. Choose RSA, 4096 bits, no expiry. Set a strong passphrase.
### 3. Get the key ID ### 3. Get the key ID
```bash ```bash
gpg --list-secret-keys --keyid-format LONG gpg --list-secret-keys --keyid-format LONG
``` ```
Copy the hex string after the `/` on the `sec` line. Copy the hex string after the `/` on the `sec` line.
### 4. Initialise pass ### 4. Initialise pass
```bash ```bash
pass init <your-key-id> pass init <your-key-id>
``` ```
### 5. Update `~/.docker/config.json` ### 5. Update `~/.docker/config.json`
Replace the entire file contents with: Replace the entire file contents with:
```json ```json
{ {
"credsStore": "pass" "credsStore": "pass"
@ -82,6 +93,7 @@ Replace the entire file contents with:
``` ```
### 6. Re-login to registries ### 6. Re-login to registries
```bash ```bash
docker login git.lilastudy.com docker login git.lilastudy.com
# dev laptop only: # dev laptop only:
@ -89,7 +101,9 @@ docker login dhi.io
``` ```
### 7. Verify ### 7. Verify
```bash ```bash
cat ~/.docker/config.json cat ~/.docker/config.json
``` ```
Should show only `"credsStore": "pass"` with no `auths` block. Should show only `"credsStore": "pass"` with no `auths` block.

View file

@ -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<string>` for rounds changed to `useState<number>`
- 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<string>` to `useState<number>`

View file

@ -4,7 +4,7 @@ export type SupportedLanguageCode = (typeof SUPPORTED_LANGUAGE_CODES)[number];
export const SUPPORTED_POS = ["noun", "verb", "adjective", "adverb"] as const; export const SUPPORTED_POS = ["noun", "verb", "adjective", "adverb"] as const;
export type SupportedPos = (typeof SUPPORTED_POS)[number]; 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 type GameRounds = (typeof GAME_ROUNDS)[number];
export const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] as const; export const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] as const;

View file

@ -12,7 +12,7 @@ export const GameRequestSchema = z.object({
target_language: z.enum(SUPPORTED_LANGUAGE_CODES), target_language: z.enum(SUPPORTED_LANGUAGE_CODES),
pos: z.enum(SUPPORTED_POS), pos: z.enum(SUPPORTED_POS),
difficulty: z.enum(DIFFICULTY_LEVELS), difficulty: z.enum(DIFFICULTY_LEVELS),
rounds: z.enum(GAME_ROUNDS), rounds: z.literal(GAME_ROUNDS),
}); });
export type GameRequest = z.infer<typeof GameRequestSchema>; export type GameRequest = z.infer<typeof GameRequestSchema>;