docs(07-audit-trail-and-download): create phase 7 plan
3 plans in 3 sequential waves: agent download token + API route (01), UI wiring for download button + signedAt column (02), human verification checkpoint (03). Covers SIGN-07 and LEGAL-03. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
284
.planning/phases/07-audit-trail-and-download/07-01-PLAN.md
Normal file
284
.planning/phases/07-audit-trail-and-download/07-01-PLAN.md
Normal file
@@ -0,0 +1,284 @@
|
||||
---
|
||||
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\\)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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.
|
||||
</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-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing token.ts exports — extend this file, do not replace -->
|
||||
<!-- Source: teressa-copeland-homes/src/lib/signing/token.ts -->
|
||||
|
||||
```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<string>
|
||||
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
|
||||
```
|
||||
|
||||
<!-- Existing client download route — reference implementation to mirror -->
|
||||
<!-- Source: teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts -->
|
||||
|
||||
```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=...' })
|
||||
}
|
||||
```
|
||||
|
||||
<!-- documents table columns relevant to download -->
|
||||
<!-- Source: teressa-copeland-homes/src/lib/db/schema.ts lines 49-62 -->
|
||||
|
||||
```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
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add agent download token functions to token.ts</name>
|
||||
<files>teressa-copeland-homes/src/lib/signing/token.ts</files>
|
||||
<action>
|
||||
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<string> {
|
||||
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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
<done>token.ts exports createAgentDownloadToken and verifyAgentDownloadToken; tsc --noEmit passes; existing exports are untouched</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create GET /api/documents/[id]/download route</name>
|
||||
<files>teressa-copeland-homes/src/app/api/documents/[id]/download/route.ts</files>
|
||||
<action>
|
||||
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
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20 && npm run build 2>&1 | tail -10</automated>
|
||||
</verify>
|
||||
<done>
|
||||
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
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/07-audit-trail-and-download/07-01-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user