B2C persona-differentiated capabilities¶
A capability layer between roles/personas and endpoints so each B2C persona (trainer, learner, creator) gets a feature surface that matches what they actually do, and institutional roles get the same clean treatment. It turns the dormant user_type column into a real authorization signal: before this, the backend gated on role only, so a B2C self-signup saw a Presentation chip that POST /presentations/generate rejected with a 403.
How it works¶
Three layers, each with one job:
Layer 1: Identity
role :: who you are to the system (RBAC)
user_type :: what kind of user you say you are (B2C only; null for institutional)
▼
Layer 2: Capabilities
get_capabilities(user) → set[Capability] single resolver, switch on (role, user_type)
answers "can this account do X at all?"
▼
Layer 3: Plan / quota
raise_if_free_b2c, AIQuotaService, etc.
answers "is X currently unlocked for this account?"
Invariants:
- Layer 1 is the only place identity is read; every other module reads Layer 2.
- Layer 2 is pure (same input, same output, no I/O), so it's easy to unit-test.
- Layer 3 runs after Layer 2 passes. A 403 from Layer 2 means "wrong persona for this feature"; a 429 or 402 from Layer 3 means "right persona, but quota or plan blocks you."
- Each endpoint declares one capability via
Depends(require_capability(Capability.X)).
The backend serves capabilities; the frontend never re-derives them. The capability fields (user_type, capabilities, plan) are returned on GET /auth/me; the standalone GET /me/capabilities is deprecated but kept for backward compatibility. The frontend useCapabilities() hook reads via TanStack Query (5 min stale, 30 min cache). On a 403 from any capability-gated endpoint, the frontend invalidates the cache and refetches, treating the 403 as the source of truth. Embedding capabilities in the JWT was rejected because it forces a token refresh on any capability change.
Persona stays a UX-framing concept (empty-state copy, conversion nudges, signup analytics); it is not in the gate path. The matrix asymmetries (below) don't survive a clean "all learners get X" rule, so capability resolution switches on (role, user_type) directly.
The B2C self-declared signup persona is stored on users.signup_intent (trainer | learner | creator | null), set once at signup from the ?as= URL param or the persona picker (default learner) and never modifiable post-signup. A null signup_intent defaults to the B2C learner capability set (the conservative one). The naming is disambiguated: DB column signup_intent is the signup input; the API field user_type (creator | learner | operator) is the derived cross-platform UX bucket.
Capability matrix¶
This is the canonical spec. Gate audits must conform to it.
| Capability | B2B Trainer | B2B Learner | B2C trainer | B2C learner | B2C creator | External educator |
|---|---|---|---|---|---|---|
chat.explain |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
chat.research |
✓ | · | ✓ | · | ✓ | ✓ |
chat.exam_prep |
· | ✓ | · | ✓ | · | · |
presentation.create |
✓ | · | ✓ | ✓ | ✓ | ✓ |
lesson_plan.create |
✓ | · | ✓ | · | ✓ | ✓ |
question_bank.create |
✓ | · | ✓ | · | ✓ | ✓ |
kb.build |
✓ | · | ✓ | ✓ | ✓ | ✓ |
kb.query |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
marketplace.publish |
· | · | · | · | ✓ | ✓ |
presentation.download |
✓ | · | ✓ (plan-gated) | ✓ (plan-gated) | ✓ | ✓ |
lesson_plan.export |
✓ | · | ✓ (plan-gated) | · | ✓ | ✓ |
Admin variants (platform_admin, org_admin, unit_manager) get a superset for audit/management, rendered in the admin section of the implementation, not in this product matrix.
Notable asymmetries:
- B2C learner gets
presentation.createbut B2B learner does not. Self-directed adult learners use slides as study aids; B2B learners live inside a trainer's curriculum where presentations are produced for them. - B2C learner gets
kb.buildbut B2B learner does not. A B2C learner curates their own corpus (notes, past papers); a B2B learner receives the trainer's KB. - B2C trainer does not get
chat.exam_prep. Exam prep is framed for the test-taker. - Learners (B2B and B2C) do not get
chat.research,lesson_plan.create, orquestion_bank.create. These are creator/trainer tools; learners are oriented toward learning loops (explain, exam prep, KB query/build). Deep research short-circuits the read-and-understand loop, and a learner generating a question bank generates their own answer key.
Where it lives¶
- Backend:
apps/backend/src/core/authorization/capabilities.py(Capabilityenum,get_capabilities, capability sets),core/authorization/dependencies.py(require_capability),api/v1/b2c_auth.py(/auth/meshape),models/user.py(UserRole enum,signup_intentcolumn). - Frontend:
apps/web/src/lib/capabilities.ts(useCapabilitieshook,<CapabilityGuard>),services/ai-tutor/index.ts(KWILO_MODES,getModesForUserchip filter),services/auth.ts(current-user schema).