Full Codebase Audit — 2026-05-18
Companion doc: Alpha UAT Master — the multi-day cross-cutting Playwright/React-Grab walkthrough doc, findings cross-referenced here as → confirmed in manual #N.
Methodology: Parallel sonnet[1m] audit team (16 narrow-scope agents), each assigned a specific subsystem with no overlap. Every finding includes file:line evidence; severity is critical | high | medium | low | info. Findings here are raw — triage + prioritization at the bottom.
Scope: entire src/ tree at commit b3f8756 (post-audit-cleanup). Excludes docs/, tests/ (covered by F3), build/Docker config, and node_modules.
Out of scope: anything in docs/BACKLOG.md already triaged. This audit looks for NEW findings not on that list.
Audit team composition
| Agent |
Scope |
| A1 — Schema: people/orgs |
src/lib/db/schema/{clients,yachts,companies,users}.ts |
| A2 — Schema: pipeline |
src/lib/db/schema/{interests,berths,reservations}.ts |
| A3 — Schema: docs+infra |
src/lib/db/schema/{documents,email,brochures,system}.ts |
| B1 — Public API |
src/app/api/public/* |
| B2 — Admin API |
src/app/api/v1/admin/* |
| B3 — v1 entity CRUD |
src/app/api/v1/{clients,interests,yachts,companies,berths}/* |
| B4 — Webhooks/auth/storage |
src/app/api/{webhooks,auth,storage}/* |
| C1 — EOI/Documenso services |
src/lib/services/{eoi-*,document-templates,custom-document-upload,documenso-client}.ts |
| C2 — Domain services |
src/lib/services/{berth-*,reminders,notifications,inquiry-notifications}.ts |
| C3 — Observability/audit |
src/lib/services/error-events.service.ts, src/lib/audit.ts, src/lib/storage/* |
| D1 — Jobs/queues |
src/lib/queue/scheduler.ts, src/lib/queue/workers/*, src/jobs/processors/* |
| E1 — Admin UI |
src/app/(dashboard)/[portSlug]/admin/* |
| E2 — Entity UI |
src/components/{interests,clients,yachts,companies,berths}/* |
| F1 — Security cross-cut |
Auth/permission gaps, XSS/SQLi, port-isolation, secret leaks |
| F2 — Performance |
Missing indexes, N+1 queries, unbounded fan-outs, hot paths |
| F3 — Tests + deps |
Coverage gaps, package.json freshness, Docker/CI |
Findings by agent
A2 — Schema: pipeline (15 findings: 3 high, 4 medium, 7 low, 1 info)
| # |
Severity |
Title |
Evidence |
| 1 |
high |
No DB-level CHECK on interests.pipeline_stage |
interests.ts:44 — text col, no CHECK; legacy 'completed' / 'eoi_signed' can persist via raw SQL |
| 2 |
high |
No DB-level CHECK on outcome, eoi_doc_status, reservation_doc_status, contract_doc_status |
interests.ts:47-49,84 — bare text on all 4 enum-shaped columns |
| 3 |
high |
No DB-level CHECK on berths.status |
berths.ts:31 — derivePublicStatus() silently falls through to 'Available' on bad values |
| 4 |
medium |
No CHECK on berth_reservations.status — breaks idx_br_active invariant |
reservations.ts:34,61-64 — misspelled 'Active' bypasses the one-active-per-berth guard |
| 5 |
medium |
Stale berthId field on Interest domain type |
src/types/domain.ts:39 — interests.berth_id was dropped in 0029; type still declares it |
| 6 |
medium |
Board query missing composite partial index — bitmap-AND scan on large ports |
interests.ts:113-117 — need (portId, pipelineStage) WHERE archivedAt IS NULL AND outcome IS NULL |
| 7 |
medium |
interestTags.tagId + berthTags.tagId are comment-only FKs, no DB constraint |
interests.ts:205-207, berths.ts:267-269 — tag deletes silently orphan junction rows |
| 8 |
medium |
berthWaitingList lacks port_id column — no schema-level cross-port isolation |
berths.ts:170-192 — defense-in-depth depends entirely on service layer |
| 9 |
low |
No index on interest_berths.is_in_eoi_bundle |
bundle lookups scan all rows for the interestId |
| 10 |
low |
berthRecommendations lacks port_id — same isolation pattern as #8 |
berths.ts:146-168 |
| 11 |
low |
interests.assignedTo, interest_berths.addedBy/eoiBypassedBy are bare text — no FK to users |
dead entries accumulate on user delete |
| 12 |
low |
berthMaintenanceLog.portId FK missing onDelete — implicit NO ACTION breaks H-01 convention |
berths.ts:204-206 |
| 13 |
low |
berthReservations.startDate/endDate use timestamptz mode:'date' — TZ off-by-one risk |
should be date() |
| 14 |
low |
idx_interests_stage is not partial — bloats with archived + closed rows |
add WHERE archivedAt IS NULL AND outcome IS NULL |
| 15 |
info |
is_primary ≤1 per interest invariant correctly enforced via partial unique index |
interests.ts:165-167 — no action needed |
B2 — API: admin (10 findings: 2 medium, 8 low)
| # |
Severity |
Title |
Evidence |
| 1 |
medium |
GET /qualification-criteria has no withPermission gate |
qualification-criteria/route.ts:9 — any authenticated user can enumerate; POST correctly gates |
| 2 |
medium |
Triage PATCH on website-submissions uses view_audit_log (read) for a write |
website-submissions/[id]/triage/route.ts:26 — semantic mismatch; should be manage_settings |
| 3 |
low |
/admin/storage/route.ts POST returns bare result without {data:...} |
storage/route.ts:64 — breaks toastError frontend hook |
| 4 |
low |
/admin/ocr-settings/test POST returns bare result without {data:...} |
ocr-settings/test/route.ts:26 |
| 5 |
low |
/admin/ocr-settings PUT returns {ok:true} — legacy success-flag pattern |
ocr-settings/route.ts:64 — should be 204 or {data: updatedConfig} |
| 6 |
low |
/admin/custom-fields/[fieldId] PATCH uses raw req.json() + manual .parse() not parseBody |
custom-fields/[fieldId]/route.ts:18-19 — generic 500 instead of structured 400 |
| 7 |
low |
/admin/ai-budget PUT — setAiBudget audit record missing ipAddress + userAgent |
ai-budget/route.ts:40 |
| 8 |
low |
/admin/ocr-settings PUT — saveOcrConfig audit record missing ipAddress + userAgent |
ocr-settings/route.ts:53 — encrypted API key swap is high-impact, deserves full context |
| 9 |
low |
/admin/brochures/[id] PATCH+DELETE pass no audit meta to service helpers |
brochures/[id]/route.ts:26,37 + brochures POST — pattern mismatch with form-templates, custom-fields, document-templates |
| 10 |
low |
/admin/email-templates PUT returns {data:{ok:true}} — flag body instead of entity or 204 |
email-templates/route.ts:84 |
A3 — Schema: docs+infra (15 findings: 1 high, 7 medium, 7 low)
| # |
Severity |
Title |
Evidence |
| 1 |
high |
documents.documenso_id has NO INDEX |
documents.ts:88 — full table scan on every webhook delivery (hottest read path); only documenso_numeric_id is indexed |
| 2 |
medium |
documentSigners.signingToken indexed but NOT unique |
documents.ts:188,193 — token collision/replay has no DB-level guard; should be partial uniqueIndex |
| 3 |
medium |
audit_logs missing 4-column inspector index |
system.ts:62-63 — neither existing index covers (port_id, entity_type, entity_id, ORDER BY created_at) without heap re-filter |
| 4 |
medium |
system_settings NULLS NOT DISTINCT lives in migration 0047 only — db:push drops it |
system.ts:144-149 — fresh db:push re-introduces the duplicate-global-settings bug 0047 fixed |
| 5 |
medium |
documentFolders.parentId self-FK MISSING from Drizzle schema (only in migration 0050) |
documents.ts:357-358 — fresh db:push skips the self-FK; orphaned folders undetectable |
| 6 |
medium |
emailMessages.attachmentFileIds text[] with no FK — dangling IDs survive RTBF wipe |
email.ts:78 + client-hard-delete.service.ts:269-277 — RTBF wipes body/subject but not attachment file references |
| 7 |
medium |
brochureVersions missing unique(brochureId, versionNumber) — unlike berth_pdf_versions |
brochures.ts:79 — concurrent uploads could assign duplicate version numbers |
| 8 |
medium |
documensoNumericId indexed non-uniquely despite being globally unique |
documents.ts:94,152 — webhook resolver matches multiple docs for same numeric ID; double-processing |
| 9 |
low |
emailThreads.clientId has no onDelete clause — defaults to RESTRICT, inconsistent with set null peers |
email.ts:50 |
| 10 |
low |
files.storagePath has no unique constraint — duplicate blob paths undetected |
documents.ts:41 — migrate-storage.ts would silently double-migrate |
| 11 |
low |
brochureVersions.storageKey + berth_pdf_versions.storageKey lack unique constraints |
same as #10 |
| 12 |
low |
documentSends.berthPdfVersionId has no index — full-scan for version-X queries |
brochures.ts:120 |
| 13 |
low |
C.2 dedup gap: SIGNED events with recipient_email=NULL fall back to broken hash-only path |
migration 0075 risk note: any v2 code path emitting global SIGNED without recipient context bypasses per-recipient dedup |
| 14 |
low |
C.2 dedup over-eager: void-then-reinvite with same email blocks the legitimate 2nd signing |
documents.ts:230-232 — partial unique on (docId, recipientEmail, eventType) treats reinvited signing as re-delivery |
| 15 |
low |
document_sends + emailMessages parallel send-audit tables with no cross-reference |
future IMAP-synced sent-folder → duplicate GDPR exports |
B1 — API: public (12 findings: 1 high, 3 medium, 5 low, 3 info)
| # |
Severity |
Title |
Evidence |
| 1 |
high |
portId is caller-controlled on /interests — NOT validated against existing ports |
interests/route.ts:40 — caller can inject client/yacht/interest into ANY tenant they know the UUID for; residential-inquiries DOES validate |
| 2 |
medium |
Health endpoint X-Intake-Secret comparison leaks secret byte-length via timing short-circuit |
health/route.ts:57 — length check before timingSafeEqual; website-inquiries does it right |
| 3 |
medium |
X-Forwarded-For spoofable — rate-limit keys are attacker-controlled on all public POST routes |
interests/residential/website-inquiries — no x-real-ip fallback; route-helpers clientIp() has it but isn't used |
| 4 |
medium |
/public/supplemental-info/[token] has NO rate limiting on GET or POST |
supplemental-info/[token]/route.ts — POST writes live client PII (name, address, email, phone) at unlimited rate |
| 5 |
low |
Unbounded string fields in public schemas — multi-MB payloads allowed |
publicInterestSchema/publicResidentialInquirySchema — no .max() on phone/notes/preferences; no segment bodySizeLimit |
| 6 |
low |
Invalid portId on /interests causes 500 (DB FK error) not 400 |
residential route has the explicit pre-check; interests doesn't |
| 7 |
low |
supplemental-info POST uses raw req.json() + .parse() instead of parseBody() |
malformed JSON returns 500 not field-level 400 |
| 8 |
low |
supplemental-info GET missing Cache-Control: no-store — intermediaries may cache token-keyed PII payload |
response includes primaryEmail/Phone/streetAddress |
| 9 |
low |
Rate limiting fails open on Redis outage — silently drops public-form protection |
rate-limit.ts:57-73 — intentional for auth, equally affects public POST |
| 10 |
info |
applySubmission distinguishes consumed vs expired token in error message |
violates the conflation principle the GET path uses |
| 11 |
info |
Authenticated health probe discloses APP_URL and NODE_ENV |
health/route.ts:86-93 — internal URL leak via authed probe |
| 12 |
info |
residential-inquiries exposes internal UUIDs and uses deprecated {success:true} envelope |
residential-inquiries/route.ts:123 |
F3 — Tests + deps + infra (15 findings: 2 critical, 3 high, 4 medium, 5 low, 1 info)
| # |
Severity |
Title |
Evidence |
| 1 |
CRITICAL |
client-hard-delete.service.ts has ZERO unit or integration tests |
GDPR/CCPA-critical path just modified today; no automated regression guard |
| 2 |
CRITICAL |
No CI/CD pipeline — .github/workflows/ does not exist |
every merge can silently break tests; the full vitest+playwright suite must be run manually |
| 3 |
high |
alert-engine-realtime.spec.ts permanently skips a test whose route now exists |
spec skip says route not implemented; route file present at /admin/alerts/run-engine |
| 4 |
high |
documenso-client.ts v1/v2 routing has no dedicated unit test |
every EOI + document-send path goes through it |
| 5 |
high |
Coverage config excludes src/app/ — route handlers never counted |
vitest.config.ts: coverage.include: ['src/lib/**'] — misleadingly low coverage on API surface |
| 6 |
medium |
Two competing image-crop libraries in production deps |
react-easy-crop + react-image-crop both live; one call site each |
| 7 |
medium |
Six PDF-related packages; pdfkit (1 usage) and unpdf (1 usage) candidate for consolidation |
pdf-lib, pdfjs-dist, pdfkit, react-pdf, unpdf, @react-pdf/renderer |
| 8 |
medium |
CLAUDE.md lists pdfme as a tech-stack dep — not in package.json |
removed 2026-05-12; CLAUDE.md outdated |
| 9 |
medium |
playwright.config.ts retries hardcoded to 0, not elevated in CI |
should be process.env.CI ? 2 : 0 for flaky network-bound realapi tests |
| 10 |
low |
No top-level test npm script — requires pnpm exec vitest run |
DX gap; CI templates expect a test alias |
| 11 |
low |
Missing test:e2e:realapi and test:e2e:visual shorthand scripts |
inconsistency vs test:e2e:smoke/exhaustive/destructive |
| 12 |
low |
@hookform/devtools devDep + FormDevtool wrapper component have no callers |
dead code |
| 13 |
low |
Dockerfile builder stage uses broad COPY . . — secrets rely entirely on .dockerignore |
well-structured .dockerignore mitigates, but targeted COPY is defense-in-depth |
| 14 |
low |
Large cluster of high-value services have no unit tests at all |
interest-berths, portal-auth, alert-engine, berth-rules-engine, documenso-webhook, document-reminders, external-eoi, residential, document-sends, notifications, webhooks (~50 services) |
| 15 |
info |
Exhaustive e2e tests use test.skip(true, ...) as soft guards when fixtures absent |
intentional graceful-degrade pattern; not a bug |
C3 — Observability + infra (10 findings: 2 high, 1 medium, 5 low, 2 info)
| # |
Severity |
Title |
Evidence |
| 1 |
high |
GDPR export bundles NOT deleted from storage on client hard-delete |
gdpr.ts:35 storageKey + client-hard-delete.service.ts:241-244 — files.clientId collected, gdprExports.storageKey never queried; cascade kills DB row but blob orphans. This is a gap in the A.7 RTBF fix shipped today. |
| 2 |
high |
NO RTBF/hard-delete path for residential_clients |
residential.ts schema holds equivalent PII to marina clients; zero hard-delete code path — no confirmation flow, no blob sweep, no audit, no API endpoint |
| 3 |
medium |
sentTo key bypasses audit masker — operator email stored plaintext in audit_logs.metadata |
client-hard-delete.service.ts:139,466 — sent_to doesn't contain 'email' substring. Fix: add 'sent_to' fragment, or rename to sentToEmail |
| 4 |
low |
S3Backend presignUpload/presignDownload lack withTimeout wrappers |
s3.ts:289-297 — every other method (put/get/head/delete) is wrapped; presigns aren't. TCP-blackhole stall risk |
| 5 |
low |
error_events.errorMessage and errorStack stored without PII redaction |
error-events.service.ts:143-145 — ORM errors embedding WHERE-clause values persist as PII |
| 6 |
low |
'auth' fragment over-masks: authorId, isAuthenticated, etc. |
audit.ts:125 — 'auth' is too broad; should be 'authorization' or use prefix match |
| 7 |
low |
RTBF website_submissions erasure only matches top-level JSONB email key |
client-hard-delete.service.ts:221-224 — nested email payloads (payload.contact.email) survive |
| 8 |
low |
hardDeleteCode rate limiter fails open + Math.random() 4-digit code |
combined attack surface during Redis outage; switch to crypto.randomInt() regardless |
| 9 |
info |
bulkHardDeleteClients emits no composite audit log for the bulk action itself |
forensic correlation requires grouping N rows by timestamp; one bulk-level log entry would fix it |
| 10 |
info |
requestBulkHardDeleteCode loads ALL port clients into memory for validation |
client-hard-delete.service.ts:408-419 — should WHERE id IN (args.clientIds) |
B4 — Webhooks + auth + storage (15 findings: 1 high, 6 medium, 5 low, 3 info)
| # |
Severity |
Title |
Evidence |
| 1 |
high |
better-auth rate limiter uses in-memory storage — multi-replica prod bypasses limits |
auth/index.ts:128-137 — N replicas multiplies attempt budget N×; documented as known. Swap to storage: database |
| 2 |
medium |
DOCUMENT_SIGNED route-level dedup hash never matches stored events — every retry re-enters handler |
webhooks/documenso/route.ts:173 vs documents.service.ts:1184 — raw-body SHA vs prefixed-form hash, never matches; dedup intent broken |
| 3 |
medium |
Concurrent SIGNED webhooks both see wasAlreadySigned=false, both dispatch cascade invites |
documents.service.ts:1130-1131,1196-1208 — read outside tx; handleDocumentCompleted has correct SELECT FOR UPDATE pattern but handleRecipientSigned doesn't |
| 4 |
medium |
Rate limiter fails open on Redis outage — auth brute-force protection disabled |
rate-limit.ts:57-73 — intentional; consider fail-closed + admin-IP allowlist escape hatch |
| 5 |
medium |
callbackURL forwarded to better-auth without origin validation in sign-in-by-identifier |
auth/sign-in-by-identifier/route.ts:63-96 — potential open redirect post-auth |
| 6 |
medium |
originAllowed() returns true when both Origin AND Referer absent — non-browser CSRF check bypassed |
proxy.ts:118-136 — SameSite=Strict is the real gate but defense-in-depth has a hole |
| 7 |
medium |
Legacy plaintext Documenso webhook secrets may persist in system_settings — no migration enforcement |
port-config.ts:469-472 — ports that never rotated retain cleartext |
| 8 |
low |
Storage proxy token p port-binding field is optional — tokens without p skip cross-port enforcement |
filesystem.ts:184-188,95-111 — future callers that omit portSlug mint cross-port tokens |
| 9 |
low |
Storage proxy PUT magic-byte check is application/pdf only — other content types accepted blind |
api/storage/[token]/route.ts:222-225 — png/jpg/csv/zip not inspected |
| 10 |
low |
Dev HMAC fallback derives storage proxy secret from BETTER_AUTH_SECRET — shared key in dev |
filesystem.ts:430-432 — prod rejects but dev exposed→internet could forge tokens with auth key |
| 11 |
low |
CSP policy has no report-uri/report-to — XSS probes blocked silently |
proxy.ts:16-37 — adding /api/csp-report would give early-warning |
| 12 |
low |
sign-in-by-identifier timing oracle: email-format skips DB; username-format always hits DB |
very low practical impact; doesn't reveal whether identifier exists |
| 13 |
info |
better-auth's built-in rate limiter doesn't add Retry-After on 429 |
direct /api/auth/sign-in/email lacks RFC 6585 compliance; sign-in-by-identifier wrapper has it |
| 14 |
info |
Session cookie lacks __Host- prefix — subdomain binding not enforced |
auth/index.ts:106 — SameSite=Strict+Secure mitigate; __Host- would forbid Path other than / |
| 15 |
info |
listDocumensoWebhookSecrets() issues full DB SELECT on every webhook with no cache |
port-config.ts:456-501 — amplifies bad-secret flood scenario; short TTL cache fixes |
C1 — EOI/Documenso services (15 findings: 3 high, 5 medium, 4 low, 3 info)
| # |
Severity |
Title |
Evidence |
|
| 1 |
high |
generateAndSignViaInApp omits portId on all Documenso calls — per-port v1/v2 config bypassed |
document-templates.ts:705,717 — portId optional → env fallback; v2-configured port uses v1 env defaults |
|
| 2 |
high |
custom-document-upload: placeFields called AFTER documensoSend — v2 envelope already PENDING when fields placed |
custom-document-upload.service.ts:285,294,323 — header comment documents correct order; code inverts. v2 may reject; all v2 contract/reservation uploads land with no signature fields |
|
| 3 |
high |
{{eoi.berthRange}} and all {{reservation.*}} tokens in VALID_MERGE_TOKENS but resolveTemplate never populates them |
merge-fields.ts:64-76 + document-templates.ts — tokens render as literal {{...}}; BR-140 doesn't catch because required:false |
|
| 4 |
medium |
sendReminder passes CRM document_signers.id (UUID) as Documenso recipient ID — v1 path sends invalid URL, v2 redistributes blindly |
document-reminders.ts:161 + documenso-client.ts:910 — v1 reminders consistently fail with 404; schema missing documenso_recipient_id column |
|
| 5 |
medium |
custom-document-upload does not persist documensoNumericId — v2 webhook numeric-id resolution can't match |
custom-document-upload.service.ts:345 — contract/reservation uploads on v2 instance miss webhook events |
|
| 6 |
medium |
generateDocumentFromTemplate v2: distribute failure swallowed — all signer rows get signingUrl=null with no auto-recovery |
documenso-client.ts:554-560 + document-templates.ts:843-884 — "Send invitation" button errors for every signer |
|
| 7 |
medium |
handleDocumentCompleted: interest side-effects (dateEoiSigned, berth-rules) run outside try/catch and are not idempotent across retries |
documents.service.ts:1574-1621 — each failed-PDF retry re-stamps dateEoiSigned |
|
| 8 |
medium |
distributeEnvelopeV2 normalize call loses numericId — self-heal callers can't persist |
documenso-client.ts:618-623 — pattern from generateDocumentFromTemplate not followed |
|
| 9 |
low |
voidDocument uses raw fetchWithTimeout without pRetry — transient 5xx/429 not retried |
documenso-client.ts:1289 |
|
| 10 |
low |
completion_cc_emails recipients have empty name — signing-completed email greeting malformed |
documents.service.ts:1722 — "Dear ," fallback; should be email as display name |
|
| 11 |
low |
normalizeSignerRole maps developer slot (order-2 SIGNER) to 'signer' not 'developer' — progress panel label wrong |
document-templates.ts:863-865,930-935 |
|
| 12 |
low |
persistDocumentOverrides source_document_id backfill uses 1-minute window — race if generation takes >60s |
eoi-overrides.service.ts:451,463,471 — widen to 5min or backfill by returned IDs |
|
| 13 |
info |
resolveTemplate ValidationError catch regex includes dead branch 'interest has no (yacht |
berth)' |
document-templates.ts:317-322 — dead from prior design; remove for clarity |
| 14 |
info |
berth-range: non-canonical (passthrough) moorings always appended after sorted canonical segments |
berth-range.ts:105-108 — cosmetic |
|
| 15 |
info |
{{interest.notes}} always empty in non-EOI (legacy) resolveTemplate path |
document-templates.ts:378 — silent blank in correspondence templates |
|
C2 — Domain services (15 findings: 1 high, 3 medium, 6 low, 5 info)
| # |
Severity |
Title |
Evidence |
| 1 |
high |
Recommender: SQL vs JS stage-scale mismatch — Tier D fires one stage too early |
berth-recommender.service.ts:212,499-502,223,554 — JS LATE_STAGE_THRESHOLD=5 (deposit_paid in JS scale) vs SQL emits 5=reservation. Tier D fires at reservation, not deposit_paid. Berths with reservation-stage active interest hidden one stage early. |
| 2 |
medium |
createNotification dedup is non-atomic SELECT-then-INSERT with no DB unique constraint (TOCTOU) |
notifications.service.ts:67-85,117 — concurrent inquiry fan-out can double-insert. Fix: partial unique on (userId, type, dedupeKey) + ON CONFLICT DO NOTHING |
| 3 |
medium |
completeReminder TOCTOU — concurrent calls both pass status guard, produce dup audit rows |
reminders.service.ts:317-332 — no WHERE status='pending' in UPDATE; no advisory lock |
| 4 |
medium |
processFollowUpReminders lacks advisory lock — concurrent workers double-insert auto-generated reminders |
reminders.service.ts:428-517 — 3 non-tx round-trips; processOverdueReminders has the right pattern, this one doesn't |
| 5 |
low |
createNotification with inApp=false + email=true silently drops the email |
notifications.service.ts:107-113 — acknowledged in comment but untracked gap |
| 6 |
low |
public-interest creates interest with legacy pipelineStage='open' instead of 'enquiry' |
public-interest.service.ts:233 — modern stage is enquiry; column default agrees |
| 7 |
low |
public-interest berth lookup outside transaction — FK violation on race-deleted berth |
public-interest.service.ts:79-87,237-244 |
| 8 |
low |
public-interest no yacht dedup — re-submissions create duplicate yacht records |
public-interest.service.ts:177-203 — client + company dedup'd; yacht isn't |
| 9 |
low |
inquiry-notifications.findUsersWithInterestsPermission has no deactivated-user filter |
inquiry-notifications.service.ts:149-168 — deactivated users still receive new_registration alerts |
| 10 |
low |
Rules engine suggest-mode unconditionally calls createAuditLog — audit flood on webhook retries |
berth-rules-engine.ts:102-117,201-207 |
| 11 |
low |
interest-berths cross-port guard silently passes when interestId doesn't exist |
interest-berths.service.ts:232-244 — should throw NotFoundError explicitly |
| 12 |
info |
processOverdueReminders un-snooze + claim are two non-tx UPDATEs — survivable, no fix required |
at-least-once semantics |
| 13 |
info |
Dynamic import in removeInterestBerth is still required (cycle break) |
interest-berths.service.ts:356-361 — not stale |
| 14 |
info |
Inconsistent evaluateRule import style — static vs dynamic across files |
maintenance hazard; documenting needed |
| 15 |
info |
STAGE_ORDER.completed=6 in recommender JS is dead code — SQL CASE never emits 'completed' |
misleads maintainers |
D1 — Jobs/queue/cron (8 findings: 3 critical, 1 high, 2 medium, 2 low)
| # |
Severity |
Title |
Evidence |
| 1 |
CRITICAL |
send-invoice + invoice-overdue-notify dispatched to queues WITH NO WORKER HANDLER |
invoices.ts:597-600,740-743 — both fall to default branch, log "Unknown … job", complete successfully. Every invoice send AND every overdue check is a silent no-op. |
| 2 |
CRITICAL |
5 maintenance cron jobs scheduled but unimplemented — silent no-ops with false-green audit |
scheduler.ts: calendar-sync, database-backup, backup-cleanup, session-cleanup, temp-file-cleanup — workers/maintenance.ts has no case for any. database-backup is the dangerous one. RECURRING_JOB_NAMES contains them so audit shows green. |
| 3 |
CRITICAL |
tenure-expiry-check scheduled, in RECURRING_JOB_NAMES, but has no handler and no service |
scheduler.ts:32 — daily 08:00 schedule; workers/notifications.ts no case; no tenure-expiry service exists |
| 4 |
high |
processDocumensoPoll TOCTOU race — concurrent ticks can double-fire cascading invite email |
jobs/processors/documenso-poll.ts:46-47 — wasAlreadySigned read outside tx; documents queue concurrency=3 with 5-min poll → overlapping ticks plausible |
| 5 |
medium |
documenso-void enqueued without natural-key jobId at both archive call sites |
clients/[id]/archive/route.ts:95, clients/bulk/route.ts:180 — double-archive enqueues two void jobs; second hits already-voided envelope → spurious dead-letter |
| 6 |
medium |
report-scheduler nextRunAt UPDATE not transactional with job enqueue — crash silently drops a period |
workers/reports.ts — 3 separate round-trips; crash between A and C skips the period |
| 7 |
low |
bounce-poll absent from RECURRING_JOB_NAMES — no cron_run audit row on successful ticks |
audit-helpers.ts:27-49 — operators can't detect stalled poller via audit log |
| 8 |
low |
maintenance queue concurrency=1 with HOL-blocking risk |
analytics-refresh + bounce-poll can starve alerts-evaluate (every 5min) — split into fast/slow queues |
F2 — Performance (8 findings: 3 high, 5 medium)
| # |
Severity |
Title |
Evidence |
| 1 |
high |
getClientById: 6 independent DB queries run SEQUENTIALLY on hot client detail path |
clients.service.ts:358,363,368,374,392,415 — 7 serial round-trips per page load; should be Promise.all([...6]) after gating client lookup |
| 2 |
high |
notification-digest: nested port×user loops → O(ports × users) sequential queries + emails |
notification-digest.service.ts:71,74,109,113 — per port: 6+ queries; per user: 1 query + 1 send, all serial. Ports + users are independent |
| 3 |
high |
Missing index on interests.reminder_enabled — processFollowUpReminders full-scans active interests per port |
reminders.service.ts:432-441 — no existing index covers (portId, reminderEnabled) WHERE archived_at IS NULL |
| 4 |
medium |
reconcileAlertsForPort: N individual INSERTs + N UPDATEs per alert-engine evaluation |
alerts.service.ts:53-80,89-99 — batch INSERT ... ON CONFLICT DO NOTHING RETURNING; UPDATE WHERE id IN (...) |
| 5 |
medium |
client-archive-dossier: N DB queries inside loop over distinctBerthIds |
client-archive-dossier.service.ts:244,252 — single query WHERE berthId IN (...) + JS group |
| 6 |
medium |
email_threads: no compound (portId, lastMessageAt) index — list endpoint forces filesort |
email.ts:57 — only idx_et_port covers portId; sort step grows with thread volume |
| 7 |
medium |
createPending (berth-reservations): 3 independent tenant-validation lookups serial |
berth-reservations.service.ts:95,100,105 — berth/client/yacht should be Promise.all |
| 8 |
medium |
webhook-dispatch: sequential INSERT + BullMQ enqueue per matching webhook |
webhook-dispatch.ts:47-75 — batch the inserts (RETURNING id), then Promise.all the queue.adds |
A1 — Schema: people/orgs (audited inline; agent stuck) (12 findings: 1 high, 6 medium, 5 low)
| # |
Severity |
Title |
Evidence |
| 1 |
high |
yachts.currentOwnerType/currentOwnerId polymorphic — NO CHECK constraint on the type discriminator |
yachts.ts:44-45 — currentOwnerType is bare text; a value other than 'client'/'company' silently corrupts ownership resolution downstream |
| 2 |
medium |
clients.mergedIntoClientId self-FK lives in migration 0042 only — db:push drift (same pattern as A3 #5) |
clients.ts:53-58 — Drizzle's table builder doesn't accept self-references in column factory; constraint missing from db:push schema |
| 3 |
medium |
clients.sourceInquiryId FK lives in migration 0065 only — db:push drift |
clients.ts:33-38 — comment acknowledges the gap; fresh db:push skips it |
| 4 |
medium |
clientAddresses.label='Primary' default + isPrimary=true default conflicts |
clients.ts:250,258 — every new address is "primary" by default; partial unique idx_ca_primary then rejects the second. Either flip the default or fail less surprising |
| 5 |
medium |
No DB CHECK on clients.preferredContactMethod enum (email/phone/whatsapp) |
clients.ts:27 |
| 6 |
medium |
No DB CHECK on yachts.status enum (active/retired/sold_away) |
yachts.ts:46 |
| 7 |
medium |
companyMemberships.role no DB CHECK on enum (director/officer/broker/representative/legal_counsel/employee/shareholder/other) |
companies.ts:65 |
| 8 |
low |
clientNotes.authorId, yachtNotes.authorId, companyNotes.authorId all bare text — no FK to user |
clients.ts:149, yachts.ts:107, companies.ts:126 — dangling on hard user delete |
| 9 |
low |
clients.archivedBy bare text — no FK to user; same dangling-on-delete pattern |
clients.ts:41 |
| 10 |
low |
clientTags.tagId, yachtTags.tagId, companyTags.tagId — bare text, comment-only FK to tags |
clients.ts:165, yachts.ts:123, companies.ts:142 — same gap as A2 #7 for pipeline tables |
| 11 |
low |
yachtOwnershipHistory has no DB-level guard that startDate ≤ endDate |
yachts.ts:83-84 — date inversion possible without CHECK |
| 12 |
low |
yachts.lengthFt/lengthM/lengthUnit denormalized triple — no DB-level invariant that lengthUnit aligns with which of (lengthFt, lengthM) is non-null |
yachts.ts:32-43 — service layer can write lengthUnit='ft' while lengthFt=null; produces broken display |
F1 — Cross-cut: security (audited inline; agent stuck) (4 findings: 1 medium, 3 low)
The cross-cutting security audit is partly redundant with B1/B4/C3 findings already reported. Only NEW issues here:
| # |
Severity |
Title |
Evidence |
| 1 |
medium |
send-document-dialog.tsx lines 248 + 274 use dangerouslySetInnerHTML for previewHtml — verify renderEmailBody() allowlist sanitization |
send-document-dialog.tsx:248,274 — flows from API; renderEmailBody documented escape-then-allowlist, but the dialog's preview path needs explicit audit to confirm no untrusted HTML leaks |
| 2 |
low |
Many findFirst queries in services without explicit port_id filter — depends on FK chain |
examples: notes.service.ts:767, email-threads.service.ts:68,101,106,144,177,255 — defense-in-depth gap; FK joins enforce isolation but a direct call from a route bypassing service wrappers could leak |
| 3 |
low |
136 raw sql\`` template literals in services — manual review-worthy for SQLi |
full sweep not done; spot-checks at known sites (berth-recommender, search) use parameterized ${} interpolation via Drizzle |
| 4 |
info |
Most other security surfaces already covered by B1/B4/C3 reports above |
see cross-references |
B3 — v1 entity CRUD (audited inline; agent stuck) (3 findings, structurally clean)
Spot-check across 303 v1 route files: structurally healthy. Sample at /api/v1/clients/route.ts is exactly the documented pattern — withAuth(withPermission(resource, action, async (req, ctx) => { try { parseBody/parseQuery + service call; return {data}; } catch (error) { return errorResponse(error); } })). No bare route handlers found.
| # |
Severity |
Title |
Evidence |
| 1 |
low |
handlers.ts sibling pattern means grep for missing withAuth needs to skip them |
not a finding per se, just a noting that the testability split documented in CLAUDE.md is honored |
| 2 |
low |
Pagination shape on /api/v1/clients returns {data, pagination: {...}} but list endpoints elsewhere return {data, total, hasMore} (CLAUDE.md convention) |
clients/route.ts:18-28 — minor shape drift; not breaking but lists aren't uniform |
| 3 |
info |
Most B3 quality findings already covered by B1 (port validation), C2 (race + dedup), C3 (audit gaps) |
this scope was already well-covered |
E1 — Admin UI (agent stuck; not audited)
The admin-ui agent went idle 4 times across multiple pings. The most likely interpretation is that the surface is large enough that even Sonnet 1M's context was filled before a useful answer landed. E1 should be re-spawned with a much narrower scope (one page at a time) or audited inline in a follow-up pass.
E2 — Entity UI (agent stuck; not audited)
Same pattern as E1. Entity-tab UI surface across 5 entity types is large; the agent didn't complete. Re-spawn with narrower scope (one entity-detail page per agent) or defer.
Triage + recommended order of operations
After 13 reported audits + 2 inline (A1, F1, B3 sketch), here are the items that should ship before the next deploy, grouped by impact and effort.
🚨 Tier S — ship-stopping production bugs (do today)
These are silently broken in production right now. Fix before any further work.
| Source |
Item |
Effort |
| D1 #1 |
send-invoice and invoice-overdue-notify BullMQ jobs have no handler → every invoice send is a no-op |
1-2h: add the cases to workers/email.ts and workers/notifications.ts |
| D1 #2 |
5 maintenance cron jobs (calendar-sync, database-backup, backup-cleanup, session-cleanup, temp-file-cleanup) silently no-op with false-green audit |
2-3h each; database-backup is the dangerous one — implement or remove the schedule |
| D1 #3 |
tenure-expiry-check cron silently no-ops; service was never written |
2-3h: write the service + handler |
| C3 #1 |
A.7 RTBF gap: gdpr_exports.storage_key blobs NOT deleted on client hard-delete (this is a gap in code shipped today) |
30min: extend client-hard-delete.service.ts to collect gdprExports.storageKey alongside files |
| C3 #2 |
No RTBF/hard-delete path for residential_clients — full PII shadow |
4h: mirror the marina hard-delete service for residentialClients |
| B1 #1 |
/api/public/interests does NOT validate caller-supplied portId against existing ports — cross-tenant data injection |
30min: copy the residential-inquiries pre-check |
| A3 #1 |
documents.documenso_id has NO index — every webhook delivery is a full table scan |
30min: migration adding index |
🔴 Tier 1 — high severity, prioritize this week
| Source |
Item |
Effort |
| B4 #1 |
better-auth rate limiter is in-memory; multi-replica prod multiplies auth limits N× |
2h: switch to storage: 'database' after running its migration |
| C1 #1 |
generateAndSignViaInApp omits portId on Documenso calls → v2-configured port silently uses v1 env defaults for every in-app EOI |
30min: thread portId through 2 calls |
| C1 #2 |
custom-document-upload calls placeFields AFTER documensoSend (wrong order) — v2 may reject placement on PENDING envelope |
30min: reorder |
| C1 #3 |
{{eoi.berthRange}} + all 5 {{reservation.*}} tokens valid but unresolved — render as literal {{...}} |
2h: populate from EoiContext.eoiBerthRange + add reservation resolver |
| C2 #1 |
Recommender SQL vs JS stage-scale mismatch — Tier D fires at reservation, not deposit_paid |
30min: change LATE_STAGE_THRESHOLD=6 to match SQL scale |
| F2 #1-3 |
3 high-impact perf: getClientById serial queries, notification-digest sequential loops, missing index on interests.reminder_enabled |
4h total |
| F3 #1-2 |
client-hard-delete has zero tests; no CI/CD pipeline |
4h: integration tests for the RTBF flow; add .github/workflows/ci.yml |
| A2 #1-3, A1 #1 |
Missing DB-level CHECK constraints on every enum-shaped text column |
2h: one consolidated migration |
🟠 Tier 2 — medium severity (next sprint)
Covers the bulk of remaining medium findings — too many to expand inline; see per-agent tables above. Highlights: drift between schema and migrations (A3 #4-5, A1 #2-3), idempotency gaps in webhook handlers (B4 #2-3, C1 #7, D1 #4), audit/IP/UA gaps in admin mutations (B2 #7-10), and the camelCase-key over-masking false-positive on 'auth' fragment (C3 #6).
🟡 Tier 3 — low severity (rolling)
Index optimizations, validation tightening, schema metadata gaps, log cleanup. The detailed tables per agent above carry the per-item file:line evidence.
📋 Tier 4 — re-spawn or inline-audit
- E1 (admin UI) and E2 (entity UI) agents failed; the surface is too large for a single Sonnet 1M spawn. Re-spawn narrower (one page or one entity per agent), or audit inline in a follow-up.
Total finding counts
| Severity |
Count |
| CRITICAL |
5 |
| high |
15 |
| medium |
36 |
| low |
53 |
| info |
19 |
| Total findings |
128 |
Across 13 of 16 agent reports + 3 inline (A1/F1/B3). E1 + E2 are missing; should be re-attempted later.