213 lines
8.6 KiB
Markdown
213 lines
8.6 KiB
Markdown
|
|
---
|
|||
|
|
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 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.
|