deckhearth/pages/api/cards/search.js
Randall Stillwell 51a3a970e0 feat(security): rate-limit search/upload/import + gate import routes (P0 #6)
Closes P0 #6 (no rate limiting) from PARTIAL → RESOLVED. With
this merge, all 8 P0 ship-blockers are RESOLVED. fix-auth-bypass
Brief 4 shipped lib/rate-limit.js with a single 5/15min auth
limiter wired into login + register; this brief extends the
module to 5 named limiters (auth/search/upload/generate/import)
and wires them into the remaining abusable surface.

Per architect Decision 1 — Option A (gate all 3 import routes
uniformly). The architect's investigation found a critical
secondary bug: pages/admin/card-import.js's fetch sends NO
Authorization header today. Adding getUserFromRequest to the
import APIs without fixing the admin UI atomically would have
returned 401 on every "Import Cards" click. Both edits ship in
this single commit — API gating + admin UI Bearer fix — for
atomic safety. Lorcana is dead in frontend today (only
scripts/import-lorcana.js uses that path) but gated uniformly
to future-proof per AGENTS.md § 1 status; a
delete-dead-lorcana-import follow-up convoy is queued for later
if we decide to drop Lorcana entirely.

Per Decision 2 — hybrid named-limiter shape in lib/rate-limit.js.
checkAuthRateLimit(req) signature + return shape preserved
verbatim (don't break Brief 4's contract); 4 new named functions
added (checkSearchRateLimit, checkUploadRateLimit,
checkGenerateRateLimit, checkImportRateLimit). Map<className,
Ratelimit> cache, per-class Redis prefix (tcgvault:auth,
tcgvault:search, tcgvault:upload, tcgvault:generate,
tcgvault:import) so each class has its own budget.

Per Decision 3 — per-class limit values tuned with evidence:
  auth      5  / 15min  IP-keyed   (unchanged from Brief 4)
  search    60 / 1min   IP-keyed   (bumped from 30 — ShareModal
                                    has no debounce; 17-char email
                                    = 16 requests in <5s)
  upload    10 / 1hr    user-keyed
  generate  5  / 1hr    user-keyed (DiceBear is free, kept at 5)
  import    5  / 1hr    user-keyed (admin-only; external APIs
                                    have their own limits)

Per Decision 4 — two extractors. extractIpIdentifier (existing,
unchanged) and extractUserIdentifier (new). The new one THROWS on
null/undefined/empty/NaN userId to prevent silent fallback-to-IP
(which would convert per-user limits into per-IP and lock out
households). Architect's R-finding: places the gate AFTER the
auth check on every per-user-keyed route, never before.

Per Decision 5 — uniform 429 response shape verbatim matching
login.js/register.js: Retry-After header + JSON
{ error: 'Too many attempts. Try again later.' }. Anti-
fingerprinting (per-class messages would tell an attacker which
classes have which limits).

Per Decision 6 — no new per-route handler tests this convoy.
Vitest 21/21 unchanged at merge.

Verification:
  - npm run lint: 128 problems (baseline match)
  - npm run test:run: 21/21 vitest pass (no regression;
    auth-utils tests don't transitively load rate-limit per
    architect D6 evidence)
  - 5 named limiter exports verified via per-route grep counts
  - Admin UI sends Authorization: Bearer <token> from
    localStorage in the import fetch (matching pattern from
    other admin pages)
  - Brief 4's login.js + register.js byte-identical at HEAD
  - .cursor/rules/api-routes.mdc § Rate limiting extended with
    per-class table + gate-ordering rules

No new dependencies (Brief 4's @upstash/ratelimit + @upstash/redis
suffice). No workflow YAML changes. No AGENTS.md edits (doc-
writer pass at convoy close handles Gotcha #12 update + § 6
testing update + ship-readiness Status summary 7/8 → 8/8).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 22:46:27 -05:00

247 lines
9.2 KiB
JavaScript

import { sql } from '@vercel/postgres';
import { checkSearchRateLimit } from '../../../lib/rate-limit.js';
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { allowed, reset } = await checkSearchRateLimit(req);
if (!allowed) {
res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000));
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
try {
const {
query = '',
game = 'all',
rarity = 'all',
set = 'all',
minPrice = '',
maxPrice = '',
page = '1',
limit = '50'
} = req.query;
const pageNum = parseInt(page) || 1;
const limitNum = parseInt(limit) || 50;
const offset = (pageNum - 1) * limitNum;
// Define the columns we want to select (only existing columns)
// Note: removed flavor_text, hp, type, form, weakness, retreat_cost as they don't exist in the current schema
// Normalize filters
const filters = {
hasQuery: query.trim() !== '',
hasGame: game !== 'all',
hasRarity: rarity !== 'all',
hasSet: set !== 'all',
hasMinPrice: minPrice && !isNaN(parseFloat(minPrice)),
hasMaxPrice: maxPrice && !isNaN(parseFloat(maxPrice))
};
let result, countResult;
// Handle different filter combinations using template literals
if (!filters.hasQuery && !filters.hasGame && !filters.hasRarity && !filters.hasSet && !filters.hasMinPrice && !filters.hasMaxPrice) {
// No filters - get all cards
result = await sql`
SELECT id, name, set_name, set_code, card_number, rarity, game,
mana_cost, cmc, card_type, colors, oracle_text,
power, toughness, image_url, stock_image_url,
current_price, market_price, scryfall_id, verified,
quantity
FROM cards
ORDER BY name ASC
LIMIT ${limitNum} OFFSET ${offset}
`;
countResult = await sql`SELECT COUNT(*) as total FROM cards`;
} else if (filters.hasQuery && !filters.hasGame && !filters.hasRarity && !filters.hasSet && !filters.hasMinPrice && !filters.hasMaxPrice) {
// Search by name only
result = await sql`
SELECT id, name, set_name, set_code, card_number, rarity, game,
mana_cost, cmc, card_type, colors, oracle_text,
power, toughness, image_url, stock_image_url,
current_price, market_price, scryfall_id, verified,
quantity
FROM cards
WHERE name ILIKE ${`%${query.trim()}%`}
ORDER BY name ASC
LIMIT ${limitNum} OFFSET ${offset}
`;
countResult = await sql`SELECT COUNT(*) as total FROM cards WHERE name ILIKE ${`%${query.trim()}%`}`;
} else if (!filters.hasQuery && filters.hasGame && !filters.hasRarity && !filters.hasSet && !filters.hasMinPrice && !filters.hasMaxPrice) {
// Filter by game only
result = await sql`
SELECT id, name, set_name, set_code, card_number, rarity, game,
mana_cost, cmc, card_type, colors, oracle_text,
power, toughness, image_url, stock_image_url,
current_price, market_price, scryfall_id, verified,
quantity
FROM cards
WHERE game = ${game}
ORDER BY name ASC
LIMIT ${limitNum} OFFSET ${offset}
`;
countResult = await sql`SELECT COUNT(*) as total FROM cards WHERE game = ${game}`;
} else if (!filters.hasQuery && !filters.hasGame && filters.hasRarity && !filters.hasSet && !filters.hasMinPrice && !filters.hasMaxPrice) {
// Filter by rarity only
result = await sql`
SELECT id, name, set_name, set_code, card_number, rarity, game,
mana_cost, cmc, card_type, colors, oracle_text,
power, toughness, image_url, stock_image_url,
current_price, market_price, scryfall_id, verified,
quantity
FROM cards
WHERE rarity = ${rarity}
ORDER BY name ASC
LIMIT ${limitNum} OFFSET ${offset}
`;
countResult = await sql`SELECT COUNT(*) as total FROM cards WHERE rarity = ${rarity}`;
} else if (!filters.hasQuery && filters.hasGame && filters.hasRarity && !filters.hasSet && !filters.hasMinPrice && !filters.hasMaxPrice) {
// Filter by game and rarity
result = await sql`
SELECT id, name, set_name, set_code, card_number, rarity, game,
mana_cost, cmc, card_type, colors, oracle_text,
power, toughness, image_url, stock_image_url,
current_price, market_price, scryfall_id, verified,
quantity
FROM cards
WHERE game = ${game} AND rarity = ${rarity}
ORDER BY name ASC
LIMIT ${limitNum} OFFSET ${offset}
`;
countResult = await sql`SELECT COUNT(*) as total FROM cards WHERE game = ${game} AND rarity = ${rarity}`;
} else if (filters.hasQuery && filters.hasGame && !filters.hasRarity && !filters.hasSet && !filters.hasMinPrice && !filters.hasMaxPrice) {
// Search with game filter
result = await sql`
SELECT id, name, set_name, set_code, card_number, rarity, game,
mana_cost, cmc, card_type, colors, oracle_text,
power, toughness, image_url, stock_image_url,
current_price, market_price, scryfall_id, verified,
quantity
FROM cards
WHERE name ILIKE ${`%${query.trim()}%`} AND game = ${game}
ORDER BY name ASC
LIMIT ${limitNum} OFFSET ${offset}
`;
countResult = await sql`SELECT COUNT(*) as total FROM cards WHERE name ILIKE ${`%${query.trim()}%`} AND game = ${game}`;
} else {
// Complex filters - build query dynamically (simplified approach)
const queryConditions = [];
if (filters.hasQuery) queryConditions.push(`name ILIKE '%${query.trim()}%'`);
if (filters.hasGame) queryConditions.push(`game = '${game}'`);
if (filters.hasRarity) queryConditions.push(`rarity = '${rarity}'`);
if (filters.hasSet) queryConditions.push(`set_name = '${set}'`);
if (filters.hasMinPrice) queryConditions.push(`market_price >= ${parseFloat(minPrice)}`);
if (filters.hasMaxPrice) queryConditions.push(`market_price <= ${parseFloat(maxPrice)}`);
const whereClause = queryConditions.length > 0 ? `WHERE ${queryConditions.join(' AND ')}` : '';
// For complex queries, use a fallback approach
result = await sql`
SELECT id, name, set_name, set_code, card_number, rarity, game,
mana_cost, cmc, card_type, colors, oracle_text,
power, toughness, image_url, stock_image_url,
current_price, market_price, scryfall_id, verified,
quantity
FROM cards
ORDER BY name ASC
LIMIT ${limitNum} OFFSET ${offset}
`;
countResult = await sql`SELECT COUNT(*) as total FROM cards`;
// Filter results in JavaScript for complex combinations
let filteredCards = result.rows;
if (filters.hasQuery) {
filteredCards = filteredCards.filter(card =>
card.name.toLowerCase().includes(query.trim().toLowerCase())
);
}
if (filters.hasGame) {
filteredCards = filteredCards.filter(card => card.game === game);
}
if (filters.hasRarity) {
filteredCards = filteredCards.filter(card => card.rarity === rarity);
}
if (filters.hasSet) {
filteredCards = filteredCards.filter(card => card.set_name === set);
}
if (filters.hasMinPrice) {
filteredCards = filteredCards.filter(card =>
card.market_price >= parseFloat(minPrice)
);
}
if (filters.hasMaxPrice) {
filteredCards = filteredCards.filter(card =>
card.market_price <= parseFloat(maxPrice)
);
}
// Update result with filtered data
result = { rows: filteredCards };
countResult = { rows: [{ total: filteredCards.length }] };
}
const total = parseInt(countResult.rows[0].total);
const totalPages = Math.ceil(total / limitNum);
// Get filter options (for dropdowns)
const filtersResult = await sql`
SELECT
ARRAY_AGG(DISTINCT game) FILTER (WHERE game IS NOT NULL) as games,
ARRAY_AGG(DISTINCT rarity) FILTER (WHERE rarity IS NOT NULL) as rarities,
ARRAY_AGG(DISTINCT set_name) FILTER (WHERE set_name IS NOT NULL) as sets
FROM cards
`;
const filterData = filtersResult.rows[0];
// Process cards data
const cards = result.rows.map(card => {
// Parse colors if it's a JSON string
if (card.colors && typeof card.colors === 'string') {
try {
card.colors = JSON.parse(card.colors);
} catch (e) {
card.colors = [];
}
}
return card;
});
res.status(200).json({
success: true,
cards,
pagination: {
page: pageNum,
limit: limitNum,
total,
pages: totalPages,
hasMore: pageNum < totalPages
},
filters: {
games: filterData.games || [],
rarities: filterData.rarities || [],
sets: filterData.sets || []
}
});
} catch (error) {
console.error('Error searching cards:', error);
res.status(500).json({
success: false,
error: 'Internal server error',
message: error.message
});
}
}