Skip to content
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c78965c
feat(dashboard): add member table component with sort and pagination
BillChirico Feb 28, 2026
7774268
feat(dashboard): add members list page
BillChirico Feb 28, 2026
87a5794
feat(dashboard): add member detail page with stats and admin actions
BillChirico Feb 28, 2026
c0bf2ac
feat(members): add member management API with detail, history, XP adj…
BillChirico Feb 28, 2026
d4144f8
feat(members): mount members router, export guild middleware, remove …
BillChirico Feb 28, 2026
97a8a85
test(members): add API endpoint tests (26 tests)
BillChirico Feb 28, 2026
6b8547f
fix: lint import ordering in member dashboard components
BillChirico Feb 28, 2026
16d53fa
fix(members-api): add rate limiting, CSV injection protection, XP tra…
BillChirico Feb 28, 2026
9fe6e2f
fix(dashboard): align member interfaces with backend API response shape
BillChirico Feb 28, 2026
10dc0a8
feat(dashboard): add Next.js API proxy routes for member endpoints
BillChirico Feb 28, 2026
7b15f01
test(members): update tests for API changes
BillChirico Feb 28, 2026
5f129c3
fix: lint and formatting fixes across all changed files
BillChirico Feb 28, 2026
bb98b4e
fix(members-api): add global + per-route rate limiting to satisfy CodeQL
BillChirico Feb 28, 2026
e65ff21
📝 Add docstrings to `feat/member-management`
coderabbitai[bot] Feb 28, 2026
7f05670
fix: correct CSV formula-injection bug in escapeCsv
BillChirico Feb 28, 2026
e9c1e32
fix: include guildId in member row click navigation
BillChirico Feb 28, 2026
43e70c2
fix: use safeGetPool in all member endpoints
BillChirico Feb 28, 2026
7269241
fix: log CSV export errors instead of silent failure
BillChirico Feb 28, 2026
f90f7cb
fix(members): dedupe rate limiting, add 401 handling, fix loading sta…
BillChirico Feb 28, 2026
a121cb0
Merge branch 'main' into feat/member-management
BillChirico Feb 28, 2026
5acc9bc
fix(members): reject fractional XP amounts (column is INTEGER)
BillChirico Feb 28, 2026
1a9f769
test: boost branch coverage to 85% with targeted tests
BillChirico Feb 28, 2026
d6bcbd5
fix(members): reject non-integer XP amounts with 400
BillChirico Feb 28, 2026
9438556
test(members): add test for fractional XP amount returning 400
BillChirico Feb 28, 2026
b270f16
fix(members): scope membersRateLimit to member routes only
BillChirico Feb 28, 2026
58eab07
fix(members-ui): fix roleColorStyle fallback for hex alpha concatenation
BillChirico Feb 28, 2026
01e0cbc
fix: biome formatting in members.js
BillChirico Feb 28, 2026
21869d6
fix(members): add AbortController and request sequencing to prevent s…
BillChirico Feb 28, 2026
b2385f5
fix(members): use Discord server-side search instead of page-local fi…
BillChirico Feb 28, 2026
9401c0c
fix(members): stream CSV export in batches to reduce memory pressure
BillChirico Feb 28, 2026
0350a59
fix: button types, useCallback deps, array keys, remove duplicate tes…
BillChirico Feb 28, 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
5 changes: 5 additions & 0 deletions src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import configRouter from './routes/config.js';
import guildsRouter from './routes/guilds.js';
import healthRouter from './routes/health.js';
import membersRouter from './routes/members.js';
import moderationRouter from './routes/moderation.js';
import webhooksRouter from './routes/webhooks.js';

Expand All @@ -23,6 +24,10 @@
// Global config routes — require API secret or OAuth2 JWT
router.use('/config', requireAuth(), configRouter);

// Member management routes — require API secret or OAuth2 JWT
// (mounted before guilds to handle /:id/members/* before the basic guilds endpoint)
router.use('/guilds', requireAuth(), membersRouter);

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.

Copilot Autofix

AI 14 days ago

In general terms, we should introduce an Express rate-limiting middleware (for example, via express-rate-limit) and apply it to the sensitive routes that perform authorization and likely touch expensive resources. This middleware should run before requireAuth() (or at least before the main route handlers) so that abusive clients get rejected early. We can scope the limiter to specific routes (/guilds, /config, /moderation, /webhooks) instead of globally, to avoid impacting public endpoints like /health and /auth.

For this file specifically (src/api/index.js), the best fix is:

  • Import express-rate-limit.
  • Define a limiter instance configured with a reasonable window and max request count (for example, 15 minutes / 100 requests, similar to the example).
  • Apply that limiter middleware on the protected route groups, by inserting it into the router.use chains before requireAuth().

Concretely:

  • At the top of src/api/index.js, add import rateLimit from 'express-rate-limit';.

  • Under const router = Router();, define a constant such as const authRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100 });.

  • Update lines 25, 29, 32, 35, and 38 so they become:

    router.use('/config', authRateLimiter, requireAuth(), configRouter);
    router.use('/guilds', authRateLimiter, requireAuth(), membersRouter);
    router.use('/guilds', authRateLimiter, requireAuth(), guildsRouter);
    router.use('/moderation', authRateLimiter, requireAuth(), moderationRouter);
    router.use('/webhooks', authRateLimiter, requireAuth(), webhooksRouter);

This way, all routes that rely on requireAuth() and likely do expensive authorization work are now rate-limited, addressing all alert variants, while not changing existing functional behavior beyond adding throttling on abusive patterns.

Suggested changeset 1
src/api/index.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/api/index.js b/src/api/index.js
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -4,6 +4,7 @@
  */
 
 import { Router } from 'express';
+import rateLimit from 'express-rate-limit';
 import { requireAuth } from './middleware/auth.js';
 import authRouter from './routes/auth.js';
 import configRouter from './routes/config.js';
@@ -15,6 +16,11 @@
 
 const router = Router();
 
+const authRateLimiter = rateLimit({
+  windowMs: 15 * 60 * 1000, // 15 minutes
+  max: 100, // limit each IP to 100 authenticated requests per windowMs
+});
+
 // Health check — public (no auth required)
 router.use('/health', healthRouter);
 
@@ -22,19 +28,19 @@
 router.use('/auth', authRouter);
 
 // Global config routes — require API secret or OAuth2 JWT
-router.use('/config', requireAuth(), configRouter);
+router.use('/config', authRateLimiter, requireAuth(), configRouter);
 
 // Member management routes — require API secret or OAuth2 JWT
 // (mounted before guilds to handle /:id/members/* before the basic guilds endpoint)
-router.use('/guilds', requireAuth(), membersRouter);
+router.use('/guilds', authRateLimiter, requireAuth(), membersRouter);
 
 // Guild routes — require API secret or OAuth2 JWT
-router.use('/guilds', requireAuth(), guildsRouter);
+router.use('/guilds', authRateLimiter, requireAuth(), guildsRouter);
 
 // Moderation routes — require API secret or OAuth2 JWT
-router.use('/moderation', requireAuth(), moderationRouter);
+router.use('/moderation', authRateLimiter, requireAuth(), moderationRouter);
 
 // Webhook routes — require API secret or OAuth2 JWT (endpoint further restricts to api-secret)
-router.use('/webhooks', requireAuth(), webhooksRouter);
+router.use('/webhooks', authRateLimiter, requireAuth(), webhooksRouter);
 
 export default router;
EOF
@@ -4,6 +4,7 @@
*/

import { Router } from 'express';
import rateLimit from 'express-rate-limit';
import { requireAuth } from './middleware/auth.js';
import authRouter from './routes/auth.js';
import configRouter from './routes/config.js';
@@ -15,6 +16,11 @@

const router = Router();

const authRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 authenticated requests per windowMs
});

// Health check — public (no auth required)
router.use('/health', healthRouter);

@@ -22,19 +28,19 @@
router.use('/auth', authRouter);

// Global config routes — require API secret or OAuth2 JWT
router.use('/config', requireAuth(), configRouter);
router.use('/config', authRateLimiter, requireAuth(), configRouter);

// Member management routes — require API secret or OAuth2 JWT
// (mounted before guilds to handle /:id/members/* before the basic guilds endpoint)
router.use('/guilds', requireAuth(), membersRouter);
router.use('/guilds', authRateLimiter, requireAuth(), membersRouter);

// Guild routes — require API secret or OAuth2 JWT
router.use('/guilds', requireAuth(), guildsRouter);
router.use('/guilds', authRateLimiter, requireAuth(), guildsRouter);

// Moderation routes — require API secret or OAuth2 JWT
router.use('/moderation', requireAuth(), moderationRouter);
router.use('/moderation', authRateLimiter, requireAuth(), moderationRouter);

// Webhook routes — require API secret or OAuth2 JWT (endpoint further restricts to api-secret)
router.use('/webhooks', requireAuth(), webhooksRouter);
router.use('/webhooks', authRateLimiter, requireAuth(), webhooksRouter);

export default router;
Copilot is powered by AI and may make mistakes. Always verify output.

// Guild routes — require API secret or OAuth2 JWT
router.use('/guilds', requireAuth(), guildsRouter);
Comment on lines +27 to 32
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

requireAuth() is mounted twice under /guilds (once for membersRouter, again for guildsRouter). For non-member guild routes this runs the auth middleware twice, which is unnecessary work and can have unintended side effects if auth ever mutates request state. Consider mounting requireAuth() once and attaching both routers under it (or mounting membersRouter inside guildsRouter before other routes).

Copilot uses AI. Check for mistakes.

Expand Down
63 changes: 18 additions & 45 deletions src/api/routes/guilds.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,12 +227,18 @@ function isOAuthGuildModerator(user, guildId) {
}

/**
* Create middleware that verifies OAuth2 users have the required guild permission.
* API-secret users and configured bot owners are trusted and pass through.
* Return Express middleware that enforces a guild-level permission for OAuth users.
*
* @param {(user: Object, guildId: string) => Promise<boolean>} permissionCheck - Permission check function
* @param {string} errorMessage - Error message for 403 responses
* @returns {import('express').RequestHandler}
* 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`,
* - 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.
* @returns {import('express').RequestHandler} Express middleware enforcing the permission.
*/
function requireGuildPermission(permissionCheck, errorMessage) {
return async (req, res, next) => {
Expand Down Expand Up @@ -265,7 +271,7 @@ function requireGuildPermission(permissionCheck, errorMessage) {
}

/** Middleware: verify OAuth2 users are guild admins. API-secret users pass through. */
const requireGuildAdmin = requireGuildPermission(
export const requireGuildAdmin = requireGuildPermission(
isOAuthGuildAdmin,
'You do not have admin access to this guild',
);
Expand All @@ -277,10 +283,13 @@ export const requireGuildModerator = requireGuildPermission(
);

/**
* Middleware: validate guild ID param and attach guild to req.
* Returns 404 if the bot is not in the requested guild.
* Validate that the requested guild exists and attach it to req.guild.
*
* If the bot is not present in the guild identified by req.params.id, sends a 404
* response with `{ error: 'Guild not found' }` and does not call `next()`. Otherwise
* sets `req.guild` to the Guild instance and calls `next()`.
*/
function validateGuild(req, res, next) {
export function validateGuild(req, res, next) {
const { client } = req.app.locals;
const guild = client.guilds.cache.get(req.params.id);

Expand Down Expand Up @@ -818,42 +827,6 @@ router.get('/:id/analytics', requireGuildAdmin, validateGuild, async (req, res)
}
});

/**
* GET /:id/members — Cursor-based paginated member list with roles
* Query params: ?limit=25&after=<userId> (max 100)
* Uses Discord's cursor-based pagination via guild.members.list().
*/
router.get('/:id/members', requireGuildAdmin, validateGuild, async (req, res) => {
let limit = Number.parseInt(req.query.limit, 10) || 25;
if (limit < 1) limit = 1;
if (limit > 100) limit = 100;
const after = req.query.after || undefined;

try {
const members = await req.guild.members.list({ limit, after });

const memberList = Array.from(members.values()).map((m) => ({
id: m.id,
username: m.user.username,
displayName: m.displayName,
roles: Array.from(m.roles.cache.values()).map((r) => ({ id: r.id, name: r.name })),
joinedAt: m.joinedAt,
}));

const lastMember = memberList[memberList.length - 1];

res.json({
limit,
after: after || null,
nextAfter: lastMember ? lastMember.id : null,
members: memberList,
});
} catch (err) {
error('Failed to fetch members', { error: err.message, guild: req.params.id });
res.status(500).json({ error: 'Failed to fetch members' });
}
});

/**
* GET /:id/moderation — Paginated moderation cases
* Query params: ?page=1&limit=25 (max 100)
Expand Down
Loading
Loading