Merge branch 'main' into fix/update-stripe-team-member-billing

This commit is contained in:
David Nguyen
2026-07-02 14:36:48 +10:00
381 changed files with 19943 additions and 3648 deletions
@@ -18,6 +18,7 @@ export const getDocumentStats = async () => {
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.CANCELLED]: 0,
[ExtendedDocumentStatus.ALL]: 0,
};
@@ -31,9 +31,13 @@ export const loadRecipientBrandingByTeamId = async ({
billingEnabled ? getOrganisationClaimByTeamId({ teamId }).catch(() => null) : Promise.resolve(null),
]);
const allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
let allowCustomBranding = !billingEnabled || claim?.flags?.embedSigningWhiteLabel === true;
const hidePoweredBy = !billingEnabled || claim?.flags?.hidePoweredBy === true;
if (!settings.brandingEnabled) {
allowCustomBranding = false;
}
if (!allowCustomBranding) {
return {
allowCustomBranding: false,
@@ -8,7 +8,10 @@ import type { SupportedLanguageCodes } from '../../constants/i18n';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentEmailSettings } from '../../types/document-email';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleDictateNextSigner } from '../signature-level/assert-compatible-dictate-next-signer';
import { assertCompatibleSigningOrder } from '../signature-level/assert-compatible-signing-order';
export type CreateDocumentMetaOptions = {
userId: number;
@@ -73,6 +76,22 @@ export const updateDocumentMeta = async ({
});
}
await assertEnvelopeMutable(envelope);
if (signingOrder !== undefined) {
assertCompatibleSigningOrder({
signatureLevel: envelope.signatureLevel,
signingOrder,
});
}
if (allowDictateNextSigner !== undefined) {
assertCompatibleDictateNextSigner({
signatureLevel: envelope.signatureLevel,
allowDictateNextSigner,
});
}
const { documentMeta: originalDocumentMeta } = envelope;
// Validate the emailId belongs to the organisation.
@@ -92,6 +111,8 @@ export const updateDocumentMeta = async ({
}
return await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const upsertedDocumentMeta = await tx.documentMeta.update({
where: {
id: envelope.documentMetaId,
@@ -0,0 +1,129 @@
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, WebhookTriggerEvents } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { isMemberManagerOrAbove } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { getMemberRoles } from '../team/get-member-roles';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CancelDocumentOptions = {
id: EnvelopeIdOptions;
userId: number;
teamId: number;
reason?: string;
requestMetadata: ApiRequestMetadata;
};
export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => {
// Resolve the envelope through the visibility-aware helper so the caller must
// have read access (ownership OR team membership with sufficient visibility OR
// team-email). This prevents cancelling a document the caller cannot see.
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id,
userId,
teamId,
type: EnvelopeType.DOCUMENT,
});
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
include: {
recipients: true,
documentMeta: true,
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
const isUserOwner = envelope.userId === userId;
const teamRole = await getMemberRoles({
teamId: envelope.teamId,
reference: {
type: 'User',
id: userId,
},
})
.then((roles) => roles.teamRole)
.catch(() => null);
const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole);
// The document is visible to the caller, but cancelling requires elevated permissions.
if (!isUserOwner && !isPrivilegedTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed',
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Only pending documents can be cancelled',
});
}
const updatedEnvelope = await prisma.$transaction(async (tx) => {
const updated = await tx.envelope.update({
where: {
id: envelope.id,
},
data: {
status: DocumentStatus.CANCELLED,
completedAt: new Date(),
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CANCELLED,
metadata: requestMetadata,
data: {
reason,
},
}),
});
return updated;
});
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// Send cancellation emails to recipients via the resilient background job.
await jobs.triggerJob({
name: 'send.document.cancelled.emails',
payload: {
documentId: legacyDocumentId,
cancellationReason: reason,
requestMetadata: requestMetadata.requestMetadata,
},
});
// Trigger the webhook with the updated (cancelled) envelope payload.
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(
mapEnvelopeToWebhookDocumentPayload({
...envelope,
status: updatedEnvelope.status,
completedAt: updatedEnvelope.completedAt,
}),
),
userId,
teamId,
});
return updatedEnvelope;
};
@@ -29,7 +29,6 @@ import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { isRecipientAuthorized } from './is-recipient-authorized';
import { sendPendingEmail } from './send-pending-email';
export type CompleteDocumentWithTokenOptions = {
token: string;
@@ -389,7 +388,13 @@ export const completeDocumentWithToken = async ({
});
if (pendingRecipients.length > 0) {
await sendPendingEmail({ id, recipientId: recipient.id });
await jobs.triggerJob({
name: 'send.document.pending.email',
payload: {
envelopeId: envelope.id,
recipientId: recipient.id,
},
});
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const [nextRecipient] = pendingRecipients;
@@ -1,13 +1,9 @@
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import type { DocumentMeta, Envelope, Recipient, User } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole, SendStatus, WebhookTriggerEvents } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
@@ -16,9 +12,8 @@ import { isDocumentCompleted } from '../../utils/document';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getMemberRoles } from '../team/get-member-roles';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type DeleteDocumentOptions = {
@@ -41,7 +36,9 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
});
}
// Note: This is an unsafe request, we validate the ownership later in the function.
// Note: This is an unsafe request. It is used purely to resolve the recipient
// self-hide path below. The authoritative delete authorization is performed
// via the visibility-aware `getEnvelopeWhereInput` helper.
const envelope = await prisma.envelope.findUnique({
where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
include: {
@@ -56,36 +53,47 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De
});
}
const isUserTeamMember = await getMemberRoles({
teamId: envelope.teamId,
reference: {
type: 'User',
id: userId,
},
// Determine whether the user has authorized delete access using the
// visibility-aware helper. This enforces ownership OR (team membership AND
// the document's visibility is permitted for the member's role) OR team-email
// access. A bare team member without sufficient visibility will resolve to
// null here and therefore must not be able to delete the document.
const hasDeleteAccess = await getEnvelopeWhereInput({
id,
userId,
teamId,
type: EnvelopeType.DOCUMENT,
})
.then(() => true)
.then(({ envelopeWhereInput }) =>
prisma.envelope.findFirst({
where: envelopeWhereInput,
select: { id: true },
}),
)
.then((result) => Boolean(result))
.catch(() => false);
const isUserOwner = envelope.userId === userId;
const userRecipient = envelope.recipients.find((recipient) => recipient.email === user.email);
if (!isUserOwner && !isUserTeamMember && !userRecipient) {
if (!hasDeleteAccess && !userRecipient) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Not allowed',
});
}
// Handle hard or soft deleting the actual document if user has permission.
if (isUserOwner || isUserTeamMember) {
await handleDocumentOwnerDelete({
if (hasDeleteAccess) {
const updatedEnvelope = await handleDocumentOwnerDelete({
envelope,
user,
requestMetadata,
});
const envelopeForWebhook = { ...envelope, ...(updatedEnvelope ?? {}) };
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CANCELLED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelope)),
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeForWebhook)),
userId,
teamId,
});
@@ -125,7 +133,7 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
return;
}
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
const { emailLanguage, emailsDisabled } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
@@ -192,50 +200,40 @@ const handleDocumentOwnerDelete = async ({ envelope, user, requestMetadata }: Ha
return deletedEnvelope;
}
// Send cancellation emails to recipients.
await Promise.all(
envelope.recipients.map(async (recipient) => {
if (
recipient.sendStatus !== SendStatus.SENT ||
!isRecipientEmailValidForSending(recipient) ||
recipient.role === RecipientRole.CC
) {
return;
}
// Enqueue cancellation emails as a background job. The envelope (and its
// documentMeta) is hard-deleted above, so the job can't look it up later —
// pass a self-contained payload with the recipients to notify.
const recipientsToNotify = envelope.recipients
.filter(
(recipient) =>
recipient.sendStatus === SendStatus.SENT &&
recipient.role !== RecipientRole.CC &&
isRecipientEmailValidForSending(recipient),
)
.map((recipient) => ({
email: recipient.email,
name: recipient.name,
}));
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentCancelTemplate, {
if (recipientsToNotify.length > 0) {
await jobs.triggerJob({
name: 'send.document.deleted.emails',
payload: {
teamId: envelope.teamId,
documentName: envelope.title,
inviterName: user.name || undefined,
inviterEmail: user.email,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document Cancelled`),
html,
text,
});
}),
);
meta: envelope.documentMeta
? {
emailId: envelope.documentMeta.emailId,
emailReplyTo: envelope.documentMeta.emailReplyTo,
language: emailLanguage,
}
: null,
recipients: recipientsToNotify,
},
});
}
return deletedEnvelope;
};
@@ -220,7 +220,11 @@ export const findDocuments = async ({
eb.or([
eb('Envelope.userId', '=', user.id),
eb.and([
eb('Envelope.status', 'in', [sql.lit(DocumentStatus.COMPLETED), sql.lit(DocumentStatus.PENDING)]),
eb('Envelope.status', 'in', [
sql.lit(DocumentStatus.COMPLETED),
sql.lit(DocumentStatus.PENDING),
sql.lit(DocumentStatus.CANCELLED),
]),
recipientExists(eb, user.email),
]),
]),
@@ -291,6 +295,16 @@ export const findDocuments = async ({
]),
),
)
.with(ExtendedDocumentStatus.CANCELLED, () =>
qb
.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED))
.where((eb) =>
eb.and([
personalDeletedFilter(eb),
eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]),
]),
),
)
.exhaustive();
};
@@ -429,6 +443,18 @@ export const findDocuments = async ({
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
}),
)
.with(ExtendedDocumentStatus.CANCELLED, () =>
qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.CANCELLED)).where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', teamData.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
}),
)
.exhaustive();
};
+18 -2
View File
@@ -239,6 +239,20 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// CANCELLED: team-owned cancelled + team-email received cancelled docs
const cancelledQuery = buildBaseQuery()
.where('Envelope.status', '=', sql.lit(DocumentStatus.CANCELLED))
.where((eb) => {
const accessBranches = [eb('Envelope.teamId', '=', team.id)];
if (teamEmail) {
accessBranches.push(senderEmailIs(eb, teamEmail));
accessBranches.push(recipientExists(eb, teamEmail));
}
return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]);
});
// INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient
// Returns 0 if the team has no team email.
const inboxQuery = teamEmail
@@ -260,21 +274,23 @@ export const getStats = async ({ userId, teamId, period, search = '', folderId,
// ─── Execute all counts in parallel ──────────────────────────────────
const [draft, pending, completed, rejected, inbox] = await Promise.all([
const [draft, pending, completed, rejected, cancelled, inbox] = await Promise.all([
cappedCount(draftQuery),
cappedCount(pendingQuery),
cappedCount(completedQuery),
cappedCount(rejectedQuery),
cappedCount(cancelledQuery),
inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0),
]);
const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP);
const all = Math.min(draft + pending + completed + rejected + cancelled + inbox, STATS_COUNT_CAP);
const stats: Record<ExtendedDocumentStatus, number> = {
[ExtendedDocumentStatus.DRAFT]: draft,
[ExtendedDocumentStatus.PENDING]: pending,
[ExtendedDocumentStatus.COMPLETED]: completed,
[ExtendedDocumentStatus.REJECTED]: rejected,
[ExtendedDocumentStatus.CANCELLED]: cancelled,
[ExtendedDocumentStatus.INBOX]: inbox,
[ExtendedDocumentStatus.ALL]: all,
};
@@ -0,0 +1,215 @@
// This is closely related to `reject-document-with-token.ts` but is intentionally
// kept as a separate method rather than merged into one. This file focuses on
// rejection from an API/programmatic perspective (an authenticated API user acting
// on behalf of a recipient), whereas `reject-document-with-token.ts` focuses on it
// from a recipient perspective (the recipient rejecting via their token).
//
// Code changes in one should probably be mirrored to the other, particularly in
// relation to the jobs triggered after a rejection.
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type RejectDocumentOnBehalfOfOptions = {
/**
* The ID of the envelope the recipient belongs to. Required so the caller
* targets an explicit envelope/recipient combination rather than resolving the
* envelope implicitly from the recipient ID.
*/
envelopeId: string;
recipientId: number;
userId: number;
teamId: number;
reason: string;
/**
* The email of a team member to attribute the rejection to. Must be a member
* of the team. When omitted the rejection is attributed to the API user that
* owns the token (`userId`).
*
* This exists so external applications can elect which team member is acting
* on behalf of the recipient, rather than always defaulting to the API user.
*/
actAsEmail?: string;
requestMetadata: ApiRequestMetadata;
};
/**
* Reject a document on behalf of a recipient as an authenticated API user.
*
* This is used to programmatically record a rejection for cases where the
* recipient declined to sign outside of the platform (e.g. before ever
* reaching it). The rejection is flagged as `isExternal` in the audit log to
* distinguish it from a rejection performed by the recipient directly.
*
* The action can optionally be attributed to a specific team member via
* `actAsEmail`; otherwise it is attributed to the API user.
*/
export async function rejectDocumentOnBehalfOf({
envelopeId,
recipientId,
userId,
teamId,
reason,
actAsEmail,
requestMetadata,
}: RejectDocumentOnBehalfOfOptions) {
// Build the access-controlled envelope query. This enforces team membership
// AND document visibility (and owner / team-email access), mirroring the
// canonical envelope access checks used across the app.
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
type: EnvelopeType.DOCUMENT,
userId,
teamId,
});
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelope: envelopeWhereInput,
},
include: {
envelope: true,
},
});
const envelope = recipient?.envelope;
if (!recipient || !envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document or recipient not found',
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document ${envelope.id} must be pending to reject`,
});
}
if (recipient.signingStatus !== SigningStatus.NOT_SIGNED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Recipient ${recipient.id} has already actioned this document`,
});
}
assertRecipientNotExpired(recipient);
// Resolve the user the rejection should be attributed to. When `actAsEmail`
// is supplied it must resolve to a member of the team; otherwise the rejection
// is attributed to the API user that owns the token.
const electedUser = await getValidatedElectedUser({ actAsEmail, teamId });
const actingUser = electedUser ?? (await prisma.user.findFirstOrThrow({ where: { id: userId } }));
// Update the recipient status to rejected and record an external rejection
// audit log within the same transaction.
const [updatedRecipient] = await prisma.$transaction([
prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signedAt: new Date(),
signingStatus: SigningStatus.REJECTED,
rejectionReason: reason,
},
}),
prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
envelopeId: envelope.id,
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED,
// Always attribute the audit log to a concrete user: the elected team
// member when supplied, otherwise the API user that owns the token.
user: { id: actingUser.id, email: actingUser.email, name: actingUser.name },
metadata: requestMetadata,
data: {
recipientEmail: recipient.email,
recipientName: recipient.name,
recipientId: recipient.id,
recipientRole: recipient.role,
reason,
isExternal: true,
// Only set when a member was explicitly elected via `actAsEmail`.
onBehalfOfUserEmail: electedUser?.email,
onBehalfOfUserName: electedUser?.name,
},
}),
}),
]);
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
// Trigger the seal document job to process the document asynchronously.
await jobs.triggerJob({
name: 'internal.seal-document',
payload: {
documentId: legacyDocumentId,
requestMetadata: requestMetadata.requestMetadata,
},
});
// Send email notifications to the rejecting recipient.
await jobs.triggerJob({
name: 'send.signing.rejected.emails',
payload: {
recipientId: recipient.id,
documentId: legacyDocumentId,
},
});
// Send cancellation emails to other recipients.
await jobs.triggerJob({
name: 'send.document.cancelled.emails',
payload: {
documentId: legacyDocumentId,
cancellationReason: reason,
requestMetadata: requestMetadata.requestMetadata,
},
});
return updatedRecipient;
}
/**
* Resolve and validate the team member elected via `actAsEmail`. Returns `null`
* when no `actAsEmail` is supplied (the rejection is then attributed to the API
* user). Throws when the email does not resolve to a member of the team.
*/
const getValidatedElectedUser = async ({ actAsEmail, teamId }: { actAsEmail?: string; teamId: number }) => {
if (!actAsEmail) {
return null;
}
const electedUser = await prisma.user.findFirst({
where: {
email: actAsEmail,
},
});
if (!electedUser) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'The user to act on behalf of must be a member of the team',
});
}
const isTeamMember = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId: electedUser.id }),
});
if (!isTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'The user to act on behalf of must be a member of the team',
});
}
return electedUser;
};
@@ -1,3 +1,11 @@
// This is closely related to `reject-document-on-behalf-of.ts` but is intentionally
// kept as a separate method rather than merged into one. This file focuses on
// rejection from a recipient perspective (the recipient rejecting via their token),
// whereas `reject-document-on-behalf-of.ts` focuses on it from an API/programmatic
// perspective (an authenticated API user acting on behalf of a recipient).
//
// Code changes in one should probably be mirrored to the other, particularly in
// relation to the jobs triggered after a rejection.
import { jobs } from '@documenso/lib/jobs/client';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
@@ -13,6 +13,7 @@ import {
EnvelopeType,
OrganisationType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
@@ -30,6 +31,7 @@ import { buildEnvelopeEmailHeaders } from '../email/build-envelope-email-headers
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { updateRecipientNextReminder } from '../recipient/update-recipient-next-reminder';
import { assertUserNotDisabled } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -117,7 +119,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
});
}
// Refresh the expiresAt on each resent recipient.
const expiresAt = resolveExpiresAt(envelope.documentMeta?.envelopeExpirationPeriod ?? null);
const recipientsToRemind = envelope.recipients.filter(
@@ -127,7 +128,6 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
recipient.role !== RecipientRole.CC,
);
// Extend the expiration deadline for recipients being resent.
if (expiresAt && recipientsToRemind.length > 0) {
await prisma.recipient.updateMany({
where: {
@@ -142,6 +142,22 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
});
}
// A manual resend restarts the reminder cycle from scratch, mirroring the
// initial send, so a recipient that hit the threshold can be reminded again.
const resentAt = new Date();
await Promise.all(
recipientsToRemind.map((recipient) =>
updateRecipientNextReminder({
recipientId: recipient.id,
envelopeId: envelope.id,
sentAt: resentAt,
lastReminderSentAt: null,
resetReminderCount: true,
}),
),
);
const isRecipientSigningRequestEmailEnabled = extractDerivedDocumentEmailSettings(
envelope.documentMeta,
).recipientSigningRequest;
@@ -276,6 +292,18 @@ export const resendDocument = async ({ id, userId, recipients, teamId, requestMe
}),
});
// Mark the recipient as sent if they were not already sent.
await prisma.recipient.updateMany({
where: {
id: recipient.id,
sendStatus: SendStatus.NOT_SENT,
},
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
},
});
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
@@ -1,3 +1,4 @@
import { materializeTspAnchorsForEnvelope } from '@documenso/ee/server-only/signing/csc/materialize-anchors';
import { resolveExpiresAt } from '@documenso/lib/constants/envelope-expiration';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@@ -29,6 +30,7 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import { isTspEnvelope } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putNormalizedPdfFileServerSide } from '../../universal/upload/put-file.server';
@@ -124,7 +126,26 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
const signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
let signingOrder = envelope.documentMeta?.signingOrder || DocumentSigningOrder.PARALLEL;
if (isTspEnvelope(envelope) && signingOrder === DocumentSigningOrder.PARALLEL && envelope.documentMeta) {
console.warn(
`[CSC] Coercing signingOrder=PARALLEL → SEQUENTIAL for ${envelope.signatureLevel} envelope ${envelope.id} at send time. The schema-layer guard should have caught this earlier.`,
);
await prisma.documentMeta.update({
where: {
id: envelope.documentMeta.id,
},
data: {
signingOrder: DocumentSigningOrder.SEQUENTIAL,
},
});
signingOrder = DocumentSigningOrder.SEQUENTIAL;
envelope.documentMeta.signingOrder = DocumentSigningOrder.SEQUENTIAL;
}
let recipientsToNotify = envelope.recipients;
@@ -139,7 +160,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
throw new Error('Missing envelope items');
}
if (envelope.formValues) {
if (envelope.formValues && envelope.status === DocumentStatus.DRAFT) {
await Promise.all(
envelope.envelopeItems.map(async (envelopeItem) => {
await injectFormValuesIntoDocument(envelope, envelopeItem);
@@ -225,6 +246,12 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
}
}
if (isTspEnvelope(envelope) && envelope.status === DocumentStatus.DRAFT) {
await materializeTspAnchorsForEnvelope({
envelopeId: envelope.id,
});
}
const updatedEnvelope = await prisma.$transaction(async (tx) => {
if (envelope.status === DocumentStatus.DRAFT) {
await tx.documentAuditLog.create({
@@ -1,107 +0,0 @@
import { DocumentPendingEmailTemplate } from '@documenso/email/templates/document-pending';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
export interface SendPendingEmailOptions {
id: EnvelopeIdOptions;
recipientId: number;
}
export const sendPendingEmail = async ({ id, recipientId }: SendPendingEmailOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT),
recipients: {
some: {
id: recipientId,
},
},
},
include: {
recipients: {
where: {
id: recipientId,
},
},
documentMeta: true,
},
});
if (!envelope) {
throw new Error('Document not found');
}
if (envelope.recipients.length === 0) {
throw new Error('Document has no recipients');
}
const { branding, emailLanguage, senderEmail, replyToEmail, emailsDisabled, emailTransport } = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
// Don't send any emails if the organisation has email sending disabled.
if (emailsDisabled) {
return;
}
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).documentPending;
if (!isDocumentPendingEmailEnabled) {
return;
}
const [recipient] = envelope.recipients;
const { email, name } = recipient;
// Skip sending email if recipient has no email address
if (!isRecipientEmailValidForSending(recipient)) {
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(DocumentPendingEmailTemplate, {
documentName: envelope.title,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: {
address: email,
name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Waiting for others to complete signing.`),
html,
text,
});
};
@@ -12,6 +12,7 @@ import { EmailDomainStatus, type OrganisationClaim, type OrganisationGlobalSetti
import type { Transporter } from 'nodemailer';
import { match, P } from 'ts-pattern';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { DOCUMENSO_INTERNAL_EMAIL } from '../../constants/email';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { logger } from '../../utils/logger';
@@ -108,7 +109,6 @@ export const getEmailContext = async (options: GetEmailContextOptions): Promise<
// "no transport". Surface it (alertable) before silently falling back to the
// system mailer + Documenso sender, so the degraded organisation is findable.
if (emailContext.claims.emailTransportId && !transportResolution) {
// Todo: Logging
logger.error({
msg: 'Configured email transport could not be resolved; falling back to the system mailer',
emailTransportId: emailContext.claims.emailTransportId,
@@ -217,13 +217,21 @@ const handleOrganisationEmailContext = async (organisationId: string) => {
const allowedEmails = getAllowedEmails(organisation);
const branding = organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
claims.flags.hidePoweredBy ?? false,
);
const allowBrandedEmailColors = !IS_BILLING_ENABLED() || claims.flags.embedSigningWhiteLabel === true;
if (!allowBrandedEmailColors) {
branding.brandingColors = undefined;
}
return {
allowedEmails,
branding: organisationGlobalSettingsToBranding(
organisation.organisationGlobalSettings,
organisation.id,
claims.flags.hidePoweredBy ?? false,
),
branding,
settings: organisation.organisationGlobalSettings,
claims,
emailsDisabled: organisation.owner.disabled || claims.flags.disableEmails === true,
@@ -273,9 +281,17 @@ const handleTeamEmailContext = async (teamId: number) => {
const teamSettings = extractDerivedTeamSettings(organisation.organisationGlobalSettings, team.teamGlobalSettings);
const branding = teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false);
const allowBrandedEmailColors = !IS_BILLING_ENABLED() || claims.flags.embedSigningWhiteLabel === true;
if (!allowBrandedEmailColors) {
branding.brandingColors = undefined;
}
return {
allowedEmails,
branding: teamGlobalSettingsToBranding(teamSettings, teamId, claims.flags.hidePoweredBy ?? false),
branding,
settings: teamSettings,
claims,
emailsDisabled: organisation.owner.disabled || claims.flags.disableEmails === true,
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type CreateAttachmentOptions = {
envelopeId: string;
@@ -15,11 +15,15 @@ export type CreateAttachmentOptions = {
};
export const createAttachment = async ({ envelopeId, teamId, userId, data }: CreateAttachmentOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
userId,
teamId,
type: null,
});
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
where: envelopeWhereInput,
});
if (!envelope) {
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type DeleteAttachmentOptions = {
id: string;
@@ -14,9 +14,6 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
@@ -29,6 +26,24 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: attachment.envelopeId },
userId,
teamId,
type: null,
});
// Additional validation to check the user has visibility-aware access to the envelope.
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
@@ -1,7 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type FindAttachmentsByEnvelopeIdOptions = {
envelopeId: string;
@@ -14,11 +14,15 @@ export const findAttachmentsByEnvelopeId = async ({
userId,
teamId,
}: FindAttachmentsByEnvelopeIdOptions) => {
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
userId,
teamId,
type: null,
});
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
team: buildTeamWhereQuery({ teamId, userId }),
},
where: envelopeWhereInput,
});
if (!envelope) {
@@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { DocumentStatus } from '@prisma/client';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type UpdateAttachmentOptions = {
id: string;
@@ -15,9 +15,6 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac
const attachment = await prisma.envelopeAttachment.findFirst({
where: {
id,
envelope: {
team: buildTeamWhereQuery({ teamId, userId }),
},
},
include: {
envelope: true,
@@ -30,6 +27,24 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: attachment.envelopeId },
userId,
teamId,
type: null,
});
// Additional validation to check the user has visibility-aware access to the envelope.
const envelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Attachment not found',
});
}
if (
attachment.envelope.status === DocumentStatus.COMPLETED ||
attachment.envelope.status === DocumentStatus.REJECTED
@@ -6,6 +6,7 @@ import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-
import { prisma } from '@documenso/prisma';
import type { Envelope, Field, Recipient } from '@prisma/client';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { convertPlaceholdersToFieldInputs, extractPdfPlaceholders } from '../pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '../pdf/helpers';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
@@ -96,6 +97,8 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
let didFieldsChange = false;
const updatedEnvelopeItem = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const updatedItem = await tx.envelopeItem.update({
where: {
id: envelopeItemId,
@@ -0,0 +1,83 @@
import { DocumentStatus, type Envelope, type Prisma } from '@prisma/client';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { isTspEnvelope } from '../../types/signature-level';
type EnvelopeMutableSnapshot = {
signatureLevel: string;
status: DocumentStatus;
};
type EnvelopeIdRef = Pick<Envelope, 'id'>;
/**
* Reject authoring mutations on an AES/QES envelope past DRAFT.
*
* The TSP mutation lock fires at distribution so the owner cannot replace the
* PDF between a recipient completing service-scope OAuth (against PDF_v1) and
* clicking Sign (now against PDF_v2). The SAD would authorise PDF_v2's digest
* while the recipient viewed PDF_v1 — a WYSIWYS break.
*
* SES envelopes pass through unchanged. The existing per-route guards still
* enforce COMPLETED/REJECTED rejection for them.
*
* Call this **twice** at every TSP-eligible authoring route:
*
* 1. Outside the transaction with the pre-fetched envelope snapshot —
* `assertEnvelopeMutable(envelope)` — fast-fail without a DB round-trip.
* 2. Inside the transaction with `tx` — `assertEnvelopeMutable(envelope, tx)`
* — re-fetches under the transaction's snapshot, closing the TOCTOU
* window against a concurrent `sendDocument` committing DRAFT → PENDING
* between the snapshot read and the mutation.
*
* Throws:
* - `ENVELOPE_TSP_LOCKED` when the envelope is PENDING (the case unique to
* the TSP lock — SES routes happily allow PENDING).
* - `ENVELOPE_COMPLETED` / `ENVELOPE_REJECTED` / `ENVELOPE_CANCELLED` for those
* terminal states, to stay consistent with the existing envelope-state error
* vocabulary.
*/
export function assertEnvelopeMutable(envelope: EnvelopeMutableSnapshot): Promise<void>;
export function assertEnvelopeMutable(envelope: EnvelopeIdRef, tx: Prisma.TransactionClient): Promise<void>;
export async function assertEnvelopeMutable(
envelope: EnvelopeMutableSnapshot | EnvelopeIdRef,
tx?: Prisma.TransactionClient,
): Promise<void> {
if (tx) {
return await refetchAndAssert(tx, (envelope as EnvelopeIdRef).id);
}
assertSnapshotMutable(envelope as EnvelopeMutableSnapshot);
}
const refetchAndAssert = async (tx: Prisma.TransactionClient, envelopeId: string): Promise<void> => {
const refetched = await tx.envelope.findFirstOrThrow({
where: { id: envelopeId },
select: { signatureLevel: true, status: true },
});
assertSnapshotMutable(refetched);
};
const assertSnapshotMutable = (envelope: EnvelopeMutableSnapshot): void => {
if (!isTspEnvelope(envelope)) {
return;
}
if (envelope.status === DocumentStatus.DRAFT) {
return;
}
const errorCode = match(envelope.status)
.with(DocumentStatus.PENDING, () => AppErrorCode.ENVELOPE_TSP_LOCKED)
.with(DocumentStatus.COMPLETED, () => AppErrorCode.ENVELOPE_COMPLETED)
.with(DocumentStatus.REJECTED, () => AppErrorCode.ENVELOPE_REJECTED)
.with(DocumentStatus.CANCELLED, () => AppErrorCode.ENVELOPE_CANCELLED)
.otherwise(() => AppErrorCode.INVALID_REQUEST);
throw new AppError(errorCode, {
message: `Envelope is locked — AES/QES envelopes cannot be modified after leaving DRAFT (current status: ${envelope.status}).`,
});
};
@@ -29,6 +29,7 @@ import type {
import type { TDocumentFormValues } from '../../types/document-form-values';
import type { TEnvelopeAttachmentType } from '../../types/envelope-attachment';
import type { TFieldAndMeta } from '../../types/field-meta';
import type { TSignatureLevel } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
@@ -37,6 +38,8 @@ import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../uti
import { buildTeamWhereQuery } from '../../utils/teams';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { getTeamSettings } from '../team/get-team-settings';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -89,6 +92,7 @@ export type CreateEnvelopeOptions = {
recipients?: CreateEnvelopeRecipientOptions[];
folderId?: string;
delegatedDocumentOwner?: string;
signatureLevel?: TSignatureLevel;
};
attachments?: Array<{
label: string;
@@ -137,8 +141,14 @@ export const createEnvelope = async ({
publicDescription,
visibility: visibilityOverride,
delegatedDocumentOwner,
signatureLevel: requestedSignatureLevel,
} = data;
const signatureLevel = resolveSignatureLevel({
requested: requestedSignatureLevel,
strict: true,
});
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
include: {
@@ -195,6 +205,17 @@ export const createEnvelope = async ({
});
}
// CSC / TSP signing flows assume the V2 envelope shape: per-recipient
// anchors, materialised PDF lineage, sequential signing, mutation lock.
// The legacy V1 (Document) model can't carry that state, so AES/QES on V1
// is structurally unsupported and must fail at create time — not later at
// sign or seal time when the cause is harder to attribute.
if (signatureLevel !== 'SES' && internalVersion === 1) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Envelopes signed at '${signatureLevel}' require internalVersion=2; the legacy V1 envelope shape cannot host TSP signing.`,
});
}
let envelopeItems = data.envelopeItems;
// Todo: Envelopes - Remove
@@ -255,6 +276,10 @@ export const createEnvelope = async ({
});
}
for (const recipient of data.recipients ?? []) {
assertCompatibleRecipientRole({ signatureLevel, role: recipient.role });
}
const visibility = visibilityOverride || settings.documentVisibility;
const emailId = meta?.emailId;
@@ -311,10 +336,14 @@ export const createEnvelope = async ({
const [documentMeta, secondaryId, delegatedOwner] = await Promise.all([
prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {
...meta,
timezone: timezoneToUse,
}),
data: extractDerivedDocumentMeta(
settings,
{
...meta,
timezone: timezoneToUse,
},
signatureLevel,
),
}),
type === EnvelopeType.DOCUMENT
? incrementDocumentId().then((v) => v.formattedDocumentId)
@@ -331,6 +360,7 @@ export const createEnvelope = async ({
internalVersion,
type,
title,
signatureLevel,
qrToken: prefixedId('qr'),
externalId,
envelopeItems: {
@@ -4,12 +4,14 @@ import pMap from 'p-map';
import { omit } from 'remeda';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { ZSignatureLevelSchema } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import { nanoid, prefixedId } from '../../universal/id';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export interface DuplicateEnvelopeOptions {
@@ -40,6 +42,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
title: true,
userId: true,
internalVersion: true,
signatureLevel: true,
templateType: true,
publicTitle: true,
publicDescription: true,
@@ -116,12 +119,21 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
? 'PRIVATE'
: (envelope.templateType ?? undefined);
// The source level is a free-form TEXT column — parse defensively before
// handing to the resolver. Coerce (not strict) because instance mode may have
// changed since the source envelope was created.
const duplicatedSignatureLevel = resolveSignatureLevel({
requested: ZSignatureLevelSchema.parse(envelope.signatureLevel),
strict: false,
});
const duplicatedEnvelope = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
secondaryId,
type: targetType,
internalVersion: envelope.internalVersion,
signatureLevel: duplicatedSignatureLevel,
userId,
teamId,
title: envelope.title + ' (copy)',
@@ -36,6 +36,7 @@ export const ZEnvelopeForSigningResponse = z.object({
authOptions: true,
userId: true,
teamId: true,
signatureLevel: true,
}).extend({
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
@@ -15,7 +15,10 @@ import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../uti
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
import { assertCompatibleDictateNextSigner } from '../signature-level/assert-compatible-dictate-next-signer';
import { assertCompatibleSigningOrder } from '../signature-level/assert-compatible-signing-order';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { assertEnvelopeMutable } from './assert-envelope-mutable';
import { getEnvelopeWhereInput } from './get-envelope-by-id';
export type UpdateEnvelopeOptions = {
@@ -76,6 +79,22 @@ export const updateEnvelope = async ({
});
}
assertEnvelopeMutable(envelope);
if (meta.signingOrder !== undefined) {
assertCompatibleSigningOrder({
signatureLevel: envelope.signatureLevel,
signingOrder: meta.signingOrder,
});
}
if (meta.allowDictateNextSigner !== undefined) {
assertCompatibleDictateNextSigner({
signatureLevel: envelope.signatureLevel,
allowDictateNextSigner: meta.allowDictateNextSigner,
});
}
if (envelope.type !== EnvelopeType.TEMPLATE && (data.publicTitle || data.publicDescription || data.templateType)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'You cannot update the template fields for document type envelopes',
@@ -297,6 +316,8 @@ export const updateEnvelope = async ({
// }
const updatedEnvelope = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const result = await tx.envelope.update({
where: {
id: envelope.id,
@@ -12,6 +12,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { type BoundingBox, whiteoutRegions } from '../pdf/auto-place-fields';
@@ -93,6 +94,8 @@ export const createEnvelopeFields = async ({
});
}
assertEnvelopeMutable(envelope);
if (envelope.type === EnvelopeType.DOCUMENT && envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete',
@@ -242,6 +245,8 @@ export const createEnvelopeFields = async ({
});
const createdFields = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const newlyCreatedFields = await tx.field.createManyAndReturn({
data: validatedFields.map((field) => ({
type: field.type,
@@ -6,6 +6,7 @@ import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface DeleteDocumentFieldOptions {
@@ -59,6 +60,8 @@ export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMeta
});
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
@@ -81,6 +84,8 @@ export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMeta
}
return await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
const deletedField = await tx.field.delete({
where: {
id: fieldId,
@@ -9,6 +9,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientFieldsBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export interface UpdateEnvelopeFieldsOptions {
@@ -60,6 +61,8 @@ export const updateEnvelopeFields = async ({
});
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete',
@@ -115,6 +118,8 @@ export const updateEnvelopeFields = async ({
});
const updatedFields = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
return await Promise.all(
fieldsToUpdate.map(async ({ originalField, updateData, recipientEmail }) => {
const updatedField = await tx.field.update({
@@ -0,0 +1,69 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { LicenseFlag, TCachedLicense } from '../../types/license';
import { env } from '../../utils/env';
import { LicenseClient } from './license-client';
type AssertLicensedForOptions = {
/**
* Override the AppError code thrown when the assertion fails.
*
* Defaults to `AppErrorCode.FORBIDDEN`. Callers that need a more specific
* surface — for example the CSC transport throwing `CSC_UNLICENSED` at
* transport-create time — pass their own code here.
*/
errorCode?: string;
/**
* Override the AppError message thrown when the assertion fails.
*/
message?: string;
};
/**
* Assert the configured Documenso licence grants `flag`. Reads the
* {@link LicenseClient} cache; never re-pings the licence server.
*
* - No `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` → throws. No licensing intent.
* - Key set, claim unverifiable (no client, null cache, read throws,
* `license: null`) → passes. Mirrors how org-claim gates keep running on
* last known state when the licence server is unreachable; paying
* operators shouldn't be locked out by transient infra.
* - Key set, claim loaded and denies the flag (bad standing or flag falsy)
* → throws.
*/
export const assertLicensedFor = async (flag: LicenseFlag, options?: AssertLicensedForOptions): Promise<void> => {
const denied = (): AppError =>
new AppError(options?.errorCode ?? AppErrorCode.FORBIDDEN, {
message: options?.message ?? `License does not include the "${flag}" feature.`,
});
// No licence key configured = no licensing intent. Fail closed unconditionally
// so unlicensed instances cannot reach gated features simply because the
// licence cache is empty.
if (!env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY')) {
throw denied();
}
let cached: TCachedLicense | null = null;
const licenseClient = LicenseClient.getInstance();
if (licenseClient) {
cached = await licenseClient?.getCachedLicense().catch(() => null);
}
// Licence key is configured but we have no positively-verified claim to
// check. Fail-open — see block comment for the full set of conditions and
// rationale.
if (!cached?.license) {
return;
}
const inGoodStanding = cached.derivedStatus === 'ACTIVE' || cached.derivedStatus === 'PAST_DUE';
const flagGranted = Boolean(cached.license.flags[flag]);
if (!inGoodStanding || !flagGranted) {
throw denied();
}
};
@@ -14,6 +14,7 @@ import { renderSVG } from 'uqr';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { APP_I18N_OPTIONS } from '../../constants/i18n';
import { getSignatureFontFamily } from '../../constants/pdf';
import { RECIPIENT_ROLE_SIGNING_REASONS, RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
import type { TDocumentAuditLogBaseSchema } from '../../types/document-audit-logs';
import { svgToPng } from '../../utils/images/svg-to-png';
@@ -302,7 +303,7 @@ const renderColumnTwo = (options: RenderColumnOptions) => {
x: 2,
text: recipient.signatureField?.signature?.typedSignature,
padding: 4,
fontFamily: 'Caveat',
fontFamily: getSignatureFontFamily(recipient.signatureField?.signature?.typedSignature),
fontSize: 16,
align: 'center',
verticalAlign: 'middle',
@@ -1,44 +0,0 @@
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { hashString } from '../auth/hash';
export const getUserByApiToken = async ({ token }: { token: string }) => {
const hashedToken = hashString(token);
const user = await prisma.user.findFirst({
where: {
apiTokens: {
some: {
token: hashedToken,
},
},
},
include: {
apiTokens: true,
},
});
if (!user) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid token',
statusCode: 401,
});
}
const retrievedToken = user.apiTokens.find((apiToken) => apiToken.token === hashedToken);
// This should be impossible but we need to satisfy TypeScript
if (!retrievedToken) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Invalid token',
statusCode: 401,
});
}
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
throw new Error('Expired token');
}
return user;
};
@@ -3,6 +3,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobsClient } from '../../jobs/client';
import { generateDatabaseId } from '../../universal/id';
import { currentMonthlyPeriod } from '../../universal/monthly-period';
import { getQuotaAlertKind } from './get-quota-alert-kind';
import type { LimitCounter } from './types';
type CheckMonthlyQuotaOptions = {
@@ -56,29 +57,33 @@ export const checkMonthlyQuota = async (opts: CheckMonthlyQuotaOptions): Promise
const newCount = latestMonthlyStat[column];
const previousCount = newCount - opts.count;
const isOverQuota = newCount > opts.quota;
// Returns 'quota' on the single request that reached (or jumped past) the quota,
// 'quotaNearing' on the single request that reached the warning threshold,
// otherwise null. See getQuotaAlertKind for the exactly-once guarantee.
const alertKind = getQuotaAlertKind({
previousCount,
newCount,
quota: opts.quota,
});
// Only notify on the single request that crossed the threshold: the count was
// at/under quota before this request and over it after. Because the DB
// serializes the atomic increment, the post-increment values are distinct and
// monotonic, so exactly one request's (previousCount, newCount] interval
// contains the quota boundary — guaranteeing the notification fires once.
const didCrossQuota = isOverQuota && previousCount <= opts.quota;
if (didCrossQuota) {
// Trigger the alert before the over-quota check — the 'quota' alert usually fires
// on the successful request that consumes the last unit of allowance, but when a
// batch jumps past the boundary it fires on this rejected request. Either way it
// will never fire again this period, so it must be enqueued before any throw.
if (alertKind) {
await jobsClient
.triggerJob({
name: 'send.organisation-limit-exceeded.email',
name: 'send.organisation-limit-alert.email',
payload: {
organisationId: opts.organisationId,
counter: opts.counter,
kind: 'quota',
kind: alertKind,
period,
},
})
.catch((error) => {
console.error({
msg: 'Failed to send organisation limit exceeded email',
msg: 'Failed to send organisation limit alert email',
error,
});
@@ -86,7 +91,7 @@ export const checkMonthlyQuota = async (opts: CheckMonthlyQuotaOptions): Promise
});
}
if (isOverQuota) {
if (newCount > opts.quota) {
throw new AppError(AppErrorCode.TOO_MANY_REQUESTS, {
message:
'Your request could not be completed at this time due to your account exceeding the fair use limits of your current plan. Please contact support.',
@@ -1,7 +1,12 @@
import { QUOTA_WARNING_THRESHOLD } from './get-quota-alert-kind';
export type QuotaFlags = {
isDocumentQuotaExceeded: boolean;
isEmailQuotaExceeded: boolean;
isApiQuotaExceeded: boolean;
isDocumentQuotaNearing: boolean;
isEmailQuotaNearing: boolean;
isApiQuotaNearing: boolean;
};
type ComputeQuotaFlagsOptions = {
@@ -20,11 +25,6 @@ type ComputeQuotaFlagsOptions = {
/**
* A quota of `null` means unlimited (never exceeded). A quota of `0` means
* blocked (always exceeded). Otherwise usage `>=` quota is exceeded.
*
* Note: this `>=` is intentionally the "reached" signal for the banner and is
* distinct from enforcement in `check-monthly-quota.ts`, which blocks the
* action that crosses the boundary using a strict `>` on the post-increment
* count. Do not "align" them — they answer different questions.
*/
const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
if (quota === null) {
@@ -38,10 +38,30 @@ const isQuotaExceeded = (quota: number | null, usage: number): boolean => {
return usage >= quota;
};
/**
* A counter is "nearing" its quota once usage reaches the warning threshold
* (80% of the quota, rounded up) but has not yet been exceeded. Nearing and
* exceeded are mutually exclusive per counter.
*/
const isQuotaNearing = (quota: number | null, usage: number): boolean => {
if (quota === null || quota === 0) {
return false;
}
if (isQuotaExceeded(quota, usage)) {
return false;
}
return usage >= Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
};
export const computeQuotaFlags = ({ quotas, usage }: ComputeQuotaFlagsOptions): QuotaFlags => {
return {
isDocumentQuotaExceeded: isQuotaExceeded(quotas.documentQuota, usage?.documentCount ?? 0),
isEmailQuotaExceeded: isQuotaExceeded(quotas.emailQuota, usage?.emailCount ?? 0),
isApiQuotaExceeded: isQuotaExceeded(quotas.apiQuota, usage?.apiCount ?? 0),
isDocumentQuotaNearing: isQuotaNearing(quotas.documentQuota, usage?.documentCount ?? 0),
isEmailQuotaNearing: isQuotaNearing(quotas.emailQuota, usage?.emailCount ?? 0),
isApiQuotaNearing: isQuotaNearing(quotas.apiQuota, usage?.apiCount ?? 0),
};
};
@@ -0,0 +1,40 @@
export const QUOTA_WARNING_THRESHOLD = 0.8;
export type QuotaAlertKind = 'quota' | 'quotaNearing';
type GetQuotaAlertKindOptions = {
previousCount: number;
newCount: number;
quota: number;
};
/**
* Determines whether the request that moved the counter from `previousCount` to
* `newCount` crossed an alert threshold.
*
* - 'quota': this request reached (or jumped past) the monthly quota.
* - 'quotaNearing': this request reached the warning threshold (80% of quota).
* - null: no threshold crossed by this request.
*
* Precondition: callers must handle `quota === null` (unlimited) and `quota === 0`
* (blocked) before calling — this function assumes a positive quota.
*/
export const getQuotaAlertKind = (opts: GetQuotaAlertKindOptions): QuotaAlertKind | null => {
const { previousCount, newCount, quota } = opts;
if (newCount >= quota) {
// Only the single request that reached the quota boundary should alert. If the
// same request also skipped past the warning threshold, the quota alert
// supersedes the warning.
return previousCount < quota ? 'quota' : null;
}
// From here newCount < quota, so for tiny quotas (1-4) where the rounded-up
// warning threshold equals the quota itself, the warning can never fire — the
// exhausting request is handled by the quota branch above.
const warningCount = Math.ceil(quota * QUOTA_WARNING_THRESHOLD);
const didCrossWarning = newCount >= warningCount && previousCount < warningCount;
return didCrossWarning ? 'quotaNearing' : null;
};
@@ -10,7 +10,9 @@ import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface CreateEnvelopeRecipientsOptions {
userId: number;
@@ -63,6 +65,8 @@ export const createEnvelopeRecipients = async ({
});
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete',
@@ -80,12 +84,21 @@ export const createEnvelopeRecipients = async ({
});
}
for (const recipient of recipientsToCreate) {
assertCompatibleRecipientRole({
signatureLevel: envelope.signatureLevel,
role: recipient.role,
});
}
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
}));
const createdRecipients = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
return await Promise.all(
normalizedRecipients.map(async (recipient) => {
const authOptions = createRecipientAuthOptions({
@@ -1,23 +1,16 @@
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import { EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { createElement } from 'react';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { logger } from '../../utils/logger';
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEmailContext } from '../email/get-email-context';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
export interface DeleteEnvelopeRecipientOptions {
userId: number;
@@ -72,6 +65,8 @@ export const deleteEnvelopeRecipient = async ({
});
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
@@ -109,6 +104,8 @@ export const deleteEnvelopeRecipient = async ({
});
const deletedRecipient = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
@@ -143,75 +140,16 @@ export const deleteEnvelopeRecipient = async ({
envelope.type === EnvelopeType.DOCUMENT &&
isRecipientEmailValidForSending(recipientToDelete)
) {
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: envelope.title,
inviterName: envelope.team?.name || user.name || undefined,
assetBaseUrl,
});
const {
branding,
emailLanguage,
senderEmail,
replyToEmail,
organisationId,
claims,
emailsDisabled,
emailTransport,
} = await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId: envelope.teamId,
},
meta: envelope.documentMeta,
});
// Don't send the removal email if the organisation has email sending disabled.
if (emailsDisabled) {
return deletedRecipient;
}
// Meter the removal email against the organisation email quota/stats.
// Add/remove churn can be used to blast unsolicited removal emails
// outside the email limits.
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
});
} catch (_err) {
logger.warn({
msg: 'Recipient removed email dropped: org email limit exceeded',
organisationId,
recipientId: recipientToDelete.id,
// Enqueue the "removed from document" email as a background job so a
// transient mail outage doesn't fail the request and the send is retried.
await jobs.triggerJob({
name: 'send.recipient.removed.email',
payload: {
envelopeId: envelope.id,
});
return deletedRecipient;
}
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: {
address: recipientToDelete.email,
name: recipientToDelete.name,
recipientEmail: recipientToDelete.email,
recipientName: recipientToDelete.name,
inviterName: envelope.team?.name || user.name || undefined,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
}
@@ -4,6 +4,7 @@ import { EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type GetRecipientByIdOptions = {
recipientId: number;
@@ -41,6 +42,27 @@ export const getRecipientById = async ({ recipientId, userId, teamId, type }: Ge
});
}
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'envelopeId',
id: recipient.envelopeId,
},
type,
userId,
teamId,
});
// Additional validation to check visibility.
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
});
}
const legacyId = {
documentId: type === EnvelopeType.DOCUMENT ? mapSecondaryIdToDocumentId(recipient.envelope.secondaryId) : null,
templateId: type === EnvelopeType.TEMPLATE ? mapSecondaryIdToTemplateId(recipient.envelope.secondaryId) : null,
@@ -1,4 +1,3 @@
import RecipientRemovedFromDocumentTemplate from '@documenso/email/templates/recipient-removed-from-document';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
import { type TRecipientActionAuthTypes, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
@@ -7,23 +6,18 @@ import { nanoid } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData, diffRecipientChanges } from '@documenso/lib/utils/document-audit-logs';
import { createRecipientAuthOptions } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { msg } from '@lingui/core/macro';
import type { Recipient } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { createElement } from 'react';
import { isDeepEqual } from 'remeda';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { jobs } from '../../jobs/client';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { logger } from '../../utils/logger';
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface SetDocumentRecipientsOptions {
userId: number;
@@ -80,20 +74,12 @@ export const setDocumentRecipients = async ({
throw new Error('Document not found');
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new Error('Document already complete');
}
const { branding, emailLanguage, senderEmail, replyToEmail, organisationId, claims, emailsDisabled, emailTransport } =
await getEmailContext({
emailType: 'RECIPIENT',
source: {
type: 'team',
teamId,
},
meta: envelope.documentMeta,
});
const recipientsHaveActionAuth = recipients.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
@@ -105,6 +91,13 @@ export const setDocumentRecipients = async ({
});
}
for (const recipient of recipients) {
assertCompatibleRecipientRole({
signatureLevel: envelope.signatureLevel,
role: recipient.role,
});
}
const normalizedRecipients = recipients.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
@@ -139,6 +132,8 @@ export const setDocumentRecipients = async ({
});
const persistedRecipients = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
return await Promise.all(
linkedRecipients.map(async (recipient) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(recipient._persisted?.authOptions);
@@ -277,67 +272,29 @@ export const setDocumentRecipients = async ({
const isRecipientRemovedEmailEnabled = extractDerivedDocumentEmailSettings(envelope.documentMeta).recipientRemoved;
// Send emails to deleted recipients who have emails.
await Promise.all(
removedRecipients.map(async (recipient) => {
if (
emailsDisabled ||
recipient.sendStatus !== SendStatus.SENT ||
recipient.role === RecipientRole.CC ||
!isRecipientRemovedEmailEnabled ||
!isRecipientEmailValidForSending(recipient)
) {
return;
}
if (isRecipientRemovedEmailEnabled) {
await Promise.all(
removedRecipients.map(async (recipient) => {
if (
recipient.sendStatus !== SendStatus.SENT ||
recipient.role === RecipientRole.CC ||
!isRecipientEmailValidForSending(recipient)
) {
return;
}
// Meter against the organisation email quota/stats so add/remove churn
// can't be used to send unsolicited "removed" emails outside the limits.
try {
await assertOrganisationRatesAndLimits({
organisationId,
organisationClaim: claims,
type: 'email',
count: 1,
await jobs.triggerJob({
name: 'send.recipient.removed.email',
payload: {
envelopeId: envelope.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
inviterName: user.name || undefined,
},
});
} catch (_err) {
logger.warn({
msg: 'Recipient removed email dropped: org email limit exceeded',
organisationId,
recipientId: recipient.id,
envelopeId: envelope.id,
});
return;
}
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
const template = createElement(RecipientRemovedFromDocumentTemplate, {
documentName: envelope.title,
inviterName: user.name || undefined,
assetBaseUrl,
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: emailLanguage, branding }),
renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }),
]);
const i18n = await getI18nInstance(emailLanguage);
await emailTransport.sendMail({
to: {
address: recipient.email,
name: recipient.name,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`You have been removed from a document`),
html,
text,
});
}),
);
}),
);
}
}
// Filter out recipients that have been removed or have been updated.
@@ -12,6 +12,7 @@ import { nanoid } from '../../universal/id';
import { createRecipientAuthOptions } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export type SetTemplateRecipientsOptions = {
userId: number;
@@ -60,6 +61,13 @@ export const setTemplateRecipients = async ({ userId, teamId, id, recipients }:
});
}
for (const recipient of recipients) {
assertCompatibleRecipientRole({
signatureLevel: envelope.signatureLevel,
role: recipient.role,
});
}
const normalizedRecipients = recipients.map((recipient) => {
// Force replace any changes to the name or email of the direct recipient.
if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) {
@@ -12,7 +12,9 @@ import { extractLegacyIds } from '../../universal/id';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapFieldToLegacyField } from '../../utils/fields';
import { canRecipientBeModified } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface UpdateEnvelopeRecipientsOptions {
userId: number;
@@ -67,6 +69,8 @@ export const updateEnvelopeRecipients = async ({
});
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope already complete',
@@ -84,6 +88,17 @@ export const updateEnvelopeRecipients = async ({
});
}
for (const recipient of recipients) {
if (recipient.role === undefined) {
continue;
}
assertCompatibleRecipientRole({
signatureLevel: envelope.signatureLevel,
role: recipient.role,
});
}
const recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id);
@@ -106,6 +121,8 @@ export const updateEnvelopeRecipients = async ({
});
const updatedRecipients = await prisma.$transaction(async (tx) => {
await assertEnvelopeMutable(envelope, tx);
return await Promise.all(
recipientsToUpdate.map(async ({ originalRecipient, updateData }) => {
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
@@ -6,22 +6,20 @@ import { resolveNextReminderAt, ZEnvelopeReminderSettings } from '../../constant
/**
* Compute and store `nextReminderAt` for a single recipient.
*
* Call this after:
* - Sending the signing email (sentAt is set)
* - Sending a reminder (lastReminderSentAt is updated)
*
* If `reminderSettings` is provided it's used directly, avoiding a query.
* Otherwise it's read from the envelope's documentMeta (already resolved
* from the org/team cascade at envelope creation time).
* Pass `resetReminderCount: true` to restart the reminder cycle (e.g. on a
* manual resend): the count is zeroed and the schedule recomputed as if the
* request was freshly sent at `sentAt`.
*/
export const updateRecipientNextReminder = async (options: {
recipientId: number;
envelopeId: string;
sentAt: Date;
lastReminderSentAt: Date | null;
reminderCount?: number;
resetReminderCount?: boolean;
reminderSettings?: ReturnType<typeof ZEnvelopeReminderSettings.parse> | null;
}) => {
const { recipientId, envelopeId, sentAt, lastReminderSentAt } = options;
const { recipientId, envelopeId, sentAt, lastReminderSentAt, reminderCount = 0, resetReminderCount } = options;
let settings = options.reminderSettings;
@@ -40,11 +38,15 @@ export const updateRecipientNextReminder = async (options: {
config: settings,
sentAt,
lastReminderSentAt,
reminderCount: resetReminderCount ? 0 : reminderCount,
});
await prisma.recipient.update({
where: { id: recipientId },
data: { nextReminderAt },
data: {
nextReminderAt,
...(resetReminderCount ? { reminderCount: 0 } : {}),
},
});
};
@@ -82,7 +84,7 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
// Don't reschedule reminders for recipients whose deadline has passed.
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
select: { id: true, sentAt: true, lastReminderSentAt: true },
select: { id: true, sentAt: true, lastReminderSentAt: true, reminderCount: true },
});
await Promise.all(
@@ -95,6 +97,7 @@ export const recomputeNextReminderForEnvelope = async (envelopeId: string) => {
config: settings,
sentAt: recipient.sentAt,
lastReminderSentAt: recipient.lastReminderSentAt,
reminderCount: recipient.reminderCount,
});
await prisma.recipient.update({
@@ -5,6 +5,7 @@ import { match, P } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { alphaid } from '../../universal/id';
import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
export type CreateSharingIdOptions =
| {
@@ -27,6 +28,7 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha
),
select: {
id: true,
teamId: true,
},
});
@@ -46,6 +48,31 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha
.then((recipient) => recipient?.email);
})
.with({ userId: P.number }, async ({ userId }) => {
// Ensure the authenticated user actually has visibility-aware access to the
// envelope before allowing them to create a share link. The share route does
// not carry a teamId, so we derive it from the envelope and reuse the canonical
// visibility check (owner OR team member with sufficient visibility).
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: {
type: 'documentId',
id: documentId,
},
userId,
teamId: envelope.teamId,
type: EnvelopeType.DOCUMENT,
});
const accessibleEnvelope = await prisma.envelope.findFirst({
where: envelopeWhereInput,
select: {
id: true,
},
});
if (!accessibleEnvelope) {
throw new AppError(AppErrorCode.NOT_FOUND);
}
return await prisma.user
.findFirst({
where: {
@@ -0,0 +1,35 @@
import { AppError, AppErrorCode } from '../../errors/app-error';
import { isTspEnvelope } from '../../types/signature-level';
type AssertCompatibleDictateNextSignerOptions = {
signatureLevel: string;
allowDictateNextSigner: boolean | null | undefined;
};
/**
* Reject `allowDictateNextSigner = true` on AES/QES envelopes.
*
* The TSP sign path has no nextSigner dictation — `prepareCscRecipientSigning`
* doesn't accept one and `executeTspSign` always advances to the strict
* SEQUENTIAL next signer. Allowing the flag to persist on a TSP envelope
* would advertise a UX feature the sign-time flow silently drops.
*
* SES envelopes pass through unchanged. A `null` / `undefined` / `false`
* value also passes through.
*/
export const assertCompatibleDictateNextSigner = ({
signatureLevel,
allowDictateNextSigner,
}: AssertCompatibleDictateNextSignerOptions): void => {
if (!isTspEnvelope({ signatureLevel })) {
return;
}
if (allowDictateNextSigner !== true) {
return;
}
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Envelopes signed at '${signatureLevel}' do not support next-signer dictation — the TSP sign path always advances to the strict SEQUENTIAL next recipient.`,
});
};
@@ -0,0 +1,33 @@
import { RecipientRole } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { isTspEnvelope } from '../../types/signature-level';
type AssertCompatibleRecipientRoleOptions = {
signatureLevel: string;
role: RecipientRole;
};
/**
* Reject `RecipientRole.ASSISTANT` on AES/QES envelopes.
*
* Assistant recipients pre-fill fields on behalf of downstream signers. The
* TSP flow signs each recipient's complete PDF state with their own CSC
* credential, so an assistant role has no sign-time identity to bind to and
* `prepareCscRecipientSigning` has no handler for it.
*
* SES envelopes pass through unchanged.
*/
export const assertCompatibleRecipientRole = ({ signatureLevel, role }: AssertCompatibleRecipientRoleOptions): void => {
if (!isTspEnvelope({ signatureLevel })) {
return;
}
if (role === RecipientRole.ASSISTANT) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Envelopes signed at '${signatureLevel}' do not support the ASSISTANT role — the TSP flow signs each recipient's bytes with their own CSC credential and has no sign-time path for an assistant.`,
});
}
return;
};
@@ -0,0 +1,41 @@
import { DocumentSigningOrder } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { isTspEnvelope } from '../../types/signature-level';
type AssertCompatibleSigningOrderOptions = {
signatureLevel: string;
signingOrder: DocumentSigningOrder | null | undefined;
};
/**
* Reject `signingOrder = PARALLEL` on AES/QES envelopes.
*
* Parallel signing produces conflicting incremental PDF updates over the
* same base state, breaking the per-recipient `/ByteRange` invariant that
* lets each TSP signature verify independently. Sequential is the only safe
* order for TSP-signed envelopes.
*
* SES envelopes pass through unchanged — PARALLEL remains the SES default.
* A `null` / `undefined` signingOrder also passes through (the create-envelope
* caller decides the default).
*
* Schema-layer guard. {@link sendDocument} re-coerces at distribution time
* as a defence-in-depth backstop.
*/
export const assertCompatibleSigningOrder = ({
signatureLevel,
signingOrder,
}: AssertCompatibleSigningOrderOptions): void => {
if (!isTspEnvelope({ signatureLevel })) {
return;
}
if (signingOrder !== DocumentSigningOrder.PARALLEL) {
return;
}
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Envelopes signed at '${signatureLevel}' require signingOrder=SEQUENTIAL — PARALLEL breaks the per-recipient /ByteRange invariant required for TSP signatures to verify independently.`,
});
};
@@ -0,0 +1,87 @@
import { CSC_INSTANCE_SIGNATURE_LEVEL, IS_INSTANCE_CSC_MODE } from '../../constants/app';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { SignatureLevel, type TSignatureLevel } from '../../types/signature-level';
type ResolveSignatureLevelOptions = {
/**
* The signature level the caller wants the envelope created at. Optional;
* when omitted the resolver returns the instance-mode default (`SES` for
* non-CSC instances, `AES` for CSC instances).
*/
requested?: TSignatureLevel;
/**
* When `true`, a conflict between `requested` and the current instance mode
* throws `CSC_INSTANCE_MODE_MISMATCH` rather than being silently coerced.
* When `false` (default), the resolver coerces incompatible inputs to the
* instance default without throwing.
*
* Omitting `requested` is accepted in both modes — the resolver returns the
* instance default rather than throwing.
*
* Use `strict: true` at call sites that take the level from external input
* (e.g. a public API) where silent coercion would mask caller mistakes.
*/
strict?: boolean;
};
/**
* Resolve the signature level for a new envelope.
*
* Server-only. Reads the `NEXT_PRIVATE_SIGNING_TRANSPORT` env var via
* {@link IS_INSTANCE_CSC_MODE} so call sites do not have to thread the
* instance mode through their own arguments. On CSC instances the coerced
* default also reads {@link CSC_INSTANCE_SIGNATURE_LEVEL} so operators can
* pick `AES` (default) or `QES` per their TSP capability.
*
* Source of truth for the `Envelope.signatureLevel` write at create-time. The
* column has no DB default by design — every caller flows through here so the
* instance-mode contract is enforced consistently.
*
* Coerce mode (default, `strict: false`):
*
* | Instance | requested | Result |
* |----------|----------------|-------------------------------------|
* | non-CSC | omitted | `SES` |
* | non-CSC | `SES` | `SES` |
* | non-CSC | `AES` / `QES` | `SES` (coerced) |
* | CSC | omitted | `CSC_INSTANCE_SIGNATURE_LEVEL()` |
* | CSC | `SES` | `CSC_INSTANCE_SIGNATURE_LEVEL()` |
* | CSC | `AES` / `QES` | passes through |
*
* Strict mode (`strict: true`): same instance defaults for the omitted case,
* but any conflict between `requested` and the instance mode throws
* `CSC_INSTANCE_MODE_MISMATCH` instead of silently coercing.
*
* Note: on CSC instances an explicit `AES`/`QES` request always passes
* through, even when it disagrees with `CSC_INSTANCE_SIGNATURE_LEVEL`. The
* env var sets the *default* legal tier; it doesn't restrict what callers
* can ask for. Cert-capability checks live at the TSP boundary.
*/
export const resolveSignatureLevel = ({
requested,
strict = false,
}: ResolveSignatureLevelOptions = {}): TSignatureLevel => {
const isCscInstance = IS_INSTANCE_CSC_MODE();
const instanceDefault = isCscInstance ? CSC_INSTANCE_SIGNATURE_LEVEL() : SignatureLevel.SES;
if (requested === undefined) {
return instanceDefault;
}
const isCompatible = isCscInstance ? requested !== SignatureLevel.SES : requested === SignatureLevel.SES;
if (isCompatible) {
return requested;
}
if (strict) {
throw new AppError(AppErrorCode.CSC_INSTANCE_MODE_MISMATCH, {
message: isCscInstance
? `signatureLevel '${requested}' is not supported on a CSC-mode instance — every recipient must sign through the configured Trust Service Provider.`
: `signatureLevel '${requested}' is not supported on a non-CSC instance — only 'SES' is permitted unless the CSC signing transport is configured.`,
});
}
return instanceDefault;
};
@@ -0,0 +1,36 @@
import { DocumentSigningOrder } from '@prisma/client';
import { isTspEnvelope } from '../../types/signature-level';
import { assertCompatibleSigningOrder } from './assert-compatible-signing-order';
type ResolveSigningOrderOptions = {
signatureLevel: string;
requested?: DocumentSigningOrder | null;
};
/**
* Resolve the persisted `signingOrder` for a new envelope's meta.
*
* - Explicit `requested` value: validated via
* {@link assertCompatibleSigningOrder} (throws on TSP + `PARALLEL`) and
* returned as-is.
* - Omitted `requested`: returns the level-appropriate default —
* `SEQUENTIAL` for AES/QES (the TSP `/ByteRange` invariant requires it),
* `PARALLEL` for SES (preserves existing SES default behaviour).
*
* Use at every create-time call site instead of the bare `|| PARALLEL`
* fallback. Mirrors {@link resolveSignatureLevel} in shape — the two pair
* up to keep create-time defaulting + TSP-mode coercion uniform.
*/
export const resolveSigningOrder = ({
signatureLevel,
requested,
}: ResolveSigningOrderOptions): DocumentSigningOrder => {
if (requested) {
assertCompatibleSigningOrder({ signatureLevel, signingOrder: requested });
return requested;
}
return isTspEnvelope({ signatureLevel }) ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL;
};
@@ -85,6 +85,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => {
// Purge all internal organisation groups that have no teams.
await tx.organisationGroup.deleteMany({
where: {
organisationId: team.organisationId,
type: OrganisationGroupType.INTERNAL_TEAM,
teamGroups: {
none: {},
@@ -3,7 +3,6 @@ import type { TeamProfile } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildTeamWhereQuery } from '../../utils/teams';
import { updateTeamPublicProfile } from './update-team-public-profile';
export type GetTeamPublicProfileOptions = {
userId: number;
@@ -32,25 +31,24 @@ export const getTeamPublicProfile = async ({
});
}
// Create and return the public profile.
// Lazily initialize a disabled public profile on first access. Membership is
// already verified by the query above, so this system initialization does not
// impose the MANAGE_TEAM gate that updateTeamPublicProfile enforces for writes.
if (!team.profile) {
const { url, profile } = await updateTeamPublicProfile({
userId: userId,
teamId,
data: {
const profile = await prisma.teamProfile.upsert({
where: {
teamId,
},
create: {
teamId,
enabled: false,
},
update: {},
});
if (!profile) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Failed to create public profile',
});
}
return {
profile,
url,
url: team.url,
};
}
@@ -1,3 +1,4 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { prisma } from '@documenso/prisma';
import { buildTeamWhereQuery } from '../../utils/teams';
@@ -13,7 +14,11 @@ export type UpdatePublicProfileOptions = {
export const updateTeamPublicProfile = async ({ userId, teamId, data }: UpdatePublicProfileOptions) => {
return await prisma.team.update({
where: buildTeamWhereQuery({ teamId, userId }),
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
data: {
profile: {
upsert: {
@@ -25,6 +25,7 @@ import type { TRecipientActionAuthTypes } from '../../types/document-auth';
import { DocumentAccessAuth, ZRecipientAuthOptionsSchema } from '../../types/document-auth';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { ZFieldMetaSchema } from '../../types/field-meta';
import { ZSignatureLevelSchema } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
@@ -43,6 +44,7 @@ import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { incrementDocumentId } from '../envelope/increment-id';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -197,6 +199,17 @@ export const createDocumentFromDirectTemplate = async ({
(recipient) => recipient.id !== directTemplateRecipient.id,
);
// Carry the template's level forward, coercing if the instance mode has
// changed since the template was created. ZSignatureLevelSchema parses the
// free-form TEXT column defensively. Resolved before meta extraction so
// signingOrder picks up the TSP-appropriate default + assertion.
const signatureLevel = resolveSignatureLevel({
requested: ZSignatureLevelSchema.parse(directTemplateEnvelope.signatureLevel),
strict: false,
});
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta, signatureLevel);
// The resulting document contains every non-direct template recipient plus the
// direct recipient that is signing now. A recipientCount of 0 means unlimited.
// This mirrors the check in `sendDocument`, but must be done here because this
@@ -211,8 +224,6 @@ export const createDocumentFromDirectTemplate = async ({
});
}
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta);
// Associate, validate and map to a query every direct template recipient field with the provided fields.
// Only process fields that are either required or have been signed by the user
const fieldsToProcess = directTemplateRecipient.fields.filter((templateField) => {
@@ -352,6 +363,7 @@ export const createDocumentFromDirectTemplate = async ({
secondaryId: incrementedDocumentId.formattedDocumentId,
type: EnvelopeType.DOCUMENT,
internalVersion: directTemplateEnvelope.internalVersion,
signatureLevel,
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE_DIRECT_LINK,
templateId: directTemplateEnvelopeLegacyId,
@@ -33,6 +33,7 @@ import type {
TTextFieldMeta,
} from '../../types/field-meta';
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZFieldMetaSchema, ZRadioFieldMeta } from '../../types/field-meta';
import { ZSignatureLevelSchema } from '../../types/signature-level';
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
@@ -51,6 +52,7 @@ import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId } from '../envelope/increment-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { getOrganisationTemplateWhereInput } from './get-organisation-template-by-id';
@@ -513,23 +515,36 @@ export const createDocumentFromTemplate = async ({
const incrementedDocumentId = await incrementDocumentId();
// Carry the template's level forward, coercing if the instance mode has
// changed since the template was created. ZSignatureLevelSchema parses the
// free-form TEXT column defensively. Resolved before meta extraction so
// signingOrder picks up the TSP-appropriate default + assertion.
const signatureLevel = resolveSignatureLevel({
requested: ZSignatureLevelSchema.parse(template.signatureLevel),
strict: false,
});
const documentMeta = await prisma.documentMeta.create({
data: extractDerivedDocumentMeta(settings, {
subject: override?.subject || template.documentMeta?.subject,
message: override?.message || template.documentMeta?.message,
timezone: override?.timezone || template.documentMeta?.timezone,
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
}),
data: extractDerivedDocumentMeta(
settings,
{
subject: override?.subject || template.documentMeta?.subject,
message: override?.message || template.documentMeta?.message,
timezone: override?.timezone || template.documentMeta?.timezone,
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
},
signatureLevel,
),
});
const { envelope, createdEnvelope } = await prisma.$transaction(async (tx) => {
@@ -539,6 +554,7 @@ export const createDocumentFromTemplate = async ({
secondaryId: incrementedDocumentId.formattedDocumentId,
type: EnvelopeType.DOCUMENT,
internalVersion: template.internalVersion,
signatureLevel,
qrToken: prefixedId('qr'),
source: DocumentSource.TEMPLATE,
externalId: externalId || template.externalId,
@@ -18,31 +18,26 @@ export const forgotPassword = async ({ email }: { email: string }) => {
return;
}
// Find a token that was created in the last hour and hasn't expired
// const existingToken = await prisma.passwordResetToken.findFirst({
// where: {
// userId: user.id,
// expiry: {
// gt: new Date(),
// },
// createdAt: {
// gt: new Date(Date.now() - ONE_HOUR),
// },
// },
// });
// if (existingToken) {
// return;
// }
const token = crypto.randomBytes(18).toString('hex');
await prisma.passwordResetToken.create({
data: {
token,
expiry: new Date(Date.now() + ONE_HOUR),
userId: user.id,
},
// Invalidate any prior reset tokens for this user before issuing a new one, so
// only a single token is ever live at a time. We still always issue a fresh
// token (and email) so the user can request a new link if a prior email never
// arrived, while bounding the number of usable tokens to one.
await prisma.$transaction(async (tx) => {
await tx.passwordResetToken.deleteMany({
where: {
userId: user.id,
},
});
await tx.passwordResetToken.create({
data: {
token,
expiry: new Date(Date.now() + ONE_HOUR),
userId: user.id,
},
});
});
await sendForgotPassword({
@@ -71,10 +71,17 @@ const isBypassedHost = (url: string): boolean => {
};
/**
* Asserts that a webhook URL does not resolve to a private or loopback
* address. Throws an AppError with WEBHOOK_INVALID_REQUEST if it does.
* Assert that a webhook URL does not point at a private/loopback address,
* checking both the literal host and its resolved DNS records. Throws an
* AppError with WEBHOOK_INVALID_REQUEST if it does. Hosts listed in
* NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
*
* Hosts listed in NEXT_PRIVATE_WEBHOOK_SSRF_BYPASS_HOSTS skip all checks.
* This is best-effort, non-exhaustive SSRF defence, NOT a complete mitigation.
* It does not cover DNS rebinding (the resolved address can change between this
* check and the actual request), obscure IP encodings, or every IPv6 form, and
* it fails open on lookup errors/timeouts (see the catch below). Network-level
* SSRF protection (firewall/egress rules, blocking internal services and cloud
* metadata endpoints) remains the responsibility of the deployment.
*/
export const assertNotPrivateUrl = async (
url: string,
@@ -3,10 +3,11 @@ import { z } from 'zod';
const ZIpSchema = z.string().ip();
/**
* Check whether a URL points to a known private/loopback address.
* Synchronously check whether a URL's host is a known private/loopback address
* (localhost, RFC 1918, link-local, loopback, etc.), regardless of protocol.
*
* Performs a synchronous check against known private hostnames and IP ranges.
* Works regardless of the URL protocol.
* Best-effort and non-exhaustive: unrecognised or unparseable hosts return
* `false` (fail open). See `assertNotPrivateUrl` for the full SSRF caveats.
*/
export const isPrivateUrl = (url: string): boolean => {
try {
@@ -17,6 +17,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
const now = new Date();
const basePayload = {
id: 10,
envelopeId: 'env_123',
externalId: null,
userId: 1,
authOptions: null,
@@ -52,6 +53,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
recipients: [
{
id: 52,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer@documenso.com',
@@ -73,6 +75,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
Recipient: [
{
id: 52,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer@documenso.com',
@@ -269,6 +272,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
recipients: [
{
id: 50,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer2@documenso.com',
@@ -291,6 +295,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
},
{
id: 51,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer1@documenso.com',
@@ -315,6 +320,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
Recipient: [
{
id: 50,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer2@documenso.com',
@@ -337,6 +343,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
},
{
id: 51,
envelopeId: 'env_123',
documentId: 10,
templateId: null,
email: 'signer1@documenso.com',
@@ -444,6 +451,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
recipients: [
{
id: 7,
envelopeId: 'env_123',
documentId: 7,
templateId: null,
email: 'signer1@documenso.com',
@@ -468,6 +476,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
Recipient: [
{
id: 7,
envelopeId: 'env_123',
documentId: 7,
templateId: null,
email: 'signer@documenso.com',
@@ -1,4 +1,6 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { assertNotPrivateUrl } from '../assert-webhook-url';
@@ -9,14 +11,36 @@ export const subscribeHandler = async (req: Request) => {
const authorization = req.headers.get('authorization');
if (!authorization) {
return new Response('Unauthorized', { status: 401 });
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
}
const { webhookUrl, eventTrigger } = await req.json();
await assertNotPrivateUrl(webhookUrl);
const result = await validateApiToken({ authorization });
const result = await validateApiToken({ authorization }).catch(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
});
const userId = result.userId ?? result.user.id;
const teamId = result.teamId ?? undefined;
// Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the
// tRPC webhook mutations (create-webhook.ts). Guards against stale-privilege
// use of a token minted while the holder was privileged.
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
});
if (!team) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to manage webhooks for this team',
});
}
const createdWebhook = await prisma.webhook.create({
data: {
@@ -24,15 +48,19 @@ export const subscribeHandler = async (req: Request) => {
eventTriggers: [eventTrigger],
secret: null,
enabled: true,
userId: result.userId ?? result.user.id,
teamId: result.teamId ?? undefined,
userId,
teamId,
},
});
return Response.json(createdWebhook);
} catch (err) {
if (err instanceof AppError) {
return Response.json({ message: err.message }, { status: 400 });
// Map authorization failures to 401, keep other AppErrors as 400 to
// preserve the existing Zapier contract (e.g. invalid webhook URL).
const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400;
return Response.json({ message: err.message }, { status });
}
console.error(err);
@@ -1,3 +1,6 @@
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
import { prisma } from '@documenso/prisma';
import { validateApiToken } from './validateApiToken';
@@ -7,23 +10,42 @@ export const unsubscribeHandler = async (req: Request) => {
const authorization = req.headers.get('authorization');
if (!authorization) {
return new Response('Unauthorized', { status: 401 });
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
}
const { webhookId } = await req.json();
const result = await validateApiToken({ authorization });
const result = await validateApiToken({ authorization }).catch(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' });
});
const userId = result.userId ?? result.user.id;
const teamId = result.teamId ?? undefined;
// Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the
// tRPC delete-webhook-by-id mutation. Guards against stale-privilege use of a
// token minted while the holder was privileged.
const deletedWebhook = await prisma.webhook.delete({
where: {
id: webhookId,
userId: result.userId ?? result.user.id,
teamId: result.teamId ?? undefined,
team: buildTeamWhereQuery({
teamId,
userId,
roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'],
}),
},
});
return Response.json(deletedWebhook);
} catch (err) {
if (err instanceof AppError) {
// Map authorization failures to 401, keep other AppErrors as 400 to
// preserve the existing Zapier contract.
const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400;
return Response.json({ message: err.message }, { status });
}
console.error(err);
return Response.json(