diff --git a/CLAUDE.md b/CLAUDE.md index b4b167b..19245f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,7 +115,7 @@ Strict palette — no deviations: - **Deploy:** Kaniko v1.23.2 --reproducible → cosign sign → Binary Auth attestation (KMS) → SBOM attest (Syft SPDX from container) → build provenance (actions/attest-build-provenance) → deploy by digest → both regions → health check → OWASP ZAP DAST - **Custom Wolfi base image:** us-docker.pkg.dev/casecomp-495718/casecomp-node24/node24. Built with apko. 9 smoke tests. 0 CVEs. - **Supply chain:** SBOM + SLSA attestations on image digest, SHA-pinned GitHub Actions, Dependabot, lockfile-lint, Socket.dev, pre-commit hook (blocks .env, secrets, large files) -- **Binary Authorization:** ENFORCED on both Cloud Run services, KMS-backed attestor, deploy pipeline creates attestations +- **Binary Authorization:** REQUIRE_ATTESTATION enforced on both Cloud Run services, KMS-backed attestor (EC P256, deploy-attestor), deploy pipeline creates attestations via `gcloud beta container binauthz attestations sign-and-create` - **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 diff --git a/api.js b/api.js index 16dd0f5..214bb53 100644 --- a/api.js +++ b/api.js @@ -281,8 +281,8 @@ app.get("/api/search", apiAuthMiddleware, (req, res, next) => { req._errorType = ...item, detectedCondition: item.detectedCondition || detectCondition(item), }))); } - if (demoResult.sold?.length) recordSoldPrices(q, demoResult.sold, demoResult.source).catch(() => {}); const demoIdentity = parseCardIdentity(q); + if (demoResult.sold?.length) recordSoldPrices(q, demoResult.sold, demoResult.source, { cardId: demoIdentity.cardId }).catch(() => {}); if (demoIdentity.cardId) { demoResult.cardId = demoIdentity.cardId; demoResult.cardIdentity = { name: demoIdentity.name, setCode: demoIdentity.setCode, rarity: demoIdentity.rarity, setName: SET_NAME_MAP[demoIdentity.setCode] || null }; @@ -367,7 +367,7 @@ app.get("/api/search", apiAuthMiddleware, (req, res, next) => { req._errorType = } if (result.sold?.length) { - recordSoldPrices(q, result.sold, result.source).catch(() => {}); + recordSoldPrices(q, result.sold, result.source, { cardId: identity.cardId }).catch(() => {}); saveGradedImages(result.sold, result.source).catch(() => {}); } getOrCreateCard(q, { source: result.source, lang: config.language }).catch(() => {}); @@ -1412,7 +1412,9 @@ app.get("/api/price-history", apiAuthMiddleware, async (req, res) => { } try { - let history = await getPriceHistory(q, { days }); + const identity = parseCardIdentity(q); + const cardId = identity.cardId || undefined; + let history = await getPriceHistory(q, { days, cardId }); let tcgData = null; const tcg = await seedFromTCGPlayer(q); @@ -1434,8 +1436,8 @@ app.get("/api/price-history", apiAuthMiddleware, async (req, res) => { priceCurrency: "USD", title: tcg.name, soldDate: new Date().toISOString().split("T")[0], - }], "tcgplayer"); - history = await getPriceHistory(q, { days }); + }], "tcgplayer", { cardId }); + history = await getPriceHistory(q, { days, cardId }); } } @@ -1453,7 +1455,7 @@ app.get("/api/price-history", apiAuthMiddleware, async (req, res) => { } const trend = computePriceTrend(history); - res.json({ query: q, days, history, stats, trend, tcgplayer: tcgData }); + res.json({ query: q, days, history, stats, trend, tcgplayer: tcgData, cardId: cardId || null }); } catch (e) { logError("price-history", e.message, req.originalUrl, req.requestId); res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); @@ -1662,7 +1664,7 @@ async function enrichPortfolioCards(cards) { let currentPrice = 0; let currentSource = ""; try { - const history = await getPriceHistory(card.query, { days: 30 }); + const history = await getPriceHistory(card.query, { days: 30, cardId: card.cardId }); if (history.length) { currentPrice = history[0].price; currentSource = history[0].source || ""; @@ -1704,7 +1706,7 @@ async function calculateGainersLosers(cards, lookbackDays) { const cardChanges = await Promise.all(cards.map(async (card) => { let priceNDaysAgo = card.purchasePrice; try { - const history = await getPriceHistory(card.query, { days: lookbackDays }); + const history = await getPriceHistory(card.query, { days: lookbackDays, cardId: card.cardId }); if (history.length) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - lookbackDays); @@ -2049,18 +2051,33 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => { } catch {} let portfolioQueries = []; + const queryToCardId = new Map(); try { const userIds = await listPortfolioUserIds(); for (const uid of userIds.slice(0, 100)) { const pCards = await getPortfolio(uid); - portfolioQueries.push(...pCards.map(c => c.query).filter(Boolean)); + for (const c of pCards) { + if (c.query) { + portfolioQueries.push(c.query); + if (c.cardId) queryToCardId.set(c.query, c.cardId); + } + } } } catch {} + for (const q of [...defaultCards, ...alertCards]) { + if (!queryToCardId.has(q)) { + const id = parseCardIdentity(q); + if (id.cardId) queryToCardId.set(q, id.cardId); + } + } + const cards = req.body?.cards || [...new Set([...defaultCards, ...alertCards, ...portfolioQueries])]; const hasEbay = !!(clientId && clientSecret); const results = []; for (const card of cards) { + const cardId = queryToCardId.get(card) || parseCardIdentity(card).cardId; + const opts = { cardId }; try { let ebaySold = []; let magiSold = []; @@ -2080,7 +2097,7 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => { ]); ebaySold = soldRes.items || []; if (ebaySold.length) { - await recordSoldPrices(card, ebaySold, "ebay"); + await recordSoldPrices(card, ebaySold, "ebay", opts); saveGradedImages(ebaySold, "ebay").catch(() => {}); } } catch (e) { @@ -2092,7 +2109,7 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => { const magiRes = await searchMagi(card, {}); magiSold = magiRes.sold || []; if (magiSold.length) { - await recordSoldPrices(card, magiSold, "magi"); + await recordSoldPrices(card, magiSold, "magi", opts); saveGradedImages(magiSold, "magi").catch(() => {}); } } catch (e) { @@ -2102,7 +2119,7 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => { if (!ebaySold.length && !magiSold.length) { const demoResult = getDemoSearchResult(card); if (demoResult.sold?.length) { - await recordSoldPrices(card, demoResult.sold, demoResult.source); + await recordSoldPrices(card, demoResult.sold, demoResult.source, opts); usedDemo = true; ebaySold = demoResult.sold; } diff --git a/docs/internals.md b/docs/internals.md index 07d01f2..011c404 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -188,7 +188,7 @@ Three workflows: `ci.yml` (all checks), `deploy.yml` (build + sign + deploy), `t | 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) | +| Binary Auth | Cloud Run | REQUIRE_ATTESTATION policy (blocks unattested images) | ## Scheduled tasks diff --git a/lib/cards/price-history.js b/lib/cards/price-history.js index 612ff6e..d33776d 100644 --- a/lib/cards/price-history.js +++ b/lib/cards/price-history.js @@ -12,7 +12,7 @@ function cardKey(query) { return query.toLowerCase().trim().replace(/[/\\. ]+/g, "_").substring(0, 200); } -export async function recordSoldPrices(query, sold, source) { +export async function recordSoldPrices(query, sold, source, { cardId } = {}) { const fs = getDb(); if (!fs || !sold?.length) return; @@ -23,7 +23,7 @@ export async function recordSoldPrices(query, sold, source) { for (const item of sold) { if (!item.price || item.price <= 0) continue; const docId = `${key}__${item.itemId || Date.now()}`; - batch.set(fs.collection(COLLECTION).doc(docId), { + const doc = { cardKey: key, query, source: source || "ebay", @@ -34,22 +34,28 @@ export async function recordSoldPrices(query, sold, source) { title: (item.title || "").substring(0, 120), listingGradeLabel: item.listingGradeLabel || null, recordedAt: now, - }, { merge: true }); + }; + if (cardId) doc.cardId = cardId; + batch.set(fs.collection(COLLECTION).doc(docId), doc, { merge: true }); } try { await batch.commit(); } catch {} } -export async function getPriceHistory(query, { days = 90, limit = 200 } = {}) { +export async function getPriceHistory(query, { days = 90, limit = 200, cardId } = {}) { const fs = getDb(); if (!fs) return []; - const key = cardKey(query); const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); try { - const snap = await fs.collection(COLLECTION) - .where("cardKey", "==", key) + let q = fs.collection(COLLECTION); + if (cardId) { + q = q.where("cardId", "==", cardId); + } else { + q = q.where("cardKey", "==", cardKey(query)); + } + const snap = await q .where("recordedAt", ">=", cutoff) .orderBy("recordedAt", "desc") .limit(limit) diff --git a/terraform/binary-auth.tf b/terraform/binary-auth.tf index 3d90bed..7d74f00 100644 --- a/terraform/binary-auth.tf +++ b/terraform/binary-auth.tf @@ -79,8 +79,12 @@ resource "google_binary_authorization_policy" "default" { global_policy_evaluation_mode = "ENABLE" default_admission_rule { - evaluation_mode = "ALWAYS_ALLOW" + evaluation_mode = "REQUIRE_ATTESTATION" enforcement_mode = "ENFORCED_BLOCK_AND_AUDIT_LOG" + + require_attestations_by = [ + google_binary_authorization_attestor.deploy.name, + ] } depends_on = [google_project_service.binaryauthorization] diff --git a/terraform/firestore.tf b/terraform/firestore.tf index d12a549..f569dff 100644 --- a/terraform/firestore.tf +++ b/terraform/firestore.tf @@ -45,6 +45,13 @@ locals { { field_path = "recordedAt", order = "DESCENDING" }, ] } + "price-history_cardId_recordedAt" = { + collection = "price-history" + fields = [ + { field_path = "cardId", order = "ASCENDING" }, + { field_path = "recordedAt", order = "DESCENDING" }, + ] + } } }