--- phase: 01-cleanup-fixes plan: 02 type: execute wave: 1 depends_on: [] files_modified: - Dockerfile - server/plugins/rate-limit.ts - server/api/contact.post.ts autonomous: true requirements: - FIX-02 - FIX-03 - DEPLOY-01 must_haves: truths: - "Dockerfile uses pnpm install --frozen-lockfile, not npm" - "Rapid POST requests to /api/contact are rejected with 429 after the limit" - "Docker build succeeds with pnpm" artifacts: - path: "Dockerfile" provides: "pnpm-based Docker build" contains: "pnpm install --frozen-lockfile" - path: "server/plugins/rate-limit.ts" provides: "In-memory rate limiting for contact API" contains: "429" key_links: - from: "server/plugins/rate-limit.ts" to: "/api/contact" via: "Nitro request hook filtering on path" pattern: "/api/contact" --- Migrate Dockerfile from npm to pnpm and add rate limiting to the contact API endpoint. Purpose: Fix build reproducibility (pnpm lockfile used in Docker) and protect against email flooding via unthrottled contact form submissions. Output: Working Dockerfile with pnpm, rate-limited contact endpoint. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/research/PITFALLS.md @.planning/research/STACK.md Task 1: Migrate Dockerfile to pnpm Dockerfile, package.json Dockerfile Replace the entire Dockerfile with: ```dockerfile # Stage 1: Build FROM node:22-alpine AS builder WORKDIR /app # Install pnpm via corepack RUN corepack enable && corepack prepare pnpm@latest --activate # Copy manifests first for layer caching COPY package.json pnpm-lock.yaml ./ # Install all dependencies (including devDeps needed for build) RUN pnpm install --frozen-lockfile # Copy source and build COPY . . RUN pnpm build # Stage 2: Runtime FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV HOST=0.0.0.0 ENV PORT=3000 # Nuxt SSR bundles all server deps into .output/server/ COPY --from=builder /app/.output /app/.output EXPOSE 3000 CMD ["node", "/app/.output/server/index.mjs"] ``` Key changes from original: - `corepack enable` + `corepack prepare pnpm@latest --activate` instead of relying on npm - `COPY package.json pnpm-lock.yaml ./` instead of `COPY package*.json ./` - `pnpm install --frozen-lockfile` instead of `npm ci` - `pnpm build` instead of `npm run build` - Explicit ENV vars for NODE_ENV, HOST, PORT in runtime stage bash -c "grep -q 'pnpm install --frozen-lockfile' Dockerfile && grep -q 'corepack enable' Dockerfile && grep -q 'pnpm build' Dockerfile && ! grep -q 'npm' Dockerfile && echo 'Dockerfile OK' || echo 'FAIL'" - `grep 'pnpm install --frozen-lockfile' Dockerfile` returns a match - `grep 'corepack enable' Dockerfile` returns a match - `grep 'pnpm build' Dockerfile` returns a match - `grep 'npm' Dockerfile` returns zero matches - `grep 'pnpm-lock.yaml' Dockerfile` returns a match Dockerfile uses pnpm exclusively with frozen lockfile for reproducible builds Task 2: Add rate limiting to contact API server/api/contact.post.ts server/plugins/rate-limit.ts Create `server/plugins/rate-limit.ts` as a Nitro server plugin implementing in-memory IP-based rate limiting for the contact endpoint. ```typescript // server/plugins/rate-limit.ts const ipMap = new Map() // Clean stale entries every 5 minutes to prevent memory leak setInterval(() => { const now = Date.now() for (const [ip, entry] of ipMap) { if (entry.reset < now) ipMap.delete(ip) } }, 5 * 60 * 1000) export default defineNitroPlugin((nitro) => { nitro.hooks.hook('request', (event) => { // Only rate-limit the contact POST endpoint if (event.method !== 'POST' || !event.path.startsWith('/api/contact')) return const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown' const now = Date.now() const window = 60_000 // 1 minute window const limit = 3 // max 3 requests per minute per IP const entry = ipMap.get(ip) if (!entry || entry.reset < now) { ipMap.set(ip, { count: 1, reset: now + window }) return } entry.count++ if (entry.count > limit) { throw createError({ statusCode: 429, message: 'Too many requests. Please try again later.' }) } }) }) ``` This uses Nitro's built-in `getRequestIP` and `createError` helpers (auto-imported in server context). The rate limit is 3 requests per IP per 60-second window. The 4th+ request within the window gets a 429 response. The plugin hooks into ALL requests but filters to only `/api/contact` POST. No changes needed to `contact.post.ts` itself. bash -c "test -f server/plugins/rate-limit.ts && grep -q '429' server/plugins/rate-limit.ts && grep -q '/api/contact' server/plugins/rate-limit.ts && echo 'rate-limit OK' || echo 'FAIL'" - `server/plugins/rate-limit.ts` exists - File contains `statusCode: 429` - File contains check for `/api/contact` - File contains `getRequestIP` - File contains `Map` - Rate limit is 3 requests per 60-second window Contact API rate-limited to 3 POST requests per IP per minute, 429 returned on excess ## Trust Boundaries | Boundary | Description | |----------|-------------| | Client -> /api/contact | Untrusted POST from internet, potential spam/abuse | | Docker build -> production | Build must use same lockfile as dev to prevent supply chain drift | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-01-03 | Denial of Service | /api/contact | mitigate | In-memory rate limit: 3 req/min/IP via Nitro plugin, returns 429 on excess | | T-01-04 | Elevation of Privilege | Dockerfile npm vs pnpm | mitigate | Use pnpm --frozen-lockfile to ensure exact dependency resolution matches dev | | T-01-05 | Tampering | Rate limit bypass via IP spoofing | accept | X-Forwarded-For can be spoofed but acceptable risk for a portfolio contact form; reverse proxy (Docker/Cloudflare) controls the header | - `grep 'pnpm install --frozen-lockfile' Dockerfile` succeeds - `grep -c 'npm' Dockerfile` returns 0 - `server/plugins/rate-limit.ts` exists with 429 response - Rate limit targets `/api/contact` POST only Dockerfile builds with pnpm, contact API rejects rapid submissions with 429. After completion, create `.planning/phases/01-cleanup-fixes/01-02-SUMMARY.md`