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>
53 KiB
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
-
pnpm-lock.yaml+src/middleware.ts— Next.js CVE-2025-29927 middleware authorization bypass.next@15.1.0is vulnerable; attacker sendsx-middleware-subrequest: middleware:middleware:…to skip middleware execution entirely, defeating the CSRF Origin gate on every authed/api/v1/**mutation. (auditor-K) -
src/lib/services/roles.service.ts:11-141— Global roles can be mutated cross-tenant. Any port admin holdingadmin.manage_userscanPATCH /api/v1/admin/roles/{id}to rewrite a role'spermissionsJSONB; that role is assigned to users in every other port viauserPortRoles. Full cross-tenant privilege escalation. (auditor-B3) -
src/app/api/v1/documents/[id]/route.ts:31(+5 more) andsrc/app/api/v1/files/folders/route.ts:15(+3 more) — silent-403 traps from non-existentwithPermissionresource keys.documents.edit,files.create,files.editare not inRolePermissions(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. Sendingx-middleware-subrequest: middleware:middleware:middleware:middleware:middlewarecauses Next to skip middleware execution. Patched in>=15.2.3. - Impact:
src/middleware.ts:46-86is 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 withSameSite=Laxcookies 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.3or later. Addproxy_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:
rolesschema (src/lib/db/schema/users.ts:213-227) has noport_id;isGlobaldefaults true.createRole/updateRole/deleteRolefilter only byroles.idand never by tenant. The PATCH route gates on the per-portadmin.manage_userspermission — noisSuperAdmincheck. - Impact: Port-A admin can
PATCH /api/v1/admin/roles/{id}to rewrite thepermissionsJSONB of a role assigned viauserPortRolesto users in port-B/port-C. Full cross-tenant privilege escalation. - Recommendation: Gate role-mutating routes on
ctx.isSuperAdmin(mirrorsadmin/ports/[id]/route.ts:15-20), OR port-scope roles viaport_role_overridesonly 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.documentsdeclares onlyview | create | send_for_signing | upload_signed | delete. There is noeditkey, soresourcePerms[action]returnsundefinedandwithPermission403s 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 aneditkey 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.filesdeclares onlyview | upload | delete | manage_folders.createandeditare 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 neweditkey.
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 callminioClient.{put,get,remove}Object/getPresignedUrldirectly: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}(...)andpresignDownload(...). Convert folder-marker objects in/files/folders/*to a DB-backed virtual-folder representation (filesystem mode has no zero-byte marker objects). MovegetPresignedUrlfrom@/lib/minioto 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, v2placeFields, v1 per-field POST loop, andvoidDocumentall callfetch(...)with nosignal/AbortController. A hung Documenso instance holds the calling worker indefinitely;documentsqueue concurrency is 3, so three hung calls saturate the worker. - Recommendation: Lift the 30s
AbortControllerpattern fromai.ts:149-178into a sharedfetchWithTimeout(url, init, ms = 30_000)helper and route every externalfetchthrough 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:
runOpenAiusesnew OpenAI({ apiKey })with default settings (notimeout).runClaudeis rawfetchwithoutsignal. A stuck connection holds the calling Bulldocumentsworker concurrency slot until OS reset (~15 min). - Impact: One slow Anthropic incident → all three
documentsworker slots tied up → receipt scans queue silently. - Recommendation: Pass
timeout: 30_000to theOpenAIconstructor; wraprunClaudewith 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-37registers SIGTERM/SIGINT and awaitsworker.close()before exiting.server.tsdoes 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 -drolling 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)))plusio.close()andredis.disconnect(). Set composestop_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:192readsprocess.env.MULTI_NODE_DEPLOYMENT === 'true'directly. The variable is not declared inenv.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')toenv.tsand import fromenv, notprocess.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=194are tenant-specific magic numbers from one Documenso instance, baked in as process-global defaults. Used indocument-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_settingskeyed byport_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:
processFollowUpRemindersloops every port → every enabled interest, then runsdb.query.clients.findFirstper interest plusinsert(reminders)andupdate(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
clientsfor the port viainArray(clients.id, clientIds)once into a Map, then bulk-insert reminders in a singledb.insert(...).values([...])andupdate().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.filtermatches bybillingEmail. NoinArraySQL filter, nolimit. - 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 4count()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 byinArrayon 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,
sendReminderIfAllowedre-fetches: doc, template-by-type, last reminder event, port (timezone). Port lookup is invariant across the loop; templates bydocumentTyperepeat heavily. - Impact: A port with 500 in-flight
sent/partially_signeddocuments 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
lastReminderAtper doc withgroupBy(documentEvents.documentId). Pre-load pendingSigners withinArray(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()socaptureErrorEventwrites theerror_eventsrow. 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): introduceUMAMI_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.tsSTACK_PATH_HINTS catches them via stack but the user-facing toast is generic. - Recommendation: Add
DOCUMENSO_UPSTREAM_ERROR(502) andDOCUMENSO_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 requirestoastError(err)fromsrc/lib/api/toast-error.tsso the request-id + error-code surface reaches users.
- 20 more files. All call
- 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.codeandApiError.requestIdstripped. - Recommendation: Route through
toastError(err)and drop the inline banner, OR extractgetErrorDisplay(err)fromtoast-error.tsso 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 extrait()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 globaldocumensoIdwithoutport_idenforcement (audit-deferred). No test exists.tests/unit/webhook-event-map.test.tsonly validates the static event-name map;tests/integration/documents-expired-webhook.test.tscallshandleDocumentExpired()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 samedocumenso_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-passwordparse credentials/tokens but never callcheckRateLimit. The sharedrateLimiters.auth(5/15min) bucket is defined insrc/lib/rate-limit.ts:73but 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.
- Retry-After response pattern from
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-versionsacceptsbody.storageKeyand forwards it touploadBerthPdf(berth-pdf.service.ts:213). Service validates the berth is in-port but never asserts the supplied key falls within the expectedberths/${berthId}/...namespace. Magic-byte check passes for any genuine PDF; content-type check acceptsapplication/octet-stream. - Impact: A rep with
berths.editon berth-X can repoint berth-X'scurrentPdfVersionIdat 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. SubsequentGET /api/v1/berths/[id]/pdf-downloadserves those bytes under berth-X's tenant-scoped permission gate, bypassingdocuments.view/brochures.viewresource checks. - Recommendation: Reject any
storageKeythat 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.idand exposed via Drizzlerelations(), 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 inrelations.tshappily resolve null; downstream code that doesn't null-check silently misbehaves. - Recommendation: Single migration
0041_missing_fk_constraints.sqladding all eight constraints withON DELETE SET NULL(orphan tolerance) for nullable columns,RESTRICTforformSubmissions.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
AppErrorsubclass throw. For admin gating, factor arequireSuperAdmin(ctx)helper that throwsForbiddenError.
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:66sequentialcreateNotificationper user — public form does ~80 round trips before responding. (6)email-compose.service.ts:25,117N+1 attachment-port-check + full-buffer load (15MB resident per 3× 5MB send). (7)files.ts:187+maintenance.ts:90directminioClient.removeObject(CLAUDE.md violation; filesystem mode orphans bytes). (8)validators/invoices.ts:33,65uncappedexpenseIdsarray → Postgres parameter limit DoS vector. (9)expense-pdf.service.ts:444unboundedinArrayon 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.sendconvention. - Recommendation: Wrap brochure + berth-pdf in
withPermission('email','send'), preview/list inwithPermission('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 inwithAuthonly. Returns{id, displayName}for every user assigned to the caller's port. Caller isreminder-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 byctx.portId, so today the path is safe — but the service is a footgun for any future caller. - Recommendation: Add
callerPortId: stringtoMergeOptions; throw ifwinnerRow.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.setPrimaryBerthandupsertInterestBerthdo guard cross-port (lines 207-221), butremoveInterestBerthis the gap. - Recommendation: Make
removeInterestBerth(interestId, berthId, portId)with the sameinterestPortId/berthPortIdcross-check asupsertInterestBerthTx.
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(detectOverdueper-row update),notifications.service.ts:222(markRead by userId only),clients.service.ts:835(deleteRelationshipby relId only). Each precedes the mutation with a port-scoped read, then writes keyed only by entity id. - Impact: Today safe under sequential execution. A future refactor that drops the read, switches to a stale cache, or runs the write under different transaction isolation will silently mutate a foreign-tenant row.
- Recommendation: Add
eq(<table>.portId, portId)to each WHERE.
src/lib/services/portal.service.ts:284-304 — getDocumentDownloadUrl fetches file without port scope
- Source: auditor-B3 (Issue 5)
- Description: Document lookup at 284-289 is correctly scoped;
follow-up file lookup at 298-300 is
where: eq(files.id, fileId)only — noeq(files.portId, portId). - Impact: If
documents.signedFileId / fileIdever 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 toreports.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.contractFileIdwritten without verifyingfiles.portId === portId. - Impact: Port-A user can attach a port-B file id to a port-A
reservation. Downstream code that resolves
reservation.contractFileIdand presigns it (portal contract download, audit export) without re-checking file.portId leaks foreign-port file content. - Recommendation: Add a port-scoped
findFirstfiles lookup before the write; throwValidationErrorif 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_typeandinvoices.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_typecolumns. - Recommendation: Extend in
0041_polymorphic_check_constraints_round2.sql: CHECK onyacht_ownership_history.owner_type IN ('client','company')anddocument_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 onbilling_entity_type, an invoice can be inserted withbilling_entity_type='client'andbilling_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 fromclientName/billingEmailheuristic 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
userrow leavesuserProfilesorphaned with brokenunique(userId)invariant. Lifecycle-critical rows shouldn't float. - Recommendation: Add FKs with
ON DELETE CASCADEforuserProfiles/userPortRoles/session;ON DELETE SET NULLfor 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 hardif (!ctx.isSuperAdmin) 403. Locks out port admins withexpenses.export = true. - Recommendation: Use the same
withPermissionfor 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 ondocuments.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-suppliedfile.mimeType(fully attacker-controlled) againstALLOWED_MIME_TYPESand stores it as MinIOContent-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. SetX-Content-Type-Options: nosniffon 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
buffertorunOcrwithmimeType = 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.sizeat 10MB.
src/app/api/v1/me/route.ts:21 — PATCH preferences uses .passthrough(), unbounded JSONB write
- Source: auditor-E3 (Issue 5)
- Description:
updateProfileSchema.preferencesisz.object({ dark_mode, locale, timezone }).passthrough(). Any extra key the client supplies survives validation, gets merged intouserProfiles.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/documentswith 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 asIdempotency-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 singleenv.DOCUMENSO_WEBHOOK_SECRET. Two ports pointed at different Documenso instances must share the same plaintext secret. - Recommendation: Add
documenso_webhook_secret_encryptedto per-portsystem_settings, fall back to env. Try each enabled port's secret withtimingSafeEqualand 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 withoutsocketTimeout,greetingTimeout,connectionTimeout. Per-message fetch loop has no per-iteration cap; one slow-streaming UID stalls the whole sync. Errors propagate as rawError. - Recommendation: Pass
socketTimeout: 60_000+ a wall-clock cap onsyncInbox(5-min fuse viaPromise.race). Wrap upstream rejects inCodedError('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-workerservice definition has nohealthcheck: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/healthfrom 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 shipsEMAIL_CREDENTIAL_KEY=000…000. - Recommendation: Add all consumed vars; reject
EMAIL_CREDENTIAL_KEYmatching the example value whenNODE_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 apackageManagerfield to package.json.
docker-compose.prod.yml:35,54 — Pulls :latest images
- Source: auditor-K (Issue 6)
- Description:
image: …/crm-app:latestand…/crm-worker:latest. No digest pin, no version tag. - Recommendation: Tag releases (
:v0.1.0or: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 --prodat line 22 (UID 0), then createsworker:nodejs, copies bundle, thenUSER 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 theworkeruser. OCR/image-processing failures only manifest at first PDF parse in prod. - Recommendation: Re-order — create user,
chown -R worker:nodejs /app, thenUSER workerbeforepnpm 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 onres.okthink the call succeeded; React-Query never enters the error path. Same anti-pattern atadmin/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. InnersignIn()failure (viaerrorResponse) 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'; letsignIn()own the 401 string.
Custom-fields suite never asserts cross-port nor cross-resource isolation
- Source: auditor-J (Issue 3)
- Description: Suite seeds one
portIdand exercises CRUD inside it. Noit(...)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
getValuesempty,setValuescross-port rejects, handler-level test that a viewer withcompanies.viewonly is403on 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 samedocumenso_id, current code mutates whichever documentfindFirstreturns. - 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
usersWithAccessandawaitscreateNotificationper 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 withuserIds[]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_timeoutfor AbortError.
src/lib/error-classifier.ts:30-180 — Misses common error shapes
- Source: auditor-G (Issue 4)
- Description: Uncategorized:
BetterAuthError/APIError, BullMQ errors, MinIOS3Error/NoSuchBucket/AccessDenied,ImapFlowError. - Recommendation: Add to
ERROR_NAME_HINTS; add abullmqstack 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...')insrc/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(...); throwCodedError('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 uniqueuniqueIndex(...).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:
handleDownloadcatches every error withconsole.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.tscurrency/rates/refresh. Bypasses thepermission_deniedaudit row.
- Recommendation: Standardize on
withPermission('admin', <key>). Add a new'admin', 'system'(or reusesystem_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()returnstruewhen both Origin and Referer are absent. SameSite=Lax mitigates but defense-in-depth weakened. - Recommendation: Default deny when both absent; whitelist specific server-to-server flows.
src/lib/validators/ports.ts:10, me/route.ts:14 — z.string().url() accepts javascript:/data: schemes
- Source: auditor-E3 (Issue 6)
- Description: URL constructor accepts arbitrary schemes. Today
rendered only inside
<img src>(neutralizesjavascript:); the day someone renders as<a href>, stored XSS / phishing. - Recommendation: Add
.refine((u) => /^https?:\/\//i.test(u), 'must be http(s)')— mirrorvalidators/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-passworduses{message}while others use{error}. - Recommendation: Factor
parsePortalAuthBody(req, schema)helper that throwsValidationError.
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:444chunkinArrayto 500-batch. (perf 10)getClientDocumentsno limit. (perf 11)getThreadno 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:172inverts 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.jsonfreshness (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 sharedmaxRetriesPerRequest:nullfoot-gun. - NocoDB import (
src/lib/services/berth-import.ts) — pure helpers, idempotent viaupdated_atwindow. - 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+rethrowanywhere insrc/lib/services/,src/lib/queue/,src/lib/storage/,src/lib/email/,src/app/api/(only legitimate boot-timeconsole.errorin 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 viaescapeMergeValue. - Brochure download-link filename HTML-escape — confirmed.
- Mass-assignment — service
…dataspreads use Zod-validated DTOs that omitportId/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 wiredisabled={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_notificationbut never claimed their task or sent a plan. Consistent symptom across 5 spawns of thefeature-dev:code-reviewerandfeature-dev:code-explorersubagent types. - Successful respawns. A3, B3, C3, E3 spawned as
general-purposeand all delivered tight, well-scoped reports. All 8general-purposespawns from the original batch (D, F, G, H, I, J, K, L) bootstrapped correctly the first time. Pattern: usegeneral-purposefor 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).