Files
pn-new-crm/src/app/api/webhooks/documenso/route.ts

218 lines
7.4 KiB
TypeScript
Raw Normal View History

import { NextRequest, NextResponse } from 'next/server';
import { createHash } from 'crypto';
import { db } from '@/lib/db';
import { verifyDocumensoSecret } from '@/lib/services/documenso-webhook';
fix(audit-tier-6): validation, perms, ops/infra, per-port webhook secret Final audit polish — closes the remaining LOW + MED items the previous tiers didn't reach: * Validation hardening: me.preferences uses .strict() + 8KB cap instead of unbounded .passthrough(); files.uploadFile gains magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan endpoint enforces 10MB cap + magic-byte check on receipt images; port logoUrl + me.avatarUrl reject javascript:/data: schemes via a shared httpUrl refinement. * Permission gates: document-sends/{brochure,berth-pdf} now require email.send (was withAuth-only); document-sends/{preview,list} on email.view; ai/email-draft on email.send; documents/[id]/send uses send_for_signing (was create); expenses/export/parent-company flips from hard isSuperAdmin to expenses.export for parity; admin/users/options gated on reminders.assign_others (was withAuth). * Envelope hygiene: auth/set-password switches the third {message} variant to errorResponse + {data: {email}}; ai/email-draft wraps jobId in {data: {jobId}}. * UI polish: reports-list.handleDownload surfaces failures via toastError (was console-only). * Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles + packageManager field in package.json; Dockerfile.worker re-orders user creation BEFORE pnpm install so node_modules / .cache dirs are worker-owned (fixes tesseract.js + sharp EACCES at first PDF parse); add Redis-ping HEALTHCHECK to the worker container. * Public health endpoint: returns full env+appUrl payload only when the caller presents X-Intake-Secret, otherwise a minimal {status} so generic uptime monitors still work but anonymous internet doesn't get deployment fingerprints. * Per-port Documenso webhook secret: new system_settings key + listDocumensoWebhookSecrets() helper. The webhook receiver iterates every configured per-port secret with timing-safe comparison + falls back to env, then forwards the resolved portId into handleDocumentExpired so two ports sharing a documensoId cannot cross-mutate. Deferred (handled in dedicated follow-up PRs): * Tier 5.1 — direct service tests for portal-auth / users / email-accounts / document-sends / sales-email-config. MED, large test-writing scope. * The {ok: true} → {data: null} envelope migration across alerts/expenses/admin-ocr-settings/storage routes. Mechanical but needs coordinated client + test updates. * CSP-nonce migration (drop unsafe-inline) — needs middleware-level nonce generation that the Next 15 router has to thread through. * Idempotency-Key header on Documenso createDocument. Requires schema column on documents to persist the key; deferred so it doesn't bundle a migration into this commit. * The 16 better-auth user_id FKs — separate dedicated migration with care (some columns are NOT NULL today and cascade decisions matter). * PermissionGate / Skeleton / EmptyState wraps across 5 admin lists (auditor-H §§36–37) and the residential-clients filter bar. Test status: 1175/1175 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43 + HIGH §9 (Documenso secrets follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:03:31 +02:00
import { listDocumensoWebhookSecrets } from '@/lib/services/port-config';
import {
handleRecipientSigned,
handleDocumentCompleted,
handleDocumentExpired,
handleDocumentOpened,
handleDocumentRejected,
handleDocumentCancelled,
} from '@/lib/services/documents.service';
import { logger } from '@/lib/logger';
import { createAuditLog } from '@/lib/audit';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
// BR-024: Dedup via signatureHash unique index on documentEvents
// Always return 200 from webhook (webhook best practice)
// Documenso emits Prisma enum names on the wire (e.g. "DOCUMENT_SIGNED").
// The UI displays them as lowercase-dotted ("document.signed") but the JSON
// body uses the enum value as-is. Normalize both forms in case 2.x ever flips.
function canonicalizeEvent(event: string): string {
return event.toUpperCase().replace(/\./g, '_');
}
type DocumensoRecipient = {
email: string;
signingStatus?: string;
readStatus?: string;
signedAt?: string | null;
};
type DocumensoWebhookBody = {
event: string;
payload: {
id: number | string;
recipients?: DocumensoRecipient[];
};
};
export async function POST(req: NextRequest): Promise<NextResponse> {
let rawBody: string;
try {
rawBody = await req.text();
} catch {
return NextResponse.json({ ok: false }, { status: 200 });
}
// Documenso v1.13 + 2.x send the secret in plaintext via X-Documenso-Secret.
fix(audit-tier-6): validation, perms, ops/infra, per-port webhook secret Final audit polish — closes the remaining LOW + MED items the previous tiers didn't reach: * Validation hardening: me.preferences uses .strict() + 8KB cap instead of unbounded .passthrough(); files.uploadFile gains magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan endpoint enforces 10MB cap + magic-byte check on receipt images; port logoUrl + me.avatarUrl reject javascript:/data: schemes via a shared httpUrl refinement. * Permission gates: document-sends/{brochure,berth-pdf} now require email.send (was withAuth-only); document-sends/{preview,list} on email.view; ai/email-draft on email.send; documents/[id]/send uses send_for_signing (was create); expenses/export/parent-company flips from hard isSuperAdmin to expenses.export for parity; admin/users/options gated on reminders.assign_others (was withAuth). * Envelope hygiene: auth/set-password switches the third {message} variant to errorResponse + {data: {email}}; ai/email-draft wraps jobId in {data: {jobId}}. * UI polish: reports-list.handleDownload surfaces failures via toastError (was console-only). * Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles + packageManager field in package.json; Dockerfile.worker re-orders user creation BEFORE pnpm install so node_modules / .cache dirs are worker-owned (fixes tesseract.js + sharp EACCES at first PDF parse); add Redis-ping HEALTHCHECK to the worker container. * Public health endpoint: returns full env+appUrl payload only when the caller presents X-Intake-Secret, otherwise a minimal {status} so generic uptime monitors still work but anonymous internet doesn't get deployment fingerprints. * Per-port Documenso webhook secret: new system_settings key + listDocumensoWebhookSecrets() helper. The webhook receiver iterates every configured per-port secret with timing-safe comparison + falls back to env, then forwards the resolved portId into handleDocumentExpired so two ports sharing a documensoId cannot cross-mutate. Deferred (handled in dedicated follow-up PRs): * Tier 5.1 — direct service tests for portal-auth / users / email-accounts / document-sends / sales-email-config. MED, large test-writing scope. * The {ok: true} → {data: null} envelope migration across alerts/expenses/admin-ocr-settings/storage routes. Mechanical but needs coordinated client + test updates. * CSP-nonce migration (drop unsafe-inline) — needs middleware-level nonce generation that the Next 15 router has to thread through. * Idempotency-Key header on Documenso createDocument. Requires schema column on documents to persist the key; deferred so it doesn't bundle a migration into this commit. * The 16 better-auth user_id FKs — separate dedicated migration with care (some columns are NOT NULL today and cascade decisions matter). * PermissionGate / Skeleton / EmptyState wraps across 5 admin lists (auditor-H §§36–37) and the residential-clients filter bar. Test status: 1175/1175 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43 + HIGH §9 (Documenso secrets follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:03:31 +02:00
// Resolve the matching port by trying each configured per-port secret
// (plus the global env fallback) with timing-safe comparison. The
// resolved portId, when non-null, is threaded into handleDocumentExpired
// so two ports sharing a documensoId can't cross-mutate (auditor-D §22).
const providedSecret = req.headers.get('x-documenso-secret') ?? '';
fix(audit-tier-6): validation, perms, ops/infra, per-port webhook secret Final audit polish — closes the remaining LOW + MED items the previous tiers didn't reach: * Validation hardening: me.preferences uses .strict() + 8KB cap instead of unbounded .passthrough(); files.uploadFile gains magic-byte verification (jpeg/png/gif/webp/pdf/doc/xlsx); OCR scan endpoint enforces 10MB cap + magic-byte check on receipt images; port logoUrl + me.avatarUrl reject javascript:/data: schemes via a shared httpUrl refinement. * Permission gates: document-sends/{brochure,berth-pdf} now require email.send (was withAuth-only); document-sends/{preview,list} on email.view; ai/email-draft on email.send; documents/[id]/send uses send_for_signing (was create); expenses/export/parent-company flips from hard isSuperAdmin to expenses.export for parity; admin/users/options gated on reminders.assign_others (was withAuth). * Envelope hygiene: auth/set-password switches the third {message} variant to errorResponse + {data: {email}}; ai/email-draft wraps jobId in {data: {jobId}}. * UI polish: reports-list.handleDownload surfaces failures via toastError (was console-only). * Ops/infra: pin pnpm@10.33.2 across all three Dockerfiles + packageManager field in package.json; Dockerfile.worker re-orders user creation BEFORE pnpm install so node_modules / .cache dirs are worker-owned (fixes tesseract.js + sharp EACCES at first PDF parse); add Redis-ping HEALTHCHECK to the worker container. * Public health endpoint: returns full env+appUrl payload only when the caller presents X-Intake-Secret, otherwise a minimal {status} so generic uptime monitors still work but anonymous internet doesn't get deployment fingerprints. * Per-port Documenso webhook secret: new system_settings key + listDocumensoWebhookSecrets() helper. The webhook receiver iterates every configured per-port secret with timing-safe comparison + falls back to env, then forwards the resolved portId into handleDocumentExpired so two ports sharing a documensoId cannot cross-mutate. Deferred (handled in dedicated follow-up PRs): * Tier 5.1 — direct service tests for portal-auth / users / email-accounts / document-sends / sales-email-config. MED, large test-writing scope. * The {ok: true} → {data: null} envelope migration across alerts/expenses/admin-ocr-settings/storage routes. Mechanical but needs coordinated client + test updates. * CSP-nonce migration (drop unsafe-inline) — needs middleware-level nonce generation that the Next 15 router has to thread through. * Idempotency-Key header on Documenso createDocument. Requires schema column on documents to persist the key; deferred so it doesn't bundle a migration into this commit. * The 16 better-auth user_id FKs — separate dedicated migration with care (some columns are NOT NULL today and cascade decisions matter). * PermissionGate / Skeleton / EmptyState wraps across 5 admin lists (auditor-H §§36–37) and the residential-clients filter bar. Test status: 1175/1175 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md MED §§28,29,30 + LOW §§32–43 + HIGH §9 (Documenso secrets follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:03:31 +02:00
const secrets = await listDocumensoWebhookSecrets();
let matchedPortId: string | null = null;
let matched = false;
for (const entry of secrets) {
if (verifyDocumensoSecret(providedSecret, entry.secret)) {
matched = true;
matchedPortId = entry.portId;
break;
}
}
if (!matched) {
const callerIp =
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
req.headers.get('x-real-ip') ??
'unknown';
// Rate-limit per IP. Real Documenso traffic won't fail the secret
// check, so any traffic here is enumeration / brute-force; we cap
// it sharply to keep audit-log volume bounded too.
const rl = await checkRateLimit(callerIp, rateLimiters.webhookBadSecret);
logger.warn(
{ providedLen: providedSecret.length, ip: callerIp, allowed: rl.allowed },
'Invalid Documenso webhook secret',
);
if (rl.allowed) {
void createAuditLog({
userId: null,
portId: null,
action: 'webhook_failed',
entityType: 'webhook_inbound',
entityId: 'documenso',
metadata: {
reason: 'invalid_secret',
providedLen: providedSecret.length,
},
ipAddress: callerIp,
userAgent: req.headers.get('user-agent') ?? '',
severity: 'warning',
source: 'webhook',
});
}
// Always return 200 (webhook best-practice — don't leak signal).
return NextResponse.json({ ok: false, error: 'Invalid secret' }, { status: 200 });
}
// Compute deduplication hash
const signatureHash = createHash('sha256').update(rawBody).digest('hex');
let parsed: DocumensoWebhookBody;
try {
parsed = JSON.parse(rawBody) as DocumensoWebhookBody;
} catch {
logger.warn('Failed to parse Documenso webhook payload');
return NextResponse.json({ ok: false }, { status: 200 });
}
// Replay guard: if any event with this hash already exists, skip.
try {
const existing = await db.query.documentEvents.findFirst({
where: (de, { eq }) => eq(de.signatureHash, signatureHash),
});
if (existing) {
logger.info({ signatureHash }, 'Duplicate Documenso webhook - skipping');
return NextResponse.json({ ok: true }, { status: 200 });
}
} catch (err) {
logger.error({ err }, 'Failed to check duplicate webhook');
}
const event = canonicalizeEvent(parsed.event);
const documensoId = String(parsed.payload?.id ?? '');
const recipients = parsed.payload?.recipients ?? [];
if (!documensoId) {
logger.warn({ event }, 'Documenso webhook missing payload.id');
return NextResponse.json({ ok: true }, { status: 200 });
}
fix(audit-verification): regressions found in post-Tier-6 review Two parallel reviews of the Tier 0–6 work surfaced one CRITICAL regression and a handful of remaining cross-tenant gaps that the original audit didn't enumerate. All fixed here: CRITICAL * document-reminders.processReminderQueue — the new bulk-fetch leftJoin to documentTemplates was scoped on `templateType` alone. Templates of the same type exist in every port; the cartesian explosion would have fired one Documenso reminder PER matching template-row per cron tick (a 5-port deploy = 5 reminders to the same signer per cycle). Added eq(documentTemplates.portId, portId) to the join. * All five remaining Documenso webhook handlers (RecipientSigned / Completed / Opened / Rejected / Cancelled) accept and require an optional portId now, with a shared resolveWebhookDocument() helper that refuses to mutate when the lookup is ambiguous across tenants without a resolved port. Tier 5's port-scoping was applied only to Expired; the route now forwards the matched portId to every handler. Tightens the WHERE clauses on subsequent UPDATEs to (id, portId) for defense-in-depth. HIGH * verifyDocumensoSecret rejects when `expected` is empty — timingSafeEqual(0-bytes, 0-bytes) was returning true, so a dev env with a blank DOCUMENSO_WEBHOOK_SECRET would accept a request whose X-Documenso-Secret header was also missing/empty. listDocumensoWebhookSecrets skips the env entry when blank. * /api/public/health — the website-intake-secret comparison was a string `===` (not constant-time). Switched to timingSafeEqual via Buffer.from(). MEDIUM * server.ts SIGTERM ordering — Socket.io closes BEFORE the HTTP drain so long-poll websockets stop holding the server open past the compose stop_grace_period. * /api/v1/me PATCH preferences merge — allow-list filter on the merged JSONB so legacy rows from the old .passthrough() era stop silently re-shipping their bloat to disk. Migration fixes (deploy-blocking) * 0041 referenced `port_role_overrides.permissions` (column is `permission_overrides`) — overrides are partial JSONB and don't need backfilling at all (deepMerge resolves edit from the base role). Removed the override UPDATEs entirely. * 0042 switched all FK + CHECK adds to NOT VALID + VALIDATE so the brief table-lock phase is decoupled from the row-scan validation, giving a cleaner abort-and-restart story if a constraint catches dirty production data. Added a pre-cleanup UPDATE for invoices.billing_entity_id = '' rows (backfills from clientName, falls back to the row id) so the new non-empty CHECK passes on a dirty table. Test status: 1175/1175 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:19:39 +02:00
// Every handler accepts an optional `portId` and refuses to mutate when
// the lookup is ambiguous across multiple ports without one. Forward
// the secret-resolved portId everywhere — not just the expired path —
// so signed/completed/opened/rejected/cancelled events can't flip a
// foreign-tenant document via documensoId reuse.
const portScope = matchedPortId ? { portId: matchedPortId } : {};
try {
switch (event) {
case 'DOCUMENT_SIGNED':
case 'DOCUMENT_RECIPIENT_COMPLETED': {
// v1.13 fires DOCUMENT_SIGNED per recipient sign;
// 2.x fires DOCUMENT_RECIPIENT_COMPLETED for the same semantics.
const signedRecipients = recipients.filter(
(r) => r.signingStatus === 'SIGNED' || Boolean(r.signedAt),
);
for (const r of signedRecipients) {
await handleRecipientSigned({
documentId: documensoId,
recipientEmail: r.email,
signatureHash: `${signatureHash}:signed:${r.email}`,
fix(audit-verification): regressions found in post-Tier-6 review Two parallel reviews of the Tier 0–6 work surfaced one CRITICAL regression and a handful of remaining cross-tenant gaps that the original audit didn't enumerate. All fixed here: CRITICAL * document-reminders.processReminderQueue — the new bulk-fetch leftJoin to documentTemplates was scoped on `templateType` alone. Templates of the same type exist in every port; the cartesian explosion would have fired one Documenso reminder PER matching template-row per cron tick (a 5-port deploy = 5 reminders to the same signer per cycle). Added eq(documentTemplates.portId, portId) to the join. * All five remaining Documenso webhook handlers (RecipientSigned / Completed / Opened / Rejected / Cancelled) accept and require an optional portId now, with a shared resolveWebhookDocument() helper that refuses to mutate when the lookup is ambiguous across tenants without a resolved port. Tier 5's port-scoping was applied only to Expired; the route now forwards the matched portId to every handler. Tightens the WHERE clauses on subsequent UPDATEs to (id, portId) for defense-in-depth. HIGH * verifyDocumensoSecret rejects when `expected` is empty — timingSafeEqual(0-bytes, 0-bytes) was returning true, so a dev env with a blank DOCUMENSO_WEBHOOK_SECRET would accept a request whose X-Documenso-Secret header was also missing/empty. listDocumensoWebhookSecrets skips the env entry when blank. * /api/public/health — the website-intake-secret comparison was a string `===` (not constant-time). Switched to timingSafeEqual via Buffer.from(). MEDIUM * server.ts SIGTERM ordering — Socket.io closes BEFORE the HTTP drain so long-poll websockets stop holding the server open past the compose stop_grace_period. * /api/v1/me PATCH preferences merge — allow-list filter on the merged JSONB so legacy rows from the old .passthrough() era stop silently re-shipping their bloat to disk. Migration fixes (deploy-blocking) * 0041 referenced `port_role_overrides.permissions` (column is `permission_overrides`) — overrides are partial JSONB and don't need backfilling at all (deepMerge resolves edit from the base role). Removed the override UPDATEs entirely. * 0042 switched all FK + CHECK adds to NOT VALID + VALIDATE so the brief table-lock phase is decoupled from the row-scan validation, giving a cleaner abort-and-restart story if a constraint catches dirty production data. Added a pre-cleanup UPDATE for invoices.billing_entity_id = '' rows (backfills from clientName, falls back to the row id) so the new non-empty CHECK passes on a dirty table. Test status: 1175/1175 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:19:39 +02:00
...portScope,
});
}
break;
}
case 'DOCUMENT_OPENED': {
fix(audit-v2): platform-wide post-merge hardening across 5 domains Five-domain audit (security, routes, DB, integrations, UI/UX) ran after the cf37d09 merge. Critical + high-impact items landed here; deferred medium/low items indexed in docs/audit-final-deferred.md (now organised into a "Audit-final v2" section). Security: - Storage proxy tokens now bind to op (`'get'` vs `'put'`). A long-lived download URL minted by `presignDownload` for an emailed brochure can no longer be replayed against the proxy PUT to overwrite the original storage object. `verifyProxyToken` requires `expectedOp` and rejects mismatches; legacy tokens missing `op` fail-closed. Regression tests added. - Markdown email merge values are now markdown-escaped (`[`, `]`, `(`, `)`, `*`, `_`, `\`, backticks, braces) before substitution into the rep-authored body. A malicious value like `[click here](https://evil)` stored in `client.fullName` no longer survives `escapeHtml` to render as a real `<a href>` in the outbound email. Phishing-via-merge-field closed; regression tests added. - Middleware now performs an Origin/Referer check on POST/PUT/PATCH/DELETE to `/api/v1/**`. Defense-in-depth on top of better-auth's SameSite=Lax cookie. Webhooks/public/auth/portal routes exempt as they don't carry the session cookie. Routes: - Template management routes were calling `withPermission('documents', 'manage', ...)` — but `documents` doesn't have a `manage` action. The registry has `document_templates.manage`. Every non-superadmin was getting 403'd on the seven template endpoints. Fixed across the /admin/templates surface. - Custom-fields permission resource is hardcoded to `clients` regardless of which entity (yacht/company/etc.) the values belong to. Documented as deferred (requires per-entity routes). DB: - documentSends: every parent FK (client_id, interest_id, berth_id, brochure_id, brochure_version_id) now uses ON DELETE SET NULL so the audit trail outlasts hard-deletes. The denormalized columns (recipient_email, document_kind, body_markdown, from_address) were added precisely for this. Migration 0035. - Polymorphic discriminators on yachts.current_owner_type and invoices.billing_entity_type now have CHECK constraints — typos like `'clients'` vs `'client'` were silently inserting unreachable rows before. Migration 0036. Integrations: - Email attachment resolution (`src/lib/email/index.ts`) was importing MinIO directly instead of `getStorageBackend()`. Filesystem-backend deployments would have broken every email-with-attachment send. Now routes through the pluggable abstraction per CLAUDE.md. - Documenso DOCUMENT_OPENED webhook filter relaxed: v2 may omit `readStatus` or send lowercase, so an event that was the SIGNAL of an open was being silently dropped. Now treats any recipient on a DOCUMENT_OPENED event as opened. UI/UX: - Expense detail used to render `receiptFileIds` as opaque UUID badges — reps couldn't view the receipt they uploaded. Now renders an image thumbnail (via `/api/v1/files/[id]/preview`) plus a Download link for PDFs. Closed the "where's my receipt?" loop in the expense flow. - Expense detail Edit + Archive buttons now `<PermissionGate>` and the archive mutation surfaces success/error toasts instead of silent 403s. - Brochures admin: setDefault/archive/create mutations now have onError toasts (only onSuccess existed before). - Removed broken bulk-upload link in scan/page (route doesn't exist; used a raw `<a>` triggering a full reload to a 404). Test status: 1168/1168 vitest passing. tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:51:39 +02:00
// Documenso v1 sends `readStatus: 'OPENED'`; v2 has used both
// upper and lower case across releases and may omit the field
// entirely (the event itself signals the open). Treat the event
// as the signal: dispatch a per-recipient open for every
// recipient on the document so v2 deployments stop silently
// dropping opens.
const openedRecipients = recipients.filter(
(r) => !r.readStatus || String(r.readStatus).toUpperCase() === 'OPENED',
);
for (const r of openedRecipients) {
await handleDocumentOpened({
documentId: documensoId,
recipientEmail: r.email,
signatureHash: `${signatureHash}:opened:${r.email}`,
fix(audit-verification): regressions found in post-Tier-6 review Two parallel reviews of the Tier 0–6 work surfaced one CRITICAL regression and a handful of remaining cross-tenant gaps that the original audit didn't enumerate. All fixed here: CRITICAL * document-reminders.processReminderQueue — the new bulk-fetch leftJoin to documentTemplates was scoped on `templateType` alone. Templates of the same type exist in every port; the cartesian explosion would have fired one Documenso reminder PER matching template-row per cron tick (a 5-port deploy = 5 reminders to the same signer per cycle). Added eq(documentTemplates.portId, portId) to the join. * All five remaining Documenso webhook handlers (RecipientSigned / Completed / Opened / Rejected / Cancelled) accept and require an optional portId now, with a shared resolveWebhookDocument() helper that refuses to mutate when the lookup is ambiguous across tenants without a resolved port. Tier 5's port-scoping was applied only to Expired; the route now forwards the matched portId to every handler. Tightens the WHERE clauses on subsequent UPDATEs to (id, portId) for defense-in-depth. HIGH * verifyDocumensoSecret rejects when `expected` is empty — timingSafeEqual(0-bytes, 0-bytes) was returning true, so a dev env with a blank DOCUMENSO_WEBHOOK_SECRET would accept a request whose X-Documenso-Secret header was also missing/empty. listDocumensoWebhookSecrets skips the env entry when blank. * /api/public/health — the website-intake-secret comparison was a string `===` (not constant-time). Switched to timingSafeEqual via Buffer.from(). MEDIUM * server.ts SIGTERM ordering — Socket.io closes BEFORE the HTTP drain so long-poll websockets stop holding the server open past the compose stop_grace_period. * /api/v1/me PATCH preferences merge — allow-list filter on the merged JSONB so legacy rows from the old .passthrough() era stop silently re-shipping their bloat to disk. Migration fixes (deploy-blocking) * 0041 referenced `port_role_overrides.permissions` (column is `permission_overrides`) — overrides are partial JSONB and don't need backfilling at all (deepMerge resolves edit from the base role). Removed the override UPDATEs entirely. * 0042 switched all FK + CHECK adds to NOT VALID + VALIDATE so the brief table-lock phase is decoupled from the row-scan validation, giving a cleaner abort-and-restart story if a constraint catches dirty production data. Added a pre-cleanup UPDATE for invoices.billing_entity_id = '' rows (backfills from clientName, falls back to the row id) so the new non-empty CHECK passes on a dirty table. Test status: 1175/1175 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:19:39 +02:00
...portScope,
});
}
break;
}
case 'DOCUMENT_COMPLETED':
fix(audit-verification): regressions found in post-Tier-6 review Two parallel reviews of the Tier 0–6 work surfaced one CRITICAL regression and a handful of remaining cross-tenant gaps that the original audit didn't enumerate. All fixed here: CRITICAL * document-reminders.processReminderQueue — the new bulk-fetch leftJoin to documentTemplates was scoped on `templateType` alone. Templates of the same type exist in every port; the cartesian explosion would have fired one Documenso reminder PER matching template-row per cron tick (a 5-port deploy = 5 reminders to the same signer per cycle). Added eq(documentTemplates.portId, portId) to the join. * All five remaining Documenso webhook handlers (RecipientSigned / Completed / Opened / Rejected / Cancelled) accept and require an optional portId now, with a shared resolveWebhookDocument() helper that refuses to mutate when the lookup is ambiguous across tenants without a resolved port. Tier 5's port-scoping was applied only to Expired; the route now forwards the matched portId to every handler. Tightens the WHERE clauses on subsequent UPDATEs to (id, portId) for defense-in-depth. HIGH * verifyDocumensoSecret rejects when `expected` is empty — timingSafeEqual(0-bytes, 0-bytes) was returning true, so a dev env with a blank DOCUMENSO_WEBHOOK_SECRET would accept a request whose X-Documenso-Secret header was also missing/empty. listDocumensoWebhookSecrets skips the env entry when blank. * /api/public/health — the website-intake-secret comparison was a string `===` (not constant-time). Switched to timingSafeEqual via Buffer.from(). MEDIUM * server.ts SIGTERM ordering — Socket.io closes BEFORE the HTTP drain so long-poll websockets stop holding the server open past the compose stop_grace_period. * /api/v1/me PATCH preferences merge — allow-list filter on the merged JSONB so legacy rows from the old .passthrough() era stop silently re-shipping their bloat to disk. Migration fixes (deploy-blocking) * 0041 referenced `port_role_overrides.permissions` (column is `permission_overrides`) — overrides are partial JSONB and don't need backfilling at all (deepMerge resolves edit from the base role). Removed the override UPDATEs entirely. * 0042 switched all FK + CHECK adds to NOT VALID + VALIDATE so the brief table-lock phase is decoupled from the row-scan validation, giving a cleaner abort-and-restart story if a constraint catches dirty production data. Added a pre-cleanup UPDATE for invoices.billing_entity_id = '' rows (backfills from clientName, falls back to the row id) so the new non-empty CHECK passes on a dirty table. Test status: 1175/1175 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:19:39 +02:00
await handleDocumentCompleted({ documentId: documensoId, ...portScope });
break;
case 'DOCUMENT_REJECTED': {
const rejecting = recipients.find((r) => r.signingStatus === 'REJECTED');
await handleDocumentRejected({
documentId: documensoId,
recipientEmail: rejecting?.email,
signatureHash,
fix(audit-verification): regressions found in post-Tier-6 review Two parallel reviews of the Tier 0–6 work surfaced one CRITICAL regression and a handful of remaining cross-tenant gaps that the original audit didn't enumerate. All fixed here: CRITICAL * document-reminders.processReminderQueue — the new bulk-fetch leftJoin to documentTemplates was scoped on `templateType` alone. Templates of the same type exist in every port; the cartesian explosion would have fired one Documenso reminder PER matching template-row per cron tick (a 5-port deploy = 5 reminders to the same signer per cycle). Added eq(documentTemplates.portId, portId) to the join. * All five remaining Documenso webhook handlers (RecipientSigned / Completed / Opened / Rejected / Cancelled) accept and require an optional portId now, with a shared resolveWebhookDocument() helper that refuses to mutate when the lookup is ambiguous across tenants without a resolved port. Tier 5's port-scoping was applied only to Expired; the route now forwards the matched portId to every handler. Tightens the WHERE clauses on subsequent UPDATEs to (id, portId) for defense-in-depth. HIGH * verifyDocumensoSecret rejects when `expected` is empty — timingSafeEqual(0-bytes, 0-bytes) was returning true, so a dev env with a blank DOCUMENSO_WEBHOOK_SECRET would accept a request whose X-Documenso-Secret header was also missing/empty. listDocumensoWebhookSecrets skips the env entry when blank. * /api/public/health — the website-intake-secret comparison was a string `===` (not constant-time). Switched to timingSafeEqual via Buffer.from(). MEDIUM * server.ts SIGTERM ordering — Socket.io closes BEFORE the HTTP drain so long-poll websockets stop holding the server open past the compose stop_grace_period. * /api/v1/me PATCH preferences merge — allow-list filter on the merged JSONB so legacy rows from the old .passthrough() era stop silently re-shipping their bloat to disk. Migration fixes (deploy-blocking) * 0041 referenced `port_role_overrides.permissions` (column is `permission_overrides`) — overrides are partial JSONB and don't need backfilling at all (deepMerge resolves edit from the base role). Removed the override UPDATEs entirely. * 0042 switched all FK + CHECK adds to NOT VALID + VALIDATE so the brief table-lock phase is decoupled from the row-scan validation, giving a cleaner abort-and-restart story if a constraint catches dirty production data. Added a pre-cleanup UPDATE for invoices.billing_entity_id = '' rows (backfills from clientName, falls back to the row id) so the new non-empty CHECK passes on a dirty table. Test status: 1175/1175 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:19:39 +02:00
...portScope,
});
break;
}
case 'DOCUMENT_CANCELLED':
fix(audit-verification): regressions found in post-Tier-6 review Two parallel reviews of the Tier 0–6 work surfaced one CRITICAL regression and a handful of remaining cross-tenant gaps that the original audit didn't enumerate. All fixed here: CRITICAL * document-reminders.processReminderQueue — the new bulk-fetch leftJoin to documentTemplates was scoped on `templateType` alone. Templates of the same type exist in every port; the cartesian explosion would have fired one Documenso reminder PER matching template-row per cron tick (a 5-port deploy = 5 reminders to the same signer per cycle). Added eq(documentTemplates.portId, portId) to the join. * All five remaining Documenso webhook handlers (RecipientSigned / Completed / Opened / Rejected / Cancelled) accept and require an optional portId now, with a shared resolveWebhookDocument() helper that refuses to mutate when the lookup is ambiguous across tenants without a resolved port. Tier 5's port-scoping was applied only to Expired; the route now forwards the matched portId to every handler. Tightens the WHERE clauses on subsequent UPDATEs to (id, portId) for defense-in-depth. HIGH * verifyDocumensoSecret rejects when `expected` is empty — timingSafeEqual(0-bytes, 0-bytes) was returning true, so a dev env with a blank DOCUMENSO_WEBHOOK_SECRET would accept a request whose X-Documenso-Secret header was also missing/empty. listDocumensoWebhookSecrets skips the env entry when blank. * /api/public/health — the website-intake-secret comparison was a string `===` (not constant-time). Switched to timingSafeEqual via Buffer.from(). MEDIUM * server.ts SIGTERM ordering — Socket.io closes BEFORE the HTTP drain so long-poll websockets stop holding the server open past the compose stop_grace_period. * /api/v1/me PATCH preferences merge — allow-list filter on the merged JSONB so legacy rows from the old .passthrough() era stop silently re-shipping their bloat to disk. Migration fixes (deploy-blocking) * 0041 referenced `port_role_overrides.permissions` (column is `permission_overrides`) — overrides are partial JSONB and don't need backfilling at all (deepMerge resolves edit from the base role). Removed the override UPDATEs entirely. * 0042 switched all FK + CHECK adds to NOT VALID + VALIDATE so the brief table-lock phase is decoupled from the row-scan validation, giving a cleaner abort-and-restart story if a constraint catches dirty production data. Added a pre-cleanup UPDATE for invoices.billing_entity_id = '' rows (backfills from clientName, falls back to the row id) so the new non-empty CHECK passes on a dirty table. Test status: 1175/1175 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:19:39 +02:00
await handleDocumentCancelled({ documentId: documensoId, signatureHash, ...portScope });
break;
case 'DOCUMENT_EXPIRED':
fix(audit-verification): regressions found in post-Tier-6 review Two parallel reviews of the Tier 0–6 work surfaced one CRITICAL regression and a handful of remaining cross-tenant gaps that the original audit didn't enumerate. All fixed here: CRITICAL * document-reminders.processReminderQueue — the new bulk-fetch leftJoin to documentTemplates was scoped on `templateType` alone. Templates of the same type exist in every port; the cartesian explosion would have fired one Documenso reminder PER matching template-row per cron tick (a 5-port deploy = 5 reminders to the same signer per cycle). Added eq(documentTemplates.portId, portId) to the join. * All five remaining Documenso webhook handlers (RecipientSigned / Completed / Opened / Rejected / Cancelled) accept and require an optional portId now, with a shared resolveWebhookDocument() helper that refuses to mutate when the lookup is ambiguous across tenants without a resolved port. Tier 5's port-scoping was applied only to Expired; the route now forwards the matched portId to every handler. Tightens the WHERE clauses on subsequent UPDATEs to (id, portId) for defense-in-depth. HIGH * verifyDocumensoSecret rejects when `expected` is empty — timingSafeEqual(0-bytes, 0-bytes) was returning true, so a dev env with a blank DOCUMENSO_WEBHOOK_SECRET would accept a request whose X-Documenso-Secret header was also missing/empty. listDocumensoWebhookSecrets skips the env entry when blank. * /api/public/health — the website-intake-secret comparison was a string `===` (not constant-time). Switched to timingSafeEqual via Buffer.from(). MEDIUM * server.ts SIGTERM ordering — Socket.io closes BEFORE the HTTP drain so long-poll websockets stop holding the server open past the compose stop_grace_period. * /api/v1/me PATCH preferences merge — allow-list filter on the merged JSONB so legacy rows from the old .passthrough() era stop silently re-shipping their bloat to disk. Migration fixes (deploy-blocking) * 0041 referenced `port_role_overrides.permissions` (column is `permission_overrides`) — overrides are partial JSONB and don't need backfilling at all (deepMerge resolves edit from the base role). Removed the override UPDATEs entirely. * 0042 switched all FK + CHECK adds to NOT VALID + VALIDATE so the brief table-lock phase is decoupled from the row-scan validation, giving a cleaner abort-and-restart story if a constraint catches dirty production data. Added a pre-cleanup UPDATE for invoices.billing_entity_id = '' rows (backfills from clientName, falls back to the row id) so the new non-empty CHECK passes on a dirty table. Test status: 1175/1175 vitest, tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:19:39 +02:00
await handleDocumentExpired({ documentId: documensoId, ...portScope });
break;
default:
logger.info({ event }, 'Unhandled Documenso webhook event type');
}
} catch (err) {
logger.error({ err, event }, 'Error processing Documenso webhook');
}
return NextResponse.json({ ok: true }, { status: 200 });
}