Permissions
Who may do what, generated from the code: the global roles of app/modules/permissions and the per-object permissions the repositories build, each with every place checking it. See Permissions for how the two work and where a new check goes. Server checks throw a 403 (requireRole, requirePermission), client ones only decide what is shown (useHasRole, useHasPermission), and hasPermission / roles.includes outside .server files may run on either side.
Global roles
Granted to the logged in user by userRoles() of mapper.server.ts.
| Role | Granted when | Checked in |
|---|---|---|
ADMIN | isAdmin(user) | Either sideTrophies |
STAFF | isStaff(user) || isAdmin(user) | Either sideTrophies |
PLUS_SERVER_MEMBER | typeof user.plusTier === "number" | ClientLFG |
VIDEO_ADDER | user.isVideoAdder | ServerVods ClientVods |
ARTIST | user.isArtist | ServerArt |
CALENDAR_EVENT_ADDER | userDiscordIdIsAged(user) || isSupporter(user) | ServerCalendar ClientCalendar |
TOURNAMENT_ADDER | user.isTournamentOrganizer || isSupporter(user) | ClientTournament organization |
API_ACCESSER | user.isTournamentOrganizer || user.isApiAccesser || isSupporter(user) | Either sideApi |
QA | isQa(user) | Either sideTrophies |
DEV | isDev(user) | ServerAuth ×2 Either sideTrophies |
SUPPORTERpatrons of "Supporter" tier or higher | isSupporter(user) | ServerAssociations Either sideImage export |
MINOR_SUPPORTpatrons of "Support" tier or higher | typeof user.patronTier === "number" | Clientapp/components/layout Either sideapp ×4 |
By feature
Each feature's Permissions page lists the per-object permissions it grants, who each is granted to and every check of it (other features' included), and the roles its code checks.
| Feature | Grants | Checks in its code |
|---|---|---|
| Admin | 17 checks | |
| Api | 1 check | |
| Art | EDIT, UNLINK | 5 checks |
| Associations | REMOVE, MANAGE, MANAGE_INVITE_LINK | 4 checks |
| Auth | 4 checks | |
| Availability | 2 checks | |
| Badges | MANAGE | 6 checks |
| Builds | EDIT | 0 checks |
| Calendar | EDIT, DELETE, REPORT_WINNERS | 13 checks |
| Chat | VIEW, POST, OBSERVE | 3 checks |
| Events | 1 check | |
| Image export | 1 check | |
| Img upload | 2 checks | |
| Leaderboards | 2 checks | |
| LFG | EDIT, DELETE | 8 checks |
| Plus suggestions | EDIT, DELETE | 4 checks |
| Plus voting | 0 checks | |
| Scanner | 2 checks | |
| Scrims | MANAGE_REQUESTS, DELETE_POST, CANCEL, MANAGE_TRACKING | 6 checks |
| SendouQ | 1 check | |
| SendouQ match | 1 check | |
| Settings | 1 check | |
| Team | EDIT, MANAGE_ROSTER, DELETE | 9 checks |
| Tournament | ADMIN, ORGANIZE, MANAGE_MATCHES, EDIT_EVENT_INFO, EDIT_IN_GAME_NAMES | 1 check |
| Tournament bracket | 8 checks | |
| Tournament organization | EDIT, BAN | 12 checks |
| Trophies | EDIT | 6 checks |
| User page | 10 checks | |
| Vods | EDIT | 6 checks |
| Voice | 0 checks |
Outside the system
Best-effort flags, some of them deliberate: the permissions doc lists the boundaries.
Granted but never checked (1 permission): no check of that name matches the feature and nothing reads it directly.
chat: OBSERVE
Checked but never granted (0 checks): no feature, or no single one, grants a permission of that name.
Read directly (8 reads): x.permissions.NAME.includes(…) skips hasPermission and with it the admin bypass.
- app/features/associations/actions/associations.server.ts:111
const isAdmin = association.permissions.MANAGE.includes(user.id); - app/features/associations/loaders/associations.server.ts:21
inviteCode: association.permissions.MANAGE_INVITE_LINK.includes(user.id) - app/features/badges/routes/badges.tsx:126
if (user && badge.permissions.MANAGE.includes(user.id)) { - app/features/scrims/routes/scrims.tsx:603
post.permissions.DELETE_POST.includes(user.id) && - app/features/scrims/routes/scrims.tsx:621
post.permissions.MANAGE_REQUESTS.includes( - app/features/tournament-organization/loaders/org.$slug.server.ts:83
user?.id && organization.permissions.BAN.includes(user.id) - app/features/voice/core/VoiceAccess.ts:19
return room.permissions.POST.includes(userId); - app/features/voice/core/VoiceAccess.ts:23
return canJoin(room, userId) && room.permissions.OBSERVE.includes(userId);
Checks of who the user is (17 calls): isAdmin, isStaff, isDev, isQa and isSupporter called outside app/modules/permissions. Authorizing an action on an object with one is a bug, a role or permission should decide it.
isAdminin app/features/admin/loaders/admin.server.ts:21if (!isAdmin(userToCheck) && !isStaff(userToCheck) && !isDev(userToCheck)) {isStaffin app/features/admin/loaders/admin.server.ts:21if (!isAdmin(userToCheck) && !isStaff(userToCheck) && !isDev(userToCheck)) {isDevin app/features/admin/loaders/admin.server.ts:21if (!isAdmin(userToCheck) && !isStaff(userToCheck) && !isDev(userToCheck)) {isAdminin app/features/auth/core/routes.server.ts:152if (isAdmin({ id: targetId }) || isStaff({ id: targetId })) {isStaffin app/features/auth/core/routes.server.ts:152if (isAdmin({ id: targetId }) || isStaff({ id: targetId })) {isSupporterin app/features/plus-voting/loaders/plus.voting.results.server.ts:50(result.wasSuggested && !result.passedVoting) || isSupporter(user);isSupporterin app/features/plus-voting/loaders/plus.voting.results.server.ts:82if (!isSupporter(user) || !result.passedVoting) {isSupporterin app/features/settings/actions/settings.server.ts:29if (!isSupporter(user)) {isSupporterin app/features/tournament/TournamentRepository.server.ts:352isSupporter(member)),isSupporterin app/features/user-page/UserRepository.server.ts:307isSupporter({ patronTier: rows[0]!.patronTier }),isSupporterin app/features/user-page/actions/u.$identifier.edit-widgets.server.ts:11{ body: async () => widgetsEditSchema(isSupporter(requireUser())) },isSupporterin app/features/user-page/core/badge-sorting.server.ts:23const effectiveFavoriteIds = isSupporter({ patronTier })isSupporterin app/features/user-page/core/trophy-sorting.server.ts:36filteredFavoriteIds = isSupporter({ patronTier })isSupporterin app/features/user-page/routes/short.$customUrl.ts:9if (!user || !isSupporter(user)) {isAdminin app/features/voice/voice-utils.server.ts:6if (!isAdmin(user) && !isDev(user)) {isDevin app/features/voice/voice-utils.server.ts:6if (!isAdmin(user) && !isDev(user)) {isSupporterin app/root.tsx:174customTheme: isSupporter(user) ? user?.customTheme : undefined,