diff --git a/packages/lib/server-only/document/send-document.ts b/packages/lib/server-only/document/send-document.ts index 1715739f7..7e555ad3e 100644 --- a/packages/lib/server-only/document/send-document.ts +++ b/packages/lib/server-only/document/send-document.ts @@ -38,9 +38,11 @@ import { isDocumentCompleted } from '../../utils/document'; import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope'; import { toCheckboxCustomText, toRadioCustomText } from '../../utils/fields'; +import { getRecipientsInActiveSigningStep } from '../../utils/recipient-groups'; import { getRecipientsWithMissingFields, isRecipientEmailValidForSending } from '../../utils/recipients'; import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf'; +import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping'; import { assertUserNotDisabledById } from '../user/assert-user-not-disabled'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -147,13 +149,17 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad envelope.documentMeta.signingOrder = DocumentSigningOrder.SEQUENTIAL; } + assertCompatibleRecipientGrouping({ + signatureLevel: envelope.signatureLevel, + recipients: envelope.recipients, + }); + let recipientsToNotify = envelope.recipients; if (signingOrder === DocumentSigningOrder.SEQUENTIAL) { - // Get the currently active recipient. - recipientsToNotify = envelope.recipients - .filter((r) => r.signingStatus === SigningStatus.NOT_SIGNED && r.role !== RecipientRole.CC) - .slice(0, 1); + // Get the currently active signing group. Recipients sharing the lowest + // pending signing order act in parallel within their group. + recipientsToNotify = getRecipientsInActiveSigningStep(envelope.recipients); } if (envelope.envelopeItems.length === 0) { diff --git a/packages/lib/server-only/signature-level/assert-compatible-recipient-grouping.ts b/packages/lib/server-only/signature-level/assert-compatible-recipient-grouping.ts new file mode 100644 index 000000000..cedb3171f --- /dev/null +++ b/packages/lib/server-only/signature-level/assert-compatible-recipient-grouping.ts @@ -0,0 +1,54 @@ +import type { Recipient } from '@prisma/client'; + +import { AppError, AppErrorCode } from '../../errors/app-error'; +import { isTspEnvelope } from '../../types/signature-level'; +import { effectiveOrder } from '../../utils/recipient-groups'; +import { isCcRecipient } from '../../utils/recipients'; + +type AssertCompatibleRecipientGroupingOptions = { + signatureLevel: string; + recipients: Array & { signingOrder?: number | null }>; +}; + +/** + * Reject recipient signing groups on AES/QES envelopes. + * + * A "group" is two or more signing recipients sharing a signing step, which + * they may then complete in any order — including at the same time. That is + * parallel signing scoped to one step + * + * Recipients sharing a step are detected by {@link effectiveOrder}, so an + * absent signing order counts too — every unordered recipient lands in the + * same tail step and would sign in parallel. + * + * CC recipients are ignored: they never sign, and their signing order carries + * no meaning anywhere else. + * + * SES envelopes pass through unchanged — signing groups are an SES feature. + */ +export const assertCompatibleRecipientGrouping = ({ + signatureLevel, + recipients, +}: AssertCompatibleRecipientGroupingOptions): void => { + if (!isTspEnvelope({ signatureLevel })) { + return; + } + + const seenOrders = new Set(); + + for (const recipient of recipients) { + if (isCcRecipient(recipient)) { + continue; + } + + const order = effectiveOrder(recipient); + + if (seenOrders.has(order)) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: `Envelopes signed at '${signatureLevel}' cannot place two recipients in the same signing step — a signing group is parallel signing within one step, which breaks the per-recipient /ByteRange invariant TSP signatures rely on. Give every signing recipient a distinct signingOrder.`, + }); + } + + seenOrders.add(order); + } +}; diff --git a/packages/lib/utils/recipient-groups.test.ts b/packages/lib/utils/recipient-groups.test.ts index e0cbce8d3..9019c6ba9 100644 --- a/packages/lib/utils/recipient-groups.test.ts +++ b/packages/lib/utils/recipient-groups.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'; import { extractRecipientToNewStep, - flattenRecipientGroups, getNextDictatableRecipient, getRecipientsInActiveSigningStep, groupRecipientsBySigningOrder, @@ -450,51 +449,6 @@ describe('locked step guards', () => { }); }); -describe('flattenRecipientGroups', () => { - const recipient = (id: number, signingOrder: number | null, role: RecipientRole = RecipientRole.SIGNER) => ({ - id, - signingOrder, - role, - }); - - it('returns no changes when every signing recipient already has their own step', () => { - expect(flattenRecipientGroups([recipient(1, 1), recipient(2, 2)])).toEqual([]); - }); - - // Sparse but distinct orders are valid; only a shared step needs repairing. - it('leaves a valid but sparse sequence alone', () => { - expect(flattenRecipientGroups([recipient(1, 1), recipient(2, 5)])).toEqual([]); - }); - - it('splits a shared step while preserving relative order', () => { - const changes = flattenRecipientGroups([recipient(1, 1), recipient(2, 2), recipient(3, 2)]); - - // Only the second member of the shared step has to move. - expect(changes).toEqual([{ id: 3, signingOrder: 3 }]); - }); - - it('gives every unordered recipient a distinct step', () => { - const changes = flattenRecipientGroups([recipient(1, null), recipient(2, null)]); - - expect(changes).toEqual([ - { id: 1, signingOrder: 1 }, - { id: 2, signingOrder: 2 }, - ]); - }); - - it('ignores CC recipients', () => { - expect(flattenRecipientGroups([recipient(1, 1), recipient(2, 1, RecipientRole.CC)])).toEqual([]); - }); - - it('does not mutate the input array order', () => { - const recipients = [recipient(3, 2), recipient(1, 1), recipient(2, 2)]; - - flattenRecipientGroups(recipients); - - expect(recipients.map((r) => r.id)).toEqual([3, 1, 2]); - }); -}); - describe('isRecipientTurnBySigningOrder', () => { const recipient = ( id: number, diff --git a/packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types.ts b/packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types.ts index 8381f2396..c36a1abcb 100644 --- a/packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types.ts +++ b/packages/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types.ts @@ -1,5 +1,9 @@ import { ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth'; -import { ZRecipientEmailSchema, ZRecipientLiteSchema } from '@documenso/lib/types/recipient'; +import { + ZRecipientEmailSchema, + ZRecipientLiteSchema, + ZRecipientSigningOrderSchema, +} from '@documenso/lib/types/recipient'; import { RecipientRole } from '@prisma/client'; import { z } from 'zod'; @@ -20,7 +24,7 @@ export const ZUpdateEnvelopeRecipientSchema = z.object({ email: ZRecipientEmailSchema.optional(), name: z.string().max(255).optional(), role: z.nativeEnum(RecipientRole).optional(), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), accessAuth: z.array(ZRecipientAccessAuthTypesSchema).default([]).optional(), actionAuth: z.array(ZRecipientActionAuthTypesSchema).default([]).optional(), }); diff --git a/packages/trpc/server/envelope-router/use-envelope.types.ts b/packages/trpc/server/envelope-router/use-envelope.types.ts index 942e459e7..cd9400286 100644 --- a/packages/trpc/server/envelope-router/use-envelope.types.ts +++ b/packages/trpc/server/envelope-router/use-envelope.types.ts @@ -15,7 +15,7 @@ import { } from '@documenso/lib/types/document-meta'; import { ZEnvelopeAttachmentTypeSchema } from '@documenso/lib/types/envelope-attachment'; import { ZFieldMetaPrefillFieldsSchema } from '@documenso/lib/types/field-meta'; -import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient'; +import { ZRecipientEmailSchema, ZRecipientSigningOrderSchema } from '@documenso/lib/types/recipient'; import { z } from 'zod'; import { zfd } from 'zod-form-data'; @@ -44,7 +44,7 @@ export const ZUseEnvelopePayloadSchema = z.object({ id: z.number().describe('The ID of the recipient in the template.'), email: ZRecipientEmailSchema, name: z.string().max(255).optional(), - signingOrder: z.number().optional(), + signingOrder: ZRecipientSigningOrderSchema.optional(), }), ) .describe('The information of the recipients to create the document with.')