--- phase: 15-multi-signer-backend plan: 01 type: execute wave: 1 depends_on: [] files_modified: - teressa-copeland-homes/src/lib/signing/token.ts - teressa-copeland-homes/src/lib/signing/signing-mailer.tsx - teressa-copeland-homes/src/app/api/sign/download/[token]/route.ts autonomous: true requirements: [MSIGN-10, MSIGN-11] must_haves: truths: - "createSigningToken accepts optional signerEmail and writes it to DB" - "createSignerDownloadToken produces a 72h JWT with purpose signer-download" - "verifySignerDownloadToken validates purpose claim and returns documentId" - "sendSignerCompletionEmail sends a plain-text email with download link" - "GET /api/sign/download/[token] serves the signed PDF for valid signer-download tokens" - "GET /api/sign/download/[token] rejects expired tokens, non-Signed documents, and path traversal" artifacts: - path: "teressa-copeland-homes/src/lib/signing/token.ts" provides: "Extended createSigningToken + signer-download token pair" exports: ["createSigningToken", "createSignerDownloadToken", "verifySignerDownloadToken"] - path: "teressa-copeland-homes/src/lib/signing/signing-mailer.tsx" provides: "Signer completion email" exports: ["sendSignerCompletionEmail"] - path: "teressa-copeland-homes/src/app/api/sign/download/[token]/route.ts" provides: "Public signer download route" exports: ["GET"] key_links: - from: "src/app/api/sign/download/[token]/route.ts" to: "src/lib/signing/token.ts" via: "verifySignerDownloadToken import" pattern: "verifySignerDownloadToken" - from: "src/app/api/sign/download/[token]/route.ts" to: "documents table" via: "db.query.documents.findFirst" pattern: "doc\\.status.*Signed" --- Create all utility building blocks for multi-signer: extend createSigningToken with optional signerEmail, add signer-download JWT token pair, add sendSignerCompletionEmail mailer function, and create the public signer download route. Purpose: Plans 02 and 03 depend on these utilities being in place before the send route and sign handlers can be rewritten. Output: Extended token.ts, extended signing-mailer.tsx, new download route file. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/15-multi-signer-backend/15-CONTEXT.md @.planning/phases/15-multi-signer-backend/15-RESEARCH.md @teressa-copeland-homes/src/lib/signing/token.ts @teressa-copeland-homes/src/lib/signing/signing-mailer.tsx @teressa-copeland-homes/src/lib/db/schema.ts From src/lib/signing/token.ts: ```typescript // Existing — extend, do not break 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 }>; export async function createAgentDownloadToken(documentId: string): Promise; export async function verifyAgentDownloadToken(token: string): Promise<{ documentId: string }>; ``` From src/lib/signing/signing-mailer.tsx: ```typescript export async function sendSigningRequestEmail(opts: { to: string; clientName?: string; documentName: string; signingUrl: string; expiresAt: Date }): Promise; export async function sendAgentNotificationEmail(opts: { clientName: string; documentName: string; signedAt: Date }): Promise; ``` From src/lib/db/schema.ts: ```typescript export interface SignatureFieldData { id: string; page: number; x: number; y: number; width: number; height: number; type?: SignatureFieldType; signerEmail?: string; } export interface DocumentSigner { email: string; color: string; } // signingTokens table has: jti, documentId, signerEmail (nullable TEXT), createdAt, expiresAt, usedAt ``` Task 1: Extend token.ts with signerEmail param and signer-download token pair teressa-copeland-homes/src/lib/signing/token.ts - teressa-copeland-homes/src/lib/signing/token.ts - teressa-copeland-homes/src/lib/db/schema.ts - teressa-copeland-homes/node_modules/next/dist/docs/ (scan for route handler guidance) Modify `createSigningToken` to accept an optional second parameter `signerEmail?: string` (per D-03). Add `signerEmail: signerEmail ?? null` to the `db.insert(signingTokens).values({...})` call. The JWT payload is unchanged — signerEmail is stored in DB only, not in the JWT. All existing call sites (`createSigningToken(doc.id)`) continue to compile because the param is optional. Add two new functions after the existing `verifyAgentDownloadToken`: ```typescript export async function createSignerDownloadToken(documentId: string): Promise { return await new SignJWT({ documentId, purpose: 'signer-download' }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('72h') .sign(getSecret()); } export async function verifySignerDownloadToken(token: string): Promise<{ documentId: string }> { const { payload } = await jwtVerify(token, getSecret()); if (payload['purpose'] !== 'signer-download') throw new Error('Not a signer download token'); return { documentId: payload['documentId'] as string }; } ``` The signer-download token has 72h TTL (longer than agent-download 5m) because signers may not open the completion email immediately. No DB record — same as agent-download tokens. Purpose claim `'signer-download'` distinguishes from all other token types. cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30 - grep confirms `signerEmail?: string` in createSigningToken signature: `grep -n "signerEmail.*string" src/lib/signing/token.ts` - grep confirms `signerEmail: signerEmail ?? null` in the insert: `grep -n "signerEmail:" src/lib/signing/token.ts` - grep confirms `createSignerDownloadToken` export: `grep -n "export async function createSignerDownloadToken" src/lib/signing/token.ts` - grep confirms `verifySignerDownloadToken` export: `grep -n "export async function verifySignerDownloadToken" src/lib/signing/token.ts` - grep confirms `purpose: 'signer-download'` in both functions: `grep -c "signer-download" src/lib/signing/token.ts` returns 2 - grep confirms `setExpirationTime('72h')` on signer-download token: `grep -A2 "createSignerDownloadToken" src/lib/signing/token.ts | grep "72h"` - `npx tsc --noEmit` passes with zero errors createSigningToken accepts optional signerEmail and persists it. createSignerDownloadToken/verifySignerDownloadToken produce and validate 72h JWTs with purpose signer-download. All existing call sites compile unchanged. Task 2: Add sendSignerCompletionEmail to signing-mailer.tsx teressa-copeland-homes/src/lib/signing/signing-mailer.tsx - teressa-copeland-homes/src/lib/signing/signing-mailer.tsx Add `sendSignerCompletionEmail` function after `sendAgentNotificationEmail`. Follow the exact same `createTransporter()` + `transporter.sendMail()` pattern (per D-12). Plain-text email — no React Email template needed for this. ```typescript export async function sendSignerCompletionEmail(opts: { to: string; documentName: string; downloadUrl: string; }): Promise { const transporter = createTransporter(); await transporter.sendMail({ from: '"Teressa Copeland Homes" ', to: opts.to, subject: `Signed copy ready: ${opts.documentName}`, text: [ `All parties have signed "${opts.documentName}".`, '', 'Download your signed copy using the link below (expires in 72 hours):', opts.downloadUrl, ].join('\n'), }); } ``` The function accepts `to`, `documentName`, and `downloadUrl`. No `expiresAt` param — the 72h expiry is baked into the text. This keeps the interface simple; the caller constructs the URL from `createSignerDownloadToken`. cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30 - grep confirms export: `grep -n "export async function sendSignerCompletionEmail" src/lib/signing/signing-mailer.tsx` - grep confirms opts shape: `grep -A4 "sendSignerCompletionEmail" src/lib/signing/signing-mailer.tsx | grep "downloadUrl"` - grep confirms subject line: `grep "Signed copy ready" src/lib/signing/signing-mailer.tsx` - `npx tsc --noEmit` passes with zero errors sendSignerCompletionEmail is exported from signing-mailer.tsx and sends a plain-text email with the signed document download link to a signer. Task 3: Create public signer download route GET /api/sign/download/[token] teressa-copeland-homes/src/app/api/sign/download/[token]/route.ts - teressa-copeland-homes/src/lib/signing/token.ts (after Task 1 modifications) - teressa-copeland-homes/src/app/api/sign/[token]/route.ts (existing pattern for path traversal guard) - teressa-copeland-homes/node_modules/next/dist/docs/ (scan for route handler API) Create a new route file at `src/app/api/sign/download/[token]/route.ts` (per D-11). This is a public route — no auth session required. The route: 1. Extracts `token` from dynamic path params (App Router async params pattern: `{ params }: { params: Promise<{ token: string }> }`) 2. Calls `verifySignerDownloadToken(token)` — returns `{ documentId }` or throws 3. On verification failure: returns `new Response('Invalid or expired download link', { status: 401 })` 4. Queries `db.query.documents.findFirst` for `signedFilePath`, `status`, `name` where `id = documentId` 5. Guards: `!doc || doc.status !== 'Signed' || !doc.signedFilePath` returns 404 "Document not yet complete" (Pitfall 6 — signedFilePath alone is not a completion signal) 6. Builds absolute path: `path.join(UPLOADS_DIR, doc.signedFilePath)` 7. Path traversal guard: `!absPath.startsWith(UPLOADS_DIR)` returns 403 8. Reads file with `readFile(absPath)` 9. Returns `new Response(fileBytes, { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': 'attachment; filename="DOC_NAME-signed.pdf"' } })` Full implementation: ```typescript import { NextRequest } from 'next/server'; import { verifySignerDownloadToken } 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'); export async function GET( _req: NextRequest, { params }: { params: Promise<{ token: string }> } ) { const { token } = await params; let documentId: string; try { ({ documentId } = await verifySignerDownloadToken(token)); } catch { return new Response('Invalid or expired download link', { status: 401 }); } const doc = await db.query.documents.findFirst({ where: eq(documents.id, documentId), columns: { signedFilePath: true, status: true, name: true }, }); if (!doc || doc.status !== 'Signed' || !doc.signedFilePath) { return new Response('Document not yet complete', { status: 404 }); } const absPath = path.join(UPLOADS_DIR, doc.signedFilePath); if (!absPath.startsWith(UPLOADS_DIR)) { return new Response('Forbidden', { status: 403 }); } const fileBytes = await readFile(absPath); return new Response(fileBytes, { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="${doc.name}-signed.pdf"`, }, }); } ``` cd teressa-copeland-homes && npx tsc --noEmit 2>&1 | head -30 - File exists: `test -f src/app/api/sign/download/\[token\]/route.ts && echo exists` - grep confirms verifySignerDownloadToken import: `grep "verifySignerDownloadToken" src/app/api/sign/download/\[token\]/route.ts` - grep confirms status check: `grep "doc.status.*Signed" src/app/api/sign/download/\[token\]/route.ts` - grep confirms path traversal guard: `grep "startsWith(UPLOADS_DIR)" src/app/api/sign/download/\[token\]/route.ts` - grep confirms Content-Disposition: `grep "Content-Disposition" src/app/api/sign/download/\[token\]/route.ts` - grep confirms no auth import (public route): `grep -c "import.*auth" src/app/api/sign/download/\[token\]/route.ts` returns 0 - `npx tsc --noEmit` passes with zero errors Public signer download route serves the signed PDF for valid signer-download tokens. Rejects expired/invalid tokens (401), incomplete documents (404), and path traversal (403). No auth session required. All three files compile: `cd teressa-copeland-homes && npx tsc --noEmit` Token utility has 3 token types: signing, agent-download, signer-download (grep for 'purpose' in token.ts yields 3+ distinct strings) Mailer has 3 email functions: sendSigningRequestEmail, sendAgentNotificationEmail, sendSignerCompletionEmail Download route exists at the correct App Router path and imports from token.ts 1. `createSigningToken(docId, 'alice@example.com')` compiles and persists signerEmail to DB 2. `createSigningToken(docId)` still compiles (backward compatible — no signerEmail written) 3. `createSignerDownloadToken(docId)` returns a JWT string; `verifySignerDownloadToken(jwt)` returns `{ documentId }` 4. `sendSignerCompletionEmail({ to, documentName, downloadUrl })` compiles and follows established mailer pattern 5. `GET /api/sign/download/[token]` route file exists, compiles, and guards against all failure modes 6. `npx tsc --noEmit` passes with zero errors across the entire project After completion, create `.planning/phases/15-multi-signer-backend/15-01-SUMMARY.md`