fix(security): stop leaking Gemini API key to browsers

Delete the public /api/config/gemini endpoint and remove client auto-load
paths so GEMINI_AI_API_KEY stays server-side only. Add a scan rate-limit
class for the upcoming server-side identify route and a CI gate that blocks
reintroducing config key leaks or new browser LLM URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Randall Stillwell 2026-05-27 08:40:00 -05:00
parent 7efb6efe69
commit 0918bd0449
13 changed files with 1047 additions and 115 deletions

View file

@ -0,0 +1,146 @@
---
name: add-real-ocr-layer
classification: feature
success_metric: |
≥70% of legitimate scans resolve at Layer-1 (Tesseract + pg_trgm) with
zero Gemini calls; scan_attempts.layer distribution proves it.
skip:
- ia
status: open
created: 2026-05-27
depends_on:
- server-side-scan-pipeline
---
# Convoy: add-real-ocr-layer
Add a cheap local OCR + fuzzy DB match layer so most scans never hit Gemini.
## Why
Every scan today (post convoy #2) calls Gemini Flash server-side. That is
slow, costs money, and hits rate limits under bulk scanning. The audit
proposed a two-layer pipeline: Layer-1 runs Tesseract on the card name strip
+ `pg_trgm` similarity against `cards.name`; only escalates to
`/api/scan/identify` (Gemini) when confidence is low. Target: **≥70%
Layer-1 hit rate** measured via `scan_attempts.layer`.
## Scope
### In scope
- **Migration** — enable `pg_trgm` extension; GIN index on
`cards (name gin_trgm_ops)`.
- **`pages/api/cards/identify-by-text.js`** (new):
- similarity > 0.85 → single match
- 0.60.85 → disambiguation list
- < 0.6 `{ escalate: true }` (client falls back to `/api/scan/identify`)
- **`tesseract.js`** dependency + **`lib/ocr-worker.js`** (new) — browser
Worker wrapping Tesseract; OCR name strip before API calls.
- **`components/CameraScanner.js`** — integrate Worker: try Layer-1 path
first, escalate on low confidence.
- **`docs/SCHEMA_MAP.md`** — index + extension documented.
- **`.github/workflows/ci.yml`** — piggyback fix: extend `schema-map-fresh`
job `if:` condition to include `migrations/**` paths (currently only
watches `scripts/add-*`, `scripts/fix-*`, `setup-neon-db.js`, and
`docs/SCHEMA_MAP.md` — misses post-`migration-tool` migrations).
### Out of scope
- **OpenCV perspective transform / card boundary detection** — queue
`improve-scan-card-detection` if Layer-1 hit rate stays below 70% after
this lands.
- **Retraining or custom ML models** — Tesseract + trigram is sufficient
for v1.
- **Scanner UX redesign** — convoy `redesign-scanner-flow` (#4).
## Roles invoked
1. `role-ux-reviewer` — Layer-1 vs escalation feedback (scanning status).
2. `role-architect` — similarity thresholds, Worker bundling, 2 briefs.
3. `role-implementer` — 2 briefs (Brief 2 depends on Brief 1).
4. `role-reviewer` + `role-design-system-auditor` + `role-a11y-auditor`.
## Todos
- [ ] Architect: ratify similarity thresholds + Worker load strategy
- [ ] Brief 1 — migration (pg_trgm) + identify-by-text route + SCHEMA_MAP + CI schema-map-fresh fix
- [ ] Brief 2 — Tesseract Worker + CameraScanner integration
- [ ] Post-ship: measure `scan_attempts.layer` distribution for 70% target
## Operator action required
**None.** No new secrets. Tesseract runs client-side; pg_trgm is a Postgres
extension enabled via migration.
## Multitask dispatch
### Slice dependencies
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- migrations/*
- pages/api/cards/identify-by-text.js
- docs/SCHEMA_MAP.md
- .github/workflows/ci.yml
- brief: 2
depends_on: [1]
files:
- components/CameraScanner.js
- lib/ocr-worker.js
- package.json
```
Serial dispatch: Brief 2 after Brief 1 (Worker calls identify-by-text route).
Post-PR audit:
```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```
Group id: `audit-add-real-ocr-layer-<pr>`.
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `schema-map-fresh` | **Modified**`if:` paths include `migrations/**`. |
| `forbidden-client-side-llm-keys` | Unchanged (no new client LLM URLs). |
| `preview-smoke.yml` | Fires. |
| `visual-diff.yml` | **Fires**`components/CameraScanner.js` in paths. |
No new grep gate. Total added CI time: ~0 beyond existing workflows.
## Decisions to ratify (architect)
1. **Similarity thresholds** — 0.85 / 0.6 defaults from audit; tune with
sample set.
2. **Tesseract language data** — bundled vs CDN fetch; impact on first-load
latency.
3. **`scan_attempts.layer` values** — recommend `1 | 2` (trgm vs gemini).
## Known constraints
- **`pg_trgm` on Neon** — verify extension availability on prod tier.
- **Worker + Turbopack** — confirm `tesseract.js` Worker path works under
Next.js 16 default bundler; fallback `--webpack` only if architect
documents regression.
- **Layer-1 must not block camera** — Worker runs off main thread.
## Acceptance criteria
1. Migration applies cleanly; GIN index exists on `cards.name`.
2. `identify-by-text` returns match / disambig / escalate per thresholds.
3. CameraScanner tries Layer-1 before `/api/scan/identify`.
4. `scan_attempts.layer` populated for analytics.
5. `schema-map-fresh` fires when only `migrations/` changes.
6. Lint + vitest baseline preserved.
## Out of scope follow-ups
- **`improve-scan-card-detection`** — if Layer-1 hit rate < 70%.
- **`god-component-split`** — CameraScanner remains large; split is P2.

View file

@ -0,0 +1,160 @@
---
name: redesign-scanner-flow
classification: feature
success_metric: |
Scanner page uses a stack-destination model (one target at top, every scan
pours into it); per-card condition + foil + quantity captured and propagate
to destinations; "you already own N" ownership badge via /api/cards/[id]/ownership;
captured frame persisted to Vercel Blob and attached to user_cards.
skip: []
status: open
created: 2026-05-27
depends_on:
- server-side-scan-pipeline
---
# Convoy: redesign-scanner-flow
Redesign the scanner session around a single destination stack, rich card
metadata, ownership feedback, and persisted scan images.
## Why
The current scanner treats each card as an isolated add with no session
context: users re-select destination every scan, cannot set condition/foil/
quantity in bulk, get no feedback when they already own a card, and lose
the captured frame after confirm. The audit flagged these as core UX gaps
that block efficient bulk scanning at the table.
## Scope
### In scope
- **Brief 1 — stack-destination UX + game pre-select**
- `pages/scanner.js` — session model: one chosen target at top; every
confirmed scan routes there.
- `components/ScannerDestinationPicker.js` (new) — collection / deck /
default collection picker with game filter.
- **Brief 2 — condition / foil / quantity + ownership badge**
- `pages/scanner.js`, `components/ScannedCardItem.js` (extract from
scanner page).
- `pages/api/user-cards.js`, `pages/api/decks/[id]/cards.js`,
`pages/api/collections/[identifier]/cards.js` — accept `condition`,
`is_foil`, `quantity` body params on POST.
- Ownership badge via existing `pages/api/cards/[id]/ownership.js`.
- **Brief 3 — captured image persistence**
- `pages/api/scan/upload-image.js` (new) — auth + Blob upload.
- `components/CameraScanner.js` — upload frame on confirm.
- `pages/api/user-cards.js` — accept `scan_image_url`.
### Out of scope
- **OCR / identify pipeline changes** — convoys #2 and #3.
- **Vocabulary rename** ("My Collection" / "Lists") — convoy
`rename-collections-vocabulary` (#5).
- **Schema cleanup** — global `cards.quantity` removal is
`schema-cleanup-from-scanner-audit`, not here.
## Roles invoked
1. `role-ia-architect` — destination stack model, nav labels.
2. `role-ux-reviewer` — bulk-scan flow, ownership badge placement.
3. `role-architect` — API param contract, 3 briefs.
4. `role-implementer` — 3 briefs (Briefs 2+3 parallel after Brief 1).
5. `role-reviewer` + `role-design-system-auditor` + `role-a11y-auditor`.
## Todos
- [ ] IA: stack-destination information architecture
- [ ] UX: condition/foil/quantity controls + ownership badge
- [ ] Architect: brief decomposition + API body-param contract
- [ ] Brief 1 — destination picker + session state
- [ ] Brief 2 — metadata + ownership (after Brief 1)
- [ ] Brief 3 — Blob persistence (after Brief 1)
- [ ] Design-system: verify theme tokens on new components
## Operator action required
**None.** Assumes `BLOB_READ_WRITE_TOKEN` is already provisioned (avatar
upload path uses Blob today).
## Multitask dispatch
### Slice dependencies
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- pages/scanner.js
- components/ScannerDestinationPicker.js
- brief: 2
depends_on: [1]
files:
- pages/scanner.js
- components/ScannedCardItem.js
- pages/api/user-cards.js
- pages/api/decks/[id]/cards.js
- pages/api/collections/[identifier]/cards.js
- brief: 3
depends_on: [1]
files:
- pages/api/scan/upload-image.js
- components/CameraScanner.js
- pages/api/user-cards.js
```
**After Brief 1 merges:** `/multitask role-implementer briefs 2, 3`
(disjoint file sets except shared `pages/scanner.js` / `user-cards.js`
architect must resolve: likely Brief 2 owns `scanner.js` queue UI, Brief 3
owns CameraScanner + upload route only; adjust `files:` if conflict).
Post-PR audit:
```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```
Group id: `audit-redesign-scanner-flow-<pr>`.
**Cross-convoy:** can run **parallel with #3** after #2 Brief 1+2 merge
(disjoint primary surfaces).
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `preview-smoke.yml` | Fires. |
| `visual-diff.yml` | **Fires**`pages/scanner.js`, `components/**` match paths; `!pages/api/**` still allows page changes through. |
| New grep gates | None. |
## Decisions to ratify (architect)
1. **Default destination** — last-used vs explicit pick-required on session start.
2. **Condition enum** — align with existing `user_cards.condition` VARCHAR values.
3. **Blob path convention** — e.g. `scans/{userId}/{uuid}.jpg`.
4. **Brief 2 vs 3 file overlap** — split `pages/scanner.js` ownership to avoid multitask conflict.
## Known constraints
- **Theme tokens** — no hardcoded hex; use `var(--*)` per ui-and-theming rule.
- **Rate limits** — upload-image may need `checkUploadRateLimit` if architect
classifies scan images as upload class (Decision pending).
- **Mobile scanner** — destination picker must work on narrow viewports.
## Acceptance criteria
1. User selects one destination; all scans in session target it until changed.
2. Condition, foil, quantity propagate on add-to-collection/deck/user-cards.
3. Ownership badge shows when user already holds the card.
4. Confirmed scan image URL stored on `user_cards` row.
5. Lint + vitest baseline preserved.
6. A11y: destination picker keyboard-operable; badge has accessible text.
## Out of scope follow-ups
- **`god-component-split`** — further split `pages/scanner.js` if still > 500 lines.
- **`harden-multipart-parser`** — if upload-image uses multipart.

View file

@ -0,0 +1,142 @@
---
name: rename-collections-vocabulary
classification: feature
success_metric: |
UI copy reads "My Collection" (ownership) and "Lists" / "Binders" (curated
lists); no rendered string "Owned Cards" / "Mark Owned" / "All My Cards"
remains in pages/ or components/; schema unchanged; AGENTS.md extended
with vocabulary table.
skip:
- arch
status: open
created: 2026-05-27
---
# Convoy: rename-collections-vocabulary
Align user-facing copy with the product taxonomy: owned cards vs curated lists.
## Why
The scanner audit and IA review found inconsistent vocabulary: "Owned Cards",
"Mark Owned", and "All My Cards" imply a different mental model than the
schema (global `cards` catalog vs per-user ownership via `user_cards` vs
curated `collections`). Users confuse "my collection" (everything I own) with
"lists/binders" (curated subsets). This convoy is copy + docs only — no
schema migration.
## Scope
### In scope
- **Brief 1 — UI copy sweep** (~20 files in `pages/` + `components/`):
- Replace stale strings per vocabulary table (IA architect supplies full
inventory).
- Target removals: `"Owned Cards"`, `"Mark Owned"`, `"All My Cards"`.
- Canonical replacements: **"My Collection"** (ownership), **"Lists"** /
**"Binders"** (curated lists).
- **Brief 2 — docs + rules**
- `AGENTS.md` § Branding — vocabulary table.
- `.cursor/rules/ui-and-theming.mdc` — copy conventions.
- `docs/SCHEMA_MAP.md` — clarify naming vs UI labels (no DDL change).
- **`.github/workflows/ci.yml`** — new `forbidden-stale-strings` grep gate
(~30s): fail if `"Mark Owned"`, `"Owned Cards"`, or `"All My Cards"`
appear in `pages/` or `components/`.
### Out of scope
- **Schema renames** — table/column names stay; UI copy only.
- **URL slug changes**`/collections` path unchanged in v1.
- **Architecture decisions**`skip: arch`; IA + UX run explicitly.
## Roles invoked
1. `role-ia-architect` — vocabulary table + file inventory (**primary owner**).
2. `role-ux-reviewer` — scan flow + nav label consistency.
3. `role-implementer` — 2 briefs (serial: Brief 2 after Brief 1).
4. `role-reviewer` + `role-design-system-auditor` + `role-a11y-auditor`
copy changes affect screen reader strings.
Note: **`role-architect` skipped** per `skip: arch`. IA architect owns
taxonomy; implementer briefs written by IA + conductor handoff or parent
agent.
## Todos
- [ ] IA: publish vocabulary table + grep inventory of stale strings
- [ ] UX: review scanner + nav + collection views for consistency
- [ ] Brief 1 — pages/ + components/ copy sweep
- [ ] Brief 2 — AGENTS.md + rules + SCHEMA_MAP glossary
- [ ] Add `forbidden-stale-strings` CI job
## Operator action required
**None.**
## Multitask dispatch
### Slice dependencies
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- pages/**/*.js
- components/*.js
notes: exclude pages/api/**
- brief: 2
depends_on: [1]
files:
- AGENTS.md
- .cursor/rules/ui-and-theming.mdc
- docs/SCHEMA_MAP.md
- .github/workflows/ci.yml
```
Serial: Brief 2 after Brief 1 (docs reference final copy).
**Cross-convoy:** parallel with #1+#2 after `secure-scanner-gemini-key`
merges — `/multitask role-implementer` **#5 Brief 1 + #6 + #2 Brief 1**
(disjoint files).
Post-PR audit:
```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```
Group id: `audit-rename-collections-vocabulary-<pr>`.
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `forbidden-stale-strings` | **New blocking job** — grep `pages/` + `components/`. |
| `visual-diff.yml` | **Likely fires** — widespread UI string changes in pages/components. |
| `preview-smoke.yml` | Fires; sign-in CTA wording must stay smoke-compatible. |
**Smoke caveat:** smoke test 2 asserts `/sign in/i` on login page — do not
rename that CTA in this convoy.
## Decisions to ratify (IA architect)
1. **"Lists" vs "Binders"** — when to use each term in nav vs empty states.
2. **Scanner button label** — replacement for "Mark Owned" (e.g. "Add to
session" vs "Confirm card").
3. **Admin UI** — out of copy sweep or separate pass?
## Acceptance criteria
1. Zero rendered occurrences of the three forbidden strings in `pages/` +
`components/`.
2. Vocabulary table committed in `AGENTS.md`.
3. `forbidden-stale-strings` CI green.
4. Schema DDL unchanged (grep `migrations/` — no new files).
5. Vitest 21/21; smoke 3/3 (sign-in CTA intact).
## Out of scope follow-ups
- **`schema-cleanup-from-scanner-audit`** — `is_system_collection` vs
`user_cards` unification (separate convoy).
- **`rename-repo-and-vercel-project`** — infra naming, not UI copy.

View file

@ -0,0 +1,109 @@
---
name: scanner-correctness-polish
classification: infra-only
success_metric: |
Mark-Owned is idempotent (in-flight lock per queue row); bulk toolbar no
longer uses setTimeout(..., 100); pages/scanner.js imports lib/use-auth
not legacy auth-context; POST /api/collections/[identifier]/cards calls
logCollectionActivity('card_added', …).
skip:
- ia
- ux
- visual
- a11y
- design
status: open
created: 2026-05-27
---
# Convoy: scanner-correctness-polish
Fix scanner-page correctness bugs without changing UX or visual design.
## Why
The scanner audit surfaced non-security bugs that cause duplicate adds,
racey bulk actions, a stale auth import, and missing activity logs. These
are small fixes with high reliability impact during bulk scanning sessions.
## Scope
### In scope
- **`pages/scanner.js`**
- Line 8: change import from legacy `lib/auth-context` to `lib/use-auth`
(should have been swept by `single-auth-provider`; scanner was missed or
regressed).
- **Mark-Owned idempotency** — in-flight lock per queue row so double-tap
/ double-click cannot duplicate POSTs.
- **Bulk toolbar** — remove `setTimeout(..., 100)` race; await or use
proper batch completion signal.
- **`pages/api/collections/[identifier]/cards.js`** — POST handler calls
`logCollectionActivity(collectionId, userId, 'card_added', details)` per
AGENTS.md convention.
### Out of scope
- **Scanner UX redesign** — convoy `redesign-scanner-flow` (#4).
- **Copy / vocabulary** — convoy `rename-collections-vocabulary` (#5).
- **OCR / identify pipeline** — convoys #2#3.
- **Visual or a11y changes** — none intended; diff should be behavior-only.
## Roles invoked
1. `role-architect` — single brief (lightweight; may be parent-owned given
infra-only classification).
2. `role-implementer` — one brief.
3. `role-reviewer` — post-PR only (design + a11y skipped).
## Todos
- [ ] Architect: brief-1 with exact line targets
- [ ] Fix use-auth import on scanner page
- [ ] Add per-row in-flight lock for Mark-Owned
- [ ] Replace setTimeout bulk-toolbar pattern
- [ ] Wire logCollectionActivity on collection card POST
## Operator action required
**None.**
## Multitask dispatch
Single brief — no implementer fan-out.
**Cross-convoy parallelism:** after #1 merges, run alongside **#2 Brief 1**
and **#5 Brief 1** — disjoint files (`pages/scanner.js` vs migrations vs
copy sweep). Coordinate if both #6 and #5 touch `pages/scanner.js` (IA
should sequence copy sweep after correctness or split files in briefs).
Post-PR audit:
```
/multitask role-reviewer
```
Group id: `audit-scanner-correctness-polish-<pr>`.
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `ci.yml` | Standard lint + vitest; no new gates. |
| `preview-smoke.yml` | Fires. |
| `visual-diff.yml` | **May fire** if `pages/scanner.js` changes — behavior-only diff should not move pixels; baseline swallow if triggered. |
## Known constraints
- **`logCollectionActivity` import** — use existing helper from
permission-middleware or documented activity module; match sibling handlers.
- **No UX copy changes** — button labels stay as-is until #5 (or avoid
overlapping scanner.js edits between #5 and #6).
## Acceptance criteria
1. `pages/scanner.js` imports from `lib/use-auth` only.
2. Double-click Mark-Owned produces one POST.
3. Bulk toolbar actions complete without setTimeout race.
4. Collection card POST emits activity log row.
5. Vitest 21/21; lint baseline preserved.

View file

@ -0,0 +1,124 @@
---
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
5367). 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
5367 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-<pr>` (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.

View file

@ -0,0 +1,212 @@
---
name: server-side-scan-pipeline
classification: feature
success_metric: |
Scanner identifies cards via server-owned /api/scan/identify (no client-side
LLM keys); unknown cards write to card_submissions for admin review (no
user-writable cards INSERT); disambiguation envelope consumed by the client
(needsUserSelection no longer silently swallowed).
skip: []
status: open
created: 2026-05-27
depends_on:
- secure-scanner-gemini-key
---
# Convoy: server-side-scan-pipeline
Move card identification entirely server-side, stop users from INSERTing
into the global `cards` catalog, and surface disambiguation when OCR is
ambiguous.
## Why
After convoy #1 closes the key leak, the scanner still runs Gemini Vision
in the browser via `lib/ai-ocr.js`. That architecture cannot be secured:
any client-side LLM call requires credentials in JS bundle or runtime fetches.
Additionally, `pages/api/cards/find-or-create.js` lets authenticated users
INSERT new rows into the global `cards` table — polluting the shared catalog
with OCR garbage. The audit also found that `CameraScanner.js` silently
swallows `needsUserSelection: true` from the identify response, forcing
wrong-card adds without user confirmation.
## Scope
### In scope
- **Migration** under `migrations/` (via `npm run migrate create`):
- `card_submissions` — id, user_id, ocr_text, ocr_confidence,
scan_image_url, candidate_card_ids, status, reviewed_by, created_at.
- `scan_attempts` — per audit § 5.2 (layer, outcome, timing fields).
- Update `docs/SCHEMA_MAP.md`.
- **`pages/api/scan/identify.js`** (new) — auth +
`checkScanRateLimit(req, user.userId)` + server-side Gemini Flash via
`process.env.GEMINI_AI_API_KEY`; returns match / disambiguation /
unknown envelope.
- **`pages/api/cards/find-or-create.js`** — delete the user-writable
INSERT path; unknown cards write to `card_submissions` instead.
- **`lib/ai-ocr.js`** — delete browser classes: `GeminiVisionOCR`,
`AICardOCR`, `OllamaVisionOCR`, `PuterVisionOCR`. Keep any shared
utilities the server route needs (architect decides split).
- **`components/CameraScanner.js`** — rewrite `processConfirmedCard` to
call `/api/scan/identify`; render disambiguation UI when
`needsUserSelection: true`.
- **`pages/scanner.js`** — wire queue + error states to new API contract.
- **`pages/admin/card-submissions.js`** (new) — admin review queue;
promote-to-`cards` action.
- **`pages/api/admin/card-submissions/**`** (new) — list, approve, reject.
- **`.cursor/rules/api-routes.mdc`** § Rate limiting — document `scan` class
if not fully done in #1.
- **Extend `forbidden-client-side-llm-keys`** — also fail if
`import.*ai-ocr` appears in `components/` (browser classes deleted).
### Out of scope
- **Tesseract / pg_trgm Layer-1 OCR** — convoy `add-real-ocr-layer` (#3).
- **Stack-destination UX, condition/foil/quantity** — convoy
`redesign-scanner-flow` (#4).
- **OpenCV perspective transform** — queued as
`improve-scan-card-detection` if Layer-1 hit rate stays below 70%.
- **Schema cleanup** (`cards.quantity`, dual visibility flags) — see
`schema-cleanup-from-scanner-audit` stub in `.convoys/ship-readiness.md`.
## Roles invoked
1. `role-ia-architect` — admin review queue IA, disambiguation flow labels.
2. `role-ux-reviewer` — disambiguation picker UX, error/loading states.
3. `role-architect` — API contract, migration shape, brief decomposition.
4. `role-implementer` — 4 briefs (2 parallel at gate 1).
5. `role-reviewer` + `role-design-system-auditor` + `role-a11y-auditor`
post-PR audit fan-out.
## Todos
- [ ] IA: admin card-submissions queue information architecture
- [ ] UX: disambiguation picker + scanner error states
- [ ] Architect: ratify Decisions 15; write 4 briefs + `slice_dependencies`
- [ ] Brief 1 — migration + SCHEMA_MAP
- [ ] Brief 2 — `/api/scan/identify` server route
- [ ] Brief 3 — client disambiguation + delete browser LLM classes (after Brief 2)
- [ ] Brief 4 — admin queue + replace find-or-create INSERT (after Briefs 1+2)
- [ ] Extend CI gate to forbid `import.*ai-ocr` in `components/`
## Operator action required
**None beyond #1.** Assumes `GEMINI_AI_API_KEY` is already rotated and
only present server-side. Confirm Vercel env var is set before testing
`/api/scan/identify` on preview.
## Multitask dispatch
### Slice dependencies (multitask-ready)
```yaml
slice_dependencies:
- brief: 1
depends_on: []
files:
- migrations/*
- docs/SCHEMA_MAP.md
- brief: 2
depends_on: []
files:
- pages/api/scan/**
- brief: 3
depends_on: [2]
files:
- components/CameraScanner.js
- lib/ai-ocr.js
- pages/scanner.js
- brief: 4
depends_on: [1, 2]
files:
- pages/api/cards/find-or-create.js
- pages/admin/card-submissions.js
- pages/api/admin/card-submissions/**
```
**Gate 1:** `/multitask role-implementer briefs 1, 2` (disjoint files).
**Gate 2:** Brief 3 after Brief 2 merges; Brief 4 after Briefs 1+2 merge
(Briefs 3 and 4 can run sequentially or Brief 4 parallel with Brief 3 if
Brief 2 is merged — file sets are disjoint between 3 and 4).
Post-PR audit fan-out:
```
/multitask role-reviewer + role-design-system-auditor + role-a11y-auditor
```
Group id: `audit-server-side-scan-pipeline-<pr>`.
**Cross-convoy (after Brief 1 + Brief 2 merge):** `/multitask` **#3 Brief 1
+ #4 Brief 1** — disjoint (DB+route / UX restructure).
## CI impact
| Workflow / job | Behavior |
| --- | --- |
| `forbidden-client-side-llm-keys` | **Extended** — grep for `import.*ai-ocr` in `components/`. |
| `ci.yml` → lint, vitest, schema-map-fresh | Fires on migration + SCHEMA_MAP changes. |
| `preview-smoke.yml` | Fires; scanner not in smoke spec today. |
| `visual-diff.yml` | **Fires** — touches `pages/scanner.js`, `pages/admin/**`, `components/CameraScanner.js`. `continue-on-error: true` until baselines seeded. |
## Decisions to ratify (architect)
1. **`/api/scan/identify` request shape.** Image as base64 in JSON vs.
multipart vs. Blob URL reference? Parent recommends base64 for v1
(matches current canvas capture); architect confirms payload size
limits + Vercel function timeout budget.
2. **Disambiguation response envelope.** Verbatim fields:
`{ needsUserSelection, candidates: [{ id, name, set, score }], … }`.
Architect locks the contract before Brief 2 ships.
3. **`card_submissions.status` enum.** Recommend:
`pending | approved | rejected`. Promotion copies vetted row into
`cards` with admin attribution.
4. **What happens to `find-or-create` callers?** Inventory all fetch sites;
migrate to `/api/scan/identify` + submissions path. Architect lists
in brief.
5. **Gemini model + prompt ownership.** Server-side only; single module
(extend existing server helper or new `lib/scan-identify.js`).
## Known constraints
- **`checkScanRateLimit` gate-ordering** — MUST sit AFTER
`getUserFromRequest` per `.convoys/add-rate-limiting.md` Decision 4
(`extractUserIdentifier` THROWS without userId).
- **`GEMINI_AI_API_KEY` server-only** — never returned in JSON; never
logged. CI gate from #1 is the regression lock.
- **No user INSERT into `cards`** — any path that let non-admins create
catalog rows must be removed or admin-gated.
- **Historical `scripts/add-*`** — no-go-zone; schema via `migrations/` only.
- **Card-import rate limits unchanged** — import routes stay admin-only;
scan limiter is a separate class.
## Acceptance criteria
1. Zero client-side references to `GEMINI_AI_API_KEY`, `apiKey:`, or
direct `generativelanguage.googleapis.com` calls outside `pages/api/`.
2. `GET/POST /api/scan/identify` requires auth; returns 401 without
token; returns 429 when scan limit exceeded.
3. Authenticated scan of unknown card creates `card_submissions` row, NOT
a `cards` row.
4. Disambiguation UI renders when API returns `needsUserSelection: true`;
user must pick before card enters queue.
5. Admin can list + approve/reject submissions at `/admin/card-submissions`.
6. Approve action promotes to `cards` with audit trail (`reviewed_by`).
7. `forbidden-client-side-llm-keys` CI job green including `components/`
ai-ocr import check.
8. `npm run lint` baseline preserved; `npm run test:run` 21/21 green.
9. `docs/SCHEMA_MAP.md` updated for new tables.
## Out of scope follow-ups
- **`add-real-ocr-layer`** (#3) — Tesseract + pg_trgm before Gemini.
- **`redesign-scanner-flow`** (#4) — destination stack, condition/foil.
- **`improve-scan-card-detection`** — OpenCV perspective if Layer-1 < 70%.
- **`fill-vitest-handler-coverage`** — per-route handler tests deferred.
- **`schema-cleanup-from-scanner-audit`** — global catalog column smells.

View file

@ -484,6 +484,53 @@ Follow-ups surfaced mid-convoy or mid-PR that didn't fit the original launch seq
- **`audit-node-pg-migrate-transitive-deps`** (priority: P3 hygiene). Surfaced 2026-05-26 by `migration-tool` (PR #32) — R5 in the convoy file. `npm audit` reports 11 vulnerabilities (6 moderate, 5 high) coming from `node-pg-migrate@8.0.4`'s `glob@~11.1.0` + `yargs@~17.7.0` transitive deps (older `brace-expansion`, `minimatch`, `picomatch` versions with known advisories). All in dev-only paths; the migration tool runs in scripts/CI, never in the deployed Next.js bundle, and the affected APIs (glob's shell-injection CLI; brace-expansion's ReDoS) are not exercised by node-pg-migrate's call sites. Surface only if a security audit specifically flags this surface, or if `node-pg-migrate` ships a v9 that updates the transitive tree. - **`audit-node-pg-migrate-transitive-deps`** (priority: P3 hygiene). Surfaced 2026-05-26 by `migration-tool` (PR #32) — R5 in the convoy file. `npm audit` reports 11 vulnerabilities (6 moderate, 5 high) coming from `node-pg-migrate@8.0.4`'s `glob@~11.1.0` + `yargs@~17.7.0` transitive deps (older `brace-expansion`, `minimatch`, `picomatch` versions with known advisories). All in dev-only paths; the migration tool runs in scripts/CI, never in the deployed Next.js bundle, and the affected APIs (glob's shell-injection CLI; brace-expansion's ReDoS) are not exercised by node-pg-migrate's call sites. Surface only if a security audit specifically flags this surface, or if `node-pg-migrate` ships a v9 that updates the transitive tree.
- **`add-migration-template`** (priority: P3 DX). Surfaced 2026-05-26 by `migration-tool` (PR #32). Add a custom template via `--template-file-name` so generated migrations include the project's preferred docstring shape + a reminder about `docs/SCHEMA_MAP.md` updates. Surface if migration authoring proves inconsistent. - **`add-migration-template`** (priority: P3 DX). Surfaced 2026-05-26 by `migration-tool` (PR #32). Add a custom template via `--template-file-name` so generated migrations include the project's preferred docstring shape + a reminder about `docs/SCHEMA_MAP.md` updates. Surface if migration authoring proves inconsistent.
### Scanner audit portfolio (2026-05-27)
Six convoys authored from the scanner audit portfolio plan. Dependency order:
`secure-scanner-gemini-key``server-side-scan-pipeline` → (`add-real-ocr-layer` ∥
`redesign-scanner-flow`); `rename-collections-vocabulary` and
`scanner-correctness-polish` parallel after #1.
- **`secure-scanner-gemini-key`** (priority: **P0 security** — ships first).
Delete `pages/api/config/gemini.js`; remove CameraScanner client key auto-load;
add sixth `scan` rate-limit class; new `forbidden-client-side-llm-keys` CI gate.
**Operator action:** rotate `GEMINI_AI_API_KEY` in Google AI Studio + Vercel
before merge. Convoy: `.convoys/secure-scanner-gemini-key.md`.
- **`server-side-scan-pipeline`** (priority: P1 feature;
`depends_on: secure-scanner-gemini-key`). Server-owned `/api/scan/identify`;
`card_submissions` + `scan_attempts` tables; remove user-writable `cards`
INSERT; admin review queue; client disambiguation UI. Four briefs; gate-1
`/multitask` briefs 1+2. Convoy: `.convoys/server-side-scan-pipeline.md`.
- **`add-real-ocr-layer`** (priority: P1 feature;
`depends_on: server-side-scan-pipeline`). Tesseract Worker + `pg_trgm`
identify-by-text route; ≥70% Layer-1 hit rate via `scan_attempts.layer`.
Piggybacks `schema-map-fresh` CI path fix for `migrations/`. Convoy:
`.convoys/add-real-ocr-layer.md`.
- **`redesign-scanner-flow`** (priority: P1 feature;
`depends_on: server-side-scan-pipeline`). Stack-destination UX,
condition/foil/quantity, ownership badge, Blob scan-image persistence. Three
briefs; `/multitask` briefs 2+3 after brief 1. Can run parallel with
`add-real-ocr-layer`. Convoy: `.convoys/redesign-scanner-flow.md`.
- **`rename-collections-vocabulary`** (priority: P2 IA/copy; parallel to #1+#2).
"My Collection" / "Lists" / "Binders" copy sweep; `forbidden-stale-strings`
CI gate; `AGENTS.md` vocabulary table. Skips architect (`skip: arch`); IA +
UX run. Convoy: `.convoys/rename-collections-vocabulary.md`.
- **`scanner-correctness-polish`** (priority: P2 infra; parallel to #1+#2).
Idempotent Mark-Owned, fix bulk `setTimeout` race, `lib/use-auth` import,
`logCollectionActivity` on collection card POST. Single brief. Convoy:
`.convoys/scanner-correctness-polish.md`.
- **`schema-cleanup-from-scanner-audit`** (priority: P2 schema; **deferred**
NOT scanner-specific). Separate convoy when ready; surfaced by the scanner
audit but applies globally:
1. **`cards.quantity` + `cards.favorited`** on the global catalog — belong on
`user_cards` / `user_favorites`; drop from `cards`.
2. **`collections.is_public` vs `visibility`** — dual visibility flags;
reconcile to one mechanism (see also P2 §14 in this file).
3. **`is_system_collection` vs `user_cards` unification** — ownership model
smell; IA + schema convoy, not a scanner deliverable.
Do not fold into the six scanner convoys above; queue as its own architect-led
migration convoy after the scan pipeline stabilizes.
## Self-analytics ## Self-analytics
After each convoy, `scripts/log-convoy-event.sh` emits a record to `.convoys/.metrics.jsonl` (gitignored). After 3-5 convoys, run the upstream `agent-pipeline/analytics/` aggregator to see where token spend goes — that data feeds whether to add or remove rules. After each convoy, `scripts/log-convoy-event.sh` emits a record to `.convoys/.metrics.jsonl` (gitignored). After 3-5 convoys, run the upstream `agent-pipeline/analytics/` aggregator to see where token spend goes — that data feeds whether to add or remove rules.

View file

@ -103,7 +103,7 @@ await logCollectionActivity(collectionId, userId, 'card_added', { cardId, quanti
## Rate limiting ## Rate limiting
`lib/rate-limit.js` exposes five named limiters, one per route class. Each named export takes `req` (and `userId` for user-keyed classes) and returns `{ allowed, remaining, reset }`. The auth-only limiter shipped in `fix-auth-bypass` Brief 4 (commit `297afca`, login + register); the four remaining classes — `search`, `upload`, `generate`, `import` — and the seven currently-gated routes shipped in `add-rate-limiting` (squash commit `708ef45`, PR #20, 2026-05-24), the convoy that closed P0 #6 and brought the launch-readiness P0 set to 8/8 RESOLVED. The five Redis key prefixes were renamed `tcgvault:*` → `deckhearth:*` in `pick-a-name` (squash commit `9abbab6`, PR #21, 2026-05-24) — call shape, return shape, and gate-ordering rules below are byte-identical post-rename; only the on-Redis namespace changed (one-time per-window counter reset accepted). `lib/rate-limit.js` exposes six named limiters, one per route class. Each named export takes `req` (and `userId` for user-keyed classes) and returns `{ allowed, remaining, reset }`. The auth-only limiter shipped in `fix-auth-bypass` Brief 4 (commit `297afca`, login + register); the four remaining classes — `search`, `upload`, `generate`, `import` — and the seven currently-gated routes shipped in `add-rate-limiting` (squash commit `708ef45`, PR #20, 2026-05-24), the convoy that closed P0 #6 and brought the launch-readiness P0 set to 8/8 RESOLVED. The `scan` class shipped in `secure-scanner-gemini-key` (2026-05-27) for `/api/scan/identify` (wired in `server-side-scan-pipeline`). The six Redis key prefixes were renamed `tcgvault:*` → `deckhearth:*` in `pick-a-name` (squash commit `9abbab6`, PR #21, 2026-05-24) — call shape, return shape, and gate-ordering rules below are byte-identical post-rename; only the on-Redis namespace changed (one-time per-window counter reset accepted).
| Class | Limit | Window | Key | Used by | Helper | | Class | Limit | Window | Key | Used by | Helper |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
@ -112,8 +112,9 @@ await logCollectionActivity(collectionId, userId, 'card_added', { cardId, quanti
| `upload` | 10 | 1 hour | user | `/api/user/avatar` | `checkUploadRateLimit(req, userId)` | | `upload` | 10 | 1 hour | user | `/api/user/avatar` | `checkUploadRateLimit(req, userId)` |
| `generate` | 5 | 1 hour | user | `/api/user/avatar/generate` | `checkGenerateRateLimit(req, userId)` | | `generate` | 5 | 1 hour | user | `/api/user/avatar/generate` | `checkGenerateRateLimit(req, userId)` |
| `import` | 5 | 1 hour | user | `/api/cards/import-mtg`, `/api/cards/import-pokemon`, `/api/cards/import-lorcana` | `checkImportRateLimit(req, userId)` | | `import` | 5 | 1 hour | user | `/api/cards/import-mtg`, `/api/cards/import-pokemon`, `/api/cards/import-lorcana` | `checkImportRateLimit(req, userId)` |
| `scan` | 5 | 1 min | user | `/api/scan/identify` | `checkScanRateLimit(req, userId)` |
**Verbatim call shape** (identical across all five classes — only the helper name and the optional `userId` argument differ): **Verbatim call shape** (identical across all six classes — only the helper name and the optional `userId` argument differ):
```js ```js
import { checkSearchRateLimit } from '../../../lib/rate-limit.js'; import { checkSearchRateLimit } from '../../../lib/rate-limit.js';
@ -142,7 +143,7 @@ export default async function handler(req, res) {
**Gate ordering rules:** **Gate ordering rules:**
1. **Method check first.** Reject the wrong verb with 405 before doing any limiter work. 1. **Method check first.** Reject the wrong verb with 405 before doing any limiter work.
2. **Auth check before any user-keyed limiter.** `extractUserIdentifier(userId)` THROWS when `userId` is null/undefined/empty (defensive). For `upload`, `generate`, and `import`, the handler MUST call `getUserFromRequest(req)` (or equivalent JWT verification) and confirm a non-null user BEFORE calling the limiter. Wrong order = anonymous user bypasses (the THROW surfaces immediately during dev; do not catch and silently fall back to IP). 2. **Auth check before any user-keyed limiter.** `extractUserIdentifier(userId)` THROWS when `userId` is null/undefined/empty (defensive). For `upload`, `generate`, `import`, and `scan`, the handler MUST call `getUserFromRequest(req)` (or equivalent JWT verification) and confirm a non-null user BEFORE calling the limiter. Wrong order = anonymous user bypasses (the THROW surfaces immediately during dev; do not catch and silently fall back to IP).
3. **For IP-keyed limiters (`auth`, `search`), gate placement is flexible** — either at the top of the handler (after the method check) or after a separate auth check that the route happens to also have (e.g. `users/search` JWT-verifies before rate-limiting, both are correct). The limiter only needs `req` for IP extraction. 3. **For IP-keyed limiters (`auth`, `search`), gate placement is flexible** — either at the top of the handler (after the method check) or after a separate auth check that the route happens to also have (e.g. `users/search` JWT-verifies before rate-limiting, both are correct). The limiter only needs `req` for IP extraction.
4. **Admin-role check, if applicable, goes between auth and rate-limit.** Used by all three `/api/cards/import-*` routes: `if (user.role !== 'admin') return res.status(403).json({ error: 'Admin access required' })` sits between the `if (!user)` 401 and the import rate-limit call. 4. **Admin-role check, if applicable, goes between auth and rate-limit.** Used by all three `/api/cards/import-*` routes: `if (user.role !== 'admin') return res.status(403).json({ error: 'Admin access required' })` sits between the `if (!user)` 401 and the import rate-limit call.
@ -153,7 +154,7 @@ export default async function handler(req, res) {
**Env vars (unchanged from Brief 4):** `KV_REST_API_URL` + `KV_REST_API_TOKEN` (auto-provisioned by Vercel's Upstash Marketplace integration). In prod, missing either var is a **fail-closed throw** on the first call. In dev / test, the module warn-and-no-ops so local work isn't blocked. See `AGENTS.md` Gotcha #12 for the full env-var contract. **Env vars (unchanged from Brief 4):** `KV_REST_API_URL` + `KV_REST_API_TOKEN` (auto-provisioned by Vercel's Upstash Marketplace integration). In prod, missing either var is a **fail-closed throw** on the first call. In dev / test, the module warn-and-no-ops so local work isn't blocked. See `AGENTS.md` Gotcha #12 for the full env-var contract.
**429 response shape is uniform across all five classes.** Same error message (`'Too many attempts. Try again later.'`) and same `Retry-After` header calculation. Per-class variation would fingerprint the limits to an attacker. **429 response shape is uniform across all six classes.** Same error message (`'Too many attempts. Try again later.'`) and same `Retry-After` header calculation. Per-class variation would fingerprint the limits to an attacker.
**Fail-open on Upstash outage.** A network failure inside `ratelimit.limit(...)` returns `{ allowed: true, remaining: Infinity, reset: 0 }` with a single `console.error('[rate-limit]', err)`. Reasoning: a hard Upstash outage should not lock the entire user base out of every gated route. Brute-force / abuse protection lives behind defense-in-depth (Vercel firewall, future fail2ban-style lockout). **Fail-open on Upstash outage.** A network failure inside `ratelimit.limit(...)` returns `{ allowed: true, remaining: Infinity, reset: 0 }` with a single `console.error('[rate-limit]', err)`. Reasoning: a hard Upstash outage should not lock the entire user base out of every gated route. Brute-force / abuse protection lives behind defense-in-depth (Vercel firewall, future fail2ban-style lockout).

View file

@ -142,6 +142,59 @@ jobs:
fi fi
echo "OK: no Access-Control-Allow-* headers under pages/api/." echo "OK: no Access-Control-Allow-* headers under pages/api/."
forbidden-client-side-llm-keys:
name: No client-side LLM key leakage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fail on key-returning config endpoints or new browser LLM URLs
run: |
# Deleted by secure-scanner-gemini-key — must not return API keys to browsers.
if [ -f pages/api/config/gemini.js ]; then
echo "::error file=pages/api/config/gemini.js::Forbidden config endpoint — do not return API keys to browsers."
exit 1
fi
CONFIG_MATCHES=$(grep -rEn 'apiKey:' pages/api/config/ 2>/dev/null || true)
if [ -n "$CONFIG_MATCHES" ]; then
echo "::error::Forbidden apiKey response under pages/api/config/."
echo "$CONFIG_MATCHES" | while IFS= read -r line; do
file=$(echo "$line" | cut -d: -f1)
lineno=$(echo "$line" | cut -d: -f2)
echo "::error file=${file},line=${lineno}::Do not return API keys from config endpoints."
done
exit 1
fi
# Grandfathered until server-side-scan-pipeline (#2) removes browser LLM clients.
GRANDFATHER=(
lib/ai-ocr.js
components/OCRSettings.js
)
LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com'
FOUND=()
while IFS= read -r file; do
skip=false
for gf in "${GRANDFATHER[@]}"; do
if [ "$file" = "$gf" ]; then
skip=true
break
fi
done
if [ "$skip" = true ]; then
continue
fi
if grep -qE "$LLM_PATTERN" "$file" 2>/dev/null; then
FOUND+=("$file")
fi
done < <(find components lib pages -name '*.js' ! -path 'pages/api/*' 2>/dev/null || true)
if [ ${#FOUND[@]} -gt 0 ]; then
echo "::error::Client-side LLM API URLs must not appear outside pages/api/ (except grandfathered files pending server-side-scan-pipeline)."
for path in "${FOUND[@]}"; do
echo "::error file=${path}::Move LLM calls server-side or add to server-side-scan-pipeline removal list."
done
exit 1
fi
echo "OK: no client-side LLM key leakage patterns detected."
test: test:
name: Unit tests (vitest) name: Unit tests (vitest)
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -29,58 +29,36 @@ export default function CameraScanner({ onCardScanned, onError }) {
// Mana symbol settings // Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false }); const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
// Load OCR settings and auto-configure Gemini // Load OCR settings from localStorage only (never fetch server-side API keys)
useEffect(() => { useEffect(() => {
const loadOcrSettings = async () => { let settings = {
let settings = { service: 'gemini',
service: 'gemini', openaiApiKey: '',
openaiApiKey: '', geminiApiKey: '',
geminiApiKey: '', ollamaUrl: 'http://localhost:11434'
ollamaUrl: 'http://localhost:11434'
};
const savedSettings = localStorage.getItem('ocrSettings');
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
settings = { ...settings, ...parsed };
} catch (error) {
console.error('Failed to load OCR settings:', error);
}
}
// Auto-load Gemini API key from environment
if (!settings.geminiApiKey) {
try {
const response = await fetch('/api/config/gemini');
if (response.ok) {
const data = await response.json();
if (data.hasKey && data.apiKey) {
settings.geminiApiKey = data.apiKey;
settings.service = 'gemini';
console.log('✅ Auto-configured Gemini API key from environment');
}
}
} catch (error) {
console.log('Could not auto-load Gemini API key:', error);
}
}
setOcrSettings(settings);
// Configure AI services
if (settings.openaiApiKey) {
aiCardOCR.setApiKey(settings.openaiApiKey);
}
if (settings.geminiApiKey) {
geminiCardOCR.setApiKey(settings.geminiApiKey);
}
if (settings.ollamaUrl) {
ollamaCardOCR.setBaseUrl(settings.ollamaUrl);
}
}; };
loadOcrSettings(); const savedSettings = localStorage.getItem('ocrSettings');
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
settings = { ...settings, ...parsed };
} catch (error) {
console.error('Failed to load OCR settings:', error);
}
}
setOcrSettings(settings);
if (settings.openaiApiKey) {
aiCardOCR.setApiKey(settings.openaiApiKey);
}
if (settings.geminiApiKey) {
geminiCardOCR.setApiKey(settings.geminiApiKey);
}
if (settings.ollamaUrl) {
ollamaCardOCR.setBaseUrl(settings.ollamaUrl);
}
}, []); }, []);
// Configure canvas contexts for optimal performance // Configure canvas contexts for optimal performance

View file

@ -11,46 +11,26 @@ export default function OCRSettings({ isOpen, onClose }) {
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState(null); const [testResult, setTestResult] = useState(null);
// Load settings from localStorage on mount // Load settings from localStorage on mount (never fetch server-side API keys)
useEffect(() => { useEffect(() => {
const loadSettings = async () => { const savedSettings = localStorage.getItem('ocrSettings');
const savedSettings = localStorage.getItem('ocrSettings'); let currentSettings = {
let currentSettings = { service: 'gemini',
service: 'gemini', openaiApiKey: '',
openaiApiKey: '', geminiApiKey: '',
geminiApiKey: '', ollamaUrl: 'http://localhost:11434'
ollamaUrl: 'http://localhost:11434'
};
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
currentSettings = { ...currentSettings, ...parsed };
} catch (error) {
console.error('Error loading OCR settings:', error);
}
}
// Try to auto-load Gemini API key from environment if not already set
if (!currentSettings.geminiApiKey) {
try {
const response = await fetch('/api/config/gemini');
if (response.ok) {
const data = await response.json();
if (data.hasKey && data.apiKey) {
currentSettings.geminiApiKey = data.apiKey;
currentSettings.service = 'gemini'; // Default to Gemini if key is available
}
}
} catch (error) {
console.log('Could not auto-load Gemini API key:', error);
}
}
setSettings(currentSettings);
}; };
loadSettings(); if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
currentSettings = { ...currentSettings, ...parsed };
} catch (error) {
console.error('Error loading OCR settings:', error);
}
}
setSettings(currentSettings);
}, []); }, []);
const saveSettings = () => { const saveSettings = () => {

View file

@ -12,6 +12,7 @@ const LIMITER_CONFIG = {
upload: { limit: 10, window: '1 h', prefix: 'deckhearth:upload' }, upload: { limit: 10, window: '1 h', prefix: 'deckhearth:upload' },
generate: { limit: 5, window: '1 h', prefix: 'deckhearth:generate' }, generate: { limit: 5, window: '1 h', prefix: 'deckhearth:generate' },
import: { limit: 5, window: '1 h', prefix: 'deckhearth:import' }, import: { limit: 5, window: '1 h', prefix: 'deckhearth:import' },
scan: { limit: 5, window: '1 m', prefix: 'deckhearth:scan' },
}; };
// Lazy singleton. Module-load init would throw in environments without // Lazy singleton. Module-load init would throw in environments without
@ -129,3 +130,7 @@ export async function checkGenerateRateLimit(req, userId) {
export async function checkImportRateLimit(req, userId) { export async function checkImportRateLimit(req, userId) {
return check('import', extractUserIdentifier(userId)); return check('import', extractUserIdentifier(userId));
} }
export async function checkScanRateLimit(req, userId) {
return check('scan', extractUserIdentifier(userId));
}

View file

@ -1,25 +0,0 @@
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// Get Gemini API key from environment
const geminiApiKey = process.env.GEMINI_AI_API_KEY;
if (geminiApiKey) {
return res.status(200).json({
hasKey: true,
apiKey: geminiApiKey
});
} else {
return res.status(200).json({
hasKey: false,
message: 'No Gemini API key found in environment'
});
}
} catch (error) {
console.error('Error getting Gemini config:', error);
return res.status(500).json({ error: 'Internal server error' });
}
}