Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion convex/lib/reporting.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export const MAX_ACTIVE_REPORTS_PER_USER = 20;
export const AUTO_HIDE_REPORT_THRESHOLD = 3;
export const MAX_REPORT_REASON_LENGTH = 500;
71 changes: 71 additions & 0 deletions convex/skillReports.runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/// <reference types="vite/client" />
/* @vitest-environment edge-runtime */
import { convexTest } from "convex-test";
import { expect, it } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";

const modules = import.meta.glob("./**/*.ts");

it.each([false, true])(
"keeps a skill public after four reports (official=%s) until a moderator acts",
async (official) => {
const t = convexTest(schema, modules);
const { skillId, reporters } = await t.run(async (ctx) => {
const ownerUserId = await ctx.db.insert("users", { handle: "owner" });
const createdSkillId = await ctx.db.insert("skills", {
slug: "reported-skill",
displayName: "Reported skill",
ownerUserId,
tags: {},
badges: official ? { official: { byUserId: ownerUserId, at: 1 } } : {},
moderationStatus: "active",
stats: { comments: 0, downloads: 0, stars: 0, versions: 0 },
createdAt: 1,
updatedAt: 1,
});
const createdReporters = [];
for (let i = 0; i < 4; i++)
createdReporters.push(await ctx.db.insert("users", { handle: `reporter-${i}` }));
return { skillId: createdSkillId, reporters: createdReporters };
});
for (const userId of reporters) {
await expect(
t.withIdentity({ subject: userId }).mutation(api.skills.report, {
skillId,
reason: "Please review this skill",
}),
).resolves.toMatchObject({ ok: true, reported: true });
}
const skill = await t.run((ctx) => ctx.db.get(skillId));
expect(skill?.reportCount).toBe(4);
expect(skill?.moderationStatus).toBe("active");
expect(skill?.softDeletedAt).toBeUndefined();
const reports = await t.run((ctx) => ctx.db.query("skillReports").collect());
expect(reports).toHaveLength(4);
expect(reports.every((report) => report.status === "open")).toBe(true);
await expect(
t.withIdentity({ subject: reporters[0] }).mutation(api.skills.report, {
skillId,
reason: "Repeated report",
}),
).resolves.toMatchObject({ alreadyReported: true, reported: false });
expect((await t.run((ctx) => ctx.db.get(skillId)))?.reportCount).toBe(4);
await expect(
t.withIdentity({ subject: reporters[0] }).mutation(api.skills.setSoftDeleted, {
skillId,
deleted: true,
reason: "Reporter cannot make the moderation decision",
}),
).rejects.toThrow("Forbidden");
const moderator = await t.run((ctx) =>
ctx.db.insert("users", { handle: "moderator", role: "moderator" }),
);
await t.withIdentity({ subject: moderator }).mutation(api.skills.setSoftDeleted, {
skillId,
deleted: true,
reason: "Moderator reviewed the reports",
});
expect((await t.run((ctx) => ctx.db.get(skillId)))?.moderationStatus).toBe("hidden");
},
);
47 changes: 4 additions & 43 deletions convex/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,7 @@ import {
requirePublisherRole,
} from "./lib/publishers";
import { RECOMMENDATION_SCORE_VERSION } from "./lib/recommendationScore";
import {
AUTO_HIDE_REPORT_THRESHOLD,
MAX_ACTIVE_REPORTS_PER_USER,
MAX_REPORT_REASON_LENGTH,
} from "./lib/reporting";
import { MAX_ACTIVE_REPORTS_PER_USER, MAX_REPORT_REASON_LENGTH } from "./lib/reporting";
import {
canReleaseReservedSlugForPublisher,
enforceReservedSlugCooldownForNewSkill,
Expand Down Expand Up @@ -4195,47 +4191,12 @@ export const report = mutation({
});

const nextReportCount = (skill.reportCount ?? 0) + 1;
const shouldAutoHide = nextReportCount > AUTO_HIDE_REPORT_THRESHOLD && !skill.softDeletedAt;
const updates: Partial<Doc<"skills">> = {
// Reports are moderator intake, not authority to hide another publisher's skill.
await ctx.db.patch(skill._id, {
reportCount: nextReportCount,
lastReportedAt: now,
updatedAt: now,
};
if (shouldAutoHide) {
Object.assign(updates, {
softDeletedAt: now,
moderationStatus: "hidden",
moderationReason: "auto.reports",
moderationNotes: "Auto-hidden after 4 unique reports.",
isSuspicious: computeIsSuspicious({
moderationFlags: skill.moderationFlags,
moderationReason: "auto.reports",
}),
hiddenAt: now,
lastReviewedAt: now,
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
});
}

const nextSkill = { ...skill, ...updates };
await ctx.db.patch(skill._id, updates);
await adjustGlobalPublicCountForSkillChange(ctx, skill, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, skill, nextSkill);

if (shouldAutoHide) {
await setSkillEmbeddingsSoftDeleted(ctx, skill._id, true, now);

await ctx.db.insert("auditLogs", {
actorUserId: userId,
action: "skill.auto_hide",
targetType: "skill",
targetId: skill._id,
metadata: { reportCount: nextReportCount },
createdAt: now,
});
}
});

await appendSkillModerationEventLog(ctx, {
kind: "report",
Expand Down
16 changes: 8 additions & 8 deletions specs/security-moderation.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
abuse list from the filtered backend dashboard state instead of applying a
separate client-side official-org filter.

## Reporting + auto-hide
## Reporting + moderation review

- Reports are unique per user + target (skill/package).
- Report reason required (trimmed, max 500 chars). Abuse of reporting may result in account bans.
Expand All @@ -229,13 +229,13 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
and the owner is not banned.
- Active package report = package exists, not soft-deleted, and the owner is
not banned/deactivated.
- Auto-hide: when unique reports exceed 3 (4th report):
- skill report flow:
- soft-delete skill (`softDeletedAt`)
- set `moderationStatus = hidden`
- set `moderationReason = auto.reports`
- set embeddings visibility `deleted`
- audit log entry: `skill.auto_hide`
- Reports never change skill visibility or installability automatically, regardless
of the number of distinct reporters or whether the skill is official. Report
submission records moderator intake and updates report counts only. Hiding a
skill requires an authorized moderator's explicit decision; this prevents a
small group of ordinary accounts from removing arbitrary catalog entries.
- Existing report, scanner, and moderator hides retain their provenance; this
change does not automatically restore previously hidden skills.
- Package reports feed `clawhub-admin package moderation-queue` and audit `package.report`,
but do not auto-hide or block downloads. Moderators can review a formal report
with an explicit final action to quarantine or revoke the affected release.
Expand Down
Loading