Files
red/.planning/phases/06-signing-flow/06-01-PLAN.md
2026-03-20 11:18:47 -06:00

13 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves, user_setup
phase plan type wave depends_on files_modified autonomous requirements must_haves user_setup
06-signing-flow 01 execute 1
teressa-copeland-homes/src/lib/db/schema.ts
teressa-copeland-homes/drizzle/0005_signing_flow.sql
teressa-copeland-homes/src/lib/signing/token.ts
teressa-copeland-homes/src/lib/signing/audit.ts
teressa-copeland-homes/src/lib/signing/embed-signature.ts
teressa-copeland-homes/package.json
true
SIGN-02
LEGAL-01
LEGAL-02
truths artifacts key_links
signingTokens table exists with jti, documentId, expiresAt, usedAt columns
auditEvents table exists with all 6 event type enum values
documents table has signedFilePath, pdfHash, signedAt columns
createSigningToken() returns a JWT with documentId and jti claim; jti stored in signingTokens
verifySigningToken() throws on expired or invalid JWT
logAuditEvent() inserts a row into auditEvents with server-side timestamp
embedSignatureInPdf() embeds PNG into prepared PDF at signatureFields coordinates, returns SHA-256 hex
npm run build passes cleanly
path provides contains
teressa-copeland-homes/src/lib/db/schema.ts signingTokens table, auditEvents table + enum, 3 new documents columns signingTokens
path provides
teressa-copeland-homes/drizzle/0005_signing_flow.sql Applied migration adding signing tables
path provides exports
teressa-copeland-homes/src/lib/signing/token.ts createSigningToken(), verifySigningToken()
createSigningToken
verifySigningToken
path provides exports
teressa-copeland-homes/src/lib/signing/audit.ts logAuditEvent()
logAuditEvent
path provides exports
teressa-copeland-homes/src/lib/signing/embed-signature.ts embedSignatureInPdf()
embedSignatureInPdf
from to via pattern
token.ts signingTokens table jti stored in DB on createSigningToken db.insert(signingTokens)
from to via pattern
embed-signature.ts uploads/ directory reads preparedFilePath, writes signedFilePath with atomic rename rename(tmpPath
service why env_vars
signing-jwt JWT secret for signing link tokens
name source
SIGNING_JWT_SECRET Generate a random 32+ character string — e.g.: openssl rand -base64 32
Lay the cryptographic and data foundation for the entire Phase 6 signing flow: database tables, server utilities, and npm packages.

Purpose: Everything in plans 02-05 depends on these primitives — token creation, audit logging, and PDF signature embedding. Must be in place first. Output: signingTokens table, auditEvents table, 3 new documents columns, createSigningToken/verifySigningToken/logAuditEvent/embedSignatureInPdf server utilities, signature_pad and @react-email packages installed.

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

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/06-signing-flow/06-CONTEXT.md @.planning/phases/06-signing-flow/06-RESEARCH.md @.planning/phases/05-pdf-fill-and-field-mapping/05-01-SUMMARY.md

From teressa-copeland-homes/src/lib/db/schema.ts (existing):

export interface SignatureFieldData {
  id: string;
  page: number;   // 1-indexed
  x: number;      // PDF user space, bottom-left origin, points
  y: number;      // PDF user space, bottom-left origin, points
  width: number;  // PDF points
  height: number; // PDF points
}

// documents table already has these columns (Phase 5):
//   signatureFields: jsonb.$type<SignatureFieldData[]>()
//   preparedFilePath: text — absolute path to the prepared PDF
//   assignedClientId: text

Most recent migration file is 0004_military_maximus.sql — next migration must be 0005_*.

Task 1: Install packages + extend schema + generate migration teressa-copeland-homes/package.json teressa-copeland-homes/src/lib/db/schema.ts teressa-copeland-homes/drizzle/0005_signing_flow.sql Install new packages from within teressa-copeland-homes/: ```bash cd teressa-copeland-homes && npm install signature_pad @react-email/render @react-email/components ``` (jose, @cantoo/pdf-lib, nodemailer, react-pdf are already installed — do NOT reinstall them)

Extend src/lib/db/schema.ts — add after the existing documentsRelations export:

  1. Add a new pgEnum for audit event types:
export const auditEventTypeEnum = pgEnum('audit_event_type', [
  'document_prepared',
  'email_sent',
  'link_opened',
  'document_viewed',
  'signature_submitted',
  'pdf_hash_computed',
]);
  1. Add signingTokens table:
export const signingTokens = pgTable('signing_tokens', {
  jti: text('jti').primaryKey(),
  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'),
});
  1. Add auditEvents table:
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(),
});
  1. Add three new columns to the existing documents pgTable definition (add alongside the existing preparedFilePath column):
signedFilePath: text('signed_file_path'),
pdfHash: text('pdf_hash'),
signedAt: timestamp('signed_at'),

Generate migration with drizzle-kit (run from teressa-copeland-homes/):

DATABASE_URL=postgresql://postgres:postgres@localhost:5432/teressa npx drizzle-kit generate

This creates drizzle/0005_*.sql. Rename it to 0005_signing_flow.sql for clarity.

Apply migration:

DATABASE_URL=postgresql://postgres:postgres@localhost:5432/teressa npx drizzle-kit migrate

Verify migration applied: psql postgresql://postgres:postgres@localhost:5432/teressa -c "\dt" should show signing_tokens and audit_events tables. cd /Users/ccopeland/temp/red/teressa-copeland-homes && DATABASE_URL=postgresql://postgres:postgres@localhost:5432/teressa psql postgresql://postgres:postgres@localhost:5432/teressa -c "\dt" 2>/dev/null | grep -E "signing_tokens|audit_events" Both signing_tokens and audit_events tables appear in \dt output; npm run build compiles with no TypeScript errors for schema.ts

Task 2: Create signing utility library (token + audit + embed) teressa-copeland-homes/src/lib/signing/token.ts teressa-copeland-homes/src/lib/signing/audit.ts teressa-copeland-homes/src/lib/signing/embed-signature.ts Create directory: teressa-copeland-homes/src/lib/signing/

token.ts — JWT token creation and verification using jose (already installed):

import { SignJWT, jwtVerify } from 'jose';
import { db } from '@/lib/db';
import { signingTokens } from '@/lib/db/schema';

const getSecret = () => new TextEncoder().encode(process.env.SIGNING_JWT_SECRET!);

export async function createSigningToken(documentId: string): Promise<{ token: string; jti: string; expiresAt: Date }> {
  const jti = crypto.randomUUID();
  const expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000); // 72 hours

  const token = await new SignJWT({ documentId, purpose: 'sign' })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('72h')
    .setJti(jti)
    .sign(getSecret());

  // Store token metadata for one-time-use enforcement
  await db.insert(signingTokens).values({
    jti,
    documentId,
    expiresAt,
  });

  return { token, jti, expiresAt };
}

export async function verifySigningToken(token: string): Promise<{ documentId: string; jti: string; exp: number }> {
  // Throws JWTExpired or JWTInvalid on failure — caller handles
  const { payload } = await jwtVerify(token, getSecret());
  return payload as { documentId: string; jti: string; exp: number };
}

audit.ts — server-side audit event logging:

import { db } from '@/lib/db';
import { auditEvents, auditEventTypeEnum } from '@/lib/db/schema';

type AuditEventType = typeof auditEventTypeEnum.enumValues[number];

export async function logAuditEvent(opts: {
  documentId: string;
  eventType: AuditEventType;
  ipAddress?: string;
  userAgent?: string;
  metadata?: Record<string, unknown>;
}): Promise<void> {
  await db.insert(auditEvents).values({
    documentId: opts.documentId,
    eventType: opts.eventType,
    ipAddress: opts.ipAddress ?? null,
    userAgent: opts.userAgent ?? null,
    metadata: opts.metadata ?? null,
    // createdAt is defaultNow() — server-side only, never from client
  });
}

embed-signature.ts — PDF signature embedding with SHA-256 hash (LEGAL-02):

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 interface SignatureToEmbed {
  fieldId: string;
  dataURL: string;   // 'data:image/png;base64,...' from signature_pad or typed canvas
  x: number;        // PDF user space (bottom-left origin, points) — from signatureFields
  y: number;
  width: number;
  height: number;
  page: number;     // 1-indexed
}

export async function embedSignatureInPdf(
  preparedPdfPath: string,   // absolute path — ALWAYS use doc.preparedFilePath, NOT filePath
  signedPdfPath: string,     // absolute path to write signed output (uploads/clients/{id}/{uuid}_signed.pdf)
  signatures: SignatureToEmbed[]
): Promise<string> {            // returns SHA-256 hex digest (LEGAL-02)
  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); // atomic rename prevents corruption on partial write

  // LEGAL-02: SHA-256 hash of final signed PDF — computed from disk after rename
  return hashFile(signedPdfPath);
}

function hashFile(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);
  });
}

Add SIGNING_JWT_SECRET to teressa-copeland-homes/.env.local:

SIGNING_JWT_SECRET=replace_with_output_of_openssl_rand_base64_32

(Use a real 32-character random string — generate with: openssl rand -base64 32) cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -5 All three utility files exist; npm run build passes with no TypeScript errors; SIGNING_JWT_SECRET placeholder added to .env.local

- Both new tables in PostgreSQL: `psql ... -c "\dt" | grep -E "signing_tokens|audit_events"` - New documents columns visible: `psql ... -c "\d documents" | grep -E "signed_file|pdf_hash|signed_at"` - Build passes: `npm run build` exits 0 - Utility files exist at expected paths in src/lib/signing/

<success_criteria> Phase 6 foundation is in place when: signingTokens and auditEvents tables exist in PostgreSQL (migration 0005 applied), documents has signedFilePath/pdfHash/signedAt columns, all three signing utility files compile without error, npm run build passes, and SIGNING_JWT_SECRET is in .env.local. </success_criteria>

After completion, create `.planning/phases/06-signing-flow/06-01-SUMMARY.md`