Roles
Overview
The role system is deliberately simple: four roles, two scope types, and pure functions that check them. There's no permission catalog, no entity-level access control, no database queries at check time. Roles live in the JWT, and checking them is a synchronous function call.
This simplicity was a conscious design choice — a previous permission system with ~1500 lines of grammar/catalog/service code was removed in favor of this approach.
The Four Roles
| Role | Purpose |
|---|---|
ADMIN |
Full system access. Superrole — passes any role check. |
STAFF |
Operations access. Can be global or location-scoped. |
USER |
Default role for all members. |
PARTNER |
External partners (referral programs). |
Scoping
Roles can be global (scope_type: null) or location-scoped (scope_type: 'location', scope_id: '<locationId>').
A staff member at one location gets { role: 'STAFF', scope_type: 'location', scope_id: 'loc-123' }. A global staff member gets { role: 'STAFF', scope_type: null, scope_id: null }.
Global ADMIN is a superrole — hasRole(claims, anyRole, anyLocation) always returns true if the user has a global ADMIN entry. This means admin checks don't need to be special-cased throughout the codebase.
Role Claims in the JWT
interface RoleClaims {
roles: RoleAssignment[]
}
interface RoleAssignment {
role: string
scope_type: string | null
scope_id: string | null
}
Embedded at app_metadata.roles in the Supabase JWT by the custom_access_token_hook Postgres function. See [[auth/overview|Auth]] for the JWT flow.
Role Check Functions
All pure and synchronous, at src/utils/auth/role-check.ts. They operate only on RoleClaims — no database, no async.
| Function | What it checks |
|---|---|
isAdmin(claims) |
Global ADMIN role |
isStaff(claims) |
Global STAFF role (or global ADMIN) |
isStaffAt(claims, locationId) |
Global ADMIN, global STAFF, or location-scoped STAFF at that location |
isStaffOrAbove(claims) |
Any ADMIN or STAFF entry (ignores scope — used for list endpoints) |
isStaffOrAboveAt(claims, locationId) |
isAdmin OR isStaffAt for that location |
hasScopedRoleAt(claims, locationId) |
Global ADMIN or location-scoped STAFF at that location (not global STAFF) |
getScopedLocationIds(claims) |
All location IDs the user has scoped roles for |
hasRole(claims, role, locationId?) |
The primitive — all above functions call this |
The key subtlety: isStaffOrAbove ignores scope_type entirely — a STAFF scoped to any single location satisfies it. This is intentional for list endpoints where any staff member should have access to the list (with results filtered by their scope).
Staleness and Session Revocation
Role changes only take effect at the next JWT refresh (every 15 minutes). The system mitigates this by calling revokeUserSessions(userId) (at src/lib/auth/token-refresh.ts) after any role assignment or removal, which deletes rows from auth.sessions via raw SQL, forcing the user to re-authenticate and get a fresh token.
Code Map
src/utils/auth/role-check.ts All role-check functions
src/lib/auth/token-refresh.ts revokeUserSessions
packages/shared-types/src/auth/index.ts Role enum, RoleClaims, RoleAssignment
See Also
- [[auth/overview|Auth]] — JWT flow and validateAuthentication
- [[auth/impersonation]] — How impersonation loads target roles fresh from DB
- [[users]] — User model and role assignment