Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,5 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Lint
run: pnpm run check

- name: Typecheck
run: pnpm run typecheck

- name: Test
run: pnpm run test
- name: Verify
run: pnpm run verify
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,5 @@ pids
# freeCodeCamp files
blocks


.scratchpad/
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"ignorePatterns": [".scratchpad", "dist", "coverage"],
"rules": {
"no-explicit-any": "error"
},
Expand Down
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ Socrates is freeCodeCamp's hint API. Takes a camper's code, challenge descriptio

### `/hint` request flow

`apiKeyAuthHook` (route `onRequest`) -> `rateLimiterHook` (plugin `preHandler`) -> `normalizeHintRequest` -> `buildPrompt` -> `generateFromGroq` -> `formatHintOutput` -> response.
`apiKeyAuthHook` (plugin `onRequest`) -> JSON Schema validation -> `rateLimiterHook` (plugin `preHandler`) -> `normalizeHintRequest` -> `buildPrompt` -> `generateFromGroq` -> `formatHintOutput` -> response.

Non-obvious: `apiKeyAuthHook` is an `onRequest` hook registered inside `hintRoutes`; `rateLimiterHook` is a `preHandler` hook on the parent plugin that encapsulates it. Fastify runs every `onRequest` hook before any `preHandler`, so auth fires **before** the rate limiter — an unauthenticated request gets 401/403 and does NOT consume the bucket. Encapsulation keeps both hooks scoped to `/hint`. (`rateLimiterHook` = `instance.addHook` in `src/index.ts:141`; `apiKeyAuthHook` = `fastify.addHook('onRequest', …)` in `src/routes/hint.ts:13`.)
**Non-obvious: rejected requests consume no rate-limit token.** Fastify's phase order is `onRequest` -> `preValidation` -> `validation` -> `preHandler`, and phase beats encapsulation depth — so a child-scope `onRequest` hook runs before a parent-scope `preHandler` one. `apiKeyAuthHook` is `fastify.addHook('onRequest', …)` in `src/routes/hint.ts`; `rateLimiterHook` is `instance.addHook('preHandler', …)` in `src/index.ts`. Result: 401, 403 and schema-400 responses return before the limiter runs. Only a fully valid request reaches it.

This order is deliberate. Before it, the limiter keyed on `body.userId` before auth ran. An unauthenticated caller could then drain a victim's bucket with the victim's `userId`. That attack is no longer possible. But the app layer no longer meters API-key guessing or malformed-body floods.

## Observability

Expand Down Expand Up @@ -48,7 +50,9 @@ Operator walkthrough — scripts, release steps, source maps, required secrets
## Gotchas

- **`pnpm run build` must copy the Lua script** (`cp -r src/lib/lua dist/lib/lua`). `src/lib/rateLimiter.ts` reads `token_bucket.lua` from disk at startup; dropping the copy silently breaks rate limiting in production.
- **API key auth skipped outside production/staging.** `apiKeyAuthHook` short-circuits for any other `NODE_ENV`.
- **Two tsconfigs on purpose.** `tsconfig.json` is the _build_ config — `rootDir: src`, emits to `dist/`. `tsconfig.check.json` is the _typecheck_ config — adds `scripts/**/*` with `noEmit`, so the CLI scripts are type-checked without landing in the shipped bundle. `src/**/__tests__` stays excluded from both: vitest's `axios` mocking and dynamic `import()` calls do not satisfy `moduleResolution: nodenext`, and forcing them to would mean rewriting the mocks, not fixing a bug. CI runs `pnpm run verify`, the same command you run locally.
- **API key auth skipped outside production/staging.** `apiKeyAuthHook` short-circuits for any other `NODE_ENV`. Consequence: the 401/403 cases in `scripts/test-hints.ts` cannot pass locally, so the runner probes the server once and skips them unless auth is actually enforced. Run it against staging to exercise them.
- **`formatHintOutput` escapes; it must never parse.** It escapes `<`, `>` and bare `&`, then re-activates only `<code>`. An HTML parser (it used `sanitize-html`) removes what it does not model — attributes, comments, doctypes — before escaping can keep them as text. Its raw-text content model also lets `<code><textarea></code>` swallow the closing tag. The `&` escape skips well-formed entities, so `HTML_PATTERNS` hints that emit `&lt;!--` are not encoded two times. A `<` inside an attribute value ends the tag scan: a malformed tag stays visible as text and the prose after it is kept. `MAX_HINT_RESPONSE_CHARS` is derived: 5 chars maximum for each escaped code point. Fastify does **not** enforce response `maxLength`, so the schema value is documentation only.
- **Per-challenge model override.** `groqClient.ts` reads `GROQ_MODEL_<TYPE>` env vars via dynamic `process.env` lookup (not in `env.ts`), falling back to `GROQ_MODEL`.
- **Groq has an in-memory circuit breaker + fallback hint.** After `MODEL_CB_FAILURES` failures the breaker opens for `MODEL_CB_COOLDOWN_MS`; `/hint` returns a canned fallback with `model_used: "fallback"`. Intentional — don't "fix" it by throwing.
- **Transient Groq failures MUST NOT escape as unhandled errors** (root cause of SOCRATES-API-3/-4). `makeGroqApiCall` throws `ModelUnavailableError` after exhausting retries on a _retryable_ error (timeout / 5xx / 429 / network); `/hint`'s catch maps `ModelUnavailableError` **and** retryable `GroqApiError` → the graceful fallback. Only _non-retryable_ Groq errors (auth, 4xx) surface to the error handler → Sentry `handled:no` (a real bug you want to see). Do NOT revert the exhausted-retry path to `throw finalError` — that reintroduces the unhandled-500 class. The exhausted-retry summary logs at `warn` (stdout only), never `error` (which ships to Sentry Logs).
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
"lint": "oxlint .",
"format": "prettier --write .",
"check": "oxlint . && prettier --check .",
"fix": "oxlint . --fix && prettier --write .",
"verify": "pnpm run check && pnpm run typecheck && pnpm run test",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"typecheck": "tsc -p tsconfig.check.json",
"test:manual": "tsx scripts/test-hints.ts",
"generate-api-key": "tsx scripts/generate-api-key.ts"
},
Expand All @@ -33,12 +35,10 @@
"dotenv": "^17.4.2",
"fastify": "^5.12.3",
"ioredis": "^6.0.0",
"pino": "^10.3.1",
"sanitize-html": "^2.17.7"
"pino": "^10.3.1"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/sanitize-html": "^2.16.1",
"nodemon": "^3.1.14",
"oxlint": "^1.81.0",
"prettier": "^3.9.6",
Expand Down
Loading