Files
red/.planning/phases/15-multi-signer-backend/15-01-PLAN.md

14 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
15-multi-signer-backend 01 execute 1
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
true
MSIGN-10
MSIGN-11
truths artifacts key_links
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
path provides exports
teressa-copeland-homes/src/lib/signing/token.ts Extended createSigningToken + signer-download token pair
createSigningToken
createSignerDownloadToken
verifySignerDownloadToken
path provides exports
teressa-copeland-homes/src/lib/signing/signing-mailer.tsx Signer completion email
sendSignerCompletionEmail
path provides exports
teressa-copeland-homes/src/app/api/sign/download/[token]/route.ts Public signer download route
GET
from to via pattern
src/app/api/sign/download/[token]/route.ts src/lib/signing/token.ts verifySignerDownloadToken import verifySignerDownloadToken
from to via pattern
src/app/api/sign/download/[token]/route.ts documents table db.query.documents.findFirst 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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:

// 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<string>;
export async function verifyAgentDownloadToken(token: string): Promise<{ documentId: string }>;

From src/lib/signing/signing-mailer.tsx:

export async function sendSigningRequestEmail(opts: { to: string; clientName?: string; documentName: string; signingUrl: string; expiresAt: Date }): Promise<void>;
export async function sendAgentNotificationEmail(opts: { clientName: string; documentName: string; signedAt: Date }): Promise<void>;

From src/lib/db/schema.ts:

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<string> {
  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<void> {
  const transporter = createTransporter();
  await transporter.sendMail({
    from: '"Teressa Copeland Homes" <teressa@tcopelandhomes.com>',
    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

<success_criteria>

  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 </success_criteria>
After completion, create `.planning/phases/15-multi-signer-backend/15-01-SUMMARY.md`