# 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/` 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 5–9) - **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(.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 ``; swap empty for ``; 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 ``. ### 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 `` + ``. ### Icon-only buttons missing `aria-label` (≥10 sites) - **Source:** auditor-H (Issue 7) - **Recommendation:** Add `aria-label` (preferred) or ``. ### 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', )`. 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 `` (neutralizes `javascript:`); the day someone renders as ``, 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 0000–0040 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 `` usage** — no `` 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).