Files
pn-new-crm/docs/audit-comprehensive-2026-05-05.md
Matt Ciaccio 312779c0c5 fix(security): tier-0 audit blockers (next CVE, role gate, perm traps, key validation, rate limits)
Closes the five highest-risk findings from
docs/audit-comprehensive-2026-05-05.md so the platform is not exposed
while the rest of the audit backlog (1 CRIT + 18 HIGH + 32 MED + 23 LOW)
is worked through:

* CVE-2025-29927 — bump next 15.1.0 → 15.2.9; nginx strips
  X-Middleware-Subrequest at the edge as defense-in-depth.
* Cross-tenant role escalation — POST/PATCH/DELETE on /admin/roles now
  require super-admin (was: any holder of admin.manage_users).  Adds
  shared `requireSuperAdmin(ctx)` helper.
* Silent-403 traps — `documents.edit` and `files.edit` keys added to
  RolePermissions; seeded role values updated; migration 0041 backfills
  the new keys on every existing roles+port_role_overrides JSONB.  File
  routes remap the dead `create` action to `upload` / `manage_folders`.
* Berth-PDF / brochure register endpoints — reject body.storageKey
  unless it matches the namespace the matching presign endpoint issued
  (prevents repointing a tenant's PDF at foreign-port bytes).
* Portal auth rate limits — sign-in 5/15min/(ip,email),
  forgot-password 3/hr/IP, activate/reset/set-password 10/hr/IP.  Adds
  `enforcePublicRateLimit()` for non-`withAuth` routes.

Test status unchanged: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md (CRITICAL, HIGH §§1–4)

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

1127 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).