deckhearth/.convoys/server-side-scan-pipeline.md
varutasu 8c58990fd9
fix(security): stop leaking Gemini API key to browsers (#34)
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>
2026-05-27 08:41:48 -05:00

8.6 KiB
Raw Blame History

name classification success_metric skip status created depends_on
server-side-scan-pipeline feature 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).
open 2026-05-27
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)

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.