docs(06): research signing flow phase
This commit is contained in:
727
.planning/phases/06-signing-flow/06-RESEARCH.md
Normal file
727
.planning/phases/06-signing-flow/06-RESEARCH.md
Normal file
@@ -0,0 +1,727 @@
|
||||
# Phase 6: Signing Flow - Research
|
||||
|
||||
**Researched:** 2026-03-20
|
||||
**Domain:** Electronic signing ceremony — JWT one-time tokens, canvas signature capture, PDF signature embedding, email delivery, audit trail, DNS compliance
|
||||
**Confidence:** HIGH
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
**Signing page layout:**
|
||||
- Full document scroll — client scrolls through the entire PDF, reading before signing
|
||||
- Signature fields use a glowing/pulsing blue outline to draw attention
|
||||
- Sticky progress bar at top or bottom: "1 of 2 signatures complete" with jump-to-next navigation
|
||||
- Page header shows: "Teressa Copeland Homes" branding + document title + short instruction ("Please review and sign the document below.")
|
||||
|
||||
**Signature canvas UX:**
|
||||
- Canvas appears in a modal overlay (not inline) — keeps drawing interaction focused, works well on mobile
|
||||
- Modal has three tabs: **Draw** | **Type** | **Use Saved**
|
||||
- Draw: freehand canvas
|
||||
- Type: client types their name, rendered in a cursive font
|
||||
- Use Saved: shows saved signature preview with "Apply" button (tab only appears if a saved sig exists)
|
||||
- Clear/Redo available within the modal before confirming
|
||||
- After all fields are signed, an explicit **"Submit Signature"** button becomes active (or appears) in the sticky bar — one intentional final action
|
||||
|
||||
**Email design:**
|
||||
- Branded HTML email (not plain text)
|
||||
- Sender: `"Teressa Copeland" <teressa@teressacopelandhomes.com>`
|
||||
- Body content: document name, expiry deadline, simple instruction ("No account needed — just click the button below"), prominent "Review & Sign" CTA button
|
||||
- Agent notification: Teressa receives an email when a client completes signing (client name, document name, timestamp) — no notification on link open
|
||||
|
||||
**Post-signing experience:**
|
||||
- Full confirmation page after submit: success checkmark, "You've signed [Document Name]", timestamp, download button for a copy of the signed PDF
|
||||
- Clean thank-you only — no agent contact info on the page
|
||||
- If a client revisits an already-used signing link: "Already signed" page showing signed date/time + download link for their copy
|
||||
- Expired links show a distinct "Link expired" message (separate from already-signed)
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact cursive font for typed signatures
|
||||
- Confirmation page visual design/layout details
|
||||
- How the one-time download link for the client copy is generated (short-lived token or inline with the confirmation session)
|
||||
- Mobile responsiveness specifics for the signing page PDF viewer
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- None — discussion stayed within phase scope
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|-----------------|
|
||||
| SIGN-01 | Client receives an email with a unique link to sign the document (no account required) | Nodemailer + `@react-email/components` HTML templates; `jose` SignJWT for token in link URL |
|
||||
| SIGN-02 | Signing link expires after 72 hours and can only be used once | `jose` JWT `expirationTime("72h")` + `signingTokens` table with `usedAt` timestamp column; enforce one-time use with DB atomic check |
|
||||
| SIGN-03 | Client opens the link in any browser and sees the prepared PDF with signature fields highlighted | Public `/sign/[token]` route (excluded from middleware matcher); react-pdf viewer + signatureFields overlay with CSS animation |
|
||||
| SIGN-04 | Client can draw a freehand signature on a canvas (works on mobile and desktop) | `signature_pad` 5.1.x + `touch-action: none` CSS + devicePixelRatio scaling |
|
||||
| SIGN-05 | Client can save a default signature and click a signature field to apply it without redrawing | localStorage or session state for saved signature DataURL; "Use Saved" modal tab |
|
||||
| SIGN-06 | Client sees a confirmation screen after successfully signing | Server redirect to `/sign/[token]/confirmed` page after successful signature embedding |
|
||||
| LEGAL-01 | System logs all 6 server-side audit trail events with server-side timestamps | `auditEvents` table with enum event types; logged in route handlers before returning response |
|
||||
| LEGAL-02 | SHA-256 hash of final signed PDF computed and stored immediately after signature embedding | Node.js built-in `crypto.createHash('sha256')` streaming from file after `@cantoo/pdf-lib` save |
|
||||
| LEGAL-04 | DNS (SPF/DKIM/DMARC) configured for teressacopelandhomes.com before first signing link | DNS TXT records: SPF `v=spf1 include:...`, DKIM key pair via SMTP provider, DMARC `p=none` initially |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 6 delivers the complete signing ceremony as a public-facing vertical slice: a unique emailed link opens a read-only signing page (no account required), the client draws or types a signature in a modal canvas, and the signed PDF is committed to disk with a tamper-evident SHA-256 hash and a six-event server-side audit trail.
|
||||
|
||||
The core technical challenges are: (1) enforcing one-time token use reliably — JWT expiry alone is insufficient because JWTs are stateless; the database must record `usedAt` and the token exchange must be atomic; (2) accurate canvas-to-PDF signature embedding — the signature PNG from `signature_pad` must be placed at the exact coordinates stored in `signatureFields` (already in PDF user space from Phase 5); (3) mobile canvas drawing — iOS Safari requires `touch-action: none` on the canvas element and a `devicePixelRatio` scale correction or signatures appear blurry; (4) DNS authentication — SPF/DKIM/DMARC records must be published before any signing link is sent to a real client or email will be filtered as spam.
|
||||
|
||||
The project already has `jose` 6.2.2 installed (used by next-auth internally), `@cantoo/pdf-lib` installed and proven in Phase 5, `nodemailer` installed for email, and the `documents` table with `preparedFilePath` and `signatureFields` columns. Phase 6 adds three new concerns to the schema: a `signingTokens` table, an `auditEvents` table, and a `pdfHash` column on `documents`.
|
||||
|
||||
**Primary recommendation:** Use `signature_pad` 5.1.x (raw, not a React wrapper) mounted in a `useEffect` in a client component modal, export PNG via `toDataURL()`, embed with `@cantoo/pdf-lib` `embedPng()` + `drawImage()` at the exact `signatureFields` coordinates, then hash the output with Node.js `crypto`. Issue tokens with `jose` `SignJWT` but enforce one-time use via a `signingTokens` DB table with `usedAt`.
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `signature_pad` | 5.1.x | Freehand canvas drawing, PNG export | Industry standard for HTML5 canvas signatures; pointer-events API; no dependencies; works on iOS Safari with `touch-action: none` |
|
||||
| `jose` | 6.2.2 (already installed) | Sign and verify JWT tokens for signing links | Already in project (next-auth peer dep); zero dependencies; works in Edge runtime; `SignJWT` + `jwtVerify` API |
|
||||
| `@cantoo/pdf-lib` | 2.6.x (already installed) | Embed PNG signature image into prepared PDF | Already proven in Phase 5; `embedPng(dataURL)` accepts base64 DataURL directly; `drawImage()` at exact coordinates |
|
||||
| `nodemailer` | 7.0.x (already installed) | Send branded HTML signing email + agent notification | Already used in Phase 2 contact form; SMTP transport configured |
|
||||
| `@react-email/render` | 2.0.4 | Render React components to email-safe HTML | Modern approach over raw HTML strings; composable; inline CSS handled automatically |
|
||||
| Node.js `crypto` | built-in | SHA-256 hash of final signed PDF | No install needed; `createHash('sha256')` with file stream |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `@react-email/components` | latest | Pre-built email-safe UI primitives (Html, Button, Text, Hr) | Use alongside `@react-email/render` for email templates |
|
||||
| `react-pdf` | 10.4.x (already installed) | Render prepared PDF on the client signing page | Already proven in Phase 4 viewer; reuse `PdfViewer` component |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| `signature_pad` raw | `react-signature-canvas` (React wrapper) | react-signature-canvas wraps signature_pad but adds abstraction; using raw gives direct ref access to the canvas for devicePixelRatio scaling — prefer raw |
|
||||
| `@react-email` | Raw HTML string templates | React Email gives component reuse and type safety; raw HTML is simpler but prone to escaping bugs |
|
||||
| `jose` JWT | `crypto.randomBytes` opaque tokens | Random tokens require storing the full token in DB; JWTs embed document ID and expiry without a lookup, but DB usedAt flag is still required for one-time enforcement |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
npm install signature_pad @react-email/render @react-email/components
|
||||
```
|
||||
(jose, @cantoo/pdf-lib, nodemailer, react-pdf are already installed)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── sign/
|
||||
│ │ └── [token]/
|
||||
│ │ ├── page.tsx # Public signing page (server component — validates token)
|
||||
│ │ ├── confirmed/
|
||||
│ │ │ └── page.tsx # Confirmation page after signing
|
||||
│ │ └── _components/
|
||||
│ │ ├── SigningPageClient.tsx # Client component — PDF viewer + signature modal
|
||||
│ │ ├── SignatureModal.tsx # Draw/Type/Use Saved tabs
|
||||
│ │ └── SigningProgressBar.tsx # Sticky "1 of 2 signatures" bar
|
||||
│ └── api/
|
||||
│ └── sign/
|
||||
│ ├── [token]/
|
||||
│ │ ├── route.ts # GET: validate token + return doc data; POST: submit signature
|
||||
│ │ └── download/
|
||||
│ │ └── route.ts # GET: short-lived client copy download (Phase 6 scope)
|
||||
├── lib/
|
||||
│ ├── signing/
|
||||
│ │ ├── token.ts # createSigningToken(), verifySigningToken() using jose
|
||||
│ │ ├── embed-signature.ts # embedSignatureInPdf() — @cantoo/pdf-lib + canvas PNG
|
||||
│ │ ├── audit.ts # logAuditEvent() — writes to auditEvents table
|
||||
│ │ └── signing-mailer.ts # sendSigningRequestEmail(), sendAgentNotificationEmail()
|
||||
│ └── db/
|
||||
│ └── schema.ts # +signingTokens table, +auditEvents table, +pdfHash column
|
||||
```
|
||||
|
||||
### Pattern 1: JWT + DB usedAt One-Time Token Enforcement
|
||||
|
||||
**What:** Issue a signed JWT containing `documentId` and embedded expiry. Store token metadata in `signingTokens` table. On use, atomically update `usedAt` and reject if already set.
|
||||
|
||||
**When to use:** Any one-time link where stateless JWT expiry alone is insufficient.
|
||||
|
||||
**Why two layers:** JWT expiry handles time-based expiry without a DB lookup. `usedAt` in DB enforces one-time use (JWTs cannot be invalidated server-side once issued).
|
||||
|
||||
```typescript
|
||||
// lib/signing/token.ts
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
|
||||
const secret = new TextEncoder().encode(process.env.SIGNING_JWT_SECRET!);
|
||||
|
||||
export async function createSigningToken(documentId: string): Promise<string> {
|
||||
return await new SignJWT({ documentId, purpose: 'sign' })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('72h')
|
||||
.setJti(crypto.randomUUID()) // unique token ID stored in signingTokens table
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
export async function verifySigningToken(token: string) {
|
||||
// Returns payload or throws — caller handles expired/invalid cases
|
||||
const { payload } = await jwtVerify(token, secret);
|
||||
return payload as { documentId: string; jti: string; exp: number };
|
||||
}
|
||||
```
|
||||
|
||||
**DB schema additions:**
|
||||
```typescript
|
||||
// signingTokens table — tracks one-time use
|
||||
export const signingTokens = pgTable('signing_tokens', {
|
||||
jti: text('jti').primaryKey(), // JWT ID (crypto.randomUUID())
|
||||
documentId: text('document_id').notNull()
|
||||
.references(() => documents.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at').notNull(),
|
||||
usedAt: timestamp('used_at'), // NULL = unused; NOT NULL = already signed
|
||||
});
|
||||
```
|
||||
|
||||
**Token consumption (atomic):**
|
||||
```typescript
|
||||
// In POST /api/sign/[token]/route.ts
|
||||
// 1. Verify JWT signature + expiry (jose)
|
||||
// 2. Look up jti in signingTokens — if usedAt is set, return 409 "already signed"
|
||||
// 3. UPDATE signingTokens SET usedAt = NOW() WHERE jti = ? AND usedAt IS NULL
|
||||
// RETURNING — if 0 rows updated, race condition: reject with 409
|
||||
// 4. Only proceed to embed signature if step 3 updated exactly 1 row
|
||||
```
|
||||
|
||||
### Pattern 2: Canvas Signature with devicePixelRatio Correction
|
||||
|
||||
**What:** Mount `signature_pad` in `useEffect`, scale canvas to `devicePixelRatio`, add `touch-action: none` on the canvas element.
|
||||
|
||||
**When to use:** Any freehand drawing canvas on a page that must work on mobile (iOS Safari, Android Chrome).
|
||||
|
||||
```typescript
|
||||
// In SignatureModal.tsx (client component)
|
||||
// Source: szimek/signature_pad README + MDN devicePixelRatio docs
|
||||
import SignaturePad from 'signature_pad';
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const sigPadRef = useRef<SignaturePad | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
const ratio = Math.max(window.devicePixelRatio || 1, 1);
|
||||
canvas.width = canvas.offsetWidth * ratio;
|
||||
canvas.height = canvas.offsetHeight * ratio;
|
||||
canvas.getContext('2d')?.scale(ratio, ratio);
|
||||
sigPadRef.current = new SignaturePad(canvas, {
|
||||
backgroundColor: 'rgba(0,0,0,0)', // transparent for PNG export
|
||||
penColor: 'black',
|
||||
});
|
||||
return () => sigPadRef.current?.off();
|
||||
}, []);
|
||||
|
||||
// CSS on canvas element: touch-action: none (prevents browser pan/zoom on mobile)
|
||||
```
|
||||
|
||||
**Export:**
|
||||
```typescript
|
||||
const dataURL = sigPadRef.current?.toDataURL('image/png'); // base64 PNG
|
||||
```
|
||||
|
||||
### Pattern 3: PDF Signature Embedding
|
||||
|
||||
**What:** Take the client's signature PNG DataURL, embed it into the prepared PDF at the exact coordinates stored in `signatureFields` (already in PDF user space from Phase 5). Hash the final PDF immediately.
|
||||
|
||||
```typescript
|
||||
// lib/signing/embed-signature.ts
|
||||
// Source: @cantoo/pdf-lib API (same as original pdf-lib)
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
|
||||
export async function embedSignatureInPdf(
|
||||
preparedPdfPath: string, // absolute path
|
||||
signedPdfPath: string, // absolute path to write output
|
||||
signatures: Array<{
|
||||
fieldId: string;
|
||||
dataURL: string; // 'data:image/png;base64,...'
|
||||
x: number; y: number; width: number; height: number; page: number;
|
||||
}>
|
||||
): Promise<string> { // returns SHA-256 hex digest
|
||||
const pdfBytes = await readFile(preparedPdfPath);
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
for (const sig of signatures) {
|
||||
const page = pages[sig.page - 1];
|
||||
if (!page) continue;
|
||||
const pngImage = await pdfDoc.embedPng(sig.dataURL); // accepts base64 DataURL directly
|
||||
page.drawImage(pngImage, {
|
||||
x: sig.x,
|
||||
y: sig.y,
|
||||
width: sig.width,
|
||||
height: sig.height,
|
||||
});
|
||||
}
|
||||
|
||||
const modifiedBytes = await pdfDoc.save();
|
||||
const tmpPath = `${signedPdfPath}.tmp`;
|
||||
await writeFile(tmpPath, modifiedBytes);
|
||||
await rename(tmpPath, signedPdfPath);
|
||||
|
||||
// LEGAL-02: SHA-256 hash of final signed PDF
|
||||
const hash = await hashFile(signedPdfPath);
|
||||
return hash;
|
||||
}
|
||||
|
||||
async function hashFile(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on('data', (d) => hash.update(d));
|
||||
stream.on('end', () => resolve(hash.digest('hex')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Six-Event Audit Trail
|
||||
|
||||
**What:** Log all 6 required events to an `auditEvents` table. All timestamps are server-side (database `defaultNow()` or explicit `new Date()`). Never trust client-supplied timestamps.
|
||||
|
||||
**DB schema:**
|
||||
```typescript
|
||||
export const auditEventTypeEnum = pgEnum('audit_event_type', [
|
||||
'document_prepared', // Phase 5 — agent clicks Prepare (can log here or in Phase 5 prepare route)
|
||||
'email_sent', // After nodemailer sendMail resolves
|
||||
'link_opened', // GET /api/sign/[token] — includes IP + user-agent
|
||||
'document_viewed', // Client scrolls past first page OR explicit "viewed" event
|
||||
'signature_submitted', // POST /api/sign/[token] — before PDF write
|
||||
'pdf_hash_computed', // After embedSignatureInPdf returns hash
|
||||
]);
|
||||
|
||||
export const auditEvents = pgTable('audit_events', {
|
||||
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
documentId: text('document_id').notNull()
|
||||
.references(() => documents.id, { onDelete: 'cascade' }),
|
||||
eventType: auditEventTypeEnum('event_type').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(), // SERVER-SIDE always
|
||||
});
|
||||
```
|
||||
|
||||
**IP/UA extraction in route handler:**
|
||||
```typescript
|
||||
// Source: Next.js docs — headers() function, app router
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export async function GET(req: Request, ...) {
|
||||
const hdrs = await headers();
|
||||
const ip = hdrs.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
?? hdrs.get('x-real-ip')
|
||||
?? 'unknown';
|
||||
const ua = hdrs.get('user-agent') ?? 'unknown';
|
||||
// ...log audit event with ip, ua
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Branded Email with @react-email
|
||||
|
||||
**What:** Use `@react-email/components` to build the signing request email as a React component, render to HTML string with `@react-email/render`, pass to nodemailer.
|
||||
|
||||
```typescript
|
||||
// lib/signing/signing-mailer.ts
|
||||
import { render } from '@react-email/render';
|
||||
import nodemailer from 'nodemailer';
|
||||
import { SigningRequestEmail } from '@/emails/SigningRequestEmail';
|
||||
|
||||
export async function sendSigningRequestEmail(opts: {
|
||||
to: string;
|
||||
documentName: string;
|
||||
signingUrl: string;
|
||||
expiresAt: Date;
|
||||
}) {
|
||||
const html = await render(
|
||||
<SigningRequestEmail
|
||||
documentName={opts.documentName}
|
||||
signingUrl={opts.signingUrl}
|
||||
expiryDate={opts.expiresAt.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
/>
|
||||
);
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.CONTACT_SMTP_HOST,
|
||||
port: Number(process.env.CONTACT_SMTP_PORT ?? 587),
|
||||
secure: false,
|
||||
auth: { user: process.env.CONTACT_EMAIL_USER, pass: process.env.CONTACT_EMAIL_PASS },
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: '"Teressa Copeland" <teressa@teressacopelandhomes.com>',
|
||||
to: opts.to,
|
||||
subject: `Please sign: ${opts.documentName}`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 6: Public Route — Middleware Exclusion
|
||||
|
||||
**What:** The `/sign/[token]` route must be publicly accessible (no agent session required). The existing middleware matcher only covers `/agent/:path*` and `/portal/:path*` — `/sign/` is already outside those matchers and requires no change.
|
||||
|
||||
**Verification:** Current `middleware.ts` matcher array: `["/agent/:path*", "/portal/:path*"]` — `/sign/` routes are not matched, so they are public by default. No middleware changes needed.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **Trusting client-supplied timestamps for audit events:** Always use `defaultNow()` in the DB insert or `new Date()` on the server. Never accept a timestamp in the request body.
|
||||
- **JWTs without a usedAt DB record:** JWT expiry prevents use after 72 hours, but does NOT prevent replay attacks within the valid window. The `signingTokens.usedAt` column is mandatory.
|
||||
- **Canvas signature without devicePixelRatio scaling:** On Retina/HiDPI screens (all modern iPhones), the drawn signature will be blurry/half-resolution in the PDF. Must scale the canvas physical pixels by `devicePixelRatio`.
|
||||
- **Not setting `touch-action: none` on canvas:** iOS Safari and Android Chrome will attempt to scroll/pan the page during drawing, making signatures impossible on mobile.
|
||||
- **Signing the source PDF (not the prepared PDF):** Always embed into `preparedFilePath` (which already has signature rectangles drawn), not the original `filePath`.
|
||||
- **Storing `signedPdfPath` in the public directory:** Signed PDFs go in `uploads/` (same as Phase 4), never in `public/`. Access via authenticated route in Phase 7.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Smooth Bezier signature curves | Raw canvas mouse/touch listeners drawing straight lines | `signature_pad` | signature_pad uses variable-width Bezier interpolation for natural-looking strokes; raw canvas produces jaggy lines |
|
||||
| JWT creation and verification | Manual base64 encoding of payload + HMAC | `jose` (already installed) | Handles alg headers, claim validation, exp checking, and edge runtime compatibility correctly |
|
||||
| Email-safe HTML templates | Raw HTML strings with inline styles | `@react-email/render` + `@react-email/components` | Handles email-client compatibility quirks (Outlook, Gmail CSS stripping); composable and type-safe |
|
||||
| SHA-256 file hash | Reading entire file into memory, manual digest | Node.js `crypto` stream API | Stream-based avoids OOM on large PDFs; one dependency (built-in) |
|
||||
| PDF image embedding | Manual PDF xref/stream manipulation | `@cantoo/pdf-lib` (already installed) | PDF object graph is complex; pdf-lib handles cross-references, image streams, and page content correctly |
|
||||
|
||||
**Key insight:** The PDF signing stack (pdf-lib + signature_pad + canvas DataURL) is a well-established pattern with multiple production implementations. Every step has a library that handles the hard parts.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: JWT Is Not One-Time Without DB Enforcement
|
||||
|
||||
**What goes wrong:** Developer issues a JWT with 72h expiry, treats it as one-time use. Client accidentally clicks the link twice in quick succession. Both requests are valid (JWT not yet expired, no DB check). Two signed copies of the same document are created.
|
||||
|
||||
**Why it happens:** JWTs are stateless — there is no server-side "invalidated tokens" list.
|
||||
|
||||
**How to avoid:** Always write `signingTokens.usedAt` atomically on first use. Use an `UPDATE ... WHERE usedAt IS NULL RETURNING` pattern — if 0 rows are returned, the token was already used. Reject with HTTP 409.
|
||||
|
||||
**Warning signs:** No `signingTokens` table in schema. Signing route does not check `usedAt` before embedding PDF.
|
||||
|
||||
### Pitfall 2: Canvas Blur on iOS Safari (Missing devicePixelRatio Scaling)
|
||||
|
||||
**What goes wrong:** Signature looks correct in the canvas during drawing, but appears blurry/thin in the final signed PDF.
|
||||
|
||||
**Why it happens:** Canvas `width`/`height` attributes default to CSS pixel dimensions. On Retina screens (`devicePixelRatio=2` or 3), the physical pixel density is 2-3x higher. The canvas draws at 1x and the browser stretches it, causing blur.
|
||||
|
||||
**How to avoid:** In the `useEffect` that initializes `SignaturePad`, set `canvas.width = canvas.offsetWidth * ratio` and `canvas.height = canvas.offsetHeight * ratio` and scale the context: `canvas.getContext('2d').scale(ratio, ratio)`. Call this again on window resize (the orientation change case on mobile).
|
||||
|
||||
**Warning signs:** Signature canvas test on iPhone shows blurry strokes in preview.
|
||||
|
||||
### Pitfall 3: Page Scroll Interference on Mobile Canvas
|
||||
|
||||
**What goes wrong:** Client tries to draw on mobile. Every pointer movement scrolls the page instead of drawing. Signature is impossible.
|
||||
|
||||
**Why it happens:** Default browser touch behavior is page scrolling. The canvas does not capture touch events unless explicitly told to.
|
||||
|
||||
**How to avoid:** Add CSS `touch-action: none` to the canvas element. This is the CSS solution (not JS). In Tailwind: `className="touch-none"`.
|
||||
|
||||
**Warning signs:** Testing on Android Chrome or iOS Safari simulator — finger moves scroll the page instead of drawing strokes.
|
||||
|
||||
### Pitfall 4: Signing Into Source PDF Instead of Prepared PDF
|
||||
|
||||
**What goes wrong:** Signature is embedded into the original `filePath` PDF (no signature field rectangles). The blue "Sign Here" rectangles from Phase 5 are absent. The signature PNG may overlap text.
|
||||
|
||||
**Why it happens:** Developer uses `doc.filePath` instead of `doc.preparedFilePath` in the embed route.
|
||||
|
||||
**How to avoid:** Always use `doc.preparedFilePath`. Guard: if `preparedFilePath` is null, return 422 — document was never prepared.
|
||||
|
||||
**Warning signs:** Signed PDF has no blue rectangle outlines around signatures.
|
||||
|
||||
### Pitfall 5: Multiple SPF Records on Domain
|
||||
|
||||
**What goes wrong:** Existing SPF record exists (from hosting provider). Developer adds a second SPF record. Per RFC 7208, multiple SPF records on the same domain = automatic PermError. All email from the domain fails SPF authentication.
|
||||
|
||||
**Why it happens:** DNS TXT records for the same label can have multiple values. The RFC says only one `v=spf1` is allowed.
|
||||
|
||||
**How to avoid:** Before adding SPF, check existing records with `dig TXT teressacopelandhomes.com`. If a record exists, merge SMTP provider include into the existing record rather than adding a new one.
|
||||
|
||||
**Warning signs:** MXToolbox SPF check shows "PermError — too many DNS records".
|
||||
|
||||
### Pitfall 6: Server Renders Signing Page Before Token Check (React Hydration Race)
|
||||
|
||||
**What goes wrong:** The `/sign/[token]/page.tsx` server component renders the PDF viewing UI before checking if the token is valid. On a used/expired link, the client briefly sees the signing canvas before getting redirected.
|
||||
|
||||
**Why it happens:** Component renders optimistically before async DB check completes.
|
||||
|
||||
**How to avoid:** The `/sign/[token]/page.tsx` server component must validate the token AND check `usedAt` synchronously before rendering ANY signing UI. If invalid/used/expired, render the appropriate static error page (no canvas, no PDF viewer).
|
||||
|
||||
**Warning signs:** A brief flash of the signing canvas on expired/used links.
|
||||
|
||||
### Pitfall 7: `@react-email/render` Used As JSX in Non-JSX Context
|
||||
|
||||
**What goes wrong:** `render(<SigningRequestEmail />)` fails at runtime because the mailer file doesn't have JSX support, or React is not imported.
|
||||
|
||||
**Why it happens:** `@react-email/render` expects valid JSX. Files calling `render()` must have `.tsx` extension and React imported (or project has automatic JSX runtime configured).
|
||||
|
||||
**How to avoid:** Name mailer file `signing-mailer.tsx`, ensure `tsconfig.json` has `"jsx": "preserve"` (default Next.js setting). Alternatively, call `render(React.createElement(SigningRequestEmail, props))` in non-JSX files.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from official sources:
|
||||
|
||||
### Signature PNG Embedding in @cantoo/pdf-lib
|
||||
```typescript
|
||||
// Source: cantoo-scribe/pdf-lib README + pdf-lib.js.org API docs
|
||||
// @cantoo/pdf-lib API is identical to original pdf-lib
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
|
||||
const pdfDoc = await PDFDocument.load(await readFile(preparedPdfPath));
|
||||
// dataURL from signaturePad.toDataURL('image/png') — accepted directly by embedPng
|
||||
const pngImage = await pdfDoc.embedPng(dataURL);
|
||||
const pages = pdfDoc.getPages();
|
||||
const page = pages[fieldPage - 1]; // field.page is 1-indexed (Phase 5 convention)
|
||||
page.drawImage(pngImage, {
|
||||
x: field.x,
|
||||
y: field.y, // PDF user space, bottom-left origin (same as signatureFields)
|
||||
width: field.width,
|
||||
height: field.height,
|
||||
});
|
||||
const signedBytes = await pdfDoc.save();
|
||||
```
|
||||
|
||||
### JWT Signing Token Creation (jose 6.x)
|
||||
```typescript
|
||||
// Source: github.com/panva/jose README
|
||||
import { SignJWT, jwtVerify } from 'jose';
|
||||
|
||||
const secret = new TextEncoder().encode(process.env.SIGNING_JWT_SECRET!);
|
||||
const jti = crypto.randomUUID();
|
||||
|
||||
const token = await new SignJWT({ documentId, jti })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('72h')
|
||||
.sign(secret);
|
||||
|
||||
// Verification (throws on expired or invalid signature):
|
||||
const { payload } = await jwtVerify(token, secret);
|
||||
// payload.exp is in Unix seconds: payload.exp! * 1000 > Date.now() is guaranteed by jwtVerify
|
||||
```
|
||||
|
||||
### SHA-256 File Hash (Node.js built-in)
|
||||
```typescript
|
||||
// Source: nodejs.org/api/crypto.html
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
|
||||
function hashFileSha256(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
createReadStream(filePath)
|
||||
.on('data', (chunk) => hash.update(chunk))
|
||||
.on('end', () => resolve(hash.digest('hex')))
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### signature_pad Canvas Initialization (React useEffect)
|
||||
```typescript
|
||||
// Source: szimek/signature_pad README — HiDPI / Retina section
|
||||
import SignaturePad from 'signature_pad';
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ratio = Math.max(window.devicePixelRatio || 1, 1);
|
||||
canvas.width = canvas.offsetWidth * ratio;
|
||||
canvas.height = canvas.offsetHeight * ratio;
|
||||
canvas.getContext('2d')?.scale(ratio, ratio);
|
||||
sigPadRef.current = new SignaturePad(canvas, { backgroundColor: 'rgba(0,0,0,0)' });
|
||||
return () => sigPadRef.current?.off();
|
||||
}, []);
|
||||
|
||||
// Required CSS on canvas element:
|
||||
// touch-action: none (Tailwind: className="touch-none")
|
||||
```
|
||||
|
||||
### IP/User-Agent in Next.js Route Handler
|
||||
```typescript
|
||||
// Source: nextjs.org/docs/app/api-reference/functions/headers
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
const hdrs = await headers();
|
||||
const ip = hdrs.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
?? hdrs.get('x-real-ip')
|
||||
?? 'unknown';
|
||||
const ua = hdrs.get('user-agent') ?? 'unknown';
|
||||
```
|
||||
|
||||
### Typed Signature Canvas Rendering
|
||||
```typescript
|
||||
// "Type" tab: render client name on an offscreen canvas in a cursive font, export as PNG
|
||||
// Claude's discretion: recommend "Dancing Script" (Google Fonts) — elegant, readable
|
||||
function renderTypedSignature(name: string, width = 300, height = 80): string {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width; canvas.height = height;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.font = `bold 40px 'Dancing Script', cursive`;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(name, 10, height / 2);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
// Load Google Font before rendering:
|
||||
// @import url('https://fonts.googleapis.com/css2?family=Dancing+Script:wght@700&display=swap')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| `pdf-lib` (Hopding) | `@cantoo/pdf-lib` (Cantoo fork) | 2023 | Project already uses Cantoo fork; same API, maintained fork |
|
||||
| Mouse/touch events directly on canvas | `signature_pad` Pointer Events API | v5.x (2023) | Unified input handling across mouse/touch/stylus |
|
||||
| `jsonwebtoken` npm package | `jose` | ~2022 | `jose` works in Edge runtime (no Node.js-only dependencies); used by next-auth |
|
||||
| Raw HTML email strings | `@react-email/components` | 2022 | Composable, type-safe, auto-inlines CSS for email client compatibility |
|
||||
| SHA-1 for document hashing | SHA-256 | Industry standard | SHA-1 deprecated; SHA-256 is the ESIGN/UETA audit trail standard |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- `jsonwebtoken` package: Does NOT work in Edge/Middleware runtime; `jose` is the replacement already in the project
|
||||
- `react-signature-canvas` wrapper: Adds indirection over `signature_pad`; direct usage preferred for HiDPI control
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **SMTP provider for `teressa@teressacopelandhomes.com` sending domain**
|
||||
- What we know: SMTP credentials exist for the contact form (`CONTACT_SMTP_HOST`, `CONTACT_EMAIL_USER`). The contact form sends FROM that email address.
|
||||
- What's unclear: The contact form uses whatever SMTP is configured. For DKIM to work for `teressacopelandhomes.com`, the SMTP provider must generate and provide DKIM DNS records for that domain. Which provider is hosting `teressacopelandhomes.com` email? (Google Workspace? Namecheap? Custom postfix?) This determines the exact DKIM setup steps.
|
||||
- Recommendation: In Wave 0 or the DNS plan task, have the human agent identify the email/DNS provider and follow their DKIM setup wizard. Document the TXT record values in `.env.local` notes or a checklist. The DNS task should be a HUMAN VERIFICATION step — cannot be automated.
|
||||
|
||||
2. **`document_prepared` audit event — Phase 5 or Phase 6?**
|
||||
- What we know: LEGAL-01 requires logging "document prepared" as audit event 1 of 6. The prepare action happens in Phase 5's `POST /api/documents/[id]/prepare` route.
|
||||
- What's unclear: Should Phase 6 retroactively add audit logging to the Phase 5 prepare route, or is "document prepared" logged when the signing token is created?
|
||||
- Recommendation: Log `document_prepared` in the Phase 5 prepare route (add the audit log insert there) as part of Phase 6 schema migration. The `auditEvents` table is new to Phase 6, so Phase 6 plan should add the insert to the existing prepare route.
|
||||
|
||||
3. **Client copy download link — session-based or short-lived token?**
|
||||
- What we know: The confirmation page shows a "Download your copy" button. This is in Claude's Discretion.
|
||||
- What's unclear: The signed PDF lives in `uploads/` (private, not public). A download requires either a session (client has none) or a short-lived token.
|
||||
- Recommendation: Generate a second short-lived JWT (15-minute TTL, single use) at signing completion time. Store in `signingTokens` with a `purpose: 'download'` claim. The confirmation page receives this token in the redirect URL or as a response body field. The download route validates it. This avoids storing client session state.
|
||||
|
||||
---
|
||||
|
||||
## DNS (LEGAL-04): SPF/DKIM/DMARC Setup Guide
|
||||
|
||||
This is a HUMAN TASK (cannot be scripted). Document the steps here so the plan can include a human verification gate.
|
||||
|
||||
### Step 1: Check Existing SPF Record
|
||||
```bash
|
||||
dig TXT teressacopelandhomes.com | grep "v=spf1"
|
||||
```
|
||||
If a record exists, MERGE the SMTP provider's include into it (do not add a second SPF record).
|
||||
|
||||
### Step 2: Add SPF Record
|
||||
```
|
||||
TXT @ "v=spf1 include:[smtp-provider-include] ~all"
|
||||
```
|
||||
Replace `[smtp-provider-include]` with whatever the SMTP provider specifies (e.g., `include:_spf.google.com` for Google Workspace).
|
||||
|
||||
### Step 3: Add DKIM Record
|
||||
Get the DKIM public key from your SMTP provider (each provider has a wizard). Add as:
|
||||
```
|
||||
TXT [selector]._domainkey.teressacopelandhomes.com "v=DKIM1; k=rsa; p=[public-key]"
|
||||
```
|
||||
|
||||
### Step 4: Add DMARC Record (monitoring mode first)
|
||||
```
|
||||
TXT _dmarc.teressacopelandhomes.com "v=DMARC1; p=none; rua=mailto:teressa@teressacopelandhomes.com"
|
||||
```
|
||||
`p=none` means monitoring only — no email blocked yet. Promotes to `p=quarantine` after confirming all legitimate mail passes.
|
||||
|
||||
### Step 5: Verify with MXToolbox
|
||||
- https://mxtoolbox.com/spf.aspx
|
||||
- https://mxtoolbox.com/dkim.aspx
|
||||
- https://mxtoolbox.com/dmarc.aspx
|
||||
|
||||
All three must show green/pass before sending first real client signing link.
|
||||
|
||||
---
|
||||
|
||||
## Schema Migration Requirements
|
||||
|
||||
Phase 6 requires migration `0005_*`:
|
||||
|
||||
```sql
|
||||
-- New enum type
|
||||
CREATE TYPE "audit_event_type" AS ENUM (
|
||||
'document_prepared', 'email_sent', 'link_opened',
|
||||
'document_viewed', 'signature_submitted', 'pdf_hash_computed'
|
||||
);
|
||||
|
||||
-- Signing tokens table
|
||||
CREATE TABLE "signing_tokens" (
|
||||
"jti" text PRIMARY KEY,
|
||||
"document_id" text NOT NULL REFERENCES "documents"("id") ON DELETE CASCADE,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"expires_at" timestamp NOT NULL,
|
||||
"used_at" timestamp
|
||||
);
|
||||
|
||||
-- Audit events table
|
||||
CREATE TABLE "audit_events" (
|
||||
"id" text PRIMARY KEY,
|
||||
"document_id" text NOT NULL REFERENCES "documents"("id") ON DELETE CASCADE,
|
||||
"event_type" "audit_event_type" NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Add columns to documents
|
||||
ALTER TABLE "documents" ADD COLUMN "signed_file_path" text;
|
||||
ALTER TABLE "documents" ADD COLUMN "pdf_hash" text;
|
||||
ALTER TABLE "documents" ADD COLUMN "signed_at" timestamp;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- `szimek/signature_pad` GitHub README + jsdocs.io API reference — `signature_pad` 5.1.x API, HiDPI scaling, touch-action
|
||||
- `panva/jose` GitHub README — `SignJWT`, `jwtVerify`, `setExpirationTime` API, jose v6.2.2
|
||||
- `cantoo-scribe/pdf-lib` GitHub + pdf-lib.js.org docs — `embedPng(dataURL)`, `drawImage()`, same API as original
|
||||
- Node.js v25 official docs — `crypto.createHash`, stream-based file hashing
|
||||
- Next.js official docs — `headers()` function for IP/UA in route handlers, App Router route handler patterns
|
||||
- `@react-email/render` npm (v2.0.4) + react.email/docs — `render()` function, nodemailer integration
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- ESIGN Act / UETA compliance — juro.com, docusign.com/learn/esign-act-ueta, esignglobal.com/blog — six-event audit trail sufficiency for real estate transactions verified across multiple sources
|
||||
- SPF/DKIM/DMARC — smartreach.io, dmarcly.com, RFC 7208 (single SPF record rule) — DNS setup patterns confirmed across multiple authoritative sources
|
||||
- Google Fonts — fonts.google.com/specimen/Dancing+Script — cursive font availability and CSS import pattern
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- Next.js 16 `middleware.ts` → `proxy.ts` rename: One search result mentioned this. Could not verify with Next.js official docs. The current project has `middleware.ts` and Next.js 16.2.0 — existing middleware.ts works (confirmed by Phase 5 success). Do NOT rename middleware.ts based on this unverified claim. Existing middleware.ts is proven working.
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — all core libraries are either already installed (jose, @cantoo/pdf-lib, nodemailer) or well-established (signature_pad 5.1.x with verified API)
|
||||
- Architecture: HIGH — JWT + usedAt pattern is verified by multiple sources; embed pattern verified against @cantoo/pdf-lib API; audit table structure standard
|
||||
- Pitfalls: HIGH — devicePixelRatio, touch-action, and multiple-SPF pitfalls are documented in official sources; one-time JWT pitfall is mathematically certain
|
||||
- DNS setup: MEDIUM — steps are standard and verified, but actual record values depend on SMTP provider which is not yet identified
|
||||
|
||||
**Research date:** 2026-03-20
|
||||
**Valid until:** 2026-04-20 (30 days — stable libraries; DNS setup is human task, not time-sensitive for research)
|
||||
Reference in New Issue
Block a user