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
43 changes: 43 additions & 0 deletions migrations/004_temp_roles.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Migration: Temporary Role Assignments
*
* Creates the temp_roles table to track roles assigned with an expiry.
* The scheduler polls this table and removes roles when they expire.
*/

'use strict';

/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE IF NOT EXISTS temp_roles (
id SERIAL PRIMARY KEY,
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
user_tag TEXT NOT NULL,
role_id TEXT NOT NULL,
role_name TEXT NOT NULL,
moderator_id TEXT NOT NULL,
moderator_tag TEXT NOT NULL,
reason TEXT,
duration TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
removed BOOLEAN NOT NULL DEFAULT FALSE,
removed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);

pgm.sql('CREATE INDEX IF NOT EXISTS idx_temp_roles_guild ON temp_roles(guild_id)');
pgm.sql(
"CREATE INDEX IF NOT EXISTS idx_temp_roles_pending ON temp_roles(removed, expires_at) WHERE removed = FALSE",
);
pgm.sql(
'CREATE INDEX IF NOT EXISTS idx_temp_roles_guild_user ON temp_roles(guild_id, user_id, removed)',
);
};

/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
exports.down = (pgm) => {
pgm.sql('DROP TABLE IF EXISTS temp_roles CASCADE');
};
4 changes: 3 additions & 1 deletion src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import moderationRouter from './routes/moderation.js';
import notificationsRouter from './routes/notifications.js';
import performanceRouter from './routes/performance.js';
import ticketsRouter from './routes/tickets.js';
import tempRolesRouter from './routes/tempRoles.js';
import webhooksRouter from './routes/webhooks.js';

const router = Router();
Expand Down Expand Up @@ -49,13 +49,15 @@

// Ticket routes β€” require API secret or OAuth2 JWT
// (mounted before guilds to handle /:id/tickets/* before the catch-all guild endpoint)
router.use('/guilds', requireAuth(), auditLogMiddleware(), ticketsRouter);

Check failure on line 52 in src/api/index.js

View workflow job for this annotation

GitHub Actions / Test (Vitest Coverage)

tests/index.test.js > index.js > should handle autocomplete errors gracefully

ReferenceError: ticketsRouter is not defined ❯ src/api/index.js:52:60 ❯ src/api/server.js:9:1 ❯ src/index.js:22:1

Check failure on line 52 in src/api/index.js

View workflow job for this annotation

GitHub Actions / Test (Vitest Coverage)

tests/index.test.js > index.js > should handle autocomplete interactions

ReferenceError: ticketsRouter is not defined ❯ src/api/index.js:52:60 ❯ src/api/server.js:9:1 ❯ src/index.js:22:1

Check failure on line 52 in src/api/index.js

View workflow job for this annotation

GitHub Actions / Test (Vitest Coverage)

tests/index.test.js > index.js > should load state from disk when state file exists

ReferenceError: ticketsRouter is not defined ❯ src/api/index.js:52:60 ❯ src/api/server.js:9:1 ❯ src/index.js:22:1

Check failure on line 52 in src/api/index.js

View workflow job for this annotation

GitHub Actions / Test (Vitest Coverage)

tests/index.test.js > index.js > should not call markUnavailable when checkMem0Health succeeds

ReferenceError: ticketsRouter is not defined ❯ src/api/index.js:52:60 ❯ src/api/server.js:9:1 ❯ src/index.js:22:1

Check failure on line 52 in src/api/index.js

View workflow job for this annotation

GitHub Actions / Test (Vitest Coverage)

tests/index.test.js > index.js > should call markUnavailable when checkMem0Health rejects

ReferenceError: ticketsRouter is not defined ❯ src/api/index.js:52:60 ❯ src/api/server.js:9:1 ❯ src/index.js:22:1

Check failure on line 52 in src/api/index.js

View workflow job for this annotation

GitHub Actions / Test (Vitest Coverage)

tests/index.test.js > index.js > should warn and skip db init when DATABASE_URL is not set

ReferenceError: ticketsRouter is not defined ❯ src/api/index.js:52:60 ❯ src/api/server.js:9:1 ❯ src/index.js:22:1

Check failure on line 52 in src/api/index.js

View workflow job for this annotation

GitHub Actions / Test (Vitest Coverage)

tests/index.test.js > index.js > should initialize startup with database when DATABASE_URL is set

ReferenceError: ticketsRouter is not defined ❯ src/api/index.js:52:60 ❯ src/api/server.js:9:1 ❯ src/index.js:22:1

Check failure on line 52 in src/api/index.js

View workflow job for this annotation

GitHub Actions / Test (Vitest Coverage)

tests/index.test.js > index.js > should configure allowedMentions to only parse users (Issue #61)

ReferenceError: ticketsRouter is not defined ❯ src/api/index.js:52:60 ❯ src/api/server.js:9:1 ❯ src/index.js:22:1

// Guild routes β€” require API secret or OAuth2 JWT
router.use('/guilds', requireAuth(), auditLogMiddleware(), guildsRouter);

// Moderation routes β€” require API secret or OAuth2 JWT
router.use('/moderation', requireAuth(), auditLogMiddleware(), moderationRouter);
// Temp role routes β€” require API secret or OAuth2 JWT
router.use('/temp-roles', requireAuth(), auditLogMiddleware(), tempRolesRouter);

// Audit log routes β€” require API secret or OAuth2 JWT
// GET-only; no audit middleware needed (reads are not mutating actions)
Expand Down
250 changes: 250 additions & 0 deletions src/api/routes/tempRoles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
/**
* Temp Roles API Routes
* Exposes temporary role assignment data for the web dashboard.
*
* @see https://github.com/VolvoxLLC/volvox-bot/issues/128
*/

import { Router } from 'express';
import { info, error as logError } from '../../logger.js';
import {
assignTempRole,
listTempRoles,
revokeTempRoleById,
} from '../../modules/tempRoleHandler.js';
import { formatDuration, parseDuration } from '../../utils/duration.js';
import { rateLimit } from '../middleware/rateLimit.js';
import { parsePagination, requireGuildModerator } from './guilds.js';

const router = Router();

/** Rate limiter β€” 120 req / 15 min per IP */
const tempRoleRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, max: 120 });

/**
* Adapt ?guildId= query param to :id path param for requireGuildModerator.
* Only use on routes that need guild id in params (GET list).
*/
function adaptGuildIdParam(req, _res, next) {
if (req.query.guildId) {
req.params.id = req.query.guildId;
}
next();
}

/**
* Adapt req.body.guildId to :id path param for requireGuildModerator.
* Used for POST route where guildId is in the body, not query string.
*/
function adaptBodyGuildId(req, _res, next) {
if (req.body?.guildId) {
req.params.id = req.body.guildId;
}
next();
}

router.use(tempRoleRateLimit);

// ─── GET /temp-roles ──────────────────────────────────────────────────────────

/**
* @openapi
* /temp-roles:
* get:
* tags: [TempRoles]
* summary: List active temp role assignments
* parameters:
* - in: query
* name: guildId
* required: true
* schema: { type: string }
* - in: query
* name: userId
* schema: { type: string }
* - in: query
* name: page
* schema: { type: integer, default: 1 }
* - in: query
* name: limit
* schema: { type: integer, default: 25, maximum: 100 }
* responses:
* "200":
* description: Paginated list of active temp roles
*/
router.get('/', adaptGuildIdParam, requireGuildModerator, async (req, res) => {
try {
const guildId = req.query.guildId;

// Validate guildId is present and is a string
if (!guildId || typeof guildId !== 'string') {
return res.status(400).json({ error: 'guildId is required and must be a string' });
}

const userId = req.query.userId || undefined;
const { page, limit, offset } = parsePagination(req.query);

const { rows, total } = await listTempRoles(guildId, { userId, limit, offset });

return res.json({
data: rows,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
});
} catch (err) {
logError('GET /temp-roles failed', { error: err.message });
return res.status(500).json({ error: 'Failed to fetch temp roles' });
}
});

// ─── DELETE /temp-roles/:id ───────────────────────────────────────────────────

/**
* @openapi
* /temp-roles/{id}:
* delete:
* tags: [TempRoles]
* summary: Revoke a temp role by record ID
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: integer }
* - in: query
* name: guildId
* required: true
* schema: { type: string }
* responses:
* "200": { description: Revoked }
* "404": { description: Not found or already removed }
*/
router.delete('/:id', async (req, res) => {
try {
const guildId = req.query.guildId;
Comment on lines +119 to +121
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

The DELETE endpoint is not protected by requireGuildModerator, unlike the GET/POST routes. This enables any authenticated user (with access to the API) to revoke temp roles by guessing IDs + guildId. Add adaptGuildIdParam (or equivalent) and requireGuildModerator middleware to the DELETE route to enforce guild-level authorization.

Copilot uses AI. Check for mistakes.

// Validate guildId is present and is a string
if (!guildId || typeof guildId !== 'string') {
return res.status(400).json({ error: 'guildId is required and must be a string' });
}

const id = Number.parseInt(req.params.id, 10);

if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'Invalid id' });
}

// Revoke by specific record id (not by user/role which can affect multiple rows)
const updated = await revokeTempRoleById(id, guildId);
if (!updated) {
return res.status(404).json({ error: 'Temp role not found or already removed' });
}

// Best-effort Discord role removal
try {
const client = res.app.locals.client;
if (client) {
const guild = await client.guilds.fetch(guildId);
const member = await guild.members.fetch(updated.user_id).catch(() => null);
if (member) {
await member.roles.remove(updated.role_id, 'Temp role revoked via dashboard');
}
}
} catch (discordErr) {
logError('Dashboard revoke: Discord role removal failed', { error: discordErr.message });
}

info('Temp role revoked via dashboard', {
guildId,
userId: updated.user_id,
roleId: updated.role_id,
moderatorId: req.user?.id,
});

return res.json({ success: true, data: updated });
} catch (err) {
logError('DELETE /temp-roles/:id failed', { error: err.message });
return res.status(500).json({ error: 'Failed to revoke temp role' });
}
});

// ─── POST /temp-roles ─────────────────────────────────────────────────────────

/**
* @openapi
* /temp-roles:
* post:
* tags: [TempRoles]
* summary: Assign a temp role via the dashboard
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [guildId, userId, roleId, duration]
* properties:
* guildId: { type: string }
* userId: { type: string }
* roleId: { type: string }
* duration: { type: string, example: "7d" }
* reason: { type: string }
* responses:
* "201": { description: Assigned }
* "400": { description: Invalid input }
*/
router.post('/', adaptBodyGuildId, requireGuildModerator, async (req, res) => {
try {
const { guildId, userId, roleId, duration: durationStr, reason } = req.body || {};

if (!guildId || !userId || !roleId || !durationStr) {
return res.status(400).json({ error: 'guildId, userId, roleId, and duration are required' });
}

const durationMs = parseDuration(durationStr);
if (!durationMs) {
return res.status(400).json({ error: 'Invalid duration. Use e.g. 1h, 7d, 2w.' });
}

const client = res.app.locals.client;
if (!client) {
return res.status(503).json({ error: 'Discord client not available' });
}

let guild, member, role;
try {
guild = await client.guilds.fetch(guildId);
member = await guild.members.fetch(userId);
role = await guild.roles.fetch(roleId);
} catch {
return res.status(400).json({ error: 'Invalid guild, user, or role' });
}

if (!role) {
return res.status(400).json({ error: 'Role not found' });
}

Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

The dashboard assignment path does not perform role hierarchy checks before attempting member.roles.add(...). When the role is above the bot/moderator, this predictably fails and returns a generic 500. Mirror the command’s hierarchy validation and return a 400/403 with a specific error (e.g., role is higher than bot/mod) to make failures actionable.

Suggested change
// Role hierarchy checks
let botMember = guild.members.me;
if (!botMember && client.user?.id) {
try {
botMember = await guild.members.fetch(client.user.id);
} catch {
// fall through and handle below
}
}
if (!botMember) {
return res.status(503).json({ error: 'Bot member not available in guild for role assignment' });
}
if (role.position >= botMember.roles.highest.position) {
return res.status(403).json({ error: 'Cannot assign a role higher than or equal to the bot’s highest role' });
}
if (req.user?.id) {
try {
const moderatorMember = await guild.members.fetch(req.user.id);
if (moderatorMember && role.position >= moderatorMember.roles.highest.position) {
return res.status(403).json({ error: 'Cannot assign a role higher than or equal to the moderator’s highest role' });
}
} catch {
// If the moderator is not a guild member, skip moderator hierarchy check
}
}

Copilot uses AI. Check for mistakes.
// Assign in Discord
await member.roles.add(roleId, reason || 'Temp role assigned via dashboard');

const expiresAt = new Date(Date.now() + durationMs);
const duration = formatDuration(durationMs);

const record = await assignTempRole({
guildId,
userId,
userTag: member.user.tag,
roleId,
roleName: role.name,
moderatorId: req.user?.id || 'dashboard',
moderatorTag: req.user?.tag || 'Dashboard',
duration,
expiresAt,
reason: reason || null,
});

return res.status(201).json({ success: true, data: record });
} catch (err) {
logError('POST /temp-roles failed', { error: err.message });
return res.status(500).json({ error: 'Failed to assign temp role' });
}
});

export default router;
Loading
Loading