From e19b1d00d052ced9703417fdec8a298f4ad1111b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 1 May 2026 14:24:42 +1000 Subject: [PATCH 01/13] fix: improve embed error messages (#2752) --- .../app/components/embed/embed-paywall.tsx | 22 +++++++++++++++++-- .../create-embedding-presign-token.ts | 3 ++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/remix/app/components/embed/embed-paywall.tsx b/apps/remix/app/components/embed/embed-paywall.tsx index aa3af647f..c94a27860 100644 --- a/apps/remix/app/components/embed/embed-paywall.tsx +++ b/apps/remix/app/components/embed/embed-paywall.tsx @@ -1,7 +1,25 @@ +import { Trans } from '@lingui/react/macro'; +import { Link } from 'react-router'; + +import { SUPPORT_EMAIL } from '@documenso/lib/constants/app'; + export const EmbedPaywall = () => { return ( -
-

Paywall

+
+
+

+ This feature is not available on your current plan +

+

+ + Please contact{' '} + + support + {' '} + if you have any questions. + +

+
); }; diff --git a/packages/trpc/server/embedding-router/create-embedding-presign-token.ts b/packages/trpc/server/embedding-router/create-embedding-presign-token.ts index 05b6ce11f..a252c8a48 100644 --- a/packages/trpc/server/embedding-router/create-embedding-presign-token.ts +++ b/packages/trpc/server/embedding-router/create-embedding-presign-token.ts @@ -46,7 +46,8 @@ export const createEmbeddingPresignTokenRoute = procedure if (!organisationClaim.flags.embedAuthoring) { throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'You do not have permission to create embedding presign tokens', + message: + 'Embedded Authoring is not included in your current plan. Please contact support.', }); } } From aebb5e20678515e8b634aabed6e2073cbcad8f1b Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 1 May 2026 15:51:58 +1000 Subject: [PATCH 02/13] fix: assistant signing auth (#2753) --- .../assistant-signing-auth.spec.ts | 172 ++++++++++++++++++ .../server-only/field/get-fields-for-token.ts | 1 + .../field/remove-signed-field-with-token.ts | 1 + .../field/sign-field-with-token.ts | 1 + .../envelope-router/sign-envelope-field.ts | 1 + 5 files changed, 176 insertions(+) create mode 100644 packages/app-tests/e2e/document-auth/assistant-signing-auth.spec.ts diff --git a/packages/app-tests/e2e/document-auth/assistant-signing-auth.spec.ts b/packages/app-tests/e2e/document-auth/assistant-signing-auth.spec.ts new file mode 100644 index 000000000..a4857e6c8 --- /dev/null +++ b/packages/app-tests/e2e/document-auth/assistant-signing-auth.spec.ts @@ -0,0 +1,172 @@ +import { type APIRequestContext, expect, test } from '@playwright/test'; +import { FieldType, SigningStatus } from '@prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; + +import { apiSeedPendingDocument } from '../fixtures/api-seeds'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +type SeededEnvelopes = { + assistantToken: string; + otherEnvelopeFieldId: number; +}; + +/** + * Seeds two unrelated pending envelopes: + * - Envelope A has an ASSISTANT (with a token) plus a SIGNER. + * - Envelope B is owned by a different user and has a SIGNER with a TEXT field. + * + * Returns the assistant's token from envelope A and the TEXT field id from + * envelope B so callers can exercise signing routes across envelopes. + */ +const seedTwoPendingEnvelopes = async (request: APIRequestContext): Promise => { + const envelopeA = await apiSeedPendingDocument(request, { + title: '[TEST] Envelope A', + recipients: [ + { + email: `assistant-${Date.now()}@documenso.com`, + name: 'Assistant', + role: 'ASSISTANT', + signingOrder: 1, + }, + { + email: `signer-a-${Date.now()}@documenso.com`, + name: 'Signer A', + role: 'SIGNER', + signingOrder: 2, + }, + ], + fieldsPerRecipient: [ + [], + // SIGNER needs a SIGNATURE field so distribution succeeds. + [{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 }], + ], + }); + + const assistant = envelopeA.distributeResult.recipients.find((r) => r.role === 'ASSISTANT'); + + if (!assistant) { + throw new Error('Assistant recipient not found in envelope A'); + } + + const envelopeB = await apiSeedPendingDocument(request, { + title: '[TEST] Envelope B', + recipients: [ + { + email: `signer-b-${Date.now()}@documenso.com`, + name: 'Signer B', + role: 'SIGNER', + signingOrder: 1, + }, + ], + // A TEXT field is used as the cross-envelope target. The V2 route has a + // separate guard that blocks assistants from signing SIGNATURE fields, + // which would mask whether the recipient lookup itself was scoped. + fieldsPerRecipient: [ + [ + { type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 }, + { type: FieldType.TEXT, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 }, + ], + ], + }); + + const otherEnvelope = await prisma.envelope.findUniqueOrThrow({ + where: { id: envelopeB.envelope.id }, + include: { fields: true }, + }); + + const textField = otherEnvelope.fields.find((f) => f.type === FieldType.TEXT); + + if (!textField) { + throw new Error('TEXT field not found in envelope B'); + } + + return { + assistantToken: assistant.token, + otherEnvelopeFieldId: textField.id, + }; +}; + +const trpcMutation = async ( + request: APIRequestContext, + procedure: string, + input: Record, +) => { + return await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, { + headers: { 'content-type': 'application/json' }, + data: JSON.stringify({ json: input }), + }); +}; + +test.describe('[ASSISTANT_SIGNING_AUTH]: cross-envelope field access', () => { + test('envelope.field.sign (V2) rejects fieldId from another envelope', async ({ request }) => { + const { assistantToken, otherEnvelopeFieldId } = await seedTwoPendingEnvelopes(request); + + const res = await trpcMutation(request, 'envelope.field.sign', { + token: assistantToken, + fieldId: otherEnvelopeFieldId, + fieldValue: { type: FieldType.TEXT, value: 'TEXT' }, + }); + + expect(res.ok()).toBeFalsy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ + where: { id: otherEnvelopeFieldId }, + }); + + expect(fieldAfter.inserted).toBe(false); + expect(fieldAfter.customText).toBe(''); + }); + + test('field.signFieldWithToken (V1) rejects fieldId from another envelope', async ({ + request, + }) => { + const { assistantToken, otherEnvelopeFieldId } = await seedTwoPendingEnvelopes(request); + + const res = await trpcMutation(request, 'field.signFieldWithToken', { + token: assistantToken, + fieldId: otherEnvelopeFieldId, + value: 'TEXT', + isBase64: false, + }); + + expect(res.ok()).toBeFalsy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ + where: { id: otherEnvelopeFieldId }, + }); + + expect(fieldAfter.inserted).toBe(false); + expect(fieldAfter.customText).toBe(''); + }); + + test('field.removeSignedFieldWithToken (V1) rejects fieldId from another envelope', async ({ + request, + }) => { + const { assistantToken, otherEnvelopeFieldId } = await seedTwoPendingEnvelopes(request); + + // Pre-insert the field so a successful (incorrect) uninsert is detectable. + await prisma.field.update({ + where: { id: otherEnvelopeFieldId }, + data: { inserted: true, customText: 'pre-existing-value' }, + }); + + const res = await trpcMutation(request, 'field.removeSignedFieldWithToken', { + token: assistantToken, + fieldId: otherEnvelopeFieldId, + }); + + expect(res.ok()).toBeFalsy(); + + const fieldAfter = await prisma.field.findUniqueOrThrow({ + where: { id: otherEnvelopeFieldId }, + include: { recipient: true }, + }); + + expect(fieldAfter.inserted).toBe(true); + expect(fieldAfter.customText).toBe('pre-existing-value'); + expect(fieldAfter.recipient.signingStatus).toBe(SigningStatus.NOT_SIGNED); + }); +}); diff --git a/packages/lib/server-only/field/get-fields-for-token.ts b/packages/lib/server-only/field/get-fields-for-token.ts index 0c4534cf8..c34e9771b 100644 --- a/packages/lib/server-only/field/get-fields-for-token.ts +++ b/packages/lib/server-only/field/get-fields-for-token.ts @@ -35,6 +35,7 @@ export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) => signingOrder: { gte: recipient.signingOrder ?? 0, }, + envelopeId: recipient.envelopeId, }, envelope: { id: recipient.envelopeId, diff --git a/packages/lib/server-only/field/remove-signed-field-with-token.ts b/packages/lib/server-only/field/remove-signed-field-with-token.ts index 0995ba4a9..1a42f9ca3 100644 --- a/packages/lib/server-only/field/remove-signed-field-with-token.ts +++ b/packages/lib/server-only/field/remove-signed-field-with-token.ts @@ -38,6 +38,7 @@ export const removeSignedFieldWithToken = async ({ signingStatus: { not: SigningStatus.SIGNED, }, + envelopeId: recipient.envelopeId, }), }, }, diff --git a/packages/lib/server-only/field/sign-field-with-token.ts b/packages/lib/server-only/field/sign-field-with-token.ts index f36b02a84..ab7c3a25f 100644 --- a/packages/lib/server-only/field/sign-field-with-token.ts +++ b/packages/lib/server-only/field/sign-field-with-token.ts @@ -78,6 +78,7 @@ export const signFieldWithToken = async ({ signingOrder: { gte: recipient.signingOrder ?? 0, }, + envelopeId: recipient.envelopeId, }), }, }, diff --git a/packages/trpc/server/envelope-router/sign-envelope-field.ts b/packages/trpc/server/envelope-router/sign-envelope-field.ts index fd9695ff7..c48c560a5 100644 --- a/packages/trpc/server/envelope-router/sign-envelope-field.ts +++ b/packages/trpc/server/envelope-router/sign-envelope-field.ts @@ -51,6 +51,7 @@ export const signEnvelopeFieldRoute = procedure signingOrder: { gte: recipient.signingOrder ?? 0, }, + envelopeId: recipient.envelopeId, } : { id: recipient.id, From a697832b432b118a9a22e195997ffa6219853fc4 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 1 May 2026 21:58:05 +1000 Subject: [PATCH 03/13] v2.10.0 --- apps/remix/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/remix/package.json b/apps/remix/package.json index 8a4d96e46..b519f37a0 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -106,5 +106,5 @@ "vite-plugin-babel-macros": "^1.0.6", "vite-tsconfig-paths": "^5.1.4" }, - "version": "2.9.1" + "version": "2.10.0" } diff --git a/package-lock.json b/package-lock.json index ff704c023..bc8d679de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "2.9.1", + "version": "2.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "2.9.1", + "version": "2.10.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -407,7 +407,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "2.9.1", + "version": "2.10.0", "dependencies": { "@cantoo/pdf-lib": "^2.5.3", "@documenso/api": "*", diff --git a/package.json b/package.json index 4b2e8ac95..2ff07ac79 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "apps/*", "packages/*" ], - "version": "2.9.1", + "version": "2.10.0", "scripts": { "postinstall": "patch-package", "build": "turbo run build", From 6243a514af2a0d3f5a6d499fb48e0a7e2f38cb8b Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Sat, 2 May 2026 09:55:51 +1000 Subject: [PATCH 04/13] fix: csp frame-ancestors on signing routes --- apps/remix/server/security-headers.ts | 60 ++++++++++++++++----------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/apps/remix/server/security-headers.ts b/apps/remix/server/security-headers.ts index 871af694f..e87d7cb7a 100644 --- a/apps/remix/server/security-headers.ts +++ b/apps/remix/server/security-headers.ts @@ -19,23 +19,32 @@ const NON_PAGE_PATH_REGEX = /^(\/api\/|\/ingest\/|\/__manifest|\/assets\/|\/appl const EMBED_PATH_REGEX = /^\/embed(\/|\.data|$)/; /** - * Auth pages reachable from inside an embed iframe during the + * Non-`/embed` page routes that customers iframe directly, plus the auth + * pages reachable from inside an embed iframe during the * reauth-as-different-account flow. * + * Signing routes (`/sign/:token`, `/d/:token`): + * Some customer integrations embed these URLs directly (without going + * through `EmbedSignDocument`). Without `frame-ancestors *` here, those + * integrations break with a "refused to connect" iframe error. + * + * Auth routes (`/signin`, `/forgot-password`, `/check-email`, + * `/unverified-account`): * `apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx` * does `window.location.href = '/signin?...'` inside the iframe when the * user needs to sign out and sign back in as a different account, and * `` links/navigates to `/forgot-password`, `/check-email`, and - * `/unverified-account` from there. - * - * Without `frame-ancestors *` on these routes, the customer's iframe gets - * blocked the moment the user clicks "Login" in the reauth dialog. + * `/unverified-account` from there. Without `frame-ancestors *` on these + * routes, the customer's iframe gets blocked the moment the user clicks + * "Login" in the reauth dialog. * * These routes still get the strict nonced `script-src`/`style-src-elem` - * policy — only `frame-ancestors` is relaxed. + * policy — only `frame-ancestors` is relaxed. The `(\/|\.data|$)` tail + * keeps `/sign` from matching `/signin`/`/signup` and `/d` from matching + * `/dashboard`. */ -const AUTH_FRAMEABLE_PATH_REGEX = - /^\/(signin|forgot-password|check-email|unverified-account)(\/|\.data|$)/; +const FRAMEABLE_PATH_REGEX = + /^\/(signin|forgot-password|check-email|unverified-account|sign|d)(\/|\.data|$)/; /** * Hono context variable name where the per-request CSP nonce is stashed. @@ -59,7 +68,7 @@ const generateNonce = () => { return btoa(binary); }; -type CspPathKind = 'embed' | 'auth' | 'default'; +type CspPathKind = 'embed' | 'frameable' | 'default'; const buildCspHeader = ({ nonce, kind }: { nonce: string; kind: CspPathKind }) => { // `'self'` is included alongside `'strict-dynamic'` as a fallback for @@ -87,18 +96,18 @@ const buildCspHeader = ({ nonce, kind }: { nonce: string; kind: CspPathKind }) = // Embeds inject customer-supplied CSS via runtime-created `