fix(audit): documenso — M2 (reservation EOI-milestone pollution), L11 (v2 numericId GET fallback), L12 (API URL normalize/validate), L13 (event dedup)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:59:07 +02:00
parent 37ffb2c3b4
commit 4084029962
4 changed files with 156 additions and 36 deletions

View File

@@ -459,6 +459,31 @@ export async function generateDocumentFromTemplate(
portId,
).then(normalizeDocument);
// Resolve an authoritative numeric pk. `/template/use` returns the
// `envelope_xxx` string under `id`/`envelopeId` but does NOT reliably
// surface the internal numeric pk, so `created.numericId` is frequently
// null. DOCUMENT_COMPLETED (and other v2 webhooks) carry ONLY that
// numeric pk as `payload.id`, and `resolveWebhookDocument` matches it
// against `documents.documenso_numeric_id`. Persisting null there means
// the webhook resolves against neither column and the completion is
// dropped (signed PDF never downloads, stage never advances, no
// completion email/tenancy) until the poll worker reconciles by
// envelope id. Re-fetch the full envelope (GET /api/v2/envelope/{id})
// when the numeric pk is missing so we persist a non-null value.
let numericId = created.numericId;
if (!numericId) {
try {
const fetched = await getDocument(created.id, portId);
if (fetched.numericId) numericId = fetched.numericId;
} catch (err) {
logger.warn(
{ docId: created.id, err: err instanceof Error ? err.message : err },
'Documenso envelope re-fetch for numericId failed - documenso_numeric_id will be null; ' +
'completion webhooks rely on the poll-worker reconciliation until then.',
);
}
}
const desiredTitle =
typeof v2Payload.title === 'string' && v2Payload.title.length > 0 ? v2Payload.title : null;
// `/template/use` silently drops the `meta` field on the request body -
@@ -571,11 +596,12 @@ export async function generateDocumentFromTemplate(
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,
// from the authoritative numericId resolved above (created response
// or envelope re-fetch) by passing it 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: numericId,
status: 'PENDING',
recipients: distributed.recipients,
});
@@ -585,7 +611,9 @@ export async function generateDocumentFromTemplate(
{ 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;
// Preserve the authoritative numericId resolved above so the persisted
// documenso_numeric_id is non-null even when distribute fails.
return { ...created, numericId };
}
}

View File

@@ -845,34 +845,50 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
// Update interest if linked
if (interest) {
// Always link the Documenso envelope to the interest, regardless of
// document type. The EOI-specific milestone columns + eoi_sent rule are
// gated below so a reservation_agreement send doesn't pollute EOI funnel
// data (dateEoiSent / eoiStatus / eoiDocStatus / eoi stage advance).
await db
.update(interests)
.set({
documensoId: documensoDoc.id,
dateEoiSent: new Date(),
eoiStatus: 'waiting_for_signatures',
updatedAt: new Date(),
})
.where(eq(interests.id, interest.id));
// Trigger berth rules
void evaluateRule('eoi_sent', interest.id, portId, meta);
// EOI documents only: stamp EOI milestones, fire the eoi_sent berth
// rule, and advance to the `eoi` stage. A reservation_agreement is not
// an EOI and must not touch these columns.
if (doc.documentType === 'eoi') {
await db
.update(interests)
.set({
dateEoiSent: new Date(),
eoiStatus: 'waiting_for_signatures',
updatedAt: new Date(),
})
.where(eq(interests.id, interest.id));
// Advance pipeline stage to eoi (no-op if already further along).
// Doc sub-status is set by the webhook receiver when Documenso confirms;
// we stamp eoiDocStatus optimistically here so the UI shows "sent".
void advanceStageIfBehindGated(
interest.id,
portId,
'eoi',
meta,
'EOI sent for signing',
'eoi_sent',
);
await db
.update(interests)
.set({ eoiDocStatus: 'sent', updatedAt: new Date() })
.where(eq(interests.id, interest.id));
// Trigger berth rules
void evaluateRule('eoi_sent', interest.id, portId, meta);
// Advance pipeline stage to eoi (no-op if already further along).
// Doc sub-status is set by the webhook receiver when Documenso confirms;
// we stamp eoiDocStatus optimistically here so the UI shows "sent".
void advanceStageIfBehindGated(
interest.id,
portId,
'eoi',
meta,
'EOI sent for signing',
'eoi_sent',
);
await db
.update(interests)
.set({ eoiDocStatus: 'sent', updatedAt: new Date() })
.where(eq(interests.id, interest.id));
}
// Reservation agreements drive the reservation stage; the contract
// pathway uses its own send call and stamps contractDocStatus.
@@ -1752,11 +1768,21 @@ export async function handleDocumentCompleted(eventData: { documentId: string; p
);
}
await db.insert(documentEvents).values({
documentId: doc.id,
eventType: 'completed',
eventData: { documensoId: eventData.documentId },
});
// Idempotent insert: DOCUMENT_COMPLETED is retried by Documenso on
// receiver 5xx and re-reconciled by the poll worker. There is exactly one
// logical `completed` event per document, so use a deterministic
// per-document hash as the dedup key — the `idx_de_dedup` partial unique
// index on (documentId, signatureHash) + onConflictDoNothing collapses
// retries into a single timeline row instead of accumulating duplicates.
await db
.insert(documentEvents)
.values({
documentId: doc.id,
eventType: 'completed',
signatureHash: `completed:${doc.id}`,
eventData: { documensoId: eventData.documentId },
})
.onConflictDoNothing();
emitToRoom(`port:${doc.portId}`, 'document:completed', { documentId: doc.id });
@@ -1915,6 +1941,11 @@ export async function handleDocumentOpened(eventData: {
documentId: doc.id,
eventType: 'viewed',
signerId: signer?.id ?? null,
// recipient_email is the key for idx_de_per_recipient_dedup (partial
// unique on (documentId, recipientEmail, eventType) WHERE
// recipientEmail IS NOT NULL). Without it the index never engages and
// v2 multi-delivery RECIPIENT_VIEWED opens can't dedup.
recipientEmail: eventData.recipientEmail ?? null,
signatureHash: eventData.signatureHash ?? null,
eventData: { recipientEmail: eventData.recipientEmail },
})

View File

@@ -10,6 +10,7 @@
import { env } from '@/lib/env';
import { normalizeBrandingUrl } from '@/lib/branding/url';
import { getSetting } from '@/lib/services/settings.service';
import { normalizeDocumensoApiUrl } from '@/lib/validators/settings';
// ─── Setting key constants ───────────────────────────────────────────────────
@@ -430,18 +431,28 @@ export async function getPortDocumensoConfig(portId: string): Promise<PortDocume
readSetting<string>(SETTING_KEYS.publicSiteUrl, portId),
]);
// Defense-in-depth: normalize any persisted Documenso API URL override at
// read time. Writes are normalized + URL-validated by upsertSettingSchema,
// but values stored before that validation landed (or seeded directly) may
// still carry a `/api/v1`|`/api/v2` suffix that would double-path requests
// (documensoFetch appends `/api/vN/...` to this base). Strip it here too.
const normalizedApiUrl =
typeof apiUrl === 'string' && apiUrl.length > 0
? (normalizeDocumensoApiUrl(apiUrl) as string)
: apiUrl;
// Determine the resolution source for the two credentials. Used by
// the documenso client to enrich 401/403 error messages so operators
// can tell at a glance whether the failing key is per-port or env.
type Source = 'port' | 'global' | 'env' | 'default' | 'none';
const apiUrlSource: Source = apiUrl ? 'port' : env.DOCUMENSO_API_URL ? 'env' : 'none';
const apiUrlSource: Source = normalizedApiUrl ? 'port' : env.DOCUMENSO_API_URL ? 'env' : 'none';
const apiKeySource: Source = apiKey ? 'port' : env.DOCUMENSO_API_KEY ? 'env' : 'none';
return {
// Env values are now optional (admin is canonical). Empty / zero
// defaults let consumers proceed and fail at the actual API call with
// a clearer "not configured" error rather than crashing at type-check.
apiUrl: apiUrl ?? env.DOCUMENSO_API_URL ?? '',
apiUrl: normalizedApiUrl ?? env.DOCUMENSO_API_URL ?? '',
apiKey: apiKey ?? env.DOCUMENSO_API_KEY ?? '',
apiVersion: apiVersion ?? env.DOCUMENSO_API_VERSION,
apiKeySource,