--- name: secure-scanner-gemini-key classification: server-only success_metric: | GEMINI_AI_API_KEY rotated; no endpoint returns the key to a browser; lib/rate-limit.js exposes a sixth scan class (5 req / 1 min, user-keyed); forbidden-client-side-llm-keys CI gate green. skip: - ia - ux - visual - a11y - design status: in-progress created: 2026-05-27 --- # Convoy: secure-scanner-gemini-key Stop leaking the Gemini API key to browsers and add a scan-specific rate limiter before the server-side scan pipeline lands in convoy #2. ## Why The scanner audit found that `pages/api/config/gemini.js` returns `process.env.GEMINI_AI_API_KEY` to any authenticated caller, and `components/CameraScanner.js` auto-fetches that endpoint on mount (lines 53–67). A logged-in user — or anyone who obtains a session token — can read the production LLM key from DevTools and call Gemini directly, bypassing app rate limits and billing controls. This is a **live secret exposure** that must be closed before any further scanner work ships. ## Scope ### In scope - **Delete** `pages/api/config/gemini.js` — remove the key-returning endpoint entirely. - **`components/CameraScanner.js`** — remove the auto-load block at lines 53–67 that fetches `/api/config/gemini` and stores the key client-side. The scanner will break temporarily until convoy #2 lands; that is acceptable for the security-only window. - **`lib/rate-limit.js`** — add a sixth `scan` entry to `LIMITER_CONFIG` (5 req / 1 min, user-keyed) and export `checkScanRateLimit(req, userId)` following the Decision-2 hybrid named-limiter pattern from `.convoys/add-rate-limiting.md`. Redis prefix: `deckhearth:scan`. - **`.github/workflows/ci.yml`** — add a new blocking `forbidden-client-side-llm-keys` job (~30s grep gate, same shape as `forbidden-cors-headers`): - Fail if `apiKey:` appears under `pages/api/config/*`. - Fail if `generativelanguage.googleapis.com` or `api.openai.com` appears outside `pages/api/` (client-side LLM URL leakage). - **`.cursor/rules/api-routes.mdc`** § Rate limiting — document the new `scan` class (architect may fold into brief or defer to convoy #2; implementer should at minimum add the lib export). ### Out of scope - **`/api/scan/identify`** server route — convoy `server-side-scan-pipeline` (#2). - **Deleting `lib/ai-ocr.js` browser classes** (`GeminiVisionOCR`, `AICardOCR`, etc.) — deferred to #2 to avoid breaking the live scanner during this security-only window (the client still needs those classes until the server pipeline replaces them). - **Rotating the key in source control** — the key lives in Vercel env only; rotation is operator action (see below). ## Roles invoked 1. `role-architect` — ratify scan limiter values; confirm grep patterns for the CI gate; single brief decomposition. 2. `role-implementer` — one brief, no fan-out. 3. `role-reviewer` — post-PR audit (design + a11y skipped per `skip:`). ## Todos - [ ] Architect: write `.convoys/secure-scanner-gemini-key/brief-1-*.md` - [x] Implementer: delete config endpoint + client auto-load; extend rate-limit lib - [x] Implementer: add `forbidden-client-side-llm-keys` CI job - [x] Operator: rotate `GEMINI_AI_API_KEY` in Google AI Studio + Vercel **before merge** - [ ] Reviewer: verify no key material in diff or CI logs ## Operator action required **URGENT — rotate before merge:** 1. **Google AI Studio** — revoke the current `GEMINI_AI_API_KEY` and issue a new key. The old key has been exposed to every browser session that loaded the scanner page. 2. **Vercel** — update the `GEMINI_AI_API_KEY` env var on Production + Preview to the new value. 3. **Verify** — after deploy, confirm `GET /api/config/gemini` returns 404 and no network tab in the scanner shows key material. Do **not** merge this PR until rotation is complete. The CI gate prevents re-introduction of the leak pattern but does not invalidate a key that was already exfiltrated. ## Multitask dispatch Single brief — **no implementer fan-out**. Post-PR audit fan-out: ``` /multitask role-reviewer ``` Group id: `audit-secure-scanner-gemini-key-` (design-system + a11y auditors skipped per `skip:` flags). **Cross-convoy parallelism (after #1 merges):** open three worktrees and `/multitask role-implementer` on **#2 Brief 1 + #5 Brief 1 + #6 (sole brief)** — disjoint file sets (migrations / UI strings / scanner polish). ## CI impact | Workflow / job | Behavior | | --- | --- | | `ci.yml` → `forbidden-client-side-llm-keys` | **New blocking job.** ~30s grep; no `npm ci`. | | `ci.yml` → lint, vitest, forbidden-endpoints, forbidden-cors-headers | Unchanged; must stay green. | | `preview-smoke.yml` | Fires (no `paths:` filter). Scanner page may error until #2; smoke spec does not exercise scanner. | | `visual-diff.yml` | **Does not fire** — API-only + `lib/` + CI YAML; `!pages/api/**` exclusion applies. | **Preserve `pipeline: skip smoke`** PR-body directive for iterative pushes.