Files
portfolio/.planning/phases/01-cleanup-fixes/01-02-PLAN.md
T

7.0 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
01-cleanup-fixes 02 execute 1
Dockerfile
server/plugins/rate-limit.ts
server/api/contact.post.ts
true
FIX-02
FIX-03
DEPLOY-01
truths artifacts key_links
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
path provides contains
Dockerfile pnpm-based Docker build pnpm install --frozen-lockfile
path provides contains
server/plugins/rate-limit.ts In-memory rate limiting for contact API 429
from to via pattern
server/plugins/rate-limit.ts /api/contact Nitro request hook filtering on path /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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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:
# 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'" <acceptance_criteria>
  • 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 </acceptance_criteria> 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.
// server/plugins/rate-limit.ts
const ipMap = new Map<string, { count: number; reset: number }>()

// 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'" <acceptance_criteria>

  • server/plugins/rate-limit.ts exists
  • File contains statusCode: 429
  • File contains check for /api/contact
  • File contains getRequestIP
  • File contains Map<string, { count: number; reset: number }>
  • Rate limit is 3 requests per 60-second window </acceptance_criteria> Contact API rate-limited to 3 POST requests per IP per minute, 429 returned on excess

<threat_model>

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
</threat_model>
- `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

<success_criteria> Dockerfile builds with pnpm, contact API rejects rapid submissions with 429. </success_criteria>

After completion, create `.planning/phases/01-cleanup-fixes/01-02-SUMMARY.md`