Skip to content
Ritesh FirodiyaGet in touch

Work / Chitragupt / Wiki / Decisions

2026-08-07-family-member-gating-bug-fix

Decisioncanonicalverified 2026-08-07

DECISION.2026-08-07.FAMILY-MEMBER-GATING-BUG-FIX

Family "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) in overview.ts, using getHouseholdHead (from family/membership.ts) to resolve the actual head uid from the CALLER's own family_membership row — this also means a caller can never probe a family_id they don't belong to (the lookup is keyed off their own membership, not the input). All four overview callables now call assertProFamilyUnlocked(headUid) and loadHeadMembers(db, family_id, headUid) instead of ctx.uid.
  • Added a new read-only callable getFamilyUnlockStatus (family_id in, {plan, ay_unlocks, family_unlocked_ays} proxying the HEAD's subscription out — deliberately omitting storage_used_bytes/ai_quota/billing ids, which members don't need). useUnlock now calls this whenever the check is family-scoped (opts.family or pillar === "family") and runs the SAME canAccessPillar predicate 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's UnlockOptions gained a familyId field; the three call sites (use-expense-view-model.ts, use-portfolio-view-model.ts, use-tax-view-model.ts) now pass the already-computed family_id through.

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 — resolveHeadUid helper; all four overview callables now resolve the household head instead of assuming ctx.uid is the head; new getFamilyUnlockStatus callable.
  • apps/functions/src/index.ts — exports getFamilyUnlockStatus.
  • packages/shared/src/contracts/family.ts — GetFamilyUnlockStatusInputSchema / OutputSchema.
  • apps/website/src/store/family.ts — useFamilyUnlockStatus hook.
  • apps/website/src/store/unlock.ts — useUnlock routes family-scoped checks through the head-proxied subscription; UnlockOptions.familyId added.
  • apps/website/src/app/(app)/{expense,portfolio,tax}/use-*-view-model.ts — pass familyId into useUnlock.
  • 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é