Files
pn-new-crm/docs/audit-final-deferred.md
Matt Ciaccio 180912ba9f fix(audit-final): pre-merge hardening + expense receipt UI
Final audit pass on feat/berth-recommender (3 parallel Opus agents)
caught 5 critical and ~12 high-severity findings. All addressed in-branch;
medium/low items deferred to docs/audit-final-deferred.md.

Critical:
- Add filesystem-backend PUT handler at /api/storage/[token] so
  presigned uploads stop 405-ing in filesystem mode (every browser-driven
  berth-PDF + brochure upload was broken). Same token-verify + replay
  protection as GET, plus magic-byte gate when c=application/pdf.
- Forward req.signal into streamExpensePdf so an aborted 1000-receipt
  export no longer keeps grinding for minutes.
- Strengthen Content-Disposition filename sanitization: \s matches CR/LF
  which would let documentName forge headers; restrict to [\w. -]+ and
  add filename* RFC 5987 fallback.
- Lock public berths feed behind an explicit slug allowlist instead of
  ?portSlug= enumeration.
- Reject cross-port interest_berths upserts (defense-in-depth on top of
  the recommender SQL port filter).

High:
- Recommender: width-only feasibility now caps length via L/W ratio so a
  200ft berth doesn't surface for a 30ft beam request; total_interest_count
  filters out junction rows whose interest is in another port.
- Mooring normalization follow-up migration (0034) catches un-hyphenated
  padded forms (A01) the original 0024 WHERE missed.
- Send-out rate limit moved AFTER validation and scoped per-(port, user)
  so typos don't burn a slot and a multi-port rep can't be DoS'd by
  another tenant.
- Default-brochure path now blocks an archived row from sneaking through
  the partial unique index.
- NocoDB import --update-snapshot honoured under --dry-run so reps can
  refresh the seed JSON without committing DB writes.
- PDF export: orderBy desc(expenseDate); apply isNull(archivedAt) when
  expenseIds are passed (was bypassed); flag rate-unavailable rows with
  an amber footer instead of silently treating them as 1:1; skip the
  USD->EUR chain when source already matches target.
- expense-form-dialog: revokeObjectURL captures the URL in the closure
  instead of revoking the still-displayed one; reset upload state on
  close.
- scan/page: handleClearReceipt resets in-flight scan/upload mutations;
  Save disabled while upload pending.
- updateExpense re-asserts receipt-or-acknowledgement at the merged
  row so PATCH can't slip past the create-time refine.

Plus the in-progress receipt upload UI for the expense form dialog
(receipt picker + "I have no receipt" checkbox + warning banner) and
a noReceiptAcknowledged flag on ExpenseRow for edit-mode hydration.

Includes the canonical plan doc (referenced in CLAUDE.md), the handoff
prompt, and a deferred-findings index for follow-up issues.

1163/1163 vitest passing. Typecheck clean.

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

4.4 KiB

Final audit deferred findings

The pre-merge audit on feat/berth-recommender produced ~30 findings. The critical + high-severity items were fixed in-branch. The items below are medium / low severity and deferred to follow-up issues so the merge isn't held up. Each entry is self-contained — pick one off and ship it.

Cross-cutting integration

  • EOI in-app pathway silently swallows missing Berth Range AcroForm fieldsrc/lib/pdf/fill-eoi-form.ts:93. setText(form, 'Berth Range', ...) is wrapped in a try/catch that succeeds silently when the field is absent. CLAUDE.md already warns ops about needing to add the field to the live Documenso template; this code change would make the deployment gap observable. Fix: when context.eoiBerthRange is non-empty AND the field is absent, log at warn level + surface a structured response field.

  • Email body merge expansion happens after token validationsrc/lib/services/document-sends.service.ts:399-403. If a merge value contains a {{token}} substring (e.g. a client name like "Acme {{discount}} Inc."), the expanded body will contain a token the unresolved-check missed and ships with literal braces. Fix: HTML- escape merge values before expansion, OR run a second findUnresolvedTokens against the expanded body.

  • Filesystem dev-fallback HMAC secret can drift across processessrc/lib/storage/filesystem.ts:328-331. The dev-only fallback derives the HMAC secret from BETTER_AUTH_SECRET. Two CRM processes running with different secrets (web vs worker) reject each other's tokens. Fix: assert BETTER_AUTH_SECRET is set when filesystem backend is active in non-prod, or document the requirement loudly.

  • Berth PDF apply path: numeric column nulling silently dropssrc/lib/services/berth-pdf.service.ts:473-475. When Number.isFinite(n) is false the apply loop continues without pushing to applied and without warning. Combined with the "no appliable fields supplied" check (only fires when ALL drop), partial silent drops are invisible. Fix: collect dropped keys and surface them.

Multi-tenant isolation hardening

  • document_sends row stores interestId without verifying port matchsrc/lib/services/document-sends.service.ts:422. Audit-log pollution rather than data exposure (the recipient lookup is port-checked already). Fix: when recipient.interestId is set, fetch with and(eq(interests.id, ...), eq(interests.portId, input.portId)) and throw if missing.

  • Storage proxy token does not bind to port_idsrc/lib/storage/filesystem.ts:73-84. ProxyTokenPayload is {k, e, n, f?, c?} with a global HMAC. The current "issuer always checks port first" relies on every issuer being correct in perpetuity. Fix: add a p (portId) claim and have the proxy route resolve key→owner row + assert owner.portId === payload.p before streaming.

  • Documenso webhook does not enforce port_id on document lookupssrc/app/api/webhooks/documenso/route.ts:96-148. Handlers dispatch by global documensoId. If two ports' documents were ever issued the same Documenso ID (replay across staging/prod, forwarded webhook from a foreign instance), the wrong port's interest could be mutated. The per-body signatureHash dedup is partial mitigation. Fix: either (a) include the originating Documenso instance/team in the lookup, or (b) verify documents(documenso_id) has a unique index port-wide.

Recent expense work polish

  • renderReceiptHeader cursor math drifts after multi-step writessrc/lib/services/expense-pdf.service.ts:854. After doc.text(...) with auto-flow, doc.y advances. Using doc.y - headerH + 10 after the rect+stroke block computes against the post-rect position; works only because pdfkit's text-after-rect hasn't moved y yet. Headers may misalign on the first receipt page after a soft page break. Fix: capture const baseY = doc.y before drawing the rect and compute all subsequent offsets relative to it.

Settings parsing

  • loadRecommenderSettings rejects string-shaped JSONB booleanssrc/lib/services/berth-recommender.service.ts:116. Postgres returns JSONB true/false as JS booleans, but if an admin saves "true" via a UI that wraps the value as a string, asBool returns null and the per-port override silently falls through to defaults. Not a security bug; a tuning footgun. Fix: accept "true"/"false" string forms in asBool.