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";
}

View File

@@ -39,6 +39,7 @@ import AdminExport from './pages/admin/AdminExport'
import AdminTemplates from './pages/admin/AdminTemplates'
import AdminSettings from './pages/admin/AdminSettings'
import AdminTeam from './pages/admin/AdminTeam'
import AdminSecurity from './pages/admin/AdminSecurity'
import AdminPlugins from './pages/admin/AdminPlugins'
import AdminJoin from './pages/admin/AdminJoin'
import ProfilePage from './pages/ProfilePage'
@@ -528,6 +529,7 @@ function Layout() {
<Route path="/admin/templates" element={<RequireAdmin><AdminTemplates /></RequireAdmin>} />
<Route path="/admin/data-retention" element={<RequireAdmin><AdminDataRetention /></RequireAdmin>} />
<Route path="/admin/team" element={<RequireAdmin><AdminTeam /></RequireAdmin>} />
<Route path="/admin/security" element={<RequireAdmin><AdminSecurity /></RequireAdmin>} />
<Route path="/admin/join/:token" element={<AdminJoin />} />
<Route path="/admin/plugins" element={<RequireAdmin><AdminPlugins /></RequireAdmin>} />
<Route path="/admin/settings" element={<RequireAdmin><AdminSettings /></RequireAdmin>} />

View File

@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { api } from '../lib/api'
import { useAdmin } from '../hooks/useAdmin'
import { IconHome, IconFileText, IconLayoutGrid, IconTag, IconTags, IconTrash, IconPlug, IconArrowLeft, IconNews, IconWebhook, IconCode, IconPalette, IconDownload, IconTemplate, IconSettings, IconUsers } from '@tabler/icons-react'
import { IconHome, IconFileText, IconLayoutGrid, IconTag, IconTags, IconTrash, IconPlug, IconArrowLeft, IconNews, IconWebhook, IconCode, IconPalette, IconDownload, IconTemplate, IconSettings, IconUsers, IconShieldLock } from '@tabler/icons-react'
import type { Icon } from '@tabler/icons-react'
interface PluginInfo {
@@ -20,6 +20,7 @@ const links: { to: string; label: string; icon: Icon; minLevel: number }[] = [
{ to: '/admin/categories', label: 'Categories', icon: IconTag, minLevel: 1 },
{ to: '/admin/tags', label: 'Tags', icon: IconTags, minLevel: 1 },
{ to: '/admin/team', label: 'Team', icon: IconUsers, minLevel: 2 },
{ to: '/admin/security', label: 'Security', icon: IconShieldLock, minLevel: 2 },
{ to: '/admin/changelog', label: 'Changelog', icon: IconNews, minLevel: 2 },
{ to: '/admin/webhooks', label: 'Webhooks', icon: IconWebhook, minLevel: 2 },
{ to: '/admin/plugins', label: 'Plugins', icon: IconPlug, minLevel: 3 },

View File

@@ -25,6 +25,7 @@ interface Props {
ariaRequired?: boolean
ariaLabel?: string
mentions?: boolean
onPaste?: () => void
}
type Action =
@@ -154,7 +155,7 @@ function TablePicker({ onSelect, onClose }: { onSelect: (cols: number, rows: num
)
}
export default function MarkdownEditor({ value, onChange, placeholder, rows = 3, autoFocus, preview: enablePreview, ariaRequired, ariaLabel, mentions: enableMentions }: Props) {
export default function MarkdownEditor({ value, onChange, placeholder, rows = 3, autoFocus, preview: enablePreview, ariaRequired, ariaLabel, mentions: enableMentions, onPaste }: Props) {
const ref = useRef<HTMLTextAreaElement>(null)
const [previewing, setPreviewing] = useState(false)
const [tablePicker, setTablePicker] = useState(false)
@@ -443,6 +444,7 @@ export default function MarkdownEditor({ value, onChange, placeholder, rows = 3,
else if ((e.key === 'Enter' || e.key === 'Tab') && mentionUsers[mentionActive]) { e.preventDefault(); insertMention(mentionUsers[mentionActive].username) }
else if (e.key === 'Escape') { setMentionUsers([]); setMentionQuery('') }
} : undefined}
onPaste={onPaste}
style={{ resize: 'vertical' }}
autoFocus={autoFocus}
aria-label={ariaLabel || 'Markdown content'}

View File

@@ -13,7 +13,8 @@ interface Post {
status: string
statusReason?: string | null
category?: string | null
voteCount: number
voteCount: number | null
votingInProgress?: boolean
commentCount: number
viewCount?: number
isPinned?: boolean
@@ -126,17 +127,21 @@ export default function PostCard({
transition: 'color var(--duration-fast) ease-out',
}}
/>
<span
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
style={{
color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)',
fontSize: 'var(--text-sm)',
}}
aria-live="polite"
aria-atomic="true"
>
{post.voteCount}
</span>
{post.votingInProgress ? (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>...</span>
) : (
<span
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
style={{
color: post.voted ? 'var(--accent)' : 'var(--text-tertiary)',
fontSize: 'var(--text-sm)',
}}
aria-live="polite"
aria-atomic="true"
>
{post.voteCount}
</span>
)}
{budgetDepleted && !post.voted && (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', textAlign: 'center', lineHeight: 1.2, marginTop: 2 }}>
0 left
@@ -159,7 +164,11 @@ export default function PostCard({
aria-label="Vote"
>
<IconChevronUp size={14} stroke={2.5} className={voteAnimating ? 'vote-bounce' : ''} />
<span className="font-semibold" style={{ fontSize: 'var(--text-xs)' }} aria-live="polite" aria-atomic="true">{post.voteCount}</span>
{post.votingInProgress ? (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>...</span>
) : (
<span className="font-semibold" style={{ fontSize: 'var(--text-xs)' }} aria-live="polite" aria-atomic="true">{post.voteCount}</span>
)}
</button>
<StatusBadge status={post.status} customStatuses={customStatuses} />
<div className="flex items-center gap-1 ml-auto" style={{ color: 'var(--text-tertiary)' }}>

View File

@@ -54,6 +54,9 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([])
const [similar, setSimilar] = useState<SimilarPost[]>([])
const [honeypot, setHoneypot] = useState('')
const [pageLoadTime] = useState(Date.now())
const [wasPasted, setWasPasted] = useState(false)
// templates
const [templates, setTemplates] = useState<Template[]>([])
@@ -157,7 +160,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
let altcha: string
try {
altcha = await solveAltcha()
altcha = await solveAltcha('normal', { boardId })
} catch {
setError('Verification failed. Please try again.')
setSubmitting(false)
@@ -196,6 +199,9 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
templateId: selectedTemplate ? selectedTemplate.id : undefined,
attachmentIds: attachmentIds.length ? attachmentIds : undefined,
altcha,
website: honeypot || undefined,
_ts: pageLoadTime,
_pasted: wasPasted || undefined,
})
reset()
onSubmit?.()
@@ -235,6 +241,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
placeholder={f.placeholder || ''}
value={templateValues[f.key] || ''}
onChange={(e) => setTemplateValues((prev) => ({ ...prev, [f.key]: e.target.value }))}
onPaste={() => setWasPasted(true)}
aria-required={f.required || undefined}
aria-invalid={!!fieldErrors[f.key]}
aria-describedby={fieldErrors[f.key] ? `err-${f.key}` : undefined}
@@ -248,6 +255,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
rows={3}
ariaRequired={f.required}
ariaLabel={f.label}
onPaste={() => setWasPasted(true)}
/>
)}
{f.type === 'select' && f.options && (
@@ -271,6 +279,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
className="card card-static p-5"
style={{ animation: 'slideUp var(--duration-normal) var(--ease-out)' }}
>
<input type="text" name="website" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} style={{ position: 'absolute', left: -9999, top: -9999 }} tabIndex={-1} autoComplete="off" aria-hidden="true" />
<div className="flex items-center justify-between mb-4">
<h2
className="font-bold"
@@ -350,6 +359,7 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro
placeholder="Brief summary"
value={title}
onChange={(e) => setTitle(e.target.value)}
onPaste={() => setWasPasted(true)}
maxLength={200}
aria-required="true"
aria-invalid={!!fieldErrors.title}

View File

@@ -14,8 +14,14 @@ async function hashHex(algorithm: string, data: string): Promise<string> {
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
}
export async function solveAltcha(difficulty: 'normal' | 'light' = 'normal'): Promise<string> {
const ch = await api.get<Challenge>(`/altcha/challenge?difficulty=${difficulty}`)
export async function solveAltcha(
difficulty: 'normal' | 'light' = 'normal',
opts?: { boardId?: string; postId?: string },
): Promise<string> {
const params = new URLSearchParams({ difficulty })
if (opts?.boardId) params.set('boardId', opts.boardId)
if (opts?.postId) params.set('postId', opts.postId)
const ch = await api.get<Challenge>(`/altcha/challenge?${params.toString()}`)
for (let n = 0; n <= ch.maxnumber; n++) {
const hash = await hashHex(ch.algorithm, ch.salt + n)

View File

@@ -20,7 +20,8 @@ interface Post {
type: 'FEATURE_REQUEST' | 'BUG_REPORT'
status: string
category?: string | null
voteCount: number
voteCount: number | null
votingInProgress?: boolean
commentCount: number
viewCount?: number
isPinned?: boolean
@@ -228,17 +229,17 @@ export default function BoardFeed() {
const handleVote = async (postId: string) => {
setPosts((prev) => prev.map((p) =>
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount + 1 } : p
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount != null ? p.voteCount + 1 : p.voteCount } : p
))
if (budget) setBudget({ ...budget, remaining: budget.remaining - 1 })
try {
const altcha = await solveAltcha('light')
const altcha = await solveAltcha('light', { postId })
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
refreshBudget()
setImportancePostId(postId)
} catch {
setPosts((prev) => prev.map((p) =>
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount - 1 } : p
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount != null ? p.voteCount - 1 : p.voteCount } : p
))
refreshBudget()
}
@@ -253,7 +254,7 @@ export default function BoardFeed() {
const handleUnvote = async (postId: string) => {
setPosts((prev) => prev.map((p) =>
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount - 1 } : p
p.id === postId ? { ...p, voted: false, voteCount: p.voteCount != null ? p.voteCount - 1 : p.voteCount } : p
))
if (budget) setBudget({ ...budget, remaining: budget.remaining + 1 })
try {
@@ -261,7 +262,7 @@ export default function BoardFeed() {
refreshBudget()
} catch {
setPosts((prev) => prev.map((p) =>
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount + 1 } : p
p.id === postId ? { ...p, voted: true, voteCount: p.voteCount != null ? p.voteCount + 1 : p.voteCount } : p
))
refreshBudget()
}

View File

@@ -8,7 +8,7 @@ import StatusBadge from '../components/StatusBadge'
import Timeline from '../components/Timeline'
import type { TimelineEntry } from '../components/Timeline'
import PluginSlot from '../components/PluginSlot'
import { IconChevronUp, IconBug, IconBulb, IconEdit, IconTrash, IconPin, IconX, IconLock, IconLockOpen, IconHistory, IconLoader2, IconEye, IconMessageOff, IconMessage, IconCheck } from '@tabler/icons-react'
import { IconChevronUp, IconBug, IconBulb, IconEdit, IconTrash, IconPin, IconX, IconLock, IconLockOpen, IconHistory, IconLoader2, IconEye, IconMessageOff, IconMessage, IconCheck, IconSnowflake, IconSnowflakeOff } from '@tabler/icons-react'
import Dropdown from '../components/Dropdown'
import EditHistoryModal from '../components/EditHistoryModal'
import Avatar from '../components/Avatar'
@@ -35,7 +35,8 @@ interface Post {
status: string
statusReason?: string | null
category?: string | null
voteCount: number
voteCount: number | null
votingInProgress?: boolean
viewCount?: number
voted: boolean
onBehalfOf?: string | null
@@ -51,6 +52,7 @@ interface Post {
isEditLocked?: boolean
isThreadLocked?: boolean
isVotingLocked?: boolean
frozenAt?: string | null
}
interface TimelineResponse {
@@ -192,6 +194,8 @@ export default function PostDetail() {
const [comment, setComment] = useState('')
const [replyTo, setReplyTo] = useState<TimelineEntry | null>(null)
const [submitting, setSubmitting] = useState(false)
const [honeypot, setHoneypot] = useState('')
const [pageLoadTime] = useState(Date.now())
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState(false)
const [editTitle, setEditTitle] = useState('')
@@ -259,13 +263,13 @@ export default function PostDetail() {
setVoteAnimating(true)
setTimeout(() => setVoteAnimating(false), 400)
}
setPost({ ...post, voted: !wasVoted, voteCount: post.voteCount + (wasVoted ? -1 : 1) })
setPost({ ...post, voted: !wasVoted, voteCount: post.voteCount != null ? post.voteCount + (wasVoted ? -1 : 1) : post.voteCount })
try {
if (wasVoted) {
await api.delete(`/boards/${boardSlug}/posts/${postId}/vote`)
toast.success('Vote removed')
} else {
const altcha = await solveAltcha('light')
const altcha = await solveAltcha('light', { postId })
await api.post(`/boards/${boardSlug}/posts/${postId}/vote`, { altcha })
toast.success('Vote added')
}
@@ -294,13 +298,14 @@ export default function PostDetail() {
if (!boardSlug || !postId || !comment.trim()) return
setSubmitting(true)
try {
const payload: Record<string, any> = { body: comment }
const payload: Record<string, any> = { body: comment, _ts: pageLoadTime }
if (replyTo) payload.replyToId = replyTo.id
if (commentAttachments.length) payload.attachmentIds = commentAttachments
if (honeypot) payload.website = honeypot
// admin skips ALTCHA
if (!admin.isAdmin) {
payload.altcha = await solveAltcha()
payload.altcha = await solveAltcha('normal', { postId })
}
await api.post(`/boards/${boardSlug}/posts/${postId}/comments`, payload)
@@ -366,6 +371,17 @@ export default function PostDetail() {
}
}
const toggleFreeze = async () => {
if (!postId || !post) return
try {
const res = await api.put<{ id: string; frozenAt: string | null }>(`/admin/posts/${postId}/freeze`)
setPost({ ...post, frozenAt: res.frozenAt })
toast.success(res.frozenAt ? 'Post frozen' : 'Post unfrozen')
} catch {
toast.error('Failed to toggle freeze')
}
}
const toggleCommentEditLock = async (commentId: string) => {
try {
await api.put(`/admin/comments/${commentId}/lock-edits`)
@@ -522,12 +538,16 @@ export default function PostDetail() {
stroke={2.5}
className={voteAnimating ? 'vote-bounce' : ''}
/>
<span
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
style={{ fontSize: 'var(--text-base)' }}
>
{post.voteCount}
</span>
{post.votingInProgress ? (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)' }}>Tallying</span>
) : (
<span
className={`font-semibold ${voteAnimating ? 'count-tick' : ''}`}
style={{ fontSize: 'var(--text-base)' }}
>
{post.voteCount}
</span>
)}
</button>
<div className="flex-1 min-w-0">
@@ -769,6 +789,23 @@ export default function PostDetail() {
: <><IconMessageOff size={12} stroke={2} aria-hidden="true" /> Lock thread</>
}
</button>
<button
onClick={toggleFreeze}
className="inline-flex items-center gap-1 px-2.5 action-btn"
style={{
minHeight: 44,
color: post.frozenAt ? '#3B82F6' : '#F59E0B',
background: post.frozenAt ? 'rgba(59, 130, 246, 0.1)' : 'rgba(245, 158, 11, 0.1)',
borderRadius: 'var(--radius-sm)',
fontSize: 'var(--text-xs)',
transition: 'all var(--duration-fast) ease-out',
}}
>
{post.frozenAt
? <><IconSnowflakeOff size={12} stroke={2} aria-hidden="true" /> Unfreeze</>
: <><IconSnowflake size={12} stroke={2} aria-hidden="true" /> Freeze</>
}
</button>
</>
)}
</div>
@@ -845,6 +882,22 @@ export default function PostDetail() {
Voting has been locked on this post
</div>
)}
{post.frozenAt && (
<div
className="flex items-center gap-2 px-4 py-2.5 mb-4"
role="alert"
style={{
background: 'rgba(59, 130, 246, 0.06)',
borderRadius: 'var(--radius-md)',
borderLeft: '3px solid #3B82F6',
fontSize: 'var(--text-xs)',
color: 'var(--text-secondary)',
}}
>
<IconSnowflake size={13} stroke={2} style={{ color: '#3B82F6', flexShrink: 0 }} />
This post is frozen - vote counts are locked pending security review
</div>
)}
{/* Structured description fields */}
@@ -961,6 +1014,7 @@ export default function PostDetail() {
className="card card-static p-6"
style={admin.isAdmin ? { borderColor: 'rgba(6, 182, 212, 0.2)' } : undefined}
>
<input type="text" name="website" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} style={{ position: 'absolute', left: -9999, top: -9999 }} tabIndex={-1} autoComplete="off" aria-hidden="true" />
<div className="flex items-center gap-2 mb-4">
<h3
className="font-semibold"

View File

@@ -0,0 +1,902 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { api } from '../../lib/api'
import { useAdmin } from '../../hooks/useAdmin'
import { useDocumentTitle } from '../../hooks/useDocumentTitle'
import { useConfirm } from '../../hooks/useConfirm'
import { useToast } from '../../hooks/useToast'
import Dropdown from '../../components/Dropdown'
import { IconPlus, IconTrash, IconCheck, IconX, IconAlertTriangle, IconShieldCheck } from '@tabler/icons-react'
// types
interface AnomalyAlert {
id: string
type: string
label: string
severity: string
status: string
targetType: string | null
targetId: string | null
boardId: string | null
metadata: Record<string, unknown>
createdAt: string
}
interface AuditEntry {
id: string
adminId: string | null
action: string
metadata: Record<string, unknown>
undone: boolean
createdAt: string
}
interface SecurityWebhook {
id: string
url: string
events: string[]
active: boolean
}
interface BoardSummary {
id: string
name: string
slug: string
sensitivityLevel?: string
velocityThreshold?: number | null
quarantined?: boolean
requireVoteVerification?: boolean
}
// helpers
const SEVERITY_COLORS: Record<string, string> = {
low: '#9CA3AF',
medium: '#F59E0B',
high: '#F97316',
critical: '#EF4444',
}
const WEBHOOK_EVENTS = [
{ value: 'anomaly_detected', label: 'Anomaly detected' },
{ value: 'brigade_confirmed', label: 'Brigade confirmed' },
{ value: 'cleanup_executed', label: 'Cleanup executed' },
]
function timeAgo(date: string): string {
const s = Math.floor((Date.now() - new Date(date).getTime()) / 1000)
if (s < 60) return 'just now'
const m = Math.floor(s / 60)
if (m < 60) return `${m} minute${m === 1 ? '' : 's'} ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h} hour${h === 1 ? '' : 's'} ago`
const d = Math.floor(h / 24)
return `${d} day${d === 1 ? '' : 's'} ago`
}
function SeverityBadge({ severity }: { severity: string }) {
const color = SEVERITY_COLORS[severity] || '#9CA3AF'
return (
<span
className="px-2 py-0.5 rounded font-medium"
style={{
fontSize: 'var(--text-xs)',
background: `${color}18`,
color,
border: `1px solid ${color}30`,
borderRadius: 'var(--radius-sm)',
}}
>
{severity}
</span>
)
}
function metaSummary(meta: Record<string, unknown>): string {
const parts: string[] = []
if (meta.voteCount) parts.push(`${meta.voteCount} votes`)
if (meta.identities) parts.push(`${(meta.identities as string[]).length} identities`)
if (meta.ratio) parts.push(`${Number(meta.ratio).toFixed(1)}x velocity`)
if (meta.coefficient) parts.push(`${(Number(meta.coefficient) * 100).toFixed(0)}% overlap`)
if (meta.count) parts.push(`${meta.count} events`)
if (meta.postCount) parts.push(`${meta.postCount} posts`)
return parts.join(' - ') || ''
}
// sub-components
function AlertsTab() {
const admin = useAdmin()
const confirm = useConfirm()
const toast = useToast()
const [alerts, setAlerts] = useState<AnomalyAlert[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pages, setPages] = useState(1)
const [severity, setSeverity] = useState('all')
const [status, setStatus] = useState('pending')
const [boardId, setBoardId] = useState('all')
const [boards, setBoards] = useState<BoardSummary[]>([])
const [expandedId, setExpandedId] = useState<string | null>(null)
const [cleanupActions, setCleanupActions] = useState<string[]>([])
const [cleaning, setCleaning] = useState(false)
useEffect(() => {
api.get<{ boards: BoardSummary[] }>('/boards').then((r) => setBoards(r.boards)).catch(() => {})
}, [])
const fetchAlerts = () => {
setLoading(true)
const params = new URLSearchParams()
if (severity !== 'all') params.set('severity', severity)
if (status !== 'all') params.set('status', status)
if (boardId !== 'all') params.set('boardId', boardId)
params.set('page', String(page))
api.get<{ alerts: AnomalyAlert[]; total: number; pages: number }>(`/admin/security/alerts?${params}`)
.then((r) => { setAlerts(r.alerts); setTotal(r.total); setPages(r.pages) })
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(fetchAlerts, [severity, status, boardId, page])
const handleConfirm = async (id: string) => {
try {
await api.put(`/admin/security/alerts/${id}/confirm`)
toast.success('Alert confirmed')
fetchAlerts()
} catch {
toast.error('Failed to confirm alert')
}
}
const handleDismiss = async (id: string) => {
try {
await api.put(`/admin/security/alerts/${id}/dismiss`)
toast.success('Alert dismissed')
fetchAlerts()
} catch {
toast.error('Failed to dismiss alert')
}
}
const handleBrigade = async (id: string) => {
if (!await confirm('Mark this as a brigade? This records the pattern for future detection.')) return
try {
await api.post(`/admin/security/alerts/${id}/mark-brigaded`)
toast.success('Marked as brigade')
fetchAlerts()
} catch {
toast.error('Failed to mark as brigade')
}
}
const handleCleanup = async (alertId: string) => {
if (cleanupActions.length === 0) return
setCleaning(true)
try {
await api.post('/admin/security/cleanup', {
anomalyEventId: alertId,
actions: cleanupActions,
})
toast.success('Cleanup applied')
setExpandedId(null)
setCleanupActions([])
fetchAlerts()
} catch {
toast.error('Failed to apply cleanup')
} finally {
setCleaning(false)
}
}
const toggleCleanupAction = (action: string) => {
setCleanupActions((prev) =>
prev.includes(action) ? prev.filter((a) => a !== action) : [...prev, action]
)
}
const severityOptions = [
{ value: 'all', label: 'All severities' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'critical', label: 'Critical' },
]
const statusOptions = [
{ value: 'all', label: 'All statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'confirmed', label: 'Confirmed' },
{ value: 'dismissed', label: 'Dismissed' },
]
const boardOptions = [
{ value: 'all', label: 'All boards' },
...boards.map((b) => ({ value: b.id, label: b.name })),
]
return (
<div>
{/* Filters */}
<div className="flex flex-wrap gap-3 mb-5">
<div style={{ minWidth: 150 }}>
<Dropdown value={severity} options={severityOptions} onChange={(v) => { setSeverity(v); setPage(1) }} aria-label="Filter by severity" />
</div>
<div style={{ minWidth: 150 }}>
<Dropdown value={status} options={statusOptions} onChange={(v) => { setStatus(v); setPage(1) }} aria-label="Filter by status" />
</div>
<div style={{ minWidth: 150 }}>
<Dropdown value={boardId} options={boardOptions} onChange={(v) => { setBoardId(v); setPage(1) }} aria-label="Filter by board" />
</div>
</div>
{loading ? (
<div>
<div className="progress-bar mb-4" />
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center justify-between p-4 mb-2" style={{ opacity: 1 - i * 0.2 }}>
<div className="skeleton h-4" style={{ width: '50%' }} />
<div className="skeleton h-4 w-20" />
</div>
))}
</div>
) : alerts.length === 0 ? (
<div className="flex flex-col items-center py-12" style={{ textAlign: 'center' }}>
<div
className="flex items-center justify-center mb-4"
style={{ width: 56, height: 56, borderRadius: 'var(--radius-lg)', background: 'rgba(34, 197, 94, 0.1)' }}
>
<IconShieldCheck size={24} stroke={1.5} style={{ color: 'var(--success)' }} />
</div>
<p className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-base)' }}>No alerts</p>
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)', marginTop: 4 }}>
{status === 'pending' ? 'No pending alerts right now' : 'No matching alerts found'}
</p>
</div>
) : (
<>
<p className="mb-3" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
{total} alert{total === 1 ? '' : 's'}
</p>
<div className="flex flex-col gap-2">
{alerts.map((alert) => {
const expanded = expandedId === alert.id
const summary = metaSummary(alert.metadata)
return (
<div key={alert.id} className="card" style={{ padding: 0 }}>
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<SeverityBadge severity={alert.severity} />
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{alert.label}
</span>
</div>
{alert.targetId && (
<p style={{ color: 'var(--text-secondary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Target: {alert.targetType} {alert.targetId.slice(0, 8)}...
</p>
)}
{summary && (
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>{summary}</p>
)}
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginTop: 4 }}>
{timeAgo(alert.createdAt)}
</p>
</div>
{alert.status === 'pending' && (
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handleConfirm(alert.id)}
className="action-btn inline-flex items-center gap-1 px-2"
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: 'var(--success)', borderRadius: 'var(--radius-sm)' }}
>
<IconCheck size={14} stroke={2} /> Confirm
</button>
<button
onClick={() => handleDismiss(alert.id)}
className="action-btn inline-flex items-center gap-1 px-2"
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: 'var(--text-tertiary)', borderRadius: 'var(--radius-sm)' }}
>
<IconX size={14} stroke={2} /> Dismiss
</button>
<button
onClick={() => handleBrigade(alert.id)}
className="action-btn inline-flex items-center gap-1 px-2"
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: '#F97316', borderRadius: 'var(--radius-sm)' }}
>
<IconAlertTriangle size={14} stroke={2} /> Brigade
</button>
{admin.isSuperAdmin && (
<button
onClick={() => {
if (expanded) { setExpandedId(null); setCleanupActions([]) }
else { setExpandedId(alert.id); setCleanupActions([]) }
}}
className="action-btn px-2"
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: 'var(--admin-accent)', borderRadius: 'var(--radius-sm)' }}
aria-expanded={expanded}
>
Cleanup
</button>
)}
</div>
)}
{alert.status !== 'pending' && (
<span
className="px-2 py-0.5 rounded shrink-0"
style={{
fontSize: 'var(--text-xs)',
background: alert.status === 'confirmed' ? 'rgba(34, 197, 94, 0.1)' : 'var(--surface-hover)',
color: alert.status === 'confirmed' ? 'var(--success)' : 'var(--text-tertiary)',
borderRadius: 'var(--radius-sm)',
}}
>
{alert.status}
</span>
)}
</div>
</div>
{expanded && (
<div
className="p-4 border-t fade-in"
style={{ borderColor: 'var(--border)', background: 'var(--bg)' }}
>
<p className="font-medium mb-3" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
Cleanup actions
</p>
<div className="flex flex-col gap-2 mb-4">
{[
{ value: 'remove_phantom_votes', label: 'Remove phantom votes' },
{ value: 'remove_flagged_votes', label: 'Remove flagged identity votes' },
{ value: 'recalculate_counts', label: 'Recalculate vote counts' },
].map((opt) => (
<label
key={opt.value}
className="flex items-center gap-2"
style={{ fontSize: 'var(--text-sm)', color: 'var(--text-secondary)', cursor: 'pointer' }}
>
<input
type="checkbox"
checked={cleanupActions.includes(opt.value)}
onChange={() => toggleCleanupAction(opt.value)}
/>
{opt.label}
</label>
))}
</div>
{summary && (
<p className="mb-3" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
Estimated impact: {summary}
</p>
)}
<div className="flex gap-2">
<button
onClick={() => handleCleanup(alert.id)}
disabled={cleanupActions.length === 0 || cleaning}
className="btn btn-admin"
style={{ fontSize: 'var(--text-sm)', opacity: cleanupActions.length === 0 || cleaning ? 0.5 : 1 }}
>
{cleaning ? 'Applying...' : 'Apply cleanup'}
</button>
<button
onClick={() => { setExpandedId(null); setCleanupActions([]) }}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-sm)' }}
>
Cancel
</button>
</div>
</div>
)}
</div>
)
})}
</div>
{/* Pagination */}
{pages > 1 && (
<div className="flex items-center justify-center gap-2 mt-6">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-sm)', opacity: page === 1 ? 0.4 : 1 }}
>
Previous
</button>
<span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
Page {page} of {pages}
</span>
<button
onClick={() => setPage((p) => Math.min(pages, p + 1))}
disabled={page === pages}
className="btn btn-ghost"
style={{ fontSize: 'var(--text-sm)', opacity: page === pages ? 0.4 : 1 }}
>
Next
</button>
</div>
)}
</>
)}
{/* Board security settings */}
{boards.length > 0 && (
<div className="mt-10">
<h2
className="font-medium mb-3"
style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', letterSpacing: '0.05em', textTransform: 'uppercase' }}
>
Board security settings
</h2>
<div className="flex flex-col gap-2">
{boards.map((board) => (
<BoardSecurityCard key={board.id} board={board} />
))}
</div>
</div>
)}
</div>
)
}
function BoardSecurityCard({ board }: { board: BoardSummary }) {
const toast = useToast()
const [sensitivity, setSensitivity] = useState(board.sensitivityLevel || 'normal')
const [threshold, setThreshold] = useState(board.velocityThreshold ?? '')
const [quarantined, setQuarantined] = useState(board.quarantined || false)
const [voteVerification, setVoteVerification] = useState(board.requireVoteVerification || false)
const [saving, setSaving] = useState(false)
const save = async (data: Record<string, unknown>) => {
setSaving(true)
try {
const res = await api.put<{ sensitivityLevel: string; velocityThreshold: number | null; quarantined: boolean; requireVoteVerification: boolean }>(`/admin/boards/${board.id}/security`, data)
setSensitivity(res.sensitivityLevel)
setThreshold(res.velocityThreshold ?? '')
setQuarantined(res.quarantined)
setVoteVerification(res.requireVoteVerification)
toast.success('Board security updated')
} catch {
toast.error('Failed to update board security')
} finally {
setSaving(false)
}
}
return (
<div
className="p-4 rounded-lg"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
>
<div className="flex items-center justify-between mb-3">
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{board.name}
</span>
{quarantined && (
<span
className="px-2 py-0.5 rounded font-medium"
style={{ fontSize: 'var(--text-xs)', background: 'rgba(239, 68, 68, 0.1)', color: 'var(--error)', borderRadius: 'var(--radius-sm)' }}
>
Quarantined
</span>
)}
</div>
<div className="flex flex-wrap items-end gap-3">
<div style={{ minWidth: 140 }}>
<label style={{ display: 'block', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Sensitivity
</label>
<Dropdown
value={sensitivity}
options={[
{ value: 'low', label: 'Low' },
{ value: 'normal', label: 'Normal' },
{ value: 'high', label: 'High' },
{ value: 'paranoid', label: 'Paranoid' },
]}
onChange={(v) => { setSensitivity(v); save({ sensitivityLevel: v }) }}
aria-label="Sensitivity level"
/>
</div>
<div style={{ minWidth: 120 }}>
<label htmlFor={`threshold-${board.id}`} style={{ display: 'block', color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)', marginBottom: 4 }}>
Velocity threshold
</label>
<input
id={`threshold-${board.id}`}
className="input"
type="number"
min={0}
placeholder="Auto"
value={threshold}
onChange={(e) => setThreshold(e.target.value === '' ? '' : Number(e.target.value))}
onBlur={() => {
const val = threshold === '' ? null : Number(threshold)
save({ velocityThreshold: val })
}}
style={{ width: 100 }}
/>
</div>
<label
className="flex items-center gap-2"
style={{ fontSize: 'var(--text-sm)', color: quarantined ? 'var(--error)' : 'var(--text-secondary)', cursor: 'pointer', minHeight: 44 }}
>
<input
type="checkbox"
checked={quarantined}
onChange={(e) => { setQuarantined(e.target.checked); save({ quarantined: e.target.checked }) }}
/>
Quarantine
</label>
<label
className="flex items-center gap-2"
style={{ fontSize: 'var(--text-sm)', color: voteVerification ? 'var(--admin-accent)' : 'var(--text-secondary)', cursor: 'pointer', minHeight: 44 }}
>
<input
type="checkbox"
checked={voteVerification}
onChange={(e) => { setVoteVerification(e.target.checked); save({ requireVoteVerification: e.target.checked }) }}
/>
Vote verification
</label>
{saving && <span style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>Saving...</span>}
</div>
</div>
)
}
function AuditLogTab() {
const admin = useAdmin()
const confirm = useConfirm()
const toast = useToast()
const [entries, setEntries] = useState<AuditEntry[]>([])
const [loading, setLoading] = useState(true)
const fetchLog = () => {
api.get<{ entries: AuditEntry[] }>('/admin/security/audit-log')
.then((r) => setEntries(r.entries))
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(fetchLog, [])
const handleUndo = async (id: string) => {
if (!await confirm('Undo this cleanup action? This marks it as reversed but does not restore deleted votes.')) return
try {
await api.post(`/admin/security/audit-log/${id}/undo`)
toast.success('Marked as undone')
fetchLog()
} catch {
toast.error('Failed to undo')
}
}
if (loading) {
return (
<div>
<div className="progress-bar mb-4" />
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.2 }}>
<div className="skeleton h-4" style={{ width: '50%' }} />
<div className="skeleton h-4 w-16" />
</div>
))}
</div>
)
}
if (entries.length === 0) {
return <p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>No cleanup actions recorded yet</p>
}
return (
<div className="flex flex-col gap-1">
{entries.map((entry) => {
const meta = entry.metadata
const results = meta.results as Record<string, Record<string, number>> | undefined
return (
<div
key={entry.id}
className="flex items-center justify-between p-3 rounded-lg"
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
opacity: entry.undone ? 0.5 : 1,
}}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{entry.action.replace(/_/g, ' ')}
</span>
{entry.undone && (
<span className="px-1.5 py-0.5 rounded" style={{ fontSize: 'var(--text-xs)', background: 'var(--surface-hover)', color: 'var(--text-tertiary)' }}>
undone
</span>
)}
</div>
<div className="flex items-center gap-3 flex-wrap" style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-xs)' }}>
<span>{timeAgo(entry.createdAt as unknown as string)}</span>
{results && Object.entries(results).map(([key, val]) => (
<span key={key}>{key.replace(/_/g, ' ')}: {Object.values(val).join(', ')}</span>
))}
</div>
</div>
{admin.isSuperAdmin && !entry.undone && (
<button
onClick={() => handleUndo(entry.id)}
className="action-btn px-2"
style={{ minHeight: 36, fontSize: 'var(--text-xs)', color: 'var(--warning)', borderRadius: 'var(--radius-sm)' }}
>
Undo
</button>
)}
</div>
)
})}
</div>
)
}
function WebhooksTab() {
const confirm = useConfirm()
const toast = useToast()
const [webhooks, setWebhooks] = useState<SecurityWebhook[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [url, setUrl] = useState('')
const [events, setEvents] = useState<string[]>(['anomaly_detected'])
const [error, setError] = useState('')
const fetchWebhooks = () => {
api.get<{ webhooks: SecurityWebhook[] }>('/admin/security/webhooks')
.then((r) => setWebhooks(r.webhooks))
.catch(() => {})
.finally(() => setLoading(false))
}
useEffect(fetchWebhooks, [])
const create = async () => {
if (!url.trim() || events.length === 0) return
setError('')
try {
await api.post('/admin/security/webhooks', { url: url.trim(), events })
setUrl('')
setEvents(['anomaly_detected'])
setShowForm(false)
fetchWebhooks()
toast.success('Security webhook created')
} catch {
setError('Failed to create webhook')
toast.error('Failed to create webhook')
}
}
const toggle = async (id: string, active: boolean) => {
try {
await api.put(`/admin/security/webhooks/${id}`, { active: !active })
fetchWebhooks()
toast.success(active ? 'Webhook disabled' : 'Webhook enabled')
} catch {
toast.error('Failed to update webhook')
}
}
const remove = async (id: string) => {
if (!await confirm('Delete this security webhook?')) return
try {
await api.delete(`/admin/security/webhooks/${id}`)
fetchWebhooks()
toast.success('Webhook deleted')
} catch {
toast.error('Failed to delete webhook')
}
}
if (loading) {
return (
<div>
<div className="progress-bar mb-4" />
{[0, 1].map((i) => (
<div key={i} className="flex items-center justify-between p-3 mb-1" style={{ opacity: 1 - i * 0.3 }}>
<div className="skeleton h-4" style={{ width: '50%' }} />
<div className="skeleton h-4 w-16" />
</div>
))}
</div>
)
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>
Receive notifications when security events occur
</p>
<button
onClick={() => setShowForm(!showForm)}
className="btn btn-admin flex items-center gap-1"
aria-expanded={showForm}
style={{ fontSize: 'var(--text-sm)' }}
>
<IconPlus size={14} stroke={2} />
Add webhook
</button>
</div>
{showForm && (
<div className="card p-4 mb-4">
<div className="mb-3">
<label htmlFor="sec-webhook-url" className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>URL</label>
<input
id="sec-webhook-url"
className="input w-full"
placeholder="https://example.com/security-hook"
value={url}
onChange={(e) => setUrl(e.target.value)}
type="url"
/>
</div>
<div className="mb-3">
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--text-tertiary)' }}>Events</label>
<div className="flex flex-wrap gap-2">
{WEBHOOK_EVENTS.map((ev) => {
const active = events.includes(ev.value)
return (
<button
key={ev.value}
type="button"
onClick={() => setEvents((evs) =>
active ? evs.filter((e) => e !== ev.value) : [...evs, ev.value]
)}
className="px-2 py-1 rounded text-xs"
style={{
background: active ? 'var(--admin-subtle)' : 'var(--surface-hover)',
color: active ? 'var(--admin-accent)' : 'var(--text-tertiary)',
border: active ? '1px solid rgba(6, 182, 212, 0.3)' : '1px solid transparent',
cursor: 'pointer',
}}
>
{ev.label}
</button>
)
})}
</div>
</div>
{error && <p role="alert" className="text-xs mb-2" style={{ color: 'var(--error)' }}>{error}</p>}
<div className="flex gap-2">
<button onClick={create} className="btn btn-admin">Create</button>
<button onClick={() => setShowForm(false)} className="btn btn-ghost">Cancel</button>
</div>
</div>
)}
{webhooks.length === 0 && !showForm ? (
<p style={{ color: 'var(--text-tertiary)', fontSize: 'var(--text-sm)' }}>No security webhooks configured</p>
) : (
<div className="flex flex-col gap-1">
{webhooks.map((wh) => (
<div
key={wh.id}
className="flex items-center justify-between p-3 rounded-lg"
style={{ background: 'var(--surface)', border: '1px solid var(--border)', opacity: wh.active ? 1 : 0.5 }}
>
<div className="flex-1 min-w-0 mr-2">
<div className="font-medium truncate" style={{ color: 'var(--text)', fontSize: 'var(--text-sm)' }}>
{wh.url}
</div>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{wh.events.map((ev) => (
<span key={ev} className="text-xs px-1.5 py-0.5 rounded" style={{ background: 'var(--surface-hover)', color: 'var(--text-tertiary)' }}>
{ev.replace(/_/g, ' ')}
</span>
))}
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => toggle(wh.id, wh.active)}
className="text-xs px-2 rounded"
style={{ minHeight: 44, color: wh.active ? 'var(--warning)' : 'var(--success)', background: 'none', border: 'none', cursor: 'pointer' }}
>
{wh.active ? 'Disable' : 'Enable'}
</button>
<button
onClick={() => remove(wh.id)}
className="text-xs px-2 rounded"
aria-label="Delete webhook"
style={{ minHeight: 44, color: 'var(--error)', background: 'none', border: 'none', cursor: 'pointer' }}
>
<IconTrash size={14} stroke={2} />
</button>
</div>
</div>
))}
</div>
)}
</div>
)
}
// main page
type Tab = 'alerts' | 'audit' | 'webhooks'
export default function AdminSecurity() {
useDocumentTitle('Security')
const admin = useAdmin()
const [tab, setTab] = useState<Tab>('alerts')
const tabs: { key: Tab; label: string; superOnly?: boolean }[] = [
{ key: 'alerts', label: 'Alerts' },
{ key: 'audit', label: 'Audit Log' },
{ key: 'webhooks', label: 'Webhooks', superOnly: true },
]
const visibleTabs = tabs.filter((t) => !t.superOnly || admin.isSuperAdmin)
return (
<div style={{ maxWidth: 780, margin: '0 auto', padding: '32px 24px' }}>
<div className="flex items-center justify-between mb-6">
<h1
className="font-bold"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--admin-accent)', fontSize: 'var(--text-3xl)' }}
>
Security
</h1>
<Link to="/admin" className="btn btn-ghost" style={{ fontSize: 'var(--text-sm)' }}>Back to dashboard</Link>
</div>
{/* Tab nav */}
<div
className="flex gap-1 mb-6"
role="tablist"
style={{
borderBottom: '1px solid var(--border)',
paddingBottom: 0,
}}
>
{visibleTabs.map((t) => (
<button
key={t.key}
role="tab"
aria-selected={tab === t.key}
onClick={() => setTab(t.key)}
className="px-4 py-2"
style={{
fontSize: 'var(--text-sm)',
fontWeight: tab === t.key ? 600 : 400,
color: tab === t.key ? 'var(--admin-accent)' : 'var(--text-tertiary)',
background: 'transparent',
border: 'none',
borderBottom: tab === t.key ? '2px solid var(--admin-accent)' : '2px solid transparent',
cursor: 'pointer',
transition: 'all var(--duration-fast) ease-out',
marginBottom: -1,
}}
>
{t.label}
</button>
))}
</div>
{/* Tab content */}
<div role="tabpanel">
{tab === 'alerts' && <AlertsTab />}
{tab === 'audit' && <AuditLogTab />}
{tab === 'webhooks' && <WebhooksTab />}
</div>
</div>
)
}