- Role, a coarse label on the principal. Checked by
requireRole. - Coach permissions, a stored blob that narrows what a non admin coach may do. Resolved by
coach-access.ts. - Plan limits, which cap how many trainees and team seats a studio can add. Enforced by
billing.limits.ts.
Studio roles
StudioRole in @perform/types:
How a role is assigned
The role on the principal comes from the organization membership in better-auth. The
Coach row has its own role column (HEAD_COACH or SUB_COACH), which is kept in step by provisioning:
webUserContextupserts aHEAD_COACHrow for anOWNERon first contact.@repo/authcallsprovisionCoachwhen an invitation is accepted or a member’s role changes:adminandownerbecomeHEAD_COACH, anything elseSUB_COACH.organizationRoleForStaff(permissionGroup, coachRole)in@perform/typesmaps the other way when a staff seat is claimed: permission groupsuper_adminor coach roleHEAD_COACHgives organization roleadmin, otherwisemember.
StaffPermissionGroup (super_admin, full_access, standard, read_only) is the value stored in Coach.permissionGroup.
requireRole
req.auth. The wrong role answers 403 FORBIDDEN with insufficient role.
Routes gated to OWNER and HEAD_COACH today:
Everything else on the web lane is open to any authenticated coach at the role level, and relies on coach permissions and assigned trainee scoping where it matters.
requireRole is applied per route in the routes file. A new write route is open to every coach unless you add the guard.Coach permissions
A team member who is not an admin carries a permission blob inCoach.permissions. The schema is coachPermissions in modules/coaches/coaches.schema.ts:
readCoachPermissions(role, stored) turns the stored value into an effective set:
HEAD_COACHalways getsUNRESTRICTED_COACH_PERMISSIONS.- A
SUB_COACHwith no blob, or a blob that is not an object or fails the schema, also gets the unrestricted set. - Otherwise the parsed blob is used.
Resolving access for a request
coachAccessFrom(role, row) in middleware/coach-access.ts combines the role from the gateway with the coach row:
The return type makes a mistake hard to write:
assignedOnly is true the type guarantees a coachId, so a narrowed query cannot be built without one.
Using it in a module
Theclients router is the reference:
withCoachAccessresolves the access once and caches it on the request.- List handlers call
coachAccessOf(req)and passassignedCoachIdOf(access)into the query. A restricted coach’s list is filtered through theClientCoachjoin (coachAssignments: { some: { coachId } }). requireAssignedClientprotects every/:idand/:clientIdroute. A restricted coach asking for an unassigned client gets404 NOT_FOUND.
clients and client-tracking. Other modules that list or act on trainees (check-ins, the assistant) apply their own scope from the same coach row. If you add a route that returns trainee data, decide how it treats a restricted coach before you ship it.
Plan limits
billingFor(ctx).limits returns a guard built by createPlanLimits. A studio over its plan keeps everything it has. It only cannot add more.
A refusal is
403 FORBIDDEN with a sentence as the message and machine readable details:
- A plan with no cap (
limits.traineesisnull) takes no lock and does no count. - Seat limits are waived during onboarding: the studio was bootstrapped by the
/startwizard, the owner has not finished it, and it was created less than 72 hours ago (STUDIO_ONBOARDING_WINDOW_MS). Only server written state is read. The user’sonboardingCompleteflag is ignored because the browser can set it. - Seats live on the Polar subscription. If Polar cannot be reached the seat limit is not enforced. A provider outage must not block a studio.
- The
/starttrainee import and the AutoFit migration do not go throughwithTraineeRoom.
Platform admins
The oRPC tier has its own check, separate from studio roles.adminProcedure in @repo/api requires user.role === 'admin' on the better-auth user (the admin plugin). It guards the admin panel procedures: studio and user lists, deletions, manual plan grants, metrics and health. A studio OWNER is not a platform admin.
Billing procedures use organization membership instead: requireBillingManager allows better-auth organization roles owner and admin.
Trainees
A trainee has no role. The token names oneclientId and every query is scoped to it. Three conditions limit a trainee:
API keys
A studio API key (pf_live_ prefix) has no role and no per key scope. It can do everything /v1/partner and /v1/automation expose for its studio. Creating and revoking keys requires OWNER or HEAD_COACH. Revoking the key is the only way to cut an integration off.