346 lines
13 KiB
Markdown
346 lines
13 KiB
Markdown
|
|
---
|
||
|
|
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"
|
||
|
|
---
|
||
|
|
|
||
|
|
<objective>
|
||
|
|
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.
|
||
|
|
</objective>
|
||
|
|
|
||
|
|
<execution_context>
|
||
|
|
@/Users/ccopeland/.claude/get-shit-done/workflows/execute-plan.md
|
||
|
|
@/Users/ccopeland/.claude/get-shit-done/templates/summary.md
|
||
|
|
</execution_context>
|
||
|
|
|
||
|
|
<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
|
||
|
|
|
||
|
|
<interfaces>
|
||
|
|
<!-- Current schema.ts ends with these exports — new tables must be added after. -->
|
||
|
|
|
||
|
|
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<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_*.
|
||
|
|
</interfaces>
|
||
|
|
</context>
|
||
|
|
|
||
|
|
<tasks>
|
||
|
|
|
||
|
|
<task type="auto">
|
||
|
|
<name>Task 1: Install packages + extend schema + generate migration</name>
|
||
|
|
<files>
|
||
|
|
teressa-copeland-homes/package.json
|
||
|
|
teressa-copeland-homes/src/lib/db/schema.ts
|
||
|
|
teressa-copeland-homes/drizzle/0005_signing_flow.sql
|
||
|
|
</files>
|
||
|
|
<action>
|
||
|
|
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<Record<string, unknown>>(),
|
||
|
|
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.
|
||
|
|
</action>
|
||
|
|
<verify>
|
||
|
|
<automated>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"</automated>
|
||
|
|
</verify>
|
||
|
|
<done>Both signing_tokens and audit_events tables appear in \dt output; npm run build compiles with no TypeScript errors for schema.ts</done>
|
||
|
|
</task>
|
||
|
|
|
||
|
|
<task type="auto">
|
||
|
|
<name>Task 2: Create signing utility library (token + audit + embed)</name>
|
||
|
|
<files>
|
||
|
|
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
|
||
|
|
</files>
|
||
|
|
<action>
|
||
|
|
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<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):
|
||
|
|
```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<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`)
|
||
|
|
</action>
|
||
|
|
<verify>
|
||
|
|
<automated>cd /Users/ccopeland/temp/red/teressa-copeland-homes && npm run build 2>&1 | tail -5</automated>
|
||
|
|
</verify>
|
||
|
|
<done>All three utility files exist; npm run build passes with no TypeScript errors; SIGNING_JWT_SECRET placeholder added to .env.local</done>
|
||
|
|
</task>
|
||
|
|
|
||
|
|
</tasks>
|
||
|
|
|
||
|
|
<verification>
|
||
|
|
- 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/
|
||
|
|
</verification>
|
||
|
|
|
||
|
|
<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>
|
||
|
|
|
||
|
|
<output>
|
||
|
|
After completion, create `.planning/phases/06-signing-flow/06-01-SUMMARY.md`
|
||
|
|
</output>
|