Files
documenso/packages/lib/server-only/envelope/create-envelope.ts
T
Lucas Smith d5ce222482 feat: add CSC AES/QES signing (v1 instance-wide config) (#2874)
Adds Cloud Signature Consortium (CSC) integration for AES/QES signing
against a configured TSP. v1 ships as instance-wide configuration via
environment variables, with per-envelope signature level selection,
license gating, and an OAuth-driven signing flow (capture + FIFO
signers, SAD session, blocking/in-progress recipient pages).

Includes signature level compatibility checks (role, signing order,
dictate next signer), envelope mutability assertions, Prisma migration
for signature level and CSC tables, and docs for the new signing
certificate options.
2026-06-16 23:37:34 +10:00

674 lines
21 KiB
TypeScript

import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { PlaceholderInfo } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { convertPlaceholdersToFieldInputs } from '@documenso/lib/server-only/pdf/auto-place-fields';
import { findRecipientByPlaceholder } from '@documenso/lib/server-only/pdf/helpers';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { DocumentMeta, DocumentVisibility, TemplateType } from '@prisma/client';
import {
DocumentSource,
EnvelopeType,
FolderType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import type {
TDocumentAccessAuthTypes,
TDocumentActionAuthTypes,
TRecipientAccessAuthTypes,
TRecipientActionAuthTypes,
} from '../../types/document-auth';
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';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { buildTeamWhereQuery } from '../../utils/teams';
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
import { getTeamSettings } from '../team/get-team-settings';
import { assertUserNotDisabledById } from '../user/assert-user-not-disabled';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
type CreateEnvelopeRecipientFieldOptions = TFieldAndMeta & {
documentDataId: string;
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
};
type CreateEnvelopeRecipientOptions = {
email: string;
name: string;
role: RecipientRole;
signingOrder?: number;
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
fields?: CreateEnvelopeRecipientFieldOptions[];
};
export type CreateEnvelopeOptions = {
userId: number;
teamId: number;
normalizePdf?: boolean;
internalVersion: 1 | 2;
data: {
type: EnvelopeType;
title: string;
externalId?: string;
envelopeItems: {
title?: string;
documentDataId: string;
order?: number;
placeholders?: PlaceholderInfo[];
}[];
formValues?: TDocumentFormValues;
userTimezone?: string;
templateType?: TemplateType;
publicTitle?: string;
publicDescription?: string;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
recipients?: CreateEnvelopeRecipientOptions[];
folderId?: string;
delegatedDocumentOwner?: string;
signatureLevel?: TSignatureLevel;
};
attachments?: Array<{
label: string;
data: string;
type?: TEnvelopeAttachmentType;
}>;
/**
* Whether to bypass adding default recipients.
*
* Defaults to false.
*/
bypassDefaultRecipients?: boolean;
meta?: Partial<Omit<DocumentMeta, 'id'>>;
requestMetadata: ApiRequestMetadata;
};
export const createEnvelope = async ({
userId,
teamId,
normalizePdf,
data,
attachments,
meta,
requestMetadata,
internalVersion,
bypassDefaultRecipients = false,
}: CreateEnvelopeOptions) => {
// Refuse to create on behalf of a disabled account. Guards every route that
// funnels through here (document.create, envelope.use, template create,
// embedding template/document create, API v1) and the seed/job paths.
await assertUserNotDisabledById({ userId });
const {
type,
title,
externalId,
formValues,
userTimezone,
folderId,
templateType,
globalAccessAuth,
globalActionAuth,
publicTitle,
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: {
organisation: {
select: {
organisationClaim: true,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
// Enforce the organisation document-creation limit before doing any work.
// Only documents count towards the limit (templates are exempt).
if (type === EnvelopeType.DOCUMENT) {
await assertOrganisationRatesAndLimits({
organisationId: team.organisationId,
organisationClaim: team.organisation.organisationClaim,
type: 'document',
count: 1,
});
}
// Verify that the folder exists and is associated with the team.
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: data.type === EnvelopeType.TEMPLATE ? FolderType.TEMPLATE : FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
const settings = await getTeamSettings({
userId,
teamId,
});
if (data.envelopeItems.length !== 1 && internalVersion === 1) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Envelope items must have exactly 1 item for version 1',
});
}
// 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
if (normalizePdf) {
envelopeItems = await Promise.all(
data.envelopeItems.map(async (item) => {
const documentData = await prisma.documentData.findFirst({
where: {
id: item.documentDataId,
},
});
if (!documentData) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document data not found',
});
}
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer), {
flattenForm: type !== EnvelopeType.TEMPLATE,
});
const titleToUse = item.title || title;
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: titleToUse,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
});
return {
title: titleToUse.endsWith('.pdf') ? titleToUse.slice(0, -4) : titleToUse,
documentDataId: newDocumentData.id,
order: item.order,
};
}),
);
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: globalAccessAuth || [],
globalActionAuth: globalActionAuth || [],
});
const recipientsHaveActionAuth = data.recipients?.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (
(authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) &&
!team.organisation.organisationClaim.flags.cfr21
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
for (const recipient of data.recipients ?? []) {
assertCompatibleRecipientRole({ signatureLevel, role: recipient.role });
}
const visibility = visibilityOverride || settings.documentVisibility;
const emailId = meta?.emailId;
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = meta?.timezone || settings.documentTimezone || userTimezone;
const getValidatedDelegatedOwner = async () => {
if (!settings.delegateDocumentOwnership || !delegatedDocumentOwner || requestMetadata.source === 'app') {
return null;
}
const delegatedOwner = await prisma.user.findFirst({
where: {
email: delegatedDocumentOwner,
},
});
if (!delegatedOwner) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Delegated document owner must be a member of the team',
});
}
const isTeamMember = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId: delegatedOwner.id }),
});
if (!isTeamMember) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Delegated document owner must be a member of the team',
});
}
return delegatedOwner;
};
const [documentMeta, secondaryId, delegatedOwner] = await Promise.all([
prisma.documentMeta.create({
data: extractDerivedDocumentMeta(
settings,
{
...meta,
timezone: timezoneToUse,
},
signatureLevel,
),
}),
type === EnvelopeType.DOCUMENT
? incrementDocumentId().then((v) => v.formattedDocumentId)
: incrementTemplateId().then((v) => v.formattedTemplateId),
getValidatedDelegatedOwner(),
]);
const envelopeOwnerId = delegatedOwner?.id ?? userId;
const createdEnvelope = await prisma.$transaction(async (tx) => {
const envelope = await tx.envelope.create({
data: {
id: prefixedId('envelope'),
secondaryId,
internalVersion,
type,
title,
signatureLevel,
qrToken: prefixedId('qr'),
externalId,
envelopeItems: {
createMany: {
data: envelopeItems.map((item, i) => ({
id: prefixedId('envelope_item'),
title: item.title || title,
order: item.order !== undefined ? item.order : i + 1,
documentDataId: item.documentDataId,
})),
},
},
envelopeAttachments: {
createMany: {
data: (attachments || []).map((attachment) => ({
label: attachment.label,
data: attachment.data,
type: attachment.type ?? 'link',
})),
},
},
userId: envelopeOwnerId,
teamId,
authOptions,
visibility,
folderId,
formValues,
source: type === EnvelopeType.DOCUMENT ? DocumentSource.DOCUMENT : DocumentSource.TEMPLATE,
documentMetaId: documentMeta.id,
// Template specific fields.
templateType: type === EnvelopeType.TEMPLATE ? templateType : undefined,
publicTitle: type === EnvelopeType.TEMPLATE ? publicTitle : undefined,
publicDescription: type === EnvelopeType.TEMPLATE ? publicDescription : undefined,
},
include: {
envelopeItems: true,
},
});
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) => {
const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const recipientFieldsToCreate = (recipient.fields || []).map((field) => {
let envelopeItemId = firstEnvelopeItem.id;
if (field.documentDataId) {
const foundEnvelopeItem = envelope.envelopeItems.find(
(item) => item.documentDataId === field.documentDataId,
);
if (!foundEnvelopeItem) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document data not found',
});
}
envelopeItemId = foundEnvelopeItem.id;
}
return {
envelopeId: envelope.id,
envelopeItemId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
};
});
await tx.recipient.create({
data: {
envelopeId: envelope.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus: recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions: recipientAuthOptions,
fields: {
createMany: {
data: recipientFieldsToCreate,
},
},
},
});
}),
);
// Create fields from PDF placeholders (extracted at upload time).
const itemsWithPlaceholders = envelopeItems.filter((item) => item.placeholders && item.placeholders.length > 0);
if (itemsWithPlaceholders.length > 0) {
// Collect all unique recipient placeholder references (e.g. "r1", "r2").
const allPlaceholders = itemsWithPlaceholders.flatMap((item) => item.placeholders ?? []);
const uniqueRecipientRefs = new Map<number, string>();
for (const p of allPlaceholders) {
const match = p.recipient.match(/^r(\d+)$/i);
if (match) {
const index = Number(match[1]);
if (!uniqueRecipientRefs.has(index)) {
uniqueRecipientRefs.set(index, `Recipient ${index}`);
}
}
}
// Fetch existing recipients (may have been created above from data.recipients or defaults).
let availableRecipients = await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true },
});
const shouldCreatePlaceholderRecipients =
(!data.recipients || data.recipients.length === 0) && uniqueRecipientRefs.size > 0;
// If recipients were not provided, create placeholder recipients even when defaults exist.
if (shouldCreatePlaceholderRecipients) {
const existingRecipientEmails = new Set(availableRecipients.map((recipient) => recipient.email.toLowerCase()));
const placeholderRecipients = Array.from(uniqueRecipientRefs.entries(), ([recipientIndex, name]) => ({
envelopeId: envelope.id,
email: `recipient.${recipientIndex}@documenso.com`,
name,
role: RecipientRole.SIGNER,
signingOrder: recipientIndex,
token: nanoid(),
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
})).filter((recipient) => !existingRecipientEmails.has(recipient.email.toLowerCase()));
if (placeholderRecipients.length > 0) {
await tx.recipient.createMany({
data: placeholderRecipients,
});
// eslint-disable-next-line require-atomic-updates
availableRecipients = await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true },
});
}
}
for (const item of itemsWithPlaceholders) {
const envelopeItem = envelope.envelopeItems.find((ei) => ei.documentDataId === item.documentDataId);
if (!envelopeItem) {
continue;
}
const fieldsToCreate = convertPlaceholdersToFieldInputs(
item.placeholders ?? [],
(recipientPlaceholder, placeholder) =>
findRecipientByPlaceholder(
recipientPlaceholder,
placeholder,
data.recipients && data.recipients.length > 0
? data.recipients.map((r) => {
const found = availableRecipients.find((cr) => cr.email === r.email);
if (!found) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: `Recipient not found for email: ${r.email}`,
});
}
return found;
})
: undefined,
availableRecipients,
),
envelopeItem.id,
);
if (fieldsToCreate.length > 0) {
await tx.field.createMany({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
}
}
}
const createdEnvelope = await tx.envelope.findFirst({
where: {
id: envelope.id,
},
include: {
documentMeta: true,
recipients: true,
fields: true,
folder: true,
envelopeAttachments: true,
envelopeItems: {
include: {
documentData: true,
},
},
},
});
if (!createdEnvelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
// Only create audit logs for documents.
if (type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
envelopeId: envelope.id,
user: {
id: envelopeOwnerId,
},
metadata: requestMetadata,
data: {
title,
source: {
type: DocumentSource.DOCUMENT,
},
},
}),
});
// Create audit log for delegated owner if validation passed
if (delegatedOwner) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELEGATED_OWNER_CREATED,
envelopeId: envelope.id,
user: {
id: userId,
},
metadata: requestMetadata,
data: {
delegatedOwnerName: delegatedOwner.name,
delegatedOwnerEmail: delegatedOwner.email,
teamName: team.name,
},
}),
});
}
}
return createdEnvelope;
});
// Trigger webhook outside the transaction to avoid holding the connection
// open during network I/O.
if (type === EnvelopeType.DOCUMENT) {
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
} else if (type === EnvelopeType.TEMPLATE) {
await triggerWebhook({
event: WebhookTriggerEvents.TEMPLATE_CREATED,
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(createdEnvelope)),
userId,
teamId,
});
}
return createdEnvelope;
};