Files
pn-new-crm/src/lib/fetch-with-timeout.ts
Matt Ciaccio 6a609ecf94 fix(audit-tier-1): timeouts, lifecycle, per-port Documenso, FK constraints
Closes the second wave of HIGH-priority audit findings:

* fetchWithTimeout helper (new src/lib/fetch-with-timeout.ts) wraps
  Documenso, OCR, currency, Umami, IMAP, etc. — a hung upstream can
  no longer pin a worker concurrency slot indefinitely.  OpenAI client
  passes timeout: 30_000.  ImapFlow gets socket / greeting / connection
  timeouts.
* SIGTERM / SIGINT handler in src/server.ts drains in-flight HTTP,
  closes Socket.io, and disconnects Redis before exit; compose
  stop_grace_period bumped to 30s.  Adds closeSocketServer() helper.
* env.ts gains zod-validated PORT and MULTI_NODE_DEPLOYMENT, and
  filesystem.ts now reads from env (a typo can no longer silently
  disable the multi-node guard).
* Per-port Documenso template + recipient IDs land in system_settings
  with env fallback (PortDocumensoConfig now exposes eoiTemplateId,
  clientRecipientId, developerRecipientId, approvalRecipientId).
  document-templates.ts uses the per-port config and threads portId
  into documensoGenerateFromTemplate().
* Migration 0042 wires the eleven HIGH-tier missing FK constraints
  (documents/files/interests/reminders/berth_waiting_list/
  form_submissions) plus polymorphic CHECK round 2
  (yacht_ownership_history.owner_type, document_sends.document_kind),
  invoices.billing_entity_id NOT EMPTY, and clients.merged_into self-FK.
  Drizzle schema columns updated to .references(...) where possible
  so the misleading "FK wired in relations.ts" comments are gone.

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §§5,6,7,8,9,10 +
MED §§14,15,16,18.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:52:58 +02:00

71 lines
2.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Fetch with a hard wall-clock timeout. Wraps `globalThis.fetch` with an
* AbortController so a hung upstream cannot pin a worker concurrency slot
* indefinitely (per docs/audit-comprehensive-2026-05-05.md HIGH §§56 and
* MED §13 — Documenso, OCR, and IMAP all needed this).
*
* - Default timeout is 30s; pass `timeoutMs` to override.
* - When the caller already supplies an AbortSignal via `init.signal`, both
* sources can abort the request — first one to fire wins.
* - On timeout the rejection is a `DOMException('TimeoutError')` from
* AbortController; callers can introspect via `err.name === 'AbortError'`
* plus the timeout flag we attach.
*/
export interface FetchWithTimeoutOptions extends RequestInit {
/** Hard timeout in ms. Default 30_000. */
timeoutMs?: number;
}
export class FetchTimeoutError extends Error {
override readonly name = 'FetchTimeoutError';
constructor(
public readonly url: string,
public readonly timeoutMs: number,
) {
super(`Request to ${url} exceeded ${timeoutMs}ms timeout`);
}
}
export async function fetchWithTimeout(
url: string,
init: FetchWithTimeoutOptions = {},
): Promise<Response> {
const { timeoutMs = 30_000, signal: callerSignal, ...rest } = init;
const controller = new AbortController();
// Compose: if the caller already passed a signal, abort our controller
// when theirs aborts. We can't reuse theirs directly because we still
// own the timeout lifecycle.
if (callerSignal) {
if (callerSignal.aborted) {
controller.abort(callerSignal.reason);
} else {
callerSignal.addEventListener('abort', () => controller.abort(callerSignal.reason), {
once: true,
});
}
}
const timeoutId = setTimeout(() => {
controller.abort(new FetchTimeoutError(url, timeoutMs));
}, timeoutMs);
try {
return await fetch(url, { ...rest, signal: controller.signal });
} catch (err) {
// If we hit our own timeout, surface a typed error instead of the
// generic AbortError so call sites can branch on it.
if (
err instanceof Error &&
(err.name === 'AbortError' || err.name === 'TimeoutError') &&
controller.signal.aborted
) {
const reason = controller.signal.reason;
if (reason instanceof FetchTimeoutError) throw reason;
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}