Authorization has three layers that stack:
  1. Role, a coarse label on the principal. Checked by requireRole.
  2. Coach permissions, a stored blob that narrows what a non admin coach may do. Resolved by coach-access.ts.
  3. Plan limits, which cap how many trainees and team seats a studio can add. Enforced by billing.limits.ts.
Trainees and API keys have their own, simpler rules, covered at the end.

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:
  • webUserContext upserts a HEAD_COACH row for an OWNER on first contact.
  • @repo/auth calls provisionCoach when an invitation is accepted or a member’s role changes: admin and owner become HEAD_COACH, anything else SUB_COACH.
  • organizationRoleForStaff(permissionGroup, coachRole) in @perform/types maps the other way when a staff seat is claimed: permission group super_admin or coach role HEAD_COACH gives organization role admin, otherwise member.
StaffPermissionGroup (super_admin, full_access, standard, read_only) is the value stored in Coach.permissionGroup.

requireRole

It needs 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 in Coach.permissions. The schema is coachPermissions in modules/coaches/coaches.schema.ts: readCoachPermissions(role, stored) turns the stored value into an effective set:
  • HEAD_COACH always gets UNRESTRICTED_COACH_PERMISSIONS.
  • A SUB_COACH with 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.
The second rule is deliberate. Coaches created before the permission model have no blob and could see the whole studio. Restricting them on deploy would cut them off from trainees they work with, so only a member configured on the team screen is narrowed.

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:
When assignedOnly is true the type guarantees a coachId, so a narrowed query cannot be built without one.

Using it in a module

The clients router is the reference:
  • withCoachAccess resolves the access once and caches it on the request.
  • List handlers call coachAccessOf(req) and pass assignedCoachIdOf(access) into the query. A restricted coach’s list is filtered through the ClientCoach join (coachAssignments: { some: { coachId } }).
  • requireAssignedClient protects every /:id and /:clientId route. A restricted coach asking for an unassigned client gets 404 NOT_FOUND.
Modules that use the access model today are 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.
Only permissions.trainees is enforced by the API today. The other keys (plans, forms, content, subscriptions, discount) are validated, stored and returned, but no code under apps/core-api/src reads them to refuse a request. They are applied by the web app’s UI only. A restricted coach who calls the API directly can still, for example, edit a template. If one of these needs to be a real boundary, add the check in the owning service using coachAccessOf(req).permissions.

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:
Rules worth knowing:
  • A plan with no cap (limits.trainees is null) takes no lock and does no count.
  • Seat limits are waived during onboarding: the studio was bootstrapped by the /start wizard, 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’s onboardingComplete flag 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 /start trainee import and the AutoFit migration do not go through withTraineeRoom.
Plan data and billing flows are on Billing and plan limits.

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 one clientId 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.