chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from 'vitest';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { assertNotPrivateUrl } from './assert-webhook-url';
const fakeLookup = (addresses: Array<{ address: string; family: number }>) => {
return vi.fn().mockResolvedValue(addresses);
};
const fakeLookupSingle = (address: string, family: number) => {
return vi.fn().mockResolvedValue({ address, family });
};
describe('assertNotPrivateUrl', () => {
describe('static URL checks', () => {
it('should throw for localhost URLs', async () => {
await expect(assertNotPrivateUrl('http://localhost:3000')).rejects.toThrow(AppError);
});
it('should throw for 127.0.0.1', async () => {
await expect(assertNotPrivateUrl('http://127.0.0.1')).rejects.toThrow(AppError);
});
it('should throw for private IPs before DNS lookup', async () => {
await expect(assertNotPrivateUrl('http://10.0.0.1')).rejects.toThrow(AppError);
await expect(assertNotPrivateUrl('http://192.168.1.1')).rejects.toThrow(AppError);
});
it('should throw with WEBHOOK_INVALID_REQUEST error code', async () => {
try {
await assertNotPrivateUrl('http://localhost');
expect.unreachable('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(AppError);
expect((err as AppError).code).toBe(AppErrorCode.WEBHOOK_INVALID_REQUEST);
}
});
});
describe('DNS resolution checks', () => {
it('should throw when hostname resolves to a private IPv4 address', async () => {
const lookup = fakeLookup([{ address: '127.0.0.1', family: 4 }]);
await expect(assertNotPrivateUrl('https://evil.example.com', { lookup })).rejects.toThrow(AppError);
});
it('should throw when hostname resolves to a private IPv6 address', async () => {
const lookup = fakeLookup([{ address: '::1', family: 6 }]);
await expect(assertNotPrivateUrl('https://evil.example.com', { lookup })).rejects.toThrow(AppError);
});
it('should throw when any resolved address is private', async () => {
const lookup = fakeLookup([
{ address: '8.8.8.8', family: 4 },
{ address: '127.0.0.1', family: 4 },
]);
await expect(assertNotPrivateUrl('https://evil.example.com', { lookup })).rejects.toThrow(AppError);
});
it('should allow hostnames that resolve to public addresses', async () => {
const lookup = fakeLookup([{ address: '93.184.216.34', family: 4 }]);
await expect(assertNotPrivateUrl('https://example.com', { lookup })).resolves.toBeUndefined();
});
it('should handle a single address result (non-array)', async () => {
const lookup = fakeLookupSingle('10.0.0.1', 4);
await expect(assertNotPrivateUrl('https://evil.example.com', { lookup })).rejects.toThrow(AppError);
});
it('should handle a single public address result', async () => {
const lookup = fakeLookupSingle('93.184.216.34', 4);
await expect(assertNotPrivateUrl('https://example.com', { lookup })).resolves.toBeUndefined();
});
});
describe('IP address URLs skip DNS', () => {
it('should not perform DNS lookup for IP address URLs', async () => {
const lookup = vi.fn();
await assertNotPrivateUrl('http://8.8.8.8', { lookup });
expect(lookup).not.toHaveBeenCalled();
});
});
describe('DNS failure handling', () => {
it('should silently allow when DNS lookup throws', async () => {
const lookup = vi.fn().mockRejectedValue(new Error('ENOTFOUND'));
await expect(assertNotPrivateUrl('https://nonexistent.example.com', { lookup })).resolves.toBeUndefined();
});
it('should re-throw AppError even within the catch block', async () => {
const lookup = fakeLookup([{ address: '192.168.0.1', family: 4 }]);
await expect(assertNotPrivateUrl('https://evil.example.com', { lookup })).rejects.toThrow(AppError);
});
it('should silently allow when DNS lookup times out (returns null)', async () => {
const lookup = vi.fn().mockReturnValue(new Promise(() => {}));
// withTimeout races the lookup against a 250ms timer and returns null
// if the lookup doesn't settle in time, so the function returns early.
await expect(assertNotPrivateUrl('https://slow.example.com', { lookup })).resolves.toBeUndefined();
}, 10_000);
});
});
@@ -0,0 +1,130 @@
import { lookup } from 'node:dns/promises';
import { z } from 'zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { withTimeout } from '../../utils/timeout';
import { isPrivateUrl } from './is-private-url';
const ZIpSchema = z.string().ip();
const WEBHOOK_DNS_LOOKUP_TIMEOUT_MS = 250;
type TLookupAddress = {
address: string;
family: number;
};
type TLookupFn = (
hostname: string,
options: {
all: true;
verbatim: true;
},
) => Promise<TLookupAddress[] | TLookupAddress>;
const normalizeHostname = (hostname: string) => hostname.toLowerCase().replace(/\.+$/, '');
const toAddressUrl = (address: string) => (address.includes(':') ? `http://[${address}]` : `http://${address}`);
/**
* Parse the NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS environment variable into
* a Set of lowercased hostnames/IPs that are allowed to resolve to private
* addresses. The Set is built once at module load and never changes.
*
* Empty or unset = no bypasses (safe default).
*/
const webhookSSRFBypassHosts = (): Set<string> => {
const raw = process.env['NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS'] ?? '';
const hosts = new Set<string>();
for (const entry of raw.split(',')) {
const trimmed = entry.trim().toLowerCase();
if (trimmed.length > 0) {
hosts.add(trimmed);
}
}
return hosts;
};
const WEBHOOK_SSRF_BYPASS_HOSTS = webhookSSRFBypassHosts();
/**
* Check whether the hostname of the given URL is present in the SSRF bypass
* list. Matches against URL.hostname which covers both DNS names and raw IP
* addresses uniformly.
*/
const isBypassedHost = (url: string): boolean => {
if (WEBHOOK_SSRF_BYPASS_HOSTS.size === 0) {
return false;
}
try {
const hostname = normalizeHostname(new URL(url).hostname);
return WEBHOOK_SSRF_BYPASS_HOSTS.has(hostname);
} catch {
return false;
}
};
/**
* Asserts that a webhook URL does not resolve to a private or loopback
* address. Throws an AppError with WEBHOOK_INVALID_REQUEST if it does.
*
* Hosts listed in NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
*/
export const assertNotPrivateUrl = async (
url: string,
options?: {
lookup?: TLookupFn;
},
) => {
if (isBypassedHost(url)) {
return;
}
if (isPrivateUrl(url)) {
throw new AppError(AppErrorCode.WEBHOOK_INVALID_REQUEST, {
message: 'Webhook URL resolves to a private or loopback address',
});
}
try {
const hostname = normalizeHostname(new URL(url).hostname);
if (hostname.length === 0 || ZIpSchema.safeParse(hostname).success) {
return;
}
const resolveHostname = options?.lookup ?? lookup;
const lookupResult = await withTimeout(
resolveHostname(hostname, {
all: true,
verbatim: true,
}),
WEBHOOK_DNS_LOOKUP_TIMEOUT_MS,
);
if (!lookupResult) {
return;
}
const addresses = Array.isArray(lookupResult) ? lookupResult : [lookupResult];
if (addresses.some(({ address }) => isPrivateUrl(toAddressUrl(address)))) {
throw new AppError(AppErrorCode.WEBHOOK_INVALID_REQUEST, {
message: 'Webhook URL resolves to a private or loopback address',
});
}
} catch (err) {
if (err instanceof AppError) {
throw err;
}
return;
}
};
@@ -1,6 +1,5 @@
import type { WebhookTriggerEvents } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { WebhookTriggerEvents } from '@prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { AppError, AppErrorCode } from '../../errors/app-error';
@@ -1,6 +1,5 @@
import type { Prisma } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { Prisma } from '@prisma/client';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
import { buildTeamWhereQuery } from '../../utils/teams';
@@ -0,0 +1,60 @@
import type { Prisma } from '@prisma/client';
import { fetchWithTimeout } from '../../utils/timeout';
import { assertNotPrivateUrl } from './assert-webhook-url';
const WEBHOOK_TIMEOUT_MS = 10_000;
export type WebhookCallResult = {
success: boolean;
responseCode: number;
responseBody: Prisma.InputJsonValue | Prisma.JsonNullValueInput;
responseHeaders: Record<string, string>;
};
const parseBody = (text: string): Prisma.InputJsonValue => {
try {
return JSON.parse(text);
} catch {
return text;
}
};
export const executeWebhookCall = async (options: {
url: string;
body: unknown;
secret: string | null;
}): Promise<WebhookCallResult> => {
const { url, body, secret } = options;
try {
await assertNotPrivateUrl(url);
const response = await fetchWithTimeout(url, {
method: 'POST',
body: JSON.stringify(body),
redirect: 'manual',
timeoutMs: WEBHOOK_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
'X-Documenso-Secret': secret ?? '',
},
});
const text = await response.text();
return {
success: response.ok,
responseCode: response.status,
responseBody: parseBody(text),
responseHeaders: Object.fromEntries(response.headers.entries()),
};
} catch (err) {
return {
success: false,
responseCode: 0,
responseBody: err instanceof Error ? err.message : 'Unknown error',
responseHeaders: {},
};
}
};
@@ -1,6 +1,5 @@
import type { WebhookTriggerEvents } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import type { WebhookTriggerEvents } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
@@ -10,11 +9,7 @@ export type GetAllWebhooksByEventTriggerOptions = {
teamId: number;
};
export const getAllWebhooksByEventTrigger = async ({
event,
userId,
teamId,
}: GetAllWebhooksByEventTriggerOptions) => {
export const getAllWebhooksByEventTrigger = async ({ event, userId, teamId }: GetAllWebhooksByEventTriggerOptions) => {
return prisma.webhook.findMany({
where: {
enabled: true,
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest';
import { isPrivateUrl } from './is-private-url';
describe('isPrivateUrl', () => {
describe('localhost', () => {
it('should detect localhost', () => {
expect(isPrivateUrl('http://localhost')).toBe(true);
expect(isPrivateUrl('http://localhost:3000')).toBe(true);
expect(isPrivateUrl('https://localhost/path')).toBe(true);
});
it('should detect localhost with trailing dot', () => {
expect(isPrivateUrl('http://localhost.')).toBe(true);
});
it('should be case insensitive', () => {
expect(isPrivateUrl('http://LOCALHOST')).toBe(true);
expect(isPrivateUrl('http://Localhost:8080')).toBe(true);
});
});
describe('IPv4 loopback', () => {
it('should detect 127.0.0.1', () => {
expect(isPrivateUrl('http://127.0.0.1')).toBe(true);
expect(isPrivateUrl('http://127.0.0.1:8080')).toBe(true);
});
it('should detect the full 127.x.x.x range', () => {
expect(isPrivateUrl('http://127.0.0.2')).toBe(true);
expect(isPrivateUrl('http://127.255.255.255')).toBe(true);
});
});
describe('IPv4 private ranges', () => {
it('should detect 10.x.x.x', () => {
expect(isPrivateUrl('http://10.0.0.1')).toBe(true);
expect(isPrivateUrl('http://10.255.255.255')).toBe(true);
});
it('should detect 172.16.0.0/12', () => {
expect(isPrivateUrl('http://172.16.0.1')).toBe(true);
expect(isPrivateUrl('http://172.31.255.255')).toBe(true);
});
it('should not flag 172.x outside the /12 range', () => {
expect(isPrivateUrl('http://172.15.0.1')).toBe(false);
expect(isPrivateUrl('http://172.32.0.1')).toBe(false);
});
it('should detect 192.168.x.x', () => {
expect(isPrivateUrl('http://192.168.0.1')).toBe(true);
expect(isPrivateUrl('http://192.168.255.255')).toBe(true);
});
it('should detect link-local 169.254.x.x', () => {
expect(isPrivateUrl('http://169.254.1.1')).toBe(true);
});
it('should detect 0.0.0.0', () => {
expect(isPrivateUrl('http://0.0.0.0')).toBe(true);
});
});
describe('IPv6', () => {
it('should detect ::1 loopback', () => {
expect(isPrivateUrl('http://[::1]')).toBe(true);
expect(isPrivateUrl('http://[::1]:3000')).toBe(true);
});
it('should detect :: unspecified', () => {
expect(isPrivateUrl('http://[::]')).toBe(true);
});
it('should detect link-local fe80:', () => {
expect(isPrivateUrl('http://[fe80::1]')).toBe(true);
});
it('should detect unique local fc/fd', () => {
expect(isPrivateUrl('http://[fc00::1]')).toBe(true);
expect(isPrivateUrl('http://[fd12::1]')).toBe(true);
});
it('should not catch IPv4-mapped IPv6 in URL form (URL parser normalizes to hex)', () => {
// new URL() normalizes "::ffff:127.0.0.1" to "::ffff:7f00:1" which none
// of the checks handle. This is fine because dns.lookup never returns
// IPv4-mapped addresses — it returns plain IPv4 (family: 4) instead.
expect(isPrivateUrl('http://[::ffff:127.0.0.1]')).toBe(false);
expect(isPrivateUrl('http://[::ffff:10.0.0.1]')).toBe(false);
expect(isPrivateUrl('http://[::ffff:8.8.8.8]')).toBe(false);
});
});
describe('public URLs', () => {
it('should allow public hostnames', () => {
expect(isPrivateUrl('https://example.com')).toBe(false);
expect(isPrivateUrl('https://api.documenso.com/webhook')).toBe(false);
});
it('should allow public IP addresses', () => {
expect(isPrivateUrl('http://8.8.8.8')).toBe(false);
expect(isPrivateUrl('http://1.1.1.1')).toBe(false);
expect(isPrivateUrl('http://203.0.113.1')).toBe(false);
});
});
describe('edge cases', () => {
it('should return false for invalid URLs', () => {
expect(isPrivateUrl('not-a-url')).toBe(false);
expect(isPrivateUrl('')).toBe(false);
});
});
});
@@ -0,0 +1,82 @@
import { z } from 'zod';
const ZIpSchema = z.string().ip();
/**
* Check whether a URL points to a known private/loopback address.
*
* Performs a synchronous check against known private hostnames and IP ranges.
* Works regardless of the URL protocol.
*/
export const isPrivateUrl = (url: string): boolean => {
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
// Strip IPv6 brackets.
const bare = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname;
const normalizedHost = bare.replace(/\.+$/, '');
if (normalizedHost === 'localhost') {
return true;
}
const parsedIp = ZIpSchema.safeParse(normalizedHost);
if (!parsedIp.success) {
return false;
}
if (normalizedHost === '::1' || normalizedHost === '::') {
return true;
}
if (normalizedHost === '0.0.0.0') {
return true;
}
if (normalizedHost.startsWith('127.')) {
return true;
}
if (normalizedHost.startsWith('10.')) {
return true;
}
if (normalizedHost.startsWith('192.168.')) {
return true;
}
if (normalizedHost.startsWith('169.254.')) {
return true;
}
if (normalizedHost.startsWith('fe80:')) {
return true;
}
if (normalizedHost.startsWith('fc') || normalizedHost.startsWith('fd')) {
return true;
}
// 172.16.0.0/12
if (normalizedHost.startsWith('172.')) {
const second = parseInt(normalizedHost.split('.')[1], 10);
if (second >= 16 && second <= 31) {
return true;
}
}
// IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
const v4Mapped = normalizedHost.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
if (v4Mapped) {
return isPrivateUrl(`http://${v4Mapped[1]}`);
}
return false;
} catch {
return false;
}
};
@@ -11,12 +11,7 @@ export type TriggerTestWebhookOptions = {
teamId: number;
};
export const triggerTestWebhook = async ({
id,
event,
userId,
teamId,
}: TriggerTestWebhookOptions) => {
export const triggerTestWebhook = async ({ id, event, userId, teamId }: TriggerTestWebhookOptions) => {
const webhook = await getWebhookById({ id, userId, teamId });
if (!webhook.enabled) {
@@ -32,7 +27,7 @@ export const triggerTestWebhook = async ({
try {
await triggerWebhook({
event,
data: samplePayload,
data: samplePayload.payload,
userId,
teamId,
});
@@ -13,10 +13,7 @@ import {
import type { WebhookPayload } from '../../../types/webhook-payload';
export const generateSampleWebhookPayload = (
event: WebhookTriggerEvents,
webhookUrl: string,
): WebhookPayload => {
export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhookUrl: string): WebhookPayload => {
const now = new Date();
const basePayload = {
id: 10,
@@ -61,7 +58,8 @@ export const generateSampleWebhookPayload = (
name: 'John Doe',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -81,7 +79,8 @@ export const generateSampleWebhookPayload = (
name: 'John Doe',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -120,7 +119,8 @@ export const generateSampleWebhookPayload = (
role: RecipientRole.VIEWER,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -139,7 +139,8 @@ export const generateSampleWebhookPayload = (
role: RecipientRole.SIGNER,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
rejectionReason: null,
@@ -168,7 +169,8 @@ export const generateSampleWebhookPayload = (
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -185,7 +187,8 @@ export const generateSampleWebhookPayload = (
readStatus: ReadStatus.OPENED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
signingOrder: 1,
@@ -222,7 +225,8 @@ export const generateSampleWebhookPayload = (
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signingOrder: 1,
rejectionReason: null,
},
@@ -243,7 +247,8 @@ export const generateSampleWebhookPayload = (
signingStatus: SigningStatus.SIGNED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signingOrder: 1,
rejectionReason: null,
},
@@ -270,7 +275,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 2',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: now,
authOptions: {
accessAuth: null,
@@ -291,7 +297,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 1',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: now,
authOptions: {
accessAuth: null,
@@ -314,7 +321,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 2',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: now,
authOptions: {
accessAuth: null,
@@ -335,7 +343,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 1',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: now,
authOptions: {
accessAuth: null,
@@ -374,7 +383,8 @@ export const generateSampleWebhookPayload = (
signingStatus: SigningStatus.REJECTED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signingOrder: 1,
},
],
@@ -391,7 +401,8 @@ export const generateSampleWebhookPayload = (
signingStatus: SigningStatus.REJECTED,
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signingOrder: 1,
},
],
@@ -425,6 +436,8 @@ export const generateSampleWebhookPayload = (
recipientRemoved: true,
documentCompleted: true,
ownerDocumentCompleted: true,
ownerRecipientExpired: true,
ownerDocumentCreated: true,
recipientSigningRequest: true,
},
},
@@ -437,7 +450,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer 1',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: {
accessAuth: null,
@@ -460,7 +474,8 @@ export const generateSampleWebhookPayload = (
name: 'Signer',
token: 'SIGNING_TOKEN',
documentDeletedAt: null,
expired: null,
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: {
accessAuth: null,
@@ -480,5 +495,167 @@ export const generateSampleWebhookPayload = (
};
}
if (event === WebhookTriggerEvents.RECIPIENT_EXPIRED) {
const expiresAt = new Date(now.getTime() - 60 * 1000); // Expired 1 minute ago
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
email: 'signer1@documenso.com',
name: 'Signer 1',
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expiresAt,
expirationNotifiedAt: now,
signedAt: null,
authOptions: null,
signingOrder: 1,
rejectionReason: null,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
Recipient: [
{
...basePayload.Recipient[0],
email: 'signer1@documenso.com',
name: 'Signer 1',
sendStatus: SendStatus.SENT,
documentDeletedAt: null,
expiresAt,
expirationNotifiedAt: now,
signedAt: null,
authOptions: null,
signingOrder: 1,
rejectionReason: null,
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
Recipient: [
{
...basePayload.recipients[0],
readStatus: ReadStatus.OPENED,
signingStatus: SigningStatus.SIGNED,
signedAt: now,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.DOCUMENT_REMINDER_SENT) {
return {
event,
payload: {
...basePayload,
status: DocumentStatus.PENDING,
recipients: [
{
...basePayload.recipients[0],
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
Recipient: [
{
...basePayload.recipients[0],
sendStatus: SendStatus.SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
],
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_CREATED) {
return {
event,
payload: {
...basePayload,
title: 'My Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_UPDATED) {
return {
event,
payload: {
...basePayload,
title: 'My Updated Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_DELETED) {
return {
event,
payload: {
...basePayload,
title: 'Deleted Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
if (event === WebhookTriggerEvents.TEMPLATE_USED) {
return {
event,
payload: {
...basePayload,
title: 'Document from Template',
status: DocumentStatus.DRAFT,
templateId: 10,
source: DocumentSource.TEMPLATE,
},
createdAt: now.toISOString(),
webhookEndpoint: webhookUrl,
};
}
throw new Error(`Unsupported event type: ${event}`);
};
@@ -55,8 +55,5 @@ export const handlerTriggerWebhooks = async (req: Request) => {
}),
);
return Response.json(
{ success: true, message: 'Webhooks queued for execution' },
{ status: 200 },
);
return Response.json({ success: true, message: 'Webhooks queued for execution' }, { status: 200 });
};
@@ -1,6 +1,5 @@
import { EnvelopeType, type Webhook } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, type Webhook } from '@prisma/client';
import { mapSecondaryIdToDocumentId } from '../../../utils/envelope';
import { getWebhooksByTeamId } from '../get-webhooks-by-team-id';
@@ -67,7 +66,9 @@ export const listDocumentsHandler = async (req: Request) => {
name: recipient.name,
token: recipient.token,
documentDeletedAt: recipient.documentDeletedAt,
expired: recipient.expired,
expired: recipient.expired, // !: deprecated Not in use. To be removed in a future migration.
expiresAt: recipient.expiresAt,
expirationNotifiedAt: recipient.expirationNotifiedAt,
signedAt: recipient.signedAt,
authOptions: recipient.authOptions,
signingOrder: recipient.signingOrder,
@@ -1,5 +1,7 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { assertNotPrivateUrl } from '../assert-webhook-url';
import { validateApiToken } from './validateApiToken';
export const subscribeHandler = async (req: Request) => {
@@ -12,6 +14,8 @@ export const subscribeHandler = async (req: Request) => {
const { webhookUrl, eventTrigger } = await req.json();
await assertNotPrivateUrl(webhookUrl);
const result = await validateApiToken({ authorization });
const createdWebhook = await prisma.webhook.create({
@@ -27,6 +31,10 @@ export const subscribeHandler = async (req: Request) => {
return Response.json(createdWebhook);
} catch (err) {
if (err instanceof AppError) {
return Response.json({ message: err.message }, { status: 400 });
}
console.error(err);
return Response.json(