Files
pn-new-crm/docs/audit-comprehensive-2026-05-06.md
Matt a0e68eb060 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>
2026-05-07 20:57:53 +02:00

32 KiB

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:

// 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 pendingsuccess 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

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

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.

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:

Round 2 — CRITICAL

R2-C1. Bulk archive discards post-commit side effects (reliability C1)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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:trueoverride_stage permission unreachable from the most-used UI path (permissions M2)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  • 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)

  1. H1 — rate-limit the hard-delete-request endpoints.
  2. H3 — collapse "no code" vs "wrong code" into one error message.
  3. H7 — three "coming soon" stubs in client/berth tabs.
  4. R2-H1 — fix smart-restore's silent berth_released no-op (or reclassify as reversibleWithPrompt).
  5. R2-H2 — add for update lock on the smart-archive berth status flip (TOCTOU race).
  6. R2-H3 — bulk-archive's wrong-interest fallback — empty-string interestId silently no-ops.
  7. R2-H6, R2-H7, R2-H8 — three permission UI-gate misses on bulk actions and the webhook-replay button. ~30 lines total.
  8. R2-H10, R2-H12, R2-H13 — frontend swallowed errors + missing invalidation + alert() instead of toast. Small fixes, immediate UX win.
  9. R2-H11audit-log-card href="#" mobile back-button trap.
  10. R2-H14 — wire 6 missing email-subject overrides through their senders.

Next sprint — HIGH/MEDIUM (operational + multi-tenant correctness)

  1. R2-H4 — wrap external-EOI in a transaction.
  2. R2-H5 — bulk-archive idempotency key + treat already-archived as success in bulk.
  3. R2-H9 — bulk hard-delete should return skipped: string[].
  4. R2-H15, R2-H16 — branding + reminder admin pages save settings nothing reads (silently broken multi-tenancy).
  5. H2 — audit-page-view de-dupe (don't spam on every filter change).
  6. H4 — synthetic seed needs documented dev-user setup or its own bootstrap script.
  7. H5 — Documenso bad-secret rate-limit per IP.
  8. R2-M1 through R2-M5 — portal memberships dead-end, company Documents stub, onboarding wizard, backup page, inquiry inbox triage.

Backlog — MEDIUM/LOW + remaining items

  1. 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.