anti-brigading system - detection engine, phantom voting, ALTCHA adaptive difficulty, honeypot fields, admin security dashboard, auto-learning

This commit is contained in:
2026-03-22 08:35:26 +02:00
parent a530ce67b0
commit 14a605b3de
23 changed files with 3104 additions and 86 deletions

View File

@@ -34,6 +34,10 @@ model Board {
rssFeedCount Int @default(50)
staleDays Int @default(0)
position Int @default(0)
sensitivityLevel String @default("normal")
velocityThreshold Int?
quarantined Boolean @default(false)
requireVoteVerification Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -43,6 +47,8 @@ model Board {
statusConfig BoardStatus[]
templates BoardTemplate[]
changelogEntries ChangelogEntry[]
baseline BoardBaseline?
brigadePatterns BrigadePattern[]
}
model BoardStatus {
@@ -69,6 +75,12 @@ model User {
displayName String?
avatarPath String?
darkMode String @default("system")
firstActionType String?
firstActionAt DateTime?
actionDiversityScore Float @default(0)
voteTimingStdDev Float?
boardInteractionCount Int @default(0)
flagCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -128,6 +140,8 @@ model Post {
isEditLocked Boolean @default(false)
isThreadLocked Boolean @default(false)
isVotingLocked Boolean @default(false)
frozenAt DateTime?
votesVisibleAfter DateTime?
onBehalfOf String?
boardId String
authorId String
@@ -148,6 +162,7 @@ model Post {
tags PostTag[]
attachments Attachment[]
editHistory EditHistory[]
voteSnapshots PostVoteSnapshot[]
@@index([boardId, status])
}
@@ -206,6 +221,9 @@ model Vote {
id String @id @default(cuid())
weight Int @default(1)
importance String?
phantom Boolean @default(false)
voterIp String?
referrer String?
postId String
voterId String
budgetPeriod String
@@ -493,3 +511,60 @@ model PluginData {
@@unique([pluginId, key])
@@index([pluginId])
}
model AnomalyEvent {
id String @id @default(cuid())
type String
severity String
targetType String
targetId String
boardId String?
metadata Json @default("{}")
status String @default("pending")
createdAt DateTime @default(now())
@@index([status, createdAt])
@@index([targetType, targetId])
@@index([boardId, createdAt])
}
model BoardBaseline {
id String @id @default(cuid())
boardId String @unique
avgVotesPerHour Float @default(0)
avgPostsPerDay Float @default(0)
avgReactionsPerHour Float @default(0)
peakHourOfDay Int @default(12)
peakDayOfWeek Int @default(2)
updatedAt DateTime @updatedAt
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
}
model PostVoteSnapshot {
id String @id @default(cuid())
postId String
voteCount Int
snapshotAt DateTime @default(now())
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@index([postId, snapshotAt])
}
model BrigadePattern {
id String @id @default(cuid())
boardId String?
features Json
matchCount Int @default(0)
createdAt DateTime @default(now())
board Board? @relation(fields: [boardId], references: [id], onDelete: Cascade)
}
model AdminWebhookConfig {
id String @id @default(cuid())
url String
events String[]
active Boolean @default(true)
}

View File

@@ -7,6 +7,26 @@ import { cleanExpiredChallenges } from "../routes/passkey.js";
import { cleanupExpiredTokens } from "../lib/token-blocklist.js";
import { getPluginCronJobs } from "../plugins/loader.js";
import { cleanupViews } from "../lib/view-tracker.js";
import {
takeVoteSnapshots,
scanVoteVelocity,
scanPostVelocity,
scanIdentityClusters,
scanInflectionPoints,
scanCohortArrivals,
scanReferrerConcentration,
scanPostSimilarity,
recalculateBoardBaselines,
scanVoterOverlap,
scanOutboundLinks,
scanCommentVoteRatio,
scanVoteDistribution,
compareSeasonalBaseline,
pruneOldSnapshots,
pruneOldAnomalyEvents,
scanOffHoursActivity,
buildVoterGraph,
} from "../services/detection-engine.js";
export function startCronJobs() {
// prune old activity events - daily at 3am
@@ -108,6 +128,53 @@ export function startCronJobs() {
// clean expired view-tracker entries - every 5 minutes
cron.schedule("*/5 * * * *", () => { cleanupViews(); });
// --- anti-brigading detection jobs ---
// every 5 min: snapshots + velocity scans
cron.schedule("*/5 * * * *", async () => {
await takeVoteSnapshots().catch(() => {});
await scanVoteVelocity().catch(() => {});
await scanPostVelocity().catch(() => {});
});
// every 10 min: identity clustering
cron.schedule("*/10 * * * *", async () => {
await scanIdentityClusters().catch(() => {});
});
// every 15 min: inflection point detection
cron.schedule("*/15 * * * *", async () => {
await scanInflectionPoints().catch(() => {});
});
// every 30 min: cohort arrivals, referrer analysis, post similarity
cron.schedule("*/30 * * * *", async () => {
await scanCohortArrivals().catch(() => {});
await scanReferrerConcentration().catch(() => {});
await scanPostSimilarity().catch(() => {});
});
// hourly: baselines, overlap, links, comment ratio, off-hours, voter graph
cron.schedule("0 * * * *", async () => {
await recalculateBoardBaselines().catch(() => {});
await scanVoterOverlap().catch(() => {});
await scanOutboundLinks().catch(() => {});
await scanCommentVoteRatio().catch(() => {});
await scanOffHoursActivity().catch(() => {});
const boards = await prisma.board.findMany({ select: { id: true } }).catch(() => [] as { id: string }[]);
for (const b of boards) {
await buildVoterGraph(b.id).catch(() => {});
}
});
// daily at 3am: distribution, seasonal, pruning
cron.schedule("0 3 * * *", async () => {
await scanVoteDistribution().catch(() => {});
await compareSeasonalBaseline().catch(() => {});
await pruneOldSnapshots().catch(() => {});
await pruneOldAnomalyEvents().catch(() => {});
});
// register plugin-provided cron jobs (min interval: every minute, reject sub-minute)
for (const job of getPluginCronJobs()) {
if (!cron.validate(job.schedule)) {

View File

@@ -0,0 +1,509 @@
import { FastifyInstance } from "fastify";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "../../lib/prisma.js";
import { buildVoterGraph } from "../../services/detection-engine.js";
const ANOMALY_TYPE_LABELS: Record<string, string> = {
vote_velocity: "Vote velocity spike",
post_velocity: "Post creation spike",
identity_cluster: "Identity cluster",
cohort_arrival: "Coordinated arrival",
voter_overlap: "Voter overlap",
vote_distribution_outlier: "Vote distribution outlier",
inflection_point: "Vote inflection point",
referrer_concentration: "Referrer concentration",
post_similarity: "Similar post titles",
outbound_link_cluster: "Outbound link cluster",
reaction_velocity: "Reaction velocity spike",
comment_vote_ratio: "Low comment-to-vote ratio",
off_hours_activity: "Off-hours activity",
voter_network_cluster: "Voter network cluster",
seasonal_deviation: "Seasonal deviation",
post_substantially_edited: "Post substantially edited after votes",
audit_cleanup: "Cleanup action",
};
const alertsQuery = z.object({
status: z.enum(["pending", "confirmed", "dismissed"]).optional(),
severity: z.string().max(20).optional(),
type: z.string().max(50).optional(),
boardId: z.string().max(50).optional(),
page: z.coerce.number().int().min(1).max(500).default(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
});
const cleanupBody = z.object({
anomalyEventId: z.string().min(1),
actions: z.array(z.enum(["remove_phantom_votes", "remove_flagged_votes", "recalculate_counts"])).min(1),
});
const boardSecurityBody = z.object({
sensitivityLevel: z.enum(["low", "normal", "high", "paranoid"]).optional(),
velocityThreshold: z.number().int().min(0).nullable().optional(),
quarantined: z.boolean().optional(),
requireVoteVerification: z.boolean().optional(),
});
const webhookBody = z.object({
url: z.string().url().max(500),
events: z.array(z.string().max(50)).min(1),
active: z.boolean().default(true),
});
const webhookUpdateBody = z.object({
url: z.string().url().max(500).optional(),
events: z.array(z.string().max(50)).min(1).optional(),
active: z.boolean().optional(),
});
export default async function adminSecurityRoutes(app: FastifyInstance) {
// 1. List anomaly events
app.get<{ Querystring: Record<string, string> }>(
"/admin/security/alerts",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const q = alertsQuery.safeParse(req.query);
if (!q.success) {
reply.status(400).send({ error: "Invalid query parameters" });
return;
}
const { status, severity, type, boardId, page, limit } = q.data;
const where: Prisma.AnomalyEventWhereInput = {};
if (status) where.status = status;
if (severity) where.severity = severity;
if (type) where.type = type;
if (boardId) where.boardId = boardId;
const [events, total] = await Promise.all([
prisma.anomalyEvent.findMany({
where,
orderBy: { createdAt: "desc" },
skip: Math.min((page - 1) * limit, 50000),
take: limit,
}),
prisma.anomalyEvent.count({ where }),
]);
reply.send({
alerts: events.map((e) => ({
...e,
label: ANOMALY_TYPE_LABELS[e.type] || e.type,
})),
total,
page,
pages: Math.ceil(total / limit),
});
}
);
// 2. Get single anomaly event
app.get<{ Params: { id: string } }>(
"/admin/security/alerts/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const event = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
if (!event) {
reply.status(404).send({ error: "Alert not found" });
return;
}
reply.send({
...event,
label: ANOMALY_TYPE_LABELS[event.type] || event.type,
});
}
);
// 3. Confirm anomaly
app.put<{ Params: { id: string } }>(
"/admin/security/alerts/:id/confirm",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const event = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
if (!event) {
reply.status(404).send({ error: "Alert not found" });
return;
}
const updated = await prisma.anomalyEvent.update({
where: { id: event.id },
data: { status: "confirmed" },
});
reply.send(updated);
}
);
// 4. Dismiss anomaly
app.put<{ Params: { id: string } }>(
"/admin/security/alerts/:id/dismiss",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const event = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
if (!event) {
reply.status(404).send({ error: "Alert not found" });
return;
}
const updated = await prisma.anomalyEvent.update({
where: { id: event.id },
data: { status: "dismissed" },
});
reply.send(updated);
}
);
// 5. One-click cleanup
app.post<{ Body: z.infer<typeof cleanupBody> }>(
"/admin/security/cleanup",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = cleanupBody.parse(req.body);
const event = await prisma.anomalyEvent.findUnique({ where: { id: body.anomalyEventId } });
if (!event) {
reply.status(404).send({ error: "Anomaly event not found" });
return;
}
const meta = (event.metadata ?? {}) as Record<string, unknown>;
const results: Record<string, unknown> = {};
// figure out which posts are affected
const flaggedPosts: string[] = (meta.flaggedPosts as string[]) || [];
const postA = meta.postA as string | undefined;
const postB = meta.postB as string | undefined;
let targetPostId = event.targetType === "post" ? event.targetId : null;
const affectedPostIds = [
...flaggedPosts,
...(postA ? [postA] : []),
...(postB ? [postB] : []),
...(targetPostId ? [targetPostId] : []),
].filter(Boolean);
for (const action of body.actions) {
if (action === "remove_phantom_votes") {
if (affectedPostIds.length === 0) {
results.remove_phantom_votes = { deleted: 0 };
continue;
}
const deleted = await prisma.vote.deleteMany({
where: { postId: { in: affectedPostIds }, phantom: true },
});
results.remove_phantom_votes = { deleted: deleted.count };
}
if (action === "remove_flagged_votes") {
const identities = (meta.identities as string[]) || [];
if (identities.length === 0) {
results.remove_flagged_votes = { deleted: 0 };
continue;
}
const deleted = await prisma.vote.deleteMany({
where: { voterId: { in: identities } },
});
results.remove_flagged_votes = { deleted: deleted.count };
}
if (action === "recalculate_counts") {
const postIds = affectedPostIds.length > 0
? affectedPostIds
: (await prisma.post.findMany({ where: { boardId: event.boardId ?? undefined }, select: { id: true }, take: 200 })).map((p) => p.id);
let recalculated = 0;
for (const pid of postIds) {
const sum = await prisma.vote.aggregate({
where: { postId: pid, phantom: false },
_sum: { weight: true },
});
await prisma.post.update({
where: { id: pid },
data: { voteCount: sum._sum.weight ?? 0 },
});
recalculated++;
}
results.recalculate_counts = { recalculated };
}
}
// create audit log entry
await prisma.anomalyEvent.create({
data: {
type: "audit_cleanup",
severity: "info",
targetType: event.targetType,
targetId: event.targetId,
boardId: event.boardId,
status: "confirmed",
metadata: {
sourceEventId: event.id,
adminId: req.adminId,
actions: body.actions,
results,
} as Prisma.InputJsonValue,
},
});
req.log.info({ adminId: req.adminId, anomalyEventId: event.id, actions: body.actions }, "security cleanup");
reply.send({ ok: true, results });
}
);
// 6. Audit log
app.get(
"/admin/security/audit-log",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (_req, reply) => {
const entries = await prisma.anomalyEvent.findMany({
where: { type: "audit_cleanup" },
orderBy: { createdAt: "desc" },
take: 200,
});
reply.send({
entries: entries.map((e) => {
const meta = (e.metadata ?? {}) as Record<string, unknown>;
return {
id: e.id,
adminId: meta.adminId || null,
action: (meta.actions as string[])?.join(", ") || "unknown",
metadata: meta,
undone: meta.undone === true,
createdAt: e.createdAt,
};
}),
});
}
);
// 7. Undo cleanup
app.post<{ Params: { id: string } }>(
"/admin/security/audit-log/:id/undo",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (req, reply) => {
const entry = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
if (!entry || entry.type !== "audit_cleanup") {
reply.status(404).send({ error: "Audit entry not found" });
return;
}
const meta = (entry.metadata ?? {}) as Record<string, unknown>;
if (meta.undone) {
reply.status(409).send({ error: "Already undone" });
return;
}
await prisma.anomalyEvent.update({
where: { id: entry.id },
data: {
metadata: { ...meta, undone: true, undoneBy: req.adminId, undoneAt: new Date().toISOString() } as Prisma.InputJsonValue,
},
});
req.log.info({ adminId: req.adminId, auditEntryId: entry.id }, "cleanup marked as undone");
reply.send({ ok: true });
}
);
// 8. Freeze/unfreeze post
app.put<{ Params: { id: string } }>(
"/admin/posts/:id/freeze",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const post = await prisma.post.findUnique({ where: { id: req.params.id } });
if (!post) {
reply.status(404).send({ error: "Post not found" });
return;
}
const updated = await prisma.post.update({
where: { id: post.id },
data: { frozenAt: post.frozenAt ? null : new Date() },
});
req.log.info({ adminId: req.adminId, postId: post.id, frozen: !!updated.frozenAt }, "post freeze toggled");
reply.send({ id: updated.id, frozenAt: updated.frozenAt });
}
);
// 9. Board security settings
app.put<{ Params: { id: string }; Body: z.infer<typeof boardSecurityBody> }>(
"/admin/boards/:id/security",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.id } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
const body = boardSecurityBody.parse(req.body);
const data: Record<string, unknown> = {};
if (body.sensitivityLevel !== undefined) data.sensitivityLevel = body.sensitivityLevel;
if (body.velocityThreshold !== undefined) data.velocityThreshold = body.velocityThreshold;
if (body.quarantined !== undefined) data.quarantined = body.quarantined;
if (body.requireVoteVerification !== undefined) data.requireVoteVerification = body.requireVoteVerification;
const updated = await prisma.board.update({
where: { id: board.id },
data,
});
req.log.info({ adminId: req.adminId, boardId: board.id }, "board security settings updated");
reply.send({
id: updated.id,
sensitivityLevel: updated.sensitivityLevel,
velocityThreshold: updated.velocityThreshold,
quarantined: updated.quarantined,
requireVoteVerification: updated.requireVoteVerification,
});
}
);
// 10. Admin notification webhook config - CRUD
app.get(
"/admin/security/webhooks",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (_req, reply) => {
const webhooks = await prisma.adminWebhookConfig.findMany();
reply.send({ webhooks });
}
);
app.post<{ Body: z.infer<typeof webhookBody> }>(
"/admin/security/webhooks",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const body = webhookBody.parse(req.body);
const wh = await prisma.adminWebhookConfig.create({
data: { url: body.url, events: body.events, active: body.active },
});
reply.status(201).send(wh);
}
);
app.put<{ Params: { id: string }; Body: z.infer<typeof webhookUpdateBody> }>(
"/admin/security/webhooks/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const wh = await prisma.adminWebhookConfig.findUnique({ where: { id: req.params.id } });
if (!wh) {
reply.status(404).send({ error: "Webhook not found" });
return;
}
const body = webhookUpdateBody.parse(req.body);
const updated = await prisma.adminWebhookConfig.update({
where: { id: wh.id },
data: {
...(body.url !== undefined && { url: body.url }),
...(body.events !== undefined && { events: body.events }),
...(body.active !== undefined && { active: body.active }),
},
});
reply.send(updated);
}
);
app.delete<{ Params: { id: string } }>(
"/admin/security/webhooks/:id",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN")], config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const wh = await prisma.adminWebhookConfig.findUnique({ where: { id: req.params.id } });
if (!wh) {
reply.status(404).send({ error: "Webhook not found" });
return;
}
await prisma.adminWebhookConfig.delete({ where: { id: wh.id } });
reply.status(204).send();
}
);
// 11. Mark as brigaded
app.post<{ Params: { id: string } }>(
"/admin/security/alerts/:id/mark-brigaded",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const event = await prisma.anomalyEvent.findUnique({ where: { id: req.params.id } });
if (!event) {
reply.status(404).send({ error: "Alert not found" });
return;
}
const meta = (event.metadata ?? {}) as Record<string, unknown>;
// extract pattern features from the anomaly
const features: Record<string, unknown> = {
anomalyType: event.type,
severity: event.severity,
};
if (meta.identities) features.identityCount = (meta.identities as string[]).length;
if (meta.referrer) features.referrer = meta.referrer;
if (meta.ratio) features.velocityRatio = meta.ratio;
if (meta.coefficient) features.overlapCoefficient = meta.coefficient;
if (meta.phrase) features.phrase = meta.phrase;
if (meta.url) features.url = meta.url;
const pattern = await prisma.brigadePattern.create({
data: {
boardId: event.boardId,
features: features as Prisma.InputJsonValue,
},
});
// also confirm the anomaly
await prisma.anomalyEvent.update({
where: { id: event.id },
data: { status: "confirmed" },
});
req.log.info({ adminId: req.adminId, patternId: pattern.id, anomalyEventId: event.id }, "brigade pattern recorded");
reply.status(201).send(pattern);
}
);
// 12. Network graph data
app.get<{ Params: { boardId: string } }>(
"/admin/security/graph/:boardId",
{ preHandler: [app.requireAdmin, app.requireRole("SUPER_ADMIN", "ADMIN")], config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { id: req.params.boardId } });
if (!board) {
reply.status(404).send({ error: "Board not found" });
return;
}
// check for cached graph data from recent anomaly events
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const cached = await prisma.anomalyEvent.findFirst({
where: {
type: "voter_network_cluster",
boardId: board.id,
createdAt: { gt: oneDayAgo },
},
orderBy: { createdAt: "desc" },
});
if (cached) {
reply.send({ source: "cached", data: cached.metadata, createdAt: cached.createdAt });
return;
}
// build fresh graph
await buildVoterGraph(board.id);
const fresh = await prisma.anomalyEvent.findFirst({
where: {
type: "voter_network_cluster",
boardId: board.id,
createdAt: { gt: new Date(Date.now() - 60 * 1000) },
},
orderBy: { createdAt: "desc" },
});
if (fresh) {
reply.send({ source: "fresh", data: fresh.metadata, createdAt: fresh.createdAt });
} else {
reply.send({ source: "fresh", data: { clusterSize: 0, identities: [], totalClusters: 0 }, createdAt: new Date() });
}
}
);
}

View File

@@ -26,6 +26,8 @@ const createCommentSchema = z.object({
altcha: z.string().optional(),
replyToId: z.string().max(30).optional(),
attachmentIds: z.array(z.string()).max(10).optional(),
website: z.string().optional(),
_ts: z.number().optional(),
});
const updateCommentSchema = z.object({
@@ -127,16 +129,32 @@ export default async function commentRoutes(app: FastifyInstance) {
reply.status(403).send({ error: "Thread is locked" });
return;
}
if (post.frozenAt) {
reply.status(403).send({ error: "Post is frozen" });
return;
}
// honeypot check - bots fill this hidden field
if ((req.body as any)?.website) {
reply.status(201).send({ id: "ok", body: (req.body as any).body || "" });
return;
}
const body = createCommentSchema.parse(req.body);
// time-to-submit check - flag users who submit too fast
if (body._ts && Date.now() - body._ts < 5000) {
await prisma.user.update({ where: { id: req.user!.id }, data: { flagCount: { increment: 1 } } }).catch(() => {});
}
// admins skip ALTCHA, regular users must provide it
if (!req.adminId) {
if (!body.altcha) {
reply.status(400).send({ error: "Challenge response required" });
return;
}
const valid = await verifyChallenge(body.altcha);
const sid = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
const valid = await verifyChallenge(body.altcha, sid);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;

View File

@@ -79,7 +79,8 @@ export default async function identityRoutes(app: FastifyInstance) {
reply.status(400).send({ error: "Verification required for display name changes" });
return;
}
const valid = await verifyChallenge(body.altcha);
const sid = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
const valid = await verifyChallenge(body.altcha, sid);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
@@ -224,7 +225,8 @@ export default async function identityRoutes(app: FastifyInstance) {
reply.status(400).send({ error: "Verification required to delete account" });
return;
}
const challengeValid = await verifyChallenge(altcha);
const sid = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
const challengeValid = await verifyChallenge(altcha, sid);
if (!challengeValid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;

View File

@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify";
import { PostType, Prisma } from "@prisma/client";
import { z } from "zod";
import { createHash } from "node:crypto";
import { unlink } from "node:fs/promises";
import { resolve } from "node:path";
import prisma from "../lib/prisma.js";
@@ -62,6 +63,9 @@ const createPostSchema = z.object({
templateId: z.string().optional(),
attachmentIds: z.array(z.string()).max(10).optional(),
altcha: z.string(),
website: z.string().optional(),
_ts: z.number().optional(),
_pasted: z.boolean().optional(),
}).superRefine((data, ctx) => {
if (data.templateId) {
// template posts use flexible description keys
@@ -226,27 +230,31 @@ export default async function postRoutes(app: FastifyInstance) {
: null;
reply.send({
posts: posts.map((p) => ({
id: p.id,
type: p.type,
title: p.title,
description: p.description,
status: p.status,
statusReason: p.statusReason,
category: p.category,
voteCount: p.voteCount,
viewCount: p.viewCount,
isPinned: p.isPinned,
isStale: staleCutoff ? p.lastActivityAt < staleCutoff : false,
commentCount: p._count.comments,
author: cleanAuthor(p.author),
tags: p.tags.map((pt) => pt.tag),
onBehalfOf: p.onBehalfOf,
voted: userVotes.has(p.id),
voteWeight: userVotes.get(p.id) ?? 0,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
})),
posts: posts.map((p) => {
const isVoteHidden = p.votesVisibleAfter && new Date(p.votesVisibleAfter) > new Date();
return {
id: p.id,
type: p.type,
title: p.title,
description: p.description,
status: p.status,
statusReason: p.statusReason,
category: p.category,
voteCount: isVoteHidden ? null : p.voteCount,
votingInProgress: isVoteHidden ? true : undefined,
viewCount: p.viewCount,
isPinned: p.isPinned,
isStale: staleCutoff ? p.lastActivityAt < staleCutoff : false,
commentCount: p._count.comments,
author: cleanAuthor(p.author),
tags: p.tags.map((pt) => pt.tag),
onBehalfOf: p.onBehalfOf,
voted: userVotes.has(p.id),
voteWeight: userVotes.get(p.id) ?? 0,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
};
}),
total,
page: q.page,
pages: Math.ceil(total / q.limit),
@@ -257,7 +265,7 @@ export default async function postRoutes(app: FastifyInstance) {
app.get<{ Params: { boardSlug: string; id: string } }>(
"/boards/:boardSlug/posts/:id",
{ preHandler: [app.optionalUser], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
{ preHandler: [app.optionalUser, app.optionalAdmin], config: { rateLimit: { max: 60, timeWindow: "1 minute" } } },
async (req, reply) => {
const board = await prisma.board.findUnique({ where: { slug: req.params.boardSlug } });
if (!board) {
@@ -327,6 +335,9 @@ export default async function postRoutes(app: FastifyInstance) {
}
}
const isVoteHidden = post.votesVisibleAfter && new Date(post.votesVisibleAfter) > new Date();
const showRealCount = req.user?.id === post.authorId || req.adminId;
reply.send({
id: post.id,
type: post.type,
@@ -335,7 +346,8 @@ export default async function postRoutes(app: FastifyInstance) {
status: post.status,
statusReason: post.statusReason,
category: post.category,
voteCount: post.voteCount,
voteCount: (isVoteHidden && !showRealCount) ? null : post.voteCount,
votingInProgress: (isVoteHidden && !showRealCount) ? true : undefined,
viewCount: post.viewCount,
isPinned: post.isPinned,
isEditLocked: post.isEditLocked,
@@ -472,14 +484,34 @@ export default async function postRoutes(app: FastifyInstance) {
return;
}
// honeypot check - bots fill this hidden field
if ((req.body as any)?.website) {
reply.status(201).send({ id: "ok", title: (req.body as any).title || "" });
return;
}
const body = createPostSchema.parse(req.body);
const valid = await verifyChallenge(body.altcha);
const sessionId = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
const valid = await verifyChallenge(body.altcha, sessionId);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
}
// time-to-submit check - flag users who submit too fast
if (body._ts && Date.now() - body._ts < 5000) {
await prisma.user.update({ where: { id: req.user!.id }, data: { flagCount: { increment: 1 } } }).catch(() => {});
}
// clipboard paste detection - flag new identities who paste content
if (body._pasted && req.user) {
const pUser = await prisma.user.findUnique({ where: { id: req.user.id }, select: { createdAt: true } });
if (pUser && Date.now() - pUser.createdAt.getTime() < 10 * 60 * 1000) {
await prisma.user.update({ where: { id: req.user.id }, data: { flagCount: { increment: 1 } } }).catch(() => {});
}
}
const cleanTitle = body.title.replace(INVISIBLE_RE, '');
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const normalizedTitle = cleanTitle.trim().replace(/\s+/g, ' ').replace(/[.!?,;:]+$/, '').toLowerCase();
@@ -501,6 +533,22 @@ export default async function postRoutes(app: FastifyInstance) {
return;
}
// cross-user content hash deduplication
const crossUserPosts = await prisma.post.findMany({
where: { boardId: board.id, createdAt: { gt: new Date(Date.now() - 60 * 60 * 1000) } },
select: { title: true, description: true, authorId: true },
take: 50,
});
const myHash = createHash("sha256").update(body.title.trim().toLowerCase() + JSON.stringify(body.description)).digest("hex");
for (const rp of crossUserPosts) {
if (rp.authorId === req.user!.id) continue;
const rpHash = createHash("sha256").update((rp.title as string).trim().toLowerCase() + JSON.stringify(rp.description)).digest("hex");
if (rpHash === myHash) {
reply.status(409).send({ error: "This has already been submitted" });
return;
}
}
if (body.templateId) {
const tmpl = await prisma.boardTemplate.findUnique({ where: { id: body.templateId } });
if (!tmpl || tmpl.boardId !== board.id) {
@@ -518,6 +566,7 @@ export default async function postRoutes(app: FastifyInstance) {
templateId: body.templateId,
boardId: board.id,
authorId: req.user!.id,
votesVisibleAfter: new Date(Date.now() + 60 * 60 * 1000),
},
});
@@ -603,7 +652,8 @@ export default async function postRoutes(app: FastifyInstance) {
reply.status(400).send({ error: "Challenge response required" });
return;
}
const valid = await verifyChallenge(body.altcha);
const sid = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
const valid = await verifyChallenge(body.altcha, sid);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
@@ -665,6 +715,48 @@ export default async function postRoutes(app: FastifyInstance) {
},
});
// check edit distance after save - flag substantial edits on voted posts
if ((titleChanged || descChanged) && post.voteCount > 5) {
const oldContent = JSON.stringify({ title: post.title, description: post.description });
const newContent = JSON.stringify({ title: updated.title, description: updated.description });
const maxLen = Math.max(oldContent.length, newContent.length);
if (maxLen > 0) {
let diffs = 0;
for (let i = 0; i < maxLen; i++) {
if (oldContent[i] !== newContent[i]) diffs++;
}
const changeRatio = diffs / maxLen;
if (changeRatio > 0.7) {
const { createAnomalyEvent } = await import("../services/detection-engine.js");
createAnomalyEvent({
type: "post_substantially_edited",
severity: "medium",
targetType: "post",
targetId: post.id,
boardId: post.boardId,
metadata: { changeRatio, voteCount: post.voteCount, editedBy: req.user!.id },
}).catch(() => {});
const voters = await prisma.vote.findMany({
where: { postId: post.id },
select: { voterId: true },
});
if (voters.length > 0) {
await prisma.notification.createMany({
data: voters.slice(0, 100).map((v) => ({
type: "post_edited",
title: "Post you voted on was edited",
body: `"${post.title}" was substantially modified after receiving votes`,
postId: post.id,
userId: v.voterId,
})),
}).catch(() => {});
}
}
}
}
reply.send({
id: updated.id, type: updated.type, title: updated.title,
description: updated.description, status: updated.status,

View File

@@ -1,17 +1,27 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import prisma from "../lib/prisma.js";
import { generateChallenge } from "../services/altcha.js";
import { generateChallenge, getAdaptiveDifficulty } from "../services/altcha.js";
import { config } from "../config.js";
const challengeQuery = z.object({
difficulty: z.enum(["normal", "light"]).default("normal"),
boardId: z.string().max(30).optional(),
postId: z.string().max(30).optional(),
});
export default async function privacyRoutes(app: FastifyInstance) {
app.get("/altcha/challenge", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => {
const { difficulty } = challengeQuery.parse(req.query);
const challenge = await generateChallenge(difficulty);
app.get("/altcha/challenge", { preHandler: [app.optionalUser], config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => {
const { difficulty, boardId, postId } = challengeQuery.parse(req.query);
const userId = req.user?.id;
const sessionId = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
const adaptiveMax = await getAdaptiveDifficulty({ postId, boardId, userId });
// use the higher of adaptive or difficulty-based default
const difficultyMax = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER;
const maxNumber = Math.max(adaptiveMax, difficultyMax);
const challenge = await generateChallenge({ maxNumber, sessionId });
reply.send(challenge);
});

View File

@@ -1,6 +1,7 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import prisma from "../lib/prisma.js";
import { checkReactionVelocity } from "../services/detection-engine.js";
const reactionBody = z.object({
emoji: z.string().min(1).max(8).regex(/^[\p{Extended_Pictographic}\u{FE0F}\u{200D}]+$/u, "Invalid emoji"),
@@ -61,6 +62,7 @@ export default async function reactionRoutes(app: FastifyInstance) {
userId: req.user!.id,
},
});
checkReactionVelocity(comment.id).catch(() => {});
reply.send({ toggled: true });
}
}

View File

@@ -82,7 +82,8 @@ export default async function recoveryRoutes(app: FastifyInstance) {
async (req, reply) => {
const body = recoverBody.parse(req.body);
const valid = await verifyChallenge(body.altcha);
const sessionId = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
const valid = await verifyChallenge(body.altcha, sessionId);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;

View File

@@ -1,8 +1,10 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { createHash } from "node:crypto";
import prisma from "../lib/prisma.js";
import { verifyChallenge } from "../services/altcha.js";
import { getCurrentPeriod, getRemainingBudget, getNextResetDate } from "../lib/budget.js";
import { recordAction, recordVoteTimingUpdate } from "../services/identity-signals.js";
const voteBody = z.object({
altcha: z.string(),
@@ -44,7 +46,8 @@ export default async function voteRoutes(app: FastifyInstance) {
}
const body = voteBody.parse(req.body);
const valid = await verifyChallenge(body.altcha);
const sessionId = req.cookies?.echoboard_token || req.cookies?.echoboard_passkey || "";
const valid = await verifyChallenge(body.altcha, sessionId);
if (!valid) {
reply.status(400).send({ error: "Invalid challenge response" });
return;
@@ -52,6 +55,14 @@ export default async function voteRoutes(app: FastifyInstance) {
const period = getCurrentPeriod(board.voteBudgetReset);
// phantom vote detection - hash IP, check rate
const ipHash = createHash("sha256").update(req.ip || "unknown").digest("hex").slice(0, 16);
const ipVoteCount = await prisma.vote.count({
where: { postId: post.id, voterIp: ipHash, createdAt: { gt: new Date(Date.now() - 60 * 60 * 1000) }, phantom: false },
});
const isPhantom = ipVoteCount >= 3 || !!post.frozenAt;
const refHeader = ((req.headers.referer || req.headers.referrer || "") as string).slice(0, 200) || null;
try {
await prisma.$transaction(async (tx) => {
const existing = await tx.vote.findUnique({
@@ -75,14 +86,20 @@ export default async function voteRoutes(app: FastifyInstance) {
postId: post.id,
voterId: req.user!.id,
budgetPeriod: period,
phantom: isPhantom,
voterIp: ipHash,
referrer: refHeader,
},
});
}
await tx.post.update({
where: { id: post.id },
data: { voteCount: { increment: 1 }, lastActivityAt: new Date() },
});
// phantom votes don't affect the visible count
if (!isPhantom) {
await tx.post.update({
where: { id: post.id },
data: { voteCount: { increment: 1 }, lastActivityAt: new Date() },
});
}
}, { isolationLevel: "Serializable" });
} catch (err: any) {
if (err.message === "ALREADY_VOTED") {
@@ -109,7 +126,11 @@ export default async function voteRoutes(app: FastifyInstance) {
},
});
const newCount = post.voteCount + 1;
// record identity signals
recordAction(req.user!.id, "vote", { boardId: post.boardId }).catch(() => {});
recordVoteTimingUpdate(req.user!.id).catch(() => {});
const newCount = isPhantom ? post.voteCount : post.voteCount + 1;
const milestones = [10, 50, 100, 250, 500];
if (milestones.includes(newCount)) {
await prisma.activityEvent.create({

View File

@@ -53,6 +53,7 @@ import recoveryRoutes from "./routes/recovery.js";
import settingsRoutes from "./routes/admin/settings.js";
import adminTeamRoutes from "./routes/admin/team.js";
import adminPluginRoutes from "./routes/admin/plugins.js";
import adminSecurityRoutes from "./routes/admin/security.js";
import pluginApiRoutes from "./routes/plugins-api.js";
const DEFAULT_OG_DESC = "Self-hosted feedback board. Anonymous by default, no email required. Vote on what matters, comment with markdown, track status changes.";
@@ -234,6 +235,7 @@ export async function createServer() {
await api.register(settingsRoutes);
await api.register(adminTeamRoutes);
await api.register(adminPluginRoutes);
await api.register(adminSecurityRoutes);
await api.register(pluginApiRoutes);
}, { prefix: "/api/v1" });

View File

@@ -1,16 +1,29 @@
import { createChallenge, verifySolution } from "altcha-lib";
import { createHash } from "node:crypto";
import { config } from "../config.js";
import prisma from "../lib/prisma.js";
import { getIdentityRiskLevel } from "./identity-signals.js";
// replay protection: track consumed challenge hashes (fingerprint -> timestamp)
const usedChallenges = new Map<string, number>();
interface ChallengeEntry {
issuedAt: number;
sessionHash: string;
}
// replay protection: track consumed challenge hashes
const usedChallenges = new Map<string, ChallengeEntry>();
// track issued challenges for solve-time validation
const issuedChallenges = new Map<string, ChallengeEntry>();
const EXPIRY_MS = 300000;
const MIN_SOLVE_MS = 50;
// clean up expired entries every 5 minutes
setInterval(() => {
const cutoff = Date.now() - EXPIRY_MS;
for (const [fp, ts] of usedChallenges) {
if (ts < cutoff) usedChallenges.delete(fp);
for (const [fp, entry] of usedChallenges) {
if (entry.issuedAt < cutoff) usedChallenges.delete(fp);
}
for (const [salt, entry] of issuedChallenges) {
if (entry.issuedAt < cutoff) issuedChallenges.delete(salt);
}
}, EXPIRY_MS);
@@ -18,17 +31,83 @@ function challengeFingerprint(payload: string): string {
return createHash("sha256").update(payload).digest("hex").slice(0, 32);
}
export async function generateChallenge(difficulty: "normal" | "light" = "normal") {
const maxNumber = difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER;
function hashSession(cookieValue: string): string {
return createHash("sha256").update(cookieValue).digest("hex").slice(0, 32);
}
export async function getAdaptiveDifficulty(context: {
postId?: string;
boardId?: string;
userId?: string;
}): Promise<number> {
const base = config.ALTCHA_MAX_NUMBER;
let multiplier = 1;
if (context.boardId) {
const board = await prisma.board.findUnique({
where: { id: context.boardId },
select: { sensitivityLevel: true, requireVoteVerification: true },
});
if (board?.sensitivityLevel === "high") multiplier *= 3;
// when vote verification is enabled, new identities get harder challenges
if (board?.requireVoteVerification && context.userId) {
const user = await prisma.user.findUnique({
where: { id: context.userId },
select: { createdAt: true },
});
if (user && Date.now() - user.createdAt.getTime() < 60 * 60 * 1000) {
multiplier *= 2;
}
}
}
if (context.postId) {
const recentAnomaly = await prisma.anomalyEvent.findFirst({
where: {
targetId: context.postId,
type: "vote_velocity",
status: "pending",
createdAt: { gt: new Date(Date.now() - 60 * 60 * 1000) },
},
});
if (recentAnomaly) multiplier *= 5;
}
if (context.userId) {
const risk = await getIdentityRiskLevel(context.userId);
if (risk === "high") multiplier *= 3;
else if (risk === "medium") multiplier *= 1.5;
}
return Math.min(Math.floor(base * multiplier), base * 10);
}
export async function generateChallenge(opts: {
difficulty?: "normal" | "light";
maxNumber?: number;
sessionId?: string;
} = {}) {
const maxNumber = opts.maxNumber
?? (opts.difficulty === "light" ? config.ALTCHA_MAX_NUMBER_VOTE : config.ALTCHA_MAX_NUMBER);
const challenge = await createChallenge({
hmacKey: config.ALTCHA_HMAC_KEY,
maxNumber,
expires: new Date(Date.now() + config.ALTCHA_EXPIRE_SECONDS * 1000),
});
// track issuance for solve-time and session validation
const sHash = hashSession(opts.sessionId || "");
issuedChallenges.set(challenge.salt, {
issuedAt: Date.now(),
sessionHash: sHash,
});
return challenge;
}
export async function verifyChallenge(payload: string): Promise<boolean> {
export async function verifyChallenge(payload: string, sessionId?: string): Promise<boolean> {
try {
const fp = challengeFingerprint(payload);
@@ -38,20 +117,36 @@ export async function verifyChallenge(payload: string): Promise<boolean> {
const ok = await verifySolution(payload, config.ALTCHA_HMAC_KEY);
if (!ok) return false;
// extract salt from payload to check issuance data
const decoded = JSON.parse(atob(payload));
const issued = issuedChallenges.get(decoded.salt);
if (issued) {
// solve-time check - reject if solved too fast (bot/script)
const solveTime = Date.now() - issued.issuedAt;
if (solveTime < MIN_SOLVE_MS) return false;
// session binding check
const currentHash = hashSession(sessionId || "");
if (issued.sessionHash !== currentHash) return false;
issuedChallenges.delete(decoded.salt);
}
// evict expired entries if map is large
if (usedChallenges.size >= 50000) {
const cutoff = Date.now() - EXPIRY_MS;
for (const [key, ts] of usedChallenges) {
if (ts < cutoff) usedChallenges.delete(key);
for (const [key, entry] of usedChallenges) {
if (entry.issuedAt < cutoff) usedChallenges.delete(key);
}
}
// hard cap - drop oldest entries if still over limit
if (usedChallenges.size >= 50000) {
const sorted = [...usedChallenges.entries()].sort((a, b) => a[1] - b[1]);
const sorted = [...usedChallenges.entries()].sort((a, b) => a[1].issuedAt - b[1].issuedAt);
const toRemove = sorted.slice(0, sorted.length - 40000);
for (const [key] of toRemove) usedChallenges.delete(key);
}
usedChallenges.set(fp, Date.now());
usedChallenges.set(fp, { issuedAt: Date.now(), sessionHash: hashSession(sessionId || "") });
return true;
} catch {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
import prisma from "../lib/prisma.js";
export async function recordAction(userId: string, actionType: string, metadata?: { boardId?: string }) {
const updates: Record<string, any> = {};
const user = await prisma.user.findUnique({ where: { id: userId }, select: { firstActionType: true, boardInteractionCount: true } });
if (user && !user.firstActionType) {
updates.firstActionType = actionType;
updates.firstActionAt = new Date();
}
if (metadata?.boardId) {
const distinctBoards = await prisma.vote.findMany({
where: { voterId: userId },
select: { post: { select: { boardId: true } } },
distinct: ["postId"],
});
const boardIds = new Set(distinctBoards.map((v) => v.post.boardId));
if (metadata.boardId) boardIds.add(metadata.boardId);
updates.boardInteractionCount = boardIds.size;
}
const recentVotes = await prisma.vote.count({ where: { voterId: userId } });
const recentComments = await prisma.comment.count({ where: { authorId: userId } });
const recentPosts = await prisma.post.count({ where: { authorId: userId } });
const recentReactions = await prisma.reaction.count({ where: { userId } });
const activeTypes = [recentVotes > 0, recentComments > 0, recentPosts > 0, recentReactions > 0].filter(Boolean).length;
updates.actionDiversityScore = activeTypes / 4;
if (Object.keys(updates).length > 0) {
await prisma.user.update({ where: { id: userId }, data: updates }).catch(() => {});
}
}
export async function recordVoteTimingUpdate(userId: string) {
const votes = await prisma.vote.findMany({
where: { voterId: userId },
orderBy: { createdAt: "desc" },
take: 20,
select: { createdAt: true },
});
if (votes.length < 3) return;
const intervals: number[] = [];
for (let i = 0; i < votes.length - 1; i++) {
intervals.push(votes[i].createdAt.getTime() - votes[i + 1].createdAt.getTime());
}
const mean = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const variance = intervals.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / intervals.length;
const stdDev = Math.sqrt(variance);
await prisma.user.update({ where: { id: userId }, data: { voteTimingStdDev: stdDev } }).catch(() => {});
}
export function checkTimeToSubmit(pageLoadTimestamp: number): boolean {
const elapsed = Date.now() - pageLoadTimestamp;
return elapsed < 5000;
}
export function classifyReferrer(referrer: string | undefined, origin: string): "internal" | "external" | "none" {
if (!referrer) return "none";
try {
const refUrl = new URL(referrer);
const originUrl = new URL(origin);
return refUrl.hostname === originUrl.hostname ? "internal" : "external";
} catch {
return "none";
}
}
export async function getIdentityRiskLevel(userId: string): Promise<"low" | "medium" | "high"> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
actionDiversityScore: true,
voteTimingStdDev: true,
boardInteractionCount: true,
flagCount: true,
createdAt: true,
},
});
if (!user) return "high";
let risk = 0;
const ageMs = Date.now() - user.createdAt.getTime();
if (ageMs < 10 * 60 * 1000) risk += 2;
if (user.actionDiversityScore < 0.25) risk += 1;
if (user.voteTimingStdDev !== null && user.voteTimingStdDev < 2000) risk += 2;
if (user.boardInteractionCount <= 1) risk += 1;
if (user.flagCount > 0) risk += user.flagCount;
if (risk >= 4) return "high";
if (risk >= 2) return "medium";
return "low";
}