This commit is contained in:
David Nguyen
2026-08-26 12:10:29 +10:00
parent 9dc83bdb06
commit 6db71b13d4
70 changed files with 5964 additions and 892 deletions
@@ -25,6 +25,7 @@ import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope';
import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@@ -423,6 +424,7 @@ export const completeDocumentWithToken = async ({
select: {
id: true,
signingOrder: true,
signingStatus: true,
name: true,
email: true,
role: true,
@@ -451,65 +453,87 @@ export const completeDocumentWithToken = async ({
});
if (envelope.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const [nextRecipient] = pendingRecipients;
// The next group: every pending recipient sharing the lowest pending
// signing order. If the completing recipient's own step is still
// pending (a group peer has not signed yet), the flow does not advance —
// the remaining peers were already activated when their step unlocked.
const nextGroup = filterRecipientsInFirstSigningGroup(pendingRecipients);
await prisma.$transaction(async (tx) => {
if (nextSigner && envelope.documentMeta?.allowDictateNextSigner) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
user: {
name: recipientName,
email: recipientEmail,
},
requestMetadata,
const currentRecipientOrder = recipient.signingOrder ?? Number.MAX_SAFE_INTEGER;
const hasCompletedCurrentStep = nextGroup.every(
(pendingRecipient) => (pendingRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER) > currentRecipientOrder,
);
if (nextGroup.length > 0 && hasCompletedCurrentStep) {
// Dictation only applies when advancing to a single-recipient step.
const canDictateNextSigner =
Boolean(nextSigner) && Boolean(envelope.documentMeta?.allowDictateNextSigner) && nextGroup.length === 1;
await prisma.$transaction(async (tx) => {
if (canDictateNextSigner && nextSigner) {
const [nextRecipient] = nextGroup;
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
envelopeId: envelope.id,
user: {
name: recipientName,
email: recipientEmail,
},
requestMetadata,
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
},
}),
});
}
for (const nextRecipient of nextGroup) {
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
recipientEmail: nextRecipient.email,
recipientName: nextRecipient.name,
recipientId: nextRecipient.id,
recipientRole: nextRecipient.role,
changes: [
{
type: RECIPIENT_DIFF_TYPE.NAME,
from: nextRecipient.name,
to: nextSigner.name,
},
{
type: RECIPIENT_DIFF_TYPE.EMAIL,
from: nextRecipient.email,
to: nextSigner.email,
},
],
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(canDictateNextSigner && nextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
}),
});
}
});
for (const nextRecipient of nextGroup) {
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
});
}
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
sendStatus: SendStatus.SENT,
sentAt: new Date(),
...(nextSigner && envelope.documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
},
});
});
await jobs.triggerJob({
name: 'send.signing.requested.email',
payload: {
userId: envelope.userId,
documentId: legacyDocumentId,
recipientId: nextRecipient.id,
requestMetadata,
},
});
}
}
}
@@ -38,6 +38,7 @@ 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 { filterRecipientsInFirstSigningGroup, flattenRecipientGroups } 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';
@@ -147,13 +148,38 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
envelope.documentMeta.signingOrder = DocumentSigningOrder.SEQUENTIAL;
}
// Signing groups cannot exist on a TSP envelope: two recipients sharing a
// step sign in parallel, which breaks the per-recipient /ByteRange invariant.
// The schema-layer guard should have caught this at write time; flatten the
// groups here so an envelope that slipped through is still distributable.
if (isTspEnvelope(envelope)) {
const flattenedOrders = flattenRecipientGroups(envelope.recipients);
if (flattenedOrders.length > 0) {
console.warn(
`[CSC] Coercing ${flattenedOrders.length} grouped recipient(s) to distinct signing orders for ${envelope.signatureLevel} envelope ${envelope.id} at send time. The schema-layer guard should have caught this earlier.`,
);
await prisma.$transaction(
flattenedOrders.map(({ id, signingOrder: order }) =>
prisma.recipient.update({ where: { id }, data: { signingOrder: order } }),
),
);
envelope.recipients = envelope.recipients.map((recipient) => {
const flattened = flattenedOrders.find((entry) => entry.id === recipient.id);
return flattened ? { ...recipient, signingOrder: flattened.signingOrder } : recipient;
});
}
}
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 = filterRecipientsInFirstSigningGroup(envelope.recipients);
}
if (envelope.envelopeItems.length === 0) {
@@ -38,7 +38,9 @@ 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 { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { assignDefaultRecipientSigningOrders } from '../signature-level/assign-default-recipient-signing-orders';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { getTeamSettings } from '../team/get-team-settings';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
@@ -280,6 +282,32 @@ export const createEnvelope = async ({
assertCompatibleRecipientRole({ signatureLevel, role: recipient.role });
}
const parsedDefaultRecipients =
settings.defaultRecipients && !bypassDefaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: [];
const defaultRecipients: CreateEnvelopeRecipientOptions[] = parsedDefaultRecipients.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
}));
// Team default recipients carry no signing order, which on a TSP envelope
// would land them all in the shared tail step — a signing group — so they
// are numbered after the payload's highest order.
const orderedDefaultRecipients = assignDefaultRecipientSigningOrders({
signatureLevel,
payloadRecipients: data.recipients ?? [],
defaultRecipients,
});
const allRecipientsToCreate = [...(data.recipients || []), ...orderedDefaultRecipients];
// The grouping assertion runs against the COMBINED set the envelope will
// actually hold, not just the payload.
assertCompatibleRecipientGrouping({ signatureLevel, recipients: allRecipientsToCreate });
const visibility = visibilityOverride || settings.documentVisibility;
const emailId = meta?.emailId;
@@ -403,21 +431,8 @@ export const createEnvelope = async ({
const firstEnvelopeItem = envelope.envelopeItems[0];
const defaultRecipients =
settings.defaultRecipients && !bypassDefaultRecipients
? ZDefaultRecipientsSchema.parse(settings.defaultRecipients)
: [];
const mappedDefaultRecipients: CreateEnvelopeRecipientOptions[] = defaultRecipients.map((recipient) => ({
email: recipient.email,
name: recipient.name,
role: recipient.role,
}));
const allRecipients = [...(data.recipients || []), ...mappedDefaultRecipients];
await Promise.all(
allRecipients.map(async (recipient) => {
allRecipientsToCreate.map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
@@ -5,13 +5,14 @@ import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/Envelope
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { z } from 'zod';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
import { ZRecipientLiteSchema } from '../../types/recipient';
import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
import { isRecipientExpired } from '../../utils/recipients';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings';
@@ -260,23 +261,9 @@ export const getEnvelopeForRecipientSigning = async ({
},
});
let isRecipientsTurn = true;
const currentRecipientIndex = envelope.recipients.findIndex((r) => r.token === token);
if (envelope.documentMeta.signingOrder === DocumentSigningOrder.SEQUENTIAL && currentRecipientIndex !== -1) {
for (let i = 0; i < currentRecipientIndex; i++) {
// CC recipients have no action to take, so they can never block the flow.
if (envelope.recipients[i].role === RecipientRole.CC) {
continue;
}
if (envelope.recipients[i].signingStatus !== SigningStatus.SIGNED) {
isRecipientsTurn = false;
break;
}
}
}
const isRecipientsTurn =
envelope.documentMeta.signingOrder !== DocumentSigningOrder.SEQUENTIAL ||
isRecipientTurnBySigningOrder(envelope.recipients, recipient);
const sender = settings.includeSenderDetails
? {
@@ -1,6 +1,8 @@
import { prisma } from '@documenso/prisma';
import { EnvelopeType, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { getLaterSigningStepRecipientsWhereInput } from '../../utils/recipients';
export type GetFieldsForTokenOptions = {
token: string;
};
@@ -31,10 +33,11 @@ export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) =>
signingStatus: {
not: SigningStatus.SIGNED,
},
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
envelopeId: recipient.envelopeId,
// Assistants can only assist those in strictly later steps —
// never their own group peers, with null orders as the tail
// step. (Own fields are matched by the sibling OR arm.)
AND: [getLaterSigningStepRecipientsWhereInput(recipient)],
},
envelope: {
id: recipient.envelopeId,
@@ -1,7 +1,7 @@
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { assertRecipientNotExpired } from '@documenso/lib/utils/recipients';
import { assertRecipientNotExpired, getFieldOwnerWhereInput } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
@@ -25,21 +25,7 @@ export const removeSignedFieldWithToken = async ({
const field = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
recipient: {
...(recipient.role !== RecipientRole.ASSISTANT
? {
id: recipient.id,
}
: {
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
signingStatus: {
not: SigningStatus.SIGNED,
},
envelopeId: recipient.envelopeId,
}),
},
recipient: getFieldOwnerWhereInput(recipient),
},
include: {
envelope: true,
@@ -13,6 +13,7 @@ import { match } from 'ts-pattern';
import { AUTO_SIGNABLE_FIELD_TYPES } from '../../constants/autosign';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '../../constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../../constants/time-zones';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { TRecipientActionAuth } from '../../types/document-auth';
import {
@@ -24,7 +25,7 @@ import {
} from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { assertRecipientNotExpired } from '../../utils/recipients';
import { assertRecipientNotExpired, getFieldOwnerWhereInput } from '../../utils/recipients';
import { validateFieldAuth } from '../document/validate-field-auth';
export type SignFieldWithTokenOptions = {
@@ -65,21 +66,7 @@ export const signFieldWithToken = async ({
const field = await prisma.field.findFirstOrThrow({
where: {
id: fieldId,
recipient: {
...(recipient.role !== RecipientRole.ASSISTANT
? {
id: recipient.id,
}
: {
signingStatus: {
not: SigningStatus.SIGNED,
},
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
envelopeId: recipient.envelopeId,
}),
},
recipient: getFieldOwnerWhereInput(recipient),
},
include: {
envelope: {
@@ -124,6 +111,18 @@ export const signFieldWithToken = async ({
throw new Error(`Field ${fieldId} has no recipientId`);
}
// Mirrors the V2 guard in `sign-envelope-field.ts`: assistants may prefill
// other recipients' fields but never their signature fields.
if (
field.type === FieldType.SIGNATURE &&
recipient.role === RecipientRole.ASSISTANT &&
field.recipientId !== recipient.id
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Assistant recipients cannot sign signature fields',
});
}
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(value, numberFieldParsedMeta, true);
@@ -12,6 +12,7 @@ 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 { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface CreateEnvelopeRecipientsOptions {
@@ -91,6 +92,13 @@ export const createEnvelopeRecipients = async ({
});
}
// Grouping is a property of the whole recipient set, so check the state the
// envelope will be left in rather than the incoming batch alone.
assertCompatibleRecipientGrouping({
signatureLevel: envelope.signatureLevel,
recipients: [...envelope.recipients, ...recipientsToCreate],
});
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
@@ -1,5 +1,7 @@
import { prisma } from '@documenso/prisma';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { DocumentSigningOrder, EnvelopeType } from '@prisma/client';
import { isRecipientTurnBySigningOrder } from '../../utils/recipient-groups';
export type GetIsRecipientTurnOptions = {
token: string;
@@ -17,11 +19,7 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
},
include: {
documentMeta: true,
recipients: {
orderBy: {
signingOrder: 'asc',
},
},
recipients: true,
},
});
@@ -29,24 +27,11 @@ export async function getIsRecipientsTurnToSign({ token }: GetIsRecipientTurnOpt
return true;
}
const { recipients } = envelope;
const currentRecipient = envelope.recipients.find((recipient) => recipient.token === token);
const currentRecipientIndex = recipients.findIndex((r) => r.token === token);
if (currentRecipientIndex === -1) {
if (!currentRecipient) {
return false;
}
for (let i = 0; i < currentRecipientIndex; i++) {
// CC recipients have no action to take, so they can never block the flow.
if (recipients[i].role === RecipientRole.CC) {
continue;
}
if (recipients[i].signingStatus !== SigningStatus.SIGNED) {
return false;
}
}
return true;
return isRecipientTurnBySigningOrder(envelope.recipients, currentRecipient);
}
@@ -1,7 +1,8 @@
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole } from '@prisma/client';
import { EnvelopeType } from '@prisma/client';
import { mapDocumentIdToSecondaryId } from '../../utils/envelope';
import { getNextDictatableRecipient } from '../../utils/recipient-groups';
export const getNextPendingRecipient = async ({
documentId,
@@ -16,33 +17,17 @@ export const getNextPendingRecipient = async ({
type: EnvelopeType.DOCUMENT,
secondaryId: mapDocumentIdToSecondaryId(documentId),
},
// CC recipients are informational only and never take part in signing,
// so they must never be offered as the next pending recipient.
role: {
not: RecipientRole.CC,
},
},
orderBy: [
{
signingOrder: {
sort: 'asc',
nulls: 'last',
},
},
{
id: 'asc',
},
],
});
const currentIndex = recipients.findIndex((r) => r.id === currentRecipientId);
const nextRecipient = getNextDictatableRecipient({ recipients, currentRecipientId });
if (currentIndex === -1 || currentIndex === recipients.length - 1) {
if (!nextRecipient) {
return null;
}
return {
...recipients[currentIndex + 1],
...nextRecipient,
token: '',
};
};
@@ -2,6 +2,7 @@ import { prisma } from '@documenso/prisma';
import { FieldType } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { getAssistableRecipientsWhereInput } from '../../utils/recipients';
export interface GetRecipientsForAssistantOptions {
token: string;
@@ -23,9 +24,9 @@ export const getRecipientsForAssistant = async ({ token }: GetRecipientsForAssis
let recipients = await prisma.recipient.findMany({
where: {
envelopeId: assistant.envelopeId,
signingOrder: {
gte: assistant.signingOrder ?? 0,
},
// The assistant themself plus strictly later steps — never their own
// group peers, with null orders treated as the tail step.
AND: [getAssistableRecipientsWhereInput(assistant)],
},
include: {
fields: {
@@ -17,6 +17,7 @@ import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface SetDocumentRecipientsOptions {
@@ -98,6 +99,13 @@ export const setDocumentRecipients = async ({
});
}
// This route replaces the whole recipient set, so the payload is the
// resulting state.
assertCompatibleRecipientGrouping({
signatureLevel: envelope.signatureLevel,
recipients,
});
const normalizedRecipients = recipients.map((recipient) => ({
...recipient,
email: recipient.email.toLowerCase(),
@@ -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 { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export type SetTemplateRecipientsOptions = {
@@ -68,6 +69,13 @@ export const setTemplateRecipients = async ({ userId, teamId, id, recipients }:
});
}
// This route replaces the whole recipient set, so the payload is the
// resulting state.
assertCompatibleRecipientGrouping({
signatureLevel: envelope.signatureLevel,
recipients,
});
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) {
@@ -14,6 +14,7 @@ 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 { assertCompatibleRecipientGrouping } from '../signature-level/assert-compatible-recipient-grouping';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export interface UpdateEnvelopeRecipientsOptions {
@@ -99,6 +100,17 @@ export const updateEnvelopeRecipients = async ({
});
}
// Grouping is a property of the whole recipient set, so check the state the
// envelope will be left in once these updates are applied.
assertCompatibleRecipientGrouping({
signatureLevel: envelope.signatureLevel,
recipients: envelope.recipients.map((existingRecipient) => {
const update = recipients.find((recipient) => recipient.id === existingRecipient.id);
return update ? { ...existingRecipient, ...update } : existingRecipient;
}),
});
const recipientsToUpdate = recipients.map((recipient) => {
const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id);
@@ -0,0 +1,80 @@
import { RecipientRole } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { SignatureLevel } from '../../types/signature-level';
import { assertCompatibleRecipientGrouping } from './assert-compatible-recipient-grouping';
const signer = (signingOrder: number | null) => ({ role: RecipientRole.SIGNER, signingOrder });
const cc = (signingOrder: number | null) => ({ role: RecipientRole.CC, signingOrder });
const expectRejected = (
signatureLevel: string,
recipients: Array<{ role: RecipientRole; signingOrder: number | null }>,
) => {
expect(() => assertCompatibleRecipientGrouping({ signatureLevel, recipients })).toThrow(
/signing group|same signing step/i,
);
};
const expectAccepted = (
signatureLevel: string,
recipients: Array<{ role: RecipientRole; signingOrder: number | null }>,
) => {
expect(() => assertCompatibleRecipientGrouping({ signatureLevel, recipients })).not.toThrow();
};
describe('assertCompatibleRecipientGrouping', () => {
describe('AES/QES envelopes', () => {
for (const signatureLevel of [SignatureLevel.AES, SignatureLevel.QES]) {
it(`rejects two signers sharing a signing order (${signatureLevel})`, () => {
expectRejected(signatureLevel, [signer(1), signer(2), signer(2)]);
});
it(`accepts distinct signing orders (${signatureLevel})`, () => {
expectAccepted(signatureLevel, [signer(1), signer(2), signer(3)]);
});
}
// Every null order collapses into the same tail step, so two of them sign
// in parallel exactly as a duplicate order would.
it('rejects two signers without a signing order', () => {
expectRejected(SignatureLevel.AES, [signer(null), signer(null)]);
});
it('rejects a signer without an order alongside an ordered signer', () => {
// The unordered recipient shares the tail step with the other null.
expectRejected(SignatureLevel.AES, [signer(1), signer(null), signer(null)]);
});
it('accepts a single signer without a signing order', () => {
expectAccepted(SignatureLevel.AES, [signer(null)]);
});
it('accepts one ordered signer and one unordered signer', () => {
expectAccepted(SignatureLevel.AES, [signer(1), signer(null)]);
});
// CC recipients never sign, so their order carries no meaning.
it('ignores CC recipients sharing an order with a signer', () => {
expectAccepted(SignatureLevel.AES, [signer(1), cc(1)]);
});
it('ignores several CC recipients sharing an order with each other', () => {
expectAccepted(SignatureLevel.AES, [signer(1), cc(2), cc(2), cc(null), cc(null)]);
});
it('accepts an empty recipient list', () => {
expectAccepted(SignatureLevel.AES, []);
});
});
describe('SES envelopes', () => {
it('permits signing groups', () => {
expectAccepted(SignatureLevel.SES, [signer(1), signer(2), signer(2)]);
});
it('permits multiple unordered signers', () => {
expectAccepted(SignatureLevel.SES, [signer(null), signer(null)]);
});
});
});
@@ -0,0 +1,64 @@
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<Pick<Recipient, 'role'> & { 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, so it breaks the same per-recipient
* `/ByteRange` invariant that {@link assertCompatibleSigningOrder} exists to
* protect: each TSP signature must cover the exact bytes it was applied to,
* and concurrent incremental updates over one base state cannot all verify.
*
* Note this is not caught by the `signingOrder = PARALLEL` guard: groups are
* expressed as duplicate `Recipient.signingOrder` values on a document whose
* `documentMeta.signingOrder` is SEQUENTIAL.
*
* 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.
*
* Schema-layer guard. {@link sendDocument} re-checks at distribution time as a
* defence-in-depth backstop.
*/
export const assertCompatibleRecipientGrouping = ({
signatureLevel,
recipients,
}: AssertCompatibleRecipientGroupingOptions): void => {
if (!isTspEnvelope({ signatureLevel })) {
return;
}
const seenOrders = new Set<number>();
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);
}
};
@@ -0,0 +1,100 @@
import { RecipientRole } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { assertCompatibleRecipientGrouping } from './assert-compatible-recipient-grouping';
import { assignDefaultRecipientSigningOrders } from './assign-default-recipient-signing-orders';
const signer = (signingOrder?: number | null) => ({
role: RecipientRole.SIGNER,
signingOrder,
});
const defaultRecipient = (email: string, role: RecipientRole = RecipientRole.SIGNER) => ({
email,
name: email,
role,
});
describe('assignDefaultRecipientSigningOrders', () => {
it('leaves defaults unordered on SES envelopes', () => {
const result = assignDefaultRecipientSigningOrders({
signatureLevel: 'SES',
payloadRecipients: [signer(1), signer(2)],
defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')],
});
expect(result.map((recipient) => recipient.signingOrder)).toEqual([undefined, undefined]);
});
it.each(['AES', 'QES'])('assigns distinct orders after the payload max on %s envelopes', (signatureLevel) => {
const result = assignDefaultRecipientSigningOrders({
signatureLevel,
payloadRecipients: [signer(1), signer(4)],
defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')],
});
expect(result.map((recipient) => recipient.signingOrder)).toEqual([5, 6]);
});
it('numbers defaults from 1 when the payload has no numeric orders', () => {
const result = assignDefaultRecipientSigningOrders({
signatureLevel: 'QES',
payloadRecipients: [],
defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')],
});
expect(result.map((recipient) => recipient.signingOrder)).toEqual([1, 2]);
});
it('skips CC defaults while numbering the rest', () => {
const result = assignDefaultRecipientSigningOrders({
signatureLevel: 'AES',
payloadRecipients: [signer(2)],
defaultRecipients: [
defaultRecipient('a@example.com'),
defaultRecipient('cc@example.com', RecipientRole.CC),
defaultRecipient('b@example.com'),
],
});
expect(result.map((recipient) => recipient.signingOrder)).toEqual([3, undefined, 4]);
});
it('produces a combined set that satisfies the TSP grouping assertion', () => {
const payloadRecipients = [signer(1), signer(2)];
const defaults = assignDefaultRecipientSigningOrders({
signatureLevel: 'QES',
payloadRecipients,
defaultRecipients: [defaultRecipient('a@example.com'), defaultRecipient('b@example.com')],
});
expect(() =>
assertCompatibleRecipientGrouping({
signatureLevel: 'QES',
recipients: [...payloadRecipients, ...defaults],
}),
).not.toThrow();
});
it('remains assertion-compatible when the payload holds a single unordered recipient', () => {
// The write-path assert permits one unordered recipient (no shared step);
// numbered defaults must not collide with it.
const payloadRecipients = [signer(null)];
const defaults = assignDefaultRecipientSigningOrders({
signatureLevel: 'AES',
payloadRecipients,
defaultRecipients: [defaultRecipient('a@example.com')],
});
expect(defaults.map((recipient) => recipient.signingOrder)).toEqual([1]);
expect(() =>
assertCompatibleRecipientGrouping({
signatureLevel: 'AES',
recipients: [...payloadRecipients, ...defaults],
}),
).not.toThrow();
});
});
@@ -0,0 +1,57 @@
import type { Recipient } from '@prisma/client';
import { isTspEnvelope } from '../../types/signature-level';
import { isCcRecipient } from '../../utils/recipients';
type AssignDefaultRecipientSigningOrdersOptions<T> = {
signatureLevel: string;
/**
* The recipients supplied by the caller, already validated by
* {@link assertCompatibleRecipientGrouping}.
*/
payloadRecipients: Array<Pick<Recipient, 'role'> & { signingOrder?: number | null }>;
/**
* The team default recipients to append.
*/
defaultRecipients: T[];
};
/**
* Assigns distinct signing orders to team default recipients appended to a
* TSP (AES/QES) envelope.
*
* Team defaults carry no signing order, so on a TSP envelope two or more of
* them would share the unordered tail step — a signing group, which TSP
* signatures cannot hold (see {@link assertCompatibleRecipientGrouping}).
* Numbering them after the payload's highest order keeps the combined set
* valid at write time instead of relying on the send-time flatten backstop.
*
* CC defaults are left unordered: they never sign and are ignored by the
* grouping assertion.
*
* SES envelopes pass through unchanged — shared steps are an SES feature.
*/
export const assignDefaultRecipientSigningOrders = <T extends Pick<Recipient, 'role'>>({
signatureLevel,
payloadRecipients,
defaultRecipients,
}: AssignDefaultRecipientSigningOrdersOptions<T>): Array<T & { signingOrder?: number }> => {
if (!isTspEnvelope({ signatureLevel })) {
return defaultRecipients;
}
let nextOrder =
payloadRecipients.reduce((highest, recipient) => Math.max(highest, recipient.signingOrder ?? 0), 0) + 1;
return defaultRecipients.map((recipient) => {
if (isCcRecipient(recipient)) {
return recipient;
}
const signingOrder = nextOrder;
nextOrder += 1;
return { ...recipient, signingOrder };
});
};
@@ -40,6 +40,7 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { filterRecipientsInFirstSigningGroup } from '../../utils/recipient-groups';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
@@ -676,6 +677,7 @@ export const createDocumentFromDirectTemplate = async ({
select: {
id: true,
signingOrder: true,
signingStatus: true,
name: true,
email: true,
role: true,
@@ -694,9 +696,27 @@ export const createDocumentFromDirectTemplate = async ({
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
});
const nextRecipient = pendingRecipients[0];
const nextGroup = filterRecipientsInFirstSigningGroup(pendingRecipients);
if (nextRecipient) {
const directRecipientOrder = createdDirectRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER;
// The direct recipient can share a step with other recipients (a signing
// group). Those peers are still pending, so without this check they would
// look like the "next" step and be dictated over — dictation may only
// affect a strictly later step.
const hasCompletedCurrentStep = nextGroup.every(
(pendingRecipient) => (pendingRecipient.signingOrder ?? Number.MAX_SAFE_INTEGER) > directRecipientOrder,
);
// Dictation only applies when the next step is a single recipient.
const nextRecipient = hasCompletedCurrentStep && nextGroup.length === 1 ? nextGroup[0] : null;
// The guard at the top of this function rejects `nextSigner` unless the
// template enables dictation, and the derived meta carries the flag
// through unchanged — but the audit log and the update must share ONE
// condition regardless, so the trail can never record a rewrite that
// was not applied.
if (nextRecipient && documentMeta.allowDictateNextSigner) {
auditLogsToCreate.push(
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
@@ -730,12 +750,8 @@ export const createDocumentFromDirectTemplate = async ({
await tx.recipient.update({
where: { id: nextRecipient.id },
data: {
...(nextSigner && documentMeta?.allowDictateNextSigner
? {
name: nextSigner.name,
email: nextSigner.email,
}
: {}),
name: nextSigner.name,
email: nextSigner.email,
},
});
}