A fleet of automated testers drove the live app across every surface, each finding independently verified. After the developer's fix round, every finding was re-tested — status is shown on each card below.
Environment dev.ritualstudiospa.comFound 51 · re-checked after fixesCost $0 (local)
51
Findings
43
Fixed
8
Remaining
0
Critical left
Strong fix round. All 8 criticals (password-hash leak, $0-checkout, PII harvest, privilege escalation, garbage-time bookings) are fixed and re-verified. 43 of 51 resolved; 8 remain — all medium/low (data consistency + input hardening).
Front-desk employee (JWT role ADMIN) can create/delete roles and rewrite global comm-settings, despite the app's own Front Desk role granting only view/limited access and denying the less-sensitive staff roster
Priya's JWT carries role=ADMIN, so owner-level write endpoints that only check the JWT role accept her. She can create arbitrary permission roles, delete roles, and overwrite the studio-wide communication settings (member-app announcement shown to all members, SMS quiet hours, default email/SMS toggles). This is a WRITE/destructive escalation distinct from the known read-only #09 finding. Critically, the backend DOES enforce a finer per-user permission layer on some endpoints (GET /staff/team and GET /staff/onboarding return 403 'Manager or administrator access is required' for Priya) — so the more dangerous config/role-management endpoints are LESS protected than reading the staff list.
Impact
A front-desk staffer can silently rewrite the message every member sees on login, disable SMS quiet-hours for the whole business, and mint or delete permission roles — effectively granting themselves or others elevated access — none of which their assigned Front Desk role permits. The inconsistency (roster is guarded, role/config writes are not) shows the finer permission layer exists but is not applied to the highest-risk endpoints.
Fix
Enforce the same per-user permission check used by /staff/team and /staff/onboarding (accessLevel/accessRoles lookup, not just JWT role=ADMIN) on settings/roles (POST/PATCH/DELETE) and settings/comm-settings (PATCH). Role management and global comms should require an owner/manager permission, not merely an ADMIN JWT.
Original evidence
As Priya (front-desk): POST /settings/roles {"name":"QA Injected Role","permissions":[]} -> 201 {"id":"cmrzf37mk00f9ryg75nq5bxue","name":"QA Injected Role"...}. DELETE /settings/roles/cmrzf4fwz00h1ryg7mfx04tw2 -> 204 (create+delete both succeeded). PATCH /settings/comm-settings {"memberAppAnnouncement":"QA-PRIYA-FRONTDESK-WROTE","smsQuietHoursEnabled":false} -> 200 body reflects the write. CONTRAST: as same Priya token GET /staff/team -> 403 {"error":{"code":"FORBIDDEN","message":"Manager or administrator access is required."}} and GET /staff/onboarding -> 403 same message. The Front Desk role definition (GET /settings/roles) grants communications:'limited' and no role-management. (Manager Maya's token behaves the same on the writes: PATCH /settings/comm-settings -> 200.) All test artifacts were cleaned up (roles deleted, announcement/quiet-hours restored to 'Welcome to Ritual'/true).
HIGHbilling03✓ Fixed
Checkout trusts client-supplied price (displayPrice); a $0 checkout is auto-marked PAID and fulfilled, letting any member obtain plans/credits/loyalty for free
Where
POST https://dev.ritualstudiospa.com/api/v1/billing/checkout
What happens
The server uses the client-provided displayPrice as the invoice amount instead of re-deriving it from the planId. Any authenticated MEMBER can set an arbitrary price for any plan. A price of 0 (number or string) causes the invoice to be created with status PAID and the purchase to be fulfilled immediately with no card/payment — granting real session credits and loyalty points.
Impact
A member can acquire memberships, drop-in sessions and loyalty points for free (or any arbitrary price) directly via the API. This is direct revenue loss and payment-integrity failure. Non-zero tampered invoices stay PENDING (pay path requires a card), but the $0 path fully bypasses payment and self-fulfills.
Fix
Never accept price from the client. Compute invoice subtotal/total server-side from the planId (and applicable discounts/tax). Ignore any client displayPrice. Do not treat a $0 total as PAID+fulfilled for member-initiated plan purchases; require a real payment (or an admin/comp code) before fulfillment.
Original evidence
As member Bob (u_bob, JWT MEMBER): POST /billing/checkout with body {"planId":"plan_dropin","planName":"Drop-In","displayPrice":0} -> 200, invoice {"status":"PAID","totalAmount":0}. users/me before: credits=4, loyaltyPoints=1540; after: credits=5, loyaltyPoints=2140 (a real Drop-In session credit + 600 loyalty granted for $0). Price is client-controlled across ALL plans, not just drop-in: {"planId":"plan_complete","displayPrice":0.01} -> invoice totalAmount=0.01 for a plan whose real price is $249 (GET /pricing/plans shows plan_complete price=249). With NO displayPrice the server uses the correct price ({"planId":"plan_dropin"} -> totalAmount=60), proving the amount comes from the client, not the plan. String "0" also works: {"displayPrice":"0"} -> status PAID, totalAmount 0.
POST https://dev.ritualstudiospa.com/api/v1/bookings (member_sarah)
What happens
The `time` field has no format/range validation. Any string is accepted and stored, and endTime is computed by naive string math, yielding corrupt persisted bookings.
Impact
These CONFIRMED bookings consume real seats and will render on the staff calendar / occupancy with impossible times; an empty time silently creates a NaN:NaN booking. Corrupts core scheduling data with a single unauthenticated-shape request from an ordinary member.
Fix
Validate `time` against ^([01]\d|2[0-3]):([0-5]\d)$ and reject anything that is not a real HH:MM before creating the booking; compute endTime from parsed minutes.
Original evidence
time="" -> HTTP 201, stored time:"", endTime:"NaN:NaN" (id cmrzf0yru008nryg7... family). time="99:99" -> 201, endTime:"102:09". time="25:00" -> 201, endTime:"26:30". time="-1:00" -> 201, endTime:"00:30". time="23:30" -> 201, endTime:"25:00" (crosses midnight, no wrap). All returned status CONFIRMED.
HIGHdashboard05✓ Fixed
Calendar endpoint leaks every booked member's bcrypt passwordHash + full PII
Where
GET /api/v1/bookings/location/loc_primary?date=YYYY-MM-DD (owner Calendar view data source)
What happens
Each booking object embeds a full `user` record that includes the member's bcrypt password hash and sensitive account fields, none of which the calendar needs.
Impact
Exposing bcrypt hashes lets any admin-scoped user (including front-desk staff, per issue #09) exfiltrate every active member's password hash for offline cracking, plus Stripe customer IDs, MFA/lock state, and full contact/address PII. Password hashes must never be serialized to any API response.
Fix
Strip passwordHash (and stripeCustomerId, mfa/lock internals) from the user object serialized in bookings/location; return only name/tier/id needed for the calendar.
Original evidence
GET /api/v1/bookings/location/loc_primary?date=2026-07-24 -> HTTP 200. All 9 booking objects contain user.passwordHash. Leaked user keys include: id,email,passwordHash,name,role,membershipTier,phone,address,addressLine1,city,state,postalCode,credits,loyaltyPoints,stripeCustomerId,stripeSubscriptionId,emailVerifiedAt,phoneVerifiedAt,pendingEmail,pendingPhone,isLocked,lockReason,lastLoginAt,mfaRequired,mfaResetRequestedAt. Sample: Harper Kim / sim50.11@example.com / passwordHash "$2a$10$AcMdYxQztAj...". 6 distinct members' hashes exposed in a single day's response. Reachable by any ADMIN-scoped token including the front-desk Employee token (Priya): her GET of the same URL also returns passwordHash (grep count 1+).
HIGHhr06✓ Fixed
Privilege escalation: a MANAGER can promote any staff member (or themselves) to OWNER via the access endpoint
Maya's app-level access is only 'Manager', yet her token can PATCH another staff member's access record to accessLevel 'Owner' with the full owner role set. She can also do it to her own profile (staff_maya). The endpoint only checks that the caller is 'Manager or administrator' — it does NOT prevent a manager from granting a level higher than their own, so a manager can mint full owners and self-promote. (For contrast, the PTO-approval and access endpoints correctly 403 a MEMBER token and the front-desk Employee-access token, so app-level RBAC exists elsewhere — it is simply missing here.)
Impact
Owner is the top RBAC tier the app enforces server-side (e.g. it gates PTO approval). Any manager can therefore grant themselves or an accomplice full owner/HR-Admin control over the whole studio.
Fix
Server-side, cap the accessLevel/accessRoles a caller may assign to at or below their own tier, and forbid self-modification of one's own access level.
Original evidence
PATCH /staff/profiles/staff_zoe/access as Maya (Manager) with {"accessLevel":"Owner","accessRoles":["Owner","Studio Admin","HR Admin","Manager"]} -> 200, GET back confirms "accessLevel":"Owner","accessRoles":["Owner","Studio Admin","HR Admin","Manager"]. Self-escalation: PATCH /staff/profiles/staff_maya/access {"accessLevel":"Owner",...} -> 200 with "accessLevel":"Owner". Both reverted to Employee/Manager afterward. Audit log recorded action 'staff_access_updated' actorUserId u_staff_maya.
HIGHhr07✓ Fixed
A MANAGER can suspend/lock out the OWNER (and any admin) account
Where
POST https://dev.ritualstudiospa.com/api/v1/staff/profiles/:id/suspend
What happens
Maya (accessLevel Manager) can suspend the Owner's staff profile. The suspend flips the linked login user's isLocked to true with an attacker-supplied lockReason, which blocks that account. There is no check that the target's privilege is at or below the caller's.
Impact
A lower-privileged manager can lock the owner and every admin out of the system, then (combined with finding 1) hold sole access — a full account-lockout / takeover path.
Fix
Require the caller's tier to strictly exceed the target's for suspend/reactivate/delete; never allow suspending an Owner from a Manager token.
Original evidence
POST /staff/profiles/staff_qa_owner/suspend as Maya with {"reason":"QA test"} -> 200; GET /staff/profiles/staff_qa_owner/access then shows "isLocked":true,"lockReason":"QA test". Reactivated via POST /staff/profiles/staff_qa_owner/reactivate -> 200 (isLocked back to false). Same suspend->200->isLocked:true reproduced on staff_zoe and reverted. Also: DELETE /staff/profiles/:id is manager-accessible (deleted two manager-created test profiles -> 204), giving managers destructive control over profiles.
HIGHmessaging04✓ Fixed
Member can harvest any user's email + phone + role via POST /messages recipientId (broken object-level authorization / PII enumeration)
Where
POST https://dev.ritualstudiospa.com/api/v1/messages (Alice, MEMBER token)
What happens
A low-privilege MEMBER supplies an arbitrary recipientId in the send-message body. The 201 response echoes back a full `recipient` object containing that user's name, email, phone and role. User IDs are guessable (u_alice, u_bob, u_sarah, u_emma, u_staff_maya, u_admin), so a member can enumerate the contact PII of every other member AND of staff/admins.
Impact
Any member (or anyone who registers) can scrape the full contact directory - names, emails, phone numbers - of every user and staff member, a serious privacy/data-protection exposure and a ready-made spam/phishing target list.
Fix
Authorize/whitelist allowable recipients for MEMBER role (e.g. only staff/support), and strip the recipient PII (email/phone) from the send-message response for non-privileged callers.
Original evidence
As Alice (MEMBER): POST /api/v1/messages {"recipientId":"u_sarah",...} -> 201, recipient={"id":"u_sarah","name":"Sarah Jenkins","email":"sarah@example.com","phone":"253-555-0103","role":"MEMBER"}. {"recipientId":"u_emma"} -> recipient email emma@example.com phone 253-555-0109. {"recipientId":"u_staff_maya"} -> recipient={"name":"Maya Torres","email":"maya@ritualstudiospa.com","phone":"253-555-0201","role":"ADMIN"}. A member should never receive other members'/staff email+phone.
HIGHvalidation08✓ Fixed
Booking `time` field is completely unvalidated — accepts arbitrary strings, corrupting core booking records on the shared calendar
Where
POST /api/v1/bookings (time field)
What happens
The `time` field accepts any string with no format check. The booking is created (201) and the record is stored and returned on the staff/admin calendar feed (GET /bookings/location/loc_primary). endTime is computed off the garbage value, producing nonsense like '102:09'.
Impact
A core-flow record (a booking) is created with invalid time data that flows onto the shared staff/admin calendar. It both breaks scheduling data integrity and injects raw unsanitized HTML/script strings into the calendar view. A clean 400 is expected for an unparseable time.
Fix
Validate `time` against a strict HH:MM 24h regex/enum of valid slot times in the Zod schema before creating the booking; reject with 400 otherwise.
Original evidence
POST /api/v1/bookings {"locationId":"loc_primary","serviceId":"svc_contrast","date":"2026-08-01","time":"99:99"} -> HTTP 201, body time:"99:99", endTime:"102:09". Also time:"abc" -> 201 (id cmrzf1xf...), time:"-5:00" -> 201, time:"<script>alert(1)</script>" -> 201 (id cmrzf1xld...). All stored verbatim as the booking time.
Should fix 356 still open
MEDdashboard23Open
Dashboard 'Today' occupancy is a flat zero while the same data says the studio is 58% full with 2 members checked in
Where
GET /api/v1/reports/overview?range=today (Owner Dashboard Live Occupancy KPI + hourly occupancy chart)
What happens
For range=today the endpoint returns liveOccupancy:0 and an occupancySeries where every hour (7AM-9PM) is 0, contradicting the real occupancy and the endpoint's own 7-day view.
Impact
The owner's headline 'Live Occupancy' KPI and today's occupancy chart read empty/idle when 6 sessions are booked and 2 people are physically in a room, making the primary real-time dashboard metric untrustworthy. This is an API-level aggregation defect reproducible via curl (not a UI refresh timing issue).
Fix
Fix the range=today branch of reports/overview to compute liveOccupancy from checkedInAt/checkedOutAt and populate the hourly series from today's bookings, consistent with the bookings/occupancy endpoint and the 7d series.
Re-check
Still reproduces (read-only; no writes made). Note: server clock advanced to 2026-07-26, so "today" is now Jul 26 (not the original Jul 24). GET /api/v1/reports/overview?range=today -> HTTP 200, metrics.liveOccupancy:0, metrics.bookings:3, and occupancySeries STILL flat zero every hour: [7AM:0,9AM:0,11AM:0,1PM:0,3PM:0,5PM:0,7PM:0,9PM:0] (identical on two consecutive reads). Same endpoint, GET /reports/overview?range=7d -> HTTP 200 with occupancySeries [Jul20:42,Jul21:42,Jul22:25,Jul23:42,Jul24:58,Jul25:75,Jul26:25] i.e. the SAME day (Jul 26) reported at 25% while the today-view reports 0% for every hour. Today genuinely has bookings (metrics.bookings:3; GET /bookings/occupanc
Original evidence
GET /reports/overview?range=today -> metrics.liveOccupancy:0 and occupancySeries=[7AM:0,9AM:0,11AM:0,1PM:0,3PM:0,5PM:0,7PM:0,9PM:0]. But GET /bookings/occupancy/loc_primary?date=2026-07-24 -> liveOccupancy:2, and the raw calendar shows 2 bookings currently checked in with no checkout (Alice Walker 09:30 checkedInAt=2026-07-24T16:14:58Z, Sarah Jenkins 11:30 checkedInAt=2026-07-24T18:20:53Z). Meanwhile GET /reports/overview?range=7d reports the SAME day (Jul 24) at occupancy:58. So within one endpoint: today=0% but 7d-view-of-today=58%.
MEDcrm17Partial
Contact name & phone accept unsanitized HTML/script and garbage via PATCH (no server-side input validation)
Where
PATCH /api/v1/contacts/{id} (owner/ADMIN token)
What happens
The API stores arbitrary strings in `name` and `phone` with no validation or sanitization. `email`, `status`, and `membershipTier` ARE validated (return 400 on bad input), but `name` and `phone` are not. Malicious/garbage values are persisted raw and returned by GET as the contact's display name used across the app.
Impact
Names are displayed everywhere and pushed to external systems (GHL, likely emails/CSV exports) that may not escape like React does, making this a latent stored-XSS and a data-integrity problem. No length or format limits on name/phone lets any admin/staff (all staff JWTs are ADMIN) silently corrupt member records.
Fix
Server-side validate/sanitize name (strip HTML, cap length) and phone (E.164/format check) in the PATCH /contacts schema, mirroring the enum validation already applied to status/membershipTier.
Re-check
Re-ran exact repro on live dev, contact cmml74rzr000oryideyf28ltg (Emma Liu), owner_admin ADMIN token. SPLIT RESULT across the two fields the bug covered: (1) phone NOW FIXED — PATCH {"phone":"garbage!!!"} -> HTTP 400 {"error":{"code":"VALIDATION_ERROR","message":"Validation failed","details":[{"path":"phone","message":"Phone must be a valid US phone number."}]}} (previously 200+stored raw). (2) name STILL OPEN — PATCH {"name":"<img src=x onerror=\"window.__XSSFIRED=(window.__XSSFIRED||0)+1\">ZZQA"} -> HTTP 200, and GET returns it v
Original evidence
PATCH /api/v1/contacts/cmml74rzr000oryideyf28ltg with {"name":"<img src=x onerror=\"window.__XSSFIRED=...\">ZZQA"} -> 200; subsequent GET returns name verbatim: "name":"<img src=x onerror=\"window.__XSSFIRED=(window.__XSSFIRED||0)+1\">ZZQA". PATCH {"phone":"garbage!!!"} -> 200 (stored). By contrast {"email":"notanemail"} -> 400, {"status":"BANANA"} -> 400, {"membershipTier":"UNOBTANIUM"} -> 400. Seed member Alice was already found corrupted by earlier writes (name="<script>alert(1)</script>", phone="abcnotaphone!!!"). Playwright render of ?view=crm-members: window.__XSSFIRED=0, injectedImgCount=0, body innerText contains literal "<img" — the React view HTML-escapes it (no JS execution observed), but the raw payload persists in the API/DB and is synced app-wide (each contact has a ghlContactId -> GoHighLevel).
MEDemployee29Partial
PTO request endpoint performs no date-range validation (accepts end-before-start, past dates, and multi-year ranges)
Where
POST https://dev.ritualstudiospa.com/api/v1/staff/me/pto (as employee Priya)
What happens
The PTO create endpoint accepts logically invalid date ranges with HTTP 201 and stores them as PENDING requests. There is no check that endDate >= startDate, no check that dates are in the future, and no maximum span.
Impact
Employees can submit negative-duration, historical, or absurdly long leave that flows into the approval queue and downstream PTO-balance/scheduling logic, producing garbage data and possible incorrect leave accounting.
Fix
Add server-side validation: endDate >= startDate, startDate not in the past, and a sane maximum span; return 400 VALIDATION_ERROR like the other field checks.
Re-check
BUG #29 is PARTIALLY fixed. Re-ran the exact repro as employee Priya on POST https://dev.ritualstudiospa.com/api/v1/staff/me/pto.
FIXED sub-case (end-before-start): POST {"type":"VACATION","startDate":"2026-09-20","endDate":"2026-09-10"} -> HTTP 400 {"error":{"code":"VALIDATION_ERROR","message":"Validation failed","details":[{"path":"endDate","message":"End date must be on or after start date"}]}}. Confirmed again with a non-overlapping window 2026-08-05->2026-08-03 -> same 400. So the endDate>=startDate rule now exists.
Original evidence
POST /staff/me/pto {"type":"VACATION","startDate":"2026-09-20","endDate":"2026-09-10"} -> HTTP 201, stored with endDate before startDate (id cmrzf0des0070ryg7xqvtpx62). POST with startDate 2020-01-01/endDate 2020-01-03 -> HTTP 201 (past dates, id cmrzf0dkb0072ryg76u98fczo). POST with startDate 2026-10-01/endDate 2031-10-01 (5-year range) -> HTTP 201 (id cmrzf0dpy0074ryg7euo699ii). By contrast empty/missing fields and a bad enum type ARE rejected with 400, so validation exists but omits date-range rules.
MEDhr33Partial
Roster and create-employee responses leak a live password-reset link for other staff
Where
GET https://dev.ritualstudiospa.com/api/v1/staff/team and POST https://dev.ritualstudiospa.com/api/v1/staff/profiles
What happens
Each invited staff row includes lastInviteLink = 'https://dev.ritualstudiospa.com?resetPassword=<JWT>'. The JWT base64 payload decodes to {"userId":"...","challengeId":"...","purpose":"password_reset",...} with a future exp — i.e. the exact token the /auth/reset-password endpoint consumes to set a new password. It is returned to any Manager+ reading the roster, and persists after the invite is sent.
Impact
Anyone who can read the roster can obtain the setup/reset link for another employee (including pending owner/admin invites) and set that account's password before the real user does.
Fix
Do not return lastInviteLink in list/collection responses; expose it only transiently to the creator at invite time, and clear/rotate it after use.
Re-check
Re-ran exact reproduction on live dev app. ROSTER SURFACE FIXED: GET /api/v1/staff/team (Maya/manager) -> HTTP 200, response has NO lastInviteLink, NO setupLink, NO resetPassword, and ZERO eyJ JWT strings anywhere -- even for a freshly-invited, un-accepted staff row (confirmed with a just-created hire that appeared in the roster with no link). The prior cross-user leak (live reset tokens for other/existing staff readable by any Manager+ on a plain GET, persisting after invite) no longer reproduces. CREATE SURFACE STILL LEAKS: POST /api/v1/staff/profiles (Maya, HTTP 201) no longer has lastInviteLink but now returns a top-level field renamed setupLink = "https://dev.ritualstudiospa.com
Original evidence
GET /staff/team (Maya) returns rows with "lastInviteLink":"https://dev.ritualstudiospa.com?resetPassword=eyJ..." for Alex Rivera, QA New Hire, Vidya Autosterea; decoded payloads = purpose:password_reset, exp 1785269564 etc (unexpired). POST /staff/profiles (create) returns the same field for the new hire. Full end-to-end takeover (POST /auth/reset-password {token,password}) could not be completed only because the shared auth rate-limiter returned 429 at test time; the token itself is a valid, unexpired reset grant.
The endpoint persists any string for accessLevel and any array for accessRoles with no allow-list check, producing nonsensical/undefined privilege states.
Impact
Unvalidated privilege strings can create accounts in undefined states and defeat any allow-list-based UI/authorization assumptions elsewhere.
Fix
Validate accessLevel against the known enum (Employee/Manager/Owner) and accessRoles against the defined role set; reject unknown values.
Re-check
Re-ran EXACT reproduction on live dev app as owner_admin against PATCH /api/v1/staff/profiles/staff_zoe/access.
(1) Exact original payload {"accessLevel":"Deity","accessRoles":["SUPERGOD","root"]} now returns HTTP 400: {"error":{"code":"VALIDATION_ERROR","message":"Validation failed","details":[{"path":"accessLevel","message":"Invalid enum value. Expected 'Employee' | 'Manager' | 'Owner', received 'Deity'"}]}}. Nothing persisted — GET /access still showed accessLevel:"Employee", accessRoles:["Employee"]. So the accessLevel
Original evidence
PATCH /staff/profiles/staff_zoe/access {"accessLevel":"Deity","accessRoles":["SUPERGOD","root"]} -> 200; GET back returns "accessLevel":"Deity","accessRoles":["SUPERGOD","root"]. Reverted to Employee afterward. (Contrast: POST /staff/profiles validates required fields, returning VALIDATION_ERROR for missing phone/hireDate — so validation exists but is absent on the access route.)
MEDmessaging22Partial
Non-existent recipientId returns HTTP 500 Internal Server Error instead of validation error
Where
POST https://dev.ritualstudiospa.com/api/v1/messages
What happens
Posting a message with a recipientId that does not correspond to any user throws an unhandled server error (500) rather than a 400/404. Missing recipient existence check reaches the DB/logic layer and crashes the request.
Impact
Unhandled 500s indicate a missing existence/validation guard on recipient; they clutter monitoring, can leak stack context, and reflect fragile write-path handling for a core messaging action.
Fix
Validate recipientId exists before persisting; return 404/400 with a clear code. Add an explicit max body length returning 413/400 rather than 500.
Re-check
Re-ran the exact reproduction on live dev app.
PART 1 (core bug — non-existent recipientId): As Alice (MEMBER), POST https://dev.ritualstudiospa.com/api/v1/messages {"recipientId":"u_doesnotexist_zzz","subject":"ghost","body":"to nobody"} now returns HTTP 201 (previously 500). Response: message created with "recipientId":null, mailbox "SUPPORT", parentId set. So the 500 crash is GONE — but it still does NOT return the 400/404 validation error the bug asked for; it silently accepts the bogus recipient and drops it to null/SUPPORT rather than rejecting.
PART 2 (second evidence item — oversized body): POST with a
Original evidence
As Alice (MEMBER): POST /api/v1/messages {"recipientId":"u_doesnotexist_zzz","subject":"ghost","body":"to nobody"} -> HTTP 500 {"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}}. (A ~500k-char body also returns 500 with no graceful 413.)
MEDauth39✓ Fixed
Access token remains fully valid after logout (no server-side access-token revocation)
Where
POST /api/v1/auth/logout ; GET /api/v1/users/me
What happens
Calling logout revokes the refresh token but does NOT invalidate the already-issued access token. The access token continues to authenticate protected requests until its own 15-minute expiry. On a shared/public computer, or if a token is captured, 'logging out' does not actually end the active session.
Impact
Logout gives the user a false sense of session termination. A stolen/observed access token or a session on a shared device remains usable for up to 15 minutes after the user explicitly logs out. A 'log out'/'log out all devices' action should invalidate active access tokens (e.g., short TTL is mitigation but not a substitute for a server-side revocation/denylist check).
Fix
On logout, add the access token's jti (or a per-session/token-version) to a short-lived denylist checked by the auth middleware, or bind access tokens to the refresh-token session so that revoking the session also rejects its access tokens.
Original evidence
Sequence on Alice's session — (A) GET /users/me with access token => 200. (B) POST /auth/logout with Authorization: Bearer <access> and refreshToken in body => 200 {"message":"Logged out"}. (C) GET /users/me REUSING the same access token AFTER logout => 200 (full profile returned). (D) POST /auth/refresh with the same refresh token AFTER logout => 401 {"code":"UNAUTHORIZED","message":"Refresh token expired or revoked"}. So refresh is correctly revoked but the access token is not — it stays usable for up to the ~15-min TTL.
MEDauth40✓ Fixed
Login rate limiter blocks legitimate correct-credential logins from the whole IP, and stays tripped for many minutes
Where
POST /api/v1/auth/login (and shared across /auth/register, /auth/forgot-password, /auth/reset-password)
What happens
Once the per-IP auth rate limit is tripped, ALL subsequent login attempts from that IP return 429 — including logins with the correct email+password for accounts that were never targeted. The block persists for 5+ minutes and continued attempts keep it tripped. The limiter is shared across the register/forgot-password/reset-password endpoints too.
Impact
On shared/NAT'd IPs (the spa's own front-desk network, corporate offices, families, cafes), a handful of failed attempts by one person locks out every other legitimate user — including those entering correct credentials — for many minutes. This is an availability/denial-of-service and support burden. Rate limiting itself is good, but keying purely on IP with a long block that also rejects successful logins is too broad.
Fix
Scope the limiter per-account (or account+IP) rather than pure IP, count only FAILED attempts (allow a request through once credentials are valid), cap the lockout duration, and use a distinct limiter/window for read-only auth endpoints so a login burst doesn't also block password resets.
Original evidence
After a series of auth probes from one IP: POST /auth/login {"email":"emma@example.com","password":"password123"} (a valid credential) => 429 {"code":"RATE_LIMITED","message":"Too many attempts, try again later"}. Retried after sleeping 100s => still 429; retried after 300s => still 429. Meanwhile POST /auth/refresh (separate limiter) responded normally (401 for a junk token, not 429), confirming it is specifically the login/auth limiter that stays blocked. Register with an existing email and forgot-password also returned 429 in the same window, showing a shared limiter.
MEDauthz09✓ Fixed
Any MEMBER can read the studio's global communication settings
Where
GET /api/v1/settings/comm-settings (member token = Alice Walker, role MEMBER)
What happens
A logged-in member can read the full global comm-settings object, which contains internal operational config (SMS quiet-hour window, default email-receipt and SMS-reminder toggles) in addition to the member-facing announcement. Writes are correctly blocked (member PATCH -> 403), but the read is not gated.
Impact
Business operational configuration is exposed to every member. Members should only receive the member-facing announcement fields, not the internal messaging-policy config.
Fix
Either restrict GET /settings/comm-settings to ADMIN/manager, or split it: expose only memberAppAnnouncement/memberAppAnnouncementEnabled to MEMBER role and keep quiet-hours/default-toggles admin-only.
Original evidence
As Alice (MEMBER): GET /settings/comm-settings -> 200 {"id":"global","smsQuietHoursEnabled":true,"quietStart":"21:00","quietEnd":"07:00","defaultEmailReceipts":true,"defaultSmsReminders":true,"memberAppAnnouncement":"Welcome to Ritual","memberAppAnnouncementEnabled":true,...}. For comparison the same token is correctly blocked on GET /contacts, /staff/team, /settings/roles, /sales, /knowledge (all 403 FORBIDDEN) and on PATCH /settings/comm-settings (403).
MEDbilling16✓ Fixed
Membership-change preview returns past-dated effective and lock dates, presented to the user as future dates
Where
POST https://dev.ritualstudiospa.com/api/v1/billing/membership-change/preview
What happens
The proration/scheduling math derives the effective date from a stale current billing cycle, producing takesEffectOn / locksUntil dates that are in the past relative to today (2026-07-24), while the human-readable message states them as future events.
Impact
Downgrades scheduled for a past effective date could either never apply or apply immediately, and the minimum-term lock (locksUntil in the past) would not actually block changes. Users see nonsensical/misleading dates and prorated charges (immediateCharge=60) computed off a stale cycle.
Fix
Recompute the current billing cycle from now() (roll forward past cycle boundaries) before deriving takesEffectOn / locksUntil / nextBillingDate, and validate that scheduled effective dates are always >= today.
Original evidence
As Alice (complete): {"planId":"plan_essential"} -> {"scheduled":true,"takesEffectOn":"2026-06-27","message":"This downgrade will take effect on June 27, 2026..."} (2026-06-27 is ~1 month in the past). As Bob (essential): {"planId":"plan_complete"} -> {"direction":"upgrade","immediateCharge":60,"takesEffectOn":"2026-05-10","locksUntil":"2026-06-07","message":"...the new recurring rate begins on May 10, 2026."} — both takesEffectOn and locksUntil are past dates.
MEDbooking10✓ Fixed
Bookings accepted outside the service's slot windows and business hours
Where
POST /api/v1/bookings (member_sarah)
What happens
svc_contrast defines slotWindows (weekday 07:00-12:00 & 15:00-20:00, weekend 09:00-17:00), slotInterval 30, and location business hours 07:00-20:00. None are enforced on booking creation.
Impact
Members can book sessions when the studio is closed or off the 30-min grid, creating shifts/occupancy the room can't service.
Fix
Only allow times that fall on a valid slotWindow boundary/interval for that weekday and whose session fits within business hours.
Same member can double-book (and overlap) the same time slot
Where
POST /api/v1/bookings (member_sarah)
What happens
A member with an active membership can create multiple CONFIRMED bookings for the identical date/time, and overlapping sessions, with no duplicate/overlap check.
Impact
One person consumes 2 of the room's 12 seats for the same slot and holds overlapping sessions they cannot attend, wasting capacity and skewing occupancy.
Fix
Reject a new booking when the member already has a non-cancelled booking for the same slot or an overlapping time range.
Original evidence
Two POSTs date=2026-07-25 time=10:00 both -> 201 CONFIRMED (ids cmrzf0jsr0076ryg7... and cmrzf0q1j0084ryg7...). Then time=10:30 (overlaps the 10:00-11:30 booking) -> 201 CONFIRMED endTime 12:00.
Invalid locationId and malformed date strings are not validated before use and throw, returning a 500 instead of a 4xx.
Impact
Unhandled exceptions on user-controlled input are a reliability/DoS surface and leak an internal-error path; the location path is inconsistent with the properly-handled service path.
Fix
Validate locationId existence (404) and date format/validity (400) before downstream use; wrap in the standard validation layer.
Null byte in contacts search returns HTTP 500 Internal Server Error
Where
GET /api/v1/contacts?search=%00
What happens
A null byte (and likely other raw bytes) in the search parameter is passed to the datastore unhandled, crashing the query with a generic 500 instead of returning 400 or an empty result set.
Impact
Unhandled input reaching the DB layer indicates missing input sanitization on the search path and produces server errors an attacker can trigger at will.
Fix
Strip/reject null bytes and validate the search string before querying; return 400 or empty results instead of 500.
Original evidence
GET /api/v1/contacts?search=%00 -> 500 {"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}} (reproduced twice). Normal search values work: ?search=alice -> 200, ?search=' OR 1=1-- -> 200 with empty data (no SQL injection).
MEDcrm19✓ Fixed
Invalid status filter value returns HTTP 500 instead of 400
Where
GET /api/v1/contacts?status={invalid}
What happens
The `status` filter value is passed straight to the query without enum validation; an unknown value crashes the request with a 500. Valid enum values work correctly.
Impact
Inconsistent validation; a malformed filter (e.g. from a stale UI option) errors the whole members list instead of degrading gracefully.
Fix
Validate the status query param against the ACTIVE|CANCELLED|FROZEN|PAST_DUE|VIP enum and return 400 on mismatch.
Original evidence
GET /api/v1/contacts?status=INVALID -> 500 {"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}}. GET /api/v1/contacts?status=ACTIVE -> 200 total:72 (works). Note PATCH validates the same enum (status=BANANA -> 400) but the list filter does not.
MEDcrm20✓ Fixed
membershipTier filter is silently ignored (returns all members regardless)
Where
GET /api/v1/contacts?membershipTier={value}
What happens
The membershipTier query filter has no effect: it returns the full directory regardless of the requested tier, so filtering members by tier in the CRM shows wrong (unfiltered) results and counts.
Impact
Staff filtering the member directory by membership tier get incorrect lists and counts, undermining segmentation/reporting.
Fix
Wire the membershipTier param into the query WHERE clause (and validate it against NONE|DROP_IN|SILVER|GOLD).
Original evidence
GET /contacts?membershipTier=PLATINUM -> total:88 (includes SILVER/GOLD members e.g. Amara James=SILVER). GET /contacts?membershipTier=GOLD -> total:88. GET /contacts (no filter) -> total:88. GET /contacts?membershipTier=INVALID -> 200 total:88. By contrast status and tag filters DO work: status=ACTIVE -> 72, tag=family -> 1, tag=zzznonexist -> 0.
MEDdashboard24✓ Fixed
Finance 'Revenue by Category' omits ~45% of paid revenue (breakdown does not reconcile to Total Paid)
Where
GET /api/v1/reports/finance (Owner Sales/Finance report — Revenue by Category panel)
What happens
revenueByCategory only recognizes two categories (DROP_IN, MEMBERSHIP); all other revenue kinds are silently dropped, so the category totals sum to far less than totalPaid.
Impact
An owner reading 'Revenue by Category' sees $9,280 categorized against $17,007 paid, so nearly half of real revenue (gift cards, plan purchases, POS, manual invoices, renewals) is invisible in the breakdown, distorting any category-based business decision.
Fix
Map every invoice kind (plan_purchase, gift_purchase, admin_pos, membership_renewal/change, manual, guest_booking) into revenueByCategory, or add an 'Other/Uncategorized' bucket so the category totals reconcile to totalPaid.
Original evidence
GET /reports/finance -> totalPaid:17007, revenueByCategory=[{DROP_IN:1020},{MEMBERSHIP:8260}] which sums to 9280 — a 7727 (45%) shortfall vs totalPaid. GET /reports/revenue?days=365 (110 invoices) shows the missing revenue kinds the breakdown ignores: manual 4350.5, plan_purchase 2822, gift_purchase 757.5, membership_renewal 378, admin_pos 275, guest_booking 54, membership_change 50 (only 'membership' 8260 survives into the finance panel).
MEDemployee30✓ Fixed
PTO endpoint does not detect overlapping requests
Where
POST https://dev.ritualstudiospa.com/api/v1/staff/me/pto
What happens
A new PTO request that overlaps an existing APPROVED leave for the same staff member is accepted rather than rejected or flagged.
Impact
Double-booked/overlapping leave corrupts scheduling and PTO accounting, and lets staff stack duplicate requests for the same days.
Fix
On create, check for date overlap against the staff member's existing PENDING/APPROVED requests and reject or flag as a conflict.
Original evidence
Priya already has APPROVED VACATION 2026-08-10..2026-08-12 (id cmrzef35e0057ryg706ablpa6). POST /staff/me/pto {"startDate":"2026-08-11","endDate":"2026-08-11"} -> HTTP 201, PENDING (id cmrzf0myv007gryg7m9ju33z2), overlapping the approved block with no warning or error.
MEDemployee31✓ Fixed
Malformed date string in PTO request causes HTTP 500 Internal Server Error
Where
POST https://dev.ritualstudiospa.com/api/v1/staff/me/pto
What happens
Submitting a non-parseable date string throws an unhandled server error (500) instead of a 400 validation response.
Impact
An unhandled exception on user input indicates the date is parsed before validation; it is a poor client experience and a reliability/observability concern (500s pollute error monitoring and can mask real faults).
Fix
Validate date format (e.g. zod date/ISO string) before parsing so bad input yields a 400 VALIDATION_ERROR, not a 500.
Original evidence
POST /staff/me/pto {"type":"VACATION","startDate":"not-a-date","endDate":"also-bad","reason":"QA garbage"} -> HTTP 500 {"error":{"code":"INTERNAL_ERROR","message":"Internal server error"}}. Well-formed-but-missing fields correctly return 400, so only the malformed-date path is unhandled.
MEDemployee32✓ Fixed
All 21 onboarding tasks are born OVERDUE because due dates are anchored to hireDate, not assignment date
Where
GET https://dev.ritualstudiospa.com/api/v1/staff/me/onboarding (employee Priya)
What happens
Priya's onboarding plan was assigned on 2026-07-18 but every task's dueDate is computed from her hireDate (2025-09-01), landing 2025-09-01..2025-09-15 — roughly 10 months in the past. As a result all 21 items are OVERDUE the moment they are created, and the summary shows 0 completed / 21 overdue / 0% progress.
Impact
Confirmed real bug (not cosmetic): any staff whose onboarding plan is (re)assigned well after their hire date sees a 100%-overdue checklist on day one, making the onboarding dashboard, progress %, and overdue alerts meaningless.
Fix
Compute onboarding due dates relative to the assignment/plan-start date (or max(hireDate, assignedAt)) rather than the raw hireDate, so freshly assigned tasks start with future due dates.
Original evidence
Response summary: {"total":21,"completed":0,"overdue":21,"progress":0}. Every item has status OVERDUE with dueDate between 2025-09-01 and 2025-09-15 while createdAt is 2026-07-18T16:49:02Z and history shows the only event is {"actor":"system","event":"assigned"} on 2026-07-18. hireDate on staff/me is 2025-09-01. Today is 2026-07-24.
MEDmarketing25✓ Fixed
Campaign send has no guardrails: instant SENT + unlimited re-send (no double-send / idempotency protection)
Where
POST https://dev.ritualstudiospa.com/api/v1/campaigns/:id/send
What happens
Posting an empty body to /send flips a DRAFT campaign to SENT immediately with no confirmation step, and re-posting /send on a campaign that is ALREADY 'SENT' still returns 200 and re-processes it (updatedAt advances each time). There is no idempotency lock or 'already sent' rejection.
Impact
If/when a real delivery pipeline is enabled, an accidental or repeated POST /send re-blasts the entire audience ('All Members') with duplicate messages. No confirmation and no idempotency means one stray retry = a second mass send.
Fix
Reject /send when status is already SENT (409), require an explicit confirmation flag, and make send idempotent per campaign.
Original evidence
Created campaign cmrzezzpd006sryg74nomt4mg (status DRAFT). POST /campaigns/cmrzezzpd006sryg74nomt4mg/send -> 200, status:"SENT", updatedAt 20:50:50. POST the SAME /send again -> 200, still status:"SENT", updatedAt advanced to 20:51:25. Also POST /campaigns/devdemo_campaign_1/send (a seed campaign already SENT) -> 200 with no rejection.
MEDmarketing26✓ Fixed
Campaign performance stats are client-writable and persist (fabricated analytics)
Where
POST https://dev.ritualstudiospa.com/api/v1/campaigns (stats field)
What happens
The create endpoint accepts a caller-supplied 'stats' object verbatim and persists it. These stats feed the marketing/analytics reporting (opens/clicks/revenue/sent).
Impact
Any admin/staff client can fabricate campaign performance numbers (sent counts, revenue, conversions), corrupting the marketing dashboard and any decisions or ROI reporting built on it.
Fix
Strip 'stats' from create/update payloads; compute stats only server-side from actual delivery/engagement events.
Original evidence
POST /campaigns with body {"name":"QA stats 0724",...,"stats":{"sent":999999},...} -> 201. GET /campaigns/cmrzf3ik800fdryg78bv8r75k returned "stats":{"sent":999999} — the injected value persisted, not server-computed.
MEDmarketing27✓ Fixed
Campaign with empty subject and empty body can be sent (no content validation before send)
Where
POST /api/v1/campaigns then POST /api/v1/campaigns/:id/send
What happens
A campaign whose email channel has subject:"" and body:"" is created and then successfully 'sent' with no validation that there is anything to send.
Impact
An empty-subject/empty-body blast to all members would go out as a blank email if delivery were live — an embarrassing, unguarded send.
Fix
Validate that at least one enabled channel has non-empty required content (subject+body for email) before allowing /send.
Original evidence
POST /campaigns {"name":"QA empty2 0724","primaryGoal":"x","audience":"All Members","channel":"EMAIL","channels":{"email":{"enabled":true,"subject":"","body":""}}} -> 201 DRAFT. POST /campaigns/cmrzf1b3z009cryg7hxh3qamo/send -> 200 status:"SENT". (Only primaryGoal+audience are required-validated; email subject/body are not.)
MEDmarketing28✓ Fixed
Front-desk staff can create AND send company-wide campaigns to All Members
Where
POST /api/v1/campaigns and /api/v1/campaigns/:id/send as employee (Priya, front-desk)
What happens
Priya is a front-desk employee, but her JWT is role=ADMIN, so she can create a campaign and fire it to the entire member base — a mass-communication action, not just data reading. (Root cause is the same ADMIN-token issue noted in known-issue #09, but this is a write/send action with mass-email blast impact rather than a hidden-data read.)
Impact
A front-desk staffer can broadcast marketing/messaging to every member without owner/manager approval. The campaigns UI may hide this, but the API grants full send capability.
Fix
Gate campaign create/send behind an owner/manager permission (real role), not the coarse ADMIN JWT claim.
Original evidence
With Priya's token: POST /campaigns {"name":"QA Priya frontdesk 0724",...,"audience":"All Members",...} -> 201 (id cmrzf20ue00djryg76q6rqdsa, DRAFT). POST /campaigns/cmrzf20ue00djryg76q6rqdsa/send -> 200 status:"SENT". Members (Alice) are correctly blocked: GET/POST /campaigns -> 403 FORBIDDEN.
MEDmember-journey41✓ Fixed
A REJECTED phone update (HTTP 400) still persists the invalid value and un-verifies the member's phone
PATCHing users/me with a malformed phone returns HTTP 400 (the write is supposedly rejected), yet the server still writes the garbage into the member's pendingPhone and flips verification.phoneVerified from true to false. The failing request has a partial side effect, and it visibly corrupts the member's profile.
Impact
A request the server says it rejected still mutates state: it replaces the displayed phone with junk and downgrades a verified member to 'Needs verification'. A user who fat-fingers a phone number and gets an error will nonetheless see their profile broken.
Fix
Validate the phone (and perform the GoHighLevel round-trip) before writing pendingPhone / touching verification flags; roll back all writes when the request returns 4xx so a failed PATCH is a true no-op.
Original evidence
As Alice (MEMBER): `PATCH users/me {"phone":"abcnotaphone!!!"}` -> HTTP 400 body {"code":"BAD_REQUEST","message":"GoHighLevel request failed: The string supplied did not seem to be a phone number"}. Immediately after, `GET users/me` -> HTTP 200 shows "pendingPhone":"abcnotaphone!!!" and "verification":{..."phoneVerified":false} (was true before the call; real phone 253-555-0101 and phoneVerifiedAt unchanged). UI confirmation: ?view=profile PHONE section renders literally "abcnotaphone!!!" with label "Needs verification", hiding the member's real verified number.
MEDmember-journey42✓ Fixed
Home 'Next Session' widget on the Ritual Journal / OS view falsely says 'No upcoming bookings'
Where
?view=journal (and ?view=my-wallet, which both render the 'Ritual Studio OS' home) NEXT SESSION card
What happens
The 'NEXT SESSION' widget on the Ritual Studio OS home (rendered by ?view=journal / ?view=my-wallet) shows 'No upcoming bookings.' even though the member has a CONFIRMED session today. The nearly-identical ?view=dashboard ('Overview') home correctly shows the same booking.
Impact
The member's landing screen tells them they have nothing booked when they actually have a session starting today, risking a missed appointment and undermining trust in the app.
Fix
Have the journal/OS home NEXT SESSION widget fetch/read the same upcoming-bookings source the dashboard uses instead of a separate (empty) path.
Original evidence
Same member (Alice), same moment, 2 consecutive runs each: ?view=journal NEXT SESSION card = 'Next Session ... No upcoming bookings.' while ?view=dashboard NEXT SESSION card = 'Next Session ... Contrast Therapy Today • 14:30 Communal Room'. API `GET bookings/mine` confirms a CONFIRMED Contrast Therapy booking today 14:30 and another 2026-07-25 09:00, and ?view=schedule lists both under UPCOMING. So the journal-view widget is simply wrong.
MEDmember-journey43✓ Fixed
Invoice header amount ($239) contradicts its own line total ($199) on membership invoices
Where
GET /api/v1/billing/invoices (inv_01, inv_02) shown in Profile 'Recent Invoices' / Wallet
What happens
Two membership invoices report a top-level amount of $239 but a subtotalAmount/totalAmount of $199 with zero tax and zero discount, so the invoice disagrees with itself by $40 with nothing to explain the gap.
Impact
The member sees inconsistent dollar figures for the same invoice; depending on which field a screen renders, the displayed charge is off by $40, which is a billing-trust problem.
Fix
Reconcile the invoice.amount field with the computed subtotal/tax/total (or recompute total from line items) so a single authoritative amount is stored and displayed.
Original evidence
As Alice: `GET billing/invoices` -> inv_02 {items:"Complete — Mar 2026", amount:239, subtotalAmount:199, discountAmount:0, taxAmount:0, totalAmount:199}; inv_01 {items:"Complete — Feb 2026", amount:239, ... totalAmount:199}. Every other invoice in the list has amount == totalAmount. Profile also shows 'NEXT BILL May 30, 2026 $239' while these Complete invoices total $199.
MEDmessaging21✓ Fixed
Members can send unsolicited direct messages to any other member/staff by user ID
Where
POST https://dev.ritualstudiospa.com/api/v1/messages with recipientId of another member
What happens
A MEMBER can set recipientId to an arbitrary other member and the message is created in mailbox DIRECT and actually delivered to that user's inbox. There is no admin/staff intermediary and no consent - a direct member-to-member (and member-to-staff) messaging channel exists that the product does not appear to intend (members otherwise route to SUPPORT).
Impact
Enables spam/harassment: any member can push arbitrary messages into any other user's inbox with no moderation, and combined with the PII enumeration above the recipients are trivially discoverable.
Fix
Restrict MEMBER-originated messages to the SUPPORT mailbox (ignore/deny caller-supplied recipientId for members), or validate recipientId against an allowed set.
Original evidence
As Alice (MEMBER): POST /api/v1/messages {"recipientId":"u_bob","subject":"direct to bob","body":"member-to-member?"} -> 201 id cmrzf0m5q007cryg7mlrtc085, mailbox DIRECT. Then GET /api/v1/messages as Bob (MEMBER) returns that message: "cmrzf0m5q007cryg7mlrtc085 | u_alice -> u_bob | direct to bob". Confirmed delivered to a different member's inbox.
MEDvalidation35✓ Fixed
Malformed JSON body returns 500 INTERNAL_ERROR instead of 400 (systemic, all write endpoints)
Where
POST/PATCH on users/me, bookings, staff/me/pto, billing/checkout, messages
What happens
Sending a body that is not valid JSON (with content-type: application/json) throws an unhandled parser error and returns a 500 instead of a clean 400 validation error.
Impact
A client sending a truncated/malformed request should get a 4xx, not a server error. 500s pollute error monitoring and mask real failures.
Fix
Add a JSON body-parser error handler (e.g. Express error middleware catching SyntaxError from express.json()) that returns 400 with a VALIDATION_ERROR code.
Original evidence
PATCH /api/v1/users/me -d '{bad json' -> HTTP 500 {"code":"INTERNAL_ERROR"}. POST /api/v1/bookings -d '{bad' -> 500. POST /api/v1/staff/me/pto -d '{bad' -> 500. POST /api/v1/billing/checkout -d '{bad' -> 500. POST /api/v1/messages -d '{bad' -> 500. Reproduces on every write endpoint.
MEDvalidation36✓ Fixed
Invalid date strings crash with 500 instead of 400 on bookings and PTO
Where
POST /api/v1/bookings (date), POST /api/v1/staff/me/pto (startDate/endDate)
What happens
A well-typed string that is not a parseable date passes the string type check, then throws downstream during date parsing, returning 500 instead of a 400 validation error.
Impact
Only the type is validated, not that the string is a real date. Any garbage date crashes the request.
Fix
Use a date-format refinement (regex YYYY-MM-DD + valid-date check) in the Zod schema so bad dates return 400.
Original evidence
POST /api/v1/bookings {"locationId":"loc_primary","serviceId":"svc_contrast","date":"not-a-date","time":"03:00"} -> HTTP 500 INTERNAL_ERROR. POST /api/v1/staff/me/pto {"type":"VACATION","startDate":"not-a-date","endDate":"also-bad"} -> HTTP 500 INTERNAL_ERROR. (Note: date as a number IS caught cleanly with 400 'Expected string, received number'.)
MEDvalidation37✓ Fixed
Nonexistent but well-formed IDs return 500 instead of 404/400 (inconsistent with serviceId)
Where
POST /api/v1/bookings (locationId), POST /api/v1/messages (recipientId)
What happens
A syntactically valid ID that does not exist crashes with 500 on some lookups, while the same class of error on serviceId correctly returns a clean 404 — so handling is inconsistent.
Impact
Unknown foreign keys should resolve to 404/400, not a server error. The inconsistency shows some lookups dereference null without a guard.
Fix
Look up location and recipient before use and return 404 when not found, matching the serviceId path.
Original evidence
POST /api/v1/bookings {...,"locationId":"loc_nonexistent","serviceId":"svc_contrast",...} -> HTTP 500. POST /api/v1/messages {"subject":"hi","body":"hi","recipientId":"u_does_not_exist"} -> HTTP 500. Contrast: bad serviceId ("' OR 1=1--") -> HTTP 404 {"code":"NOT_FOUND","message":"Service not found"}.
MEDvalidation38✓ Fixed
PTO request with endDate before startDate is accepted
Where
POST /api/v1/staff/me/pto
What happens
No date-range validation: a request whose endDate precedes startDate is created with status PENDING (201).
Impact
An inverted date range is nonsensical for time-off accounting and can produce negative durations downstream.
Fix
Add a schema refinement requiring endDate >= startDate; reject with 400 otherwise.
Original evidence
POST /api/v1/staff/me/pto {"type":"VACATION","startDate":"2026-12-10","endDate":"2026-12-01"} -> HTTP 201, body startDate:"2026-12-10", endDate:"2026-12-01", status:"PENDING" (id cmrzf2pnn00e3ryg717h4bqyk).
MEDwallet13✓ Fixed
Refunds never reverse earned loyalty points (points farming / inflation)
Where
POST /api/v1/billing/invoices/:id/refund (loyalty ledger)
What happens
When a paid purchase is refunded, the loyalty points it granted are never reversed - not even when the refund explicitly requests wallet reversal. A $60 drop-in grants 600 points; after a full refund the 600 points remain, so a purchase-then-refund cycle nets 600 free points at zero net cost.
Impact
Members keep loyalty rewards for purchases that were fully refunded (chargebacks, goodwill refunds, mistaken buys), letting points be farmed and later redeemed for real value.
Fix
On refund, reverse the loyalty points originally awarded for that invoice (proportional to refunded amount) as part of the same transaction.
Original evidence
Emma (u_emma) loyaltyPoints=4200 before. POST /billing/invoices/cmrzf1fuw009yryg7wcej3gzc/refund with {"revokeWalletCredits":true,"restoreInventory":true} -> 200 status:"REFUNDED", walletReversal:{revoked:true}; credits correctly dropped 7->6 BUT loyaltyPoints stayed 4200 (600 points not reversed). A second full refund of cmrzf0n7o007kryg7481xfbqs also left loyaltyPoints at 4200.
MEDwallet14✓ Fixed
Default refund does NOT revoke the granted session credit - member keeps a free session
Where
POST /api/v1/billing/invoices/:id/refund (revokeWalletCredits defaults false)
What happens
A standard admin refund returns the money but leaves the purchased drop-in/session credit in the member's wallet. The credit is only removed if the caller explicitly passes revokeWalletCredits:true, which is not the default, so the common refund path gives a full cash refund AND a free usable session.
Impact
Front-desk staff issuing a routine refund unknowingly leave a free paid-for session in the member's account, a direct revenue leak; the safe behavior should be opt-out, not opt-in.
Fix
Default revokeWalletCredits to true for full refunds (revoke the inventory that was sold), requiring an explicit override to keep the credit as goodwill.
Original evidence
POST /billing/invoices/cmrzf0n7o007kryg7481xfbqs/refund with {amount:60,reason:...} (no revoke flag) -> status:"REFUNDED", refund record revokeWalletCredits:false, metadata walletReversal:{revoked:false}; Emma's credits stayed at 7 (kept the drop-in) despite full $60 refund. Contrast: the same call with revokeWalletCredits:true correctly decremented credits 7->6.
MEDwallet15✓ Fixed
Purchased drop-in credits never expire despite advertised 180-day validity
Where
POST /api/v1/billing/checkout (drop_in bucket creation) vs shop UI copy
What happens
The shop UI advertises one-time items as 'Valid for 180 days' (expiryDays default 180), but every drop-in credit created by checkout is persisted with expiresAt:null, so purchased credits never expire.
Impact
Credits sold with a stated 180-day shelf life remain redeemable forever, inflating outstanding liability and breaking the advertised terms; no expiry sweep can ever fire because the timestamp is never set.
Fix
Set bucket.expiresAt = purchaseDate + expiryDays (180 for drop-ins) at checkout, and enforce it when funding bookings.
Original evidence
Frontend bundle /assets/index-B0sDXODF.js renders `Valid for ${l.expiryDays||180} days` for non-recurring items. Every drop_in sessionBucket returned by GET /users/me has expiresAt:null and cycleEndsAt:null - e.g. Emma's 7 freshly-purchased drop_in buckets all {expiresAt:null}, and Alice/Bob/Sarah membership buckets likewise expiresAt:null.
Polish 82 still open
LOWmember-journey50Partial
Next-billing-date differs between Wallet and Profile (off-by-one) and shows a date already in the past
Where
?view=wallet vs ?view=profile (next billing date)
What happens
The Wallet shows 'NEXT BILLING DATE MAY 29' while the Profile shows 'NEXT BILL May 30, 2026' for the same membership; both derive from cycleEndsAt 2026-05-30T00:00:00.000Z (a UTC/local off-by-one). Additionally the 'next' billing date (May 2026) is ~6 weeks in the past relative to today (2026-07-24).
Impact
Two member screens show different next-billing dates for the same plan, and both point to a date that has already elapsed, which looks broken.
Fix
Format cycleEndsAt in a single consistent timezone across views and advance the displayed next-billing date past 'now'.
Re-check
Re-ran exact repro as Alice on live dev app (today 2026-07-26).
CORE OFF-BY-ONE STILL REPRODUCES: Wallet (?view=wallet) renders "YOUR MEMBERSHIP ... NEXT BILLING DATE AUGUST 21"; Profile (?view=profile) renders "MEMBERSHIP ... NEXT BILL | Aug 22, 2026 | $239" — the two views still disagree by exactly one day. Confirmed stable across 2 independent runs (RUN1 & RUN2 both: wallet=AUGUST 21, profile=Aug 22, 2026).
PAST-DATE SYMPTOM FIXED: both views now show August 2026 (future relative to 2026-07-26), whereas before they showed May 2026 (~6 weeks past). GET /api/v1/users/me still returns sessionBuckets[].cycleEndsAt="2026-05-30T00:00:00.000Z", but the UI now
Original evidence
As Alice: ?view=wallet renders 'YOUR MEMBERSHIP ... NEXT BILLING DATE MAY 29'; ?view=profile renders 'NEXT BILL May 30, 2026'; `GET users/me` sessionBuckets[].cycleEndsAt = '2026-05-30T00:00:00.000Z'. Current date 2026-07-24, so the displayed next-billing date is already past.
LOWvalidation48Partial
No length limit / HTML sanitization on free-text fields (messages body/subject, pto reason) while name is capped at 100
Where
POST /api/v1/messages (subject, body), POST /api/v1/staff/me/pto (reason)
What happens
10,000-char strings and raw <script> tags are accepted and stored verbatim (201). By contrast, users/me `name` is correctly capped at 100 chars — so limits are applied inconsistently.
Impact
Unbounded input allows storage abuse, and unsanitized HTML persisted into staff-facing views is a latent stored-XSS risk (relies solely on the frontend escaping).
Fix
Apply consistent max-length limits to all free-text fields and strip/encode HTML on input.
Re-check
Re-ran exact repro on live dev app (all curl over HTTPS). LENGTH LIMITS NOW FIXED across all three fields: POST /api/v1/messages body=10000 chars -> HTTP 400 VALIDATION_ERROR "String must contain at most 5000 character(s)"; subject=10000 chars -> HTTP 400 "at most 160 character(s)"; POST /api/v1/staff/me/pto (Maya token) reason=10000 chars -> HTTP 400 "at most 500 character(s)". Control PATCH /users/me name=10000 -> HTTP 400 "at most 100 character(s)" (unchanged). The name-vs-freetext inconsistency that was the bug's headline is resolved.
HTML SANITIZATION only PARTLY fixed: PTO reason now rejects HTML -> POST /staff/me/pto reason="
Original evidence
POST /api/v1/messages {"subject":"<script>alert(1)</script>","body":"CCCC...(10000 chars)","recipientId":"u_alice"} -> HTTP 201, subject and 10k body stored raw (id cmrzf37l5...). POST /api/v1/staff/me/pto {...,"reason":"BBBB...(10000)"} -> HTTP 201. Meanwhile PATCH /users/me {"name":"AAA...(10000)"} -> HTTP 400 'String must contain at most 100 character(s)'.
LOWbooking44✓ Fixed
No upper bound on booking date - sessions accepted far in the future
Where
POST /api/v1/bookings (member_sarah)
What happens
Past sessions are correctly rejected ("This session time has already passed") but there is no forward-booking limit.
Impact
Members can reserve seats years out, letting them squat capacity and clutter the schedule.
Fix
Enforce a reasonable booking-horizon cap (e.g. 30-90 days).
Original evidence
date="2099-01-01" time="10:00" -> HTTP 201 CONFIRMED (id cmrzf0yds008eryg7...).
LOWcrm46✓ Fixed
GET nonexistent contact returns 200 null; PATCH nonexistent returns 400 (should be 404)
Where
GET /api/v1/contacts/{badId} ; PATCH /api/v1/contacts/{badId}
What happens
Requests for a nonexistent contact id do not return 404. GET returns 200 with a null body; PATCH returns 400 with 'Contact not found'. (This also causes non-id paths like /contacts/stats and /contacts/segments to resolve to 200 null via the :id route.)
Impact
Clients cannot distinguish 'not found' from success; a 200-null on GET can be mishandled by consumers, and the 400 vs 404 mismatch is inconsistent REST semantics.
Fix
Return 404 for missing contacts on both GET and PATCH.
Original evidence
GET /api/v1/contacts/nonexistent123 -> 200 body: null. PATCH /api/v1/contacts/nonexistent123 {"name":"x"} -> 400 {"error":{"code":"BAD_REQUEST","message":"Contact not found"}}. GET /api/v1/contacts/stats -> 200 null and /contacts/segments -> 200 null (caught by :id route).
LOWmarketing47✓ Fixed
Campaign scheduledFor accepts dates far in the past
Where
POST /api/v1/campaigns (scheduledFor)
What happens
A campaign can be created with status SCHEDULED and scheduledFor set to a past date with no validation that the schedule time is in the future.
Impact
A past scheduled time is nonsensical and, with an active scheduler, could trigger an immediate unintended send. Two seed campaigns (scheduledFor 2026-05-14 and 2026-03-11, both past) also remain stuck in SCHEDULED, indicating no scheduler ever fired them.
Fix
Validate scheduledFor is in the future when status is SCHEDULED.
Original evidence
POST /campaigns {...,"status":"SCHEDULED","scheduledFor":"2020-01-01T00:00:00.000Z",...} -> 201 with "status":"SCHEDULED","scheduledFor":"2020-01-01T00:00:00.000Z".
LOWmember-journey51✓ Fixed
Error response leaks internal CRM integration name (GoHighLevel) to the member
Where
PATCH /api/v1/users/me (invalid phone) 400 response body
What happens
When a phone update fails, the raw upstream error from the backend CRM is passed straight through to the member, disclosing that the backend integrates with GoHighLevel and echoing its internal message.
Impact
Leaking third-party integration names and raw upstream errors to end users aids fingerprinting and is a poor user-facing message.
Fix
Catch upstream integration errors and return a generic client-facing validation message (e.g. 'Please enter a valid phone number') without the vendor name.
Original evidence
As Alice: `PATCH users/me {"phone":"abcnotaphone!!!"}` -> HTTP 400 {"error":{"code":"BAD_REQUEST","message":"GoHighLevel request failed: The string supplied did not seem to be a phone number"}}.
LOWvalidation49✓ Fixed
billing/checkout returns misleading 'Plan is required' when a plan value is present but invalid or wrong-typed
Where
POST /api/v1/billing/checkout
What happens
Supplying an invalid plan string, or a numeric plan, returns the same 400 message as omitting it entirely ('Plan is required'), which is misleading.
Impact
A caller passing a real (but wrong) plan value is told it is missing, which is confusing to debug.
Fix
Distinguish missing vs. unknown/invalid plan and return an accurate message (e.g. 'Unknown plan').
Original evidence
POST /api/v1/billing/checkout {"plan":"nonexistent_plan_xyz"} -> HTTP 400 {"message":"Plan is required"}. {"plan":12345} -> HTTP 400 {"message":"Plan is required"}. {} -> HTTP 400 {"message":"Plan is required"}.
LOWwallet45✓ Fixed
billing/checkout ignores dryRun and persists real invoices
Where
POST /api/v1/billing/checkout with {"dryRun":true}
What happens
Sending dryRun:true does not preview - it creates real (PENDING) invoices in the member's billing history.
Impact
A quote/preview call pollutes billing history with dangling PENDING invoices, which can trigger dunning/retry logic or confuse reconciliation.
Fix
Honor dryRun by computing pricing without persisting an invoice/payment intent, or reject unknown flags explicitly.
Original evidence
As Sarah (u_sarah): POST /billing/checkout {planId:plan_essential,dryRun:true} and {planId:plan_complete,dryRun:true} each returned pricing but subsequently GET /billing/invoices showed two brand-new PENDING invoices cmrzf3juu00ffryg76jzgsfoh (essential) and cmrzf3k0v00firyg71c9v24df (complete) dated at the request time.