deckhearth/.convoys/server-side-scan-pipeline.md

213 lines
8.6 KiB
Markdown
Raw Normal View History

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