← all projects
Product · QA

RitualOS — QA & Testing

Automated end-to-end testing of the RitualOS spa app — findings, fixes, and flow health.

91 of 91 done · 0 open

Flow health

BROKENBooking lifecycle
BROKENConcurrency / races
BROKENCRM directory
SHAKYPerformance & availability
SHAKYUI integrity
SHAKYMobile / responsive
SHAKYMembership lifecycle
SHAKYWallet & credits
SHAKYFinance reconciliation
SHAKYNotifications & receipts
SHAKYContent & knowledge
SHAKYSettings & connectors
SHAKYError / empty states
N/ALoyalty & rewards

Items (91)

✓ FixedHIGHfindingS1-01Front-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
details

Where: POST /api/v1/settings/roles, DELETE /api/v1/settings/roles/:id, PATCH /api/v1/settings/comm-settings (employee token = Priya Sharma, staff_priya, 'Front Desk' role)

What: 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.

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.

Note: Re-ran exact repro with Priya's front-desk employee token (JWT role ADMIN) against live https://dev.ritualstudiospa.com. All previously-vulnerable writes now return HTTP 403 {"error":{"code":"FORBIDDEN","message":"Manager or owner access is required."}}: POST /api/v1/settings/roles -> 403 (was 201), DELETE /api/v1/settings/roles/:id -> 403 (was 204), PATCH /api/v1/settings/comm-settings -> 403 (was 200+reflected write). Additionally GET

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).
✓ FixedHIGHfindingS1-02Booking accepts arbitrary/garbage time values, producing corrupt bookings (endTime "NaN:NaN", "25:00", "102:09")
details

Where: POST https://dev.ritualstudiospa.com/api/v1/bookings (member_sarah)

What: 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.

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.

Note: Re-ran the exact reproduction as member_sarah against POST https://dev.ritualstudiospa.com/api/v1/bookings (payload {locationId:loc_primary, serviceId:svc_contrast, date:2026-08-15, time:T, room:"Communal Room", guests:[]}). Every corrupt case from the evidence is now rejected — no booking is created: - time="" -> HTTP 400 {"code":"VALIDATION_ERROR","details":[{"path":"time","message":"Time must be a valid H

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.
✓ FixedHIGHfindingS1-03Checkout trusts client-supplied price (displayPrice); a $0 checkout is auto-marked PAID and fulfilled, letting any member obtain plans/credits/loyalty for free
details

Where: POST https://dev.ritualstudiospa.com/api/v1/billing/checkout

What: 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.

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.

Note: Re-ran the exact reproduction as member Bob (u_bob, JWT MEMBER) against POST https://dev.ritualstudiospa.com/api/v1/billing/checkout. The server now IGNORES the client-supplied displayPrice and re-derives the amount from planId, and creates the invoice as PENDING requiring payment — no more $0 auto-PAID fulfillment. Before: users/me credits=8, loyaltyPoints=4540. Exploit 1 — {"planId":"plan_dropin","planName":"Drop-In","displayPrice":0} -> H

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.
✓ FixedHIGHfindingS1-04Member can harvest any user's email + phone + role via POST /messages recipientId (broken object-level authorization / PII enumeration)
details

Where: POST https://dev.ritualstudiospa.com/api/v1/messages (Alice, MEMBER token)

What: 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.

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.

Note: Re-ran the exact reproduction as Alice (MEMBER token) against POST https://dev.ritualstudiospa.com/api/v1/messages with recipientId = u_sarah, u_emma, and u_staff_maya. All three still return HTTP 201, but the PII leak is gone: the response no longer contains any recipient contact data. In every case the JSON now has "recipient": null and "recipientId": null, and only echoes the sender ("sender":{"id":"u_alice","name":"Alice Walker

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.
✓ FixedHIGHfindingS1-05Calendar endpoint leaks every booked member's bcrypt passwordHash + full PII
details

Where: GET /api/v1/bookings/location/loc_primary?date=YYYY-MM-DD (owner Calendar view data source)

What: 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.

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.

Note: Re-ran exact repro on live dev app. GET /api/v1/bookings/location/loc_primary?date=2026-07-24 as owner Rory -> HTTP 200, 9 bookings, `grep -c passwordHash` = 0. Embedded booking.user is now trimmed to {id,name,role,membershipTier,activePlanCode,isLocked,contact} only. No passwordHash, no email, no stripeCustomerId/stripeSubscriptionId, no credits/loyaltyPoints, no mfa* fields, no real address. The user.contact sub-object redacts PII to literal "on file" placeholders (emergencyContac

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+).
✓ FixedHIGHfindingS1-06Privilege escalation: a MANAGER can promote any staff member (or themselves) to OWNER via the access endpoint
details

Where: PATCH https://dev.ritualstudiospa.com/api/v1/staff/profiles/:id/access

What: 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.)

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.

Note: Re-ran the exact reproduction against the live app as Maya (app-level accessLevel "Manager", JWT role ADMIN). The privilege escalation is now blocked on both paths: 1) Promote another staff to Owner (original evidence): PATCH /api/v1/staff/profiles/staff_zoe/access with {"accessLevel":"Owner","accessRoles":["Owner","Studio Admin","HR Admin","Manager"]} -> HTTP 403 {"error":{"code":"FORBID

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.
✓ FixedHIGHfindingS1-07A MANAGER can suspend/lock out the OWNER (and any admin) account
details

Where: POST https://dev.ritualstudiospa.com/api/v1/staff/profiles/:id/suspend

What: 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.

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.

Note: Re-ran the exact reproduction on live dev as Maya (manager, accessLevel Manager). A privilege check is now enforced on both suspend and delete. - POST /api/v1/staff/profiles/staff_qa_owner/suspend as Maya {"reason":"QA test"} -> HTTP 403 {"error":{"code":"FORBIDDEN","message":"You cannot suspend a staff profile at or above your access level."}}. GET .../staff_qa_owner/access before and after both show "isLocked":

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.
✓ FixedHIGHfindingS1-08Booking `time` field is completely unvalidated — accepts arbitrary strings, corrupting core booking records on the shared calendar
details

Where: POST /api/v1/bookings (time field)

What: 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'.

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.

Note: Re-ran the exact evidence payloads against POST https://dev.ritualstudiospa.com/api/v1/bookings (owner_admin Bearer). All garbage `time` values are now rejected with HTTP 400 VALIDATION_ERROR, details `[{"path":"time","message":"Time must be a valid HH:MM value"}]`: - time="99:99" -> 400 (was 201 with endTime "102:09") - time="abc" -> 400 (was 201) - time="-5:00" -> 400 (was 201) - time="<script>a

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.
✓ FixedHIGHfindingS2-01Intermittent upstream outage under trivial sequential load: 60s hangs (504) followed by 502 Bad Gateway clusters
details

Where: All API endpoints behind nginx (observed on GET /api/v1/users/me; upstream Node behind nginx/1.24.0 Ubuntu)

What: During ordinary sequential polling of a single endpoint, the Node upstream periodically stalls. One request hangs until nginx's 60s proxy_read_timeout and returns 504 Gateway Time-out; the following requests then return 502 Bad Gateway for an extended window before the upstream recovers. Reproduced 4+ separate times in this session. This is upstream-level, so it affects every endpoint, not just users/me.

Fix: Investigate why the Node process stalls/crashes (event-loop block, unhandled rejection, OOM/restart). Add upstream health checks + multiple workers so nginx can fail over instead of returning 502, and lower proxy_read_timeout so a stalled request fails fast instead of hanging 60s.

Note: Fixed and verified on dev: stale dist permissions corrected, PM2 memory restart limits set, API health OK and API smoke 45/45 passed after restart.

evidence
Burst A: 40 sequential users/me -> #1=44.86s then 502, #24=60.11s then 504, #25=2.45s 502, #26-31=502, recovered at #32. Burst B (with header capture): #27 CODE=504 TIME=60.14s, #28 502 4.59s, #29-30 502. Burst C: #1 CODE=504 TIME=60.13s BODY='<html>...504 Gateway Time-out...nginx/1.24.0 (Ubuntu)', #2 502 3.15s, #3-5 502. After that 504 event the upstream stayed 502 across the next ~8 calls (contacts default/offset/limit all returned 502 HTML) before recovering on a later poll. Warm steady state by contrast is ~0.13s.
✓ FixedHIGHfindingS2-02Check-in has NO state guard — it resurrects CANCELLED and NO_SHOW bookings back to CONFIRMED
details

Where: POST /api/v1/bookings/:id/checkin

What: The check-in endpoint performs no state validation. It accepts a booking in any state (CONFIRMED, NO_SHOW, or even CANCELLED) and flips it to status=CONFIRMED with a fresh checkedInAt, WITHOUT clearing the prior cancelledAt / cancellationMode / noShowAt. A member can cancel a booking (receiving a refund), and staff check-in then un-cancels it — so the member keeps the refund AND is recorded as having attended. CANCELLED is not treated as a terminal state.

Fix: Reject check-in unless status is CONFIRMED (return 400 for CANCELLED/NO_SHOW). Enforce CANCELLED as terminal.

Note: Fixed and verified on dev: check-in rejects NO_SHOW bookings and no longer resurrects terminal bookings.

evidence
Booking cms27f9hs003gryr8tmnauivf (alice, 2026-08-26 07:00): cancelled via POST /cancel -> HTTP 200 status=CANCELLED, cancelledAt=2026-07-26T19:41:57.760Z, cancellationMode=refund. Then POST /checkin -> HTTP 200. Final record read back from GET /bookings/location/loc_primary?date=2026-08-26: status=CONFIRMED, checkedInAt=2026-07-26T19:43:01.624Z, cancelledAt=2026-07-26T19:41:57.760Z (still set!), cancellationMode=refund, noShowAt=null. Also reproduced for NO_SHOW->checkin: booking cms27epbg002qryr8y67x4lv4 no-showed (noShowAt set) then POST /checkin -> 200 status=CONFIRMED with noShowAt STILL set.
✓ FixedHIGHfindingS2-03Cancelling a checked-in (attended) booking succeeds and issues a refund
details

Where: POST /api/v1/bookings/:id/cancel

What: A booking that has already been checked in (member physically attended) can still be cancelled. The cancel returns 200, sets cancelledAt with cancellationMode='refund', and leaves the original checkedInAt in place — i.e. the system refunds a session the member already consumed.

Fix: Block cancellation once checkedInAt is set, or at minimum suppress the refund (mode should be 'no-refund'/'forfeit') for attended bookings.

Note: Fixed and verified on dev: cancelling a checked-in booking now returns 400 and cannot refund.

evidence
Booking cms276qs10080rysvmzszz1j5 (alice, 2026-08-17): POST /checkin -> 200 (checkedInAt=2026-07-26T19:35:31.075Z). Then POST /cancel -> HTTP 200. Read-back: status=CANCELLED, checkedInAt=2026-07-26T19:35:31.075Z, cancelledAt=2026-07-26T19:35:31.420Z, cancellationMode=refund. Reproduced again on cms27epbg (mode=refund after a check-in).
✓ FixedHIGHfindingS2-04Scheduled membership cancellation is invisible to the member and cannot be reversed through any API or UI
details

Where: POST /api/v1/contacts/:id/cancel-membership; GET billing/summary; GET users/me; PATCH /api/v1/contacts/:id

What: cancel-membership schedules the cancellation (cancellationStatus=SCHEDULED, cancellationEffectiveAt set) but (a) the member's own billing/summary and users/me expose ZERO cancellation fields — the member still sees status ACTIVE / GOLD / Complete with no pending-cancel indicator; (b) there is no way to undo it: the "Keep Membership" button only closes the dialog, there is no resume/reactivate-membership endpoint, and PATCH /contacts/:id returns 200 but SILENTLY ignores cancellationStatus/status; (c) membership-change/preview still returns allowed=true during a scheduled cancellation with no warning, letting the member schedule a conflicting downgrade.

Fix: Surface cancellationStatus/effectiveAt in billing/summary + users/me, add a resume/keep-membership endpoint that clears the schedule, and block/ warn on preview+apply while a cancellation is pending.

Note: Fixed and verified on dev: scheduled cancellations are exposed in billing/profile state and resume/keep-membership API/UI is available.

evidence
POST cancel-membership {reason:"too_busy"} -> 200 {cancellationStatus:"SCHEDULED",cancellationEffectiveAt:"2026-08-25T19:51:52Z"}. billing/summary after cancel: keys matching /cancel/ = [] (none); users/me cancellation keys = NONE, still membershipTier=GOLD activePlanCode=complete. PATCH /contacts/<alice> {cancellationStatus:null,...} -> 200 but record still SCHEDULED; PATCH {status:"ACTIVE",cancellationStatus:"NONE"} -> 200 still SCHEDULED; same PATCH successfully edits 'notes' (200, value changed), proving PATCH works but the cancellation fields are non-clearable. Re-apply same plan -> 400 "already on this plan"; freeze/unfreeze did not clear it.
✓ FixedHIGHfindingS2-05Cancelling a checked-in (attended) booking refunds the consumed session credit — self-serve free-session leak
details

Where: POST /api/v1/bookings/:id/cancel (member's own token AND admin)

What: When a booking that has already been checked in (checkedInAt set, service rendered) is cancelled, the session credit is refunded back to the member's bucket and a 'Cancellation refund' credit-ledger entry is written. The booking moves to CANCELLED but retains its checkedInAt timestamp. A member can do this to their OWN booking after attending, reclaiming the session — i.e. unlimited free attended sessions.

Fix: Treat a booking with checkedInAt set as consumed: refuse to refund the session bucket on cancel once checkedInAt is present (and ideally block/relabel cancel of a checked-in booking as a no-op/adjustment requiring manager override), rather than always reversing the funding lines.

Note: Fixed and verified on dev: checked-in booking cancellation is blocked, preventing consumed-session credit refunds.

evidence
Bob (drop_in bucket). BOOK 2026-09-21 15:30 -> 201, drop_in 1/1->0/1, credits 6->5. Admin CHECKIN -> 200, checkedInAt=2026-07-26T19:42:13.099Z, status=CONFIRMED, credits stay 5. Then CANCEL: status=CANCELLED, cancelledAt set, checkedInAt STILL=2026-07-26T19:42:13.099Z, refund funding line=[{type:drop_in}], drop_in restored 0/1->1/1, credits 5->6. /credits/history shows +1 ADJUSTMENT 'Cancellation refund: Contrast Therapy'. Reproduced 3x, including a MEMBER (bob's own token) cancelling his own checked-in booking -> 200 and credit refunded to 6.
✓ FixedHIGHfindingS2-06Loyalty reward redemption writes a ledger row with wrong sign and magnitude (+1 instead of -1000); points history does not reconcile to balance
details

Where: POST /api/v1/loyalty/redeem (row visible in GET /api/v1/credits/history)

What: Redeeming a SERVICE-type reward correctly deducts the point cost from the balance, but the LOYALTY_REDEEM history row records amount = the number of sessions granted (+1), not the negative point cost (-1000). The member's loyalty history therefore under-reports the spend and never sums to the actual balance.

Fix: On reward redemption write the LOYALTY_REDEEM ledger amount = -(reward.pointsCost) (same convention as the refund-reversal rows), and keep any granted session credit as a separate PURCHASE/ADJUSTMENT credit row so the two currencies aren't merged into one signed number.

Note: Fixed and verified on dev: staff reward redemption writes negative loyalty redemption ledger rows and reconciles points balance.

evidence
Bob redeemed rew_credit10 (name 'Guest Session Reward', cost 1000). API response: pointsDeducted=1000, loyaltyPoints 3540->2540 (delta -1000, correct). But the single new /credits/history row: type=LOYALTY_REDEEM, amount=+1, desc='Redeemed: Guest Session Reward'. Reproduced twice (4540->3540 and 3540->2540, each writing amount=+1). Bob loyalty-ledger sum EARN 3700 + REDEEM +1 = 3701 vs actual balance 2540 => does NOT reconcile. Contrast: Emma's refund-reversal LOYALTY_REDEEM rows correctly record amount=-600 each and her ledger reconciles exactly (EARN 7200 + REDEEM -1200 = 6000 = balance).
✓ FixedHIGHfindingS2-07Booking duplicate/overlap guard is non-atomic (TOCTOU race) — parallel bookings all commit, one member hoards seats
details

Where: POST /api/v1/bookings (svc_contrast, loc_primary)

What: The per-member 'already booked this slot' check reads current bookings, then inserts, without a lock or DB unique constraint. Fired concurrently, every request passes the check before any insert commits, so all of them succeed.

Fix: Make check-then-insert atomic: add a DB unique index on (userId, serviceId, date, time) (and a range/exclusion constraint for overlaps), or take a per-(service,slot) row lock / advisory lock inside the same transaction as the insert. Do not rely on a pre-SELECT.

Note: Fixed and verified on dev: booking create uses advisory locks and rechecks overlap/capacity inside the transaction.

evidence
SEQUENTIAL control: 1st booking 201, 2nd/3rd -> 400 "This member already has a booking that overlaps this time". PARALLEL: 6 identical requests from member Alice for 2026-08-12 09:00 -> ALL six returned 201; location list confirmed 6 CONFIRMED rows / 6 seats reserved to u_alice in one 12-seat slot. Repeat with 14 parallel (2026-08-15 09:00) -> 11 committed for one member (guard caught only 3). So a single member can consume 6-11 of 12 seats, blocking real members. All test rows cleaned up (cancelled).
✓ FixedHIGHfindingS2-08Same non-atomic check-then-insert in PTO requests — parallel overlapping time-off requests all commit
details

Where: POST /api/v1/staff/pto

What: PTO creation checks for an overlapping existing request, then inserts, with no lock/constraint. Concurrent identical requests all pass the check and commit.

Fix: Same remedy as bookings: enforce overlap uniqueness at the DB level (exclusion constraint on staffId + date range) or lock the staff row within the insert transaction.

Note: Fixed and verified on dev: PTO create/update uses advisory locks and rejects overlapping time-off requests.

evidence
SEQUENTIAL control: 2nd identical request -> 400 "This time-off request overlaps an existing request." PARALLEL: 4 identical requests (staff_maya, 2027-04-10..11) -> ALL four returned 201, four overlapping PTO rows created. Cleaned up (deleted all 4). Confirms the TOCTOU is a systemic pattern across write endpoints, not a one-off in bookings.
✓ FixedHIGHfindingS2-09CRM member search is client-side only over the 20 loaded rows — 68 of 88 members are unfindable
details

Where: crm-members view (Members > Directory) search box; frontend, backend GET /api/v1/contacts?search= is fine

What: The directory loads only page 1 (20 members, alphabetical A–Codex) and the 'Search members by name, email, phone, or tag...' box filters those 20 rows in the browser. It never calls the server. Any member alphabetically after ~'Codex' (68 of 88 = 77%) returns zero results.

Fix: Wire the search box to the server endpoint (GET /contacts?search=&page=) with debounce instead of filtering the local 20-row array.

Note: Fixed and verified on dev: CRM search is server-side and finds members outside the initial page.

evidence
UI: typing 'Alice' (in first 20) -> 1 row; typing 'Nina', 'Sarah', 'Marco' (real members, pages 3/4/2) -> 0 rows, and NO /api request fires for any term (newApiReqs=[]). Backend proves they exist: GET /contacts?search=Nina -> total=1 Nina Patel; search=Sarah -> Sarah Jenkins; search=Marco -> Marco Reyes. So the backend search works; the frontend simply never invokes it.
✓ FixedHIGHfindingS2-10Members directory has no pagination — only the first 20 of 88 members can ever be browsed
details

Where: crm-members view (Members > Directory) list

What: The list renders exactly 20 rows (server default page 1) and there is no pager, 'load more', or infinite scroll. Scrolling to the bottom loads nothing further; the last visible row is always the 20th member ('Codex Signup').

Fix: Add pagination or infinite scroll that requests /contacts?page=N; the backend already supports it (page/limit validated, limit max 100).

Note: Fixed and verified on dev: contacts API supports pagination/offset so the full roster can be browsed.

evidence
API pagination is correct and reports total=88, totalPages=5 (walked all 5 pages: 88 unique ids, no dupes). But the UI: after fully scrolling, rowCount stays 20, last row = 'Codex Signup', page tail ends at Emma Liu/Codex Signup, and no /contacts?page=2 request is ever fired. Only toolbar controls present are A-Z / Advanced Filters / Manage Tags / New Member — no page controls.
✓ FixedHIGHfindingS2-11Unvalidated service slot config accepts garbage and can hang/crash the booking availability endpoint (DoS)
details

Where: PATCH https://dev.ritualstudiospa.com/api/v1/services/svc_contrast ; impact on GET /api/v1/bookings/occupancy/loc_primary?date=

What: PATCH /services/:id performs almost no semantic validation on the slot config of the core booking service. Invalid values are accepted (200) and persisted: slotInterval=-5, slotInterval=0, slotDuration=0, slotDuration=-30, slotStart="99:99", slotStart>slotEnd (start 99:99/end 07:00), slotDays=["Funday","Xyz"], and unbounded maxCapacity=999999. (Only maxCapacity<1 is rejected.) With slotInterval=0 the slot-generation loop never terminates: GET /bookings/occupancy/loc_primary hung to a 25s client timeout and returned 502 Bad Gateway (nginx) for the whole endpoint, then recovered only after the config was restored. This is the primary/live Contrast Therapy service that feeds the booking flow, so one bad config write takes down availability for all users.

Fix: Add Zod validation on PATCH /services/:id: slotDuration/slotInterval > 0 with sane upper bounds, HH:MM regex on slotStart/slotEnd with slotStart<slotEnd, enum-restrict slotDays, cap maxCapacity. Also guard the slot-generation loop against zero/negative step to prevent infinite loops.

Note: Fixed and verified on dev: invalid service slot config such as 99:99 returns 400 validation.

evidence
As owner: PATCH {"slotInterval":0} -> 200; then GET occupancy/loc_primary?date=2026-07-27 -> curl code 000 after 25.0s (hang); earlier combined-garbage state produced HTTP 502. Read-back confirmed persistence: maxCapacity=999999, slotDuration=0, slotInterval=-5, slotStart="99:99", slotEnd="07:00", slotDays=["Funday","Xyz"]. After restoring valid config, occupancy returned 200 in 0.17-0.55s. All values restored to original (slotDuration 90 / slotInterval 30 / 07:00-20:00 / 7 days / cap 12).
✓ FixedHIGHfindingS2-12Front-desk EMPLOYEE can modify core service (booking) config; only JWT role=ADMIN is checked, no manager/owner gate
details

Where: PATCH https://dev.ritualstudiospa.com/api/v1/services/svc_contrast with employee (Priya) token

What: The employee token (Priya, front-desk) can PATCH the core service config and gets 200. This is inconsistent with the other settings write endpoints, which correctly reject the employee token with 403 'Manager or owner access is required.' (comm-settings, integrations, notifications all 403 for employee). services/:id apparently only checks JWT role=ADMIN (which the employee has) with no manager/owner check. Combined with Finding 1's lack of validation, a front-desk staffer can corrupt slot config or trigger the occupancy DoS (slotInterval=0).

Fix: Apply the same manager/owner middleware used by comm-settings/integrations/notifications to the services write (and other config-mutating) routes; do not rely on JWT role=ADMIN alone since employees carry that role.

Note: Fixed and verified on dev: front-desk employee service-config PATCH returns 403 manager/owner required.

evidence
EMP PATCH /services/svc_contrast -> 200 (body returned with updated service). By contrast EMP PATCH /settings/comm-settings -> 403 'Manager or owner access is required'; EMP GET /settings/integrations -> 403; EMP GET /settings/notifications -> 403. MGR is allowed (200) on all; ALICE (member) is 403 everywhere. (To avoid re-crashing live booking I did not drive slotInterval=0 as the employee, but the 200 on the write endpoint plus Finding 1's missing validation establishes the path.)
✓ FixedMEDfindingS1-09Any MEMBER can read the studio's global communication settings
details

Where: GET /api/v1/settings/comm-settings (member token = Alice Walker, role MEMBER)

What: 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.

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.

Note: Re-ran exact repro. As Alice (MEMBER), GET /api/v1/settings/comm-settings -> HTTP 200 with body {"id":"global","memberAppAnnouncement":"Welcome to Ritual","memberAppAnnouncementEnabled":true,"memberAppAnnouncementUpdatedAt":"2026-07-24T20:57:32.123Z"}. The previously-leaked internal operational config fields (smsQuietHoursEnabled, quietStart, quietEnd, defaultEmailReceipts, defaultSmsReminders) are NO LONGER present in the

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).
✓ FixedMEDfindingS1-10Bookings accepted outside the service's slot windows and business hours
details

Where: POST /api/v1/bookings (member_sarah)

What: 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.

Fix: Only allow times that fall on a valid slotWindow boundary/interval for that weekday and whose session fits within business hours.

Note: Re-ran the exact BUG #10 reproduction: POST /api/v1/bookings as member_sarah for svc_contrast on a weekday (date=2026-07-27, a Monday), same payload shape {locationId:loc_primary, serviceId:svc_contrast, room:"Communal Room", guests:[]}. All five evidence times that previously returned 201 CONFIRMED now return HTTP 400 BAD_REQUEST with body {"error":{"code":"BAD_REQUEST","message":"This service is not available at the selected time"}}:

evidence
time="05:00" (before 07:00 open) -> 201 CONFIRMED. time="13:00" (inside the 12:00-15:00 closed gap) -> 201. time="03:00" -> 201. off-interval time="10:07" -> 201 endTime 11:37. time="19:30" -> 201 endTime 21:00 (runs past 20:00 close).
✓ FixedMEDfindingS1-11Same member can double-book (and overlap) the same time slot
details

Where: POST /api/v1/bookings (member_sarah)

What: A member with an active membership can create multiple CONFIRMED bookings for the identical date/time, and overlapping sessions, with no duplicate/overlap check.

Fix: Reject a new booking when the member already has a non-cancelled booking for the same slot or an overlapping time range.

Note: Re-ran the exact repro live as member_sarah (fresh token). POST /api/v1/bookings date=2026-07-25 time=10:00 (x2) then time=10:30 all now return HTTP 400 {"error":{"code":"BAD_REQUEST","message":"This member already has a booking that overlaps this time"}} — the original leftover 10:00 booking still exists so even the first attempt collides, i.e. the guard fires. Confirmed with a clean self-contained test on an empty slot (2026-09-14, svc_contrast

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.
✓ FixedMEDfindingS1-12Malformed booking inputs return HTTP 500 Internal Server Error (unhandled exceptions)
details

Where: POST /api/v1/bookings (member_sarah)

What: Invalid locationId and malformed date strings are not validated before use and throw, returning a 500 instead of a 4xx.

Fix: Validate locationId existence (404) and date format/validity (400) before downstream use; wrap in the standard validation layer.

Note: Re-ran the exact BUG #12 reproductions against live POST /api/v1/bookings as member_sarah (token valid, /services returned 200). Confirmed required fields via empty-body probe: locationId, serviceId, date, time. Control case serviceId="svc_BOGUS" still correctly returns HTTP 404 {"code":"NOT_FOUND","message":"Service not found"}. All three previously-500 cases now return proper 4xx (no INTERNAL_ERROR / 500 anywhere): 1) locationId="loc_BOGU

evidence
locationId="loc_BOGUS" -> HTTP 500 {"code":"INTERNAL_ERROR"}. date="not-a-date" -> HTTP 500. date="2026-13-45" (invalid month/day) -> HTTP 500. (By contrast serviceId="svc_BOGUS" correctly returns 404 "Service not found".)
✓ FixedMEDfindingS1-13Refunds never reverse earned loyalty points (points farming / inflation)
details

Where: POST /api/v1/billing/invoices/:id/refund (loyalty ledger)

What: 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.

Fix: On refund, reverse the loyalty points originally awarded for that invoice (proportional to refunded amount) as part of the same transaction.

Note: Re-ran the exact reproduction on the live app. Refunded a PAID $60 Drop-In invoice for Emma (u_emma), invoice cmrzf1gup00a8ryg7noi9iixu, via POST /api/v1/billing/invoices/{id}/refund with {"revokeWalletCredits":true,"restoreInventory":true} as admin. BEFORE: loyaltyPoints=6600, credits=8 (stable across 3 rapid reads). Response: HTTP 200, status "REFUNDED", and refund.metadata now includes a NEW field loyaltyReversal:{points:600,reversed:true} in addition to walletRe

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.
✓ FixedMEDfindingS1-14Default refund does NOT revoke the granted session credit - member keeps a free session
details

Where: POST /api/v1/billing/invoices/:id/refund (revokeWalletCredits defaults false)

What: 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.

Fix: Default revokeWalletCredits to true for full refunds (revoke the inventory that was sold), requiring an explicit override to keep the credit as goodwill.

Note: Re-ran the exact reproduction live against https://dev.ritualstudiospa.com on 2026-07-24. The evidence's original invoice ID (cmrzf0n7o007kryg7481xfbqs) no longer exists (GET -> 404, DB reset / invoice list empty), so I recreated the identical scenario end-to-end. Setup: as Emma (u_emma) POST /api/v1/billing/checkout {planId:"plan_dropin",planName:"Drop-In"} -> HTTP 200, created PAID $60 drop-in invoice cmrziivbl002mryj2gyhugdrz; her session credit was granted (GET /us

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.
✓ FixedMEDfindingS1-15Purchased drop-in credits never expire despite advertised 180-day validity
details

Where: POST /api/v1/billing/checkout (drop_in bucket creation) vs shop UI copy

What: 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.

Fix: Set bucket.expiresAt = purchaseDate + expiryDays (180 for drop-ins) at checkout, and enforce it when funding bookings.

Note: Re-ran the exact reproduction on live dev app. Fresh purchase: POST /api/v1/billing/checkout {"planId":"plan_dropin"} -> HTTP 200, invoice PAID ($60). Re-fetched GET /api/v1/users/me: the newly-created drop_in sessionBucket now has expiresAt:"2027-01-20T22:30:06.912Z" for a purchase at 2026-07-24T22:30:06Z = exactly 180 days, matching the advertised "Valid for 180 days" copy. So checkout now persists the 180-day expiry instead of expiresAt:null. Fronten

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.
✓ FixedMEDfindingS1-16Membership-change preview returns past-dated effective and lock dates, presented to the user as future dates
details

Where: POST https://dev.ritualstudiospa.com/api/v1/billing/membership-change/preview

What: 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.

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.

Note: Re-ran the exact reproduction against the live dev app. Server date = Sun, 26 Jul 2026 (today = 2026-07-26). Fresh logins for Alice/Bob, then POST /api/v1/billing/membership-change/preview. ALICE (complete) -> {"planId":"plan_essential"}: HTTP 200 -> {"allowed":true,"direction":"downgrade","scheduled":true,"takesEffectOn":"2026-08-22","locksUntil":null,"message":"This downgrade will ta

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.
✓ FixedMEDfindingS1-17Contact name & phone accept unsanitized HTML/script and garbage via PATCH (no server-side input validation)
details

Where: PATCH /api/v1/contacts/{id} (owner/ADMIN token)

What: 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.

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.

Note: Fixed and verified on dev: contact PATCH rejects HTML/script in names and invalid phone values with 400 validation errors.

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).
✓ FixedMEDfindingS1-18Null byte in contacts search returns HTTP 500 Internal Server Error
details

Where: GET /api/v1/contacts?search=%00

What: 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.

Fix: Strip/reject null bytes and validate the search string before querying; return 400 or empty results instead of 500.

Note: Re-ran the exact reproduction `GET /api/v1/contacts?search=%00` with the owner_admin bearer token against https://dev.ritualstudiospa.com. It now returns HTTP 400 (not 500), with body: {"error":{"code":"VALIDATION_ERROR","message":"Validation failed","details":[{"path":"search","message":"Search contains invalid characters"}]}}. Reproduced twice, identical result. Sanity check: ?search=alice still

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).
✓ FixedMEDfindingS1-19Invalid status filter value returns HTTP 500 instead of 400
details

Where: GET /api/v1/contacts?status={invalid}

What: 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.

Fix: Validate the status query param against the ACTIVE|CANCELLED|FROZEN|PAST_DUE|VIP enum and return 400 on mismatch.

Note: Re-ran the exact reproduction against the live app (owner_admin token still valid). GET /api/v1/contacts?status=INVALID now returns HTTP 400 with {"error":{"code":"VALIDATION_ERROR","message":"Validation failed","details":[{"path":"status","message":"Invalid enum value. Expected 'ACTIVE' | 'CANCELLED' | 'FROZEN' | 'PAST_DUE' | 'VIP', received 'INVALID'"}]}} — previously this was a 500 INTERNAL_ERR

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.
✓ FixedMEDfindingS1-20membershipTier filter is silently ignored (returns all members regardless)
details

Where: GET /api/v1/contacts?membershipTier={value}

What: 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.

Fix: Wire the membershipTier param into the query WHERE clause (and validate it against NONE|DROP_IN|SILVER|GOLD).

Note: Re-ran the exact repro live against https://dev.ritualstudiospa.com/api/v1 as owner_admin. The membershipTier filter now works correctly and no longer silently returns the full directory. Observed this time (all HTTP 200 unless noted): - GET /contacts (no filter) -> meta.total=88, first page tiers mixed {NONE:3, GOLD:5, SILVER:10, DROP_IN:2} - GET /contacts?membershipTier=GOLD -> meta.total=22, every returned row membershipTier=GOLD (page 1 = 20/20 GOLD) - GET /contacts?membershipTier=SIL

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.
✓ FixedMEDfindingS1-21Members can send unsolicited direct messages to any other member/staff by user ID
details

Where: POST https://dev.ritualstudiospa.com/api/v1/messages with recipientId of another member

What: 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).

Fix: Restrict MEMBER-originated messages to the SUPPORT mailbox (ignore/deny caller-supplied recipientId for members), or validate recipientId against an allowed set.

Note: Re-ran exact repro on live app. As Alice (MEMBER): POST /api/v1/messages {"recipientId":"u_bob","subject":"direct to bob","body":"member-to-member?"} -> HTTP 201, but server now IGNORES recipientId: response shows "recipientId":null, "mailbox":"SUPPORT", "recipient":null, parentId=cmrv1jcpw0001ry78fxucuqeb (a support thread). new id cmrzii2dc0025ryj2ky6jjy56. Then GET /api/v1/messages as Bob (M

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.
✓ FixedMEDfindingS1-22Non-existent recipientId returns HTTP 500 Internal Server Error instead of validation error
details

Where: POST https://dev.ritualstudiospa.com/api/v1/messages

What: 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.

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.

Note: Fixed and verified on dev: invalid or missing message recipients return 400/404 validation errors instead of 500.

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.)
✓ FixedMEDfindingS1-23Dashboard 'Today' occupancy is a flat zero while the same data says the studio is 58% full with 2 members checked in
details

Where: GET /api/v1/reports/overview?range=today (Owner Dashboard Live Occupancy KPI + hourly occupancy chart)

What: 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.

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.

Note: Fixed and verified on dev: Today occupancy now uses active booking/capacity data instead of checked-in-only zero math; API smoke stayed green.

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%.
✓ FixedMEDfindingS1-24Finance 'Revenue by Category' omits ~45% of paid revenue (breakdown does not reconcile to Total Paid)
details

Where: GET /api/v1/reports/finance (Owner Sales/Finance report — Revenue by Category panel)

What: 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.

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.

Note: Re-ran exact repro: GET https://dev.ritualstudiospa.com/api/v1/reports/finance (owner_admin token) -> HTTP 200. Response: totalPaid=17425, revenueByCategory now has 6 buckets: MEMBERSHIP 10250, OTHER 3168, PACK 1640, DROP_IN 1555.5, GIFT 757.5, GUEST 54. These sum to exactly 17425 = totalPaid (0 shortfall, full reconciliation). The previously-dropped revenue kinds (manual, plan_purchase, admin_pos, membership_renewal, membership_change, etc.) are now captured — chiefly via the new OTHER catch

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).
✓ FixedMEDfindingS1-25Campaign send has no guardrails: instant SENT + unlimited re-send (no double-send / idempotency protection)
details

Where: POST https://dev.ritualstudiospa.com/api/v1/campaigns/:id/send

What: 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.

Fix: Reject /send when status is already SENT (409), require an explicit confirmation flag, and make send idempotent per campaign.

Note: Re-ran the exact reproduction against live dev app (owner_admin token). Both defects from BUG #25 are now guarded: 1) No-confirmation instant send — FIXED. Created DRAFT campaign cmrziir1k002iryj2f20r8mi8. POST /api/v1/campaigns/{id}/send with empty body {} now returns HTTP 400 {"error":{"code":"BAD_REQUEST","message":"Confirm campaign send before sending."}} instead of flipping to SENT. A DRAFT only sends when body includes {"confirm"

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.
✓ FixedMEDfindingS1-26Campaign performance stats are client-writable and persist (fabricated analytics)
details

Where: POST https://dev.ritualstudiospa.com/api/v1/campaigns (stats field)

What: The create endpoint accepts a caller-supplied 'stats' object verbatim and persists it. These stats feed the marketing/analytics reporting (opens/clicks/revenue/sent).

Fix: Strip 'stats' from create/update payloads; compute stats only server-side from actual delivery/engagement events.

Note: Re-ran the exact repro on the live app. POST https://dev.ritualstudiospa.com/api/v1/campaigns (owner_admin) with body containing an injected stats object {"sent":999999,"opens":123456,"clicks":7777,"totalRevenue":424242} -> HTTP 201, but the response body returned "stats":null (the client-supplied stats were stripped, not persisted). GET /campaigns/cmrziizwy002xryj2ksa6o5e5 -> HTTP 200, still "stats":null, confirming the injected

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.
✓ FixedMEDfindingS1-27Campaign with empty subject and empty body can be sent (no content validation before send)
details

Where: POST /api/v1/campaigns then POST /api/v1/campaigns/:id/send

What: A campaign whose email channel has subject:"" and body:"" is created and then successfully 'sent' with no validation that there is anything to send.

Fix: Validate that at least one enabled channel has non-empty required content (subject+body for email) before allowing /send.

Note: Re-ran the exact reproduction against live dev app as owner_admin. 1) POST /api/v1/campaigns {"name":"QA empty reverify 0712","primaryGoal":"x","audience":"All Members","channel":"EMAIL","channels":{"email":{"enabled":true,"subject":"","body":""}}} -> 201, status DRAFT (creating an empty draft is still allowed, as before). 2) POST /api/v1/camp

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.)
✓ FixedMEDfindingS1-28Front-desk staff can create AND send company-wide campaigns to All Members
details

Where: POST /api/v1/campaigns and /api/v1/campaigns/:id/send as employee (Priya, front-desk)

What: 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.)

Fix: Gate campaign create/send behind an owner/manager permission (real role), not the coarse ADMIN JWT claim.

Note: Re-ran BUG #28 exact repro against live https://dev.ritualstudiospa.com with a FRESH Priya (front-desk employee) token (re-logged-in to rule out expiry). Both the create and send actions are now blocked: - POST /api/v1/campaigns as Priya (audience "All Members") -> HTTP 403 {"error":{"code":"FORBIDDEN","message":"Manager or owner access is required."}} (previously 201 DRAFT). - POST /api/v1/campaigns/:id/send as Priya on a REAL owne

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.
✓ FixedMEDfindingS1-29PTO request endpoint performs no date-range validation (accepts end-before-start, past dates, and multi-year ranges)
details

Where: POST https://dev.ritualstudiospa.com/api/v1/staff/me/pto (as employee Priya)

What: 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.

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.

Note: Fixed and verified on dev: PTO rejects past dates, end-before-start, long spans, and overlapping requests.

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.
✓ FixedMEDfindingS1-30PTO endpoint does not detect overlapping requests
details

Where: POST https://dev.ritualstudiospa.com/api/v1/staff/me/pto

What: A new PTO request that overlaps an existing APPROVED leave for the same staff member is accepted rather than rejected or flagged.

Fix: On create, check for date overlap against the staff member's existing PENDING/APPROVED requests and reject or flag as a conflict.

Note: Re-ran the exact repro on the live dev app. Precondition still holds: Priya has APPROVED VACATION 2026-08-10..2026-08-12 (id cmrzef35e0057ryg706ablpa6). POST https://dev.ritualstudiospa.com/api/v1/staff/me/pto with body {"startDate":"2026-08-11","endDate":"2026-08-11","type":"VACATION"} as Priya now returns HTTP 400 with body {"error":{"code":"BAD_REQUEST","message":"This time-off request ove

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.
✓ FixedMEDfindingS1-31Malformed date string in PTO request causes HTTP 500 Internal Server Error
details

Where: POST https://dev.ritualstudiospa.com/api/v1/staff/me/pto

What: Submitting a non-parseable date string throws an unhandled server error (500) instead of a 400 validation response.

Fix: Validate date format (e.g. zod date/ISO string) before parsing so bad input yields a 400 VALIDATION_ERROR, not a 500.

Note: Ran the exact repro: POST https://dev.ritualstudiospa.com/api/v1/staff/me/pto with employee (Priya) Bearer token and body {"type":"VACATION","startDate":"not-a-date","endDate":"also-bad","reason":"QA garbage"}. Result this time: HTTP 400 (not 500) with body {"error":{"code":"VALIDATION_ERROR","message":"Validation failed","details":[{"path":"e

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.
✓ FixedMEDfindingS1-32All 21 onboarding tasks are born OVERDUE because due dates are anchored to hireDate, not assignment date
details

Where: GET https://dev.ritualstudiospa.com/api/v1/staff/me/onboarding (employee Priya)

What: 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.

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.

Note: Re-ran GET /api/v1/staff/me/onboarding as employee Priya (HTTP 200). The reported behavior no longer occurs. Original bug: summary {total:21, completed:0, overdue:21, progress:0}; every task dueDate anchored to hireDate (2025-09-01), 2025-09-01..2025-09-15, all 21 born OVERDUE. Now (today 2026-07-24): summary {total:21, completed:1, overdue:4, blocked:0, progress:5}. Status counts across the 21 items: OVERDUE 4, NOT_STARTED 16, COMPLETED 1. Due dates are rebased forward to the assignment windo

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.
✓ FixedMEDfindingS1-33Roster and create-employee responses leak a live password-reset link for other staff
details

Where: GET https://dev.ritualstudiospa.com/api/v1/staff/team and POST https://dev.ritualstudiospa.com/api/v1/staff/profiles

What: 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.

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.

Note: Fixed and verified on dev: employee roster/create responses no longer expose setup/reset links; invite links only appear on explicit invite/reset flows.

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.
✓ FixedMEDfindingS1-34Access endpoint accepts arbitrary unvalidated accessLevel / accessRoles values
details

Where: PATCH https://dev.ritualstudiospa.com/api/v1/staff/profiles/:id/access

What: The endpoint persists any string for accessLevel and any array for accessRoles with no allow-list check, producing nonsensical/undefined privilege states.

Fix: Validate accessLevel against the known enum (Employee/Manager/Owner) and accessRoles against the defined role set; reject unknown values.

Note: Fixed and verified on dev: staff access roles/levels are allow-listed and invalid role values return 400.

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.)
✓ FixedMEDfindingS1-35Malformed JSON body returns 500 INTERNAL_ERROR instead of 400 (systemic, all write endpoints)
details

Where: POST/PATCH on users/me, bookings, staff/me/pto, billing/checkout, messages

What: 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.

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.

Note: Re-ran the exact reproduction on live https://dev.ritualstudiospa.com. All 5 write endpoints now return a clean HTTP 400 instead of 500 INTERNAL_ERROR when sent malformed JSON with content-type: application/json: - PATCH /api/v1/users/me -d '{bad json' -> HTTP 400 {"error":{"code":"INVALID_JSON","message":"Request body must be valid JSON"}} - POST /api/v1/bookings -d '{bad' -> HTTP 400 {"error":{"code":"INVALID_JSO

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.
✓ FixedMEDfindingS1-36Invalid date strings crash with 500 instead of 400 on bookings and PTO
details

Where: POST /api/v1/bookings (date), POST /api/v1/staff/me/pto (startDate/endDate)

What: 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.

Fix: Use a date-format refinement (regex YYYY-MM-DD + valid-date check) in the Zod schema so bad dates return 400.

Note: Re-ran exact repro on live app. TEST 1: POST /api/v1/bookings {"locationId":"loc_primary","serviceId":"svc_contrast","date":"not-a-date","time":"03:00"} (member_alice) -> HTTP 400, body {"error":{"code":"VALIDATION_ERROR","message":"Validation failed","details":[{"path":"date","message":"Date must be YYYY-MM-DD"},{&

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'.)
✓ FixedMEDfindingS1-37Nonexistent but well-formed IDs return 500 instead of 404/400 (inconsistent with serviceId)
details

Where: POST /api/v1/bookings (locationId), POST /api/v1/messages (recipientId)

What: 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.

Fix: Look up location and recipient before use and return 404 when not found, matching the serviceId path.

Note: Bug #37 no longer reproduces — nonexistent-but-well-formed IDs now return clean 404s consistently, matching the serviceId behavior. BOOKINGS (POST /api/v1/bookings): The booking schema was changed to require `date`+`time` (not `startTime`). With the correct payload shape so the request actually reaches the ID lookup: - Bad locationId + valid serviceId: {"locationId":"loc_nonexistent","serviceId":"svc_contrast","date":"2026-07-27","

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"}.
✓ FixedMEDfindingS1-38PTO request with endDate before startDate is accepted
details

Where: POST /api/v1/staff/me/pto

What: No date-range validation: a request whose endDate precedes startDate is created with status PENDING (201).

Fix: Add a schema refinement requiring endDate >= startDate; reject with 400 otherwise.

Note: Re-ran the exact repro: POST https://dev.ritualstudiospa.com/api/v1/staff/me/pto with Priya (employee, JWT ADMIN) Bearer token and body {"type":"VACATION","startDate":"2026-12-10","endDate":"2026-12-01"}. Response is now HTTP 400 (previously 201) with body: {"error":{"code":"VALIDATION_ERROR","message":"Validation failed","details":[{"path":"endDate","m

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).
✓ FixedMEDfindingS1-39Access token remains fully valid after logout (no server-side access-token revocation)
details

Where: POST /api/v1/auth/logout ; GET /api/v1/users/me

What: 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.

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.

Note: Re-ran the exact reproduction on Alice's live session (fresh login tokens, whole sequence in ~2s, well under the 15-min access-token TTL). (A) GET /api/v1/users/me with the access token BEFORE logout => HTTP 200 (valid). (B) POST /api/v1/auth/logout with Authorization: Bearer <access> and refreshToken in body => HTTP 200 {"message":"Logged out"}. (C) GET /api/v1/users/me REUSING the SAME access token AFTER logout => HTTP 401 {"error":{"code"

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.
✓ FixedMEDfindingS1-40Login rate limiter blocks legitimate correct-credential logins from the whole IP, and stays tripped for many minutes
details

Where: POST /api/v1/auth/login (and shared across /auth/register, /auth/forgot-password, /auth/reset-password)

What: 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.

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.

Note: Re-ran the exact evidence repro on live dev app. Fired ~640+ bad-credential login probes across multiple bursts (30 seq, 60 seq, 100 concurrent, 300 concurrent, 150 seq) — EVERY completed request returned HTTP 401, and NOT a single HTTP 429/RATE_LIMITED was ever observed. Immediately after the bursts, the exact evidence case POST /api/v1/auth/login {emma@example.com / password123} returned HTTP 200 with fresh tokens (repeated success; alice also 200). Shared-limiter endpoints in the same window:

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.
✓ FixedMEDfindingS1-41A REJECTED phone update (HTTP 400) still persists the invalid value and un-verifies the member's phone
details

Where: PATCH /api/v1/users/me (phone) + Profile view (?view=profile)

What: 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.

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.

Note: Exact reproduction re-run as Alice (MEMBER) via curl against the live dev API. BEFORE (GET /api/v1/users/me, HTTP 200): phone="253-555-0101", pendingPhone="2535550199", phoneVerifiedAt="2026-07-18T16:48:46.739Z", verification.phoneVerified=false. (The false/pending state here is leftover from a prior test round, not from this run.) PATCH /api/v1/users/me {"phone":"abcnotaphone!!!"} -> HTTP 400, body {"error":{"code":"

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.
✓ FixedMEDfindingS1-42Home 'Next Session' widget on the Ritual Journal / OS view falsely says 'No upcoming bookings'
details

Where: ?view=journal (and ?view=my-wallet, which both render the 'Ritual Studio OS' home) NEXT SESSION card

What: 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.

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.

Note: Re-ran exact repro live on 2026-07-26 as Alice (fresh login). API GET /bookings/mine: 25 CONFIRMED bookings; earliest upcoming after today is Contrast Therapy 2026-08-01 10:00 Communal Room (original "today"/07-25 bookings have rolled into the past). Rendered NEXT SESSION widget via Playwright (token in localStorage), reading live DOM innerText on all three homes: ?view=dashboard NEXT SESSION = "Contrast Therapy · Sat, Aug 1 • 10:00 · COMMUNAL ROOM"; ?view=journal (Ritual Stu

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.
✓ FixedMEDfindingS1-43Invoice header amount ($239) contradicts its own line total ($199) on membership invoices
details

Where: GET /api/v1/billing/invoices (inv_01, inv_02) shown in Profile 'Recent Invoices' / Wallet

What: 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.

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.

Note: Re-ran exact repro as Alice: `GET https://dev.ritualstudiospa.com/api/v1/billing/invoices` -> HTTP 200. Both membership invoices now self-consistent: inv_02 {items:"Complete — Mar 2026", amount:199, subtotalAmount:199, discountAmount:0, taxAmount:0, totalAmount:199, amountPaid:199, status:PAID}; inv_01 {items:"Complete — Feb 2026", amount:199, subtotalAmount:199, discountAmount:0, taxAmount:0, totalAmount:199, amountPaid:199, status:PAID}. Previously both reported top-leve

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.
✓ FixedMEDfindingS2-13nginx returns raw HTML error pages (not the JSON error envelope) for API routes on 502/504
details

Where: nginx/1.24.0 error responses on /api/v1/* during upstream outage

What: When the upstream is down, the API returns Content-Type text/html with an nginx HTML body instead of the app's {"error":{"code":...}} JSON. SPA/JSON clients that JSON.parse the response will throw on '<html>...' exactly when the backend is failing, producing blank screens / unhandled errors rather than a graceful message.

Fix: Configure nginx error_page for 502/504/503 to emit a JSON body with the standard error envelope and application/json content-type.

Note: Fixed and verified on dev: nginx API upstream failures now return JSON error envelope instead of raw HTML.

evidence
Captured bodies during outage: '<html><head><title>504 Gateway Time-out</title></head>...<center>nginx/1.24.0 (Ubuntu)</center>' and '<html><head><title>502 Bad Gateway</title></head>...'. 502 body size 166 bytes returned for GET /contacts, /contacts?offset=5, /contacts?limit=5.
✓ FixedMEDfindingS2-14Members → Pipeline view (?view=crm-pipeline) renders a completely blank content area
details

Where: ?view=crm-pipeline (Members > PIPELINE sub-nav); owner_admin

What: Loading the documented crm-pipeline view returns doc=200 but renders only the left nav and the top search bar — the entire main content region is empty. Its sibling Members tabs render fully: DIRECTORY (crm-members, bodyLen 2807) and LOYALTY (crm-loyalty, bodyLen 954, real earning-rules/rewards content).

Fix: Fix the crm-pipeline route/component to render the pipeline board (or an empty-state placeholder if there is genuinely no pipeline data).

Note: Fixed and verified on dev browser: ?view=crm-pipeline routes to CRM pipeline/lead content and is not blank.

evidence
3/3 clean runs: `run1 doc=200 bodyLen=414 contentAfterSearchBar=""`, run2/run3 identical. Screenshot qa-view-crm-pipeline.png shows the sidebar with Members highlighted and a fully empty white content pane. Only h-heading present is 'RITUAL STUDIO OS' (the chrome); no view heading. No 4xx/5xx /api calls fired — the view simply renders nothing.
✓ FixedMEDfindingS2-15Lifecycle timestamp flags are never cleared on transition — a single booking accumulates mutually-exclusive states (noShowAt + checkedInAt + cancelledAt)
details

Where: POST /bookings/:id/{no-show,checkin,cancel}

What: Each transition only sets its own timestamp and never clears the others, so a booking can simultaneously carry noShowAt, checkedInAt and cancelledAt. In particular a NO_SHOW that is later checked in keeps noShowAt set — corrupting no-show tracking (noShows90d), which drives member risk/identity flags.

Fix: On each transition, clear the timestamps that no longer apply, or model a single explicit status field as the source of truth.

Note: Fixed and verified on dev: lifecycle transitions clear mutually exclusive state and terminal bookings cannot be checked in/cancelled incorrectly.

evidence
Booking cms27epbg002qryr8y67x4lv4: CONFIRMED -> no-show (noShowAt=2026-07-26T19:41:31.581Z) -> checkin -> status=CONFIRMED with checkedInAt=2026-07-26T19:41:31.913Z AND noShowAt still 2026-07-26T19:41:31.581Z -> cancel -> final: checkedInAt + cancelledAt(mode=refund) + noShowAt all populated at once. (Note: no-show AFTER checkin is correctly blocked with 400, so the guard is asymmetric.)
✓ FixedMEDfindingS2-16Freeze/pause succeeds on plans that explicitly disallow freezing (freezeAllowed=false on every plan)
details

Where: POST /api/v1/contacts/:id/freeze (contact cmml74rxg0008ryidpvj3svfe / u_alice)

What: Freezing Alice returned HTTP 200 and set status=FROZEN even though her Complete plan has freezeAllowed=false — and in fact ALL plans in /pricing/plans have freezeAllowed=false, so no plan is supposed to be freezable, yet freeze always succeeds. The endpoint takes no freeze duration / end-date, and freezing did NOT push out the billing date (nextBillDate went 2026-08-23 -> 2026-08-22), so a "frozen" member keeps getting billed on schedule.

Fix: Reject freeze with 400 when the target plan has freezeAllowed=false; when freeze IS allowed, require a duration and extend nextBillDate / pause billing accordingly.

Note: Fixed and verified on dev: membership freeze rejects plans where freezeAllowed=false.

evidence
pricing/plans: every plan has "freezeAllowed":false. POST /contacts/<alice>/freeze -> 200 with body "status":"FROZEN". billing/summary while frozen still returned nextBillDate 2026-08-22 (not extended). Unfreeze -> 200 status ACTIVE.
✓ FixedMEDfindingS2-17Cancellation effective date is now+30 days, landing AFTER the next billing date — member is re-billed a full month then cancelled days later with no refund
details

Where: POST /api/v1/contacts/:id/cancel-membership vs GET billing/summary nextBillDate

What: Cancellation was scheduled for exactly requestedAt+30 days (2026-07-26 -> 2026-08-25) rather than aligned to the end of the current paid term. Alice's computed next bill date is 2026-08-22, which falls BEFORE the cancellation effective date. So she gets charged for a new full month on 08-22 and is cancelled 3 days into it on 08-25, and preview text explicitly states "There is no mid-cycle refund."

Fix: Set cancellationEffectiveAt to the end of the current billing period (the nextBillDate), not a flat +30 days, so no extra renewal is charged.

Note: Fixed and verified on dev: cancellation effective dates align to paid-cycle end/next bill fallback instead of now+30 after billing.

evidence
cancel response cancellationEffectiveAt=2026-08-25; billing/summary nextBillDate=2026-08-22; preview message: "...take effect on August 22, 2026. There is no mid-cycle refund."
✓ FixedMEDfindingS2-18/credits/history conflates two different currencies (loyalty points vs session credits) in one unlabeled amount column
details

Where: GET /api/v1/credits/history

What: A single history feed interleaves LOYALTY_EARN/LOYALTY_REDEEM rows whose amount is loyalty points (e.g. 600, -600) with USAGE/ADJUSTMENT/PURCHASE rows whose amount is session credits (±1), under one 'amount' field with no unit/currency discriminator. Aggregating the feed is meaningless.

Fix: Add an explicit currency/unit field (POINTS vs CREDITS) to each ledger row, or split into two endpoints (credits history vs loyalty history), so each stream reconciles independently to its balance.

Note: Fixed and verified on dev: credits history labels unit/currency/amount type so loyalty points and sessions are distinct.

evidence
Bob feed: by-type sums {ADJUSTMENT:9, USAGE:-17, PURCHASE:14, LOYALTY_EARN:3700, LOYALTY_REDEEM:1}; naive sum(amount)=3707 which matches neither credits (6) nor loyaltyPoints (2540). Only after splitting by currency does it reconcile: credit rows 9-17+14=6 = credits; loyalty rows are the ones broken per finding above. A LOYALTY_EARN of 600 sits directly beside a USAGE of -1 in the same list.
✓ FixedMEDfindingS2-19reports/finance headline totalFailed ($318) does not reconcile with the 5 failedInvoices it returns ($865)
details

Where: GET /api/v1/reports/finance (fields: totalFailed, failedCount, failedInvoices[])

What: On the same finance report, the 'Failed' KPI shows totalFailed=318 with failedCount=5, but the failedInvoices detail array it returns contains 5 rows whose amountDue sums to 865. The count reconciles (5==5) but the dollar total does not — the headline only sums a subset of the listed failures. 318 = 189+129 (exactly 2 of the 5 rows), so the headline aggregates ~2 of the 5 failed invoices while the count and the drill-down list all 5.

Fix: Compute totalFailed from the same query/window that produces failedInvoices (sum failedInvoices.amountDue) so headline, count, and detail all derive from one source. If totalFailed is intentionally scoped to a different window than the detail list, scope failedInvoices/failedCount identically so the three agree.

Note: Fixed and verified on dev: reports/finance totalFailed reconciles with failed invoice sums ($865 in current dev).

evidence
GET /reports/finance (fetched 3x, stable): {"totalFailed":318,"failedCount":5,"failedInvoices":[{"customer":"Sarah Jenkins","amountDue":189},{"customer":"Jake Morrison","amountDue":189},{"customer":"Bob Martin","amountDue":169},{"customer":"Amara James","amountDue":189},{"customer":"Bob Martin","amountDue":129}]}. Sum of amountDue = 865, not 318 (189+129=318).
✓ FixedMEDfindingS2-20CRM 'days since last visit' (lastVisitDays) is stale/inconsistent with lastVisit timestamp for every contact
details

Where: GET /api/v1/contacts (fields lastVisit vs lastVisitDays), current date 2026-07-26

What: lastVisitDays does not match the lastVisit timestamp on the same record for any of the 20 contacts checked. Two patterns: (a) March visitors show tiny values — Alice Walker lastVisit=2026-03-30 (actually 118 days ago) reports lastVisitDays=1; Bob Martin lastVisit=2026-03-25 (123 days) reports 6; Carlos Mendes 2026-03-10 (138 days) reports 0; Emma Liu 2026-03-22 (126 days) reports 9. (b) July visitors are uniformly ~5 days too low — Avery lastVisit 2026-07-20 computes 6 but field=1; Blake 07-16 computes 10 field=5; Amara 07-05 computes 21 field=16; the offset is exactly 5 across all July rows, i.e. the field appears frozen at a snapshot date of 2026-07-21 rather than 'now'.

Fix: Compute lastVisitDays on read as floor((now - lastVisit)/1d) instead of storing/seeding a static value, or refresh the derived field on a daily job. Ensure the reference is the current date, not a build/seed snapshot.

Note: Fixed and verified on dev: lastVisitDays is computed live from lastVisit instead of stale stored values.

evidence
From GET /contacts data[]: Alice Walker {lastVisit:'2026-03-30', lastVisitDays:1} (computed 118); Bob Martin {'2026-03-25', 6} (123); Carlos Mendes {'2026-03-10', 0} (138); Avery Simpson {'2026-07-20', 1} (6); Blake Evans {'2026-07-16', 5} (10) — 16/16 contacts with a lastVisit were MISMATCH; all July rows off by exactly 5 days.
✓ FixedMEDfindingS2-21Booking lifecycle (create / check-in / cancel) generates no confirmation, notification or reminder record
details

Where: POST /api/v1/bookings, /bookings/:id/checkin, /bookings/:id/cancel; GET /api/v1/messages; settings/comm-settings

What: A member booking a session receives no confirmation and no reminder through any channel; the same is true on check-in and cancellation. There is no queryable notification store at all (GET /api/v1/notifications = 404), and the messages table is untouched by booking events.

Fix: On booking create/checkin/cancel, enqueue a confirmation/reminder honoring comm-settings; expose a notifications or scheduled-reminders resource so delivery is observable.

Note: Fixed and verified on dev: QA booking creation produces visible BOOKING_CONFIRMATION notification records.

evidence
Created booking cms276mqu007urysv3cm455xm for u_alice (HTTP 201). Alice's GET /messages count was 24 before booking and stayed 24 after booking, after check-in (owner POST /checkin 200), and after cancel (owner POST /cancel 200). Newest message remained cms26fwed001mrysvd1sp0ltn dated 2026-07-26T19:14 (pre-test). Meanwhile settings/comm-settings advertises defaultSmsReminders:true and defaultEmailReceipts:true.
✓ FixedMEDfindingS2-22email-receipt sends a 'receipt' for an UNPAID invoice (status PENDING, amountPaid 0)
details

Where: POST /api/v1/billing/invoices/:id/email-receipt and GET .../receipt

What: The receipt endpoint does not verify the invoice is paid. It happily emails a receipt for an invoice where nothing has been collected, and the generated receipt document itself shows status PENDING with amountPaid 0.

Fix: Reject email-receipt (and receipt generation) unless status is PAID (or partially paid, clearly labeled); never label a PENDING/unpaid invoice as a receipt.

Note: Fixed and verified on dev: real unpaid invoice email-receipt returns 400 with paid-receipt-only message.

evidence
Invoice cms27obn40003rywxxq9uevpp was PENDING (amountPaid:0, amountDue:169). POST /billing/invoices/cms27obn40003rywxxq9uevpp/email-receipt (owner) => 200 {"sent":true,"invoiceId":"cms27obn40003rywxxq9uevpp","to":"bob@example.com"}. GET .../receipt => 200 with receiptNumber 'RIT-20260726-9UEVPP', status 'PENDING', amountPaid 0, netAmount 0.
✓ FixedMEDfindingS2-23Admin message with channel=EMAIL fires a real SendGrid send in the DEV environment (no sandbox/suppression)
details

Where: POST /api/v1/messages (channel:EMAIL)

What: On the DEV host, composing a message with channel EMAIL immediately performs a real outbound send through the live SendGrid integration to the recipient's actual email address, returning a real provider message id. There is no dev/test suppression.

Fix: Gate real SendGrid sends behind an environment flag; in DEV route to a sink/sandbox or suppress delivery while still recording the message; restrict outbound-email composition to appropriate roles.

Note: Fixed and verified on dev: manual admin EMAIL messages are stored as DEV_PREVIEW/queued preview records and do not call SendGrid.

evidence
POST /messages {subject:'qa-email-test', recipientId:'u_alice', channel:'EMAIL'} (owner) => created message with deliverySource:'EMAIL', deliveryStatus:'SENT', deliveryProvider:'SendGrid', externalMessageId:'9ZjcztWVQhyqzMmWgG9FTw', toEmail:'alice@example.com'. Manual receipt send similarly returned {sent:true,to:'bob@example.com'}.
✓ FixedMEDfindingS2-24Sort (LTV / Tier / Rewards) reorders only the loaded 20 rows, producing incorrect global rankings
details

Where: crm-members view sort dropdown ('A-Z' button -> Tier & Status / LTV / Rewards); column header 'LTV' also

What: Choosing a sort re-sorts the 20 page-1 rows client-side (fires no server request), so a 'sort by LTV descending' silently ranks only the alphabetically-first 20 members, not all 88. The result looks like a full ranking but omits higher-value members.

Fix: Implement server-side sort on /contacts (add validated sortBy/order) and have the UI request sorted+paged data instead of sorting the local page.

Note: Fixed and verified on dev: LTV/tier/rewards sorting is handled by the backend globally, not just the first loaded rows.

evidence
UI 'sort by LTV' top rows: Carlos $8,900, Alice $4,850, Bob $3,400... But the true 2nd/3rd highest-LTV members are Nina Patel $7,200 (page 3) and Marco Reyes $5,400 (page 2) — computed from all 88 via API — and neither appears anywhere in the 'sorted' list. Clicking sort options fired no /contacts request (reqs=[]). Backend also ignores sort params entirely: sort/sortBy/order/sortOrder/orderBy/sort=-ltv all return identical name-ascending order.
✓ FixedMEDfindingS2-25Backend contacts search only matches name and email — phone, tag, notes, referral code, and address are ignored
details

Where: GET /api/v1/contacts?search=

What: The search box placeholder promises 'name, email, phone, or tag', but the server search only matches name and email. Phone, tags, notes, referral code, and billing address all return zero.

Fix: Extend the search predicate to include phone (normalized) and tags to match the advertised placeholder.

Note: Fixed and verified on dev: contact search includes phone, tags, notes, referral code, billing/address fields, and user fields.

evidence
With proper URL-encoding: search='alice' -> 1; search='example.com' -> 81 (email matches). But search='253-555-0101' -> 0, search='2535550101' -> 0, search='0101' -> 0 (Alice's real phone), search='family' -> 0 (tag), search='Prefers early' -> 0 (notes), search='ALICE-RITUAL' -> 0 (referral code), search='University Place' -> 0 (address). Tag filtering only works via the separate ?tag= param. SQL-injection payloads are safely handled (returned 0, no error).
✓ FixedMEDfindingS2-26Unpublished DRAFT blog posts are served to members via GET /content/blog (broken authorization / unpublished-content leak)
details

Where: GET https://dev.ritualstudiospa.com/api/v1/content/blog (Content Hub blog feed)

What: The blog feed applies no status filter for non-admin callers, so any MEMBER receives DRAFT (unpublished) posts with full body content, not just PUBLISHED ones. Work-in-progress or embargoed posts are exposed to end users before publication.

Fix: For non-admin roles, filter the blog query to status=PUBLISHED server-side; only owner/manager should receive drafts.

Note: Fixed and verified on dev: DRAFT blog posts are hidden from member list/detail routes.

evidence
As owner I created {"title":"QA_DRAFT_DELETEME","status":"DRAFT","content":"<p>unpublished secret</p>"} -> 201. Immediately GET /content/blog as member Alice (u_alice, JWT MEMBER) -> 200 returned 7 items including that DRAFT plus a second front-desk-created DRAFT ("EMP_POST_DELETEME"), each with status:"DRAFT" and full content ('unpublished secret'). With no drafts present the same call returns exactly the 5 PUBLISHED posts. Members are otherwise correctly blocked from /content/subscribers (403), so read gating exists elsewhere but not on draft filtering. Cleaned up (all test posts hard-deleted).
✓ FixedMEDfindingS2-27DELETE on knowledge/SOP is a soft-delete that leaves 'deleted' items visible in the staff Employee Hub library
details

Where: DELETE /api/v1/knowledge/:id and GET /api/v1/knowledge (Employee Hub SOP/Policy/Training panels)

What: DELETE does not remove a knowledge item; it returns 200 with the item flipped to status=ARCHIVED. The default GET /knowledge listing (and the Employee Hub, which fetches GET /knowledge?type=SOP|POLICY|TRAINING) applies NO status filter, so archived/'deleted' SOPs keep rendering in the staff resource library alongside active ones. Retired procedures never disappear.

Fix: Have the Employee Hub / default library call pass status=ACTIVE (or default-exclude ARCHIVED server-side), and offer a separate 'Archived' tab.

Note: Fixed and verified on dev: archived knowledge/SOP items are hidden by default and only show with status=ALL.

evidence
DELETE /knowledge/<id> -> 200 body status:"ARCHIVED" (not removed). Subsequent GET /knowledge default listing = 24 items including 6 ARCHIVED (e.g. seed 'QA SOP 0724' plus my archived test items). ?status=ACTIVE correctly returns 18 (0 archived), proving the filter exists but the default/UI call omits it. Employee Hub UI (owner token, ?view=employee-hub) issued GET /knowledge?type=SOP / ?type=POLICY / ?type=TRAINING (no status param) and rendered the archived items (archived-title probe = true). Contrast: blog DELETE is a hard delete (204, gone), so the two content surfaces behave inconsistently.
✓ FixedMEDfindingS2-28comm-settings quiet-hours times are not validated (accepts "99:99", "25:77", "not-a-time")
details

Where: PATCH https://dev.ritualstudiospa.com/api/v1/settings/comm-settings

What: quietStart/quietEnd accept arbitrary strings and persist them (200). smsQuietHoursEnabled IS type-checked (rejects "yes" with 400 VALIDATION_ERROR), so validation exists on the endpoint but the time fields have no HH:MM format check.

Fix: Add HH:MM regex validation to quietStart/quietEnd (and reject quietStart==quietEnd or otherwise document wrap-around semantics).

Note: Fixed and verified on dev: quiet-hours times validate HH:MM and reject invalid/equal ranges.

evidence
PATCH {"quietStart":"99:99","quietEnd":"07:00"} -> 200, read-back quietStart="99:99"; PATCH {"quietStart":"not-a-time","quietEnd":"25:77"} -> 200, persisted; PATCH {"smsQuietHoursEnabled":"yes"} -> 400 (boolean enforced). Restored to 21:00/07:00.
✓ FixedMEDfindingS2-29Backend data outage silently shows fabricated placeholder data instead of an error state
details

Where: Member dashboard (?view=dashboard) + membership view; any data endpoint returning 5xx

What: When all data endpoints are forced to 500 (users/me kept OK), the member dashboard renders a fully-populated, healthy-looking screen with fabricated numbers and NO error/retry indicator anywhere. Real values are replaced by sentinel placeholders: 'SESSIONS REMAINING' shows 999 (real = 'Unlimited'), 'GUEST PASSES' shows 0 (real = 1), 'NEXT SESSION' shows 'No upcoming bookings' (real = a booked Contrast Therapy session). A member during an outage cannot tell the data is stale/wrong.

Fix: On data-fetch failure, render an explicit error state (e.g. 'Couldn't load your membership — retry') rather than substituting default/sentinel values; never let the 999 'Unlimited' sentinel leak to the UI.

Note: Fixed and verified on dev browser: forced billing outage shows Membership Data Unavailable, hides upcoming bookings, and no 999/fake data leaks.

evidence
Forced route fulfill 500 on **/api/v1/** (except auth + users/me). Full innerText captured: 'MEMBERSHIP STATUS / Complete / 999 / SESSIONS REMAINING / 0 / GUEST PASSES / NEXT SESSION / No upcoming bookings.' vs real dashboard 'Complete Unlimited SESSIONS REMAINING 1 GUEST PASSES ... Contrast Therapy Sat, Aug 1'. Reproduced 3x (dashboard, membership view, isolated repro). pageErr=0, no 'error/try again/retry' text present. Scripts: err-ux-inject.mjs, q-full.mjs; screenshots in errux/inject-alice-dashboard-data500.png.
✓ FixedLOWfindingS1-44No upper bound on booking date - sessions accepted far in the future
details

Where: POST /api/v1/bookings (member_sarah)

What: Past sessions are correctly rejected ("This session time has already passed") but there is no forward-booking limit.

Fix: Enforce a reasonable booking-horizon cap (e.g. 30-90 days).

Note: Re-ran the exact repro as member_sarah (re-logged in since the provided token was expired/401). POST /api/v1/bookings with {locationId:"loc_primary", serviceId:"svc_contrast", date:"2099-01-01", time:"10:00", room:"Communal Room", guests:[]} now returns HTTP 400: {"error":{"code":"BAD_REQUEST","message":"Bookings can only be made up to 90 days in advance"}} — previously this returned HTTP 201 CONF

evidence
date="2099-01-01" time="10:00" -> HTTP 201 CONFIRMED (id cmrzf0yds008eryg7...).
✓ FixedLOWfindingS1-45billing/checkout ignores dryRun and persists real invoices
details

Where: POST /api/v1/billing/checkout with {"dryRun":true}

What: Sending dryRun:true does not preview - it creates real (PENDING) invoices in the member's billing history.

Fix: Honor dryRun by computing pricing without persisting an invoice/payment intent, or reject unknown flags explicitly.

Note: Re-ran exact repro as Sarah (u_sarah) on live dev app. Before: GET /billing/invoices returned 6 invoices. Then POST /api/v1/billing/checkout {"planId":"plan_essential","dryRun":true} -> HTTP 200 {"dryRun":true,"planId":"plan_essential","planName":"Essential","planType":"RECURRING","amount":189,"nextBillDate":"2026-05-30T00:00:00.000Z","bookingQuote":null}

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.
✓ FixedLOWfindingS1-46GET nonexistent contact returns 200 null; PATCH nonexistent returns 400 (should be 404)
details

Where: GET /api/v1/contacts/{badId} ; PATCH /api/v1/contacts/{badId}

What: 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.)

Fix: Return 404 for missing contacts on both GET and PATCH.

Note: Re-ran the exact BUG #46 reproduction on live app (owner_admin token). All previously-bad behaviors now return proper 404s: - GET /api/v1/contacts/nonexistent123 -> HTTP 404 {"error":{"code":"NOT_FOUND","message":"Contact not found"}} (was: 200 body null). - PATCH /api/v1/contacts/nonexistent123 {"name":"x"} -> HTTP 400 {"error":{"code":"VALIDATION_ERROR","message":"Validatio

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).
✓ FixedLOWfindingS1-47Campaign scheduledFor accepts dates far in the past
details

Where: POST /api/v1/campaigns (scheduledFor)

What: 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.

Fix: Validate scheduledFor is in the future when status is SCHEDULED.

Note: Re-ran the exact repro against live app. POST /api/v1/campaigns (owner_admin) with {"status":"SCHEDULED","scheduledFor":"2020-01-01T00:00:00.000Z",...} now returns HTTP 400 with body {"error":{"code":"BAD_REQUEST","message":"Scheduled campaigns must be set for a future time."}} and creates no record (previously 201 with the past date persisted). Control test confirms the endpoint still works for valid input

evidence
POST /campaigns {...,"status":"SCHEDULED","scheduledFor":"2020-01-01T00:00:00.000Z",...} -> 201 with "status":"SCHEDULED","scheduledFor":"2020-01-01T00:00:00.000Z".
✓ FixedLOWfindingS1-48No length limit / HTML sanitization on free-text fields (messages body/subject, pto reason) while name is capped at 100
details

Where: POST /api/v1/messages (subject, body), POST /api/v1/staff/me/pto (reason)

What: 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.

Fix: Apply consistent max-length limits to all free-text fields and strip/encode HTML on input.

Note: Fixed and verified on dev: message subject/body, contact name, PTO reason, and oversized payloads are validated; 70KB message body returns 413 JSON.

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)'.
✓ FixedLOWfindingS1-49billing/checkout returns misleading 'Plan is required' when a plan value is present but invalid or wrong-typed
details

Where: POST /api/v1/billing/checkout

What: Supplying an invalid plan string, or a numeric plan, returns the same 400 message as omitting it entirely ('Plan is required'), which is misleading.

Fix: Distinguish missing vs. unknown/invalid plan and return an accurate message (e.g. 'Unknown plan').

Note: Re-ran the exact repro on live dev (POST /api/v1/billing/checkout, member_alice JWT, all HTTP 400, auth OK). The endpoint actually consumes field `planId` (not `plan`) and now properly differentiates invalid vs missing: - {"planId":"nonexistent_plan_xyz"} -> 400 {"error":{"code":"BAD_REQUEST","message":"Unknown plan"}} - {"planId":12345} -> 400 {"error":{"code":"BAD_REQUEST","

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"}.
✓ FixedLOWfindingS1-50Next-billing-date differs between Wallet and Profile (off-by-one) and shows a date already in the past
details

Where: ?view=wallet vs ?view=profile (next billing date)

What: 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).

Fix: Format cycleEndsAt in a single consistent timezone across views and advance the displayed next-billing date past 'now'.

Note: Fixed and verified on dev build: Wallet/Profile billing dates share the same date helper and no longer render mismatched/off-by-one dates.

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.
✓ FixedLOWfindingS1-51Error response leaks internal CRM integration name (GoHighLevel) to the member
details

Where: PATCH /api/v1/users/me (invalid phone) 400 response body

What: 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.

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.

Note: Re-ran the exact repro as Alice: `PATCH /api/v1/users/me` with body `{"phone":"abcnotaphone!!!"}` against https://dev.ritualstudiospa.com/api/v1/users/me. Response now: HTTP 400 {"error":{"code":"BAD_REQUEST","message":"Phone must be a valid US phone number."}}. The GoHighLevel/CRM name and its raw upstream message are no longer leaked; the error is now a clean, generic validation message. Token was valid (400, not 401). No da

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"}}.
✓ FixedLOWfindingS2-30Documented nav-group view params silently fall back to the Studio Overview dashboard instead of their screen
details

Where: ?view=sales, marketing, operations, analytics, content, membership, my-schedule (owner_admin)

What: These documented view identifiers are nav-GROUP names, not real leaf views. Deep-linking to them renders the dashboard 'Studio Overview' (occupancy/bookings tiles) verbatim rather than the group's screen or a not-found. Real sidebar clicks instead route to distinct leaf ids that work: Sales→sales-cart ('Assisted Sale'), Marketing→crm-marketing, Operations→ops-tasks, Analytics→reports-ops, Content Hub→content-blog ('CONTENT HUB').

Fix: Redirect a group-name view param to the group's default leaf (e.g. analytics→reports-ops) or show a not-found, rather than silently rendering the dashboard.

Note: Fixed and verified on dev browser: documented view aliases route correctly and unknown views show Page Not Available.

evidence
For sales/marketing/content/analytics/membership/my-schedule the h1/h2 headings were identical to the dashboard: `heads=["RITUAL STUDIO OS","Studio Overview"]`; screenshot qa-view-analytics.png of ?view=analytics is pixel-for-pixel the Studio Overview dashboard with no nav item highlighted. Nav-click test showed the real destinations: `[Sales] url=/?view=sales-cart`, `[Analytics] url=/?view=reports-ops`, etc., all rendering their own content.
✓ FixedLOWfindingS2-31Employee portal fires an admin-only settings/roles request on every page load → 403 on every employee view
details

Where: Employee Hub portal (Priya, staff, JWT role=ADMIN) — every ?view=* load

What: Regardless of the requested view, the staff Employee Hub renders and issues GET /api/v1/settings/roles, which the backend rejects with 403 'Manager or owner access is required'. This produces a failed /api request and a console error on every single employee page load.

Fix: Gate the settings/roles fetch behind an actual manager/owner check on the client so the employee portal never calls it.

Note: Fixed and verified on dev: employee self-service no longer fires admin settings/roles on page load; Employee Hub privacy suite 6/6 passed.

evidence
14/14 Priya views logged `APIFAIL: GET 403 /api/v1/settings/roles :: {"error":{"code":"FORBIDDEN","message":"Manager or owner access is required."}}` (fired twice per load) and a matching console 'Failed to load resource: 403 (Forbidden)'. The body always renders the same 'Employee Hub HOME' regardless of the view param.
✓ FixedLOWfindingS2-32Sub-24px text tap targets on member views (VIEW ALL / UPDATE / Complete)
details

Where: Member views dashboard, membership, journal, profile at 375 and 414. Buttons: 'VIEW ALL' (52x16), 'UPDATE' (46x16), 'Complete' (63x20).

What: Several text-only action buttons render only ~16-20px tall, below the WCAG 2.2 (2.5.8) 24px minimum target size and well under the ~44px comfortable-touch guideline. Measured heights: VIEW ALL 16px, UPDATE 16px, Complete 20px.

Fix: Increase hit area to at least 24px (ideally 44px) via padding/min-height on these text buttons without changing visual font size.

Note: Fixed and verified on dev browser: member profile VIEW ALL/UPDATE/Complete tap targets are >=24px.

evidence
mobile-audit/results.json tinyTargets: e.g. member-profile-iphoneX {'button.text-xs.font-black «UPDATE»' 46x16}, {'button.text-sm.font-bold «Complete»' 63x20}, member-dashboard {'button.text-xs.text-evergreen «VIEW ALL»' 52x16}. Present across dashboard/membership/journal/profile.
✓ FixedLOWfindingS2-33Backend 502/504 surfaces raw unstyled nginx error page on mobile (no in-app error state)
details

Where: Observed on owner loads of calendar, analytics, crm-members, sales during testing (dev backend intermittently returns 502 Bad Gateway / 504 Gateway Time-out).

What: When the API/backend returns 502/504, the app does not render an in-app error boundary; the browser shows nginx's raw '502 Bad Gateway nginx/1.24.0 (Ubuntu)' page. That page has NO viewport meta, so on a 375px device it lays out at 980px (innerWidth reported 981/scrollWidth 980) — i.e. a zoomed-out, tiny, non-responsive error screen. sales load 504'd and left the SPA stuck mid-navigation.

Fix: Add a client error boundary / fetch-failure UI for 5xx responses so users get a styled, responsive 'something went wrong, retry' state; investigate the upstream 502/504s on the dev backend.

Note: Fixed and verified on dev: mobile/API upstream failure path now has app/nginx error states instead of raw nginx HTML.

evidence
owner-probe.mjs run: crm-members httpStatus 502, bodyText '502 Bad Gateway nginx/1.24.0 (Ubuntu)', metaViewport '(none)', innerW 981/scrollW 980; sales -> 'Failed to load resource: 504 (Gateway Time-out)' with page.evaluate failing (execution context destroyed). Also console 502 captured in mobile-audit/results.json for calendar/analytics on first pass. Direct curl to /api/v1/auth/login returned HTML 502 twice during the session, then 200 on retry.
✓ FixedLOWfindingS2-34Inconsistent price for the Complete plan across catalog, contact, invoice, and preview
details

Where: GET /pricing/plans, GET /contacts/:id (planPrice), GET billing/invoices, POST membership-change/preview (nextBillingPrice)

What: The same Complete plan is priced $249 in /pricing/plans and in preview nextBillingPrice, but $239 in the contact record's planPrice, and a paid invoice line reads "Complete — Mar 2026" $199. The preview quotes Alice a $249 next-billing price even though her locked contact rate is $239.

Fix: Have preview/nextBillingPrice read the member's locked contract price (planPrice) rather than the current catalog list price.

Note: Fixed and verified on dev: membership preview uses contact planPrice/cancellation metadata, keeping Complete pricing consistent.

evidence
pricing/plans plan_complete price=249; contact.planPrice=239; invoice inv_02 amount=199 items "Complete — Mar 2026"; preview complete->complete nextBillingPrice=249.
✓ FixedLOWfindingS2-35Cancel endpoint returns HTTP 200 for concurrent duplicate cancellations (not idempotent at the HTTP layer)
details

Where: POST /api/v1/bookings/:id/cancel

What: Firing 6 simultaneous cancels of the same booking returns two 200s and four 400s. Accounting stays correct (only one refund is applied / one ledger entry written), so there is no credit drift, but two requests both report a successful cancellation of the same booking.

Fix: Guard the cancel transition with a conditional update (only the first request that flips CONFIRMED->CANCELLED returns 200; subsequent ones return 409/400 already-cancelled) so exactly one success is reported.

Note: Fixed and verified on dev: concurrent duplicate cancellation serializes; one request succeeds and the duplicate returns 400.

evidence
Promise.all of 6 concurrent cancels on one booking -> statuses [200,200,400,400,400,400]; credits refunded = exactly 1 (5->6), single 'Cancellation refund' ledger row. So no double-refund, but two callers each received a 200 success for the same state transition.
✓ FixedLOWfindingS2-36'offset' query param silently ignored; repeated multi-value params rejected with 400
details

Where: GET /api/v1/contacts

What: ?offset=10 is accepted (200) but ignored — it returns page 1 unchanged (only page/limit are honored). Passing a query param twice (e.g. tag=family&tag=qa_temp_zz or status=ACTIVE&status=VIP) makes the value an array and fails validation with 400 'Expected string, received array'. Pagination validation itself is otherwise solid (limit>100, limit<1, page<1, non-numeric all 400).

Fix: Either support offset or reject it; and accept comma-separated or array values for tag/status if multi-select filtering is intended.

Note: Fixed and verified on dev: contacts offset and repeated/comma multi-value params are accepted.

evidence
?offset=10 -> 200, first row still 'Alice Walker' (same as default). Duplicated tag param -> 400 VALIDATION_ERROR 'Expected string, received array'; duplicated status -> 400 same. limit=1000 -> 400 'less than or equal to 100'; page=0/-1 -> 400; limit=abc -> 400.
✓ FixedLOWfindingS2-37Knowledge create accepts arbitrary non-enum type and status values (no enum validation)
details

Where: POST /api/v1/knowledge (owner/manager)

What: Required fields are validated (400 on missing type/category/title) but the enum values for `type` and `status` are not checked. Any string is persisted, producing knowledge items that don't match any category and, for status, escape both the ACTIVE and ARCHIVED filters.

Fix: Validate type/status against their enums with a 400 on invalid values, as is already done for required fields.

Note: Fixed and verified on dev: knowledge type/status enums reject invalid values like AESTHETICS.

evidence
POST /knowledge {"type":"BOGUS_TYPE","category":"x","title":"x","summary":"x","content":"x"} -> 201 with type:"BOGUS_TYPE". POST {...,"status":"BOGUS"} -> 201 with status:"BOGUS". Real enum types observed: SOP/POLICY/TRAINING/ONBOARDING/DOCUMENT_CATEGORY. (Malformed JSON here is correctly handled: 400 INVALID_JSON, unlike the systemic 500 on other write endpoints.)
✓ FixedLOWfindingS2-38Inconsistent write-gating between content surfaces: front-desk (JWT ADMIN) is blocked from editing the SOP library but CAN create/publish company blog posts
details

Where: POST /api/v1/knowledge vs POST /api/v1/content/blog (employee Priya, front-desk)

What: Knowledge write enforces an app-level role check (front-desk -> 403 'Manager or HR access is required to change the resource library'), but the blog-post write path has no equivalent secondary check, so the same front-desk token can author and publish company blog content. Same JWT-role=ADMIN root cause as known issues, but the two content surfaces gate it differently.

Fix: Apply the same Manager/HR (or per-permission) check on /content/blog and /content/subscribers write/read that already guards /knowledge writes.

Note: Fixed and verified on dev: front-desk employee blog create/publish returns 403 manager/owner required.

evidence
As Priya (u_staff_priya, front-desk, JWT ADMIN): POST /knowledge {type:POLICY,...} -> 403 {"code":"FORBIDDEN","message":"Manager or HR access is required to change the resource library."}. POST /content/blog {"title":"EMP_POST_DELETEME","status":"DRAFT",...} -> 201 (created). Members are correctly 403 on both. Cleaned up.
✓ FixedLOWfindingS2-39Front-desk employee can read full newsletter subscriber list including external (non-member) email addresses
details

Where: GET /api/v1/content/subscribers (employee Priya, front-desk)

What: The subscriber roster (marketing list) is readable by the front-desk ADMIN-JWT token. It contains external website signups (non-members) with names and emails, not just studio members. Members are correctly blocked.

Fix: Gate /content/subscribers behind Manager/HR or a marketing permission rather than raw JWT role.

Note: Fixed and verified on dev: front-desk employee subscriber-list read returns 403 manager/owner required.

evidence
GET /content/subscribers as Priya (front-desk) -> 200 with rows incl. ryan.ob@gmail.com (Ryan O'Brien), newsletter@techcorp.com (TechCorp HR), james@wilson.com (James Wilson), plus member emails; source:WEBSITE. As member Alice -> 403 FORBIDDEN. Same JWT-role=ADMIN root cause as known read-access issues, flagged here because the exposed data is an external marketing list.
✓ FixedLOWfindingS2-40No 404 / 'not found' / 'no access' state — unknown and unauthorized views silently fall back to home
details

Where: ?view=<anything> for all roles

What: Navigating to a nonexistent view (e.g. ?view=garbage-nonexistent-zzz) or a view the role cannot access (member visiting ?view=calendar, analytics, settings, crm-members, sales, marketing, operations, content, etc.) silently renders the user's own home overview. For members the bogus URL is left unchanged in the address bar; for staff it is rewritten to ?view=employee-hub-home. There is never a 'page not found' or 'you don't have access' message.

Fix: Add a not-found/no-access view for unrecognized or unauthorized ?view= values, and normalize the URL when falling back.

Note: Fixed and verified on dev browser: unknown admin/member views show Page Not Available instead of falling home.

evidence
Member (alice) across 23 known views: garbage view -> home (bodyLen 449, url stays ?view=garbage-nonexistent-zzz); calendar/analytics/settings/crm-*/sales/marketing/operations/content/journal all render member home shell (bodyLen ~440). Staff (priya) ?view=totally-fake-view-9999 -> rewritten to ?view=employee-hub-home. Scripts: err-ux-test.mjs, err-ux-test2.mjs (errux/member-views.json).

Runs / log

2026-07-26
Sweep 2 — deep (perf / concurrency / lifecycles) · 40 found
Deeper pass + flow-health report; 3 flows broken.
2026-07-24
Sweep 1 — full app · 51 found
Fleet across every surface; adversarially verified.