Permissions
A role is a named set of players. Roles are configuration rather than code, and they drive the manifest, so a player who lacks a role never sees the command exists.
Operator.Start({
Roles = {
owner = { UserIds = { 1234567 }, Inherits = { "moderator" } },
moderator = { GroupId = 7654321, MinRank = 200 },
},
})That is the whole wiring. Start uses the same predicate for what a player is shown and what they are allowed to run, so the two can never disagree.
Defining a role
| Field | Meaning |
|---|---|
UserIds | a list of user ids that hold the role |
GroupId | a group to check membership in |
MinRank / MaxRank | the rank window inside that group, defaulting to 1 and 255 |
Resolve | (player) -> boolean, for anything else: a DataStore, an HTTP call, your own data |
Inherits | other roles this one also grants |
A role matches if any of its conditions match. Resolve may yield, and is wrapped in a timeout.
Inherits is the field that makes the example above behave the way you would expect: an owner is not in the moderator group, but holding owner grants moderator, so owners can run moderator commands without being listed twice. Inheritance is transitive, and a cycle is handled rather than hanging.
How permissions are decided
- A player holding no role at all gets nothing: no manifest, no bundle, no console.
- A command with no
:permission(...)runs for anyone holding at least one role. - A command with permissions runs for a player holding any one of them, directly or inherited.
With no roles configured
Roles.new() with no config falls back to place owner only and logs a one-line warning saying so. The console works out of the box for you and nobody else. There is no hook you must write first, and no state where everything is silently open.
For a group-owned place the owner is whoever holds rank 255 in the owning group.
Custom providers
Anything that can answer "which roles does this player hold" can be plugged in, alongside or instead of the config:
Roles.new(config, {
providers = {
{
resolve = function(player)
return MyDataStore.getRoles(player.UserId)
end,
},
},
})A provider returns a list of role names and may yield. Roles from providers are unioned with roles from the config, then inheritance is expanded over the result. A provider that raises or times out is logged and skipped, so it can never accidentally grant a role.
Caching, and changing roles mid-session
Roles are resolved once per player and cached, so there is never a group or DataStore call per command. Concurrent callers share a single lookup rather than each starting their own, and a player's entry is released when they leave.
When something changes what a player is allowed to do:
transport:refresh(player) -- or transport:refreshAll()Passing roles to Transport.new makes refresh invalidate that player's cached roles first, so one call re-resolves, rebuilds the manifest and sends it. If they lost access entirely the bundle is removed; if they lost only some commands, the bundle stays and the manifest shrinks in place.
The full method surface, covering querying, invalidating, and the predicate the dispatcher uses, is in api/roles.