Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 29 additions & 12 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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(() => {});
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
}
}

Expand All @@ -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 });
Expand Down Expand Up @@ -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 || "";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 = [];
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion docs/internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 13 additions & 7 deletions lib/cards/price-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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",
Expand All @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion terraform/binary-auth.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 7 additions & 0 deletions terraform/firestore.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
}
}
}

Expand Down
Loading