Files
pn-new-crm/docs/AUDIT-FINDINGS-2026-05-15.md
Matt 4b5f85cb7d fix(audit): comprehensive 2026-05-15 audit fix wave + Documenso v2 polish
Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).

CRITICAL (3):
 - C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
   no longer silently drop interest links
 - C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
 - C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
   callers must go through /stage with the override-guard chain

HIGH (14/15):
 - H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
   interests/documents/reservations/reminders/invoices (migration 0070)
 - H-02 login page reads ?redirect= param with same-origin guard
 - H-03 CRM invite token moves to URL fragment so it never lands in
   nginx access logs / Referer headers
 - H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
 - H-05 toggleAccount writes an audit row
 - H-06 upsertSetting masks any value whose key ends with _encrypted
 - H-07 archiveClient cascade fires per-interest audit rows
 - H-08 createSalesTransporter applies SMTP_TIMEOUTS
 - H-09 AppShell stable children — viewport flip across breakpoint no
   longer destroys in-progress form drafts
 - H-10 portal documents page swaps Unicode glyph status icons for
   Lucide CheckCircle2/XCircle/Circle + aria-labels
 - H-12 list components swap alert(...) for toast.warning(...)
 - H-13 5 icon-only buttons gain aria-label
 - H-14 parseBody treats empty bodies as {}
 - H-15 admin layout renders a 403 panel instead of silent bounce
 - H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet

MEDIUM (28+):
 - M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
   WHEREs across custom-fields, notes (all 6 entity types x update +
   delete), client-contacts, yacht ownerClient lookup, webhook reads
 - M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
 - M-EM01 portal-auth emails thread through portId
 - M-EM02 sendEmail accepts cc/bcc params
 - M-EM04 notification_digest catalog key
 - M-IN01 portal presigned download URLs use 4h TTL
 - M-IN02 OpenAI client lazy-instantiated
 - M-IN04 stale pdfme refs updated to pdf-lib AcroForm
 - M-IN05 umami.testConnection returns tagged union
 - M-L01 reservations tenure_type unified with berths
 - M-L02 report-generators canonicalize stage values
 - M-AU01 audit log placeholder copy fixed
 - M-AU04 outcome_set / outcome_cleared distinct audit verbs
 - M-NEW-2 activity feed entity name+type separator
 - M-R01 portal allowlist narrowed + portal_session backstop in proxy
 - M-SC02 companies archived partial index
 - M-SC04 audit_logs.searchText documented as DB-managed
 - M-S01 storage_s3_access_key_encrypted admin field
 - M-U01 audit log empty state uses <EmptyState>
 - M-U09 invoice delete dialog -> <AlertDialog>
 - M-U10 toast.success on ClientForm + InterestForm create/edit
 - M-U11 settings-form-card logo preview alt text
 - M-U14 mobile topbar title on clients/yachts/interests/berths
 - M-U15 Invoices in mobile More-sheet

LOW (6/8):
 - L-AU01 severity defaults for security-relevant verbs
 - L-AU02 +13 missing actions in admin audit filter
 - L-AU03 +7 missing entity types in admin audit filter
 - L-AU04 dead listAuditLogs stubbed
 - L-D02 CLAUDE.md Owner-wins chain tightened

Bonus — Document detail polish (#67 partial, 3/6 deliverables):
 - state-aware action button per signer
 - watcher Add UI with display-name resolution
 - cleanSignerName cleanup

Prior session work bundled in:
 - Documenso v2 webhook + envelope-ID normalization + sequential signing
 - SigningProgress UI redesign (avatars, per-signer state, timestamps)
 - env->admin settings registry + RegistryDrivenForm + encrypted creds
 - Embedded-signing card + Test connection + setup help
 - Dev-mode EMAIL_REDIRECT_TO banner
 - Pipeline rules admin page
 - Sales email config card
 - Audit log details Sheet
 - EOI tab: Finalising badge, absolute timestamps, sequential indicator
 - Notes pipeline_stage_at_creation (migration 0069)
 - Documenso numeric ID dual-key webhook (migration 0068)
 - Dimensions criterion copy (migration 0067)

Tests: 1374/1374 vitest pass. tsc clean. lint clean.

See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.

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

41 KiB
Raw Blame History

Comprehensive Audit Findings — 2026-05-15

Discovery pass across all 19 areas of docs/AUDIT-CATALOG.md. Code-side via 9 parallel sub-agents + browser sweep via Playwright MCP. Per-agent raw output cached under docs/audit-findings-tmp/.

Scoreboard

Severity Count
🔴 CRITICAL 3
🟠 HIGH 15
🟡 MEDIUM 48
🟢 LOW 8
Total 74

The 3 critical and the most actionable HIGH issues should head the next fix wave.


🔴 CRITICAL

  • Files: src/lib/services/interest-berths.service.ts:55 (getPrimaryBerth), :87 (getPrimaryBerthsForInterests), :140 (listBerthsForInterest)
  • What: Three helpers use INNER JOIN berths ON berths.id = interestBerths.berthId. Hard-deleting a berth makes the join silently drop the row.
  • Impact: Interest detail shows berthId: null / berthMooringNumber: null. Kanban card shows no berth chip. EOI generation produces empty mooring field. archiveInterest calls getPrimaryBerth before evaluating the berth rule — null result causes the rule to be skipped entirely.
  • Fix: Switch all three to LEFT JOIN berths. Callers already handle null. Add service-layer guard preventing hard-delete of berths with interest_berths rows (require unlink or soft-archive first).

C-02 (R-021) — /setup missing from PUBLIC_PATHS — bootstrap unreachable on fresh DB

  • File: src/proxy.ts:51-73
  • What: PUBLIC_PATHS includes /api/v1/bootstrap/ but NOT /setup. Unauthenticated user → /setup → middleware redirects to /login?redirect=/setup. Login useEffect fetches bootstrap status, calls router.replace('/setup') → middleware again → infinite redirect loop.
  • Impact: Fresh deployment (no super admin) is functionally deadlocked. The first operator cannot reach setup without already having a session — impossible on a fresh DB.
  • Fix: Add '/setup' to PUBLIC_PATHS. POST /api/v1/bootstrap/super-admin already self-protects with hasAnySuperAdmin().
  • Browser-verified: Navigating to /setup unauthenticated redirects to /login (no ?redirect= even). The bootstrap-status check at src/app/(auth)/login/page.tsx:41 confirms: if (payload.data?.needsBootstrap) router.replace('/setup'); — feeds the loop on fresh DB.

C-03 (NEW, browser-discovered) — Generic PATCH /api/v1/interests/[id] bypasses ALL stage-transition guards

  • Files: src/app/api/v1/interests/[id]/route.ts:20-32 (calls updateInterest); src/lib/services/interests.service.ts:701 (updateInterest); src/lib/validators/interests.ts:68,90 (pipelineStage flows through updateInterestSchema to the service)
  • What: The /stage endpoint (src/app/api/v1/interests/[id]/stage/route.ts) calls changeInterestStage which enforces STAGE_NOOP early-return, canTransitionStage() table guard, override-requires-permission, and override-requires-≥5-char-reason. The generic PATCH endpoint calls updateInterest which writes the full payload (incl. pipelineStage) directly to the DB with none of those guards.
  • Browser proof:
    • PATCH /api/v1/interests/<deposit-paid-id> with { pipelineStage: 'enquiry' }200 OK, interest demoted to enquiry. (Same call via /stage correctly returned 400 with "Cannot move from Deposit Paid directly to New Enquiry. Use the override option ...".)
    • PATCH /api/v1/interests/<eoi-id> with { pipelineStage: 'eoi' } (same-stage) → 200 with full 1249-byte body instead of 204. F27 fix only works through /stage.
    • Backwards write via generic PATCH leaves eoiDocStatus: 'sent' while pipelineStage = 'enquiry' — corrupted state.
    • Audit row written as generic action: 'update' with diff, not action: 'stage_change' with proper metadata. Webhook event interest:updated not interest:stageChanged.
  • Impact: Any caller (rep tool, integration, mistake in frontend) hitting the generic PATCH can drive an interest to any stage with no override permission, no reason, no audit-as-stage-change. Same-stage spam fires no-op writes that bump updated_at and emit redundant socket+webhook events. The corrupted-state surface (stage rolled back but doc-status still says signed) breaks downstream rules-engine evaluations that branch on stage.
  • Fix: In updateInterestSchema, omit pipelineStage (force callers to use /stage); OR in updateInterest, when pipelineStage is in the payload, delegate to changeInterestStage with the full guard chain. Either prevents the bypass surface from existing.

🟠 HIGH

H-01 (SC-02) — Multiple FKs ON DELETE NO ACTION while Drizzle declares them nullable

  • Files: src/lib/db/schema/interests.ts:29,32 (portId/clientId); src/lib/db/schema/documents.ts:72,85,86,176 (clientId/fileId/signedFileId/signerId); src/lib/db/schema/reservations.ts:18,24,25,27,28,33 (all 6 berthReservations FKs); src/lib/db/schema/operations.ts:25 (reminders.clientId); src/lib/db/schema/financial.ts:120 (invoices.pdfFileId)
  • What: .references(...) without { onDelete } emits ON DELETE NO ACTION. Hard-deleting a parent (client, berth, yacht, file) blocks at FK level.
  • Fix: Add { onDelete: 'set null' } for nullable FKs that should tolerate parent deletion; explicit { onDelete: 'restrict' } for those that intentionally block (interests.clientId design intent is archive-first).

H-02 (R-017/018) — CRM post-login redirect ignores ?redirect= param

  • File: src/app/(auth)/login/page.tsx:79
  • What: Middleware redirects unauthenticated → /login?redirect=<path>. Login page never reads useSearchParams(); always router.push('/dashboard').
  • Impact: Email/bookmark/shared deep links into specific clients/interests silently dump to dashboard.
  • Fix: Read searchParams.get('redirect'), validate same-origin (startsWith('/'), not '//'), use as push target.

H-03 (R-023) — CRM invite token in query string leaks to access logs

  • File: src/lib/services/crm-invite.service.ts:71,233
  • What: ${env.APP_URL}/set-password?token=${raw} — raw 32-byte token in query param. Portal flow was migrated to #token= fragment in 2026-05-14 specifically to keep tokens out of logs/Referer; CRM invite path missed the migration.
  • Impact: Every nginx/Caddy access log line for GET /set-password?token=<raw> persists token to disk. Forwarded to SIEM/S3/monitoring → token visible to anyone with log access. Token grants account creation.
  • Fix: Change createCrmInvite + resendCrmInvite to emit ${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}. Update set-password/page.tsx to use the fragment-reading pattern from PasswordSetForm (readTokenFromUrl()) with ?token= back-compat for outstanding tokens.

H-04 (R-029) — sign-in-by-identifier 429 missing Retry-After

  • File: src/app/api/auth/sign-in-by-identifier/route.ts:47-51
  • What: Builds 429 response with headers: rateLimitHeaders(rl) which only emits X-RateLimit-Limit/Remaining/Reset. enforcePublicRateLimit adds Retry-After; this route uses checkRateLimit directly and skips it.
  • Impact: RFC 6585 §4 violation. Automated clients can't back off correctly.
  • Fix: Add 'Retry-After': Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000)).toString().

H-05 (AU-01a) — toggleAccount writes no audit row

  • File: src/lib/services/email-accounts.service.ts:86-116
  • What: Sets isActive on email account with no createAuditLog call. connectAccount (line 70) and disconnectAccount (line 139) do, but enable/disable in between is silent.
  • Impact: Silently disabling an email account suppresses bounce-detection or reroutes replies — compliance gap on a security-relevant config change.
  • Fix: Add void createAuditLog({ action: 'update', entityType: 'email_account', entityId: accountId, newValue: { isActive: data.isActive }, ... }) inside toggleAccount.

H-06 (AU-02) — Encrypted credential ciphertext stored in audit log without masking

  • Files: src/lib/services/settings.service.ts:66-76 + src/lib/services/sales-email-config.service.ts:281-299
  • What: updateSalesEmailConfig calls upsertSetting('sales_smtp_pass_encrypted', <ciphertext>, portId, meta). upsertSetting records newValue: { value: '<ciphertext>' }. maskSensitiveFields checks JSON keys against SENSITIVE_KEY_FRAGMENTS; the wrapping key "value" isn't in the list. Ciphertext lands verbatim in audit_logs.new_value.
  • Impact: Audit log readable by all admins with admin.view_audit_log. DB read access exfils ciphertext; if EMAIL_CREDENTIAL_KEY is ever compromised, the historical audit log becomes a credential store.
  • Fix: In upsertSetting, detect when key ends with _encrypted (or accept redactValue?: boolean) and record newValue: { value: '[redacted]' }.

H-07 (AU-10) — Cascade-archived interests produce no individual audit rows

  • File: src/lib/services/clients.service.ts:578-618
  • What: archiveClient batch-archives open interests, writes ONE entityType: 'client' row with newValue: { cascadedInterestIds: [...] }. No per-interest rows. search_text doesn't include new_value, so searching for an interest ID returns nothing.
  • Impact: Auditor querying for a specific archived interest sees no archive event; must know to look at parent client row.
  • Fix: Loop over archivedInterestIds and emit per-interest createAuditLog({ action: 'archive', entityType: 'interest', entityId, metadata: { cascadeSource: 'client_archive', clientId } }) (fire-and-forget).

H-08 (EM-XX) — Sales transporter missing SMTP timeouts

  • File: src/lib/services/sales-email-config.service.ts:331-337
  • What: createSalesTransporter builds nodemailer transport with no timeout options. Compare createTransporter in src/lib/email/index.ts:26-37 which uses SMTP_TIMEOUTS = { connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 30_000 }.
  • Impact: Hung SMTP relay can stall send-out indefinitely. Email queue concurrency=5, maxAttempts=5. One stuck TCP connection → 2-min default × 5 retries = 10min/job × 5 slots = whole pool blocked for 10min by a single flaky send.
  • Fix: Apply SMTP_TIMEOUTS constant to nodemailer.createTransport in createSalesTransporter.

H-09 (B-16) — AppShell remounts children on breakpoint crossing, destroying form state

  • File: src/components/layout/app-shell.tsx:58-70
  • What: When isMobile flips on resize, the shell switches between <MobileLayout>{children}</MobileLayout> and the desktop <div>...{children}...</div>. React unmounts and remounts children, destroying any in-progress useState form drafts including InlineEditableField.
  • Impact: User editing a client name on desktop who resizes past mobile breakpoint loses unsaved draft text. Multi-step modal forms (reconcile wizard) open during resize get unmounted.
  • Fix: Wrap shared content with stable key, or use CSS-only responsive layout so children subtree never remounts. Alternatively key={isMobile ? 'mobile' : 'desktop'} only on shell wrappers with children stable via Portal.

H-10 (U-059) — Unicode glyphs as status icons in portal documents page

  • File: src/app/(portal)/portal/documents/page.tsx:85-89
  • What: Signer status rendered as raw Unicode ('✓' signed, '✗' declined, '○' pending) inside colour-coded <span> with no aria-label.
  • Impact: Screen readers read literal Unicode names. Project memory: decorative unicode glyphs explicitly flagged. inline-stage-picker.tsx:443 comment confirms the pattern ("was ⚑ unicode glyph — replaced with a Lucide").
  • Fix: Replace with <CheckCircle2> / <XCircle> / <Circle> Lucide icons + aria-label.

H-11 (U-066) — Vaul Drawer used for mobile search overlay (violates Sheet doctrine)

  • File: src/components/search/mobile-search-overlay.tsx:6
  • What: import { Drawer as VaulDrawer } from 'vaul'. Search overlay is full-screen, not a bottom sheet. CLAUDE.md: Vaul reserved for mobile-bottom-sheet only (currently MoreSheet only).
  • Fix: Convert to <Sheet side="bottom"> or <Dialog> fullscreen. Custom visualViewport handling (lines 50-89) becomes redundant with Radix dialog backing.

H-12 (U-076) — Native alert() for bulk-action failure feedback in 3 lists

  • Files: src/components/interests/interest-list.tsx:146, src/components/companies/company-list.tsx:73, src/components/yachts/yacht-list.tsx:66
  • What: Partial-failure feedback via alert(...). client-list.tsx:145 uses toast.warning(...) correctly.
  • Impact: Native alert blocks main thread, can't be styled, fires in tests without suppression.
  • Fix: Replace with toast.warning(...) matching client-list.tsx.

H-13 (U-079) — Icon-only buttons missing aria-label (5 sites)

  • Files: src/components/notifications/notification-bell.tsx:65, src/components/files/file-grid.tsx:121, src/components/admin/forms/form-template-list.tsx:102, src/components/email/email-accounts-list.tsx:159, src/components/companies/company-members-tab.tsx:228
  • Pattern reference: src/components/shared/folder-actions-menu.tsx:96 correctly uses <span className="sr-only">More folder actions</span>.
  • Fix: Add aria-label to each, following the folder-actions-menu sr-only pattern.

H-14 (NEW, browser-discovered) — DELETE /api/v1/interests/[id]/outcome with empty body crashes 500

  • File: src/app/api/v1/interests/[id]/outcome/route.ts:27-30; src/lib/api/route-helpers.ts (parseBody)
  • What: The DELETE handler calls parseBody(req, clearOutcomeSchema). clearOutcomeSchema says reopenStage is optional. But DELETE with no body causes parseBody to throw an unhandled error → 500 internal-server-error JSON. Sending { reopenStage: 'qualified' } returns 200.
  • Browser proof: Two consecutive DELETE /api/v1/interests/<wonId>/outcome calls (no body) returned 500 with requestId: bc807db5-... / d21b5b3e-.... Same call with body {} would presumably also work (not tested) — the issue is empty-vs-omitted body.
  • Impact: F26 reopen flow — when the user clicks "Reopen" without overriding the auto-detected previous stage, the request crashes. Frontend may always send a body, but the API contract claims optional and the wire-level test fails.
  • Fix: In parseBody, treat empty request body as {} for DELETE/POST routes whose schemas have all-optional fields; OR in the route handler, parse the body conditionally on req.headers.get('content-length') !== '0'.

H-15 (NEW, browser-discovered) — Sales-agent visiting an admin page silently bounces to dashboard (no 403 / feedback)

  • Files: Middleware in src/proxy.ts and/or per-route admin layout
  • What: Sales-agent navigating to http://localhost:3000/port-amador/admin/audit lands at http://localhost:3000/port-amador/dashboard. URL silently changes; no toast, no 403 page, no "Access denied" feedback. The API itself correctly returns 403 ("Insufficient permissions" or "No access to this port") — the UI just hides the failure.
  • Impact: A rep clicking a deep link to an admin page (in an email, bookmark, or shared link) is silently redirected without explanation. They can't tell whether the link was wrong, whether their permission lapsed, or whether the page just doesn't exist. (The earlier A18 verification said "/admin/audit correctly 403s" at the API level, which is true — but the UI layer hides it.)
  • Fix: Render a /403 page or surface a toast on access denial in the admin route layout. Keep the URL on the failed route so users can verify what they tried to reach.

🟡 MEDIUM (45 findings — by area)

Multi-tenancy (5)

ID Title File:line Fix sketch
M-MT01 updateDefinition UPDATE missing portId in WHERE src/lib/services/custom-fields.service.ts:136-145 Add and(eq(...id), eq(...portId, portId)) to UPDATE WHERE
M-MT02 Notes UPDATE/DELETE missing entityId scope src/lib/services/notes.service.ts:846-850, 869-873, 897-901 Add eq(...notes.<parent>Id, entityId) to WHERE
M-MT03 Contact UPDATE/DELETE missing clientId scope src/lib/services/clients.service.ts:737-741, 764 Add eq(clientContacts.clientId, clientId) to WHERE
M-MT04 listForYachtAggregated ownerClientId lookup no portId src/lib/services/notes.service.ts:276-283 Add eq(clients.portId, portId)
M-MT05 Webhook reads expose row before JS portId check src/lib/services/webhooks.service.ts:103-108, 133-137, 170-174 Move portId into findFirst WHERE

Schema (5)

ID Title File:line Fix sketch
M-SC01 Migrations 0000-0036 not idempotent (no IF NOT EXISTS / DO blocks) src/lib/db/migrations/0000_narrow_longshot.sql, 0036_polymorphic_check_constraints.sql Standardize IF NOT EXISTS / DO block pattern for new migrations; document 0000-0036 not re-runnable
M-SC02 companies missing soft-delete partial index src/lib/db/schema/companies.ts:39-45 CREATE INDEX IF NOT EXISTS idx_companies_archived ON companies (port_id) WHERE archived_at IS NULL;
M-SC03 FTS GIN index missing for interests and berths src/lib/db/migrations/0057_search_fts_indexes.sql Add CREATE INDEX CONCURRENTLY ... USING gin (...) for both
M-SC04 audit_logs.searchText schema/DB mismatch (Drizzle plain, DB GENERATED ALWAYS) src/lib/db/schema/system.ts:53-54 Annotate as non-updateable / generated marker
M-SC05 documents.clientId Drizzle nullable but DB ON DELETE NO ACTION src/lib/db/schema/documents.ts:72, migration 0000_narrow_longshot.sql:814 Migration mirroring 0059's fix for files.client_id: drop + re-add with ON DELETE SET NULL

Routes / Middleware (2)

ID Title File:line Fix sketch
M-R01 /portal/ blanket allowlist removes middleware backstop src/proxy.ts:65 Allowlist only unauthenticated portal routes individually; add middleware portal-cookie check
M-R02 No explicit OPTIONS handlers, no CORS headers (defer until cross-origin consumer exists) All route.ts under src/app/api/ Add explicit Access-Control-Allow-Origin: <marketing-domain> to public routes when needed

Audit log (4)

ID Title File:line Fix sketch
M-AU01 FTS search_text covers only 4 fields; placeholder text misleads migration 0014_black_banshee.sql:47-55 + audit-log-list.tsx:360 Change placeholder OR add metadata to GENERATED expression
M-AU02 Admin audit log shows field names but no old→new diff audit-log-list.tsx:290-305 + audit-log-card.tsx:84-91 Add row-expand using buildDiffLine from activity-feed.tsx
M-AU03 No audit log CSV export endpoint (absent) GET /api/v1/admin/audit/export/csv reusing searchAuditLogs
M-AU04 Outcome change uses action: 'update' not distinct verb interests.service.ts:1047-1058 Add 'outcome_change' to AuditAction; use in setInterestOutcome/clearInterestOutcome; add to dropdown + severity map

Documents/files (1)

ID Title File:line Fix sketch
M-D01 Real-time invalidation event-name mismatch ('file:created' vs 'file:uploaded') src/components/documents/documents-hub.tsx:141 Change to 'file:uploaded': [['files']] matching other components

Security (1)

ID Title File:line Fix sketch
M-S01 S3 access key ID stored plaintext in system_settings (secret encrypted, key not) src/lib/storage/index.ts:136, src/components/admin/storage-admin-panel.tsx:80 Apply same encrypt() / *IsSet pattern as secret key; migration to re-key existing rows

Email + Integrations (8)

ID Title File:line Fix sketch
M-EM01 Portal activation/reset emails not threaded with portId — falls back to global SMTP src/lib/services/portal-auth.service.ts:163-164 Pass portId as 6th arg to both sendEmail calls
M-EM02 No CC/BCC in main sendEmail src/lib/email/index.ts:54-68 Add optional cc/bcc to SendEmailOptions
M-EM03 Bounce-to-interest linking not implemented src/lib/services/sales-email-config.service.ts:13 Wire BullMQ recurring job using imapflow to scan inbox for bounce NDRs (Phase 7 §14.9 deferred)
M-EM04 Notification digest uses 'crm_invite' as any for subject resolution src/lib/services/notification-digest.service.ts:161-169 Add 'notification_digest' to TEMPLATE_KEYS; update digest service
M-IN01 Presigned URL TTL fixed at 900s for portal downloads src/lib/storage/index.ts:240-254; src/lib/services/portal.service.ts:350 Pass expirySeconds: 4 * 3600 for portal links, or sign on-demand from API
M-IN02 OpenAI receipt-scanner module-level instantiation, no credential health check src/lib/services/receipt-scanner.ts:4 Guard OPENAI_API_KEY upfront; add health-check endpoint
M-IN03 Receipt OCR ignores per-port config; hardcoded gpt-4o src/lib/services/receipt-scanner.ts:19 Accept portId, call getResolvedOcrConfig(portId), branch on provider
M-IN04 Stale "pdfme" references in comments/seed src/lib/db/seed-data.ts:807, src/lib/services/document-templates.ts:573 Update comments to reference pdf-lib AcroForm fill
M-IN05 Umami testConnection throws instead of typed { ok: false } src/lib/services/umami.service.ts:80-101, 292 Return { ok: false, error } to match checkDocumensoHealth

Performance + Behavioral (1)

ID Title File:line Fix sketch
M-P01 Leading-wildcard ILIKE '%term%' in buildListQuery defeats indexes src/lib/db/query-builder.ts Migrate to pg_trgm GIN indexes on searched columns, or move to FTS via existing search_text GIN

Legacy enum drift (2)

ID Title File:line Fix sketch
M-L01 Tenure type enum diverges between berths and reservations src/lib/db/schema/berths.ts:65 vs src/lib/db/schema/reservations.ts:32 Pick canonical enum union; update both schemas + comments
M-L02 Reports stage rollup raw pipelineStage without canonicalizeStage src/lib/services/report-generators.ts:71-76, 88-106, 124-138, 176-192 Wrap row.stage with canonicalizeStage() before keying maps (defensive)

UX/forms (12)

ID Title File:line Fix sketch
M-U01 Audit log uses inline div instead of <EmptyState> src/components/admin/audit/audit-log-list.tsx:524 Replace with <EmptyState title="..." />
M-U02 Two duplicate EmptyState components with incompatible APIs src/components/ui/empty-state.tsx vs src/components/shared/empty-state.tsx Migrate 3 ui/ callers to shared/, delete ui/empty-state
M-U03 Required-field marker inconsistent client-form.tsx:273, interest-form.tsx:281 Single pattern: <Label>Field <span aria-hidden>*</span></Label> + aria-required="true"
M-U04 Help-text discoverability inconsistent src/components/shared/filter-bar.tsx, client-form.tsx Document a rule (always-visible for constraints; tooltips only for icons)
M-U05 Cancel/dismiss without unsaved-changes warning on ClientForm/YachtForm client-form.tsx, yacht-form.tsx Add isDirty guard + discard AlertDialog matching InterestForm
M-U06 FileUploadZone size limit not surfaced as client-side check src/components/files/file-upload-zone.tsx:170 Wire client-side size check before upload
M-U07 No jump-to-page input in pagination src/components/shared/data-table.tsx:420 Add small <input type="number"> between Previous/Next
M-U08 No column resize/reorder on DataTable src/components/shared/data-table.tsx Opt-in enableColumnResizing per table via TanStack v8
M-U09 Invoice delete uses custom overlay, not AlertDialog src/app/(dashboard)/[portSlug]/invoices/page.tsx:167 Replace with <ConfirmationDialog>
M-U10 Success toast missing on ClientForm + InterestForm create/edit client-form.tsx:215, interest-form.tsx:235 toast.success(isEdit ? 'Client updated' : 'Client created')
M-U11 Logo preview <img alt=""> should describe state src/components/admin/shared/settings-form-card.tsx:420 alt="Port logo preview" or dynamic from field label
M-U12 Heading hierarchy inconsistent within tab components email-accounts-list.tsx:114, interest-contract-tab.tsx:130/251/291/364 Audit each tab; standardize h2/h3 nesting
M-U13 DialogContent missing aria-describedby on minimal dialogs compose-dialog.tsx:95 + ~40 others Add <DialogDescription className="sr-only"> or aria-describedby={undefined}
M-U14 Mobile topbar title blank on list pages client-list.tsx, yacht-list.tsx, interest-list.tsx, berth-list.tsx useMobileChrome({ title, showBackButton: false }) per list
M-U15 Invoices missing from mobile navigation src/components/layout/mobile/more-sheet.tsx:54 Add { label: 'Invoices', icon: FileText, segment: 'invoices' } to Operations group

🟢 LOW (8)

ID Title File:line
L-AU01 Tier map sparse; new actions default to 'info' (password_change, portal_activate, revoke_invite) src/lib/audit.ts:220-222
L-AU02 Action filter dropdown missing 12 verbs audit-log-list.tsx:393-415
L-AU03 Entity-type filter dropdown missing 7 entries audit-log-list.tsx:88-102
L-AU04 Dead code — listAuditLogs (ILIKE) src/lib/services/audit.service.ts
L-D01 HubRootView has 2 sections, not 3 (CLAUDE.md spec inaccuracy) src/components/documents/hub-root-view.tsx:50-100
L-D02 interest.yachtId branch in chain doc spec is unreachable (interests.clientId NOT NULL) src/lib/services/documents.service.ts:1225-1251
L-P01 List endpoint limit cap = 1000 (audit log uses 200 + cursor as the better pattern) src/lib/api/list-query.ts
L-L01 Reports stage-revenue rollup raw pipelineStage (defensive concern, no active bug) src/lib/services/report-generators.ts:71-192

Areas verified clean

  • Documents/files structurally solid across 22 checks (one event-name mismatch + 2 doc divergences only)
  • Security XSS / SQLi / path traversal / SSRF / encryption-at-rest all clean (one S3 access key plaintext)
  • Multi-tenancy entry-point port isolation correct everywhere; gaps are TOCTOU-style only
  • Documenso v1+v2 routing complete and version-aware; magic-byte verification on both upload paths
  • Public berths API + public health endpoint + cookie flags + CSP + CSRF all correctly configured
  • Audit log core write path covers all sampled mutations; maskSensitiveFields covers expected PII fragments
  • Better-auth session fixation, token expiry, audit-log tamper-resistance all clean
  • Legacy 9-stage enum refactor — rank tables now include both legacy + modern keys (commit 9821106 closed the gap); all rendering surfaces route through stageLabelFor or LEGACY_STAGE_REMAP
  • BullMQ retry/backoff configured; Redis noeviction enforced in compose; worker process bootstraps all 10 queues
  • pdf-lib AcroForm fill, EOI merge tokens, formatBerthRange (single/contig/non-contig/cross-pontoon)
  • Inline editing pattern present on all 6 detail page types; NotesList polymorphic across all 6 entity types


Browser sweep findings (Playwright MCP) — 2026-05-15

Live exploratory testing of the dev instance (port-amador + port-nimara seeded) using Playwright MCP. All findings below were either (a) confirmation of static findings, or (b) new bugs only visible at runtime.

New criticals + highs from browser sweep

  • 🔴 C-03 — Generic PATCH /api/v1/interests/[id] bypasses ALL stage-transition guards (see C-03 above for full detail). The single most impactful new finding from the sweep.
  • 🟠 H-14DELETE /outcome with empty body returns 500 (see H-14 above).
  • 🟠 H-15 — Sales-agent → /admin/* silently bounces to /dashboard, no 403 page or toast (see H-15 above).

New medium from browser sweep

  • M-NEW-1/api/v1/me and /api/v1/me/ports return 400 "Port context required" for non-super-admin callers without the X-Port-Id header. Super-admin works without the header. Impact: chicken-and-egg for the bootstrap flow that needs to know which ports a user has access to in order to choose one. Frontend likely passes the header from cookie state, but the contract is asymmetric per role. Fix: treat absent X-Port-Id on /me/ports as "list all ports the user has access to, regardless of context".
  • M-NEW-2 — Activity feed entity-type label rendered without separator: "Test Person 1interest", "Audit_loglist", "Settingrecom" — entity name + type concatenated. File: src/components/dashboard/activity-feed.tsx (the line that renders the entity label + type tag). Fix: add a separator (space, dot, or pipe) between name and type.

Verifications confirmed clean in browser

Check Result
C-02 /setup deadlock confirmed: navigation redirects to /login (no ?redirect= param even); bootstrap/status returns needsBootstrap: false on populated DB; loop fires when fresh
H-02 ?redirect= ignored confirmed: signed in with ?redirect=%2Fport-amador%2Fclients%2Fsome-fake-id → landed at /port-amador/dashboard
H-04 Retry-After missing confirmed: 429 fired on 2nd bad sign-in attempt, headers x-ratelimit-limit/remaining/reset present, NO Retry-After
R-004 cross-port URL clean: /port-amador/clients/<port-nimara-uuid> shows friendly "Client not found... different port" page
MT-02 cross-port PATCH clean: PATCH /api/v1/interests/<port-nimara-id> with X-Port-Id: port-amador → 404 "We couldn't find that interest"
Viewer permissions clean: read 200, write same-port 403 "Insufficient permissions", write cross-port 403 "No access to this port"
F27 same-stage no-op clean via /stage endpoint (returns 204); broken via generic PATCH (200 + body) — see C-03
Forbidden transition clean via /stage (400 with override-required-reason copy); bypassed via generic PATCH (see C-03)
Override no-reason clean via /stage (400 "Override requires a reason (min 5 chars)")
Override short-reason clean via /stage (same 400)
AU-11 permission_denied filter activity feed shows no raw permission_denied rows
A2 legacy enum in feed no raw deposit_10pct / eoi_sent / contract_signed in activity feed text
R-008 mooring URL canonicalization A1=200, a1=400, A%201=400, A-1=400
B-10 webhook empty/malformed body both return 200 {ok:false} (graceful)
Tag CRUD (AD-014) 201 create + 204 delete
Settings update (AD-008) 200 with persisted body
Interest detail render EOI badge, milestone "EOI sent May 14, 2026", no raw legacy values, no errors
Interest reopen with reopenStage 200 ok
Public berths shape 117 berths, statuses split Sold=11 / Under Offer=49 / Available=57

Out of scope for this sweep (not exercised)

  • Live Documenso integration (requires real-API project — pnpm exec playwright test --project=realapi)
  • IMAP bounce probe round-trip (requires SMTP+IMAP credentials)
  • C-01 berth-INNER-JOIN bug — would require hard-deleting a berth in the live DB (destructive); static analysis already conclusive
  • Browser-side cross-browser testing (BR-* — Safari, Firefox, Edge)
  • Drag-and-drop kanban interactions
  • Visual regression baselines (--project=visual snapshots)