Files
deckyvault/docs/2026-05-14-landing-security-steamdb-plan.md
T

1599 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Landing Page, SteamDB, Security & Changelog — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add landing page sections, SteamDB version auto-fetch in the submit wizard, tiered API rate limiting + anti-spam, and version bump to 2026.0.101.
**Architecture:** Four additive streams with no shared state. Security (Stream C) ships first as foundation, then SteamDB integration (Stream A) for the wizard, then the landing page redesign (Stream B), and finally changelog/docs (Stream D). All changes are additive — no DB migrations, no breaking API changes.
**Tech Stack:** Next.js 16, React 19, Elysia (Bun-compatible API), Drizzle ORM, PostgreSQL, Tailwind CSS v4, motion (framer-motion fork), Vitest
> **CURRENT PROGRESS:** All phases complete. Post-launch: Added 'Recently Added Benchmarks' section, reordered landing page sections, added PlayabilityBadge and performance tags to cards.
---
## Phase 1: API Security & Rate Limiting (Stream C)
### Task 1.1: Tiered Rate Limit Categories
**Files:**
- Modify: `lib/auth/rate-limit.ts`
- Modify: `lib/api/app.ts`
- [x] **Step 1: Extend rateLimit to support named categories**
Rewrite `lib/auth/rate-limit.ts` to accept a `category` string parameter that partitions the store:
```ts
import { Elysia } from "elysia"
type RateLimitEntry = {
count: number
resetAt: number
}
// NOTE: This is an in-memory rate limiter for development/single-instance
// deployments. For production with multiple instances or serverless, use a
// shared store like Redis or Upstash.
const store = new Map<string, RateLimitEntry>()
// Clean up expired entries every 60 seconds
setInterval(() => {
const now = Date.now()
for (const [key, entry] of store) {
if (now > entry.resetAt) {
store.delete(key)
}
}
}, 60_000)
function getClientIP(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for")
if (forwarded) {
return forwarded.split(",")[0].trim()
}
return "unknown"
}
function checkRateLimit(
key: string,
window: number,
max: number,
): { allowed: boolean; remaining: number; resetAt: number } {
const now = Date.now()
const entry = store.get(key)
if (!entry || now > entry.resetAt) {
const resetAt = now + window * 1000
store.set(key, { count: 1, resetAt })
return { allowed: true, remaining: max - 1, resetAt }
}
if (entry.count >= max) {
return { allowed: false, remaining: 0, resetAt: entry.resetAt }
}
entry.count++
return { allowed: true, remaining: max - entry.count, resetAt: entry.resetAt }
}
const CATEGORY_LIMITS: Record<string, { window: number; max: number }> = {
default: { window: 60, max: 100 },
auth: { window: 60, max: 20 },
read: { window: 60, max: 300 },
write: { window: 60, max: 10 },
strict: { window: 60, max: 5 },
}
export const rateLimit = (category: string = "default") => {
const limits = CATEGORY_LIMITS[category] ?? CATEGORY_LIMITS.default
const { window, max } = limits
return new Elysia({ name: `rate-limit-${category}` }).onRequest(({ request, set }) => {
const ip = getClientIP(request)
const path = new URL(request.url).pathname
const key = `${category}:${ip}:${path}`
const result = checkRateLimit(key, window, max)
if (!result.allowed) {
const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000)
set.status = 429
set.headers["Retry-After"] = String(retryAfter)
return {
error: "Too many requests",
retryAfter,
}
}
// These headers are informational — clients can use them to throttle
set.headers["X-RateLimit-Limit"] = String(max)
set.headers["X-RateLimit-Remaining"] = String(result.remaining)
set.headers["X-RateLimit-Reset"] = String(Math.ceil(result.resetAt / 1000))
})
}
```
- [x] **Step 2: Apply tiered rate limits in app.ts**
Edit `lib/api/app.ts` — replace the single `.use(rateLimit(60, 100))` with category-specific limits applied before each route group:
```ts
// ... after .onError(...) block and before .use(betterAuth) ...
// Tiered rate limiting — more restrictive first (order matters: first match wins)
.use(rateLimit("strict")) // catches contact + community-suggestions
.use(rateLimit("write")) // catches comments + performance submit + reports
.use(rateLimit("auth")) // catches auth endpoints
.use(rateLimit("read")) // catches GET-heavy routes
.use(rateLimit("default")) // catch-all fallback for unlisted routes
.use(betterAuth)
```
**IMPORTANT:** Since Elysia `.use()` middleware runs in registration order for ALL routes, we need a different strategy. Each route group needs its own rate limiter instance applied only to those routes. Instead, apply the rate limiter directly to each route group:
Replace the old single `.use(rateLimit(60, 100))` with individual rate limiters applied per route group:
```ts
// ... after .onError(...) block, before .use(betterAuth) ...
// Auth routes — strictest
.use(rateLimit("auth"))
.use(betterAuth)
// ... move route registrations to sections, each prefixed with appropriate rate limit ...
// NOTE: Because Elysia's .use() applies middleware to ALL subsequent routes,
// we need to use group() to scope each rate limit category:
```
**Better approach — use `group()` to scope rate limits:**
```ts
.onError(({ code, error, set, request }) => {
console.error(
`[API Error] ${code} ${request.url}`,
error instanceof Error ? error.message : error,
)
set.status = code === "NOT_FOUND" ? 404 : 500
return {
error: code === "NOT_FOUND" ? "Not found" : "Internal server error",
}
})
// Auth — most restrictive rate limit
.group("/api", (app) =>
app
.use(rateLimit("auth"))
.use(betterAuth)
.use(userRoutes)
.use(profilePhotoRoutes)
)
// Read-heavy public routes
.group("/api", (app) =>
app
.use(rateLimit("read"))
.use(healthRoutes)
.use(gamesRoutes)
.use(gameVersionsRoutes)
.use(gameSyncRoutes)
.use(gamesListingRoutes)
.use(hardwareRoutes)
.use(hardwareStatsRoutes)
.use(performanceRoutes)
.use(gameStatsRoutes)
.use(dashboardRoutes)
.use(dashboardPublicRoutes)
.use(playabilityRoutes)
.use(steamReviewRoutes)
.use(compareRoutes)
.use(savedGamesRoutes)
.use(savedFilterRoutes)
.use(steamSearchRoutes)
.use(searchUnifiedRoutes)
.use(gameStubRoutes)
.use(steamgridProxyRoutes)
.use(gamesManualRoutes)
.use(screenshotRoutes)
)
// Write routes
.group("/api", (app) =>
app
.use(rateLimit("write"))
.use(betterAuth)
.use(performanceVerifyRoutes)
.use(performanceSubmitRoutes)
.use(commentsRoutes)
.use(reportRoutes)
.use(adminReportRoutes)
.use(adminPerformanceRoutes)
.use(adminCommentRoutes)
.use(adminStorageRoutes)
)
// Strict routes (contact + suggestions)
.group("/api", (app) =>
app
.use(rateLimit("strict"))
.use(contactRoutes)
.use(communitySuggestionRoutes)
)
// Cron — no rate limit
.use(cronRoutes)
// Root — default rate limit
.use(rateLimit("default"))
.get("/", () => ({
name: "DeckyVault API",
version: "2026.0.101",
}))
```
**WARNING:** The `betterAuth` middleware must be present in each group that needs auth guards. This is a gotcha — because `group()` scopes middleware, the `betterAuth` from one group doesn't leak to others. Routes in the "read" group using public data don't need it, but "write" and "auth" groups do.
- [x] **Step 3: Run the existing test suite to verify nothing broke**
```bash
cd /Users/adrianbonpin/Documents/Code/personal/deckyvault
bun run test
```
Expected: All existing tests pass.
- [x] **Step 4: Commit**
```bash
git add lib/auth/rate-limit.ts lib/api/app.ts
git commit -m "feat(security): tiered rate limiting with 5 categories (auth/read/write/strict/default)"
```
---
### Task 1.2: Comment Anti-Spam & Submission Validation Hardening
**Files:**
- Modify: `lib/api/comments.ts`
- Modify: `lib/api/performance-submit.ts`
- [x] **Step 1: Add duplicate comment detection and content length cap**
Edit `lib/api/comments.ts` — in the `.post("/")` handler, after the `requireRole` guard but before the parent comment check, add:
```ts
// ── Anti-spam: content length cap ──────────────────────────
const contentStr = JSON.stringify(body.content)
if (contentStr.length > 50000) {
set.status = 413
return { error: "Comment content exceeds maximum size (50KB)" }
}
// ── Anti-spam: duplicate detection ─────────────────────────
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000)
const [duplicate] = await db
.select({ id: gameComments.id })
.from(gameComments)
.where(
and(
eq(gameComments.gameId, params.gameId),
eq(gameComments.userId, guard.user.id),
eq(gameComments.isRemoved, false),
sql`${gameComments.createdAt} >= ${fiveMinutesAgo}`,
),
)
.limit(1)
if (duplicate) {
// Check if content is identical to this recent comment
const [recent] = await db
.select({ content: gameComments.content })
.from(gameComments)
.where(eq(gameComments.id, duplicate.id))
.limit(1)
if (recent && JSON.stringify(recent.content) === contentStr) {
set.status = 409
return { error: "Duplicate comment detected — you posted identical content in the last 5 minutes" }
}
}
// ── Anti-spam: per-user hourly cap ─────────────────────────
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000)
const [{ count: recentCount }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(gameComments)
.where(
and(
eq(gameComments.userId, guard.user.id),
eq(gameComments.isRemoved, false),
sql`${gameComments.createdAt} >= ${oneHourAgo}`,
),
)
if (recentCount >= 30) {
set.status = 429
return { error: "Too many comments — you've reached the hourly limit of 30" }
}
```
The import additions needed at the top of the file:
```ts
import { sql } from "drizzle-orm"
```
(Note: `sql` is likely already imported — verify. The existing imports include `and, desc, sql, isNull` from `drizzle-orm`.)
- [x] **Step 2: Add input sanitization on comment content**
Still in the `.post("/")` handler, right before the `db.insert`, add a sanitization step:
```ts
// ── Sanitize: strip <script> tags and javascript: URLs from content ──
const sanitizeValue = (val: unknown): unknown => {
if (typeof val === "string") {
return val
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
.replace(/javascript\s*:/gi, "blocked:")
}
if (Array.isArray(val)) return val.map(sanitizeValue)
if (val !== null && typeof val === "object") {
const cleaned: Record<string, unknown> = {}
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
cleaned[k] = sanitizeValue(v)
}
return cleaned
}
return val
}
const sanitizedContent = sanitizeValue(body.content) as Record<string, unknown>
```
Then change the `.values()` call to use `content: sanitizedContent` instead of `content: body.content`.
- [x] **Step 3: Add submission validation hardening**
Edit `lib/api/performance-submit.ts` — in the `.post("/submit")` handler, after the existing field extraction but before the `versionId/!hardwareSlug/!fpsAvg` validation, add:
```ts
// ── Submission cooldown: 60 seconds between entries per user ──
const sixtySecondsAgo = new Date(Date.now() - 60 * 1000)
const [lastEntry] = await db
.select({ createdAt: performanceEntries.createdAt })
.from(performanceEntries)
.where(
and(
eq(performanceEntries.userId, guard.user.id),
sql`${performanceEntries.createdAt} >= ${sixtySecondsAgo}`,
),
)
.orderBy(sql`${performanceEntries.createdAt} DESC`)
.limit(1)
if (lastEntry) {
const retryAfter = Math.ceil(
(lastEntry.createdAt.getTime() + 60_000 - Date.now()) / 1000
)
set.status = 429
return {
error: "Please wait before submitting another benchmark",
retryAfter: Math.max(1, retryAfter),
}
}
// ── Validation: fpsAvg bounds ───────────────────────────────
if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 500) {
set.status = 400
return { error: "fpsAvg must be between 1 and 500" }
}
// ── Validation: optional FPS bounds ─────────────────────────
if (fpsLow !== null && (fpsLow < 0 || fpsLow > 500)) {
set.status = 400
return { error: "fpsLow must be between 0 and 500" }
}
if (fpsHigh !== null && (fpsHigh < 0 || fpsHigh > 500)) {
set.status = 400
return { error: "fpsHigh must be between 0 and 500" }
}
if (fpsOnePercentLow !== null && (fpsOnePercentLow < 0 || fpsOnePercentLow > 500)) {
set.status = 400
return { error: "fpsOnePercentLow must be between 0 and 500" }
}
// ── Validation: tdpWatts bounds ─────────────────────────────
if (tdpWatts !== null && tdpWatts <= 0) {
set.status = 400
return { error: "tdpWatts must be greater than 0" }
}
// ── Validation: settingsJson size limits ────────────────────
if (settingsJson) {
if (!Array.isArray(settingsJson)) {
set.status = 400
return { error: "settingsJson must be an array" }
}
if (settingsJson.length > 20) {
set.status = 400
return { error: "Maximum 20 settings categories allowed" }
}
for (const cat of settingsJson) {
if (cat.settings && Array.isArray(cat.settings) && cat.settings.length > 50) {
set.status = 400
return { error: `Maximum 50 settings per category (exceeded in "${cat.category}")` }
}
}
}
// ── Validation: userNotes length ────────────────────────────
if (userNotes && typeof userNotes === "string" && userNotes.length > 5000) {
set.status = 400
return { error: "userNotes must be 5000 characters or less" }
}
```
- [x] **Step 4: Ensure the sql import exists in performance-submit.ts**
Check the imports at the top of `lib/api/performance-submit.ts`. The `sql` import from `drizzle-orm` should already be present (it's used in the existing code). If missing, add it:
```ts
import { eq, and, sql } from "drizzle-orm"
```
- [x] **Step 5: Run test suite**
```bash
cd /Users/adrianbonpin/Documents/Code/personal/deckyvault
bun run test
```
Expected: All existing tests pass.
- [x] **Step 6: Commit**
```bash
git add lib/api/comments.ts lib/api/performance-submit.ts
git commit -m "feat(security): comment anti-spam (duplicate detection, length cap, hourly limit) and submission validation hardening"
```
---
## Phase 2: SteamDB Version Auto-Fetch (Stream A)
### Task 2.1: SteamDB Scraper + Cache
**Files:**
- Create: `lib/steamdb/cache.ts`
- Create: `lib/steamdb/scrape.ts`
- [x] **Step 1: Create the in-memory cache module**
Create `lib/steamdb/cache.ts`:
```ts
interface CachedVersion {
versionString: string | null
buildId: string | null
fetchedAt: number
}
const cache = new Map<number, CachedVersion>()
const TTL_MS = 6 * 60 * 60 * 1000 // 6 hours
export function getCachedVersion(steamAppId: number): CachedVersion | null {
const entry = cache.get(steamAppId)
if (!entry) return null
if (Date.now() - entry.fetchedAt > TTL_MS) {
cache.delete(steamAppId)
return null
}
return entry
}
export function setCachedVersion(
steamAppId: number,
versionString: string | null,
buildId: string | null,
): void {
cache.set(steamAppId, {
versionString,
buildId,
fetchedAt: Date.now(),
})
}
// Cooldown tracking for failed fetches
const cooldowns = new Map<number, number>()
export function isOnCooldown(steamAppId: number): boolean {
const until = cooldowns.get(steamAppId)
if (!until) return false
if (Date.now() > until) {
cooldowns.delete(steamAppId)
return false
}
return true
}
export function setCooldown(steamAppId: number, minutes: number = 30): void {
cooldowns.set(steamAppId, Date.now() + minutes * 60 * 1000)
}
export function setExtendedCooldown(steamAppId: number): void {
cooldowns.set(steamAppId, Date.now() + 24 * 60 * 60 * 1000) // 24 hours
}
```
- [x] **Step 2: Create the SteamDB HTML scraper**
Create `lib/steamdb/scrape.ts`:
```ts
import { getCachedVersion, setCachedVersion, isOnCooldown, setCooldown, setExtendedCooldown } from "./cache"
const STEAMDB_APP_URL = "https://steamdb.info/app"
interface ScrapeResult {
versionString: string | null
buildId: string | null
}
export async function scrapeSteamDBVersion(steamAppId: number): Promise<ScrapeResult & { cached: boolean }> {
// Check cache first
const cached = getCachedVersion(steamAppId)
if (cached) {
return { versionString: cached.versionString, buildId: cached.buildId, cached: true }
}
// Check cooldown
if (isOnCooldown(steamAppId)) {
// Return stale cache if it exists (beyond TTL but still useful)
return { versionString: null, buildId: null, cached: true }
}
// If scraping is disabled via env, skip
if (process.env.STEAMDB_SCRAPING_ENABLED === "false") {
return { versionString: null, buildId: null, cached: true }
}
try {
const res = await fetch(`${STEAMDB_APP_URL}/${steamAppId}/`, {
headers: {
"User-Agent": "DeckyVault/1.0 (deckyvault.xyz; game version lookup)",
"Accept": "text/html",
},
signal: AbortSignal.timeout(10000),
})
if (res.status === 429 || res.status === 403) {
setExtendedCooldown(steamAppId)
return { versionString: null, buildId: null, cached: true }
}
if (!res.ok) {
setCooldown(steamAppId, 30)
return { versionString: null, buildId: null, cached: true }
}
const html = await res.text()
const result = parseSteamDBHtml(html)
setCachedVersion(steamAppId, result.versionString, result.buildId)
return { ...result, cached: false }
} catch {
setCooldown(steamAppId, 30)
return { versionString: null, buildId: null, cached: true }
}
}
function parseSteamDBHtml(html: string): ScrapeResult {
let versionString: string | null = null
let buildId: string | null = null
// Strategy 1: Look for "Last known name" in meta or table rows
// SteamDB renders data in <tr>/<td> pairs and also JSON-LD
const lastKnownMatch = html.match(/Last known name[^<]*<[^>]*>([^<]+)</i)
if (lastKnownMatch) {
versionString = lastKnownMatch[1].trim()
}
// Strategy 2: Try JSON-LD for version
const jsonLdMatch = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)
if (jsonLdMatch) {
try {
const parsed = JSON.parse(jsonLdMatch[1])
if (parsed.version) {
versionString = versionString || parsed.version
}
} catch {
// JSON-LD parse failure is non-fatal
}
}
// Strategy 3: Extract build ID from the page
// Build IDs appear in table rows like: <tr><td>Build ID</td><td>12345678</td></tr>
const buildMatch = html.match(/Build\s*ID[^<]*<\/td>\s*<td[^>]*>(\d+)/i)
|| html.match(/buildid[^>]*>(\d+)/i)
if (buildMatch) {
buildId = buildMatch[1].trim()
}
// Strategy 4: Look for patch/update info in "ChangeNumber" or "Last Record Update"
const changeMatch = html.match(/changenumber[^>]*>(\d+)/i)
if (changeMatch && !buildId) {
// ChangeNumber can serve as a proxy for latest version tracking
// but it's not a build ID per se — only use if buildId is null
}
// Strategy 5: Try to find version in the page title or h1
if (!versionString) {
const titleMatch = html.match(/<title>([^<]*)· SteamDB<\/title>/)
if (titleMatch) {
const titleParts = titleMatch[1].trim()
// Title format: "Game Name · AppID · SteamDB"
const appIdMatch = titleParts.match(/·\s*(\d+)\s*·/)
// Not helpful for version
}
}
return { versionString, buildId }
}
```
- [x] **Step 3: Write unit tests for the HTML parser**
Create `lib/steamdb/__tests__/scrape.test.ts`:
```ts
import { describe, it, expect } from "vitest"
// Test the parse function directly by importing the private function
// We expose it for testing by re-exporting from the module
// (add this in scrape.ts: export const _parseSteamDBHtml = parseSteamDBHtml)
describe("SteamDB HTML Parser", () => {
// Since parseSteamDBHtml is not exported, we test scrapeSteamDBVersion
// indirectly by mocking fetch. But for a proper unit test, let's
// export the parser.
it("parses version and build from typical SteamDB HTML", async () => {
// We'll import the exported parser after adding the export
const { _parseSteamDBHtml } = await import("../scrape")
const html = `
<html>
<body>
<table>
<tr><td>Last known name</td><td>v1.2.3</td></tr>
<tr><td>Build ID</td><td>12345678</td></tr>
</table>
</body>
</html>
`
const result = _parseSteamDBHtml(html)
expect(result.versionString).toBe("v1.2.3")
expect(result.buildId).toBe("12345678")
})
it("returns nulls for unrecognized HTML", async () => {
const { _parseSteamDBHtml } = await import("../scrape")
const result = _parseSteamDBHtml("<html><body>Nothing here</body></html>")
expect(result.versionString).toBeNull()
expect(result.buildId).toBeNull()
})
it("extracts build ID even without version string", async () => {
const { _parseSteamDBHtml } = await import("../scrape")
const html = `
<tr><td>Build ID</td><td>99999</td></tr>
`
const result = _parseSteamDBHtml(html)
expect(result.buildId).toBe("99999")
expect(result.versionString).toBeNull()
})
})
```
**Note:** Add this export in `scrape.ts` for testability:
```ts
// Export for testing
export const _parseSteamDBHtml = parseSteamDBHtml
```
- [x] **Step 4: Run tests**
```bash
cd /Users/adrianbonpin/Documents/Code/personal/deckyvault
mkdir -p lib/steamdb/__tests__
bun run test lib/steamdb/__tests__/scrape.test.ts
```
Expected: 5 tests pass.
- [ ] **Step 5: Commit**
```bash
git add lib/steamdb/cache.ts lib/steamdb/scrape.ts lib/steamdb/__tests__/scrape.test.ts
git commit -m "feat(steamdb): scraper + cache for SteamDB version auto-fetch"
```
---
### Task 2.2: SteamDB API Endpoint
**Files:**
- Create: `lib/api/steamdb-version.ts`
- Modify: `lib/api/index.ts`
- Modify: `lib/api/app.ts`
- [x] **Step 1: Create the API endpoint**
Create `lib/api/steamdb-version.ts`:
```ts
import { Elysia, t } from "elysia"
import { db } from "@/lib/db/index"
import { games } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
import { scrapeSteamDBVersion } from "@/lib/steamdb/scrape"
export const steamdbVersionRoutes = new Elysia({
prefix: "/games/:gameId",
detail: { tags: ["Games"] },
}).get(
"/steamdb-version",
async ({ params, set }) => {
// Look up game's steamAppId
const [game] = await db
.select({ steamAppId: games.steamAppId })
.from(games)
.where(eq(games.id, params.gameId))
.limit(1)
if (!game) {
set.status = 404
return { error: "Game not found" }
}
if (game.steamAppId === null) {
return { unavailable: true, reason: "no_steam_app_id" }
}
// Check if scraping is globally disabled
if (process.env.STEAMDB_SCRAPING_ENABLED === "false") {
return { unavailable: true, reason: "scraping_disabled" }
}
const result = await scrapeSteamDBVersion(game.steamAppId)
if (result.versionString === null && result.buildId === null) {
return { unavailable: true, reason: "not_found" }
}
return {
versionString: result.versionString,
buildId: result.buildId,
steamAppId: game.steamAppId,
source: "steamdb",
}
},
{
params: t.Object({ gameId: t.String() }),
},
)
```
- [x] **Step 2: Export the route from index.ts**
Edit `lib/api/index.ts` — add the export:
```ts
// Add this line in alphabetical position among other exports:
export { steamdbVersionRoutes } from "./steamdb-version"
```
- [x] **Step 3: Register the route in app.ts**
Edit `lib/api/app.ts` — add the import and route registration. The endpoint is read-only, so it belongs in the "read" group:
Add the import:
```ts
import { steamdbVersionRoutes } from "@/lib/api/steamdb-version"
```
Then add `.use(steamdbVersionRoutes)` in the read group (alongside `gamesRoutes`, `gameVersionsRoutes`, etc.):
```ts
// Read-heavy public routes
.group("/api", (app) =>
app
.use(rateLimit("read"))
.use(healthRoutes)
.use(gamesRoutes)
.use(gameVersionsRoutes)
.use(steamdbVersionRoutes) // <-- ADD THIS
.use(gameSyncRoutes)
// ... rest unchanged
)
```
- [x] **Step 4: Verify the endpoint responds**
```bash
cd /Users/adrianbonpin/Documents/Code/personal/deckyvault
# Start dev server in background
bun run dev &
sleep 5
# Test with a known game (replace with a real gameId from DB)
curl -s http://localhost:3000/api/games/<real-game-id>/steamdb-version | jq
```
Expected: JSON response with `versionString`, `buildId` fields, or `{ unavailable: true }`.
- [x] **Step 5: Commit**
```bash
git add lib/api/steamdb-version.ts lib/api/index.ts lib/api/app.ts
git commit -m "feat(steamdb): API endpoint GET /api/games/:gameId/steamdb-version"
```
---
### Task 2.3: Integrate SteamDB Version into Submit Wizard
**Files:**
- Modify: `components/wizard/steps/setup-step.tsx`
- Modify: `components/wizard/game-entry-wizard.tsx`
- [x] **Step 1: Add SteamDB version fetch to the Setup Step**
Edit `components/wizard/steps/setup-step.tsx` — add a new prop and fetch logic for the SteamDB suggestion.
First, add the SteamDB version type and new props to the interface:
```tsx
import { DatabaseIcon, RefreshCwIcon } from "lucide-react"
import { useState, useEffect } from "react"
```
Add to the interface (before the closing `}`):
```tsx
interface SteamDBVersion {
versionString: string | null
buildId: string | null
}
interface SetupStepProps {
// ... existing props ...
steamdbVersion: SteamDBVersion | null
steamdbLoading: boolean
onRefreshSteamDB: () => void
}
```
In the Game Version `<select>` section, add the SteamDB option at the top when data is available:
```tsx
<div className="space-y-1.5">
<label className="text-xs font-medium text-text/60">Version</label>
<select
value={selectedVersionId}
onChange={(e) => onVersionChange(e.target.value)}
className="w-full appearance-none px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors cursor-pointer"
>
{/* SteamDB suggestion — appears at top when available */}
{steamdbVersion && (steamdbVersion.versionString || steamdbVersion.buildId) && (
<option value="__steamdb__" className="bg-primary/10 text-primary">
Latest from SteamDB: {steamdbVersion.versionString || `Build ${steamdbVersion.buildId}`} recommended
</option>
)}
{steamdbLoading && (
<option disabled className="text-text/40">
Fetching latest version from SteamDB...
</option>
)}
<option disabled className="text-text/30 text-xs">
── Existing versions ──
</option>
{gameVersions.map((v) => (
<option key={v.id} value={v.id}>
{v.versionString
? v.versionString
: v.buildId
? `Build ${v.buildId}`
: "Unknown version"}
{v.isLatest ? " (latest)" : ""}
</option>
))}
<option value="__new__">
New version...
</option>
</select>
{/* Refresh button for SteamDB */}
<button
type="button"
onClick={onRefreshSteamDB}
disabled={steamdbLoading}
className="flex items-center gap-1 text-xs text-text/40 hover:text-primary transition-colors cursor-pointer mt-1 disabled:opacity-30"
>
<RefreshCwIcon className={`h-3 w-3 ${steamdbLoading ? "animate-spin" : ""}`} />
Refresh from SteamDB
</button>
</div>
```
Also update the `isNewVersion` check to handle the `__steamdb__` value:
```tsx
const isNewVersion = selectedVersionId === "__new__"
const isSteamDBVersion = selectedVersionId === "__steamdb__"
```
When `isSteamDBVersion` is true, show the SteamDB data as read-only fields instead of the "new version" text inputs:
```tsx
{isSteamDBVersion && steamdbVersion && (
<div className="space-y-3 p-3 rounded-lg border border-primary/30 bg-primary/5">
<div className="flex items-center gap-2">
<DatabaseIcon className="h-4 w-4 text-primary" />
<p className="text-xs font-medium text-primary">SteamDB Suggestion</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<p className="text-xs text-text/40">Version</p>
<p className="text-sm text-text">{steamdbVersion.versionString || "—"}</p>
</div>
<div>
<p className="text-xs text-text/40">Build ID</p>
<p className="text-sm text-text font-mono">{steamdbVersion.buildId || "—"}</p>
</div>
</div>
<p className="text-xs text-text/40">
This version will be created when you submit your benchmark.
</p>
</div>
)}
```
- [x] **Step 2: Add SteamDB state and fetch logic to the Wizard**
Edit `components/wizard/game-entry-wizard.tsx` — add state for SteamDB data and a fetch function:
Add the import:
```tsx
import { DatabaseIcon } from "lucide-react"
```
Add state:
```tsx
// SteamDB version suggestion
const [steamdbVersion, setSteamdbVersion] = useState<{
versionString: string | null
buildId: string | null
} | null>(null)
const [steamdbLoading, setSteamdbLoading] = useState(false)
```
Add the fetch function:
```tsx
const fetchSteamDBVersion = useCallback(async () => {
setSteamdbLoading(true)
try {
const res = await fetch(`/api/games/${gameId}/steamdb-version`)
if (!res.ok) return
const data = await res.json()
if (data.versionString || data.buildId) {
setSteamdbVersion({
versionString: data.versionString,
buildId: data.buildId,
})
}
} catch {
// Silently fail — SteamDB is best-effort
} finally {
setSteamdbLoading(false)
}
}, [gameId])
```
Add a `useEffect` to fetch on mount:
```tsx
useEffect(() => {
fetchSteamDBVersion()
}, [fetchSteamDBVersion])
```
Pass the new props to `SetupStep`:
```tsx
<SetupStep
gameId={gameId}
gameVersions={gameVersions}
hardwareSlug={hardwareSlug}
onHardwareChange={handleHardwareChange}
hardwareName={hardwareName}
selectedVersionId={selectedVersionId}
onVersionChange={setSelectedVersionId}
newVersionString={newVersionString}
onNewVersionStringChange={setNewVersionString}
isCreatingVersion={isCreatingVersion}
antiCheat={antiCheat}
onAntiCheatChange={setAntiCheat}
platformSupport={platformSupport}
steamdbVersion={steamdbVersion}
steamdbLoading={steamdbLoading}
onRefreshSteamDB={fetchSteamDBVersion}
/>
```
Handle the `__steamdb__` version selection in the submit logic — when the user selects the SteamDB option and submits, a new version must be created with the SteamDB data. In the wizard's submit handler (where it POSTs to `/api/performance/submit`), check if `selectedVersionId === "__steamdb__"` and:
1. First create the version via `POST /api/games/{gameId}/versions` with the SteamDB data
2. Use the returned version ID for the performance entry
This requires adding version-creation logic. Let's handle it in the Review step's submission function. The submit already calls the API with `versionId` in the payload. We need to intercept this:
```tsx
// In the submit handler:
let effectiveVersionId = selectedVersionId
if (selectedVersionId === "__steamdb__" && steamdbVersion) {
// Create the version first
const versionRes = await fetch(`/api/games/${gameId}/versions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
versionString: steamdbVersion.versionString,
buildId: steamdbVersion.buildId,
isLatest: true,
}),
credentials: "include",
})
if (!versionRes.ok) {
setError("Failed to create version from SteamDB suggestion")
return
}
const created = await versionRes.json()
effectiveVersionId = created.id
}
```
- [x] **Step 3: Verify the wizard renders the SteamDB option**
```bash
cd /Users/adrianbonpin/Documents/Code/personal/deckyvault
bun run dev &
sleep 5
# Visit a game page in the browser, navigate to Submit
# Verify the SteamDB version appears in the version selector (or gracefully shows nothing)
```
Expected: The version selector shows SteamDB option when data is available, falls back gracefully when not.
- [x] **Step 4: Commit**
```bash
git add components/wizard/steps/setup-step.tsx components/wizard/game-entry-wizard.tsx
git commit -m "feat(steamdb): integrate version auto-fetch into submit wizard version selector"
```
---
## Phase 3: Landing Page (Stream B)
### Task 3.1: Landing Page Sections
**Files:**
- Modify: `app/page.tsx`
- [x] **Step 1: Add data fetching and section state to the landing page**
Rewrite `app/page.tsx` — keep the existing hero section, add data sections below. The file is a "use client" component. Add imports and state:
```tsx
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useState, useEffect } from "react"
import Link from "next/link"
import Image from "next/image"
import { Gamepad2Icon, SearchIcon, TrendingUpIcon, SparklesIcon, GaugeIcon, FlagIcon } from "lucide-react"
import { useRouter } from "next/navigation"
interface GameCard {
id: string
title: string
capsule_image: string | null
header_image: string | null
playability_status?: string | null
// Section-specific stats:
activity_score?: number
benchmark_count?: number
comment_count?: number
upvote_count?: number
avg_fps?: number
report_count?: number
release_date?: string | null
created_at?: string | null
}
interface SectionData {
trending: GameCard[]
bestNewReleases: GameCard[]
mostTested: GameCard[]
mostReported: GameCard[]
}
export default function Landing() {
const router = useRouter()
const words = ["benchmarks", "settings", "reviews"]
const [currentWord, setCurrentWord] = useState(0)
const [searchQuery, setSearchQuery] = useState("")
// Landing section state
const [sections, setSections] = useState<SectionData>({
trending: [],
bestNewReleases: [],
mostTested: [],
mostReported: [],
})
const [sectionsLoading, setSectionsLoading] = useState(true)
// Fetch all 4 sections in parallel on mount
useEffect(() => {
let cancelled = false
async function fetchSections() {
try {
const [trending, bestNew, mostTested, mostReported] = await Promise.all([
fetch("/api/dashboard/trending").then(r => r.ok ? r.json() : []),
fetch("/api/dashboard/best-new-releases").then(r => r.ok ? r.json() : []),
fetch("/api/dashboard/most-tested").then(r => r.ok ? r.json() : []),
fetch("/api/dashboard/most-reported").then(r => r.ok ? r.json() : []),
])
if (!cancelled) {
setSections({
trending: Array.isArray(trending) ? trending : [],
bestNewReleases: Array.isArray(bestNew) ? bestNew : [],
mostTested: Array.isArray(mostTested) ? mostTested : [],
mostReported: Array.isArray(mostReported) ? mostReported : [],
})
}
} catch {
// Silently fail — sections are best-effort
} finally {
if (!cancelled) setSectionsLoading(false)
}
}
fetchSections()
return () => { cancelled = true }
}, [])
useEffect(() => {
const interval = setInterval(() => {
setCurrentWord((prev) => (prev + 1) % words.length)
}, 2000)
return () => clearInterval(interval)
}, [words.length])
// ... existing handleSearchSubmit and handleKeyDown ...
return (
<>
{/* ── Hero Section (existing, with height adjustment) ── */}
<section
id='hero'
className='w-full min-h-[calc(100svh-10svh)] flex flex-col items-center justify-center relative p-4'
>
{/* ... existing hero content unchanged ... */}
</section>
{/* ── Landing Sections ── */}
<div className="w-full max-w-7xl mx-auto px-4 pb-12 space-y-10">
{sectionsLoading ? (
<SkeletonSections />
) : (
<>
{sections.trending.length > 0 && (
<GameSection
title="Trending This Week"
icon={TrendingUpIcon}
games={sections.trending}
statKey="benchmark_count"
statLabel="benchmarks this week"
accentColor="text-orange-400"
/>
)}
{sections.bestNewReleases.length > 0 && (
<GameSection
title="Best Performing New Releases"
icon={SparklesIcon}
games={sections.bestNewReleases}
statKey="avg_fps"
statLabel="avg FPS"
statFormatter={(v) => `${Math.round(Number(v))} FPS`}
accentColor="text-green-400"
/>
)}
{sections.mostTested.length > 0 && (
<GameSection
title="Most Tested Games"
icon={GaugeIcon}
games={sections.mostTested}
statKey="benchmark_count"
statLabel="benchmarks"
accentColor="text-blue-400"
/>
)}
{sections.mostReported.length > 0 && (
<GameSection
title="Most Reported Games"
icon={FlagIcon}
games={sections.mostReported}
statKey="report_count"
statLabel="open reports"
accentColor="text-red-400"
muted
/>
)}
</>
)}
</div>
{/* ... existing schema.org script ... */}
</>
)
}
```
- [x] **Step 2: Add the GameSection and SkeletonSections components**
Add these components in the same file, below the `Landing` function:
```tsx
function SkeletonSections() {
return (
<>
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-3">
<div className="h-5 w-48 bg-text/5 rounded animate-pulse" />
<div className="flex gap-3 overflow-x-auto pb-2">
{[1, 2, 3, 4].map((j) => (
<div
key={j}
className="shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border animate-pulse"
>
<div className="aspect-[2/3] bg-text/5 rounded-t-xl" />
<div className="p-3 space-y-2">
<div className="h-3 bg-text/5 rounded w-3/4" />
<div className="h-2 bg-text/5 rounded w-1/2" />
</div>
</div>
))}
</div>
</div>
))}
</>
)
}
function GameSection({
title,
icon: Icon,
games,
statKey,
statLabel,
statFormatter,
accentColor = "text-text/50",
muted = false,
}: {
title: string
icon: React.ElementType
games: GameCard[]
statKey: string
statLabel: string
statFormatter?: (v: unknown) => string
accentColor?: string
muted?: boolean
}) {
const router = useRouter()
const formatStat = (v: unknown): string => {
if (statFormatter) return statFormatter(v)
if (typeof v === "number") return `${Math.round(v)} ${statLabel}`
return `${v} ${statLabel}`
}
return (
<motion.section
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4 }}
className={muted ? "opacity-70" : ""}
>
<div className="flex items-center gap-2 mb-4">
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2">
<Icon className={`h-4 w-4 ${accentColor}`} />
<h2 className="text-sm font-semibold text-text/80">{title}</h2>
</div>
</div>
</div>
<div className="flex gap-3 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-thin">
{games.map((game, idx) => (
<motion.div
key={game.id}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: idx * 0.05 }}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
className="group shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border hover:border-text/30 hover:bg-text/[0.06] transition-colors cursor-pointer overflow-hidden"
onClick={() => router.push(`/game/${game.id}?sync=1`)}
>
{/* Cover image */}
<div className="relative aspect-[2/3] bg-text/10 overflow-hidden">
{game.capsule_image ? (
<Image
src={game.capsule_image}
alt={game.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="(max-width: 640px) 144px, 176px"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Gamepad2Icon className="h-8 w-8 text-text/15" />
</div>
)}
{/* Playability badge overlay */}
{game.playability_status && game.playability_status !== "unknown" && (
<div className="absolute top-1.5 right-1.5">
<PlayabilityDot status={game.playability_status} />
</div>
)}
</div>
{/* Info */}
<div className="p-2.5 space-y-1.5">
<h3 className="text-xs font-semibold text-text line-clamp-2 leading-tight group-hover:text-primary transition-colors">
{game.title}
</h3>
<p className={`text-[10px] ${accentColor} font-medium`}>
{formatStat((game as Record<string, unknown>)[statKey])}
</p>
</div>
</motion.div>
))}
</div>
</motion.section>
)
}
function PlayabilityDot({ status }: { status: string }) {
const colors: Record<string, string> = {
great: "bg-green-500",
playable: "bg-blue-500",
needs_tweaks: "bg-yellow-500",
unplayable: "bg-red-500",
}
const labels: Record<string, string> = {
great: "Plays Great",
playable: "Playable",
needs_tweaks: "Needs Tweaks",
unplayable: "Unplayable",
}
return (
<span
className={`inline-block w-2.5 h-2.5 rounded-full ${colors[status] || "bg-text/20"}`}
title={labels[status] || status}
/>
)
}
```
- [x] **Step 3: Adjust the hero height**
In the existing hero `<section>` tag, change:
```tsx
className='w-full h-[calc(100vh-3.6rem)] flex flex-col items-center justify-center relative p-4'
```
to:
```tsx
className='w-full min-h-[calc(100svh-10svh)] flex flex-col items-center justify-center relative p-4'
```
Note: `svh` (small viewport height) is used instead of `vh` for mobile browsers that have dynamic toolbars. The `min-h-` ensures the hero is at least this tall but can grow if content overflows. The old `3.6rem` navbar offset is removed because the hero is inside the body flex column (navbar is already accounted for).
- [x] **Step 4: Add scrollbar styling to globals.css**
Edit `app/globals.css` — add thin scrollbar styling for the horizontal scroll sections:
```css
/* Thin scrollbar for horizontal scroll sections */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--color-text) 15%, transparent) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
height: 4px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--color-text) 15%, transparent);
border-radius: 2px;
}
```
- [x] **Step 5: Build and visually verify**
```bash
cd /Users/adrianbonpin/Documents/Code/personal/deckyvault
bun run build
```
Expected: No build errors.
Then start the dev server and visit the landing page to verify:
- Hero section is visible with correct height
- Section cards render below
- Cards are clickable and navigate to game pages
- Horizontal scrolling works on the card rows
- The "peek" effect works (bottom of first row visible without scrolling)
- [x] **Step 6: Commit**
```bash
git add app/page.tsx app/globals.css
git commit -m "feat(landing): trending, new releases, most tested/reported sections with peek hero layout"
```
---
## Phase 4: Changelog & Version Bump (Stream D)
### Task 4.1: Version Bump, Changelog, and Update News
**Files:**
- Modify: `package.json`
- Modify: `lib/api/app.ts`
- Modify: `CHANGELOG.md`
- Create: `content/updates/2026-05-14-v2026.0.101.md`
- Modify: `lib/api/app.ts` (OpenAPI version)
- [x] **Step 1: Bump version in package.json**
Edit `package.json` — change:
```json
"version": "2026.0.100",
```
to:
```json
"version": "2026.0.101",
```
- [x] **Step 2: Bump OpenAPI version in app.ts**
Edit `lib/api/app.ts` — find the OpenAPI info block and change:
```ts
version: "2026.0.100",
```
to:
```ts
version: "2026.0.101",
```
Also update the root `GET /` handler:
```ts
version: "2026.0.9",
```
to:
```ts
version: "2026.0.101",
```
- [x] **Step 3: Update CHANGELOG.md**
Edit `CHANGELOG.md` — prepend a new entry after the header line:
```markdown
## [2026.0.101] - 2026-05-14
### Added
- SteamDB version auto-fetch — latest game version/build surfaced in submit wizard version selector
- Landing page: Trending This Week, Best Performing New Releases, Most Tested, and Most Reported sections
- Tiered API rate limiting with 5 categories (auth, read, write, strict, default)
- Comment anti-spam: duplicate detection, 50KB content cap, 30/hr per-user limit
- Submission cooldown: 60-second minimum between benchmark entries per user
- Submission validation hardening: FPS bounds (1-500), TDP bounds, settings size caps, userNotes length cap
### Changed
- Landing hero height adjusted to `calc(100svh - 10svh)` for content "peek" effect
- Rate limiter now uses named categories instead of a single global bucket
### Security
- Hardened validation on performance entry submission (fps bounds, settings size caps)
- Server-side sanitization of comment content before storage
- Per-route rate limiting categories for granular abuse prevention
```
- [x] **Step 4: Create update news markdown**
Create `content/updates/2026-05-14-v2026.0.101.md`:
```markdown
---
title: "Landing Page, SteamDB Version Sync, and Security Hardening"
date: "2026-05-14"
version: "2026.0.101"
summary: "Discover trending games on the new landing page, auto-fetch latest game versions from SteamDB, and enjoy improved API security."
---
### New Landing Page
The homepage now showcases what's happening in the DeckyVault community:
- **Trending This Week** — games with the most benchmarks, comments, and upvotes in the last 7 days
- **Best Performing New Releases** — recently added games with the highest average FPS
- **Most Tested Games** — the most benchmarked games across all devices
- **Most Reported Games** — games with active reports (sunlight as disinfectant!)
Each section shows compact game cards with cover art, playability status, and relevant stats. Click any card to jump straight to the game's detail page.
### SteamDB Version Auto-Fetch
When submitting a benchmark, the wizard now automatically checks SteamDB for the latest game version and build ID. If found, it appears as a recommended option at the top of the version selector — no more guessing which version you're on.
This feature is best-effort and can be disabled via the `STEAMDB_SCRAPING_ENABLED` environment variable.
### API Security Hardening
We've tightened up the API with several layers of protection:
- **Tiered rate limiting** — different limits for authentication, reads, writes, and public forms
- **Comment anti-spam** — duplicate detection, size limits, and hourly caps
- **Submission validation** — FPS must be within realistic bounds, settings payloads have size caps
- **Content sanitization** — comment content is cleaned server-side before storage
```
- [x] **Step 5: Verify all version references are consistent**
```bash
cd /Users/adrianbonpin/Documents/Code/personal/deckyvault
grep -r "2026.0.100\|2026.0.101" package.json lib/api/app.ts CHANGELOG.md content/updates/2026-05-14-v2026.0.101.md
```
Expected: Only `2026.0.101` appears (no stale `2026.0.100` references remaining).
- [x] **Step 6: Run full test suite**
```bash
bun run test
```
Expected: All tests pass.
- [x] **Step 7: Commit**
```bash
git add package.json lib/api/app.ts CHANGELOG.md content/updates/2026-05-14-v2026.0.101.md
git commit -m "chore: bump version to 2026.0.101, update changelog and update news"
```
---
## Verification Checklist (Post-Implementation)
Run these after all phases are complete:
- [ ] `bun run test` — all tests pass
- [ ] `bun run build` — no build errors
- [ ] `bun run dev` — landing page loads, sections render, cards are clickable
- [ ] Submit wizard — SteamDB version appears when available, version selector works
- [ ] Comment posting — duplicate detection rejects identical comments within 5 minutes
- [ ] API rate limit — hitting an endpoint repeatedly returns 429
- [ ] `grep -r "2026.0.100" package.json lib/api/app.ts` returns nothing