261 lines
10 KiB
Markdown
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>
|