mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 09:54:51 +10:00
chore: merged main
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app';
|
||||
import { ZRecipientActionAuthTypesSchema, ZRecipientAuthOptionsSchema } from '@documenso/lib/types/document-auth';
|
||||
import type { TEditorEnvelope } from '@documenso/lib/types/envelope-editor';
|
||||
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
|
||||
@@ -22,11 +23,49 @@ const LocalRecipientSchema = z.object({
|
||||
|
||||
type TLocalRecipient = z.infer<typeof LocalRecipientSchema>;
|
||||
|
||||
export const ZEditorRecipientsFormSchema = z.object({
|
||||
signers: z.array(LocalRecipientSchema),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
});
|
||||
/**
|
||||
* Backstop validation that mirrors the CSC-mode UI overrides in
|
||||
* `EnvelopeEditorProvider`. If anything bypasses the disabled controls (URL
|
||||
* tampering, legacy form state, embedded host) the form refuses to submit
|
||||
* rather than persisting values the TSP flow can't honour.
|
||||
*/
|
||||
export const ZEditorRecipientsFormSchema = z
|
||||
.object({
|
||||
signers: z.array(LocalRecipientSchema),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
allowDictateNextSigner: z.boolean().default(false),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!IS_INSTANCE_CSC_MODE()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.signingOrder !== DocumentSigningOrder.SEQUENTIAL) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'CSC envelopes must use SEQUENTIAL signing order.',
|
||||
path: ['signingOrder'],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.allowDictateNextSigner) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'CSC envelopes do not support next-signer dictation.',
|
||||
path: ['allowDictateNextSigner'],
|
||||
});
|
||||
}
|
||||
|
||||
data.signers.forEach((signer, index) => {
|
||||
if (signer.role === RecipientRole.ASSISTANT) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'CSC envelopes do not support the assistant role.',
|
||||
path: ['signers', index, 'role'],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export type TEditorRecipientsFormSchema = z.infer<typeof ZEditorRecipientsFormSchema>;
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IS_INSTANCE_CSC_MODE } from '@documenso/lib/constants/app';
|
||||
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||
import {
|
||||
DEFAULT_EDITOR_CONFIG,
|
||||
@@ -36,6 +37,12 @@ type EnvelopeEditorProviderValue = {
|
||||
isEmbedded: boolean;
|
||||
isDocument: boolean;
|
||||
isTemplate: boolean;
|
||||
/**
|
||||
* Whether the instance is running in CSC (Cloud Signature Consortium) mode.
|
||||
* Components can branch on this for any additional CSC-only UI gating
|
||||
* beyond the overrides already baked into `editorConfig`.
|
||||
*/
|
||||
isCscMode: boolean;
|
||||
|
||||
setLocalEnvelope: (localEnvelope: Partial<TEditorEnvelope>) => void;
|
||||
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
|
||||
@@ -91,7 +98,7 @@ export const useCurrentEnvelopeEditor = () => {
|
||||
|
||||
export const EnvelopeEditorProvider = ({
|
||||
children,
|
||||
editorConfig = DEFAULT_EDITOR_CONFIG,
|
||||
editorConfig: providedEditorConfig = DEFAULT_EDITOR_CONFIG,
|
||||
initialEnvelope,
|
||||
organisationEmails,
|
||||
}: EnvelopeEditorProviderProps) => {
|
||||
@@ -103,6 +110,31 @@ export const EnvelopeEditorProvider = ({
|
||||
const [envelope, _setEnvelope] = useState(initialEnvelope);
|
||||
const [autosaveError, setAutosaveError] = useState<boolean>(false);
|
||||
|
||||
const isCscMode = IS_INSTANCE_CSC_MODE();
|
||||
|
||||
/**
|
||||
* CSC-mode overrides applied on top of any caller-supplied editor config.
|
||||
* TSP envelopes are forced SEQUENTIAL at send-time and the sign path has no
|
||||
* nextSigner dictation; the assistant role's pre-fill semantics don't map
|
||||
* onto each recipient signing their own complete PDF state. Hide all three
|
||||
* up-front so authors don't pick options that would get silently coerced.
|
||||
*/
|
||||
const editorConfig = useMemo<EnvelopeEditorConfig>(() => {
|
||||
if (!isCscMode || !providedEditorConfig.recipients) {
|
||||
return providedEditorConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
...providedEditorConfig,
|
||||
recipients: {
|
||||
...providedEditorConfig.recipients,
|
||||
allowConfigureSigningOrder: false,
|
||||
allowConfigureDictateNextSigner: false,
|
||||
allowAssistantRole: false,
|
||||
},
|
||||
};
|
||||
}, [isCscMode, providedEditorConfig]);
|
||||
|
||||
const envelopeRef = useRef(initialEnvelope);
|
||||
|
||||
const externalFlushCallbacksRef = useRef<Map<string, () => Promise<void>>>(new Map());
|
||||
@@ -467,6 +499,7 @@ export const EnvelopeEditorProvider = ({
|
||||
isEmbedded,
|
||||
isDocument: envelope.type === EnvelopeType.DOCUMENT,
|
||||
isTemplate: envelope.type === EnvelopeType.TEMPLATE,
|
||||
isCscMode,
|
||||
setLocalEnvelope,
|
||||
getRecipientColorKey,
|
||||
updateEnvelope,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
import { SignatureLevel, type TSignatureLevel } from '../types/signature-level';
|
||||
|
||||
export const APP_DOCUMENT_UPLOAD_SIZE_LIMIT = Number(env('NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT')) || 50;
|
||||
|
||||
@@ -33,3 +35,54 @@ export const IS_AI_FEATURES_CONFIGURED = () => !!env('GOOGLE_VERTEX_PROJECT_ID')
|
||||
export const NEXT_PRIVATE_USE_PLAYWRIGHT_PDF = () => env('NEXT_PRIVATE_USE_PLAYWRIGHT_PDF') === 'true';
|
||||
|
||||
export const NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY = () => env('NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY');
|
||||
|
||||
/**
|
||||
* Whether this Documenso instance is running in CSC (Cloud Signature Consortium) mode.
|
||||
*
|
||||
* CSC mode routes signing through a third-party Trust Service Provider for
|
||||
* Advanced and Qualified Electronic Signatures (AES/QES). It is instance-wide
|
||||
* and mutually exclusive with the other signing transports.
|
||||
*/
|
||||
export const IS_INSTANCE_CSC_MODE = (): boolean => {
|
||||
if (typeof window === 'undefined') {
|
||||
return env('NEXT_PRIVATE_SIGNING_TRANSPORT') === 'csc';
|
||||
}
|
||||
|
||||
return env('NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC') === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* The default signature level applied to envelopes created on a CSC-mode
|
||||
* instance when the caller doesn't specify one (or asks for `SES` and the
|
||||
* resolver is in loose-coerce mode).
|
||||
*
|
||||
* Set via `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL`; accepts `AES` or `QES`
|
||||
* only; defaults to `AES` when unset. An explicit `AES`/`QES` request on
|
||||
* envelope create still passes through unchanged — this constant only affects
|
||||
* the coerced default.
|
||||
*
|
||||
* Throws on an invalid value rather than silently falling back. A typo here
|
||||
* (e.g. `qes`) would otherwise silently downgrade qualified-tier instances
|
||||
* to advanced-tier, which has legal consequences.
|
||||
*
|
||||
* Only consulted on CSC-mode instances. Non-CSC instances always default to
|
||||
* `SES` regardless of this var.
|
||||
*/
|
||||
export const CSC_INSTANCE_SIGNATURE_LEVEL = (): TSignatureLevel => {
|
||||
// Cast through `string | undefined` because shells can deliver
|
||||
// `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL=` as an empty string at runtime
|
||||
// — the typed env signature narrows to `'AES' | 'QES' | undefined` only.
|
||||
const value = env('NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL');
|
||||
|
||||
if (!value) {
|
||||
return SignatureLevel.AES;
|
||||
}
|
||||
|
||||
if (value !== SignatureLevel.AES && value !== SignatureLevel.QES) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: `NEXT_PRIVATE_SIGNING_CSC_SIGNATURE_LEVEL must be '${SignatureLevel.AES}' or '${SignatureLevel.QES}', got '${value}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ export enum AppErrorCode {
|
||||
NOT_FOUND = 'NOT_FOUND',
|
||||
NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
|
||||
NOT_SETUP = 'NOT_SETUP',
|
||||
MISSING_ENV_VAR = 'MISSING_ENV_VAR',
|
||||
INVALID_CAPTCHA = 'INVALID_CAPTCHA',
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
@@ -27,6 +28,35 @@ export enum AppErrorCode {
|
||||
ENVELOPE_COMPLETED = 'ENVELOPE_COMPLETED',
|
||||
ENVELOPE_REJECTED = 'ENVELOPE_REJECTED',
|
||||
ENVELOPE_LEGACY = 'ENVELOPE_LEGACY',
|
||||
/**
|
||||
* Authoring mutation rejected because the envelope is an AES/QES envelope
|
||||
* past DRAFT — the TSP mutation lock fires at distribution to preserve
|
||||
* WYSIWYS. SES envelopes never hit this code.
|
||||
*/
|
||||
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
|
||||
|
||||
/**
|
||||
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
|
||||
* for the recovery taxonomy.
|
||||
*/
|
||||
CSC_INSTANCE_MODE_MISMATCH = 'CSC_INSTANCE_MODE_MISMATCH',
|
||||
CSC_UNLICENSED = 'CSC_UNLICENSED',
|
||||
CSC_PROVIDER_INFO_FAILED = 'CSC_PROVIDER_INFO_FAILED',
|
||||
CSC_PROVIDER_NO_TSA = 'CSC_PROVIDER_NO_TSA',
|
||||
CSC_CREDENTIAL_LIST_EMPTY = 'CSC_CREDENTIAL_LIST_EMPTY',
|
||||
CSC_CERT_INVALID = 'CSC_CERT_INVALID',
|
||||
CSC_ALGORITHM_REFUSED = 'CSC_ALGORITHM_REFUSED',
|
||||
CSC_SAD_EXPIRED_PRE_SIGN = 'CSC_SAD_EXPIRED_PRE_SIGN',
|
||||
CSC_TSP_TIMEOUT = 'CSC_TSP_TIMEOUT',
|
||||
CSC_EMBED_FAILED = 'CSC_EMBED_FAILED',
|
||||
CSC_BASE_DOCUMENT_MUTATED = 'CSC_BASE_DOCUMENT_MUTATED',
|
||||
/**
|
||||
* Generic catch-all for CSC HTTP transport failures — network error, non-2xx
|
||||
* response without a more specific semantic match, malformed JSON, or
|
||||
* response schema mismatch. Carries the TSP's HTTP status in `statusCode`
|
||||
* and the TSP's `error` / `error_description` in the message when available.
|
||||
*/
|
||||
CSC_REQUEST_FAILED = 'CSC_REQUEST_FAILED',
|
||||
}
|
||||
|
||||
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> = {
|
||||
@@ -39,6 +69,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
|
||||
[AppErrorCode.NOT_IMPLEMENTED]: { code: 'INTERNAL_SERVER_ERROR', status: 501 },
|
||||
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.MISSING_ENV_VAR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
|
||||
[AppErrorCode.FORBIDDEN]: { code: 'FORBIDDEN', status: 403 },
|
||||
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
@@ -50,6 +81,23 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
|
||||
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
||||
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.CSC_PROVIDER_NO_TSA]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
[AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_CERT_INVALID]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_ALGORITHM_REFUSED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_TSP_TIMEOUT]: { code: 'TIMEOUT', status: 408 },
|
||||
[AppErrorCode.CSC_EMBED_FAILED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_BASE_DOCUMENT_MUTATED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
// Generic transport failure — the TSP is upstream so server-side from our
|
||||
// perspective; 500 keeps the caller surface conservative. The TSP's actual
|
||||
// HTTP status rides along in AppError.statusCode for the few callers that
|
||||
// need to discriminate (e.g. 401 → re-auth, 429 → backoff).
|
||||
[AppErrorCode.CSC_REQUEST_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
};
|
||||
|
||||
export const ZAppErrorJsonSchema = z.object({
|
||||
@@ -239,10 +287,17 @@ export class AppError extends Error {
|
||||
AppErrorCode.ENVELOPE_COMPLETED,
|
||||
AppErrorCode.ENVELOPE_REJECTED,
|
||||
AppErrorCode.ENVELOPE_LEGACY,
|
||||
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
||||
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||
AppErrorCode.CSC_CERT_INVALID,
|
||||
AppErrorCode.CSC_ALGORITHM_REFUSED,
|
||||
AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN,
|
||||
AppErrorCode.CSC_EMBED_FAILED,
|
||||
() => 400 as const,
|
||||
)
|
||||
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
|
||||
.with(AppErrorCode.FORBIDDEN, () => 403 as const)
|
||||
.with(AppErrorCode.FORBIDDEN, AppErrorCode.CSC_UNLICENSED, () => 403 as const)
|
||||
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
|
||||
.with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const)
|
||||
.otherwise(() => 500 as const);
|
||||
|
||||
@@ -20,6 +20,7 @@ const BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION_SCHEMA = z.object({
|
||||
cfr21: z.literal(true).optional(),
|
||||
hipaa: z.literal(true).optional(),
|
||||
signingReminders: z.literal(true).optional(),
|
||||
cscQesSigning: z.literal(true).optional(),
|
||||
// Do NOT backport disableEmails.
|
||||
// Todo: Envelopes - Do we need to check?
|
||||
// authenticationPortal & emailDomains missing here.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import { PDFDocument } from '@cantoo/pdf-lib';
|
||||
import { finalizeTspEnvelopeCompletion } from '@documenso/ee/server-only/signing/csc/finalize-tsp-completion';
|
||||
import { addRejectionStampToPdf } from '@documenso/lib/server-only/pdf/add-rejection-stamp-to-pdf';
|
||||
import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf';
|
||||
import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf';
|
||||
@@ -22,6 +23,7 @@ import { legacy_insertFieldInPDF } from '../../../server-only/pdf/legacy-insert-
|
||||
import { getTeamSettings } from '../../../server-only/team/get-team-settings';
|
||||
import { triggerWebhook } from '../../../server-only/webhooks/trigger/trigger-webhook';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE, type TDocumentAuditLog } from '../../../types/document-audit-logs';
|
||||
import { isTspEnvelope } from '../../../types/signature-level';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../../../types/webhook-payload';
|
||||
import { prefixedId } from '../../../universal/id';
|
||||
import { getFileServerSide } from '../../../universal/upload/get-file.server';
|
||||
@@ -164,6 +166,33 @@ export const run = async ({ payload, io }: { payload: TSealDocumentJobDefinition
|
||||
|
||||
const finalEnvelopeStatus = isRejected ? DocumentStatus.REJECTED : DocumentStatus.COMPLETED;
|
||||
|
||||
if (isTspEnvelope(envelope)) {
|
||||
if (isResealing) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'Re-sealing TSP envelopes is not supported — recipient signatures cannot be regenerated externally.',
|
||||
});
|
||||
}
|
||||
|
||||
if (isRejected) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message:
|
||||
'TSP envelope rejection is not supported in V1 — rejection stamps would invalidate PAdES signatures.',
|
||||
});
|
||||
}
|
||||
|
||||
await finalizeTspEnvelopeCompletion({
|
||||
envelope,
|
||||
envelopeCompletedAuditLog,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
return {
|
||||
envelopeId: envelope.id,
|
||||
envelopeStatus: envelope.status,
|
||||
isRejected,
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-fetch all PDF data so we can read dimensions and pass it
|
||||
// to decorateAndSignPdf without fetching again.
|
||||
const prefetchedItems = await Promise.all(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* One entry in a CSC sign-time session, pinning the bytes (`documentDataId`)
|
||||
* whose digest (`hashB64`) was captured at prep time for a given envelope
|
||||
* item. The `ordinal` is the position of this entry in the session items array
|
||||
* — it lines up with the position-ordered `signatures/signHash` response per
|
||||
* CSC v1.0.4.0 §11.9.
|
||||
*/
|
||||
export const ZCscSessionItemSchema = z.object({
|
||||
envelopeItemId: z.string(),
|
||||
documentDataId: z.string(),
|
||||
hashB64: z.string(),
|
||||
ordinal: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Contract between CSC prep and sign on `CscSession.itemsJson`.
|
||||
*
|
||||
* Built at prep time alongside the captured signedAttrs digests. At sign time
|
||||
* the mutation re-derives each item's digest against the pinned
|
||||
* `documentDataId` bytes and dispatches one batched `signatures/signHash` per
|
||||
* recipient.
|
||||
*/
|
||||
export const ZCscSessionItemsSchema = z.array(ZCscSessionItemSchema);
|
||||
|
||||
export type TCscSessionItem = z.infer<typeof ZCscSessionItemSchema>;
|
||||
export type TCscSessionItems = z.infer<typeof ZCscSessionItemsSchema>;
|
||||
@@ -54,6 +54,13 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
|
||||
|
||||
// CSC / TSP signing events.
|
||||
'DOCUMENT_RECIPIENT_CSC_AUTHENTICATED', // Service-scope OAuth complete; CSC credential persisted.
|
||||
'DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED', // Service-scope OAuth completed but TSP returned a blocking error (empty credential list / invalid cert / refused algorithm).
|
||||
'DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED', // Recipient clicked Sign; CSC session created with captured per-item hashes.
|
||||
'DOCUMENT_RECIPIENT_CSC_AUTHORIZED', // Credential-scope OAuth complete; SAD attached to the CSC session.
|
||||
'DOCUMENT_RECIPIENT_CSC_SIGNED', // TSP returned signatures and they were embedded into the recipient's PDF bytes.
|
||||
]);
|
||||
|
||||
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||
@@ -722,6 +729,71 @@ export const ZDocumentAuditLogEventRecipientExpiredSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient completed CSC service-scope OAuth — credential discovered, certificate persisted.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientCscAuthenticatedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
providerId: z.string(),
|
||||
credentialId: z.string(),
|
||||
signatureAlgorithm: z.string(),
|
||||
digestAlgorithm: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient's CSC service-scope OAuth completed but the TSP returned a blocking error.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientCscAuthenticationFailedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
providerId: z.string(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient initiated TSP signing — CSC session created with per-item hashes.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientCscSignRequestedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
providerId: z.string(),
|
||||
credentialId: z.string(),
|
||||
sessionId: z.string(),
|
||||
numSignatures: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient completed CSC credential-scope OAuth — SAD attached to session.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientCscAuthorizedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHORIZED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
providerId: z.string(),
|
||||
credentialId: z.string(),
|
||||
sessionId: z.string(),
|
||||
sadExpiresAt: z.coerce.date(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: TSP returned signatures and they were embedded into the recipient's PDF bytes.
|
||||
*/
|
||||
export const ZDocumentAuditLogEventDocumentRecipientCscSignedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGNED),
|
||||
data: ZBaseRecipientDataSchema.extend({
|
||||
providerId: z.string(),
|
||||
credentialId: z.string(),
|
||||
sessionId: z.string(),
|
||||
numItemsSigned: z.number(),
|
||||
signatureAlgorithm: z.string(),
|
||||
digestAlgorithm: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@@ -770,6 +842,11 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
||||
ZDocumentAuditLogEventRecipientRemovedSchema,
|
||||
ZDocumentAuditLogEventRecipientExpiredSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCscAuthenticatedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCscAuthenticationFailedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCscSignRequestedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCscAuthorizedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCscSignedSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ export const ZLicenseClaimSchema = z.object({
|
||||
hipaa: z.boolean().optional(),
|
||||
authenticationPortal: z.boolean().optional(),
|
||||
billing: z.boolean().optional(),
|
||||
instanceCscSigning: z.boolean().optional(),
|
||||
cscQesSigning: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -43,6 +45,13 @@ export type TLicenseClaim = z.infer<typeof ZLicenseClaimSchema>;
|
||||
export type TLicenseRequest = z.infer<typeof ZLicenseRequestSchema>;
|
||||
export type TLicenseResponse = z.infer<typeof ZLicenseResponseSchema>;
|
||||
|
||||
/**
|
||||
* String-literal union of every flag the licence server can grant. Adding a
|
||||
* field to `ZLicenseClaimSchema` automatically extends this type so that
|
||||
* `assertLicensedFor(flag)` and similar helpers stay in sync.
|
||||
*/
|
||||
export type LicenseFlag = keyof TLicenseClaim;
|
||||
|
||||
/**
|
||||
* Schema for the cached license data stored in the file.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The cryptographic signature tier an envelope is signed at.
|
||||
*
|
||||
* - `SES` — Simple Electronic Signature; the default Documenso flow signed
|
||||
* with the instance-held certificate.
|
||||
* - `AES` — Advanced Electronic Signature; recipient-bound signing through a
|
||||
* Cloud Signature Consortium (CSC) Trust Service Provider.
|
||||
* - `QES` — Qualified Electronic Signature; the eIDAS-qualified variant of
|
||||
* AES, also recipient-bound through a CSC TSP.
|
||||
*
|
||||
* Stored as free-form TEXT on `Envelope.signatureLevel` so the legal-tier
|
||||
* taxonomy can expand without a DB enum migration. Validation lives here.
|
||||
*/
|
||||
export const ZSignatureLevelSchema = z.enum(['SES', 'AES', 'QES']);
|
||||
|
||||
export const SignatureLevel = ZSignatureLevelSchema.enum;
|
||||
|
||||
export type TSignatureLevel = z.infer<typeof ZSignatureLevelSchema>;
|
||||
|
||||
/**
|
||||
* Whether an envelope's signature level routes through a Cloud Signature
|
||||
* Consortium Trust Service Provider (`AES` or `QES`). The single branch point
|
||||
* for TSP-vs-SES runtime divergence — seal handler, download endpoint,
|
||||
* completion email, and send-time PDF prep all key off this.
|
||||
*
|
||||
* Accepts a raw `string` because the Prisma column is TEXT; unknown values
|
||||
* are conservatively treated as non-TSP so a malformed row can't accidentally
|
||||
* trigger TSP-only code paths.
|
||||
*/
|
||||
export const isTspEnvelope = (envelope: { signatureLevel: string }): boolean =>
|
||||
envelope.signatureLevel === SignatureLevel.AES || envelope.signatureLevel === SignatureLevel.QES;
|
||||
@@ -51,6 +51,8 @@ export const ZClaimFlagsSchema = z.object({
|
||||
|
||||
signingReminders: z.boolean().optional(),
|
||||
|
||||
cscQesSigning: z.boolean().optional(),
|
||||
|
||||
/**
|
||||
* Controls whether an organisation is prevented from sending emails.
|
||||
*
|
||||
@@ -128,6 +130,11 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
key: 'signingReminders',
|
||||
label: 'Signing reminders',
|
||||
},
|
||||
cscQesSigning: {
|
||||
key: 'cscQesSigning',
|
||||
label: 'QES signing',
|
||||
isEnterprise: true,
|
||||
},
|
||||
disableEmails: {
|
||||
key: 'disableEmails',
|
||||
label: 'Disable emails',
|
||||
|
||||
@@ -597,6 +597,31 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
|
||||
user: message,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED }, () => ({
|
||||
anonymous: msg`Recipient authenticated with the signing provider`,
|
||||
you: msg`You authenticated with the signing provider`,
|
||||
user: msg`${user} authenticated with the signing provider`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED }, () => ({
|
||||
anonymous: msg`Recipient's signing provider authentication failed`,
|
||||
you: msg`Your signing provider authentication failed`,
|
||||
user: msg`${user}'s signing provider authentication failed`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED }, () => ({
|
||||
anonymous: msg`Recipient requested a remote signature`,
|
||||
you: msg`You requested a remote signature`,
|
||||
user: msg`${user} requested a remote signature`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHORIZED }, () => ({
|
||||
anonymous: msg`Recipient authorised the remote signature`,
|
||||
you: msg`You authorised the remote signature`,
|
||||
user: msg`${user} authorised the remote signature`,
|
||||
}))
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGNED }, () => ({
|
||||
anonymous: msg`Recipient's remote signature was applied`,
|
||||
you: msg`Your remote signature was applied`,
|
||||
user: msg`${user}'s remote signature was applied`,
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
let selectedDescription = description.anonymous;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { DocumentMeta, Envelope, OrganisationGlobalSettings, Recipient, Team, User } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client';
|
||||
import { DocumentDistributionMethod, DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
|
||||
import { resolveSigningOrder } from '../server-only/signature-level/resolve-signing-order';
|
||||
import type { TDocumentLite, TDocumentMany } from '../types/document';
|
||||
import { DEFAULT_DOCUMENT_EMAIL_SETTINGS } from '../types/document-email';
|
||||
import { SignatureLevel } from '../types/signature-level';
|
||||
import { mapSecondaryIdToDocumentId } from './envelope';
|
||||
import { mapRecipientToLegacyRecipient } from './recipients';
|
||||
|
||||
@@ -23,11 +25,17 @@ export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | Documen
|
||||
*
|
||||
* @param settings - The merged organisation/team settings.
|
||||
* @param overrideMeta - The meta to override the settings with.
|
||||
* @param signatureLevel - The envelope's signature level. Optional; defaults
|
||||
* to `SES` for backward compatibility, which preserves the legacy `PARALLEL`
|
||||
* signing-order default. New callers should pass the resolved level so the
|
||||
* TSP envelopes get the `SEQUENTIAL` default + assertion against explicit
|
||||
* `PARALLEL`.
|
||||
* @returns The derived document meta.
|
||||
*/
|
||||
export const extractDerivedDocumentMeta = (
|
||||
settings: Omit<OrganisationGlobalSettings, 'id'>,
|
||||
overrideMeta: Partial<DocumentMeta> | undefined | null,
|
||||
signatureLevel: string = SignatureLevel.SES,
|
||||
) => {
|
||||
const meta = overrideMeta ?? {};
|
||||
|
||||
@@ -41,7 +49,7 @@ export const extractDerivedDocumentMeta = (
|
||||
subject: meta.subject || null,
|
||||
redirectUrl: meta.redirectUrl || null,
|
||||
|
||||
signingOrder: meta.signingOrder || DocumentSigningOrder.PARALLEL,
|
||||
signingOrder: resolveSigningOrder({ signatureLevel, requested: meta.signingOrder }),
|
||||
allowDictateNextSigner: meta.allowDictateNextSigner ?? false,
|
||||
distributionMethod: meta.distributionMethod || DocumentDistributionMethod.EMAIL, // Todo: Make this a setting.
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/// <reference types="@documenso/tsconfig/process-env.d.ts" />
|
||||
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__ENV__?: Record<string, string | undefined>;
|
||||
@@ -20,6 +22,30 @@ export const env = <K extends EnvKey>(variable: K): EnvValue<K> => {
|
||||
return (typeof process !== 'undefined' ? process?.env?.[variable] : undefined) as EnvValue<K>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read an env var and assert it is set and non-empty. Throws `error` when
|
||||
* provided, otherwise an `AppError(MISSING_ENV_VAR)` naming the missing
|
||||
* variable.
|
||||
*
|
||||
* Empty-string is treated as unset — a shell-supplied `FOO=` is functionally
|
||||
* equivalent to omission.
|
||||
*/
|
||||
export const requireEnv = <K extends EnvKey>(variable: K, error?: Error): NonNullable<EnvValue<K>> => {
|
||||
const value = env(variable);
|
||||
|
||||
if (!value) {
|
||||
throw (
|
||||
error ??
|
||||
new AppError(AppErrorCode.MISSING_ENV_VAR, {
|
||||
message: `Required environment variable "${String(variable)}" is unset.`,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return value as NonNullable<EnvValue<K>>;
|
||||
};
|
||||
|
||||
export const createPublicEnv = () => ({
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_'))),
|
||||
// Derived from the private URL so the public flag cannot drift from the
|
||||
@@ -27,4 +53,7 @@ export const createPublicEnv = () => ({
|
||||
// env var with the same name.
|
||||
// The `? 'true' : 'false'` might seem dumb but it's because we're expecting env var strings.
|
||||
NEXT_PUBLIC_DOCUMENT_CONVERSION_ENABLED: process.env.NEXT_PRIVATE_DOCUMENT_CONVERSION_URL ? 'true' : 'false',
|
||||
// Derived from the private transport so the client can detect CSC mode for
|
||||
// authoring UI gating without exposing the raw transport value.
|
||||
NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC: process.env.NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc' ? 'true' : 'false',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user