Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import authRouter from './routes/auth.js';
import configRouter from './routes/config.js';
import guildsRouter from './routes/guilds.js';
import healthRouter from './routes/health.js';
import moderationRouter from './routes/moderation.js';
import webhooksRouter from './routes/webhooks.js';

const router = Router();
Expand All @@ -25,6 +26,9 @@ router.use('/config', requireAuth(), configRouter);
// Guild routes — require API secret or OAuth2 JWT
router.use('/guilds', requireAuth(), guildsRouter);

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

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

Expand Down
5 changes: 3 additions & 2 deletions src/api/routes/health.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ router.get('/', async (req, res) => {
const pool = getRestartPool();
if (pool) {
const rows = await getRestarts(pool, 20);
body.restarts = rows.map(r => ({
timestamp: r.timestamp instanceof Date ? r.timestamp.toISOString() : String(r.timestamp),
body.restarts = rows.map((r) => ({
timestamp:
r.timestamp instanceof Date ? r.timestamp.toISOString() : String(r.timestamp),
reason: r.reason || 'unknown',
version: r.version ?? null,
uptimeBefore: r.uptime_seconds ?? null,
Expand Down
329 changes: 329 additions & 0 deletions src/api/routes/moderation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
/**
Copy link

Choose a reason for hiding this comment

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

🟡 Warning — No tests for new API routes: Per AGENTS.md: "Any new code must include tests — PRs that drop coverage below 80% will fail CI." Every other route file has a corresponding test file under tests/api/routes/: auth.test.js, config.test.js, guilds.test.js, health.test.js, webhooks.test.js. This file has 329 lines across 4 endpoints with zero test coverage.

Create tests/api/routes/moderation.test.js covering at minimum:

  • GET /cases — pagination, filters, missing guildId → 400
  • GET /cases/:caseNumber — valid case, not found → 404, invalid caseNumber → 400, guild scoping
  • GET /stats — aggregation shape, missing guildId → 400
  • GET /user/:userId/history — pagination, summary shape, missing params → 400

* Moderation API Routes
* Exposes mod case data for the web dashboard.
*/

import { Router } from 'express';
import { getPool } from '../../db.js';
import { info, error as logError } from '../../logger.js';

const router = Router();

// ─── GET /cases ───────────────────────────────────────────────────────────────

/**
* List mod cases for a guild with optional filters and pagination.
*
* Query params:
* guildId (required) — Discord guild ID
* targetId — Filter by target user ID
* action — Filter by action type (warn, kick, ban, …)
* page (default 1)
* limit (default 25, max 100)
*/
router.get('/cases', async (req, res) => {
const { guildId, targetId, action } = req.query;

if (!guildId) {
return res.status(400).json({ error: 'guildId is required' });
}

const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 25));
const offset = (page - 1) * limit;

try {
const pool = getPool();

// Build dynamic WHERE clause
const conditions = ['guild_id = $1'];
const values = [guildId];
let paramIdx = 2;

if (targetId) {
conditions.push(`target_id = $${paramIdx++}`);
values.push(targetId);
}

if (action) {
conditions.push(`action = $${paramIdx++}`);
values.push(action);
}

const where = conditions.join(' AND ');

const [casesResult, countResult] = await Promise.all([
pool.query(
`SELECT
id,
case_number,
action,
target_id,
target_tag,
moderator_id,
moderator_tag,
reason,
duration,
expires_at,
log_message_id,
created_at
FROM mod_cases
WHERE ${where}
ORDER BY created_at DESC
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`,
[...values, limit, offset],
),
pool.query(`SELECT COUNT(*)::integer AS total FROM mod_cases WHERE ${where}`, values),
]);

const total = countResult.rows[0]?.total ?? 0;
const pages = Math.ceil(total / limit);

info('Mod cases listed', { guildId, page, limit, total });

return res.json({
cases: casesResult.rows,
total,
page,
pages,
});
} catch (err) {
logError('Failed to list mod cases', { error: err.message, guildId });
return res.status(500).json({ error: 'Failed to fetch mod cases' });
}
});

// ─── GET /cases/:caseNumber ────────────────────────────────────────────────────

/**
* Get a single mod case by case_number + guild, including any scheduled actions.
*
* Query params:
* guildId (required) — scoped to prevent cross-guild data exposure
*/
router.get('/cases/:caseNumber', async (req, res) => {
const caseNumber = parseInt(req.params.caseNumber, 10);
if (isNaN(caseNumber)) {
return res.status(400).json({ error: 'Invalid case number' });
}

const { guildId } = req.query;
if (!guildId) {
return res.status(400).json({ error: 'guildId is required' });
}

try {
const pool = getPool();

const caseResult = await pool.query(
`SELECT
id,
guild_id,
case_number,
action,
target_id,
target_tag,
moderator_id,
moderator_tag,
reason,
duration,
expires_at,
log_message_id,
created_at
FROM mod_cases
WHERE case_number = $1 AND guild_id = $2`,
Copy link

Choose a reason for hiding this comment

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

🟡 Warning — case_number not guaranteed unique per guild: Per AGENTS.md (pitfall #11), case_number is assigned via COALESCE(MAX(case_number), 0) + 1 inside createCase(). Without a UNIQUE(guild_id, case_number) constraint, concurrent case creation could theoretically produce duplicates. This query would then return multiple rows.

Consider adding LIMIT 1 as a safety measure, or adding a UNIQUE(guild_id, case_number) constraint in a migration.

[caseNumber, guildId],
);

if (caseResult.rows.length === 0) {
return res.status(404).json({ error: 'Case not found' });
}

const caseRow = caseResult.rows[0];

const scheduledResult = await pool.query(
`SELECT id, action, target_id, execute_at, executed, created_at
FROM mod_scheduled_actions
WHERE case_id = $1
ORDER BY execute_at ASC`,
[caseRow.id],
);

return res.json({
...caseRow,
scheduledActions: scheduledResult.rows,
});
} catch (err) {
logError('Failed to fetch mod case', { error: err.message, caseNumber, guildId });
return res.status(500).json({ error: 'Failed to fetch mod case' });
}
});

// ─── GET /stats ───────────────────────────────────────────────────────────────

/**
* Get moderation stats summary for a guild.
*
* Query params:
* guildId (required)
*/
router.get('/stats', async (req, res) => {
const { guildId } = req.query;

if (!guildId) {
return res.status(400).json({ error: 'guildId is required' });
}

try {
const pool = getPool();

const [totalResult, last24hResult, last7dResult, byActionResult, topTargetsResult] =
await Promise.all([
// Total cases
pool.query('SELECT COUNT(*)::integer AS total FROM mod_cases WHERE guild_id = $1', [
guildId,
]),

// Last 24 hours
pool.query(
`SELECT COUNT(*)::integer AS total FROM mod_cases
WHERE guild_id = $1 AND created_at > NOW() - INTERVAL '24 hours'`,
[guildId],
),

// Last 7 days
pool.query(
`SELECT COUNT(*)::integer AS total FROM mod_cases
WHERE guild_id = $1 AND created_at > NOW() - INTERVAL '7 days'`,
[guildId],
),

// Breakdown by action
pool.query(
`SELECT action, COUNT(*)::integer AS count
FROM mod_cases
WHERE guild_id = $1
GROUP BY action`,
[guildId],
),

// Top targets (most cases in last 30 days)
pool.query(
`SELECT target_id AS "userId", target_tag AS tag, COUNT(*)::integer AS count
FROM mod_cases
WHERE guild_id = $1 AND created_at > NOW() - INTERVAL '30 days'
GROUP BY target_id, target_tag
ORDER BY count DESC
LIMIT 10`,
[guildId],
),
]);

// Convert byAction rows to a flat object
const byAction = {};
for (const row of byActionResult.rows) {
byAction[row.action] = row.count;
}

return res.json({
totalCases: totalResult.rows[0]?.total ?? 0,
last24h: last24hResult.rows[0]?.total ?? 0,
last7d: last7dResult.rows[0]?.total ?? 0,
byAction,
topTargets: topTargetsResult.rows,
});
} catch (err) {
logError('Failed to fetch mod stats', { error: err.message, guildId });
return res.status(500).json({ error: 'Failed to fetch mod stats' });
}
});

// ─── GET /user/:userId/history ────────────────────────────────────────────────

/**
* Get full moderation history for a specific user in a guild.
*
* Query params:
* guildId (required) — Discord guild ID
* page (default 1)
* limit (default 25, max 100)
*/
router.get('/user/:userId/history', async (req, res) => {
const { userId } = req.params;
const { guildId } = req.query;

if (!guildId) {
return res.status(400).json({ error: 'guildId is required' });
}

if (!userId) {
return res.status(400).json({ error: 'userId is required' });
}

const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 25));
const offset = (page - 1) * limit;

try {
const pool = getPool();

const [casesResult, countResult, summaryResult] = await Promise.all([
pool.query(
`SELECT
id,
case_number,
action,
target_id,
target_tag,
moderator_id,
moderator_tag,
reason,
duration,
expires_at,
log_message_id,
created_at
FROM mod_cases
WHERE guild_id = $1 AND target_id = $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4`,
[guildId, userId, limit, offset],
),
pool.query(
`SELECT COUNT(*)::integer AS total FROM mod_cases
WHERE guild_id = $1 AND target_id = $2`,
[guildId, userId],
),
pool.query(
`SELECT action, COUNT(*)::integer AS count
FROM mod_cases
WHERE guild_id = $1 AND target_id = $2
GROUP BY action`,
[guildId, userId],
),
]);

const total = countResult.rows[0]?.total ?? 0;
const pages = Math.ceil(total / limit);

const byAction = {};
for (const row of summaryResult.rows) {
byAction[row.action] = row.count;
}

info('User mod history fetched', { guildId, userId, page, limit, total });

return res.json({
userId,
cases: casesResult.rows,
total,
page,
pages,
byAction,
});
} catch (err) {
logError('Failed to fetch user mod history', { error: err.message, guildId, userId });
return res.status(500).json({ error: 'Failed to fetch user mod history' });
}
});

export default router;
4 changes: 1 addition & 3 deletions src/api/ws/logStream.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,7 @@ function validateTicket(ticket, secret) {
if (!Number.isFinite(expiryNum) || expiryNum <= Date.now()) return false;

// Re-derive HMAC and compare with timing-safe equality
const expected = createHmac('sha256', secret)
.update(`${nonce}.${expiry}`)
.digest('hex');
const expected = createHmac('sha256', secret).update(`${nonce}.${expiry}`).digest('hex');

try {
return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(hmac, 'hex'));
Expand Down
4 changes: 2 additions & 2 deletions src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
* PostgreSQL connection pool and migration runner
*/

import { fileURLToPath } from 'node:url';
import path from 'node:path';
import pg from 'pg';
import { fileURLToPath } from 'node:url';
import { runner } from 'node-pg-migrate';
import pg from 'pg';
import { info, error as logError } from './logger.js';

const { Pool } = pg;
Expand Down
Loading
Loading