mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 09:25:08 +10:00
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.
This commit is contained in:
@@ -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';
|
||||
@@ -36,6 +37,8 @@ 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';
|
||||
@@ -89,6 +92,7 @@ export type CreateEnvelopeOptions = {
|
||||
recipients?: CreateEnvelopeRecipientOptions[];
|
||||
folderId?: string;
|
||||
delegatedDocumentOwner?: string;
|
||||
signatureLevel?: TSignatureLevel;
|
||||
};
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
@@ -137,8 +141,14 @@ export const createEnvelope = async ({
|
||||
publicDescription,
|
||||
visibility: visibilityOverride,
|
||||
delegatedDocumentOwner,
|
||||
signatureLevel: requestedSignatureLevel,
|
||||
} = data;
|
||||
|
||||
const signatureLevel = resolveSignatureLevel({
|
||||
requested: requestedSignatureLevel,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: buildTeamWhereQuery({ teamId, userId }),
|
||||
include: {
|
||||
@@ -195,6 +205,17 @@ export const createEnvelope = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// CSC / TSP signing flows assume the V2 envelope shape: per-recipient
|
||||
// anchors, materialised PDF lineage, sequential signing, mutation lock.
|
||||
// The legacy V1 (Document) model can't carry that state, so AES/QES on V1
|
||||
// is structurally unsupported and must fail at create time — not later at
|
||||
// sign or seal time when the cause is harder to attribute.
|
||||
if (signatureLevel !== 'SES' && internalVersion === 1) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: `Envelopes signed at '${signatureLevel}' require internalVersion=2; the legacy V1 envelope shape cannot host TSP signing.`,
|
||||
});
|
||||
}
|
||||
|
||||
let envelopeItems = data.envelopeItems;
|
||||
|
||||
// Todo: Envelopes - Remove
|
||||
@@ -255,6 +276,10 @@ export const createEnvelope = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of data.recipients ?? []) {
|
||||
assertCompatibleRecipientRole({ signatureLevel, role: recipient.role });
|
||||
}
|
||||
|
||||
const visibility = visibilityOverride || settings.documentVisibility;
|
||||
|
||||
const emailId = meta?.emailId;
|
||||
@@ -311,10 +336,14 @@ export const createEnvelope = async ({
|
||||
|
||||
const [documentMeta, secondaryId, delegatedOwner] = await Promise.all([
|
||||
prisma.documentMeta.create({
|
||||
data: extractDerivedDocumentMeta(settings, {
|
||||
...meta,
|
||||
timezone: timezoneToUse,
|
||||
}),
|
||||
data: extractDerivedDocumentMeta(
|
||||
settings,
|
||||
{
|
||||
...meta,
|
||||
timezone: timezoneToUse,
|
||||
},
|
||||
signatureLevel,
|
||||
),
|
||||
}),
|
||||
type === EnvelopeType.DOCUMENT
|
||||
? incrementDocumentId().then((v) => v.formattedDocumentId)
|
||||
@@ -331,6 +360,7 @@ export const createEnvelope = async ({
|
||||
internalVersion,
|
||||
type,
|
||||
title,
|
||||
signatureLevel,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId,
|
||||
envelopeItems: {
|
||||
|
||||
@@ -4,11 +4,13 @@ import pMap from 'p-map';
|
||||
import { omit } from 'remeda';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { ZSignatureLevelSchema } from '../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import { nanoid, prefixedId } from '../../universal/id';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
@@ -40,6 +42,7 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
|
||||
title: true,
|
||||
userId: true,
|
||||
internalVersion: true,
|
||||
signatureLevel: true,
|
||||
templateType: true,
|
||||
publicTitle: true,
|
||||
publicDescription: true,
|
||||
@@ -116,12 +119,21 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
|
||||
? 'PRIVATE'
|
||||
: (envelope.templateType ?? undefined);
|
||||
|
||||
// The source level is a free-form TEXT column — parse defensively before
|
||||
// handing to the resolver. Coerce (not strict) because instance mode may have
|
||||
// changed since the source envelope was created.
|
||||
const duplicatedSignatureLevel = resolveSignatureLevel({
|
||||
requested: ZSignatureLevelSchema.parse(envelope.signatureLevel),
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const duplicatedEnvelope = await prisma.envelope.create({
|
||||
data: {
|
||||
id: prefixedId('envelope'),
|
||||
secondaryId,
|
||||
type: targetType,
|
||||
internalVersion: envelope.internalVersion,
|
||||
signatureLevel: duplicatedSignatureLevel,
|
||||
userId,
|
||||
teamId,
|
||||
title: envelope.title + ' (copy)',
|
||||
|
||||
@@ -36,6 +36,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
authOptions: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
signatureLevel: true,
|
||||
}).extend({
|
||||
documentMeta: DocumentMetaSchema.pick({
|
||||
signingOrder: true,
|
||||
|
||||
@@ -15,7 +15,10 @@ import { createDocumentAuthOptions, extractDocumentAuthMethods } from '../../uti
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { buildTeamWhereQuery, canAccessTeamDocument } from '../../utils/teams';
|
||||
import { recomputeNextReminderForEnvelope } from '../recipient/update-recipient-next-reminder';
|
||||
import { assertCompatibleDictateNextSigner } from '../signature-level/assert-compatible-dictate-next-signer';
|
||||
import { assertCompatibleSigningOrder } from '../signature-level/assert-compatible-signing-order';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { assertEnvelopeMutable } from './assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from './get-envelope-by-id';
|
||||
|
||||
export type UpdateEnvelopeOptions = {
|
||||
@@ -76,6 +79,22 @@ export const updateEnvelope = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (meta.signingOrder !== undefined) {
|
||||
assertCompatibleSigningOrder({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
signingOrder: meta.signingOrder,
|
||||
});
|
||||
}
|
||||
|
||||
if (meta.allowDictateNextSigner !== undefined) {
|
||||
assertCompatibleDictateNextSigner({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
allowDictateNextSigner: meta.allowDictateNextSigner,
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.type !== EnvelopeType.TEMPLATE && (data.publicTitle || data.publicDescription || data.templateType)) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'You cannot update the template fields for document type envelopes',
|
||||
@@ -297,6 +316,8 @@ export const updateEnvelope = async ({
|
||||
// }
|
||||
|
||||
const updatedEnvelope = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const result = await tx.envelope.update({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { type BoundingBox, whiteoutRegions } from '../pdf/auto-place-fields';
|
||||
|
||||
@@ -93,6 +94,8 @@ export const createEnvelopeFields = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.type === EnvelopeType.DOCUMENT && envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
@@ -242,6 +245,8 @@ export const createEnvelopeFields = async ({
|
||||
});
|
||||
|
||||
const createdFields = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const newlyCreatedFields = await tx.field.createManyAndReturn({
|
||||
data: validatedFields.map((field) => ({
|
||||
type: field.type,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface DeleteDocumentFieldOptions {
|
||||
@@ -59,6 +60,8 @@ export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMeta
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Document already complete',
|
||||
@@ -81,6 +84,8 @@ export const deleteDocumentField = async ({ userId, teamId, fieldId, requestMeta
|
||||
}
|
||||
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
const deletedField = await tx.field.delete({
|
||||
where: {
|
||||
id: fieldId,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientFieldsBeModified } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
|
||||
export interface UpdateEnvelopeFieldsOptions {
|
||||
@@ -60,6 +61,8 @@ export const updateEnvelopeFields = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
@@ -115,6 +118,8 @@ export const updateEnvelopeFields = async ({
|
||||
});
|
||||
|
||||
const updatedFields = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
return await Promise.all(
|
||||
fieldsToUpdate.map(async ({ originalField, updateData, recipientEmail }) => {
|
||||
const updatedField = await tx.field.update({
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { LicenseFlag, TCachedLicense } from '../../types/license';
|
||||
import { env } from '../../utils/env';
|
||||
import { LicenseClient } from './license-client';
|
||||
|
||||
type AssertLicensedForOptions = {
|
||||
/**
|
||||
* Override the AppError code thrown when the assertion fails.
|
||||
*
|
||||
* Defaults to `AppErrorCode.FORBIDDEN`. Callers that need a more specific
|
||||
* surface — for example the CSC transport throwing `CSC_UNLICENSED` at
|
||||
* transport-create time — pass their own code here.
|
||||
*/
|
||||
errorCode?: string;
|
||||
|
||||
/**
|
||||
* Override the AppError message thrown when the assertion fails.
|
||||
*/
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the configured Documenso licence grants `flag`. Reads the
|
||||
* {@link LicenseClient} cache; never re-pings the licence server.
|
||||
*
|
||||
* - No `NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY` → throws. No licensing intent.
|
||||
* - Key set, claim unverifiable (no client, null cache, read throws,
|
||||
* `license: null`) → passes. Mirrors how org-claim gates keep running on
|
||||
* last known state when the licence server is unreachable; paying
|
||||
* operators shouldn't be locked out by transient infra.
|
||||
* - Key set, claim loaded and denies the flag (bad standing or flag falsy)
|
||||
* → throws.
|
||||
*/
|
||||
export const assertLicensedFor = async (flag: LicenseFlag, options?: AssertLicensedForOptions): Promise<void> => {
|
||||
const denied = (): AppError =>
|
||||
new AppError(options?.errorCode ?? AppErrorCode.FORBIDDEN, {
|
||||
message: options?.message ?? `License does not include the "${flag}" feature.`,
|
||||
});
|
||||
|
||||
// No licence key configured = no licensing intent. Fail closed unconditionally
|
||||
// so unlicensed instances cannot reach gated features simply because the
|
||||
// licence cache is empty.
|
||||
if (!env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY')) {
|
||||
throw denied();
|
||||
}
|
||||
|
||||
let cached: TCachedLicense | null = null;
|
||||
|
||||
const licenseClient = LicenseClient.getInstance();
|
||||
|
||||
if (licenseClient) {
|
||||
cached = await licenseClient?.getCachedLicense().catch(() => null);
|
||||
}
|
||||
|
||||
// Licence key is configured but we have no positively-verified claim to
|
||||
// check. Fail-open — see block comment for the full set of conditions and
|
||||
// rationale.
|
||||
if (!cached?.license) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inGoodStanding = cached.derivedStatus === 'ACTIVE' || cached.derivedStatus === 'PAST_DUE';
|
||||
|
||||
const flagGranted = Boolean(cached.license.flags[flag]);
|
||||
|
||||
if (!inGoodStanding || !flagGranted) {
|
||||
throw denied();
|
||||
}
|
||||
};
|
||||
@@ -10,7 +10,9 @@ import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapRecipientToLegacyRecipient } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export interface CreateEnvelopeRecipientsOptions {
|
||||
userId: number;
|
||||
@@ -63,6 +65,8 @@ export const createEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
@@ -80,12 +84,21 @@ export const createEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipientsToCreate) {
|
||||
assertCompatibleRecipientRole({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
role: recipient.role,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipientsToCreate.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
}));
|
||||
|
||||
const createdRecipients = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
return await Promise.all(
|
||||
normalizedRecipients.map(async (recipient) => {
|
||||
const authOptions = createRecipientAuthOptions({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -22,7 +22,9 @@ import { logger } from '../../utils/logger';
|
||||
import { canRecipientBeModified, isRecipientEmailValidForSending } from '../../utils/recipients';
|
||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||
import { getEmailContext } from '../email/get-email-context';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
|
||||
export interface SetDocumentRecipientsOptions {
|
||||
@@ -80,6 +82,8 @@ export const setDocumentRecipients = async ({
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new Error('Document already complete');
|
||||
}
|
||||
@@ -105,6 +109,13 @@ export const setDocumentRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
assertCompatibleRecipientRole({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
role: recipient.role,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
email: recipient.email.toLowerCase(),
|
||||
@@ -139,6 +150,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);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { nanoid } from '../../universal/id';
|
||||
import { createRecipientAuthOptions } from '../../utils/document-auth';
|
||||
import { type EnvelopeIdOptions, mapSecondaryIdToTemplateId } from '../../utils/envelope';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export type SetTemplateRecipientsOptions = {
|
||||
userId: number;
|
||||
@@ -60,6 +61,13 @@ export const setTemplateRecipients = async ({ userId, teamId, id, recipients }:
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
assertCompatibleRecipientRole({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
role: recipient.role,
|
||||
});
|
||||
}
|
||||
|
||||
const normalizedRecipients = recipients.map((recipient) => {
|
||||
// Force replace any changes to the name or email of the direct recipient.
|
||||
if (envelope.directLink && recipient.id === envelope.directLink.directTemplateRecipientId) {
|
||||
|
||||
@@ -12,7 +12,9 @@ import { extractLegacyIds } from '../../universal/id';
|
||||
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||
import { mapFieldToLegacyField } from '../../utils/fields';
|
||||
import { canRecipientBeModified } from '../../utils/recipients';
|
||||
import { assertEnvelopeMutable } from '../envelope/assert-envelope-mutable';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { assertCompatibleRecipientRole } from '../signature-level/assert-compatible-recipient-role';
|
||||
|
||||
export interface UpdateEnvelopeRecipientsOptions {
|
||||
userId: number;
|
||||
@@ -67,6 +69,8 @@ export const updateEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
assertEnvelopeMutable(envelope);
|
||||
|
||||
if (envelope.completedAt) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Envelope already complete',
|
||||
@@ -84,6 +88,17 @@ export const updateEnvelopeRecipients = async ({
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (recipient.role === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
assertCompatibleRecipientRole({
|
||||
signatureLevel: envelope.signatureLevel,
|
||||
role: recipient.role,
|
||||
});
|
||||
}
|
||||
|
||||
const recipientsToUpdate = recipients.map((recipient) => {
|
||||
const originalRecipient = envelope.recipients.find((existingRecipient) => existingRecipient.id === recipient.id);
|
||||
|
||||
@@ -106,6 +121,8 @@ export const updateEnvelopeRecipients = async ({
|
||||
});
|
||||
|
||||
const updatedRecipients = await prisma.$transaction(async (tx) => {
|
||||
await assertEnvelopeMutable(envelope, tx);
|
||||
|
||||
return await Promise.all(
|
||||
recipientsToUpdate.map(async ({ originalRecipient, updateData }) => {
|
||||
let authOptions = ZRecipientAuthOptionsSchema.parse(originalRecipient.authOptions);
|
||||
|
||||
@@ -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';
|
||||
@@ -43,6 +44,7 @@ import { sendDocument } from '../document/send-document';
|
||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||
import { incrementDocumentId } from '../envelope/increment-id';
|
||||
import { assertOrganisationRatesAndLimits } from '../rate-limit/assert-organisation-rates-and-limits';
|
||||
import { resolveSignatureLevel } from '../signature-level/resolve-signature-level';
|
||||
import { getTeamSettings } from '../team/get-team-settings';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
|
||||
@@ -197,6 +199,17 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
(recipient) => recipient.id !== directTemplateRecipient.id,
|
||||
);
|
||||
|
||||
// Carry the template's level forward, coercing if the instance mode has
|
||||
// changed since the template was created. ZSignatureLevelSchema parses the
|
||||
// free-form TEXT column defensively. Resolved before meta extraction so
|
||||
// signingOrder picks up the TSP-appropriate default + assertion.
|
||||
const signatureLevel = resolveSignatureLevel({
|
||||
requested: ZSignatureLevelSchema.parse(directTemplateEnvelope.signatureLevel),
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta, signatureLevel);
|
||||
|
||||
// The resulting document contains every non-direct template recipient plus the
|
||||
// direct recipient that is signing now. A recipientCount of 0 means unlimited.
|
||||
// This mirrors the check in `sendDocument`, but must be done here because this
|
||||
@@ -211,8 +224,6 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const derivedDocumentMeta = extractDerivedDocumentMeta(settings, directTemplateEnvelope.documentMeta);
|
||||
|
||||
// Associate, validate and map to a query every direct template recipient field with the provided fields.
|
||||
// Only process fields that are either required or have been signed by the user
|
||||
const fieldsToProcess = directTemplateRecipient.fields.filter((templateField) => {
|
||||
@@ -352,6 +363,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
secondaryId: incrementedDocumentId.formattedDocumentId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
internalVersion: directTemplateEnvelope.internalVersion,
|
||||
signatureLevel,
|
||||
qrToken: prefixedId('qr'),
|
||||
source: DocumentSource.TEMPLATE_DIRECT_LINK,
|
||||
templateId: directTemplateEnvelopeLegacyId,
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
TTextFieldMeta,
|
||||
} from '../../types/field-meta';
|
||||
import { ZCheckboxFieldMeta, ZDropdownFieldMeta, ZFieldMetaSchema, ZRadioFieldMeta } from '../../types/field-meta';
|
||||
import { ZSignatureLevelSchema } from '../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../types/webhook-payload';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||
@@ -50,6 +51,7 @@ import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
|
||||
import { incrementDocumentId } from '../envelope/increment-id';
|
||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||
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 { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
@@ -513,23 +515,36 @@ export const createDocumentFromTemplate = async ({
|
||||
|
||||
const incrementedDocumentId = await incrementDocumentId();
|
||||
|
||||
// Carry the template's level forward, coercing if the instance mode has
|
||||
// changed since the template was created. ZSignatureLevelSchema parses the
|
||||
// free-form TEXT column defensively. Resolved before meta extraction so
|
||||
// signingOrder picks up the TSP-appropriate default + assertion.
|
||||
const signatureLevel = resolveSignatureLevel({
|
||||
requested: ZSignatureLevelSchema.parse(template.signatureLevel),
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const documentMeta = await prisma.documentMeta.create({
|
||||
data: extractDerivedDocumentMeta(settings, {
|
||||
subject: override?.subject || template.documentMeta?.subject,
|
||||
message: override?.message || template.documentMeta?.message,
|
||||
timezone: override?.timezone || template.documentMeta?.timezone,
|
||||
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
|
||||
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
|
||||
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
|
||||
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
|
||||
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
|
||||
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
|
||||
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
|
||||
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
|
||||
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
|
||||
}),
|
||||
data: extractDerivedDocumentMeta(
|
||||
settings,
|
||||
{
|
||||
subject: override?.subject || template.documentMeta?.subject,
|
||||
message: override?.message || template.documentMeta?.message,
|
||||
timezone: override?.timezone || template.documentMeta?.timezone,
|
||||
dateFormat: override?.dateFormat || template.documentMeta?.dateFormat,
|
||||
redirectUrl: override?.redirectUrl || template.documentMeta?.redirectUrl,
|
||||
distributionMethod: override?.distributionMethod || template.documentMeta?.distributionMethod,
|
||||
emailSettings: override?.emailSettings || template.documentMeta?.emailSettings,
|
||||
signingOrder: override?.signingOrder || template.documentMeta?.signingOrder,
|
||||
language: override?.language || template.documentMeta?.language || settings.documentLanguage,
|
||||
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
|
||||
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
|
||||
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
|
||||
},
|
||||
signatureLevel,
|
||||
),
|
||||
});
|
||||
|
||||
const { envelope, createdEnvelope } = await prisma.$transaction(async (tx) => {
|
||||
@@ -539,6 +554,7 @@ export const createDocumentFromTemplate = async ({
|
||||
secondaryId: incrementedDocumentId.formattedDocumentId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
internalVersion: template.internalVersion,
|
||||
signatureLevel,
|
||||
qrToken: prefixedId('qr'),
|
||||
source: DocumentSource.TEMPLATE,
|
||||
externalId: externalId || template.externalId,
|
||||
|
||||
Reference in New Issue
Block a user