diff --git a/convex/comments.handlers.ts b/convex/comments.handlers.ts index 6f02aff5b..6f0f06520 100644 --- a/convex/comments.handlers.ts +++ b/convex/comments.handlers.ts @@ -1,8 +1,31 @@ -import type { Id } from './_generated/dataModel' -import type { MutationCtx } from './_generated/server' -import { assertModerator, requireUser } from './lib/access' +import type { Doc, Id } from './_generated/dataModel' +import type { MutationCtx, QueryCtx } from './_generated/server' +import { assertAdmin, assertModerator, requireUser } from './lib/access' import { insertStatEvent } from './skillStatEvents' +const MAX_ACTIVE_REPORTS_PER_USER = 20 +const AUTO_HIDE_REPORT_THRESHOLD = 3 +const MAX_LIST_BULK_LIMIT = 200 +const MAX_LIST_TAKE = 1000 +const MAX_REPORT_REASON_SAMPLE = 5 + +type CommentStatus = 'active' | 'hidden' | 'removed' + +function clampInt(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, Math.floor(value))) +} + +function getCommentStatus(comment: Pick, 'moderationStatus' | 'softDeletedAt'>): CommentStatus { + if (comment.moderationStatus) return comment.moderationStatus + return comment.softDeletedAt ? 'hidden' : 'active' +} + +export function isCommentVisible( + comment: Pick, 'moderationStatus' | 'softDeletedAt'>, +) { + return !comment.softDeletedAt && getCommentStatus(comment) === 'active' +} + export async function addHandler(ctx: MutationCtx, args: { skillId: Id<'skills'>; body: string }) { const { userId } = await requireUser(ctx) const body = args.body.trim() @@ -18,6 +41,13 @@ export async function addHandler(ctx: MutationCtx, args: { skillId: Id<'skills'> createdAt: Date.now(), softDeletedAt: undefined, deletedBy: undefined, + moderationStatus: 'active', + moderationReason: undefined, + moderationNotes: undefined, + reportCount: 0, + lastReportedAt: undefined, + hiddenAt: undefined, + lastReviewedAt: undefined, }) await insertStatEvent(ctx, { skillId: skill._id, kind: 'comment' }) @@ -27,16 +57,21 @@ export async function removeHandler(ctx: MutationCtx, args: { commentId: Id<'com const { user } = await requireUser(ctx) const comment = await ctx.db.get(args.commentId) if (!comment) throw new Error('Comment not found') - if (comment.softDeletedAt) return + if (!isCommentVisible(comment)) return const isOwner = comment.userId === user._id if (!isOwner) { assertModerator(user) } + const now = Date.now() await ctx.db.patch(comment._id, { - softDeletedAt: Date.now(), + softDeletedAt: now, deletedBy: user._id, + moderationStatus: 'removed', + moderationReason: isOwner ? 'manual.user_delete' : 'manual.moderator_delete', + moderationNotes: undefined, + lastReviewedAt: now, }) await insertStatEvent(ctx, { skillId: comment.skillId, kind: 'uncomment' }) @@ -47,6 +82,237 @@ export async function removeHandler(ctx: MutationCtx, args: { commentId: Id<'com targetType: 'comment', targetId: comment._id, metadata: { skillId: comment.skillId }, + createdAt: now, + }) +} + +async function countActiveReportsForUser(ctx: MutationCtx, userId: Id<'users'>) { + const reports = await ctx.db + .query('commentReports') + .withIndex('by_user', (q) => q.eq('userId', userId)) + .collect() + + let count = 0 + for (const report of reports) { + const comment = await ctx.db.get(report.commentId) + if (!comment) continue + if (!isCommentVisible(comment)) continue + const owner = await ctx.db.get(comment.userId) + if (!owner || owner.deletedAt || owner.deactivatedAt) continue + count += 1 + if (count >= MAX_ACTIVE_REPORTS_PER_USER) break + } + + return count +} + +export async function reportHandler( + ctx: MutationCtx, + args: { commentId: Id<'comments'>; reason: string }, +) { + const { userId } = await requireUser(ctx) + const comment = await ctx.db.get(args.commentId) + if (!comment || getCommentStatus(comment) === 'removed') { + throw new Error('Comment not found') + } + if (!isCommentVisible(comment)) { + throw new Error('Comment is already hidden.') + } + + const reason = args.reason.trim() + if (!reason) { + throw new Error('Report reason required.') + } + + const existing = await ctx.db + .query('commentReports') + .withIndex('by_comment_user', (q) => q.eq('commentId', args.commentId).eq('userId', userId)) + .unique() + if (existing) return { ok: true as const, reported: false, alreadyReported: true } + + const activeReports = await countActiveReportsForUser(ctx, userId) + if (activeReports >= MAX_ACTIVE_REPORTS_PER_USER) { + throw new Error('Report limit reached. Please wait for moderation before reporting more.') + } + + const now = Date.now() + await ctx.db.insert('commentReports', { + commentId: args.commentId, + skillId: comment.skillId, + userId, + reason: reason.slice(0, 500), + createdAt: now, + }) + + const nextReportCount = (comment.reportCount ?? 0) + 1 + const shouldAutoHide = nextReportCount > AUTO_HIDE_REPORT_THRESHOLD && isCommentVisible(comment) + const updates: Partial> = { + reportCount: nextReportCount, + lastReportedAt: now, + } + if (shouldAutoHide) { + Object.assign(updates, { + softDeletedAt: now, + moderationStatus: 'hidden', + moderationReason: 'auto.reports', + moderationNotes: 'Auto-hidden after 4 unique reports.', + hiddenAt: now, + lastReviewedAt: now, + deletedBy: undefined, + }) + } + + await ctx.db.patch(comment._id, updates) + + if (shouldAutoHide) { + await insertStatEvent(ctx, { skillId: comment.skillId, kind: 'uncomment' }) + await ctx.db.insert('auditLogs', { + actorUserId: userId, + action: 'comment.auto_hide', + targetType: 'comment', + targetId: comment._id, + metadata: { skillId: comment.skillId, reportCount: nextReportCount }, + createdAt: now, + }) + } + + return { ok: true as const, reported: true, alreadyReported: false } +} + +export async function setSoftDeletedHandler( + ctx: MutationCtx, + args: { commentId: Id<'comments'>; deleted: boolean }, +) { + const { user } = await requireUser(ctx) + assertModerator(user) + const comment = await ctx.db.get(args.commentId) + if (!comment) throw new Error('Comment not found') + + const beforeVisible = isCommentVisible(comment) + const now = Date.now() + const patch: Partial> = args.deleted + ? { + softDeletedAt: now, + deletedBy: user._id, + moderationStatus: 'hidden', + moderationReason: 'manual.moderation', + moderationNotes: 'Hidden by moderator.', + hiddenAt: now, + lastReviewedAt: now, + } + : { + softDeletedAt: undefined, + deletedBy: undefined, + moderationStatus: 'active', + moderationReason: undefined, + moderationNotes: undefined, + hiddenAt: undefined, + lastReviewedAt: now, + } + const afterVisible = !args.deleted + await ctx.db.patch(comment._id, patch) + + if (beforeVisible && !afterVisible) { + await insertStatEvent(ctx, { skillId: comment.skillId, kind: 'uncomment' }) + } else if (!beforeVisible && afterVisible) { + await insertStatEvent(ctx, { skillId: comment.skillId, kind: 'comment' }) + } + + await ctx.db.insert('auditLogs', { + actorUserId: user._id, + action: args.deleted ? 'comment.hide' : 'comment.restore', + targetType: 'comment', + targetId: comment._id, + metadata: { skillId: comment.skillId }, + createdAt: now, + }) +} + +export async function hardDeleteHandler(ctx: MutationCtx, args: { commentId: Id<'comments'> }) { + const { user } = await requireUser(ctx) + assertAdmin(user) + const comment = await ctx.db.get(args.commentId) + if (!comment) return { deleted: false as const } + + const beforeVisible = isCommentVisible(comment) + const reports = await ctx.db + .query('commentReports') + .withIndex('by_comment', (q) => q.eq('commentId', comment._id)) + .collect() + for (const report of reports) { + await ctx.db.delete(report._id) + } + await ctx.db.delete(comment._id) + + if (beforeVisible) { + await insertStatEvent(ctx, { skillId: comment.skillId, kind: 'uncomment' }) + } + + await ctx.db.insert('auditLogs', { + actorUserId: user._id, + action: 'comment.hard_delete', + targetType: 'comment', + targetId: comment._id, + metadata: { skillId: comment.skillId, reportCount: reports.length }, createdAt: Date.now(), }) + + return { deleted: true as const } +} + +export async function listReportedCommentsHandler(ctx: QueryCtx, args: { limit?: number }) { + const { user } = await requireUser(ctx) + assertModerator(user) + + const limit = clampInt(args.limit ?? 25, 1, MAX_LIST_BULK_LIMIT) + const takeLimit = Math.min(limit * 5, MAX_LIST_TAKE) + const entries = await ctx.db.query('comments').order('desc').take(takeLimit) + const reported = entries + .filter((comment) => (comment.reportCount ?? 0) > 0) + .sort((a, b) => (b.lastReportedAt ?? 0) - (a.lastReportedAt ?? 0)) + .slice(0, limit) + + const reporterCache = new Map, Promise | null>>() + const getReporter = (reporterId: Id<'users'>) => { + const cached = reporterCache.get(reporterId) + if (cached) return cached + const reporterPromise = ctx.db.get(reporterId) + reporterCache.set(reporterId, reporterPromise) + return reporterPromise + } + + return Promise.all( + reported.map(async (comment) => { + const [skill, commenter] = await Promise.all([ + ctx.db.get(comment.skillId), + ctx.db.get(comment.userId), + ]) + const owner = skill ? await ctx.db.get(skill.ownerUserId) : null + const reports = await ctx.db + .query('commentReports') + .withIndex('by_comment_createdAt', (q) => q.eq('commentId', comment._id)) + .order('desc') + .take(MAX_REPORT_REASON_SAMPLE) + const reportEntries = await Promise.all( + reports.map(async (report) => { + const reporter = await getReporter(report.userId) + const reason = report.reason?.trim() + return { + reason: reason && reason.length > 0 ? reason : 'No reason provided.', + createdAt: report.createdAt, + reporterHandle: reporter?.handle ?? reporter?.name ?? null, + reporterId: report.userId, + } + }), + ) + + return { + comment, + skill, + owner, + commenter, + reports: reportEntries, + } + }), + ) } diff --git a/convex/comments.test.ts b/convex/comments.test.ts index 43d7c1ce6..b5c5c0dc6 100644 --- a/convex/comments.test.ts +++ b/convex/comments.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' vi.mock('./lib/access', () => ({ + assertAdmin: vi.fn(), assertModerator: vi.fn(), requireUser: vi.fn(), })) @@ -10,12 +11,19 @@ vi.mock('./skillStatEvents', () => ({ insertStatEvent: vi.fn(), })) -const { requireUser, assertModerator } = await import('./lib/access') +const { requireUser, assertAdmin, assertModerator } = await import('./lib/access') const { insertStatEvent } = await import('./skillStatEvents') -const { addHandler, removeHandler } = await import('./comments.handlers') +const { + addHandler, + hardDeleteHandler, + removeHandler, + reportHandler, + setSoftDeletedHandler, +} = await import('./comments.handlers') describe('comments mutations', () => { afterEach(() => { + vi.mocked(assertAdmin).mockReset() vi.mocked(assertModerator).mockReset() vi.mocked(requireUser).mockReset() vi.mocked(insertStatEvent).mockReset() @@ -68,6 +76,7 @@ describe('comments mutations', () => { expect(patch).toHaveBeenCalledTimes(1) const deletePatch = vi.mocked(patch).mock.calls[0]?.[1] as Record expect(deletePatch.updatedAt).toBeUndefined() + expect(deletePatch.moderationStatus).toBe('removed') expect(insertStatEvent).toHaveBeenCalledWith(ctx, { skillId: 'skills:1', kind: 'uncomment', @@ -124,4 +133,270 @@ describe('comments mutations', () => { expect(insert).not.toHaveBeenCalled() expect(insertStatEvent).not.toHaveBeenCalled() }) + + it('report auto-hides comment on the 4th unique report', async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: 'users:9', + user: { _id: 'users:9', role: 'user' }, + } as never) + + const comment = { + _id: 'comments:4', + skillId: 'skills:4', + userId: 'users:owner', + softDeletedAt: undefined, + moderationStatus: 'active', + reportCount: 3, + lastReportedAt: undefined, + } + + const query = vi.fn((table: string) => { + if (table !== 'commentReports') throw new Error(`unexpected table ${table}`) + return { + withIndex: (name: string) => { + if (name === 'by_comment_user') return { unique: async () => null } + if (name === 'by_user') return { collect: async () => [] } + throw new Error(`unexpected index ${name}`) + }, + } + }) + const get = vi.fn().mockResolvedValue(comment) + const insert = vi.fn() + const patch = vi.fn() + const ctx = { db: { get, insert, patch, query } } as never + + const result = await reportHandler(ctx, { + commentId: 'comments:4', + reason: 'contains malware loader', + } as never) + + expect(result).toEqual({ ok: true, reported: true, alreadyReported: false }) + expect(patch).toHaveBeenCalledWith( + 'comments:4', + expect.objectContaining({ + reportCount: 4, + moderationStatus: 'hidden', + moderationReason: 'auto.reports', + }), + ) + expect(insertStatEvent).toHaveBeenCalledWith(ctx, { + skillId: 'skills:4', + kind: 'uncomment', + }) + expect(insert).toHaveBeenCalledWith( + 'auditLogs', + expect.objectContaining({ + action: 'comment.auto_hide', + targetId: 'comments:4', + }), + ) + }) + + it('report is idempotent per reporter and comment', async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: 'users:9', + user: { _id: 'users:9', role: 'user' }, + } as never) + + const comment = { + _id: 'comments:4', + skillId: 'skills:4', + userId: 'users:owner', + softDeletedAt: undefined, + moderationStatus: 'active', + reportCount: 1, + } + + const query = vi.fn((table: string) => { + if (table !== 'commentReports') throw new Error(`unexpected table ${table}`) + return { + withIndex: (name: string) => { + if (name === 'by_comment_user') { + return { unique: async () => ({ _id: 'commentReports:1' }) } + } + throw new Error(`unexpected index ${name}`) + }, + } + }) + const get = vi.fn().mockResolvedValue(comment) + const insert = vi.fn() + const patch = vi.fn() + const ctx = { db: { get, insert, patch, query } } as never + + const result = await reportHandler(ctx, { + commentId: 'comments:4', + reason: 'malicious', + } as never) + + expect(result).toEqual({ ok: true, reported: false, alreadyReported: true }) + expect(insert).not.toHaveBeenCalled() + expect(patch).not.toHaveBeenCalled() + expect(insertStatEvent).not.toHaveBeenCalled() + }) + + it('report enforces reason requirement and active report cap', async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: 'users:9', + user: { _id: 'users:9', role: 'user' }, + } as never) + + const targetComment = { + _id: 'comments:target', + skillId: 'skills:9', + userId: 'users:owner', + softDeletedAt: undefined, + moderationStatus: 'active', + reportCount: 0, + } + + const activeReports = Array.from({ length: 20 }, (_, index) => ({ + _id: `commentReports:${index}`, + commentId: `comments:old-${index}`, + userId: 'users:9', + })) + + const query = vi.fn((table: string) => { + if (table !== 'commentReports') throw new Error(`unexpected table ${table}`) + return { + withIndex: (name: string) => { + if (name === 'by_comment_user') return { unique: async () => null } + if (name === 'by_user') return { collect: async () => activeReports } + throw new Error(`unexpected index ${name}`) + }, + } + }) + + const get = vi.fn(async (id: string) => { + if (id === 'comments:target') return targetComment + if (id.startsWith('comments:old-')) { + return { + _id: id, + userId: 'users:owner', + softDeletedAt: undefined, + moderationStatus: 'active', + } + } + if (id === 'users:owner') return { _id: 'users:owner' } + return null + }) + + const insert = vi.fn() + const patch = vi.fn() + const ctx = { db: { get, insert, patch, query } } as never + + await expect( + reportHandler(ctx, { commentId: 'comments:target', reason: ' ' } as never), + ).rejects.toThrow('Report reason required.') + + await expect( + reportHandler(ctx, { commentId: 'comments:target', reason: 'suspicious payload' } as never), + ).rejects.toThrow('Report limit reached. Please wait for moderation before reporting more.') + expect(insert).not.toHaveBeenCalled() + expect(patch).not.toHaveBeenCalled() + }) + + it('setSoftDeleted hides and restores comments while adjusting stats', async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: 'users:mod', + user: { _id: 'users:mod', role: 'moderator' }, + } as never) + + const activeComment = { + _id: 'comments:5', + skillId: 'skills:5', + userId: 'users:owner', + softDeletedAt: undefined, + moderationStatus: 'active', + } + const hiddenComment = { + _id: 'comments:6', + skillId: 'skills:5', + userId: 'users:owner', + softDeletedAt: 123, + moderationStatus: 'hidden', + } + + const get = vi.fn(async (id: string) => { + if (id === 'comments:5') return activeComment + if (id === 'comments:6') return hiddenComment + return null + }) + const insert = vi.fn() + const patch = vi.fn() + const ctx = { db: { get, insert, patch } } as never + + await setSoftDeletedHandler(ctx, { commentId: 'comments:5', deleted: true } as never) + await setSoftDeletedHandler(ctx, { commentId: 'comments:6', deleted: false } as never) + + expect(patch).toHaveBeenNthCalledWith( + 1, + 'comments:5', + expect.objectContaining({ + moderationStatus: 'hidden', + moderationReason: 'manual.moderation', + }), + ) + expect(patch).toHaveBeenNthCalledWith( + 2, + 'comments:6', + expect.objectContaining({ + moderationStatus: 'active', + softDeletedAt: undefined, + }), + ) + expect(insertStatEvent).toHaveBeenNthCalledWith(1, ctx, { skillId: 'skills:5', kind: 'uncomment' }) + expect(insertStatEvent).toHaveBeenNthCalledWith(2, ctx, { skillId: 'skills:5', kind: 'comment' }) + }) + + it('hardDelete removes comment reports and decrements visible comment stats', async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: 'users:admin', + user: { _id: 'users:admin', role: 'admin' }, + } as never) + + const comment = { + _id: 'comments:7', + skillId: 'skills:7', + userId: 'users:owner', + softDeletedAt: undefined, + moderationStatus: 'active', + } + + const query = vi.fn((table: string) => { + if (table !== 'commentReports') throw new Error(`unexpected table ${table}`) + return { + withIndex: (name: string) => { + if (name === 'by_comment') { + return { + collect: async () => [{ _id: 'commentReports:1' }, { _id: 'commentReports:2' }], + } + } + throw new Error(`unexpected index ${name}`) + }, + } + }) + + const get = vi.fn().mockResolvedValue(comment) + const insert = vi.fn() + const patch = vi.fn() + const del = vi.fn() + const ctx = { db: { get, insert, patch, delete: del, query } } as never + + const result = await hardDeleteHandler(ctx, { commentId: 'comments:7' } as never) + + expect(result).toEqual({ deleted: true }) + expect(del).toHaveBeenCalledWith('commentReports:1') + expect(del).toHaveBeenCalledWith('commentReports:2') + expect(del).toHaveBeenCalledWith('comments:7') + expect(insertStatEvent).toHaveBeenCalledWith(ctx, { + skillId: 'skills:7', + kind: 'uncomment', + }) + expect(insert).toHaveBeenCalledWith( + 'auditLogs', + expect.objectContaining({ + action: 'comment.hard_delete', + }), + ) + }) }) diff --git a/convex/comments.ts b/convex/comments.ts index 146c98933..26f84eb22 100644 --- a/convex/comments.ts +++ b/convex/comments.ts @@ -1,7 +1,15 @@ import { v } from 'convex/values' import type { Doc } from './_generated/dataModel' import { mutation, query } from './_generated/server' -import { addHandler, removeHandler } from './comments.handlers' +import { + addHandler, + hardDeleteHandler, + isCommentVisible, + listReportedCommentsHandler, + removeHandler, + reportHandler, + setSoftDeletedHandler, +} from './comments.handlers' import { type PublicUser, toPublicUser } from './lib/public' export const listBySkill = query({ @@ -14,7 +22,7 @@ export const listBySkill = query({ .order('desc') .take(limit) - const visible = comments.filter((comment) => !comment.softDeletedAt) + const visible = comments.filter((comment) => isCommentVisible(comment)) return Promise.all( visible.map( async (comment): Promise<{ comment: Doc<'comments'>; user: PublicUser | null }> => ({ @@ -35,3 +43,23 @@ export const remove = mutation({ args: { commentId: v.id('comments') }, handler: removeHandler, }) + +export const report = mutation({ + args: { commentId: v.id('comments'), reason: v.string() }, + handler: reportHandler, +}) + +export const listReportedComments = query({ + args: { limit: v.optional(v.number()) }, + handler: listReportedCommentsHandler, +}) + +export const setSoftDeleted = mutation({ + args: { commentId: v.id('comments'), deleted: v.boolean() }, + handler: setSoftDeletedHandler, +}) + +export const hardDelete = mutation({ + args: { commentId: v.id('comments') }, + handler: hardDeleteHandler, +}) diff --git a/convex/schema.ts b/convex/schema.ts index a8993da53..a7e8c4fa9 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -404,6 +404,15 @@ const comments = defineTable({ createdAt: v.number(), softDeletedAt: v.optional(v.number()), deletedBy: v.optional(v.id('users')), + moderationStatus: v.optional( + v.union(v.literal('active'), v.literal('hidden'), v.literal('removed')), + ), + moderationReason: v.optional(v.string()), + moderationNotes: v.optional(v.string()), + reportCount: v.optional(v.number()), + lastReportedAt: v.optional(v.number()), + hiddenAt: v.optional(v.number()), + lastReviewedAt: v.optional(v.number()), }) .index('by_skill', ['skillId']) .index('by_user', ['userId']) @@ -419,6 +428,18 @@ const skillReports = defineTable({ .index('by_user', ['userId']) .index('by_skill_user', ['skillId', 'userId']) +const commentReports = defineTable({ + commentId: v.id('comments'), + skillId: v.id('skills'), + userId: v.id('users'), + reason: v.optional(v.string()), + createdAt: v.number(), +}) + .index('by_comment', ['commentId']) + .index('by_comment_createdAt', ['commentId', 'createdAt']) + .index('by_user', ['userId']) + .index('by_comment_user', ['commentId', 'userId']) + const soulComments = defineTable({ soulId: v.id('souls'), userId: v.id('users'), @@ -585,6 +606,7 @@ export default defineSchema({ skillStatUpdateCursors, comments, skillReports, + commentReports, soulComments, stars, soulStars, diff --git a/docs/api.md b/docs/api.md index 38e5a16df..ade9b65ec 100644 --- a/docs/api.md +++ b/docs/api.md @@ -46,6 +46,12 @@ Auth required: - `POST /api/v1/skills/{slug}/undelete` - `GET /api/v1/whoami` +## Moderation note + +- Skill reporting + auto-hide is implemented in backend mutations used by the web app. +- Comment reporting + auto-hide is implemented in Convex backend mutations used by the web app. +- Public REST (`/api/v1/*`) does not currently expose comment reporting endpoints. + ## Legacy Legacy `/api/*` and `/api/cli/*` still available. See `DEPRECATIONS.md`. diff --git a/docs/architecture.md b/docs/architecture.md index 0eabeb5e8..a54d7bc1e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,3 +59,8 @@ read_when: - Compute fingerprint; compare to registry state. - Optionally reports telemetry (see `docs/telemetry.md`). - Publishes new/changed skills (skips modified installed skills inside install root). + +## Moderation pipeline + +- Skills: user reports -> report counter -> auto-hide on 4th unique report -> staff review in management -> restore/delete/ban actions with audit logs. +- Comments: owner/moderator soft-delete path + user report counter + auto-hide on 4th unique report + staff review in management + restore/hard-delete actions with audit logs. diff --git a/docs/manual-testing.md b/docs/manual-testing.md index 82a19abf8..bd543c564 100644 --- a/docs/manual-testing.md +++ b/docs/manual-testing.md @@ -62,3 +62,19 @@ Run against a local preview server: ``` bun run test:e2e:local ``` + +## Moderation smoke (web) + +- Current comment delete path: + - Post a comment as user A. + - Verify user A can delete own comment. + - Verify user B (non-mod) cannot delete user A comment. + - Verify moderator can delete user A comment. +- Comment report path: + - Submit report with empty reason -> expect validation error. + - Submit first unique report -> comment still visible. + - Submit duplicate report by same user -> no-op. + - Submit 4th unique report -> comment auto-hidden. + - Verify comment appears in management `Reported comments` tab queue. + - Verify moderator restore returns comment to public list. + - Verify admin hard delete removes comment permanently. diff --git a/docs/security.md b/docs/security.md index a8c1054d0..2e8bc6560 100644 --- a/docs/security.md +++ b/docs/security.md @@ -4,14 +4,15 @@ read_when: - Working on moderation or abuse controls - Reviewing upload restrictions - Troubleshooting hidden/removed skills + - Working on comment abuse controls --- # Security + Moderation ## Roles + permissions -- user: upload skills/souls (subject to GitHub age gate), report skills. -- moderator: hide/restore skills, view hidden skills, unhide, soft-delete, ban users (except admins). +- user: upload skills/souls (subject to GitHub age gate), report skills, and report comments. +- moderator: hide/restore skills, view hidden skills, unhide, soft-delete comments, ban users (except admins). - admin: all moderator actions + hard delete skills, change owners, change roles. ## Reporting + auto-hide @@ -32,6 +33,19 @@ read_when: - Skills directory supports an optional "Hide suspicious" filter to exclude active-but-flagged (`flagged.suspicious`) entries from browse/search results. +## Comment moderation + +- Comment authors and moderators can soft-delete comments. +- Reports are unique per user + comment. +- Report reason required (trimmed, max 500 chars). +- Per-user cap: 20 **active** comment reports. +- Auto-hide: when unique reports exceed 3 (4th report), the comment is: + - soft-deleted (`softDeletedAt`) + - `moderationStatus = hidden` + - `moderationReason = auto.reports` + - audit log entry: `comment.auto_hide` +- Reported comments are surfaced to moderators/admins in management for restore/delete decisions. + ## Bans - Banning a user: diff --git a/docs/spec.md b/docs/spec.md index 1d050aca0..822ddbdde 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -16,7 +16,7 @@ read_when: - Vector-based search over skill text + metadata. - Versioning, tags (`latest` + user tags), changelog, rollback (tag movement). - Public read access; upload requires auth. -- Moderation: badges + comment delete; audit everything. +- Moderation: badges + report-driven hide/review for skills and comments; audit everything. ## Non-goals (v1) - Paid features, private skills, or binary assets. @@ -108,8 +108,18 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions: ### Comment - `skillId`, `userId`, `body` - `softDeletedAt`, `deletedBy` +- `moderationStatus`: `active | hidden | removed` +- `moderationReason`, `moderationNotes` +- `reportCount`, `lastReportedAt` +- `hiddenAt`, `lastReviewedAt` - `createdAt` +### CommentReport +- `commentId`, `skillId`, `userId`, `reason`, `createdAt` +- uniqueness: one report per user + comment +- cap: 20 active reports per reporter +- auto-hide threshold: >3 unique reports (4th report) + ### Star - `skillId`, `userId`, `createdAt` @@ -126,6 +136,7 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions: - Management console: moderators can hide/restore skills + mark duplicates + ban users; admins can change owners, approve badges, hard-delete skills, and ban users (deletes owned skills). - Role changes are admin-only and audited. - Reporting: any user can report skills; per-user cap 20 active reports; skills auto-hide after >3 unique reports (mods can review/unhide/delete/ban). +- Comment reporting: users can report comments with the same cap/threshold model; staff review in management. ## Upload flow (50MB per version) 1) Client requests upload session. diff --git a/e2e/menu-smoke.pw.test.ts b/e2e/menu-smoke.pw.test.ts index 8448da9b4..c9378a673 100644 --- a/e2e/menu-smoke.pw.test.ts +++ b/e2e/menu-smoke.pw.test.ts @@ -37,8 +37,9 @@ test('header menu routes render', async ({ page }) => { if (label === 'Import') { await expect(page).toHaveURL(/\/import/) const heading = page.getByRole('heading', { name: 'Import from GitHub' }) + const loadingCard = page.locator('text=Loading…') const signInCard = page.locator('text=Sign in to import and publish skills.') - await expect(heading.or(signInCard)).toBeVisible() + await expect(heading.or(signInCard).or(loadingCard)).toBeVisible() } if (label === 'Search') { diff --git a/e2e/search-exact.pw.test.ts b/e2e/search-exact.pw.test.ts index 025cab116..6fd386e46 100644 --- a/e2e/search-exact.pw.test.ts +++ b/e2e/search-exact.pw.test.ts @@ -2,6 +2,10 @@ import { expect, test } from '@playwright/test' test('skills search paginates exact results', async ({ page }) => { await page.addInitScript(() => { + // Force button-based pagination so this test is deterministic across runners. + ;(window as typeof window & { IntersectionObserver?: typeof IntersectionObserver }) + .IntersectionObserver = undefined + const makeSearchResults = (count: number) => Array.from({ length: count }, (_, index) => ({ score: 0.9, @@ -86,12 +90,12 @@ test('skills search paginates exact results', async ({ page }) => { const input = page.getByPlaceholder('Filter by name, slug, or summary…') await input.fill('remind') await expect(page.getByText('Skill 0')).toBeVisible() - await expect(page.getByText('Scroll to load more')).toBeVisible() + await expect(page.getByRole('button', { name: 'Load more' })).toBeVisible() - await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)) - await expect(page.getByText('Skill 75')).toBeVisible() + await page.getByRole('button', { name: 'Load more' }).click() + await expect(page.getByText('Skill 49')).toBeVisible() const limits = await page.evaluate( () => (window as typeof window & { __searchLimits: number[] }).__searchLimits, ) - expect(limits).toEqual([50, 100]) + expect(limits).toEqual([25, 50]) }) diff --git a/src/__tests__/management.route.test.tsx b/src/__tests__/management.route.test.tsx new file mode 100644 index 000000000..5935807fa --- /dev/null +++ b/src/__tests__/management.route.test.tsx @@ -0,0 +1,175 @@ +/* @vitest-environment jsdom */ +import { fireEvent, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Route } from '../routes/management' + +const useQueryMock = vi.fn() +const useMutationMock = vi.fn() +const useAuthStatusMock = vi.fn() +const searchMock: Record = {} + +const setRoleMock = vi.fn() +const banUserMock = vi.fn() +const setBatchMock = vi.fn() +const setSoftDeletedSkillMock = vi.fn() +const hardDeleteSkillMock = vi.fn() +const setSoftDeletedCommentMock = vi.fn() +const hardDeleteCommentMock = vi.fn() +const changeOwnerMock = vi.fn() +const setDuplicateMock = vi.fn() +const setOfficialBadgeMock = vi.fn() +const setDeprecatedBadgeMock = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: + () => + (config: { component: unknown; validateSearch: unknown }) => ({ + ...config, + useSearch: () => searchMock, + }), + Link: (props: { children: ReactNode }) => {props.children}, +})) + +vi.mock('convex/react', () => ({ + useQuery: (...args: unknown[]) => useQueryMock(...args), + useMutation: (...args: unknown[]) => useMutationMock(...args), +})) + +vi.mock('../lib/useAuthStatus', () => ({ + useAuthStatus: () => useAuthStatusMock(), +})) + +describe('Management route', () => { + beforeEach(() => { + useQueryMock.mockReset() + useMutationMock.mockReset() + useAuthStatusMock.mockReset() + + setRoleMock.mockReset() + banUserMock.mockReset() + setBatchMock.mockReset() + setSoftDeletedSkillMock.mockReset() + hardDeleteSkillMock.mockReset() + setSoftDeletedCommentMock.mockReset() + hardDeleteCommentMock.mockReset() + changeOwnerMock.mockReset() + setDuplicateMock.mockReset() + setOfficialBadgeMock.mockReset() + setDeprecatedBadgeMock.mockReset() + + useAuthStatusMock.mockReturnValue({ + me: { _id: 'users:admin', role: 'admin' }, + }) + + let mutationCall = 0 + const mutationFns = [ + setRoleMock, + banUserMock, + setBatchMock, + setSoftDeletedSkillMock, + hardDeleteSkillMock, + setSoftDeletedCommentMock, + hardDeleteCommentMock, + changeOwnerMock, + setDuplicateMock, + setOfficialBadgeMock, + setDeprecatedBadgeMock, + ] + useMutationMock.mockImplementation(() => { + mutationCall += 1 + const slot = (mutationCall - 1) % mutationFns.length + return mutationFns[slot] + }) + + let limit20Calls = 0 + useQueryMock.mockImplementation((_fn: unknown, args: unknown) => { + if (args === 'skip') return undefined + if (args && typeof args === 'object' && 'search' in args) { + return { items: [], total: 0 } + } + if (args && typeof args === 'object' && 'limit' in args) { + const limit = (args as { limit: number }).limit + if (limit === 20) { + limit20Calls += 1 + return limit20Calls === 1 ? [] : [] + } + if (limit === 25) return [] + if (limit === 30) { + return [ + { + comment: { + _id: 'comments:1', + skillId: 'skills:1', + userId: 'users:commenter', + body: 'base64 | bash', + reportCount: 4, + lastReportedAt: 1700000000000, + softDeletedAt: undefined, + }, + skill: { + _id: 'skills:1', + slug: 'blogwatcher', + displayName: 'Blogwatcher', + ownerUserId: 'users:owner', + }, + owner: { + _id: 'users:owner', + handle: 'steipete', + name: 'Peter', + }, + commenter: { + _id: 'users:commenter', + handle: 'spammer', + name: 'Spammer', + }, + reports: [ + { + reason: 'suspicious download-and-execute command', + createdAt: 1700000000000, + reporterHandle: 'alice', + reporterId: 'users:alice', + }, + ], + }, + ] + } + } + return undefined + }) + + vi.spyOn(window, 'confirm').mockReturnValue(true) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders reported comments with report reasons', () => { + const Component = (Route as unknown as { component: () => JSX.Element }).component + render() + + fireEvent.click(screen.getByRole('tab', { name: 'Reported comments' })) + + expect(screen.getByRole('heading', { name: 'Reported comments' })).toBeTruthy() + expect(screen.getByText('Blogwatcher')).toBeTruthy() + expect(screen.getByText('base64 | bash')).toBeTruthy() + expect(screen.getByText('suspicious download-and-execute command')).toBeTruthy() + }) + + it('calls comment moderation mutations from reported comments actions', () => { + const Component = (Route as unknown as { component: () => JSX.Element }).component + render() + + fireEvent.click(screen.getByRole('tab', { name: 'Reported comments' })) + + fireEvent.click(screen.getByRole('button', { name: 'Hide' })) + expect(setSoftDeletedCommentMock).toHaveBeenCalledWith({ + commentId: 'comments:1', + deleted: true, + }) + + fireEvent.click(screen.getByRole('button', { name: 'Hard delete' })) + expect(hardDeleteCommentMock).toHaveBeenCalledWith({ commentId: 'comments:1' }) + }) +}) diff --git a/src/components/CommentReportDialog.tsx b/src/components/CommentReportDialog.tsx new file mode 100644 index 000000000..ad9bfdec6 --- /dev/null +++ b/src/components/CommentReportDialog.tsx @@ -0,0 +1,67 @@ +type CommentReportDialogProps = { + isOpen: boolean + isSubmitting: boolean + reportReason: string + reportError: string | null + onReasonChange: (value: string) => void + onCancel: () => void + onSubmit: () => void +} + +export function CommentReportDialog({ + isOpen, + isSubmitting, + reportReason, + reportError, + onReasonChange, + onCancel, + onSubmit, +}: CommentReportDialogProps) { + if (!isOpen) return null + + return ( +
+
+

+ Report comment +

+

+ Describe the issue so moderators can review it quickly. +

+
{ + event.preventDefault() + onSubmit() + }} + > +