import pRetry, { AbortError } from 'p-retry'; import { env } from '@/lib/env'; import { CodedError } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { getPortDocumensoConfig, type DocumensoApiVersion } from '@/lib/services/port-config'; import { fetchWithTimeout, FetchTimeoutError } from '@/lib/fetch-with-timeout'; interface DocumensoCreds { baseUrl: string; apiKey: string; apiVersion: DocumensoApiVersion; } interface ResolvedCreds extends DocumensoCreds { /** Provenance of the API key - surfaces in error messages so an * operator can tell at a glance whether a 401 is the env fallback's * stale key vs. a per-port admin entry. */ apiKeySource: 'port' | 'global' | 'env' | 'default' | 'none'; apiUrlSource: 'port' | 'global' | 'env' | 'default' | 'none'; } async function resolveCreds(portId?: string): Promise { // env.DOCUMENSO_API_URL / env.DOCUMENSO_API_KEY are now optional - the // canonical config lives in admin settings. Empty fallbacks let the call // proceed; if both env + admin are blank, the downstream fetch hits an // empty URL and errors with a clear "Documenso not configured" upstream // (vs. crashing at type-check or boot). if (!portId) { return { baseUrl: env.DOCUMENSO_API_URL ?? '', apiKey: env.DOCUMENSO_API_KEY ?? '', apiVersion: env.DOCUMENSO_API_VERSION, apiKeySource: env.DOCUMENSO_API_KEY ? 'env' : 'none', apiUrlSource: env.DOCUMENSO_API_URL ? 'env' : 'none', }; } const cfg = await getPortDocumensoConfig(portId); return { baseUrl: cfg.apiUrl ?? '', apiKey: cfg.apiKey ?? '', apiVersion: cfg.apiVersion, apiKeySource: cfg.apiKeySource ?? (cfg.apiKey ? 'env' : 'none'), apiUrlSource: cfg.apiUrlSource ?? (cfg.apiUrl ? 'env' : 'none'), }; } async function documensoFetchOnce( path: string, options: RequestInit | undefined, portId: string | undefined, ): Promise { const { baseUrl, apiKey } = await resolveCreds(portId); let res: Response; try { res = await fetchWithTimeout(`${baseUrl}${path}`, { ...options, headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', ...options?.headers, }, }); } catch (err) { if (err instanceof FetchTimeoutError) { // Retry timeouts - transient network issue. throw new CodedError('DOCUMENSO_TIMEOUT', { internalMessage: `${path} timed out after ${err.timeoutMs}ms`, }); } throw err; } if (!res.ok) { const err = await res.text(); logger.error({ path, status: res.status, err, portId }, 'Documenso API error'); if (res.status === 401 || res.status === 403) { // Auth failures are not retryable - wrong key won't fix itself. // Surface the resolver source in the error message so the operator // sees "key resolved from env fallback" vs "per-port override" and // knows whether to edit the deploy env or the port admin row. const { apiKeySource, apiUrlSource } = await resolveCreds(portId); throw new AbortError( new CodedError('DOCUMENSO_AUTH_FAILURE', { internalMessage: `${path} → ${res.status} (api key source: ${apiKeySource}, api url source: ${apiUrlSource}, port: ${portId ?? 'global'})`, }), ); } if (res.status >= 400 && res.status < 500 && res.status !== 429) { // 4xx (other than 429) means we sent something Documenso rejected - // retrying won't help. 429 (rate-limit) goes through the retry path // with backoff so we politely re-attempt after delay. throw new AbortError( new CodedError('DOCUMENSO_UPSTREAM_ERROR', { internalMessage: `${path} → ${res.status}: ${err}`, }), ); } // 5xx + 429 → transient, retry. throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { internalMessage: `${path} → ${res.status}: ${err}`, }); } return res.json(); } /** * Wraps every Documenso call in p-retry: 3 attempts total (1 + 2 retries) * with exponential backoff (1s, 4s) + jitter. AbortError short-circuits * for auth failures and 4xx-not-429 - those will never succeed on retry. * * This recovers the "single connection blip drops the whole signing flow" * scenario the audit's services pass flagged. */ async function documensoFetch( path: string, options?: RequestInit, portId?: string, ): Promise { return pRetry(() => documensoFetchOnce(path, options, portId), { retries: 2, factor: 2, minTimeout: 1000, randomize: true, onFailedAttempt: (ctx) => { logger.warn( { path, portId, attempt: ctx.attemptNumber, retriesLeft: ctx.retriesLeft, err: ctx.error.message, }, 'Documenso fetch retry', ); }, }); } // Documenso 2.x has THREE potential ID fields on responses depending on the // endpoint: // - `envelopeId: string` - the public 'envelope_xxx' identifier. This is // what every downstream endpoint expects (/envelope/update, // /envelope/distribute, /envelope/{id}, DELETE etc). // - `documentId: number|string` - an alias on some responses. // - `id` - on /template/use this is the INTERNAL numeric // pk (e.g. 17). On other endpoints `id` is sometimes the envelope_xxx // string. On v1.13 `id` is the only field and represents the document. // // Resolution order: envelopeId (most reliable, v2-only) → documentId → // id. We coerce to string everywhere downstream. A previous version of // this normalizer used `documentId ?? id` which picked up the numeric // internal pk from /template/use, broke envelope/update + envelope/distribute // with "Invalid envelope ID", and silently failed every title-change + // distribute on freshly-created envelopes. function normalizeDocument(raw: unknown): DocumensoDocument { const r = (raw ?? {}) as Record; const id = String(r.envelopeId ?? r.documentId ?? r.id ?? ''); // Documenso v2 also exposes a numeric internal pk (`id`) alongside the // envelope_xxx string - webhooks ONLY carry the numeric id, so we // surface it separately so the webhook resolver can match by either. // For v1 responses `id` IS the (numeric) document id, so this is the // same value as `id` above. For v2 with envelopeId set, this captures // the internal pk that the webhook payload uses. const numericIdRaw = r.id; const numericId = typeof numericIdRaw === 'number' ? String(numericIdRaw) : typeof numericIdRaw === 'string' && /^\d+$/.test(numericIdRaw) ? numericIdRaw : null; const status = String(r.status ?? 'PENDING'); // v1.32+ payloads carry a `Recipient` (capital R) array as a legacy // duplicate of `recipients` - fall through to it so we still resolve // tokens / URLs when only the legacy field is populated. const recipientsRaw = (r.recipients as Array> | undefined) ?? (r.Recipient as Array> | undefined) ?? []; const recipients = recipientsRaw.map((rec) => { // Coalesce the two field names Documenso has used for rejection // reason across versions. Empty string → undefined so consumers // can `?? null`-fallback cleanly without re-checking truthiness. const rawReason = (rec.rejectionReason ?? rec.declineReason) as string | undefined; const rejectionReason = typeof rawReason === 'string' && rawReason.trim().length > 0 ? rawReason.trim() : undefined; return { id: String(rec.recipientId ?? rec.id ?? ''), name: String(rec.name ?? ''), email: String(rec.email ?? ''), role: String(rec.role ?? ''), signingOrder: Number(rec.signingOrder ?? 0), status: String(rec.signingStatus ?? rec.status ?? 'PENDING'), signingUrl: typeof rec.signingUrl === 'string' ? rec.signingUrl : undefined, embeddedUrl: typeof rec.embeddedUrl === 'string' ? rec.embeddedUrl : undefined, // Per-recipient signing token - required on the v1 Recipient model, // present on every v2 envelope-distribute response. Documenso uses // it as the URL tail (`/sign/`) so it also matches what we // see on subsequent webhook deliveries. token: typeof rec.token === 'string' ? rec.token : undefined, rejectionReason, }; }); return { id, numericId, status, recipients }; } export interface DocumensoRecipient { name: string; email: string; role: string; signingOrder: number; } export interface DocumensoDocument { id: string; /** Documenso v2 numeric internal pk. Populated alongside the * envelope_xxx `id` so callers can persist both - webhooks use this * one. Null when the response didn't include a numeric id. */ numericId: string | null; status: string; recipients: Array<{ id: string; name: string; email: string; role: string; signingOrder: number; status: string; signingUrl?: string; embeddedUrl?: string; /** v1 + v2 recipient token. Used to populate * `document_signers.signing_token` so the webhook handler can * match recipients without leaning on email (which may be reused * across roles). */ token?: string; /** Free-text rejection reason. v2 payloads use `rejectionReason`; * some 1.x payloads use the legacy `declineReason`. Coalesced * here so downstream poll/webhook consumers see one stable field. */ rejectionReason?: string; }>; } /** * When EMAIL_REDIRECT_TO is set (dev / staging), rewrite every recipient * email so Documenso doesn't accidentally email real clients during a * data import / migration dry-run. Names are prefixed with the original * email so the recipient (you) can tell who would have received the doc. * * In production this env var is unset and recipients flow through unchanged. */ function applyRecipientRedirect(recipients: DocumensoRecipient[]): DocumensoRecipient[] { if (!env.EMAIL_REDIRECT_TO) return recipients; return recipients.map((r) => ({ ...r, name: `${r.name} (was: ${r.email})`, email: env.EMAIL_REDIRECT_TO!, })); } /** * Same idea for the template-generate endpoint, which takes a payload * shape with recipient email/name nested inside `formValues` (Documenso * v1.13) or `recipients` (Documenso 2.x). We rewrite both shapes. */ function applyPayloadRedirect(payload: Record): Record { if (!env.EMAIL_REDIRECT_TO) return payload; const out: Record = { ...payload }; // 2.x recipient shape if (Array.isArray(out.recipients)) { out.recipients = (out.recipients as Array>).map((r) => ({ ...r, name: `${String(r.name ?? '')} (was: ${String(r.email ?? '')})`, email: env.EMAIL_REDIRECT_TO, })); } // v1.13 formValues shape - keys vary per template; key by anything that // looks like an email field. The conservative approach: only touch keys // that already hold a string and end with `Email` / `email`. if (out.formValues && typeof out.formValues === 'object') { const fv = { ...(out.formValues as Record) }; for (const key of Object.keys(fv)) { if (/email$/i.test(key) && typeof fv[key] === 'string') { fv[key] = env.EMAIL_REDIRECT_TO; } } out.formValues = fv; } return out; } /** * Optional metadata applied to the document on creation. v1 accepts * `redirectUrl` and `subject`/`message` on its `/documents` endpoint. * v2's `/envelope/create` accepts the same plus `signingOrder` for * PARALLEL-vs-SEQUENTIAL signing enforcement. */ export interface CreateDocumentMeta { subject?: string; message?: string; redirectUrl?: string; /** v2 only. v1 ignores. */ signingOrder?: 'PARALLEL' | 'SEQUENTIAL'; } export async function createDocument( title: string, pdfBase64: string, recipients: DocumensoRecipient[], portId?: string, meta?: CreateDocumentMeta, ): Promise { const safeRecipients = applyRecipientRedirect(recipients); if (env.EMAIL_REDIRECT_TO) { logger.info( { redirected: safeRecipients.length, original: recipients.map((r) => r.email) }, 'Documenso recipients redirected to EMAIL_REDIRECT_TO', ); } const { apiVersion } = await resolveCreds(portId); if (apiVersion === 'v2') { // v2: multipart /envelope/create with payload + files. Convert the // base64 PDF to a Buffer and ship it under `files`. Returns // `{ id: envelopeId }` only - caller distributes separately via // sendDocument(envelopeId). const { baseUrl, apiKey } = await resolveCreds(portId); const pdfBuffer = Buffer.from(pdfBase64, 'base64'); const form = new FormData(); const payload = { type: 'DOCUMENT', title, recipients: safeRecipients.map((r, i) => ({ email: r.email, name: r.name, role: r.role, signingOrder: r.signingOrder || i + 1, })), ...(meta ? { meta: { ...(meta.subject ? { subject: meta.subject } : {}), ...(meta.message ? { message: meta.message } : {}), ...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}), ...(meta.signingOrder ? { signingOrder: meta.signingOrder } : {}), }, } : {}), }; form.append('payload', JSON.stringify(payload)); form.append( 'files', new Blob([pdfBuffer], { type: 'application/pdf' }), `${title.replace(/[^a-z0-9-_]+/gi, '-')}.pdf`, ); let res: Response; try { res = await fetchWithTimeout(`${baseUrl}/api/v2/envelope/create`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, body: form, }); } catch (err) { if (err instanceof FetchTimeoutError) { throw new CodedError('DOCUMENSO_TIMEOUT', { internalMessage: `/api/v2/envelope/create timed out after ${err.timeoutMs}ms`, }); } throw err; } if (!res.ok) { const errText = await res.text(); logger.error( { status: res.status, err: errText, portId }, 'Documenso v2 envelope/create error', ); if (res.status === 401 || res.status === 403) { throw new CodedError('DOCUMENSO_AUTH_FAILURE', { internalMessage: `v2 envelope/create → ${res.status}`, }); } throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { internalMessage: `v2 envelope/create → ${res.status}: ${errText}`, }); } const created = (await res.json()) as Record; // v2 returns just `{ id }`. Re-fetch the full envelope so the // caller gets recipients (without signing URLs - those come after // distribute). Keeps shape identical to v1's createDocument response. const envelopeId = String(created.id ?? created.documentId ?? ''); return getDocument(envelopeId, portId); } // v1: existing path. Meta keys are accepted at the top level. return documensoFetch( '/api/v1/documents', { method: 'POST', body: JSON.stringify({ title, document: pdfBase64, recipients: safeRecipients, ...(meta?.subject || meta?.message || meta?.redirectUrl ? { meta: { ...(meta.subject ? { subject: meta.subject } : {}), ...(meta.message ? { message: meta.message } : {}), ...(meta.redirectUrl ? { redirectUrl: meta.redirectUrl } : {}), }, } : {}), }), }, portId, ).then(normalizeDocument); } export async function generateDocumentFromTemplate( templateId: number, payload: Record, portId?: string, ): Promise { const safePayload = applyPayloadRedirect(payload); const { apiVersion } = await resolveCreds(portId); if (env.EMAIL_REDIRECT_TO) { logger.info( { templateId, apiVersion }, 'Documenso template-generate payload redirected to EMAIL_REDIRECT_TO', ); } // v2 uses POST /api/v2/template/use with `prefillFields` keyed by field ID. // The payload builder emits `prefillFields` when a cached field name→ID map // exists for the port - see `buildDocumensoPayload` + `documenso-template- // sync.service.ts`. When no map is cached we still hit /template/use but // skip prefillFields (recipients-only); v2 instances ignore the legacy // `formValues` field, so emit it only on v1 paths. // // v1 (incl. Documenso 1.13.x) uses the legacy // /api/v1/templates/{id}/generate-document with `formValues` by name. if (apiVersion === 'v2') { const v2Payload = safePayload as Record; // Title PATCH must happen BEFORE distribution because Documenso v2 // restricts `envelope/update` to DRAFT envelopes only. So the v2 // flow is: 1) /template/use without distribute → DRAFT envelope, 2) // /envelope/update with the title, 3) /envelope/distribute → PENDING // envelope with signingUrls populated. Step 3 is REQUIRED because // v2 doesn't return signingUrls from /template/use - without it // `document_signers.signing_url` stays null and the manual // "Send invitation" button errors with "Signer has no Documenso URL". const created = await documensoFetch( `/api/v2/template/use`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...v2Payload, templateId }), }, portId, ).then(normalizeDocument); const desiredTitle = typeof v2Payload.title === 'string' && v2Payload.title.length > 0 ? v2Payload.title : null; // `/template/use` silently drops the `meta` field on the request body - // signingOrder, subject, message, redirectUrl all inherit from the // template's stored defaults. To enforce the per-port `documenso_signing_ // order` (PARALLEL vs SEQUENTIAL) and per-port subject/message, replay // the meta fields through `/envelope/update` while the envelope is still // DRAFT (update is rejected once distributed). const payloadMeta = (v2Payload.meta as Record | undefined) ?? {}; const updateMeta: Record = {}; for (const key of ['signingOrder', 'subject', 'message', 'redirectUrl', 'language'] as const) { const value = payloadMeta[key]; if (value !== undefined && value !== null && value !== '') { updateMeta[key] = value; } } const hasMetaPatch = Object.keys(updateMeta).length > 0; if (desiredTitle || hasMetaPatch) { try { const updateBody: Record = { envelopeId: created.id }; if (desiredTitle) updateBody.data = { title: desiredTitle }; if (hasMetaPatch) updateBody.meta = updateMeta; const updateResponse = await documensoFetch( `/api/v2/envelope/update`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updateBody), }, portId, ); // Log the raw response so we can debug when Documenso's UI keeps // showing the template PDF filename despite our update succeeding. // The update endpoint returns `{success: true}` on a clean ack; // anything else hints that the title field wasn't accepted. logger.info( { docId: created.id, desiredTitle, updateMeta, updateResponse }, 'Documenso envelope update - response', ); // Belt-and-braces verify: re-read the envelope and confirm the // title persisted. Documenso v2's listing surface has been known // to render the underlying PDF filename rather than envelope.title // - surfacing the actual returned `title` here lets us tell // whether the API accepted our value (and the UI is the issue) // vs the update silently no-op'd. try { const verify = (await documensoFetch( `/api/v2/envelope/${created.id}`, { method: 'GET' }, portId, )) as Record; logger.info( { docId: created.id, desiredTitle, actualTitle: verify?.title, titleMatches: verify?.title === desiredTitle, actualMeta: verify?.documentMeta ?? verify?.envelopeMeta ?? verify?.meta, }, 'Documenso envelope update - verification', ); } catch { // GET verify is best-effort; don't fail generate on it. } } catch (err) { logger.warn( { docId: created.id, updateMeta, err: err instanceof Error ? err.message : err }, 'Documenso envelope update failed - created envelope keeps template default title/meta', ); } } // Distribute the envelope so per-recipient signing URLs are minted. // Without this, the recipients returned by /template/use have // `signingUrl: null` and our "Send invitation" button errors out // with "Signer has no Documenso URL yet." // // Documenso v2's distribute fires its own emails by default, but // our payload sets `meta.distributionMethod: 'NONE'` so it just // mints the URLs without emailing - our branded // `sendSigningInvitation` is the dispatcher. // // We replace `created` with the distribute response because that's // the call that actually returns recipients with `signingUrl` // populated; downstream code (the document_signers insert in // generateAndSignViaDocumensoTemplate) reads from this object. // CRITICAL: pass `meta.distributionMethod: 'NONE'` in the distribute // body. `/template/use` doesn't accept a `meta` field at all - our // payload's `meta.distributionMethod: 'NONE'` is silently dropped at // template-use time, so the envelope inherits the TEMPLATE's // distributionMethod (which defaults to EMAIL). Without overriding // it on the distribute call, Documenso fires its own emails the // moment distribute runs - which clashes with our branded // `sendSigningInvitation` flow and ignores the per-port // `eoi_send_mode: 'manual'` setting. The override here is the // authoritative one for v2 envelopes. try { const distributed = (await documensoFetch( `/api/v2/envelope/distribute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ envelopeId: created.id, meta: { distributionMethod: 'NONE' }, }), }, portId, )) as Record; const normalized = normalizeDocument({ envelopeId: distributed.id ?? created.id, // Distribute doesn't return the numeric id, so we synthesize it // from the original /template/use response by passing the numeric // id as Documenso's `id` field - normalizeDocument picks it up // as numericId. Without this, the row would lose its numeric id // on distribute and webhooks couldn't resolve back to it. id: created.numericId, status: 'PENDING', recipients: distributed.recipients, }); return normalized; } catch (err) { logger.warn( { docId: created.id, err: err instanceof Error ? err.message : err }, 'Documenso envelope distribute failed - signingUrl will be null. Send-invitation will fail until the envelope is distributed.', ); return created; } } return documensoFetch( `/api/v1/templates/${templateId}/generate-document`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(safePayload), }, portId, ).then(normalizeDocument); } /** * Tell Documenso to actually email the document to its recipients. The * recipients themselves are set at create-time (and rerouted to * EMAIL_REDIRECT_TO when set), but this is a belt-and-braces guard for * documents that may have been created BEFORE the redirect was turned on * (i.e. real-recipient documents now triggered by an automation while * we're trying to hold comms). When the redirect is on we skip the API * call entirely and return a synthetic "still pending" response. */ /** * v2-only: distribute an envelope. Moves it from DRAFT → PENDING and * mints per-recipient signing URLs. Does NOT email recipients when the * envelope's `meta.distributionMethod` is `NONE` (our default - branded * emails are dispatched by `sendSigningInvitation`). * * Direct call bypassing `sendDocument`'s dev-mode short-circuit. The * self-heal path for envelopes created before the auto-distribute fix * shipped uses this so the URLs actually get minted in dev too - * `EMAIL_REDIRECT_TO` already rewrites recipient emails to a safe * address at envelope-creation time, so distribute can't accidentally * email a real client. */ export async function distributeEnvelopeV2( envelopeId: string, portId?: string, ): Promise { // Architectural rule (Matt 2026-05-15): ALL outbound emails go through // our branded `sendSigningInvitation` path - Documenso never fires its // own emails for our envelopes. `meta.distributionMethod: 'NONE'` // here is the ONLY place where this contract is actually enforced // for v2 envelopes (the corresponding flag in /template/use is // silently dropped because that endpoint doesn't accept a meta field). const distributed = (await documensoFetch( `/api/v2/envelope/distribute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ envelopeId, meta: { distributionMethod: 'NONE' }, }), }, portId, )) as Record; return normalizeDocument({ id: distributed.id ?? envelopeId, status: 'PENDING', recipients: distributed.recipients, }); } export async function sendDocument(docId: string, portId?: string): Promise { if (env.EMAIL_REDIRECT_TO) { logger.warn( { docId, portId, redirect: env.EMAIL_REDIRECT_TO }, 'sendDocument SKIPPED - EMAIL_REDIRECT_TO is set, outbound comms paused', ); // Return the existing doc shape so downstream code doesn't see an // unexpected null. The document remains in DRAFT/PENDING from // Documenso's perspective. return getDocument(docId, portId); } const { apiVersion } = await resolveCreds(portId); if (apiVersion === 'v2') { // v2: POST /api/v2/envelope/distribute with body { envelopeId }. // Returns the envelope with per-recipient signingUrl fields populated - // this is one of the genuine v2 wins (saves a separate GET round-trip). // `meta.distributionMethod: 'NONE'` is the authoritative way to suppress // Documenso's own emails for v2 envelopes - see distributeEnvelopeV2 // for the full rationale. Branded sends are routed through // `sendSigningInvitation` separately. const distributed = (await documensoFetch( '/api/v2/envelope/distribute', { method: 'POST', body: JSON.stringify({ envelopeId: docId, meta: { distributionMethod: 'NONE' }, }), }, portId, )) as Record; // Distribute response shape: { success, id, recipients: [...] }. // The recipients carry name/email/token/role/signingOrder/signingUrl. // Normalize by re-wrapping into the document shape that downstream // callers already consume. return normalizeDocument({ id: distributed.id, // v2 doesn't return `status` on the distribute response - the call // itself moves the envelope from DRAFT to PENDING, so PENDING is // the correct authoritative state. status: 'PENDING', recipients: distributed.recipients, }); } return documensoFetch( `/api/v1/documents/${docId}/send`, { method: 'POST', }, portId, ).then(normalizeDocument); } export async function getDocument(docId: string, portId?: string): Promise { const { apiVersion } = await resolveCreds(portId); // v1: GET /api/v1/documents/{id} // v2: GET /api/v2/envelope/{id} - same response normalizer (id ↔ documentId, // recipientId ↔ id handled by normalizeDocument). const path = apiVersion === 'v2' ? `/api/v2/envelope/${docId}` : `/api/v1/documents/${docId}`; return documensoFetch(path, undefined, portId).then(normalizeDocument); } // ─── Template introspection ───────────────────────────────────────────────── export interface DocumensoTemplateRecipient { id: number; role: string; // 'SIGNER' | 'APPROVER' | 'CC' | 'VIEWER' signingOrder: number; name?: string; email?: string; } export interface DocumensoTemplateField { id: number; type: string; /** * The human label assigned in the template editor - for v2 templates this * comes from `field.fieldMeta.label`; for v1 templates it's available as * `field.fieldMeta.label` too (the shape was preserved). Used as the key * for the cached field-name → ID map that drives v2's `prefillFields`. */ label?: string; } export interface DocumensoTemplate { id: number; title: string; recipients: DocumensoTemplateRecipient[]; fields: DocumensoTemplateField[]; /** * v2 only. Each entry corresponds to one underlying PDF file on the * template - usually a single envelope item per template, but Documenso * 2.x supports stitching multiple PDFs together. Used by the sync flow * to download each PDF and inspect its native AcroForm fields. */ envelopeItems: Array<{ id: string }>; /** * v2 only. The template's stored meta - signing order, distribution * method, redirect URL. Surfaced in the sync report so the admin can * confirm what the template itself is configured to do at envelope * creation time. /template/use does NOT accept a signingOrder override, * so these values are what every generated envelope inherits. */ meta: { signingOrder: 'PARALLEL' | 'SEQUENTIAL' | null; distributionMethod: 'EMAIL' | 'NONE' | null; redirectUrl: string | null; }; } function normalizeTemplate(raw: unknown): DocumensoTemplate { const r = (raw ?? {}) as Record; const id = Number(r.templateId ?? r.id ?? 0); const title = String(r.title ?? ''); const recipientsRaw = (r.recipients as Array> | undefined) ?? (r.Recipient as Array> | undefined) ?? []; const recipients: DocumensoTemplateRecipient[] = recipientsRaw.map((rec) => ({ id: Number(rec.recipientId ?? rec.id ?? 0), role: String(rec.role ?? 'SIGNER'), signingOrder: Number(rec.signingOrder ?? 0), name: typeof rec.name === 'string' ? rec.name : undefined, email: typeof rec.email === 'string' ? rec.email : undefined, })); const fieldsRaw = (r.fields as Array> | undefined) ?? []; const fields: DocumensoTemplateField[] = fieldsRaw.map((f) => { const fieldMeta = (f.fieldMeta as Record | undefined) ?? {}; return { id: Number(f.id ?? 0), type: String(f.type ?? ''), label: typeof fieldMeta.label === 'string' ? fieldMeta.label : undefined, }; }); const itemsRaw = (r.envelopeItems as Array> | undefined) ?? []; const envelopeItems = itemsRaw .map((it) => ({ id: typeof it.id === 'string' ? it.id : '' })) .filter((it) => it.id); // templateMeta on v2 carries the signing order + distribution method + // post-sign redirect. v1 templates put the same data on the doc root, so // try both shapes. const metaRaw = (r.templateMeta as Record | undefined) ?? (r as Record); const signingOrderRaw = metaRaw.signingOrder; const distributionRaw = metaRaw.distributionMethod; const redirectRaw = metaRaw.redirectUrl; const meta: DocumensoTemplate['meta'] = { signingOrder: signingOrderRaw === 'PARALLEL' || signingOrderRaw === 'SEQUENTIAL' ? signingOrderRaw : null, distributionMethod: distributionRaw === 'EMAIL' || distributionRaw === 'NONE' ? distributionRaw : null, redirectUrl: typeof redirectRaw === 'string' && redirectRaw ? redirectRaw : null, }; return { id, title, recipients, fields, envelopeItems, meta }; } /** * v2-only: download the raw PDF bytes for one envelope-item (each template * is backed by 1+ envelope items, one per uploaded PDF). The sync flow * uses this to inspect the PDF's AcroForm field names, surfacing whether * the operator's fillable PDF matches the CRM's expected field-label set. * * v1 templates aren't supported here - the v1 download endpoint requires * a documentId, not a templateId, and v1 doesn't expose envelope items. */ export async function downloadEnvelopeItemPdf( envelopeItemId: string, portId?: string, version: 'signed' | 'original' = 'original', ): Promise { const { baseUrl, apiKey } = await resolveCreds(portId); const res = await fetchWithTimeout( `${baseUrl}/api/v2/envelope/item/${envelopeItemId}/download?version=${version}`, { headers: { Authorization: `Bearer ${apiKey}` } }, ); if (!res.ok) { const errText = await res.text().catch(() => ''); throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { internalMessage: `download envelope item ${envelopeItemId} → ${res.status} ${errText}`, }); } return Buffer.from(await res.arrayBuffer()); } /** * List every template visible to the configured API key. Used by the * admin "Documenso-first templates" picker (R62) so reps can browse * available templates instead of typing numeric IDs. * * v2 path: paginated `GET /api/v2/template`. Returns 100 per page; we * walk through pages until empty (cap at 5 pages = 500 templates which * comfortably covers every observed Documenso instance). * * v1 path: `GET /api/v1/templates`. Same pagination via * `?page=N&perPage=100`. v1 returns the legacy shape - we normalize to * the same `{ id, name }` summary the UI consumes. */ export async function listTemplates( portId: string | undefined, ): Promise> { const { apiVersion } = await resolveCreds(portId); const out: Array<{ id: number; name: string }> = []; for (let page = 1; page <= 5; page++) { const path = apiVersion === 'v2' ? `/api/v2/template?perPage=100&page=${page}` : `/api/v1/templates?perPage=100&page=${page}`; const res = (await documensoFetch(path, undefined, portId)) as { templates?: Array<{ id?: number; templateId?: number; name?: string; title?: string }>; data?: Array<{ id?: number; templateId?: number; name?: string; title?: string }>; }; const items = res.templates ?? res.data ?? []; if (items.length === 0) break; for (const t of items) { const id = t.templateId ?? t.id; const name = t.name ?? t.title ?? ''; if (typeof id === 'number') out.push({ id, name }); } if (items.length < 100) break; } return out; } /** * Fetch a Documenso template by ID. Used by the admin "Sync from Documenso" * flow to discover recipient slot IDs + template field IDs without forcing * the operator to type them in by hand. * * - v2 path: `GET /api/v2/template/{templateId}` (returns envelope-shape JSON) * - v1 path: `GET /api/v1/templates/{templateId}` (returns legacy doc shape; * recipient + field arrays are present but with `id` instead of * `recipientId`/`templateId`). */ export async function getTemplate(templateId: number, portId?: string): Promise { const { apiVersion } = await resolveCreds(portId); const path = apiVersion === 'v2' ? `/api/v2/template/${templateId}` : `/api/v1/templates/${templateId}`; return documensoFetch(path, undefined, portId).then(normalizeTemplate); } /** * v2-only: resolve a Documenso template by its envelope ID (the * `envelope_xxxxxxxx` string that appears in the Documenso UI URL). The * admin pastes that URL slug into the Sync input and we look up the * matching numeric template id via `GET /api/v2/template`. Returns null * when no template matches. * * Documenso 2.x's template editor URL is * `https://.../templates/envelope_xxxxxxxx`, but the numeric `id` is not * surfaced anywhere in the UI - so admins have no way to enter the * numeric id by hand. This resolver bridges the gap. */ export async function findTemplateIdByEnvelopeId( envelopeId: string, portId?: string, ): Promise { const { apiVersion } = await resolveCreds(portId); if (apiVersion !== 'v2') return null; // Paginate through templates (perPage maxes at ~100 on the upstream). // Most installs have <50 templates so the first page is usually enough. let page = 1; const perPage = 100; while (page < 20) { const res = (await documensoFetch( `/api/v2/template?page=${page}&perPage=${perPage}`, undefined, portId, )) as { data?: Array>; templates?: Array> }; const rows = res.data ?? res.templates ?? []; if (!Array.isArray(rows) || rows.length === 0) return null; for (const row of rows) { const rowEnvelopeId = String(row.envelopeId ?? ''); if (rowEnvelopeId === envelopeId) { const numericId = Number(row.id ?? 0); return Number.isInteger(numericId) && numericId > 0 ? numericId : null; } } if (rows.length < perPage) return null; page += 1; } return null; } /** * Email a signing reminder to one recipient. Skipped entirely when * EMAIL_REDIRECT_TO is set - the recipient's stored email may still be * a real client address from before the redirect was enabled. */ export async function sendReminder( docId: string, signerId: string, portId?: string, ): Promise { if (env.EMAIL_REDIRECT_TO) { logger.warn( { docId, signerId, portId, redirect: env.EMAIL_REDIRECT_TO }, 'sendReminder SKIPPED - EMAIL_REDIRECT_TO is set, outbound comms paused', ); return; } const { apiVersion } = await resolveCreds(portId); if (apiVersion === 'v2') { // v2 sends reminders via redistribute. Documenso 2.x doesn't expose a // recipient-targeted reminder endpoint directly; instead /envelope/redistribute // resends to all pending recipients on the envelope. Single-recipient // targeting requires admin-side filtering. For now we redistribute the // entire envelope, which is functionally equivalent for the typical // case (most reminders go to the one outstanding signer). await documensoFetch( '/api/v2/envelope/redistribute', { method: 'POST', body: JSON.stringify({ envelopeId: docId, recipientIds: [signerId] }), }, portId, ); return; } await documensoFetch( `/api/v1/documents/${docId}/recipients/${signerId}/remind`, { method: 'POST', }, portId, ); } export async function downloadSignedPdf(docId: string, portId?: string): Promise { const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId); // v2 download is a two-step lookup: there's no /envelope/{id}/download path // (that 404s - see audit-2026-05-15). The canonical flow is: // 1. GET /envelope/{id} → read envelopeItems[0].id // 2. GET /envelope/item/{itemId}/download?version=signed // v1 keeps the direct /documents/{id}/download single-call path. if (apiVersion === 'v2') { const envelope = (await documensoFetch( `/api/v2/envelope/${docId}`, { method: 'GET' }, portId, )) as { envelopeItems?: Array<{ id?: string }> }; const itemId = envelope.envelopeItems?.[0]?.id; if (!itemId) { throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { internalMessage: `v2 envelope ${docId} has no envelopeItems - cannot download signed PDF`, }); } return downloadEnvelopeItemPdf(itemId, portId, 'signed'); } const path = `/api/v1/documents/${docId}/download`; let res: Response; try { res = await fetchWithTimeout(`${baseUrl}${path}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); } catch (err) { if (err instanceof FetchTimeoutError) { throw new CodedError('DOCUMENSO_TIMEOUT', { internalMessage: `${path} timed out after ${err.timeoutMs}ms`, }); } throw err; } if (!res.ok) { const err = await res.text(); logger.error({ docId, status: res.status, err, portId }, 'Documenso download error'); if (res.status === 401 || res.status === 403) { throw new CodedError('DOCUMENSO_AUTH_FAILURE', { internalMessage: `${path} → ${res.status}`, }); } throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { internalMessage: `${path} → ${res.status}: ${err}`, }); } const arrayBuffer = await res.arrayBuffer(); return Buffer.from(arrayBuffer); } /** Convenience health-check used by the admin "Test connection" button. * * v2 cloud (Documenso 2.x) doesn't expose `/api/v1/health` - the old v1 * path is gone. So we probe the appropriate cheap list endpoint per * version (`GET /api/v2/document` for v2, `GET /api/v1/health` for v1) * and treat a 401 as "creds rejected" and a 200 as "all good". A 404 * means the URL points at something that isn't Documenso. */ export async function checkDocumensoHealth( portId?: string, ): Promise<{ ok: boolean; status?: number; error?: string; apiVersion?: DocumensoApiVersion }> { try { const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId); const path = apiVersion === 'v2' ? '/api/v2/document' : '/api/v1/health'; const res = await fetchWithTimeout(`${baseUrl}${path}`, { headers: { Authorization: `Bearer ${apiKey}` }, }); // 2xx = full success; 401/403 = creds wrong but URL right (still a // partial-success signal - the admin should know it's an auth issue, // not a typoed URL). 404 = wrong URL. return { ok: res.ok, status: res.status, apiVersion }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' }; } } // ─── Version-aware abstractions (Phase A PR2) ───────────────────────────────── // // Documenso v1.13 and v2.x diverge on field placement and document deletion: // // v1.13: per-field POST /api/v1/documents/{id}/fields with PIXEL coords; // DELETE /api/v1/documents/{id} for void. // v2.x: bulk POST /api/v2/envelope/field/create-many with PERCENT // coords (0-100) and rich `fieldMeta`; // DELETE /api/v2/envelope/{id} for void. // // Callers always work in PERCENT (0-100). For v1 the abstraction multiplies by // the page dimensions returned by Documenso (cached per docId for the lifetime // of the process - fields for a given doc usually go in a single batch). /** * Every field type Documenso supports across v1 and v2. The earlier * subset (SIGNATURE/INITIALS/DATE/TEXT/EMAIL) covered the EOI flow's * needs but locks out custom-uploaded contracts/reservations that * may need checkboxes (e.g. "Lease vs Purchase"), dropdowns (e.g. * "Berth class A/B/C"), or radio groups. Extending now so the * field-placement UI can surface the full palette without later * widening this type and patching every call site. * * Per-type fieldMeta expectations (passed through verbatim): * - SIGNATURE / FREE_SIGNATURE / INITIALS / DATE / EMAIL / NAME - no meta * - TEXT - { text?: string, label?: string, required?: bool, readOnly?: bool } * - NUMBER - { numberFormat?: string, min?: number, max?: number, required?: bool } * - CHECKBOX - { values: Array<{ checked: bool, value: string }>, validationRule?: string } * - DROPDOWN - { values: Array<{ value: string }>, defaultValue?: string } * - RADIO - { values: Array<{ checked: bool, value: string }> } * * `fieldMeta` is sent verbatim to v2's create-many endpoint and * silently ignored by v1 (which doesn't accept the property). v1 * rendering of TEXT/NUMBER/CHECKBOX/DROPDOWN/RADIO falls back to * blank-input behaviour without the meta. */ export type DocumensoFieldType = | 'SIGNATURE' | 'FREE_SIGNATURE' | 'INITIALS' | 'DATE' | 'EMAIL' | 'NAME' | 'TEXT' | 'NUMBER' | 'CHECKBOX' | 'DROPDOWN' | 'RADIO'; /** * Typed metadata shapes per field type - surfaces what fieldMeta * actually carries in well-known cases. Used by the field-placement * UI to render the right config form per field type. Pass-through to * Documenso retains the loose `Record` shape so we * can ship without locking down every property. */ export interface DocumensoTextFieldMeta { text?: string; label?: string; required?: boolean; readOnly?: boolean; } export interface DocumensoNumberFieldMeta { numberFormat?: string; min?: number; max?: number; required?: boolean; } export interface DocumensoChoiceOption { value: string; /** Whether the option is pre-selected. Applies to checkbox + radio. */ checked?: boolean; } export interface DocumensoChoiceFieldMeta { values: DocumensoChoiceOption[]; defaultValue?: string; validationRule?: string; } /** * Returns true when this field type expects a fieldMeta payload from * the placement UI (so the UI can prompt the rep to configure * options, defaults, validation, etc). Field types not in this list * carry no per-instance configuration beyond position + recipient. */ export function fieldTypeNeedsMeta(type: DocumensoFieldType): boolean { return ( type === 'TEXT' || type === 'NUMBER' || type === 'CHECKBOX' || type === 'DROPDOWN' || type === 'RADIO' ); } export interface DocumensoFieldPlacement { /** Documenso recipient id; v1 expects number, v2 string - coerced internally. */ recipientId: number | string; type: DocumensoFieldType; pageNumber: number; /** All four are 0-100 percent of page dimensions. */ pageX: number; pageY: number; pageWidth: number; pageHeight: number; /** Optional v2 fieldMeta - passed through verbatim, ignored on v1. */ fieldMeta?: Record; } export interface DocumensoPageDimensions { width: number; height: number; } const DEFAULT_PAGE_DIMENSIONS: DocumensoPageDimensions = { width: 595, height: 842 }; // A4 pt const pageDimensionCache = new Map(); /** Test seam - clears the page-dimension memoization. */ export function __resetDocumensoCachesForTests(): void { pageDimensionCache.clear(); } async function getPageDimensions(docId: string, portId?: string): Promise { const cached = pageDimensionCache.get(docId); if (cached) return cached; // v1 doesn't expose page dimensions cleanly via the public API; the auto- // placement use case is footer-anchored signature fields, where a default A4 // page rendered by Documenso is a safe assumption. Real page dims can be // wired in a follow-up by parsing the document/document-data endpoints. void portId; pageDimensionCache.set(docId, DEFAULT_PAGE_DIMENSIONS); return DEFAULT_PAGE_DIMENSIONS; } /** * Place one or more fields on a Documenso document. Coordinates are PERCENT * (0-100) and converted to pixels for v1 internally. * * v1: dispatches one POST per field (no bulk endpoint). * v2: single bulk POST. */ export async function placeFields( docId: string, fields: DocumensoFieldPlacement[], portId?: string, ): Promise { if (fields.length === 0) return; const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId); if (apiVersion === 'v2') { // Documenso v2 field schema (confirmed against live trpc error // responses, 2026-05-22): // - `recipientId` must be a NUMBER (the v1 client returns it // stringified; we coerce here so callers can pass either form) // - the page index field is named `page`, NOT `pageNumber` (v1's // key) - wrong key surfaces as Zod "Required at data[i].page" // - `positionX/Y` + `width/height` carry percent values (0-100) const v2Fields = fields.map((f) => ({ recipientId: Number(f.recipientId), type: f.type, page: f.pageNumber, positionX: f.pageX, positionY: f.pageY, width: f.pageWidth, height: f.pageHeight, ...(f.fieldMeta ? { fieldMeta: f.fieldMeta } : {}), })); // Documenso v2 expects the field array under `data`, not `fields` // (the endpoint is a trpc-style createMany whose input schema wraps // the bulk payload in `{ envelopeId, data: [...] }`). The previous // shape returned 400: "Input validation failed - Required at data" // and surfaced as DOCUMENSO_UPSTREAM_ERROR on every custom-upload // send. Confirmed against the live Documenso 2.x error response. const res = await fetchWithTimeout(`${baseUrl}/api/v2/envelope/field/create-many`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ envelopeId: docId, data: v2Fields }), }); if (!res.ok) { const err = await res.text(); logger.error({ docId, status: res.status, err, portId }, 'Documenso v2 placeFields error'); if (res.status === 401 || res.status === 403) { throw new CodedError('DOCUMENSO_AUTH_FAILURE', { internalMessage: `v2 placeFields ${docId} → ${res.status}`, }); } throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { internalMessage: `v2 placeFields ${docId} → ${res.status}: ${err}`, }); } return; } const dims = await getPageDimensions(docId, portId); for (const f of fields) { const body = { recipientId: typeof f.recipientId === 'string' ? Number(f.recipientId) : f.recipientId, type: f.type, pageNumber: f.pageNumber, pageX: Math.round((f.pageX / 100) * dims.width), pageY: Math.round((f.pageY / 100) * dims.height), pageWidth: Math.round((f.pageWidth / 100) * dims.width), pageHeight: Math.round((f.pageHeight / 100) * dims.height), }; // Retry transient failures so one flaky 5xx mid-loop doesn't leave // the document with a partial field set. 3 attempts at 250 / 500 / // 1000 ms; 4xx responses (validation errors) fail-fast. let lastError: { status: number; body: string } | null = null; for (let attempt = 0; attempt < 3; attempt += 1) { const res = await fetchWithTimeout(`${baseUrl}/api/v1/documents/${docId}/fields`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (res.ok) { lastError = null; break; } const errBody = await res.text().catch(() => ''); lastError = { status: res.status, body: errBody }; // Don't retry on 4xx - that's a validation error, won't change. if (res.status >= 400 && res.status < 500) break; // Backoff: 250ms, 500ms (skipped on the 3rd iteration because we exit). if (attempt < 2) { await new Promise((r) => setTimeout(r, 250 * Math.pow(2, attempt))); } } if (lastError) { logger.error( { docId, status: lastError.status, err: lastError.body, portId }, 'Documenso v1 placeField error', ); if (lastError.status === 401 || lastError.status === 403) { throw new CodedError('DOCUMENSO_AUTH_FAILURE', { internalMessage: `v1 placeField ${docId} → ${lastError.status}`, }); } throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { internalMessage: `v1 placeField ${docId} → ${lastError.status}: ${lastError.body}`, }); } } } /** * Auto-position one SIGNATURE field per recipient at the last-page footer, * staggered horizontally so multiple signers don't overlap. Used by the * upload-path wizard - admins can refine in Documenso afterwards. * * Layout (percent of page): * y = 88 (footer band) * height = 6 * width = min(20, 80 / N) * x = i * (80/N) + (40 - 80/N * N / 2) (centered row) */ export async function placeDefaultSignatureFields( docId: string, recipients: Array<{ id: number | string; pageNumber: number }>, portId?: string, ): Promise { if (recipients.length === 0) return; const fields: DocumensoFieldPlacement[] = computeDefaultSignatureLayout(recipients); await placeFields(docId, fields, portId); } /** Pure function exported for unit testing layout math. */ export function computeDefaultSignatureLayout( recipients: Array<{ id: number | string; pageNumber: number }>, ): DocumensoFieldPlacement[] { const n = recipients.length; if (n === 0) return []; const slot = Math.min(20, 80 / n); // percent width per signer const rowWidth = slot * n; const startX = 50 - rowWidth / 2; return recipients.map((r, i) => ({ recipientId: r.id, type: 'SIGNATURE', pageNumber: r.pageNumber, pageX: Math.max(0, startX + i * slot), pageY: 88, pageWidth: slot, pageHeight: 6, })); } /** * Void/cancel a Documenso document. * * v1: DELETE /api/v1/documents/{id} * v2: DELETE /api/v2/envelope/{id} * * Idempotent on 404 (already gone) - logs and resolves. */ export async function voidDocument(docId: string, portId?: string): Promise { const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId); const path = apiVersion === 'v2' ? `/api/v2/envelope/${docId}` : `/api/v1/documents/${docId}`; const res = await fetchWithTimeout(`${baseUrl}${path}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, }); if (res.status === 404) { logger.warn({ docId, portId }, 'Documenso voidDocument: already deleted'); return; } if (!res.ok) { const err = await res.text(); logger.error({ docId, status: res.status, err, portId }, 'Documenso voidDocument error'); if (res.status === 401 || res.status === 403) { throw new CodedError('DOCUMENSO_AUTH_FAILURE', { internalMessage: `voidDocument ${docId} → ${res.status}`, }); } throw new CodedError('DOCUMENSO_UPSTREAM_ERROR', { internalMessage: `voidDocument ${docId} → ${res.status}: ${err}`, }); } } /** * Update an envelope's metadata while it's still in DRAFT or PENDING - title, * subject, message, redirect URL, signing order, language. v2-only feature * (Documenso 1.13.x has no equivalent; admins on v1 need to void + recreate). * * Returns the normalized document shape so callers can persist the latest * title locally in the `documents` row. */ export async function updateEnvelope( docId: string, patch: { title?: string; meta?: { subject?: string; message?: string; redirectUrl?: string; language?: string; signingOrder?: 'PARALLEL' | 'SEQUENTIAL'; timezone?: string; dateFormat?: string; }; }, portId?: string, ): Promise { const { apiVersion } = await resolveCreds(portId); if (apiVersion !== 'v2') { throw new CodedError('DOCUMENSO_V1_NOT_SUPPORTED', { internalMessage: 'updateEnvelope requires Documenso 2.x - the v1.13.x API has no envelope/update endpoint', }); } // v2 update is POST /api/v2/envelope/update with the envelopeId in the // body - NOT a PATCH against a per-id path. The body splits document // properties (title, externalId, visibility, email) under `data` from // email/signing settings under `meta`. Restricted to DRAFT envelopes. const body: Record = { envelopeId: docId }; if (patch.title !== undefined) { body.data = { title: patch.title }; } if (patch.meta) { body.meta = patch.meta; } return documensoFetch( `/api/v2/envelope/update`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }, portId, ).then(normalizeDocument); }