Skip to content
Open
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
24 changes: 16 additions & 8 deletions apps/bot/src/features/automation/system/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,28 @@ import type {
StepConditionConfig,
} from "@fluxcore/systems/actions/types";

// --- Per-guild rate limiting ---
// --- Per-(guild, eventType) rate limiting ---
//
// Each event type gets its own 60/min bucket so a noisy event class
// (e.g. messageCreate) cannot starve quieter ones (e.g. memberJoin).

const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
const RATE_LIMIT_MAX_EXECUTIONS = 60; // max 60 action executions per guild per minute
const RATE_LIMIT_MAX_EXECUTIONS = 60; // max 60 action executions per (guild, eventType) per minute

const guildExecutionCounts = new Map<string, { count: number; resetAt: number }>();
const eventBuckets = new Map<string, { count: number; resetAt: number }>();

function checkRateLimit(guildId: string): boolean {
function bucketKey(guildId: string, eventType: string): string {
return `${guildId}\u0000${eventType}`;
}

function checkRateLimit(guildId: string, eventType: string): boolean {
const key = bucketKey(guildId, eventType);
const now = Date.now();
let entry = guildExecutionCounts.get(guildId);
let entry = eventBuckets.get(key);

if (!entry || now >= entry.resetAt) {
entry = { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
guildExecutionCounts.set(guildId, entry);
eventBuckets.set(key, entry);
}

if (entry.count >= RATE_LIMIT_MAX_EXECUTIONS) {
Expand Down Expand Up @@ -173,7 +181,7 @@ async function executeSteps(

switch (step.type) {
case "action": {
if (!checkRateLimit(context.guildId)) {
if (!checkRateLimit(context.guildId, context.eventType)) {
logger.warn(`Rate limit hit for guild ${context.guildId} during rule "${rule.name}"`);
return;
}
Expand Down Expand Up @@ -271,7 +279,7 @@ export async function processEvent(
// V1: linear actions
if (!rule.actions?.length) continue;
for (const actionConfig of rule.actions) {
if (!checkRateLimit(context.guildId)) {
if (!checkRateLimit(context.guildId, context.eventType)) {
logger.warn(`Rate limit hit for guild ${context.guildId} during rule "${rule.name}"`);
return;
}
Expand Down
126 changes: 126 additions & 0 deletions apps/bot/tests/features/automation/system/executor-rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@fluxcore/config", () => ({
config: {
token: "test-token",
clientId: "test-client-id",
guildId: undefined,
logLevel: "info",
},
}));

vi.mock("@fluxcore/utils", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));

const { getRulesForEventMock, executorMock, logExecutionMock } = vi.hoisted(
() => ({
getRulesForEventMock: vi.fn(),
executorMock: vi.fn().mockResolvedValue(undefined),
logExecutionMock: vi.fn().mockResolvedValue(undefined),
}),
);

vi.mock("@fluxcore/systems/actions/cache", () => ({
getRulesForEvent: (...args: unknown[]) => getRulesForEventMock(...args),
}));

vi.mock("@fluxcore/systems/actions/config", () => ({
getGuildSettingsOrDefault: () => ({
globalEnabled: true,
maxRules: 25,
logChannelId: null,
}),
}));

vi.mock("@fluxcore/systems/actions/persistence", () => ({
logExecution: (...args: unknown[]) => logExecutionMock(...args),
}));

vi.mock("../../../../src/features/automation/system/registry.js", () => ({
getExecutor: () => executorMock,
}));

import type { Client } from "discord.js";

const { processEvent } = await import(
"../../../../src/features/automation/system/executor.js"
);

type TestEventContext = {
eventType: string;
guildId: string;
timestamp: string;
};

const fakeClient = {} as Client;

function makeRule(eventType: string, id = 1) {
return {
id,
guildId: "g-rl",
name: `r-${eventType}-${id}`,
enabled: true,
eventType,
actions: [{ type: "addRole", roleId: "r" }],
conditions: {},
priority: 0,
createdBy: "u",
};
}

function ctx(guildId: string, eventType: string): TestEventContext {
return {
eventType,
guildId,
timestamp: new Date().toISOString(),
};
}

describe("processEvent rate limiting", () => {
beforeEach(() => {
executorMock.mockClear();
getRulesForEventMock.mockReset();
logExecutionMock.mockClear();
});

it("does not let one event type starve another", async () => {
const guildId = `guild-starve-${Date.now()}`;
getRulesForEventMock.mockImplementation((_g: string, ev: string) => [
makeRule(ev),
]);

// Burn through the messageCreate budget
for (let i = 0; i < 80; i++) {
await processEvent(fakeClient, ctx(guildId, "messageCreated") as never);
}

const messageCreateExecCount = executorMock.mock.calls.length;
expect(messageCreateExecCount).toBeLessThanOrEqual(60);
expect(messageCreateExecCount).toBeGreaterThan(0);

// Now memberJoin must still be allowed: separate bucket
executorMock.mockClear();
await processEvent(fakeClient, ctx(guildId, "memberJoin") as never);

expect(executorMock).toHaveBeenCalledTimes(1);
});

it("enforces the per-(guild, eventType) cap independently per guild", async () => {
const a = `guild-a-${Date.now()}`;
const b = `guild-b-${Date.now()}`;
getRulesForEventMock.mockImplementation((_g: string, ev: string) => [
makeRule(ev),
]);

for (let i = 0; i < 70; i++) {
await processEvent(fakeClient, ctx(a, "messageCreated"));
}
const aCount = executorMock.mock.calls.length;
expect(aCount).toBeLessThanOrEqual(60);

executorMock.mockClear();
await processEvent(fakeClient, ctx(b, "messageCreated"));
expect(executorMock).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
createDashboardAuditLog,
invalidatePermissionCache,
} from "../../shared/permissions.js";
import { deleteDashboardRoleWithAudit } from "../../shared/dashboardRoleDelete.js";

const MAX_ROLES_PER_GUILD = 25;
const MAX_PERMISSIONS_PER_ROLE = 100;
Expand Down Expand Up @@ -303,8 +304,12 @@ export function registerDashboardRoleRoutes(app: FastifyInstance): void {
return;
}

await prisma.dashboardRole.delete({ where: { id: roleId } });
invalidatePermissionCache(guildId);
await deleteDashboardRoleWithAudit({
guildId,
roleId,
actorId: session.userId,
actorUsername: session.username,
});

await createDashboardAuditLog({
guildId,
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/src/server/features/permissions/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ export function registerDashboardPermissionRoutes(app: FastifyInstance): void {
action: e.action,
targetType: e.targetType,
targetId: e.targetId,
details: JSON.parse(e.details),
details: e.details,
createdAt: e.createdAt,
})),
total,
Expand Down
26 changes: 26 additions & 0 deletions apps/dashboard/src/server/shared/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,29 @@ export function decrypt(encoded: string): string {
decipher.final(),
]).toString("utf8");
}

const MIN_ENCRYPTED_BYTES = IV_LENGTH + AUTH_TAG_LENGTH + 1;

/**
* Best-effort check whether a stored string was produced by `encrypt()`.
* Used to detect legacy plaintext rows during the encryption backfill.
*/
export function isEncrypted(value: string): boolean {
if (!value) return false;
// Base64 alphabet check (allow padding)
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return false;
let buf: Buffer;
try {
buf = Buffer.from(value, "base64");
} catch {
return false;
}
if (buf.length < MIN_ENCRYPTED_BYTES) return false;
// Verify by attempting decryption with the active key
try {
decrypt(value);
return true;
} catch {
return false;
}
}
64 changes: 64 additions & 0 deletions apps/dashboard/src/server/shared/dashboardRoleDelete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { getPrisma } from "@fluxcore/database";
import { logger } from "@fluxcore/utils";
import { invalidatePermissionCache } from "./permissions.js";

export interface DeleteDashboardRoleArgs {
guildId: string;
roleId: string;
actorId: string;
actorUsername: string;
}

/**
* Safely delete a DashboardRole: enumerate assignments, write an audit
* entry per unassignment, invalidate per-user permission caches, and
* only then delete the role itself. The schema's onDelete: Restrict
* guarantees that this is the ONLY safe path to remove a role.
*/
export async function deleteDashboardRoleWithAudit(
args: DeleteDashboardRoleArgs,
): Promise<void> {
const prisma = getPrisma();

const assignedUserIds: string[] = await prisma.$transaction(async (tx) => {
const assignments = await tx.dashboardRoleAssignment.findMany({
where: { guildId: args.guildId, roleId: args.roleId },
});

for (const a of assignments) {
await tx.dashboardRoleAssignment.delete({ where: { id: a.id } });
await tx.dashboardAuditLog.create({
data: {
guildId: args.guildId,
userId: args.actorId,
username: args.actorUsername,
action: "role.unassign",
targetType: "user",
targetId: a.userId,
details: { roleId: args.roleId, reason: "role-delete" },
},
});
}

await tx.dashboardRole.delete({ where: { id: args.roleId } });
await tx.dashboardAuditLog.create({
data: {
guildId: args.guildId,
userId: args.actorId,
username: args.actorUsername,
action: "role.delete",
targetType: "role",
targetId: args.roleId,
details: { unassignedUsers: assignments.map((a) => a.userId) },
},
});

return assignments.map((a) => a.userId);
});

// Invalidate cached permissions for every previously-assigned user
invalidatePermissionCache(args.guildId);
logger.info(
`Dashboard role ${args.roleId} deleted in guild ${args.guildId} by ${args.actorUsername} (${assignedUserIds.length} unassigned)`,
);
}
2 changes: 1 addition & 1 deletion apps/dashboard/src/server/shared/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export async function createDashboardAuditLog(
action: entry.action,
targetType: entry.targetType ?? null,
targetId: entry.targetId ?? null,
details: JSON.stringify(entry.details ?? {}),
details: (entry.details ?? {}) as object,
},
});
} catch (err) {
Expand Down
Loading