Files
red/.planning/phases/06-signing-flow/06-RESEARCH.md
2026-03-20 11:07:33 -06:00

38 KiB

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:

npm install signature_pad @react-email/render @react-email/components

(jose, @cantoo/pdf-lib, nodemailer, react-pdf are already installed)


Architecture Patterns

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

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

// 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):

// 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).

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

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.

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

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:

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

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

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

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

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

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

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

// "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.

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

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

All three must show green/pass before sending first real client signing link.


Schema Migration Requirements

Phase 6 requires migration 0005_*:

-- 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.tsproxy.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)