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