v0.3.1 — Security fix: credential leak in inquiry/follow-up endpoints
First real pilot-user feedback, triaged and fixed. Three critical bugs
(hit hourly by a working sales team), two "check before building" items
that turned out to be backend-complete, and a security leak found along
the way.
Security
- Credential leak, fixed: staff-facing inquiry and follow-up
endpoints returned password hashes, TOTP secrets, and recovery codes
to any authenticated staff user.GET /inquiries,GET /inquiries/:id, andGET /inquiries/:id/follow-upsembedded the
fullUserrow forassignedTo/createdBy— including
passwordHash,totpSecret, andrecoveryCodes— because a bare
Prismainclude: { relation: true }returns every scalar column on
the related model, not just the ones the UI displays. Any staff user
with read access to inquiries (not just admins) could see this data
in their browser's network tab on every list/detail/follow-up load.
Fixed by scoping both call sites (InquiryService.findAll/
findOne,FollowUpService.findAllForInquiry) to explicitselects
({id, name, email}/{id, name}), verified absent both in a
through-the-wire regression test and by inspecting raw response JSON
on a live deployed instance. If you are running v0.3.0 or earlier,
upgrade to v0.3.1 and rotate any staff password or TOTP secret you
consider exposed — anyone who had inquiry-read access and inspected
network traffic (or logs, if any client/proxy captured response
bodies) during that window could have captured these values.
Fixed
- A rep's own inquiry could silently reassign to admin.
InquiryService.create()
ran round-robin unconditionally whenever a project was set, with no
notion of who created the inquiry —Inquiryhad nocreatedById
column at all. New default policy: an interactively-created inquiry is
assigned straight to its creator; round-robin only runs for
machine-driven intake (inbound lead API, bulk import), which has no
human creator to retain ownership for. Configurable via
CompanyConfig.presalesCreatorRetainsLead(default on). - Editing a user, for any field, always failed.
UserForm.tsx's
edit-mode submit sent a create-shaped payload (includingemail) to a
.strict()update endpoint that never declared it — every save 400'd.
A deeper client-side bug sat in front of it: the form's validation
resolver was alwayscreateUserSchema, which requirespassword—
edit mode never renders that field, so submission failed silently at
the validation layer before a request was ever sent. Both fixed; a new
sharedpickForSchema()helper (projects onto an update schema's own
declared keys, rather than subtracting fields off a create-shaped
object) closes the class of bug, applied at all three known sites
(this one, Masters, Broker commission payment) plus a regression test
covering every create/update schema pair this package exports. - Direct URL access to an admin page rendered the full page shell for
any authenticated user, regardless of permission — the backend
correctly 403'd the underlying data fetch, but nothing distinguished
"no data yet" from "not allowed here." Every protected route now
renders an access-denied screen instead when the user lacks the
permission that gates it.
Added
- Bulk Excel inquiry import now has a staff UI. The backend
(row-level validation, applicant dedup,ExcelJS-based parsing) has
existed since Phase 3 with no caller inapps/web— upload UI, a
downloadable XLSX template (GET /inquiries/import-template, single
source of truth shared with the parser's own header map), and
per-row error reporting added to the Inquiries page. - Follow-up log now shows who logged it.
FollowUp.createdByIdhas
been captured since Phase 3 and was already being fetched — just never
rendered.