docs: comprehensive audits + Documenso build plan + admin UX backlog
Six audit documents capture the 2026-05-06 review pass (comprehensive, frontend, missing-features, permissions, reliability) along with the Documenso integration audit + locked build plan that drove the bulk of subsequent feature work. Adds `docs/admin-ux-backlog.md` as a living tracker for the autonomous push — every item marked DONE or REMAINING with file pointers and scope estimates so future sessions can pick up where this one stopped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
753
docs/audit-comprehensive-2026-05-06.md
Normal file
753
docs/audit-comprehensive-2026-05-06.md
Normal file
@@ -0,0 +1,753 @@
|
||||
# Comprehensive Audit — 2026-05-06
|
||||
|
||||
Conducted directly after the smart-archive / hard-delete / bulk-wizard /
|
||||
audit-overhaul / synthetic-seed batches landed (commits `d07f1ed`
|
||||
through `9890d06`). Prior comprehensive audit:
|
||||
`docs/audit-comprehensive-2026-05-05.md`.
|
||||
|
||||
Findings are sorted by severity. Each has a concrete file:line, a
|
||||
scenario, and a fix recommendation.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### C1. 5 of 10 BullMQ workers are never imported (production + dev)
|
||||
|
||||
**Files:** `src/worker.ts:13-17`, `src/server.ts:72-76`
|
||||
|
||||
`src/worker.ts` (production) and `src/server.ts` (dev fallback) both
|
||||
import only:
|
||||
|
||||
- `emailWorker`
|
||||
- `documentsWorker`
|
||||
- `notificationsWorker`
|
||||
- `importWorker`
|
||||
- `exportWorker`
|
||||
|
||||
**Missing:** `aiWorker`, `bulkWorker`, `maintenanceWorker`, `reportsWorker`, `webhooksWorker`.
|
||||
|
||||
Because BullMQ workers are constructed at the top of each worker
|
||||
module and only "start" when the module is imported, never importing
|
||||
them means:
|
||||
|
||||
- **Webhooks never deliver.** `webhooksWorker` is what processes the
|
||||
`webhooks` queue; the admin "Replay" button we just shipped enqueues
|
||||
jobs that pile up in `pending` forever.
|
||||
- **All maintenance crons silently no-op.** `maintenanceWorker` handles
|
||||
`database-backup`, `backup-cleanup`, `session-cleanup`,
|
||||
`currency-refresh`, `gdpr-export-cleanup`, `ai-usage-retention`,
|
||||
`error-events-retention`, `website-submissions-retention`,
|
||||
`alerts-evaluate`, `analytics-refresh`, `calendar-sync`,
|
||||
`temp-file-cleanup`, `form-expiry-check` — none run.
|
||||
- **Scheduled reports never generate.** `reportsWorker` handles
|
||||
`report-scheduler` (every minute).
|
||||
- **Bulk jobs never process** (the synchronous bulk endpoints work, but
|
||||
any deferred-bulk path is dead).
|
||||
- **AI usage features never run.**
|
||||
|
||||
**Impact:** Production CRM has been silently shedding webhook
|
||||
deliveries, never running retention/cleanup, never sending scheduled
|
||||
reports.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```ts
|
||||
// Append to src/worker.ts AND the inline section of src/server.ts:
|
||||
import { aiWorker } from '@/lib/queue/workers/ai';
|
||||
import { bulkWorker } from '@/lib/queue/workers/bulk';
|
||||
import { maintenanceWorker } from '@/lib/queue/workers/maintenance';
|
||||
import { reportsWorker } from '@/lib/queue/workers/reports';
|
||||
import { webhooksWorker } from '@/lib/queue/workers/webhooks';
|
||||
|
||||
const workers = [
|
||||
emailWorker,
|
||||
documentsWorker,
|
||||
notificationsWorker,
|
||||
importWorker,
|
||||
exportWorker,
|
||||
aiWorker,
|
||||
bulkWorker,
|
||||
maintenanceWorker,
|
||||
reportsWorker,
|
||||
webhooksWorker,
|
||||
];
|
||||
```
|
||||
|
||||
After fix, run `pnpm dev` and watch `/admin/webhooks/{id}` deliveries
|
||||
go from `pending` → `success` to confirm.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### H1. Hard-delete request endpoints have zero rate limiting
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/app/api/v1/clients/[id]/hard-delete-request/route.ts:1-37`
|
||||
- `src/app/api/v1/clients/bulk-hard-delete-request/route.ts:1-32`
|
||||
|
||||
Each call writes a fresh code to Redis and emails it to the operator's
|
||||
address. No `withRateLimit(...)`. An attacker who has compromised an
|
||||
admin account (or even just the new `permanently_delete_clients`
|
||||
permission) can:
|
||||
|
||||
1. Email-bomb the admin's own inbox (every request → email).
|
||||
2. Probe whether arbitrary client IDs exist (200 + `sentToMaskedEmail`
|
||||
vs 404 `client not found` is a UID oracle).
|
||||
3. Burn SMTP quota.
|
||||
|
||||
**Fix:** add `withRateLimit('auth', ...)` or a new dedicated bucket
|
||||
(e.g. 5 per hour per user). Pattern is already in
|
||||
`src/app/api/v1/clients/[id]/gdpr-export/route.ts`.
|
||||
|
||||
### H2. Audit-page view fires on every paginated reload (log spam)
|
||||
|
||||
**File:** `src/app/api/v1/admin/audit/route.ts:48-72`
|
||||
|
||||
I added a "watch the watchers" `view` audit row for first-page audit
|
||||
fetches. That's the right idea, but the page also re-fires the request
|
||||
on every filter change (severity, source, action, date range, search).
|
||||
A diligent admin filtering through the inspector for an investigation
|
||||
will write dozens of `view` audit rows per minute — making it harder to
|
||||
find the actual events they're looking for.
|
||||
|
||||
**Fix:** dedupe in Redis with a 60-second per-user TTL key, only emit
|
||||
if the key didn't exist. Or only fire when no filters are active.
|
||||
|
||||
### H3. Hard-delete error messages distinguish "no code" vs "wrong code"
|
||||
|
||||
**File:** `src/lib/services/client-hard-delete.service.ts:166-174`
|
||||
|
||||
```ts
|
||||
if (!stored) throw new ValidationError('Confirmation code expired or not requested');
|
||||
if (!safeEqualStr(stored, args.code.trim())) {
|
||||
throw new ValidationError('Confirmation code is incorrect');
|
||||
}
|
||||
```
|
||||
|
||||
The two messages let an attacker distinguish "you've never requested a
|
||||
code" (so spam the request endpoint to open the window) from "wrong
|
||||
code" (so brute-force more codes). 4-digit space is only 10,000 — with
|
||||
distinguishable feedback an attacker can confirm code validity in
|
||||
≤5,000 attempts on average.
|
||||
|
||||
**Fix:** collapse to a single `'Invalid or expired code'` message; the
|
||||
operator already has the email open and knows what they typed.
|
||||
|
||||
### H4. Synthetic seed leaves `super_admin` linked-port-roles empty
|
||||
|
||||
**File:** `src/lib/db/seed-bootstrap.ts:147-160`
|
||||
|
||||
The bootstrap creates the `userProfiles` row with
|
||||
`isSuperAdmin: true` for `super-admin-matt-portnimara`, but doesn't
|
||||
create `userPortRoles` rows. The actual real `user` rows (admin@,
|
||||
agent@, viewer@) are only created via the Playwright global-setup.
|
||||
Anyone running `pnpm db:seed:synthetic` then `pnpm dev` and trying to
|
||||
log in via the UI hits an unauthenticated state until they also run
|
||||
playwright setup or sign up via better-auth manually.
|
||||
|
||||
**Fix:** either document this in `CLAUDE.md` Quick Reference, or add a
|
||||
`pnpm db:seed:dev-users` companion script that signs up the three
|
||||
test users + links roles. Today's synthetic-seed flow felt clean
|
||||
because the playwright setup was still applied; in a fresh clone it
|
||||
will surprise.
|
||||
|
||||
### H5. Documenso bad-secret 200 response is correct, but enables enum oracle
|
||||
|
||||
**File:** `src/app/api/webhooks/documenso/route.ts:67-86`
|
||||
|
||||
The route returns `200 ok=false error=Invalid secret` for a wrong
|
||||
secret. That's webhook best-practice (don't leak signal to attackers),
|
||||
but combined with the new audit row that captures
|
||||
`metadata.providedLen`, an attacker can probe secret-length over time
|
||||
without being detected (just a "warning" row per attempt). On an admin
|
||||
inspector with 1000s of rows, a slow-rate probe is invisible.
|
||||
|
||||
**Fix:** add per-IP rate limit (5/min) to `/api/webhooks/documenso/`
|
||||
when secret check fails. Don't block real Documenso traffic — it
|
||||
shouldn't fail the secret check.
|
||||
|
||||
### H6. The audit-log inspector page itself isn't backed by a real "view" gate beyond `admin.view_audit_log`
|
||||
|
||||
**File:** `src/app/api/v1/admin/audit/route.ts:31`
|
||||
|
||||
Audit log has the most sensitive cross-cutting data in the system
|
||||
(every login attempt with attempted email, every secret-regenerate,
|
||||
every hard-delete). It's gated only by `admin.view_audit_log`. The
|
||||
seed grants this to `director` AND `super_admin`. Consider:
|
||||
|
||||
- making the page super-admin-only for production, OR
|
||||
- adding a secondary confirmation when viewing rows that contain
|
||||
attempted emails / IP ranges (PII).
|
||||
|
||||
**Fix:** change `withPermission('admin', 'view_audit_log', ...)` to
|
||||
add `if (!ctx.isSuperAdmin) check sensitive_audit_view`. Or accept
|
||||
the current model but document it in the role docs.
|
||||
|
||||
### H7. Three "coming soon" stubs in production UI
|
||||
|
||||
**Files:**
|
||||
|
||||
- `src/components/clients/client-tabs.tsx:276` — "File attachments coming soon."
|
||||
- `src/components/clients/client-reservations-tab.tsx:41` — "History is coming soon."
|
||||
- `src/components/berths/berth-tabs.tsx:327` — "{label} coming soon"
|
||||
|
||||
Visible to every user on every client / berth detail page. Either ship
|
||||
the feature or hide the tab.
|
||||
|
||||
**Fix:** for `client-tabs.tsx` line 276 (Files), the `files` table
|
||||
already exists and supports clientId — ship a list view.
|
||||
For `berth-tabs.tsx` line 327 — find the calling tab labels and
|
||||
either implement or remove from the tabs array.
|
||||
For `client-reservations-tab.tsx` line 41 — query past reservations
|
||||
when the user toggles a "show history" filter.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### M1. `attachWorkerAudit` recurring job names list duplicates scheduler.ts (drift risk)
|
||||
|
||||
**File:** `src/lib/queue/audit-helpers.ts:23-46`
|
||||
|
||||
The 20 recurring job names are hardcoded in the audit helper; the
|
||||
scheduler also has its own list. If someone adds a new cron without
|
||||
updating both, the cron_run audit row never fires for that job.
|
||||
|
||||
**Fix:** export the list from `scheduler.ts` and import it in
|
||||
`audit-helpers.ts`. Single source of truth.
|
||||
|
||||
### M2. `client-merge-log.surviving_client_id` deleted by hard-delete (history loss)
|
||||
|
||||
**File:** `src/lib/services/client-hard-delete.service.ts:200-202`
|
||||
|
||||
Hard-delete drops every `client_merge_log` row whose surviving id
|
||||
matches. Those rows are the audit trail of WHO was merged INTO this
|
||||
client. Once deleted, you've lost evidence of the prior merge.
|
||||
|
||||
**Fix:** replace `delete` with a column nullification, or move the row
|
||||
to a `client_merge_log_archive` table. Audit trail per GDPR Article 5
|
||||
should outlive the data.
|
||||
|
||||
### M3. Bulk hard-delete loops one-shot codes through Redis (5x writes)
|
||||
|
||||
**File:** `src/lib/services/client-hard-delete.service.ts:382-396`
|
||||
|
||||
For a 100-client bulk delete, the function writes 100 single-client
|
||||
codes to Redis just to satisfy `hardDeleteClient`'s expectation. Each
|
||||
write is a round-trip; on a Redis hiccup mid-loop, you can end up
|
||||
with a half-deleted batch.
|
||||
|
||||
**Fix:** refactor `hardDeleteClient` so the inner deletion can be called
|
||||
without the per-client code check (extract `_doHardDelete()` private
|
||||
helper used by both single and bulk paths). Keeps Redis clean.
|
||||
|
||||
### M4. Smart-restore wizard has dead reversal applier for `berth_released`
|
||||
|
||||
**File:** `src/lib/services/client-restore.service.ts:360-372`
|
||||
|
||||
The `applyReversal` switch case for `'berth_released'` does nothing —
|
||||
it just leaves the berth available. The wizard surfaces this as
|
||||
"auto-reversible" if the berth is still free, but the actual restore
|
||||
doesn't re-attach the berth to any interest. Operator clicks Restore
|
||||
expecting their berth back; nothing changes on the berth.
|
||||
|
||||
**Fix:** either (a) at archive time, persist the original interestId
|
||||
in the decision metadata so we can re-link, or (b) update the wizard
|
||||
copy to make clear the berth is "available for re-attach" rather than
|
||||
"will be re-attached."
|
||||
|
||||
### M5. Several services use `void createAuditLog(...)` without `.catch()`
|
||||
|
||||
**Files:** widespread; e.g. `src/lib/services/client-hard-delete.service.ts:127-136, 230-240`,
|
||||
`src/lib/services/portal-auth.service.ts:269-276`
|
||||
|
||||
`createAuditLog` is documented as never-throwing (catches internally),
|
||||
but defense-in-depth: a `void` Promise that throws produces an
|
||||
unhandled rejection event. Most paths are fine because the helper
|
||||
catches; if anyone refactors `createAuditLog` and removes the catch,
|
||||
this becomes a process-killer.
|
||||
|
||||
**Fix:** convention rule: every `void someAsync()` must have a `.catch()`.
|
||||
Codify with a custom ESLint rule, or wrap at call sites:
|
||||
`void createAuditLog({...}).catch(() => undefined);`
|
||||
|
||||
### M6. Hard-delete audit metadata leaks client `fullName`
|
||||
|
||||
**File:** `src/lib/services/client-hard-delete.service.ts:241-247`
|
||||
|
||||
After the hard-delete the audit row carries
|
||||
`metadata: { fullName: client.fullName }`. The client record itself is
|
||||
gone but their name lives on in the audit log. For a GDPR data subject
|
||||
who exercised their right-to-erasure, this is technically a retention
|
||||
of personal data in audit history. Not necessarily wrong (audit logs
|
||||
have a legitimate-interest basis), but should be conscious.
|
||||
|
||||
**Fix:** decide policy: either (a) keep as-is and document, (b) replace
|
||||
with a hash of the name, or (c) substitute a tombstone identifier.
|
||||
|
||||
### M7. Webhook delivery DLQ admin-replay can re-trigger downstream side-effects
|
||||
|
||||
**File:** `src/lib/services/webhooks.service.ts:282-326`
|
||||
|
||||
Replaying a successful webhook (operator presses Replay on a delivery
|
||||
that already had `status: 'success'`) re-fires the same payload to the
|
||||
recipient. If the recipient's idempotency check is weak, you've just
|
||||
caused a duplicate. The replay payload includes `retried_from` /
|
||||
`retried_at` markers, which is good — but most recipients won't honor
|
||||
them.
|
||||
|
||||
**Fix:** disable the Replay button when `status === 'success'`. The UI
|
||||
already gates on `'failed' || 'dead_letter'` — verify it stays that
|
||||
way (`webhook-delivery-log.tsx:118-131` looks correct; double-check
|
||||
no regressions).
|
||||
|
||||
### M8. `audit_logs` table has no DELETE permission gate
|
||||
|
||||
**Files:** schema and routes
|
||||
|
||||
There's no admin endpoint to delete audit rows (good). But there's no
|
||||
DB-level guard either. A super_admin who runs `db:reset` wipes audit
|
||||
history. Audit retention should be enforced at the schema level so
|
||||
even a misconfigured operator can't blow away the trail.
|
||||
|
||||
**Fix:** create a `audit_logs_no_delete_role` postgres role that lacks
|
||||
DELETE on the table; document that the app's DB user should not have
|
||||
DELETE on `audit_logs` in production deployments.
|
||||
|
||||
### M9. Documenso void worker uses dynamic import every time
|
||||
|
||||
**File:** `src/lib/queue/workers/documents.ts:25`
|
||||
|
||||
```ts
|
||||
const { voidDocument } = await import('@/lib/services/documenso-client');
|
||||
```
|
||||
|
||||
Dynamic import inside a hot per-job path is fine the first time but
|
||||
slows every subsequent call slightly. Move to top-of-file import
|
||||
unless there's a deliberate reason (circular dep?).
|
||||
|
||||
**Fix:** test moving to top-level import; if it works (no circular
|
||||
deps), keep it there.
|
||||
|
||||
### M10. Bulk archive wizard "blocked" reason copy truncates at first line
|
||||
|
||||
**File:** `src/components/clients/bulk-archive-wizard.tsx:153-163`
|
||||
|
||||
The wizard shows `b.blockers[0]` for blocked clients. If the dossier
|
||||
has multiple blockers, only the first is shown. Operators may fix the
|
||||
first one, retry, and discover a second.
|
||||
|
||||
**Fix:** show all blockers (joined with `·`) or a "+N more" badge
|
||||
with click-to-expand.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
### L1. `next-in-line-notify.service.ts` could double-fire on archive retry
|
||||
|
||||
**File:** `src/app/api/v1/clients/[id]/archive/route.ts:114-135`
|
||||
|
||||
If the smart-archive request succeeds at the DB transaction level but
|
||||
the response upload-side fails (network blip, browser closes), the
|
||||
operator may retry. Each retry re-fires the next-in-line notification
|
||||
to all sales recipients. The `dedupeKey: berth-released:{berthId}`
|
||||
inside the notification helper deduplicates within a cooldown window —
|
||||
so this is mitigated, but worth verifying the cooldown is set and
|
||||
not 0.
|
||||
|
||||
### L2. `interests.berth_id` reference in `seed-data.ts` (legacy seed)
|
||||
|
||||
**File:** `src/lib/db/seed-data.ts:973`
|
||||
|
||||
The realistic seed inserts `berthId: ...` on the interests table. Per
|
||||
`CLAUDE.md`, that column was dropped in migration 0029 and replaced
|
||||
with `interest_berths` junction. The synthetic seed uses the junction
|
||||
correctly. The realistic seed will FAIL at insert time if anyone
|
||||
tries to run it on a freshly-migrated DB.
|
||||
|
||||
**Fix:** rewrite `seed-data.ts:969-982` to insert into `interests`
|
||||
without `berthId`, then insert the junction rows separately (mirror
|
||||
the synthetic seed's pattern).
|
||||
|
||||
### L3. Audit log entry for failed login uses `entityId = attemptedEmail` (unbounded)
|
||||
|
||||
**File:** `src/app/api/auth/[...all]/route.ts:53-68`
|
||||
|
||||
If the entityId is very long (a 500-char "email"), it goes into the
|
||||
DB column. The column is `text` (unbounded) so no DB error, but FTS
|
||||
search-text may bloat.
|
||||
|
||||
**Fix:** truncate attempted email to 256 chars before using as
|
||||
entityId.
|
||||
|
||||
### L4. The "watch the watchers" audit fires for filtered queries too
|
||||
|
||||
**File:** `src/app/api/v1/admin/audit/route.ts:48-72`
|
||||
|
||||
(See H2 above for the page-spam variant.) Even on a single search,
|
||||
an audit row containing the search term is written. If the search
|
||||
term itself is sensitive (e.g. an admin searches for a specific
|
||||
client's name in audit logs), it's now in the audit log of audit-log
|
||||
viewing. Acceptable but worth documenting.
|
||||
|
||||
### L5. Import worker is a stub
|
||||
|
||||
**File:** `src/lib/queue/workers/import.ts:13`
|
||||
|
||||
`// TODO(L2): implement import job handlers` — the worker is wired
|
||||
into the queue and registered, but does nothing. If anyone enqueues
|
||||
an `import:*` job, it returns immediately. Either ship the feature
|
||||
or remove the queue.
|
||||
|
||||
### L6. `interest-form.tsx` two TODOs about company-yacht filter + add-yacht inline
|
||||
|
||||
**File:** `src/components/interests/interest-form.tsx:332-333`
|
||||
|
||||
Real product gaps. When creating an interest for a client who's a
|
||||
member of a company, you can't pick a yacht owned by that company.
|
||||
And there's no inline "Add yacht" shortcut in the form.
|
||||
|
||||
### L7. `berth-spec-template.ts` defaults to `'Price: TBD'` when price is null
|
||||
|
||||
**File:** `src/lib/pdf/templates/berth-spec-template.ts:128`
|
||||
|
||||
Generated berth-spec PDFs say "Price: TBD" for any berth without a
|
||||
price. Cosmetic — verify whether sales considers this an acceptable
|
||||
fallback or wants to suppress the line entirely.
|
||||
|
||||
---
|
||||
|
||||
## Things checked and found OK (so we don't re-audit)
|
||||
|
||||
- Tenant isolation on hard-delete (`portId` filter on every query and
|
||||
inside the tx).
|
||||
- `withPermission` gates on every new route (bulk-archive-preflight,
|
||||
hard-delete-_, bulk-hard-delete-_, redeliver).
|
||||
- Audit log: no public DELETE endpoint, no PATCH endpoint.
|
||||
- Sidebar nav properly gates marina sections from `residential_partner`
|
||||
via `hasMarinaAccess`.
|
||||
- Auth wrapper rebuilds the request body correctly so the upstream
|
||||
better-auth handler can re-read it (no body-already-consumed bug).
|
||||
- Webhook outbound SSRF guard with DNS rebinding protection still
|
||||
intact.
|
||||
- 1175/1175 vitest suite passing as of last run.
|
||||
|
||||
---
|
||||
|
||||
## Recommended fix order (ROUND 1 + 2 combined — see below for Round 2)
|
||||
|
||||
See **"Triage list" at the end** of this document — combined ranking
|
||||
across both audit rounds.
|
||||
|
||||
---
|
||||
|
||||
## Round 2 — focused agents (added 2026-05-06 evening)
|
||||
|
||||
After the original synthesis above, four scoped agents (smaller blast
|
||||
radius, hard finding caps) successfully audited their domains and
|
||||
produced dedicated docs. Findings are linked here with `R2-`-prefixed
|
||||
IDs. Detail in:
|
||||
|
||||
- [audit-reliability-2026-05-06.md](audit-reliability-2026-05-06.md) — 11 findings
|
||||
- [audit-frontend-2026-05-06.md](audit-frontend-2026-05-06.md) — 12 findings
|
||||
- [audit-permissions-2026-05-06.md](audit-permissions-2026-05-06.md) — 9 findings
|
||||
- [audit-missing-features-2026-05-06.md](audit-missing-features-2026-05-06.md) — 12 findings
|
||||
|
||||
### Round 2 — CRITICAL
|
||||
|
||||
**R2-C1. Bulk archive discards post-commit side effects** ([reliability C1](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/app/api/v1/clients/bulk/route.ts:68-134`
|
||||
- The bulk wizard's `runBulk` callback discards the return value from
|
||||
`archiveClientWithDecisions`. **Documenso envelopes marked
|
||||
`void_documenso` are never queued for void; "next-in-line" sales
|
||||
notifications never fire**. The CRM ends up showing `documents.status='cancelled'`
|
||||
while the live envelope is still out for signature — a signer can
|
||||
legally complete a doc the CRM thinks is voided.
|
||||
- Same severity tier as the original C1 (worker-imports).
|
||||
|
||||
**R2-C2. Frontend: Restore icon hovers destructive-red on archived clients** ([frontend C1](audit-frontend-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/client-detail-header.tsx:174-186`
|
||||
- Conditional `hover:text-destructive` is overridden by an unconditional
|
||||
`hover:text-foreground` earlier in the class string. Result: the
|
||||
Restore button on archived clients hovers blood-red, signalling
|
||||
"destructive" on a fully reversible action. Users hesitate to click.
|
||||
Promoted to "critical UX" because it's directly misleading on every
|
||||
archived client view.
|
||||
|
||||
### Round 2 — HIGH
|
||||
|
||||
**R2-H1. Smart-restore wizard's `berth_released` reversal is a no-op but the audit log claims success**
|
||||
([reliability H1](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/lib/services/client-restore.service.ts:359-372`
|
||||
- Already noted as M4 in the original synthesis. Round-2 reliability
|
||||
agent escalated to HIGH because the wizard counter increments and
|
||||
the audit log records "1 auto-reversed" — operator believes the berth
|
||||
was re-attached when nothing happened. Same fix path: persist the
|
||||
original `interestId` in the decision detail and re-link on restore.
|
||||
|
||||
**R2-H2. Smart-archive berth status update has TOCTOU race**
|
||||
([reliability H2](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/lib/services/client-archive.service.ts:191-207`
|
||||
- Berth row read outside tx, mutated inside tx without `for update`
|
||||
lock. Concurrent archive + sale of the same berth can race: the
|
||||
archive flow flips a freshly-sold berth back to `available`. Add
|
||||
`select … for update` on `berths` before the status flip.
|
||||
|
||||
**R2-H3. Bulk archive can pick the wrong interest for berth release**
|
||||
([reliability H3](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/app/api/v1/clients/bulk/route.ts:95-103`
|
||||
- Lookup by `primaryBerthMooring` falls back to `dossier.interests[0]?.interestId ?? ''`.
|
||||
Empty-string `interestId` reaches the delete and silently matches
|
||||
zero rows; the link is silently retained while the audit log claims
|
||||
it was removed.
|
||||
|
||||
**R2-H4. External EOI runs five operations outside a transaction**
|
||||
([reliability H4](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/lib/services/external-eoi.service.ts:67-155`
|
||||
- Storage upload + 4 DB writes are independent. Mid-flight failure
|
||||
leaves orphan PDFs in S3/MinIO and partial DB state.
|
||||
|
||||
**R2-H5. Bulk wizard double-submit treats `ConflictError('already archived')` as a per-row error**
|
||||
([reliability H5](audit-reliability-2026-05-06.md))
|
||||
|
||||
- File: `src/app/api/v1/clients/bulk/route.ts:68-120`
|
||||
- No idempotency key on the bulk endpoint. A double-submit (network
|
||||
retry, double click) makes the second response look like all rows
|
||||
failed even though the first succeeded.
|
||||
|
||||
**R2-H6. Webhook replay button has no UI permission gate (403 toast spam)**
|
||||
([permissions H1](audit-permissions-2026-05-06.md))
|
||||
|
||||
- File: `src/components/admin/webhooks/webhook-delivery-log.tsx:118-131`
|
||||
- Replay button renders for any user who can load the page. Server gates
|
||||
on `admin.manage_webhooks`. Non-admins see enabled buttons; clicking
|
||||
surfaces a generic 403 toast.
|
||||
|
||||
**R2-H7. Bulk Archive bulk action exposed to roles without `clients.delete`**
|
||||
([permissions H2](audit-permissions-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/client-list.tsx:182-190`
|
||||
- `sales_agent` and `viewer` see the Archive bulk action; clicking
|
||||
surfaces a 403 from preflight. Mirror the `canHardDelete` pattern:
|
||||
`const canBulkArchive = can('clients', 'delete');`
|
||||
|
||||
**R2-H8. Bulk add_tag / remove_tag exposed to viewer**
|
||||
([permissions H3](audit-permissions-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/client-list.tsx:165-181`
|
||||
- Same pattern as R2-H7 — no UI gate; server gates on `clients.edit`.
|
||||
|
||||
**R2-H9. Bulk hard-delete silently skips rows that vanish between preflight and execute**
|
||||
([permissions H4](audit-permissions-2026-05-06.md))
|
||||
|
||||
- File: `src/lib/services/client-hard-delete.service.ts:377`
|
||||
- `if (!c) continue;` swallows any client that was archived/restored/
|
||||
deleted by another operator between preflight and execute. Operator
|
||||
sees a `deletedCount` lower than requested and no signal which IDs
|
||||
were skipped.
|
||||
|
||||
**R2-H10. Frontend: `webhook-delivery-log` and `audit-log-list` swallow fetch errors silently**
|
||||
([frontend H3, H4](audit-frontend-2026-05-06.md))
|
||||
|
||||
- Files: `src/components/admin/webhooks/webhook-delivery-log.tsx:61-74`,
|
||||
`src/components/admin/audit/audit-log-list.tsx:150-175`
|
||||
- Both wrap fetches in `try/finally` with no `catch`. Failed loads show
|
||||
spinner forever or stale data; user has no signal that anything
|
||||
failed. Surface via `toast.error` + inline retry banner.
|
||||
|
||||
**R2-H11. Frontend: `audit-log-card` renders as `<a href="#">` — page-jumps on mobile tap**
|
||||
([frontend H5](audit-frontend-2026-05-06.md))
|
||||
|
||||
- File: `src/components/admin/audit/audit-log-card.tsx:96`
|
||||
- Card view rows on mobile insert `#` in URL on tap (back-button trap).
|
||||
Render as button or div, or link to a useful destination.
|
||||
|
||||
**R2-H12. Frontend: `smart-archive-dialog` doesn't invalidate the dossier or single-client query**
|
||||
([frontend H6](audit-frontend-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/smart-archive-dialog.tsx:197-212`
|
||||
- Detail page header keeps showing client as un-archived after a
|
||||
successful archive until hard reload. Add
|
||||
`qc.invalidateQueries({queryKey: ['clients', clientId]})` and
|
||||
`qc.removeQueries({queryKey: ['client-archive-dossier', clientId]})`.
|
||||
|
||||
**R2-H13. Frontend: bulk tag mutation uses `alert()` and lacks `onError`**
|
||||
([frontend H2](audit-frontend-2026-05-06.md))
|
||||
|
||||
- File: `src/components/clients/client-list.tsx:88-106`
|
||||
- Native `alert()` blocks the page on partial failure; pure network
|
||||
failure shows nothing. Replace with `toast.warning` / `toast.error`.
|
||||
|
||||
**R2-H14. Email-template subject overrides are no-ops for 6 of 8 templates**
|
||||
([missing-features V1](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Files: `src/components/admin/email-templates-admin.tsx:24-72` (UI),
|
||||
`src/lib/services/portal-auth.service.ts:120,332` (only consumers)
|
||||
- Admin sees an "Overridden" badge after saving a custom subject for
|
||||
CRM invite, inquiry confirmation, residential templates, etc. — but
|
||||
the senders ship the hardcoded subject regardless. Wire
|
||||
`loadSubjectOverride(portId, key)` into the 6 missing senders.
|
||||
|
||||
**R2-H15. Branding admin saves 5 settings that nothing reads**
|
||||
([missing-features V2](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Files: `src/app/(dashboard)/[portSlug]/admin/branding/page.tsx`,
|
||||
`src/lib/services/port-config.ts:240-272`
|
||||
- Logo URL, app name, primary color, header HTML, footer HTML all
|
||||
dead-end. `getPortBrandingConfig` has zero callers. **Multi-tenant
|
||||
promise broken — every port's emails ship Port Nimara's branding.**
|
||||
|
||||
**R2-H16. Reminder admin saves digest defaults that no scheduler applies**
|
||||
([missing-features V3](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Files: `src/app/(dashboard)/[portSlug]/admin/reminders/page.tsx`,
|
||||
`src/lib/services/port-config.ts:284-306`
|
||||
- Sales reps think they configured a daily digest at 09:00 in their
|
||||
TZ; they get fire-as-they-hit notifications instead. The digest
|
||||
scheduler doesn't exist.
|
||||
|
||||
### Round 2 — MEDIUM (selected highlights)
|
||||
|
||||
**R2-M1. Portal "My Memberships" tile is a dead-end** ([missing-features V4](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Tile on `/portal/dashboard` has no `href`; route doesn't exist. Either
|
||||
ship `/portal/memberships` or remove the tile.
|
||||
|
||||
**R2-M2. Company detail Documents tab is a "Coming soon" stub** ([missing-features V5](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- `src/components/companies/company-tabs.tsx:230-234`. Same problem
|
||||
as the three already-noted "coming soon" stubs but on a different
|
||||
entity.
|
||||
|
||||
**R2-M3. Onboarding page is a static checklist not the wizard it advertises** ([missing-features V6](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- The page literally says "what this page will become". Either build
|
||||
the wizard or relabel the landing card.
|
||||
|
||||
**R2-M4. Backup admin page is a docs page despite landing copy promising "on-demand exports"** ([missing-features V7](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Once C1 (worker imports) is fixed, the existing `database-backup`
|
||||
job is reachable; small lift to wire a "Take backup now" button.
|
||||
|
||||
**R2-M5. Inquiry inbox has zero triage actions** ([missing-features V8](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- No "Convert to client", no "Resolve", no "Assign". `website_submissions`
|
||||
table is permanent; sales has to copy-paste emails into client forms.
|
||||
|
||||
**R2-M6. external-eoi grants only `documents.upload_signed` but mutates interest state** ([permissions M1](audit-permissions-2026-05-06.md))
|
||||
|
||||
- A custom role with `documents.upload_signed:true` + `interests.edit:false`
|
||||
can flip an interest to "signed" via the external-EOI route.
|
||||
|
||||
**R2-M7. `InlineStagePicker` never sends `override:true` — `override_stage` permission unreachable from the most-used UI path** ([permissions M2](audit-permissions-2026-05-06.md))
|
||||
|
||||
- Users with the perm have to fall back to the modal `InterestStagePicker`
|
||||
to actually use it.
|
||||
|
||||
**R2-M8. `sales_agent` granted `interests.override_stage:true` — likely copy-paste from sales_manager** ([permissions M3](audit-permissions-2026-05-06.md))
|
||||
|
||||
- All other trust-elevated flags are stripped from sales_agent. Needs a
|
||||
product decision; either flip to false or document intent.
|
||||
|
||||
**R2-M9. `bulk-archive-preflight` leaks dossier-loader error text in `blockers`** ([permissions M4](audit-permissions-2026-05-06.md))
|
||||
|
||||
- An attacker enumerating UUIDs can distinguish "doesn't exist" vs
|
||||
"exists but you can't see it". Replace with generic "Could not load
|
||||
dossier".
|
||||
|
||||
**R2-M10. Documenso void worker has no max-retry alert hook** ([reliability M2](audit-reliability-2026-05-06.md))
|
||||
|
||||
- A persistent 401/403 retries forever. On exhaustion, write back to
|
||||
`documents` (`cancellation_failed=true`) and notify admin.
|
||||
|
||||
**R2-M11. Mobile More-sheet missing residential, notifications, berth-reservations, website-analytics** ([missing-features V9](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Mobile users have zero path to entire feature domains. Add to
|
||||
`MORE_ITEMS`.
|
||||
|
||||
**R2-M12. Portal has no profile / change-password surface** ([missing-features V10](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Forces every portal user to use the forgot-password flow even when
|
||||
they remember their old password. Ship `/portal/profile`.
|
||||
|
||||
**R2-M13. Portal invoices show amounts but no PDF download** ([missing-features V11](audit-missing-features-2026-05-06.md))
|
||||
|
||||
- Documents page does have downloads; mirror the pattern.
|
||||
|
||||
(Plus several more medium/low items in the dedicated docs; see those
|
||||
for the full set.)
|
||||
|
||||
---
|
||||
|
||||
## TRIAGE LIST (combined Round 1 + Round 2)
|
||||
|
||||
### Ship now — CRITICAL
|
||||
|
||||
1. **C1** — wire the 5 missing BullMQ workers (`worker.ts`, `server.ts`)
|
||||
— 5-line fix; every webhook + cron flow is currently dead.
|
||||
2. **R2-C1** — make bulk archive enqueue Documenso voids + next-in-line
|
||||
notifications (return value plumbing in `bulk/route.ts`).
|
||||
3. **R2-C2** — fix the destructive-red hover on the Restore button
|
||||
(`client-detail-header.tsx`). Trivial CSS fix.
|
||||
|
||||
### Ship this week — HIGH (security/UX with concrete user impact)
|
||||
|
||||
4. **H1** — rate-limit the hard-delete-request endpoints.
|
||||
5. **H3** — collapse "no code" vs "wrong code" into one error message.
|
||||
6. **H7** — three "coming soon" stubs in client/berth tabs.
|
||||
7. **R2-H1** — fix smart-restore's silent `berth_released` no-op (or
|
||||
reclassify as `reversibleWithPrompt`).
|
||||
8. **R2-H2** — add `for update` lock on the smart-archive berth status
|
||||
flip (TOCTOU race).
|
||||
9. **R2-H3** — bulk-archive's wrong-interest fallback — empty-string
|
||||
interestId silently no-ops.
|
||||
10. **R2-H6, R2-H7, R2-H8** — three permission UI-gate misses on
|
||||
bulk actions and the webhook-replay button. ~30 lines total.
|
||||
11. **R2-H10, R2-H12, R2-H13** — frontend swallowed errors + missing
|
||||
invalidation + alert() instead of toast. Small fixes, immediate UX
|
||||
win.
|
||||
12. **R2-H11** — `audit-log-card` `href="#"` mobile back-button trap.
|
||||
13. **R2-H14** — wire 6 missing email-subject overrides through their
|
||||
senders.
|
||||
|
||||
### Next sprint — HIGH/MEDIUM (operational + multi-tenant correctness)
|
||||
|
||||
14. **R2-H4** — wrap external-EOI in a transaction.
|
||||
15. **R2-H5** — bulk-archive idempotency key + treat already-archived as
|
||||
success in bulk.
|
||||
16. **R2-H9** — bulk hard-delete should return `skipped: string[]`.
|
||||
17. **R2-H15, R2-H16** — branding + reminder admin pages save settings
|
||||
nothing reads (silently broken multi-tenancy).
|
||||
18. **H2** — audit-page-view de-dupe (don't spam on every filter change).
|
||||
19. **H4** — synthetic seed needs documented dev-user setup or its own
|
||||
bootstrap script.
|
||||
20. **H5** — Documenso bad-secret rate-limit per IP.
|
||||
21. **R2-M1 through R2-M5** — portal memberships dead-end, company
|
||||
Documents stub, onboarding wizard, backup page, inquiry inbox triage.
|
||||
|
||||
### Backlog — MEDIUM/LOW + remaining items
|
||||
|
||||
22. The remaining MEDIUM/LOW from both rounds — see the dedicated docs.
|
||||
|
||||
---
|
||||
|
||||
## Headline numbers (combined)
|
||||
|
||||
- **3 CRITICAL** (worker imports, bulk-archive side-effects, restore-button hover)
|
||||
- **22 HIGH** (security + UX with concrete impact)
|
||||
- **~15 MEDIUM** (operational hygiene, multi-tenancy gaps, unfinished features)
|
||||
- **~10 LOW** (cleanup, defensive)
|
||||
|
||||
Round 1 was a manual synthesis after agent-pool stalls; Round 2 was
|
||||
four focused agents with hard finding caps that all completed inside
|
||||
the watchdog window. Every finding is grounded in code references.
|
||||
Reference in New Issue
Block a user