diff --git a/packages/api/prisma/schema.prisma b/packages/api/prisma/schema.prisma index 1f38a01..57557ce 100644 --- a/packages/api/prisma/schema.prisma +++ b/packages/api/prisma/schema.prisma @@ -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) +} diff --git a/packages/api/src/cron/index.ts b/packages/api/src/cron/index.ts index 8af2b13..f99c5b2 100644 --- a/packages/api/src/cron/index.ts +++ b/packages/api/src/cron/index.ts @@ -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)) { diff --git a/packages/api/src/routes/admin/security.ts b/packages/api/src/routes/admin/security.ts new file mode 100644 index 0000000..dff7265 --- /dev/null +++ b/packages/api/src/routes/admin/security.ts @@ -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 = { + 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 }>( + "/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 }>( + "/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; + const results: Record = {}; + + // 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; + 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; + 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 }>( + "/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 = {}; + 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 }>( + "/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 }>( + "/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; + + // extract pattern features from the anomaly + const features: Record = { + 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() }); + } + } + ); +} diff --git a/packages/api/src/routes/comments.ts b/packages/api/src/routes/comments.ts index 4814386..87cbaad 100644 --- a/packages/api/src/routes/comments.ts +++ b/packages/api/src/routes/comments.ts @@ -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; diff --git a/packages/api/src/routes/identity.ts b/packages/api/src/routes/identity.ts index 913a429..034d519 100644 --- a/packages/api/src/routes/identity.ts +++ b/packages/api/src/routes/identity.ts @@ -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; diff --git a/packages/api/src/routes/posts.ts b/packages/api/src/routes/posts.ts index 5286eeb..76d5459 100644 --- a/packages/api/src/routes/posts.ts +++ b/packages/api/src/routes/posts.ts @@ -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, diff --git a/packages/api/src/routes/privacy.ts b/packages/api/src/routes/privacy.ts index d0899d2..81b86b5 100644 --- a/packages/api/src/routes/privacy.ts +++ b/packages/api/src/routes/privacy.ts @@ -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); }); diff --git a/packages/api/src/routes/reactions.ts b/packages/api/src/routes/reactions.ts index 72e3b02..b614a0b 100644 --- a/packages/api/src/routes/reactions.ts +++ b/packages/api/src/routes/reactions.ts @@ -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 }); } } diff --git a/packages/api/src/routes/recovery.ts b/packages/api/src/routes/recovery.ts index 975b827..db6a0e4 100644 --- a/packages/api/src/routes/recovery.ts +++ b/packages/api/src/routes/recovery.ts @@ -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; diff --git a/packages/api/src/routes/votes.ts b/packages/api/src/routes/votes.ts index a41a991..c16f775 100644 --- a/packages/api/src/routes/votes.ts +++ b/packages/api/src/routes/votes.ts @@ -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({ diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index f415814..97066c2 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -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" }); diff --git a/packages/api/src/services/altcha.ts b/packages/api/src/services/altcha.ts index 9717340..4a5865e 100644 --- a/packages/api/src/services/altcha.ts +++ b/packages/api/src/services/altcha.ts @@ -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(); +interface ChallengeEntry { + issuedAt: number; + sessionHash: string; +} + +// replay protection: track consumed challenge hashes +const usedChallenges = new Map(); +// track issued challenges for solve-time validation +const issuedChallenges = new Map(); 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 { + 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 { +export async function verifyChallenge(payload: string, sessionId?: string): Promise { try { const fp = challengeFingerprint(payload); @@ -38,20 +117,36 @@ export async function verifyChallenge(payload: string): Promise { 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 { diff --git a/packages/api/src/services/detection-engine.ts b/packages/api/src/services/detection-engine.ts new file mode 100644 index 0000000..25d84ac --- /dev/null +++ b/packages/api/src/services/detection-engine.ts @@ -0,0 +1,1039 @@ +import { Prisma } from "@prisma/client"; +import prisma from "../lib/prisma.js"; + +async function recentEventExists(type: string, targetId: string) { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + const existing = await prisma.anomalyEvent.findFirst({ + where: { type, targetId, createdAt: { gt: oneHourAgo } }, + select: { id: true }, + }); + return !!existing; +} + +export async function matchBrigadePatterns(event: { + type: string; + targetId: string; + boardId?: string; + metadata: Record; +}) { + const patterns = await prisma.brigadePattern.findMany({ + where: { + OR: [ + { boardId: event.boardId ?? undefined }, + { boardId: null }, + ], + }, + }); + + if (patterns.length === 0) return null; + + for (const pattern of patterns) { + const features = pattern.features as Record; + let matchScore = 0; + let totalChecks = 0; + + if (features.avgIdentityAge && event.metadata.avgIdentityAge) { + totalChecks++; + const patternAge = features.avgIdentityAge as number; + const eventAge = event.metadata.avgIdentityAge as number; + if (Math.abs(patternAge - eventAge) / Math.max(patternAge, eventAge) < 0.3) matchScore++; + } + + if (features.avgDiversityScore !== undefined && event.metadata.avgDiversityScore !== undefined) { + totalChecks++; + const pDiv = features.avgDiversityScore as number; + const eDiv = event.metadata.avgDiversityScore as number; + if (Math.abs(pDiv - eDiv) < 0.2) matchScore++; + } + + if (features.avgVoteTimingStdDev !== undefined && event.metadata.avgVoteTimingStdDev !== undefined) { + totalChecks++; + const pTiming = features.avgVoteTimingStdDev as number; + const eTiming = event.metadata.avgVoteTimingStdDev as number; + if (pTiming > 0 && eTiming > 0 && Math.abs(pTiming - eTiming) / Math.max(pTiming, eTiming) < 0.5) matchScore++; + } + + if (features.commonReferrer && event.metadata.commonReferrer) { + totalChecks++; + if (features.commonReferrer === event.metadata.commonReferrer) matchScore++; + } + + if (features.peakHour !== undefined && event.metadata.peakHour !== undefined) { + totalChecks++; + const pHour = features.peakHour as number; + const eHour = event.metadata.peakHour as number; + if (Math.abs(pHour - eHour) <= 2) matchScore++; + } + + if (totalChecks > 0 && matchScore / totalChecks >= 0.7) { + await prisma.brigadePattern.update({ + where: { id: pattern.id }, + data: { matchCount: { increment: 1 } }, + }).catch(() => {}); + return pattern; + } + } + + return null; +} + +export async function createAnomalyEvent(data: { + type: string; + severity: string; + targetType: string; + targetId: string; + boardId?: string; + metadata?: Record; +}) { + if (await recentEventExists(data.type, data.targetId)) return null; + + const event = await prisma.anomalyEvent.create({ + data: { + type: data.type, + severity: data.severity, + targetType: data.targetType, + targetId: data.targetId, + boardId: data.boardId ?? null, + metadata: (data.metadata ?? {}) as Prisma.InputJsonValue, + }, + }); + + const match = await matchBrigadePatterns({ + type: data.type, + targetId: data.targetId, + boardId: data.boardId, + metadata: (data.metadata ?? {}) as Record, + }).catch(() => null); + + if (match && data.severity !== "critical") { + await prisma.anomalyEvent.update({ + where: { id: event.id }, + data: { + severity: "critical", + metadata: { ...(data.metadata ?? {}), matchedPatternId: match.id } as Prisma.InputJsonValue, + }, + }).catch(() => {}); + } + + return event; +} + +export async function recalculateBoardBaselines() { + const boards = await prisma.board.findMany({ select: { id: true } }); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + for (const board of boards) { + const [voteCount, postCount, reactionCount] = await Promise.all([ + prisma.vote.count({ where: { post: { boardId: board.id }, createdAt: { gt: sevenDaysAgo } } }), + prisma.post.count({ where: { boardId: board.id, createdAt: { gt: sevenDaysAgo } } }), + prisma.reaction.count({ where: { comment: { post: { boardId: board.id } }, createdAt: { gt: sevenDaysAgo } } }), + ]); + + const hours = 7 * 24; + const days = 7; + + const posts = await prisma.post.findMany({ + where: { boardId: board.id, createdAt: { gt: sevenDaysAgo } }, + select: { createdAt: true }, + }); + + const hourCounts = new Array(24).fill(0); + const dayCounts = new Array(7).fill(0); + for (const p of posts) { + hourCounts[p.createdAt.getHours()]++; + dayCounts[p.createdAt.getDay()]++; + } + const peakHour = hourCounts.indexOf(Math.max(...hourCounts)); + const peakDay = dayCounts.indexOf(Math.max(...dayCounts)); + + await prisma.boardBaseline.upsert({ + where: { boardId: board.id }, + create: { + boardId: board.id, + avgVotesPerHour: voteCount / hours, + avgPostsPerDay: postCount / days, + avgReactionsPerHour: reactionCount / hours, + peakHourOfDay: peakHour >= 0 ? peakHour : 12, + peakDayOfWeek: peakDay >= 0 ? peakDay : 2, + }, + update: { + avgVotesPerHour: voteCount / hours, + avgPostsPerDay: postCount / days, + avgReactionsPerHour: reactionCount / hours, + peakHourOfDay: peakHour >= 0 ? peakHour : 12, + peakDayOfWeek: peakDay >= 0 ? peakDay : 2, + }, + }); + } +} + +export async function getBaseline(boardId: string) { + const baseline = await prisma.boardBaseline.findUnique({ where: { boardId } }); + if (baseline) return baseline; + return { avgVotesPerHour: 2, avgPostsPerDay: 3, avgReactionsPerHour: 1, peakHourOfDay: 12, peakDayOfWeek: 2 }; +} + +export async function takeVoteSnapshots() { + const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000); + const recentVotes = await prisma.vote.findMany({ + where: { createdAt: { gt: fiveMinAgo } }, + select: { postId: true }, + distinct: ["postId"], + }); + + for (const { postId } of recentVotes) { + const post = await prisma.post.findUnique({ where: { id: postId }, select: { voteCount: true } }); + if (!post) continue; + await prisma.postVoteSnapshot.create({ + data: { postId, voteCount: post.voteCount }, + }); + } +} + +export async function pruneOldSnapshots() { + const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + await prisma.postVoteSnapshot.deleteMany({ where: { snapshotAt: { lt: cutoff } } }); +} + +export async function pruneOldAnomalyEvents() { + const cutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + await prisma.anomalyEvent.deleteMany({ where: { createdAt: { lt: cutoff } } }); +} + +// --- Vote entropy helper --- + +export async function calculateVoteEntropy(postId: string): Promise { + const voters = await prisma.vote.findMany({ + where: { postId }, + select: { voter: { select: { createdAt: true, actionDiversityScore: true } } }, + }); + if (voters.length < 3) return 1; + + // bucket creation times into 10-minute windows + const timeBuckets = new Map(); + for (const v of voters) { + const bucket = Math.floor(v.voter.createdAt.getTime() / (10 * 60 * 1000)); + timeBuckets.set(bucket, (timeBuckets.get(bucket) ?? 0) + 1); + } + + // bucket diversity scores into 0.1 ranges + const divBuckets = new Map(); + for (const v of voters) { + const bucket = Math.floor(v.voter.actionDiversityScore * 10); + divBuckets.set(bucket, (divBuckets.get(bucket) ?? 0) + 1); + } + + const n = voters.length; + let entropy = 0; + const allBuckets = [...timeBuckets.values(), ...divBuckets.values()]; + const total = allBuckets.reduce((a, b) => a + b, 0); + for (const count of allBuckets) { + const p = count / total; + if (p > 0) entropy -= p * Math.log2(p); + } + + // normalize to 0-1 range + const maxEntropy = Math.log2(allBuckets.length || 1); + return maxEntropy > 0 ? entropy / maxEntropy : 1; +} + +// --- 1. Vote velocity scanning --- + +export async function scanVoteVelocity() { + const boards = await prisma.board.findMany({ + select: { id: true, velocityThreshold: true }, + }); + const fifteenMinAgo = new Date(Date.now() - 15 * 60 * 1000); + + for (const board of boards) { + const baseline = await getBaseline(board.id); + const expectedPer15Min = baseline.avgVotesPerHour / 4; + const threshold = board.velocityThreshold ?? expectedPer15Min * 3; + if (threshold <= 0) continue; + + const recentVotes = await prisma.vote.count({ + where: { post: { boardId: board.id }, createdAt: { gt: fifteenMinAgo } }, + }); + + if (recentVotes <= threshold) continue; + + const ratio = expectedPer15Min > 0 ? recentVotes / expectedPer15Min : recentVotes; + let severity = "medium"; + if (ratio > 10) severity = "critical"; + else if (ratio > 5) severity = "high"; + + // find which posts got abnormal votes + const postVotes = await prisma.vote.groupBy({ + by: ["postId"], + where: { post: { boardId: board.id }, createdAt: { gt: fifteenMinAgo } }, + _count: true, + orderBy: { _count: { postId: "desc" } }, + take: 10, + }); + + const flaggedPosts = postVotes + .filter((p) => p._count > Math.max(threshold / 5, 3)) + .map((p) => p.postId); + + // compute entropy for top flagged posts + const entropyScores: Record = {}; + for (const pid of flaggedPosts.slice(0, 5)) { + entropyScores[pid] = await calculateVoteEntropy(pid); + } + + // gather voter identity stats for pattern matching + const recentVoters = await prisma.vote.findMany({ + where: { post: { boardId: board.id }, createdAt: { gt: fifteenMinAgo } }, + select: { + referrer: true, + createdAt: true, + voter: { select: { createdAt: true, actionDiversityScore: true, voteTimingStdDev: true } }, + }, + take: 200, + }); + + const now = Date.now(); + const ages = recentVoters.map((v) => now - v.voter.createdAt.getTime()); + const divScores = recentVoters.map((v) => v.voter.actionDiversityScore); + const timingVals = recentVoters.map((v) => v.voter.voteTimingStdDev).filter((t): t is number => t !== null); + const avgIdentityAge = ages.length > 0 ? ages.reduce((a, b) => a + b, 0) / ages.length : undefined; + const avgDiversityScore = divScores.length > 0 ? divScores.reduce((a, b) => a + b, 0) / divScores.length : undefined; + const avgVoteTimingStdDev = timingVals.length > 0 ? timingVals.reduce((a, b) => a + b, 0) / timingVals.length : undefined; + + // find common referrer (>50% share) + const refCounts = new Map(); + for (const v of recentVoters) { + if (v.referrer) refCounts.set(v.referrer, (refCounts.get(v.referrer) ?? 0) + 1); + } + let commonReferrer: string | undefined; + for (const [ref, cnt] of refCounts) { + if (cnt / recentVoters.length > 0.5) { commonReferrer = ref; break; } + } + + // peak hour + const hourBuckets = new Array(24).fill(0); + for (const v of recentVoters) hourBuckets[v.createdAt.getUTCHours()]++; + const peakHour = hourBuckets.indexOf(Math.max(...hourBuckets)); + + await createAnomalyEvent({ + type: "vote_velocity", + severity, + targetType: "board", + targetId: board.id, + boardId: board.id, + metadata: { + recentVotes, threshold, ratio, flaggedPosts, entropyScores, + avgIdentityAge, avgDiversityScore, avgVoteTimingStdDev, commonReferrer, peakHour, + }, + }); + } +} + +// --- 2. Post creation velocity --- + +export async function scanPostVelocity() { + const boards = await prisma.board.findMany({ select: { id: true } }); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + + for (const board of boards) { + const baseline = await getBaseline(board.id); + const expectedPerHour = baseline.avgPostsPerDay / 24; + const threshold = expectedPerHour * 3; + + const count = await prisma.post.count({ + where: { boardId: board.id, createdAt: { gt: oneHourAgo } }, + }); + + if (count <= threshold || count < 3) continue; + + const posts = await prisma.post.findMany({ + where: { boardId: board.id, createdAt: { gt: oneHourAgo } }, + select: { id: true, title: true, authorId: true }, + orderBy: { createdAt: "desc" }, + take: 20, + }); + + await createAnomalyEvent({ + type: "post_velocity", + severity: "medium", + targetType: "board", + targetId: board.id, + boardId: board.id, + metadata: { count, threshold, posts: posts.map((p) => ({ id: p.id, title: p.title, authorId: p.authorId })) }, + }); + } +} + +// --- 3. Temporal identity clustering --- + +export async function scanIdentityClusters() { + const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000); + + const recentUsers = await prisma.user.findMany({ + where: { createdAt: { gt: thirtyMinAgo } }, + select: { id: true, createdAt: true }, + orderBy: { createdAt: "asc" }, + }); + + if (recentUsers.length < 5) return; + + // find clusters created within 3-minute windows + const clusters: { ids: string[]; windowStart: Date }[] = []; + for (let i = 0; i < recentUsers.length; i++) { + const windowEnd = new Date(recentUsers[i].createdAt.getTime() + 3 * 60 * 1000); + const cluster = [recentUsers[i].id]; + for (let j = i + 1; j < recentUsers.length; j++) { + if (recentUsers[j].createdAt <= windowEnd) cluster.push(recentUsers[j].id); + else break; + } + if (cluster.length >= 5) { + clusters.push({ ids: cluster, windowStart: recentUsers[i].createdAt }); + } + } + + // deduplicate overlapping clusters - keep the largest + const seen = new Set(); + for (const cluster of clusters.sort((a, b) => b.ids.length - a.ids.length)) { + const key = cluster.ids.sort().join(","); + if (seen.has(key)) continue; + seen.add(key); + + // check if these identities performed similar actions + const votes = await prisma.vote.findMany({ + where: { voterId: { in: cluster.ids }, createdAt: { gt: thirtyMinAgo } }, + select: { postId: true, voterId: true }, + }); + + const posts = await prisma.post.findMany({ + where: { authorId: { in: cluster.ids }, createdAt: { gt: thirtyMinAgo } }, + select: { boardId: true, authorId: true }, + }); + + // check for overlapping vote targets + const votedPosts = new Map(); + for (const v of votes) { + const arr = votedPosts.get(v.postId) ?? []; + arr.push(v.voterId); + votedPosts.set(v.postId, arr); + } + const sharedVotes = [...votedPosts.entries()].filter(([, voters]) => voters.length >= 3); + + const boardIds = [...new Set(posts.map((p) => p.boardId))]; + + if (sharedVotes.length > 0 || boardIds.length > 0) { + // gather cluster identity stats for pattern matching + const clusterUsers = await prisma.user.findMany({ + where: { id: { in: cluster.ids } }, + select: { createdAt: true, actionDiversityScore: true, voteTimingStdDev: true }, + }); + + const cNow = Date.now(); + const cAges = clusterUsers.map((u) => cNow - u.createdAt.getTime()); + const cDiv = clusterUsers.map((u) => u.actionDiversityScore); + const cTiming = clusterUsers.map((u) => u.voteTimingStdDev).filter((t): t is number => t !== null); + + const clusterVotes = await prisma.vote.findMany({ + where: { voterId: { in: cluster.ids }, createdAt: { gt: thirtyMinAgo } }, + select: { referrer: true, createdAt: true }, + take: 200, + }); + + const cRefCounts = new Map(); + for (const v of clusterVotes) { + if (v.referrer) cRefCounts.set(v.referrer, (cRefCounts.get(v.referrer) ?? 0) + 1); + } + let cCommonReferrer: string | undefined; + for (const [ref, cnt] of cRefCounts) { + if (clusterVotes.length > 0 && cnt / clusterVotes.length > 0.5) { cCommonReferrer = ref; break; } + } + + const cHourBuckets = new Array(24).fill(0); + for (const v of clusterVotes) cHourBuckets[v.createdAt.getUTCHours()]++; + const cPeakHour = cHourBuckets.indexOf(Math.max(...cHourBuckets)); + + await createAnomalyEvent({ + type: "identity_cluster", + severity: cluster.ids.length >= 10 ? "high" : "medium", + targetType: "user_group", + targetId: cluster.ids[0], + boardId: boardIds[0] ?? undefined, + metadata: { + identities: cluster.ids, + windowStart: cluster.windowStart, + sharedVotePosts: sharedVotes.map(([pid]) => pid), + boardIds, + avgIdentityAge: cAges.length > 0 ? cAges.reduce((a, b) => a + b, 0) / cAges.length : undefined, + avgDiversityScore: cDiv.length > 0 ? cDiv.reduce((a, b) => a + b, 0) / cDiv.length : undefined, + avgVoteTimingStdDev: cTiming.length > 0 ? cTiming.reduce((a, b) => a + b, 0) / cTiming.length : undefined, + commonReferrer: cCommonReferrer, + peakHour: cPeakHour, + }, + }); + } + } +} + +// --- 4. Cross-board cohort detection --- + +export async function scanCohortArrivals() { + const thirtyMinAgo = new Date(Date.now() - 30 * 60 * 1000); + + const boards = await prisma.board.findMany({ select: { id: true } }); + + for (const board of boards) { + // find users who voted for the first time on this board in the last 30 min + const recentVotes = await prisma.vote.findMany({ + where: { post: { boardId: board.id }, createdAt: { gt: thirtyMinAgo } }, + select: { voterId: true, createdAt: true }, + }); + + const recentPosts = await prisma.post.findMany({ + where: { boardId: board.id, createdAt: { gt: thirtyMinAgo } }, + select: { authorId: true, createdAt: true }, + }); + + // for each user, check if this is their first interaction on this board + const candidateIds = [...new Set([ + ...recentVotes.map((v) => v.voterId), + ...recentPosts.map((p) => p.authorId), + ])]; + + if (candidateIds.length < 5) continue; + + const newArrivals: string[] = []; + for (const uid of candidateIds) { + const priorVote = await prisma.vote.findFirst({ + where: { voterId: uid, post: { boardId: board.id }, createdAt: { lt: thirtyMinAgo } }, + select: { id: true }, + }); + if (priorVote) continue; + const priorPost = await prisma.post.findFirst({ + where: { authorId: uid, boardId: board.id, createdAt: { lt: thirtyMinAgo } }, + select: { id: true }, + }); + if (!priorPost) newArrivals.push(uid); + } + + if (newArrivals.length < 5) continue; + + // check if they did the same action type + const voterSet = new Set(recentVotes.map((v) => v.voterId)); + const posterSet = new Set(recentPosts.map((p) => p.authorId)); + const votingArrivals = newArrivals.filter((id) => voterSet.has(id)); + const postingArrivals = newArrivals.filter((id) => posterSet.has(id)); + + const actionType = votingArrivals.length >= postingArrivals.length ? "vote" : "post"; + const cohort = actionType === "vote" ? votingArrivals : postingArrivals; + + if (cohort.length < 5) continue; + + await createAnomalyEvent({ + type: "cohort_arrival", + severity: cohort.length >= 15 ? "high" : "medium", + targetType: "board", + targetId: board.id, + boardId: board.id, + metadata: { identities: cohort, actionType, count: cohort.length }, + }); + } +} + +// --- 5. Voter overlap coefficient --- + +export async function scanVoterOverlap() { + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const boards = await prisma.board.findMany({ select: { id: true } }); + + for (const board of boards) { + const posts = await prisma.post.findMany({ + where: { boardId: board.id, createdAt: { gt: oneDayAgo }, voteCount: { gt: 5 } }, + select: { id: true }, + }); + + if (posts.length < 2) continue; + + // batch fetch all votes for these posts + const votes = await prisma.vote.findMany({ + where: { postId: { in: posts.map((p) => p.id) } }, + select: { postId: true, voterId: true }, + }); + + const votersByPost = new Map>(); + for (const v of votes) { + const s = votersByPost.get(v.postId) ?? new Set(); + s.add(v.voterId); + votersByPost.set(v.postId, s); + } + + const postIds = posts.map((p) => p.id); + for (let i = 0; i < postIds.length; i++) { + for (let j = i + 1; j < postIds.length; j++) { + const a = votersByPost.get(postIds[i]); + const b = votersByPost.get(postIds[j]); + if (!a || !b) continue; + const smaller = a.size <= b.size ? a : b; + const larger = a.size <= b.size ? b : a; + let overlap = 0; + for (const id of smaller) { + if (larger.has(id)) overlap++; + } + const coeff = overlap / Math.min(a.size, b.size); + if (coeff > 0.8 && overlap >= 4) { + await createAnomalyEvent({ + type: "voter_overlap", + severity: "high", + targetType: "post_pair", + targetId: `${postIds[i]}:${postIds[j]}`, + boardId: board.id, + metadata: { postA: postIds[i], postB: postIds[j], overlap, coefficient: coeff }, + }); + } + } + } + } +} + +// --- 7. Vote distribution curve --- + +export async function scanVoteDistribution() { + const boards = await prisma.board.findMany({ select: { id: true } }); + + for (const board of boards) { + const posts = await prisma.post.findMany({ + where: { boardId: board.id, voteCount: { gt: 0 } }, + select: { id: true, voteCount: true }, + }); + + if (posts.length < 5) continue; + + const counts = posts.map((p) => p.voteCount); + const mean = counts.reduce((a, b) => a + b, 0) / counts.length; + const variance = counts.reduce((a, b) => a + (b - mean) ** 2, 0) / counts.length; + const stddev = Math.sqrt(variance); + if (stddev === 0) continue; + + const outlierThreshold = mean + 3 * stddev; + const outliers = posts.filter((p) => p.voteCount > outlierThreshold); + + for (const p of outliers) { + await createAnomalyEvent({ + type: "vote_distribution_outlier", + severity: "medium", + targetType: "post", + targetId: p.id, + boardId: board.id, + metadata: { voteCount: p.voteCount, mean, stddev, threshold: outlierThreshold }, + }); + } + } +} + +// --- 8. Social proof inflection --- + +export async function scanInflectionPoints() { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + + // get posts with recent snapshots + const snapshots = await prisma.postVoteSnapshot.findMany({ + where: { snapshotAt: { gt: oneHourAgo } }, + select: { postId: true, voteCount: true, snapshotAt: true }, + orderBy: { snapshotAt: "asc" }, + }); + + const byPost = new Map(); + for (const s of snapshots) { + const arr = byPost.get(s.postId) ?? []; + arr.push({ voteCount: s.voteCount, snapshotAt: s.snapshotAt }); + byPost.set(s.postId, arr); + } + + for (const [postId, snaps] of byPost) { + if (snaps.length < 2) continue; + + const post = await prisma.post.findUnique({ + where: { id: postId }, + select: { boardId: true }, + }); + if (!post) continue; + + const baseline = await getBaseline(post.boardId); + const expectedPer5Min = baseline.avgVotesPerHour / 12; + const jumpThreshold = Math.max(expectedPer5Min * 5, 3); + + for (let i = 1; i < snaps.length; i++) { + const diff = snaps[i].voteCount - snaps[i - 1].voteCount; + if (diff > jumpThreshold) { + await createAnomalyEvent({ + type: "inflection_point", + severity: diff > jumpThreshold * 3 ? "high" : "medium", + targetType: "post", + targetId: postId, + boardId: post.boardId, + metadata: { + jump: diff, + threshold: jumpThreshold, + from: snaps[i - 1].voteCount, + to: snaps[i].voteCount, + inflectionAt: snaps[i].snapshotAt, + }, + }); + break; // one event per post + } + } + } +} + +// --- 9. Referrer concentration --- + +export async function scanReferrerConcentration() { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + + // get posts that received votes in the last hour + const recentVotes = await prisma.vote.findMany({ + where: { createdAt: { gt: oneHourAgo }, referrer: { not: null } }, + select: { postId: true, referrer: true }, + }); + + if (recentVotes.length === 0) return; + + const byPost = new Map(); + for (const v of recentVotes) { + if (!v.referrer) continue; + const arr = byPost.get(v.postId) ?? []; + arr.push(v.referrer); + byPost.set(v.postId, arr); + } + + for (const [postId, referrers] of byPost) { + if (referrers.length < 5) continue; + + // count referrer frequency + const counts = new Map(); + for (const r of referrers) { + counts.set(r, (counts.get(r) ?? 0) + 1); + } + + for (const [ref, count] of counts) { + if (count / referrers.length > 0.8) { + const post = await prisma.post.findUnique({ + where: { id: postId }, + select: { boardId: true }, + }); + + await createAnomalyEvent({ + type: "referrer_concentration", + severity: "high", + targetType: "post", + targetId: postId, + boardId: post?.boardId, + metadata: { referrer: ref, fraction: count / referrers.length, totalVotes: referrers.length }, + }); + } + } + } +} + +// --- 10. Post similarity / n-gram analysis --- + +function extractNgrams(text: string, n: number): string[] { + const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); + const ngrams: string[] = []; + for (let i = 0; i <= words.length - n; i++) { + ngrams.push(words.slice(i, i + n).join(" ")); + } + return ngrams; +} + +export async function scanPostSimilarity() { + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + + const boards = await prisma.board.findMany({ select: { id: true } }); + + for (const board of boards) { + const posts = await prisma.post.findMany({ + where: { boardId: board.id, createdAt: { gt: oneHourAgo } }, + select: { id: true, title: true, authorId: true }, + }); + + if (posts.length < 3) continue; + + // extract 3-word phrases from titles + const phraseMap = new Map(); + for (const p of posts) { + const ngrams = extractNgrams(p.title, 3); + for (const ng of ngrams) { + const arr = phraseMap.get(ng) ?? []; + arr.push({ postId: p.id, authorId: p.authorId }); + phraseMap.set(ng, arr); + } + } + + // find phrases appearing in 3+ posts from different authors + for (const [phrase, entries] of phraseMap) { + const uniqueAuthors = new Set(entries.map((e) => e.authorId)); + if (entries.length >= 3 && uniqueAuthors.size >= 3) { + await createAnomalyEvent({ + type: "post_similarity", + severity: "medium", + targetType: "board", + targetId: board.id, + boardId: board.id, + metadata: { + phrase, + postIds: entries.map((e) => e.postId), + authorIds: [...uniqueAuthors], + }, + }); + } + } + } +} + +// --- 11. Outbound link clustering --- + +function extractUrls(json: unknown): string[] { + const urls: string[] = []; + const urlRe = /https?:\/\/[^\s"'<>)}\]]+/g; + const walk = (val: unknown) => { + if (typeof val === "string") { + const matches = val.match(urlRe); + if (matches) urls.push(...matches); + } else if (Array.isArray(val)) { + for (const item of val) walk(item); + } else if (val && typeof val === "object") { + for (const v of Object.values(val as Record)) walk(v); + } + }; + walk(json); + return urls; +} + +export async function scanOutboundLinks() { + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + const boards = await prisma.board.findMany({ select: { id: true } }); + + for (const board of boards) { + const posts = await prisma.post.findMany({ + where: { boardId: board.id, createdAt: { gt: oneDayAgo } }, + select: { id: true, description: true, authorId: true }, + }); + + if (posts.length < 3) continue; + + const urlMap = new Map(); + for (const p of posts) { + const urls = extractUrls(p.description); + for (const url of urls) { + const arr = urlMap.get(url) ?? []; + arr.push({ postId: p.id, authorId: p.authorId }); + urlMap.set(url, arr); + } + } + + for (const [url, entries] of urlMap) { + const uniqueAuthors = new Set(entries.map((e) => e.authorId)); + if (entries.length >= 3 && uniqueAuthors.size >= 3) { + await createAnomalyEvent({ + type: "outbound_link_cluster", + severity: "medium", + targetType: "board", + targetId: board.id, + boardId: board.id, + metadata: { + url, + postIds: entries.map((e) => e.postId), + authorIds: [...uniqueAuthors], + }, + }); + } + } + } +} + +// --- 12. Reaction velocity (called inline, not from cron) --- + +export async function checkReactionVelocity(commentId: string) { + const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000); + const count = await prisma.reaction.count({ + where: { commentId, createdAt: { gt: fiveMinAgo } }, + }); + + if (count <= 20) return; + + const comment = await prisma.comment.findUnique({ + where: { id: commentId }, + select: { post: { select: { boardId: true } } }, + }); + + await createAnomalyEvent({ + type: "reaction_velocity", + severity: "medium", + targetType: "comment", + targetId: commentId, + boardId: comment?.post?.boardId, + metadata: { count, window: "5m" }, + }); +} + +// --- 13. Comment-to-vote ratio --- + +export async function scanCommentVoteRatio() { + const posts = await prisma.post.findMany({ + where: { voteCount: { gt: 20 } }, + select: { id: true, voteCount: true, boardId: true, _count: { select: { comments: true } } }, + }); + + for (const p of posts) { + if (p._count.comments > 0) continue; + + await createAnomalyEvent({ + type: "comment_vote_ratio", + severity: "low", + targetType: "post", + targetId: p.id, + boardId: p.boardId, + metadata: { voteCount: p.voteCount, commentCount: 0 }, + }); + } +} + +// --- 14. Off-hours activity detection --- + +export async function scanOffHoursActivity() { + const boards = await prisma.board.findMany({ select: { id: true } }); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + const currentHour = new Date().getUTCHours(); + + for (const board of boards) { + const baseline = await getBaseline(board.id); + const hourDiff = Math.abs(currentHour - baseline.peakHourOfDay); + const dist = Math.min(hourDiff, 24 - hourDiff); + if (dist <= 6) continue; + + const [votes, posts] = await Promise.all([ + prisma.vote.count({ where: { post: { boardId: board.id }, createdAt: { gt: oneHourAgo } } }), + prisma.post.count({ where: { boardId: board.id, createdAt: { gt: oneHourAgo } } }), + ]); + + const activity = votes + posts; + const expectedPerHour = baseline.avgVotesPerHour + baseline.avgPostsPerDay / 24; + + if (activity > expectedPerHour * 3 && activity >= 5) { + await createAnomalyEvent({ + type: "off_hours_activity", + severity: "medium", + targetType: "board", + targetId: board.id, + boardId: board.id, + metadata: { currentHour, peakHour: baseline.peakHourOfDay, hourDistance: dist, activity, expected: expectedPerHour }, + }); + } + } +} + +// --- 15. Network graph building --- + +export async function buildVoterGraph(boardId: string) { + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + const votes = await prisma.vote.findMany({ + where: { post: { boardId }, createdAt: { gt: sevenDaysAgo } }, + select: { voterId: true, postId: true }, + }); + + // build voter -> posts map + const voterPosts = new Map>(); + for (const v of votes) { + const s = voterPosts.get(v.voterId) ?? new Set(); + s.add(v.postId); + voterPosts.set(v.voterId, s); + } + + // build adjacency: identities that voted on the same posts + const voterIds = [...voterPosts.keys()]; + const clusters: string[][] = []; + + for (let i = 0; i < voterIds.length; i++) { + const cluster = [voterIds[i]]; + const postsA = voterPosts.get(voterIds[i])!; + + for (let j = i + 1; j < voterIds.length; j++) { + const postsB = voterPosts.get(voterIds[j])!; + let common = 0; + for (const p of postsA) { + if (postsB.has(p)) common++; + } + if (common > 3) cluster.push(voterIds[j]); + } + + if (cluster.length >= 3) clusters.push(cluster); + } + + if (clusters.length === 0) return; + + // keep only the largest cluster per board + const largest = clusters.sort((a, b) => b.length - a.length)[0]; + + await createAnomalyEvent({ + type: "voter_network_cluster", + severity: largest.length >= 10 ? "high" : "medium", + targetType: "board", + targetId: boardId, + boardId, + metadata: { clusterSize: largest.length, identities: largest.slice(0, 50), totalClusters: clusters.length }, + }); +} + +// --- 16. Seasonal baseline comparison --- + +export async function compareSeasonalBaseline() { + const boards = await prisma.board.findMany({ select: { id: true } }); + const now = Date.now(); + + for (const board of boards) { + // get this week's totals + const thisWeekStart = new Date(now - 7 * 24 * 60 * 60 * 1000); + const thisWeekVotes = await prisma.vote.count({ + where: { post: { boardId: board.id }, createdAt: { gt: thisWeekStart } }, + }); + + // get past 4 weeks (excluding this week) + const weekTotals: number[] = []; + for (let w = 1; w <= 4; w++) { + const start = new Date(now - (w + 1) * 7 * 24 * 60 * 60 * 1000); + const end = new Date(now - w * 7 * 24 * 60 * 60 * 1000); + const count = await prisma.vote.count({ + where: { post: { boardId: board.id }, createdAt: { gt: start, lt: end } }, + }); + weekTotals.push(count); + } + + if (weekTotals.length < 4) continue; + + const mean = weekTotals.reduce((a, b) => a + b, 0) / weekTotals.length; + const variance = weekTotals.reduce((a, b) => a + (b - mean) ** 2, 0) / weekTotals.length; + const stddev = Math.sqrt(variance); + if (stddev === 0) continue; + + const deviation = (thisWeekVotes - mean) / stddev; + + if (Math.abs(deviation) > 2) { + await createAnomalyEvent({ + type: "seasonal_deviation", + severity: Math.abs(deviation) > 4 ? "high" : "medium", + targetType: "board", + targetId: board.id, + boardId: board.id, + metadata: { + thisWeek: thisWeekVotes, + rollingAvg: mean, + stddev, + deviation, + direction: deviation > 0 ? "spike" : "drop", + }, + }); + } + } +} diff --git a/packages/api/src/services/identity-signals.ts b/packages/api/src/services/identity-signals.ts new file mode 100644 index 0000000..3cc2419 --- /dev/null +++ b/packages/api/src/services/identity-signals.ts @@ -0,0 +1,98 @@ +import prisma from "../lib/prisma.js"; + +export async function recordAction(userId: string, actionType: string, metadata?: { boardId?: string }) { + const updates: Record = {}; + + 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"; +} diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 91dd6d6..7da5db7 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/web/src/components/AdminSidebar.tsx b/packages/web/src/components/AdminSidebar.tsx index 86501eb..0035ee0 100644 --- a/packages/web/src/components/AdminSidebar.tsx +++ b/packages/web/src/components/AdminSidebar.tsx @@ -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 }, diff --git a/packages/web/src/components/MarkdownEditor.tsx b/packages/web/src/components/MarkdownEditor.tsx index c99c82c..d567240 100644 --- a/packages/web/src/components/MarkdownEditor.tsx +++ b/packages/web/src/components/MarkdownEditor.tsx @@ -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(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'} diff --git a/packages/web/src/components/PostCard.tsx b/packages/web/src/components/PostCard.tsx index 6204e33..2f39372 100644 --- a/packages/web/src/components/PostCard.tsx +++ b/packages/web/src/components/PostCard.tsx @@ -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', }} /> - - {post.voteCount} - + {post.votingInProgress ? ( + ... + ) : ( + + {post.voteCount} + + )} {budgetDepleted && !post.voted && ( 0 left @@ -159,7 +164,11 @@ export default function PostCard({ aria-label="Vote" > - {post.voteCount} + {post.votingInProgress ? ( + ... + ) : ( + {post.voteCount} + )}
diff --git a/packages/web/src/components/PostForm.tsx b/packages/web/src/components/PostForm.tsx index fd106db..3b2736b 100644 --- a/packages/web/src/components/PostForm.tsx +++ b/packages/web/src/components/PostForm.tsx @@ -54,6 +54,9 @@ export default function PostForm({ boardSlug, boardId, onSubmit, onCancel }: Pro const [fieldErrors, setFieldErrors] = useState({}) const [categories, setCategories] = useState<{ id: string; name: string; slug: string }[]>([]) const [similar, setSimilar] = useState([]) + const [honeypot, setHoneypot] = useState('') + const [pageLoadTime] = useState(Date.now()) + const [wasPasted, setWasPasted] = useState(false) // templates const [templates, setTemplates] = useState([]) @@ -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)' }} > + setHoneypot(e.target.value)} style={{ position: 'absolute', left: -9999, top: -9999 }} tabIndex={-1} autoComplete="off" aria-hidden="true" />

setTitle(e.target.value)} + onPaste={() => setWasPasted(true)} maxLength={200} aria-required="true" aria-invalid={!!fieldErrors.title} diff --git a/packages/web/src/lib/altcha.ts b/packages/web/src/lib/altcha.ts index 83b5f74..433d202 100644 --- a/packages/web/src/lib/altcha.ts +++ b/packages/web/src/lib/altcha.ts @@ -14,8 +14,14 @@ async function hashHex(algorithm: string, data: string): Promise { return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('') } -export async function solveAltcha(difficulty: 'normal' | 'light' = 'normal'): Promise { - const ch = await api.get(`/altcha/challenge?difficulty=${difficulty}`) +export async function solveAltcha( + difficulty: 'normal' | 'light' = 'normal', + opts?: { boardId?: string; postId?: string }, +): Promise { + 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(`/altcha/challenge?${params.toString()}`) for (let n = 0; n <= ch.maxnumber; n++) { const hash = await hashHex(ch.algorithm, ch.salt + n) diff --git a/packages/web/src/pages/BoardFeed.tsx b/packages/web/src/pages/BoardFeed.tsx index f801b4e..098aab9 100644 --- a/packages/web/src/pages/BoardFeed.tsx +++ b/packages/web/src/pages/BoardFeed.tsx @@ -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() } diff --git a/packages/web/src/pages/PostDetail.tsx b/packages/web/src/pages/PostDetail.tsx index f5c26e5..33cb070 100644 --- a/packages/web/src/pages/PostDetail.tsx +++ b/packages/web/src/pages/PostDetail.tsx @@ -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(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 = { body: comment } + const payload: Record = { 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' : ''} /> - - {post.voteCount} - + {post.votingInProgress ? ( + Tallying + ) : ( + + {post.voteCount} + + )}
@@ -769,6 +789,23 @@ export default function PostDetail() { : <>
@@ -845,6 +882,22 @@ export default function PostDetail() { Voting has been locked on this post

)} + {post.frozenAt && ( +
+ + This post is frozen - vote counts are locked pending security review +
+ )} {/* 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} > + setHoneypot(e.target.value)} style={{ position: 'absolute', left: -9999, top: -9999 }} tabIndex={-1} autoComplete="off" aria-hidden="true" />

+ createdAt: string +} + +interface AuditEntry { + id: string + adminId: string | null + action: string + metadata: Record + 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 = { + 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 ( + + {severity} + + ) +} + +function metaSummary(meta: Record): 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([]) + 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([]) + const [expandedId, setExpandedId] = useState(null) + const [cleanupActions, setCleanupActions] = useState([]) + 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 ( +
+ {/* Filters */} +
+
+ { setSeverity(v); setPage(1) }} aria-label="Filter by severity" /> +
+
+ { setStatus(v); setPage(1) }} aria-label="Filter by status" /> +
+
+ { setBoardId(v); setPage(1) }} aria-label="Filter by board" /> +
+
+ + {loading ? ( +
+
+ {[0, 1, 2].map((i) => ( +
+
+
+
+ ))} +
+ ) : alerts.length === 0 ? ( +
+
+ +
+

No alerts

+

+ {status === 'pending' ? 'No pending alerts right now' : 'No matching alerts found'} +

+
+ ) : ( + <> +

+ {total} alert{total === 1 ? '' : 's'} +

+
+ {alerts.map((alert) => { + const expanded = expandedId === alert.id + const summary = metaSummary(alert.metadata) + return ( +
+
+
+
+
+ + + {alert.label} + +
+ {alert.targetId && ( +

+ Target: {alert.targetType} {alert.targetId.slice(0, 8)}... +

+ )} + {summary && ( +

{summary}

+ )} +

+ {timeAgo(alert.createdAt)} +

+
+ + {alert.status === 'pending' && ( +
+ + + + {admin.isSuperAdmin && ( + + )} +
+ )} + {alert.status !== 'pending' && ( + + {alert.status} + + )} +
+
+ + {expanded && ( +
+

+ Cleanup actions +

+
+ {[ + { 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) => ( + + ))} +
+ {summary && ( +

+ Estimated impact: {summary} +

+ )} +
+ + +
+
+ )} +
+ ) + })} +
+ + {/* Pagination */} + {pages > 1 && ( +
+ + + Page {page} of {pages} + + +
+ )} + + )} + + {/* Board security settings */} + {boards.length > 0 && ( +
+

+ Board security settings +

+
+ {boards.map((board) => ( + + ))} +
+
+ )} +
+ ) +} + +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) => { + 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 ( +
+
+ + {board.name} + + {quarantined && ( + + Quarantined + + )} +
+
+
+ + { setSensitivity(v); save({ sensitivityLevel: v }) }} + aria-label="Sensitivity level" + /> +
+
+ + setThreshold(e.target.value === '' ? '' : Number(e.target.value))} + onBlur={() => { + const val = threshold === '' ? null : Number(threshold) + save({ velocityThreshold: val }) + }} + style={{ width: 100 }} + /> +
+ + + {saving && Saving...} +
+
+ ) +} + +function AuditLogTab() { + const admin = useAdmin() + const confirm = useConfirm() + const toast = useToast() + const [entries, setEntries] = useState([]) + 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 ( +
+
+ {[0, 1, 2].map((i) => ( +
+
+
+
+ ))} +
+ ) + } + + if (entries.length === 0) { + return

No cleanup actions recorded yet

+ } + + return ( +
+ {entries.map((entry) => { + const meta = entry.metadata + const results = meta.results as Record> | undefined + return ( +
+
+
+ + {entry.action.replace(/_/g, ' ')} + + {entry.undone && ( + + undone + + )} +
+
+ {timeAgo(entry.createdAt as unknown as string)} + {results && Object.entries(results).map(([key, val]) => ( + {key.replace(/_/g, ' ')}: {Object.values(val).join(', ')} + ))} +
+
+ {admin.isSuperAdmin && !entry.undone && ( + + )} +
+ ) + })} +
+ ) +} + +function WebhooksTab() { + const confirm = useConfirm() + const toast = useToast() + const [webhooks, setWebhooks] = useState([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [url, setUrl] = useState('') + const [events, setEvents] = useState(['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 ( +
+
+ {[0, 1].map((i) => ( +
+
+
+
+ ))} +
+ ) + } + + return ( +
+
+

+ Receive notifications when security events occur +

+ +
+ + {showForm && ( +
+
+ + setUrl(e.target.value)} + type="url" + /> +
+
+ +
+ {WEBHOOK_EVENTS.map((ev) => { + const active = events.includes(ev.value) + return ( + + ) + })} +
+
+ {error &&

{error}

} +
+ + +
+
+ )} + + {webhooks.length === 0 && !showForm ? ( +

No security webhooks configured

+ ) : ( +
+ {webhooks.map((wh) => ( +
+
+
+ {wh.url} +
+
+ {wh.events.map((ev) => ( + + {ev.replace(/_/g, ' ')} + + ))} +
+
+
+ + +
+
+ ))} +
+ )} +
+ ) +} + +// main page + +type Tab = 'alerts' | 'audit' | 'webhooks' + +export default function AdminSecurity() { + useDocumentTitle('Security') + const admin = useAdmin() + const [tab, setTab] = useState('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 ( +
+
+

+ Security +

+ Back to dashboard +
+ + {/* Tab nav */} +
+ {visibleTabs.map((t) => ( + + ))} +
+ + {/* Tab content */} +
+ {tab === 'alerts' && } + {tab === 'audit' && } + {tab === 'webhooks' && } +
+
+ ) +}