Work / Chitragupt / Wiki / Decisions
2026-08-07-family-member-gating-bug-fix
Decisioncanonicalverified 2026-08-07
DECISION.2026-08-07.FAMILY-MEMBER-GATING-BUG-FIXFamily "member" role couldn't read the household view at all — server denied it, client paywalled it
Decision
While verifying the family-tier Portfolio view-only rendering (a non-head "member" signing in and switching to the household workspace), found that a member on a Pro Family household was shown a "locked / upgrade to Pro Family" paywall on Expense, Portfolio, and Tax — even though the household itself was on Pro Family. This was two independent bugs stacked on top of each other, both rooted in the same wrong assumption: "the caller's own subscription doc reflects the household's plan."
Server bug (the more serious one): getFamilyTaxOverview, getMemberTaxReview, getFamilyExpenseOverview, and getFamilyPortfolioOverview (apps/functions/src/family/overview.ts) all called assertProFamilyUnlocked(ctx.uid) and loadHeadMembers(db, family_id, ctx.uid), both of which require the CALLER to be the head — loadHeadMembers throws permission-denied ("Only the family head can read overviews") for anyone else, and assertProFamilyUnlocked checks the caller's own subscription/current doc, which is plan: "free" for a non-head member by design (see apps/website/src/utils/effective-plan.ts). A member could never successfully call any of the four overview callables — not a display bug, an outright access denial.
Client bug: apps/website/src/store/unlock.ts (useUnlock) fed the caller's own useSubscription() result straight into canAccessPillar. Same root cause — a member's own doc has no family_unlocked_ays and plan !== "pro_family", so unlocked came back false and the page rendered the Locked/paywall variant before the (also-broken) overview callable was ever reached.
Fixed both, mirroring the existing precedent in apps/functions/src/ai/gates.ts:resolveEffectiveAiSubscription (which already solves this exact problem for the AI Q&A gate by routing to the head's subscription via getHouseholdHead):
- Added
resolveHeadUid(callerUid, familyId)inoverview.ts, usinggetHouseholdHead(fromfamily/membership.ts) to resolve the actual head uid from the CALLER's ownfamily_membershiprow — this also means a caller can never probe afamily_idthey don't belong to (the lookup is keyed off their own membership, not the input). All four overview callables now callassertProFamilyUnlocked(headUid)andloadHeadMembers(db, family_id, headUid)instead ofctx.uid. - Added a new read-only callable
getFamilyUnlockStatus(family_idin,{plan, ay_unlocks, family_unlocked_ays}proxying the HEAD's subscription out — deliberately omittingstorage_used_bytes/ai_quota/billing ids, which members don't need).useUnlocknow calls this whenever the check is family-scoped (opts.familyorpillar === "family") and runs the SAMEcanAccessPillarpredicate against the proxied head subscription instead of the caller's own — no new business logic, the shared predicate stays the single source of truth. useUnlock'sUnlockOptionsgained afamilyIdfield; the three call sites (use-expense-view-model.ts,use-portfolio-view-model.ts,use-tax-view-model.ts) now pass the already-computedfamily_idthrough.
Why
Caught during manual browser verification of the family-tier Portfolio work (rahul.sharma@chitragupt.ai, a "member" of the Sharma household, signed in and switched to the household workspace expecting the real view-only combined view built earlier this session). Instead got the paywall upsell. Digging in found the paywall was only the visible half — the overview callables themselves would have thrown permission-denied for a member regardless, meaning the entire "member sees the household read-only" feature built earlier this session was unreachable for any actual non-head member, only ever exercised as the head.
Impact
apps/functions/src/family/overview.ts—resolveHeadUidhelper; all four overview callables now resolve the household head instead of assumingctx.uidis the head; newgetFamilyUnlockStatuscallable.apps/functions/src/index.ts— exportsgetFamilyUnlockStatus.packages/shared/src/contracts/family.ts—GetFamilyUnlockStatusInputSchema/OutputSchema.apps/website/src/store/family.ts—useFamilyUnlockStatushook.apps/website/src/store/unlock.ts—useUnlockroutes family-scoped checks through the head-proxied subscription;UnlockOptions.familyIdadded.apps/website/src/app/(app)/{expense,portfolio,tax}/use-*-view-model.ts— passfamilyIdintouseUnlock.- Verified in-browser both ways: rahul.sharma (member) now sees the real combined household view (with the purple "you're viewing as a member" banner) on Expense/Portfolio/Tax; aarav.sharma (head) unaffected — same output as before the fix, confirming no regression on the paying head's path.
Status
Active
Sources
- apps/functions/src/family/overview.ts
- apps/functions/src/ai/gates.ts (
resolveEffectiveAiSubscription— the precedent this fix mirrors) - apps/website/src/store/unlock.ts
- apps/website/src/utils/effective-plan.ts
Every project of mine is written down like this.
Read the résumé