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
45 changes: 45 additions & 0 deletions decisions/2026-06-23-role-groups-composite-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ADR: Role Groups Composite Actions & Concurrency Hardening

**Date:** 2026-06-23
**Status:** Accepted
**Scope:** Role Groups service (feat/role-groups PR #1544)

## Decision

### Shipped (v2.21.0+)

1. **IDOR Protection (A1, A2):** All role group queries now require guild ownership verification. `addRoleToGroup` now signature-checks `guildId`; `createRoleGroup` rejects `fromMessageId` from other guilds. Integration tests added.
2. **Feature-Breaking Validation (B1):** Schema fixed to accept CUID message IDs (not Discord snowflakes). Existing tests pass.
3. **Tie-Breaking Correctness (C1):** `seedStyleFromMessage` now correctly defaults to Primary on any non-Primary tie. Test updated to exercise real Secondary×1 + Danger×1 case.
4. **Type Safety (D):** All `any` types replaced with Prisma model types (`RoleGroupModel`, `ReactionRoleMappingModel`). Lint reports 0 errors (from 12).
5. **Test Hygiene (E1):** `process.env.DISCORD_TOKEN` now restored after each test suite (afterAll hook).
6. **UX (E2):** Error state cleared before retry in `handleCreateRoleGroup`, preventing stale banners.
7. **Transaction Atomicity (F1):** `createRoleGroup` link operation wrapped in `prisma.$transaction` with conditional `updateMany` to detect race and reject if message already grouped during transaction.

### Deferred (v2.2 post-launch)

**F2: Concurrency Guard for `addRoleToMessage` Appends**

- Current design: guard checks `length >= 25` but concurrent appends can both pass, then both insert.
- Symptom: Message ends up with >25 buttons.
- Fix: Wrap in transaction + final constraint OR DB unique on (messageId, sequence).
- Opened GitHub issue #1558 (label: `ready-for-agent`).
- Priority: v2.2; does not block launch.

## Also Filed

- **Issue #1559:** CI lint gate reports green despite 12 type-aware errors in backend (type safety gate gap).

## Test Coverage

- New IDOR tests: `createRoleGroup` rejects cross-guild message, `addRoleToGroup` rejects cross-guild group.
- Tie-breaking test: real Secondary+Danger tie.
- Concurrency (transaction): conditional updateMany validates link atomicity.

## References

- Service: `packages/backend/src/services/RoleGroupService.ts`
- Routes: `packages/backend/src/routes/roleGroups.ts`
- Schema: `packages/backend/src/schemas/management.ts`
- Tests: `packages/backend/tests/unit/services/RoleGroupService.test.ts`, `packages/backend/tests/integration/routes/roleGroups.test.ts`
- Frontend: `packages/frontend/src/pages/ReactionRoles.tsx`
4 changes: 4 additions & 0 deletions packages/backend/src/errors/AppError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,8 @@ export class AppError extends Error {
static gatewayTimeout(message = 'Gateway timeout'): AppError {
return new AppError(504, message)
}

static conflict(message: string): AppError {
return new AppError(409, message)
}
}
7 changes: 7 additions & 0 deletions packages/backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { setupRecommendationsRoutes } from './recommendations'
import { setupTwitchRoutes } from './twitch'
import { setupLyricsRoutes } from './lyrics'
import { setupRolesRoutes } from './roles'
import { setupRoleGroupsRoutes } from './roleGroups'
import { setupRbacRoutes } from './rbac'
import { setupGuildAutomationRoutes } from './guildAutomation'
import { setupLevelsRoutes } from './levels'
Expand Down Expand Up @@ -64,6 +65,11 @@ const guildGuardConfigs: GuildGuardConfig[] = [
{ path: '/api/guilds/:guildId/levels', module: 'settings' },
{ path: '/api/guilds/:guildId/starboard', module: 'settings' },
{ path: '/api/guilds/:guildId/recommendations', module: 'settings' },
{
path: '/api/guilds/:guildId/role-groups',
module: 'settings',
mode: 'manage',
},
]

const routeSetups = [
Expand All @@ -80,6 +86,7 @@ const routeSetups = [
setupTwitchRoutes,
setupLyricsRoutes,
setupRolesRoutes,
setupRoleGroupsRoutes,
setupRbacRoutes,
setupGuildAutomationRoutes,
setupLevelsRoutes,
Expand Down
190 changes: 190 additions & 0 deletions packages/backend/src/routes/roleGroups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import type { Express, Response } from 'express'
import type { AuthenticatedRequest } from '../middleware/auth'
import { validateParams, validateBody } from '../middleware/validate'
import { asyncHandler } from '../middleware/asyncHandler'
import { AppError } from '../errors/AppError'
import { managementSchemas as s } from '../schemas/management'
import { writeLimiter } from '../middleware/rateLimit'
import { roleGroupService } from '../services/RoleGroupService'

function p(val: string | string[]): string {
return typeof val === 'string' ? val : val[0]
}

export function setupRoleGroupsRoutes(app: Express): void {
app.post(
'/api/guilds/:guildId/role-groups',
writeLimiter,
validateParams(s.guildIdParam),
validateBody(s.createRoleGroupBody),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const guildId = p(req.params.guildId)
const data = s.createRoleGroupBody.parse(req.body)

try {
const group = await roleGroupService.createRoleGroup({
guildId,
name: data.name,
fromMessageId: data.fromMessageId,
style: data.style,
})
res.status(201).json(group)
} catch (error: unknown) {
if (error instanceof AppError) {
throw error as AppError

Check warning on line 34 in packages/backend/src/routes/roleGroups.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected an error object to be thrown.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ72ZUMb20gtn1HCHlEz&open=AZ72ZUMb20gtn1HCHlEz&pullRequest=1557
}
const err =
error instanceof Error ? error : new Error(String(error))
const message = err.message || 'Failed to create role group'
if (
message.includes('not found') ||
message.includes('not-found')
) {
throw AppError.notFound(message)
}
if (
message.includes('conflict') ||
message.includes('already-grouped')
) {
throw AppError.conflict(message)
}
throw AppError.badRequest(message)
}
}),
)

app.get(
'/api/guilds/:guildId/role-groups',
validateParams(s.guildIdParam),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const guildId = p(req.params.guildId)

const groups = await roleGroupService.listRoleGroups(guildId)
res.json({ groups })
}),
)

app.get(
'/api/guilds/:guildId/role-groups/:id',
validateParams(s.roleGroupIdParam),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const guildId = p(req.params.guildId)
const id = p(req.params.id)

const group = await roleGroupService.getRoleGroup(id, guildId)
if (!group) {
throw AppError.notFound('Role group not found')
}
res.json(group)
}),
)

app.patch(
'/api/guilds/:guildId/role-groups/:id',
writeLimiter,
validateParams(s.roleGroupIdParam),
validateBody(s.updateRoleGroupBody),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const guildId = p(req.params.guildId)
const id = p(req.params.id)
const data = s.updateRoleGroupBody.parse(req.body)

try {
const group = await roleGroupService.updateRoleGroup(
id,
guildId,
data,
)
if (!group) {
throw AppError.notFound('Role group not found')
}
res.json(group)
} catch (error: unknown) {
if (error instanceof AppError) {
throw error as AppError

Check warning on line 104 in packages/backend/src/routes/roleGroups.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected an error object to be thrown.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ72ZUMb20gtn1HCHlE0&open=AZ72ZUMb20gtn1HCHlE0&pullRequest=1557
}
const err =
error instanceof Error ? error : new Error(String(error))
const message = err.message || 'Failed to update role group'
throw AppError.badRequest(message)
}
}),
)

app.post(
'/api/guilds/:guildId/role-groups/:id/roles',
writeLimiter,
validateParams(s.roleGroupIdParam),
validateBody(s.addRoleToGroupBody),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {

Check warning on line 119 in packages/backend/src/routes/roleGroups.ts

View workflow job for this annotation

GitHub Actions / Checks

Async arrow function has a complexity of 16. Maximum allowed is 15
const guildId = p(req.params.guildId)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const id = p(req.params.id)
const botToken = process.env.DISCORD_TOKEN?.trim()
if (!botToken) {
throw AppError.serviceUnavailable('Bot token not configured')
}

const data = s.addRoleToGroupBody.parse(req.body)

try {
const result = await roleGroupService.addRoleToGroup(
guildId,
id,
data,
botToken,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.json(result)
} catch (error: unknown) {
if (error instanceof AppError) {
throw error as AppError

Check warning on line 139 in packages/backend/src/routes/roleGroups.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Expected an error object to be thrown.

See more on https://sonarcloud.io/project/issues?id=LucasSantana-Dev_Lucky&issues=AZ72ZUMb20gtn1HCHlE1&open=AZ72ZUMb20gtn1HCHlE1&pullRequest=1557
}
const err =
error instanceof Error ? error : new Error(String(error))
const message = err.message || 'Failed to add role to group'

// Check for specific error patterns (from tests and RoleGroupService)
if (
message.includes('not found') ||
message.includes('not-found')
) {
throw AppError.notFound(message)
}
if (
message.includes('25 buttons') ||
message.includes('25-buttons') ||
message.includes('250 roles') ||
message.includes('250-roles') ||
message.includes('already mapped') ||
message.includes('duplicate-name')
) {
throw AppError.conflict(message)
}
if (message.includes('Label too long')) {
throw AppError.badRequest(message)
}
throw AppError.badRequest(message)
}
}),
)

app.delete(
'/api/guilds/:guildId/role-groups/:id/roles/:roleId',
writeLimiter,
validateParams(s.roleGroupRoleIdParam),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
const guildId = p(req.params.guildId)
const id = p(req.params.id)
const roleId = p(req.params.roleId)

const success = await roleGroupService.detachRoleFromGroup(
id,
roleId,
guildId,
)
if (!success) {
throw AppError.notFound('Role group or role not found')
}
res.json({ success: true })
}),
)
}
62 changes: 62 additions & 0 deletions packages/backend/src/schemas/management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,63 @@ const messageIdParam = guildIdParam.extend({
messageId: z.string().regex(/^\d{17,20}$/, 'Invalid message ID'),
})

const roleGroupIdParam = guildIdParam.extend({
id: z.string().min(1, 'Role group ID is required'),
})

const roleGroupRoleIdParam = roleGroupIdParam.extend({
roleId: z.string().regex(/^\d{17,20}$/, 'Invalid role ID'),
})

const colorHex = z
.string()
.regex(/^0x[0-9A-Fa-f]{6}$/, 'Color must be hex format: 0xRRGGBB')
Comment thread
LucasSantana-Dev marked this conversation as resolved.
.optional()
.nullable()

const createRoleGroupBody = z
.object({
name: z.string().min(1, 'Name is required').max(100),
fromMessageId: z.string().min(1, 'Message ID is required').optional(),
style: z
.object({
color: colorHex,
hoist: z.boolean().optional(),
mentionable: z.boolean().optional(),
buttonStyle: z
.enum(['Primary', 'Secondary', 'Success', 'Danger'])
Comment thread
LucasSantana-Dev marked this conversation as resolved.
.optional(),
})
.strict()
.optional(),
})
.strict()

const updateRoleGroupBody = z
.object({
color: colorHex,
hoist: z.boolean().optional(),
mentionable: z.boolean().optional(),
buttonStyle: z
.enum(['Primary', 'Secondary', 'Success', 'Danger'])
.optional()
.nullable(),
})
.strict()

const addRoleToGroupBody = z
.object({
name: z.string().min(1, 'Role name is required').max(100),
label: z.string().max(80, 'Label must be 80 chars or less').optional(),
emoji: z.string().max(100).optional(),
colorOverride: z
.string()
.regex(/^0x[0-9A-Fa-f]{6}$/, 'Color must be hex format: 0xRRGGBB')
.optional(),
dryRun: z.boolean().optional(),
})
.strict()

export const managementSchemas = {
guildIdParam,
commandNameParam,
Expand All @@ -163,4 +220,9 @@ export const managementSchemas = {
createReactionRoleBody,
updateReactionRoleBody,
messageIdParam,
roleGroupIdParam,
roleGroupRoleIdParam,
createRoleGroupBody,
updateRoleGroupBody,
addRoleToGroupBody,
}
Loading
Loading