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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 1 addition & 2 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
116 changes: 115 additions & 1 deletion lib/cards/card-database.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(() => []),
Expand All @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions terraform/secrets.tf
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ locals {
"GOOGLE_OAUTH_CLIENT_ID",
"CASECOMP_ADMIN_SUB",
"TOGETHER_API_KEY",
"POKEWALLET_API_KEY",
]
}

Expand Down
5 changes: 3 additions & 2 deletions test/api-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down
Loading