diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index af7cea1..9793687 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3867f44..86d9366 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,5 @@ pids # freeCodeCamp files blocks + +.scratchpad/ diff --git a/.oxlintrc.json b/.oxlintrc.json index a1e990b..0796bce 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -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" }, diff --git a/CLAUDE.md b/CLAUDE.md index dfeb4d9..0023f4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 ``. 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 `