mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 09:54:51 +10:00
chore: merge origin/main into pr-2889 (resolve cancelled/expired status conflicts)
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
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` / `ENVELOPE_CANCELLED` 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)
|
||||
.with(DocumentStatus.CANCELLED, () => AppErrorCode.ENVELOPE_CANCELLED)
|
||||
.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,9 @@ 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 { 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';
|
||||
@@ -88,6 +92,7 @@ export type CreateEnvelopeOptions = {
|
||||
recipients?: CreateEnvelopeRecipientOptions[];
|
||||
folderId?: string;
|
||||
delegatedDocumentOwner?: string;
|
||||
signatureLevel?: TSignatureLevel;
|
||||
};
|
||||
attachments?: Array<{
|
||||
label: string;
|
||||
@@ -136,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: {
|
||||
@@ -155,6 +166,17 @@ export const createEnvelope = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Enforce the organisation document-creation limit before doing any work.
|
||||
// Only documents count towards the limit (templates are exempt).
|
||||
if (type === EnvelopeType.DOCUMENT) {
|
||||
await assertOrganisationRatesAndLimits({
|
||||
organisationId: team.organisationId,
|
||||
organisationClaim: team.organisation.organisationClaim,
|
||||
type: 'document',
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify that the folder exists and is associated with the team.
|
||||
if (folderId) {
|
||||
const folder = await prisma.folder.findUnique({
|
||||
@@ -183,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
|
||||
@@ -243,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;
|
||||
@@ -299,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)
|
||||
@@ -319,6 +360,7 @@ export const createEnvelope = async ({
|
||||
internalVersion,
|
||||
type,
|
||||
title,
|
||||
signatureLevel,
|
||||
qrToken: prefixedId('qr'),
|
||||
externalId,
|
||||
envelopeItems: {
|
||||
|
||||
@@ -4,11 +4,14 @@ 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 { 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 {
|
||||
@@ -25,7 +28,7 @@ export interface DuplicateEnvelopeOptions {
|
||||
export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: DuplicateEnvelopeOptions) => {
|
||||
const { duplicateAsTemplate = false, includeRecipients = true, includeFields = true } = overrides ?? {};
|
||||
|
||||
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
|
||||
id,
|
||||
type: null,
|
||||
userId,
|
||||
@@ -39,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,
|
||||
@@ -83,6 +87,15 @@ export const duplicateEnvelope = async ({ id, userId, teamId, overrides }: Dupli
|
||||
|
||||
const targetType = duplicateAsTemplate ? EnvelopeType.TEMPLATE : envelope.type;
|
||||
|
||||
// Enforce the organisation document-creation limit before creating the duplicate.
|
||||
if (targetType === EnvelopeType.DOCUMENT) {
|
||||
await assertOrganisationRatesAndLimits({
|
||||
organisationId: team.organisationId,
|
||||
type: 'document',
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const [{ legacyNumberId, secondaryId }, createdDocumentMeta] = await Promise.all([
|
||||
targetType === EnvelopeType.DOCUMENT
|
||||
? incrementDocumentId().then(({ documentId, formattedDocumentId }) => ({
|
||||
@@ -106,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,
|
||||
|
||||
Reference in New Issue
Block a user