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

51 KiB
Raw Permalink Blame History

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

  • Step 1: Extend rateLimit to support named categories

Rewrite lib/auth/rate-limit.ts to accept a category string parameter that partitions the store:

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))
  })
}
  • 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:

  // ... 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:

  // ... 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:

  .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.

  • Step 3: Run the existing test suite to verify nothing broke
cd /Users/adrianbonpin/Documents/Code/personal/deckyvault
bun run test

Expected: All existing tests pass.

  • Step 4: Commit
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

  • 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:

      // ── 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:

import { sql } from "drizzle-orm"

(Note: sql is likely already imported — verify. The existing imports include and, desc, sql, isNull from drizzle-orm.)

  • Step 2: Add input sanitization on comment content

Still in the .post("/") handler, right before the db.insert, add a sanitization step:

      // ── 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.

  • 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:

      // ── 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" }
      }
  • 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:

import { eq, and, sql } from "drizzle-orm"
  • Step 5: Run test suite
cd /Users/adrianbonpin/Documents/Code/personal/deckyvault
bun run test

Expected: All existing tests pass.

  • Step 6: Commit
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

  • Step 1: Create the in-memory cache module

Create lib/steamdb/cache.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
}
  • Step 2: Create the SteamDB HTML scraper

Create lib/steamdb/scrape.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 }
}
  • Step 3: Write unit tests for the HTML parser

Create lib/steamdb/__tests__/scrape.test.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:

// Export for testing
export const _parseSteamDBHtml = parseSteamDBHtml
  • Step 4: Run tests
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
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

  • Step 1: Create the API endpoint

Create lib/api/steamdb-version.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() }),
  },
)
  • Step 2: Export the route from index.ts

Edit lib/api/index.ts — add the export:

// Add this line in alphabetical position among other exports:
export { steamdbVersionRoutes } from "./steamdb-version"
  • 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:

import { steamdbVersionRoutes } from "@/lib/api/steamdb-version"

Then add .use(steamdbVersionRoutes) in the read group (alongside gamesRoutes, gameVersionsRoutes, etc.):

  // 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
  )
  • Step 4: Verify the endpoint responds
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 }.

  • Step 5: Commit
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

  • 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:

import { DatabaseIcon, RefreshCwIcon } from "lucide-react"
import { useState, useEffect } from "react"

Add to the interface (before the closing }):

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:

          <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:

  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:

          {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>
          )}
  • 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:

import { DatabaseIcon } from "lucide-react"

Add state:

  // SteamDB version suggestion
  const [steamdbVersion, setSteamdbVersion] = useState<{
    versionString: string | null
    buildId: string | null
  } | null>(null)
  const [steamdbLoading, setSteamdbLoading] = useState(false)

Add the fetch function:

  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:

  useEffect(() => {
    fetchSteamDBVersion()
  }, [fetchSteamDBVersion])

Pass the new props to SetupStep:

        <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:

  // 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
  }
  • Step 3: Verify the wizard renders the SteamDB option
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.

  • Step 4: Commit
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

  • 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:

"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 ... */}
      </>
    )
}
  • Step 2: Add the GameSection and SkeletonSections components

Add these components in the same file, below the Landing function:

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}
    />
  )
}
  • Step 3: Adjust the hero height

In the existing hero <section> tag, change:

className='w-full h-[calc(100vh-3.6rem)] flex flex-col items-center justify-center relative p-4'

to:

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).

  • Step 4: Add scrollbar styling to globals.css

Edit app/globals.css — add thin scrollbar styling for the horizontal scroll sections:

/* 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;
}
  • Step 5: Build and visually verify
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)

  • Step 6: Commit

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)

  • Step 1: Bump version in package.json

Edit package.json — change:

"version": "2026.0.100",

to:

"version": "2026.0.101",
  • Step 2: Bump OpenAPI version in app.ts

Edit lib/api/app.ts — find the OpenAPI info block and change:

version: "2026.0.100",

to:

version: "2026.0.101",

Also update the root GET / handler:

version: "2026.0.9",

to:

version: "2026.0.101",
  • Step 3: Update CHANGELOG.md

Edit CHANGELOG.md — prepend a new entry after the header line:

## [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
  • Step 4: Create update news markdown

Create content/updates/2026-05-14-v2026.0.101.md:

---
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
  • Step 5: Verify all version references are consistent
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).

  • Step 6: Run full test suite
bun run test

Expected: All tests pass.

  • Step 7: Commit
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