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>
41 KiB
41 KiB
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
C-01 (B-01) — INNER JOIN on hard-deleted berth silently drops interest→berth links
- 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.archiveInterestcallsgetPrimaryBerthbefore 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 withinterest_berthsrows (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_PATHSincludes/api/v1/bootstrap/but NOT/setup. Unauthenticated user →/setup→ middleware redirects to/login?redirect=/setup. Login useEffect fetches bootstrap status, callsrouter.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'toPUBLIC_PATHS.POST /api/v1/bootstrap/super-adminalready self-protects withhasAnySuperAdmin(). - Browser-verified: Navigating to
/setupunauthenticated redirects to/login(no?redirect=even). The bootstrap-status check atsrc/app/(auth)/login/page.tsx:41confirms: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(callsupdateInterest);src/lib/services/interests.service.ts:701(updateInterest);src/lib/validators/interests.ts:68,90(pipelineStageflows throughupdateInterestSchemato the service) - What: The
/stageendpoint (src/app/api/v1/interests/[id]/stage/route.ts) callschangeInterestStagewhich enforcesSTAGE_NOOPearly-return,canTransitionStage()table guard, override-requires-permission, and override-requires-≥5-char-reason. The generic PATCH endpoint callsupdateInterestwhich 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/stagecorrectly 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'whilepipelineStage = 'enquiry'— corrupted state. - Audit row written as generic
action: 'update'with diff, notaction: 'stage_change'with proper metadata. Webhook eventinterest:updatednotinterest:stageChanged.
- PATCH
- 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_atand 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, omitpipelineStage(force callers to use/stage); OR inupdateInterest, whenpipelineStageis in the payload, delegate tochangeInterestStagewith 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 }emitsON 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.clientIddesign 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 readsuseSearchParams(); alwaysrouter.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+resendCrmInviteto emit${env.APP_URL}/set-password#token=${encodeURIComponent(raw)}. Updateset-password/page.tsxto use the fragment-reading pattern fromPasswordSetForm(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 emitsX-RateLimit-Limit/Remaining/Reset.enforcePublicRateLimitaddsRetry-After; this route usescheckRateLimitdirectly 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
isActiveon email account with nocreateAuditLogcall.connectAccount(line 70) anddisconnectAccount(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 }, ... })insidetoggleAccount.
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:
updateSalesEmailConfigcallsupsertSetting('sales_smtp_pass_encrypted', <ciphertext>, portId, meta).upsertSettingrecordsnewValue: { value: '<ciphertext>' }.maskSensitiveFieldschecks JSON keys againstSENSITIVE_KEY_FRAGMENTS; the wrapping key"value"isn't in the list. Ciphertext lands verbatim inaudit_logs.new_value. - Impact: Audit log readable by all admins with
admin.view_audit_log. DB read access exfils ciphertext; ifEMAIL_CREDENTIAL_KEYis ever compromised, the historical audit log becomes a credential store. - Fix: In
upsertSetting, detect when key ends with_encrypted(or acceptredactValue?: boolean) and recordnewValue: { value: '[redacted]' }.
H-07 (AU-10) — Cascade-archived interests produce no individual audit rows
- File:
src/lib/services/clients.service.ts:578-618 - What:
archiveClientbatch-archives open interests, writes ONEentityType: 'client'row withnewValue: { cascadedInterestIds: [...] }. No per-interest rows.search_textdoesn't includenew_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
archivedInterestIdsand emit per-interestcreateAuditLog({ 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:
createSalesTransporterbuilds nodemailer transport with no timeout options. ComparecreateTransporterinsrc/lib/email/index.ts:26-37which usesSMTP_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_TIMEOUTSconstant tonodemailer.createTransportincreateSalesTransporter.
H-09 (B-16) — AppShell remounts children on breakpoint crossing, destroying form state
- File:
src/components/layout/app-shell.tsx:58-70 - What: When
isMobileflips on resize, the shell switches between<MobileLayout>{children}</MobileLayout>and the desktop<div>...{children}...</div>. React unmounts and remountschildren, destroying any in-progressuseStateform drafts includingInlineEditableField. - 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. Alternativelykey={isMobile ? 'mobile' : 'desktop'}only on shell wrappers withchildrenstable 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 noaria-label. - Impact: Screen readers read literal Unicode names. Project memory: decorative unicode glyphs explicitly flagged.
inline-stage-picker.tsx:443comment 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 (currentlyMoreSheetonly). - 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:145usestoast.warning(...)correctly. - Impact: Native alert blocks main thread, can't be styled, fires in tests without suppression.
- Fix: Replace with
toast.warning(...)matchingclient-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:96correctly uses<span className="sr-only">More folder actions</span>. - Fix: Add
aria-labelto 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).clearOutcomeSchemasaysreopenStageis 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>/outcomecalls (no body) returned 500 withrequestId: 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 onreq.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.tsand/or per-route admin layout - What: Sales-agent navigating to
http://localhost:3000/port-amador/admin/auditlands athttp://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
/403page 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;
maskSensitiveFieldscovers 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
9821106closed the gap); all rendering surfaces route throughstageLabelFororLEGACY_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-14 —
DELETE /outcomewith 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/meand/api/v1/me/portsreturn 400 "Port context required" for non-super-admin callers without theX-Port-Idheader. 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 absentX-Port-Idon/me/portsas "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=visualsnapshots)