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

53 KiB
Raw Blame History

Comprehensive platform audit — 2026-05-05

12-domain agent-team audit of Port Nimara CRM. Each domain owned by a single teammate working in an iTerm2 split pane; reports consolidated here.

Test status at audit start

  • pnpm exec vitest run1168/1168 passing (103 test files; 5.58s)
  • pnpm exec tsc --noEmitclean (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.tsMULTI_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:222calculateBulkScores 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:204sendReminderIfAllowed — 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-360mergeClients 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-282removeInterestBerth 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-304getDocumentDownloadUrl 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-176activate 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:103invoices.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:8documents.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-159createDocument 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 awaits 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:38merged_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:217roles.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:39current_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:14z.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-231getResidentialInterestById 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. Twosql.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).