diff --git a/teressa-copeland-homes/src/app/api/sign/[token]/route.ts b/teressa-copeland-homes/src/app/api/sign/[token]/route.ts new file mode 100644 index 0000000..17c220e --- /dev/null +++ b/teressa-copeland-homes/src/app/api/sign/[token]/route.ts @@ -0,0 +1,96 @@ +import { headers } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { verifySigningToken } from '@/lib/signing/token'; +import { logAuditEvent } from '@/lib/signing/audit'; +import { db } from '@/lib/db'; +import { signingTokens, documents } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; + +// Public route — no auth session required +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params; + + // Extract IP and user-agent for audit logging + 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'; + + // 1. Verify JWT + let payload: { documentId: string; jti: string; exp: number }; + try { + payload = await verifySigningToken(token); + } catch (err: unknown) { + // Check if it's a JWT expiry error + const errName = err instanceof Error ? err.constructor.name : ''; + const errMessage = err instanceof Error ? err.message : ''; + if (errName === 'JWTExpired' || errMessage.includes('expired')) { + return NextResponse.json({ status: 'expired' }, { status: 200 }); + } + return NextResponse.json({ status: 'invalid' }, { status: 200 }); + } + + // 2. Look up jti in signingTokens table + const tokenRow = await db.query.signingTokens.findFirst({ + where: eq(signingTokens.jti, payload.jti), + }); + + if (!tokenRow) { + return NextResponse.json({ status: 'invalid' }, { status: 200 }); + } + + // 3. Check one-time use + if (tokenRow.usedAt !== null) { + return NextResponse.json( + { status: 'used', signedAt: tokenRow.usedAt.toISOString() }, + { status: 200 } + ); + } + + // 4. Fetch document + const doc = await db.query.documents.findFirst({ + where: eq(documents.id, payload.documentId), + columns: { + id: true, + name: true, + signatureFields: true, + preparedFilePath: true, + }, + }); + + if (!doc || !doc.preparedFilePath) { + return NextResponse.json({ status: 'invalid' }, { status: 200 }); + } + + // 5 & 6. Log audit events: link_opened + document_viewed + await logAuditEvent({ + documentId: payload.documentId, + eventType: 'link_opened', + ipAddress: ip, + userAgent: ua, + }); + + await logAuditEvent({ + documentId: payload.documentId, + eventType: 'document_viewed', + ipAddress: ip, + userAgent: ua, + }); + + // 7. Return pending state with document data + return NextResponse.json({ + status: 'pending', + document: { + id: doc.id, + name: doc.name, + signatureFields: doc.signatureFields ?? [], + preparedFilePath: doc.preparedFilePath, + }, + expiresAt: new Date(payload.exp * 1000).toISOString(), + }); +}