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>
8.7 KiB
| name | classification | success_metric | skip | status | created | depends_on | |
|---|---|---|---|---|---|---|---|
| server-side-scan-pipeline | feature | 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). | shipped | 2026-05-27 |
|
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/(vianpm 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 viaprocess.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 tocard_submissionsinstead.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— rewriteprocessConfirmedCardto call/api/scan/identify; render disambiguation UI whenneedsUserSelection: true.pages/scanner.js— wire queue + error states to new API contract.pages/admin/card-submissions.js(new) — admin review queue; promote-to-cardsaction.pages/api/admin/card-submissions/**(new) — list, approve, reject..cursor/rules/api-routes.mdc§ Rate limiting — documentscanclass if not fully done in #1.- Extend
forbidden-client-side-llm-keys— also fail ifimport.*ai-ocrappears incomponents/(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-detectionif Layer-1 hit rate stays below 70%. - Schema cleanup (
cards.quantity, dual visibility flags) — seeschema-cleanup-from-scanner-auditstub in.convoys/ship-readiness.md.
Roles invoked
role-ia-architect— admin review queue IA, disambiguation flow labels.role-ux-reviewer— disambiguation picker UX, error/loading states.role-architect— API contract, migration shape, brief decomposition.role-implementer— 4 briefs (2 parallel at gate 1).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/identifyserver 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-ocrincomponents/
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)
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)
-
/api/scan/identifyrequest 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. -
Disambiguation response envelope. Verbatim fields:
{ needsUserSelection, candidates: [{ id, name, set, score }], … }. Architect locks the contract before Brief 2 ships. -
card_submissions.statusenum. Recommend:pending | approved | rejected. Promotion copies vetted row intocardswith admin attribution. -
What happens to
find-or-createcallers? Inventory all fetch sites; migrate to/api/scan/identify+ submissions path. Architect lists in brief. -
Gemini model + prompt ownership. Server-side only; single module (extend existing server helper or new
lib/scan-identify.js).
Known constraints
checkScanRateLimitgate-ordering — MUST sit AFTERgetUserFromRequestper.convoys/add-rate-limiting.mdDecision 4 (extractUserIdentifierTHROWS without userId).GEMINI_AI_API_KEYserver-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 viamigrations/only. - Card-import rate limits unchanged — import routes stay admin-only; scan limiter is a separate class.
Acceptance criteria
- Zero client-side references to
GEMINI_AI_API_KEY,apiKey:, or directgenerativelanguage.googleapis.comcalls outsidepages/api/. GET/POST /api/scan/identifyrequires auth; returns 401 without token; returns 429 when scan limit exceeded.- Authenticated scan of unknown card creates
card_submissionsrow, NOT acardsrow. - Disambiguation UI renders when API returns
needsUserSelection: true; user must pick before card enters queue. - Admin can list + approve/reject submissions at
/admin/card-submissions. - Approve action promotes to
cardswith audit trail (reviewed_by). forbidden-client-side-llm-keysCI job green includingcomponents/ai-ocr import check.npm run lintbaseline preserved;npm run test:run21/21 green.docs/SCHEMA_MAP.mdupdated 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.