mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
Tune publisher abuse pressure labels
Retune publisher-abuse aggregate scoring to v4, keep this path flag-only for rollout, clear stale aggregate nominations, and remove the direct publisher-abuse ban UI.
This commit is contained in:
@@ -4,19 +4,26 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeCurrentSkillTemporalAbuseScore,
|
||||
computeHistoricalSkillTemporalAbuseScore,
|
||||
computePublisherAbusePressure,
|
||||
computePublisherAbuseRawScore,
|
||||
computeTemporalAbuseCohortBenchmark,
|
||||
computeTemporalPublisherAbuseZScore,
|
||||
DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
labelForPublisherAbuseScore,
|
||||
labelForTemporalPublisherAbuse,
|
||||
labelForPublisherAbuseZScore,
|
||||
scorePublisherAbuseCohort,
|
||||
} from "./publisherAbuseScoring";
|
||||
|
||||
describe("publisher abuse scoring", () => {
|
||||
it("uses a stronger superlinear output elasticity for bulk publishers", () => {
|
||||
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.modelVersion).toBe("publisher-abuse-pressure.v2");
|
||||
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.outputElasticity).toBe(1.5);
|
||||
it("uses the mature catalog pivot for publisher spam abuse checks", () => {
|
||||
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.modelVersion).toBe("publisher-abuse-pressure.v4");
|
||||
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.skillPivot).toBe(200);
|
||||
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.outputElasticity).toBe(1.2);
|
||||
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.engagementElasticity).toBe(0.25);
|
||||
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.minPublishedSkillsForAggregateLabel).toBe(200);
|
||||
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.installTrustElasticity).toBe(1);
|
||||
expect(DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG.starTrustElasticity).toBe(1.1);
|
||||
});
|
||||
|
||||
it("uses the dry-run z-score thresholds", () => {
|
||||
@@ -109,29 +116,170 @@ describe("publisher abuse scoring", () => {
|
||||
expect(byHandle.get("peand-rover")?.rank).toBeLessThan(byHandle.get("byungkyu")?.rank ?? 0);
|
||||
});
|
||||
|
||||
it("keeps catalog pressure linear below the bulk publisher pivot", () => {
|
||||
const score50 = computePublisherAbuseRawScore(
|
||||
publisher("ordinary-50", {
|
||||
publishedSkills: 50,
|
||||
totalInstalls: 50,
|
||||
totalStars: 1.25,
|
||||
totalDownloads: 12_500,
|
||||
it("keeps high-adoption bulk publishers out of aggregate spam labels", () => {
|
||||
const scored = scorePublisherAbuseCohort([
|
||||
...Array.from({ length: 200 }, (_, index) =>
|
||||
publisher(`ordinary-${index}`, {
|
||||
publishedSkills: 3,
|
||||
totalInstalls: 30,
|
||||
totalStars: 2,
|
||||
totalDownloads: 600,
|
||||
}),
|
||||
),
|
||||
publisher("ivangdavila-shape", {
|
||||
publishedSkills: 955,
|
||||
totalInstalls: 84_756,
|
||||
totalStars: 4_924,
|
||||
totalDownloads: 2_347_109,
|
||||
}),
|
||||
);
|
||||
const score100 = computePublisherAbuseRawScore(
|
||||
publisher("ordinary-100", {
|
||||
publishedSkills: 100,
|
||||
totalInstalls: 100,
|
||||
totalStars: 2.5,
|
||||
totalDownloads: 25_000,
|
||||
publisher("harrylabsj-shape", {
|
||||
publishedSkills: 600,
|
||||
totalInstalls: 7_521,
|
||||
totalStars: 17,
|
||||
totalDownloads: 201_855,
|
||||
}),
|
||||
);
|
||||
publisher("oomol-shape", {
|
||||
publishedSkills: 582,
|
||||
totalInstalls: 4_153,
|
||||
totalStars: 0,
|
||||
totalDownloads: 111_003,
|
||||
}),
|
||||
publisher("justoneapi-shape", {
|
||||
publishedSkills: 224,
|
||||
totalInstalls: 3_164,
|
||||
totalStars: 0,
|
||||
totalDownloads: 83_782,
|
||||
}),
|
||||
publisher("ai-gaoqian-shape", {
|
||||
publishedSkills: 212,
|
||||
totalInstalls: 855,
|
||||
totalStars: 5,
|
||||
totalDownloads: 24_362,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(score50.pressure).toBeGreaterThan(0);
|
||||
expect(score50.pressure / score100.pressure).toBeCloseTo(0.5);
|
||||
const byHandle = new Map(scored.map((score) => [score.input.handleSnapshot, score]));
|
||||
expect(byHandle.get("ivangdavila-shape")?.label).toBe("pass");
|
||||
expect(byHandle.get("harrylabsj-shape")?.label).toBe("pass");
|
||||
expect(byHandle.get("oomol-shape")?.label).toBe("potential_ban_candidate");
|
||||
expect(byHandle.get("justoneapi-shape")?.label).toBe("review");
|
||||
expect(byHandle.get("ai-gaoqian-shape")?.label).toBe("potential_ban_candidate");
|
||||
});
|
||||
|
||||
it("increases catalog pressure faster than skill count above the pivot", () => {
|
||||
it("keeps below-pivot catalogs out of aggregate spam abuse labels", () => {
|
||||
const score199 = computePublisherAbuseRawScore(
|
||||
publisher("ordinary-199", {
|
||||
publishedSkills: 199,
|
||||
totalInstalls: 0,
|
||||
totalStars: 0,
|
||||
totalDownloads: 50_000,
|
||||
}),
|
||||
);
|
||||
const score200 = computePublisherAbuseRawScore(
|
||||
publisher("bulk-200", {
|
||||
publishedSkills: 200,
|
||||
totalInstalls: 0,
|
||||
totalStars: 0,
|
||||
totalDownloads: 50_000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(score199.pressure).toBeGreaterThan(0);
|
||||
expect(score199.logPressure).toBeGreaterThan(0);
|
||||
expect(score199.reasonCodes).toEqual([]);
|
||||
expect(labelForPublisherAbuseScore(score199, 3)).toBe("pass");
|
||||
expect(score200.pressure).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps tiny catalogs out of aggregate spam abuse labels", () => {
|
||||
const score6 = computePublisherAbuseRawScore(
|
||||
publisher("tiny-6", {
|
||||
publishedSkills: 6,
|
||||
totalInstalls: 0,
|
||||
totalStars: 0,
|
||||
totalDownloads: 0,
|
||||
}),
|
||||
);
|
||||
const score200 = computePublisherAbuseRawScore(
|
||||
publisher("bulk-200", {
|
||||
publishedSkills: 200,
|
||||
totalInstalls: 0,
|
||||
totalStars: 0,
|
||||
totalDownloads: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(score6.pressure).toBeGreaterThan(0);
|
||||
expect(score6.reasonCodes).toEqual([]);
|
||||
expect(score200.pressure).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not nominate publishers before the catalog reaches the bulk maturity pivot", () => {
|
||||
const belowPivot = computePublisherAbuseRawScore(
|
||||
publisher("spacesq-shape", {
|
||||
publishedSkills: 62,
|
||||
totalInstalls: 0,
|
||||
totalStars: 0,
|
||||
totalDownloads: 29_906,
|
||||
}),
|
||||
);
|
||||
const abovePivot = computePublisherAbuseRawScore(
|
||||
publisher("justoneapi-shape", {
|
||||
publishedSkills: 224,
|
||||
totalInstalls: 33,
|
||||
totalStars: 0,
|
||||
totalDownloads: 83_543,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(labelForPublisherAbuseScore(belowPivot, 3)).toBe("pass");
|
||||
expect(labelForPublisherAbuseScore(abovePivot, 3)).toBe("potential_ban_candidate");
|
||||
});
|
||||
|
||||
it("preserves legacy configs where the skill pivot was not a label floor", () => {
|
||||
const legacyConfig = {
|
||||
...DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
modelVersion: "publisher-abuse-pressure.v2",
|
||||
skillPivot: 100,
|
||||
minPublishedSkillsForAggregateLabel: undefined,
|
||||
};
|
||||
const score99 = computePublisherAbuseRawScore(
|
||||
publisher("legacy-99", {
|
||||
publishedSkills: 99,
|
||||
totalInstalls: 0,
|
||||
totalStars: 0,
|
||||
totalDownloads: 100,
|
||||
}),
|
||||
legacyConfig,
|
||||
);
|
||||
|
||||
expect(labelForPublisherAbuseScore(score99, 3, legacyConfig)).toBe("potential_ban_candidate");
|
||||
});
|
||||
|
||||
it("preserves legacy below-pivot catalog pressure for resumed stored configs", () => {
|
||||
const legacyConfig = {
|
||||
...DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
modelVersion: "publisher-abuse-pressure.v2",
|
||||
skillPivot: 100,
|
||||
outputElasticity: 1.5,
|
||||
engagementElasticity: undefined,
|
||||
minPublishedSkillsForAggregateLabel: undefined,
|
||||
};
|
||||
|
||||
const pressure = computePublisherAbusePressure(
|
||||
{
|
||||
publishedSkills: 25,
|
||||
totalInstalls: 50,
|
||||
totalStars: 1.25,
|
||||
totalDownloads: 6_250,
|
||||
},
|
||||
legacyConfig,
|
||||
);
|
||||
|
||||
expect(pressure).toBeCloseTo(0.25);
|
||||
});
|
||||
|
||||
it("increases catalog pressure when catalog grows without matching adoption", () => {
|
||||
const score200 = computePublisherAbuseRawScore(
|
||||
publisher("bulk-200", {
|
||||
publishedSkills: 200,
|
||||
@@ -143,14 +291,13 @@ describe("publisher abuse scoring", () => {
|
||||
const score400 = computePublisherAbuseRawScore(
|
||||
publisher("bulk-400", {
|
||||
publishedSkills: 400,
|
||||
totalInstalls: 400,
|
||||
totalStars: 10,
|
||||
totalDownloads: 50_000,
|
||||
totalInstalls: 200,
|
||||
totalStars: 5,
|
||||
totalDownloads: 25_000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(score200.pressure).toBeGreaterThan(0);
|
||||
expect(score400.pressure / score200.pressure).toBeCloseTo(2 ** 1.5);
|
||||
expect(score400.pressure / score200.pressure).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const PUBLISHER_ABUSE_MODEL_VERSION = "publisher-abuse-pressure.v2";
|
||||
export const PUBLISHER_ABUSE_MODEL_VERSION = "publisher-abuse-pressure.v4";
|
||||
export const PUBLISHER_TEMPORAL_ABUSE_MODEL_VERSION = "publisher-abuse-temporal.v1";
|
||||
|
||||
export type PublisherAbuseLabel = "pass" | "review" | "potential_ban_candidate";
|
||||
@@ -10,6 +10,8 @@ export type PublisherAbuseModelConfig = {
|
||||
starsPerSkillPivot: number;
|
||||
downloadsPerSkillPivot: number;
|
||||
outputElasticity: number;
|
||||
engagementElasticity?: number;
|
||||
minPublishedSkillsForAggregateLabel?: number;
|
||||
installTrustElasticity: number;
|
||||
starTrustElasticity: number;
|
||||
downloadDemandElasticity: number;
|
||||
@@ -99,15 +101,17 @@ export type TemporalAbuseCohortBenchmark = {
|
||||
|
||||
export const DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG = {
|
||||
modelVersion: PUBLISHER_ABUSE_MODEL_VERSION,
|
||||
skillPivot: 100,
|
||||
skillPivot: 200,
|
||||
// Two installs per skill is only a rough review calibration point. It can be
|
||||
// the author plus one friend, so it is not proof of legitimacy or abuse.
|
||||
installsPerSkillPivot: 2,
|
||||
starsPerSkillPivot: 0.05,
|
||||
downloadsPerSkillPivot: 250,
|
||||
outputElasticity: 1.5,
|
||||
installTrustElasticity: 0.8,
|
||||
starTrustElasticity: 1,
|
||||
outputElasticity: 1.2,
|
||||
engagementElasticity: 0.25,
|
||||
minPublishedSkillsForAggregateLabel: 200,
|
||||
installTrustElasticity: 1,
|
||||
starTrustElasticity: 1.1,
|
||||
downloadDemandElasticity: 0.2,
|
||||
minInstallsPerSkill: 0.05,
|
||||
minStarsPerSkill: 0.02,
|
||||
@@ -139,6 +143,30 @@ export function labelForPublisherAbuseZScore(
|
||||
return "pass";
|
||||
}
|
||||
|
||||
export function labelForPublisherAbuseScore(
|
||||
score: Pick<PublisherAbuseRawScore, "publishedSkills">,
|
||||
zScore: number,
|
||||
config: PublisherAbuseModelConfig = DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
): PublisherAbuseLabel {
|
||||
if (!isPublisherAbuseCheckEligible(score, config)) return "pass";
|
||||
if (zScore < config.reviewZThreshold) return "pass";
|
||||
if (
|
||||
zScore >= config.potentialBanCandidateZThreshold &&
|
||||
isPublisherAbuseCheckEligible(score, config)
|
||||
) {
|
||||
return "potential_ban_candidate";
|
||||
}
|
||||
return "review";
|
||||
}
|
||||
|
||||
export function isPublisherAbuseCheckEligible(
|
||||
score: Pick<PublisherAbuseRawScore, "publishedSkills">,
|
||||
config: PublisherAbuseModelConfig = DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
) {
|
||||
const minPublishedSkills = Math.max(1, config.minPublishedSkillsForAggregateLabel ?? 1);
|
||||
return score.publishedSkills >= minPublishedSkills;
|
||||
}
|
||||
|
||||
export function computeTemporalPublisherAbuseZScore(input: {
|
||||
label: PublisherAbuseLabel;
|
||||
highTemporalSkillCount: number;
|
||||
@@ -169,9 +197,9 @@ export function computePublisherAbuseRawScore(
|
||||
const pressure = computePublisherAbusePressure(
|
||||
{
|
||||
publishedSkills,
|
||||
installsPerSkill,
|
||||
starsPerSkill,
|
||||
downloadsPerSkill,
|
||||
totalInstalls,
|
||||
totalStars,
|
||||
totalDownloads,
|
||||
},
|
||||
config,
|
||||
);
|
||||
@@ -200,34 +228,51 @@ export function computePublisherAbuseRawScore(
|
||||
export function computePublisherAbusePressure(
|
||||
input: {
|
||||
publishedSkills: number;
|
||||
installsPerSkill: number;
|
||||
starsPerSkill: number;
|
||||
downloadsPerSkill: number;
|
||||
totalInstalls: number;
|
||||
totalStars: number;
|
||||
totalDownloads: number;
|
||||
},
|
||||
config: PublisherAbuseModelConfig = DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
): number {
|
||||
if (input.publishedSkills <= 0) return 0;
|
||||
const skills = Math.max(1, input.publishedSkills);
|
||||
const skillPivot = Math.max(1, config.skillPivot);
|
||||
const installsPerSkill = Math.max(config.minInstallsPerSkill, input.installsPerSkill);
|
||||
const installsPerSkillPivot = Math.max(config.minInstallsPerSkill, config.installsPerSkillPivot);
|
||||
const starsPerSkill = Math.max(config.minStarsPerSkill, input.starsPerSkill);
|
||||
const starsPerSkillPivot = Math.max(config.minStarsPerSkill, config.starsPerSkillPivot);
|
||||
const downloadsPerSkill = Math.max(config.minDownloadsPerSkill, input.downloadsPerSkill);
|
||||
const downloadsPerSkillPivot = Math.max(
|
||||
config.minDownloadsPerSkill,
|
||||
config.downloadsPerSkillPivot,
|
||||
);
|
||||
|
||||
const skillOutputRatio = skills / skillPivot;
|
||||
const catalogPressure =
|
||||
skillOutputRatio <= 1 ? skillOutputRatio : skillOutputRatio ** config.outputElasticity;
|
||||
const usesWholePublisherEngagement = typeof config.engagementElasticity === "number";
|
||||
const catalogPressure = usesWholePublisherEngagement
|
||||
? skillOutputRatio ** config.outputElasticity
|
||||
: skillOutputRatio <= 1
|
||||
? skillOutputRatio
|
||||
: skillOutputRatio ** config.outputElasticity;
|
||||
const engagementScale = skillOutputRatio ** (config.engagementElasticity ?? 1);
|
||||
const installBenchmark = installsPerSkillPivot * skillPivot * engagementScale;
|
||||
const starBenchmark = starsPerSkillPivot * skillPivot * engagementScale;
|
||||
const downloadBenchmark = downloadsPerSkillPivot * skillPivot * engagementScale;
|
||||
const totalInstalls = Math.max(
|
||||
config.minInstallsPerSkill * skillPivot * engagementScale,
|
||||
input.totalInstalls,
|
||||
);
|
||||
const totalStars = Math.max(
|
||||
config.minStarsPerSkill * skillPivot * engagementScale,
|
||||
input.totalStars,
|
||||
);
|
||||
const totalDownloads = Math.max(
|
||||
config.minDownloadsPerSkill * skillPivot * engagementScale,
|
||||
input.totalDownloads,
|
||||
);
|
||||
|
||||
return (
|
||||
catalogPressure *
|
||||
(installsPerSkillPivot / installsPerSkill) ** config.installTrustElasticity *
|
||||
(starsPerSkillPivot / starsPerSkill) ** config.starTrustElasticity *
|
||||
(downloadsPerSkillPivot / downloadsPerSkill) ** config.downloadDemandElasticity
|
||||
(installBenchmark / totalInstalls) ** config.installTrustElasticity *
|
||||
(starBenchmark / totalStars) ** config.starTrustElasticity *
|
||||
(downloadBenchmark / totalDownloads) ** config.downloadDemandElasticity
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,20 +281,23 @@ export function scorePublisherAbuseCohort(
|
||||
config: PublisherAbuseModelConfig = DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
): PublisherAbuseScore[] {
|
||||
const rawScores = inputs.map((input) => computePublisherAbuseRawScore(input, config));
|
||||
const mean = average(rawScores.map((score) => score.logPressure));
|
||||
const scoredRawScores = rawScores.filter((score) => score.publishedSkills > 0);
|
||||
const mean = average(scoredRawScores.map((score) => score.logPressure));
|
||||
const stdDev = standardDeviation(
|
||||
rawScores.map((score) => score.logPressure),
|
||||
scoredRawScores.map((score) => score.logPressure),
|
||||
mean,
|
||||
);
|
||||
const safeStdDev = stdDev === 0 ? 1 : stdDev;
|
||||
|
||||
return rawScores
|
||||
.map((score) => {
|
||||
const zScore = (score.logPressure - mean) / safeStdDev;
|
||||
const zScore = isPublisherAbuseCheckEligible(score, config)
|
||||
? (score.logPressure - mean) / safeStdDev
|
||||
: 0;
|
||||
return {
|
||||
...score,
|
||||
zScore,
|
||||
label: labelForPublisherAbuseZScore(zScore, config),
|
||||
label: labelForPublisherAbuseScore(score, zScore, config),
|
||||
rank: 0,
|
||||
};
|
||||
})
|
||||
@@ -453,6 +501,7 @@ function reasonCodesForPublisher(input: {
|
||||
}) {
|
||||
const codes: string[] = [];
|
||||
if (input.publishedSkills <= 0) return codes;
|
||||
if (!isPublisherAbuseCheckEligible(input, input.config)) return codes;
|
||||
if (input.publishedSkills >= input.config.skillPivot) codes.push("high_catalog_volume");
|
||||
if (input.installsPerSkill < input.config.installsPerSkillPivot) {
|
||||
codes.push("low_installs_per_skill");
|
||||
|
||||
+597
-75
@@ -49,6 +49,7 @@ const TEST_MODEL_CONFIG = {
|
||||
starsPerSkillPivot: 0.05,
|
||||
downloadsPerSkillPivot: 250,
|
||||
outputElasticity: 1.5,
|
||||
engagementElasticity: 0.25,
|
||||
installTrustElasticity: 0.8,
|
||||
starTrustElasticity: 1,
|
||||
downloadDemandElasticity: 0.2,
|
||||
@@ -275,6 +276,7 @@ function makeNomination(
|
||||
ownerUserId: string;
|
||||
latestScoreId: string;
|
||||
handleSnapshot: string;
|
||||
modelVersion: string;
|
||||
label: "potential_ban_candidate" | "review" | "pass";
|
||||
status: PublisherAbuseTestTriageStatus;
|
||||
lastScoredAt: number;
|
||||
@@ -289,7 +291,7 @@ function makeNomination(
|
||||
ownerUserId: fields.ownerUserId,
|
||||
handleSnapshot: fields.handleSnapshot ?? "owner",
|
||||
latestScoreId: fields.latestScoreId ?? "publisherAbuseScores:score",
|
||||
modelVersion: "publisher-abuse-pressure.v2",
|
||||
modelVersion: fields.modelVersion ?? "publisher-abuse-pressure.v2",
|
||||
label: fields.label ?? "potential_ban_candidate",
|
||||
status: fields.status ?? "pending",
|
||||
openedAt: 1,
|
||||
@@ -666,7 +668,7 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bans the linked owner and resolves the nomination in one mutation", async () => {
|
||||
it("rejects publisher abuse ban enforcement while nominations are flag-only", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
@@ -703,73 +705,9 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
expectedUpdatedAt: 1,
|
||||
reason: " confirmed spam ",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, status: "banned" });
|
||||
|
||||
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
|
||||
actorUserId: "users:moderator",
|
||||
targetUserId: "users:owner",
|
||||
reason: "confirmed spam",
|
||||
});
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"publisherAbuseReviewNominations:nomination",
|
||||
expect.objectContaining({
|
||||
status: "banned",
|
||||
reviewedByUserId: "users:moderator",
|
||||
notes: "confirmed spam",
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"publisherAbuseReviewEvents",
|
||||
expect.objectContaining({
|
||||
eventType: "triage_status_changed",
|
||||
previousStatus: "pending",
|
||||
nextStatus: "banned",
|
||||
notes: "confirmed spam",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not resolve the nomination when linked owner ban fails", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:moderator",
|
||||
user: { _id: "users:moderator", role: "moderator" },
|
||||
} as never);
|
||||
const runMutation = vi.fn(async () => {
|
||||
throw new Error("Ban failed");
|
||||
});
|
||||
const patch = vi.fn(async () => null);
|
||||
const insert = vi.fn(async (table: string) => `${table}:new`);
|
||||
const ctx = {
|
||||
runMutation,
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "publisherAbuseReviewNominations:nomination") {
|
||||
return {
|
||||
_id: "publisherAbuseReviewNominations:nomination",
|
||||
ownerKey: "user:owner",
|
||||
ownerUserId: "users:owner",
|
||||
latestScoreId: "publisherAbuseScores:score",
|
||||
label: "potential_ban_candidate",
|
||||
status: "pending",
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
insert,
|
||||
patch,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
banPublisherAbuseOwnerHandler(ctx, {
|
||||
nominationId: "publisherAbuseReviewNominations:nomination",
|
||||
expectedLatestScoreId: "publisherAbuseScores:score",
|
||||
expectedUpdatedAt: 1,
|
||||
reason: "confirmed spam",
|
||||
}),
|
||||
).rejects.toThrow("Ban failed");
|
||||
).rejects.toThrow(/publisher abuse bans are disabled/i);
|
||||
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -881,7 +819,7 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
},
|
||||
};
|
||||
build(q);
|
||||
expect(constraints.modelVersion).toBe("publisher-abuse-pressure.v2");
|
||||
expect(constraints.modelVersion).toBe("publisher-abuse-pressure.v4");
|
||||
return {
|
||||
order: () => ({
|
||||
first: async () => latestRun,
|
||||
@@ -1747,6 +1685,180 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("collects finite score rows for stored configs without engagement elasticity", async () => {
|
||||
const legacyModelConfig: Partial<typeof TEST_MODEL_CONFIG> = { ...TEST_MODEL_CONFIG };
|
||||
delete legacyModelConfig.engagementElasticity;
|
||||
const insertedScores: unknown[] = [];
|
||||
const insert = vi.fn(async (table: string, doc?: unknown) => {
|
||||
if (table === "publisherAbuseScores") insertedScores.push(doc);
|
||||
return `${table}:new`;
|
||||
});
|
||||
const patch = vi.fn(async () => null);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async () => ({
|
||||
_id: "publisherAbuseScoreRuns:run",
|
||||
modelVersion: legacyModelConfig.modelVersion,
|
||||
modelConfig: legacyModelConfig,
|
||||
status: "running",
|
||||
phase: "collecting",
|
||||
collectCursor: undefined,
|
||||
scannedPublishers: 0,
|
||||
scoredPublishers: 0,
|
||||
sumLogPressure: 0,
|
||||
sumSquaredLogPressure: 0,
|
||||
})),
|
||||
insert,
|
||||
patch,
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publishers") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
paginate: async () => ({
|
||||
page: [
|
||||
{
|
||||
_id: "publishers:legacy-config",
|
||||
handle: "legacy-config",
|
||||
linkedUserId: "users:legacy-config",
|
||||
publishedSkills: 250,
|
||||
publishedPackages: 0,
|
||||
totalInstalls: 25,
|
||||
totalStars: 1,
|
||||
totalDownloads: 10_000,
|
||||
},
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "packages") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
paginate: async () => ({
|
||||
page: [],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(collectHandler(ctx, { runId: "publisherAbuseScoreRuns:run" })).resolves.toEqual(
|
||||
expect.objectContaining({ isDone: false, scanned: 1, phase: "finalizing" }),
|
||||
);
|
||||
|
||||
expect(insertedScores).toHaveLength(1);
|
||||
const [insertedScore] = insertedScores;
|
||||
if (typeof insertedScore !== "object" || insertedScore === null) {
|
||||
throw new Error("Expected publisher abuse score insert");
|
||||
}
|
||||
const pressure = Object.getOwnPropertyDescriptor(insertedScore, "pressure")?.value;
|
||||
const logPressure = Object.getOwnPropertyDescriptor(insertedScore, "logPressure")?.value;
|
||||
expect(Number.isFinite(pressure)).toBe(true);
|
||||
expect(Number.isFinite(logPressure)).toBe(true);
|
||||
expect(insertedScore).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerKey: "publisher:publishers:legacy-config",
|
||||
handleSnapshot: "legacy-config",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves legacy stored config label semantics while finalizing score rows", async () => {
|
||||
const legacyModelConfig: Partial<typeof TEST_MODEL_CONFIG> = { ...TEST_MODEL_CONFIG };
|
||||
delete legacyModelConfig.engagementElasticity;
|
||||
const insert = vi.fn(async (table: string) => `${table}:new`);
|
||||
const patch = vi.fn(async () => null);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async () => ({
|
||||
_id: "publisherAbuseScoreRuns:legacy-run",
|
||||
status: "running",
|
||||
phase: "finalizing",
|
||||
modelVersion: "publisher-abuse-pressure.v2",
|
||||
modelConfig: legacyModelConfig,
|
||||
scoredPublishers: 1,
|
||||
finalizedScores: 0,
|
||||
passCount: 0,
|
||||
reviewCount: 0,
|
||||
potentialBanCandidateCount: 0,
|
||||
nominatedPublishers: 0,
|
||||
sumLogPressure: 3,
|
||||
sumSquaredLogPressure: 9,
|
||||
})),
|
||||
insert,
|
||||
patch,
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherAbuseScores") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
order: () => ({
|
||||
paginate: async () => ({
|
||||
page: [
|
||||
{
|
||||
_id: "publisherAbuseScores:legacy-score",
|
||||
ownerKey: "publisher:publishers:legacy-score",
|
||||
ownerPublisherId: "publishers:legacy-score",
|
||||
ownerUserId: "users:legacy-score",
|
||||
handleSnapshot: "legacy-score",
|
||||
modelVersion: "publisher-abuse-pressure.v2",
|
||||
pressure: 1000,
|
||||
logPressure: 6,
|
||||
publishedSkills: 99,
|
||||
totalInstalls: 0,
|
||||
totalStars: 0,
|
||||
totalDownloads: 100,
|
||||
installsPerSkill: 0,
|
||||
starsPerSkill: 0,
|
||||
downloadsPerSkill: 1.01,
|
||||
reasonCodes: ["low_installs_per_skill"],
|
||||
},
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publisherAbuseReviewNominations") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
first: async () => null,
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery();
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
finalizeHandler(ctx, { runId: "publisherAbuseScoreRuns:legacy-run" }),
|
||||
).resolves.toEqual(expect.objectContaining({ isDone: true, finalized: 1, nominations: 1 }));
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"publisherAbuseScores:legacy-score",
|
||||
expect.objectContaining({ label: "potential_ban_candidate", zScore: 3 }),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"publisherAbuseReviewNominations",
|
||||
expect.objectContaining({
|
||||
latestScoreId: "publisherAbuseScores:legacy-score",
|
||||
modelVersion: "publisher-abuse-pressure.v2",
|
||||
label: "potential_ban_candidate",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes official publishers from abuse scoring even when they match abuse-pressure criteria", async () => {
|
||||
const insert = vi.fn(async (table: string) => `${table}:new`);
|
||||
const patch = vi.fn(async () => null);
|
||||
@@ -2948,6 +3060,7 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
_id: "publisherAbuseReviewNominations:existing",
|
||||
status: "pending",
|
||||
}),
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -2968,6 +3081,410 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps below-pivot high z-score publishers out of spam abuse review", async () => {
|
||||
const modelConfig = {
|
||||
...TEST_MODEL_CONFIG,
|
||||
modelVersion: "publisher-abuse-pressure.v4",
|
||||
skillPivot: 200,
|
||||
minPublishedSkillsForAggregateLabel: 200,
|
||||
};
|
||||
const staleV2Nomination = makeNomination({
|
||||
_id: "publisherAbuseReviewNominations:stale-v2",
|
||||
ownerKey: "publisher:publishers:spacesq-shape",
|
||||
ownerPublisherId: "publishers:spacesq-shape",
|
||||
ownerUserId: "users:spacesq-shape",
|
||||
latestScoreId: "publisherAbuseScores:old-v2-score",
|
||||
handleSnapshot: "spacesq-shape",
|
||||
label: "potential_ban_candidate",
|
||||
status: "pending",
|
||||
lastScoredAt: 1,
|
||||
updatedAt: 1,
|
||||
});
|
||||
const insert = vi.fn(async (table: string) => `${table}:new`);
|
||||
const patch = vi.fn(async () => null);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async () => ({
|
||||
_id: "publisherAbuseScoreRuns:run",
|
||||
status: "running",
|
||||
phase: "finalizing",
|
||||
modelVersion: modelConfig.modelVersion,
|
||||
modelConfig,
|
||||
scoredPublishers: 1,
|
||||
finalizedScores: 0,
|
||||
passCount: 0,
|
||||
reviewCount: 0,
|
||||
potentialBanCandidateCount: 0,
|
||||
nominatedPublishers: 0,
|
||||
sumLogPressure: 3,
|
||||
sumSquaredLogPressure: 9,
|
||||
})),
|
||||
insert,
|
||||
patch,
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherAbuseScores") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
order: () => ({
|
||||
paginate: async () => ({
|
||||
page: [
|
||||
{
|
||||
_id: "publisherAbuseScores:spacesq-shape",
|
||||
ownerKey: "publisher:publishers:spacesq-shape",
|
||||
ownerPublisherId: "publishers:spacesq-shape",
|
||||
ownerUserId: "users:spacesq-shape",
|
||||
handleSnapshot: "spacesq-shape",
|
||||
modelVersion: modelConfig.modelVersion,
|
||||
pressure: 1000,
|
||||
logPressure: 6,
|
||||
publishedSkills: 62,
|
||||
totalInstalls: 0,
|
||||
totalStars: 0,
|
||||
totalDownloads: 29_906,
|
||||
installsPerSkill: 0,
|
||||
starsPerSkill: 0,
|
||||
downloadsPerSkill: 482.35,
|
||||
reasonCodes: ["low_installs_per_skill", "low_stars_per_skill"],
|
||||
},
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publisherAbuseReviewNominations") {
|
||||
return {
|
||||
withIndex: (
|
||||
indexName: string,
|
||||
build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
expect(indexName).toBe("by_owner_key_and_model_version");
|
||||
const constraints: Record<string, unknown> = {};
|
||||
const q = {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
build(q);
|
||||
return {
|
||||
take: async () =>
|
||||
constraints.ownerKey === staleV2Nomination.ownerKey ? [staleV2Nomination] : [],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery();
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(finalizeHandler(ctx, { runId: "publisherAbuseScoreRuns:run" })).resolves.toEqual(
|
||||
expect.objectContaining({ isDone: true, finalized: 1, nominations: 0 }),
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"publisherAbuseScores:spacesq-shape",
|
||||
expect.objectContaining({ label: "pass", zScore: 0 }),
|
||||
);
|
||||
expect(insert).not.toHaveBeenCalledWith("publisherAbuseReviewNominations", expect.anything());
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
staleV2Nomination._id,
|
||||
expect.objectContaining({
|
||||
latestScoreId: "publisherAbuseScores:spacesq-shape",
|
||||
label: "pass",
|
||||
lastScoredAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"publisherAbuseReviewEvents",
|
||||
expect.objectContaining({
|
||||
nominationId: staleV2Nomination._id,
|
||||
eventType: "nomination_score_updated",
|
||||
previousLabel: "potential_ban_candidate",
|
||||
nextLabel: "pass",
|
||||
scoreId: "publisherAbuseScores:spacesq-shape",
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"publisherAbuseScoreRuns:run",
|
||||
expect.objectContaining({
|
||||
passCount: 1,
|
||||
reviewCount: 0,
|
||||
potentialBanCandidateCount: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears stale higher-severity aggregate nominations after a downgrade to review", async () => {
|
||||
const modelConfig = {
|
||||
...TEST_MODEL_CONFIG,
|
||||
modelVersion: "publisher-abuse-pressure.v4",
|
||||
skillPivot: 200,
|
||||
minPublishedSkillsForAggregateLabel: 200,
|
||||
};
|
||||
const staleV2Nomination = makeNomination({
|
||||
_id: "publisherAbuseReviewNominations:stale-v2",
|
||||
ownerKey: "publisher:publishers:downgraded",
|
||||
ownerPublisherId: "publishers:downgraded",
|
||||
ownerUserId: "users:downgraded",
|
||||
latestScoreId: "publisherAbuseScores:old-v2-score",
|
||||
handleSnapshot: "downgraded",
|
||||
modelVersion: "publisher-abuse-pressure.v2",
|
||||
label: "potential_ban_candidate",
|
||||
status: "pending",
|
||||
lastScoredAt: 1,
|
||||
updatedAt: 1,
|
||||
});
|
||||
const insert = vi.fn(async (table: string) =>
|
||||
table === "publisherAbuseReviewNominations"
|
||||
? "publisherAbuseReviewNominations:current-v4"
|
||||
: `${table}:new`,
|
||||
);
|
||||
const patch = vi.fn(async () => null);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async () => ({
|
||||
_id: "publisherAbuseScoreRuns:run",
|
||||
status: "running",
|
||||
phase: "finalizing",
|
||||
modelVersion: modelConfig.modelVersion,
|
||||
modelConfig,
|
||||
scoredPublishers: 1,
|
||||
finalizedScores: 0,
|
||||
passCount: 0,
|
||||
reviewCount: 0,
|
||||
potentialBanCandidateCount: 0,
|
||||
nominatedPublishers: 0,
|
||||
sumLogPressure: 3,
|
||||
sumSquaredLogPressure: 9,
|
||||
})),
|
||||
insert,
|
||||
patch,
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherAbuseScores") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
order: () => ({
|
||||
paginate: async () => ({
|
||||
page: [
|
||||
{
|
||||
_id: "publisherAbuseScores:downgraded-v4",
|
||||
ownerKey: "publisher:publishers:downgraded",
|
||||
ownerPublisherId: "publishers:downgraded",
|
||||
ownerUserId: "users:downgraded",
|
||||
handleSnapshot: "downgraded",
|
||||
modelVersion: modelConfig.modelVersion,
|
||||
pressure: 100,
|
||||
logPressure: 5,
|
||||
publishedSkills: 220,
|
||||
totalInstalls: 80,
|
||||
totalStars: 2,
|
||||
totalDownloads: 2_000,
|
||||
installsPerSkill: 0.36,
|
||||
starsPerSkill: 0.009,
|
||||
downloadsPerSkill: 9.09,
|
||||
reasonCodes: ["high_catalog_volume"],
|
||||
},
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publisherAbuseReviewNominations") {
|
||||
return {
|
||||
withIndex: (
|
||||
indexName: string,
|
||||
build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
expect(indexName).toBe("by_owner_key_and_model_version");
|
||||
const constraints: Record<string, unknown> = {};
|
||||
const q = {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
build(q);
|
||||
return {
|
||||
first: async () => null,
|
||||
take: async () =>
|
||||
constraints.ownerKey === staleV2Nomination.ownerKey ? [staleV2Nomination] : [],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery();
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(finalizeHandler(ctx, { runId: "publisherAbuseScoreRuns:run" })).resolves.toEqual(
|
||||
expect.objectContaining({ isDone: true, finalized: 1, nominations: 1 }),
|
||||
);
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"publisherAbuseScores:downgraded-v4",
|
||||
expect.objectContaining({ label: "review", zScore: 2 }),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"publisherAbuseReviewNominations",
|
||||
expect.objectContaining({
|
||||
latestScoreId: "publisherAbuseScores:downgraded-v4",
|
||||
modelVersion: modelConfig.modelVersion,
|
||||
label: "review",
|
||||
status: "pending",
|
||||
}),
|
||||
);
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
staleV2Nomination._id,
|
||||
expect.objectContaining({
|
||||
latestScoreId: "publisherAbuseScores:downgraded-v4",
|
||||
label: "pass",
|
||||
lastScoredAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"publisherAbuseReviewEvents",
|
||||
expect.objectContaining({
|
||||
nominationId: staleV2Nomination._id,
|
||||
eventType: "nomination_score_updated",
|
||||
previousLabel: "potential_ban_candidate",
|
||||
nextLabel: "pass",
|
||||
scoreId: "publisherAbuseScores:downgraded-v4",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not clear newer aggregate nominations when an older stored run finalizes", async () => {
|
||||
const modelConfig = {
|
||||
...TEST_MODEL_CONFIG,
|
||||
modelVersion: "publisher-abuse-pressure.v2",
|
||||
minPublishedSkillsForAggregateLabel: undefined,
|
||||
};
|
||||
const newerV4Nomination = makeNomination({
|
||||
_id: "publisherAbuseReviewNominations:newer-v4",
|
||||
ownerKey: "publisher:publishers:late-v2",
|
||||
ownerPublisherId: "publishers:late-v2",
|
||||
ownerUserId: "users:late-v2",
|
||||
latestScoreId: "publisherAbuseScores:newer-v4-score",
|
||||
handleSnapshot: "late-v2",
|
||||
modelVersion: "publisher-abuse-pressure.v4",
|
||||
label: "potential_ban_candidate",
|
||||
status: "pending",
|
||||
lastScoredAt: 2,
|
||||
updatedAt: 2,
|
||||
});
|
||||
const insert = vi.fn(async (table: string) => `${table}:new`);
|
||||
const patch = vi.fn(async () => null);
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async () => ({
|
||||
_id: "publisherAbuseScoreRuns:late-v2",
|
||||
status: "running",
|
||||
phase: "finalizing",
|
||||
modelVersion: modelConfig.modelVersion,
|
||||
modelConfig,
|
||||
scoredPublishers: 1,
|
||||
finalizedScores: 0,
|
||||
passCount: 0,
|
||||
reviewCount: 0,
|
||||
potentialBanCandidateCount: 0,
|
||||
nominatedPublishers: 0,
|
||||
sumLogPressure: 0,
|
||||
sumSquaredLogPressure: 0,
|
||||
})),
|
||||
insert,
|
||||
patch,
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "publisherAbuseScores") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
order: () => ({
|
||||
paginate: async () => ({
|
||||
page: [
|
||||
{
|
||||
_id: "publisherAbuseScores:late-v2-score",
|
||||
ownerKey: "publisher:publishers:late-v2",
|
||||
ownerPublisherId: "publishers:late-v2",
|
||||
ownerUserId: "users:late-v2",
|
||||
handleSnapshot: "late-v2",
|
||||
modelVersion: modelConfig.modelVersion,
|
||||
pressure: 1,
|
||||
logPressure: 0,
|
||||
publishedSkills: 220,
|
||||
totalInstalls: 600,
|
||||
totalStars: 20,
|
||||
totalDownloads: 80_000,
|
||||
installsPerSkill: 2.72,
|
||||
starsPerSkill: 0.09,
|
||||
downloadsPerSkill: 363.64,
|
||||
reasonCodes: [],
|
||||
},
|
||||
],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (table === "publisherAbuseReviewNominations") {
|
||||
return {
|
||||
withIndex: (
|
||||
indexName: string,
|
||||
build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown,
|
||||
) => {
|
||||
expect(indexName).toBe("by_owner_key_and_model_version");
|
||||
const constraints: Record<string, unknown> = {};
|
||||
const q = {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return q;
|
||||
},
|
||||
};
|
||||
build(q);
|
||||
return {
|
||||
take: async () =>
|
||||
constraints.ownerKey === newerV4Nomination.ownerKey ? [newerV4Nomination] : [],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery();
|
||||
throw new Error(`unexpected table ${table}`);
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
finalizeHandler(ctx, { runId: "publisherAbuseScoreRuns:late-v2" }),
|
||||
).resolves.toEqual(expect.objectContaining({ isDone: true, finalized: 1, nominations: 0 }));
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
"publisherAbuseScores:late-v2-score",
|
||||
expect.objectContaining({ label: "pass", zScore: 0 }),
|
||||
);
|
||||
expect(patch).not.toHaveBeenCalledWith(
|
||||
newerV4Nomination._id,
|
||||
expect.objectContaining({ label: "pass" }),
|
||||
);
|
||||
expect(insert).not.toHaveBeenCalledWith(
|
||||
"publisherAbuseReviewEvents",
|
||||
expect.objectContaining({
|
||||
nominationId: newerV4Nomination._id,
|
||||
nextLabel: "pass",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create nominations for official publisher score rows left by an older run", async () => {
|
||||
const insert = vi.fn(async (table: string) => `${table}:new`);
|
||||
const patch = vi.fn(async () => null);
|
||||
@@ -3116,7 +3633,7 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
if (table === "publisherAbuseReviewNominations") {
|
||||
return {
|
||||
withIndex: () => ({
|
||||
first: async () => null,
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -3214,6 +3731,7 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
reviewedByUserId: "users:admin",
|
||||
reviewedAt: 100,
|
||||
}),
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -3314,6 +3832,7 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
reviewedByUserId: "users:admin",
|
||||
reviewedAt: 100,
|
||||
}),
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -3413,6 +3932,7 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
reviewedByUserId: "users:admin",
|
||||
reviewedAt: 100,
|
||||
}),
|
||||
take: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -3492,13 +4012,15 @@ describe("publisher abuse dry-run persistence", () => {
|
||||
};
|
||||
}
|
||||
if (table === "publisherAbuseReviewNominations") {
|
||||
const existingNomination = {
|
||||
_id: "publisherAbuseReviewNominations:existing",
|
||||
ownerKey: "publisher:publishers:recovered",
|
||||
modelVersion: "publisher-abuse-pressure.v2",
|
||||
label: "review",
|
||||
};
|
||||
return {
|
||||
withIndex: () => ({
|
||||
first: async () => ({
|
||||
_id: "publisherAbuseReviewNominations:existing",
|
||||
ownerKey: "publisher:publishers:recovered",
|
||||
label: "review",
|
||||
}),
|
||||
take: async () => [existingNomination],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+103
-63
@@ -18,11 +18,12 @@ import {
|
||||
computeCurrentSkillTemporalAbuseScore,
|
||||
computeHistoricalSkillTemporalAbuseScore,
|
||||
computePublisherAbuseRawScore,
|
||||
labelForPublisherAbuseScore,
|
||||
computeTemporalAbuseCohortBenchmark,
|
||||
computeTemporalPublisherAbuseZScore,
|
||||
DEFAULT_PUBLISHER_ABUSE_MODEL_CONFIG,
|
||||
isPublisherAbuseCheckEligible,
|
||||
labelForTemporalPublisherAbuse,
|
||||
labelForPublisherAbuseZScore,
|
||||
PUBLISHER_TEMPORAL_ABUSE_MODEL_VERSION,
|
||||
summarizePublisherAbuseLogPressure,
|
||||
type PublisherAbuseInput,
|
||||
@@ -40,10 +41,10 @@ const MAX_MAX_PAGES = 50;
|
||||
const ACTION_CONTINUATION_DELAY_MS = 60_000;
|
||||
const MAX_ACTIVE_SKILL_FALLBACK_SCAN = 500;
|
||||
const MAX_ACTIVE_SKILL_FALLBACK_SCANS_PER_PAGE = 20;
|
||||
const MAX_OWNER_NOMINATION_VERSION_SCAN = 20;
|
||||
const MAX_REVIEW_DASHBOARD_SCAN_MULTIPLIER = 3;
|
||||
const MAX_REVIEW_DASHBOARD_SCORE_SCAN_MULTIPLIER = 32;
|
||||
const MAX_REVIEW_DASHBOARD_SCORE_SCAN = 2000;
|
||||
const MAX_BAN_REASON_LENGTH = 500;
|
||||
const DEFAULT_TEMPORAL_BATCH_SIZE = 50;
|
||||
const MAX_TEMPORAL_BATCH_SIZE = 100;
|
||||
const DEFAULT_TEMPORAL_CANDIDATE_LIMIT = 1000;
|
||||
@@ -300,56 +301,10 @@ export const banPublisherAbuseOwner = mutation({
|
||||
}
|
||||
await requirePublisherAbuseNominationNotExcluded(ctx, nomination);
|
||||
|
||||
const reason = normalizeBanReason(args.reason);
|
||||
await ctx.runMutation(internal.users.banUserInternal, {
|
||||
actorUserId: user._id,
|
||||
targetUserId: nomination.ownerUserId,
|
||||
reason,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
await setPublisherAbuseReviewStatusWithActor(ctx, {
|
||||
nomination,
|
||||
status: "banned",
|
||||
notes: reason,
|
||||
actorUserId: user._id,
|
||||
now,
|
||||
});
|
||||
|
||||
return { ok: true, status: "banned" as const };
|
||||
throw new Error("Publisher abuse bans are disabled while the scoring model is flag-only.");
|
||||
},
|
||||
});
|
||||
|
||||
async function setPublisherAbuseReviewStatusWithActor(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
args: {
|
||||
nomination: Doc<"publisherAbuseReviewNominations">;
|
||||
status: TriageStatus;
|
||||
notes: string | undefined;
|
||||
actorUserId: Id<"users">;
|
||||
now: number;
|
||||
},
|
||||
) {
|
||||
await ctx.db.patch(args.nomination._id, {
|
||||
status: args.status,
|
||||
reviewedByUserId: args.status === "pending" ? undefined : args.actorUserId,
|
||||
reviewedAt: args.status === "pending" ? undefined : args.now,
|
||||
notes: args.notes,
|
||||
updatedAt: args.now,
|
||||
});
|
||||
await ctx.db.insert("publisherAbuseReviewEvents", {
|
||||
nominationId: args.nomination._id,
|
||||
ownerKey: args.nomination.ownerKey,
|
||||
actorUserId: args.actorUserId,
|
||||
scoreId: args.nomination.latestScoreId,
|
||||
eventType: "triage_status_changed",
|
||||
previousStatus: args.nomination.status,
|
||||
nextStatus: args.status,
|
||||
notes: args.notes,
|
||||
createdAt: args.now,
|
||||
});
|
||||
}
|
||||
|
||||
function requireFreshPublisherAbuseReviewNomination(
|
||||
nomination: Doc<"publisherAbuseReviewNominations">,
|
||||
expected: { expectedLatestScoreId: Id<"publisherAbuseScores">; expectedUpdatedAt: number },
|
||||
@@ -643,8 +598,10 @@ export async function finalizePublisherAbuseScoresPageInternalHandler(
|
||||
finalized += 1;
|
||||
continue;
|
||||
}
|
||||
const zScore = (score.logPressure - meanLogPressure) / safeStdDev;
|
||||
const label = labelForPublisherAbuseZScore(zScore, modelConfig);
|
||||
const zScore = isPublisherAbuseCheckEligible(score, modelConfig)
|
||||
? (score.logPressure - meanLogPressure) / safeStdDev
|
||||
: 0;
|
||||
const label = labelForPublisherAbuseScore(score, zScore, modelConfig);
|
||||
const rank = rankedScoresSoFar + ranked + 1;
|
||||
labelCounts[label] += 1;
|
||||
ranked += 1;
|
||||
@@ -1548,6 +1505,7 @@ async function upsertPublisherAbuseReviewNomination(
|
||||
nextStatus: shouldReopen ? "pending" : undefined,
|
||||
createdAt: args.now,
|
||||
});
|
||||
await markStaleAggregatePublisherAbuseReviewNominationsAsPass(ctx, args);
|
||||
return existing._id;
|
||||
}
|
||||
|
||||
@@ -1575,9 +1533,38 @@ async function upsertPublisherAbuseReviewNomination(
|
||||
nextLabel: args.score.label,
|
||||
createdAt: args.now,
|
||||
});
|
||||
await markStaleAggregatePublisherAbuseReviewNominationsAsPass(ctx, args);
|
||||
return nominationId;
|
||||
}
|
||||
|
||||
async function markStaleAggregatePublisherAbuseReviewNominationsAsPass(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
args: {
|
||||
score: ScoreDoc;
|
||||
run: ScoreRun;
|
||||
now: number;
|
||||
},
|
||||
) {
|
||||
if (!isAggregatePublisherAbuseModelVersion(args.score.modelVersion)) return null;
|
||||
|
||||
const existingNominations = await ctx.db
|
||||
.query("publisherAbuseReviewNominations")
|
||||
.withIndex("by_owner_key_and_model_version", (q) => q.eq("ownerKey", args.score.ownerKey))
|
||||
.take(MAX_OWNER_NOMINATION_VERSION_SCAN);
|
||||
|
||||
let updatedNominationId: Id<"publisherAbuseReviewNominations"> | null = null;
|
||||
for (const existing of existingNominations) {
|
||||
if (
|
||||
!shouldClearStaleAggregatePublisherAbuseReviewNomination(existing, args.score.modelVersion)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await markPublisherAbuseReviewNominationAsPass(ctx, { ...args, existing });
|
||||
updatedNominationId ??= existing._id;
|
||||
}
|
||||
return updatedNominationId;
|
||||
}
|
||||
|
||||
async function updateExistingPublisherAbuseReviewNominationForPass(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
args: {
|
||||
@@ -1586,15 +1573,74 @@ async function updateExistingPublisherAbuseReviewNominationForPass(
|
||||
now: number;
|
||||
},
|
||||
) {
|
||||
const existing = await ctx.db
|
||||
if (!isAggregatePublisherAbuseModelVersion(args.score.modelVersion)) {
|
||||
const existing = await ctx.db
|
||||
.query("publisherAbuseReviewNominations")
|
||||
.withIndex("by_owner_key_and_model_version", (q) =>
|
||||
q.eq("ownerKey", args.score.ownerKey).eq("modelVersion", args.score.modelVersion),
|
||||
)
|
||||
.first();
|
||||
if (!existing) return null;
|
||||
await markPublisherAbuseReviewNominationAsPass(ctx, { ...args, existing });
|
||||
return existing._id;
|
||||
}
|
||||
|
||||
const existingNominations = await ctx.db
|
||||
.query("publisherAbuseReviewNominations")
|
||||
.withIndex("by_owner_key_and_model_version", (q) =>
|
||||
q.eq("ownerKey", args.score.ownerKey).eq("modelVersion", args.score.modelVersion),
|
||||
)
|
||||
.first();
|
||||
.withIndex("by_owner_key_and_model_version", (q) => q.eq("ownerKey", args.score.ownerKey))
|
||||
.take(MAX_OWNER_NOMINATION_VERSION_SCAN);
|
||||
|
||||
if (!existing) return null;
|
||||
let updatedNominationId: Id<"publisherAbuseReviewNominations"> | null = null;
|
||||
for (const existing of existingNominations) {
|
||||
if (
|
||||
existing.modelVersion !== args.score.modelVersion &&
|
||||
!shouldClearStaleAggregatePublisherAbuseReviewNomination(existing, args.score.modelVersion)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
await markPublisherAbuseReviewNominationAsPass(ctx, { ...args, existing });
|
||||
updatedNominationId ??= existing._id;
|
||||
}
|
||||
return updatedNominationId;
|
||||
}
|
||||
|
||||
function shouldClearStaleAggregatePublisherAbuseReviewNomination(
|
||||
nomination: Doc<"publisherAbuseReviewNominations">,
|
||||
currentModelVersion: string,
|
||||
) {
|
||||
const nominationVersion = aggregatePublisherAbuseModelVersionNumber(nomination.modelVersion);
|
||||
const currentVersion = aggregatePublisherAbuseModelVersionNumber(currentModelVersion);
|
||||
return (
|
||||
nominationVersion !== null &&
|
||||
currentVersion !== null &&
|
||||
nominationVersion < currentVersion &&
|
||||
nomination.status === "pending" &&
|
||||
nomination.label !== "pass"
|
||||
);
|
||||
}
|
||||
|
||||
function isAggregatePublisherAbuseModelVersion(modelVersion: string) {
|
||||
return modelVersion.startsWith("publisher-abuse-pressure.");
|
||||
}
|
||||
|
||||
function aggregatePublisherAbuseModelVersionNumber(modelVersion: string) {
|
||||
const match = /^publisher-abuse-pressure\.v(\d+)$/.exec(modelVersion);
|
||||
const versionText = match?.[1];
|
||||
if (!versionText) return null;
|
||||
const version = Number(versionText);
|
||||
return Number.isSafeInteger(version) ? version : null;
|
||||
}
|
||||
|
||||
async function markPublisherAbuseReviewNominationAsPass(
|
||||
ctx: Pick<MutationCtx, "db">,
|
||||
args: {
|
||||
existing: Doc<"publisherAbuseReviewNominations">;
|
||||
score: ScoreDoc;
|
||||
run: ScoreRun;
|
||||
now: number;
|
||||
},
|
||||
) {
|
||||
const { existing } = args;
|
||||
await ctx.db.patch(existing._id, {
|
||||
latestScoreId: args.score._id,
|
||||
label: "pass",
|
||||
@@ -1906,12 +1952,6 @@ function summarizeUserForAbuseReview(user: Doc<"users">) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBanReason(rawReason?: string) {
|
||||
const reason = rawReason?.trim();
|
||||
if (!reason) return undefined;
|
||||
return reason.slice(0, MAX_BAN_REASON_LENGTH);
|
||||
}
|
||||
|
||||
function publisherAbuseLabelSeverity(label: PublisherAbuseLabel) {
|
||||
if (label === "potential_ban_candidate") return 2;
|
||||
if (label === "review") return 1;
|
||||
|
||||
@@ -478,6 +478,8 @@ const publisherAbuseModelConfigValidator = v.object({
|
||||
starsPerSkillPivot: v.number(),
|
||||
downloadsPerSkillPivot: v.number(),
|
||||
outputElasticity: v.number(),
|
||||
engagementElasticity: v.optional(v.number()),
|
||||
minPublishedSkillsForAggregateLabel: v.optional(v.number()),
|
||||
installTrustElasticity: v.number(),
|
||||
starTrustElasticity: v.number(),
|
||||
downloadDemandElasticity: v.number(),
|
||||
|
||||
@@ -45,10 +45,20 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic
|
||||
- Publisher abuse scoring is a staff review signal for bulk-publishing abuse.
|
||||
It must not directly ban users; staff action goes through the publisher abuse
|
||||
nomination review path.
|
||||
- Catalog volume pressure is linear up to the 100-skill pivot and superlinear
|
||||
above it. Doubling an already-bulk catalog should raise review pressure
|
||||
meaningfully more than 2x while still allowing legitimate high-engagement
|
||||
publishers to stay below review thresholds.
|
||||
- Publisher abuse enforcement is flag-only during the current rollout. The
|
||||
scorer can open review and high-risk nominations, but the publisher-abuse
|
||||
action path must not ban users until enforcement is explicitly re-enabled.
|
||||
- Aggregate publisher spam-abuse labels start at the 200-skill pivot. Below
|
||||
that pivot, publishers can contribute to the population baseline, but they
|
||||
cannot receive aggregate spam reason codes or be nominated by this score path.
|
||||
- At or above the pivot, catalog volume raises pressure multiplicatively while
|
||||
installs, stars, and downloads lower it. Legitimate high-adoption publishers
|
||||
with large catalogs should stay below review thresholds.
|
||||
- Engagement expectations scale sublinearly with catalog size. The scorer must
|
||||
not require every extra skill to have the same per-skill installs/stars as the
|
||||
first 200 skills; a few successful skills can lower pressure for the broader
|
||||
catalog. Catalog size still raises pressure, so large low-adoption catalogs
|
||||
remain eligible for review or ban-candidate labels.
|
||||
- Official publishers are excluded from publisher abuse scoring and
|
||||
enforcement. An excluded publisher must not contribute to score-run cohort
|
||||
statistics, receive a score label/rank, open or update a nomination, appear in
|
||||
|
||||
@@ -547,14 +547,11 @@ describe("Management", () => {
|
||||
expect(screen.getByText("of 42 scored")).toBeTruthy();
|
||||
expect(screen.getAllByText("spammy-pub").length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const banReason = screen.getByPlaceholderText("Why are you taking this action? (optional)");
|
||||
fireEvent.change(banReason, { target: { value: "first publisher reason" } });
|
||||
expect(banReason).toHaveProperty("value", "first publisher reason");
|
||||
expect(screen.getByText("Flagged for review")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Ban user" })).toBeNull();
|
||||
expect(screen.queryByPlaceholderText("Why are you taking this action? (optional)")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByText("second-pub"));
|
||||
expect(
|
||||
screen.getByPlaceholderText("Why are you taking this action? (optional)"),
|
||||
).toHaveProperty("value", "");
|
||||
expect(screen.queryByRole("button", { name: "Ban user" })).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the abuse drawer when search hides the selected nomination", async () => {
|
||||
@@ -725,9 +722,11 @@ describe("Management", () => {
|
||||
expect(screen.getByText("Peer 30d P95")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("marks the abuse nomination resolved after banning its linked user", async () => {
|
||||
it("shows publisher abuse ban candidates as flag-only review items", () => {
|
||||
const banUser = vi.fn(async () => ({ ok: true }));
|
||||
const banPublisherAbuseOwner = vi.fn(async () => ({ ok: true, status: "banned" }));
|
||||
const banPublisherAbuseOwner = vi.fn(async () => {
|
||||
throw new Error("Publisher abuse bans are disabled");
|
||||
});
|
||||
const item = makePublisherAbuseItem();
|
||||
useMutationMock.mockImplementation((mutation) => {
|
||||
const name = getFunctionName(mutation);
|
||||
@@ -757,92 +756,13 @@ describe("Management", () => {
|
||||
render(<Management />);
|
||||
|
||||
fireEvent.click(screen.getByText("spammy-pub"));
|
||||
const banReason = screen.getByPlaceholderText("Why are you taking this action? (optional)");
|
||||
expect(banReason.getAttribute("maxlength")).toBe("500");
|
||||
fireEvent.change(banReason, { target: { value: "confirmed spam" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ban user" }));
|
||||
const confirmButtons = screen.getAllByRole("button", { name: "Ban user" });
|
||||
fireEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(banPublisherAbuseOwner).toHaveBeenCalledWith({
|
||||
nominationId: "publisherAbuseReviewNominations:1",
|
||||
expectedLatestScoreId: "publisherAbuseScores:1",
|
||||
expectedUpdatedAt: 1,
|
||||
reason: "confirmed spam",
|
||||
});
|
||||
expect(banUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables abuse bans for the current user", () => {
|
||||
const item = makePublisherAbuseItem({
|
||||
handle: "self-pub",
|
||||
ownerKey: "user:admin",
|
||||
ownerUserId: "users:admin",
|
||||
});
|
||||
useQueryMock.mockImplementation((query, args) => {
|
||||
if (args === "skip") return undefined;
|
||||
const name = getFunctionName(query);
|
||||
if (name === "skills:listRecentVersions") return [];
|
||||
if (name === "skills:listReportedSkills") return [];
|
||||
if (name === "skills:listDuplicateCandidates") return [];
|
||||
if (name === "publisherAbuse:listReviewDashboard") {
|
||||
return {
|
||||
latestRun: null,
|
||||
pendingItems: [],
|
||||
pendingPotentialBanCandidateItems: [item],
|
||||
pendingReviewItems: [],
|
||||
recentResolvedItems: [],
|
||||
};
|
||||
}
|
||||
if (name === "users:list") return { items: [], total: 0 };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(<Management />);
|
||||
|
||||
fireEvent.click(screen.getByText("self-pub"));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Ban user" })).toHaveProperty("disabled", true);
|
||||
});
|
||||
|
||||
it("disables abuse bans for admin-owned candidates when viewed by moderators", () => {
|
||||
authUser = {
|
||||
_id: "users:moderator",
|
||||
handle: "moderator",
|
||||
role: "moderator",
|
||||
};
|
||||
const item = makePublisherAbuseItem({
|
||||
handle: "admin-pub",
|
||||
ownerKey: "user:owner-admin",
|
||||
ownerRole: "admin",
|
||||
ownerUserId: "users:owner-admin",
|
||||
});
|
||||
useQueryMock.mockImplementation((query, args) => {
|
||||
if (args === "skip") return undefined;
|
||||
const name = getFunctionName(query);
|
||||
if (name === "skills:listRecentVersions") return [];
|
||||
if (name === "skills:listReportedSkills") return [];
|
||||
if (name === "skills:listDuplicateCandidates") return [];
|
||||
if (name === "publisherAbuse:listReviewDashboard") {
|
||||
return {
|
||||
latestRun: null,
|
||||
pendingItems: [],
|
||||
pendingPotentialBanCandidateItems: [item],
|
||||
pendingReviewItems: [],
|
||||
recentResolvedItems: [],
|
||||
};
|
||||
}
|
||||
if (name === "users:list") return { items: [], total: 0 };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
render(<Management />);
|
||||
|
||||
fireEvent.click(screen.getByText("admin-pub"));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Ban user" })).toHaveProperty("disabled", true);
|
||||
expect(screen.getByText("Flagged for review")).toBeTruthy();
|
||||
expect(screen.getByText(/Publisher-abuse bans are disabled/i)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Ban user" })).toBeNull();
|
||||
expect(screen.queryByPlaceholderText("Why are you taking this action? (optional)")).toBeNull();
|
||||
expect(banPublisherAbuseOwner).not.toHaveBeenCalled();
|
||||
expect(banUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not show non-ban resolution controls for potential-ban nominations", () => {
|
||||
@@ -876,7 +796,7 @@ describe("Management", () => {
|
||||
|
||||
fireEvent.click(screen.getByText("spammy-pub"));
|
||||
|
||||
expect(screen.getByRole("button", { name: "Ban user" })).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Ban user" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Mark reviewed" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "False positive" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Needs discussion" })).toBeNull();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Ban, Copy, ExternalLink, RefreshCcw, Search } from "lucide-react";
|
||||
import { Copy, ExternalLink, RefreshCcw, Search } from "lucide-react";
|
||||
import type { Id } from "../../../convex/_generated/dataModel";
|
||||
import { Badge, type BadgeProps } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "../../components/ui/sheet";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import {
|
||||
formatRatio,
|
||||
formatScore,
|
||||
@@ -22,40 +21,29 @@ import {
|
||||
type PublisherAbuseReviewItem,
|
||||
type PublisherAbuseReviewScore,
|
||||
type PublisherAbuseTab,
|
||||
USER_BAN_REASON_MAX_LENGTH,
|
||||
} from "./managementShared";
|
||||
|
||||
export function AbusePage({
|
||||
admin,
|
||||
currentUserId,
|
||||
dashboard,
|
||||
detail,
|
||||
items,
|
||||
notes,
|
||||
search,
|
||||
selectedItem,
|
||||
selectedNominationId,
|
||||
tab,
|
||||
onBanOwner,
|
||||
onChangeNotes,
|
||||
onChangeSearch,
|
||||
onChangeTab,
|
||||
onClose,
|
||||
onRefresh,
|
||||
onSelect,
|
||||
}: {
|
||||
admin: boolean;
|
||||
currentUserId: Id<"users"> | null;
|
||||
dashboard: PublisherAbuseReviewDashboard | undefined;
|
||||
detail: PublisherAbuseReviewDetail | undefined;
|
||||
items: PublisherAbuseReviewItem[];
|
||||
notes: string;
|
||||
search: string;
|
||||
selectedItem: PublisherAbuseReviewItem | null;
|
||||
selectedNominationId: Id<"publisherAbuseReviewNominations"> | null;
|
||||
tab: PublisherAbuseTab;
|
||||
onBanOwner: (item: PublisherAbuseReviewItem) => void;
|
||||
onChangeNotes: (value: string) => void;
|
||||
onChangeSearch: (value: string) => void;
|
||||
onChangeTab: (value: PublisherAbuseTab) => void;
|
||||
onClose: () => void;
|
||||
@@ -65,7 +53,6 @@ export function AbusePage({
|
||||
const latestRun = dashboard?.latestRun ?? null;
|
||||
const selectedScore = selectedItem?.latestScore ?? null;
|
||||
const selectedPublisher = selectedItem?.publisher ?? null;
|
||||
const canBanSelectedUser = canBanPublisherAbuseOwner(selectedItem, currentUserId, admin);
|
||||
const visiblePending = dashboard ? getPublisherAbuseVisiblePendingItems(dashboard) : [];
|
||||
const totalPending = visiblePending.length;
|
||||
const potentialBan = visiblePending.filter(
|
||||
@@ -425,26 +412,11 @@ export function AbusePage({
|
||||
</section>
|
||||
) : selectedItem.nomination.label === "potential_ban_candidate" ? (
|
||||
<section className="pa-zone pa-review">
|
||||
<div className="pa-section-label">Triage note</div>
|
||||
<Textarea
|
||||
maxLength={USER_BAN_REASON_MAX_LENGTH}
|
||||
placeholder="Why are you taking this action? (optional)"
|
||||
value={notes}
|
||||
onChange={(event) => onChangeNotes(event.target.value)}
|
||||
/>
|
||||
<div className="pa-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="pa-ban"
|
||||
disabled={!canBanSelectedUser}
|
||||
onClick={() => onBanOwner(selectedItem)}
|
||||
>
|
||||
<Ban size={14} />
|
||||
Ban user
|
||||
</Button>
|
||||
</div>
|
||||
<div className="pa-section-label">Flagged for review</div>
|
||||
<p className="pa-hint">
|
||||
This publisher is in the high-risk bucket. Publisher-abuse bans are disabled
|
||||
while the scoring model is flag-only.
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<section className="pa-zone pa-review">
|
||||
@@ -607,18 +579,6 @@ function isVisiblePublisherAbuseItem(item: PublisherAbuseReviewItem) {
|
||||
);
|
||||
}
|
||||
|
||||
export function canBanPublisherAbuseOwner(
|
||||
item: PublisherAbuseReviewItem | null,
|
||||
currentUserId: Id<"users"> | null,
|
||||
admin: boolean,
|
||||
) {
|
||||
const ownerUser = item?.ownerUser;
|
||||
if (!ownerUser?._id) return false;
|
||||
if (ownerUser._id === currentUserId) return false;
|
||||
if (ownerUser.role === "admin" && !admin) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getPublisherAbuseVisiblePendingItems(dashboard: PublisherAbuseReviewDashboard) {
|
||||
return [...dashboard.pendingPotentialBanCandidateItems, ...dashboard.pendingReviewItems].filter(
|
||||
isVisiblePublisherAbuseItem,
|
||||
|
||||
@@ -30,7 +30,6 @@ import { isAdmin, isModerator } from "../lib/roles";
|
||||
import { useAuthStatus } from "../lib/useAuthStatus";
|
||||
import {
|
||||
AbusePage,
|
||||
canBanPublisherAbuseOwner,
|
||||
comparePublisherAbuseItems,
|
||||
filterPublisherAbuseItems,
|
||||
getPublisherAbuseItemsForTab,
|
||||
@@ -47,7 +46,6 @@ import {
|
||||
type ManagementUserListResult,
|
||||
type ManagementView,
|
||||
type PluginByNameResult,
|
||||
type PublisherAbuseReviewItem,
|
||||
type PublisherAbuseTab,
|
||||
type RecentVersionEntry,
|
||||
type ReportedSkillEntry,
|
||||
@@ -233,7 +231,6 @@ export function Management() {
|
||||
const setDeprecatedBadge = useMutation(api.skills.setDeprecatedBadge);
|
||||
const setSkillManualOverride = useMutation(api.skills.setSkillManualOverride);
|
||||
const clearSkillManualOverride = useMutation(api.skills.clearSkillManualOverride);
|
||||
const banPublisherAbuseOwnerMutation = useMutation(api.publisherAbuse.banPublisherAbuseOwner);
|
||||
const startPublisherAbuseScoreRun = useAction(api.publisherAbuse.startPublisherAbuseScoreRun);
|
||||
|
||||
const [selectedDuplicate, setSelectedDuplicate] = useState("");
|
||||
@@ -251,7 +248,6 @@ export function Management() {
|
||||
const [publisherAbuseTab, setPublisherAbuseTab] =
|
||||
useState<PublisherAbuseTab>("potential_ban_candidate");
|
||||
const [publisherAbuseSearch, setPublisherAbuseSearch] = useState("");
|
||||
const [publisherAbuseNotes, setPublisherAbuseNotes] = useState("");
|
||||
const [selectedPublisherAbuseNominationId, setSelectedPublisherAbuseNominationId] =
|
||||
useState<Id<"publisherAbuseReviewNominations"> | null>(null);
|
||||
|
||||
@@ -340,10 +336,6 @@ export function Management() {
|
||||
if (!stillVisible) setSelectedPublisherAbuseNominationId(null);
|
||||
}, [filteredPublisherAbuseItems, selectedPublisherAbuseNominationId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPublisherAbuseNotes("");
|
||||
}, [selectedPublisherAbuseNominationId]);
|
||||
|
||||
if (isAuthLoading) {
|
||||
return <ManagementSkeleton />;
|
||||
}
|
||||
@@ -533,34 +525,6 @@ export function Management() {
|
||||
});
|
||||
};
|
||||
|
||||
const banPublisherAbuseOwner = (item: PublisherAbuseReviewItem) => {
|
||||
const ownerUser = item.ownerUser;
|
||||
if (!ownerUser || !canBanPublisherAbuseOwner(item, me?._id ?? null, admin)) return;
|
||||
const label = `@${ownerUser.handle ?? ownerUser.name ?? item.nomination.handleSnapshot}`;
|
||||
// The review notes box above the Ban button is the ban reason — no separate prompt.
|
||||
const reason = publisherAbuseNotes.trim() || undefined;
|
||||
setConfirmRequest({
|
||||
title: `Ban ${label}?`,
|
||||
body: "Hides their skills and personal package/plugin resources, and revokes package publish tokens.",
|
||||
confirmLabel: "Ban user",
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
void banPublisherAbuseOwnerMutation({
|
||||
nominationId: item.nomination._id,
|
||||
expectedLatestScoreId: item.nomination.latestScoreId,
|
||||
expectedUpdatedAt: item.nomination.updatedAt,
|
||||
reason,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(`Banned ${label}.`);
|
||||
setPublisherAbuseNotes("");
|
||||
setSelectedPublisherAbuseNominationId(null);
|
||||
})
|
||||
.catch((error) => toast.error(formatMutationError(error)));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="management-shell">
|
||||
<ManagementSidebar
|
||||
@@ -585,18 +549,13 @@ export function Management() {
|
||||
|
||||
{activeView === "abuse" ? (
|
||||
<AbusePage
|
||||
admin={admin}
|
||||
currentUserId={me?._id ?? null}
|
||||
dashboard={publisherAbuseDashboard}
|
||||
detail={selectedPublisherAbuseDetail}
|
||||
items={filteredPublisherAbuseItems}
|
||||
notes={publisherAbuseNotes}
|
||||
search={publisherAbuseSearch}
|
||||
selectedItem={selectedPublisherAbuseItem}
|
||||
selectedNominationId={selectedPublisherAbuseNominationId}
|
||||
tab={publisherAbuseTab}
|
||||
onBanOwner={banPublisherAbuseOwner}
|
||||
onChangeNotes={setPublisherAbuseNotes}
|
||||
onChangeSearch={setPublisherAbuseSearch}
|
||||
onChangeTab={setPublisherAbuseTab}
|
||||
onRefresh={() => {
|
||||
|
||||
Reference in New Issue
Block a user