chore: merge origin/main into pr-2889 (resolve cancelled/expired status conflicts)

This commit is contained in:
ephraimduncan
2026-06-25 11:14:29 +00:00
460 changed files with 33632 additions and 5002 deletions
@@ -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,