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>
13 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 RangeAcroForm field —src/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: whencontext.eoiBerthRangeis non-empty AND the field is absent, log at warn level + surface a structured response field. -
Email body merge expansion happens after token validation —
src/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 secondfindUnresolvedTokensagainst the expanded body. -
Filesystem dev-fallback HMAC secret can drift across processes —
src/lib/storage/filesystem.ts:328-331. The dev-only fallback derives the HMAC secret fromBETTER_AUTH_SECRET. Two CRM processes running with different secrets (web vs worker) reject each other's tokens. Fix: assertBETTER_AUTH_SECRETis set when filesystem backend is active in non-prod, or document the requirement loudly. -
Berth PDF apply path: numeric column nulling silently drops —
src/lib/services/berth-pdf.service.ts:473-475. WhenNumber.isFinite(n)is false the apply loopcontinues without pushing toappliedand 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
interestIdwithout verifying port match —src/lib/services/document-sends.service.ts:422. Audit-log pollution rather than data exposure (the recipient lookup is port-checked already). Fix: whenrecipient.interestIdis set, fetch withand(eq(interests.id, ...), eq(interests.portId, input.portId))and throw if missing. -
Storage proxy token does not bind to port_id —
src/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 ap(portId) claim and have the proxy route resolve key→owner row + assertowner.portId === payload.pbefore streaming. -
Documenso webhook does not enforce port_id on document lookups —
src/app/api/webhooks/documenso/route.ts:96-148. Handlers dispatch by globaldocumensoId. 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-bodysignatureHashdedup is partial mitigation. Fix: either (a) include the originating Documenso instance/team in the lookup, or (b) verifydocuments(documenso_id)has a unique index port-wide.
Recent expense work polish
- renderReceiptHeader cursor math drifts after multi-step writes —
src/lib/services/expense-pdf.service.ts:854. Afterdoc.text(...)with auto-flow,doc.yadvances. Usingdoc.y - headerH + 10after 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: captureconst baseY = doc.ybefore drawing the rect and compute all subsequent offsets relative to it.
Settings parsing
loadRecommenderSettingsrejects string-shaped JSONB booleans —src/lib/services/berth-recommender.service.ts:116. Postgres returns JSONBtrue/falseas JS booleans, but if an admin saves"true"via a UI that wraps the value as a string,asBoolreturns null and the per-port override silently falls through to defaults. Not a security bug; a tuning footgun. Fix: accept"true"/"false"string forms inasBool.
Audit-final v2 (post-merge platform-wide pass) deferred findings
A second comprehensive audit (security, routes, DB, integrations, UI/UX)
ran after the merge. The high-impact items landed in commit
fix(audit-final-v2): platform-wide hardening (or similar). Items below
are deferred follow-ups.
Routes / API
-
Saved-views routes lack
withPermission—src/app/api/v1/saved-views/[id]/route.ts:4-5andsrc/app/api/v1/saved-views/route.ts:24. Convention iswithAuth(withPermission(...)). Verify the service applies(ctx.userId, ctx.portId)ownership filtering, then add either an explicit owner-only comment or wrap with a benign permission gate. -
Custom-fields permission resource hardcoded to
clients—src/app/api/v1/custom-fields/[entityId]/route.ts:15,29. Custom fields attach to client / yacht / interest / berth / company, but the route always checksclients.view/clients.edit. A user withcompanies.viewcan read confidential company custom-field values via this endpoint (the service-levelcustomFieldDefinitions.portIdfilter prevents cross-tenant access but not cross-resource within a tenant). Fix: split into per-entity routes, OR resolveentityTypeand gate on the matching permission inline. -
alerts/[id]/acknowledge|dismissungated —src/app/api/v1/alerts/[id]/acknowledge/route.ts:6etc. onlywithAuth, nowithPermission. Verify the service requires user ownership; if not, gate onreports.view_dashboardor similar. -
Public POST routes bypass service layer —
src/app/api/public/interests/route.ts,…/website-inquiries/route.ts,…/residential-inquiries/route.ts. These do extensivetx.insert(...)with hand-rolled audit logs (userId: null as unknown as string). Extract apublicInterestService.create(...)so the same code path is unit-testable and port-id discipline is uniform. Verifyaudit_logs.user_idis nullable (the cast pattern signals it is, but enforce in schema if not). -
Inconsistent response shapes — most endpoints return
{ data: ... }, butnotifications/[notificationId]returns{ success: true },website-inquiriesreturns{ id, deduped }. Document a convention in CLAUDE.md and migrate. -
req.json()withoutparseBodyhelper — admin custom-fields routes useawait req.json(); schema.parse(body)directly instead of the project'sparseBody(req, schema)helper. Migrate for uniform 400 error shapes.
Documenso integration
-
v2 voidDocument endpoint may not match real API —
src/lib/services/documenso-client.ts:450-466. The audit flagged that Documenso 2.x exposes envelope deletion asPOST /api/v2/envelope/deletewith{ envelopeId }body, notDELETE /api/v2/envelope/{id}. The unit test mocks fetch so it can't catch the real shape. Verify against a live Documenso 2.x instance (pnpm exec playwright test --project=realapi) before flipping any port to v2. -
Webhook dedup vs per-recipient signed events —
src/app/api/webhooks/documenso/route.ts:103-110. The top-levelsignatureHash(sha256 of raw body) blocks exact replays, but a duplicate webhook delivery for a multi-recipient document with a re-encoded body will go through the per-recipient loop. MakedocumentEvents.signatureHashunique cover the suffixed values OR add a composite unique index(documensoDocumentId, recipientEmail, eventType). -
v1
placeFieldsper-field POST has no retry —src/lib/services/documenso-client.ts:374-398. A single transient 500 mid-loop leaves the document with a partial field set. Add 3-attempt exponential backoff on 5xx + voidDocument on final failure.
Storage
-
S3 backend has no startup bucket-exists check —
src/lib/storage/s3.ts:100-111. A typo'd bucket name surfaces as a 500 inside a user-facing request rather than at boot. Addawait client.bucketExists(bucket)inS3Backend.createwith a clear error message. -
Storage cache fingerprint includes encrypted secret —
src/lib/storage/index.ts:158-159. After a key rotation the old cached client survives untilresetStorageBackendCache()is called (already called via the settings-write hook). Document the invariant or fingerprint on a content-hash that excludes encrypted material. -
Filesystem dev HMAC silent fallback —
src/lib/storage/filesystem.ts:309-332. Two dev nodes started with differentBETTER_AUTH_SECRETderive different secrets and reject each other's tokens. Log a one-line warn at backend boot in non-prod.
DB schema
-
berths.current_pdf_version_idlacks Drizzle FK —src/lib/db/schema/berths.ts:83. The FK exists in migration 0030 but not in the schema source-of-truth, sopnpm db:pushagainst an empty DB skips the constraint. Either add the FK with a deferred declaration or document thatdb:pushis unsupported. -
Missing indexes on FK columns —
berthReservations.interestId,berthReservations.contractFileId,documents.fileId,documents.signedFileId,documentEvents.signerId,documentTemplates.sourceFileId,formSubmissions.formTemplateId,formSubmissions.clientId,documentSends.brochureId,documentSends.brochureVersionId,documentSends.sentByUserId. Addindex(...)declarations to avoid full-scan FK checks on parent delete. -
systemSettingsPK / unique-index drift —src/lib/db/schema/system.ts:119-133. Schema declares only auniqueIndexon(key, port_id)but the migration useskeyas PK.port_idis nullable so(key, port_id)cannot serve as a PK with default NULLs-not-equal semantics. Reconcile: declareprimaryKey({ columns: [table.key, table.portId] })(after makingportIdnon-null with a sentinel) OR use partial unique indexes for global + per-port settings. -
Composite vs partial archived indexes — many tables use
index('idx_*_archived').on(portId, archivedAt)when the dominant query isWHERE port_id = ? AND archived_at IS NULL. Convert toindex(...).on(portId).where(sql\archived_at IS NULL`)` partial indexes for smaller storage + faster planner choice. -
documentSends.sentByUserIdungated FK —src/lib/db/schema/brochures.ts:118isnotNull()but has no FK reference. If a user is hard-deleted (rare; we soft-delete), an orphan id remains. Add.references(() => users.id, { onDelete: 'set null' })and make the column nullable. Same audit-trail rationale as the other documentSends FK fixes (commit 0035).
UI/UX
-
Storage admin migration mutation lacks toasts —
src/components/admin/storage-admin-panel.tsx:61-72. AddonSuccesstoast with row count +onErrortoast. -
Invoice detail send/payment mutations lack error feedback + gates —
src/components/invoices/invoice-detail.tsx:93-99,152-167. AddonError: (e) => toast.error(...)and wrap mutating buttons in<PermissionGate resource="invoices" action="send">/record_payment. -
Admin user list edit button ungated —
src/components/admin/users/user-list.tsx:114. Wrap in<PermissionGate resource="admin" action="manage_users">. -
Email threads list missing skeleton —
src/components/email/email-threads-list.tsx:29-45. Use<Skeleton>rows during load +<EmptyState>for the empty case. -
Scan page mutations swallow OCR errors —
src/app/(dashboard)/[portSlug]/expenses/scan/page.tsx:67-87. Add an inline error state forscanMutation.isError(the upload-side already does this). -
Invoice detail uses
anyfor query data — strict-mode escape hatch. Define a proper response type matching the API contract.
Security defense-in-depth
-
Storage proxy token does not bind to port_id —
src/lib/storage/filesystem.ts:73-84. Token's HMAC is global. Fix: addp(portId) claim and have the proxy resolve key→owner row + assertowner.portId === payload.p. -
Documenso webhook does not enforce port_id —
src/app/api/webhooks/documenso/route.ts:96-148. Handlers dispatch by globaldocumensoId. Verifydocuments(documenso_id)is unique port-wide OR include the originating instance/team in the lookup. -
EOI in-app pathway silently swallows missing
Berth Rangefield —src/lib/pdf/fill-eoi-form.ts:93. Log warn whencontext.eoiBerthRangeis non-empty AND the field is absent so the Documenso template deployment gap is observable. -
AI worker has no cost-tracking ledger write —
src/lib/queue/workers/ai.ts:122-177. Persist token usage to theai_usageledger after every call. -
Logger redact paths miss nested credentials —
src/lib/logger.ts:5-19. Extend redact list to cover*.headers.authorization,**.token,secretKeyEncrypted, etc.