feat(documenso-audit-phase-1): persist documensoId early + preflight + state machine + reconciliation + tests

Phase 1 of the comprehensive Documenso upload audit per the
2026-05-26 locked-decisions block in docs/superpowers/audits/active-uat.md.

P1.1 — persist documensoId immediately after create
Was set only at the late `status: 'sent'` commit. Any throw between
documensoCreate and the late update left an orphaned Documenso
envelope the CRM had no link to. Now the UPDATE runs right after
documensoCreate succeeds; rollback paths can find and void the
envelope.

P1.2 — pre-flight validation hard-blocks Submit
UploadForSigningDialog computes a submissionErrors memo over
recipients + fields. Submit button disabled when errors > 0. Inline
amber summary lists every issue (missing email, invalid email,
missing name, field assigned to non-existent recipient, no fields
placed). Service layer mirrors the same email + name checks so
direct API hits reject early. No override path per locked decision.

P1.3 — cancel/delete affordance audit + sweep
Document-list per-row Delete + Send for Signing actions now:
- Wrapped in PermissionGate (documents.delete + send_for_signing).
- Surface toast on success + toastError on failure (were silently
  swallowing errors).
- Use a broader predicate-based query invalidation so every doc
  list across the app refreshes, not just the local key.
EOI tab Regenerate + Cancel EOI buttons + reservation/contract
tab Cancel buttons wrapped in PermissionGate (documents.edit, the
cancel route's auth check).

P1.4 — Documenso webhook URL auto-PATCH (env-gated)
scripts/update-documenso-webhook.ts written. Reads
DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK env flag (when 1, runs; otherwise
no-op). Lists every webhook on the Documenso instance via v2 (with
v1 fallback), identifies webhooks pointing at trycloudflare.com
hosts OR /api/webhooks/documenso paths, PATCHes them to the new
tunnel URL. scripts/tunnel-url.sh chains the script after the URL
print so a re-tunnel auto-rotates the webhook (when flag set).

P1.5 — state-machine refactor with rollbackTo() helper
custom-document-upload.service.ts:
- Single try around create → send → place steps.
- state.step tracks which step is current; state.documensoDocId
  records the envelope id once we have it.
- rollbackTo(reason) composes the recovery: status='cancelled' on
  the CRM row, documensoVoidSafe on the envelope when applicable.
  Idempotent — calling twice is safe.
- Removes three independent try/catches.

P1.6 — recipient ↔ Documenso identity reconciliation
After documensoSend, validates every distinct email we sent
appears in sentDoc.recipients. If Documenso silently dropped one,
a ConflictError fires before field placement so the rollback path
triggers. Explicit message names the missing emails for the rep.

P1.7 — vitest extension + per-failure audit-log entries
- 5 new vitest cases (blank email, whitespace email, malformed
  email, blank name, duplicate-emails-OK semantic).
- rollbackTo writes a structured audit_log entry with failedStep,
  documensoEnvelopeId, errorClass, errorMessage. Post-mortem
  investigation has structured data instead of just logger lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 21:09:50 +02:00
parent b00cc24565
commit b6c27b506d
10 changed files with 595 additions and 138 deletions

View File

@@ -49,3 +49,16 @@ if [[ "${1:-}" == "--copy" ]]; then
printf "%s/api/webhooks/documenso" "$URL" | pbcopy
echo "(webhook URL copied to clipboard)"
fi
# Auto-PATCH Documenso's webhook URL when the env flag is set. Gated so
# production ports can never have their webhook rotated by a stale dev
# script. The TS script reads DOCUMENSO_API_URL + DOCUMENSO_API_KEY +
# DOCUMENSO_API_VERSION from .env and updates every webhook whose URL
# already points at our path OR at any *.trycloudflare.com host.
if [[ "${DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK:-}" == "1" ]]; then
echo ""
echo "DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 — updating Documenso webhook(s)…"
cd "$(dirname "$0")/.." || exit 1
DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 \
pnpm tsx scripts/update-documenso-webhook.ts "$URL"
fi

View File

@@ -0,0 +1,194 @@
/**
* Documenso webhook URL auto-updater. Called by `./scripts/tunnel-url.sh`
* when the env flag `DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1` is set so a
* freshly-restarted cloudflared quick-tunnel (which gets a NEW hostname
* on every restart) doesn't leave Documenso pointing at a dead URL.
*
* Gated by env flag so production ports — which may have a stable
* webhook URL — can never have their config rotated by a stale dev
* script. Reads Documenso credentials from env (DOCUMENSO_API_URL +
* DOCUMENSO_API_KEY + optional DOCUMENSO_API_VERSION).
*
* Usage (manual invocation):
* DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK=1 pnpm tsx scripts/update-documenso-webhook.ts https://foo.trycloudflare.com
*
* Behaviour:
* - Lists every webhook currently configured on the Documenso
* instance.
* - Identifies webhooks whose `webhookUrl` looks like a
* trycloudflare.com domain OR matches our `/api/webhooks/documenso`
* path suffix. These are the ones to rotate.
* - PATCHes each matching webhook to point at the new tunnel URL.
* - Leaves all other webhooks alone (in case the instance also
* services another tenant or a stable production URL).
*
* Tries Documenso v2 first, falls back to v1 if the v2 endpoint
* returns 404. Both versions support GET /webhook(s) + PATCH on the
* webhook resource — the shape differs slightly between them but the
* fields we touch (`id`, `webhookUrl`) are stable across versions.
*/
import 'dotenv/config';
const ENABLE_FLAG = process.env.DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK;
const TUNNEL_BASE = process.argv[2];
if (ENABLE_FLAG !== '1') {
console.log(
'DEV_AUTO_UPDATE_DOCUMENSO_WEBHOOK is not set to 1 — skipping Documenso webhook update.',
);
process.exit(0);
}
if (!TUNNEL_BASE) {
console.error('Usage: pnpm tsx scripts/update-documenso-webhook.ts <tunnel-base-url>');
console.error(
'Example: pnpm tsx scripts/update-documenso-webhook.ts https://foo.trycloudflare.com',
);
process.exit(1);
}
const API_URL = process.env.DOCUMENSO_API_URL;
const API_KEY = process.env.DOCUMENSO_API_KEY;
const API_VERSION = (process.env.DOCUMENSO_API_VERSION ?? 'v2').toLowerCase();
if (!API_URL || !API_KEY) {
console.error('DOCUMENSO_API_URL and DOCUMENSO_API_KEY must be set in env to update webhooks.');
process.exit(1);
}
// Trim trailing slash so we can compose paths cleanly.
const BASE = API_URL.replace(/\/+$/, '');
const NEW_WEBHOOK_URL = `${TUNNEL_BASE.replace(/\/+$/, '')}/api/webhooks/documenso`;
async function documensoRequest(path: string, init?: RequestInit): Promise<Response> {
return fetch(`${BASE}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: API_KEY!,
...(init?.headers ?? {}),
},
});
}
interface DocumensoWebhook {
id: string | number;
webhookUrl: string;
}
/**
* Pluck the array of webhooks out of whatever shape the Documenso
* version returned. v1 historically returned an array directly; v2
* tends to wrap in `{ data: [...] }` or similar. Be tolerant.
*/
function extractWebhooks(raw: unknown): DocumensoWebhook[] {
if (Array.isArray(raw)) return raw as DocumensoWebhook[];
if (raw && typeof raw === 'object') {
const r = raw as Record<string, unknown>;
if (Array.isArray(r.data)) return r.data as DocumensoWebhook[];
if (Array.isArray(r.webhooks)) return r.webhooks as DocumensoWebhook[];
}
return [];
}
async function listWebhooks(): Promise<{ webhooks: DocumensoWebhook[]; version: 'v1' | 'v2' }> {
if (API_VERSION === 'v2' || API_VERSION === 'v2.0' || API_VERSION === 'v2.x') {
const res = await documensoRequest('/api/v2/webhook');
if (res.ok) {
const body = (await res.json()) as unknown;
return { webhooks: extractWebhooks(body), version: 'v2' };
}
if (res.status !== 404) {
console.error(`v2 webhook list returned ${res.status}: ${await res.text()}`);
}
// Fall through to v1.
}
const res = await documensoRequest('/api/v1/webhooks');
if (!res.ok) {
console.error(`v1 webhook list returned ${res.status}: ${await res.text()}`);
process.exit(1);
}
const body = (await res.json()) as unknown;
return { webhooks: extractWebhooks(body), version: 'v1' };
}
async function patchWebhook(
version: 'v1' | 'v2',
webhook: DocumensoWebhook,
newUrl: string,
): Promise<boolean> {
const path =
version === 'v2'
? '/api/v2/webhook'
: `/api/v1/webhooks/${encodeURIComponent(String(webhook.id))}`;
const body = version === 'v2' ? { id: webhook.id, webhookUrl: newUrl } : { webhookUrl: newUrl };
const res = await documensoRequest(path, {
method: 'PATCH',
body: JSON.stringify(body),
});
if (!res.ok) {
console.error(`PATCH ${path} (id=${webhook.id}) returned ${res.status}: ${await res.text()}`);
return false;
}
return true;
}
/**
* Decide whether a given existing webhook is "ours" (i.e. matches the
* pattern we want to rotate). Two signals:
* 1. Path tail matches `/api/webhooks/documenso` — the CRM-side
* handler we own.
* 2. Host matches `*.trycloudflare.com` — almost certainly a stale
* quick-tunnel URL. Rotating these is always safe.
*/
function isRotatableWebhook(w: DocumensoWebhook): boolean {
if (!w.webhookUrl) return false;
if (w.webhookUrl.endsWith('/api/webhooks/documenso')) return true;
try {
const host = new URL(w.webhookUrl).hostname;
if (host.endsWith('.trycloudflare.com')) return true;
} catch {
/* malformed — leave alone */
}
return false;
}
async function main(): Promise<void> {
console.log(`Listing webhooks via Documenso ${API_VERSION.toUpperCase()} (base: ${BASE})…`);
const { webhooks, version } = await listWebhooks();
console.log(`Found ${webhooks.length} webhook(s).`);
const rotatable = webhooks.filter(isRotatableWebhook);
if (rotatable.length === 0) {
console.log(
`No rotatable webhooks found (looking for paths ending /api/webhooks/documenso or *.trycloudflare.com hosts).`,
);
console.log(`If your dev webhook is configured differently, point it at: ${NEW_WEBHOOK_URL}`);
return;
}
console.log(`Updating ${rotatable.length} webhook(s) to ${NEW_WEBHOOK_URL}`);
let ok = 0;
let fail = 0;
for (const w of rotatable) {
if (w.webhookUrl === NEW_WEBHOOK_URL) {
console.log(` ${w.id}: already at the target URL, skipping.`);
continue;
}
const succeeded = await patchWebhook(version, w, NEW_WEBHOOK_URL);
if (succeeded) {
ok++;
console.log(` ${w.id}: ${w.webhookUrl} -> ${NEW_WEBHOOK_URL}`);
} else {
fail++;
}
}
console.log(`Done. ${ok} updated, ${fail} failed.`);
if (fail > 0) process.exit(1);
}
main().catch((err) => {
console.error('Documenso webhook update failed:', err);
process.exit(1);
});