A faithful, minimal implementation of Discord's permission resolution algorithm — built with pure functions, BigInt bitmasks, and zero dependencies.
Discord's permission system looks simple on the surface (it's just bitwise math) but its real behaviour depends on a strict resolution order across roles, @everyone, role overwrites, and member-specific overwrites. Getting that order wrong is the most common source of permission bugs in bots and self-hosted tools.
This library implements the algorithm exactly as Discord documents it, in roughly 100 lines of core logic.
Permissions are resolved in this exact order:
- Base permissions —
ORtogether every role the member has, including@everyone. - Administrator short-circuit — if the
ADMINISTRATORflag is set, return all permissions immediately. No overwrite can revoke it. @everyonechannel overwrite — apply(perms & ~deny) | allow.- Role overwrites — accumulate
allowanddenyacross every role overwrite that matches one of the member's roles, then apply(perms & ~deny) | allowonce. - Member overwrite — apply the member-specific overwrite last. This always wins.
The core formula at every overwrite step is:
permissions = (permissions & ~deny) | allow
git clone <this-repo>
cd discord-permission-engine
npm installNo dependencies are required.
const { resolve, PERMISSIONS } = require('./src/resolver');
const result = resolve({
member: { id: 'user_1', guildId: 'guild_1', roles: ['mods'] },
roles: [
{ id: 'guild_1', name: '@everyone', permissions: '1024' },
{ id: 'mods', name: 'Moderators', permissions: '8192' }
],
channel: {
overwrites: {
everyone: { allow: '0', deny: '2048' },
members: [{ id: 'user_1', allow: '0', deny: '8192' }]
}
}
});
result.has('VIEW_CHANNEL'); // true
result.has('SEND_MESSAGES'); // false (denied by @everyone overwrite)
result.has('MANAGE_MESSAGES'); // false (denied by member overwrite)
result.permissions; // ['VIEW_CHANNEL']
result.bitmaskString; // '1024'node cli.js examples/basic.jsonOutput:
CREATE_INSTANT_INVITE ❌
KICK_MEMBERS ❌
...
VIEW_CHANNEL ✅
SEND_MESSAGES ✅
MANAGE_MESSAGES ❌
...
Filter to just the flags you care about:
node cli.js examples/conflict.json --filter VIEW_CHANNEL,SEND_MESSAGES,MANAGE_MESSAGESVIEW_CHANNEL ✅
SEND_MESSAGES ❌
MANAGE_MESSAGES ❌
JSON output:
node cli.js examples/basic.json --jsonPermission values are strings to safely round-trip through JSON without losing precision (Discord uses 53+ bit values). Internally everything is BigInt.
In examples/conflict.json, the user has the Moderators role, which is granted MANAGE_MESSAGES both at the guild level and via a role overwrite. Yet the final resolution returns MANAGE_MESSAGES = false.
Why? Because the member-specific overwrite is always applied last, after all role overwrites have been merged. A single member overwrite can revoke a permission that every one of the user's roles grants. This is the rule that trips most people up — and it's the exact behaviour Discord's client and API enforce.
The only thing that bypasses this is ADMINISTRATOR, which short-circuits the entire pipeline.
npm testCovers base merging, admin override, conflicting role overwrites, member-overwrite priority, and the no-channel path.
src/
flags.js permission constants (BigInt bit shifts)
engine.js pure resolution logic
formatter.js bitmask → readable output
resolver.js public API
cli.js CLI entry
examples/ sample inputs
tests/ node:test suite
MIT
{ "member": { "id": "user_1", "guildId": "guild_1", "roles": ["role_a", "role_b"] }, "roles": [ { "id": "guild_1", "name": "@everyone", "permissions": "1024" }, { "id": "role_a", "permissions": "2048" } ], "channel": { "overwrites": { "everyone": { "allow": "0", "deny": "0" }, "roles": [{ "id": "role_a", "allow": "0", "deny": "0" }], "members": [{ "id": "user_1", "allow": "0", "deny": "0" }] } } }