Roles
local Operator = require(game:GetService("ServerStorage").Operator)
local Roles = Operator.RolesA declarative layer above guards. A guard is a function you write; a role is configuration, and it drives the manifest, so a player who lacks a role never sees the command exists.
Functions
Roles.new
Roles.new(config: Config?, options: Options?): Roleslocal roles = Roles.new({
owner = { UserIds = { 1234567 }, Inherits = { "moderator" } },
moderator = { GroupId = 7654321, MinRank = 200 },
})With no config this falls back to place owner only and logs a one-line warning saying so. For a group-owned place the owner is whoever holds rank 255 in the owning group.
Methods
:getRoles
roles:getRoles(player: Player): RoleSetThe player's role set, as { [roleName]: true }. Resolves on first call and is cached after; concurrent callers share one lookup rather than each starting their own, and a player's entry is released when they leave.
:has
roles:has(player: Player, role: string): boolean:canRun
roles:canRun(player: Player, command: Command): booleanThe predicate for both the dispatcher and the transport, so there is a single answer to "may this player use this command".
- 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.
:invalidate
roles:invalidate(player: Player): ()Drops one player's cached roles, so the next lookup re-resolves.
:invalidateAll
roles:invalidateAll(): ():getRoleNames
roles:getRoleNames(): { string }:destroy
roles:destroy(): ()Releases the cache and its connections.
Types
RoleDefinition
type RoleDefinition = {
UserIds: { number }?,
GroupId: number?,
MinRank: number?,
MaxRank: number?,
Resolve: ((player: Player) -> boolean)?,
Inherits: { string }?,
}| Field | Default | Meaning |
|---|---|---|
UserIds | none | user ids that hold the role |
GroupId | none | a group to check membership in |
MinRank | 1 | lowest rank in that group that counts |
MaxRank | 255 | highest rank in that group that counts |
Resolve | none | anything else: a DataStore, an HTTP call, your own data |
Inherits | none | other roles this one also grants |
A role matches if any of its conditions match. Resolve may yield and is wrapped in a timeout. Inheritance is transitive, and a cycle is handled rather than hanging.
Config
type Config = { [string]: RoleDefinition }Options
type Options = {
logger: Log.Logger?,
providers: { RoleProvider }?,
isPlaceOwner: ((player: Player) -> boolean)?,
resolveTimeout: number?,
}RoleProvider
type RoleProvider = {
resolve: (player: Player) -> { string },
}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.
Roles.new(config, {
providers = {
{ resolve = function(player) return MyDataStore.getRoles(player.UserId) end },
},
})RoleSet
type RoleSet = { [string]: boolean }Changing roles mid-session
Roles are resolved once per player and cached, so there is never a group or DataStore call per command. When something changes what a player may 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.