diff --git a/.env.example b/.env.example index 735f94d..cb8a573 100644 --- a/.env.example +++ b/.env.example @@ -90,6 +90,11 @@ LOCAL_GRADER_URL= # Without this, emails come from onboarding@resend.dev #RESEND_VERIFIED_DOMAIN=1 +# ── PokeWallet (Japanese card rarity data) ─────────────────── +# API key from pokewallet.io — used to fetch rarity for JP-only sets +# Free tier: 100 req/hr, 1000/day +#POKEWALLET_API_KEY=pk_live_ + # Firestore for persistent storage (grades, drops, webhooks). # On Cloud Run, auto-authenticates via the service account — no config needed. # Locally, set this to your GCP project ID and run: gcloud auth application-default login diff --git a/CLAUDE.md b/CLAUDE.md index 4764a06..45b18bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -119,7 +119,7 @@ Strict palette — no deviations: - **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:** 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 +- 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, POKEWALLET_API_KEY ## Frontend (casecomp.xyz) diff --git a/api.js b/api.js index 488d1ee..16e56f1 100644 --- a/api.js +++ b/api.js @@ -906,8 +906,7 @@ app.get("/api/sets/:setCode", requireCardDb, (req, res) => { }); // POST /api/card-database/sync -app.post("/api/card-database/sync", apiAuthMiddleware, async (req, res) => { - if (req.authTier !== "owner") return res.status(403).json({ error: "Owner only" }); +app.post("/api/card-database/sync", ownerOnly, async (req, res) => { try { const force = req.query.force === "true"; const result = await syncCardDatabase({ force }); diff --git a/lib/cards/card-database.js b/lib/cards/card-database.js index 390f9d1..054cc70 100644 --- a/lib/cards/card-database.js +++ b/lib/cards/card-database.js @@ -57,6 +57,19 @@ const RARITY_MAP = { "Crown": "CR", }; +const POKEWALLET_RARITY_MAP = { + "Special Art Rare": "SAR", + "Illustration Rare": "IR", + "Ultra Rare": "UR", + "Hyper Rare": "HR", + "Super Rare": "SR", + "Double Rare": "RR", + "Art Rare": "AR", + "Shiny Rare": "AR", + "ACE SPEC Rare": "ACE", + "Crown Rare": "CR", +}; + const RARITY_TIERS = Object.keys(RARITY_MAP); let cardIndex = []; @@ -123,6 +136,72 @@ async function fetchRarities(lang, timeout = 15000) { return map; } +async function fetchPokewalletRarities(setCodesWithoutRarity, timeout = 15000) { + const apiKey = process.env.POKEWALLET_API_KEY; + if (!apiKey || !setCodesWithoutRarity.length) return new Map(); + + const map = new Map(); + let pwSets; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + const res = await fetch("https://api.pokewallet.io/sets", { + headers: { "X-API-Key": apiKey }, + signal: controller.signal, + }); + clearTimeout(timer); + if (!res.ok) return map; + pwSets = await res.json(); + } catch { + return map; + } + + const pwByCode = new Map(); + for (const s of pwSets) { + if (s.language === "jap" && s.set_code) { + pwByCode.set(s.set_code.toLowerCase(), s.set_code); + } + } + + const toFetch = setCodesWithoutRarity + .filter(code => pwByCode.has(code.toLowerCase())) + .slice(0, 20); + + for (const code of toFetch) { + const pwCode = pwByCode.get(code.toLowerCase()); + try { + let page = 1; + while (page <= 8) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + const res = await fetch( + `https://api.pokewallet.io/sets/${pwCode}?page=${page}&limit=25`, + { headers: { "X-API-Key": apiKey }, signal: controller.signal } + ); + clearTimeout(timer); + if (!res.ok) break; + const data = await res.json(); + const cards = data.cards || []; + if (!cards.length) break; + for (const c of cards) { + const info = c.card_info || {}; + const rarity = POKEWALLET_RARITY_MAP[info.rarity]; + if (!rarity) continue; + const num = info.card_number?.split("/")[0]?.replace(/^0+/, "") || ""; + if (num) { + map.set(`${code.toLowerCase()}-${num}`, rarity); + } + } + if (cards.length < 25) break; + page++; + } + } catch {} + } + + if (map.size) console.log(`Card database: ${map.size} JP rarities from PokeWallet (${toFetch.length} sets)`); + return map; +} + function parseSetCode(id) { const m = id.match(/^([^-]+)-/); return m ? m[1] : null; @@ -294,6 +373,39 @@ function buildIndexFromApi(enCards, jaCards, rarityMap = new Map()) { return index; } +function findSetsWithoutRarity(index) { + const setRarity = new Map(); + for (const card of index) { + if (!card.setCode || card.lang !== "jp") continue; + const code = card.setCode.toLowerCase(); + if (!setRarity.has(code)) setRarity.set(code, { total: 0, hasRarity: 0 }); + const entry = setRarity.get(code); + entry.total++; + if (card.rarity) entry.hasRarity++; + } + return [...setRarity.entries()] + .filter(([, v]) => v.hasRarity / v.total < 0.1) + .map(([code]) => code); +} + +async function enrichWithPokewalletRarities(index) { + const setsNeeding = findSetsWithoutRarity(index); + if (!setsNeeding.length) return; + const pwRarities = await fetchPokewalletRarities(setsNeeding); + if (!pwRarities.size) return; + let enriched = 0; + for (const card of index) { + if (card.rarity) continue; + const normId = normalizeCardId(card.id); + const rarity = pwRarities.get(normId); + if (rarity) { + card.rarity = rarity; + enriched++; + } + } + if (enriched) console.log(`Card database: enriched ${enriched} cards with PokeWallet rarities`); +} + async function fetchAndBuildIndex() { const [enCards, jaCards, enRarities, jaRarities, setMeta] = await Promise.all([ fetchCards("en").catch(() => []), @@ -305,7 +417,9 @@ async function fetchAndBuildIndex() { if (!enCards.length && !jaCards.length) return null; const rarityMap = new Map([...enRarities, ...jaRarities]); for (const [id, meta] of setMeta) tcgdexSetMeta.set(id, meta); - return buildIndexFromApi(enCards, jaCards, rarityMap); + const index = buildIndexFromApi(enCards, jaCards, rarityMap); + await enrichWithPokewalletRarities(index); + return index; } export async function initCardDatabase() { diff --git a/terraform/secrets.tf b/terraform/secrets.tf index f72e928..a1a38e0 100644 --- a/terraform/secrets.tf +++ b/terraform/secrets.tf @@ -11,6 +11,7 @@ locals { "GOOGLE_OAUTH_CLIENT_ID", "CASECOMP_ADMIN_SUB", "TOGETHER_API_KEY", + "POKEWALLET_API_KEY", ] } diff --git a/test/api-test.js b/test/api-test.js index d0098e7..dea3569 100644 --- a/test/api-test.js +++ b/test/api-test.js @@ -948,8 +948,9 @@ async function run() { }); await test("POST /api/card-database/sync requires owner key", async () => { - const { res } = await json("/api/card-database/sync", { method: "POST" }); - assert(res.status === 403, `expected 403, got ${res.status}`); + const { res } = await jsonNoAuth("/api/card-database/sync", { method: "POST" }); + if (API_KEY) assert(res.status === 403, `expected 403, got ${res.status}`); + else assert(res.status === 200, `expected 200 in local mode, got ${res.status}`); }); // ── Price trend ──