chore: merged main

This commit is contained in:
Catalin Pit
2026-06-17 12:04:51 +03:00
103 changed files with 6524 additions and 77 deletions
@@ -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,
@@ -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({
@@ -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,81 @@
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` 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)
.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';
@@ -38,6 +39,8 @@ import { getRecipientSigningOrder } from '../../utils/recipients';
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';
@@ -90,6 +93,7 @@ export type CreateEnvelopeOptions = {
recipients?: CreateEnvelopeRecipientOptions[];
folderId?: string;
delegatedDocumentOwner?: string;
signatureLevel?: TSignatureLevel;
};
attachments?: Array<{
label: string;
@@ -138,8 +142,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: {
@@ -196,6 +206,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
@@ -256,6 +277,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;
@@ -312,10 +337,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)
@@ -332,6 +361,7 @@ export const createEnvelope = async ({
internalVersion,
type,
title,
signatureLevel,
qrToken: prefixedId('qr'),
externalId,
envelopeItems: {
@@ -4,6 +4,7 @@ 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';
@@ -11,6 +12,7 @@ import { getRecipientSigningOrder } from '../../utils/recipients';
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 {
@@ -41,6 +43,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
title: true,
userId: true,
internalVersion: true,
signatureLevel: true,
templateType: true,
publicTitle: true,
publicDescription: true,
@@ -117,12 +120,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();
}
};
@@ -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 { getRecipientSigningOrder, 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({
@@ -16,6 +16,7 @@ import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../u
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';
@@ -72,6 +73,8 @@ export const deleteEnvelopeRecipient = async ({
});
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document already complete',
@@ -109,6 +112,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({
@@ -26,8 +26,10 @@ import {
} 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;
@@ -84,6 +86,8 @@ export const setDocumentRecipients = async ({
throw new Error('Document not found');
}
assertEnvelopeMutable(envelope);
if (envelope.completedAt) {
throw new Error('Document already complete');
}
@@ -109,6 +113,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(),
@@ -143,6 +154,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);
@@ -13,6 +13,7 @@ import { createRecipientAuthOptions } from '../../utils/document-auth';
import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getRecipientSigningOrder } from '../../utils/recipients';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
export type SetTemplateRecipientsOptions = {
userId: number;
@@ -61,6 +62,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, getRecipientSigningOrder } 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);
@@ -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;
};
@@ -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';
@@ -44,6 +45,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';
@@ -198,6 +200,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
@@ -212,8 +225,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) => {
@@ -353,6 +364,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';
@@ -52,6 +53,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';
@@ -514,23 +516,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) => {
@@ -540,6 +555,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,