Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e6f55ce
Add dashboard permission access controls
MohsinCoding Mar 25, 2026
d57b33a
Harden guild access resolution
MohsinCoding Mar 25, 2026
e509673
Validate web guild access responses
MohsinCoding Mar 25, 2026
b4633d3
Fix retry header fallback handling
MohsinCoding Mar 25, 2026
9af0a96
Align dashboard roles with moderator access
MohsinCoding Mar 25, 2026
5e2e2b2
Merge remote-tracking branch 'origin/main' into mohsin/volvox-bot-perms
MohsinCoding Mar 25, 2026
d48d8b7
Merge branch 'main' into mohsin/volvox-bot-perms
MohsinCoding Mar 26, 2026
97a00c8
Merge branch 'main' of https://github.com/VolvoxLLC/volvox-bot into m…
MohsinCoding Mar 30, 2026
fd23008
fix(api): parallelize guild access checks
MohsinCoding Apr 1, 2026
13f7c93
refactor(web): share guild directory state
MohsinCoding Apr 1, 2026
4b3e27a
fix(web): correct retry attempt logging
MohsinCoding Apr 1, 2026
f691704
docs: align moderator access comments
MohsinCoding Apr 1, 2026
05ce563
Merge remote-tracking branch 'origin/main' into mohsin/volvox-bot-perms
MohsinCoding Apr 1, 2026
173e0af
fix(api): preserve dashboard xp actor attribution
MohsinCoding Apr 1, 2026
2e41665
fix(api): cap guild access batch size
MohsinCoding Apr 1, 2026
cb77c42
fix(web): require botPresent in guild context
MohsinCoding Apr 1, 2026
118520c
fix(web): validate forwarded discord identities
MohsinCoding Apr 2, 2026
c48e27f
test: align dashboard and permission coverage
MohsinCoding Apr 2, 2026
aaca827
test: increase root vitest timeout budget
MohsinCoding Apr 2, 2026
62d6c6e
Merge remote-tracking branch 'origin/main' into mohsin/volvox-bot-perms
MohsinCoding Apr 2, 2026
d07da88
Merge branch 'main' into mohsin/volvox-bot-perms
BillChirico Apr 2, 2026
6bd4cfa
Refactor conditional checks for claude-review job
BillChirico Apr 2, 2026
b8df2c5
Update claude-review workflow to specify project context and enhance …
BillChirico Apr 2, 2026
3173e75
feat(dashboard): add WYSIWYG Discord markdown editor component (#422)
MohsinCoding Apr 3, 2026
1574422
feat(dashboard): add visual Discord embed builder component (#423)
MohsinCoding Apr 3, 2026
be9f595
Merge remote-tracking branch 'origin/main' into mohsin/volvox-bot-perms
MohsinCoding Apr 3, 2026
b7c726e
Fix guild access batching and XP auth fallback
MohsinCoding Apr 3, 2026
4affbea
Enforce editor and embed character limits
MohsinCoding Apr 3, 2026
b93c0ad
Merge branch 'main' into mohsin/volvox-bot-perms
MohsinCoding Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions migrations/014_audit_logs_user_tag_backfill.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Repair migration for audit_logs schema drift.
*
* Background:
* `013_audit_log.cjs` now creates `audit_logs.user_tag`, but some databases
* already had an older `audit_logs` table created before the `user_tag`
* column existed. Because `013_audit_log.cjs` uses `ifNotExists`, those
* existing tables do not receive the new column automatically.
Comment on lines +5 to +8
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify historical note about user_tag origin to avoid confusion.

The comment implies user_tag was introduced by 013_audit_log.cjs, but migrations/001_initial-schema.cjs already defines user_tag. Please reword this as schema-drift remediation for databases that skipped/retained older table definitions.

✏️ Suggested wording update
- *   `013_audit_log.cjs` now creates `audit_logs.user_tag`, but some databases
- *   already had an older `audit_logs` table created before the `user_tag`
- *   column existed. Because `013_audit_log.cjs` uses `ifNotExists`, those
- *   existing tables do not receive the new column automatically.
+ *   Some environments ended up with `audit_logs` missing `user_tag` due to
+ *   migration-order/schema-drift paths combined with `IF NOT EXISTS` guards.
+ *   This repair migration backfills the missing column/index safely.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* `013_audit_log.cjs` now creates `audit_logs.user_tag`, but some databases
* already had an older `audit_logs` table created before the `user_tag`
* column existed. Because `013_audit_log.cjs` uses `ifNotExists`, those
* existing tables do not receive the new column automatically.
* Some environments ended up with `audit_logs` missing `user_tag` due to
* migration-order/schema-drift paths combined with `IF NOT EXISTS` guards.
* This repair migration backfills the missing column/index safely.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@migrations/014_audit_logs_user_tag_backfill.cjs` around lines 5 - 8, The
comment's historical note incorrectly credits `013_audit_log.cjs` with
introducing `audit_logs.user_tag`; update the wording in
`migrations/014_audit_logs_user_tag_backfill.cjs` to state this migration is a
schema-drift remediation for databases that retained older `audit_logs`
definitions and therefore missed the `user_tag` column (which exists in
`migrations/001_initial-schema.cjs`), making clear this file adds the column
where it was skipped rather than originally introducing it.

*
* Purpose:
* Preserve the historical `014_*` slot already recorded in some databases
* and backfill the missing column/index when needed.
Comment on lines +1 to +12
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This migration’s header comment references 013_audit_log.cjs, but the repository’s migrations directory does not contain that migration and the base schema migration already defines audit_logs.user_tag and idx_audit_logs_guild_user (migrations/001_initial-schema.cjs). Please update the comment to reflect the actual migration history in this repo so future debugging doesn’t chase a non-existent migration.

Copilot uses AI. Check for mistakes.
*/

'use strict';

/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.up = (pgm) => {
pgm.sql(`
ALTER TABLE IF EXISTS audit_logs
ADD COLUMN IF NOT EXISTS user_tag VARCHAR(100)
`);

pgm.sql(`
CREATE INDEX IF NOT EXISTS idx_audit_logs_guild_user
ON audit_logs(guild_id, user_id)
`);
};

/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.down = (pgm) => {
// Keep idx_audit_logs_guild_user in place because 013_audit_log.cjs also
// creates it; dropping it here would leave that migration chain inconsistent.
pgm.sql(`
ALTER TABLE IF EXISTS audit_logs
DROP COLUMN IF EXISTS user_tag
`);
};
9 changes: 9 additions & 0 deletions src/api/middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import crypto from 'node:crypto';
import { warn } from '../../logger.js';
import { handleOAuthJwt } from './oauthJwt.js';

const DISCORD_SNOWFLAKE_PATTERN = /^\d{17,20}$/;

/**
* Performs a constant-time comparison of the given secret against BOT_API_SECRET.
*
Expand Down Expand Up @@ -48,6 +50,13 @@ export function requireAuth() {
});
} else if (isValidSecret(apiSecret)) {
req.authMethod = 'api-secret';
const trustedUserId =
typeof req.headers['x-discord-user-id'] === 'string'
? req.headers['x-discord-user-id'].trim()
: '';
if (trustedUserId && DISCORD_SNOWFLAKE_PATTERN.test(trustedUserId)) {
req.user = { userId: trustedUserId };
}
Comment on lines 52 to +59
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

x-discord-user-id is accepted verbatim and attached to req.user for API-secret requests. To avoid polluting audit logs / attribution with arbitrary strings, validate this header as a Discord snowflake (digits-only, reasonable length) before trusting it, otherwise ignore it and fall back to a generic actor (e.g. 'api-secret').

Copilot uses AI. Check for mistakes.
return next();
} else {
// BOT_API_SECRET is configured but the provided secret doesn't match.
Expand Down
6 changes: 6 additions & 0 deletions src/api/middleware/rateLimit.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Simple in-memory per-IP rate limiter with no external dependencies
*/

import { isTrustedInternalRequest } from './trustedInternalRequest.js';

const DEFAULT_MESSAGE = 'Too many requests, please try again later';

/**
Expand Down Expand Up @@ -56,6 +58,10 @@ export function rateLimit({
cleanup.unref();

const middleware = (req, res, next) => {
if (isTrustedInternalRequest(req)) {
return next();
}

const ip = req.ip;
const now = Date.now();

Expand Down
5 changes: 5 additions & 0 deletions src/api/middleware/redisRateLimit.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { getRedis } from '../../redis.js';
import { rateLimit as inMemoryRateLimit } from './rateLimit.js';
import { isTrustedInternalRequest } from './trustedInternalRequest.js';

/**
* Creates Redis-backed rate limiting middleware using a sliding window counter.
Expand All @@ -24,6 +25,10 @@ export function redisRateLimit({ windowMs = 15 * 60 * 1000, max = 100, keyPrefix
const fallback = inMemoryRateLimit({ windowMs, max });

const middleware = async (req, res, next) => {
if (isTrustedInternalRequest(req)) {
return next();
}

const redis = getRedis();

// Fall back to in-memory if Redis isn't available
Expand Down
21 changes: 21 additions & 0 deletions src/api/middleware/trustedInternalRequest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Trusted internal requests originate from the dashboard/web server and
* authenticate with the shared bot API secret. These requests should not
* consume the public per-IP rate-limit budget because they are proxied
* server-to-server and would otherwise all collapse to localhost in dev.
*
* @param {import('express').Request} req
* @returns {boolean}
*/
export function isTrustedInternalRequest(req) {
const expectedSecret = process.env.BOT_API_SECRET;
const providedSecret =
typeof req.get === 'function' ? req.get('x-api-secret') : req.headers?.['x-api-secret'];

return (
typeof expectedSecret === 'string' &&
expectedSecret.length > 0 &&
typeof providedSecret === 'string' &&
providedSecret === expectedSecret
);
}
204 changes: 181 additions & 23 deletions src/api/routes/guilds.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Router } from 'express';
import { error, info, warn } from '../../logger.js';
import { getConfig, setConfigValue } from '../../modules/config.js';
import { cacheGetOrSet, TTL } from '../../utils/cache.js';
import { getBotOwnerIds } from '../../utils/permissions.js';
import { getBotOwnerIds, isAdmin, isModerator } from '../../utils/permissions.js';
import { safeSend } from '../../utils/safeSend.js';
import {
maskSensitiveFields,
Expand All @@ -26,6 +26,8 @@ const router = Router();
const ADMINISTRATOR_FLAG = 0x8;
/** Discord MANAGE_GUILD permission flag */
const MANAGE_GUILD_FLAG = 0x20;
const ACCESS_LOOKUP_CONCURRENCY = 10;
const MAX_ACCESS_LOOKUP_GUILDS = 100;

/**
* Upper bound on content length for abuse prevention.
Expand Down Expand Up @@ -235,28 +237,123 @@ function isOAuthGuildModerator(user, guildId) {
return hasOAuthGuildPermission(user, guildId, ADMINISTRATOR_FLAG | MANAGE_GUILD_FLAG);
}

function accessSatisfiesRequirement(access, requiredAccess) {
if (access === 'bot-owner') return true;
if (requiredAccess === 'admin') return access === 'admin';
return access === 'admin' || access === 'moderator';
}

function hasPermissionFlag(permissions, flag) {
try {
return (BigInt(permissions) & BigInt(flag)) === BigInt(flag);
} catch {
return false;
}
}

function getOAuthDerivedAccessLevel(owner, permissions) {
if (owner) return 'admin';
if (hasPermissionFlag(permissions, ADMINISTRATOR_FLAG)) return 'admin';
if (hasPermissionFlag(permissions, MANAGE_GUILD_FLAG)) return 'moderator';
return null;
}

function isUnknownMemberError(err) {
return err?.code === 10007 || err?.message?.includes('Unknown Member');
}

async function mapWithConcurrency(items, concurrency, iteratee) {
const results = new Array(items.length);
let index = 0;

async function worker() {
while (index < items.length) {
const currentIndex = index++;
results[currentIndex] = await iteratee(items[currentIndex], currentIndex);
}
}

const workerCount = Math.min(concurrency, items.length);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return results;
}

/**
* Resolve dashboard access for a guild member using the bot's configured role rules.
*
* @param {import('discord.js').Guild} guild
* @param {string} userId
* @returns {Promise<'bot-owner'|'admin'|'moderator'|'viewer'>}
*/
async function getGuildAccessLevel(guild, userId) {
const config = getConfig(guild.id);

if (getBotOwnerIds(config).includes(userId)) {
return 'bot-owner';
}

let member = guild.members.cache.get(userId) || null;
if (!member && typeof guild.members?.fetch === 'function') {
try {
member = await guild.members.fetch(userId);
} catch (err) {
if (isUnknownMemberError(err)) {
member = null;
} else {
throw err;
}
}
}

if (!member) {
return 'viewer';
}

if (isAdmin(member, config)) {
return 'admin';
}

if (isModerator(member, config)) {
return 'moderator';
}

return 'viewer';
}

/**
* Return Express middleware that enforces a guild-level permission for OAuth users.
*
* The middleware bypasses checks for API-secret requests and for configured bot owners.
* For OAuth-authenticated requests it calls `permissionCheck(user, guildId)` and:
* - responds 403 with `errorMessage` when the check resolves to `false`,
* For cached bot guilds it resolves dashboard access via `getGuildAccessLevel(...)`;
* otherwise it falls back to `permissionCheck(user, guildId)`. The resolved access
* level must satisfy `requiredAccess`.
* - responds 403 with `errorMessage` when the resolved access is insufficient,
* - responds 502 when the permission verification throws,
* - otherwise allows the request to continue.
* Unknown or missing auth methods receive a 401 response.
*
* @param {(user: Object, guildId: string) => Promise<boolean>} permissionCheck - Function that returns `true` if the provided user has the required permission in the specified guild, `false` otherwise.
* @param {string} errorMessage - Message to include in the 403 response when permission is denied.
* @param {'moderator'|'admin'} requiredAccess - Minimum dashboard access level required for the route.
* @returns {import('express').RequestHandler} Express middleware enforcing the permission.
*/
function requireGuildPermission(permissionCheck, errorMessage) {
function requireGuildPermission(permissionCheck, errorMessage, requiredAccess) {
return async (req, res, next) => {
Comment on lines 323 to 341
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requireGuildPermission JSDoc is now out of date: the middleware may resolve access via getGuildAccessLevel (bot-config role checks) before falling back to permissionCheck, and it takes a new requiredAccess parameter. Please update the doc comment/signature docs so it accurately describes the current authorization behavior.

Copilot uses AI. Check for mistakes.
if (req.authMethod === 'api-secret') return next();

if (req.authMethod === 'oauth') {
if (isOAuthBotOwner(req.user)) return next();

try {
const guild = req.app.locals.client?.guilds?.cache?.get(req.params.id);
if (guild) {
const access = await getGuildAccessLevel(guild, req.user.userId);
if (!accessSatisfiesRequirement(access, requiredAccess)) {
return res.status(403).json({ error: errorMessage });
}
return next();
}

if (!(await permissionCheck(req.user, req.params.id))) {
return res.status(403).json({ error: errorMessage });
}
Expand All @@ -283,12 +380,14 @@ function requireGuildPermission(permissionCheck, errorMessage) {
export const requireGuildAdmin = requireGuildPermission(
isOAuthGuildAdmin,
'You do not have admin access to this guild',
'admin',
);

/** Middleware: verify OAuth2 users are guild moderators. API-secret users pass through. */
export const requireGuildModerator = requireGuildPermission(
isOAuthGuildModerator,
'You do not have moderator access to this guild',
'moderator',
);

/**
Expand Down Expand Up @@ -390,27 +489,29 @@ router.get('/', async (req, res) => {

try {
const userGuilds = await fetchUserGuilds(req.user.userId, accessToken);
const filtered = userGuilds.reduce((acc, ug) => {
const permissions = Number(ug.permissions);
const hasAdmin = (permissions & ADMINISTRATOR_FLAG) !== 0;
const hasManageGuild = (permissions & MANAGE_GUILD_FLAG) !== 0;
const access = hasAdmin ? 'admin' : hasManageGuild ? 'moderator' : null;
if (!access) return acc;

// Single lookup avoids has/get TOCTOU.
const resolvedGuilds = await mapWithConcurrency(
userGuilds,
ACCESS_LOOKUP_CONCURRENCY,
async (ug) => {
const botGuild = botGuilds.get(ug.id);
if (!botGuild) return acc;
acc.push({
id: ug.id,
name: botGuild.name,
icon: botGuild.iconURL(),
memberCount: botGuild.memberCount,
access,
});
return acc;
}, []);
if (!botGuild) return null;

const access =
getOAuthDerivedAccessLevel(ug.owner, ug.permissions) ??
(await getGuildAccessLevel(botGuild, req.user.userId));
if (access === 'viewer') return null;

return res.json(filtered);
return {
id: ug.id,
name: botGuild.name,
icon: botGuild.iconURL(),
memberCount: botGuild.memberCount,
access,
};
},
);

return res.json(resolvedGuilds.filter(Boolean));
} catch (err) {
error('Failed to fetch user guilds from Discord', {
error: err.message,
Expand All @@ -435,6 +536,63 @@ router.get('/', async (req, res) => {
return res.status(401).json({ error: 'Unauthorized' });
});

router.get('/access', async (req, res) => {
if (req.authMethod !== 'api-secret') {
return res
.status(401)
.json({ error: 'Guild access endpoint requires API secret authentication' });
}

const userId = typeof req.query.userId === 'string' ? req.query.userId.trim() : '';
const guildIdsRaw = typeof req.query.guildIds === 'string' ? req.query.guildIds : '';

if (!userId) {
return res.status(400).json({ error: 'Missing userId query parameter' });
}

const guildIds = [
...new Set(
guildIdsRaw
.split(',')
.map((id) => id.trim())
.filter(Boolean),
),
];
if (guildIds.length === 0) {
return res.json([]);
}
if (guildIds.length > MAX_ACCESS_LOOKUP_GUILDS) {
return res.status(400).json({
error: `guildIds may include at most ${MAX_ACCESS_LOOKUP_GUILDS} entries`,
});
}

const { client } = req.app.locals;

try {
const accessEntries = await mapWithConcurrency(
guildIds,
ACCESS_LOOKUP_CONCURRENCY,
async (guildId) => {
const guild = client.guilds.cache.get(guildId);
if (!guild) return null;

const access = await getGuildAccessLevel(guild, userId);
return { id: guildId, access };
},
);

return res.json(accessEntries.filter(Boolean));
} catch (err) {
error('Failed to resolve guild access entries', {
error: err.message,
userId,
guildCount: guildIds.length,
});
return res.status(502).json({ error: 'Failed to verify guild permissions with Discord' });
}
});

/** Maximum number of channels to return to avoid oversized payloads. */
const MAX_CHANNELS = 500;

Expand Down
Loading
Loading