Compare commits

..

8 Commits

Author SHA1 Message Date
Ephraim Duncan 8ca8ad907e Merge branch 'main' into feat/external-2fa-codes 2026-05-27 13:45:46 +00:00
ephraimduncan f7b3554b2a Merge remote-tracking branch 'origin/main' into pr-2468
# Conflicts:
#	packages/lib/translations/de/web.po
#	packages/lib/translations/en/web.po
#	packages/lib/translations/es/web.po
#	packages/lib/translations/fr/web.po
#	packages/lib/translations/it/web.po
#	packages/lib/translations/ja/web.po
#	packages/lib/translations/ko/web.po
#	packages/lib/translations/nl/web.po
#	packages/lib/translations/pl/web.po
#	packages/lib/translations/pt-BR/web.po
#	packages/lib/translations/zh/web.po
2026-05-14 15:44:01 +00:00
ephraimduncan 6ff8cd7cb2 chore: merge main, resolve biome formatting conflicts 2026-05-12 12:20:22 +00:00
ephraimduncan 138d663c25 chore: merge main, resolve biome formatting conflicts
Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
2026-05-12 11:46:11 +00:00
ephraimduncan 9194884fbe test: remove flaky external 2fa auth test 2026-02-11 00:10:10 +00:00
ephraimduncan 9de87ca906 fix: move 2FA reason codes to shared constants to fix client bundle
Importing SIGNING_2FA_VERIFY_REASON_CODES from a server-only module
pulled prisma into the browser bundle, causing "process is not defined"
and breaking all client-side JS hydration.
2026-02-10 13:57:51 +00:00
ephraimduncan 7163800d36 chore: remove .sisyphus planning artifacts 2026-02-10 12:48:54 +00:00
ephraimduncan bd56929db1 refactor(signing-2fa): simplify server-side and UI code for external 2FA
- Extract throwVerificationError helper in verify-signing-two-factor-token.ts
- Extract throwIssuanceDenied helper in issue-signing-two-factor-token.ts
- Eliminate duplicated attemptsRemaining state in UI component
- Use imported SIGNING_2FA_VERIFY_REASON_CODES constants
- Add statusQuery.refetch() after failed verify for single source of truth
- Fix TypeScript control flow with explicit returns after throws
2026-02-10 12:39:13 +00:00
46 changed files with 1669 additions and 2923 deletions
@@ -1,430 +0,0 @@
---
date: 2026-05-21
title: Acroform Field Detection And Reuse
---
## Problem
Users routinely prepare PDFs in Adobe Acrobat (or other PDF editors) with AcroForm fields — signatures, text inputs, dates, checkboxes — and upload them to Documenso. Today those widgets are either stripped (`DOCUMENT` upload flattens via `form.flatten()` in `normalize-pdf.ts`) or preserved as static interactive controls (`TEMPLATE` upload), but never reused as Documenso fields. Users have to re-place every field in the editor.
Issue: https://github.com/documenso/documenso/issues/2697 (labels: `type: enhancement`, `apps: web`).
## Goal
On upload, detect AcroForm fields, map supported types to Documenso fields, persist their page geometry, then flatten the PDF so no duplicate interactive controls remain. Imported fields should be ordinary `Field` rows — visible in the editor, assignable to recipients, signable like any other field.
## Background
The placeholder pipeline (`{{signature, r1}}` style) already does almost everything we need:
- `packages/lib/server-only/pdf/auto-place-fields.ts` extracts `PlaceholderInfo[]` (with `fieldAndMeta: TFieldAndMeta`, top-left percentages, page index), then `convertPlaceholdersToFieldInputs(placeholders, recipientResolver, envelopeItemId)` returns `tx.field.createMany` payloads.
- `packages/trpc/server/envelope-router/create-envelope.ts` per file: `convertToPdf` → optional `insertFormValuesInPdf``normalizePdf({ flattenForm: type !== 'TEMPLATE' })``extractPdfPlaceholders(normalized)``putPdfFileServerSide(cleanedPdf)` → forwards `{ title, documentDataId, placeholders }`.
- `packages/lib/server-only/envelope-item/create-envelope-items.ts` runs the same per-file pipeline when files are appended to an existing envelope.
- `packages/lib/server-only/envelope/create-envelope.ts` consumes `envelopeItems[].placeholders`, creates placeholder `SIGNER` recipients (`recipient.${i}@documenso.com`) when `data.recipients` is empty, then calls `convertPlaceholdersToFieldInputs` + `tx.field.createMany` inside its existing transaction.
`@libpdf/core` exposes the AcroForm primitives we need (verified in `node_modules/@libpdf/core/dist/index.d.mts`):
- `PDF.isEncrypted: boolean`, `PDF.getForm(): PDFForm | null`, `PDF.getPages()[i].ref` + `.getRotation()`.
- `PDFForm.getFields(): FormField[]`.
- `FormField`: `name`, `partialName`, `alternateName` (/TU), `isRequired()`, `isReadOnly()`, `acroField(): PdfDict` (raw dict access), `type: 'text' | 'checkbox' | 'radio' | 'dropdown' | 'listbox' | 'signature' | 'button' | 'unknown' | 'non-terminal'`.
- `WidgetAnnotation`: `rect`, `width`, `height`, `pageRef: PdfRef | null`, `isHidden()`, `isPrintable()`, `getOnValue()`.
- Typed subclasses: `CheckboxField.isChecked()` / `getOnValues()` / `getOnValue()`, `DropdownField.getOptions()`, `RadioField.getOptions()`, `SignatureField`, `TextField.getText()`.
`ZBaseFieldMeta` (in `packages/lib/types/field-meta.ts`) already carries `label`, `required`, `readOnly`. We extend it once with `source?: 'acroform'` so imported fields are introspectable without UI changes.
## Scope
In scope: server-side extraction at upload time for v2 envelopes (both new envelopes and items appended to existing envelopes), per-field `FIELD_CREATED` audit log entries for imported fields, and the one-line schema extension to record provenance. Out of scope: editor UI changes (badges, banners, review modals), signing surface changes, v1 path, listbox, button/unknown/non-terminal field types, true radio-group consolidation, recipient inference beyond first-signer selection.
## Field Mapping
### Type resolution
| AcroForm type | Documenso field | Rule |
| --- | --- | --- |
| `signature` (unsigned) | `SIGNATURE` | Import. |
| `signature` (signed — `SignatureField.isSigned()` returns true) | — | **Skip.** Log `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`. Also downgrade `flattenForm` to `false` for that envelope item (do not re-flatten a signed PDF). |
| `text` | resolved by heuristic below | Heuristic order: AcroForm format action → name token → default to TEXT. |
| `checkbox` | `CHECKBOX` | One Documenso field per widget. |
| `radio` | `RADIO` | One Documenso field per widget. Store group's `getOptions()` in each `fieldMeta.values`. Semantics intentionally differ from PDF (each is independent); documented under Risks. |
| `dropdown` | `DROPDOWN` | Preserve `getOptions()` in `fieldMeta.values`, current selection in `fieldMeta.defaultValue`. |
| `listbox`, `button`, `unknown`, `non-terminal` | — | Skip, return as `AcroFormUnsupportedFieldInfo`. Never block upload. |
### Text-field heuristic (resolved in order — first match wins)
AcroForm format actions take precedence over every name token. If `/AA → /F → /JS` references a known formatter, that result is final. Only when no format action is detected do the name regexes apply.
1. **DATE** if `acroField()` carries an additional-actions date format (`/AA``/F``JS` containing `AFDate_FormatEx` or `AFDate_Format`).
2. **NUMBER** if `acroField()` carries an `AFNumber_Format` action.
3. **DATE** if name/alternateName matches `/date|dob|birth/i`.
4. **NUMBER** if name/alternateName matches `/amount|qty|count|number/i` AND `/MaxLen <= 10`.
5. **EMAIL** if name/alternateName matches `/email|e[-_]?mail/i`.
6. **NAME** if name/alternateName matches `/name/i`.
7. **INITIALS** if name/alternateName matches `/initial/i`.
8. Else **TEXT**.
All regexes are case-insensitive and run against `partialName` then `alternateName`.
Patterns are intentionally lenient to handle CamelCase Adobe Acrobat names (e.g. `CustomerName`, `BirthDate`) that strict word-boundary patterns miss. Expected false positives — `username` → NAME, `birth_name` → DATE, `initialize` → INITIALS — are tolerable because the editor is the final arbiter; false negatives fall through to TEXT (always safe).
### Metadata mapping
For every imported field:
- `fieldMeta.required = field.isRequired()` (boolean; omit when false).
- `fieldMeta.readOnly = field.isReadOnly()` (boolean; omit when false). Read-only fields **are** imported (rendered for reference in the editor); the renderer already honours `readOnly`.
- `fieldMeta.label`: `alternateName ?? partialName` for label-supporting types (TEXT, NUMBER, DATE, INITIALS, NAME, EMAIL, DROPDOWN, RADIO, CHECKBOX). SIGNATURE has no label slot — drop it.
- `fieldMeta.source = 'acroform'` on every imported field (see Schema Extension).
### CHECKBOX required semantics
AcroForm "required" on a checkbox means "must be checked". Documenso CHECKBOX has both `required` (field must be present) and `validationRule`/`validationLength` (e.g. "at least N of M"). Mapping:
```ts
if (field.isRequired()) {
fieldMeta.required = true;
fieldMeta.validationRule = 'at-least';
fieldMeta.validationLength = 1;
}
```
This approximates "must be checked to submit" for a single-widget checkbox field.
### Default values (only when `formValues` was NOT provided on this upload)
`formValues` (via `insertFormValuesInPdf`) is authoritative when present — it bakes values into the flattened background, so the imported fields stay empty for the signer to fill. When `formValues` is absent, we copy PDF defaults into `fieldMeta` so the editor preview matches the source PDF:
| Source | Target |
| --- | --- |
| `TextField.getText()` (non-empty) | `fieldMeta.text` (TEXT/DATE/INITIALS/NAME/EMAIL) or `fieldMeta.value` (NUMBER) |
| `DropdownField` current selection | `fieldMeta.defaultValue` |
| `CheckboxField.isChecked()` | `fieldMeta.values[0].checked = true` |
| `RadioField` current selection | `fieldMeta.values[i].checked = true` on the matching option |
Always emit `inserted: false` and `customText: ''` — the signer still confirms each field, defaults are editor-only hints.
All metadata flows through `ZEnvelopeFieldAndMetaSchema.parse(...)` so imported fields match what the editor already expects.
## Pre-extraction Guards
Run in this order before any AcroForm work:
1. **Encrypted PDFs**: if `pdfDoc.isEncrypted` → log `{ event: 'acroform-import.skip', reason: 'encrypted', envelopeItemTitle }`, return `{ fields: [], unsupported: [] }`. Upload proceeds with zero imported fields.
2. **XFA hybrid**: detect via the catalog's `AcroForm` dict carrying an `XFA` key (best-effort via raw dict access; if @libpdf/core's public surface can't read it, fall through — mirrored AcroForm fields in XFA hybrids are fine to import). When detected, log `{ event: 'acroform-import.skip', reason: 'xfa-hybrid' }` and return empty results.
3. **`getForm()` is null** → return empty results silently.
4. **Top-level try/catch** around steps 16: any throw → `logger.error({ event: 'acroform-import.error', envelopeItemTitle, err })`, return `{ fields: [], unsupported: [] }`. Upload proceeds untouched. Never bubble.
## Coordinate Handling
Reuse the placeholder convention (top-left percentages, see `auto-place-fields.ts:121-138`):
1. Build a page lookup once: `pages = pdfDoc.getPages(); pageByRef = new Map(pages.map((p, i) => [p.ref, i]))`.
2. For each widget:
1. Read `widget.rect = [x1, y1, x2, y2]` (bottom-left, points).
2. Normalize: `left = min(x1, x2)`, `right = max`, `bottom = min(y1, y2)`, `top = max`.
3. Resolve `pageIndex` via `pageByRef.get(widget.pageRef)`. Skip if no match.
4. Read `page.width`, `page.height`, `rot = page.getRotation()` (degrees, normalized to `0|90|180|270`).
5. Apply inverse rotation transform so the field lands at the rendered top-left percentage:
- `rot === 0`: `x = left`, `y = pageH - top`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
- `rot === 90`: `x = bottom`, `y = left`, `w = top - bottom`, `h = right - left`. Page dims swap: `(pageH, pageW)`.
- `rot === 180`: `x = pageW - right`, `y = bottom`, `w = right - left`, `h = top - bottom`. Page dims `(pageW, pageH)`.
- `rot === 270`: `x = pageH - top`, `y = pageW - right`, `w = top - bottom`, `h = right - left`. Page dims swap.
6. Out-of-bounds policy: if the entire rect is outside the rotated page bounds, skip + emit `AcroFormUnsupportedFieldInfo` with `reason: 'off-page'`. Otherwise clamp to `[0, renderedW] × [0, renderedH]`.
7. Convert to percentages against the rendered page dimensions from step 5.
8. Apply the existing `MIN_HEIGHT_THRESHOLD` / `DEFAULT_FIELD_HEIGHT_PERCENT` fallback used by placeholders.
3. Skip widgets that are `isHidden()` or have zero/negative `width`/`height` after normalization.
## Ordering
Sort imported fields before `createMany` by `(pageIndex asc, top-to-bottom, left-to-right)`. Concretely: ascending `pageIndex`, then ascending `y` (top-of-page first), then ascending `x` within `±2%` y-buckets so a row of fields stays a row. This matches how a signer visually scans the page; it does not rely on AcroForm `/Tabs` metadata (often wrong).
## Audit Logging
Every imported field emits one `FIELD_CREATED` entry matching `create-envelope-fields.ts:264`'s shape:
```ts
await tx.documentAuditLog.createMany({
data: createdFields.map((f) => createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: { fieldId: f.secondaryId, fieldRecipientEmail, fieldRecipientId, fieldType: f.type },
})),
});
```
The placeholder branch in `create-envelope.ts` is silent today and stays silent — AcroForm import does not retroactively change that. Distinguishing imported vs. placeholder vs. user-placed fields is done via `fieldMeta.source`, not a new audit type.
## Schema Extension
One field added to `ZBaseFieldMeta` in `packages/lib/types/field-meta.ts`:
```ts
export const ZBaseFieldMeta = z.object({
// existing...
source: z.enum(['acroform']).optional(),
});
```
No DB migration (fieldMeta is JSON). No editor change. No API contract change beyond the optional field. Forwards-compatible: future sources (`'placeholder'`, `'figma'`, etc.) extend the enum.
## Plan
### 1. Add the AcroForm extractor
New file `packages/lib/server-only/pdf/acroform-fields.ts`:
```ts
export type AcroFormFieldImportInfo = {
source: 'acroform';
fieldName: string;
widgetIndex: number;
fieldAndMeta: TFieldAndMeta;
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
export type AcroFormUnsupportedFieldInfo = {
fieldName: string;
acroFormType: string;
reason: 'unsupported-type' | 'hidden' | 'off-page' | 'zero-size' | 'no-page-match' | 'signed-signature';
};
export type AcroFormExtractionResult = {
fields: AcroFormFieldImportInfo[];
unsupported: AcroFormUnsupportedFieldInfo[];
/** True when a signed signature widget was found — caller MUST set flattenForm: false for that item. */
hasSignedSignature: boolean;
/** True when extraction returned empty for a reason that should be surfaced in logs but not propagated. */
skipReason?: 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error';
};
export const extractAcroFormFieldsFromPDF = async (
pdf: Buffer,
): Promise<AcroFormExtractionResult>;
export const convertAcroFormFieldsToFieldInputs = (
fields: AcroFormFieldImportInfo[],
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
envelopeItemId?: string,
): FieldToCreate[];
```
`extractAcroFormFieldsFromPDF`:
- Wraps everything in try/catch (top-level guard).
- Loads via `PDF.load(new Uint8Array(pdf))`.
- Runs pre-extraction guards (encrypted, XFA, null form) — returns early with `skipReason` set.
- Builds the page-ref → index + rotation lookup once.
- Iterates `form.getFields()`, applies the type-resolution heuristic, geometry pipeline, default-value mapping.
- Records signed-signature widgets in `unsupported` with `reason: 'signed-signature'` AND sets `hasSignedSignature = true`.
- Logger is module-scoped (no apiRequestMetadata in this file — pure function).
`convertAcroFormFieldsToFieldInputs` mirrors `convertPlaceholdersToFieldInputs` — pure point→percentage transform, no DB access. After mapping, sort by `(page, y, x)` as in Ordering.
Kept separate from `auto-place-fields.ts`: placeholders are text-driven and emit white rectangles via `whiteoutRegions`; AcroForm import is widget-driven and relies on the post-extraction `form.flatten()` to clean the PDF. Sharing types prematurely would couple both paths.
### 2. Extract AcroForm fields before flattening
In both upload entry points the order becomes:
```
convertToPdf (router only)
→ insertFormValuesInPdf if formValues
→ extractAcroFormFieldsFromPDF(pdf) // new — must run BEFORE normalizePdf
→ const shouldFlatten = type !== 'TEMPLATE' && !extraction.hasSignedSignature
→ normalizePdf({ flattenForm: shouldFlatten })
→ extractPdfPlaceholders(normalized)
→ putPdfFileServerSide(cleanedPdf)
→ forward { placeholders, acroFormFields, formValuesProvided }
```
Why before `normalizePdf`: for `DOCUMENT` uploads `normalizePdf` calls `form.flatten()` and destroys widget geometry. Extraction must read the unflattened buffer. `formValues` filling stays first so user-prefilled values still bake into the flattened background.
When `extraction.hasSignedSignature` is true, also `logger.warn({ event: 'acroform-import.signed-pdf-no-flatten', envelopeItemTitle })`.
`formValuesProvided` (boolean) is forwarded to the converter so the default-value mapping can skip prefill when the user-supplied values pipeline already filled the PDF.
Template flattening policy is unchanged in this plan: templates continue to preserve AcroForm widgets (no `form.flatten()`), so imported fields will visually duplicate the still-interactive PDF widgets in the template preview. Flipping templates to flatten is a follow-up — it's a breaking change for API users relying on template `formValues`.
### 3. Thread `acroFormFields` through `createEnvelope`
Extend `CreateEnvelopeOptions.data.envelopeItems[number]` (`packages/lib/server-only/envelope/create-envelope.ts:70-75`):
```ts
envelopeItems: {
title?: string;
documentDataId: string;
order?: number;
placeholders?: PlaceholderInfo[];
acroFormFields?: AcroFormFieldImportInfo[]; // new
formValuesProvided?: boolean; // new — already-applied prefill
}[];
```
Inside the existing transaction (alongside the `itemsWithPlaceholders` branch at `:431-538`), add an `itemsWithAcroFormFields` branch:
- Run AFTER the placeholder branch so `availableRecipients` reflects any placeholder signers it created.
- Recipient resolution:
- **First-signer rule**: pick `availableRecipients.filter(r => r.role === SIGNER || r.role === APPROVER).sort((a, b) => (a.signingOrder ?? Infinity) - (b.signingOrder ?? Infinity) || a.id - b.id)[0]`.
- If none: the placeholder branch may have created `Recipient 1` already — reuse. If still none (no recipients, no placeholders), create one placeholder `SIGNER` via the same `recipient.1@documenso.com` shape used by the placeholder branch.
- All imported fields → that one recipient. User reassigns in editor.
- Call `convertAcroFormFieldsToFieldInputs(item.acroFormFields, resolver, envelopeItem.id)` then `tx.field.createMany(...)` with the same `{ envelopeId, envelopeItemId, recipientId, type, page, positionX, positionY, width, height, customText: '', inserted: false, fieldMeta }` shape used by the placeholder branch.
- Immediately after, emit per-field `FIELD_CREATED` audit log entries (see Audit Logging).
### 4. Mirror in `UNSAFE_createEnvelopeItems`
`packages/lib/server-only/envelope-item/create-envelope-items.ts:47-77` — carry `acroFormFields` and `formValuesProvided` alongside `placeholders` in `envelopeItemsToCreate`. Inside the existing `if (envelope.recipients.length > 0)` block (`:111-160`), after the placeholder loop, run the AcroForm loop using the same first-signer rule (SIGNER|APPROVER, signingOrder asc, id asc). Emit per-field `FIELD_CREATED` entries with `apiRequestMetadata`. If `envelope.recipients.length === 0`, skip — appending widgets to a recipient-less envelope is the user's setup phase and is handled when they add recipients (matches current placeholder behavior on append).
### 5. Log unsupported fields, never block upload
In both entry points, after extraction:
```ts
if (extraction.unsupported.length > 0) {
logger.info({
event: 'acroform-import.unsupported',
envelopeItemTitle,
count: extraction.unsupported.length,
byReason: groupBy(extraction.unsupported, u => u.reason),
});
}
if (extraction.skipReason) {
logger.info({ event: 'acroform-import.skip', envelopeItemTitle, reason: extraction.skipReason });
}
```
No new error type, no upload rejection, no response-shape change. A UI surface for warnings comes later once the upload response has a stable warning shape.
### 6. Schema extension
`packages/lib/types/field-meta.ts`: add `source: z.enum(['acroform']).optional()` to `ZBaseFieldMeta`. Single-line change, no callers need updating because the field is optional.
## Files
| File | Change |
| --- | --- |
| `packages/lib/types/field-meta.ts` | Add `source?: 'acroform'` to `ZBaseFieldMeta`. |
| `packages/lib/server-only/pdf/acroform-fields.ts` | **new** — extractor, converter, types, pre-extraction guards, heuristics, geometry pipeline, ordering. |
| `packages/lib/server-only/envelope/create-envelope.ts` | Extend `CreateEnvelopeOptions.envelopeItems[]` with `acroFormFields` + `formValuesProvided`. Add AcroForm branch beside placeholder branch (~`:431-538`). Emit per-field `FIELD_CREATED` audit entries. |
| `packages/trpc/server/envelope-router/create-envelope.ts` | Insert `extractAcroFormFieldsFromPDF` before `normalizePdf` in the per-file loop (`:110-141`). Downgrade `flattenForm` when `hasSignedSignature`. Forward `acroFormFields` + `formValuesProvided` into the `envelopeItems` payload (`:135-140`). Log unsupported + skipReason. |
| `packages/lib/server-only/envelope-item/create-envelope-items.ts` | Insert `extractAcroFormFieldsFromPDF` before `normalizePdf` (`:48-77`). Same flatten downgrade. Carry `acroFormFields` + `formValuesProvided` in `envelopeItemsToCreate`. Add AcroForm loop inside `envelope.recipients.length > 0` (`:111-160`). Per-field audit entries. Log unsupported + skipReason. |
| `packages/lib/server-only/pdf/acroform-fields.test.ts` | **new** — unit suite (see Tests). |
| `packages/app-tests/e2e/scenarios/acroform-import.spec.ts` | **new** — e2e suite (see Tests). |
| `scripts/generate-acroform-test-pdf.mjs` | **new** — one-off generator (committed) producing `assets/acroform-import-test.pdf` + rotated variants. |
| `assets/acroform-import-test.pdf` | **new** — base fixture: one of each supported type. |
| `assets/acroform-import-rotated-90.pdf` | **new** — rotated-page fixture. |
| `assets/acroform-import-rotated-180.pdf` | **new** — rotated-page fixture. |
| `assets/acroform-import-rotated-270.pdf` | **new** — rotated-page fixture. |
| `assets/acroform-import-signed.pdf` | **new** — fixture with one signed signature widget + supported widgets. |
No DB schema change. No new tRPC route. No public API surface change beyond the optional `fieldMeta.source`.
## Tests
### Unit (`packages/lib/server-only/pdf/acroform-fields.test.ts`)
Drive from the committed fixture set; synthesize edge-case PDFs inline via `@libpdf/core`'s form builder where a static file is overkill.
Type resolution:
- text / signature / checkbox / radio / dropdown widgets each produce the expected Documenso field type.
- Heuristic positives: `signed_date` / `dob` → DATE; `initial` / `initials` → INITIALS; `customer_email` → EMAIL; `full_name` / `fname` → NAME; field with `AFNumber_Format` action → NUMBER; field with `MaxLen: 5` + name `qty` → NUMBER; plain `customer_id` → TEXT.
- AcroForm format actions take precedence over name tokens (a field named `customer_name` with an `AFDate_FormatEx` action → DATE).
Metadata:
- `isRequired` / `isReadOnly` round-trip into `fieldMeta`.
- `alternateName` (or `partialName` fallback) → `fieldMeta.label` on label-supporting types; SIGNATURE has no label.
- Required CHECKBOX → `required: true` + `validationRule: 'at-least'` + `validationLength: 1`.
- Every imported field has `fieldMeta.source = 'acroform'`.
Default values:
- TextField with non-empty value AND `formValuesProvided = false``fieldMeta.text` set.
- TextField with non-empty value AND `formValuesProvided = true``fieldMeta.text` NOT set.
- DropdownField selection → `fieldMeta.defaultValue`.
- CheckboxField checked → `values[0].checked = true`.
- RadioField selected → matching `values[i].checked = true`.
Geometry:
- Bottom-left widget rect `[100, 600, 200, 620]` on a 612×792 page → top-left percentages within ±0.01% of expected.
- 90° rotated page: same widget rect → rotated coordinates as defined in Coordinate Handling step 5.
- 180° and 270° rotated pages: same.
- Hidden widgets (annotation flags hidden bit) → skipped.
- Widgets with zero/negative dimensions → skipped.
- Widgets with `pageRef` not in `pdfDoc.getPages()``unsupported` with `reason: 'no-page-match'`.
- Widget rect entirely off-page → `unsupported` with `reason: 'off-page'`.
- Widget rect partially off-page → clamped, imported.
Ordering:
- Two pages × four widgets in scrambled creation order → output sorted by `(page, y, x)`.
Skips and unsupported:
- listbox / button / unknown / non-terminal → `unsupported`, never thrown.
- Encrypted PDF → `skipReason: 'encrypted'`, `fields: []`, no throw.
- XFA hybrid PDF (best-effort detect) → `skipReason: 'xfa-hybrid'` when detectable; otherwise extraction proceeds normally.
- Signed signature widget (`SignatureField.isSigned()` returns true) → `unsupported` with `reason: 'signed-signature'` AND `hasSignedSignature: true`.
- Buffer corruption → top-level try/catch, returns empty + `skipReason: 'error'`, no throw.
### E2E (`packages/app-tests/e2e/scenarios/acroform-import.spec.ts`)
- Upload `assets/acroform-import-test.pdf` as a `DOCUMENT` via the v2 envelope router with one provided SIGNER recipient → assert envelope has one `Field` per supported widget, types match, `positionX/Y/width/height` within ±1% of expected, every field's recipient is that one SIGNER, stored PDF (`documentData`) loaded via `PDF.load` reports `getForm() === null` or `getFields().length === 0`. Audit log contains N `FIELD_CREATED` entries.
- Upload with `formValues` populated → `formValues` persists, imported fields exist but have no default values set in fieldMeta, the flattened PDF reflects the prefilled values.
- Upload with two recipients: one CC + one SIGNER → all imported fields assigned to the SIGNER (CC skipped).
- Upload with two recipients: one SIGNER (signingOrder=1) + one APPROVER (signingOrder=2) → all imported fields assigned to the SIGNER.
- Upload with zero recipients → placeholder `Recipient 1` created (shared with the placeholder branch's behavior; if both placeholders and AcroForm fields exist in the same file, only one `Recipient 1` exists).
- Upload `assets/acroform-import-signed.pdf` → signed signature widget skipped, other widgets imported, stored PDF is NOT flattened (`getForm() !== null`, widgets still present).
- Append `assets/acroform-import-test.pdf` to an existing envelope with one SIGNER via `UNSAFE_createEnvelopeItems` → new `envelopeItem.id` carries the imported fields, all assigned to that SIGNER.
- Append to a recipient-less envelope → AcroForm extraction runs, fields are NOT created (skipped, matching placeholder behavior).
- Upload `TEMPLATE` → template still preserves AcroForm widgets in the stored PDF (current behavior unchanged), imported fields ALSO exist (visual duplication acknowledged in Risks).
- Upload PDF with one `listbox` + one supported `text` field → upload succeeds, only the text field becomes a Documenso field, log line emitted for the listbox.
- Upload rotated PDFs (90/180/270 fixtures) → field geometry lands within ±1% of the expected rendered position on each page.
### Regression
```bash
npx tsc --noEmit -p apps/remix/tsconfig.json
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/form-flattening.spec.ts
npm run test:dev -w @documenso/app-tests -- packages/app-tests/e2e/scenarios/acroform-import.spec.ts
```
## Behavior Matrix
| Upload | Has AcroForm | Signed sig? | `formValues`? | `recipients`? | Result |
| --- | --- | --- | --- | --- | --- |
| `DOCUMENT` | yes | no | none | 1 SIGNER/APPROVER | All imported fields → that recipient. Stored PDF flat. Per-field `FIELD_CREATED` audit. |
| `DOCUMENT` | yes | no | none | N≥2 mixed roles | All imported fields → first SIGNER|APPROVER by (signingOrder asc, id asc). CC/VIEWER skipped. User reassigns in editor. |
| `DOCUMENT` | yes | no | none | only CC/VIEWER | Treated as "no signable recipients" — placeholder `Recipient 1` SIGNER created. |
| `DOCUMENT` | yes | no | none | none | One placeholder `Recipient 1` SIGNER created (reused if placeholder branch already made one). |
| `DOCUMENT` | yes | no | provided | any | `formValues` filled → flattened values visible → empty supported fields imported with `source: 'acroform'`, no `fieldMeta.text`/`defaultValue` prefill. |
| `DOCUMENT` | yes | yes | any | any | Signed signature(s) skipped + logged. `flattenForm` downgraded to false → stored PDF retains widgets. Other supported widgets imported normally. |
| `DOCUMENT` | no | n/a | any | any | Unchanged. |
| `TEMPLATE` | yes | n/a | any | any | Imported fields created **and** PDF still contains interactive widgets (known artifact, follow-up). |
| Encrypted PDF | n/a | n/a | any | any | Extraction skipped + logged. Upload proceeds with zero AcroForm imports. |
| XFA hybrid (detected) | n/a | n/a | any | any | Extraction skipped + logged. Same as encrypted. |
| Append to existing envelope w/ recipients | yes | no | n/a | n/a | Imported fields → first SIGNER|APPROVER of the envelope. Per-field audit. |
| Append to existing envelope w/o recipients | yes | n/a | n/a | n/a | Skipped (matches current placeholder behavior on append). |
## Out of Scope / Follow-ups
- Flipping `TEMPLATE` uploads to flatten after import — breaking for API users relying on template AcroForm `formValues`.
- Editor UI surface: "Imported from PDF" badge (using `fieldMeta.source`), warning toast for skipped widgets, encrypted/XFA banner. Data is captured now; UI ships separately.
- A signed-AcroForm-signature → completed Documenso signature mapping.
- True radio-group consolidation (one Documenso field per AcroForm radio group instead of per widget) — needs `fieldMeta` schema extension for multi-position groups.
- Same-name multi-widget non-radio fields (one AcroForm text field rendered on N pages) — currently emit N independent Documenso fields; future work could sync values at signing time via a shared `groupId` in fieldMeta.
- Listbox support.
- Recipient inference from PDF authoring metadata (Adobe's role/recipient hints, tab order grouping).
- AcroForm `/Tabs` ordering as a signal — current spatial sort suffices.
## Risks
- **Rotated pages**: covered by inverse-rotation transform with 90/180/270 fixtures gating the unit suite. Skewed rotations (non-cardinal) are not supported; should be rejected as `off-page` if their normalized rect doesn't land within page bounds.
- **Radio groups**: emitting one Documenso field per widget will look right visually but signing semantics differ from a single PDF radio group (each option becomes independently checkable). Gating fixture in e2e covers visual placement; signing semantics divergence is documented and ships as a known limitation.
- **Template behavior**: leaving `TEMPLATE` uploads unflattened means imported fields and live widgets coexist. Acceptable for v1, but the template preview will show duplicated controls.
- **Signed signature + flattenForm downgrade**: a `DOCUMENT` upload containing a signed signature now stores an un-flattened PDF. Existing code paths that assume `DOCUMENT` PDFs are always flat (signing renderer, downstream conversion) MUST be re-verified — add an integration check in the e2e suite that signing still works on the signed-fixture envelope.
- **Recipient ambiguity**: AcroForm widgets don't encode Documenso recipients. Deterministic "all to first signer" + editor review is the safest first cut; smarter assignment is a follow-up.
- **XFA detection**: best-effort; if @libpdf/core's public surface doesn't expose the catalog AcroForm dict, we fall through and import any mirrored AcroForm fields. Acceptable — XFA-only PDFs with no mirror produce empty AcroForm extraction and the upload proceeds. Worst case is a noisy log line on a misclassified hybrid.
- **Heuristic false positives**: expanded heuristic (NAME/EMAIL/NUMBER/DATE/INITIALS) increases the chance of mis-typing a field. Mitigation: every imported field is editable in the editor before sending. False negatives fall through to TEXT (always safe).
@@ -0,0 +1,289 @@
---
date: 2026-02-02
title: Support For External 2fa Codes
---
## Objective
Enable organizations to enforce a second factor for document signing while keeping delivery fully external (for example customer-owned SMS), with strong recipient/session binding and auditable controls.
## Problem Context
- Many legacy organizations still rely on SMS for second-factor delivery.
- Their users cannot realistically migrate to authenticator apps or passkeys yet.
- Operating first-party SMS infrastructure in Documenso is costly, risky, and outside core scope.
- Customers need an API-first integration path that fits existing notification infrastructure and compliance controls.
## Proposed Solution
Introduce external 2FA codes for signing:
1. A trusted backend service requests a one-time signing token via API.
2. The customer delivers that token to the signer through their own existing channel (for example SMS).
3. The signer enters the token in the signing flow.
4. Documenso validates the submitted token, then issues a short-lived session-bound verification proof.
5. Signature completion is allowed only when the proof is present and valid for that recipient signing session.
## Decisions Captured In Interview
- Enforcement scope: template-level default with per-recipient override.
- Issuer trust boundary: scoped machine API keys with explicit permission.
- Token lifecycle: newest token immediately revokes prior active token for same recipient/document.
- Brute-force control: token-scoped hard attempt cap.
- Security defaults: TTL 10 minutes, max 5 attempts.
- Verification unlock: session-bound proof (not global recipient unlock).
- Issuance contract: idempotent-ish reissue behavior with explicit structured denial reasons.
- Audit privacy: never log token/code material; log identifiers and reason codes only.
- Missing token at signing time: block with actionable state.
- Rollback behavior: feature-flag off for new sessions only.
- Resend/recovery in v1: support-owned reissue guidance only (no signer self-serve trigger).
- Workspace policy controls in v1: no per-workspace TTL/attempt overrides.
- Session proof TTL in v1: 10 minutes.
## Scope
### In Scope
- API endpoint to issue short-lived signing 2FA tokens for eligible recipients.
- Secure storage/verification mechanism (hashed token + expiry + attempt tracking).
- Signing UI step to collect token before signature submission.
- Standard operating flow: token is generated via API and entered by the recipient in the UI.
- Verification endpoint/path integrated into signing completion checks.
- Audit logging for token issuance and verification attempts.
- Template policy defaults with per-recipient override support.
- Session-bound verification proof issuance after successful code validation.
- Feature-flagged rollout controls at workspace/organization scope.
### Out of Scope
- Native SMS sending/providers inside Documenso.
- New authenticator/passkey implementation.
- Cross-channel delivery guarantees (owned by customer infrastructure).
- UI-only token generation as the primary flow in this phase.
- Fully configurable TTL/attempt policy per workspace in v1.
- Customer callback/webhook resend orchestration in v1.
- Signer-triggered self-serve reissue controls in v1.
## Functional Requirements
- Token is recipient-bound and document/session-bound.
- Token cannot be shared across recipients or recipient roles.
- A recipient token only authorizes signature actions for that same recipient identity.
- If the same human is represented by multiple recipient records, each recipient record still requires its own token.
- Token has strict TTL of 10 minutes and single-use semantics.
- Token verification fails on expiry, mismatch, too many attempts, or reuse.
- Endpoint access is restricted to scoped API clients with explicit issuance permission.
- Clear, localized user errors for invalid/expired tokens.
- Max 5 verification attempts per token; on cap reached, token becomes unusable and signer must use a newly issued token.
- Issuing a new token revokes any existing active token for the same recipient/document pair.
- Successful verification creates a short-lived session-bound proof; only that session can complete signature.
- If 2FA is required but no valid token has been issued yet, signing must be blocked with actionable guidance.
## Non-Functional Requirements
- Verification and consumption path must be atomic and race-safe under concurrent requests.
- Error responses must use stable machine-readable reason codes for customer integrations.
- p95 verification latency should remain within existing signing guardrail budget (target: <= 300 ms server-side).
- Security controls and audit logging must not expose token/code values in logs, traces, or analytics payloads.
## Policy Model
- Default requirement is configured at template/workflow level.
- Sender can override requirement per recipient before send.
- Effective policy is materialized on recipient/document at send time to avoid template drift during in-flight signing.
- Feature flag gates enforcement by workspace/organization for rollout and rollback.
## API Contract
### Token Issuance Endpoint
- Auth: scoped API key with dedicated permission (for example `signing_2fa:issue`).
- Input: recipient/document context and optional idempotency metadata.
- Behavior:
- Eligible recipient: always issues a fresh token and revokes prior active token.
- Ineligible/forbidden state: returns structured 4xx with explicit reason code.
- Never returns previously generated plaintext token; token is visible exactly once at issuance.
- Output:
- Plaintext token (single response only).
- Metadata for integration handling (expiresAt, ttlSeconds, attemptLimit, issuedAt).
### Verification Endpoint
- Input: token submission from signing UI bound to current signing session context.
- Behavior:
- Valid token: atomically consumes token and issues session-bound verification proof.
- Invalid token: increments attempts and returns reason code.
- Expired/revoked/consumed/capped: returns denial reason without revealing sensitive internals.
- Output:
- Success: verification state for current session.
- Failure: localized user-safe message + machine reason code.
### Resend/Reissue Behavior (v1)
- No signer-triggered callback/webhook or self-serve reissue endpoint in v1.
- If token is missing/expired/revoked/capped, signer sees actionable guidance to contact sender/support.
- Reissue remains an API-key-initiated operation from trusted customer backend only.
### Suggested Reason Codes
- `TWO_FA_NOT_REQUIRED`
- `TWO_FA_NOT_ISSUED`
- `TWO_FA_TOKEN_INVALID`
- `TWO_FA_TOKEN_EXPIRED`
- `TWO_FA_TOKEN_REVOKED`
- `TWO_FA_TOKEN_CONSUMED`
- `TWO_FA_ATTEMPT_LIMIT_REACHED`
- `TWO_FA_ISSUER_FORBIDDEN`
- `TWO_FA_RECIPIENT_INELIGIBLE`
## Data Model
Create `signing_two_factor_tokens` (name indicative):
- `id`
- `recipientId`
- `documentId`
- `tokenHash`
- `tokenSalt` (or use KDF settings sufficient to avoid raw-secret recovery)
- `expiresAt`
- `consumedAt` nullable
- `revokedAt` nullable
- `attempts` default 0
- `attemptLimit` default 5
- `issuedByApiKeyId` (or actor reference)
- `createdAt`
Optional companion table/entity for session proof:
- `signing_session_2fa_proofs`
- `sessionId`
- `recipientId`
- `documentId`
- `verifiedAt`
- `expiresAt`
Constraints and indexes:
- Index on (`recipientId`, `documentId`, `expiresAt`).
- At most one active token per (`recipientId`, `documentId`) enforced by transactional revoke-on-issue.
- Guard against lost-update on attempts and consume via row lock or atomic update conditions.
## Signing UX
- Insert 2FA code step before signature commit when effective policy requires it.
- UX states:
- Waiting for code input.
- Invalid code (remaining attempts shown where safe).
- Expired/revoked/attempt cap reached with clear next-step copy.
- Not issued yet state with actionable guidance.
- Recovery copy in v1 must direct signer to sender/support (no in-product resend action).
- Localization required for all user-facing errors.
- Accessibility: input labeling, error announcement, keyboard submission, mobile-friendly numeric entry.
- Session-bound proof behavior must be transparent to user (no global unlock across devices/tabs).
## Security Requirements
- Never persist plaintext token; store salted hash only.
- Rate-limit issuance and verification attempts.
- Invalidate previous active token immediately when a new token is issued.
- Emit security/audit events with actor, recipient, document, timestamp, and reason codes.
- Prevent token leakage in logs, telemetry, and error payloads.
- Use constant-time comparison and hardened random token generation.
- Enforce short proof lifetime for verified session to reduce replay window.
- Set proof TTL to 10 minutes in v1.
## Observability And Audit
Emit events for:
- `2fa_token_issued`
- `2fa_token_issue_denied`
- `2fa_token_verify_succeeded`
- `2fa_token_verify_failed`
- `2fa_token_consumed`
- `2fa_token_revoked`
Event fields:
- `workspaceId`, `documentId`, `recipientId`
- `actorType` (api_key, signer_session, system)
- `actorId` (where applicable)
- `reasonCode`
- `ipHash`, `userAgentHash` (if available)
- `timestamp`
Metrics and alerts:
- Issuance success/failure rates.
- Verification success/failure rate split by reason code.
- Attempt-limit-hit rate.
- p95 verification latency.
- Alert on unusual spikes in invalid attempts per recipient/document/workspace.
## Implementation Plan
1. Domain model
- Add signing 2FA token entity/table and session-proof persistence.
2. Token issuance API
- Add authenticated route for scoped API keys; issue fresh token, revoke prior active.
3. Verification logic
- Validate token state, increment attempts atomically, consume on success, mint session proof.
4. Signing flow integration
- Add UI token prompt and backend guard requiring valid session proof.
5. Observability
- Add reason-coded events and dashboards/alerts.
6. Controls
- Add rate limits, attempt cap (5), revoke-on-reissue, and feature flag checks.
7. Testing
- Unit tests for generation/verification edge cases.
- Integration tests for API and signing flow.
- Concurrency tests for double-submit and parallel verification.
## Testing Matrix
- Token issuance for eligible/ineligible recipients.
- Reissue revokes previous token immediately.
- Verification success path creates session-bound proof.
- Verification fails on mismatch, expiry, revoked, consumed, cap reached.
- Attempt counter increments correctly under concurrent requests.
- Signature blocked when proof absent or expired.
- Recipient A token rejected for recipient B (including same human/multiple recipient records).
- Feature flag off: new sessions bypass external 2FA requirement.
- Audit events emitted with expected reason codes and no token material.
## Acceptance Criteria
- External system can request a token for an eligible signer through API.
- Signer cannot complete signing without valid token when policy requires 2FA.
- A token issued for recipient A is always rejected for recipient B, including when both recipients map to the same underlying person.
- Valid token allows signing exactly once within TTL.
- Expired/reused/invalid tokens are rejected with clear errors.
- No Documenso-owned SMS infrastructure is introduced.
- Audit trail captures issuance and verification outcomes.
- Default policy can be set at template level with per-recipient override at send time.
- New token issuance revokes prior active token for same recipient/document.
- Max 5 failed attempts per token is enforced.
- Successful verification unlocks only the active signing session.
- If no token has been issued yet, signer is blocked with actionable guidance.
## Rollout Strategy
- Ship behind feature flag (workspace-level or organization-level).
- Enable first for pilot customers in regulated domains.
- Monitor verification failure rates and support feedback.
- Gradually expand availability once stable.
- Rollback path: disable flag for new sessions only; preserve already verified in-flight sessions.
## Risks and Mitigations
- Brute-force attempts -> enforce attempt caps, lockouts, and rate limits.
- Delivery delays in customer SMS systems -> allow controlled token re-issue.
- Support burden from expiry confusion -> clear UX copy and resend guidance.
- Concurrency race on consume/attempt updates -> use transactional atomic updates and dedicated tests.
- Misconfigured API clients -> explicit permission scopes and structured denial reasons.
- Forensic gaps vs privacy over-collection -> reason-coded audits with hashed network metadata only.
## Open Questions
- None for v1 scope.
- v1.1 exploration candidate: customer-controlled signer-triggered callback/reissue flow with abuse protections.
@@ -13,6 +13,7 @@ import { match, P } from 'ts-pattern';
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
import { DocumentSigningAuthExternal2FA } from './document-signing-auth-external-2fa';
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
@@ -58,15 +59,8 @@ export const DocumentSigningAuthDialog = ({
return;
}
// Reset selected auth type when dialog closes
if (!value) {
setSelectedAuthType(() => {
if (validAuthTypes.length === 1) {
return validAuthTypes[0];
}
return null;
});
setSelectedAuthType(validAuthTypes.length === 1 ? validAuthTypes[0] : null);
}
onOpenChange(value);
@@ -123,6 +117,7 @@ export const DocumentSigningAuthDialog = ({
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</Trans>)
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => <Trans>Verification code</Trans>)
.with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
.exhaustive()}
</div>
@@ -132,6 +127,9 @@ export const DocumentSigningAuthDialog = ({
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
.with(DocumentAuth.PASSKEY, () => <Trans>Use your passkey for authentication</Trans>)
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>Enter your 2FA code</Trans>)
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => (
<Trans>Enter the verification code provided to you</Trans>
))
.with(DocumentAuth.PASSWORD, () => <Trans>Enter your password</Trans>)
.exhaustive()}
</div>
@@ -169,6 +167,13 @@ export const DocumentSigningAuthDialog = ({
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, () => (
<DocumentSigningAuthExternal2FA
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()}
</DialogContent>
@@ -0,0 +1,223 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { SIGNING_2FA_VERIFY_REASON_CODES } from '@documenso/lib/constants/document-auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
export type DocumentSigningAuthExternal2FAProps = {
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
const ZExternal2FAFormSchema = z.object({
code: z
.string()
.length(6, { message: 'Code must be exactly 6 digits' })
.regex(/^\d{6}$/, { message: 'Code must contain only digits' }),
});
type TExternal2FAFormSchema = z.infer<typeof ZExternal2FAFormSchema>;
export const DocumentSigningAuthExternal2FA = ({
onReauthFormSubmit,
open,
onOpenChange,
}: DocumentSigningAuthExternal2FAProps) => {
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext();
const [formError, setFormError] = useState<string | null>(null);
const statusQuery = trpc.envelope.signing2fa.getStatus.useQuery(
{ token: recipient.token },
{ enabled: open },
);
const verifyMutation = trpc.envelope.signing2fa.verify.useMutation();
const form = useForm<TExternal2FAFormSchema>({
resolver: zodResolver(ZExternal2FAFormSchema),
defaultValues: {
code: '',
},
});
const onFormSubmit = async ({ code }: TExternal2FAFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
setFormError(null);
await verifyMutation.mutateAsync({
token: recipient.token,
code,
});
await onReauthFormSubmit({
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
});
onOpenChange(false);
} catch (err) {
const error = AppError.parseError(err);
if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED) {
setFormError('Too many failed attempts. Please request a new code.');
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED) {
setFormError('The code has expired. Please request a new code.');
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED) {
setFormError('No code has been issued yet. Please contact the document sender.');
} else {
setFormError('Invalid code. Please try again.');
}
await statusQuery.refetch();
form.reset({ code: '' });
} finally {
setIsCurrentlyAuthenticating(false);
}
};
useEffect(() => {
form.reset({ code: '' });
setFormError(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const attemptsRemaining = statusQuery.data?.attemptsRemaining ?? null;
const hasActiveToken = statusQuery.data?.hasActiveToken ?? false;
const hasValidProof = statusQuery.data?.hasValidProof ?? false;
if (hasValidProof) {
return (
<div className="space-y-4">
<Alert>
<AlertDescription>
<Trans>Your identity has already been verified. You can proceed to sign.</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button
type="button"
onClick={async () => {
await onReauthFormSubmit({
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
});
onOpenChange(false);
}}
>
<Trans>Continue</Trans>
</Button>
</DialogFooter>
</div>
);
}
if (!hasActiveToken && !statusQuery.isLoading) {
return (
<div className="space-y-4">
<Alert variant="warning">
<AlertTitle>
<Trans>Verification code required</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
A verification code is required to sign this document. Please contact the document
sender to request your code.
</Trans>
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
</DialogFooter>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<Trans>Enter the 6-digit verification code that was provided to you.</Trans>
</p>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Verification code</Trans>
</FormLabel>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{attemptsRemaining !== null && attemptsRemaining > 0 && (
<p className="text-xs text-muted-foreground">
<Trans>{attemptsRemaining} attempts remaining</Trans>
</p>
)}
{formError && (
<Alert variant="destructive">
<AlertTitle>
<Trans>Verification failed</Trans>
</AlertTitle>
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
<Trans>Verify</Trans>
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
);
};
@@ -61,13 +61,13 @@ export const useRequiredDocumentSigningAuthContext = () => {
return context;
};
export interface DocumentSigningAuthProviderProps {
export type DocumentSigningAuthProviderProps = {
documentAuthOptions: Envelope['authOptions'];
recipient: SigningAuthRecipient;
isDirectTemplate?: boolean;
user?: SessionUser | null;
children: React.ReactNode;
}
};
export const DocumentSigningAuthProvider = ({
documentAuthOptions: initialDocumentAuthOptions,
@@ -169,12 +169,12 @@ export const DocumentSigningAuthProvider = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [passkeyData.passkeys]);
// Assume that a user must be logged in for any auth requirements.
const authMethodsRequiringLogin = derivedRecipientActionAuth?.filter(
(method) => method !== DocumentAuth.EXPLICIT_NONE && method !== DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
);
const isAuthRedirectRequired = Boolean(
derivedRecipientActionAuth &&
derivedRecipientActionAuth.length > 0 &&
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) &&
user?.email !== recipient.email,
authMethodsRequiringLogin && authMethodsRequiringLogin.length > 0 && user?.email !== recipient.email,
);
const refetchPasskeys = async () => {
@@ -106,8 +106,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
}))
.with(undefined, () => undefined)
.with(
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD),
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
P.union(
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
),
() => 'NOT_SUPPORTED' as const,
)
.exhaustive();
@@ -18,20 +18,18 @@ import {
} from '@documenso/lib/types/field-meta';
import { getEnvelopeItemPermissions } from '@documenso/lib/utils/envelope';
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { useToast } from '@documenso/ui/primitives/use-toast';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, FormInputIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FileTextIcon, PencilIcon, SparklesIcon } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@@ -78,8 +76,7 @@ export const EnvelopeEditorFieldsPage = () => {
const scrollableContainerRef = useRef<HTMLDivElement>(null);
const { envelope, editorFields, navigateToStep, editorConfig, flushAutosave, syncEnvelope } =
useCurrentEnvelopeEditor();
const { envelope, editorFields, navigateToStep, editorConfig } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
@@ -87,32 +84,7 @@ export const EnvelopeEditorFieldsPage = () => {
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const [acroFormHasFieldsByItemRevision, setAcroFormHasFieldsByItemRevision] = useState<Record<string, boolean>>({});
const { revalidate } = useRevalidator();
const { toast } = useToast();
const { mutateAsync: importFieldsFromPdf, isPending: isImportingFieldsFromPdf } =
trpc.envelope.field.importFromPdf.useMutation();
const currentEnvelopeItemRevision = currentEnvelopeItem
? `${currentEnvelopeItem.id}:${currentEnvelopeItem.documentDataId}`
: null;
const currentItemHasAcroForm =
currentEnvelopeItemRevision !== null && acroFormHasFieldsByItemRevision[currentEnvelopeItemRevision] === true;
const onAcroFormDetected = useCallback(
(hasFields: boolean) => {
if (!currentEnvelopeItemRevision) {
return;
}
setAcroFormHasFieldsByItemRevision((prev) =>
prev[currentEnvelopeItemRevision] === hasFields ? prev : { ...prev, [currentEnvelopeItemRevision]: hasFields },
);
},
[currentEnvelopeItemRevision],
);
const envelopeItemPermissions = useMemo(
() => getEnvelopeItemPermissions(envelope, envelope.recipients),
@@ -180,40 +152,6 @@ export const EnvelopeEditorFieldsPage = () => {
});
};
const onImportFromPdfClick = async () => {
try {
await flushAutosave();
const result = await importFieldsFromPdf({ envelopeId: envelope.id });
if (result.fieldsCreated === 0) {
toast({
title: _(msg`No form fields found`),
description: _(msg`This PDF does not contain any importable form fields.`),
duration: 5000,
});
return;
}
await syncEnvelope();
toast({
title: _(msg`Fields imported`),
description: _(
msg`Imported ${result.fieldsCreated} field${result.fieldsCreated === 1 ? '' : 's'} from the PDF form. Review and reassign in the editor.`,
),
duration: 5000,
});
} catch {
toast({
title: _(msg`Could not import fields`),
description: _(msg`Something went wrong while importing fields from the PDF.`),
variant: 'destructive',
duration: 5000,
});
}
};
return (
<div className="relative flex h-full">
<div className="flex h-full w-full flex-col overflow-y-auto px-2" ref={scrollableContainerRef}>
@@ -278,7 +216,6 @@ export const EnvelopeEditorFieldsPage = () => {
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
scrollParentRef={scrollableContainerRef}
errorMessage={PDF_VIEWER_ERROR_MESSAGES.editor}
onAcroFormDetected={onAcroFormDetected}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
@@ -371,20 +308,6 @@ export const EnvelopeEditorFieldsPage = () => {
/>
</>
)}
{currentItemHasAcroForm && envelope.status === DocumentStatus.DRAFT && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={() => void onImportFromPdfClick()}
disabled={isImportingFieldsFromPdf}
>
<FormInputIcon className="mr-2 -ml-1 h-4 w-4" />
{isImportingFieldsFromPdf ? <Trans>Importing...</Trans> : <Trans>Import from PDF form</Trans>}
</Button>
)}
</section>
{/* Field details section. */}
@@ -46,7 +46,7 @@ export const EnvelopePdfViewer = ({ errorMessage, className, ...props }: Envelop
return (
<PDFViewerLazy
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}-${currentEnvelopeItem.documentDataId}`}
key={`${currentEnvelopeItem.envelopeId}-${currentEnvelopeItem.id}`}
{...props}
className={cn('h-full w-full max-w-[800px]', className)}
data={currentEnvelopeItem.data}
@@ -50,7 +50,6 @@ export type PDFViewerProps = {
scrollParentRef: ScrollTarget;
onDocumentLoad?: () => void;
onAcroFormDetected?: (hasFields: boolean) => void;
/**
* Additional component to render next to the image, such as a Konva canvas
@@ -64,7 +63,6 @@ export default function PDFViewer({
data,
scrollParentRef,
onDocumentLoad,
onAcroFormDetected,
customPageRenderer,
...props
}: PDFViewerProps) {
@@ -126,20 +124,6 @@ export default function PDFViewer({
// eslint-disable-next-line require-atomic-updates
pdfRef.current = loadedPdf;
if (onAcroFormDetected) {
try {
const fieldObjects = await loadedPdf.getFieldObjects();
if (!isCancelled) {
onAcroFormDetected(fieldObjects !== null && Object.keys(fieldObjects).length > 0);
}
} catch {
if (!isCancelled) {
onAcroFormDetected(false);
}
}
}
// Fetch the pages
const pages = await pMap(Array.from({ length: loadedPdf.numPages }), async (_, pageIndex) => {
const page = await loadedPdf.getPage(pageIndex + 1);
@@ -184,7 +168,7 @@ export default function PDFViewer({
pdfRef.current = null;
}
};
}, [data, onAcroFormDetected]);
}, [data]);
// Notify when document is loaded
useEffect(() => {
@@ -150,6 +150,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
let authLevel = match(actionAuthMethod)
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
.with('EXTERNAL_TWO_FACTOR_AUTH', () => _(msg`External Two-Factor Re-Authentication`))
.with('PASSWORD', () => _(msg`Password Re-Authentication`))
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => _(msg`Email`))
-715
View File
@@ -1,715 +0,0 @@
%PDF-1.7
%âãÏÓ
1 0 obj
<<
/Type /Pages
/Kids [4 0 R 5 0 R]
/Count 2
>>
endobj
2 0 obj
<<
/Type /Catalog
/Pages 1 0 R
/AcroForm 6 0 R
>>
endobj
3 0 obj
<<
/Title (Untitled)
/Author (Unknown)
/Creator (@libpdf/core)
/Producer (@libpdf/core)
/CreationDate (D:20260521033345Z)
/ModDate (D:20260521033345Z)
>>
endobj
4 0 obj
<<
/Type /Page
/MediaBox [0 0 612 792]
/Resources <<
>>
/Parent 1 0 R
/Annots [8 0 R 11 0 R 14 0 R]
>>
endobj
5 0 obj
<<
/Type /Page
/MediaBox [0 0 612 792]
/Resources <<
>>
/Parent 1 0 R
/Annots [18 0 R 21 0 R 24 0 R 27 0 R 31 0 R 34 0 R 37 0 R]
>>
endobj
6 0 obj
<<
/Fields [7 0 R 10 0 R 13 0 R 17 0 R 20 0 R 30 0 R 33 0 R 36 0 R]
/DR <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
/DA (/Helv 0 Tf 0 g)
/NeedAppearances false
/SigFlags 3
>>
endobj
7 0 obj
<<
/FT /Tx
/T (CustomerName)
/Kids [8 0 R]
/DA (0 g)
>>
endobj
8 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 620 280 644]
/P 4 0 R
/Parent 7 0 R
/F 4
/AP <<
/N 9 0 R
>>
>>
endobj
9 0 obj
<<
/Length 73/Type /XObject
/Subtype /Form
/BBox [0 0 200 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 198 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
10 0 obj
<<
/FT /Tx
/T (signed_date)
/Kids [11 0 R]
/DA (0 g)
>>
endobj
11 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 560 280 584]
/P 4 0 R
/Parent 10 0 R
/F 4
/AP <<
/N 12 0 R
>>
>>
endobj
12 0 obj
<<
/Length 73/Type /XObject
/Subtype /Form
/BBox [0 0 200 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 198 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
13 0 obj
<<
/FT /Btn
/T (accept_terms)
/Kids [14 0 R]
/V /Off
>>
endobj
14 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 500 98 518]
/P 4 0 R
/Parent 13 0 R
/F 4
/AS /Off
/AP <<
/N <<
/Yes 15 0 R
/Off 16 0 R
>>
>>
>>
endobj
15 0 obj
<<
/Length 47/Type /XObject
/Subtype /Form
/BBox [0 0 18 18]
/Resources <<
/Font <<
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
>>
stream
q
BT
/ZaDb 12.6 Tf
0 g
2.7 4.59 Td
(4) Tj
ET
Q
endstream
endobj
16 0 obj
<<
/Length 0/Type /XObject
/Subtype /Form
/BBox [0 0 18 18]
/Resources <<
>>
>>
stream
endstream
endobj
17 0 obj
<<
/FT /Ch
/T (country)
/Ff 131072
/Kids [18 0 R]
/Opt [(USA) (Canada) (Germany)]
/DA (0 g)
/V (USA)
/DV (USA)
>>
endobj
18 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 700 280 724]
/P 5 0 R
/Parent 17 0 R
/F 4
/AP <<
/N 19 0 R
>>
>>
endobj
19 0 obj
<<
/Length 76/Type /XObject
/Subtype /Form
/BBox [0 0 200 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 178 22 re
W
n
BT
/Helv 14 Tf
0 g
2 9.872 Td
(USA) Tj
ET
Q
EMC
endstream
endobj
20 0 obj
<<
/FT /Btn
/T (payment_method)
/Ff 32768
/Kids [21 0 R 24 0 R 27 0 R]
/V /PayPal
/DV /PayPal
/Opt [(Credit Card) (PayPal) (Bank Transfer)]
>>
endobj
21 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 640 96 656]
/P 5 0 R
/Parent 20 0 R
/F 4
/AS /Off
/AP <<
/N <<
/Credit#20Card 22 0 R
/Off 23 0 R
>>
>>
>>
endobj
22 0 obj
<<
/Length 46/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
/Font <<
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
>>
stream
q
BT
/ZaDb 9.6 Tf
0 g
3.2 4.64 Td
(l) Tj
ET
Q
endstream
endobj
23 0 obj
<<
/Length 159/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
>>
>>
stream
q
0 G
12.8 8 m
12.8 10.65104 10.65104 12.8 8 12.8 c
5.34896 12.8 3.2 10.65104 3.2 8 c
3.2 5.34896 5.34896 3.2 8 3.2 c
10.65104 3.2 12.8 5.34896 12.8 8 c
h
S
Q
endstream
endobj
24 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 615 96 631]
/P 5 0 R
/Parent 20 0 R
/F 4
/AS /PayPal
/AP <<
/N <<
/PayPal 25 0 R
/Off 26 0 R
>>
>>
>>
endobj
25 0 obj
<<
/Length 46/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
/Font <<
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
>>
stream
q
BT
/ZaDb 9.6 Tf
0 g
3.2 4.64 Td
(l) Tj
ET
Q
endstream
endobj
26 0 obj
<<
/Length 159/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
>>
>>
stream
q
0 G
12.8 8 m
12.8 10.65104 10.65104 12.8 8 12.8 c
5.34896 12.8 3.2 10.65104 3.2 8 c
3.2 5.34896 5.34896 3.2 8 3.2 c
10.65104 3.2 12.8 5.34896 12.8 8 c
h
S
Q
endstream
endobj
27 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 590 96 606]
/P 5 0 R
/Parent 20 0 R
/F 4
/AS /Off
/AP <<
/N <<
/Bank#20Transfer 28 0 R
/Off 29 0 R
>>
>>
>>
endobj
28 0 obj
<<
/Length 46/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
/Font <<
/ZaDb <<
/Type /Font
/Subtype /Type1
/BaseFont /ZapfDingbats
>>
>>
>>
>>
stream
q
BT
/ZaDb 9.6 Tf
0 g
3.2 4.64 Td
(l) Tj
ET
Q
endstream
endobj
29 0 obj
<<
/Length 159/Type /XObject
/Subtype /Form
/BBox [0 0 16 16]
/Resources <<
>>
>>
stream
q
0 G
12.8 8 m
12.8 10.65104 10.65104 12.8 8 12.8 c
5.34896 12.8 3.2 10.65104 3.2 8 c
3.2 5.34896 5.34896 3.2 8 3.2 c
10.65104 3.2 12.8 5.34896 12.8 8 c
h
S
Q
endstream
endobj
30 0 obj
<<
/FT /Tx
/T (initials)
/Kids [31 0 R]
/DA (0 g)
>>
endobj
31 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [80 540 140 564]
/P 5 0 R
/Parent 30 0 R
/F 4
/AP <<
/N 32 0 R
>>
>>
endobj
32 0 obj
<<
/Length 72/Type /XObject
/Subtype /Form
/BBox [0 0 60 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 58 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
33 0 obj
<<
/FT /Tx
/T (contact_email)
/Kids [34 0 R]
/DA (0 g)
>>
endobj
34 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [160 540 380 564]
/P 5 0 R
/Parent 33 0 R
/F 4
/AP <<
/N 35 0 R
>>
>>
endobj
35 0 obj
<<
/Length 73/Type /XObject
/Subtype /Form
/BBox [0 0 220 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 218 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
36 0 obj
<<
/FT /Tx
/T (item_qty)
/Kids [37 0 R]
/DA (0 g)
/MaxLen 4
>>
endobj
37 0 obj
<<
/Type /Annot
/Subtype /Widget
/Rect [400 540 460 564]
/P 5 0 R
/Parent 36 0 R
/F 4
/AP <<
/N 38 0 R
>>
>>
endobj
38 0 obj
<<
/Length 72/Type /XObject
/Subtype /Form
/BBox [0 0 60 24]
/Resources <<
/Font <<
/Helv <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
>>
stream
/Tx BMC
q
1 1 58 22 re
W
n
BT
/Helv 14 Tf
0 g
2 6.974 Td
() Tj
ET
Q
EMC
endstream
endobj
xref
0 39
0000000000 65535 f
0000000015 00000 n
0000000078 00000 n
0000000143 00000 n
0000000312 00000 n
0000000430 00000 n
0000000577 00000 n
0000000866 00000 n
0000000937 00000 n
0000001058 00000 n
0000001319 00000 n
0000001391 00000 n
0000001515 00000 n
0000001777 00000 n
0000001849 00000 n
0000002004 00000 n
0000002242 00000 n
0000002356 00000 n
0000002486 00000 n
0000002610 00000 n
0000002875 00000 n
0000003034 00000 n
0000003199 00000 n
0000003436 00000 n
0000003711 00000 n
0000003872 00000 n
0000004109 00000 n
0000004384 00000 n
0000004551 00000 n
0000004788 00000 n
0000005063 00000 n
0000005132 00000 n
0000005256 00000 n
0000005516 00000 n
0000005590 00000 n
0000005715 00000 n
0000005977 00000 n
0000006056 00000 n
0000006181 00000 n
trailer
<<
/Size 39
/Root 2 0 R
/Info 3 0 R
/ID [<4798EA6ACCB2CE19827C36CD841F759A> <4798EA6ACCB2CE19827C36CD841F759A>]
>>
startxref
6441
%%EOF
@@ -1,368 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
import { UNSAFE_replaceEnvelopeItemPdf } from '@documenso/lib/server-only/envelope-item/replace-envelope-item-pdf';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { prisma } from '@documenso/prisma';
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { PDF, PdfString } from '@libpdf/core';
import { type APIRequestContext, expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
const ACROFORM_FIXTURE = fs.readFileSync(path.join(__dirname, '../../../../assets/acroform-import-test.pdf'));
const ACROFORM_DOCUMENT_PAYLOAD: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document',
recipients: [
{
email: 'signer@example.com',
name: 'Signer',
role: RecipientRole.SIGNER,
},
],
};
const API_REQUEST_METADATA: ApiRequestMetadata = {
requestMetadata: {},
source: 'apiV1',
auth: 'api',
};
type TestUser = Awaited<ReturnType<typeof seedUser>>['user'];
type TestTeam = Awaited<ReturnType<typeof seedUser>>['team'];
const seedUserWithApiToken = async (): Promise<{ token: string; user: TestUser; team: TestTeam }> => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
return { token, user, team };
};
const pdfHasFormFields = async (pdf: Uint8Array): Promise<boolean> => {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
const form = pdfDoc.getForm();
return (form?.fieldCount ?? 0) > 0;
};
const createSignedSignatureAcroFormPdf = (): Promise<Uint8Array> => {
const pdf = PDF.create();
const page = pdf.addPage({ size: 'letter' });
const form = pdf.getOrCreateForm();
const textField = form.createTextField('full_name');
const signatureField = form.createSignatureField('signed_signature');
page.drawField(textField, { x: 100, y: 700, width: 200, height: 24 });
signatureField.getDict().set('V', PdfString.fromString('fake-signature'));
return pdf.save();
};
const uploadAcroFormEnvelope = async ({
request,
token,
payload = ACROFORM_DOCUMENT_PAYLOAD,
file = ACROFORM_FIXTURE,
fileName = 'acroform-import-test.pdf',
}: {
request: APIRequestContext;
token: string;
payload?: TCreateEnvelopePayload;
file?: Uint8Array;
fileName?: string;
}): Promise<TCreateEnvelopeResponse> => {
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('files', new File([file], fileName, { type: 'application/pdf' }));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
const importAcroFormFieldsWithSession = ({
page,
teamId,
envelopeId,
}: {
page: Page;
teamId: number;
envelopeId: string;
}) =>
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/envelope.field.importFromPdf`, {
headers: {
'content-type': 'application/json',
'x-team-id': String(teamId),
},
data: JSON.stringify({ json: { envelopeId } }),
});
const loadEnvelopeForImport = async (envelopeId: string) =>
prisma.envelope.findUniqueOrThrow({
where: { id: envelopeId },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
},
});
test.describe.configure({
mode: 'parallel',
});
test.describe('AcroForm Import', () => {
test('upload does not create fields and preserves widgets in the stored PDF', async ({ request }) => {
const { token } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({ request, token });
const envelope = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
expect(envelope.fields).toHaveLength(0);
const pdfBuffer = await getFileServerSide(envelope.envelopeItems[0].documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('replacement preserves widgets in the stored PDF for later import', async ({ request }) => {
const { token, user } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({ request, token });
const envelope = await loadEnvelopeForImport(response.id);
const oldDocumentDataId = envelope.envelopeItems[0].documentDataId;
await UNSAFE_replaceEnvelopeItemPdf({
envelope,
recipients: envelope.recipients,
envelopeItemId: envelope.envelopeItems[0].id,
oldDocumentDataId,
data: {
title: 'Replacement AcroForm document',
file: new File([ACROFORM_FIXTURE], 'replacement-acroform.pdf', { type: 'application/pdf' }),
},
user,
apiRequestMetadata: API_REQUEST_METADATA,
});
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
expect(after.fields).toHaveLength(0);
expect(after.envelopeItems[0].documentDataId).not.toBe(oldDocumentDataId);
const pdfBuffer = await getFileServerSide(after.envelopeItems[0].documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('import creates fields assigned to the signer, flattens the PDF, and emits audit logs', async ({ request }) => {
const { token } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({ request, token });
const envelope = await loadEnvelopeForImport(response.id);
const oldDocumentDataId = envelope.envelopeItems[0].documentDataId;
const result = await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: API_REQUEST_METADATA,
});
expect(result.fieldsCreated).toBeGreaterThan(0);
expect(result.itemsProcessed).toBe(1);
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
recipients: true,
fields: true,
},
});
expect(after.fields.length).toBeGreaterThanOrEqual(8);
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
for (const field of after.fields) {
const meta = field.fieldMeta as { source?: string } | null;
expect(meta?.source).toBe('acroform');
}
const auditEntries = await prisma.documentAuditLog.findMany({
where: { envelopeId: after.id, type: 'FIELD_CREATED' },
});
expect(auditEntries.length).toBe(after.fields.length);
expect(after.envelopeItems[0].documentDataId).not.toBe(oldDocumentDataId);
const flattenedPdf = await getFileServerSide(after.envelopeItems[0].documentData);
expect(await pdfHasFormFields(flattenedPdf)).toBe(false);
const oldRecord = await prisma.documentData.findUnique({ where: { id: oldDocumentDataId } });
expect(oldRecord).toBeNull();
});
test('import creates a placeholder Recipient 1 SIGNER when no recipients exist', async ({ request }) => {
const { token } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({
request,
token,
payload: {
type: EnvelopeType.DOCUMENT,
title: 'AcroForm document without recipients',
},
});
const envelope = await loadEnvelopeForImport(response.id);
expect(envelope.recipients).toHaveLength(0);
await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: API_REQUEST_METADATA,
});
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: { recipients: true, fields: true },
});
expect(after.recipients).toHaveLength(1);
expect(after.recipients[0].email).toBe('recipient.1@documenso.com');
expect(after.recipients[0].role).toBe(RecipientRole.SIGNER);
expect(after.fields.length).toBeGreaterThanOrEqual(8);
expect(after.fields.every((f) => f.recipientId === after.recipients[0].id)).toBe(true);
});
test('import endpoint rejects template envelopes without mutating stored widgets', async ({ page, request }) => {
const { token, user, team } = await seedUserWithApiToken();
const response = await uploadAcroFormEnvelope({
request,
token,
payload: {
type: EnvelopeType.TEMPLATE,
title: 'AcroForm template',
},
});
await apiSignin({ page, email: user.email });
const res = await importAcroFormFieldsWithSession({
page,
teamId: team.id,
envelopeId: response.id,
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
const after = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
expect(after.fields).toHaveLength(0);
const pdfBuffer = await getFileServerSide(after.envelopeItems[0].documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
});
test('import does not duplicate fields when signed signatures prevent flattening', async ({ request }) => {
const { token } = await seedUserWithApiToken();
const signedPdf = await createSignedSignatureAcroFormPdf();
const response = await uploadAcroFormEnvelope({
request,
token,
file: signedPdf,
fileName: 'signed-acroform.pdf',
});
const envelope = await loadEnvelopeForImport(response.id);
const firstResult = await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: API_REQUEST_METADATA,
});
expect(firstResult.fieldsCreated).toBeGreaterThan(0);
expect(firstResult.itemsProcessed).toBe(1);
expect(firstResult.signedSignatureCount).toBe(1);
const afterFirst = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: {
envelopeItems: { include: { documentData: true } },
fields: true,
},
});
const firstFieldCount = afterFirst.fields.length;
const preservedPdf = await getFileServerSide(afterFirst.envelopeItems[0].documentData);
expect(await pdfHasFormFields(preservedPdf)).toBe(true);
const secondEnvelope = await loadEnvelopeForImport(response.id);
const secondResult = await UNSAFE_importAcroFormFieldsFromEnvelope({
envelope: secondEnvelope,
apiRequestMetadata: API_REQUEST_METADATA,
});
expect(secondResult.fieldsCreated).toBe(0);
expect(secondResult.itemsProcessed).toBe(0);
const afterSecond = await prisma.envelope.findUniqueOrThrow({
where: { id: response.id },
include: { fields: true },
});
expect(afterSecond.fields).toHaveLength(firstFieldCount);
});
});
@@ -93,7 +93,7 @@ test.describe('Form Flattening', () => {
const formFieldsPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/form-fields-test.pdf'));
test.describe('Envelope Creation (DOCUMENT type)', () => {
test('should preserve form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
test('should flatten form fields when creating a DOCUMENT envelope with formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -136,16 +136,16 @@ test.describe('Form Flattening', () => {
expect(envelope.formValues).toEqual(TEST_FORM_VALUES);
expect(envelope.type).toBe(EnvelopeType.DOCUMENT);
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe(
TEST_FORM_VALUES[FORM_FIELDS.TEXT_FIELD],
);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
});
test('should preserve form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
test('should flatten form fields when creating a DOCUMENT envelope without formValues', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
@@ -157,6 +157,7 @@ test.describe('Form Flattening', () => {
const payload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Document without Form Values',
// No formValues - but form should still be flattened for DOCUMENT type
};
const formData = new FormData();
@@ -183,10 +184,13 @@ test.describe('Form Flattening', () => {
},
});
// Get the PDF and verify form fields are flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
const hasFormFields = await pdfHasFormFields(pdfBuffer);
expect(hasFormFields).toBe(false);
});
});
@@ -743,10 +747,11 @@ test.describe('Form Flattening', () => {
},
});
// Form should still be flattened for DOCUMENT type
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
});
test('should handle partial formValues (only some fields)', async ({ request }) => {
@@ -793,11 +798,11 @@ test.describe('Form Flattening', () => {
[FORM_FIELDS.TEXT_FIELD]: 'Only this field',
});
// Form should still be flattened
const documentData = envelope.envelopeItems[0].documentData;
const pdfBuffer = await getFileServerSide(documentData);
expect(await pdfHasFormFields(pdfBuffer)).toBe(true);
expect(await getPdfTextFieldValue(pdfBuffer, FORM_FIELDS.TEXT_FIELD)).toBe('Only this field');
expect(await pdfHasFormFields(pdfBuffer)).toBe(false);
});
});
});
@@ -43,7 +43,6 @@ type EnvelopeRenderItem = {
title: string;
order: number;
envelopeId: string;
documentDataId: string;
/**
* The PDF data to render.
+13
View File
@@ -26,8 +26,21 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
key: DocumentAuth.PASSWORD,
value: msg`Require password`,
},
[DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH]: {
key: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
value: msg`Require external 2FA`,
},
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: msg`None (Overrides global settings)`,
},
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
export const SIGNING_2FA_VERIFY_REASON_CODES = {
TWO_FA_TOKEN_INVALID: 'TWO_FA_TOKEN_INVALID',
TWO_FA_TOKEN_EXPIRED: 'TWO_FA_TOKEN_EXPIRED',
TWO_FA_TOKEN_REVOKED: 'TWO_FA_TOKEN_REVOKED',
TWO_FA_TOKEN_CONSUMED: 'TWO_FA_TOKEN_CONSUMED',
TWO_FA_ATTEMPT_LIMIT_REACHED: 'TWO_FA_ATTEMPT_LIMIT_REACHED',
TWO_FA_NOT_ISSUED: 'TWO_FA_NOT_ISSUED',
} as const;
@@ -115,11 +115,28 @@ export const completeDocumentWithToken = async ({
}
// Check ACCESS AUTH 2FA validation during document completion
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
if (derivedRecipientActionAuth.includes(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH)) {
const validProof = await prisma.signingSessionTwoFactorProof.findFirst({
where: {
sessionId: token,
envelopeId: envelope.id,
expiresAt: { gt: new Date() },
},
});
if (!validProof) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'External 2FA verification required before completing document',
statusCode: 403,
});
}
}
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
if (!accessAuthOptions) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
@@ -31,6 +31,7 @@ type IsRecipientAuthorizedOptions = {
* using the user ID.
*/
authOptions?: TDocumentAuthMethods;
recipientToken?: string;
};
const getUserByEmail = async (email: string) => {
@@ -56,6 +57,7 @@ export const isRecipientAuthorized = async ({
recipient,
userId,
authOptions,
recipientToken,
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: documentAuthOptions,
@@ -163,6 +165,21 @@ export const isRecipientAuthorized = async ({
password,
});
})
.with({ type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, async () => {
if (!recipientToken) {
return false;
}
const validProof = await prisma.signingSessionTwoFactorProof.findFirst({
where: {
sessionId: recipientToken,
envelopeId: recipient.envelopeId,
expiresAt: { gt: new Date() },
},
});
return !!validProof;
})
.with({ type: DocumentAuth.EXPLICIT_NONE }, () => {
return true;
})
@@ -11,6 +11,7 @@ export type ValidateFieldAuthOptions = {
field: Field;
userId?: number;
authOptions?: TRecipientActionAuth;
recipientToken?: string;
};
/**
@@ -24,6 +25,7 @@ export const validateFieldAuth = async ({
field,
userId,
authOptions,
recipientToken,
}: ValidateFieldAuthOptions) => {
// Override all non-signature fields to not require any auth.
if (field.type !== FieldType.SIGNATURE) {
@@ -36,6 +38,7 @@ export const validateFieldAuth = async ({
recipient,
userId,
authOptions,
recipientToken,
});
if (!isValid) {
@@ -54,7 +54,7 @@ export const UNSAFE_createEnvelopeItems = async ({
}
const normalized = await normalizePdf(buffer, {
flattenForm: false,
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
@@ -1,349 +0,0 @@
import { prisma } from '@documenso/prisma';
import type { DocumentData, Envelope, EnvelopeItem, Field, Recipient } from '@prisma/client';
import { EnvelopeType, RecipientRole, SendStatus, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
import { nanoid } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { logger } from '../../utils/logger';
import {
type AcroFormExtractionResult,
type AcroFormSkipReason,
convertAcroFormFieldsToFieldInputs,
extractAcroFormFieldsFromPDF,
} from '../pdf/acroform-fields';
import { normalizePdf } from '../pdf/normalize-pdf';
type UnsafeImportAcroFormFieldsOptions = {
envelope: Pick<Envelope, 'id' | 'type' | 'formValues'> & {
envelopeItems: (Pick<EnvelopeItem, 'id' | 'title' | 'documentDataId'> & {
documentData: DocumentData;
})[];
recipients: Recipient[];
};
apiRequestMetadata: ApiRequestMetadata;
};
type PerItemSkip = {
envelopeItemId: string;
envelopeItemTitle: string;
reason: AcroFormSkipReason;
};
export type ImportAcroFormFieldsResult = {
itemsProcessed: number;
fieldsCreated: number;
unsupportedCount: number;
signedSignatureCount: number;
skippedItems: PerItemSkip[];
fields: Field[];
};
type PreparedItem = {
envelopeItemId: string;
envelopeItemTitle: string;
oldDocumentDataId: string;
extraction: AcroFormExtractionResult;
newDocumentData?: DocumentData;
};
export const UNSAFE_importAcroFormFieldsFromEnvelope = async ({
envelope,
apiRequestMetadata,
}: UnsafeImportAcroFormFieldsOptions): Promise<ImportAcroFormFieldsResult> => {
if (envelope.type !== EnvelopeType.DOCUMENT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'AcroForm import is only supported for document envelopes',
});
}
const prepared: PreparedItem[] = await Promise.all(
envelope.envelopeItems.map(async (item): Promise<PreparedItem> => {
const buffer = await getFileServerSide(item.documentData);
const extraction = await extractAcroFormFieldsFromPDF(Buffer.from(buffer), {
formValuesProvided: Boolean(envelope.formValues),
});
if (extraction.skipReason) {
logger.info(
{
event: 'acroform-import.skip',
envelopeItemId: item.id,
envelopeItemTitle: item.title,
reason: extraction.skipReason,
},
'AcroForm extraction skipped',
);
}
if (extraction.unsupported.length > 0) {
const byReason: Record<string, number> = {};
for (const entry of extraction.unsupported) {
byReason[entry.reason] = (byReason[entry.reason] ?? 0) + 1;
}
logger.info(
{
event: 'acroform-import.unsupported',
envelopeItemId: item.id,
envelopeItemTitle: item.title,
count: extraction.unsupported.length,
byReason,
},
'AcroForm import skipped unsupported widgets',
);
}
if (extraction.hasSignedSignature) {
logger.warn(
{
event: 'acroform-import.signed-pdf-no-flatten',
envelopeItemId: item.id,
envelopeItemTitle: item.title,
},
'Signed AcroForm signature detected — skipping flatten to preserve signature',
);
}
const base: PreparedItem = {
envelopeItemId: item.id,
envelopeItemTitle: item.title,
oldDocumentDataId: item.documentDataId,
extraction,
};
if (extraction.fields.length === 0 || extraction.hasSignedSignature) {
return base;
}
const flattened = await normalizePdf(Buffer.from(buffer), {
flattenForm: true,
});
const { documentData: newDocumentData } = await putPdfFileServerSide({
name: item.title,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(flattened),
});
return {
...base,
newDocumentData,
};
}),
);
const totalFieldsToCreate = prepared.reduce((sum, p) => sum + p.extraction.fields.length, 0);
const unsupportedCount = prepared.reduce((sum, p) => sum + p.extraction.unsupported.length, 0);
const signedSignatureCount = prepared.filter((p) => p.extraction.hasSignedSignature).length;
const skippedItems: PerItemSkip[] = [];
for (const p of prepared) {
const reason = p.extraction.skipReason;
if (!reason) {
continue;
}
skippedItems.push({
envelopeItemId: p.envelopeItemId,
envelopeItemTitle: p.envelopeItemTitle,
reason,
});
}
if (totalFieldsToCreate === 0) {
return {
itemsProcessed: 0,
fieldsCreated: 0,
unsupportedCount,
signedSignatureCount,
skippedItems,
fields: [],
};
}
const { createdFields, importedItemsCount } = await prisma.$transaction(async (tx) => {
const pickFirstSignableRecipient = (recipients: Pick<Recipient, 'id' | 'email' | 'role' | 'signingOrder'>[]) => {
const signable = recipients.filter((r) => r.role === RecipientRole.SIGNER || r.role === RecipientRole.APPROVER);
if (signable.length === 0) {
return null;
}
return signable.sort((a, b) => {
const aOrder = a.signingOrder ?? Number.MAX_SAFE_INTEGER;
const bOrder = b.signingOrder ?? Number.MAX_SAFE_INTEGER;
if (aOrder !== bOrder) {
return aOrder - bOrder;
}
return a.id - b.id;
})[0];
};
const signedItemIds = prepared
.filter((item) => item.extraction.hasSignedSignature && item.extraction.fields.length > 0)
.map((item) => item.envelopeItemId);
const alreadyImportedSignedItemIds = new Set<string>();
if (signedItemIds.length > 0) {
const existingImportedFields = await tx.field.findMany({
where: {
envelopeId: envelope.id,
envelopeItemId: {
in: signedItemIds,
},
},
select: {
envelopeItemId: true,
fieldMeta: true,
},
});
for (const field of existingImportedFields) {
const fieldMeta = field.fieldMeta;
if (
fieldMeta &&
typeof fieldMeta === 'object' &&
!Array.isArray(fieldMeta) &&
(fieldMeta as { source?: unknown }).source === 'acroform'
) {
alreadyImportedSignedItemIds.add(field.envelopeItemId);
}
}
}
const itemsToImport = prepared.filter((item) => {
if (item.extraction.fields.length === 0) {
return false;
}
return !(item.extraction.hasSignedSignature && alreadyImportedSignedItemIds.has(item.envelopeItemId));
});
const createdFields: Field[] = [];
if (itemsToImport.length === 0) {
return { createdFields, importedItemsCount: 0 };
}
let recipient = pickFirstSignableRecipient(
await tx.recipient.findMany({
where: { envelopeId: envelope.id },
select: { id: true, email: true, role: true, signingOrder: true },
}),
);
if (!recipient) {
const placeholderEmail = 'recipient.1@documenso.com';
recipient = await tx.recipient.create({
data: {
envelopeId: envelope.id,
email: placeholderEmail,
name: 'Recipient 1',
role: RecipientRole.SIGNER,
signingOrder: 1,
token: nanoid(),
sendStatus: SendStatus.NOT_SENT,
signingStatus: SigningStatus.NOT_SIGNED,
},
select: { id: true, email: true, role: true, signingOrder: true },
});
}
let importedItemsCount = 0;
for (const item of itemsToImport) {
if (item.newDocumentData) {
await tx.envelopeItem.update({
where: { id: item.envelopeItemId },
data: { documentDataId: item.newDocumentData.id },
});
}
const fieldsToCreate = convertAcroFormFieldsToFieldInputs(
item.extraction.fields,
() => recipient,
item.envelopeItemId,
);
const itemCreatedFields = await tx.field.createManyAndReturn({
data: fieldsToCreate.map((field) => ({
envelopeId: envelope.id,
envelopeItemId: item.envelopeItemId,
recipientId: field.recipientId,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta || undefined,
})),
});
createdFields.push(...itemCreatedFields);
importedItemsCount += 1;
if (envelope.type === EnvelopeType.DOCUMENT) {
await tx.documentAuditLog.createMany({
data: itemCreatedFields.map((createdField) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED,
envelopeId: envelope.id,
metadata: apiRequestMetadata,
data: {
fieldId: createdField.secondaryId,
fieldRecipientEmail: recipient.email,
fieldRecipientId: createdField.recipientId,
fieldType: createdField.type,
},
}),
),
});
}
}
return { createdFields, importedItemsCount };
});
await Promise.all(
prepared
.filter((p) => p.newDocumentData !== undefined)
.map((p) =>
prisma.documentData.delete({ where: { id: p.oldDocumentDataId } }).catch((err) => {
logger.error(
{
event: 'acroform-import.delete-old-document-data-failed',
envelopeItemId: p.envelopeItemId,
oldDocumentDataId: p.oldDocumentDataId,
err,
},
'Failed to delete orphaned DocumentData after AcroForm import',
);
}),
),
);
return {
itemsProcessed: importedItemsCount,
fieldsCreated: createdFields.length,
unsupportedCount,
signedSignatureCount,
skippedItems,
fields: createdFields,
};
};
@@ -81,7 +81,7 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
}
const normalized = await normalizePdf(buffer, {
flattenForm: false,
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
@@ -177,6 +177,7 @@ export const signFieldWithToken = async ({
field,
userId,
authOptions,
recipientToken: token,
});
const documentMeta = await prisma.documentMeta.findFirst({
@@ -1,834 +0,0 @@
import {
PDF,
type PDFPage,
type PdfDict,
type PdfObject,
type PdfRef,
type PdfStream,
type PdfString,
} from '@libpdf/core';
import { FieldType, type Recipient } from '@prisma/client';
import {
FIELD_CHECKBOX_META_DEFAULT_VALUES,
FIELD_DATE_META_DEFAULT_VALUES,
FIELD_DROPDOWN_META_DEFAULT_VALUES,
FIELD_EMAIL_META_DEFAULT_VALUES,
FIELD_INITIALS_META_DEFAULT_VALUES,
FIELD_NAME_META_DEFAULT_VALUES,
FIELD_NUMBER_META_DEFAULT_VALUES,
FIELD_RADIO_META_DEFAULT_VALUES,
FIELD_SIGNATURE_META_DEFAULT_VALUES,
FIELD_TEXT_META_DEFAULT_VALUES,
type TCheckboxFieldMeta,
type TDropdownFieldMeta,
type TFieldAndMeta,
type TNumberFieldMeta,
type TRadioFieldMeta,
type TTextFieldMeta,
ZEnvelopeFieldAndMetaSchema,
} from '../../types/field-meta';
import { logger } from '../../utils/logger';
import type { FieldToCreate } from './auto-place-fields';
/**
* Local shape for the widget annotations returned by @libpdf/core.
*
* The library exposes WidgetAnnotation as a class but does not re-export the
* type from its public surface. We duck-type the subset we actually read.
*/
type WidgetAnnotation = {
readonly rect: [number, number, number, number];
readonly width: number;
readonly height: number;
readonly pageRef: PdfRef | null;
isHidden(): boolean;
getOnValue(): string | null;
};
/**
* Function shape that follows a {@link PdfRef} to the referenced object.
*
* `@libpdf/core` does not re-export the `RefResolver` type alias, so we redeclare
* it locally. Built via {@link makeResolver} from a loaded {@link PDF}.
*/
type RefResolver = (ref: PdfRef) => PdfObject | null;
const makeResolver =
(pdfDoc: PDF): RefResolver =>
(ref: PdfRef) =>
pdfDoc.context.resolve(ref);
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
const MIN_HEIGHT_THRESHOLD = 0.01;
const DATE_NAME_PATTERN = /date|dob|birth/i;
const NUMBER_NAME_PATTERN = /amount|qty|count|number/i;
const EMAIL_NAME_PATTERN = /email|e[-_]?mail/i;
const NAME_NAME_PATTERN = /name/i;
const INITIALS_NAME_PATTERN = /initial/i;
const ROW_TOLERANCE_PERCENT = 2;
const ACROFORM_FIELD_SOURCE = 'acroform';
export type AcroFormUnsupportedReason =
| 'unsupported-type'
| 'hidden'
| 'off-page'
| 'zero-size'
| 'no-page-match'
| 'signed-signature'
| 'rotated-out-of-bounds';
export type AcroFormSkipReason = 'encrypted' | 'xfa-hybrid' | 'no-form' | 'error';
export type AcroFormFieldImportInfo = {
source: 'acroform';
fieldName: string;
widgetIndex: number;
fieldAndMeta: TFieldAndMeta;
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
export type AcroFormUnsupportedFieldInfo = {
fieldName: string;
acroFormType: string;
reason: AcroFormUnsupportedReason;
};
export type AcroFormExtractionResult = {
fields: AcroFormFieldImportInfo[];
unsupported: AcroFormUnsupportedFieldInfo[];
/**
* True when a signed signature widget was found.
*
* Callers MUST set `flattenForm: false` for that envelope item so the signed
* PDF is not re-flattened (which would invalidate the signature).
*/
hasSignedSignature: boolean;
/**
* Set when extraction returned empty for a reason that should be surfaced in
* logs but not propagated to the user. Absent when extraction ran normally.
*/
skipReason?: AcroFormSkipReason;
};
type ResolvedGeometry = {
page: number;
x: number;
y: number;
width: number;
height: number;
pageWidth: number;
pageHeight: number;
};
const EMPTY_RESULT = (skipReason?: AcroFormSkipReason): AcroFormExtractionResult => ({
fields: [],
unsupported: [],
hasSignedSignature: false,
skipReason,
});
/**
* Detect XFA-hybrid PDFs by inspecting the catalog's `/AcroForm` dict for an
* `/XFA` key.
*
* Uses public accessors (`pdf.context.catalog.getDict()`) and a {@link RefResolver}
* so an indirect `/AcroForm` entry is followed. Returns `false` on any error
* (e.g. malformed catalog) so the caller can fall through to normal extraction.
*/
const hasXfa = (pdfDoc: PDF, resolver: RefResolver): boolean => {
try {
const catalogDict = pdfDoc.context.catalog.getDict();
const acroFormDict = catalogDict.getDict('AcroForm', resolver);
return Boolean(acroFormDict?.has('XFA'));
} catch {
return false;
}
};
const isDateFieldByName = (name: string | null | undefined): boolean => !!name && DATE_NAME_PATTERN.test(name);
const isNumberFieldByName = (name: string | null | undefined): boolean => !!name && NUMBER_NAME_PATTERN.test(name);
const isEmailFieldByName = (name: string | null | undefined): boolean => !!name && EMAIL_NAME_PATTERN.test(name);
const isNameFieldByName = (name: string | null | undefined): boolean => !!name && NAME_NAME_PATTERN.test(name);
const isInitialsFieldByName = (name: string | null | undefined): boolean => !!name && INITIALS_NAME_PATTERN.test(name);
/**
* Detect AcroForm format actions on a text field dictionary.
*
* Adobe attaches a JavaScript format action via `/AA` → `/F` → `/JS`. The script
* body references `AFDate_FormatEx` or `AFNumber_Format` depending on the
* intended format. The action dict and its `/F` entry are frequently stored
* as indirect refs in real-world PDFs, so a {@link RefResolver} MUST be
* threaded through the lookups. We do a string-contains check on the script
* to avoid pulling in a JS parser.
*/
const getTextFieldFormatHint = (fieldDict: PdfDict, resolver: RefResolver): 'date' | 'number' | null => {
try {
const formatDict = fieldDict.getDict('AA', resolver)?.getDict('F', resolver);
if (!formatDict) {
return null;
}
const js = formatDict.get('JS', resolver);
if (!js || typeof js !== 'object') {
return null;
}
let script: string;
if (js.type === 'string') {
script = (js as PdfString).asString();
} else if (js.type === 'stream') {
script = new TextDecoder().decode((js as PdfStream).getDecodedData());
} else {
return null;
}
if (script.includes('AFDate_FormatEx') || script.includes('AFDate_Format')) {
return 'date';
}
if (script.includes('AFNumber_Format')) {
return 'number';
}
return null;
} catch {
return null;
}
};
type FormFieldWithDict = {
name: string;
partialName: string;
alternateName: string | null;
isReadOnly(): boolean;
isRequired(): boolean;
acroField(): PdfDict;
getWidgets(): WidgetAnnotation[];
};
type ResolvedTextDocumensoType =
| typeof FieldType.TEXT
| typeof FieldType.DATE
| typeof FieldType.NUMBER
| typeof FieldType.EMAIL
| typeof FieldType.NAME
| typeof FieldType.INITIALS;
const resolveTextSubtype = (
field: FormFieldWithDict,
resolver: RefResolver,
): {
documensoType: ResolvedTextDocumensoType;
} => {
const candidateNames = [field.partialName, field.alternateName];
const formatHint = getTextFieldFormatHint(field.acroField(), resolver);
// AcroForm format actions take precedence over name tokens — Adobe set them
// explicitly, so they're a stronger signal than a heuristic regex hit.
if (formatHint === 'date') {
return { documensoType: FieldType.DATE };
}
if (formatHint === 'number') {
return { documensoType: FieldType.NUMBER };
}
const maxLen = field.acroField().getNumber('MaxLen', resolver)?.value ?? Number.POSITIVE_INFINITY;
if (candidateNames.some(isDateFieldByName)) {
return { documensoType: FieldType.DATE };
}
if (maxLen <= 10 && candidateNames.some(isNumberFieldByName)) {
return { documensoType: FieldType.NUMBER };
}
if (candidateNames.some(isEmailFieldByName)) {
return { documensoType: FieldType.EMAIL };
}
if (candidateNames.some(isNameFieldByName)) {
return { documensoType: FieldType.NAME };
}
if (candidateNames.some(isInitialsFieldByName)) {
return { documensoType: FieldType.INITIALS };
}
return { documensoType: FieldType.TEXT };
};
const pickLabel = (field: FormFieldWithDict): string | undefined => {
// /TU is the human-facing tooltip/label; /T is the internal field identifier.
return field.alternateName || undefined;
};
type RotationDegrees = 0 | 90 | 180 | 270;
type RawRect = { x1: number; y1: number; x2: number; y2: number };
const getRectFromWidget = (widget: WidgetAnnotation): RawRect | null => {
const rect = widget.rect;
if (!rect || rect.length !== 4) {
return null;
}
const [x1, y1, x2, y2] = rect;
if (![x1, y1, x2, y2].every((v) => Number.isFinite(v))) {
return null;
}
return { x1, y1, x2, y2 };
};
const resolveGeometry = (
widget: WidgetAnnotation,
pageIndex: number,
page: PDFPage,
): { geometry: ResolvedGeometry | null; reason: AcroFormUnsupportedReason | null } => {
const rect = getRectFromWidget(widget);
if (!rect) {
return { geometry: null, reason: 'zero-size' };
}
const xL = Math.min(rect.x1, rect.x2);
const xR = Math.max(rect.x1, rect.x2);
const yB = Math.min(rect.y1, rect.y2);
const yT = Math.max(rect.y1, rect.y2);
if (xR - xL <= 0 || yT - yB <= 0) {
return { geometry: null, reason: 'zero-size' };
}
const mediaBox = page.getMediaBox();
const mediaW = mediaBox.width;
const mediaH = mediaBox.height;
const rotation = page.rotation as RotationDegrees;
// PDFPage.width / .height return rotation-adjusted dimensions, which is what
// we want for percent-based positioning relative to the rendered page.
const renderedW = page.width;
const renderedH = page.height;
let renderedX: number;
let renderedY: number;
let renderedFieldW: number;
let renderedFieldH: number;
if (rotation === 90) {
renderedX = yB;
renderedY = xL;
renderedFieldW = yT - yB;
renderedFieldH = xR - xL;
} else if (rotation === 180) {
renderedX = mediaW - xR;
renderedY = yB;
renderedFieldW = xR - xL;
renderedFieldH = yT - yB;
} else if (rotation === 270) {
renderedX = mediaH - yT;
renderedY = mediaW - xR;
renderedFieldW = yT - yB;
renderedFieldH = xR - xL;
} else {
renderedX = xL;
renderedY = mediaH - yT;
renderedFieldW = xR - xL;
renderedFieldH = yT - yB;
}
// Out-of-bounds: skip if the entire rect is outside the rendered page bounds.
const left = renderedX;
const right = renderedX + renderedFieldW;
const top = renderedY;
const bottom = renderedY + renderedFieldH;
if (right <= 0 || left >= renderedW || bottom <= 0 || top >= renderedH) {
return { geometry: null, reason: 'off-page' };
}
// Partial out-of-bounds: clamp.
const clampedLeft = Math.max(0, Math.min(left, renderedW));
const clampedRight = Math.max(0, Math.min(right, renderedW));
const clampedTop = Math.max(0, Math.min(top, renderedH));
const clampedBottom = Math.max(0, Math.min(bottom, renderedH));
const clampedW = clampedRight - clampedLeft;
const clampedH = clampedBottom - clampedTop;
if (clampedW <= 0 || clampedH <= 0) {
return { geometry: null, reason: 'off-page' };
}
return {
geometry: {
page: pageIndex + 1,
x: clampedLeft,
y: clampedTop,
width: clampedW,
height: clampedH,
pageWidth: renderedW,
pageHeight: renderedH,
},
reason: null,
};
};
const buildSignatureFieldAndMeta = (field: FormFieldWithDict): TFieldAndMeta => {
return ZEnvelopeFieldAndMetaSchema.parse({
type: FieldType.SIGNATURE,
fieldMeta: {
...FIELD_SIGNATURE_META_DEFAULT_VALUES,
required: field.isRequired() || undefined,
readOnly: field.isReadOnly() || undefined,
source: ACROFORM_FIELD_SOURCE,
},
});
};
const buildTextFieldAndMeta = (
field: FormFieldWithDict,
documensoType: ResolvedTextDocumensoType,
defaultText: string | undefined,
): TFieldAndMeta => {
const label = pickLabel(field);
const required = field.isRequired() || undefined;
const readOnly = field.isReadOnly() || undefined;
const defaultValue = defaultText && defaultText.length > 0 ? defaultText : undefined;
if (documensoType === FieldType.NUMBER) {
const fieldMeta: TNumberFieldMeta = {
...FIELD_NUMBER_META_DEFAULT_VALUES,
label: label ?? FIELD_NUMBER_META_DEFAULT_VALUES.label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
value: defaultValue,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: documensoType, fieldMeta });
}
if (documensoType === FieldType.TEXT) {
const fieldMeta: TTextFieldMeta = {
...FIELD_TEXT_META_DEFAULT_VALUES,
label: label ?? FIELD_TEXT_META_DEFAULT_VALUES.label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
text: defaultValue ?? FIELD_TEXT_META_DEFAULT_VALUES.text,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: documensoType, fieldMeta });
}
if (documensoType === FieldType.DATE) {
return ZEnvelopeFieldAndMetaSchema.parse({
type: documensoType,
fieldMeta: {
...FIELD_DATE_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
},
});
}
if (documensoType === FieldType.EMAIL) {
return ZEnvelopeFieldAndMetaSchema.parse({
type: documensoType,
fieldMeta: {
...FIELD_EMAIL_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
},
});
}
if (documensoType === FieldType.NAME) {
return ZEnvelopeFieldAndMetaSchema.parse({
type: documensoType,
fieldMeta: {
...FIELD_NAME_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
},
});
}
return ZEnvelopeFieldAndMetaSchema.parse({
type: documensoType,
fieldMeta: {
...FIELD_INITIALS_META_DEFAULT_VALUES,
label,
required,
readOnly,
source: ACROFORM_FIELD_SOURCE,
},
});
};
const buildCheckboxFieldAndMeta = (
field: FormFieldWithDict,
onValue: string | undefined,
isChecked: boolean,
): TFieldAndMeta => {
const required = field.isRequired();
const value = onValue && onValue.length > 0 ? onValue : 'Yes';
const fieldMeta: TCheckboxFieldMeta = {
...FIELD_CHECKBOX_META_DEFAULT_VALUES,
label: pickLabel(field) ?? FIELD_CHECKBOX_META_DEFAULT_VALUES.label,
required: required || undefined,
readOnly: field.isReadOnly() || undefined,
source: ACROFORM_FIELD_SOURCE,
values: [{ id: 1, checked: isChecked, value }],
validationRule: required ? 'at-least' : '',
validationLength: required ? 1 : 0,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.CHECKBOX, fieldMeta });
};
const buildRadioFieldAndMeta = (
field: FormFieldWithDict,
options: string[],
selectedValue: string | null,
widgetOnValue: string | null,
): TFieldAndMeta => {
const values =
options.length > 0
? options.map((value, index) => ({
id: index + 1,
checked: selectedValue !== null && value === selectedValue,
value,
}))
: [
{
id: 1,
checked: widgetOnValue !== null && widgetOnValue === selectedValue,
value: widgetOnValue ?? '',
},
];
const fieldMeta: TRadioFieldMeta = {
...FIELD_RADIO_META_DEFAULT_VALUES,
label: pickLabel(field) ?? '',
required: field.isRequired() || undefined,
readOnly: field.isReadOnly() || undefined,
source: ACROFORM_FIELD_SOURCE,
values,
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.RADIO, fieldMeta });
};
const buildDropdownFieldAndMeta = (
field: FormFieldWithDict,
options: string[],
defaultValue: string | undefined,
): TFieldAndMeta => {
const fieldMeta: TDropdownFieldMeta = {
...FIELD_DROPDOWN_META_DEFAULT_VALUES,
label: pickLabel(field) ?? '',
required: field.isRequired() || undefined,
readOnly: field.isReadOnly() || undefined,
source: ACROFORM_FIELD_SOURCE,
values: options.length > 0 ? options.map((value) => ({ value })) : FIELD_DROPDOWN_META_DEFAULT_VALUES.values,
defaultValue: defaultValue ?? '',
};
return ZEnvelopeFieldAndMetaSchema.parse({ type: FieldType.DROPDOWN, fieldMeta });
};
type WidgetWithPage = { widget: WidgetAnnotation; pageIndex: number; page: PDFPage };
const resolveWidgetPages = (
widgets: WidgetAnnotation[],
pageByRef: Map<PdfRef, { index: number; page: PDFPage }>,
): {
matched: WidgetWithPage[];
unmatched: WidgetAnnotation[];
} => {
const matched: WidgetWithPage[] = [];
const unmatched: WidgetAnnotation[] = [];
for (const widget of widgets) {
const pageRef = widget.pageRef;
const resolved = pageRef ? pageByRef.get(pageRef) : null;
if (!resolved) {
unmatched.push(widget);
continue;
}
matched.push({ widget, pageIndex: resolved.index, page: resolved.page });
}
return { matched, unmatched };
};
export type ExtractAcroFormOptions = {
/**
* When true, `insertFormValuesInPdf` already ran for this buffer. The
* extractor will not copy AcroForm default values into `fieldMeta` to
* avoid duplicating values that are already baked into the flattened PDF.
*/
formValuesProvided?: boolean;
};
/**
* Extract AcroForm fields from a PDF and convert them to Documenso field
* imports.
*
* Runs before flattening so widget geometry is still present in the buffer.
* Returns an empty result for non-AcroForm PDFs, encrypted PDFs, pure XFA forms
* with no AcroForm widgets, and on any internal error (with `skipReason` set so
* callers can log).
*/
export const extractAcroFormFieldsFromPDF = async (
pdf: Buffer,
options: ExtractAcroFormOptions = {},
): Promise<AcroFormExtractionResult> => {
try {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
if (pdfDoc.isEncrypted) {
return EMPTY_RESULT('encrypted');
}
const resolver = makeResolver(pdfDoc);
const hasXfaForm = hasXfa(pdfDoc, resolver);
const form = pdfDoc.getForm();
if (!form) {
return EMPTY_RESULT(hasXfaForm ? 'xfa-hybrid' : 'no-form');
}
const formFields = form.getFields();
if (hasXfaForm && formFields.length === 0) {
return EMPTY_RESULT('xfa-hybrid');
}
const pages = pdfDoc.getPages();
const pageByRef = new Map<PdfRef, { index: number; page: PDFPage }>();
pages.forEach((page, index) => {
pageByRef.set(page.ref, { index, page });
});
const fields: AcroFormFieldImportInfo[] = [];
const unsupported: AcroFormUnsupportedFieldInfo[] = [];
let hasSignedSignature = false;
const usePdfDefaults = !options.formValuesProvided;
const addUnsupported = (fieldName: string, acroFormType: string, reason: AcroFormUnsupportedReason): void => {
unsupported.push({ fieldName, acroFormType, reason });
};
for (const field of formFields) {
const acroFormType = field.type;
if (
acroFormType === 'listbox' ||
acroFormType === 'button' ||
acroFormType === 'unknown' ||
acroFormType === 'non-terminal'
) {
addUnsupported(field.name, acroFormType, 'unsupported-type');
continue;
}
// Signed signature widgets are skipped entirely and the caller is asked
// to keep the form intact (no flatten) so the signature stays valid.
if (acroFormType === 'signature') {
type SignatureFieldDuck = FormFieldWithDict & { isSigned(): boolean };
const sigField = field as unknown as SignatureFieldDuck;
if (typeof sigField.isSigned === 'function' && sigField.isSigned()) {
hasSignedSignature = true;
addUnsupported(field.name, acroFormType, 'signed-signature');
continue;
}
}
const formField = field as unknown as FormFieldWithDict;
const widgets = formField.getWidgets();
const { matched, unmatched } = resolveWidgetPages(widgets, pageByRef);
for (let i = 0; i < unmatched.length; i += 1) {
addUnsupported(field.name, acroFormType, 'no-page-match');
}
let widgetCounter = 0;
for (const { widget, pageIndex, page } of matched) {
if (widget.isHidden()) {
addUnsupported(field.name, acroFormType, 'hidden');
continue;
}
const { geometry, reason } = resolveGeometry(widget, pageIndex, page);
if (!geometry) {
addUnsupported(field.name, acroFormType, reason ?? 'zero-size');
continue;
}
let fieldAndMeta: TFieldAndMeta;
if (acroFormType === 'signature') {
fieldAndMeta = buildSignatureFieldAndMeta(formField);
} else if (acroFormType === 'text') {
type TextFieldDuck = FormFieldWithDict & {
getValue(): string;
getDefaultValue(): string;
};
const textField = field as unknown as TextFieldDuck;
const { documensoType } = resolveTextSubtype(formField, resolver);
const defaultText = usePdfDefaults ? textField.getValue?.() || textField.getDefaultValue?.() || '' : '';
fieldAndMeta = buildTextFieldAndMeta(formField, documensoType, defaultText);
} else if (acroFormType === 'checkbox') {
type CheckboxFieldDuck = FormFieldWithDict & {
isChecked(): boolean;
getOnValue(): string;
};
const checkbox = field as unknown as CheckboxFieldDuck;
const onValue = widget.getOnValue() ?? checkbox.getOnValue?.();
const checked = usePdfDefaults ? (checkbox.isChecked?.() ?? false) : false;
fieldAndMeta = buildCheckboxFieldAndMeta(formField, onValue ?? undefined, checked);
} else if (acroFormType === 'radio') {
type RadioFieldDuck = FormFieldWithDict & {
getOptions(): string[];
getValue(): string | null;
};
const radio = field as unknown as RadioFieldDuck;
const selectedValue = usePdfDefaults ? (radio.getValue?.() ?? null) : null;
fieldAndMeta = buildRadioFieldAndMeta(
formField,
radio.getOptions?.() ?? [],
selectedValue,
widget.getOnValue(),
);
} else if (acroFormType === 'dropdown') {
type DropdownFieldDuck = FormFieldWithDict & {
getOptions(): Array<{ value: string; display: string }>;
getValue(): string;
getDefaultValue(): string;
};
const dropdown = field as unknown as DropdownFieldDuck;
const rawOptions = dropdown.getOptions?.() ?? [];
const optionValues = rawOptions.map((opt) => opt.value);
const currentSelection = usePdfDefaults ? dropdown.getValue?.() || dropdown.getDefaultValue?.() || '' : '';
fieldAndMeta = buildDropdownFieldAndMeta(formField, optionValues, currentSelection || undefined);
} else {
addUnsupported(field.name, acroFormType, 'unsupported-type');
continue;
}
fields.push({
source: ACROFORM_FIELD_SOURCE,
fieldName: field.name,
widgetIndex: widgetCounter,
fieldAndMeta,
page: geometry.page,
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
pageWidth: geometry.pageWidth,
pageHeight: geometry.pageHeight,
});
widgetCounter += 1;
}
}
return {
fields,
unsupported,
hasSignedSignature,
};
} catch (err) {
logger.error({ event: 'acroform-import.error', err }, 'AcroForm extraction threw');
return EMPTY_RESULT('error');
}
};
const sortFieldsForCreate = (fields: AcroFormFieldImportInfo[]): AcroFormFieldImportInfo[] => {
return [...fields].sort((a, b) => {
if (a.page !== b.page) {
return a.page - b.page;
}
const aRowPercent = (a.y / a.pageHeight) * 100;
const bRowPercent = (b.y / b.pageHeight) * 100;
if (Math.abs(aRowPercent - bRowPercent) > ROW_TOLERANCE_PERCENT) {
return aRowPercent - bRowPercent;
}
return a.x - b.x;
});
};
/**
* Convert pre-extracted AcroForm fields to field creation inputs.
*
* Pure data transform — converts points to percentages and resolves the
* recipient via the provided callback. No DB calls.
*/
export const convertAcroFormFieldsToFieldInputs = (
fields: AcroFormFieldImportInfo[],
recipientResolver: (fieldName: string) => Pick<Recipient, 'id'>,
envelopeItemId?: string,
): FieldToCreate[] => {
return sortFieldsForCreate(fields).map((f) => {
const xPercent = (f.x / f.pageWidth) * 100;
const yPercent = (f.y / f.pageHeight) * 100;
const widthPercent = (f.width / f.pageWidth) * 100;
const heightPercent = (f.height / f.pageHeight) * 100;
const finalHeightPercent = heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
const recipient = recipientResolver(f.fieldName);
return {
...f.fieldAndMeta,
envelopeItemId,
recipientId: recipient.id,
page: f.page,
positionX: xPercent,
positionY: yPercent,
width: widthPercent,
height: finalHeightPercent,
};
});
};
@@ -101,6 +101,7 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
let authLevel = match(actionAuthMethod)
.with('ACCOUNT', () => i18n._(msg`Account Re-Authentication`))
.with('TWO_FACTOR_AUTH', () => i18n._(msg`Two-Factor Re-Authentication`))
.with('EXTERNAL_TWO_FACTOR_AUTH', () => i18n._(msg`External Two-Factor Re-Authentication`))
.with('PASSWORD', () => i18n._(msg`Password Re-Authentication`))
.with('PASSKEY', () => i18n._(msg`Passkey Re-Authentication`))
.with('EXPLICIT_NONE', () => i18n._(msg`Email`))
@@ -0,0 +1,105 @@
import { prisma } from '@documenso/prisma';
import { DocumentAuth } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
export type GetSigningTwoFactorStatusOptions = {
recipientId: number;
envelopeId: string;
sessionId: string;
};
export type SigningTwoFactorStatus = {
required: boolean;
hasActiveToken: boolean;
hasValidProof: boolean;
tokenExpiresAt: Date | null;
proofExpiresAt: Date | null;
attemptsRemaining: number | null;
};
const NOT_REQUIRED_STATUS: SigningTwoFactorStatus = {
required: false,
hasActiveToken: false,
hasValidProof: false,
tokenExpiresAt: null,
proofExpiresAt: null,
attemptsRemaining: null,
};
export const getSigningTwoFactorStatus = async ({
recipientId,
envelopeId,
sessionId,
}: GetSigningTwoFactorStatusOptions): Promise<SigningTwoFactorStatus> => {
const envelope = await prisma.envelope.findFirst({
where: { id: envelopeId },
select: {
authOptions: true,
recipients: {
where: { id: recipientId },
select: {
authOptions: true,
},
},
},
});
if (!envelope || envelope.recipients.length === 0) {
return NOT_REQUIRED_STATUS;
}
const [recipient] = envelope.recipients;
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
const required = derivedRecipientActionAuth.includes(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH);
if (!required) {
return NOT_REQUIRED_STATUS;
}
const now = new Date();
const [activeToken, validProof] = await Promise.all([
prisma.signingTwoFactorToken.findFirst({
where: {
recipientId,
envelopeId,
status: 'ACTIVE',
expiresAt: { gt: now },
},
orderBy: { createdAt: 'desc' },
select: {
expiresAt: true,
attempts: true,
attemptLimit: true,
},
}),
prisma.signingSessionTwoFactorProof.findFirst({
where: {
sessionId,
recipientId,
envelopeId,
expiresAt: { gt: now },
},
select: {
expiresAt: true,
},
}),
]);
return {
required: true,
hasActiveToken: !!activeToken,
hasValidProof: !!validProof,
tokenExpiresAt: activeToken?.expiresAt ?? null,
proofExpiresAt: validProof?.expiresAt ?? null,
attemptsRemaining: activeToken
? Math.max(0, activeToken.attemptLimit - activeToken.attempts)
: null,
};
};
@@ -0,0 +1,176 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { DocumentAuth } from '../../types/document-auth';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { generateSigningTwoFactorToken, generateTokenSalt, hashToken } from './token-utils';
const TOKEN_TTL_MINUTES = 10;
const DEFAULT_ATTEMPT_LIMIT = 5;
export const SIGNING_2FA_REASON_CODES = {
TWO_FA_NOT_REQUIRED: 'TWO_FA_NOT_REQUIRED',
TWO_FA_RECIPIENT_INELIGIBLE: 'TWO_FA_RECIPIENT_INELIGIBLE',
TWO_FA_ISSUER_FORBIDDEN: 'TWO_FA_ISSUER_FORBIDDEN',
} as const;
export type IssueSigningTwoFactorTokenOptions = {
recipientId: number;
envelopeId: string;
apiTokenId: number;
};
export const issueSigningTwoFactorToken = async ({
recipientId,
envelopeId,
apiTokenId,
}: IssueSigningTwoFactorTokenOptions) => {
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
type: EnvelopeType.DOCUMENT,
},
include: {
recipients: {
where: {
id: recipientId,
},
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
statusCode: 404,
});
}
if (envelope.status !== DocumentStatus.PENDING) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `Document must be in PENDING status`,
statusCode: 400,
});
}
if (envelope.recipients.length === 0) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found for this document',
statusCode: 404,
});
}
const [recipient] = envelope.recipients;
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
const requiresExternal2FA = derivedRecipientActionAuth.includes(
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
);
if (!requiresExternal2FA) {
await throwIssuanceDenied({
envelopeId,
recipient,
reasonCode: SIGNING_2FA_REASON_CODES.TWO_FA_NOT_REQUIRED,
});
}
if (recipient.signingStatus === 'SIGNED') {
await throwIssuanceDenied({
envelopeId,
recipient,
reasonCode: SIGNING_2FA_REASON_CODES.TWO_FA_RECIPIENT_INELIGIBLE,
});
}
const plaintextToken = generateSigningTwoFactorToken();
const salt = generateTokenSalt();
const tokenHash = hashToken(plaintextToken, salt);
const expiresAt = new Date(Date.now() + TOKEN_TTL_MINUTES * 60 * 1000);
const result = await prisma.$transaction(async (tx) => {
await tx.signingTwoFactorToken.updateMany({
where: {
recipientId,
envelopeId,
status: 'ACTIVE',
},
data: {
status: 'REVOKED',
revokedAt: new Date(),
},
});
const newToken = await tx.signingTwoFactorToken.create({
data: {
recipientId,
envelopeId,
tokenHash,
tokenSalt: salt,
expiresAt,
attemptLimit: DEFAULT_ATTEMPT_LIMIT,
issuedByApiTokenId: apiTokenId,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId: newToken.id,
},
}),
});
return newToken;
});
return {
token: plaintextToken,
tokenId: result.id,
expiresAt: result.expiresAt,
ttlSeconds: TOKEN_TTL_MINUTES * 60,
attemptLimit: result.attemptLimit,
issuedAt: result.createdAt,
};
};
const throwIssuanceDenied = async ({
envelopeId,
recipient,
reasonCode,
}: {
envelopeId: string;
recipient: { id: number; email: string; name: string | null };
reasonCode: string;
}) => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name ?? '',
reasonCode,
},
}),
});
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: reasonCode,
statusCode: 400,
});
};
@@ -0,0 +1,30 @@
import crypto from 'crypto';
const TOKEN_LENGTH = 6;
const SALT_LENGTH = 32;
const HASH_ITERATIONS = 100000;
const HASH_KEY_LENGTH = 64;
const HASH_DIGEST = 'sha512';
export const generateSigningTwoFactorToken = (): string => {
const bytes = crypto.randomBytes(4);
const num = bytes.readUInt32BE(0) % 10 ** TOKEN_LENGTH;
return num.toString().padStart(TOKEN_LENGTH, '0');
};
export const generateTokenSalt = (): string => {
return crypto.randomBytes(SALT_LENGTH).toString('hex');
};
export const hashToken = (token: string, salt: string): string => {
return crypto
.pbkdf2Sync(token, salt, HASH_ITERATIONS, HASH_KEY_LENGTH, HASH_DIGEST)
.toString('hex');
};
export const verifyTokenHash = (token: string, salt: string, expectedHash: string): boolean => {
const hash = hashToken(token, salt);
return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(expectedHash, 'hex'));
};
@@ -0,0 +1,245 @@
import { prisma } from '@documenso/prisma';
import { SIGNING_2FA_VERIFY_REASON_CODES } from '../../constants/document-auth';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { verifyTokenHash } from './token-utils';
export { SIGNING_2FA_VERIFY_REASON_CODES };
const PROOF_TTL_MINUTES = 10;
export type VerifySigningTwoFactorTokenOptions = {
recipientId: number;
envelopeId: string;
token: string;
sessionId: string;
};
export const verifySigningTwoFactorToken = async ({
recipientId,
envelopeId,
token: plaintextToken,
sessionId,
}: VerifySigningTwoFactorTokenOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
envelopeId,
},
select: {
id: true,
email: true,
name: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
statusCode: 404,
});
}
const activeToken = await prisma.signingTwoFactorToken.findFirst({
where: {
recipientId,
envelopeId,
status: 'ACTIVE',
},
orderBy: {
createdAt: 'desc',
},
});
if (!activeToken) {
await throwVerificationError({
envelopeId,
recipient,
tokenId: 'none',
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED,
attemptsUsed: 0,
attemptLimit: 0,
errorCode: AppErrorCode.INVALID_REQUEST,
statusCode: 400,
});
return;
}
if (activeToken.expiresAt < new Date()) {
await prisma.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
status: 'EXPIRED',
},
});
await throwVerificationError({
envelopeId,
recipient,
tokenId: activeToken.id,
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED,
attemptsUsed: activeToken.attempts,
attemptLimit: activeToken.attemptLimit,
errorCode: AppErrorCode.EXPIRED_CODE,
statusCode: 400,
});
return;
}
if (activeToken.attempts >= activeToken.attemptLimit) {
await prisma.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
status: 'REVOKED',
revokedAt: new Date(),
},
});
await throwVerificationError({
envelopeId,
recipient,
tokenId: activeToken.id,
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED,
attemptsUsed: activeToken.attempts,
attemptLimit: activeToken.attemptLimit,
errorCode: AppErrorCode.TOO_MANY_REQUESTS,
statusCode: 429,
});
return;
}
const isValid = verifyTokenHash(plaintextToken, activeToken.tokenSalt, activeToken.tokenHash);
if (!isValid) {
const updatedToken = await prisma.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
attempts: { increment: 1 },
},
});
await throwVerificationError({
envelopeId,
recipient,
tokenId: activeToken.id,
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_INVALID,
attemptsUsed: updatedToken.attempts,
attemptLimit: updatedToken.attemptLimit,
errorCode: AppErrorCode.INVALID_REQUEST,
statusCode: 400,
});
return;
}
const proofExpiresAt = new Date(Date.now() + PROOF_TTL_MINUTES * 60 * 1000);
const result = await prisma.$transaction(async (tx) => {
await tx.signingTwoFactorToken.update({
where: { id: activeToken.id },
data: {
status: 'CONSUMED',
consumedAt: new Date(),
attempts: { increment: 1 },
},
});
const proof = await tx.signingSessionTwoFactorProof.upsert({
where: {
sessionId_recipientId_envelopeId: {
sessionId,
recipientId,
envelopeId,
},
},
create: {
sessionId,
recipientId,
envelopeId,
expiresAt: proofExpiresAt,
},
update: {
verifiedAt: new Date(),
expiresAt: proofExpiresAt,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId: activeToken.id,
},
}),
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId: activeToken.id,
},
}),
});
return proof;
});
return {
verified: true,
proofId: result.id,
expiresAt: result.expiresAt,
};
};
type ThrowVerificationErrorOptions = {
envelopeId: string;
recipient: { id: number; email: string; name: string };
tokenId: string;
reasonCode: string;
attemptsUsed: number;
attemptLimit: number;
errorCode: AppErrorCode;
statusCode: number;
};
const throwVerificationError = async ({
envelopeId,
recipient,
tokenId,
reasonCode,
attemptsUsed,
attemptLimit,
errorCode,
statusCode,
}: ThrowVerificationErrorOptions): Promise<never> => {
await prisma.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED,
envelopeId,
data: {
recipientId: recipient.id,
recipientEmail: recipient.email,
recipientName: recipient.name,
tokenId,
reasonCode,
attemptsUsed,
attemptLimit,
},
}),
});
throw new AppError(errorCode, {
message: reasonCode,
statusCode,
});
};
+67
View File
@@ -54,6 +54,14 @@ 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.
// External signing 2FA events.
'EXTERNAL_2FA_TOKEN_ISSUED',
'EXTERNAL_2FA_TOKEN_ISSUE_DENIED',
'EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED',
'EXTERNAL_2FA_TOKEN_VERIFY_FAILED',
'EXTERNAL_2FA_TOKEN_CONSUMED',
'EXTERNAL_2FA_TOKEN_REVOKED',
]);
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
@@ -710,6 +718,59 @@ export const ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema = z.objec
}),
});
const ZExternal2FARecipientDataSchema = z.object({
recipientId: z.number(),
recipientEmail: z.string(),
recipientName: z.string(),
});
export const ZDocumentAuditLogEventExternal2FATokenIssuedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
reasonCode: z.string().optional(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED),
data: ZExternal2FARecipientDataSchema.extend({
reasonCode: z.string(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
reasonCode: z.string(),
attemptsUsed: z.number(),
attemptLimit: z.number(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenConsumedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
}),
});
export const ZDocumentAuditLogEventExternal2FATokenRevokedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_REVOKED),
data: ZExternal2FARecipientDataSchema.extend({
tokenId: z.string(),
reasonCode: z.string(),
}),
});
/**
* Event: Recipient's signing window expired.
*/
@@ -769,6 +830,12 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventExternal2FATokenIssuedSchema,
ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema,
ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema,
ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema,
ZDocumentAuditLogEventExternal2FATokenConsumedSchema,
ZDocumentAuditLogEventExternal2FATokenRevokedSchema,
ZDocumentAuditLogEventRecipientExpiredSchema,
]),
);
+23 -2
View File
@@ -5,7 +5,14 @@ import { ZAuthenticationResponseJSONSchema } from './webauthn';
/**
* All the available types of document authentication options for both access and action.
*/
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', 'TWO_FACTOR_AUTH', 'PASSWORD', 'EXPLICIT_NONE']);
export const ZDocumentAuthTypesSchema = z.enum([
'ACCOUNT',
'PASSKEY',
'TWO_FACTOR_AUTH',
'EXTERNAL_TWO_FACTOR_AUTH',
'PASSWORD',
'EXPLICIT_NONE',
]);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
@@ -34,6 +41,10 @@ const ZDocumentAuth2FASchema = z.object({
method: z.enum(['email', 'authenticator']).default('authenticator').optional(),
});
const ZDocumentAuthExternal2FASchema = z.object({
type: z.literal(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH),
});
/**
* All the document auth methods for both accessing and actioning.
*/
@@ -42,6 +53,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthExplicitNoneSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExternal2FASchema,
ZDocumentAuthPasswordSchema,
]);
@@ -67,10 +79,17 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExternal2FASchema,
ZDocumentAuthPasswordSchema,
]);
export const ZDocumentActionAuthTypesSchema = z
.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD])
.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
])
.describe(
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
);
@@ -97,6 +116,7 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExternal2FASchema,
ZDocumentAuthPasswordSchema,
ZDocumentAuthExplicitNoneSchema,
]);
@@ -105,6 +125,7 @@ export const ZRecipientActionAuthTypesSchema = z
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth.TWO_FACTOR_AUTH,
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
DocumentAuth.PASSWORD,
DocumentAuth.EXPLICIT_NONE,
])
-1
View File
@@ -72,7 +72,6 @@ export const ZBaseFieldMeta = z.object({
readOnly: z.boolean().optional(),
fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(),
overflow: ZFieldOverflowMode.optional(),
source: z.enum(['acroform']).optional(),
});
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
+7
View File
@@ -34,6 +34,8 @@ export const ZClaimFlagsSchema = z.object({
allowLegacyEnvelopes: z.boolean().optional(),
externalSigning2fa: z.boolean().optional(),
signingReminders: z.boolean().optional(),
});
@@ -102,6 +104,11 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
key: 'allowLegacyEnvelopes',
label: 'Allow Legacy Envelopes',
},
externalSigning2fa: {
key: 'externalSigning2fa',
label: 'External signing 2FA',
isEnterprise: true,
},
signingReminders: {
key: 'signingReminders',
label: 'Signing reminders',
+42
View File
@@ -597,6 +597,48 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
user: message,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED }, ({ data }) => {
const message = msg({
message: `External 2FA token issued for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED }, ({ data }) => {
const message = msg({
message: `External 2FA token issuance denied for recipient ${data.recipientEmail}: ${data.reasonCode}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED }, ({ data }) => {
const message = msg({
message: `External 2FA verification succeeded for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED }, ({ data }) => {
const message = msg({
message: `External 2FA verification failed for recipient ${data.recipientEmail}: ${data.reasonCode} (attempt ${data.attemptsUsed}/${data.attemptLimit})`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED }, ({ data }) => {
const message = msg({
message: `External 2FA token consumed for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_REVOKED }, ({ data }) => {
const message = msg({
message: `External 2FA token revoked for recipient ${data.recipientEmail}`,
context: `Audit log format`,
});
return { anonymous: message, you: message, user: message };
})
.exhaustive();
let selectedDescription = description.anonymous;
@@ -0,0 +1,60 @@
-- CreateEnum
CREATE TYPE "SigningTwoFactorTokenStatus" AS ENUM ('ACTIVE', 'CONSUMED', 'REVOKED', 'EXPIRED');
-- CreateTable
CREATE TABLE "SigningTwoFactorToken" (
"id" TEXT NOT NULL,
"recipientId" INTEGER NOT NULL,
"envelopeId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"tokenSalt" TEXT NOT NULL,
"status" "SigningTwoFactorTokenStatus" NOT NULL DEFAULT 'ACTIVE',
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"attempts" INTEGER NOT NULL DEFAULT 0,
"attemptLimit" INTEGER NOT NULL DEFAULT 5,
"issuedByApiTokenId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SigningTwoFactorToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SigningSessionTwoFactorProof" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"recipientId" INTEGER NOT NULL,
"envelopeId" TEXT NOT NULL,
"verifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SigningSessionTwoFactorProof_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SigningTwoFactorToken_recipientId_envelopeId_status_idx" ON "SigningTwoFactorToken"("recipientId", "envelopeId", "status");
-- CreateIndex
CREATE INDEX "SigningTwoFactorToken_envelopeId_idx" ON "SigningTwoFactorToken"("envelopeId");
-- CreateIndex
CREATE INDEX "SigningSessionTwoFactorProof_recipientId_envelopeId_idx" ON "SigningSessionTwoFactorProof"("recipientId", "envelopeId");
-- CreateIndex
CREATE INDEX "SigningSessionTwoFactorProof_expiresAt_idx" ON "SigningSessionTwoFactorProof"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "SigningSessionTwoFactorProof_sessionId_recipientId_envelope_key" ON "SigningSessionTwoFactorProof"("sessionId", "recipientId", "envelopeId");
-- AddForeignKey
ALTER TABLE "SigningTwoFactorToken" ADD CONSTRAINT "SigningTwoFactorToken_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SigningTwoFactorToken" ADD CONSTRAINT "SigningTwoFactorToken_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SigningSessionTwoFactorProof" ADD CONSTRAINT "SigningSessionTwoFactorProof_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SigningSessionTwoFactorProof" ADD CONSTRAINT "SigningSessionTwoFactorProof_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+58
View File
@@ -437,6 +437,9 @@ model Envelope {
envelopeAttachments EnvelopeAttachment[]
signingTwoFactorTokens SigningTwoFactorToken[]
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
@@index([type])
@@index([status])
@@index([userId])
@@ -604,6 +607,9 @@ model Recipient {
fields Field[]
signatures Signature[]
signingTwoFactorTokens SigningTwoFactorToken[]
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
@@index([token])
@@index([email])
@@index([envelopeId])
@@ -1115,6 +1121,58 @@ model Counter {
value Int
}
enum SigningTwoFactorTokenStatus {
ACTIVE
CONSUMED
REVOKED
EXPIRED
}
model SigningTwoFactorToken {
id String @id @default(cuid())
recipientId Int
envelopeId String
tokenHash String
tokenSalt String
status SigningTwoFactorTokenStatus @default(ACTIVE)
expiresAt DateTime
consumedAt DateTime?
revokedAt DateTime?
attempts Int @default(0)
attemptLimit Int @default(5)
issuedByApiTokenId Int?
createdAt DateTime @default(now())
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
@@index([recipientId, envelopeId, status])
@@index([envelopeId])
}
model SigningSessionTwoFactorProof {
id String @id @default(cuid())
sessionId String
recipientId Int
envelopeId String
verifiedAt DateTime @default(now())
expiresAt DateTime
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
@@unique([sessionId, recipientId, envelopeId])
@@index([recipientId, envelopeId])
@@index([expiresAt])
}
model RateLimit {
key String
action String
@@ -6,6 +6,7 @@ import { extractPdfPlaceholders } from '@documenso/lib/server-only/pdf/auto-plac
import { normalizePdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { EnvelopeType } from '@prisma/client';
import type { Logger } from 'pino';
import { insertFormValuesInPdf } from '../../../lib/server-only/pdf/insert-form-values-in-pdf';
@@ -119,7 +120,7 @@ export const createEnvelopeRouteCaller = async ({
}
const normalized = await normalizePdf(pdf, {
flattenForm: false,
flattenForm: type !== EnvelopeType.TEMPLATE,
});
// Todo: Embeds - Might need to add this for client-side embeds in the future.
@@ -1,68 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { UNSAFE_importAcroFormFieldsFromEnvelope } from '@documenso/lib/server-only/envelope-item/import-acroform-fields';
import { prisma } from '@documenso/prisma';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
import {
ZImportAcroFormFieldsRequestSchema,
ZImportAcroFormFieldsResponseSchema,
} from './import-acroform-fields.types';
export const importAcroFormFieldsRoute = authenticatedProcedure
.input(ZImportAcroFormFieldsRequestSchema)
.output(ZImportAcroFormFieldsResponseSchema)
.mutation(async ({ input, ctx }) => {
const { user, teamId, metadata } = ctx;
const { envelopeId } = input;
ctx.logger.info({ input: { envelopeId } });
const { envelopeWhereInput } = await getEnvelopeWhereInput({
id: { type: 'envelopeId', id: envelopeId },
type: EnvelopeType.DOCUMENT,
userId: user.id,
teamId,
});
const envelope = await prisma.envelope.findUnique({
where: envelopeWhereInput,
include: {
recipients: true,
envelopeItems: {
include: { documentData: true },
orderBy: { order: 'asc' },
},
},
});
if (!envelope) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Envelope not found',
});
}
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'AcroForm import is only supported for version 2 envelopes',
});
}
if (envelope.status !== DocumentStatus.DRAFT) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'AcroForm import is only allowed while the envelope is in draft',
});
}
if (envelope.envelopeItems.length === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Envelope has no items to import from',
});
}
return UNSAFE_importAcroFormFieldsFromEnvelope({
envelope,
apiRequestMetadata: metadata,
});
});
@@ -1,24 +0,0 @@
import { ZEnvelopeFieldSchema } from '@documenso/lib/types/field';
import { z } from 'zod';
export const ZImportAcroFormFieldsRequestSchema = z.object({
envelopeId: z.string(),
});
export const ZImportAcroFormFieldsResponseSchema = z.object({
itemsProcessed: z.number().int().min(0),
fieldsCreated: z.number().int().min(0),
unsupportedCount: z.number().int().min(0),
signedSignatureCount: z.number().int().min(0),
skippedItems: z.array(
z.object({
envelopeItemId: z.string(),
envelopeItemTitle: z.string(),
reason: z.enum(['encrypted', 'xfa-hybrid', 'no-form', 'error']),
}),
),
fields: z.array(ZEnvelopeFieldSchema),
});
export type TImportAcroFormFieldsRequest = z.infer<typeof ZImportAcroFormFieldsRequestSchema>;
export type TImportAcroFormFieldsResponse = z.infer<typeof ZImportAcroFormFieldsResponseSchema>;
@@ -28,13 +28,15 @@ import { getEnvelopeRoute } from './get-envelope';
import { getEnvelopeItemsRoute } from './get-envelope-items';
import { getEnvelopeItemsByTokenRoute } from './get-envelope-items-by-token';
import { getEnvelopesByIdsRoute } from './get-envelopes-by-ids';
import { importAcroFormFieldsRoute } from './import-acroform-fields';
import { redistributeEnvelopeRoute } from './redistribute-envelope';
import { replaceEnvelopeItemPdfRoute } from './replace-envelope-item-pdf';
import { saveAsTemplateRoute } from './save-as-template';
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
import { signEnvelopeFieldRoute } from './sign-envelope-field';
import { getSigningTwoFactorStatusRoute } from './signing-2fa/get-signing-two-factor-status';
import { issueSigningTwoFactorTokenRoute } from './signing-2fa/issue-signing-two-factor-token';
import { verifySigningTwoFactorTokenRoute } from './signing-2fa/verify-signing-two-factor-token';
import { signingStatusEnvelopeRoute } from './signing-status-envelope';
import { updateEnvelopeRoute } from './update-envelope';
import { updateEnvelopeItemsRoute } from './update-envelope-items';
@@ -76,7 +78,6 @@ export const envelopeRouter = router({
delete: deleteEnvelopeFieldRoute,
set: setEnvelopeFieldsRoute,
sign: signEnvelopeFieldRoute,
importFromPdf: importAcroFormFieldsRoute,
},
find: findEnvelopesRoute,
auditLog: {
@@ -99,5 +100,10 @@ export const envelopeRouter = router({
saveAsTemplate: saveAsTemplateRoute,
distribute: distributeEnvelopeRoute,
redistribute: redistributeEnvelopeRoute,
signing2fa: {
issue: issueSigningTwoFactorTokenRoute,
verify: verifySigningTwoFactorTokenRoute,
getStatus: getSigningTwoFactorStatusRoute,
},
signingStatus: signingStatusEnvelopeRoute,
});
@@ -176,6 +176,7 @@ export const signEnvelopeFieldRoute = procedure
field,
userId: user?.id,
authOptions,
recipientToken: token,
});
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
@@ -0,0 +1,45 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getSigningTwoFactorStatus } from '@documenso/lib/server-only/signing-2fa/get-signing-two-factor-status';
import { prisma } from '@documenso/prisma';
import { procedure } from '../../trpc';
import {
ZGetSigningTwoFactorStatusRequestSchema,
ZGetSigningTwoFactorStatusResponseSchema,
} from './get-signing-two-factor-status.types';
export const getSigningTwoFactorStatusRoute = procedure
.input(ZGetSigningTwoFactorStatusRequestSchema)
.output(ZGetSigningTwoFactorStatusResponseSchema)
.query(async ({ input, ctx }) => {
const { token } = input;
ctx.logger.info({
input: {
token: '***',
},
});
const recipient = await prisma.recipient.findFirst({
where: {
token,
},
select: {
id: true,
envelopeId: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
statusCode: 404,
});
}
return await getSigningTwoFactorStatus({
recipientId: recipient.id,
envelopeId: recipient.envelopeId,
sessionId: token,
});
});
@@ -0,0 +1,24 @@
import { z } from 'zod';
export const ZGetSigningTwoFactorStatusRequestSchema = z.object({
token: z.string().describe('The recipient signing token from the signing URL.'),
});
export const ZGetSigningTwoFactorStatusResponseSchema = z.object({
required: z.boolean().describe('Whether external 2FA is required for this recipient.'),
hasActiveToken: z.boolean().describe('Whether an active (unexpired) token exists.'),
hasValidProof: z.boolean().describe('Whether a valid session proof exists.'),
tokenExpiresAt: z.date().nullable().describe('When the active token expires, if any.'),
proofExpiresAt: z.date().nullable().describe('When the session proof expires, if any.'),
attemptsRemaining: z
.number()
.nullable()
.describe('Remaining verification attempts for the active token.'),
});
export type TGetSigningTwoFactorStatusRequest = z.infer<
typeof ZGetSigningTwoFactorStatusRequestSchema
>;
export type TGetSigningTwoFactorStatusResponse = z.infer<
typeof ZGetSigningTwoFactorStatusResponseSchema
>;
@@ -0,0 +1,53 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
import { issueSigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/issue-signing-two-factor-token';
import { authenticatedProcedure } from '../../trpc';
import {
ZIssueSigningTwoFactorTokenRequestSchema,
ZIssueSigningTwoFactorTokenResponseSchema,
issueSigningTwoFactorTokenMeta,
} from './issue-signing-two-factor-token.types';
export const issueSigningTwoFactorTokenRoute = authenticatedProcedure
.meta(issueSigningTwoFactorTokenMeta)
.input(ZIssueSigningTwoFactorTokenRequestSchema)
.output(ZIssueSigningTwoFactorTokenResponseSchema)
.mutation(async ({ input, ctx }) => {
const { envelopeId, recipientId } = input;
ctx.logger.info({
input: {
envelopeId,
recipientId,
},
});
const authorizationHeader = ctx.req.headers.get('authorization');
if (!authorizationHeader) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token required to issue signing 2FA tokens',
statusCode: 401,
});
}
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
if (!token) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'API token required to issue signing 2FA tokens',
statusCode: 401,
});
}
const apiToken = await getApiTokenByToken({ token });
const result = await issueSigningTwoFactorToken({
recipientId,
envelopeId,
apiTokenId: apiToken.id,
});
return result;
});
@@ -0,0 +1,35 @@
import { z } from 'zod';
import type { TrpcRouteMeta } from '../../trpc';
export const issueSigningTwoFactorTokenMeta: TrpcRouteMeta = {
openapi: {
method: 'POST',
path: '/envelope/signing-2fa/issue',
summary: 'Issue a signing 2FA token',
description:
'Issue a one-time signing two-factor authentication token for a recipient. The caller is responsible for delivering the token to the signer through their own channel (e.g., SMS).',
tags: ['Envelope'],
},
};
export const ZIssueSigningTwoFactorTokenRequestSchema = z.object({
envelopeId: z.string().describe('The ID of the envelope.'),
recipientId: z.number().describe('The ID of the recipient to issue the token for.'),
});
export const ZIssueSigningTwoFactorTokenResponseSchema = z.object({
token: z.string().describe('The plaintext one-time token. Visible exactly once.'),
tokenId: z.string().describe('The ID of the created token record.'),
expiresAt: z.date().describe('When the token expires.'),
ttlSeconds: z.number().describe('Token time-to-live in seconds.'),
attemptLimit: z.number().describe('Maximum verification attempts allowed.'),
issuedAt: z.date().describe('When the token was issued.'),
});
export type TIssueSigningTwoFactorTokenRequest = z.infer<
typeof ZIssueSigningTwoFactorTokenRequestSchema
>;
export type TIssueSigningTwoFactorTokenResponse = z.infer<
typeof ZIssueSigningTwoFactorTokenResponseSchema
>;
@@ -0,0 +1,51 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { verifySigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/verify-signing-two-factor-token';
import { prisma } from '@documenso/prisma';
import { procedure } from '../../trpc';
import {
ZVerifySigningTwoFactorTokenRequestSchema,
ZVerifySigningTwoFactorTokenResponseSchema,
} from './verify-signing-two-factor-token.types';
export const verifySigningTwoFactorTokenRoute = procedure
.input(ZVerifySigningTwoFactorTokenRequestSchema)
.output(ZVerifySigningTwoFactorTokenResponseSchema)
.mutation(async ({ input, ctx }) => {
const { token, code } = input;
ctx.logger.info({
input: {
token: '***',
},
});
const recipient = await prisma.recipient.findFirst({
where: {
token,
},
select: {
id: true,
envelopeId: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
statusCode: 404,
});
}
const result = await verifySigningTwoFactorToken({
recipientId: recipient.id,
envelopeId: recipient.envelopeId,
token: code,
sessionId: token,
});
return {
verified: result!.verified,
expiresAt: result!.expiresAt,
};
});
@@ -0,0 +1,23 @@
import { z } from 'zod';
export const ZVerifySigningTwoFactorTokenRequestSchema = z.object({
token: z.string().describe('The recipient signing token from the signing URL.'),
code: z
.string()
.min(6)
.max(6)
.regex(/^\d{6}$/)
.describe('The 6-digit one-time code to verify.'),
});
export const ZVerifySigningTwoFactorTokenResponseSchema = z.object({
verified: z.boolean().describe('Whether the code was successfully verified.'),
expiresAt: z.date().describe('When the session proof expires.'),
});
export type TVerifySigningTwoFactorTokenRequest = z.infer<
typeof ZVerifySigningTwoFactorTokenRequestSchema
>;
export type TVerifySigningTwoFactorTokenResponse = z.infer<
typeof ZVerifySigningTwoFactorTokenResponseSchema
>;