--- phase: 06-signing-flow plan: "01" type: execute wave: 1 depends_on: [] files_modified: - 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 autonomous: true requirements: - SIGN-02 - LEGAL-01 - LEGAL-02 must_haves: truths: - "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" artifacts: - path: "teressa-copeland-homes/src/lib/db/schema.ts" provides: "signingTokens table, auditEvents table + enum, 3 new documents columns" contains: "signingTokens" - path: "teressa-copeland-homes/drizzle/0005_signing_flow.sql" provides: "Applied migration adding signing tables" - path: "teressa-copeland-homes/src/lib/signing/token.ts" provides: "createSigningToken(), verifySigningToken()" exports: ["createSigningToken", "verifySigningToken"] - path: "teressa-copeland-homes/src/lib/signing/audit.ts" provides: "logAuditEvent()" exports: ["logAuditEvent"] - path: "teressa-copeland-homes/src/lib/signing/embed-signature.ts" provides: "embedSignatureInPdf()" exports: ["embedSignatureInPdf"] key_links: - from: "token.ts" to: "signingTokens table" via: "jti stored in DB on createSigningToken" pattern: "db.insert\\(signingTokens\\)" - from: "embed-signature.ts" to: "uploads/ directory" via: "reads preparedFilePath, writes signedFilePath with atomic rename" pattern: "rename\\(tmpPath" user_setup: - service: signing-jwt why: "JWT secret for signing link tokens" env_vars: - name: SIGNING_JWT_SECRET source: "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. @/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md @/Users/ccopeland/.claude/get-shit-done/templates/summary.md @.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): ```typescript 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() // 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: ```typescript export const auditEventTypeEnum = pgEnum('audit_event_type', [ 'document_prepared', 'email_sent', 'link_opened', 'document_viewed', 'signature_submitted', 'pdf_hash_computed', ]); ``` 2. Add signingTokens table: ```typescript 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'), }); ``` 3. Add auditEvents table: ```typescript 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>(), createdAt: timestamp('created_at').defaultNow().notNull(), }); ``` 4. Add three new columns to the existing documents pgTable definition (add alongside the existing preparedFilePath column): ```typescript signedFilePath: text('signed_file_path'), pdfHash: text('pdf_hash'), signedAt: timestamp('signed_at'), ``` Generate migration with drizzle-kit (run from teressa-copeland-homes/): ```bash 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: ```bash 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): ```typescript 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: ```typescript 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; }): Promise { 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): ```typescript 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 { // 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 { 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/ 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. After completion, create `.planning/phases/06-signing-flow/06-01-SUMMARY.md`