Skip to content

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.tsxDiscoveryPage.tsx /questionnaire
Life Design LifeDesign.tsxLifeDesignPage.tsx /life-design
Wellness WellnessProfile.tsxWellnessPage.tsx /wellness-profile
Financial FinancialPlan.tsxFinancialPage.tsx /financial-plan
Self Mastery SelfInquiryPage.tsxSelfMasteryPage.tsx /self-inquiry
People Blueprint CorporatePage.tsxPeopleBlueprintPage.tsx /corporate
Relationship Lens ProfileCompare.tsxRelationshipLensPage.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.tsx with 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_access type → skips Stripe entirely; plan set directly by backend
  • percent_off / fixed_off type → 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 inline hasPaidAccess() 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/undefined 404 errors (MongoDB _id returned but frontend read .id)
  • Inconsistent state where user.plan and the coaches collection were out of sync

The fix: All plan changes now go through a single endpoint:

POST /api/admin/change-plan  { uid, plan }
  → hostinger/personality-app/src/routes/admin.js

This endpoint atomically:

  1. Computes planExpiresAt (+1 year for explorer/coach; null for free)
  2. Updates user.plan, user.planExpiresAt, clears user.coachAppStatus
  3. Upserts the coaches doc (status: active) when upgrading to coach
  4. Sets the coaches doc to status: inactive when downgrading from coach

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/:uid with plan or planExpiresAt fields 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

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

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

  3. 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 on plan: 'free', never gets promoted.

  4. Test sign-up with an email that does NOT match any coach. After any change to auth.ts or routes/coaches.js, verify that a brand-new registration results in plan: 'free' (not 'coach' or 'explorer').

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

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