Guards
Decide whether a call may run, before the handler or anything costly sees it, and tell the user why when it may not.
You'll learn
- Write a guard that allows or refuses a call
- Apply guards to a handler, a controller or the whole bot
- Give one use of a guard its own settings, checked when the code compiles
- Read facts about the handler, and a message command's members and roles, from a guard
Before this
A guard decides whether a handler runs. It's a class with one method, canActivate, which returns true to let the
call through and false to stop it. To tell the user why they were stopped, it throws
GuardDeniedError with the reason instead.
Guards run before anything that fetches from Discord or counts a call. Before an interaction's or a message's guards,
only @Defer's acknowledgement, and the app's themeFor lookup when it has one, come first; a reaction's partial
message or user is fetched before its guards. So a caller they refuse costs the bot almost nothing.
When to use it
Use a guard for who may run a handler, and where: a command only the staff may use, a button only the user who opened it may press, a command that only works in some channels.
A limit on how often isn't a guard's job; use a cooldown. A check on the input's shape, such as a number in range, belongs in validation, which says exactly what's wrong.
Example
This guard lets only the user whose ID a button's customId carries press it:
import { type ButtonInteraction } from 'discord.js'
import { GuardDeniedError } from 'meocord/common'
import { Guard } from 'meocord/decorator'
import { type GuardInterface } from 'meocord/interface'
/** Lets only the user whose id the button carries use it: `card/{ownerId}/…` */
@Guard()
export class OwnerGuard implements GuardInterface {
canActivate(interaction: ButtonInteraction, { ownerId }: { ownerId: string }): boolean {
// Thrown, it is answered privately; returning false would deny silently
if (interaction.user.id !== ownerId) throw new GuardDeniedError('Only the user who opened this can use it.')
return true
}
}canActivate receives the handler's own arguments: the interaction, and the params its customId pattern captured.
A stranger pressing the button is told privately that it isn't theirs, and the handler never runs.
How it works
A guard runs after @Defer's acknowledgement and the app's themeFor lookup, and after a message
command's words are read, and before
everything else in the call: the fetch of a message's entities, interceptors, validation, cooldowns and the handler.
How a call runs shows the whole order.
canActivate can be async. Its answer means:
| It | The call |
|---|---|
returns true | goes on to the next guard, then the rest of the call |
returns false | stops silently; observers see the outcome 'denied' |
throws GuardDeniedError | stops, and the user is told the error's message |
throws UserError | stops, and the user is told the message; observers see 'refused' |
| throws anything else | goes to the exception filters as an error |
A GuardDeniedError or a UserError passes through the exception filters too, so a filter that catches it answers
in its place. The first guard that doesn't allow the call ends it; the ones after it don't run.
A new guard instance is made for every call, and it injects services like any class, so keep what must outlast one call, such as counts, in a service.
Where guards apply
@UseGuard goes on a handler, or on a controller for every handler it declares or
inherits. To guard every handler in the bot, list the guard in @MeoCord({ guards }). Global guards run first, then
the controller's, then the method's.
A controller's guards also guard every class that extends it, so a base controller can hold the rule for a whole group of commands:
@Controller()
@RequireRoles(ROLE_IDS.moderator)
export abstract class StaffSlashController {}
@Controller()
export class MuteSlashController extends StaffSlashController {
// Runs RolesGuard, from the base class, before the command
@Command('mute', CommandType.SLASH)
async mute(interaction: ChatInputCommandInteraction) {
await respond(interaction).send({ content: 'Muted.' })
}
}A subclass that shouldn't take its bases' guards for the handlers it declares sets
@Controller({ inheritStages: false }).
A guard runs for every kind of handler it applies to. Global guards also run before gateway event handlers, where
canActivate receives the event's arguments, such as a GuildMember for guildMemberAdd. A guard written for
interactions declares @Guard({ types: ['interaction'] }), and is skipped for any other call.
Settings for one use
When a value configures one use of a guard, such as the channels a command is allowed in, pass it with
{ provide, params }. The guard reads it as this.params, and a guard that declares the type of its params has
every use checked against it when the code compiles:
import { type ChatInputCommandInteraction } from 'discord.js'
import { Guard, UseGuard } from 'meocord/decorator'
import { type GuardInterface } from 'meocord/interface'
@Guard()
export class ChannelGuard implements GuardInterface {
// Set per use with @UseGuard({ provide: ChannelGuard, params: { channelIds } }), and checked against this
declare readonly params?: { channelIds: string[] }
canActivate(interaction: ChatInputCommandInteraction): boolean {
const channelIds = this.params?.channelIds ?? []
return channelIds.length === 0 || channelIds.includes(interaction.channelId)
}
}
export const OnlyInChannels = (...channelIds: string[]) => UseGuard({ provide: ChannelGuard, params: { channelIds } })it('checks the params given to a guard against the ones it declares', () => {
// @ts-expect-error channelId is not one of ChannelGuard's params
expect(() => UseGuard({ provide: ChannelGuard, params: { channelId: '111111111111111111' } })).not.toThrow()
})A guard that declares no params takes any. params is optional, so { provide: ChannelGuard } works like the class
alone.
Facts about the handler
For a fact about a handler that any guard can read, such as the IDs of the roles it requires, make a typed decorator
with createMetadata. A guard reads it through
ExecutionContext, which it injects, and a handler's value wins over its
controller's:
import { type ChatInputCommandInteraction } from 'discord.js'
import { applyDecorators, createMetadata, ExecutionContext } from 'meocord/common'
import { Guard, UseGuard } from 'meocord/decorator'
import { type GuardInterface } from 'meocord/interface'
// A typed decorator that stores a value on a handler, or on a whole controller: here, the IDs of the roles it requires
export const Roles = createMetadata<string[]>('roles')
@Guard()
export class RolesGuard implements GuardInterface {
// Each call gets its own context, describing the handler being guarded
constructor(private readonly context: ExecutionContext) {}
canActivate(interaction: ChatInputCommandInteraction): boolean {
const required = this.context.get(Roles) ?? []
if (required.length === 0) return true
// A member's roles are keyed by ID, which is what Roles holds
return interaction.inCachedGuild() && required.some(roleId => interaction.member.roles.cache.has(roleId))
}
}
export const RequireRoles = (...roleIds: string[]) => applyDecorators(Roles(roleIds), UseGuard(RolesGuard))RequireRoles applies both the metadata and the guard, so a handler takes one decorator:
@Command('trade', CommandType.SLASH)
@OnlyInChannels('111111111111111111')
async trade(interaction: ChatInputCommandInteraction) {
await respond(interaction).send({ content: 'Trade opened.' })
}
@Command('ban', CommandType.SLASH)
@RequireRoles(ROLE_IDS.admin, ROLE_IDS.moderator)
async ban(interaction: ChatInputCommandInteraction) {
await respond(interaction).send({ content: 'Banned.' })
}The guard checks role IDs, not names: discord.js keys a member's roles by ID, and anyone who manages roles can rename
one. Read your server's role IDs from .env, adding ADMIN_ROLE_ID and MODERATOR_ROLE_ID to it:
// Your server's role IDs, from .env: with Developer Mode on in Discord, right-click a role and choose Copy Role ID.
// discord.js keys a member's roles by ID, and anyone who manages roles can rename one, so guards check IDs.
export const ROLE_IDS = {
admin: process.env.ADMIN_ROLE_ID ?? '',
moderator: process.env.MODERATOR_ROLE_ID ?? '',
}A decorator reads them when your controller loads, and that works: MeoCord loads meocord.config.ts, which loads your
.env files with dotenv at its top, before your application's code.
ExecutionContext also gives the guard the handler's params with getHandlerParams(), raw, before validation and
pipes, and the call's type, controller and handler. Only guards inject it; the other stages receive it as an argument.
Members and roles in a message command
A message command with a member, user, role or channel param fetches nothing from
Discord before its guards. Each such param reaches the guard as an EntityRef, typed by
ParamRefsOf: its id, the entity as cached when discord.js already has it, and
resolve() to fetch it. A guard that needs the entity fetches it; one that doesn't costs no request. This one refuses
a caller without the permission silently, before any request, and tells one who doesn't outrank the target why:
import { type Message } from 'discord.js'
import { GuardDeniedError } from 'meocord/common'
import { Guard } from 'meocord/decorator'
import { type GuardInterface, type ParamRefsOf } from 'meocord/interface'
/** Lets a moderator act only on a member ranked below them. */
@Guard()
export class OutranksTargetGuard implements GuardInterface {
async canActivate(
message: Message,
{ target }: ParamRefsOf<'ban {target:member} {duration:duration?} {reason...?}'>,
) {
// The cheap check first, so a caller without the permission costs no request, and gets no reply
if (!message.member?.permissions.has('BanMembers')) return false
const member = target.cached ?? (await target.resolve())
if (member && member.roles.highest.position >= message.member.roles.highest.position) {
throw new GuardDeniedError('You can only ban members ranked below you.')
}
return true
}
}// !ban @ana spamming gives { target, reason: 'spamming' }
// !ban @ana 7d spamming gives { target, duration: 604_800_000, reason: 'spamming' }
@MessageHandler('ban {target:member} {duration:duration?} {reason...?}')
@UseGuard(OutranksTargetGuard)
async ban(
message: Message,
{ target, duration, reason }: { target: GuildMember; duration?: number; reason?: string },
) {
await target.ban({ reason, deleteMessageSeconds: duration ? Math.min(duration / 1000, 604_800) : undefined })
await message.reply(`Banned ${target.displayName}`)
}Once the guards let the call through, whatever the cache lacks is fetched, and the handler receives the entities themselves.
Refusing a call
GuardDeniedError's message goes to the user who made the call:
- after an interaction, privately, as a reply or a follow-up, whichever the answer allows;
- after a message command, as a reply in the channel without a ping, deleted after
@MeoCord({ messages: { deleteUsageRepliesAfter } })seconds.
A guard that denies a handler for every message, a reaction handler or an event handler answers nothing, since it only
filters what the handler takes. Before an autocomplete, a guard must not answer at all: returning false closes the
menu with an empty list.
Testing
The testing module runs guards as the bot does. invoke resolves with ran: false when a guard
returned false, and rejects with the GuardDeniedError one threw, unless a filter handles it; dispatch answers it
as the bot does:
it('runs in an allowed channel, and a guard that returns false stops it silently', async () => {
const allowed = createMockInteraction(ChatInputCommandInteraction, { channelId: '111111111111111111' })
const elsewhere = createMockInteraction(ChatInputCommandInteraction, { channelId: '222222222222222222' })
await expect(module.invoke(ModerationSlashController, 'trade', allowed)).resolves.toEqual({ ran: true })
await expect(module.invoke(ModerationSlashController, 'trade', elsewhere)).resolves.toEqual({ ran: false })
expect(getResponse(elsewhere).sent).toBe(false)
})A guard can also be tested alone, with createExecutionContext building the context it injects:
it('reads the roles from the handler, in a unit test of the guard', () => {
// Created without a guildId, the mock is outside a server, with no member to have the roles
const interaction = createMockInteraction(ChatInputCommandInteraction)
const guard = new RolesGuard(createExecutionContext(ModerationSlashController, 'ban', { args: [interaction] }))
expect(guard.canActivate(interaction)).toBe(false)
})Gotchas
- A guard that injects needs
@Guard(). Without it, TypeScript records none of its constructor's types, and the bot refuses to start, naming the decorator to add. - Returning
falsetells the user nothing. On a button, that's often right. On a command, throwGuardDeniedErrorso they know why nothing happened. - A global guard runs before event handlers too. One that reads
interaction.userthrows on an event; declare@Guard({ types: ['interaction'] }). - A guard bound once is shared. Listing a guard in
servicesorprovidersmakes one instance for every call. Each call still reads its ownparams, but other fields are shared between calls in progress. - A controller method called directly runs its guards, and nothing else of the pipeline. Test through
invoketo run every stage.
Build it
The staff channel shows each piece of feedback with Approve and Reject buttons, and anyone who can see the channel can
press them. Only the staff should decide, so add a guard that checks for the staff role. It reads the role from the
settings' staffRoleId, so add STAFF_ROLE_ID to .env, with your staff role's ID:
// Lets members with the staff role through; anyone else is told why, privately
@Guard()
export class StaffGuard implements GuardInterface {
constructor(private readonly settings: FeedbackSettings) {}
canActivate(interaction: ButtonInteraction): boolean {
if (interaction.inCachedGuild() && interaction.member.roles.cache.has(this.settings.staffRoleId)) return true
throw new GuardDeniedError(t.for(interaction)('feedback.staffOnly'))
}
}Apply it to both review buttons at once, on the controller:
import { UseGuard } from 'meocord/decorator'
// …
import { StaffGuard } from '@src/tutorial/staff.guard'
// …
// Both buttons need the staff role
@UseGuard(StaffGuard)Press Approve as a member without the staff role. The bot tells only you that only the staff can review feedback, and the post keeps its buttons. Press it as a member with the role, and the verdict is posted.
Next steps
- Validation and pipes: check the input a guard let through.
- Exception filters: answer a
GuardDeniedErrorin your own words. - Testing recipes: test a guard with and without the role it needs.