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