--- phase: "01-foundation-client-dashboard" plan: 03 type: execute wave: 2 depends_on: - "01-01" - "01-02" files_modified: - src/middleware.ts - app/api/internal/validate-token/route.ts - src/lib/client-view.ts - app/c/[token]/page.tsx - app/c/[token]/layout.tsx autonomous: true requirements: - DASH-01 - DASH-02 - DASH-03 - DASH-04 must_haves: truths: - "Middleware validates token at edge and returns 404 if token not found" - "Client can open /c/[token] without login" - "Server Component fetches client data from DB via token" - "ClientView type ensures quote_items is never exposed to client API" - "All phase, task, payment, document, and note data is fetched and passed to UI" - "TypeScript types are exported for downstream UI rendering" artifacts: - path: "src/middleware.ts" provides: "Token validation using fetch to internal API route (Edge-compatible)" contains: "function middleware" - path: "app/api/internal/validate-token/route.ts" provides: "Node.js API route that queries DB and returns 200/404 for token validation" min_lines: 20 contains: "clients.token" - path: "src/lib/client-view.ts" provides: "Client-safe type definitions and query functions" contains: "ClientView" - path: "app/c/[token]/page.tsx" provides: "Server Component rendering client dashboard" min_lines: 30 contains: "export default async function" - path: "app/c/[token]/layout.tsx" provides: "Layout for token-authenticated routes" min_lines: 10 key_links: - from: "src/middleware.ts" to: "app/api/internal/validate-token/route.ts" via: "fetch('/api/internal/validate-token?token=X')" pattern: "validate-token" - from: "app/api/internal/validate-token/route.ts" to: "Database query for token validation" via: "db.select().from(clients).where(eq(clients.token, token))" pattern: "clients\\.token" - from: "app/c/[token]/page.tsx" to: "src/lib/client-view.ts" via: "import { getClientView }" pattern: "getClientView" - from: "ClientView type" to: "Rendering props" via: "ensures no quote_items" pattern: "quote_items" --- **Token Middleware + Client Portal Data Layer:** Create Next.js middleware to validate client tokens at the edge, build the ClientView type system that enforces ClientView vs. AdminView separation, and create a Server Component that fetches and prepares all client dashboard data without exposing admin secrets (quote_items, service prices). Purpose: Establish the secure client access pattern: middleware validates token → Server Component fetches data → UI receives ClientView shape only. This prevents accidental exposure of admin data to clients. Output: Fully functional `/c/[token]` route that fetches real client data and prepares it for rendering. No client-side waterfalls. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/research/ARCHITECTURE.md (Data Flow section, lines 29-50) @.planning/research/PITFALLS.md (Pitfall 2: Client API Exposes Admin Data, lines 26-38) @.planning/phases/01-foundation-client-dashboard/01-CONTEXT.md @.planning/phases/01-foundation-client-dashboard/01-02-SUMMARY.md Task 1: Create src/middleware.ts (Edge-compatible fetch pattern) + internal validate-token API route src/middleware.ts app/api/internal/validate-token/route.ts src/db/schema.ts (clients table definition) package.json (verify Next.js version) **Why two files:** Next.js middleware runs in the Edge runtime by default. The postgres-js driver (used by Drizzle) requires Node.js `net`/`tls` APIs unavailable at the Edge. The solution is a two-layer pattern: middleware uses `fetch()` to call an internal API route that runs in the Node.js runtime and does the actual DB query. Create `app/api/internal/validate-token/route.ts` (Node.js runtime, does DB query): ```typescript import { NextRequest, NextResponse } from 'next/server'; import { eq } from 'drizzle-orm'; import { db } from '@/db'; import { clients } from '@/db/schema'; export async function GET(request: NextRequest) { const token = request.nextUrl.searchParams.get('token'); if (!token) { return NextResponse.json({ valid: false }, { status: 400 }); } try { const rows = await db .select({ id: clients.id }) .from(clients) .where(eq(clients.token, token)) .limit(1); if (rows.length === 0) { return NextResponse.json({ valid: false }, { status: 404 }); } return NextResponse.json({ valid: true }, { status: 200 }); } catch { return NextResponse.json({ valid: false }, { status: 500 }); } } ``` Create `src/middleware.ts` (Edge-compatible, uses fetch): ```typescript import { NextRequest, NextResponse } from 'next/server'; export async function middleware(request: NextRequest) { const pathname = request.nextUrl.pathname; // Extract token from path: /c/[token]/... const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/); if (!tokenMatch) { return NextResponse.rewrite(new URL('/not-found', request.url)); } const token = tokenMatch[1]; try { // Call internal Node.js API route — Edge middleware cannot use postgres-js directly const validateUrl = new URL( `/api/internal/validate-token?token=${encodeURIComponent(token)}`, request.url ); const res = await fetch(validateUrl.toString()); if (!res.ok) { return NextResponse.rewrite(new URL('/not-found', request.url)); } return NextResponse.next(); } catch { return NextResponse.rewrite(new URL('/not-found', request.url)); } } export const config = { matcher: ['/c/:path*'], }; ``` Key points: - Middleware is Edge-compatible: no Node.js imports, only `fetch()` - DB query lives in the API route (Node.js runtime) where postgres-js works correctly - Token is URL-encoded before being passed as query param - Non-existent or invalid tokens resolve to `/not-found` (Next.js built-in 404 page) - Internal API route should not be called directly by clients (no auth secret needed — it only returns boolean valid/invalid) test -f src/middleware.ts && echo "middleware.ts exists" grep -q "export.*function middleware" src/middleware.ts && echo "middleware function exported" grep -q "matcher.*c/" src/middleware.ts && echo "matcher configured for /c/ routes" ! grep -q "from '@/db'" src/middleware.ts && echo "middleware does not import drizzle/db (good — Edge safe)" test -f app/api/internal/validate-token/route.ts && echo "internal validate-token route exists" grep -q "clients.token" app/api/internal/validate-token/route.ts && echo "Token DB query in API route" - `src/middleware.ts` does NOT import Drizzle/postgres-js (Edge-safe) - `src/middleware.ts` fetches `/api/internal/validate-token?token=X` - `app/api/internal/validate-token/route.ts` queries `clients.token` via Drizzle - Non-existent tokens return `/not-found` (404) - Matcher configured for `/c/:path*` - TypeScript compiles without errors Task 2: Create src/lib/client-view.ts with ClientView type and query functions src/lib/client-view.ts src/db/schema.ts (all table definitions) Create `src/lib/client-view.ts`: ```typescript import { eq, inArray } from 'drizzle-orm'; import { db } from '@/db'; import { clients, phases, tasks, deliverables, payments, documents, notes } from '@/db/schema'; /** * ClientView: The ONLY data shape returned to client-facing routes. * Deliberately excludes: quote_items, service_catalog, service prices. * Enforced server-side: client API never touches admin data. */ export interface ClientView { client: { id: string; name: string; brand_name: string; brief: string; accepted_total: string; // only total, never breakdown }; phases: Array<{ id: string; title: string; status: 'upcoming' | 'active' | 'done'; sort_order: number; tasks: Array<{ id: string; title: string; description: string | null; status: 'todo' | 'in_progress' | 'done'; sort_order: number; deliverables: Array<{ id: string; title: string; url: string | null; status: 'pending' | 'submitted' | 'approved'; approved_at: string | null; // ISO timestamp }>; }>; progress_pct: number; // % of tasks done in this phase }>; payments: Array<{ id: string; label: string; // "Acconto 50%" | "Saldo 50%" status: 'da_saldare' | 'inviata' | 'saldato'; }>; documents: Array<{ id: string; label: string; url: string; }>; notes: Array<{ id: string; body: string; created_at: string; // ISO timestamp }>; global_progress_pct: number; // % of all tasks done across all phases } /** * getClientView: Fetch all client data and return only ClientView shape. * NEVER queries quote_items. */ export async function getClientView(token: string): Promise { // Fetch client const clientRow = await db .select() .from(clients) .where(eq(clients.token, token)) .limit(1); if (clientRow.length === 0) { return null; } const client = clientRow[0]; // Fetch all phases for this client const phasesRows = await db .select() .from(phases) .where(eq(phases.client_id, client.id)) .orderBy(phases.sort_order); // Fetch tasks scoped to this client's phases only const phaseIds = phasesRows.map((p) => p.id); const tasksRows = phaseIds.length === 0 ? [] : await db .select() .from(tasks) .where(inArray(tasks.phase_id, phaseIds)) .orderBy(tasks.sort_order); // Fetch deliverables scoped to this client's tasks only const taskIds = tasksRows.map((t) => t.id); const deliverables_rows = taskIds.length === 0 ? [] : await db .select() .from(deliverables) .where(inArray(deliverables.task_id, taskIds)); // Fetch payments const paymentsRows = await db .select() .from(payments) .where(eq(payments.client_id, client.id)); // Fetch documents const documentsRows = await db .select() .from(documents) .where(eq(documents.client_id, client.id)); // Fetch notes const notesRows = await db .select() .from(notes) .where(eq(notes.client_id, client.id)) .orderBy(notes.created_at); // Build hierarchical structure const phasesList = phasesRows.map((phase) => { const phaseTasksRows = tasksRows.filter((t) => t.phase_id === phase.id); const tasksList = phaseTasksRows.map((task) => { const taskDeliverables = deliverables_rows .filter((d) => d.task_id === task.id) .map((d) => ({ id: d.id, title: d.title, url: d.url, status: d.status as 'pending' | 'submitted' | 'approved', approved_at: d.approved_at ? new Date(d.approved_at).toISOString() : null, })); return { id: task.id, title: task.title, description: task.description, status: task.status as 'todo' | 'in_progress' | 'done', sort_order: task.sort_order, deliverables: taskDeliverables, }; }); // Calculate progress for this phase const taskCount = tasksList.length; const doneCount = tasksList.filter((t) => t.status === 'done').length; const progress_pct = taskCount === 0 ? 0 : Math.round((doneCount / taskCount) * 100); return { id: phase.id, title: phase.title, status: phase.status as 'upcoming' | 'active' | 'done', sort_order: phase.sort_order, tasks: tasksList, progress_pct, }; }); // Calculate global progress const allTasks = phasesRows.flatMap((p) => tasksRows.filter((t) => t.phase_id === p.id) ); const allDoneTasks = allTasks.filter((t) => t.status === 'done').length; const globalProgressPct = allTasks.length === 0 ? 0 : Math.round((allDoneTasks / allTasks.length) * 100); // Map payments (do NOT expose amount — only label and status) const paymentsList = paymentsRows.map((p) => ({ id: p.id, label: p.label, status: p.status as 'da_saldare' | 'inviata' | 'saldato', })); // Map documents const documentsList = documentsRows.map((d) => ({ id: d.id, label: d.label, url: d.url, })); // Map notes const notesList = notesRows.map((n) => ({ id: n.id, body: n.body, created_at: new Date(n.created_at).toISOString(), })); return { client: { id: client.id, name: client.name, brand_name: client.brand_name, brief: client.brief, accepted_total: client.accepted_total ?? '0', }, phases: phasesList, payments: paymentsList, documents: documentsList, notes: notesList, global_progress_pct: globalProgressPct, }; } ``` Key points: - `ClientView` interface explicitly omits admin data - `getClientView()` never queries `quote_items`, `service_catalog`, or service prices - Payments are returned WITHOUT amount (only label and status) - All timestamps are ISO strings for JSON serialization - Progress percentages are calculated server-side test -f src/lib/client-view.ts && echo "client-view.ts exists" grep -q "interface ClientView" src/lib/client-view.ts && echo "ClientView interface defined" grep -q "export async function getClientView" src/lib/client-view.ts && echo "getClientView function exported" ! grep -q "quote_items\|service_catalog" src/lib/client-view.ts && echo "quote_items not referenced (good)" grep -q "inArray" src/lib/client-view.ts && echo "inArray scoping present" grep -q "accepted_total.*?? '0'" src/lib/client-view.ts && echo "null coalescing on accepted_total" npm run build 2>&1 | grep -v "warning" | grep -q "error" && echo "TypeScript errors" || echo "TypeScript OK" - `src/lib/client-view.ts` exists with `ClientView` interface and `getClientView()` function - Interface does NOT include quote_items, service_catalog, or individual service prices - Payments are returned with only label and status (no amount) - Function returns hierarchical data: client → phases → tasks → deliverables - Progress percentages are calculated server-side - TypeScript compiles without errors Task 3: Create app/c/[token]/page.tsx Server Component to render client dashboard app/c/[token]/page.tsx app/c/[token]/layout.tsx src/lib/client-view.ts (ClientView interface) Create `app/c/[token]/layout.tsx`: ```typescript import type { Metadata } from 'next'; export const metadata: Metadata = { title: 'Client Portal', description: 'Project status dashboard', }; export default function ClientLayout({ children, params, }: { children: React.ReactNode; params: { token: string }; }) { return <>{children}; } ``` Create `app/c/[token]/page.tsx` (Server Component): ```typescript import { getClientView } from '@/lib/client-view'; import { notFound } from 'next/navigation'; export const revalidate = 60; // ISR: revalidate every 60 seconds export default async function ClientDashboard({ params, }: { params: { token: string }; }) { const view = await getClientView(params.token); if (!view) { notFound(); } return (
{/* Placeholder: Dashboard will be built in Plan 04 */}

{view.client.brand_name}

{view.client.brief}

Token: {params.token}

); } ``` This page: - Fetches ClientView data via `getClientView()` - Uses Server Component (no Client Component overhead) - Returns 404 if token not found - Minimal placeholder content (full UI in Plan 04) - ISR enabled: revalidates every 60 seconds so updates are visible within a minute
test -f app/c/\[token\]/page.tsx && echo "Client page route exists" grep -q "export default async function" app/c/\[token\]/page.tsx && echo "Server Component syntax correct" grep -q "getClientView" app/c/\[token\]/page.tsx && echo "getClientView is called" grep -q "notFound()" app/c/\[token\]/page.tsx && echo "404 handling in place" test -f app/c/\[token\]/layout.tsx && echo "Layout file exists" - `app/c/[token]/page.tsx` exists as a Server Component - `app/c/[token]/layout.tsx` exists with metadata - Page calls `getClientView()` and renders minimal placeholder - 404 is returned if view is null - `npm run build` succeeds
## Trust Boundaries | Boundary | Description | |----------|-------------| | Client request → Middleware | Middleware validates token before any page renders; 404 on invalid token | | Server Component → Database | getClientView() queries only client-safe fields; never queries quote_items | | ClientView → Serialization | ClientView type prevents accidental inclusion of admin data in JSON responses | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-03-001 | Information Disclosure | ClientView shape | mitigate | TypeScript interface enforces shape; admin data fields are never included; IDE warnings if field is accessed | | T-03-002 | Tampering | Token parameter | mitigate | Middleware validates token before page renders; invalid tokens → 404 before DB state is exposed | | T-03-003 | Denial of Service | getClientView() query | accept | Queries are indexed on client_id and token; no N+1 queries; Postgres will handle reasonable load | After plan execution: 1. Run `npm run build` → no errors 2. Visit `http://localhost:3000/c/invalid-token` → should return 404 (after db is seeded) 3. Check `src/middleware.ts` → validates token at edge 4. Check `src/lib/client-view.ts` → ClientView interface does not expose quote_items 5. Check `app/c/[token]/page.tsx` → Server Component structure correct - Middleware validates tokens at the edge - Server Component fetches ClientView data without exposing admin secrets - Invalid tokens return 404 - TypeScript enforces ClientView shape (no quote_items, no prices) - Route is ready for UI rendering (Plan 04) - Ready to proceed to Plan 04 (Dashboard UI) After completion, create `.planning/phases/01-foundation-client-dashboard/01-03-SUMMARY.md`