# 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 --- ## 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: ```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() // 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 = { 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: ```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. - [ ] **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. - [ ] **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` - [ ] **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`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`.) - [ ] **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