diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..3efd070
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,142 @@
+# CLAUDE.md
+
+## Project
+
+Casecomp — Pokemon TCG card research tool. API at api.casecomp.xyz (Cloud Run `casecomp-api`), frontend at casecomp.xyz (Cloud Run `casecomp-site` + Cloudflare). Chrome extension for drop queue auto-join.
+
+## Git workflow
+
+- **Ask before every shared-state action.** Push, PR, merge, deploy, terraform apply — each requires separate explicit approval. Never chain them. Stop after each action and wait.
+- **No Co-Authored-By lines** in commits. No "Generated with Claude Code" in PR descriptions or any public text.
+- **Branch flow:** push to dev or main → CI runs → deploy on main push.
+- **Commits:** concise message, no attribution trailers.
+- **PR template:** .github/PULL_REQUEST_TEMPLATE.md
+
+## Code style
+
+- No comments unless the WHY is non-obvious.
+- No emojis in code, commits, or PRs.
+- Node 24 pinned across Dockerfile, CI, package.json.
+- User-facing language: "sample data" not "demo".
+
+## lib/ structure
+
+```
+lib/
+ sources/ ebay, magi (cheerio), snkrdunk, yahooauctions, tcgplayer
+ grading/ grading (8-subgrade v3), preprocessing (card detection + SSRF + corner crops), psa, psaTiers
+ auth/ auth (Google OAuth + JWT), api-keys (developer key CRUD)
+ cards/ card-database, card-identity, demo, price-history, grading-dataset
+ security/ rasp (RASP middleware, detection rules, anomaly scoring, Firestore event logging)
+ data/ firestore, cache, redis-cache, analytics, csv, email
+ search/ filters (condition, outlier), listingQuery, ebayCategories, output
+ swagger.js OpenAPI spec
+```
+
+## UI design rules
+
+Strict palette — no deviations:
+- Backgrounds: `#07070a` (body), `#0c0d12` (panels), `#14151c` (inset)
+- Gold accent: `#d9b676` only. No blue, no purple.
+- Status: `#7ce0a8` (green), `#ff5d5d` (red). No other greens/reds.
+- Borders: `rgba(255,255,255,0.08)` — not solid gray.
+- Fonts: Space Grotesk for headings, Inter Tight for body, JetBrains Mono for mono/labels.
+- No emojis anywhere.
+
+## Architecture decisions
+
+- **Caching:** Firestore only, no Redis. Stale-while-revalidate on active listings. TCGdex card DB cached (24h TTL). PSA negative caching (7 days).
+- **CORS:** Wildcard `*`. API key is the access control layer.
+- **Auth:** Owner `CC_LIVE_` (60/min), sandbox (5/min), developer keys (per-key rate limit enforced), demo `?demo=true` (360/min). Local: no auth.
+- **authMiddleware** checks owner → sandbox → JWT (Google OAuth) → Firestore developer keys (30s cache). `apiAuthMiddleware` adds demo bypass.
+- **Developer self-serve:** auto-key on first Google sign-in, max 3 per user, rotate, revoke. `GET/POST/DELETE /api/developer/keys` + stats.
+- **Admin:** `CASECOMP_ADMIN_SUB` env var (Google sub). `GET/PATCH/DELETE /api/admin/keys`.
+- **eBay search:** active returned immediately, sold in background. Ship-to skipped. Seller feedback + relevance filtering.
+- **Autocomplete:** TCGdex EN+JP (~29K cards) cached in Firestore, instant startup.
+- **Error responses:** Sanitized via `safeErrorMessage()`. Never leak internals. Global JSON 404 handler + error handler at bottom of `api.js` — unmatched routes return `{"error":"Not found"}`, unhandled exceptions return sanitized JSON.
+- **RASP:** `lib/security/rasp.js` middleware inspects all inputs (query, body, path, headers) for SQLi, XSS, command injection, path traversal, NoSQL injection, prototype pollution. Bot fingerprinting + per-IP anomaly scoring (5-min half-life). Monitor mode by default (`RASP_MODE=block` to enforce). Events logged to `security-events` Firestore collection. `GET /api/security/events` (owner-only).
+
+## AI grading
+
+- **v3 pipeline:** card detection (Sonnet, or Together AI GLM-4.6V when `TOGETHER_API_KEY` set) → crop to card → corner crops per side → 8 parallel subgrade calls.
+- 8 subgrades: centering/corners/edges/surface × front/back. Each receives only its target image.
+- Overall = `(frontAvg × 0.60) + (backAvg × 0.40)`, capped at lowest subgrade + 1 (excessive defect rule).
+- Card detection: 4-corner detection → tilt correction (sharp rotate) → crop. Skips when card fills >80% of frame. SSRF-protected (DNS resolution, private IP blocking).
+- Centering subgrades return `lr`/`tb` ratios (e.g. "55/45", "52/48") for frontend overlay positioning.
+- Full PSA rubric (grades 5-10). Corner crops via `sharp`. eBay images upgraded to s-l1600.
+- Falls back to single prompt for non-Claude or missing back image.
+- Token usage + estimated cost tracked per grade ($3/$15 per 1M for Claude, $2.50/$10 for OpenAI).
+- **Grade probability distribution:** `gradeDistribution` field in response (e.g. `{"8": 65, "8.5": 12, "7.5": 23}`). Computed from overall + confidence.
+- **Centering hint:** POST `/api/grade` accepts optional `centeringHint` with user-measured ratios, appended to centering prompts.
+- **Shareable reports:** `GET /api/grade/report/:id` returns PNG card (SVG→sharp→PNG) with scores, distribution, limiter.
+- **ML dataset pipeline:** track-prices passively collects graded slab images (eBay sold) into `grading-dataset` Firestore collection. Parses PSA/BGS/CGC/TAG grade from listing title. `GET /api/grading-dataset/stats` (owner-only) monitors collection.
+- **Roadmap:** multi-pass median for deterministic grading, defect heatmap overlay, accuracy calibration once dataset reaches ~500 images.
+
+## Set browser
+
+- `GET /api/sets` — 238 sets with logos, era groups, officialCards/secretCards
+- `GET /api/sets/:setCode` — cards with rarity (~4K tagged)
+- `GET /api/portfolio/set/:setCode` — collection tracking (owned cardIds)
+
+## Key endpoints
+
+- `/api/search` — multi-source search
+- `/api/autocomplete` — card name autocomplete (29K cards)
+- `/api/sets`, `/api/sets/:setCode` — set browser
+- `/api/card/view/:setCode/:number` — card view with raw/graded, PSA, grading ROI
+- `/api/price-history` — historical prices + trend signal
+- `/api/psa` — PSA grading signal
+- `/api/grade` — AI pre-grade (accepts cardId + centeringHint, returns gradeId + gradeDistribution)
+- `/api/grade/report/:id` — shareable grade report as PNG
+- `/api/grades/mine` — user's grade history
+- `/api/grades/:id` (DELETE) — delete a grade
+- `/api/upload-url` — signed GCS upload URL for card images
+- `/api/portfolio` — portfolio CRUD
+- `/api/portfolio/set/:setCode` — collection progress
+- `/api/developer/keys` — self-serve API key CRUD
+- `/api/developer/stats` — per-user usage stats
+- `/api/admin/keys` — admin key management
+- `/api/analytics` — request analytics (owner-only)
+- `/api/grading-dataset/stats` — ML dataset collection stats (owner-only)
+- `/api/security/events` — RASP security event log (owner-only)
+- `/auth/google` — Google OAuth → JWT
+
+## Free tier strategy
+
+**Public (no key):** card DB, sets, autocomplete, sitemap, docs, health.
+**Demo (`?demo=true`):** 3 hardcoded sample cards. No live API calls.
+**Gated (requires key):** live search, AI grading, alerts, portfolio CRUD, admin.
+
+## Infrastructure
+
+- GCP: Cloud Run in asia-south1 + us-central1, Firestore (asia-south1), HTTPS LB (global, geo-routes), Cloud CDN, Secret Manager, Cloud Scheduler
+- Terraform: GCS state, 8 files by resource type, CI plan/apply, `for_each` over regions
+- **CI (ci.yml):** single workflow. Jobs: unit, api (demo-based, continue-on-error), smoke (non-blocking), codeql, scan (SBOM+Grype), audit (npm+lockfile-lint), secrets (gitleaks). Required: unit + codeql.
+- **Deploy:** Kaniko v1.23.2 --reproducible → cosign sign → SLSA provenance → deploy by digest → both regions → health check → OWASP ZAP DAST
+- **Custom Wolfi base image:** gcr.io/casecomp-495718/casecomp-node24. Built with apko. 9 smoke tests. 0 CVEs.
+- **Supply chain:** Dependabot, lockfile-lint, Socket.dev, pre-commit hook (blocks .env, secrets, large files)
+- **Binary Authorization:** ENFORCED on both Cloud Run services
+- **Secret workflow:** Add to secrets.tf → CI creates → `gcloud secrets versions add` for value. Never `gcloud secrets create`.
+- Secrets: EBAY_CLIENT_ID/SECRET, ANTHROPIC_API_KEY, TOGETHER_API_KEY, PSA_AUTH_TOKEN, CASECOMP_API_KEY, CASECOMP_SANDBOX_KEY, RESEND_API_KEY, CASECOMP_JWT_SECRET, GOOGLE_OAUTH_CLIENT_ID, CASECOMP_ADMIN_SUB
+
+## Frontend (casecomp.xyz)
+
+- Separate repo: Pyronewbic/casecomp-drop
+- TanStack Start SSR, Cloudflare Workers
+- 63 Lovable prompts. 9 routes: /, /search, /sets, /set/$, /card/$, /portfolio, /price-guide, /developers, /install
+- Auth: GoogleLogin popup → JWT → authFetch. isAdmin flag for admin features.
+- GradeCard.tsx: 2-step upload (front/back), card autocomplete selection (cardId), cloud grading + WebLLM local, v3 8-subgrade display with tap-to-expand, post-grade centering lines, grade probability distribution bar, share button, "Add to collection" post-grade
+- Portfolio: graded cards history section (GET /api/grades/mine), expandable subgrade details, delete grades
+- Landing: nav cards (clickable feature grid), supported sources, pricing. Agent-ready (.well-known/*).
+- CardSearch.tsx: TrendBadge, Raw/Graded tabs, ROI card, Grade/Prices detail panel, AlertForm, Add to Portfolio
+- MyApiKeys.tsx, AdminKeys.tsx on /developers
+- Nav: Search · Sets · Portfolio · API + avatar dropdown (My Portfolio, Sign Out)
+
+## Backlog
+
+- **Defect heatmap overlay** — LLM outputs bounding boxes for detected defects, overlay on image via sharp
+- **Grading accuracy eval** — benchmark script once dataset hits ~500 images, publish accuracy vs PSA
+- **Self-hosted vLLM** — RunPod serverless with GLM-4.6V-Flash when volume justifies (~$4/1K grades vs $7.40 Together)
+- **TCGPlayer seeding** — accumulate daily
+- **Price comparison table** — side-by-side 2-3 cards
+- **Grading batch calculator** — multi-card ROI
\ No newline at end of file
diff --git a/README.md b/README.md
index d794d37..5cb771b 100644
--- a/README.md
+++ b/README.md
@@ -129,6 +129,7 @@ curl -X POST -H "Authorization: Bearer $CASECOMP_KEY" \
- **Reproducible builds** - Kaniko `--reproducible` flag, pinned version
- **Multi-region** - Cloud Run in asia-south1 + us-central1, global LB auto geo-routes to nearest
- **Custom base image** - Wolfi + apko Node 24 image, manual rebuild, zero CVEs by design
+- **RASP** - runtime request inspection for SQLi, XSS, command injection, path traversal, NoSQL injection, prototype pollution. Per-IP anomaly scoring, bot fingerprinting, Firestore event logging
- **Supply chain** - SLSA provenance, Dependabot, lockfile-lint, Socket.dev, pre-commit secret blocking
## Claude Code Skills
diff --git a/api.js b/api.js
index 1bde09c..f80809a 100644
--- a/api.js
+++ b/api.js
@@ -27,6 +27,7 @@ import { verifyGoogleToken, generateJwt, verifyJwt } from "./lib/auth/auth.js";
import { seedFromTCGPlayer } from "./lib/sources/tcgplayer.js";
import { getOrCreateCard, findCardByQuery, parseCardIdentity, resolveCardIdToQuery, SET_NAME_MAP } from "./lib/cards/card-identity.js";
import { initCardDatabase, searchCards, refreshCardDatabase, getAllSets, getSetWithCards, findCardByCardId } from "./lib/cards/card-database.js";
+import { raspMiddleware, getSecurityEvents } from "./lib/security/rasp.js";
import { fileURLToPath } from "url";
import path from "path";
@@ -43,9 +44,10 @@ app.use(helmet({
app.use(compression());
app.use(express.json({ limit: "100kb" }));
+app.use(raspMiddleware({ mode: process.env.RASP_MODE || "monitor" }));
app.use((req, res, next) => {
- req.requestId = crypto.randomUUID().slice(0, 8);
+ req.requestId = req.requestId || crypto.randomUUID().slice(0, 8);
res.setHeader("X-Request-Id", req.requestId);
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
@@ -647,6 +649,17 @@ app.get("/api/grading-dataset/stats", ownerOnly, async (req, res) => {
}
});
+app.get("/api/security/events", ownerOnly, async (req, res) => {
+ const days = Math.min(30, Math.max(1, Number(req.query.days) || 7));
+ const category = req.query.category || null;
+ try {
+ const events = await getSecurityEvents({ days, limit: 200, category });
+ res.json({ events, count: events.length, days });
+ } catch (e) {
+ res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId });
+ }
+});
+
// GET /api/health
app.get("/api/health", async (req, res) => {
const firestoreStatus = await getFirestoreStatus();
diff --git a/docs/env-vars.md b/docs/env-vars.md
index fc67f1f..34d0639 100644
--- a/docs/env-vars.md
+++ b/docs/env-vars.md
@@ -55,6 +55,7 @@ Copy **`.env.example`** to **`.env`** and fill in the required values.
| `CASECOMP_ADMIN_SUB` | *(none)* | Google account `sub` claim for admin access |
| `GOOGLE_OAUTH_CLIENT_ID` | *(none)* | Google OAuth client ID for sign-in (popup flow) |
| `TOGETHER_API_KEY` | *(none)* | Together AI key for card detection (GLM-4.6V, falls back to Claude Sonnet) |
+| `RASP_MODE` | `monitor` | RASP enforcement mode: `monitor` (log only) or `block` (reject malicious requests) |
## Email notifications
diff --git a/docs/internals.md b/docs/internals.md
index b847cd7..8eb321a 100644
--- a/docs/internals.md
+++ b/docs/internals.md
@@ -35,6 +35,8 @@ lib/
email.js Alert emails via Resend
firestore.js Firestore: grade logs, drops, webhooks, cache
redis-cache.js Redis cache (optional)
+ security/
+ rasp.js RASP middleware, detection rules, anomaly scoring, event logging
search/
filters.js Language, relevance, condition detection, outlier flagging
listingQuery.js eBay search query builder (raw vs slab)
@@ -59,7 +61,7 @@ test/
- **Auth middleware**: owner key (`CC_LIVE_`) → sandbox → JWT (Google OAuth) → Firestore developer keys (30s cache). `apiAuthMiddleware` adds demo bypass.
- **Rate limiting**: 60/min authenticated, 360/min demo, 5/min sandbox, 10/min auth endpoint.
-- **Security**: Helmet headers, trust proxy = 1, request IDs, compression, `safeErrorMessage()` on all errors. Global JSON 404 catch-all + error handler at bottom of file.
+- **Security**: Helmet headers, trust proxy = 1, request IDs, compression, `safeErrorMessage()` on all errors. Global JSON 404 catch-all + error handler at bottom of file. RASP middleware on all routes.
- **CORS**: wildcard `*` — API key is the access control layer.
- **Dashboard**: static files from `public/` served at `/` and `/admin`.
- **Docs**: Swagger UI at `/docs`, spec at `/docs/spec.json`.
@@ -184,6 +186,7 @@ Three workflows: `ci.yml` (all checks), `deploy.yml` (build + sign + deploy), `t
| Pre-commit hook | Local | Blocks .env, >1MB files, secret patterns |
| apko + Wolfi | Base image | Custom Node 24 image, manual `workflow_dispatch` |
| Dependabot | Weekly | npm + GitHub Actions version updates |
+| RASP | Runtime | SQLi/XSS/cmdi/traversal/NoSQLi/proto-pollution detection, anomaly scoring |
| Binary Auth | Cloud Run | ENFORCED policy (blocks unsigned images) |
## Scheduled tasks
diff --git a/lib/security/rasp.js b/lib/security/rasp.js
new file mode 100644
index 0000000..c3a2f98
--- /dev/null
+++ b/lib/security/rasp.js
@@ -0,0 +1,338 @@
+import crypto from "crypto";
+import { Firestore } from "@google-cloud/firestore";
+
+const COLLECTION = "security-events";
+
+let db = null;
+function getDb() {
+ if (db) return db;
+ try { db = new Firestore(); return db; } catch { return null; }
+}
+
+// ── Detection rules ──
+
+export const DETECTION_RULES = [
+ { id: "sqli-union", category: "sqli", severity: "high", pattern: /\bunion\s+(all\s+)?select\b/i },
+ { id: "sqli-stacked", category: "sqli", severity: "high", pattern: /['"];\s*(drop|alter|insert|update|delete|exec)\b/i },
+ { id: "sqli-tautology", category: "sqli", severity: "high", pattern: /\bor\b\s+['"]?\d+['"]?\s*=\s*['"]?\d+/i },
+ { id: "sqli-comment", category: "sqli", severity: "high", pattern: /'\s*(--|#|\/\*)/i },
+ { id: "sqli-keyword", category: "sqli", severity: "high", pattern: /\b(select\s+.{1,60}\s+from|insert\s+into|update\s+.{1,60}\s+set|delete\s+from|drop\s+(table|database)|alter\s+table)\b/i },
+
+ { id: "xss-script", category: "xss", severity: "high", pattern: /") !== null);
+});
+test("detectXss: catches javascript protocol", () => {
+ assert(detectXss("javascript:void(0)") !== null);
+});
+test("detectXss: catches onerror handler", () => {
+ assert(detectXss('
') !== null);
+});
+test("detectXss: catches iframe", () => {
+ assert(detectXss("