diff --git a/teressa-copeland-homes/src/lib/signing/audit.ts b/teressa-copeland-homes/src/lib/signing/audit.ts new file mode 100644 index 0000000..9bf83a0 --- /dev/null +++ b/teressa-copeland-homes/src/lib/signing/audit.ts @@ -0,0 +1,21 @@ +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 + }); +} diff --git a/teressa-copeland-homes/src/lib/signing/embed-signature.ts b/teressa-copeland-homes/src/lib/signing/embed-signature.ts new file mode 100644 index 0000000..502ca98 --- /dev/null +++ b/teressa-copeland-homes/src/lib/signing/embed-signature.ts @@ -0,0 +1,54 @@ +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); + }); +} diff --git a/teressa-copeland-homes/src/lib/signing/token.ts b/teressa-copeland-homes/src/lib/signing/token.ts new file mode 100644 index 0000000..f4a323e --- /dev/null +++ b/teressa-copeland-homes/src/lib/signing/token.ts @@ -0,0 +1,32 @@ +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 }; +}