Files
red/.planning/phases/07-audit-trail-and-download/07-04-PLAN.md

261 lines
10 KiB
Markdown

---
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"
---
<objective>
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'
</objective>
<execution_context>
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-audit-trail-and-download/07-VERIFICATION.md
</context>
<interfaces>
<!-- Current state of the two files being modified. Executor uses these directly — no codebase exploration needed. -->
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 (
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-3 text-sm">
{/* ... Prev/Next/Zoom buttons ... */}
<a // <-- LINES 60-66: THE GAP
href={`/api/documents/${docId}/file`}
download
className="px-3 py-1 border rounded hover:bg-gray-100"
>
Download
</a>
</div>
{/* ... Document/Page render ... */}
</div>
);
}
```
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Restrict /file route to original PDF only</name>
<files>teressa-copeland-homes/src/app/api/documents/[id]/file/route.ts</files>
<action>
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.
</action>
<verify>
<automated>grep -n "signedFilePath" /Users/ccopeland/temp/red/teressa-copeland-homes/src/app/api/documents/\[id\]/file/route.ts; echo "exit:$?"</automated>
</verify>
<done>
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).
</done>
</task>
<task type="auto">
<name>Task 2: Hide Download anchor in PdfViewer for Signed documents</name>
<files>teressa-copeland-homes/src/app/portal/(protected)/documents/[docId]/_components/PdfViewer.tsx</files>
<action>
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
<a
href={`/api/documents/${docId}/file`}
download
className="px-3 py-1 border rounded hover:bg-gray-100"
>
Download
</a>
```
Replace with:
```tsx
{docStatus !== 'Signed' && (
<a
href={`/api/documents/${docId}/file`}
download
className="px-3 py-1 border rounded hover:bg-gray-100"
>
Download
</a>
)}
```
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.
</action>
<verify>
<automated>grep -n "docStatus !== 'Signed'" /Users/ccopeland/temp/red/teressa-copeland-homes/src/app/portal/\(protected\)/documents/\[docId\]/_components/PdfViewer.tsx</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<verification>
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.
</verification>
<success_criteria>
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
</success_criteria>
<output>
After completion, create `.planning/phases/07-audit-trail-and-download/07-04-SUMMARY.md` following the summary template.
</output>