fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish

Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 13:28:50 +02:00
parent 397dbd1490
commit 4b5f85cb7d
158 changed files with 12255 additions and 1303 deletions

View File

@@ -13,15 +13,24 @@ interface DocumensoCreds {
}
async function resolveCreds(portId?: string): Promise<DocumensoCreds> {
// 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,
baseUrl: env.DOCUMENSO_API_URL ?? '',
apiKey: env.DOCUMENSO_API_KEY ?? '',
apiVersion: env.DOCUMENSO_API_VERSION,
};
}
const cfg = await getPortDocumensoConfig(portId);
return { baseUrl: cfg.apiUrl, apiKey: cfg.apiKey, apiVersion: cfg.apiVersion };
return {
baseUrl: cfg.apiUrl ?? '',
apiKey: cfg.apiKey ?? '',
apiVersion: cfg.apiVersion,
};
}
async function documensoFetchOnce(
@@ -113,12 +122,38 @@ async function documensoFetch(
});
}
// Documenso 2.x renamed top-level `id` → `documentId` and recipient `id` →
// `recipientId`; v1.13 still uses `id`. Normalize both shapes to the legacy
// `id` form that this codebase consumes everywhere downstream.
// 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<string, unknown>;
const id = String(r.documentId ?? r.id ?? '');
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
@@ -142,7 +177,7 @@ function normalizeDocument(raw: unknown): DocumensoDocument {
// see on subsequent webhook deliveries.
token: typeof rec.token === 'string' ? rec.token : undefined,
}));
return { id, status, recipients };
return { id, numericId, status, recipients };
}
export interface DocumensoRecipient {
@@ -154,6 +189,10 @@ export interface DocumensoRecipient {
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;
@@ -353,16 +392,179 @@ export async function generateDocumentFromTemplate(
portId?: string,
): Promise<DocumensoDocument> {
const safePayload = applyPayloadRedirect(payload);
const { apiVersion } = await resolveCreds(portId);
if (env.EMAIL_REDIRECT_TO) {
logger.info(
{ templateId },
{ 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<string, unknown>;
// 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<string, unknown> | undefined) ?? {};
const updateMeta: Record<string, unknown> = {};
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<string, unknown> = { 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<string, unknown>;
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<string, unknown>;
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,
@@ -378,6 +580,48 @@ export async function generateDocumentFromTemplate(
* 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<DocumensoDocument> {
// 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<string, unknown>;
return normalizeDocument({
id: distributed.id ?? envelopeId,
status: 'PENDING',
recipients: distributed.recipients,
});
}
export async function sendDocument(docId: string, portId?: string): Promise<DocumensoDocument> {
if (env.EMAIL_REDIRECT_TO) {
logger.warn(
@@ -395,11 +639,18 @@ export async function sendDocument(docId: string, portId?: string): Promise<Docu
// 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 }),
body: JSON.stringify({
envelopeId: docId,
meta: { distributionMethod: 'NONE' },
}),
},
portId,
)) as Record<string, unknown>;
@@ -435,6 +686,190 @@ export async function getDocument(docId: string, portId?: string): Promise<Docum
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<string, unknown>;
const id = Number(r.templateId ?? r.id ?? 0);
const title = String(r.title ?? '');
const recipientsRaw =
(r.recipients as Array<Record<string, unknown>> | undefined) ??
(r.Recipient as Array<Record<string, unknown>> | 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<Record<string, unknown>> | undefined) ?? [];
const fields: DocumensoTemplateField[] = fieldsRaw.map((f) => {
const fieldMeta = (f.fieldMeta as Record<string, unknown> | 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<Record<string, unknown>> | 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<string, unknown> | undefined) ?? (r as Record<string, unknown>);
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<Buffer> {
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());
}
/**
* 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<DocumensoTemplate> {
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<number | null> {
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<Record<string, unknown>>; templates?: Array<Record<string, unknown>> };
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
@@ -482,12 +917,26 @@ export async function sendReminder(
export async function downloadSignedPdf(docId: string, portId?: string): Promise<Buffer> {
const { baseUrl, apiKey, apiVersion } = await resolveCreds(portId);
// v2: /api/v2/envelope/{id}/download (mirrors the v1 path under the
// envelope namespace). v1: existing /documents/{id}/download.
const path =
apiVersion === 'v2'
? `/api/v2/envelope/${docId}/download`
: `/api/v1/documents/${docId}/download`;
// 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}`, {
@@ -519,18 +968,25 @@ export async function downloadSignedPdf(docId: string, portId?: string): Promise
return Buffer.from(arrayBuffer);
}
/** Convenience health-check used by the admin "Test connection" button. */
/** 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);
// Both v1 and v2 expose /api/v1/health (v2 keeps the v1 path for
// backward compat). If a v2 deployment ever moves this we'll add a
// v2 branch — but as of Documenso 2.x there isn't a v2 health path.
const res = await fetchWithTimeout(`${baseUrl}/api/v1/health`, {
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' };
@@ -851,3 +1307,56 @@ export async function voidDocument(docId: string, portId?: string): Promise<void
});
}
}
/**
* 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<DocumensoDocument> {
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<string, unknown> = { 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);
}