---
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