This file summarizes the runtime architecture and data model of the bot as
implemented in the repository. It is derived directly from the code
(index.js, core/, events/, utils/, models/). When code and this doc
disagree, the code wins — please update this file in the same change.
The project has two layers:
- The Discord bot — the classic command/event/scheduler runtime.
- The plugin platform — process isolation, a capability broker, an HTTP API, a React admin dashboard, and a multi-tenant RBAC system layered on top.
index.js— entry point (npm start). Constructs the discord.jsClient, initializes theDatabasesingleton,TaskScheduler,HookBus, andPluginManager, optionally starts the API server, loads plugins, then logs in. See Startup sequence below.- Command loader /
deploy-commands.js— registers slash commands with the Discord REST API.npm run deployrunsscripts/build-plugins.jsfirst (rebuilds plugin dashboards) thendeploy-commands.js. - Events (
events/) —interactionCreate.js(slash/button/modal/select dispatch, cooldowns, per-guild plugin gate),messageCreate.js(XP, AI auto-response), plusready.js,guildMemberAdd.js, etc. - Database layer (
utils/database.js+models/schemas.js) — a singleton wrapping Mongoose models. See Data models. - Scheduler (
utils/scheduler.js) —node-cronjobs: daily/weekly resets, hourly leaderboard, role checks, birthday checks.
PluginManager.js— discovers, load-orders (topological), loads, and hot-reloads plugins. Owns the per-guild enable index (see RBAC below).PluginContext.js— the frozen, namespacedctxhanded to each plugin (db,scheduler,hooks,logger,registerCommand/Event,defineModel). Wrapshooksso handlers are guild-gated.HookBus.js— inter-plugin pub/sub (onLevelUp,onPluginUnload, …).- Isolation / RPC (
core/rpc/) — untrusted plugins run inworker_threads;broker.jsis the CapabilityBroker that mediates every privileged call over an RPC protocol (protocol.js,worker-manager.js,worker-client.js,process-router.js).capabilities.js+manifest-schema.jsdefine and validate what a plugin may request. - API server (
core/api/server.js) — Fastify app exposing the dashboard REST API.adminPlugin.jsregisters the guild/admin routes. Auth is Discord OAuth + session cookie. - RBAC (
core/permission-resolver.js+core/dashboard-permissions.js) — derives who-can-do-what per request. See Multi-tenant RBAC. - Watchdog (
core/adb-watchdog.js) — independent supervisor process + reverse proxy. Restarts the bot on the dashboard "Restart & Deploy" action and proxies/dashboardand plugin UIs. Runs as its own process (not started byindex.js).
initializeDatabase()— connect Mongo (MONGODB_URI).- Construct
TaskScheduler,HookBus,PluginManager(wired toclient,db,scheduler,hooks). - Unless
PLUGIN_ISOLATION=false, callpluginManager.enableIsolation()so package plugins load in workers. - If
BOT_API_ENABLED=true,startApiServer(...)withstartListening: false(built now, bound later). pluginManager.loadAll()— loads plugins and warms the per-guild enable index.apiServer.listen()— bind the API port now that plugins are registered.client.login(DISCORD_TOKEN).
A plugin's source determines how it loads and whether it is gateable:
| Source | Where | Loads in | Gateable per-guild? |
|---|---|---|---|
core / builtin |
plugins/* in-repo |
main process | No — always on |
local |
local dev dirs | main process | No — always on |
package (isolated) |
node_modules/adb-plugin-* |
worker thread | Yes |
package + raw-client |
declares system: ["raw-client"] |
main process (real client) |
No — always on, API rejects toggling |
Gateable = source === "package" and NOT raw-client. Isolated package
plugins go through the broker, so the platform can gate them. raw-client
plugins bypass the broker (they hold the real bot token), so a per-guild gate on
them would be unenforceable — the API returns not_toggleable instead of
pretending.
The dashboard is multi-tenant: one bot instance serves many guilds, and access is resolved live per request from the bot's member cache (60s TTL), never from a login-time snapshot — a user demoted in Discord loses dashboard access without re-logging in.
| Tier | Who | Powers |
|---|---|---|
HOST_OWNER |
listed in OWNER_IDS |
The operator. Sole authority to install/uninstall plugins. Holds every permission in every guild, even guilds it hasn't joined. |
GUILD_ADMIN |
guild owner, or ADMINISTRATOR / MANAGE_GUILD in that guild |
Every permission for that guild, including enabling/disabling installed plugins — but never install/uninstall. |
MEMBER |
in the guild | Union of permissions granted to their Discord roles via GuildRoleGrant. Usually empty. |
NONE |
not in the guild (or bot isn't) | 403. |
resolve(userId, guildId) is cached per (userId, guildId) for DEFAULT_TTL_MS
(60s) and invalidated eagerly on guildMemberUpdate/Remove, roleUpdate/Delete,
and on any grant edit (invalidateGuild).
- Core permissions (platform-level, plugin
null):guild.view,guild.configure,plugins.manage,roles.manage. - Per-plugin, auto-derived for every loaded plugin:
plugin.<name>.view,plugin.<name>.configure. A plugin may declare its own keys inmanifest.dashboard.permissions[]; those are always re-namespaced underplugin.<name>.so a plugin cannot mint a permission outside its namespace (e.g. claimplugins.manage). - Guild admins map Discord roles → sets of these keys (
GuildRoleGrant); the resolver turns a member's roles into the union of granted keys.
- Installed gateable plugins are off by default per guild; a guild admin opts in from the dashboard.
- Enforced synchronously on hot paths via a pre-warmed enable index — a Set
of
"guildId:pluginName"rebuilt from one query (getAllEnabledPluginRows) on a 60s TTL, plus eagersetEnabledForGuildon toggle. On a DB error the last good snapshot is kept. - Chokepoints that consult the gate: Discord event forwarding to workers
(
worker-manager.broadcastEventfilter), the HookBus facade inPluginContext, and command dispatch inevents/interactionCreate.js. (In normal isolated operation the broker boundary is the real gate; the in-process hook/event checks are defense-in-depth and coverPLUGIN_ISOLATION=false.)
Served by core/api/server.js (+ core/adminPlugin.js), all guild routes
guarded by requireGuildAccess(request, reply, <permission>):
GET /api/guild/:guildId— guild summary; returns calleraccess: { tier, permissions }.GET /api/guild/:guildId/plugins— installed plugins annotated withgateableandenabledForGuild.PUT /api/guild/:guildId/plugins/:name/enabled— toggle a gateable plugin for the guild (400 not_toggleablefor non-gateable). Gatedplugins.manage.GET /api/guild/:guildId/permissions/catalog— full permission catalog. Gatedroles.manage.GET /api/guild/:guildId/roles/grants— guild roles + current grants. Gatedroles.manage.PUT|DELETE /api/guild/:guildId/roles/grants/:roleId— set/clear a role's granted permissions (validated against the catalog). Gatedroles.manage.- Host-owner-only:
/api/plugins/*(install, uninstall, update, marketplace, restart).
The React admin dashboard lives in plugins/administration/web (CRA, built to
/dashboard). Its Sidebar and pages are permission-filtered from the access
payload; the Access page (Roles.jsx) is the role→permission editor.
Defined in models/schemas.js, exposed via utils/database.js:
- ServerConfig — per-guild config (AI, tickets, XP/role automation, birthdays…).
- UserProfile — per-user-per-guild (
wallet,bank,totalXp,level, …). - PluginConfig — per-guild-per-plugin config and the
enabledflag that drives the per-guild enable gate. - GuildRoleGrant — the only RBAC table:
(guildId, roleId) → permissions[]. - Ticket, AIRateLimit, XPTransaction, Leaderboard, Birthday, GuildEconomy, ShopItem, TruthOrDareConfig, AntiRaid — feature-specific schemas.
(See models/schemas.js for full field lists and indexes.)
- Discord (discord.js v14) — runtime + components + OAuth
- MongoDB (Mongoose 8) — persistence and sessions
- Fastify 4 — dashboard API (cors, cookie, session, static)
- Google Gemini (
@google/genai) — AI features (GEMINI_API_KEY) node-cron— schedulingworker_threads— plugin isolation
Core bot:
DISCORD_TOKEN(required) — bot tokenCLIENT_ID(fordeploy-commands.js) — application idGUILD_ID(optional) — guild-scoped command deployMONGODB_URI(required) — Mongo connection stringGEMINI_API_KEY(optional) — Google Gemini
Platform / dashboard:
BOT_API_ENABLED—trueto start the Fastify APIBOT_API_PORT,BOT_API_BASE_URL— API bind/baseOWNER_IDS— comma-separated host-owner user IDs (the HOST_OWNER tier)DISCORD_OAUTH_CLIENT_ID,DISCORD_OAUTH_CLIENT_SECRET,DISCORD_OAUTH_REDIRECT_URI— dashboard loginSESSION_SECRET,DASHBOARD_REDIRECT_URL,CORS_ORIGIN— session/redirect/CORSPLUGIN_ISOLATION—falseto disable worker isolation (not for production)PLUGIN_REGISTRY_URL— plugin marketplace sourceWATCHDOG_PORT— watchdog reverse-proxy portINVITE_FORCE_ADMIN,TRIAL_MODE,DEBUG,PORT— misc/optional
- Intents (
index.js):Guilds,GuildMembers,GuildMessages,MessageContent,GuildMessageReactions,GuildVoiceStates,GuildPresences. The privileged ones (Message Content, Guild Members, Presences) must be enabled in the Developer Portal. - The invite permission integer is computed from enabled plugins'
discordPermissions(core/permissions.js), not hardcoded.
- Scheduler and the enable index are in-process; running multiple bot instances duplicates scheduled jobs and each keeps its own index — coordinate (single scheduler process / leader election) if you scale out.
- The watchdog is a separate process; the dashboard "Restart & Deploy" goes through it.
- RBAC resolution and the enable index both fail closed (empty index = gateable plugins off; DB error on grants = no permissions granted).
- New command: add a file under
commands/<category>/exportingdata+execute; runnpm run deploy. - New event: add a file in
events/exporting{ name, execute, once? }. - New plugin: see
CREATE-PLUGIN.md. Declare capabilities anddiscordPermissionsin the manifest; optionally declaredashboard.permissions[]. - New DB model: update
models/schemas.jsand expose viautils/database.js. - New dashboard permission: it is auto-derived from the plugin name; only
declare
dashboard.permissions[]when you need finer-grained keys.
- Spec 1 — Multi-tenant RBAC: ✅ implemented (this document).
- Spec 2 — Member portal: 🚧 platform foundation shipped. A separate
/me/*self-service surface where members see their own data (rank, reminders, tickets). Landed: thewebUi.memberPagesmanifest field (normalize + validate),PluginManager.getMemberPages(guildId)(gated by the per-guild enable flag), theGET /api/me/guild/:guildId/pagesAPI (any tier above NONE), and the/meroute tree in the dashboard SPA (guild picker → guild pages → embedded plugin page). Remaining: individual plugins declare theirmemberPagesand serve the corresponding member routes — propagated to the ~18 plugin repos incrementally.