Access Control — Single Source of Truth¶
Last updated: June 2026 (revised — MongoDB references corrected, SelfInquiryPage.tsx deletion noted)
This document is the definitive reference for all access tiers, gate conditions, and grant paths. If there is ever a conflict between this file and another doc, this file wins. The code implementation lives in
src/utils/journeyGates.ts.
⚠️ File Naming Convention — Read Before Coding¶
As of April 2026 all page component files have been renamed to match their menu display names exactly. Before making any change that touches a page file, refer to this table:
| Menu name | File (old → new) | Route |
|---|---|---|
| Discovery | QuestionnairePage.tsx → DiscoveryPage.tsx |
/questionnaire |
| Life Design | LifeDesign.tsx → LifeDesignPage.tsx |
/life-design |
| Wellness | WellnessProfile.tsx → WellnessPage.tsx |
/wellness-profile |
| Financial | FinancialPlan.tsx → FinancialPage.tsx |
/financial-plan |
| Self Mastery | SelfInquiryPage.tsx → SelfMasteryPage.tsx |
/self-inquiry |
| People Blueprint | CorporatePage.tsx → PeopleBlueprintPage.tsx |
/corporate |
| Relationship Lens | ProfileCompare.tsx → RelationshipLensPage.tsx |
/compare |
Sub-folder step files are not renamed —
lifeDesign/,financialPlan/,wellnessProfile/,growthLoop/sub-directories keep their original names. Any future new page file MUST follow the convention:<MenuName>Page.tsxwith no spaces (PascalCase).
1. The Three Tiers¶
Free (default)¶
Every registered user starts here. No payment required, no expiry.
What they can access:
| Feature | Access |
|---|---|
| Registration + profile setup | ✅ Full |
| Personality assessment (45 questions) | ✅ Full |
| Report — Phase 01 Core Identity | ✅ Full |
| Report — Phases 02–13 | ⚠️ Locked preview (3–4 lines visible per phase, upgrade prompt shown) |
| Dashboard | ✅ Full |
| Find a Coach CTA | ⚠️ Visible but locked (upgrade prompt) |
| Workshops CTA | ⚠️ Visible but locked (upgrade prompt) |
| Wellness | ❌ Locked |
| Life Design (Future Studio) | ❌ Locked |
| Growth Loop | ❌ Locked |
| Financial | ❌ Locked |
| Relationship Lens (Compatibility Compare) | ❌ Locked |
| Self Mastery | ❌ Locked |
| People Blueprint | ❌ Locked |
| Coach Portal | ❌ N/A |
DB field: plan: 'free' (default, no planExpiresAt)
Explorer¶
Full platform access. Expires 1 year from grant (except admin-granted = permanent).
What they can access:
| Feature | Access |
|---|---|
| Everything in Free | ✅ Full (not locked) |
| Report — all 13 phases | ✅ Full |
Wellness (WellnessPage.tsx) |
✅ Requires Discovery completed |
Life Design / Future Studio (LifeDesignPage.tsx) |
✅ Requires Discovery completed |
Growth Loop (GrowthLoop.tsx) |
✅ Requires Discovery + Life Design completed |
Financial (FinancialPage.tsx) |
✅ Requires Discovery completed |
Relationship Lens (RelationshipLensPage.tsx) |
✅ Requires all 3 core stages + active plan |
Self Mastery (SelfMasteryPage.tsx) |
✅ Requires Discovery completed |
People Blueprint (PeopleBlueprintPage.tsx) |
✅ Requires Discovery + Life Design completed |
| Find a Coach | ✅ Full |
| Workshops | ✅ Full |
| PDF export | ✅ Full |
| Coach Portal | ❌ N/A |
DB fields: plan: 'explorer', planExpiresAt: <ISO date> (absent = permanent admin grant)
Coach¶
Everything in Explorer plus the Coach Portal. Plan set by admin after payment is confirmed — not automatically by webhook.
What they can access:
| Feature | Access |
|---|---|
| Everything in Explorer | ✅ Full |
| Coach Portal | ✅ Full (clients, cohorts, earnings, workshops) |
| Create & manage workshops | ✅ |
| Referral link (20% commission) | ✅ |
| Coach training materials | ✅ |
DB fields: plan: 'coach', planExpiresAt: <ISO date> (managed by Stripe Subscription Schedule)
2. How to Get Explorer Access¶
There are 5 paths to an Explorer plan. All result in the same DB state — the source only affects expiry:
| Path | Who acts | How | Expiry |
|---|---|---|---|
| Self-pay (Stripe) | User on /pricing → pays RM 99 |
Stripe webhook sets plan: 'explorer' + planExpiresAt (+1 year) |
1 year, auto-renews |
| Discount promo code | User applies percent_off or fixed_off code at /pricing |
Stripe Checkout runs with Stripe coupon applied; webhook sets plan | 1 year |
| Free-access promo code | User applies free_access code at /pricing |
redeemFreeAccessCode() in frontend calls backend directly — no Stripe session |
1 year from redemption |
| Admin grant | Platform Admin → Admin > Users > plan dropdown | POST /api/admin/change-plan sets plan: 'explorer' + planExpiresAt (+1 year from grant date) |
1 year from grant |
| Org plan activation | Platform Admin activates the org's plan | Batch-sets plan: 'explorer' on all org members |
While org subscription active |
Key distinction for promo codes:
free_accesstype → skips Stripe entirely; plan set directly by backendpercent_off/fixed_offtype → Stripe Checkout still runs; coupon reduces the charge amount
3. How to Get Coach Access¶
The coach onboarding has 5 distinct stages — it is never automatic:
1. User fills in /coach-apply form
└─ coachApplications doc created (status: 'pending')
2. Admin reviews → clicks "Pre-Approve"
└─ coachApplications → status: 'approved_pending_payment'
user doc: coachAppStatus = 'approved_pending_payment'
"Coach Portal" nav link appears for this user
3. Coach pays RM 599 via Stripe (button shown inside Coach Portal)
└─ Stripe webhook (checkout.session.completed):
coachApplications → status: 'payment_received'
user doc: coachAppStatus = 'payment_received'
Stripe SubscriptionSchedule created (RM 599 yr1 → RM 199/yr)
4. Admin sees "Activate Coach" button → clicks it
└─ coaches doc created
user: plan = 'coach', coachAppStatus cleared
5. Full Coach Portal unlocked
CoachPortal screens by coachAppStatus:
coachAppStatus value |
Screen shown in CoachPortal |
|---|---|
'approved_pending_payment' |
Payment gate — RM 599 Stripe button + promo code option |
'payment_received' |
"Payment Confirmed — waiting for admin activation" |
(none, but plan === 'coach') |
"Activating portal…" fallback screen |
(none, no plan: 'coach') |
"Not a registered coach" + Apply link |
4. Gate Logic (code reference)¶
All gate functions live in src/utils/journeyGates.ts. Both ProtectedRoute.tsx (page-level redirect) and Layout.tsx (nav lock icons) import exclusively from this file.
| Function | Condition | Used for |
|---|---|---|
hasPaidAccess(profile) |
role === 'admin' OR plan === 'coach' OR (plan === 'explorer' AND not expired) |
Base paid check used by all other gates |
canAccessFutureStudio(profile) |
discoveryCompleted === true |
Life Design (LifeDesignPage.tsx) nav + ProtectedRoute |
canAccessGrowthLoop(profile) |
discoveryCompleted && lifeDesignCompleted |
Growth Loop (GrowthLoop.tsx) nav + ProtectedRoute |
canAccessWellnessProfile(profile) |
discoveryCompleted && hasPaidAccess |
Wellness (WellnessPage.tsx) nav + ProtectedRoute |
canAccessFinancialPlan(profile) |
discoveryCompleted && hasPaidAccess |
Financial (FinancialPage.tsx) nav + ProtectedRoute |
canAccessCompare(profile) |
discoveryCompleted && lifeDesignCompleted && growthLoopStarted && hasPaidAccess |
Relationship Lens (RelationshipLensPage.tsx) nav + GateScreen |
canAccessSelfInquiry(profile) |
discoveryCompleted && hasPaidAccess |
Self Mastery (SelfMasteryPage.tsx) nav + ProtectedRoute |
canAccessCorporate(profile) |
discoveryCompleted && lifeDesignCompleted && hasPaidAccess |
People Blueprint (PeopleBlueprintPage.tsx) nav + ProtectedRoute |
Rule: If you need to change who can access something, change it only in
journeyGates.ts. Do not add inlinehasPaidAccess()calls in Layout.tsx or elsewhere — the gate functions are the single source of truth.
5. Admin Bypass¶
A user with role: 'admin' bypasses all gates. hasPaidAccess() returns true, and all canAccess*() functions return true regardless of completion flags or plan.
Admin access is set directly in MongoDB — there is no UI flow for it.
6. Expiry Behaviour¶
| Scenario | Result |
|---|---|
planExpiresAt is absent |
Treated as permanent (admin-granted) — hasPaidAccess returns true |
planExpiresAt is a future date |
hasPaidAccess returns true |
planExpiresAt is a past date |
hasPaidAccess returns false — user sees locked screens as if on Free |
Stripe customer.subscription.deleted webhook fires |
Backend sets plan: 'free', clears planExpiresAt |
Stripe invoice.payment_succeeded (renewal cycle) |
Backend extends planExpiresAt by +1 year from today |
7. Organisation Access¶
Org members get plan: 'explorer' batch-set when the org plan is activated. There is no Stripe per-user charge — the org subscription is a manual B2B arrangement managed by the Platform Admin.
When the org plan is deactivated, all members are reverted to plan: 'free'. Their data is preserved.
8. Plan Change Architecture (April 2026)¶
Rule: all plan/payment state changes must be atomic — one backend call, one source of truth.
Before April 2026, the admin plan-change flow made 2–3 separate API calls from the frontend (PUT /api/users/:uid, POST /api/coaches, PUT /api/coaches/:id), each of which could fail independently. This caused:
- "Failed to update plan" alerts even when the plan had actually been saved (coach-sync threw after the user write succeeded)
PUT /api/coaches/undefined404 errors (MongoDB_idreturned but frontend read.id)- Inconsistent state where
user.planand thecoachescollection were out of sync
The fix: All plan changes now go through a single endpoint:
This endpoint atomically:
- Computes
planExpiresAt(+1 year forexplorer/coach;nullforfree) - Updates
user.plan,user.planExpiresAt, clearsuser.coachAppStatus - Upserts the
coachesdoc (status:active) when upgrading tocoach - Sets the
coachesdoc tostatus: inactivewhen downgrading fromcoach
Frontend entry point: updateUserPlan(uid, plan) in src/api/db.ts calls POST /api/admin/change-plan. AdminDashboard.tsx calls only this one function — no coach-sync side effects on the frontend.
Stripe webhook (/api/stripe/webhook) handles plan changes for self-serve purchases and remains the other write path for plan/planExpiresAt. These two paths (/api/admin/change-plan and Stripe webhook) are the only places in the codebase that may write plan state.
Defensive rule: Never call
PUT /api/users/:uidwithplanorplanExpiresAtfields directly from the frontend. Always go through/api/admin/change-plan(admin changes) or the Stripe webhook (payment-triggered changes).
9. Known Bug History & Defensive Rules¶
Bug (April 2026) — All new users received plan: 'coach' on sign-up¶
Symptom: Every new registration appeared in Admin > Users with plan: 'coach' regardless of whether they had applied or paid.
Root cause: GET /api/coaches in hostinger/personality-app/src/routes/coaches.js ignored all query parameters and always returned all active coaches. The front-end getCoachByEmail(email) in src/api/db.ts called GET /api/coaches?email=<email> and checked whether the response array was non-empty — since the backend returned the full list for every request, list[0] was always truthy as long as at least one coach existed. The same path ran in both registerUser() in src/api/auth.ts.
The same bug silently also affected getCoachByReferralCode() — the referralCode query param was also ignored, so referral-code lookups were unreliable.
Fix applied: GET /api/coaches now applies email and referralCode filters when those query params are present:
// routes/coaches.js — GET /
const filter = { status: 'active' };
if (req.query.email) filter.email = req.query.email.trim().toLowerCase();
if (req.query.referralCode) filter.referralCode = req.query.referralCode.trim();
const coaches = await col().find(filter).toArray();
Data remediation required: Any users who registered while this bug was active will have plan: 'coach' set incorrectly. Correct them via Admin > Users > plan dropdown (set to free or explorer as appropriate).
Defensive rules to prevent recurrence¶
-
Every
GET /api/<collection>route that accepts query params MUST apply those params as DB filters. Returning an unfiltered collection when a filter param is provided is a data-leak and logic bug. -
Never infer plan elevation from a non-empty array. If a lookup is expected to return 0 or 1 results (email match, referral code match), verify the returned record actually matches the requested value — do not rely solely on
list.length > 0. -
plan: 'free'is the only safe default. Any code path that conditionally elevates a plan must fail closed — if the lookup throws or returns unexpected results, the user stays onplan: 'free', never gets promoted. -
Test sign-up with an email that does NOT match any coach. After any change to
auth.tsorroutes/coaches.js, verify that a brand-new registration results inplan: 'free'(not'coach'or'explorer'). -
Backend route query-param support must be documented in the API table in
architecture.md. If a frontend function passes a query param, confirm the backend actually reads it. -
Multi-collection writes must be atomic on the backend. Any operation that writes to more than one collection (e.g.
users+coaches) must be implemented as a single backend endpoint. The frontend must never orchestrate multi-step mutations — see §8 for the plan-change pattern.
Security Audit (April 2026) — 4 issues fixed¶
Issue 1 (Critical): ALLOWED_SELF_UPDATE included plan fields
Symptom: Any authenticated user could self-upgrade their plan by sending { plan: 'explorer' } (or 'coach') in PUT /api/users/me.
Root cause: ALLOWED_SELF_UPDATE in hostinger/personality-app/src/routes/users.js listed plan, planExpiresAt, stripeCustomerId, stripeSubscriptionId, and coachAppStatus — all fields that control access tier.
Fix: Removed those fields from ALLOWED_SELF_UPDATE. Plan state can now only be written by /api/admin/change-plan (admin) or the Stripe webhook (payment). PUT /api/users/me will silently ignore any attempt to set these fields.
Issue 2: /team route missing requiresPayment
Symptom: Free users who had completed Discovery could access TeamReport by navigating directly to /team.
Root cause: The /team route in App.tsx was protected with requiresDiscovery only — requiresPayment was absent.
Fix: Added requiresPayment to the /team ProtectedRoute in App.tsx.
Issue 3: /compare route under-gated
Symptom: The /compare route (RelationshipLensPage) only required Discovery at the route level, weaker than the canAccessCompare() gate defined in journeyGates.ts (which also checks Life Design, Growth Loop start, and paid plan).
Root cause: App.tsx had requiresDiscovery only on the /compare ProtectedRoute.
Fix: Updated to requiresDiscovery requiresLifeDesign requiresPayment — consistent with the journeyGates.ts spec.
Issue 4: isConfigMissing banner checked a removed env var
Symptom: A Firebase configuration warning banner was permanently visible to all users after Firebase was removed.
Root cause: Layout.tsx had an isConfigMissing flag that checked VITE_FIREBASE_API_KEY — an env var that no longer exists. The condition was always true, so the banner always rendered.
Fix: Removed the isConfigMissing check and the warning banner entirely from Layout.tsx.