Files
pn-new-crm/docs/audit-comprehensive-2026-05-05.md

1127 lines
53 KiB
Markdown
Raw Normal View History

# Comprehensive platform audit — 2026-05-05
12-domain agent-team audit of Port Nimara CRM. Each domain owned by a
single teammate working in an iTerm2 split pane; reports consolidated
here.
## Test status at audit start
- `pnpm exec vitest run`**1168/1168 passing** (103 test files; 5.58s)
- `pnpm exec tsc --noEmit`**clean** (exit 0)
## Findings summary
- **CRITICAL:** 1
- **HIGH:** 18
- **MEDIUM:** 32
- **LOW:** 23
(94 total. Severity counts include all 12 domain reports; clean
domains and `audit-final-deferred.md` items are not duplicated here.)
## Top 3 most critical
1. **`pnpm-lock.yaml` + `src/middleware.ts` — Next.js CVE-2025-29927
middleware authorization bypass.** `next@15.1.0` is vulnerable;
attacker sends `x-middleware-subrequest: middleware:middleware:…`
to skip middleware execution entirely, defeating the CSRF Origin
gate on every authed `/api/v1/**` mutation. **(auditor-K)**
2. **`src/lib/services/roles.service.ts:11-141` — Global roles can be
mutated cross-tenant.** Any port admin holding `admin.manage_users`
can `PATCH /api/v1/admin/roles/{id}` to rewrite a role's
`permissions` JSONB; that role is assigned to users in every other
port via `userPortRoles`. Full cross-tenant privilege escalation.
**(auditor-B3)**
3. **`src/app/api/v1/documents/[id]/route.ts:31` (+5 more) and
`src/app/api/v1/files/folders/route.ts:15` (+3 more) — silent-403
traps from non-existent `withPermission` resource keys.**
`documents.edit`, `files.create`, `files.edit` are not in
`RolePermissions` (`src/lib/db/schema/users.ts:28-130`); every
non-superadmin call to those routes is a silent 403. Six document
mutation endpoints + four file mutation endpoints are unreachable
for any non-superadmin role no matter how flags are set.
**(auditor-A3)**
---
## CRITICAL
### `pnpm-lock.yaml` + `src/middleware.ts:71` — Next.js CVE-2025-29927 middleware authorization bypass
- **Source:** auditor-K (Issue 1)
- **Description:** `next@15.1.0` (package.json:72, lockfile confirmed)
is vulnerable to CVE-2025-29927. Sending
`x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware`
causes Next to skip middleware execution. Patched in `>=15.2.3`.
- **Impact:** `src/middleware.ts:46-86` is the only layer enforcing the
CSRF Origin-equality check on state-changing `/api/v1/**` requests.
Bypassing middleware lets a malicious origin issue cookie-bearing
POST/PUT/PATCH/DELETE without the origin header gate. Combined with
`SameSite=Lax` cookies and any stolen cross-site context, this
re-opens CSRF on every authed mutating route. Page-level auth
(login redirect) is also stripped.
- **Recommendation:** Bump to `next@15.2.3` or later. Add
`proxy_set_header X-Middleware-Subrequest ""` in nginx as
defense-in-depth. Add an integration test that sends the header and
asserts middleware still runs.
---
## HIGH
### `src/lib/services/roles.service.ts:11-141` — Global roles mutated by per-port admins
- **Source:** auditor-B3 (Issue 1)
- **Description:** `roles` schema (`src/lib/db/schema/users.ts:213-227`)
has no `port_id`; `isGlobal` defaults true.
`createRole`/`updateRole`/`deleteRole` filter only by `roles.id` and
never by tenant. The PATCH route gates on the per-port
`admin.manage_users` permission — no `isSuperAdmin` check.
- **Impact:** Port-A admin can `PATCH /api/v1/admin/roles/{id}` to
rewrite the `permissions` JSONB of a role assigned via
`userPortRoles` to users in port-B/port-C. Full cross-tenant
privilege escalation.
- **Recommendation:** Gate role-mutating routes on `ctx.isSuperAdmin`
(mirrors `admin/ports/[id]/route.ts:15-20`), OR port-scope roles via
`port_role_overrides` only and refuse base-role mutation outside
super-admin.
### `src/app/api/v1/documents/[id]/route.ts:31` (+5) — `documents.edit` is a silent-403 trap
- **Source:** auditor-A3 (Issue 1)
- **Description:** `RolePermissions.documents` declares only
`view | create | send_for_signing | upload_signed | delete`. There
is no `edit` key, so `resourcePerms[action]` returns `undefined` and
`withPermission` 403s every non-superadmin. Affected:
`documents/[id]/route.ts:31` (PATCH), `documents/[id]/cancel/route.ts:8`,
`documents/[id]/remind/route.ts:15`,
`documents/[id]/upload-signed/route.ts:8`,
`documents/[id]/watchers/route.ts:25`,
`documents/[id]/watchers/[userId]/route.ts:8`.
- **Recommendation:** Replace the action with `'upload_signed'` /
`'send_for_signing'` (currently a dead key) per route, OR add an
`edit` key to the schema + every seeded role.
### `src/app/api/v1/files/folders/route.ts:15` (+3) — `files.create`/`files.edit` are silent-403 traps
- **Source:** auditor-A3 (Issue 2)
- **Description:** `RolePermissions.files` declares only
`view | upload | delete | manage_folders`. `create` and `edit` are
not keys. `files/folders/route.ts:15` (POST 'create'),
`files/upload/route.ts:9` (POST 'create'),
`files/folders/[...path]/route.ts:23` (PATCH 'edit'),
`files/[id]/route.ts:21` (PATCH 'edit') all silently 403 every
non-superadmin.
- **Recommendation:** Map POSTs to `('files','upload')`, folder PATCH
to `('files','manage_folders')`, files PATCH to a new `edit` key.
### 12 sites — direct MinIO imports bypass the storage abstraction
- **Source:** auditor-D (Issue 1)
- **Description:** CLAUDE.md says "Code never imports MinIO/S3
directly. All file I/O goes through `getStorageBackend()`." Twelve
sites still call `minioClient.{put,get,remove}Object` /
`getPresignedUrl` directly:
`documents.service.ts:23,552,662,863`;
`document-templates.ts:16,528,664,813`;
`invoices.ts:22,598`;
`files.ts:10,53,96,107,187`;
`gdpr-export.service.ts:24,166`;
`portal.service.ts:14,304`;
`email-compose.service.ts:117-118`;
`queue/workers/maintenance.ts:11,90`;
`app/api/v1/files/folders/route.ts:7,33`;
`app/api/v1/files/folders/[...path]/route.ts:7,42,45,68`.
- **Impact:** Filesystem-mode deployments silently break — uploads,
GDPR export, invoice PDFs, file lists, portal downloads, folder
rename/delete all hit a non-existent S3 endpoint and return 500.
The factory cache, presigned-URL HMAC, and admin storage migration
are bypassed.
- **Recommendation:** Replace each site with
`(await getStorageBackend()).{put,get,delete}(...)` and
`presignDownload(...)`. Convert folder-marker objects in
`/files/folders/*` to a DB-backed virtual-folder representation
(filesystem mode has no zero-byte marker objects). Move
`getPresignedUrl` from `@/lib/minio` to a deprecation shim.
### `src/lib/services/documenso-client.ts:23-45,244-256,358-371,390-419,470-486` — Documenso fetch helpers lack timeout
- **Source:** auditor-D (Issue 2)
- **Description:** `documensoFetch`, `downloadSignedPdf`, v2
`placeFields`, v1 per-field POST loop, and `voidDocument` all call
`fetch(...)` with no `signal`/AbortController. A hung Documenso
instance holds the calling worker indefinitely; `documents` queue
concurrency is 3, so three hung calls saturate the worker.
- **Recommendation:** Lift the 30s `AbortController` pattern from
`ai.ts:149-178` into a shared
`fetchWithTimeout(url, init, ms = 30_000)` helper and route every
external `fetch` through it (Documenso, OCR providers, Umami,
Frankfurter).
### `src/lib/services/ocr-providers.ts:75-105,107-153` — OCR providers have no timeout
- **Source:** auditor-D (Issue 3)
- **Description:** `runOpenAi` uses `new OpenAI({ apiKey })` with
default settings (no `timeout`). `runClaude` is raw `fetch` without
`signal`. A stuck connection holds the calling Bull `documents`
worker concurrency slot until OS reset (~15 min).
- **Impact:** One slow Anthropic incident → all three `documents`
worker slots tied up → receipt scans queue silently.
- **Recommendation:** Pass `timeout: 30_000` to the `OpenAI`
constructor; wrap `runClaude` with the shared timeout helper from
the prior finding.
### `src/server.ts:22-60` — Web container has no SIGTERM handler
- **Source:** auditor-K (Issue 2)
- **Description:** `worker.ts:36-37` registers SIGTERM/SIGINT and
awaits `worker.close()` before exiting. `server.ts` does not — the
HTTP server, Socket.io, and the inline BullMQ workers (dev) are
never closed. Compose default stop-grace is 10s; the process gets
SIGKILL'd.
- **Impact:** Every prod deploy / `docker compose up -d` rolling
restart drops every in-flight request, every Socket.io frame, and
every Postgres/Redis connection mid-statement. Affects file uploads
(50MB body), EOI generation, document signing webhooks, and
Documenso requests in flight.
- **Recommendation:** Add
`process.on('SIGTERM'|'SIGINT', () => httpServer.close(() => process.exit(0)))`
plus `io.close()` and `redis.disconnect()`. Set compose
`stop_grace_period: 30s`.
### `src/lib/storage/filesystem.ts:192` + missing from `src/lib/env.ts` — `MULTI_NODE_DEPLOYMENT` runtime gate not in env schema
- **Source:** auditor-K (Issue 3)
- **Description:** `filesystem.ts:192` reads
`process.env.MULTI_NODE_DEPLOYMENT === 'true'` directly. The
variable is not declared in `env.ts`'s zod schema and not in
`.env.example`. A typo (`MULTI_NODE_DEPLOY=true`,
`MULTINODE_DEPLOYMENT=true`, or simply unset) silently disables the
multi-node guard.
- **Impact:** The CLAUDE.md invariant "filesystem backend refuses to
start when multi-node" relies entirely on this string match. A
misconfigured prod deploy with two app nodes + filesystem backend
silently splits per-node storage — receipts visible from one node,
gone from the other.
- **Recommendation:** Add
`MULTI_NODE_DEPLOYMENT: z.enum(['true','false']).default('false').transform(v=>v==='true')`
to `env.ts` and import from `env`, not `process.env`.
### `src/lib/env.ts:28-31` — Documenso recipient/template IDs hardcoded as global env defaults
- **Source:** auditor-K (Issue 4)
- **Description:** `DOCUMENSO_TEMPLATE_ID_EOI=8`,
`DOCUMENSO_CLIENT_RECIPIENT_ID=192`,
`DOCUMENSO_DEVELOPER_RECIPIENT_ID=193`,
`DOCUMENSO_APPROVAL_RECIPIENT_ID=194` are tenant-specific magic
numbers from one Documenso instance, baked in as process-global
defaults. Used in `document-templates.ts:883-925`.
- **Impact:** Multi-tenant deploy is a lie — every port shares the
same EOI template + same Documenso recipient IDs. Adding a second
port with its own Documenso template requires running two CRM
processes per port, or redeploying with new envs and breaking the
first port.
- **Recommendation:** Move to `system_settings` keyed by `port_id`
(`documenso_template_id_eoi`, `documenso_recipient_*`); fall back
to env only as bootstrap.
### `src/lib/services/reminders.service.ts:441` — N+1 client lookup inside hourly follow-up cron
- **Source:** auditor-I (Issue 1)
- **Description:** `processFollowUpReminders` loops every port → every
enabled interest, then runs `db.query.clients.findFirst` per
interest plus `insert(reminders)` and `update(interests)`
sequentially per row. Three round-trips per interest.
- **Impact:** A port with 1000 follow-up-enabled interests does 3000+
sequential DB calls per hour; tail latency dominated by network RTT
× N. Blocks the maintenance worker (concurrency 1).
- **Recommendation:** Pre-fetch all `clients` for the port via
`inArray(clients.id, clientIds)` once into a Map, then bulk-insert
reminders in a single `db.insert(...).values([...])` and
`update().where(inArray(...))`.
### `src/lib/services/portal.service.ts:254` — Portal `getClientInvoices` fetches every port invoice
- **Source:** auditor-I (Issue 2)
- **Description:**
`db.select().from(invoices).where(eq(invoices.portId, portId))`
returns every invoice in the port; `Array.filter` matches by
`billingEmail`. No `inArray` SQL filter, no `limit`.
- **Impact:** Every portal invoice page-load full-scans the invoices
table. After 12 months of operation this is the worst portal
endpoint.
- **Recommendation:** `where(and(eq(portId), inArray(sql\`lower(billingEmail)\`, emailContacts)))`
- `.limit(100)` defensively.
### `src/lib/services/interest-scoring.service.ts:222` — `calculateBulkScores` fans out 4 `count()` per interest
- **Source:** auditor-I (Issue 3)
- **Description:** `Promise.allSettled(allInterests.map(...))` — each
call issues 1 redis lookup, 1 interests.findFirst, then 4 `count()`
queries (notes, reminders, emails, interestBerths).
- **Impact:** Port with 1000 interests → ~6000 DB round trips on cold
cache. Used by the dashboard "interest scores" panel and the alerts
engine indirectly. Redis cache band-aid; flush is a latency cliff.
- **Recommendation:** Replace per-interest counts with one grouped
query each (`groupBy(notes.interestId)` filtered by `inArray` on
the port's interest IDs); merge in JS.
### `src/lib/services/document-reminders.ts:204` → `sendReminderIfAllowed` — invariants re-fetched per doc
- **Source:** auditor-I (Issue 4)
- **Description:** For every active document, `sendReminderIfAllowed`
re-fetches: doc, template-by-type, last reminder event, port
(timezone). Port lookup is invariant across the loop; templates by
`documentType` repeat heavily.
- **Impact:** A port with 500 in-flight `sent`/`partially_signed`
documents fires 5×500 = 2500 round trips per cron tick (every 15
min default).
- **Recommendation:** Hoist port + per-type templateMap fetches above
the loop. Bulk-fetch `lastReminderAt` per doc with
`groupBy(documentEvents.documentId)`. Pre-load pendingSigners with
`inArray(documentSigners.documentId, docIds)`.
### `src/app/api/v1/website-analytics/route.ts:110` (+10 more) — Manual 5xx responses bypass `error_events` persistence
- **Source:** auditor-F (Issue 2), cross-checked auditor-G (Issue 3)
- **Description:** 11 routes manually emit 500/502/503 in catch
blocks instead of throwing. Sites: `portal/invoices/route.ts:13`,
`portal/dashboard/route.ts:18`, `portal/documents/route.ts:13`,
`portal/documents/[documentId]/download/route.ts:24`,
`portal/interests/route.ts:13`, `public/berths/route.ts:128`,
`public/berths/[mooringNumber]/route.ts:104`,
`public/website-inquiries/route.ts:67,176`,
`website-analytics/route.ts:110` (502),
`storage/[token]/route.ts:232`.
- **Impact:** Per docs/error-handling.md, every 5xx must round-trip
through `errorResponse()` so `captureErrorEvent` writes the
`error_events` row. These routes log to pino but produce no
`/admin/errors/<id>` entry; users see "Failed to load X" with no
Reference ID to copy.
- **Recommendation:** Throw + `return errorResponse(err)`. For
website-analytics 502 (Umami upstream): introduce
`UMAMI_UPSTREAM_ERROR` (status 502).
### `src/lib/services/documenso-client.ts:41,251,369,416,484` — Documenso surfaces upstream HTTP status as bare Error
- **Source:** auditor-G (Issue 2), overlaps auditor-D (Issue 4)
- **Description:** Five throw sites format
`Documenso API error: ${res.status}`. These bubble as 500 with the
generic message; user can't distinguish "Documenso down" from "DB
issue". `error-classifier.ts` STACK_PATH_HINTS catches them via
stack but the user-facing toast is generic.
- **Recommendation:** Add `DOCUMENSO_UPSTREAM_ERROR` (502) and
`DOCUMENSO_AUTH_FAILURE` (502) codes.
### 76 sites — Mutation/handler sites bypass `toastError(err)`, request IDs invisible to users
- **Source:** auditor-H (Issue 1)
- **Description:** 38 distinct files; reps:
`src/components/shared/inline-editable-field.tsx:88`,
`inline-tag-editor.tsx:48`, `inline-phone-field.tsx:69`,
`addresses-editor.tsx:122,365`,
`send-document-dialog.tsx:140,152`,
`clients/contacts-editor.tsx:164,173,346,357`,
`clients/gdpr-export-button.tsx:82,95`,
`documents/document-detail.tsx:154,166,180,360`,
`documents/create-document-wizard.tsx:161`,
- 20 more files. All call `toast.error(err instanceof Error ? err.message : '…')`
directly. CLAUDE.md requires `toastError(err)` from
`src/lib/api/toast-error.ts` so the request-id + error-code surface
reaches users.
- **Impact:** Every failed inline-edit, save, send, archive, or
upload prints a bare message with no Reference ID and no Copy-ID
action. The new admin error inspector (`/errors/[id]`) is unusable
from user-reported issues because the user has no ID to give
support.
- **Recommendation:** Codemod sweep. Shared inline-\* editors (~6
files) cover the highest call volume; do those first.
### `src/components/admin/roles/role-form.tsx:130,204-206,286` (+6 admin form files) — Admin custom forms hand-roll `setError(message)` instead of `toastError`
- **Source:** auditor-H (Issue 2)
- **Description:** 7 admin forms (roles, users, ports, webhooks,
custom-fields, document-templates, tags) capture
`err instanceof Error ? err.message : 'Something went wrong'` into
local state and render a plain banner. Same data loss as Issue
above — `ApiError.code` and `ApiError.requestId` stripped.
- **Recommendation:** Route through `toastError(err)` and drop the
inline banner, OR extract `getErrorDisplay(err)` from
`toast-error.ts` so the banner can show "Error code / Reference
ID" too.
### Five auth-critical/admin-tier services have zero direct test coverage
- **Source:** auditor-J (Issue 1, top-5 of 30)
- **Description:** 30 of 83 services lack any direct test import.
Top-5 by risk: `portal-auth.service.ts` (full lifecycle —
password hashing, token-hash equality, activation TTL,
multi-tenant), `users.service.ts` (admin-tier
create/update/remove), `email-accounts.service.ts` (AES-256-GCM at
rest), `document-sends.service.ts` (rate-limit, file-size
threshold, XSS-safe body, recipient port-match —
service-level audit-deferred isolation gap is uncovered),
`sales-email-config.service.ts` (decrypts SMTP/IMAP creds, redacts
on response).
- **Recommendation:** Each test should include happy path AND a
"different `portId` → not found / no-op / not-decrypted" assertion.
For Drizzle services that take `(id, portId)`, the negative-path
test is one extra `it()` per export.
### `src/app/api/webhooks/documenso/route.ts:1` — Documenso webhook receiver has no integration test
- **Source:** auditor-J (Issue 2)
- **Description:** The receiver handles `DOCUMENT_SIGNED`,
`DOCUMENT_COMPLETED`, etc. and dispatches by global `documensoId`
without `port_id` enforcement (audit-deferred). No test exists.
`tests/unit/webhook-event-map.test.ts` only validates the static
event-name map; `tests/integration/documents-expired-webhook.test.ts`
calls `handleDocumentExpired()` directly, bypassing the route,
signature check, dedup, and per-recipient loop.
- **Recommendation:** Add `tests/integration/documenso-webhook-route.test.ts`:
(a) valid secret + DOCUMENT_SIGNED writes a documentEvents row;
(b) wrong secret → 401; (c) duplicate body → second call no-op;
(d) two ports holding the same `documenso_id` — assert the wrong
port cannot mutate the right port's interest (locks in the
audit-deferred fix when it lands).
### `src/app/api/portal/auth/sign-in/route.ts:15` (+4) — Portal & invite auth endpoints have no rate limit
- **Source:** auditor-E3 (Issue 1), overlaps auditor-A3 (Issue 9)
- **Description:** `/api/portal/auth/sign-in`, `/forgot-password`,
`/reset-password`, `/activate`, and CRM `/api/auth/set-password`
parse credentials/tokens but never call `checkRateLimit`. The
shared `rateLimiters.auth` (5/15min) bucket is defined in
`src/lib/rate-limit.ts:73` but unused on these surfaces.
- **Impact:** Unauthenticated attackers can mount credential-stuffing
against portal sign-in; brute-force activation/reset tokens (32-byte
token is at most ~10⁷ attempts in 24h with parallel clients);
enumerate emails through forgot-password timing differences.
- **Recommendation:** Wrap each route with per-IP rate-limit. Portal
sign-in: 5 attempts per 15min per `(ip, email)`. Forgot-password:
3/hour/IP. Activate/reset/set-password: 10/hour/IP. Mirror the 429
- Retry-After response pattern from `public/interests/route.ts`.
### `src/app/api/v1/berths/[id]/pdf-versions/handlers.ts:39` — Berth-PDF register trusts arbitrary `storageKey` from body
- **Source:** auditor-E3 (Issue 2)
- **Description:** POST `/api/v1/berths/[id]/pdf-versions` accepts
`body.storageKey` and forwards it to `uploadBerthPdf`
(`berth-pdf.service.ts:213`). Service validates the berth is
in-port but never asserts the supplied key falls within the
expected `berths/${berthId}/...` namespace. Magic-byte check passes
for any genuine PDF; content-type check accepts
`application/octet-stream`.
- **Impact:** A rep with `berths.edit` on berth-X can repoint
berth-X's `currentPdfVersionId` at the storage key of any other
PDF — confidential signed EOI PDFs, brochure version blobs, or
another port's berth PDF if cross-tenant key paths leak via logs /
error inspector. Subsequent
`GET /api/v1/berths/[id]/pdf-download` serves those bytes under
berth-X's tenant-scoped permission gate, bypassing
`documents.view` / `brochures.view` resource checks.
- **Recommendation:** Reject any `storageKey` that doesn't match
`^berths/${berthId}/uploads/[0-9a-f-]{36}_` in the handler. Same
fix should land for brochures' analogous register-presigned
endpoint.
### Seven schema FK columns lack DB constraints
- **Source:** auditor-C3 (Issue 1)
- **Description:** Several columns are commented
`// FK wired in relations.ts` / `// references X.id` and exposed
via Drizzle `relations()`, but no Postgres FK constraint exists.
`relations()` only configures relational query JOINs — it does NOT
install constraints. Affected:
`documents.interest_id|yacht_id|company_id|reservation_id`,
`files.yacht_id|company_id`, `interests.yacht_id`,
`reminders.interest_id|berth_id`,
`berth_waiting_list.yacht_id`, `form_submissions.interest_id`.
- **Impact:** Orphan rows on hard-delete; service can write
`interest_id='nonexistent'` without DB rejection. Joins in
`relations.ts` happily resolve null; downstream code that doesn't
null-check silently misbehaves.
- **Recommendation:** Single migration `0041_missing_fk_constraints.sql`
adding all eight constraints with `ON DELETE SET NULL` (orphan
tolerance) for nullable columns, `RESTRICT` for
`formSubmissions.formTemplateId`. Update each schema column to use
`.references(() => …, { onDelete: '…' })` and remove the
misleading "FK wired in relations.ts" comments.
---
## MEDIUM
### `src/lib/services/*.ts` — 62 bare-Error throws surface as generic 500s with no code
- **Source:** auditor-G (Issue 1)
- **Description:** Hotspots: `umami.service.ts` (6), `client-merge.service.ts`
(6), `reports.service.ts` (5), `notes.service.ts` (5),
`documenso-client.ts` (5), `brochures.service.ts` (3),
`email-threads.service.ts` (3), `documents.service.ts` (2),
`invoices.ts` (2), `residential.service.ts` (2),
`email-accounts.service.ts` (2), `expense-dedup.service.ts` (2),
`system-monitoring.service.ts` (2), `gdpr-export.service.ts` (2),
`ai-budget.service.ts` (2). Plus 1-shot in 13 other services.
- **Impact:** User has no actionable info; admin must resolve via
request-ID lookup. ~25 of 62 are "post-insert returning row
missing" — should be `CodedError('INTERNAL', { internalMessage })`
so the toast surfaces request-ID copy.
- **Recommendation:** Mass-rename "guard against null insert" sites
to `CodedError('INTERNAL', { internalMessage: '...' })`. Replace
user-impactful ones (client-merge self-merge, expense self-merge,
ai-budget cap validation) with new domain codes. Top 10
user-facing migration candidates: interests stage transitions,
documents state machine, berth-pdf mooring mismatch, document-sends
email pipeline, expense-dedup, client-merge, companies duplicate
name, roles duplicate/system protected, users duplicate email,
portal-auth lifecycle codes.
### 34 manual `NextResponse.json({error}, {status: 4xx})` sites bypass `errorResponse()`
- **Source:** auditor-F (Issue 1), auditor-G (Issue 3)
- **Description:** Five sub-clusters: 13 admin "Insufficient
permissions"/"Super admin only" 403; 5 path-param "Missing id"
guards; 10 inline validation 400 sites; 3 profile/portal-user 404;
3 AI feature-flag 404. Reps: `src/app/api/v1/admin/queues/route.ts:10`,
`admin/storage/migrate/route.ts:33`, `alerts/[id]/acknowledge/route.ts:8`,
`me/route.ts:46`, `ai/interest-score/route.ts:22`.
- **Impact:** Failed requests don't carry `code`/`requestId`. Toast
cannot show Reference ID. Admin error-inspector skips them
entirely.
- **Recommendation:** Replace each branch with the appropriate
`AppError` subclass throw. For admin gating, factor a
`requireSuperAdmin(ctx)` helper that throws `ForbiddenError`.
### `src/lib/services/document-reminders.ts:204` and others — High-impact perf medium-tier (5 findings)
- **Source:** auditor-I (Issues 59)
- **Description:** (5) `inquiry-notifications.service.ts:66`
sequential `createNotification` per user — public form does ~80
round trips before responding. (6) `email-compose.service.ts:25,117`
N+1 attachment-port-check + full-buffer load (15MB resident per 3×
5MB send). (7) `files.ts:187` + `maintenance.ts:90` direct
`minioClient.removeObject` (CLAUDE.md violation; filesystem mode
orphans bytes). (8) `validators/invoices.ts:33,65` uncapped
`expenseIds` array → Postgres parameter limit DoS vector. (9)
`expense-pdf.service.ts:444` unbounded `inArray` on file IDs (5000+
bind list).
### `src/app/api/v1/document-sends/{brochure,berth-pdf,preview}/route.ts` — Send routes lack `withPermission`
- **Source:** auditor-A3 (Issue 4)
- **Description:** brochure/route.ts:15, berth-pdf/route.ts:17,
preview/route.ts:17, document-sends/route.ts:9 are bare
`withAuth`. Mutation POSTs fire nodemailer at client recipients;
the 50/user/hour rate limit is the only restriction.
- **Impact:** Any authenticated user (lowest-privilege role) at a
port can send brochures or per-berth PDFs to any recipient the
service permits. Bypasses `email.send` convention.
- **Recommendation:** Wrap brochure + berth-pdf in
`withPermission('email','send')`, preview/list in
`withPermission('email','view')`.
### `src/app/api/v1/admin/users/options/route.ts:9` — User list returned with no admin gate
- **Source:** auditor-A3 (Issue 6)
- **Description:** Lives under `/admin/...` but wrapped in `withAuth`
only. Returns `{id, displayName}` for every user assigned to the
caller's port. Caller is `reminder-form.tsx:72`.
- **Impact:** Any authenticated user at a port can enumerate every
colleague's user id + display name (PII enumeration; user-id
leakage helps targeted attacks).
- **Recommendation:** Gate on `withPermission('reminders','assign_others')`
(matches actual consumer); consider moving the route out of
`/admin/`.
### `src/lib/services/client-merge.service.ts:82-360` — `mergeClients` does not verify caller's port
- **Source:** auditor-B3 (Issue 2)
- **Description:** Takes no `callerPortId`. Lines 91-100 load both
clients by id only; line 104 asserts winner+loser same-port but
never that either equals caller's port. Sole caller pre-validates
by `ctx.portId`, so today the path is safe — but the service is a
footgun for any future caller.
- **Recommendation:** Add `callerPortId: string` to `MergeOptions`;
throw if `winnerRow.portId !== callerPortId`.
### `src/lib/services/interest-berths.service.ts:273-282` — `removeInterestBerth` deletes junction without port assertion
- **Source:** auditor-B3 (Issue 3)
- **Description:** `removeInterestBerth(interestId, berthId)` deletes
with no port check. `setPrimaryBerth` and `upsertInterestBerth` do
guard cross-port (lines 207-221), but `removeInterestBerth` is the
gap.
- **Recommendation:** Make `removeInterestBerth(interestId, berthId, portId)`
with the same `interestPortId/berthPortId` cross-check as
`upsertInterestBerthTx`.
### Six service mutation sites: UPDATE/DELETE WHEREs missing `portId` despite available context
- **Source:** auditor-B3 (Issue 4)
- **Description:** `form-templates.service.ts:87,110`,
`company-memberships.service.ts:138,176,230`,
`invoices.ts:765` (`detectOverdue` per-row update),
`notifications.service.ts:222` (markRead by userId only),
`clients.service.ts:835` (`deleteRelationship` by relId only).
Each precedes the mutation with a port-scoped read, then writes
keyed only by entity id.
- **Impact:** Today safe under sequential execution. A future
refactor that drops the read, switches to a stale cache, or runs
the write under different transaction isolation will silently
mutate a foreign-tenant row.
- **Recommendation:** Add `eq(<table>.portId, portId)` to each WHERE.
### `src/lib/services/portal.service.ts:284-304` — `getDocumentDownloadUrl` fetches file without port scope
- **Source:** auditor-B3 (Issue 5)
- **Description:** Document lookup at 284-289 is correctly scoped;
follow-up file lookup at 298-300 is `where: eq(files.id, fileId)`
only — no `eq(files.portId, portId)`.
- **Impact:** If `documents.signedFileId / fileId` ever points at a
foreign-port file id (Documenso webhook bug — already deferred —
bulk-import drift, malicious admin write), a portal client receives
a presigned URL to that foreign file.
- **Recommendation:** Replace with
`where: and(eq(files.id, fileId), eq(files.portId, portId))`. Same
fix applies to `reports.service.ts:158-160`.
### `src/lib/services/berth-reservations.service.ts:155-176` — `activate` accepts `contractFileId` without port verification
- **Source:** auditor-B3 (Issue 7)
- **Description:** Reservation loaded port-scoped but
`data.contractFileId` written without verifying
`files.portId === portId`.
- **Impact:** Port-A user can attach a port-B file id to a port-A
reservation. Downstream code that resolves
`reservation.contractFileId` and presigns it (portal contract
download, audit export) without re-checking file.portId leaks
foreign-port file content.
- **Recommendation:** Add a port-scoped `findFirst` files lookup
before the write; throw `ValidationError` if missing.
### `src/lib/db/migrations/0036_polymorphic_check_constraints.sql:7-13` — Polymorphic CHECK misses load-bearing discriminators
- **Source:** auditor-C3 (Issue 2)
- **Description:** 0036 only added CHECK on
`yachts.current_owner_type` and `invoices.billing_entity_type`.
Other code-branching discriminators are still free-text:
`yacht_ownership_history.owner_type` (yachts.ts:67),
`document_sends.document_kind` (brochures.ts:117), and the
audit-style `*.entity_type` columns.
- **Recommendation:** Extend in `0041_polymorphic_check_constraints_round2.sql`:
CHECK on `yacht_ownership_history.owner_type IN ('client','company')`
and `document_sends.document_kind IN ('berth_pdf','brochure')`.
### `src/lib/db/schema/financial.ts:103` — `invoices.billing_entity_id` default `''` defeats NOT NULL
- **Source:** auditor-C3 (Issue 3)
- **Description:** `notNull().default('')` — combined with the 0036
CHECK on `billing_entity_type`, an invoice can be inserted with
`billing_entity_type='client'` and `billing_entity_id=''`. The
polymorphic resolver looks up empty-string and returns null
without a DB-level signal.
- **Recommendation:** Drop default + add CHECK
`billing_entity_id <> ''`. Backfill empty rows from
`clientName`/`billingEmail` heuristic first.
### Sixteen better-auth `user_id` columns lack FK to `user` table
- **Source:** auditor-C3 (Issue 4)
- **Description:** `userProfiles.userId`, `userPortRoles.userId`,
`session.userId`, `savedViews.userId`, `notifications.userId`,
`emailAccounts.userId`, `reminders.assignedTo|createdBy`,
`*Notes.authorId`, etc. — 16 columns commented "references Better
Auth user ID" but no FK constraint.
- **Impact:** Hard-deleting a `user` row leaves `userProfiles`
orphaned with broken `unique(userId)` invariant. Lifecycle-critical
rows shouldn't float.
- **Recommendation:** Add FKs with `ON DELETE CASCADE` for
`userProfiles`/`userPortRoles`/`session`; `ON DELETE SET NULL` for
audit-style columns (need to be made nullable first).
### `src/app/api/v1/expenses/export/parent-company/route.ts:11` — Gates on `isSuperAdmin` instead of `expenses.export`
- **Source:** auditor-A3 (Issue 5)
- **Description:** Sibling exports use
`withPermission('expenses','export')`. parent-company diverges to
hard `if (!ctx.isSuperAdmin) 403`. Locks out port admins with
`expenses.export = true`.
- **Recommendation:** Use the same `withPermission` for parity.
### `src/app/api/v1/ai/email-draft/route.ts:12` — POST has no permission gate
- **Source:** auditor-A3 (Issue 7)
- **Description:** Feature-flag gated only. Spends OpenAI tokens and
renders client/interest-scoped draft content.
- **Recommendation:** Add `withPermission('email','send')`.
### `src/app/api/v1/documents/[id]/send/route.ts:8` — `documents.send_for_signing` declared but never enforced
- **Source:** auditor-A3 (Issue 3)
- **Description:** Schema declares
`send_for_signing: boolean` (users.ts:33) but no route uses it.
send/route.ts gates on `documents.create`; user with create-only
rights can still trigger Documenso send.
- **Recommendation:** Switch to
`withPermission('documents','send_for_signing')`.
### `src/lib/services/files.ts:39` — `/api/v1/files/upload` trusts client-supplied MIME without magic-byte verification
- **Source:** auditor-E3 (Issue 3)
- **Description:** `uploadFile()` validates browser-supplied
`file.mimeType` (fully attacker-controlled) against
`ALLOWED_MIME_TYPES` and stores it as MinIO `Content-Type`. No
magic-byte gate. Berth-PDF and brochure paths verify `%PDF-`; the
legacy generic uploader does not.
- **Recommendation:** Add a magic-byte switch keyed by claimed MIME
before the `putObject` (jpeg→`FF D8 FF`, png→`89 50 4E 47`, etc.).
Reject mismatches. Set `X-Content-Type-Options: nosniff` on any
download proxy that streams these bytes.
### `src/app/api/v1/expenses/scan-receipt/route.ts:35` — Uploads to OCR provider without magic-byte verification
- **Source:** auditor-E3 (Issue 4)
- **Description:** Forwards `buffer` to `runOcr` with
`mimeType = file.type || 'image/jpeg'`. No size cap, no magic-byte.
Authenticated rep can grief their own port's AI budget by burning
OCR tokens.
- **Recommendation:** Validate magic bytes against
JPEG/PNG/WEBP/HEIC; cap `file.size` at 10MB.
### `src/app/api/v1/me/route.ts:21` — PATCH preferences uses `.passthrough()`, unbounded JSONB write
- **Source:** auditor-E3 (Issue 5)
- **Description:** `updateProfileSchema.preferences` is
`z.object({ dark_mode, locale, timezone }).passthrough()`. Any
extra key the client supplies survives validation, gets merged into
`userProfiles.preferences` (line 56), and is returned by GET. No
size cap.
- **Recommendation:** Drop `.passthrough()` + explicit allow-list of
preference keys. Cap merged JSONB at 8 KB.
### `src/lib/services/documenso-client.ts:138-159` — `createDocument` has no idempotency key
- **Source:** auditor-D (Issue 5)
- **Description:** POST `/api/v1/documents` with no client-generated
idempotency token. Transient timeout + BullMQ retry → two Documenso
envelopes for the same EOI; second attached to no local document
row.
- **Recommendation:** Generate UUID per local `documents.id`,
persist on the row, pass as `Idempotency-Key`. Documenso 2.x
supports it; v1.13 ignores unknown headers.
### `src/lib/env.ts:27` + webhooks/documenso/route.ts:54 — Documenso webhook secret is single global env var
- **Source:** auditor-D (Issue 6)
- **Description:** Documenso URL/key are per-port
(`getPortDocumensoConfig`), but the webhook receiver verifies
against single `env.DOCUMENSO_WEBHOOK_SECRET`. Two ports pointed
at different Documenso instances must share the same plaintext
secret.
- **Recommendation:** Add `documenso_webhook_secret_encrypted` to
per-port `system_settings`, fall back to env. Try each enabled
port's secret with `timingSafeEqual` and use the matched portId as
the lookup scope (also addresses the deferred port_id finding
without needing instance/team in the body).
### `src/lib/services/email-threads.service.ts:259-345` — IMAP `syncInbox` has no socket/idle timeout
- **Source:** auditor-D (Issue 7)
- **Description:** `new ImapFlow({...})` constructed without
`socketTimeout`, `greetingTimeout`, `connectionTimeout`. Per-message
fetch loop has no per-iteration cap; one slow-streaming UID stalls
the whole sync. Errors propagate as raw `Error`.
- **Recommendation:** Pass `socketTimeout: 60_000` + a wall-clock cap
on `syncInbox` (5-min fuse via `Promise.race`). Wrap upstream
rejects in `CodedError('imap_upstream_error', { accountId })`.
### `nginx/nginx.conf:77` — CSP allows `'unsafe-inline'` for script-src
- **Source:** auditor-K (Issue 7)
- **Description:** CSP header is `script-src 'self' 'unsafe-inline'`.
Neuters CSP's primary XSS defense.
- **Recommendation:** Switch to nonce-based CSP via Next 15's
`experimental.cspNonce` (or middleware-generated nonce); drop
`'unsafe-inline'`.
### Worker container has no healthcheck (compose files)
- **Source:** auditor-K (Issue 8)
- **Description:** `crm-worker` service definition has no
`healthcheck:` block; the worker process exposes no HTTP port and
no exec probe is configured.
- **Impact:** A worker whose Redis connection has dropped but whose
process is alive (BullMQ silently rejects new jobs) is invisible to
compose/swarm. Jobs queue up; no restart.
- **Recommendation:** Add `healthcheck: test: ["CMD", "node", "-e",
"require('ioredis').Redis.createClient(process.env.REDIS_URL).ping()..."]`
or expose a tiny HTTP `/health` from worker.ts.
### `.env.example` missing 13+ runtime envs
- **Source:** auditor-K (Issue 9)
- **Description:** `IMAP_*`, `SMTP_USER/PASS/FROM`,
`EMAIL_REDIRECT_TO`, `MULTI_NODE_DEPLOYMENT`,
`MINIO_AUTO_CREATE_BUCKET`, `STORAGE_FILESYSTEM_ROOT`,
`EOI_TEMPLATE_PDF_PATH`, `DOCUMENSO_API_VERSION`,
`DOCUMENSO_TEMPLATE_ID_EOI`, `DOCUMENSO_*_RECIPIENT_ID`, `PORT`.
Example also ships `EMAIL_CREDENTIAL_KEY=000…000`.
- **Recommendation:** Add all consumed vars; reject
`EMAIL_CREDENTIAL_KEY` matching the example value when
`NODE_ENV==='production'`.
### `Dockerfile`, `Dockerfile.dev`, `Dockerfile.worker` — pnpm@latest, non-reproducible
- **Source:** auditor-K (Issue 5)
- **Description:** All three Dockerfiles run
`corepack prepare pnpm@latest --activate`.
- **Recommendation:** Pin to exact version (`pnpm@9.15.0`); add a
`packageManager` field to package.json.
### `docker-compose.prod.yml:35,54` — Pulls `:latest` images
- **Source:** auditor-K (Issue 6)
- **Description:** `image: …/crm-app:latest` and
`…/crm-worker:latest`. No digest pin, no version tag.
- **Recommendation:** Tag releases (`:v0.1.0` or `:sha-abc1234`); pin
to digest (`@sha256:…`) in CI.
### `Dockerfile.worker:21-25` — Installs deps as root after creating worker user
- **Source:** auditor-K (Issue 10)
- **Description:** Stage-3 runner runs `pnpm install --prod` at line
22 (UID 0), then creates `worker:nodejs`, copies bundle, then
`USER worker`. `node_modules/` ends up root-owned.
- **Impact:** Tesseract.js, sharp, and other deps that lazily
download/cache binaries to `node_modules/.cache/*` will EACCES
under the `worker` user. OCR/image-processing failures only
manifest at first PDF parse in prod.
- **Recommendation:** Re-order — create user, `chown -R worker:nodejs /app`,
then `USER worker` before `pnpm install`.
### `src/app/api/v1/website-analytics/route.ts:104` — 200 OK with error body breaks `res.ok` semantics
- **Source:** auditor-F (Issue 5)
- **Description:** Returns `status: 200` + `{error: 'umami_not_configured', metric, range}`.
Clients branching on `res.ok` think the call succeeded; React-Query
never enters the error path. Same anti-pattern at
`admin/umami/test/route.ts:21`.
- **Recommendation:** 409 + `CodedError('UMAMI_NOT_CONFIGURED')` for
the unconfigured case, OR `{data: null}` 200 (configured-but-empty
contract). For umami/test: 502 with `{ok: false, error}`.
### `src/app/api/portal/auth/sign-in/route.ts:25` — Conflates "malformed email" 400 and "invalid password" 401
- **Source:** auditor-F (Issue 9)
- **Description:** zod safeParse failure returns
`{error: 'Invalid email or password'}` with status 400. Inner
`signIn()` failure (via `errorResponse`) returns 401 with the same
string. Status differs but body is identical, so a probing client
can distinguish "malformed email" from "wrong password" by status
alone — partially defeats the enumeration-safe phrasing.
- **Recommendation:** Change the 400 path to
`'Email format is invalid'`; let `signIn()` own the 401 string.
### Custom-fields suite never asserts cross-port nor cross-resource isolation
- **Source:** auditor-J (Issue 3)
- **Description:** Suite seeds one `portId` and exercises CRUD inside
it. No `it(...)` covering "definition created in port A is invisible
from port B" or cross-resource permission gating. Combined with the
audit-deferred custom-fields-hardcoded-clients gap, no test would
catch a regression.
- **Recommendation:** Three negative tests — cross-port `getValues`
empty, `setValues` cross-port rejects, handler-level test that a
viewer with `companies.view` only is `403` on a clients-gated
custom-field route.
### `tests/integration/documents-expired-webhook.test.ts:90` — Lacks cross-port assertion
- **Source:** auditor-J (Issue 4)
- **Description:** Three `it()` blocks: happy path, interest cascade,
no-op for unknown documensoId. The "no-op" is enumeration, not
tenant. With two ports holding the same `documenso_id`, current
code mutates whichever document `findFirst` returns.
- **Recommendation:** Add
`it('does not flip a document in port B when port A receives the expired event', ...)`.
Will fail until the audit-deferred port_id fix ships, which is the
point.
### Fresh `{ ok: true }` envelope drift on POST mutations
- **Source:** auditor-F (Issue 4)
- **Description:** 6 sites: alerts/[id]/{acknowledge,dismiss}/route.ts:10;
expenses/[id]/{clear-duplicate,merge}/route.ts:13,23;
admin/ocr-settings/route.ts:67 (PUT);
storage/[token]/route.ts:235 (filesystem upload PUT). Deferred doc
flagged `{success: true}` and `{items}` drift but missed the
`{ok: …}` family.
- **Recommendation:** Pick one — 204 No-Content (admin/queues DELETE
already does this) or `{data: null}` 200. Migrate together.
### `src/lib/services/inquiry-notifications.service.ts:66` — Sequential per-user createNotification
- **Source:** auditor-I (Issue 5)
- **Description:** Inside a public inquiry POST, loops
`usersWithAccess` and `await`s `createNotification` per user (≥3 DB
round trips + 2 socket emits per call).
- **Impact:** Port with 20 users → ~80 round trips per public inquiry
before response.
- **Recommendation:** `await Promise.all(usersWithAccess.map(...))`,
or bulk insert + emit-once with `userIds[]` payload.
---
## LOW
### `src/lib/services/documenso-client.ts` — Throws raw Error not CodedError (5 sites)
- **Source:** auditor-D (Issue 4)
- **Recommendation:** `throw new CodedError('documenso_upstream_error', { status, path })`
- `documenso_timeout` for AbortError.
### `src/lib/error-classifier.ts:30-180` — Misses common error shapes
- **Source:** auditor-G (Issue 4)
- **Description:** Uncategorized: `BetterAuthError`/`APIError`, BullMQ
errors, MinIO `S3Error`/`NoSuchBucket`/`AccessDenied`, `ImapFlowError`.
- **Recommendation:** Add to `ERROR_NAME_HINTS`; add a `bullmq` stack
path hint.
### Public residential-inquiries route does direct service work + bare-throws
- **Source:** auditor-G (Issue 6)
- **Description:** Two `throw new Error('Failed to create...')` in
`src/app/api/public/residential-inquiries/route.ts:92,105`. Dual
with audit-final-deferred "Public POST routes bypass service
layer".
- **Recommendation:** Move to `publicResidentialService.create(...)`;
throw `CodedError('INTERNAL')`.
### `src/lib/db/schema/clients.ts:38` — `merged_into_client_id` text without self-FK
- **Source:** auditor-C3 (Issue 5)
- **Recommendation:** `.references(() => clients.id, { onDelete: 'set null' })`.
### `src/lib/db/schema/users.ts:217` — `roles.name` lacks uniqueness
- **Source:** auditor-C3 (Issue 6)
- **Recommendation:** Either unique index on `name`, OR a partial
unique `uniqueIndex(...).on(name).where(sql\`is_global = true\`)`
if globals are meant to be unique-by-name.
### `src/lib/db/schema/yachts.ts:39` — `current_owner_id` NOT NULL with no FK + no CHECK on emptiness
- **Source:** auditor-C3 (Issue 7)
- **Recommendation:** Future generated-column CHECK; for now monitor
in services.
### Reports list silently swallows download errors
- **Source:** auditor-H (Issue 3)
- **File:** `src/components/reports/reports-list.tsx:63-73`
- **Description:** `handleDownload` catches every error with
`console.error('Download failed', err)` only — no toast, no UI
feedback.
- **Recommendation:** `toastError(err, 'Download failed')`.
### Residential Clients list lacks PermissionGate / Skeleton / EmptyState / filter bar
- **Source:** auditor-H (Issue 4)
- **File:** `src/components/residential/residential-clients-list.tsx:46-191`
- **Recommendation:** Wrap New button in PermissionGate; swap loading
for `<Skeleton>`; swap empty for `<EmptyState>`; add status +
source select filters.
### Five admin list create buttons not wrapped in PermissionGate
- **Source:** auditor-H (Issue 5)
- **Files:** roles/role-list.tsx, tags/tag-list.tsx,
ports/port-list.tsx, document-templates/template-list.tsx:194,
forms/form-template-list.tsx:53-62.
- **Recommendation:** Wrap each `New …` button in
`<PermissionGate resource="admin" action="manage_roles|manage_tags|...">`.
### Form-template list uses ad-hoc loading + empty states
- **Source:** auditor-H (Issue 6)
- **File:** `src/components/admin/forms/form-template-list.tsx:65-70`
- **Recommendation:** Replace with `<Skeleton>` + `<EmptyState>`.
### Icon-only buttons missing `aria-label` (≥10 sites)
- **Source:** auditor-H (Issue 7)
- **Recommendation:** Add `aria-label` (preferred) or
`<span className="sr-only">…</span>`.
### 11 admin endpoints use ad-hoc `if (!ctx.isSuperAdmin) 403` instead of `withPermission`
- **Source:** auditor-A3 (Issue 8)
- **Description:** `admin/{health,connections,queues,storage,storage/migrate,errors,alerts/run-engine}/route.ts`
- `currency/rates/refresh`. Bypasses the
`permission_denied` audit row.
- **Recommendation:** Standardize on `withPermission('admin', <key>)`.
Add a new `'admin', 'system'` (or reuse `system_backup`) for
queue/storage/health.
### `src/middleware.ts:65-69` — CSRF middleware allows requests with neither Origin nor Referer
- **Source:** auditor-A3 (Issue 10)
- **Description:** `originAllowed()` returns `true` when both Origin
and Referer are absent. SameSite=Lax mitigates but defense-in-depth
weakened.
- **Recommendation:** Default deny when both absent; whitelist
specific server-to-server flows.
### `src/lib/validators/ports.ts:10`, `me/route.ts:14` — `z.string().url()` accepts `javascript:`/`data:` schemes
- **Source:** auditor-E3 (Issue 6)
- **Description:** URL constructor accepts arbitrary schemes. Today
rendered only inside `<img src>` (neutralizes `javascript:`); the
day someone renders as `<a href>`, stored XSS / phishing.
- **Recommendation:** Add
`.refine((u) => /^https?:\/\//i.test(u), 'must be http(s)')`
mirror `validators/webhooks.ts:92`.
### `src/lib/services/residential.service.ts:220-231` — `getResidentialInterestById` joins client without port scope
- **Source:** auditor-B3 (Issue 6)
- **Recommendation:**
`where: and(eq(residentialClients.id, ...), eq(residentialClients.portId, portId))`.
### `src/app/api/public/health/route.ts:15-25` — Discloses NODE_ENV + APP_URL to anonymous internet
- **Source:** auditor-K (Issue 11)
- **Recommendation:** Require shared secret header (mirror
`WEBSITE_INTAKE_SECRET`); or hash the response.
### `src/server.ts:20` — PORT env unvalidated
- **Source:** auditor-K (Issue 12)
- **Description:** `parseInt(process.env.PORT ?? '3000', 10)`
`PORT=foo` → NaN → silent ephemeral-port listen.
- **Recommendation:** `PORT: z.coerce.number().int().positive().default(3000)`
in env.ts.
### Webhook receiver returns 200 OK on auth failure
- **Source:** auditor-F (Issue 8)
- **File:** `src/app/api/webhooks/documenso/route.ts:56`
- **Recommendation:** 401 + `{ok: false}` on auth failure. Coordinate
with auditor-D before flipping; check whether realapi
documenso-replay test asserts 200.
### Naked-object success responses bypass `{data}` envelope
- **Source:** auditor-F (Issue 7)
- **Description:** 3 fresh sites:
`ai/email-draft/route.ts:34`, `auth/set-password/route.ts:33`,
`portal/auth/sign-in/route.ts:30`.
- **Recommendation:** `{data: {jobId}}`, `{data: {email}}`,
`{data: null}` respectively.
### portal-auth manual 4xx + a third envelope shape
- **Source:** auditor-F (Issue 6)
- **Description:** 5 sites; `set-password` uses `{message}` while
others use `{error}`.
- **Recommendation:** Factor `parsePortalAuthBody(req, schema)`
helper that throws `ValidationError`.
### `storage/[token]` line 232 disk-failure 500 doesn't persist
- **Source:** auditor-F (Issue 10)
- **Recommendation:** Convert only line 232 to
`throw err; return errorResponse(err)`. Leave 4xx token-failure
paths opaque.
### `inArray` chunking, audit-search super-admin path, notification cross-port test
- **Source:** auditor-I, auditor-J
- **Description:** (perf 9) `expense-pdf.service.ts:444` chunk
`inArray` to 500-batch. (perf 10) `getClientDocuments` no limit.
(perf 11) `getThread` no limit. (perf 12) socket emit fan-out per
client/contact change. (perf 13) alertEngine sequential ports×rules.
(perf 14) AI worker concurrency 2. (testing 6) `audit-search.test.ts:172`
inverts assertion. (testing 7) notification-lifecycle suite no
cross-port read isolation.
---
## Clean domains
- **auditor-L (migrations + seed integrity).** Domain clean —
migrations 00000040 consistent with schema, journal order matches
file numbering, seed produces a bootable port. Spot-checked
hand-written 0034-0040 idempotency, backfill-before-NOT-NULL,
destructive-drop ordering (0028→0029 interest_berths ladder is
safe), schema-vs-SQL drift (only documented deferred items remain),
`seed-data/berths.json` freshness (refreshed 2026-05-02).
## Confirmed-clean sub-areas (within otherwise non-clean domains)
- **Redis** (`src/lib/redis.ts`) — `lazyConnect`, retryStrategy,
error/connect/reconnect logging.
- **Rate-limit** (`src/lib/rate-limit.ts`) — sliding-window via Redis
sorted set, per-window pexpire.
- **BullMQ queue config** (`src/lib/queue/index.ts`) — exponential
backoff, per-queue maxAttempts, bounded retention; per-Worker
connections so no shared `maxRetriesPerRequest:null` foot-gun.
- **NocoDB import** (`src/lib/services/berth-import.ts`) — pure
helpers, idempotent via `updated_at` window.
- **Filesystem proxy** — multi-node guard, key validation regex,
op-bound HMAC, realpath symlink check, atomic temp-rename. Open
items already in deferred list.
- **AI worker** (`src/lib/queue/workers/ai.ts`) — 30s
AbortController, 10KB output cap, port-scoped fetches, ai_usage
ledger write. Deferred "no cost-tracking" item is now resolved.
- **Pino logging** — no `console.error+rethrow` anywhere in
`src/lib/services/`, `src/lib/queue/`, `src/lib/storage/`,
`src/lib/email/`, `src/app/api/` (only legitimate boot-time
`console.error` in env.ts and seed.ts).
- **SQL injection** — every backtick-tagged `sql\`…\``template uses
Drizzle`${}`parameterization. Two`sql.raw` callers
(storage/migrate.ts:128, admin/storage/route.ts:32) interpolate
hardcoded constants only.
- **Documenso webhook signature verification** — timing-safe
(`documenso-webhook.ts:9`).
- **Markdown email rendering** (`renderEmailBody`) — escape-first
allow-list; merge values markdown-escaped via `escapeMergeValue`.
- **Brochure download-link filename HTML-escape** — confirmed.
- **Mass-assignment** — service `…data` spreads use Zod-validated
DTOs that omit `portId`/tenant cols.
- **Internal `<Link>` usage** — no `<a href="/…">` for internal nav
anywhere in dashboard/portal pages.
- **Form submit `disabled-while-pending`** — spot-checked 22 forms;
all wire `disabled={isSubmitting || mutation.isPending}`.
---
## Audit team — operational notes
- **12 panes spawned.** 5 of the 12 (auditor-A code-reviewer, B
code-explorer, C code-reviewer, E code-reviewer, plus E2/A2/B2/C2
respawns of those types) failed to bootstrap inside the team
workflow — they responded with `idle_notification` but never
claimed their task or sent a plan. Consistent symptom across 5
spawns of the `feature-dev:code-reviewer` and
`feature-dev:code-explorer` subagent types.
- **Successful respawns.** A3, B3, C3, E3 spawned as
`general-purpose` and all delivered tight, well-scoped reports.
All 8 `general-purpose` spawns from the original batch (D, F, G,
H, I, J, K, L) bootstrapped correctly the first time. **Pattern:
use `general-purpose` for team-context spawns; the custom
subagent types appear incompatible.**
- **Plan-approval gate** worked as designed for the 8 successful
panes — each sent a plan, received feedback or approval, and
proceeded only after that handshake.
- **Cross-team dedup** done at consolidation time (no cross-pane
messaging needed): A3/B3 overlap on roles/permissions; C3/I no
overlap (C3 deliberately skipped index work since I had it);
D/E3 overlap on storage abstraction (D claimed it as
integrations-domain HIGH); F/G overlap on manual `NextResponse.json`
(F counted/grouped, G classified migration targets).