From 6239a30bfd80d260fb0c7058be3a2c070fd192cd Mon Sep 17 00:00:00 2001 From: Chandler Copeland Date: Sat, 21 Mar 2026 10:52:53 -0600 Subject: [PATCH] docs(07-audit-trail-and-download): create gap closure plan 07-04 for LEGAL-03 --- .../07-audit-trail-and-download/07-04-PLAN.md | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 .planning/phases/07-audit-trail-and-download/07-04-PLAN.md diff --git a/.planning/phases/07-audit-trail-and-download/07-04-PLAN.md b/.planning/phases/07-audit-trail-and-download/07-04-PLAN.md new file mode 100644 index 0000000..e582a4b --- /dev/null +++ b/.planning/phases/07-audit-trail-and-download/07-04-PLAN.md @@ -0,0 +1,260 @@ +--- +phase: 07-audit-trail-and-download +plan: 04 +type: execute +wave: 1 +depends_on: [] +files_modified: + - teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts + - teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx +autonomous: true +gap_closure: true +requirements: + - SIGN-07 + - LEGAL-03 + +must_haves: + truths: + - "GET /api/documents/[id]/file NEVER serves the signed PDF — it always reads doc.filePath (the unsigned original only)" + - "The Download anchor in PdfViewer is absent (not rendered) when docStatus is 'Signed'" + - "LEGAL-03 satisfied: the only download path for a signed PDF is /api/documents/[id]/download?adt=[token] (presigned, 5-min TTL)" + artifacts: + - path: "teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts" + provides: "Restricted /file route serving original PDF only" + contains: "doc.filePath" + must_not_contain: "signedFilePath" + - path: "teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx" + provides: "PdfViewer without Download anchor for Signed documents" + must_not_contain: "docStatus !== 'Signed'" + key_links: + - from: "PdfViewer.tsx" + to: "/api/documents/[id]/file" + via: "Document file prop (viewing only — not downloadable for Signed)" + note: "The viewer still LOADS the PDF via /file (original), but the Download anchor is hidden when Signed" +--- + + +Close LEGAL-03 gap: restrict the `/file` route to serve only the unsigned original PDF and remove the Download anchor from PdfViewer when a document is Signed. + +Purpose: LEGAL-03 requires "agent downloads via authenticated presigned URLs only." The `/file` route currently serves `signedFilePath ?? filePath` — serving the signed PDF via session auth alone, with no short-lived token. This creates a second download path for signed PDFs that bypasses the presigned token system built in Plans 07-01 and 07-02. + +User decision (Option A — locked): Remove the `signedFilePath` fallback from `/file`. Hide PdfViewer's Download anchor for Signed documents. The PreparePanel presigned URL is the sole signed PDF download path. + +Output: +- `route.ts` — serves `doc.filePath` only (2-line change) +- `PdfViewer.tsx` — Download anchor conditionally hidden when docStatus is 'Signed' + + + +@/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-VERIFICATION.md + + + + + +From teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts (full file, 41 lines): +```typescript +import { auth } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { documents } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +const UPLOADS_BASE = path.join(process.cwd(), 'uploads'); + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (!session) return new Response('Unauthorized', { status: 401 }); + + const { id } = await params; + + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, id), + }); + if (!doc || !doc.filePath) return new Response('Not found', { status: 404 }); + + // Serve signed PDF for completed documents, original otherwise + const relativePath = doc.signedFilePath ?? doc.filePath; // <-- LINE 25: THE GAP + const filePath = path.join(UPLOADS_BASE, relativePath); + + // Path traversal guard — critical security check + if (!filePath.startsWith(UPLOADS_BASE)) { + return new Response('Forbidden', { status: 403 }); + } + + try { + const buffer = await readFile(filePath); + return new Response(buffer, { + headers: { 'Content-Type': 'application/pdf' }, + }); + } catch { + return new Response('File not found', { status: 404 }); + } +} +``` + +From teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx (full file, 94 lines): +```tsx +'use client'; +import { useState } from 'react'; +import { Document, Page, pdfjs } from 'react-pdf'; +import 'react-pdf/dist/Page/AnnotationLayer.css'; +import 'react-pdf/dist/Page/TextLayer.css'; +import { FieldPlacer } from './FieldPlacer'; + +pdfjs.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, +).toString(); + +interface PageInfo { + originalWidth: number; + originalHeight: number; + width: number; + height: number; + scale: number; +} + +export function PdfViewer({ docId, docStatus }: { docId: string; docStatus?: string }) { + // ... + const readOnly = docStatus !== 'Draft'; + + return ( +
+
+ {/* ... Prev/Next/Zoom buttons ... */} + + Download + +
+ {/* ... Document/Page render ... */} +
+ ); +} +``` +
+ + + + + Task 1: Restrict /file route to original PDF only + teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts + +Replace line 25 (the signedFilePath fallback) with a direct use of doc.filePath. + +Current line 25: +```typescript + const relativePath = doc.signedFilePath ?? doc.filePath; +``` + +Replace with: +```typescript + // Always serve the original uploaded PDF — signed PDF is exclusively + // available via the presigned /download?adt=[token] route (LEGAL-03) + const relativePath = doc.filePath; +``` + +Also update the comment on the line above (currently "Serve signed PDF for completed documents, original otherwise") to remove the misleading description. Remove that comment entirely or replace it with: +```typescript + // Serve the original unsigned PDF only — see LEGAL-03 +``` + +No other changes to this file. The session auth check, path traversal guard, and readFile/Response logic are all correct and must remain untouched. + + + grep -n "signedFilePath" /Users/ccopeland/temp/red/teressa-copeland-homes/src/app/api/documents/\[id\]/file/route.ts; echo "exit:$?" + + + grep returns no matches (exit 1 from grep is acceptable — means the string is absent). + The file still compiles (no TypeScript errors introduced — doc.filePath is string | null per schema, same type as before). + + + + + Task 2: Hide Download anchor in PdfViewer for Signed documents + teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx + +Wrap the Download anchor (lines 60-66) in a conditional that hides it when the document status is 'Signed'. + +Current code (lines 60-66): +```tsx + + Download + +``` + +Replace with: +```tsx + {docStatus !== 'Signed' && ( + + Download + + )} +``` + +Rationale: For Signed documents, the PreparePanel already renders a Download Signed PDF anchor pointing to the presigned /download?adt=[token] URL. The viewer toolbar Download (which points to /file) is redundant and now incorrect for Signed docs since /file no longer serves the signed PDF. For Draft/Sent/Viewed documents, the toolbar Download is still useful — it serves the original PDF for reference. + +No other changes to PdfViewer.tsx. The Document file prop (`/api/documents/${docId}/file`) remains unchanged — the PDF viewer still loads the original for in-browser display regardless of status. + + + grep -n "docStatus !== 'Signed'" /Users/ccopeland/temp/red/teressa-copeland-homes/src/app/portal/\(protected\)/documents/\[docId\]/_components/PdfViewer.tsx + + + grep finds the conditional wrapping the Download anchor. + The Download anchor is present in the file but only rendered when docStatus !== 'Signed'. + The Document file prop (`/api/documents/${docId}/file`) is unchanged on line 72. + + + + + + +After both tasks complete: + +1. Confirm /file no longer contains signedFilePath: + `grep -n "signedFilePath" teressa-copeland-homes/src/app/api/documents/\[id\]/file/route.ts` — must return nothing. + +2. Confirm PdfViewer Download anchor is conditional: + `grep -n "docStatus !== 'Signed'" teressa-copeland-homes/src/app/portal/\(protected\)/documents/\[docId\]/_components/PdfViewer.tsx` — must find the conditional. + +3. TypeScript build check (optional but recommended): + `cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20` + No new errors expected — both changes use types already present in the codebase. + + + +LEGAL-03 is fully satisfied when: +1. GET /api/documents/[id]/file always returns the unsigned original PDF (doc.filePath), never the signed PDF (doc.signedFilePath) +2. A logged-in agent navigating to /api/documents/[signed-doc-id]/file receives the original unsigned PDF, not the signed one +3. The PDF viewer toolbar shows no Download button when docStatus is 'Signed' +4. The PreparePanel Download Signed PDF anchor (presigned, 5-min TTL) remains the only way to download a signed PDF +5. SIGN-07 is unaffected — agent can still download signed PDFs via the PreparePanel presigned URL + + + +After completion, create `.planning/phases/07-audit-trail-and-download/07-04-SUMMARY.md` following the summary template. +