18 KiB
Architectural Spec: Landing Page, SteamDB Integration, API Security, Changelog
Date: 2026-05-14 Target Version: 2026.0.101 Status: Draft — pending user review
1. Objective Overview
Four independent workstreams bundled into one release:
| # | Stream | Scope |
|---|---|---|
| A | [Submit] SteamDB Version Auto-Fetch | Auto-fetch latest game version from SteamDB and surface it in the submit wizard version selector |
| B | [Feature] Landing Page | Add trending/hot/new/tested/reported sections below the hero, with a "peek" effect |
| C | [Infra] API Security & Rate Limiting | Abuse prevention on submissions, anti-spam for comments, general hardening |
| D | [CHANGELOG] | Bump version, write update news, update CHANGELOG.md |
2. Stream A — SteamDB Version Auto-Fetch
2.1 Current State
gameVersionstable:id,gameId,buildId,versionString,isLatest,createdAt- Submit wizard (Setup step) shows a
<select>of existing versions plus a "New version…" text field - The submit page server component creates a default empty version if none exists
- Search results already link to
steamdb.info/app/{appId}as a reference - No automated version fetching exists
2.2 Design
2.2.1 Data Source
SteamDB does not offer a public REST API. The approach is server-side HTML scraping of https://steamdb.info/app/{steamAppId}/ to extract:
versionString: The "Last known name" or latest patch/update entry (e.g., "v1.2.3" or "Patch 4")buildId: The numeric build ID shown in the "Builds" or "Last Record Update" section
2.2.2 New API Endpoint
GET /api/games/:gameId/steamdb-version
- Auth: Public (no auth required — this is read-only reference data)
- Logic:
- Look up the game's
steamAppId. Ifnull, return{ unavailable: true }. - Check a cache key (
steamdb:version:{steamAppId}) — if cached within 6 hours, return cached data. - Fetch
https://steamdb.info/app/{steamAppId}/with a reasonable User-Agent header. - Parse the HTML to extract version string and build ID.
- Store in cache, return
{ versionString, buildId, fetchedAt }.
- Look up the game's
- Error handling: If SteamDB is unreachable, returns
{ unavailable: true, reason: "steamdb_unreachable" }. If parsing fails, returns{ unavailable: true, reason: "parse_failed" }. - Rate limiting: SteamDB may block aggressive scraping. Enforce a per-appId cooldown of 30 minutes (separate from the 6-hour cache) — if a fetch fails with 429/403, extend cooldown to 24 hours.
2.2.3 Cache Strategy
- In-memory Map (consistent with existing rate-limit pattern) keyed by
steamAppId - Entry shape:
{ versionString, buildId, fetchedAt, ttl } - TTL: 6 hours (game versions don't change minute-to-minute)
- The cache is populated lazily — only when the endpoint is hit
2.2.4 UI Integration in Submit Wizard
In the Setup step's version selector:
- On mount (or when hardware changes), call
GET /api/games/:gameId/steamdb-version - If data is available, add a special option at the top of the
<select>:⬇ Latest from SteamDB: v1.2.3 (Build 12345678) — recommended - Selecting this option triggers version creation with the SteamDB data pre-filled, or (if the build ID already matches an existing version) selects that existing version.
- The option should be visually distinct (e.g., with a small SteamDB badge/icon) to distinguish it from user-entered versions.
2.2.5 Constraints
- No SteamDB scraping if
steamAppIdis null (manual/GOG/Epic games) - No automatic version creation — the user must explicitly choose the SteamDB suggestion
- Respect SteamDB's rate limits — do not fetch on every keystroke; fetch only on wizard mount or explicit refresh
- The scraping is best-effort — SteamDB may change their HTML structure; graceful degradation is required
2.3 New Files
| File | Purpose |
|---|---|
lib/api/steamdb-version.ts |
New Elysia route for the endpoint |
lib/steamdb/scrape.ts |
HTML scraping logic (fetch + parse) |
lib/steamdb/cache.ts |
In-memory cache with TTL |
2.4 Modified Files
| File | Change |
|---|---|
lib/api/index.ts |
Export new steamdbVersionRoutes |
lib/api/app.ts |
Register steamdbVersionRoutes |
components/wizard/steps/setup-step.tsx |
Add SteamDB version fetch + special option |
app/game/[id]/submit/page.tsx |
No changes needed (data fetched client-side) |
3. Stream B — Landing Page
3.1 Current State
app/page.tsxrenders a full-height hero section with animated tagline and search bar- No game cards, no data sections
- Server-route
/api/dashboard/trending,/api/dashboard/best-new-releases,/api/dashboard/most-tested,/api/dashboard/most-reportedalready exist and return data
3.2 Design
3.2.1 Layout & "Peek" Effect
┌─────────────────────────────────┐
│ │
│ HERO SECTION │ height: calc(100svh - 10svh)
│ (tagline, search, etc.) │
│ │
├─────────────────────────────────┤ ← 10svh of next section peeks above fold
│ ┌───────────────────────────┐ │
│ │ Trending This Week │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │Game │ │Game │ │Game │ │ │
│ │ └─────┘ └─────┘ └─────┘ │ │
│ └───────────────────────────┘ │
│ ┌───────────────────────────┐ │
│ │ Best New Releases │ │
│ ... │
Implementation:
- Hero section gets
min-h-[calc(100svh-10svh)](formerlyh-[calc(100vh-3.6rem)]) - The sections below start immediately, so ~10svh of the first card row is visible without scrolling
- This REQUIRES the hero to not use
overflow: hidden— let content naturally overflow
3.2.2 Section Order (top to bottom)
- Hero (existing — keep as-is with height adjustment)
- Trending This Week — from
/api/dashboard/trending - Best Performing New Releases — from
/api/dashboard/best-new-releases - Most Tested Games — from
/api/dashboard/most-tested - Most Reported Games — from
/api/dashboard/most-reported
3.2.3 Card Design
Each section renders a horizontal scrollable row of compact game cards. Card design follows the search result card pattern from app/search/page.tsx:
- Cover image (aspect 2:3,
w-20 sm:w-24 md:w-28, rounded-lg) - Title (truncated, semibold)
- Playability badge (from search card)
- Relevant stat (varies by section):
- Trending: activity score or benchmark count
- Best New Releases: avg FPS
- Most Tested: benchmark count
- Most Reported: report count
- Hover/tap: scales to 1.02, redirects to
/game/{id}?sync=1
3.2.4 Data Fetching Strategy
The landing page remains a client-side component (consistent with the current "use client" pattern and the search page):
- Hero section keeps its animations and client state (search bar, rotating tagline)
- Data sections fetch from the existing public dashboard endpoints on mount via
useEffect+fetch:GET /api/dashboard/trendingGET /api/dashboard/best-new-releasesGET /api/dashboard/most-testedGET /api/dashboard/most-reported
- Each section shows a skeleton loader (pulsing placeholder cards) while fetching
- All 4 fetches fire in parallel via
Promise.all - The page does NOT need to be a Server Component — the dashboard queries are fast, public, and the hero already requires client-side interactivity
3.2.5 Empty States
- If a section returns 0 results, hide the section entirely (don't show "No trending games")
- This avoids a dead page for new/quiet periods
3.2.6 Styling Constraints
- Follow existing Tailwind theme variables (
--color-background,--color-primary,--color-text,--color-border, etc.) - Section headers:
text-sm font-semibold text-text/80with a subtle left border accent (border-l-2 border-primary pl-3) - Horizontal scroll: use
overflow-x-autowithscrollbar-hideor custom thin scrollbar - Cards: replicate the
bg-text/3 border border-border rounded-xlpattern from search cards - Use
motion(framer-motion fork) for stagger animations on card reveal - Maintain 44px touch targets for interactive elements
3.3 New/Modified Files
| File | Change |
|---|---|
app/page.tsx |
Major refactor — add data sections below hero, adjust hero height |
components/landing/ |
New directory with section components (or inline in page.tsx for MVP) |
4. Stream C — API Security & Rate Limiting
4.1 Current State
- Rate limiter: In-memory, global 60s window, 100 requests per IP+path (
lib/auth/rate-limit.ts) - Auth guards: Role-based (
requireAuth,requireRole,requireAdmin,requireContributorOrAdmin) - Comments: Auth required, soft-delete, no duplicate/spam detection
- Submissions: Auth required, no per-user rate limits
- Reports: One per user per entry (DB unique constraint)
- Community suggestions: One per user per game per field (DB unique constraint)
4.2 Identified Gaps
| Gap | Risk | Severity |
|---|---|---|
| No per-route rate limits | A logged-in user can POST comments as fast as they can send requests | Medium |
| No duplicate comment detection | Identical content can be posted repeatedly | Low-Medium |
| No submission flood protection | A user can submit many benchmarks in quick succession | Medium |
| Global rate limit is coarse | All routes share one 100 req/min bucket; legitimate burst traffic may be blocked | Low |
| No content length validation on comments | Extremely long comments could be submitted | Low |
| No input sanitization beyond Elysia validation | Tiptap JSON is stored as-is; XSS risk if renderer is flawed | Low |
| No CSRF protection on state-changing endpoints | Token-based auth mitigates this partially | Low |
| In-memory rate limiter doesn't scale | Multi-instance deployments would have separate counters | Low (current deploy is single-instance) |
4.3 Design
4.3.1 Tiered Rate Limiting
Replace the single global rate limiter with route-category limits:
| Category | Window | Max Requests | Applies To |
|---|---|---|---|
default |
60s | 100 | All unlisted routes |
auth |
60s | 20 | Login, OTP, passkey endpoints |
read |
60s | 300 | GET endpoints (search, listing, game details) |
write |
60s | 10 | POST comment, POST submission, POST report |
strict |
60s | 5 | POST contact form, community suggestion |
Implementation:
- Extend
rateLimitto accept an optionalcategoryparameter - Apply different limits per route group in
app.ts - Maintain the existing in-memory store pattern (no Redis dependency for now)
4.3.2 Comment Anti-Spam
-
Duplicate detection (exact match):
- Before inserting a comment, check if the user has posted a comment with identical
content(JSON stringified) to the same game in the last 5 minutes - If yes, return
409 Conflictwith{ error: "Duplicate comment detected" }
- Before inserting a comment, check if the user has posted a comment with identical
-
Content length cap:
- Validate that
JSON.stringify(body.content).length <= 50000(50KB) - Return
413 Payload Too Largeif exceeded
- Validate that
-
Rate limit on comment creation:
- Covered by the
writetier (10 req/min per IP) - Additional per-user limit: max 30 comments per hour across all games (DB query check)
- Covered by the
-
Ghost-ban pattern (future consideration):
- Add
isShadowBannedboolean to users table (admin-only) - Shadow-banned users' comments appear to themselves but are hidden from others
- Scope: Out of scope for this release; note as future enhancement
- Add
4.3.3 Submission Abuse Prevention
-
Per-user submission cooldown:
- Before inserting a performance entry, check when the user last submitted ANY entry
- If < 60 seconds ago, return
429 Too Many Requestswith{ error: "Please wait before submitting another benchmark", retryAfter: N } - This is a lightweight DB query:
SELECT MAX(createdAt) FROM performance_entries WHERE userId = ?
-
Duplicate entry detection:
- Check for existing entry with same
userId,versionId,hardwareSlug,upscalerType,frameGenMethodwithin last 10 minutes - If found, warn but don't block (user may be re-submitting with corrections)
- Check for existing entry with same
-
Validation hardening:
fpsAvg: must be between 1 and 500fpsLow/fpsHigh/fpsOnePercentLow: if provided, must be between 0 and 500tdpWatts: if provided, must be between 1 and 100settingsJson: maximum 20 categories, each with maximum 50 settingsuserNotes: maximum 5000 characters
4.3.4 Additional Hardening
-
Input sanitization on comment content:
- Strip
<script>tags andjavascript:URLs from Tiptap JSON before storage - Tiptap's JSON format is generally safe, but add a server-side sanitization pass as defense-in-depth
- Strip
-
Rate limit headers on all responses:
- Already implemented — ensure they're visible on all routes
-
CORS review:
- Verify CORS headers are restrictive (only allow the app's own origin)
4.4 New/Modified Files
| File | Change |
|---|---|
lib/auth/rate-limit.ts |
Extend with category-based limits |
lib/api/app.ts |
Apply tiered limits per route group |
lib/api/comments.ts |
Add duplicate detection, length cap, content sanitization |
lib/api/performance-submit.ts |
Add submission cooldown, duplicate detection, validation hardening |
lib/api/community-suggestions.ts |
Add rate limit per user |
lib/api/contact.ts |
Add rate limit per IP |
5. Stream D — Changelog & Version Bump
5.1 Version Bump
Current version: 2026.0.100 → New version: 2026.0.101
Files to update:
package.json→"version": "2026.0.101"lib/api/app.ts→ OpenAPIversion: "2026.0.101"
5.2 Changelog Entry
Append to CHANGELOG.md following the existing format:
## [2026.0.101] - 2026-05-14
### Added
- SteamDB version auto-fetch — latest game version/buid surfaced in submit wizard
- Landing page: Trending This Week, Best New Releases, Most Tested, Most Reported sections
- Tiered API rate limiting with per-category limits
- Comment duplicate detection and content length validation
- Submission cooldown (60s between entries per user)
### Changed
- Landing hero height adjusted to show content "peek" below the fold
- Rate limiter now enforces different limits for auth, read, write, and strict categories
### 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
5.3 Update News (Markdown)
Create content/updates/2026-05-14-v2026.0.101.md:
Frontmatter:
---
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."
---
Sections:
- New Landing Page — trending, new releases, most tested/reported sections below the hero
- SteamDB Version Auto-Fetch — submit wizard now suggests the latest version from SteamDB
- API Security Hardening — tiered rate limits, comment spam prevention, submission validation
6. Cross-Cutting Concerns
6.1 Non-Breaking Changes
All four streams are additive — no existing APIs change signature, no database migrations are required. The landing page replaces the current page but the hero section is preserved.
6.2 Testing Strategy
| Stream | Test Focus |
|---|---|
| A | Unit test the HTML parser with sample SteamDB HTML; mock HTTP in endpoint test |
| B | Visual regression on landing page; verify all 4 sections render with data and empty states |
| C | Rate limiter unit tests for each tier; integration test for comment duplicate detection |
| D | Verify version consistency across package.json, API spec, and changelog |
6.3 Rollout Order
- C (Security) first — foundational, protects everything else
- A (SteamDB) second — independent, can be tested in isolation
- B (Landing) third — depends on existing dashboard APIs (already deployed)
- D (Changelog) last — documents everything above
6.4 Rollback Plan
- All changes are additive; rolling back means reverting the commit
- No database migrations = no rollback risk
- SteamDB scraping is best-effort — if it breaks, the wizard falls back to existing behavior
7. Open Questions for User
-
Landing page data fetching: Server Component (import query directly) or client-side fetch? Server Component is better for SEO and initial load but requires
page.tsxto become async. Client-side is simpler and consistent with current pattern. Recommendation: Client-side fetch with skeleton loaders (consistent with search page pattern). -
"Most Reported" section visibility: Should this be public on the landing page, or admin-only? Public visibility may discourage spam (sunlight as disinfectant). Admin-only keeps it cleaner for end users. Recommendation: Public (transparency), but with low visual prominence.
-
SteamDB scraping ethics: SteamDB is a community resource. Scraping their pages adds load. Should we add a configurable toggle (
STEAMDB_SCRAPING_ENABLED) so it can be disabled? Recommendation: Yes, add an env var gate.