From 0cda534825aaf2a59cc8ee0c54902ea7b84b20c1 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Thu, 14 May 2026 23:55:21 +0800 Subject: [PATCH] docs: implementation plan for landing page, SteamDB, security, and changelog --- ...026-05-14-landing-security-steamdb-plan.md | 1596 +++++++++++++++++ 1 file changed, 1596 insertions(+) create mode 100644 docs/2026-05-14-landing-security-steamdb-plan.md diff --git a/docs/2026-05-14-landing-security-steamdb-plan.md b/docs/2026-05-14-landing-security-steamdb-plan.md new file mode 100644 index 0000000..6444d57 --- /dev/null +++ b/docs/2026-05-14-landing-security-steamdb-plan.md @@ -0,0 +1,1596 @@ +# 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