feat: add qr signatures

This commit is contained in:
David Nguyen
2026-08-18 11:49:15 +10:00
parent 779de01fe8
commit 9825ea88b1
99 changed files with 1686 additions and 48 deletions
@@ -5,6 +5,7 @@ import {
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaQrSignatureEnabledSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
@@ -65,6 +66,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
@@ -5,6 +5,7 @@ import {
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaQrSignatureEnabledSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
@@ -62,6 +63,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
@@ -30,6 +30,7 @@ export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
@@ -5,6 +5,7 @@ import {
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaQrSignatureEnabledSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
@@ -66,6 +67,7 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
@@ -5,6 +5,7 @@ import {
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaQrSignatureEnabledSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
@@ -66,6 +67,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
@@ -7,6 +7,7 @@ import {
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaQrSignatureEnabledSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
@@ -94,6 +95,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
allowDictateNextSigner: z.boolean().optional(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
})
@@ -37,6 +37,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
defaultRecipients,
delegateDocumentOwnership,
envelopeExpirationPeriod,
@@ -104,6 +105,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
uploadSignatureEnabled ?? organisation.organisationGlobalSettings.uploadSignatureEnabled;
const derivedDrawSignatureEnabled =
drawSignatureEnabled ?? organisation.organisationGlobalSettings.drawSignatureEnabled;
const derivedQrSignatureEnabled = qrSignatureEnabled ?? organisation.organisationGlobalSettings.qrSignatureEnabled;
const derivedDelegateDocumentOwnership =
delegateDocumentOwnership ?? organisation.organisationGlobalSettings.delegateDocumentOwnership;
@@ -111,7 +113,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
if (
derivedTypedSignatureEnabled === false &&
derivedUploadSignatureEnabled === false &&
derivedDrawSignatureEnabled === false
derivedDrawSignatureEnabled === false &&
derivedQrSignatureEnabled === false
) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'At least one signature type must be enabled',
@@ -165,6 +168,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
@@ -25,6 +25,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
typedSignatureEnabled: z.boolean().optional(),
uploadSignatureEnabled: z.boolean().optional(),
drawSignatureEnabled: z.boolean().optional(),
qrSignatureEnabled: z.boolean().optional(),
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
+2
View File
@@ -10,6 +10,7 @@ import { folderRouter } from './folder-router/router';
import { organisationRouter } from './organisation-router/router';
import { profileRouter } from './profile-router/router';
import { recipientRouter } from './recipient-router/router';
import { signatureRouter } from './signature-router/router';
import { teamRouter } from './team-router/router';
import { templateRouter } from './template-router/router';
import { router } from './trpc';
@@ -24,6 +25,7 @@ export const appRouter = router({
field: fieldRouter,
folder: folderRouter,
recipient: recipientRouter,
signature: signatureRouter,
admin: adminRouter,
organisation: organisationRouter,
apiToken: apiTokenRouter,
@@ -0,0 +1,72 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { qrSignatureCompleteRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { prisma } from '@documenso/prisma';
import { AnonymousVerificationTokenType } from '@prisma/client';
import { procedure } from '../../trpc';
import { ZCompleteQrSignatureRequestSchema, ZCompleteQrSignatureResponseSchema } from './complete-qr-signature.types';
/**
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
*
* Called from the mobile signing page to attach a drawn signature to a QR
* signature session. The desktop pad picks it up by polling `qr.get`.
*/
export const completeQrSignatureRoute = procedure
.input(ZCompleteQrSignatureRequestSchema)
.output(ZCompleteQrSignatureResponseSchema)
.mutation(async ({ input, ctx }) => {
const { token, signature } = input;
const { ipAddress } = ctx.metadata.requestMetadata;
const rateLimitResult = await qrSignatureCompleteRateLimit.check({
ip: ipAddress ?? 'unknown',
identifier: token,
});
assertRateLimit(rateLimitResult);
const qrSignatureSession = await prisma.anonymousVerificationToken.findFirst({
where: {
token,
type: AnonymousVerificationTokenType.QR_SIGNATURE,
},
});
if (!qrSignatureSession) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'QR signature session not found or expired',
});
}
if (qrSignatureSession.expiresAt < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, {
message: 'QR signature session has expired',
});
}
if (qrSignatureSession.value) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'A signature has already been submitted for this session',
});
}
const { count: updatedCount } = await prisma.anonymousVerificationToken.updateMany({
where: {
id: qrSignatureSession.id,
type: AnonymousVerificationTokenType.QR_SIGNATURE,
value: null,
},
data: {
value: signature,
},
});
if (updatedCount === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'A signature has already been submitted for this session',
});
}
});
@@ -0,0 +1,17 @@
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { z } from 'zod';
export const ZCompleteQrSignatureRequestSchema = z.object({
token: z.string().min(1).max(64).describe('The QR signature session token'),
signature: z
.string()
.min(1)
.max(1_000_000)
.refine((value) => isBase64Image(value), {
message: 'Signature must be a base64 encoded PNG image',
}),
});
export const ZCompleteQrSignatureResponseSchema = z.void();
export type TCompleteQrSignatureRequest = z.infer<typeof ZCompleteQrSignatureRequestSchema>;
@@ -0,0 +1,64 @@
import { QR_SIGNATURE_TOKEN_EXPIRY_MINUTES } from '@documenso/lib/constants/signatures';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { qrSignatureCreateRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { AnonymousVerificationTokenType } from '@prisma/client';
import { DateTime } from 'luxon';
import { procedure } from '../../trpc';
import { ZCreateQrSignatureRequestSchema, ZCreateQrSignatureResponseSchema } from './create-qr-signature.types';
/**
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
*
* Creates a short-lived anonymous session which allows a signature drawn on a
* mobile device to be handed off to the desktop signature pad. The token is
* the sole authorization for the session.
*/
export const createQrSignatureRoute = procedure
.input(ZCreateQrSignatureRequestSchema)
.output(ZCreateQrSignatureResponseSchema)
.mutation(async ({ input, ctx }) => {
const { context } = input;
const { ipAddress } = ctx.metadata.requestMetadata;
const rateLimitResult = await qrSignatureCreateRateLimit.check({
ip: ipAddress ?? 'unknown',
});
assertRateLimit(rateLimitResult);
if (context?.type === 'DOCUMENT_SIGNATURE') {
const recipient = await prisma.recipient.findFirst({
where: {
token: context.recipientToken,
},
select: {
id: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found for the provided token',
});
}
}
const qrSignatureSession = await prisma.anonymousVerificationToken.create({
data: {
type: AnonymousVerificationTokenType.QR_SIGNATURE,
token: nanoid(),
metadata: context ? { context } : undefined,
expiresAt: DateTime.now().plus({ minutes: QR_SIGNATURE_TOKEN_EXPIRY_MINUTES }).toJSDate(),
},
});
return {
token: qrSignatureSession.token,
expiresAt: qrSignatureSession.expiresAt,
};
});
@@ -0,0 +1,14 @@
import { ZQrSignatureContextSchema } from '@documenso/lib/types/qr-signature';
import { z } from 'zod';
export const ZCreateQrSignatureRequestSchema = z.object({
context: ZQrSignatureContextSchema.nullish(),
});
export const ZCreateQrSignatureResponseSchema = z.object({
token: z.string(),
expiresAt: z.date(),
});
export type TCreateQrSignatureRequest = z.infer<typeof ZCreateQrSignatureRequestSchema>;
export type TCreateQrSignatureResponse = z.infer<typeof ZCreateQrSignatureResponseSchema>;
@@ -0,0 +1,99 @@
import { ZQrSignatureContextSchema } from '@documenso/lib/types/qr-signature';
import { prisma } from '@documenso/prisma';
import { AnonymousVerificationTokenType } from '@prisma/client';
import { z } from 'zod';
import { procedure } from '../../trpc';
import {
ZGetQrSignatureSessionRequestSchema,
ZGetQrSignatureSessionResponseSchema,
} from './get-qr-signature-session.types';
const ZSessionMetadataSchema = z.object({
context: ZQrSignatureContextSchema,
});
/**
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
*
* Classify a QR signature session token for the mobile signing page and
* resolve the context stored on the session.
*
* A missing row is indistinguishable from an expired one by design.
*
* Called once per page load; the global trpc rate limit covers it, matching
* the polling `qr.get` route.
*/
export const getQrSignatureSessionRoute = procedure
.input(ZGetQrSignatureSessionRequestSchema)
.output(ZGetQrSignatureSessionResponseSchema)
.query(async ({ input }) => {
const { token } = input;
const qrSignatureSession = await prisma.anonymousVerificationToken.findUnique({
where: {
token,
type: AnonymousVerificationTokenType.QR_SIGNATURE,
},
});
if (!qrSignatureSession || qrSignatureSession.expiresAt < new Date()) {
return { status: 'EXPIRED' } as const;
}
if (qrSignatureSession.value) {
return { status: 'ALREADY_SUBMITTED' } as const;
}
const parsedMetadata = ZSessionMetadataSchema.nullish().safeParse(qrSignatureSession.metadata);
if (!parsedMetadata.success) {
return { status: 'INVALID' } as const;
}
// Sessions created without a context are valid, but generic.
if (!parsedMetadata.data) {
return { status: 'VALID', context: { type: 'NONE' } } as const;
}
const { context } = parsedMetadata.data;
if (context.type === 'PROFILE_SIGNATURE') {
return { status: 'VALID', context: { type: context.type } } as const;
}
if (context.recipientToken.length < 1) {
return { status: 'INVALID' } as const;
}
const recipient = await prisma.recipient.findFirst({
where: {
token: context.recipientToken,
},
select: {
envelope: {
select: {
title: true,
team: {
select: {
name: true,
},
},
},
},
},
});
if (!recipient) {
return { status: 'INVALID' } as const;
}
return {
status: 'VALID',
context: {
type: 'DOCUMENT_SIGNATURE',
documentTitle: recipient.envelope.title,
teamName: recipient.envelope.team.name,
},
} as const;
});
@@ -0,0 +1,47 @@
import { z } from 'zod';
export const ZGetQrSignatureSessionRequestSchema = z.object({
token: z.string().min(1).max(64).describe('The QR signature session token'),
});
/**
* The resolved context of a valid QR signature session.
*
* `NONE` is a session created without any context, in which case the mobile
* page shows a generic "Signature requested".
*/
export const ZQrSignatureSessionContextSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('NONE'),
}),
z.object({
type: z.literal('PROFILE_SIGNATURE'),
}),
z.object({
type: z.literal('DOCUMENT_SIGNATURE'),
documentTitle: z.string(),
teamName: z.string(),
}),
]);
export const ZGetQrSignatureSessionResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('EXPIRED'),
}),
z.object({
status: z.literal('ALREADY_SUBMITTED'),
}),
z.object({
// The session references a signing flow that no longer exists, or carries
// malformed metadata.
status: z.literal('INVALID'),
}),
z.object({
status: z.literal('VALID'),
context: ZQrSignatureSessionContextSchema,
}),
]);
export type TGetQrSignatureSessionRequest = z.infer<typeof ZGetQrSignatureSessionRequestSchema>;
export type TGetQrSignatureSessionResponse = z.infer<typeof ZGetQrSignatureSessionResponseSchema>;
export type TQrSignatureSessionContext = z.infer<typeof ZQrSignatureSessionContextSchema>;
@@ -0,0 +1,57 @@
import { prisma } from '@documenso/prisma';
import { AnonymousVerificationTokenType } from '@prisma/client';
import { procedure } from '../../trpc';
import { ZGetQrSignatureRequestSchema, ZGetQrSignatureResponseSchema } from './get-qr-signature.types';
/**
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
*
* Polled by the desktop signature pad while waiting for a mobile signature.
*
* A missing row is indistinguishable from an expired one by design, so we
* return EXPIRED for both. Once the signature is returned the row is deleted,
* making the token single-use.
*/
export const getQrSignatureRoute = procedure
.input(ZGetQrSignatureRequestSchema)
.output(ZGetQrSignatureResponseSchema)
.query(async ({ input }) => {
const { token } = input;
const qrSignatureSession = await prisma.anonymousVerificationToken.findUnique({
where: {
token,
type: AnonymousVerificationTokenType.QR_SIGNATURE,
},
});
if (!qrSignatureSession || qrSignatureSession.expiresAt < new Date()) {
return {
status: 'EXPIRED',
} as const;
}
if (!qrSignatureSession.value) {
return {
status: 'PENDING',
} as const;
}
const { count: deletedCount } = await prisma.anonymousVerificationToken.deleteMany({
where: {
id: qrSignatureSession.id,
},
});
if (deletedCount === 0) {
return {
status: 'EXPIRED',
} as const;
}
return {
status: 'COMPLETED',
signature: qrSignatureSession.value,
} as const;
});
@@ -0,0 +1,21 @@
import { z } from 'zod';
export const ZGetQrSignatureRequestSchema = z.object({
token: z.string().min(1).max(64).describe('The QR signature session token to poll'),
});
export const ZGetQrSignatureResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('PENDING'),
}),
z.object({
status: z.literal('EXPIRED'),
}),
z.object({
status: z.literal('COMPLETED'),
signature: z.string(),
}),
]);
export type TGetQrSignatureRequest = z.infer<typeof ZGetQrSignatureRequestSchema>;
export type TGetQrSignatureResponse = z.infer<typeof ZGetQrSignatureResponseSchema>;
@@ -0,0 +1,14 @@
import { router } from '../trpc';
import { completeQrSignatureRoute } from './qr/complete-qr-signature';
import { createQrSignatureRoute } from './qr/create-qr-signature';
import { getQrSignatureRoute } from './qr/get-qr-signature';
import { getQrSignatureSessionRoute } from './qr/get-qr-signature-session';
export const signatureRouter = router({
qr: {
create: createQrSignatureRoute,
get: getQrSignatureRoute,
getSession: getQrSignatureSessionRoute,
complete: completeQrSignatureRoute,
},
});
@@ -36,6 +36,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
delegateDocumentOwnership,
envelopeExpirationPeriod,
reminderSettings,
@@ -66,7 +67,12 @@ export const updateTeamSettingsRoute = authenticatedProcedure
}
// Signatures will only be inherited if all are NULL.
if (typedSignatureEnabled === false && uploadSignatureEnabled === false && drawSignatureEnabled === false) {
if (
typedSignatureEnabled === false &&
uploadSignatureEnabled === false &&
drawSignatureEnabled === false &&
qrSignatureEnabled === false
) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'At least one signature type must be enabled',
});
@@ -168,6 +174,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
delegateDocumentOwnership,
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
@@ -29,6 +29,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
typedSignatureEnabled: z.boolean().nullish(),
uploadSignatureEnabled: z.boolean().nullish(),
drawSignatureEnabled: z.boolean().nullish(),
qrSignatureEnabled: z.boolean().nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),
@@ -9,6 +9,7 @@ import {
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaQrSignatureEnabledSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
@@ -66,6 +67,7 @@ export const ZTemplateMetaUpsertSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
allowDictateNextSigner: z.boolean().optional(),
});
@@ -147,6 +149,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
allowDictateNextSigner: z.boolean().optional(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
})