--- 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: shipped created: 2026-05-27 depends_on: - secure-scanner-gemini-key --- # Convoy: server-side-scan-pipeline **As-shipped:** PR #35 (scanner wave, 2026-05-27). Server-owned `/api/scan/identify`, `card_submissions`, disambiguation UI. 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 1–5; 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-`. **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.