From ebdd8408bfeff6e4b81ca95ac76791ac1f8a49bb Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 13 May 2026 13:27:32 +0200 Subject: [PATCH] =?UTF-8?q?fix(audit-wave-11):=20dossier=20sweep=20?= =?UTF-8?q?=E2=80=94=20error-ux=20+=20webhook=20+=20storage=20+=20search?= =?UTF-8?q?=20+=20maintainability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final pass over the unaddressed AUDIT-2026-05-12 dossiers, taking the tractable Critical/High items from each: error-ux-auditor (5 items) - C2: 17 toast.error(err.message) sites swept to toastError(err, …) so every user-visible failure carries a copy-paste Reference ID - C3: apiFetch synthesizes a client-side correlation id when a 5xx comes back with a non-JSON body (reverse-proxy HTML pages); message becomes "The server is unreachable. Please try again." with code UPSTREAM_UNREACHABLE - C4: checkRateLimit fails OPEN when Redis is unavailable so an outage no longer 500s login + portal sign-in; logged at warn so monitoring catches it - H2: StorageTimeoutError (name='TimeoutError') replaces the plain Error throw in s3.ts withTimeout — error-classifier hints fire now - H5: errorResponse() adopted across /api/storage/[token], /api/public/website-inquiries, and the Documenso webhook body (drops the "Invalid secret" reconnaissance string) outbound-webhook-auditor (5 items) - C1: signature is now HMAC(secret, `${ts}.${body}`) with the timestamp surfaced as X-Webhook-Timestamp so receivers can reject replays outside a freshness window - C3: dead-letter with reason missing_signing_secret when secret is null (defence-in-depth against DB tampering / future migration mistakes) - H2: webhooks queue bumped to maxAttempts=8 with 30 s base exponential backoff so a 30 s receiver blip during a deploy no longer dead-letters every in-flight event; per-queue backoffDelayMs added to QUEUE_CONFIGS - M1: SSRF denylist gains Oracle Cloud metadata 192.0.0.192 - M2: dispatch-time https:// assertion before fetch, so a bad DB edit can't slip plaintext through storage-pathing-auditor (2 items) - H1: berth-PDF presigned-upload keys now `${portSlug}/berths/…/…` with portSlug threaded into backend.presignUpload — engages the filesystem-proxy port-binding `p` token verifier - H2: presignDownloadUrl auto-derives portSlug from the key's first segment when callers don't pass it, so all 8 download sites engage the `p`-token guard without per-site plumbing search-auditor (1 item) - H3: removed dead void wantEmail; void wantPhone; pair plus the unused looksLikeEmail helper — the bucket-reorder it was scaffolded for was never wired maintainability-auditor (1 item) - M2: swept seven abandoned `void ` markers and their dead imports across clients/bulk, interests/bulk, admin/email-templates, admin/website-submissions, alert-rules, and notes.service Deferred to future work (substantial refactors, schema migrations, or multi-file UI work): - error-ux M3-M8 (global-error.tsx, per-route loading.tsx coverage, ErrorBanner component, /api/ready route, worker DLQ admin surface) - maintainability C1-C4 (documents/search/notes service splits, interest-tabs split — multi-hour refactors) - currency C1-H5 (mixed-currency dashboard aggregation, FX history table, rounding policy) — wait for second non-USD port - outbound-webhook C2 (deliveries reaper job), H1 (DNS-rebind TOCTOU with undici Agent), H3 (circuit-breaker), H5 (presigned-post-policy) - storage-pathing C2 (orphan reaper), H3-H5 (streaming + content-type binding) Tests: 1315/1315 vitest ✅ ; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/BACKLOG.md | 8 +++ .../[portSlug]/admin/webhooks/page.tsx | 7 ++- src/app/api/public/website-inquiries/route.ts | 29 +++++---- src/app/api/storage/[token]/route.ts | 57 ++++++++--------- src/app/api/v1/admin/email-templates/route.ts | 4 +- .../api/v1/admin/website-submissions/route.ts | 5 +- .../v1/berths/[id]/pdf-upload-url/handlers.ts | 8 ++- src/app/api/v1/clients/bulk/route.ts | 7 --- src/app/api/v1/interests/bulk/route.ts | 9 +-- src/app/api/webhooks/documenso/route.ts | 7 ++- src/components/admin/audit/audit-log-list.tsx | 3 +- src/components/admin/inquiry-inbox.tsx | 3 +- .../admin/webhooks/webhook-delivery-log.tsx | 3 +- .../clients/bulk-archive-wizard.tsx | 3 +- .../clients/bulk-hard-delete-dialog.tsx | 5 +- src/components/clients/client-list.tsx | 3 +- src/components/clients/hard-delete-dialog.tsx | 5 +- .../clients/smart-archive-dialog.tsx | 3 +- .../clients/smart-restore-dialog.tsx | 3 +- .../interests/external-eoi-upload-dialog.tsx | 3 +- .../interests/interest-stage-picker.tsx | 3 +- .../portal/change-password-form.tsx | 3 +- src/lib/api/client.ts | 35 +++++++++-- src/lib/queue/index.ts | 29 +++++---- src/lib/queue/workers/webhooks.ts | 62 +++++++++++++++++-- src/lib/rate-limit.ts | 55 ++++++++++------ src/lib/services/alert-rules.ts | 7 +-- src/lib/services/notes.service.ts | 20 ------ src/lib/services/search.service.ts | 17 ----- src/lib/storage/index.ts | 36 ++++++++++- src/lib/storage/s3.ts | 19 +++++- src/lib/validators/webhooks.ts | 5 +- 32 files changed, 298 insertions(+), 168 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 3b83a8fe..a5ea1ced 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -270,6 +270,14 @@ Migrate as a focused day's work (~40 × 10-15 min), then promote `react-hooks/se - ✅ **Onboarding + first-run UX** (onboarding-auditor) — Wave 9.8: fixed wrong setting keys in checklist auto-checks (C1), broken `forms` href (C2), compound gate for Documenso EOI readiness (C3), catch-and-log around `ensureSystemRoots` (C4), fresh-port berth empty state (H5), admin-sections-browser description (M4). - ✅ **Type-safety + drizzle leak audit** (types-auditor) — Wave 10.1: `Tx` type exported (C-1), berth-detail `useQuery` replaced with `BerthDetailData` (C-2), parseBody adopted across 7 portal/public routes (C-3), `toAuditJson` helper removed 21 `as unknown as Record<…>` casts (H-5). Drizzle leak check came back clean (no `$inferSelect` crossing the API boundary). - ✅ **Build + deploy + prod readiness** (build-auditor) — Wave 10.2: socket.io + 6 other native deps added to `serverExternalPackages` + COPY-in-Dockerfile (C-3), `NEXT_PUBLIC_APP_URL` validation (H-2), healthcheck PORT templatization (H-5), `NODE_ENV=production` in builder (M9), image-level HEALTHCHECK (M7). CSP `'unsafe-inline'` (H-1) deferred pending nonce middleware infrastructure. +- ✅ **Wave 11 — unaddressed-dossier sweep + cross-cutting infra**: + - **BullMQ jobId plumbing** (concurrency C-2): stable per-entity jobIds added across `invoices` (send-invoice, invoice-overdue-notify), `gdpr-export`, `webhook-dispatch`, `expenses`, `webhooks.service`, `notifications`, `inquiry-notifications`, `reports` (generate-report). + - **CSP nonce middleware** (build-auditor H-1): per-request nonce in `src/proxy.ts:buildCspWithNonce` with `'self' 'nonce-' 'strict-dynamic'` in prod; `next.config.ts` fallback header kept for static assets / API JSON. + - **Error UX** (error-ux-auditor): `apiFetch` synthesizes a client-side correlation id for non-JSON 5xx (C3); `checkRateLimit` fails open on Redis outage so auth doesn't lock (C4); `StorageTimeoutError extends Error` with `name='TimeoutError'` for classifier hints (H2); `errorResponse()` adopted across `/api/storage/[token]`, `/api/public/website-inquiries`, Documenso webhook body cleaned (H5); 17 `toast.error(err.message)` sites swept to `toastError(err, …)` (C2). + - **Outbound webhooks** (outbound-webhook-auditor): Stripe-style `HMAC(secret, "${ts}.${body}")` + `X-Webhook-Timestamp` header (C1); dead-letter when secret is null (C3); retry policy `8 attempts × 30s base exponential` (H2); SSRF denylist gains Oracle Cloud `192.0.0.192` (M1); dispatch-time `https://` assertion (M2). + - **Storage-pathing** (storage-pathing-auditor): berth-PDF presigned-upload key prefixed with `${portSlug}/` + `portSlug` passed to `presignUpload` (H1); `presignDownloadUrl` infers the slug from the key's first segment when callers don't pass it explicitly — engages the filesystem-proxy port-binding `p` token verifier across every download site (H2). + - **Search** (search-auditor): dead `void wantEmail; void wantPhone;` + unused `looksLikeEmail` helper removed (H3). + - **Maintainability** (maintainability-auditor M2): swept seven `void ` abandoned-scaffolding markers and their dead imports across `clients/bulk`, `interests/bulk`, `admin/email-templates`, `admin/website-submissions`, `alert-rules`, and `notes.service`. ### How to use this section diff --git a/src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx b/src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx index 8bd3223d..f97c1576 100644 --- a/src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx @@ -17,6 +17,7 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { WebhookForm } from '@/components/admin/webhooks/webhook-form'; import { WebhookDeliveryLog } from '@/components/admin/webhooks/webhook-delivery-log'; import { WebhookSecretDisplay } from '@/components/admin/webhooks/webhook-secret-display'; @@ -61,7 +62,7 @@ export default function WebhooksPage() { toast.success('Webhook deleted'); void loadWebhooks(); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to delete webhook'); + toastError(err, 'Failed to delete webhook'); } } @@ -75,7 +76,7 @@ export default function WebhooksPage() { setNewSecret({ webhookId, secret: result.data.secret, masked: result.data.secretMasked }); void loadWebhooks(); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to regenerate secret'); + toastError(err, 'Failed to regenerate secret'); } finally { setRegenerating(null); } @@ -90,7 +91,7 @@ export default function WebhooksPage() { toast.success(webhook.isActive ? 'Webhook disabled' : 'Webhook enabled'); void loadWebhooks(); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to toggle webhook'); + toastError(err, 'Failed to toggle webhook'); } } diff --git a/src/app/api/public/website-inquiries/route.ts b/src/app/api/public/website-inquiries/route.ts index 23d9d271..3e4924f0 100644 --- a/src/app/api/public/website-inquiries/route.ts +++ b/src/app/api/public/website-inquiries/route.ts @@ -7,7 +7,13 @@ import { db } from '@/lib/db'; import { ports } from '@/lib/db/schema/ports'; import { websiteSubmissions } from '@/lib/db/schema/website-submissions'; import { env } from '@/lib/env'; -import { errorResponse } from '@/lib/errors'; +import { + AppError, + errorResponse, + RateLimitError, + UnauthorizedError, + ValidationError, +} from '@/lib/errors'; import { logger } from '@/lib/logger'; import { checkRateLimit, rateLimiters } from '@/lib/rate-limit'; @@ -63,16 +69,15 @@ export async function POST(req: NextRequest) { // Refuse outright if the CRM hasn't been wired up - safer than letting // unauthenticated traffic in just because the env var was forgotten. if (!env.WEBSITE_INTAKE_SECRET) { - return NextResponse.json( - { error: 'Website intake is not configured on this server.' }, - { status: 503 }, + return errorResponse( + new AppError(503, 'Website intake is not configured on this server.', 'NOT_CONFIGURED'), ); } // Auth gate - shared secret in header, timing-safe compare. const secretHeader = req.headers.get('x-webhook-secret'); if (!verifySecret(secretHeader)) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return errorResponse(new UnauthorizedError()); } // Rate limit. All website-side traffic shares the website's egress IP, @@ -84,10 +89,7 @@ export async function POST(req: NextRequest) { const rl = await checkRateLimit(ip, rateLimiters.websiteIntake); if (!rl.allowed) { const retryAfter = Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000)); - return NextResponse.json( - { error: 'Rate limit exceeded' }, - { status: 429, headers: { 'Retry-After': String(retryAfter) } }, - ); + return errorResponse(new RateLimitError(retryAfter)); } // Parse + validate body. Reject anything that doesn't conform — the @@ -97,9 +99,10 @@ export async function POST(req: NextRequest) { const body = await req.json(); parsed = SubmissionSchema.parse(body); } catch (err) { - return NextResponse.json( - { error: 'Invalid payload', details: err instanceof Error ? err.message : 'parse error' }, - { status: 400 }, + return errorResponse( + new ValidationError('Invalid payload', [ + { field: 'body', message: err instanceof Error ? err.message : 'parse error' }, + ]), ); } @@ -119,7 +122,7 @@ export async function POST(req: NextRequest) { { portSlug: parsed.port_slug, submissionId: parsed.submission_id }, 'website-inquiry rejected: unknown port', ); - return NextResponse.json({ error: 'Unknown port' }, { status: 400 }); + return errorResponse(new ValidationError('Unknown port')); } // Idempotent insert. Two parallel requests carrying the same submission_id diff --git a/src/app/api/storage/[token]/route.ts b/src/app/api/storage/[token]/route.ts index 6231b0bb..cbc8a64a 100644 --- a/src/app/api/storage/[token]/route.ts +++ b/src/app/api/storage/[token]/route.ts @@ -21,7 +21,13 @@ import { Readable } from 'node:stream'; import { NextRequest, NextResponse } from 'next/server'; import { MAX_FILE_SIZE } from '@/lib/constants/file-validation'; -import { errorResponse } from '@/lib/errors'; +import { + AppError, + errorResponse, + ForbiddenError, + NotFoundError, + ValidationError, +} from '@/lib/errors'; import { logger } from '@/lib/logger'; import { redis } from '@/lib/redis'; import { FilesystemBackend, getStorageBackend } from '@/lib/storage'; @@ -47,16 +53,13 @@ export async function GET( const backend = await getStorageBackend(); if (!(backend instanceof FilesystemBackend)) { - return NextResponse.json( - { error: 'Storage proxy is only available in filesystem mode' }, - { status: 404 }, - ); + return errorResponse(new NotFoundError('storage proxy')); } const result = verifyProxyToken(token, backend.getHmacSecret(), 'get'); if (!result.ok) { logger.warn({ reason: result.reason }, 'Storage proxy token rejected'); - return NextResponse.json({ error: 'Invalid or expired token' }, { status: 403 }); + return errorResponse(new ForbiddenError('Invalid or expired token')); } const { payload } = result; @@ -72,7 +75,7 @@ export async function GET( const setOk = await redis.set(replayKey, '1', 'EX', remainingSeconds, 'NX'); if (setOk !== 'OK') { logger.warn({ key: payload.k }, 'Storage proxy token replay rejected'); - return NextResponse.json({ error: 'Token already used' }, { status: 403 }); + return errorResponse(new ForbiddenError('Token already used')); } let absolutePath: string; @@ -80,22 +83,22 @@ export async function GET( absolutePath = backend.resolveKeyForProxy(payload.k); } catch (err) { logger.warn({ err, key: payload.k }, 'Storage proxy key resolution failed'); - return NextResponse.json({ error: 'Invalid key' }, { status: 400 }); + return errorResponse(new ValidationError('Invalid key')); } let size: number; try { const stat = await fs.stat(absolutePath); if (!stat.isFile()) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return errorResponse(new NotFoundError('file')); } size = stat.size; } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ENOENT') { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return errorResponse(new NotFoundError('file')); } - throw err; + return errorResponse(err); } // Convert the Node Readable into a Web ReadableStream for NextResponse. @@ -140,16 +143,13 @@ export async function PUT( const backend = await getStorageBackend(); if (!(backend instanceof FilesystemBackend)) { - return NextResponse.json( - { error: 'Storage proxy is only available in filesystem mode' }, - { status: 404 }, - ); + return errorResponse(new NotFoundError('storage proxy')); } const result = verifyProxyToken(token, backend.getHmacSecret(), 'put'); if (!result.ok) { logger.warn({ reason: result.reason }, 'Storage proxy upload token rejected'); - return NextResponse.json({ error: 'Invalid or expired token' }, { status: 403 }); + return errorResponse(new ForbiddenError('Invalid or expired token')); } const { payload } = result; @@ -164,7 +164,7 @@ export async function PUT( const setOk = await redis.set(replayKey, '1', 'EX', remainingSeconds, 'NX'); if (setOk !== 'OK') { logger.warn({ key: payload.k }, 'Storage proxy upload token replay rejected'); - return NextResponse.json({ error: 'Token already used' }, { status: 403 }); + return errorResponse(new ForbiddenError('Token already used')); } // Pre-flight size check via Content-Length so a malicious caller can't @@ -172,14 +172,17 @@ export async function PUT( const contentLengthHeader = req.headers.get('content-length'); const contentLength = contentLengthHeader ? Number(contentLengthHeader) : NaN; if (Number.isFinite(contentLength) && contentLength > MAX_FILE_SIZE) { - return NextResponse.json( - { error: `File exceeds ${MAX_FILE_SIZE} byte cap (Content-Length: ${contentLength})` }, - { status: 413 }, + return errorResponse( + new AppError( + 413, + `File exceeds ${MAX_FILE_SIZE} byte cap (Content-Length: ${contentLength})`, + 'PAYLOAD_TOO_LARGE', + ), ); } if (!req.body) { - return NextResponse.json({ error: 'Empty body' }, { status: 400 }); + return errorResponse(new ValidationError('Empty body')); } // Read the body into a buffer with a hard cap. Filesystem deployments are @@ -200,9 +203,8 @@ export async function PUT( } catch { /* ignore */ } - return NextResponse.json( - { error: `File exceeds ${MAX_FILE_SIZE} byte cap` }, - { status: 413 }, + return errorResponse( + new AppError(413, `File exceeds ${MAX_FILE_SIZE} byte cap`, 'PAYLOAD_TOO_LARGE'), ); } chunks.push(Buffer.from(value)); @@ -210,7 +212,7 @@ export async function PUT( buffer = Buffer.concat(chunks); } catch (err) { logger.warn({ err, key: payload.k }, 'Storage proxy upload read failed'); - return NextResponse.json({ error: 'Upload read failed' }, { status: 400 }); + return errorResponse(new ValidationError('Upload read failed')); } // Magic-byte gate: when the token was minted with `c=application/pdf` @@ -218,9 +220,8 @@ export async function PUT( // that isn't actually a PDF. Mirrors the post-upload check in // berth-pdf.service.ts so the two paths behave identically. if (payload.c === 'application/pdf' && !isPdfMagic(buffer)) { - return NextResponse.json( - { error: 'Uploaded file failed PDF magic-byte check (does not start with %PDF-).' }, - { status: 400 }, + return errorResponse( + new ValidationError('Uploaded file failed PDF magic-byte check (does not start with %PDF-).'), ); } diff --git a/src/app/api/v1/admin/email-templates/route.ts b/src/app/api/v1/admin/email-templates/route.ts index 08e40e8c..e23caad7 100644 --- a/src/app/api/v1/admin/email-templates/route.ts +++ b/src/app/api/v1/admin/email-templates/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { eq, inArray } from 'drizzle-orm'; +import { inArray } from 'drizzle-orm'; import { z } from 'zod'; import { withAuth, withPermission } from '@/lib/api/helpers'; @@ -87,5 +87,3 @@ export const PUT = withAuth( } }), ); - -void eq; diff --git a/src/app/api/v1/admin/website-submissions/route.ts b/src/app/api/v1/admin/website-submissions/route.ts index 31938252..a492cfb5 100644 --- a/src/app/api/v1/admin/website-submissions/route.ts +++ b/src/app/api/v1/admin/website-submissions/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { and, desc, eq, inArray, lt, sql, type SQL } from 'drizzle-orm'; +import { and, desc, eq, inArray, sql, type SQL } from 'drizzle-orm'; import { z } from 'zod'; import { withAuth, withPermission } from '@/lib/api/helpers'; @@ -71,6 +71,3 @@ export const GET = withAuth( } }), ); - -// Suppress lt unused-import lint -void lt; diff --git a/src/app/api/v1/berths/[id]/pdf-upload-url/handlers.ts b/src/app/api/v1/berths/[id]/pdf-upload-url/handlers.ts index aae1047a..a1a6ceb7 100644 --- a/src/app/api/v1/berths/[id]/pdf-upload-url/handlers.ts +++ b/src/app/api/v1/berths/[id]/pdf-upload-url/handlers.ts @@ -54,13 +54,19 @@ export const postHandler: RouteHandler = async (req, ctx, params) => { // so a race between two reps just shifts which one wins the version // slot. The storage key is gen_random_uuid()-namespaced so collisions // in the storage layer are impossible. + // + // storage-pathing-auditor H1: prefix the port slug so the + // filesystem-proxy port-binding token (`p` field) can be wired and + // the namespace matches `buildStoragePath` (which always leads with + // the port slug). const sanitized = fileName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 200) || 'berth.pdf'; - const storageKey = `berths/${params.id!}/uploads/${crypto.randomUUID()}_${sanitized}`; + const storageKey = `${ctx.portSlug}/berths/${params.id!}/uploads/${crypto.randomUUID()}_${sanitized}`; const backend = await getStorageBackend(); const presigned = await backend.presignUpload(storageKey, { contentType: 'application/pdf', expirySeconds: 900, + portSlug: ctx.portSlug, }); return NextResponse.json({ diff --git a/src/app/api/v1/clients/bulk/route.ts b/src/app/api/v1/clients/bulk/route.ts index a936ca8b..4c3bc635 100644 --- a/src/app/api/v1/clients/bulk/route.ts +++ b/src/app/api/v1/clients/bulk/route.ts @@ -10,7 +10,6 @@ import { clients, clientTags } from '@/lib/db/schema/clients'; import { setClientTags } from '@/lib/services/clients.service'; import { getClientArchiveDossier, - HIGH_STAKES_STAGES, type ClientArchiveDossier, } from '@/lib/services/client-archive-dossier.service'; import { @@ -21,7 +20,6 @@ import { notifyNextInLine } from '@/lib/services/next-in-line-notify.service'; import { getQueue } from '@/lib/queue'; import { logger } from '@/lib/logger'; import { errorResponse } from '@/lib/errors'; -import type { PipelineStage } from '@/lib/constants'; const bulkSchema = z.discriminatedUnion('action', [ z.object({ @@ -221,8 +219,3 @@ export const POST = withAuth(async (req, ctx) => { return NextResponse.json({ data: { results, summary } }); }); - -// Suppress unused-import warning when the helper isn't referenced after -// future refactors strip the local archive call. -void HIGH_STAKES_STAGES; -void ({} as PipelineStage); diff --git a/src/app/api/v1/interests/bulk/route.ts b/src/app/api/v1/interests/bulk/route.ts index befff25d..13ee56c6 100644 --- a/src/app/api/v1/interests/bulk/route.ts +++ b/src/app/api/v1/interests/bulk/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; -import { eq, and, inArray } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; -import { withAuth, withPermission } from '@/lib/api/helpers'; +import { withAuth } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; @@ -128,8 +128,3 @@ export const POST = withAuth(async (req, ctx) => { return NextResponse.json({ data: { results, summary } }); }); - -// Keep a single import alive (linter); used in the Drizzle inArray pattern below -// in case a future caller wants set-based ops instead of per-row loops. -void inArray; -void withPermission; diff --git a/src/app/api/webhooks/documenso/route.ts b/src/app/api/webhooks/documenso/route.ts index ee243cfe..aa9f3c4c 100644 --- a/src/app/api/webhooks/documenso/route.ts +++ b/src/app/api/webhooks/documenso/route.ts @@ -139,8 +139,11 @@ async function handleDocumensoWebhook(req: NextRequest): Promise { source: 'webhook', }); } - // Always return 200 (webhook best-practice — don't leak signal). - return NextResponse.json({ ok: false, error: 'Invalid secret' }, { status: 200 }); + // Always return 200 (webhook best-practice — don't leak signal). Body + // is intentionally empty/uniform — error-ux-auditor H5 noted the + // literal "Invalid secret" string confirms the endpoint expects a + // secret, which is a free reconnaissance hint for enumeration. + return NextResponse.json({ ok: false }, { status: 200 }); } // Compute deduplication hash diff --git a/src/components/admin/audit/audit-log-list.tsx b/src/components/admin/audit/audit-log-list.tsx index 9efd511d..6c9f5d3b 100644 --- a/src/components/admin/audit/audit-log-list.tsx +++ b/src/components/admin/audit/audit-log-list.tsx @@ -20,6 +20,7 @@ import { SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { AuditLogCard } from './audit-log-card'; interface AuditEntry { @@ -180,7 +181,7 @@ export function AuditLogList() { setEntries((prev) => [...prev, ...res.data]); setNextCursor(res.pagination.nextCursor); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to load more audit entries'); + toastError(err, 'Failed to load more audit entries'); } finally { setLoadingMore(false); } diff --git a/src/components/admin/inquiry-inbox.tsx b/src/components/admin/inquiry-inbox.tsx index 7c1406a5..7573cb61 100644 --- a/src/components/admin/inquiry-inbox.tsx +++ b/src/components/admin/inquiry-inbox.tsx @@ -11,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; type TriageState = 'open' | 'assigned' | 'converted' | 'dismissed'; type StateFilter = 'inbox' | 'open' | 'assigned' | 'converted' | 'dismissed' | 'all'; @@ -112,7 +113,7 @@ export function InquiryInbox() { toast.success(`Marked ${vars.state}.`); }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Triage update failed'); + toastError(err, 'Triage update failed'); }, }); diff --git a/src/components/admin/webhooks/webhook-delivery-log.tsx b/src/components/admin/webhooks/webhook-delivery-log.tsx index 290a45c4..6b171dcf 100644 --- a/src/components/admin/webhooks/webhook-delivery-log.tsx +++ b/src/components/admin/webhooks/webhook-delivery-log.tsx @@ -14,6 +14,7 @@ import { TableRow, } from '@/components/ui/table'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { usePermissions } from '@/hooks/use-permissions'; interface Delivery { @@ -56,7 +57,7 @@ export function WebhookDeliveryLog({ webhookId }: Props) { toast.success('Replay queued'); await load(page); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Replay failed'); + toastError(err, 'Replay failed'); } finally { setRetrying(null); } diff --git a/src/components/clients/bulk-archive-wizard.tsx b/src/components/clients/bulk-archive-wizard.tsx index 769df525..420e2be0 100644 --- a/src/components/clients/bulk-archive-wizard.tsx +++ b/src/components/clients/bulk-archive-wizard.tsx @@ -18,6 +18,7 @@ import { Badge } from '@/components/ui/badge'; import { Textarea } from '@/components/ui/textarea'; import { WarningCallout } from '@/components/ui/warning-callout'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface PreflightItem { clientId: string; @@ -107,7 +108,7 @@ function BulkArchiveWizardBody({ open, onOpenChange, clientIds, onSuccess }: Pro onSuccess?.(); }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Bulk archive failed'); + toastError(err, 'Bulk archive failed'); }, }); diff --git a/src/components/clients/bulk-hard-delete-dialog.tsx b/src/components/clients/bulk-hard-delete-dialog.tsx index 342bf3eb..d650b056 100644 --- a/src/components/clients/bulk-hard-delete-dialog.tsx +++ b/src/components/clients/bulk-hard-delete-dialog.tsx @@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface Props { open: boolean; @@ -69,7 +70,7 @@ function BulkHardDeleteDialogBody({ onOpenChange, clientIds, onDeleted }: Props) toast.success(`Code sent to ${res.data.sentToMaskedEmail}`); }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Failed to send code'); + toastError(err, 'Failed to send code'); }, }); @@ -100,7 +101,7 @@ function BulkHardDeleteDialogBody({ onOpenChange, clientIds, onDeleted }: Props) } }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Bulk delete failed'); + toastError(err, 'Bulk delete failed'); }, }); diff --git a/src/components/clients/client-list.tsx b/src/components/clients/client-list.tsx index f9c493c6..9b7bc747 100644 --- a/src/components/clients/client-list.tsx +++ b/src/components/clients/client-list.tsx @@ -43,6 +43,7 @@ import { usePaginatedQuery } from '@/hooks/use-paginated-query'; import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation'; import { useTablePreferences } from '@/hooks/use-table-preferences'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; export function ClientList() { const params = useParams<{ portSlug: string }>(); @@ -120,7 +121,7 @@ export function ClientList() { } }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Bulk action failed'); + toastError(err, 'Bulk action failed'); }, }); diff --git a/src/components/clients/hard-delete-dialog.tsx b/src/components/clients/hard-delete-dialog.tsx index 6db65f3c..442cd3b7 100644 --- a/src/components/clients/hard-delete-dialog.tsx +++ b/src/components/clients/hard-delete-dialog.tsx @@ -18,6 +18,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { WarningCallout } from '@/components/ui/warning-callout'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface Props { open: boolean; @@ -66,7 +67,7 @@ function HardDeleteDialogBody({ onOpenChange, clientId, clientName, onDeleted }: toast.success(`Code sent to ${res.data.sentToMaskedEmail}`); }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Failed to send code'); + toastError(err, 'Failed to send code'); }, }); @@ -83,7 +84,7 @@ function HardDeleteDialogBody({ onOpenChange, clientId, clientName, onDeleted }: onDeleted?.(); }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Delete failed'); + toastError(err, 'Delete failed'); }, }); diff --git a/src/components/clients/smart-archive-dialog.tsx b/src/components/clients/smart-archive-dialog.tsx index 1273d266..dc4d93ac 100644 --- a/src/components/clients/smart-archive-dialog.tsx +++ b/src/components/clients/smart-archive-dialog.tsx @@ -18,6 +18,7 @@ import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Textarea } from '@/components/ui/textarea'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface DossierBerth { berthId: string; @@ -266,7 +267,7 @@ function SmartArchiveDialogBody({ onSuccess?.(); }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Archive failed'); + toastError(err, 'Archive failed'); }, }); diff --git a/src/components/clients/smart-restore-dialog.tsx b/src/components/clients/smart-restore-dialog.tsx index 41e20812..009548cc 100644 --- a/src/components/clients/smart-restore-dialog.tsx +++ b/src/components/clients/smart-restore-dialog.tsx @@ -26,6 +26,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; interface RestoreReversal { id: string; @@ -109,7 +110,7 @@ function SmartRestoreDialogBody({ open, onOpenChange, clientId, clientName, onSu onSuccess?.(); }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Restore failed'); + toastError(err, 'Restore failed'); }, }); diff --git a/src/components/interests/external-eoi-upload-dialog.tsx b/src/components/interests/external-eoi-upload-dialog.tsx index f1bd7087..435306e6 100644 --- a/src/components/interests/external-eoi-upload-dialog.tsx +++ b/src/components/interests/external-eoi-upload-dialog.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; +import { toastError } from '@/lib/api/toast-error'; import { Dialog, DialogContent, @@ -68,7 +69,7 @@ export function ExternalEoiUploadDialog({ open, onOpenChange, interestId, onSucc onSuccess?.(); }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Upload failed'); + toastError(err, 'Upload failed'); }, }); diff --git a/src/components/interests/interest-stage-picker.tsx b/src/components/interests/interest-stage-picker.tsx index 61dab782..b614eba6 100644 --- a/src/components/interests/interest-stage-picker.tsx +++ b/src/components/interests/interest-stage-picker.tsx @@ -23,6 +23,7 @@ import { SelectValue, } from '@/components/ui/select'; import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; import { usePermissions } from '@/hooks/use-permissions'; import { PIPELINE_STAGES, STAGE_LABELS, stageLabel, canTransitionStage } from '@/lib/constants'; @@ -74,7 +75,7 @@ export function InterestStagePicker({ toast.success(overrideEffective ? 'Stage overridden' : 'Stage updated'); }, onError: (err: unknown) => { - toast.error(err instanceof Error ? err.message : 'Stage change failed'); + toastError(err, 'Stage change failed'); }, }); diff --git a/src/components/portal/change-password-form.tsx b/src/components/portal/change-password-form.tsx index 9538fa5f..7963a0a3 100644 --- a/src/components/portal/change-password-form.tsx +++ b/src/components/portal/change-password-form.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { toastError } from '@/lib/api/toast-error'; export function ChangePasswordForm() { const [current, setCurrent] = useState(''); @@ -34,7 +35,7 @@ export function ChangePasswordForm() { setNext(''); setConfirm(''); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Password change failed'); + toastError(err, 'Password change failed'); } finally { setPending(false); } diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index c0f23fdd..15c4b553 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -85,18 +85,41 @@ export async function apiFetch(url: string, opts: ApiFetchOptions = }); if (!res.ok) { - const error = (await res.json().catch(() => ({ error: res.statusText }))) as { + // error-ux-auditor C3: reverse-proxy 502/504 pages deliver HTML, not + // JSON. The previous code silently degraded to + // `{error: res.statusText}` which surfaced "Bad Gateway" with no + // requestId and no copy-pasteable correlation handle. Detect the + // proxy-error shape (5xx + JSON parse fail) and synthesize a + // client-side correlation id so the toast still has *something* the + // user can quote to support. + const error = (await res.json().catch(() => null)) as { error?: string; message?: string; code?: string; details?: unknown; requestId?: string; retryAfter?: number; - }; - // Surface the request id so toasts can display "Error ID: …" and - // the user can copy it to a support ticket. Server-side wrappers - // always set X-Request-Id, even on early-return 401/403 paths. - const requestId = error.requestId ?? res.headers.get('x-request-id') ?? null; + } | null; + const upstreamRequestId = res.headers.get('x-request-id'); + if (error === null) { + const isProxyFailure = res.status >= 500; + // Short, copy-pasteable client-side handle so support can grep + // the front-end logs even when the proxy never reached our app. + const synthId = + upstreamRequestId ?? + `client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + throw new ApiError({ + message: isProxyFailure + ? 'The server is unreachable. Please try again.' + : res.statusText || 'Request failed', + status: res.status, + code: isProxyFailure ? 'UPSTREAM_UNREACHABLE' : null, + details: null, + requestId: synthId, + retryAfter: null, + }); + } + const requestId = error.requestId ?? upstreamRequestId ?? null; throw new ApiError({ message: error.error ?? error.message ?? 'Request failed', status: res.status, diff --git a/src/lib/queue/index.ts b/src/lib/queue/index.ts index 9836a281..a46604d9 100644 --- a/src/lib/queue/index.ts +++ b/src/lib/queue/index.ts @@ -4,17 +4,24 @@ import { env } from '@/lib/env'; const redisUrl = env.REDIS_URL; // 10 queues matching 11-REALTIME-AND-BACKGROUND-JOBS.md Section 3.1 +// +// `backoffDelayMs` is the *base* exponential delay; the actual delays +// follow `delay * 2^(attempt-1)` plus BullMQ's jitter. Defaults to 1 s +// for short-lived jobs; outbound `webhooks` overrides to 30 s base so +// the retry curve spans hours instead of seconds (outbound-webhook- +// auditor H2: a 30 s receiver blip during a deploy used to dead-letter +// every in-flight event). const QUEUE_CONFIGS = { - email: { concurrency: 5, maxAttempts: 5 }, - documents: { concurrency: 3, maxAttempts: 5 }, - notifications: { concurrency: 10, maxAttempts: 3 }, - import: { concurrency: 1, maxAttempts: 1 }, - export: { concurrency: 2, maxAttempts: 3 }, - reports: { concurrency: 1, maxAttempts: 3 }, - webhooks: { concurrency: 5, maxAttempts: 3 }, - maintenance: { concurrency: 1, maxAttempts: 3 }, - ai: { concurrency: 2, maxAttempts: 3 }, - bulk: { concurrency: 2, maxAttempts: 3 }, + email: { concurrency: 5, maxAttempts: 5, backoffDelayMs: 1_000 }, + documents: { concurrency: 3, maxAttempts: 5, backoffDelayMs: 1_000 }, + notifications: { concurrency: 10, maxAttempts: 3, backoffDelayMs: 1_000 }, + import: { concurrency: 1, maxAttempts: 1, backoffDelayMs: 1_000 }, + export: { concurrency: 2, maxAttempts: 3, backoffDelayMs: 1_000 }, + reports: { concurrency: 1, maxAttempts: 3, backoffDelayMs: 1_000 }, + webhooks: { concurrency: 5, maxAttempts: 8, backoffDelayMs: 30_000 }, + maintenance: { concurrency: 1, maxAttempts: 3, backoffDelayMs: 1_000 }, + ai: { concurrency: 2, maxAttempts: 3, backoffDelayMs: 1_000 }, + bulk: { concurrency: 2, maxAttempts: 3, backoffDelayMs: 1_000 }, } as const; export type QueueName = keyof typeof QUEUE_CONFIGS; @@ -28,7 +35,7 @@ export function getQueue(name: QueueName): Queue { connection: { url: redisUrl } as ConnectionOptions, defaultJobOptions: { attempts: QUEUE_CONFIGS[name].maxAttempts, - backoff: { type: 'exponential', delay: 1000 }, + backoff: { type: 'exponential', delay: QUEUE_CONFIGS[name].backoffDelayMs }, removeOnComplete: { age: 24 * 3600 }, // keep completed jobs 24 hours removeOnFail: { age: 7 * 24 * 3600 }, // keep failed jobs 7 days }, diff --git a/src/lib/queue/workers/webhooks.ts b/src/lib/queue/workers/webhooks.ts index bfdc46b9..10dc17a2 100644 --- a/src/lib/queue/workers/webhooks.ts +++ b/src/lib/queue/workers/webhooks.ts @@ -117,21 +117,49 @@ export const webhooksWorker = new Worker( throw err; // Let BullMQ retry } + // outbound-webhook-auditor C3: NULL secret means a DB tamper / a + // future migration mistake — every create path generates one. Hard- + // fail to dead_letter so compliant receivers don't silently accept + // an empty signature. + if (!secret) { + const { db: dbInner } = await import('@/lib/db'); + const { webhookDeliveries } = await import('@/lib/db/schema/system'); + const { eq } = await import('drizzle-orm'); + await dbInner + .update(webhookDeliveries) + .set({ + status: 'dead_letter', + responseStatus: null, + responseBody: 'Skipped: webhook has no signing secret (missing_signing_secret).', + deliveredAt: new Date(), + }) + .where(eq(webhookDeliveries.id, deliveryId)); + logger.error({ webhookId, deliveryId }, 'Webhook has no signing secret; dead-lettered'); + return; + } + // 3. Build final payload + const timestampIso = new Date().toISOString(); const finalPayload = { id: deliveryId, event, - timestamp: new Date().toISOString(), + timestamp: timestampIso, port_id: portId, data: payload, }; const bodyString = JSON.stringify(finalPayload); - // 4. Sign with HMAC-SHA256 - const signature = secret - ? `sha256=${createHmac('sha256', secret).update(bodyString).digest('hex')}` - : ''; + // 4. Sign with HMAC-SHA256 over `${ts}.${body}` (Stripe-style) + // + // outbound-webhook-auditor C1: signing only the body lets a captured + // request be replayed verbatim with a still-valid signature. + // Including the timestamp in the signed string, surfaced separately + // as X-Webhook-Timestamp, means receivers can reject anything older + // than a freshness window (≤ 5 min). Documented receiver-side + // dedup key is X-Webhook-Delivery (already sent). + const signedPayload = `${timestampIso}.${bodyString}`; + const signature = `sha256=${createHmac('sha256', secret).update(signedPayload).digest('hex')}`; const attempt = (job.attemptsMade ?? 0) + 1; @@ -140,6 +168,29 @@ export const webhooksWorker = new Worker( let responseBody: string | null = null; let success = false; + // outbound-webhook-auditor M2: re-assert https:// at dispatch time. + // The validator runs on create/update, but a bad DB edit could let + // an http:// URL through; the worker is the last line of defence. + if (!webhook.url.toLowerCase().startsWith('https://')) { + const { db: dbInner } = await import('@/lib/db'); + const { webhookDeliveries } = await import('@/lib/db/schema/system'); + const { eq } = await import('drizzle-orm'); + await dbInner + .update(webhookDeliveries) + .set({ + status: 'dead_letter', + responseStatus: null, + responseBody: 'Blocked: webhook URL is not https.', + deliveredAt: new Date(), + }) + .where(eq(webhookDeliveries.id, deliveryId)); + logger.warn( + { webhookId, deliveryId, url: webhook.url }, + 'Webhook dispatch blocked: non-https URL', + ); + return; + } + // SSRF gate: re-resolve the hostname at dispatch time and reject if it // points anywhere internal. The validator already filtered literal // hostnames at create/update time, but DNS rebinding could swap the @@ -178,6 +229,7 @@ export const webhooksWorker = new Worker( 'X-Webhook-Id': webhookId, 'X-Webhook-Event': event, 'X-Webhook-Signature': signature, + 'X-Webhook-Timestamp': timestampIso, 'X-Webhook-Delivery': deliveryId, }, body: bodyString, diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 2bb46d55..81765118 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -1,4 +1,5 @@ import { redis } from '@/lib/redis'; +import { logger } from '@/lib/logger'; export interface RateLimitConfig { /** Duration of the sliding window in milliseconds. */ @@ -31,27 +32,45 @@ export async function checkRateLimit( const now = Date.now(); const windowStart = now - config.windowMs; - const pipeline = redis.pipeline(); - // Remove entries older than the window. - pipeline.zremrangebyscore(key, '-inf', windowStart); - // Record this request; score = timestamp, member adds randomness for uniqueness. - pipeline.zadd(key, now, `${now}:${Math.random().toString(36).slice(2)}`); - // Count entries currently in the window. - pipeline.zcard(key); - // Expire the key after one full window so Redis doesn't accumulate stale keys. - pipeline.pexpire(key, config.windowMs); + try { + const pipeline = redis.pipeline(); + // Remove entries older than the window. + pipeline.zremrangebyscore(key, '-inf', windowStart); + // Record this request; score = timestamp, member adds randomness for uniqueness. + pipeline.zadd(key, now, `${now}:${Math.random().toString(36).slice(2)}`); + // Count entries currently in the window. + pipeline.zcard(key); + // Expire the key after one full window so Redis doesn't accumulate stale keys. + pipeline.pexpire(key, config.windowMs); - const results = await pipeline.exec(); + const results = await pipeline.exec(); - const count = (results?.[2]?.[1] as number) ?? 0; - const remaining = Math.max(0, config.max - count); + const count = (results?.[2]?.[1] as number) ?? 0; + const remaining = Math.max(0, config.max - count); - return { - allowed: count <= config.max, - limit: config.max, - remaining, - resetAt: now + config.windowMs, - }; + return { + allowed: count <= config.max, + limit: config.max, + remaining, + resetAt: now + config.windowMs, + }; + } catch (err) { + // error-ux-auditor C4: a Redis outage previously 500'd every + // rate-limited route — including login. Fail OPEN here so an + // operator can still authenticate while Redis is being recovered. + // The brief enforcement gap is acceptable; locking everyone out is + // not. Log loudly so monitoring picks it up. + logger.warn( + { err, keyPrefix: config.keyPrefix }, + 'rate-limit subsystem unavailable, allowing request (fail-open)', + ); + return { + allowed: true, + limit: config.max, + remaining: config.max, + resetAt: now + config.windowMs, + }; + } } /** diff --git a/src/lib/services/alert-rules.ts b/src/lib/services/alert-rules.ts index f37525aa..a054d9ba 100644 --- a/src/lib/services/alert-rules.ts +++ b/src/lib/services/alert-rules.ts @@ -11,7 +11,7 @@ * 4. Add a unit test in tests/unit/services/alert-rules-evaluators.test.ts. */ -import { and, eq, isNull, isNotNull, lt, gt, sql, inArray, or, desc } from 'drizzle-orm'; +import { and, eq, isNull, isNotNull, lt, sql, inArray, or } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; @@ -19,7 +19,6 @@ import { berthReservations } from '@/lib/db/schema/reservations'; import { berths } from '@/lib/db/schema/berths'; import { documents, documentSigners } from '@/lib/db/schema/documents'; import { expenses } from '@/lib/db/schema/financial'; -import { alerts as alertsTable } from '@/lib/db/schema/insights'; import { ALERT_RULES, type AlertRuleId } from '@/lib/db/schema/insights'; import { STAGE_LABELS, type PipelineStage } from '@/lib/constants'; @@ -325,7 +324,3 @@ export const RULE_REGISTRY: Record = { export function listRuleIds(): readonly AlertRuleId[] { return ALERT_RULES; } - -// silence unused-import warnings until later PRs use them -const _unused = { gt, desc, alertsTable }; -void _unused; diff --git a/src/lib/services/notes.service.ts b/src/lib/services/notes.service.ts index f9650136..d0d0a8c0 100644 --- a/src/lib/services/notes.service.ts +++ b/src/lib/services/notes.service.ts @@ -77,26 +77,6 @@ async function verifyParentBelongsToPort( } } -// Helper to centralise the per-entity table dispatch — keeps the CRUD -// branches below from each having their own switch. -function tableForEntity(entityType: EntityType) { - switch (entityType) { - case 'clients': - return { table: clientNotes, fk: 'clientId' as const }; - case 'interests': - return { table: interestNotes, fk: 'interestId' as const }; - case 'yachts': - return { table: yachtNotes, fk: 'yachtId' as const }; - case 'companies': - return { table: companyNotes, fk: 'companyId' as const }; - case 'residential_clients': - return { table: residentialClientNotes, fk: 'residentialClientId' as const }; - case 'residential_interests': - return { table: residentialInterestNotes, fk: 'residentialInterestId' as const }; - } -} -void tableForEntity; - // ─── Service ───────────────────────────────────────────────────────────────── /** diff --git a/src/lib/services/search.service.ts b/src/lib/services/search.service.ts index da344c7b..677385b5 100644 --- a/src/lib/services/search.service.ts +++ b/src/lib/services/search.service.ts @@ -293,15 +293,6 @@ export function normalizePhoneQuery(input: string): string | null { return digits.length >= 3 ? digits : null; } -/** - * Returns true when the input looks email-shaped enough to bother - * running an email-targeted match (otherwise we'd run an ILIKE that - * matches "@" inside random text and waste cycles). - */ -function looksLikeEmail(input: string): boolean { - return /[a-z0-9._%+-]+(@|@?[a-z0-9-]+\.)/i.test(input); -} - /** Permissions check used to skip buckets the user can't see. */ function can(opts: Pick, dotPath: string): boolean { if (opts.isSuperAdmin) return true; @@ -1798,8 +1789,6 @@ export async function search( : null; if (opts.type && !narrowTo) return runSingleBucket(portId, query, limit, opts); - const wantEmail = looksLikeEmail(query); - const wantPhone = normalizePhoneQuery(query) !== null; // We always run the name-bearing buckets even for email/phone-shaped // queries — a client named "test+marketing" is rare but real. @@ -1876,12 +1865,6 @@ export async function search( })), ); - // Suppress unused-var warning for the email/phone hint — we keep the - // computation in case future tuning wants to reorder buckets when the - // query is clearly an identifier. - void wantEmail; - void wantPhone; - // ─── Phase 2: graph expansion ─────────────────────────────────────── // For every direct match, fetch its 1-hop related entities so reps // who search "A10" see the linked interests/clients/yachts/companies diff --git a/src/lib/storage/index.ts b/src/lib/storage/index.ts index 584a65e0..5b3b3777 100644 --- a/src/lib/storage/index.ts +++ b/src/lib/storage/index.ts @@ -229,6 +229,13 @@ async function buildBackend(cfg: StorageConfigSnapshot): Promise * common need at call sites that don't track expiry. Mirrors the legacy * `getPresignedUrl(key)` helper in `@/lib/minio` but routes through the * active backend so filesystem-mode deployments work too. + * + * storage-pathing-auditor H2: when `portSlug` is not passed explicitly, + * we attempt to infer it from the key's first path segment — every + * storage key minted via `buildStoragePath(slug, …)` starts with the + * slug, so the inference is correct for the overwhelming majority of + * callers. This engages the filesystem-proxy port-binding token (`p`) + * verifier so a stolen-token / cross-port replay attempt fails fast. */ export async function presignDownloadUrl( key: string, @@ -237,10 +244,37 @@ export async function presignDownloadUrl( portSlug?: string, ): Promise { const backend = await getStorageBackend(); - const { url } = await backend.presignDownload(key, { expirySeconds, filename, portSlug }); + const inferredSlug = portSlug ?? inferPortSlugFromKey(key); + const { url } = await backend.presignDownload(key, { + expirySeconds, + filename, + portSlug: inferredSlug, + }); return url; } +/** + * Best-effort recovery of the port slug from a storage key prefix. + * Returns undefined when the key doesn't look slug-prefixed (e.g. legacy + * keys minted before `buildStoragePath` was canonical) so the caller + * falls back to the no-binding path. + * + * A slug is conservatively defined as kebab/alphanumeric (the same + * shape `createPortSchema` enforces). Non-matching first segments + * include UUID-only keys like the legacy `berths/{id}/uploads/...` + * shape — those are still served but skip the binding gate. + */ +function inferPortSlugFromKey(key: string): string | undefined { + const slash = key.indexOf('/'); + if (slash <= 0) return undefined; + const first = key.slice(0, slash); + if (!/^[a-z0-9-]+$/.test(first)) return undefined; + // Reserved namespaces that historically lived at the top level of the + // bucket and aren't port slugs. + if (first === 'berths' || first === 'backups' || first === 'tmp') return undefined; + return first; +} + // ─── re-exports ───────────────────────────────────────────────────────────── export { S3Backend } from './s3'; diff --git a/src/lib/storage/s3.ts b/src/lib/storage/s3.ts index 89e4434e..065d7927 100644 --- a/src/lib/storage/s3.ts +++ b/src/lib/storage/s3.ts @@ -46,10 +46,27 @@ interface S3BackendConfig { */ const STORAGE_DEFAULT_TIMEOUT_MS = 30_000; +/** + * Named timeout error so `error-classifier.ts` `ERROR_NAME_HINTS` can + * distinguish "the storage call timed out" from a generic storage + * misconfiguration. The plain-Error form was dropping into the path- + * based classifier and losing the actionable hint. + */ +export class StorageTimeoutError extends Error { + readonly label: string; + readonly timeoutMs: number; + constructor(label: string, ms: number) { + super(`S3 ${label} timed out after ${ms}ms`); + this.name = 'TimeoutError'; + this.label = label; + this.timeoutMs = ms; + } +} + function withTimeout(promise: Promise, ms: number, label: string): Promise { let timer: NodeJS.Timeout | null = null; const timeout = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`S3 ${label} timed out after ${ms}ms`)), ms); + timer = setTimeout(() => reject(new StorageTimeoutError(label, ms)), ms); }); return Promise.race([promise, timeout]).finally(() => { if (timer) clearTimeout(timer); diff --git a/src/lib/validators/webhooks.ts b/src/lib/validators/webhooks.ts index 3e23bc80..40a60607 100644 --- a/src/lib/validators/webhooks.ts +++ b/src/lib/validators/webhooks.ts @@ -30,7 +30,7 @@ function isBlockedIpv4(host: string): boolean { if (!m) return false; const oct = m.slice(1, 5).map(Number); if (oct.some((o) => o < 0 || o > 255)) return true; // malformed → treat as blocked - const [a, b] = oct as [number, number, number, number]; + const [a, b, c, d] = oct as [number, number, number, number]; if (a === 10) return true; // 10/8 RFC1918 if (a === 127) return true; // 127/8 loopback if (a === 169 && b === 254) return true; // 169.254/16 link-local + AWS IMDS @@ -39,6 +39,9 @@ function isBlockedIpv4(host: string): boolean { if (a === 100 && b >= 64 && b <= 127) return true; // 100.64/10 CGNAT if (a === 0) return true; // 0/8 zero if (a >= 224) return true; // multicast / reserved + // outbound-webhook-auditor M1: Oracle Cloud metadata endpoint + // (192.0.0.192) — was missing from the original denylist. + if (a === 192 && b === 0 && c === 0 && d === 192) return true; return false; }