---
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-<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.
