docs(07-audit-trail-and-download): create gap closure plan 07-04 for LEGAL-03
This commit is contained in:
260
.planning/phases/07-audit-trail-and-download/07-04-PLAN.md
Normal file
260
.planning/phases/07-audit-trail-and-download/07-04-PLAN.md
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user