deckhearth/.convoys/server-side-scan-pipeline.md
varutasu ad0e254324
chore(convoys): mark shipped convoys and refresh ship-readiness (#66)
Close stale convoy frontmatter for merged scanner, lint, and hygiene work;
record P1 #11.5 and queued follow-ups as RESOLVED with PR references.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 11:01:27 -05:00

214 lines
8.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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