13 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 | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06-signing-flow | 05 | execute | 4 |
|
|
true |
|
|
Purpose: SIGN-06 — client must see confirmation after signing. The confirmation page is the final touch in the signing ceremony UX (locked decision: success checkmark, document name, timestamp, download button). Output: Confirmation page, already-signed download link, and /api/sign/[token]/download route for secure client PDF access.
<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/phases/06-signing-flow/06-CONTEXT.md @.planning/phases/06-signing-flow/06-RESEARCH.md @.planning/phases/06-signing-flow/06-04-SUMMARY.md From teressa-copeland-homes/src/lib/signing/token.ts: ```typescript 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 }> ```Note: For the download token, use the same createSigningToken pattern but with a different purpose. The research recommends a second short-lived JWT with purpose: 'download' claim and 15-min TTL. Extend token.ts to add:
export async function createDownloadToken(documentId: string): Promise<string>
// SignJWT with { documentId, purpose: 'download' }, setExpirationTime('15m'), no DB record needed
export async function verifyDownloadToken(token: string): Promise<{ documentId: string }>
// jwtVerify + check payload.purpose === 'download'
From teressa-copeland-homes/src/lib/db/schema.ts:
// documents table — added in Plan 06-01:
// signedFilePath: text — absolute path to signed PDF
// signedAt: timestamp
// pdfHash: text
//
// signingTokens table:
// usedAt: timestamp — set when signing was completed
From Plan 06-04: POST /api/sign/[token] returns { ok: true } on success. SigningPageClient.tsx submits via fetch POST and receives this response — needs router.push to /sign/[token]/confirmed after ok:true.
Task 1: Download token utilities + download API route teressa-copeland-homes/src/lib/signing/token.ts teressa-copeland-homes/src/app/api/sign/[token]/download/route.ts **Extend src/lib/signing/token.ts** — add two new exports for the client download token.Append to the end of the existing token.ts file:
// Short-lived download token for client copy download (15-min TTL, no DB record)
// purpose: 'download' claim distinguishes from signing tokens
export async function createDownloadToken(documentId: string): Promise<string> {
return await new SignJWT({ documentId, purpose: 'download' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(getSecret());
}
export async function verifyDownloadToken(token: string): Promise<{ documentId: string }> {
const { payload } = await jwtVerify(token, getSecret());
if (payload['purpose'] !== 'download') throw new Error('Not a download token');
return { documentId: payload['documentId'] as string };
}
Create src/app/api/sign/[token]/download/route.ts — GET handler:
The [token] in this path is the SIGNING token (used to identify the document via context). The actual download authorization uses a dt query parameter (the short-lived download token).
Logic:
- Resolve signing token from params, verify it (allows expired JWT here — signed docs should remain downloadable briefly after link expiry)
- Actually: do NOT use the signing token for auth here. Instead, use ONLY the
dtquery param for authorization.
- Actually: do NOT use the signing token for auth here. Instead, use ONLY the
- Get
dtfrom URL search params:const url = new URL(req.url); const dt = url.searchParams.get('dt') - If no
dt, return 401 - Call
verifyDownloadToken(dt)— if throws, return 401 "Download link expired or invalid" - Fetch document by
documentIdfrom the verified download token payload - Guard: if
doc.signedFilePathis null, return 404 "Signed PDF not found" - Path traversal guard: ensure
doc.signedFilePathstarts with expected uploads/ prefix (same guard pattern used in Phase 4) - Read file:
const fileBuffer = await readFile(doc.signedFilePath) - Return as streaming PDF response:
return new Response(fileBuffer, { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="${doc.name.replace(/[^a-zA-Z0-9-_ ]/g, '')}_signed.pdf"`, }, });
No agent auth required — download token is the authorization mechanism. cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "sign/[token]/download" | head -5 createDownloadToken and verifyDownloadToken exported from token.ts; download/route.ts exists; build passes
Task 2: Confirmation page + redirect from signing client teressa-copeland-homes/src/app/sign/[token]/confirmed/page.tsx teressa-copeland-homes/src/app/sign/[token]/_components/SigningPageClient.tsx **Create src/app/sign/[token]/confirmed/page.tsx** — server component showing signing confirmation (locked UX decisions from CONTEXT.md):- Success checkmark
- "You've signed [Document Name]"
- Timestamp of signing
- "Download your copy" button (generates a download token, passes as
dtquery param) - Clean thank-you only — no agent contact info on the page (locked decision)
import { verifySigningToken } from '@/lib/signing/token';
import { createDownloadToken } from '@/lib/signing/token';
import { db } from '@/lib/db';
import { signingTokens, documents } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
interface Props {
params: Promise<{ token: string }>;
}
export default async function ConfirmedPage({ params }: Props) {
const { token } = await params;
// Verify signing token to get documentId (allow expired — confirmation page visited after signing)
let documentId: string | null = null;
let jti: string | null = null;
try {
const payload = await verifySigningToken(token);
documentId = payload.documentId;
jti = payload.jti;
} catch {
// Token expired is OK here — signing may have happened right before expiry
// Try to look up by jti if token body is still parseable
}
if (!documentId) {
return <div style={{ padding: '40px', textAlign: 'center', fontFamily: 'Georgia, serif', color: '#1B2B4B' }}>Document not found.</div>;
}
const doc = await db.query.documents.findFirst({
where: eq(documents.id, documentId),
});
// Get signed timestamp from signingTokens.usedAt
let signedAt: Date | null = null;
if (jti) {
const tokenRow = await db.query.signingTokens.findFirst({ where: eq(signingTokens.jti, jti) });
signedAt = tokenRow?.usedAt ?? doc?.signedAt ?? null;
}
if (!doc || !doc.signedFilePath) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: '#FAF9F7', fontFamily: 'Georgia, serif' }}>
<div style={{ textAlign: 'center', padding: '40px' }}>
<h1 style={{ color: '#1B2B4B' }}>Document not yet available</h1>
<p style={{ color: '#555' }}>Please check back shortly.</p>
</div>
</div>
);
}
// Generate 15-minute download token for client copy
const downloadToken = await createDownloadToken(doc.id);
const downloadUrl = `/api/sign/${token}/download?dt=${downloadToken}`;
const formattedDate = signedAt
? signedAt.toLocaleString('en-US', { month: 'long', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
: 'Just now';
return (
<div style={{ minHeight: '100vh', backgroundColor: '#FAF9F7', fontFamily: 'Georgia, serif', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', maxWidth: '480px', padding: '48px 32px' }}>
{/* Success checkmark */}
<div style={{ width: '72px', height: '72px', borderRadius: '50%', backgroundColor: '#1B2B4B', color: '#C9A84C', fontSize: '36px', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 24px' }}>
✓
</div>
<h1 style={{ color: '#1B2B4B', fontSize: '26px', marginBottom: '8px' }}>
You've signed
</h1>
<p style={{ color: '#1B2B4B', fontSize: '20px', fontWeight: 'bold', marginBottom: '8px' }}>
{doc.name}
</p>
<p style={{ color: '#888', fontSize: '14px', marginBottom: '32px' }}>
Signed on {formattedDate}
</p>
<a
href={downloadUrl}
style={{
display: 'inline-block',
backgroundColor: '#C9A84C',
color: '#fff',
padding: '12px 28px',
borderRadius: '4px',
textDecoration: 'none',
fontWeight: 'bold',
fontSize: '15px',
}}
>
Download your copy
</a>
<p style={{ color: '#aaa', fontSize: '12px', marginTop: '16px' }}>
Download link valid for 15 minutes.
</p>
</div>
</div>
);
}
Update SigningPageClient.tsx — add navigation to confirmed page after successful submission.
Find the handleSubmit function (or where the POST response is handled). After receiving { ok: true }:
import { useRouter } from 'next/navigation';
// ...
const router = useRouter();
// After POST returns ok:true:
router.push(`/sign/${token}/confirmed`);
The token prop is already passed from page.tsx to SigningPageClient. Use it for the redirect URL.
cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | grep -E "confirmed|error|Error" | grep -v "^$" | head -10
confirmed/page.tsx exists; SigningPageClient.tsx uses router.push to confirmed after successful submit; download route streams PDF; npm run build passes
<success_criteria> Post-signing flow complete when: confirmed page renders success checkmark + document name + signed timestamp + download button, download route streams the signedFilePath PDF using a 15-min token, SigningPageClient navigates to confirmed after successful POST, and npm run build passes. </success_criteria>
After completion, create `.planning/phases/06-signing-flow/06-05-SUMMARY.md`