--- phase: 07-audit-trail-and-download plan: "01" type: execute wave: 1 depends_on: [] files_modified: - teressa-copeland-homes/src/lib/signing/token.ts - teressa-copeland-homes/src/app/api/documents/[id]/download/route.ts autonomous: true requirements: - SIGN-07 - LEGAL-03 must_haves: truths: - "GET /api/documents/[id]/download?adt=[token] streams the signed PDF when the agent-download JWT is valid" - "A missing or expired adt token returns 401 — no file served" - "An adt token for document A cannot download document B (route ID vs token documentId mismatch returns 403)" - "A signedFilePath containing path traversal characters returns 403" - "A document with no signedFilePath (unsigned) returns 404" artifacts: - path: "teressa-copeland-homes/src/lib/signing/token.ts" provides: "createAgentDownloadToken and verifyAgentDownloadToken exports" contains: "purpose: 'agent-download'" - path: "teressa-copeland-homes/src/app/api/documents/[id]/download/route.ts" provides: "GET handler streaming signed PDF for authenticated agent download" exports: ["GET"] key_links: - from: "teressa-copeland-homes/src/app/api/documents/[id]/download/route.ts" to: "teressa-copeland-homes/src/lib/signing/token.ts" via: "verifyAgentDownloadToken import" pattern: "verifyAgentDownloadToken" - from: "route.ts download handler" to: "uploads/ directory on disk" via: "readFile + path.join(UPLOADS_DIR, signedFilePath) + startsWith guard" pattern: "absPath.startsWith\\(UPLOADS_DIR\\)" --- Add agent-authenticated PDF download: extend token.ts with agent-download JWT utilities and create GET /api/documents/[id]/download route that streams signed PDFs behind a 5-min presigned token. Purpose: Satisfy LEGAL-03 (signed PDFs never accessible via guessable public URLs; agent downloads via authenticated presigned URLs only) and provide the API surface SIGN-07 requires. Output: Two files — updated token.ts with agent download token functions, new download API route. @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/07-audit-trail-and-download/07-RESEARCH.md ```typescript import { SignJWT, jwtVerify } from 'jose'; import { db } from '@/lib/db'; import { signingTokens } from '@/lib/db/schema'; const getSecret = () => new TextEncoder().encode(process.env.SIGNING_JWT_SECRET!); // Existing exports (keep all of these): export async function createSigningToken(documentId: string): Promise<{ token: string; jti: string; expiresAt: Date }> export async function verifySigningToken(token: string): Promise<{ documentId: string; jti: string; exp: number }> // Client download token — purpose: 'download', 15-min TTL, no DB record export async function createDownloadToken(documentId: string): Promise export async function verifyDownloadToken(token: string): Promise<{ documentId: string }> // Phase 7 adds agent download token — purpose: 'agent-download', 5-min TTL, no DB record // ADD: createAgentDownloadToken and verifyAgentDownloadToken ``` ```typescript // Pattern: query param token (dt=) → verify → path traversal guard → readFile → Response // Agent route uses same pattern with adt= query param and purpose: 'agent-download' const UPLOADS_DIR = path.join(process.cwd(), 'uploads'); export async function GET(req: NextRequest) { const url = new URL(req.url); const dt = url.searchParams.get('dt'); // ... verifyDownloadToken(dt) → doc.signedFilePath → absPath traversal guard → readFile → Response const absPath = path.join(UPLOADS_DIR, doc.signedFilePath); if (!absPath.startsWith(UPLOADS_DIR)) return 403; // new Response(new Uint8Array(fileBuffer), { 'Content-Disposition': 'attachment; filename=...' }) } ``` ```typescript status: documentStatusEnum("status").notNull().default("Draft"), signedFilePath: text("signed_file_path"), // null until signed pdfHash: text("pdf_hash"), signedAt: timestamp("signed_at"), // null until signed ``` Task 1: Add agent download token functions to token.ts teressa-copeland-homes/src/lib/signing/token.ts Append two new exported functions to the end of the existing token.ts. Do NOT modify or remove any existing functions. Add: ```typescript // Agent download token — purpose: 'agent-download', 5-min TTL, no DB record // Generated server-side only (server component or API route). Never in a client component. export async function createAgentDownloadToken(documentId: string): Promise { return await new SignJWT({ documentId, purpose: 'agent-download' }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('5m') .sign(getSecret()); } export async function verifyAgentDownloadToken(token: string): Promise<{ documentId: string }> { const { payload } = await jwtVerify(token, getSecret()); if (payload['purpose'] !== 'agent-download') throw new Error('Not an agent download token'); return { documentId: payload['documentId'] as string }; } ``` This is consistent with the existing createDownloadToken/verifyDownloadToken pattern (purpose: 'download', 15m TTL) — same signing secret, different purpose string, shorter TTL per success criterion. cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20 token.ts exports createAgentDownloadToken and verifyAgentDownloadToken; tsc --noEmit passes; existing exports are untouched Task 2: Create GET /api/documents/[id]/download route teressa-copeland-homes/src/app/api/documents/[id]/download/route.ts Create the directory and file `teressa-copeland-homes/src/app/api/documents/[id]/download/route.ts`. This is the agent-facing download endpoint. It mirrors the existing `/api/sign/[token]/download/route.ts` pattern exactly, substituting `verifyAgentDownloadToken` for `verifyDownloadToken` and adding a document ID cross-check. Implementation: ```typescript import { NextRequest } from 'next/server'; import { verifyAgentDownloadToken } from '@/lib/signing/token'; import { db } from '@/lib/db'; import { documents } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; import path from 'node:path'; import { readFile } from 'node:fs/promises'; const UPLOADS_DIR = path.join(process.cwd(), 'uploads'); // GET /api/documents/[id]/download?adt=[agentDownloadToken] // Requires: valid agent-download JWT in adt query param (generated server-side in document detail page) // No Auth.js session check at this route — the short-lived JWT IS the credential (same as client download pattern) export async function GET( req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const url = new URL(req.url); const adt = url.searchParams.get('adt'); if (!adt) { return new Response(JSON.stringify({ error: 'Missing download token' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } let documentId: string; try { const verified = await verifyAgentDownloadToken(adt); documentId = verified.documentId; } catch { return new Response(JSON.stringify({ error: 'Download link expired or invalid' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Defense in depth: token documentId must match route [id] param if (documentId !== id) { return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }); } const doc = await db.query.documents.findFirst({ where: eq(documents.id, documentId), columns: { id: true, name: true, signedFilePath: true }, }); if (!doc || !doc.signedFilePath) { return new Response(JSON.stringify({ error: 'Signed PDF not found' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); } // Path traversal guard — required on every file read from uploads/ const absPath = path.join(UPLOADS_DIR, doc.signedFilePath); if (!absPath.startsWith(UPLOADS_DIR)) { return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }); } let fileBuffer: Buffer; try { fileBuffer = await readFile(absPath); } catch { return new Response(JSON.stringify({ error: 'File not found on disk' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); } const safeName = doc.name.replace(/[^a-zA-Z0-9-_ ]/g, ''); return new Response(new Uint8Array(fileBuffer), { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="${safeName}_signed.pdf"`, }, }); } ``` Key invariants: - `new Uint8Array(fileBuffer)` not `fileBuffer` directly — required for Next.js 16 TypeScript strict mode (Buffer is not assignable to BodyInit; established in Phase 6) - `absPath.startsWith(UPLOADS_DIR)` traversal guard before every readFile — never skip - Token `documentId` === route `id` cross-check — prevents token for doc A downloading doc B cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20 && npm run build 2>&1 | tail -10 Route file exists at correct path; tsc --noEmit passes; npm run build passes; GET /api/documents/[id]/download without adt returns 401; GET /api/documents/[id]/download with expired/invalid token returns 401; GET /api/documents/[id]/download with valid token for a non-existent docId returns 404 1. `npx tsc --noEmit` passes with no errors 2. `npm run build` completes successfully 3. `src/lib/signing/token.ts` exports: createSigningToken, verifySigningToken, createDownloadToken, verifyDownloadToken, createAgentDownloadToken, verifyAgentDownloadToken (6 total) 4. Route file exists at `src/app/api/documents/[id]/download/route.ts` 5. Route contains `absPath.startsWith(UPLOADS_DIR)` guard 6. Route verifies `documentId !== id` mismatch returns 403 - createAgentDownloadToken creates a JWT with purpose:'agent-download' and 5-min expiry - verifyAgentDownloadToken throws if purpose is not 'agent-download' - GET /api/documents/[id]/download streams signed PDF when adt JWT is valid - Route returns 401 for missing/expired token, 403 for ID mismatch, 403 for path traversal, 404 for unsigned document - No new npm packages required; no existing exports modified After completion, create `.planning/phases/07-audit-trail-and-download/07-01-SUMMARY.md`