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:
Chandler Copeland
2026-03-20 11:28:51 -06:00
parent 4bca04f988
commit e1306dab69

View 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(),
});
}