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>
214 lines
8.7 KiB
Markdown
214 lines
8.7 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: 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.
|