fix(documenso): align webhook receiver with Documenso v1.13 + 2.x protocol
Documenso authenticates outbound webhooks via the X-Documenso-Secret header carrying the plaintext secret (no HMAC). The previous receiver verified an HMAC against a non-existent x-documenso-signature header and switched on parsed.type, neither of which Documenso emits — so every real delivery was being silently rejected. - Read X-Documenso-Secret, compare timing-safe to env secret - Switch on parsed.event with uppercase normalization for both v1.13 (DOCUMENT_SIGNED) and 2.x (lowercase-dotted UI labels) wire formats - Alias DOCUMENT_RECIPIENT_COMPLETED to DOCUMENT_SIGNED (same semantics across versions) - Handle DOCUMENT_OPENED / DOCUMENT_REJECTED / DOCUMENT_CANCELLED in addition to the existing DOCUMENT_SIGNED + DOCUMENT_COMPLETED paths - Bypass session middleware for /api/webhooks/* (signature is the auth) Verified end-to-end against signatures.letsbe.solutions: real DOCUMENT_RECIPIENT_COMPLETED + DOCUMENT_COMPLETED deliveries now pass secret verification, dispatch correctly, and the handler updates state (or warns gracefully when the documensoId is unknown). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,12 @@
|
||||
import { createHmac } from 'crypto';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
export function verifyDocumensoSignature(
|
||||
payload: string,
|
||||
signature: string,
|
||||
secret: string,
|
||||
): boolean {
|
||||
const hmac = createHmac('sha256', secret).update(payload).digest('hex');
|
||||
// Documenso (v1.13 + 2.x) authenticates outbound webhooks by sending the
|
||||
// configured secret in plaintext via the `X-Documenso-Secret` header.
|
||||
// There is no HMAC. Compare the provided value timing-safely to the env secret.
|
||||
export function verifyDocumensoSecret(provided: string, expected: string): boolean {
|
||||
if (!provided || provided.length !== expected.length) return false;
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(hmac), Buffer.from(signature));
|
||||
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -635,3 +635,128 @@ export async function handleDocumentExpired(eventData: { documentId: string }) {
|
||||
|
||||
emitToRoom(`port:${doc.portId}`, 'document:expired', { documentId: doc.id });
|
||||
}
|
||||
|
||||
export async function handleDocumentOpened(eventData: {
|
||||
documentId: string;
|
||||
recipientEmail: string;
|
||||
signatureHash?: string;
|
||||
}) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
|
||||
const [signer] = await db
|
||||
.select()
|
||||
.from(documentSigners)
|
||||
.where(
|
||||
and(
|
||||
eq(documentSigners.documentId, doc.id),
|
||||
eq(documentSigners.signerEmail, eventData.recipientEmail),
|
||||
),
|
||||
);
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
documentId: doc.id,
|
||||
eventType: 'viewed',
|
||||
signerId: signer?.id ?? null,
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { recipientEmail: eventData.recipientEmail },
|
||||
});
|
||||
|
||||
emitToRoom(`port:${doc.portId}`, 'document:signer:opened', {
|
||||
documentId: doc.id,
|
||||
signerEmail: eventData.recipientEmail,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleDocumentRejected(eventData: {
|
||||
documentId: string;
|
||||
recipientEmail?: string;
|
||||
signatureHash?: string;
|
||||
}) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
|
||||
let signerId: string | null = null;
|
||||
if (eventData.recipientEmail) {
|
||||
const [signer] = await db
|
||||
.update(documentSigners)
|
||||
.set({ status: 'declined' })
|
||||
.where(
|
||||
and(
|
||||
eq(documentSigners.documentId, doc.id),
|
||||
eq(documentSigners.signerEmail, eventData.recipientEmail),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
signerId = signer?.id ?? null;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'rejected', updatedAt: new Date() })
|
||||
.where(eq(documents.id, doc.id));
|
||||
|
||||
if (doc.interestId && doc.documentType === 'eoi') {
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ eoiStatus: 'rejected', updatedAt: new Date() })
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
documentId: doc.id,
|
||||
eventType: 'rejected',
|
||||
signerId,
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { recipientEmail: eventData.recipientEmail ?? null },
|
||||
});
|
||||
|
||||
emitToRoom(`port:${doc.portId}`, 'document:rejected', {
|
||||
documentId: doc.id,
|
||||
signerEmail: eventData.recipientEmail ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleDocumentCancelled(eventData: {
|
||||
documentId: string;
|
||||
signatureHash?: string;
|
||||
}) {
|
||||
const doc = await db.query.documents.findFirst({
|
||||
where: eq(documents.documensoId, eventData.documentId),
|
||||
});
|
||||
if (!doc) {
|
||||
logger.warn({ documensoId: eventData.documentId }, 'Document not found for webhook');
|
||||
return;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(documents)
|
||||
.set({ status: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(documents.id, doc.id));
|
||||
|
||||
if (doc.interestId && doc.documentType === 'eoi') {
|
||||
await db
|
||||
.update(interests)
|
||||
.set({ eoiStatus: 'cancelled', updatedAt: new Date() })
|
||||
.where(eq(interests.id, doc.interestId));
|
||||
}
|
||||
|
||||
await db.insert(documentEvents).values({
|
||||
documentId: doc.id,
|
||||
eventType: 'cancelled',
|
||||
signatureHash: eventData.signatureHash ?? null,
|
||||
eventData: { documensoId: eventData.documentId },
|
||||
});
|
||||
|
||||
emitToRoom(`port:${doc.portId}`, 'document:cancelled', { documentId: doc.id });
|
||||
}
|
||||
|
||||
@@ -143,6 +143,9 @@ export interface ServerToClientEvents {
|
||||
clientName?: string;
|
||||
}) => void;
|
||||
'document:expired': (payload: { documentId: string }) => void;
|
||||
'document:cancelled': (payload: { documentId: string }) => void;
|
||||
'document:rejected': (payload: { documentId: string; signerEmail?: string | null }) => void;
|
||||
'document:signer:opened': (payload: { documentId: string; signerEmail?: string }) => void;
|
||||
'document:reminderSent': (payload: { documentId: string; recipientEmail: string }) => void;
|
||||
|
||||
// Document template events
|
||||
|
||||
Reference in New Issue
Block a user