feat(06-03): GET /api/sign/[token] route — token validation + audit logging
- Validates JWT with verifySigningToken(); returns expired/invalid/used/pending - Checks signingTokens.usedAt for one-time-use enforcement - Logs link_opened + document_viewed audit events on valid pending access - Extracts IP from x-forwarded-for/x-real-ip headers for audit trail - Public route — no auth() import or session required
This commit is contained in:
96
teressa-copeland-homes/src/app/api/sign/[token]/route.ts
Normal file
96
teressa-copeland-homes/src/app/api/sign/[token]/route.ts
Normal file
@@ -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(),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user