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>
11 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07-audit-trail-and-download | 01 | execute | 1 |
|
true |
|
|
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.
<execution_context> @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/07-audit-trail-and-download/07-RESEARCH.mdimport { 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
// 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=...' })
}
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
Add:
// 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. cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20 token.ts exports createAgentDownloadToken and verifyAgentDownloadToken; tsc --noEmit passes; existing exports are untouched
Task 2: Create GET /api/documents/[id]/download route teressa-copeland-homes/src/app/api/documents/[id]/download/route.ts 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:
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)notfileBufferdirectly — 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=== routeidcross-check — prevents token for doc A downloading doc B cd /Users/ccopeland/temp/red/teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -20 && npm run build 2>&1 | tail -10 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
<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>