--- phase: 09 plan: 03 type: execute wave: 2 depends_on: - 09-01 - 09-02 files_modified: - src/app/quote/[token]/layout.tsx - src/app/quote/[token]/page.tsx - src/app/quote/[token]/actions.ts - src/components/public/quote/QuoteMultistep.tsx - src/components/public/quote/QuoteStep1Overview.tsx - src/components/public/quote/QuoteStep2Selection.tsx - src/components/public/quote/QuoteStep3Summary.tsx - src/middleware.ts - src/lib/rate-limit.ts autonomous: true requirements: - QUOTE-02 - QUOTE-03 - QUOTE-04 - QUOTE-05 user_setup: [] must_haves: truths: - "Public `/quote/[token]` route accessible without login via token-gated middleware" - "Multistep wizard renders three steps: overview, tier/service selection, summary + accept" - "Rate limit enforced: 3 views per minute per IP" - "Accept button calls server action with email + notes capture" - "Server action validates token, updates accepted_at immutably, returns success" - "quote_items never exposed in public API responses; only accepted_total visible" artifacts: - path: src/app/quote/[token]/page.tsx provides: "Public quote page entry point" contains: "QuoteMultistep" - path: src/components/public/quote/QuoteMultistep.tsx provides: "Multi-step form state and step navigation" min_lines: 100 - path: src/middleware.ts provides: "Token validation and rate limiting for /quote/[token]" pattern: "quote.*token" - path: src/lib/rate-limit.ts provides: "Rate limit check function (3 views/min per IP)" exports: ["checkRateLimit"] - path: src/app/quote/[token]/actions.ts provides: "acceptQuote server action" exports: ["acceptQuote"] key_links: - from: "src/middleware.ts" to: "src/lib/rate-limit.ts" via: "rate limit check" pattern: "checkRateLimit" - from: "src/app/quote/[token]/page.tsx" to: "src/components/public/quote/QuoteMultistep.tsx" via: "import and render" pattern: "import.*QuoteMultistep" - from: "src/components/public/quote/QuoteMultistep.tsx" to: "src/app/quote/[token]/actions.ts" via: "acceptQuote server action call" pattern: "await acceptQuote" - from: "src/app/quote/[token]/actions.ts" to: "src/db/schema.ts" via: "quote update (accepted_at)" pattern: "db.update.*quotes" --- Implement the public-facing Quote Page (`/quote/[token]`) with token-gated access, rate limiting, and a multistep wizard (overview → tier selection → summary + accept). Client views quote details, accepts or rejects, and optionally provides email + notes. Purpose: Enable public sharing of quotes via nanoid tokens; collect client acceptance with email capture; immutably record acceptance timestamp. Output: - `/quote/[token]` route accessible via unique token (no login required) - Middleware validates token and enforces rate limit (3 views/min per IP) - Three-step form: overview (read-only) → tier/service selection (read-only or editable) → summary + accept/reject - Server action `acceptQuote()` updates `accepted_at` immutably; triggers Phase 11 auto-provisioning later - Quote items never exposed; only `accepted_total` and phase/service count visible to client @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/09-quote-builder-client-acceptance/09-RESEARCH.md @.planning/phases/09-quote-builder-client-acceptance/09-02-SUMMARY.md ### Key Interfaces PublicQuoteView (from quote-service.ts Phase 9 Plan 1): ```typescript type PublicQuoteView = { id: string; token: string; state: "draft" | "sent" | "viewed" | "accepted" | "rejected"; accepted_total: string; accepted_at: Date | null; offerName: string; phaseSummary: Array<{ id: string; title: string; serviceCount: number; }>; }; ``` Accept Request (Step 3 form data): ```typescript type AcceptQuoteInput = { token: string; email?: string; notes?: string; }; ``` Quote Accept Response: ```typescript type AcceptQuoteResponse = { success: boolean; message?: string; error?: string; quoteId?: string; }; ``` Task 1: Add rate limiting to middleware and token validation src/middleware.ts, src/lib/rate-limit.ts Implement rate limiting in middleware and a reusable rate-limit utility: **src/lib/rate-limit.ts** — In-memory rate limit store (basic MVP; production uses Upstash Redis): Create a simple rate limiter that tracks requests per IP: - RATE_LIMIT_WINDOW: 60 seconds (60,000 ms) - RATE_LIMIT_MAX: 3 views per minute - ipRequests: Map Helper function `checkRateLimit(ip: string): boolean` - Get current time - Look up IP in map - If entry exists and within window: - If count >= 3: return false (rate limit exceeded) - Otherwise: increment count, return true - If entry expired or missing: create new entry with count=1, resetAt=now+60000 Export function for use in middleware. **src/middleware.ts** — Update existing middleware to protect /quote/[token]: - Add new matcher: `/quote/:path*` - On request to /quote/* route: - Extract client IP from x-forwarded-for header (fallback to request.socket.remoteAddress) - Call checkRateLimit(ip) - If limit exceeded: return 429 Too Many Requests JSON response - If limit ok: proceed to handler Keep existing patterns for /admin/* and /client/* routes intact. Note: This is in-memory, so it resets on serverless cold start (limitation noted in RESEARCH). For production, recommend Upstash Redis (Phase 10+). npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Middleware compiles" Rate limit utility and middleware protection added. Public quote route enforces 3 views/min per IP. Returns 429 if exceeded. Task 2: Create multistep form components (Steps 1-3) src/components/public/quote/QuoteMultistep.tsx, src/components/public/quote/QuoteStep1Overview.tsx, src/components/public/quote/QuoteStep2Selection.tsx, src/components/public/quote/QuoteStep3Summary.tsx Create four React components for the public quote multistep form: **src/components/public/quote/QuoteMultistep.tsx** — Parent managing step state: - "use client" component - useState for step (1-3) and form data - useForm from React Hook Form for shared form state across all steps - Render appropriate step component based on current step - Previous/Next buttons with step navigation logic - On Step 3 submit: call acceptQuote server action **src/components/public/quote/QuoteStep1Overview.tsx** — Read-only overview: - Display offer name (public_name) - Display accepted_total as large price - Show transformation promise - Display phase list (count only, no pricing details) - "Continua" button to go to Step 2 **src/components/public/quote/QuoteStep2Selection.tsx** — Tier/service selection (read-only in MVP): - Show list of offer_phases with service counts per phase - Read-only mode: just display what's included (no client edits in Phase 9) - For Phase 10+, this becomes interactive with tier options - Display calculated total based on offer structure - "Continua" and "Indietro" buttons **src/components/public/quote/QuoteStep3Summary.tsx** — Final acceptance form: - Summary of entire quote (offer, total, phases) - Form fields: email (optional), notes (optional, max 500 chars) - CTA buttons: "Accetta Preventivo" (submit) and "Rifiuta" (reject with optional note) - On submit: call acceptQuote server action - On success: show thank you message or redirect All components use shadcn/ui Form, Input, Button, Card for consistency. Use Zod validation (acceptQuoteSchema from quote-validators.ts) for Step 3 form. npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Components compile" Four multistep form components created. Form state lifted to QuoteMultistep parent. Step navigation working. On submit, form calls acceptQuote action. Task 3: Create /quote/[token] page and acceptQuote server action src/app/quote/[token]/page.tsx, src/app/quote/[token]/layout.tsx, src/app/quote/[token]/actions.ts Create page structure for public quote route: **src/app/quote/[token]/layout.tsx** — Public quote layout: - No authenticated header (unlike /admin or /client layouts) - Simple centered container with quote branding - Display logo or company name - No sidebar or admin navigation **src/app/quote/[token]/page.tsx** — Quote page entry point: - Server component that validates token exists in database - If token not found: return 404 or error page - If quote already accepted: show read-only "Già accettato" message with accepted_at date - Otherwise: fetch quote via getQuoteByToken() from quote-service.ts - Render QuoteMultistep component with token and quote data - Pass quote data as prop to enable form pre-fill **src/app/quote/[token]/actions.ts** — Accept server action: ```typescript "use server"; import { z } from "zod"; import { eq } from "drizzle-orm"; import { db } from "@/db"; import { quotes } from "@/db/schema"; import { acceptQuoteSchema } from "@/lib/quote-validators"; import { revalidatePath } from "next/cache"; export async function acceptQuote( token: string, email?: string, notes?: string ) { // Validate input const parsed = acceptQuoteSchema.safeParse({ token, email, notes }); if (!parsed.success) { return { success: false, error: parsed.error.issues[0]?.message || "Dati invalidi", }; } const { token: validatedToken, email: validatedEmail, notes: validatedNotes } = parsed.data; try { // Fetch quote const [quote] = await db .select() .from(quotes) .where(eq(quotes.token, validatedToken)) .limit(1); if (!quote) { return { success: false, error: "Preventivo non trovato" }; } // Check if already accepted (immutability) if (quote.accepted_at !== null) { return { success: false, error: "Preventivo già accettato" }; } // Atomic update: set accepted_at (immutable) + optional email/notes const updated = await db .update(quotes) .set({ accepted_at: new Date(), state: "accepted", client_email: validatedEmail || null, client_notes: validatedNotes || null, updated_at: new Date(), }) .where(eq(quotes.token, validatedToken)) .returning(); if (!updated[0]) { return { success: false, error: "Errore nel salvataggio" }; } // Revalidate page to show accepted state revalidatePath(`/quote/${validatedToken}`); // Phase 11 will hook into this event for auto-provisioning // For now, just return success return { success: true, message: "Preventivo accettato con successo!", quoteId: quote.id, }; } catch (error) { console.error("acceptQuote error:", error); return { success: false, error: "Errore del server" }; } } ``` This action enforces immutability at the database level: once accepted_at is set, the quote cannot be modified by further submissions (Phase 11 will read accepted_at and auto-provision). npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Page and actions compile" Page structure and acceptQuote server action created. Token-gated route validates token, displays multistep form, accepts quote immutably. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Client → Token URL | Token in URL; cannot be guessed; rate limited at middleware | | Client Network → Server | Quote data is public (via token); pricing visible but immutable | | Client Submit → Server | Email and notes are user-supplied; validated via Zod | | Server → Database | Acceptance is immutable; accepted_at timestamp cannot be unset | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-09-08 | Spoofing | Token guessing / brute force | mitigate | Nanoid 21-char (122-bit entropy); rate limit 3 views/min per IP via middleware | | T-09-09 | Tampering | Accept quote twice (re-submission) | mitigate | Database check: `accepted_at IS NOT NULL` before update; server action checks and rejects re-accept | | T-09-10 | Information Disclosure | quote_items exposed via page source | mitigate | QuoteMultistep never receives quote_items; query layer filters via getQuoteByToken() | | T-09-11 | Denial of Service | Email field spam / abuse | mitigate | Email optional; rate limit (3 views/min) limits submission volume; Zod validates email format | | T-09-12 | Information Disclosure | Token in browser history / logs | accept | Token is sensitive but expires after accept; audit trail in accepted_at immutable timestamp | After Phase 9 Plan 3 execution: 1. Rate limit works: First 3 requests to /quote/[token] return 200; 4th returns 429 2. Page loads: `/quote/[token]` renders QuoteMultistep with quote data 3. Form validates: Zod schemas reject invalid email or empty required fields 4. Accept works: Clicking "Accetta Preventivo" calls acceptQuote action, updates accepted_at 5. Immutability enforced: Refreshing page after accept shows "Già accettato" message; re-submit returns error 6. quote_items not exposed: Inspecting network response shows no quote_items in page or API data 7. Middleware protects: Requests to /quote/* are rate-limited; exceeding limit returns 429 - `/quote/[token]` route accessible via token (no Auth.js login required) - Multistep wizard renders and navigates between steps - Step 1: Read-only overview of quote (offer name, total, transformation promise) - Step 2: Read-only phase/service listing (count only, no line item prices) - Step 3: Email + notes form, Accept/Reject buttons - Rate limit enforced: 3 views/min per IP returns 429 after limit - acceptQuote action validates token and updates accepted_at immutably - On success: accepted_at timestamp set; subsequent accepts rejected - quote_items never transmitted to client; only accepted_total and phase/service summary visible - Middleware protects route; invalid token returns 404 After execution, create `.planning/phases/09-quote-builder-client-acceptance/09-03-SUMMARY.md`