diff --git a/convex/crons.test.ts b/convex/crons.test.ts index e92f3b5326..e607ccfa70 100644 --- a/convex/crons.test.ts +++ b/convex/crons.test.ts @@ -159,6 +159,7 @@ describe("crons", () => { { mode: "current", dryRun: true, + archiveDryRunSignals: true, candidateLimit: 1_000, batchSize: 50, maxPages: 20, diff --git a/convex/crons.ts b/convex/crons.ts index a94df19c20..7c39e2d23b 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -111,6 +111,7 @@ if (process.env.CLAWHUB_DISABLE_CRONS !== "1") { { mode: "current", dryRun: true, + archiveDryRunSignals: true, candidateLimit: 1_000, batchSize: 50, maxPages: 20, diff --git a/convex/lib/publisherAbuseScoring.test.ts b/convex/lib/publisherAbuseScoring.test.ts index d240b2894d..0a054cd854 100644 --- a/convex/lib/publisherAbuseScoring.test.ts +++ b/convex/lib/publisherAbuseScoring.test.ts @@ -121,12 +121,12 @@ describe("publisher abuse scoring", () => { expect(potentialBan).toBeGreaterThan(review); }); - it("escalates one P99 temporal hit as a potential ban candidate", () => { + it("keeps P99 temporal hits as review-only signals", () => { expect( labelForTemporalPublisherAbuse({ highTemporalSkillCount: 1, p99TemporalSkillCount: 1 }), - ).toBe("potential_ban_candidate"); + ).toBe("review"); expect( - labelForTemporalPublisherAbuse({ highTemporalSkillCount: 1, p99TemporalSkillCount: 0 }), + labelForTemporalPublisherAbuse({ highTemporalSkillCount: 2, p99TemporalSkillCount: 2 }), ).toBe("review"); }); @@ -485,6 +485,20 @@ describe("publisher abuse scoring", () => { expect(score.reasonCodes).toContain("temporal_installs_track_downloads"); }); + it("flags recent install/download ratios at least twice the observed high end", () => { + const todayDay = 100; + const score = computeCurrentSkillTemporalAbuseScore({ + todayDay, + dailyStats: dailyRange(94, 7, { downloads: 100, installs: 10 }), + }); + + expect(score.recent7Downloads).toBe(700); + expect(score.recent7Installs).toBe(70); + expect(score.installDownloadRatio7).toBeCloseTo(0.1); + expect(score.nearConversion).toBe(true); + expect(score.reasonCodes).toContain("temporal_installs_track_downloads"); + }); + it("keeps low-volume one-to-one install traffic below close-ratio thresholds", () => { const todayDay = 100; const score = computeCurrentSkillTemporalAbuseScore({ @@ -510,7 +524,7 @@ describe("publisher abuse scoring", () => { expect(score.reasonCodes).not.toContain("temporal_installs_track_downloads"); }); - it("requires installs to be close to downloads, not just statistically elevated", () => { + it("requires installs to clear the doubled observed high-end ratio", () => { const todayDay = 100; const score = computeCurrentSkillTemporalAbuseScore({ todayDay, @@ -529,12 +543,15 @@ describe("publisher abuse scoring", () => { const todayDay = 100; const score = computeCurrentSkillTemporalAbuseScore({ todayDay, - dailyStats: dailyRange(71, 30, { downloads: 100, installs: 80 }), + dailyStats: [ + ...dailyRange(71, 23, { downloads: 100, installs: 13 }), + ...dailyRange(94, 7, { downloads: 100, installs: 3 }), + ], }); expect(score.nearConversion).toBe(true); - expect(score.installDownloadRatio7).toBeCloseTo(0.8); - expect(score.installDownloadRatio30).toBeCloseTo(0.8); + expect(score.installDownloadRatio7).toBeCloseTo(0.03); + expect(score.installDownloadRatio30).toBeCloseTo(0.1067, 4); expect(score.nearConversionWindowStartDay).toBe(71); expect(score.nearConversionWindowEndDay).toBe(100); }); diff --git a/convex/lib/publisherAbuseScoring.ts b/convex/lib/publisherAbuseScoring.ts index aee91236e6..0452ca5075 100644 --- a/convex/lib/publisherAbuseScoring.ts +++ b/convex/lib/publisherAbuseScoring.ts @@ -127,12 +127,13 @@ const TEMPORAL_SUSTAINED_DAYS = 30; const TEMPORAL_MAX_SPIKE_INSTALLS = 2; const TEMPORAL_MAX_SUSTAINED_INSTALLS = 5; const TEMPORAL_MIN_BASELINE_7_DOWNLOADS = 100; -const TEMPORAL_MIN_NEAR_CONVERSION_7_DOWNLOADS = 1_000; -const TEMPORAL_MIN_NEAR_CONVERSION_30_DOWNLOADS = 2_000; -const TEMPORAL_MIN_NEAR_CONVERSION_INSTALLS = 500; +const TEMPORAL_MIN_NEAR_CONVERSION_7_DOWNLOADS = 500; +const TEMPORAL_MIN_NEAR_CONVERSION_30_DOWNLOADS = 1_000; +const TEMPORAL_MIN_NEAR_CONVERSION_7_INSTALLS = 50; +const TEMPORAL_MIN_NEAR_CONVERSION_30_INSTALLS = 100; const TEMPORAL_EXPECTED_INSTALL_DOWNLOAD_RATIO = 0.012; -const TEMPORAL_MIN_INSTALL_DOWNLOAD_RATIO = 0.5; -const TEMPORAL_MIN_INSTALL_DOWNLOAD_EXCESS_Z_SCORE = 50; +const TEMPORAL_MIN_INSTALL_DOWNLOAD_RATIO = 0.1; +const TEMPORAL_MIN_INSTALL_DOWNLOAD_EXCESS_Z_SCORE = 10; export function labelForPublisherAbuseZScore( zScore: number, @@ -402,9 +403,6 @@ export function labelForTemporalPublisherAbuse(input: { highTemporalSkillCount: number; p99TemporalSkillCount?: number; }): PublisherAbuseLabel { - if ((input.p99TemporalSkillCount ?? 0) >= 1 || input.highTemporalSkillCount >= 2) { - return "potential_ban_candidate"; - } if (input.highTemporalSkillCount >= 1) return "review"; return "pass"; } @@ -547,12 +545,12 @@ function computeSkillTemporalAbuseScoreForWindows(input: { }); const nearConversion7 = recent7.downloads >= TEMPORAL_MIN_NEAR_CONVERSION_7_DOWNLOADS && - recent7.installs >= TEMPORAL_MIN_NEAR_CONVERSION_INSTALLS && + recent7.installs >= TEMPORAL_MIN_NEAR_CONVERSION_7_INSTALLS && installDownloadRatio7 >= TEMPORAL_MIN_INSTALL_DOWNLOAD_RATIO && installDownloadExcessZScore7 >= TEMPORAL_MIN_INSTALL_DOWNLOAD_EXCESS_Z_SCORE; const nearConversion30 = recent30.downloads >= TEMPORAL_MIN_NEAR_CONVERSION_30_DOWNLOADS && - recent30.installs >= TEMPORAL_MIN_NEAR_CONVERSION_INSTALLS && + recent30.installs >= TEMPORAL_MIN_NEAR_CONVERSION_30_INSTALLS && installDownloadRatio30 >= TEMPORAL_MIN_INSTALL_DOWNLOAD_RATIO && installDownloadExcessZScore30 >= TEMPORAL_MIN_INSTALL_DOWNLOAD_EXCESS_Z_SCORE; const nearConversion = nearConversion7 || nearConversion30; diff --git a/convex/lib/retentionPolicy.test.ts b/convex/lib/retentionPolicy.test.ts index 40d5bdf2b0..7fe577e068 100644 --- a/convex/lib/retentionPolicy.test.ts +++ b/convex/lib/retentionPolicy.test.ts @@ -53,6 +53,12 @@ describe("retention policies", () => { }); }); + it("documents publisher abuse signals as durable review evidence", () => { + expect(getRetentionPolicy("publisherAbuseSignals")).toMatchObject({ + classification: "permanent", + }); + }); + it("documents package stat events as processed-event retention", () => { expect(getRetentionPolicy("packageStatEvents")).toMatchObject({ classification: "ephemeral", diff --git a/convex/lib/retentionPolicy.ts b/convex/lib/retentionPolicy.ts index 3c1ead1f8d..cb825f9ee2 100644 --- a/convex/lib/retentionPolicy.ts +++ b/convex/lib/retentionPolicy.ts @@ -203,6 +203,8 @@ export const RETENTION_POLICIES = { publisherAbuseScores: permanent("Abuse score history used for review decisions."), publisherAbuseReviewNominations: permanent("Abuse review workflow state."), publisherAbuseReviewEvents: permanent("Abuse review event history."), + publisherAbuseSignals: permanent("Durable publisher abuse signal archive for staff review."), + publisherAbuseSignalReviewEvents: permanent("Abuse signal review event history."), vtScanLogs: permanent("VirusTotal scan log history."), apiTokens: permanent("User API tokens until revoked."), cliDeviceCodes: ephemeral("CLI device codes expire quickly.", { diff --git a/convex/publisherAbuse.test.ts b/convex/publisherAbuse.test.ts index 0d6f89ef5d..afefe4a22a 100644 --- a/convex/publisherAbuse.test.ts +++ b/convex/publisherAbuse.test.ts @@ -24,14 +24,32 @@ vi.mock("./_generated/api", () => ({ autoBanPublisherAbuseCandidatesPageInternal: Symbol( "autoBanPublisherAbuseCandidatesPageInternal", ), + archiveTemporalPublisherAbuseSignalsInternal: Symbol( + "archiveTemporalPublisherAbuseSignalsInternal", + ), + archiveTemporalPublisherAbuseSignalsPageInternal: Symbol( + "archiveTemporalPublisherAbuseSignalsPageInternal", + ), collectPublisherAbuseScoresPageInternal: Symbol("collectPublisherAbuseScoresPageInternal"), collectTemporalPublisherAbuseSkillCandidatesPageInternal: Symbol( "collectTemporalPublisherAbuseSkillCandidatesPageInternal", ), + claimPublisherAbuseSignalNotificationsInternal: Symbol( + "claimPublisherAbuseSignalNotificationsInternal", + ), finalizePublisherAbuseScoresPageInternal: Symbol("finalizePublisherAbuseScoresPageInternal"), getOrStartPublisherAbuseScoreRunInternal: Symbol("getOrStartPublisherAbuseScoreRunInternal"), getPublisherAbuseScoreRunStateInternal: Symbol("getPublisherAbuseScoreRunStateInternal"), + markPublisherAbuseSignalNotificationsFailedInternal: Symbol( + "markPublisherAbuseSignalNotificationsFailedInternal", + ), + markPublisherAbuseSignalNotificationsSucceededInternal: Symbol( + "markPublisherAbuseSignalNotificationsSucceededInternal", + ), markPublisherAbuseScoreRunFailedInternal: Symbol("markPublisherAbuseScoreRunFailedInternal"), + notifyPublisherAbuseSignalChangesInternal: Symbol( + "notifyPublisherAbuseSignalChangesInternal", + ), persistTemporalPublisherAbuseCandidatesInternal: Symbol( "persistTemporalPublisherAbuseCandidatesInternal", ), @@ -81,6 +99,7 @@ const TEST_MODEL_CONFIG = { type Handler = (ctx: unknown, args: TArgs) => Promise; type Wrapped = { _handler: Handler }; +type TemporalSkillCandidate = ReturnType; const collectHandler = ( publisherAbuse.collectPublisherAbuseScoresPageInternal as unknown as Wrapped< @@ -122,6 +141,7 @@ const temporalRunHandler = ( runId?: string; mode?: "current" | "backfill"; dryRun?: boolean; + archiveDryRunSignals?: boolean; candidateLimit?: number; batchSize?: number; maxPages?: number; @@ -198,6 +218,8 @@ const persistTemporalHandler = ( spikeWindowEndDay?: number; sustainedWindowStartDay?: number; sustainedWindowEndDay?: number; + nearConversionWindowStartDay?: number; + nearConversionWindowEndDay?: number; reasonCodes: string[]; }; }>; @@ -227,6 +249,8 @@ const listDashboardHandler = ( pendingPotentialBanCandidateItems: unknown[]; pendingReviewItems: unknown[]; recentResolvedItems: Array<{ nomination: { _id: string } }>; + signalCount: number; + signalCountHasMore: boolean; } > )._handler; @@ -245,6 +269,59 @@ const listReviewItemsPageHandler = ( > )._handler; +const listSignalsPageHandler = ( + publisherAbuse.listSignalsPage as unknown as Wrapped< + { + signalType?: "high_install_download_ratio" | "sustained_downloads_flat_installs"; + reviewStatus?: "open" | "snoozed" | "dismissed"; + paginationOpts: { numItems: number; cursor: string | null }; + }, + { + page: unknown[]; + isDone: boolean; + continueCursor: string; + } + > +)._handler; + +const archiveTemporalPublisherAbuseSignalsPageHandler = ( + publisherAbuse.archiveTemporalPublisherAbuseSignalsPageInternal as unknown as Wrapped< + { + runId: string; + candidates: TemporalSkillCandidate[]; + now: number; + }, + { + archivedCandidates: number; + archivedSignals: number; + changedSignals: number; + } + > +)._handler; + +const archiveTemporalPublisherAbuseSignalsHandler = ( + publisherAbuse.archiveTemporalPublisherAbuseSignalsInternal as unknown as Wrapped< + { + runId: string; + candidates: TemporalSkillCandidate[]; + now: number; + offset?: number; + batchSize?: number; + maxPages?: number; + notifyHermit?: boolean; + }, + { + ok: true; + pages: number; + archivedCandidates: number; + archivedSignals: number; + changedSignals: number; + isDone: boolean; + offset: number; + } + > +)._handler; + const getReviewNominationDetailHandler = ( publisherAbuse.getReviewNominationDetail as unknown as Wrapped< { nominationId: string }, @@ -269,6 +346,55 @@ const setPublisherAbuseAutobanEnabledHandler = ( > )._handler; +const snoozePublisherAbuseSignalHandler = ( + publisherAbuse.snoozePublisherAbuseSignal as unknown as Wrapped< + { signalId: string; note?: string; days?: number }, + { ok: true; status: "snoozed" } + > +)._handler; + +const dismissPublisherAbuseSignalHandler = ( + publisherAbuse.dismissPublisherAbuseSignal as unknown as Wrapped< + { signalId: string; note?: string }, + { ok: true; status: "dismissed" } + > +)._handler; + +const reopenPublisherAbuseSignalHandler = ( + publisherAbuse.reopenPublisherAbuseSignal as unknown as Wrapped< + { signalId: string; note?: string }, + { ok: true; status: "open"; alreadyOpen?: true } + > +)._handler; + +const claimPublisherAbuseSignalNotificationsHandler = ( + publisherAbuse.claimPublisherAbuseSignalNotificationsInternal as unknown as Wrapped< + { limit?: number }, + { signals: unknown[]; hasMore: boolean; claimedAt: number } + > +)._handler; + +const markPublisherAbuseSignalNotificationsSucceededHandler = ( + publisherAbuse.markPublisherAbuseSignalNotificationsSucceededInternal as unknown as Wrapped< + { signalIds: string[]; claimedAt: number; now: number }, + null + > +)._handler; + +const markPublisherAbuseSignalNotificationsFailedHandler = ( + publisherAbuse.markPublisherAbuseSignalNotificationsFailedInternal as unknown as Wrapped< + { signalIds: string[]; claimedAt: number; error: string }, + null + > +)._handler; + +const notifyPublisherAbuseSignalChangesHandler = ( + publisherAbuse.notifyPublisherAbuseSignalChangesInternal as unknown as Wrapped< + { limit?: number }, + { ok: boolean; sent?: boolean; skipped?: boolean; count?: number; error?: string } + > +)._handler; + const banPublisherAbuseOwnerHandler = ( publisherAbuse.banPublisherAbuseOwner as unknown as Wrapped< { @@ -468,6 +594,62 @@ function makeEmptyPublisherMembersQuery() { }; } +function makePublisherAbuseSignalCountQuery(signals: unknown[]) { + return { + withIndex: ( + indexName: string, + build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(indexName).toBe("by_review_status_and_last_seen_at"); + const constraints: Record = {}; + const q = { + eq(field: string, value: unknown) { + constraints[field] = value; + return q; + }, + }; + build(q); + expect(constraints).toEqual({ reviewStatus: "open" }); + return { + order: (direction: "asc" | "desc") => { + expect(direction).toBe("desc"); + return { + take: async (limit: number) => signals.slice(0, limit), + }; + }, + }; + }, + }; +} + +function makeEmptyPublisherAbuseScoreRunsQuery() { + return { + withIndex: ( + indexName: string, + build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(indexName).toBe("by_model_version_and_started_at"); + const constraints: Record = {}; + const q = { + eq(field: string, value: unknown) { + constraints[field] = value; + return q; + }, + }; + build(q); + expect(constraints.modelVersion).toBeTypeOf("string"); + return { + order: (direction: "asc" | "desc") => { + expect(direction).toBe("desc"); + return { + first: async () => null, + }; + }, + }; + }, + }; +} + function hasNoUndefinedValues(value: unknown): boolean { if (value === undefined) return false; if (value === null || typeof value !== "object") return true; @@ -574,6 +756,8 @@ describe("publisher abuse dry-run persistence", () => { pendingPotentialBanCandidateItems: [], pendingReviewItems: [], recentResolvedItems: [], + signalCount: 0, + signalCountHasMore: false, }); await expect( getPublisherAbuseAutobanSettingHandler({ db: { query: dbQuery } }, {}), @@ -629,6 +813,8 @@ describe("publisher abuse dry-run persistence", () => { pendingPotentialBanCandidateItems: [], pendingReviewItems: [], recentResolvedItems: [], + signalCount: 0, + signalCountHasMore: false, }); await expect(getPublisherAbuseAutobanSettingHandler({ db }, {})).resolves.toEqual({ enabled: false, @@ -641,121 +827,969 @@ describe("publisher abuse dry-run persistence", () => { { nominationId: "publisherAbuseReviewNominations:nomination" }, ), ).resolves.toBeNull(); + await expect( + listSignalsPageHandler( + { db }, + { + signalType: "high_install_download_ratio", + paginationOpts: { numItems: 10, cursor: null }, + }, + ), + ).resolves.toEqual({ + page: [], + isDone: true, + continueCursor: "", + }); expect(assertModerator).not.toHaveBeenCalled(); expect(db.get).not.toHaveBeenCalled(); expect(db.query).not.toHaveBeenCalled(); }); - it("defaults publisher abuse autobans to disabled when no setting exists", async () => { - const user = { _id: "users:moderator", role: "moderator" }; + it("lets moderators snooze, dismiss, and reopen archived signals with audit rows", async () => { vi.mocked(requireUser).mockResolvedValue({ userId: "users:moderator", - user, + user: { _id: "users:moderator", role: "moderator" }, } as never); + const signal = { + _id: "publisherAbuseSignals:ratio", + signalType: "high_install_download_ratio", + ownerKey: "publisher:publishers:ratio-owner", + ownerPublisherId: "publishers:ratio-owner", + ownerUserId: "users:ratio-owner", + handleSnapshot: "ratio-owner", + skillId: "skills:ratio", + skillSlug: "ratio", + skillDisplayName: "Ratio", + firstSeenAt: 10, + lastSeenAt: 20, + seenCount: 2, + recent7Downloads: 800, + recent7Installs: 96, + recent7InstallDownloadRatio: 0.12, + recent30Downloads: 2_400, + recent30Installs: 288, + recent30InstallDownloadRatio: 0.12, + allTimeDownloads: 10_000, + allTimeInstalls: 1_200, + allTimeInstallDownloadRatio: 0.12, + reviewStatus: "open", + lastChangedAt: 20, + needsNotification: true, + }; + const snoozedSignal = { ...signal, reviewStatus: "snoozed" }; + const dismissedSignal = { ...signal, reviewStatus: "dismissed" }; + const patch = vi.fn(async () => null); + const insert = vi.fn(async () => "publisherAbuseSignalReviewEvents:event"); const ctx = { db: { - query: vi.fn((table: string) => { - if (table === "systemSettings") return makePublisherAbuseAutobanSettingQuery(null); - throw new Error(`unexpected table ${table}`); + get: vi.fn(async () => { + if (patch.mock.calls.length === 0) return signal; + if (patch.mock.calls.length === 1) return snoozedSignal; + return dismissedSignal; }), + patch, + insert, }, + scheduler: { runAfter: vi.fn(async () => null) }, }; - await expect(getPublisherAbuseAutobanSettingHandler(ctx, {})).resolves.toEqual({ - enabled: false, - updatedAt: null, - updatedByUserId: null, - }); + await expect( + snoozePublisherAbuseSignalHandler(ctx, { + signalId: "publisherAbuseSignals:ratio", + note: "looks crawler-ish", + days: 14, + }), + ).resolves.toEqual({ ok: true, status: "snoozed" }); + await expect( + dismissPublisherAbuseSignalHandler(ctx, { + signalId: "publisherAbuseSignals:ratio", + note: "not actionable", + }), + ).resolves.toEqual({ ok: true, status: "dismissed" }); + await expect( + reopenPublisherAbuseSignalHandler(ctx, { + signalId: "publisherAbuseSignals:ratio", + note: "recurring", + }), + ).resolves.toEqual({ ok: true, status: "open" }); - expect(ctx.db.query).toHaveBeenCalledWith("systemSettings"); + expect(patch).toHaveBeenNthCalledWith( + 1, + "publisherAbuseSignals:ratio", + expect.objectContaining({ + reviewStatus: "snoozed", + reviewNote: "looks crawler-ish", + needsNotification: false, + }), + ); + expect(patch).toHaveBeenNthCalledWith( + 2, + "publisherAbuseSignals:ratio", + expect.objectContaining({ + reviewStatus: "dismissed", + reviewNote: "not actionable", + needsNotification: false, + }), + ); + expect(patch).toHaveBeenNthCalledWith( + 3, + "publisherAbuseSignals:ratio", + expect.objectContaining({ + reviewStatus: "open", + reviewNote: "recurring", + needsNotification: true, + lastChangedAt: expect.any(Number), + }), + ); + expect(insert).toHaveBeenCalledWith( + "publisherAbuseSignalReviewEvents", + expect.objectContaining({ + signalId: "publisherAbuseSignals:ratio", + actorUserId: "users:moderator", + eventType: "snoozed", + previousStatus: "open", + nextStatus: "snoozed", + }), + ); + expect(insert).toHaveBeenCalledWith( + "publisherAbuseSignalReviewEvents", + expect.objectContaining({ + eventType: "dismissed", + nextStatus: "dismissed", + }), + ); + expect(insert).toHaveBeenCalledWith( + "publisherAbuseSignalReviewEvents", + expect.objectContaining({ + eventType: "reopened", + nextStatus: "open", + }), + ); + expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(0, expect.any(Symbol), {}); }); - it("lets admins enable publisher abuse autobans with an audit log", async () => { - const user = { _id: "users:admin", role: "admin" }; + it("does not reopen or notify already-open publisher abuse signals", async () => { vi.mocked(requireUser).mockResolvedValue({ - userId: "users:admin", - user, + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, } as never); - const inserted: Array<{ table: string; value: unknown }> = []; + const signal = { + _id: "publisherAbuseSignals:open", + signalType: "high_install_download_ratio", + ownerKey: "publisher:publishers:ratio-owner", + ownerPublisherId: "publishers:ratio-owner", + ownerUserId: "users:ratio-owner", + handleSnapshot: "ratio-owner", + skillId: "skills:ratio", + skillSlug: "ratio", + skillDisplayName: "Ratio", + firstSeenAt: 10, + lastSeenAt: 20, + seenCount: 2, + recent7Downloads: 800, + recent7Installs: 96, + recent7InstallDownloadRatio: 0.12, + recent30Downloads: 2_400, + recent30Installs: 288, + recent30InstallDownloadRatio: 0.12, + allTimeDownloads: 10_000, + allTimeInstalls: 1_200, + allTimeInstallDownloadRatio: 0.12, + reviewStatus: "open", + lastChangedAt: 20, + needsNotification: true, + }; const ctx = { db: { - query: vi.fn((table: string) => { - if (table === "systemSettings") return makePublisherAbuseAutobanSettingQuery(null); - throw new Error(`unexpected table ${table}`); - }), - insert: vi.fn(async (table: string, value: unknown) => { - inserted.push({ table, value }); - return `${table}:new`; - }), - patch: vi.fn(), + get: vi.fn(async () => signal), + patch: vi.fn(async () => null), + insert: vi.fn(async () => "publisherAbuseSignalReviewEvents:event"), }, + scheduler: { runAfter: vi.fn(async () => null) }, }; - await expect(setPublisherAbuseAutobanEnabledHandler(ctx, { enabled: true })).resolves.toEqual({ - enabled: true, - updatedAt: expect.any(Number), - updatedByUserId: "users:admin", - }); - - expect(assertAdmin).toHaveBeenCalledWith(user); + await expect( + reopenPublisherAbuseSignalHandler(ctx, { + signalId: "publisherAbuseSignals:open", + note: "already open", + }), + ).resolves.toEqual({ ok: true, status: "open", alreadyOpen: true }); expect(ctx.db.patch).not.toHaveBeenCalled(); - expect(inserted).toEqual([ - { - table: "systemSettings", - value: { - key: "publisherAbuseAutobanEnabled", - enabled: true, - updatedAt: expect.any(Number), - updatedByUserId: "users:admin", - }, - }, - { - table: "auditLogs", - value: { - actorUserId: "users:admin", - action: "publisher_abuse.autoban_setting.set", - targetType: "system", - targetId: "publisherAbuseAutobanEnabled", - metadata: { - previousEnabled: false, - nextEnabled: true, - }, - createdAt: expect.any(Number), - }, - }, - ]); + expect(ctx.db.insert).not.toHaveBeenCalled(); + expect(ctx.scheduler.runAfter).not.toHaveBeenCalled(); }); - it("lets admins disable publisher abuse autobans with an audit log", async () => { - const user = { _id: "users:admin", role: "admin" }; - vi.mocked(requireUser).mockResolvedValue({ - userId: "users:admin", - user, - } as never); - const existingSetting = { - _id: "systemSettings:autoban", - key: "publisherAbuseAutobanEnabled", - enabled: true, - updatedAt: 1, - updatedByUserId: "users:admin", - }; - const inserted: Array<{ table: string; value: unknown }> = []; - const ctx = { - db: { - query: vi.fn((table: string) => { - if (table === "systemSettings") { - return makePublisherAbuseAutobanSettingQuery(existingSetting); - } - throw new Error(`unexpected table ${table}`); + it("sends one Hermit digest for claimed changed signals and clears notification state", async () => { + const previousEnv = { ...process.env }; + const previousFetch = globalThis.fetch; + process.env.CLAWHUB_HERMIT_TOKEN = "hermit-token"; + process.env.HERMIT_PUBLISHER_ABUSE_BASE_URL = "https://forms.example.test"; + process.env.SITE_URL = "https://clawhub.example.test"; + const fetchMock = vi.fn(async () => new Response("ok", { status: 200 })); + globalThis.fetch = fetchMock as typeof fetch; + const signal = { + _id: "publisherAbuseSignals:ratio", + signalType: "high_install_download_ratio", + ownerKey: "publisher:publishers:ratio-owner", + ownerPublisherId: "publishers:ratio-owner", + ownerUserId: "users:ratio-owner", + handleSnapshot: "ratio-owner", + skillId: "skills:ratio", + skillSlug: "ratio-skill", + skillDisplayName: "Ratio Skill", + firstSeenAt: 1715900000000, + lastSeenAt: 1716000000000, + seenCount: 3, + recent7Downloads: 800, + recent7Installs: 96, + recent7InstallDownloadRatio: 0.12, + recent30Downloads: 2_400, + recent30Installs: 288, + recent30InstallDownloadRatio: 0.12, + allTimeDownloads: 10_000, + allTimeInstalls: 1_200, + allTimeInstallDownloadRatio: 0.12, + reviewStatus: "open", + needsNotification: false, + }; + const runMutation = vi.fn(async (target: unknown) => { + const name = String(target); + if (name.includes("claimPublisherAbuseSignalNotificationsInternal")) { + return { signals: [signal], hasMore: false, claimedAt: 1_000 }; + } + if (name.includes("markPublisherAbuseSignalNotificationsSucceededInternal")) { + return null; + } + throw new Error(`unexpected mutation ${name}`); + }); + const scheduler = { runAfter: vi.fn(async () => null) }; + + try { + await expect( + notifyPublisherAbuseSignalChangesHandler({ runMutation, scheduler }, {}), + ).resolves.toEqual({ ok: true, sent: true, count: 1 }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://forms.example.test/api/clawhub-publisher-abuse/signals/digest", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer hermit-token", + "Content-Type": "application/json", + }), }), - insert: vi.fn(async (table: string, value: unknown) => { - inserted.push({ table, value }); - return `${table}:new`; + ); + const [, requestInit] = fetchMock.mock.calls[0] ?? []; + const requestBody = (requestInit as RequestInit | undefined)?.body; + if (typeof requestBody !== "string") throw new Error("Expected Hermit request body"); + const payload = JSON.parse(requestBody); + expect(payload).toEqual( + expect.objectContaining({ + kind: "publisher_abuse_signals_changed", + changedCount: 1, + hasMore: false, + dashboardUrl: "https://clawhub.example.test/management?view=abuse&tab=signals", + topSignals: [ + expect.objectContaining({ + publisher: "ratio-owner", + skillSlug: "ratio-skill", + severity: "high", + seenCount: 3, + skillUrl: "https://clawhub.example.test/ratio-owner/skills/ratio-skill", + }), + ], }), - patch: vi.fn(), - }, - }; + ); + expect(runMutation).toHaveBeenCalledWith( + expect.any(Symbol), + expect.objectContaining({ + signalIds: ["publisherAbuseSignals:ratio"], + claimedAt: 1_000, + now: expect.any(Number), + }), + ); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + } finally { + process.env = previousEnv; + globalThis.fetch = previousFetch; + } + }); + + it("schedules the next Hermit signal digest immediately when more changed signals remain", async () => { + const previousEnv = { ...process.env }; + const previousFetch = globalThis.fetch; + process.env.CLAWHUB_HERMIT_TOKEN = "hermit-token"; + process.env.HERMIT_PUBLISHER_ABUSE_BASE_URL = "https://forms.example.test"; + process.env.SITE_URL = "https://clawhub.example.test"; + globalThis.fetch = vi.fn(async () => new Response("ok", { status: 200 })); + const signal = { + _id: "publisherAbuseSignals:ratio", + signalType: "high_install_download_ratio", + ownerKey: "publisher:publishers:ratio-owner", + ownerPublisherId: "publishers:ratio-owner", + ownerUserId: "users:ratio-owner", + handleSnapshot: "ratio-owner", + skillId: "skills:ratio", + skillSlug: "ratio-skill", + skillDisplayName: "Ratio Skill", + firstSeenAt: 1715900000000, + lastSeenAt: 1716000000000, + seenCount: 3, + recent7Downloads: 800, + recent7Installs: 96, + recent7InstallDownloadRatio: 0.12, + recent30Downloads: 2_400, + recent30Installs: 288, + recent30InstallDownloadRatio: 0.12, + allTimeDownloads: 10_000, + allTimeInstalls: 1_200, + allTimeInstallDownloadRatio: 0.12, + reviewStatus: "open", + needsNotification: false, + }; + const runMutation = vi.fn(async (target: unknown) => { + const name = String(target); + if (name.includes("claimPublisherAbuseSignalNotificationsInternal")) { + return { signals: [signal], hasMore: true, claimedAt: 1_000 }; + } + if (name.includes("markPublisherAbuseSignalNotificationsSucceededInternal")) { + return null; + } + throw new Error(`unexpected mutation ${name}`); + }); + const scheduler = { + runAfter: vi.fn(async (_delay: number, _target: unknown, _args: unknown) => null), + }; + + try { + await expect( + notifyPublisherAbuseSignalChangesHandler({ runMutation, scheduler }, { limit: 1 }), + ).resolves.toEqual({ ok: true, sent: true, count: 1 }); + expect(scheduler.runAfter).toHaveBeenCalledWith(expect.any(Number), expect.any(Symbol), { + limit: 1, + }); + expect(scheduler.runAfter.mock.calls[0]?.[0]).toBe(0); + const [, requestInit] = vi.mocked(globalThis.fetch).mock.calls[0] ?? []; + const requestBody = (requestInit as RequestInit | undefined)?.body; + if (typeof requestBody !== "string") throw new Error("Expected Hermit request body"); + expect(JSON.parse(requestBody)).toEqual(expect.objectContaining({ hasMore: true })); + } finally { + process.env = previousEnv; + globalThis.fetch = previousFetch; + } + }); + + it("continues Hermit signal notification claims when a skipped empty batch has more rows", async () => { + const previousEnv = { ...process.env }; + const previousFetch = globalThis.fetch; + process.env.CLAWHUB_HERMIT_TOKEN = "hermit-token"; + process.env.HERMIT_PUBLISHER_ABUSE_BASE_URL = "https://forms.example.test"; + process.env.SITE_URL = "https://clawhub.example.test"; + globalThis.fetch = vi.fn(async () => new Response("ok", { status: 200 })); + const runMutation = vi.fn(async (target: unknown) => { + const name = String(target); + if (name.includes("claimPublisherAbuseSignalNotificationsInternal")) { + return { signals: [], hasMore: true, claimedAt: 1_000 }; + } + throw new Error(`unexpected mutation ${name}`); + }); + const scheduler = { + runAfter: vi.fn(async (_delay: number, _target: unknown, _args: unknown) => null), + }; + + try { + await expect( + notifyPublisherAbuseSignalChangesHandler({ runMutation, scheduler }, { limit: 1 }), + ).resolves.toEqual({ ok: true, sent: false }); + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(scheduler.runAfter).toHaveBeenCalledWith(0, expect.any(Symbol), { limit: 1 }); + } finally { + process.env = previousEnv; + globalThis.fetch = previousFetch; + } + }); + + it("requeues Hermit signal notifications after a failed digest POST", async () => { + const previousEnv = { ...process.env }; + const previousFetch = globalThis.fetch; + process.env.CLAWHUB_HERMIT_TOKEN = "hermit-token"; + process.env.HERMIT_PUBLISHER_ABUSE_BASE_URL = "https://forms.example.test"; + process.env.SITE_URL = "https://clawhub.example.test"; + globalThis.fetch = vi.fn(async () => new Response("nope", { status: 500 })); + const signal = { + _id: "publisherAbuseSignals:ratio", + signalType: "high_install_download_ratio", + ownerKey: "publisher:publishers:ratio-owner", + ownerPublisherId: "publishers:ratio-owner", + ownerUserId: "users:ratio-owner", + handleSnapshot: "ratio-owner", + skillId: "skills:ratio", + skillSlug: "ratio-skill", + skillDisplayName: "Ratio Skill", + firstSeenAt: 1715900000000, + lastSeenAt: 1716000000000, + seenCount: 3, + recent7Downloads: 800, + recent7Installs: 96, + recent7InstallDownloadRatio: 0.12, + recent30Downloads: 2_400, + recent30Installs: 288, + recent30InstallDownloadRatio: 0.12, + allTimeDownloads: 10_000, + allTimeInstalls: 1_200, + allTimeInstallDownloadRatio: 0.12, + reviewStatus: "open", + needsNotification: false, + }; + const runMutation = vi.fn(async (target: unknown) => { + const name = String(target); + if (name.includes("claimPublisherAbuseSignalNotificationsInternal")) { + return { signals: [signal], hasMore: false, claimedAt: 1_000 }; + } + if (name.includes("markPublisherAbuseSignalNotificationsFailedInternal")) { + return null; + } + throw new Error(`unexpected mutation ${name}`); + }); + const scheduler = { runAfter: vi.fn(async () => null) }; + + try { + await expect( + notifyPublisherAbuseSignalChangesHandler({ runMutation, scheduler }, { limit: 1 }), + ).resolves.toEqual( + expect.objectContaining({ + ok: false, + sent: false, + error: expect.stringContaining("Hermit publisher abuse digest failed: 500 nope"), + }), + ); + expect(runMutation).toHaveBeenCalledWith( + expect.any(Symbol), + expect.objectContaining({ + signalIds: ["publisherAbuseSignals:ratio"], + claimedAt: 1_000, + error: expect.stringContaining("Hermit publisher abuse digest failed: 500 nope"), + }), + ); + expect(scheduler.runAfter).toHaveBeenCalledWith(60 * 60 * 1000, expect.any(Symbol), { + limit: 1, + }); + } finally { + process.env = previousEnv; + globalThis.fetch = previousFetch; + } + }); + + it("does not requeue newer claimed signal notifications when a stale digest fails", async () => { + const staleFailureSignal = { + _id: "publisherAbuseSignals:stale-failure", + needsNotification: false, + notificationClaimedAt: 2_000, + }; + const changedAgainSignal = { + _id: "publisherAbuseSignals:changed-again", + needsNotification: true, + notificationClaimedAt: undefined, + lastChangedAt: 3_000, + }; + const patch = vi.fn(async () => null); + const get = vi.fn(async (id: string) => { + if (id === "publisherAbuseSignals:stale-failure") return staleFailureSignal; + if (id === "publisherAbuseSignals:changed-again") return changedAgainSignal; + return null; + }); + + await expect( + markPublisherAbuseSignalNotificationsFailedHandler( + { db: { get, patch } }, + { + signalIds: ["publisherAbuseSignals:stale-failure", "publisherAbuseSignals:changed-again"], + claimedAt: 1_000, + error: "old failure", + }, + ), + ).resolves.toBeUndefined(); + + expect(patch).not.toHaveBeenCalled(); + }); + + it("requeues stale Hermit signal notification claims before claiming the next batch", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000_000); + const staleSignal = { + _id: "publisherAbuseSignals:stale", + reviewStatus: "open", + needsNotification: false, + notificationClaimedAt: 10, + }; + const pendingSignal = { + _id: "publisherAbuseSignals:pending", + reviewStatus: "open", + needsNotification: true, + lastChangedAt: 900_000, + }; + const patch = vi.fn(async () => null); + const db = { + patch, + query: vi.fn((table: string) => { + expect(table).toBe("publisherAbuseSignals"); + return { + withIndex: ( + indexName: string, + build: (q: { + eq: (field: string, value: unknown) => unknown; + gte: (field: string, value: unknown) => unknown; + lt: (field: string, value: unknown) => unknown; + }) => unknown, + ) => { + const constraints: Array<[string, string, unknown]> = []; + const q = { + eq(field: string, value: unknown) { + constraints.push(["eq", field, value]); + return q; + }, + gte(field: string, value: unknown) { + constraints.push(["gte", field, value]); + return q; + }, + lt(field: string, value: unknown) { + constraints.push(["lt", field, value]); + return q; + }, + }; + build(q); + if (indexName === "by_needs_notification_and_notification_claimed_at") { + expect(constraints).toEqual([ + ["eq", "needsNotification", false], + ["gte", "notificationClaimedAt", 1], + ["lt", "notificationClaimedAt", 100_000], + ]); + return { + take: async (limit: number) => { + expect(limit).toBe(6); + return [staleSignal]; + }, + }; + } + expect(indexName).toBe("by_needs_notification_and_last_changed_at"); + expect(constraints).toEqual([["eq", "needsNotification", true]]); + return { + order: (direction: "asc" | "desc") => { + expect(direction).toBe("desc"); + return { + take: async (limit: number) => { + expect(limit).toBe(6); + return [pendingSignal]; + }, + }; + }, + }; + }, + }; + }), + }; + + try { + await expect( + claimPublisherAbuseSignalNotificationsHandler({ db }, { limit: 5 }), + ).resolves.toEqual({ + signals: [pendingSignal], + hasMore: false, + claimedAt: 1_000_000, + }); + expect(patch).toHaveBeenCalledWith( + "publisherAbuseSignals:stale", + expect.objectContaining({ + needsNotification: true, + notificationClaimedAt: undefined, + lastNotificationError: "Retrying after stale Hermit notification claim.", + }), + ); + expect(patch).toHaveBeenCalledWith("publisherAbuseSignals:pending", { + needsNotification: false, + notificationClaimedAt: 1_000_000, + }); + } finally { + nowSpy.mockRestore(); + } + }); + + it("continues Hermit signal notification claims when stale claims exceed the batch", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000_000); + const staleClaims = Array.from({ length: 6 }, (_, index) => ({ + _id: `publisherAbuseSignals:stale-${index}`, + reviewStatus: "open", + needsNotification: false, + notificationClaimedAt: 10, + lastChangedAt: 900_000 - index, + })); + const pendingSignals = staleClaims.slice(0, 5).map((signal) => ({ + ...signal, + needsNotification: true, + notificationClaimedAt: undefined, + })); + const patch = vi.fn(async () => null); + const db = { + patch, + query: vi.fn((table: string) => { + expect(table).toBe("publisherAbuseSignals"); + return { + withIndex: (indexName: string) => { + if (indexName === "by_needs_notification_and_notification_claimed_at") { + return { + take: async (limit: number) => { + expect(limit).toBe(6); + return staleClaims; + }, + }; + } + expect(indexName).toBe("by_needs_notification_and_last_changed_at"); + return { + order: (direction: "asc" | "desc") => { + expect(direction).toBe("desc"); + return { + take: async (limit: number) => { + expect(limit).toBe(6); + return pendingSignals; + }, + }; + }, + }; + }, + }; + }), + }; + + try { + await expect( + claimPublisherAbuseSignalNotificationsHandler({ db }, { limit: 5 }), + ).resolves.toEqual({ + signals: pendingSignals, + hasMore: true, + claimedAt: 1_000_000, + }); + expect(patch).toHaveBeenCalledTimes(10); + for (const signal of pendingSignals) { + expect(patch).toHaveBeenCalledWith( + signal._id, + expect.objectContaining({ + needsNotification: true, + notificationClaimedAt: undefined, + lastNotificationError: "Retrying after stale Hermit notification claim.", + }), + ); + expect(patch).toHaveBeenCalledWith(signal._id, { + needsNotification: false, + notificationClaimedAt: 1_000_000, + }); + } + expect(patch).not.toHaveBeenCalledWith("publisherAbuseSignals:stale-5", expect.anything()); + } finally { + nowSpy.mockRestore(); + } + }); + + it("does not clear newer queued signal notifications when a stale digest succeeds", async () => { + const deliveredSignal = { + _id: "publisherAbuseSignals:delivered", + needsNotification: false, + notificationClaimedAt: 1_000, + }; + const changedAgainSignal = { + _id: "publisherAbuseSignals:changed-again", + needsNotification: true, + notificationClaimedAt: undefined, + lastChangedAt: 2_000, + }; + const patch = vi.fn(async () => null); + const get = vi.fn(async (id: string) => { + if (id === "publisherAbuseSignals:delivered") return deliveredSignal; + if (id === "publisherAbuseSignals:changed-again") return changedAgainSignal; + return null; + }); + + await expect( + markPublisherAbuseSignalNotificationsSucceededHandler( + { db: { get, patch } }, + { + signalIds: ["publisherAbuseSignals:delivered", "publisherAbuseSignals:changed-again"], + claimedAt: 1_000, + now: 3_000, + }, + ), + ).resolves.toBeUndefined(); + + expect(patch).toHaveBeenCalledTimes(1); + expect(patch).toHaveBeenCalledWith("publisherAbuseSignals:delivered", { + needsNotification: false, + notificationClaimedAt: undefined, + lastNotifiedAt: 3_000, + lastNotificationError: undefined, + }); + }); + + it("returns a bounded publisher abuse signal count on the dashboard", async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); + const signals = Array.from({ length: 26 }, (_, index) => ({ + _id: `publisherAbuseSignals:${index}`, + })); + const db = { + get: vi.fn(async () => null), + query: vi.fn((table: string) => { + if (table === "publisherAbuseScoreRuns") return makeEmptyPublisherAbuseScoreRunsQuery(); + if (table === "publisherAbuseSignals") return makePublisherAbuseSignalCountQuery(signals); + if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery(); + throw new Error(`unexpected table ${table}`); + }), + }; + + await expect(listDashboardHandler({ db }, {})).resolves.toEqual({ + latestRun: null, + pendingItems: [], + pendingPotentialBanCandidateItems: [], + pendingReviewItems: [], + recentResolvedItems: [], + signalCount: 25, + signalCountHasMore: true, + }); + + expect(db.query).toHaveBeenCalledWith("publisherAbuseSignals"); + }); + + it("does not mark the signal count as approximate at the exact scan limit", async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); + const signals = Array.from({ length: 100 }, (_, index) => ({ + _id: `publisherAbuseSignals:${index}`, + ownerPublisherId: index === 0 ? undefined : `publishers:staff-${index}`, + })); + const db = { + get: vi.fn(async (id: string) => { + if (id.startsWith("publishers:staff-")) { + const index = id.replace("publishers:staff-", ""); + return { + _id: id, + kind: "user", + handle: `staff-${index}`, + linkedUserId: `users:staff-${index}`, + }; + } + if (id.startsWith("users:staff-")) return { _id: id, role: "admin" }; + return null; + }), + query: vi.fn((table: string) => { + if (table === "publisherAbuseScoreRuns") return makeEmptyPublisherAbuseScoreRunsQuery(); + if (table === "publisherAbuseSignals") return makePublisherAbuseSignalCountQuery(signals); + if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery(); + throw new Error(`unexpected table ${table}`); + }), + }; + + await expect(listDashboardHandler({ db }, {})).resolves.toEqual({ + latestRun: null, + pendingItems: [], + pendingPotentialBanCandidateItems: [], + pendingReviewItems: [], + recentResolvedItems: [], + signalCount: 1, + signalCountHasMore: false, + }); + + expect(db.query).toHaveBeenCalledWith("publisherAbuseSignals"); + }); + + it("excludes official publishers from the publisher abuse signal count", async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); + const signals = [ + { + _id: "publisherAbuseSignals:official", + ownerPublisherId: "publishers:official", + }, + { + _id: "publisherAbuseSignals:visible", + ownerPublisherId: "publishers:visible", + }, + ]; + const db = { + get: vi.fn(async (id: string) => { + if (id === "publishers:official") { + return { + _id: "publishers:official", + kind: "user", + handle: "official", + linkedUserId: "users:official", + }; + } + if (id === "publishers:visible") { + return { + _id: "publishers:visible", + kind: "user", + handle: "visible", + linkedUserId: "users:visible", + }; + } + if (id === "users:visible" || id === "users:official") return { _id: id, role: "user" }; + throw new Error(`unexpected get ${id}`); + }), + query: vi.fn((table: string) => { + if (table === "publisherAbuseScoreRuns") return makeEmptyPublisherAbuseScoreRunsQuery(); + if (table === "publisherAbuseSignals") return makePublisherAbuseSignalCountQuery(signals); + if (table === "officialPublishers") { + return { + withIndex: ( + indexName: string, + build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(indexName).toBe("by_publisher"); + const constraints: Record = {}; + const q = { + eq(field: string, value: unknown) { + constraints[field] = value; + return q; + }, + }; + build(q); + return { + unique: async () => + constraints.publisherId === "publishers:official" + ? { _id: "officialPublishers:official" } + : null, + }; + }, + }; + } + throw new Error(`unexpected table ${table}`); + }), + }; + + await expect(listDashboardHandler({ db }, {})).resolves.toEqual({ + latestRun: null, + pendingItems: [], + pendingPotentialBanCandidateItems: [], + pendingReviewItems: [], + recentResolvedItems: [], + signalCount: 1, + signalCountHasMore: false, + }); + }); + + it("defaults publisher abuse autobans to disabled when no setting exists", async () => { + const user = { _id: "users:moderator", role: "moderator" }; + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user, + } as never); + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table === "systemSettings") return makePublisherAbuseAutobanSettingQuery(null); + throw new Error(`unexpected table ${table}`); + }), + }, + }; + + await expect(getPublisherAbuseAutobanSettingHandler(ctx, {})).resolves.toEqual({ + enabled: false, + updatedAt: null, + updatedByUserId: null, + }); + + expect(ctx.db.query).toHaveBeenCalledWith("systemSettings"); + }); + + it("lets admins enable publisher abuse autobans with an audit log", async () => { + const user = { _id: "users:admin", role: "admin" }; + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:admin", + user, + } as never); + const inserted: Array<{ table: string; value: unknown }> = []; + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table === "systemSettings") return makePublisherAbuseAutobanSettingQuery(null); + throw new Error(`unexpected table ${table}`); + }), + insert: vi.fn(async (table: string, value: unknown) => { + inserted.push({ table, value }); + return `${table}:new`; + }), + patch: vi.fn(), + }, + }; + + await expect(setPublisherAbuseAutobanEnabledHandler(ctx, { enabled: true })).resolves.toEqual({ + enabled: true, + updatedAt: expect.any(Number), + updatedByUserId: "users:admin", + }); + + expect(assertAdmin).toHaveBeenCalledWith(user); + expect(ctx.db.patch).not.toHaveBeenCalled(); + expect(inserted).toEqual([ + { + table: "systemSettings", + value: { + key: "publisherAbuseAutobanEnabled", + enabled: true, + updatedAt: expect.any(Number), + updatedByUserId: "users:admin", + }, + }, + { + table: "auditLogs", + value: { + actorUserId: "users:admin", + action: "publisher_abuse.autoban_setting.set", + targetType: "system", + targetId: "publisherAbuseAutobanEnabled", + metadata: { + previousEnabled: false, + nextEnabled: true, + }, + createdAt: expect.any(Number), + }, + }, + ]); + }); + + it("lets admins disable publisher abuse autobans with an audit log", async () => { + const user = { _id: "users:admin", role: "admin" }; + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:admin", + user, + } as never); + const existingSetting = { + _id: "systemSettings:autoban", + key: "publisherAbuseAutobanEnabled", + enabled: true, + updatedAt: 1, + updatedByUserId: "users:admin", + }; + const inserted: Array<{ table: string; value: unknown }> = []; + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table === "systemSettings") { + return makePublisherAbuseAutobanSettingQuery(existingSetting); + } + throw new Error(`unexpected table ${table}`); + }), + insert: vi.fn(async (table: string, value: unknown) => { + inserted.push({ table, value }); + return `${table}:new`; + }), + patch: vi.fn(), + }, + }; await expect(setPublisherAbuseAutobanEnabledHandler(ctx, { enabled: false })).resolves.toEqual({ enabled: false, @@ -2327,24 +3361,279 @@ describe("publisher abuse dry-run persistence", () => { ), }; const ctx = { - scheduler, + scheduler, + runMutation, + db: { + get: vi.fn(async (id: string) => { + if (id === firstScore._id) return firstScore; + if (id === secondScore._id) return secondScore; + if (id === run._id) return run; + if (id === publisher._id) return publisher; + if (id === user._id) return user; + if (id === nomination._id) return nomination; + return null; + }), + patch: vi.fn(async (id: string, patch: Record) => { + if (id !== nomination._id) throw new Error(`unexpected patch id ${id}`); + Object.assign(nomination, patch); + return null; + }), + insert: vi.fn(async (table: string) => `${table}:new`), + query: vi.fn((table: string) => { + if (table === "publisherAbuseReviewNominations") { + return makeAutoBanNominationQuery([nomination]); + } + if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery(); + if (table === "systemSettings") { + return makePublisherAbuseAutobanSettingQuery({ + key: "publisherAbuseAutobanEnabled", + enabled: true, + updatedAt: 1, + updatedByUserId: "users:admin", + }); + } + throw new Error(`unexpected table ${table}`); + }), + }, + }; + const nowSpy = vi.spyOn(Date, "now"); + nowSpy.mockReturnValue(warningPendingAt); + + await expect(autoBanPublisherAbuseCandidatesPageHandler(ctx, {})).resolves.toEqual({ + ok: true, + processed: 1, + warned: 1, + banned: 0, + alreadyBanned: 0, + skipped: 0, + isDone: true, + }); + expect(scheduledWarnings).toHaveLength(1); + expect(nomination).toMatchObject({ + warningPendingAt, + warningPendingScoreId: firstScore._id, + warningPendingRunId: run._id, + }); + expect(nomination).not.toHaveProperty("warningSentAt"); + + await expect( + recordPublisherAbuseWarningSentHandler(ctx, { + nominationId: scheduledWarnings[0].nominationId, + ownerKey: scheduledWarnings[0].ownerKey, + runId: scheduledWarnings[0].runId, + scoreId: scheduledWarnings[0].scoreId, + warningPendingAt: scheduledWarnings[0].warningPendingAt, + warningSentAt, + deadlineAt, + }), + ).resolves.toEqual({ ok: true }); + expect(nomination).toMatchObject({ + warningSentAt, + warningExpiresAt: deadlineAt, + warningScoreId: firstScore._id, + warningRunId: run._id, + warningPendingAt: undefined, + }); + + Object.assign(nomination, { + latestScoreId: secondScore._id, + lastScoredAt: secondScore.createdAt, + updatedAt: secondScore.createdAt, + }); + nowSpy.mockReturnValue(deadlineAt + 2); + + await expect(autoBanPublisherAbuseCandidatesPageHandler(ctx, {})).resolves.toEqual({ + ok: true, + processed: 1, + warned: 0, + banned: 1, + alreadyBanned: 0, + skipped: 0, + isDone: true, + }); + + expect(scheduler.runAfter).toHaveBeenCalledTimes(1); + expect(runMutation).toHaveBeenCalledWith(expect.anything(), { + ownerUserId: user._id, + nominationId: nomination._id, + scoreId: secondScore._id, + reason: + "publisher_abuse: potential ban candidate (publisher-abuse-pressure.v4): high_catalog_volume, low_installs_per_skill", + }); + nowSpy.mockRestore(); + }); + + it("does not warn or ban candidates when the page sees autobans disabled", async () => { + const runMutation = vi.fn(); + const scheduler = { runAfter: vi.fn(async () => null) }; + const patch = vi.fn(async () => null); + const insert = vi.fn(async (table: string) => `${table}:new`); + const get = vi.fn(); + const ctx = { + scheduler, + runMutation, + db: { + get, + patch, + insert, + query: vi.fn((table: string) => { + if (table === "systemSettings") { + return makePublisherAbuseAutobanSettingQuery({ + key: "publisherAbuseAutobanEnabled", + enabled: false, + updatedAt: 100, + }); + } + throw new Error(`unexpected table ${table}`); + }), + }, + }; + + await expect(autoBanPublisherAbuseCandidatesPageHandler(ctx, {})).resolves.toEqual({ + ok: true, + processed: 0, + warned: 0, + banned: 0, + alreadyBanned: 0, + skipped: 0, + isDone: true, + }); + + expect(runMutation).not.toHaveBeenCalled(); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + expect(get).not.toHaveBeenCalled(); + expect(patch).not.toHaveBeenCalled(); + expect(insert).not.toHaveBeenCalled(); + }); + + it("moves candidates without email to manual review instead of warning or banning", async () => { + const nomination = makeNomination({ + _id: "publisherAbuseReviewNominations:no-email", + ownerKey: "publisher:publishers:no-email", + ownerPublisherId: "publishers:no-email", + ownerUserId: "users:no-email", + latestScoreId: "publisherAbuseScores:no-email", + handleSnapshot: "no-email", + label: "potential_ban_candidate", + status: "pending", + }); + const publisher = { + _id: "publishers:no-email", + kind: "user", + handle: "no-email", + linkedUserId: "users:no-email", + }; + const patch = vi.fn(async () => null); + const insert = vi.fn(async (table: string) => `${table}:new`); + const runMutation = vi.fn(); + const scheduler = { runAfter: vi.fn(async () => null) }; + const ctx = { + scheduler, + runMutation, + db: { + get: vi.fn(async (id: string) => { + if (id === "publisherAbuseScores:no-email") { + return makeScore({ + _id: "publisherAbuseScores:no-email", + ownerKey: "publisher:publishers:no-email", + ownerPublisherId: "publishers:no-email", + }); + } + if (id === "publisherAbuseScoreRuns:latest") return makeCompletedPressureScoreRun(); + if (id === "publishers:no-email") return publisher; + if (id === "users:no-email") { + return { _id: "users:no-email", handle: "no-email", role: "user" }; + } + return null; + }), + patch, + insert, + query: vi.fn((table: string) => { + if (table === "publisherAbuseReviewNominations") { + return makeAutoBanNominationQuery([nomination]); + } + if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery(); + if (table === "systemSettings") { + return makePublisherAbuseAutobanSettingQuery({ + key: "publisherAbuseAutobanEnabled", + enabled: true, + updatedAt: 1, + updatedByUserId: "users:admin", + }); + } + throw new Error(`unexpected table ${table}`); + }), + }, + }; + + await expect(autoBanPublisherAbuseCandidatesPageHandler(ctx, {})).resolves.toEqual({ + ok: true, + processed: 1, + warned: 0, + banned: 0, + alreadyBanned: 0, + skipped: 1, + isDone: true, + }); + + expect(runMutation).not.toHaveBeenCalled(); + expect(scheduler.runAfter).not.toHaveBeenCalled(); + expect(patch).toHaveBeenCalledWith( + "publisherAbuseReviewNominations:no-email", + expect.objectContaining({ + status: "needs_policy_discussion", + notes: "Autoban warning skipped: linked user has no email address; manual review required.", + }), + ); + }); + + it("moves completed current temporal candidates out of the autoban queue", async () => { + const nomination = makeNomination({ + _id: "publisherAbuseReviewNominations:temporal", + ownerKey: "publisher:publishers:temporal", + ownerPublisherId: "publishers:temporal", + ownerUserId: "users:temporal", + latestScoreId: "publisherAbuseScores:temporal", + openedByRunId: "publisherAbuseScoreRuns:temporal", + label: "potential_ban_candidate", + status: "pending", + }); + const score = makeScore({ + _id: "publisherAbuseScores:temporal", + runId: "publisherAbuseScoreRuns:temporal", + ownerKey: "publisher:publishers:temporal", + ownerPublisherId: "publishers:temporal", + }); + const patch = vi.fn(async () => null); + const insert = vi.fn(async (table: string) => `${table}:new`); + const runMutation = vi.fn(); + const ctx = { runMutation, db: { get: vi.fn(async (id: string) => { - if (id === firstScore._id) return firstScore; - if (id === secondScore._id) return secondScore; - if (id === run._id) return run; - if (id === publisher._id) return publisher; - if (id === user._id) return user; - if (id === nomination._id) return nomination; - return null; - }), - patch: vi.fn(async (id: string, patch: Record) => { - if (id !== nomination._id) throw new Error(`unexpected patch id ${id}`); - Object.assign(nomination, patch); + if (id === "publisherAbuseScores:temporal") return score; + if (id === "publisherAbuseScoreRuns:temporal") { + return { + _id: "publisherAbuseScoreRuns:temporal", + modelVersion: "publisher-abuse-temporal.v1", + status: "completed", + phase: "completed", + temporalMode: "current", + temporalScanComplete: true, + }; + } + if (id === "publishers:temporal") { + return { + _id: "publishers:temporal", + kind: "user", + handle: "temporal", + linkedUserId: "users:temporal", + }; + } return null; }), - insert: vi.fn(async (table: string) => `${table}:new`), + patch, + insert, query: vi.fn((table: string) => { if (table === "publisherAbuseReviewNominations") { return makeAutoBanNominationQuery([nomination]); @@ -2362,448 +3651,619 @@ describe("publisher abuse dry-run persistence", () => { }), }, }; - const nowSpy = vi.spyOn(Date, "now"); - nowSpy.mockReturnValue(warningPendingAt); await expect(autoBanPublisherAbuseCandidatesPageHandler(ctx, {})).resolves.toEqual({ ok: true, processed: 1, - warned: 1, + warned: 0, banned: 0, alreadyBanned: 0, - skipped: 0, + skipped: 1, isDone: true, }); - expect(scheduledWarnings).toHaveLength(1); - expect(nomination).toMatchObject({ - warningPendingAt, - warningPendingScoreId: firstScore._id, - warningPendingRunId: run._id, + + expect(runMutation).not.toHaveBeenCalled(); + expect(patch).toHaveBeenCalledWith( + "publisherAbuseReviewNominations:temporal", + expect.objectContaining({ + status: "candidate_for_future_action", + notes: "Autoban skipped: temporal publisher abuse signals require manual review.", + }), + ); + expect(insert).toHaveBeenCalledWith( + "publisherAbuseReviewEvents", + expect.objectContaining({ + nominationId: "publisherAbuseReviewNominations:temporal", + nextStatus: "candidate_for_future_action", + }), + ); + }); + + it("uses nomination order while the latest score run is failed", async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); + const failedRun = { + _id: "publisherAbuseScoreRuns:failed", + modelVersion: "publisher-abuse-pressure.v2", + trigger: "manual", + status: "failed", + phase: "finalizing", + startedAt: 10, + updatedAt: 20, + scannedPublishers: 100, + scoredPublishers: 100, + finalizedScores: 50, + nominatedPublishers: 1, + passCount: 0, + reviewCount: 1, + potentialBanCandidateCount: 0, + }; + const failedRunScore = makeScore({ + _id: "publisherAbuseScores:failed-run-score", + ownerKey: "user:failed-run", + label: "review", + rank: 1, + zScore: 2.1, }); - expect(nomination).not.toHaveProperty("warningSentAt"); + const nomination = makeNomination({ + _id: "publisherAbuseReviewNominations:failed-run", + ownerKey: "user:failed-run", + latestScoreId: "publisherAbuseScores:failed-run-score", + label: "review", + handleSnapshot: "failed-run-pending", + lastScoredAt: 20, + }); + const query = vi.fn((table: string) => { + if (table === "publisherAbuseScoreRuns") { + return { + withIndex: () => ({ + order: () => ({ + first: async () => failedRun, + }), + }), + }; + } + if (table === "publisherAbuseScores") { + throw new Error("failed latest runs should use nomination order, not score-rank order"); + } + if (table === "publisherAbuseReviewNominations") { + return { + withIndex: ( + indexName: string, + build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + const constraints: Record = {}; + const q = { + eq(field: string, value: unknown) { + constraints[field] = value; + return q; + }, + }; + build(q); + if (indexName === "by_status_and_label_and_last_scored_at") { + return { + order: () => ({ + paginate: async (paginationOpts: { numItems: number; cursor: string | null }) => { + expect(paginationOpts).toEqual({ numItems: 1, cursor: null }); + return { + page: + constraints.label === "review" && constraints.status === "pending" + ? [nomination] + : [], + isDone: true, + continueCursor: "", + }; + }, + }), + }; + } + if (indexName === "by_status_and_reviewed_at") { + return { + order: () => ({ + take: async () => [], + }), + }; + } + throw new Error(`unexpected nomination index ${indexName}`); + }, + }; + } + throw new Error(`unexpected table ${table}`); + }); + const ctx = { + db: { + get: vi.fn(async (id: string) => { + if (id === "publisherAbuseScores:failed-run-score") return failedRunScore; + if (id === "publisherAbuseScoreRuns:failed") return failedRun; + return null; + }), + query, + }, + }; await expect( - recordPublisherAbuseWarningSentHandler(ctx, { - nominationId: scheduledWarnings[0].nominationId, - ownerKey: scheduledWarnings[0].ownerKey, - runId: scheduledWarnings[0].runId, - scoreId: scheduledWarnings[0].scoreId, - warningPendingAt: scheduledWarnings[0].warningPendingAt, - warningSentAt, - deadlineAt, + listReviewItemsPageHandler(ctx, { + tab: "review", + paginationOpts: { numItems: 1, cursor: null }, }), - ).resolves.toEqual({ ok: true }); - expect(nomination).toMatchObject({ - warningSentAt, - warningExpiresAt: deadlineAt, - warningScoreId: firstScore._id, - warningRunId: run._id, - warningPendingAt: undefined, - }); + ).resolves.toEqual( + expect.objectContaining({ + page: [ + expect.objectContaining({ + nomination: expect.objectContaining({ + _id: "publisherAbuseReviewNominations:failed-run", + latestScoreId: "publisherAbuseScores:failed-run-score", + }), + }), + ], + }), + ); + }); - Object.assign(nomination, { - latestScoreId: secondScore._id, - lastScoredAt: secondScore.createdAt, - updatedAt: secondScore.createdAt, + it("pages nomination rows while skipping hidden dashboard candidates", async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); + const hiddenNomination = makeNomination({ + _id: "publisherAbuseReviewNominations:hidden", + ownerKey: "publisher:publishers:hidden", + ownerPublisherId: "publishers:hidden", + ownerUserId: "users:hidden", + latestScoreId: "publisherAbuseScores:hidden", + label: "potential_ban_candidate", + status: "pending", }); - nowSpy.mockReturnValue(deadlineAt + 2); - - await expect(autoBanPublisherAbuseCandidatesPageHandler(ctx, {})).resolves.toEqual({ - ok: true, - processed: 1, - warned: 0, - banned: 1, - alreadyBanned: 0, - skipped: 0, - isDone: true, + const visibleNomination = makeNomination({ + _id: "publisherAbuseReviewNominations:visible", + ownerKey: "publisher:publishers:visible", + ownerPublisherId: "publishers:visible", + ownerUserId: "users:visible", + latestScoreId: "publisherAbuseScores:visible", + label: "potential_ban_candidate", + status: "pending", }); - - expect(scheduler.runAfter).toHaveBeenCalledTimes(1); - expect(runMutation).toHaveBeenCalledWith(expect.anything(), { - ownerUserId: user._id, - nominationId: nomination._id, - scoreId: secondScore._id, - reason: - "publisher_abuse: potential ban candidate (publisher-abuse-pressure.v4): high_catalog_volume, low_installs_per_skill", + const hiddenScore = makeScore({ + _id: "publisherAbuseScores:hidden", + ownerKey: hiddenNomination.ownerKey, }); - nowSpy.mockRestore(); - }); - - it("does not warn or ban candidates when the page sees autobans disabled", async () => { - const runMutation = vi.fn(); - const scheduler = { runAfter: vi.fn(async () => null) }; - const patch = vi.fn(async () => null); - const insert = vi.fn(async (table: string) => `${table}:new`); - const get = vi.fn(); + const visibleScore = makeScore({ + _id: "publisherAbuseScores:visible", + ownerKey: visibleNomination.ownerKey, + }); + const nominationsPaginate = vi.fn( + async (paginationOpts: { numItems: number; cursor: string | null }) => { + expect(paginationOpts).toEqual({ numItems: 2, cursor: null }); + return { + page: [hiddenNomination, visibleNomination], + isDone: true, + continueCursor: "", + }; + }, + ); const ctx = { - scheduler, - runMutation, db: { - get, - patch, - insert, + get: vi.fn(async (id: string) => { + if (id === "publishers:hidden") { + return { + _id: "publishers:hidden", + kind: "user", + handle: "hidden", + linkedUserId: "users:hidden", + deletedAt: 10, + }; + } + if (id === "publishers:visible") { + return { + _id: "publishers:visible", + kind: "user", + handle: "visible", + linkedUserId: "users:visible", + }; + } + if (id === "publisherAbuseScores:hidden") return hiddenScore; + if (id === "publisherAbuseScores:visible") return visibleScore; + if (id === "users:hidden") { + return { _id: "users:hidden", handle: "hidden", role: "user" }; + } + if (id === "users:visible") { + return { _id: "users:visible", handle: "visible", role: "user" }; + } + return null; + }), query: vi.fn((table: string) => { - if (table === "systemSettings") { - return makePublisherAbuseAutobanSettingQuery({ - key: "publisherAbuseAutobanEnabled", - enabled: false, - updatedAt: 100, - }); + if (table === "publisherAbuseReviewNominations") { + return { + withIndex: ( + indexName: string, + build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + const constraints: Record = {}; + const q = { + eq(field: string, value: unknown) { + constraints[field] = value; + return q; + }, + }; + build(q); + if (indexName === "by_status_and_label_and_last_scored_at") { + expect(constraints).toEqual({ + status: "pending", + label: "potential_ban_candidate", + }); + return { + order: () => ({ + paginate: nominationsPaginate, + }), + }; + } + throw new Error(`unexpected nomination index ${indexName}`); + }, + }; } + if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery(); throw new Error(`unexpected table ${table}`); }), }, }; - await expect(autoBanPublisherAbuseCandidatesPageHandler(ctx, {})).resolves.toEqual({ - ok: true, - processed: 0, - warned: 0, - banned: 0, - alreadyBanned: 0, - skipped: 0, - isDone: true, - }); - - expect(runMutation).not.toHaveBeenCalled(); - expect(scheduler.runAfter).not.toHaveBeenCalled(); - expect(get).not.toHaveBeenCalled(); - expect(patch).not.toHaveBeenCalled(); - expect(insert).not.toHaveBeenCalled(); + await expect( + listReviewItemsPageHandler(ctx, { + tab: "potential_ban_candidate", + paginationOpts: { numItems: 2, cursor: null }, + }), + ).resolves.toEqual( + expect.objectContaining({ + page: [ + expect.objectContaining({ + nomination: expect.objectContaining({ + _id: "publisherAbuseReviewNominations:visible", + latestScoreId: "publisherAbuseScores:visible", + }), + latestScore: expect.objectContaining({ + _id: "publisherAbuseScores:visible", + zScore: visibleScore.zScore, + reasonCodes: visibleScore.reasonCodes, + }), + publisher: expect.objectContaining({ + displayName: null, + handle: "visible", + }), + }), + ], + }), + ); + expect(nominationsPaginate).toHaveBeenCalledWith({ numItems: 2, cursor: null }); }); - it("moves candidates without email to manual review instead of warning or banning", async () => { + it("excludes staff-managed org rows without a second paginated query", async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); const nomination = makeNomination({ - _id: "publisherAbuseReviewNominations:no-email", - ownerKey: "publisher:publishers:no-email", - ownerPublisherId: "publishers:no-email", - ownerUserId: "users:no-email", - latestScoreId: "publisherAbuseScores:no-email", - handleSnapshot: "no-email", + _id: "publisherAbuseReviewNominations:staff-org", + ownerKey: "publisher:publishers:staff-org", + ownerPublisherId: "publishers:staff-org", + ownerUserId: "users:owner", + latestScoreId: "publisherAbuseScores:staff-org", label: "potential_ban_candidate", status: "pending", }); - const publisher = { - _id: "publishers:no-email", - kind: "user", - handle: "no-email", - linkedUserId: "users:no-email", - }; - const patch = vi.fn(async () => null); - const insert = vi.fn(async (table: string) => `${table}:new`); - const runMutation = vi.fn(); - const scheduler = { runAfter: vi.fn(async () => null) }; + const nominationsPaginate = vi.fn(async () => ({ + page: [nomination], + isDone: true, + continueCursor: "", + })); + const publisherMembersTake = vi.fn(async () => [ + { + _id: "publisherMembers:staff", + publisherId: "publishers:staff-org", + userId: "users:staff", + role: "owner", + }, + ]); const ctx = { - scheduler, - runMutation, db: { get: vi.fn(async (id: string) => { - if (id === "publisherAbuseScores:no-email") { - return makeScore({ - _id: "publisherAbuseScores:no-email", - ownerKey: "publisher:publishers:no-email", - ownerPublisherId: "publishers:no-email", - }); - } - if (id === "publisherAbuseScoreRuns:latest") return makeCompletedPressureScoreRun(); - if (id === "publishers:no-email") return publisher; - if (id === "users:no-email") { - return { _id: "users:no-email", handle: "no-email", role: "user" }; + if (id === "publishers:staff-org") { + return { + _id: "publishers:staff-org", + kind: "org", + handle: "staff-org", + displayName: "Staff Org", + linkedUserId: "users:owner", + }; } + if (id === "users:owner") return { _id: "users:owner", role: "user" }; + if (id === "users:staff") return { _id: "users:staff", role: "moderator" }; return null; }), - patch, - insert, query: vi.fn((table: string) => { if (table === "publisherAbuseReviewNominations") { - return makeAutoBanNominationQuery([nomination]); + return { + withIndex: () => ({ + order: () => ({ + paginate: nominationsPaginate, + }), + }), + }; } if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery(); - if (table === "systemSettings") { - return makePublisherAbuseAutobanSettingQuery({ - key: "publisherAbuseAutobanEnabled", - enabled: true, - updatedAt: 1, - updatedByUserId: "users:admin", - }); + if (table === "publisherMembers") { + return { + withIndex: () => ({ + take: publisherMembersTake, + }), + }; } throw new Error(`unexpected table ${table}`); }), }, }; - await expect(autoBanPublisherAbuseCandidatesPageHandler(ctx, {})).resolves.toEqual({ - ok: true, - processed: 1, - warned: 0, - banned: 0, - alreadyBanned: 0, - skipped: 1, - isDone: true, - }); - - expect(runMutation).not.toHaveBeenCalled(); - expect(scheduler.runAfter).not.toHaveBeenCalled(); - expect(patch).toHaveBeenCalledWith( - "publisherAbuseReviewNominations:no-email", + await expect( + listReviewItemsPageHandler(ctx, { + tab: "potential_ban_candidate", + paginationOpts: { numItems: 1, cursor: null }, + }), + ).resolves.toEqual( expect.objectContaining({ - status: "needs_policy_discussion", - notes: "Autoban warning skipped: linked user has no email address; manual review required.", + page: [], + isDone: true, + continueCursor: "", }), ); + expect(nominationsPaginate).toHaveBeenCalledWith({ numItems: 1, cursor: null }); + expect(publisherMembersTake).toHaveBeenCalledWith(100); }); - it("moves completed current temporal candidates out of the autoban queue", async () => { + it("normalizes absent optional publisher and user fields in nomination rows", async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); const nomination = makeNomination({ - _id: "publisherAbuseReviewNominations:temporal", - ownerKey: "publisher:publishers:temporal", - ownerPublisherId: "publishers:temporal", - ownerUserId: "users:temporal", - latestScoreId: "publisherAbuseScores:temporal", - openedByRunId: "publisherAbuseScoreRuns:temporal", + _id: "publisherAbuseReviewNominations:legacy", + ownerKey: "publisher:publishers:legacy", + ownerPublisherId: "publishers:legacy", + ownerUserId: "users:legacy", + latestScoreId: "publisherAbuseScores:legacy", label: "potential_ban_candidate", status: "pending", }); - const score = makeScore({ - _id: "publisherAbuseScores:temporal", - runId: "publisherAbuseScoreRuns:temporal", - ownerKey: "publisher:publishers:temporal", - ownerPublisherId: "publishers:temporal", - }); - const patch = vi.fn(async () => null); - const insert = vi.fn(async (table: string) => `${table}:new`); - const runMutation = vi.fn(); const ctx = { - runMutation, db: { get: vi.fn(async (id: string) => { - if (id === "publisherAbuseScores:temporal") return score; - if (id === "publisherAbuseScoreRuns:temporal") { - return { - _id: "publisherAbuseScoreRuns:temporal", - modelVersion: "publisher-abuse-temporal.v1", - status: "completed", - phase: "completed", - temporalMode: "current", - temporalScanComplete: true, - }; - } - if (id === "publishers:temporal") { + if (id === "publishers:legacy") { return { - _id: "publishers:temporal", + _id: "publishers:legacy", kind: "user", - handle: "temporal", - linkedUserId: "users:temporal", + handle: "legacy", + createdAt: 1, + updatedAt: 1, }; } + if (id === "users:legacy") { + return { _id: "users:legacy" }; + } return null; }), - patch, - insert, query: vi.fn((table: string) => { if (table === "publisherAbuseReviewNominations") { - return makeAutoBanNominationQuery([nomination]); + return { + withIndex: () => ({ + order: () => ({ + paginate: async () => ({ + page: [nomination], + isDone: true, + continueCursor: "", + }), + }), + }), + }; } if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery(); - if (table === "systemSettings") { - return makePublisherAbuseAutobanSettingQuery({ - key: "publisherAbuseAutobanEnabled", - enabled: true, - updatedAt: 1, - updatedByUserId: "users:admin", - }); - } throw new Error(`unexpected table ${table}`); }), }, }; - await expect(autoBanPublisherAbuseCandidatesPageHandler(ctx, {})).resolves.toEqual({ - ok: true, - processed: 1, - warned: 0, - banned: 0, - alreadyBanned: 0, - skipped: 1, - isDone: true, + const result = await listReviewItemsPageHandler(ctx, { + tab: "potential_ban_candidate", + paginationOpts: { numItems: 1, cursor: null }, }); - expect(runMutation).not.toHaveBeenCalled(); - expect(patch).toHaveBeenCalledWith( - "publisherAbuseReviewNominations:temporal", - expect.objectContaining({ - status: "candidate_for_future_action", - notes: "Autoban skipped: temporal publisher abuse signals require manual review.", - }), - ); - expect(insert).toHaveBeenCalledWith( - "publisherAbuseReviewEvents", + const [item] = result.page as Array<{ + publisher: unknown; + ownerUser: unknown; + }>; + expect(result.page).toEqual([ expect.objectContaining({ - nominationId: "publisherAbuseReviewNominations:temporal", - nextStatus: "candidate_for_future_action", + publisher: expect.objectContaining({ + displayName: null, + linkedUserId: null, + publishedSkills: 0, + publishedPackages: 0, + totalInstalls: 0, + totalStars: 0, + totalDownloads: 0, + skillTotalInstalls: 0, + skillTotalStars: 0, + skillTotalDownloads: 0, + deletedAt: null, + deactivatedAt: null, + }), + ownerUser: expect.objectContaining({ + handle: null, + name: null, + displayName: null, + role: "user", + image: null, + deletedAt: null, + deactivatedAt: null, + banReason: null, + }), }), - ); + ]); + expect(hasNoUndefinedValues(item.publisher)).toBe(true); + expect(hasNoUndefinedValues(item.ownerUser)).toBe(true); }); - it("uses nomination order while the latest score run is failed", async () => { + it("pages archived publisher abuse signals for staff review", async () => { vi.mocked(requireUser).mockResolvedValue({ userId: "users:moderator", user: { _id: "users:moderator", role: "moderator" }, } as never); - const failedRun = { - _id: "publisherAbuseScoreRuns:failed", - modelVersion: "publisher-abuse-pressure.v2", - trigger: "manual", - status: "failed", - phase: "finalizing", - startedAt: 10, - updatedAt: 20, - scannedPublishers: 100, - scoredPublishers: 100, - finalizedScores: 50, - nominatedPublishers: 1, - passCount: 0, - reviewCount: 1, - potentialBanCandidateCount: 0, + const signal = { + _id: "publisherAbuseSignals:ratio", + _creationTime: 100, + signalType: "high_install_download_ratio", + ownerKey: "publisher:publishers:ratio-owner", + ownerPublisherId: "publishers:ratio-owner", + ownerUserId: "users:ratio-owner", + handleSnapshot: "ratio-owner", + skillId: "skills:ratio", + skillSlug: "ratio-skill", + skillDisplayName: "Ratio Skill", + latestRunId: "publisherAbuseScoreRuns:temporal", + firstSeenAt: 100, + lastSeenAt: 200, + seenCount: 2, + recent7Downloads: 600, + recent7Installs: 72, + recent7InstallDownloadRatio: 0.12, + recent30Downloads: 2_000, + recent30Installs: 240, + recent30InstallDownloadRatio: 0.12, + allTimeDownloads: 10_000, + allTimeInstalls: 1_200, + allTimeInstallDownloadRatio: 0.12, }; - const failedRunScore = makeScore({ - _id: "publisherAbuseScores:failed-run-score", - ownerKey: "user:failed-run", - label: "review", - rank: 1, - zScore: 2.1, - }); - const nomination = makeNomination({ - _id: "publisherAbuseReviewNominations:failed-run", - ownerKey: "user:failed-run", - latestScoreId: "publisherAbuseScores:failed-run-score", - label: "review", - handleSnapshot: "failed-run-pending", - lastScoredAt: 20, - }); - const query = vi.fn((table: string) => { - if (table === "publisherAbuseScoreRuns") { - return { - withIndex: () => ({ - order: () => ({ - first: async () => failedRun, - }), - }), - }; - } - if (table === "publisherAbuseScores") { - throw new Error("failed latest runs should use nomination order, not score-rank order"); - } - if (table === "publisherAbuseReviewNominations") { + const signalPaginate = vi.fn( + async (paginationOpts: { numItems: number; cursor: string | null }) => { + expect(paginationOpts).toEqual({ numItems: 10, cursor: null }); return { - withIndex: ( - indexName: string, - build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, - ) => { - const constraints: Record = {}; - const q = { - eq(field: string, value: unknown) { - constraints[field] = value; - return q; - }, - }; - build(q); - if (indexName === "by_status_and_label_and_last_scored_at") { - return { - order: () => ({ - paginate: async (paginationOpts: { numItems: number; cursor: string | null }) => { - expect(paginationOpts).toEqual({ numItems: 1, cursor: null }); - return { - page: - constraints.label === "review" && constraints.status === "pending" - ? [nomination] - : [], - isDone: true, - continueCursor: "", - }; - }, - }), - }; - } - if (indexName === "by_status_and_reviewed_at") { - return { - order: () => ({ - take: async () => [], - }), - }; - } - throw new Error(`unexpected nomination index ${indexName}`); - }, + page: [signal], + isDone: true, + continueCursor: "", }; - } - throw new Error(`unexpected table ${table}`); - }); + }, + ); const ctx = { db: { get: vi.fn(async (id: string) => { - if (id === "publisherAbuseScores:failed-run-score") return failedRunScore; - if (id === "publisherAbuseScoreRuns:failed") return failedRun; - return null; + if (id === "publishers:ratio-owner") { + return { + _id: "publishers:ratio-owner", + kind: "user", + handle: "ratio-owner", + linkedUserId: "users:ratio-owner", + }; + } + if (id === "users:ratio-owner") { + return { + _id: "users:ratio-owner", + handle: "ratio-owner", + role: "user", + }; + } + throw new Error(`unexpected get ${id}`); + }), + query: vi.fn((table: string) => { + if (table === "publisherAbuseSignals") { + return { + withIndex: ( + indexName: string, + build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + const constraints: Record = {}; + const q = { + eq(field: string, value: unknown) { + constraints[field] = value; + return q; + }, + }; + build(q); + expect(indexName).toBe("by_signal_type_and_last_seen_at"); + expect(constraints).toEqual({ signalType: "high_install_download_ratio" }); + return { + order: () => ({ + paginate: signalPaginate, + }), + }; + }, + }; + } + if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery(); + throw new Error(`unexpected table ${table}`); }), - query, }, }; await expect( - listReviewItemsPageHandler(ctx, { - tab: "review", - paginationOpts: { numItems: 1, cursor: null }, + listSignalsPageHandler(ctx, { + signalType: "high_install_download_ratio", + paginationOpts: { numItems: 10, cursor: null }, }), - ).resolves.toEqual( - expect.objectContaining({ - page: [ - expect.objectContaining({ - nomination: expect.objectContaining({ - _id: "publisherAbuseReviewNominations:failed-run", - latestScoreId: "publisherAbuseScores:failed-run-score", - }), + ).resolves.toEqual({ + page: [ + expect.objectContaining({ + signal, + publisher: expect.objectContaining({ + _id: "publishers:ratio-owner", + handle: "ratio-owner", }), - ], - }), - ); + ownerUser: expect.objectContaining({ + _id: "users:ratio-owner", + handle: "ratio-owner", + }), + }), + ], + isDone: true, + continueCursor: "", + }); + expect(signalPaginate).toHaveBeenCalledWith({ numItems: 10, cursor: null }); }); - it("pages nomination rows while skipping hidden dashboard candidates", async () => { + it("pages archived publisher abuse signals by review status", async () => { vi.mocked(requireUser).mockResolvedValue({ userId: "users:moderator", user: { _id: "users:moderator", role: "moderator" }, } as never); - const hiddenNomination = makeNomination({ - _id: "publisherAbuseReviewNominations:hidden", - ownerKey: "publisher:publishers:hidden", - ownerPublisherId: "publishers:hidden", - ownerUserId: "users:hidden", - latestScoreId: "publisherAbuseScores:hidden", - label: "potential_ban_candidate", - status: "pending", - }); - const visibleNomination = makeNomination({ - _id: "publisherAbuseReviewNominations:visible", - ownerKey: "publisher:publishers:visible", - ownerPublisherId: "publishers:visible", - ownerUserId: "users:visible", - latestScoreId: "publisherAbuseScores:visible", - label: "potential_ban_candidate", - status: "pending", - }); - const hiddenScore = makeScore({ - _id: "publisherAbuseScores:hidden", - ownerKey: hiddenNomination.ownerKey, - }); - const visibleScore = makeScore({ - _id: "publisherAbuseScores:visible", - ownerKey: visibleNomination.ownerKey, - }); - const nominationsPaginate = vi.fn( + const signal = { + _id: "publisherAbuseSignals:snoozed", + _creationTime: 100, + signalType: "sustained_downloads_flat_installs", + ownerKey: "publisher:publishers:snoozed-owner", + ownerPublisherId: null, + ownerUserId: null, + handleSnapshot: "snoozed-owner", + skillId: "skills:snoozed", + skillSlug: "snoozed-skill", + skillDisplayName: "Snoozed Skill", + latestRunId: "publisherAbuseScoreRuns:temporal", + firstSeenAt: 100, + lastSeenAt: 200, + seenCount: 2, + reviewStatus: "snoozed", + recent7Downloads: 600, + recent7Installs: 1, + recent7InstallDownloadRatio: 0, + recent30Downloads: 2_000, + recent30Installs: 2, + recent30InstallDownloadRatio: 0.001, + allTimeDownloads: 10_000, + allTimeInstalls: 100, + allTimeInstallDownloadRatio: 0.01, + }; + const signalPaginate = vi.fn( async (paginationOpts: { numItems: number; cursor: string | null }) => { - expect(paginationOpts).toEqual({ numItems: 2, cursor: null }); + expect(paginationOpts).toEqual({ numItems: 10, cursor: null }); return { - page: [hiddenNomination, visibleNomination], + page: [signal], isDone: true, continueCursor: "", }; @@ -2812,35 +4272,10 @@ describe("publisher abuse dry-run persistence", () => { const ctx = { db: { get: vi.fn(async (id: string) => { - if (id === "publishers:hidden") { - return { - _id: "publishers:hidden", - kind: "user", - handle: "hidden", - linkedUserId: "users:hidden", - deletedAt: 10, - }; - } - if (id === "publishers:visible") { - return { - _id: "publishers:visible", - kind: "user", - handle: "visible", - linkedUserId: "users:visible", - }; - } - if (id === "publisherAbuseScores:hidden") return hiddenScore; - if (id === "publisherAbuseScores:visible") return visibleScore; - if (id === "users:hidden") { - return { _id: "users:hidden", handle: "hidden", role: "user" }; - } - if (id === "users:visible") { - return { _id: "users:visible", handle: "visible", role: "user" }; - } - return null; + throw new Error(`unexpected get ${id}`); }), query: vi.fn((table: string) => { - if (table === "publisherAbuseReviewNominations") { + if (table === "publisherAbuseSignals") { return { withIndex: ( indexName: string, @@ -2854,18 +4289,13 @@ describe("publisher abuse dry-run persistence", () => { }, }; build(q); - if (indexName === "by_status_and_label_and_last_scored_at") { - expect(constraints).toEqual({ - status: "pending", - label: "potential_ban_candidate", - }); - return { - order: () => ({ - paginate: nominationsPaginate, - }), - }; - } - throw new Error(`unexpected nomination index ${indexName}`); + expect(indexName).toBe("by_review_status_and_last_seen_at"); + expect(constraints).toEqual({ reviewStatus: "snoozed" }); + return { + order: () => ({ + paginate: signalPaginate, + }), + }; }, }; } @@ -2876,93 +4306,239 @@ describe("publisher abuse dry-run persistence", () => { }; await expect( - listReviewItemsPageHandler(ctx, { - tab: "potential_ban_candidate", - paginationOpts: { numItems: 2, cursor: null }, + listSignalsPageHandler(ctx, { + reviewStatus: "snoozed", + paginationOpts: { numItems: 10, cursor: null }, }), - ).resolves.toEqual( - expect.objectContaining({ - page: [ - expect.objectContaining({ - nomination: expect.objectContaining({ - _id: "publisherAbuseReviewNominations:visible", - latestScoreId: "publisherAbuseScores:visible", - }), - latestScore: expect.objectContaining({ - _id: "publisherAbuseScores:visible", - zScore: visibleScore.zScore, - reasonCodes: visibleScore.reasonCodes, - }), - publisher: expect.objectContaining({ - displayName: null, - handle: "visible", - }), - }), - ], + ).resolves.toEqual({ + page: [ + expect.objectContaining({ + signal, + publisher: null, + ownerUser: null, + }), + ], + isDone: true, + continueCursor: "", + }); + expect(signalPaginate).toHaveBeenCalledWith({ numItems: 10, cursor: null }); + }); + + it("rejects combined archived signal filters instead of paginating the wrong index", async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); + const ctx = { + db: { + query: vi.fn(() => { + throw new Error("query should not run"); + }), + }, + }; + + await expect( + listSignalsPageHandler(ctx, { + signalType: "high_install_download_ratio", + reviewStatus: "snoozed", + paginationOpts: { numItems: 10, cursor: null }, }), - ); - expect(nominationsPaginate).toHaveBeenCalledWith({ numItems: 2, cursor: null }); + ).rejects.toThrow("Filter by signalType or reviewStatus, not both."); }); - it("excludes staff-managed org rows without a second paginated query", async () => { + it("pages unfiltered archived publisher abuse signals by last seen time", async () => { vi.mocked(requireUser).mockResolvedValue({ userId: "users:moderator", user: { _id: "users:moderator", role: "moderator" }, } as never); - const nomination = makeNomination({ - _id: "publisherAbuseReviewNominations:staff-org", - ownerKey: "publisher:publishers:staff-org", - ownerPublisherId: "publishers:staff-org", - ownerUserId: "users:owner", - latestScoreId: "publisherAbuseScores:staff-org", - label: "potential_ban_candidate", - status: "pending", - }); - const nominationsPaginate = vi.fn(async () => ({ - page: [nomination], + const freshSignal = { + _id: "publisherAbuseSignals:fresh", + _creationTime: 100, + signalType: "high_install_download_ratio", + ownerKey: "publisher:publishers:fresh", + ownerPublisherId: "publishers:fresh", + ownerUserId: "users:fresh", + handleSnapshot: "fresh", + skillId: "skills:fresh", + skillSlug: "fresh-skill", + skillDisplayName: "Fresh Skill", + latestRunId: "publisherAbuseScoreRuns:temporal", + firstSeenAt: 100, + lastSeenAt: 300, + seenCount: 1, + recent7Downloads: 500, + recent7Installs: 75, + recent7InstallDownloadRatio: 0.15, + recent30Downloads: 1_000, + recent30Installs: 150, + recent30InstallDownloadRatio: 0.15, + allTimeDownloads: 1_000, + allTimeInstalls: 150, + allTimeInstallDownloadRatio: 0.15, + }; + const olderSignal = { + ...freshSignal, + _id: "publisherAbuseSignals:older", + ownerKey: "publisher:publishers:older", + ownerPublisherId: "publishers:older", + ownerUserId: "users:older", + handleSnapshot: "older", + skillId: "skills:older", + skillSlug: "older-skill", + skillDisplayName: "Older Skill", + lastSeenAt: 200, + }; + const signalPaginate = vi.fn(async () => ({ + page: [freshSignal, olderSignal], isDone: true, continueCursor: "", })); - const publisherMembersTake = vi.fn(async () => [ - { - _id: "publisherMembers:staff", - publisherId: "publishers:staff-org", - userId: "users:staff", - role: "owner", - }, - ]); const ctx = { db: { get: vi.fn(async (id: string) => { - if (id === "publishers:staff-org") { + if (id === "publishers:fresh" || id === "publishers:older") { + const handle = id.split(":").at(-1); return { - _id: "publishers:staff-org", - kind: "org", - handle: "staff-org", - displayName: "Staff Org", - linkedUserId: "users:owner", + _id: id, + kind: "user", + handle, + linkedUserId: `users:${handle}`, }; } - if (id === "users:owner") return { _id: "users:owner", role: "user" }; - if (id === "users:staff") return { _id: "users:staff", role: "moderator" }; - return null; + if (id === "users:fresh" || id === "users:older") { + const handle = id.split(":").at(-1); + return { _id: id, handle, role: "user" }; + } + throw new Error(`unexpected get ${id}`); }), query: vi.fn((table: string) => { - if (table === "publisherAbuseReviewNominations") { + if (table === "publisherAbuseSignals") { return { - withIndex: () => ({ - order: () => ({ - paginate: nominationsPaginate, - }), - }), + withIndex: (indexName: string) => { + expect(indexName).toBe("by_last_seen_at"); + return { + order: (direction: string) => { + expect(direction).toBe("desc"); + return { paginate: signalPaginate }; + }, + }; + }, }; } if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery(); - if (table === "publisherMembers") { + throw new Error(`unexpected table ${table}`); + }), + }, + }; + + await expect( + listSignalsPageHandler(ctx, { + paginationOpts: { numItems: 2, cursor: null }, + }), + ).resolves.toEqual({ + page: [ + expect.objectContaining({ + signal: freshSignal, + publisher: expect.objectContaining({ handle: "fresh" }), + ownerUser: expect.objectContaining({ handle: "fresh" }), + }), + expect.objectContaining({ + signal: olderSignal, + publisher: expect.objectContaining({ handle: "older" }), + ownerUser: expect.objectContaining({ handle: "older" }), + }), + ], + isDone: true, + continueCursor: "", + }); + expect(signalPaginate).toHaveBeenCalledWith({ numItems: 2, cursor: null }); + }); + + it("excludes archived publisher abuse signals for now-excluded publishers", async () => { + vi.mocked(requireUser).mockResolvedValue({ + userId: "users:moderator", + user: { _id: "users:moderator", role: "moderator" }, + } as never); + const signal = { + _id: "publisherAbuseSignals:official", + _creationTime: 100, + signalType: "high_install_download_ratio", + ownerKey: "publisher:publishers:official", + ownerPublisherId: "publishers:official", + ownerUserId: "users:official", + handleSnapshot: "official", + skillId: "skills:official", + skillSlug: "official-skill", + skillDisplayName: "Official Skill", + latestRunId: "publisherAbuseScoreRuns:temporal", + firstSeenAt: 100, + lastSeenAt: 300, + seenCount: 1, + recent7Downloads: 500, + recent7Installs: 75, + recent7InstallDownloadRatio: 0.15, + recent30Downloads: 1_000, + recent30Installs: 150, + recent30InstallDownloadRatio: 0.15, + allTimeDownloads: 1_000, + allTimeInstalls: 150, + allTimeInstallDownloadRatio: 0.15, + }; + const signalPaginate = vi.fn(async () => ({ + page: [signal], + isDone: true, + continueCursor: "", + })); + const ctx = { + db: { + get: vi.fn(async (id: string) => { + if (id === "publishers:official") { return { - withIndex: () => ({ - take: publisherMembersTake, - }), + _id: "publishers:official", + kind: "user", + handle: "official", + linkedUserId: "users:official", + }; + } + if (id === "users:official") return { _id: "users:official", role: "user" }; + throw new Error(`unexpected get ${id}`); + }), + query: vi.fn((table: string) => { + if (table === "publisherAbuseSignals") { + return { + withIndex: (indexName: string) => { + expect(indexName).toBe("by_last_seen_at"); + return { + order: (direction: string) => { + expect(direction).toBe("desc"); + return { paginate: signalPaginate }; + }, + }; + }, + }; + } + if (table === "officialPublishers") { + return { + withIndex: ( + indexName: string, + build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(indexName).toBe("by_publisher"); + const constraints: Record = {}; + const q = { + eq(field: string, value: unknown) { + constraints[field] = value; + return q; + }, + }; + build(q); + return { + unique: async () => + constraints.publisherId === "publishers:official" + ? { _id: "officialPublishers:official" } + : null, + }; + }, }; } throw new Error(`unexpected table ${table}`); @@ -2971,111 +4547,128 @@ describe("publisher abuse dry-run persistence", () => { }; await expect( - listReviewItemsPageHandler(ctx, { - tab: "potential_ban_candidate", + listSignalsPageHandler(ctx, { paginationOpts: { numItems: 1, cursor: null }, }), - ).resolves.toEqual( - expect.objectContaining({ - page: [], - isDone: true, - continueCursor: "", - }), - ); - expect(nominationsPaginate).toHaveBeenCalledWith({ numItems: 1, cursor: null }); - expect(publisherMembersTake).toHaveBeenCalledWith(100); + ).resolves.toEqual({ + page: [], + isDone: true, + continueCursor: "", + }); + expect(signalPaginate).toHaveBeenCalledWith({ numItems: 1, cursor: null }); }); - it("normalizes absent optional publisher and user fields in nomination rows", async () => { + it("returns a nonfinal empty signal page when the raw page is excluded", async () => { vi.mocked(requireUser).mockResolvedValue({ userId: "users:moderator", user: { _id: "users:moderator", role: "moderator" }, } as never); - const nomination = makeNomination({ - _id: "publisherAbuseReviewNominations:legacy", - ownerKey: "publisher:publishers:legacy", - ownerPublisherId: "publishers:legacy", - ownerUserId: "users:legacy", - latestScoreId: "publisherAbuseScores:legacy", - label: "potential_ban_candidate", - status: "pending", - }); + const officialSignal = { + _id: "publisherAbuseSignals:official", + _creationTime: 100, + signalType: "high_install_download_ratio", + ownerKey: "publisher:publishers:official", + ownerPublisherId: "publishers:official", + ownerUserId: "users:official", + handleSnapshot: "official", + skillId: "skills:official", + skillSlug: "official-skill", + skillDisplayName: "Official Skill", + latestRunId: "publisherAbuseScoreRuns:temporal", + firstSeenAt: 100, + lastSeenAt: 300, + seenCount: 1, + recent7Downloads: 500, + recent7Installs: 75, + recent7InstallDownloadRatio: 0.15, + recent30Downloads: 1_000, + recent30Installs: 150, + recent30InstallDownloadRatio: 0.15, + allTimeDownloads: 1_000, + allTimeInstalls: 150, + allTimeInstallDownloadRatio: 0.15, + }; + const signalPaginate = vi.fn( + async (paginationOpts: { numItems: number; cursor: string | null }) => { + expect(paginationOpts).toEqual({ numItems: 1, cursor: null }); + return { + page: [officialSignal], + isDone: false, + continueCursor: "after-official", + }; + }, + ); const ctx = { db: { get: vi.fn(async (id: string) => { - if (id === "publishers:legacy") { + if (id === "publishers:official") { + const handle = id.split(":").at(-1); return { - _id: "publishers:legacy", + _id: id, kind: "user", - handle: "legacy", - createdAt: 1, - updatedAt: 1, + handle, + linkedUserId: `users:${handle}`, }; } - if (id === "users:legacy") { - return { _id: "users:legacy" }; + if (id === "users:official") { + const handle = id.split(":").at(-1); + return { _id: id, handle, role: "user" }; } - return null; + throw new Error(`unexpected get ${id}`); }), query: vi.fn((table: string) => { - if (table === "publisherAbuseReviewNominations") { + if (table === "publisherAbuseSignals") { return { - withIndex: () => ({ - order: () => ({ - paginate: async () => ({ - page: [nomination], - isDone: true, - continueCursor: "", - }), - }), - }), + withIndex: (indexName: string) => { + expect(indexName).toBe("by_last_seen_at"); + return { + order: (direction: string) => { + expect(direction).toBe("desc"); + return { paginate: signalPaginate }; + }, + }; + }, + }; + } + if (table === "officialPublishers") { + return { + withIndex: ( + indexName: string, + build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(indexName).toBe("by_publisher"); + const constraints: Record = {}; + const q = { + eq(field: string, value: unknown) { + constraints[field] = value; + return q; + }, + }; + build(q); + return { + unique: async () => + constraints.publisherId === "publishers:official" + ? { _id: "officialPublishers:official" } + : null, + }; + }, }; } - if (table === "officialPublishers") return makeEmptyOfficialPublishersQuery(); throw new Error(`unexpected table ${table}`); }), }, }; - const result = await listReviewItemsPageHandler(ctx, { - tab: "potential_ban_candidate", - paginationOpts: { numItems: 1, cursor: null }, - }); - - const [item] = result.page as Array<{ - publisher: unknown; - ownerUser: unknown; - }>; - expect(result.page).toEqual([ - expect.objectContaining({ - publisher: expect.objectContaining({ - displayName: null, - linkedUserId: null, - publishedSkills: 0, - publishedPackages: 0, - totalInstalls: 0, - totalStars: 0, - totalDownloads: 0, - skillTotalInstalls: 0, - skillTotalStars: 0, - skillTotalDownloads: 0, - deletedAt: null, - deactivatedAt: null, - }), - ownerUser: expect.objectContaining({ - handle: null, - name: null, - displayName: null, - role: "user", - image: null, - deletedAt: null, - deactivatedAt: null, - banReason: null, - }), + await expect( + listSignalsPageHandler(ctx, { + paginationOpts: { numItems: 1, cursor: null }, }), - ]); - expect(hasNoUndefinedValues(item.publisher)).toBe(true); - expect(hasNoUndefinedValues(item.ownerUser)).toBe(true); + ).resolves.toEqual({ + page: [], + isDone: false, + continueCursor: "after-official", + }); + expect(signalPaginate).toHaveBeenCalledTimes(1); }); it("queries recent resolved nominations by review time", async () => { @@ -6392,64 +7985,242 @@ describe("publisher abuse dry-run persistence", () => { phase: "collecting", }); - expect(indexBuilder.eq).toHaveBeenCalledWith("modelVersion", "publisher-abuse-pressure.v4"); - expect(indexBuilder.eq).toHaveBeenCalledWith("status", "running"); - expect(ctx.db.insert).toHaveBeenCalledWith( - "publisherAbuseScoreRuns", + expect(indexBuilder.eq).toHaveBeenCalledWith("modelVersion", "publisher-abuse-pressure.v4"); + expect(indexBuilder.eq).toHaveBeenCalledWith("status", "running"); + expect(ctx.db.insert).toHaveBeenCalledWith( + "publisherAbuseScoreRuns", + expect.objectContaining({ + modelVersion: "publisher-abuse-pressure.v4", + status: "running", + phase: "collecting", + }), + ); + }); + + it("dry-runs the temporal backfill without persisting nominations", async () => { + const candidate = temporalCandidate("skills:polymarket-trade", { + slug: "polymarket-trade", + displayName: "Polymarket Trade", + }); + const ctx = { + runQuery: vi.fn(async () => ({ + cursor: undefined, + isDone: true, + scannedSkills: 1, + candidates: [candidate], + })), + runMutation: vi.fn(), + }; + + await expect( + temporalRunHandler(ctx, { + mode: "backfill", + dryRun: true, + candidateLimit: 1, + batchSize: 1, + maxPages: 1, + todayDay: 100, + }), + ).resolves.toEqual({ + ok: true, + dryRun: true, + mode: "backfill", + scannedSkills: 1, + highTemporalSkills: 0, + flaggedPublishers: 0, + nominations: 0, + candidates: [], + benchmark: { + sampleSize: 1, + downloads30dAverage: 2_000, + downloads30dMedian: 2_000, + downloads30dP95: 2_000, + downloads30dP99: 2_000, + spikeMultiplier7dP95: 20, + spikeMultiplier7dP99: 20, + }, + }); + + expect(ctx.runQuery).toHaveBeenCalledTimes(1); + expect(ctx.runMutation).not.toHaveBeenCalled(); + }); + + it("keeps current temporal dry-runs read-only unless archival is requested", async () => { + const candidate = temporalCandidate("skills:read-only-ratio", { + slug: "read-only-ratio", + displayName: "Read Only Ratio", + }); + candidate.temporalScore.spike = false; + candidate.temporalScore.nearConversion = true; + candidate.temporalScore.recent7Downloads = 800; + candidate.temporalScore.recent7Installs = 96; + candidate.temporalScore.recent30Downloads = 2_400; + candidate.temporalScore.recent30Installs = 288; + candidate.temporalScore.installDownloadRatio7 = 0.12; + candidate.temporalScore.installDownloadRatio30 = 0.12; + candidate.temporalScore.installDownloadExcessZScore7 = 12; + candidate.temporalScore.installDownloadExcessZScore30 = 12; + candidate.temporalScore.reasonCodes = ["temporal_installs_track_downloads"]; + const ctx = { + scheduler: { runAfter: vi.fn(async () => null) }, + runQuery: vi.fn(async () => ({ + cursor: undefined, + isDone: true, + scannedSkills: 1, + candidates: [candidate], + })), + runMutation: vi.fn(), + }; + + await expect( + temporalRunHandler(ctx, { + mode: "current", + dryRun: true, + candidateLimit: 1, + batchSize: 1, + maxPages: 1, + todayDay: 100, + }), + ).resolves.toMatchObject({ + ok: true, + dryRun: true, + mode: "current", + scannedSkills: 1, + highTemporalSkills: 1, + flaggedPublishers: 1, + nominations: 0, + }); + + expect(ctx.runMutation).not.toHaveBeenCalled(); + expect(ctx.scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it("archives completed current temporal dry-run signals when requested", async () => { + const candidate = temporalCandidate("skills:ratio", { + slug: "ratio", + displayName: "Ratio", + }); + candidate.temporalScore.spike = false; + candidate.temporalScore.nearConversion = true; + candidate.temporalScore.recent7Downloads = 800; + candidate.temporalScore.recent7Installs = 96; + candidate.temporalScore.recent30Downloads = 2_400; + candidate.temporalScore.recent30Installs = 288; + candidate.temporalScore.installDownloadRatio7 = 0.12; + candidate.temporalScore.installDownloadRatio30 = 0.12; + candidate.temporalScore.installDownloadExcessZScore7 = 12; + candidate.temporalScore.installDownloadExcessZScore30 = 12; + candidate.temporalScore.reasonCodes = ["temporal_installs_track_downloads"]; + const runMutation = vi.fn(async (_target: unknown, _args: unknown) => ({ + archivedCandidates: 1, + archivedSignals: 1, + changedSignals: 1, + })); + const ctx = { + scheduler: { runAfter: vi.fn(async () => null) }, + runQuery: vi.fn(async () => ({ + cursor: undefined, + isDone: true, + scannedSkills: 1, + candidates: [candidate], + })), + runMutation, + }; + + await expect( + temporalRunHandler(ctx, { + mode: "current", + dryRun: true, + archiveDryRunSignals: true, + candidateLimit: 1, + batchSize: 1, + maxPages: 1, + todayDay: 100, + }), + ).resolves.toMatchObject({ + ok: true, + dryRun: true, + mode: "current", + scannedSkills: 1, + highTemporalSkills: 1, + flaggedPublishers: 1, + nominations: 0, + }); + + expect(ctx.runMutation).toHaveBeenCalledTimes(1); + expect(String(ctx.runMutation.mock.calls[0]?.[0])).toContain( + "archiveTemporalPublisherAbuseSignalsPageInternal", + ); + expect(ctx.runMutation).toHaveBeenCalledWith( + expect.anything(), expect.objectContaining({ - modelVersion: "publisher-abuse-pressure.v4", - status: "running", - phase: "collecting", + candidates: [expect.objectContaining({ skillId: "skills:ratio" })], }), ); + expect(ctx.runMutation.mock.calls[0]?.[1]).not.toHaveProperty("runId"); + expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(0, expect.any(Symbol), {}); }); - it("dry-runs the temporal backfill without persisting nominations", async () => { - const candidate = temporalCandidate("skills:polymarket-trade", { - slug: "polymarket-trade", - displayName: "Polymarket Trade", + it("archives bounded current temporal dry-run signals before scan completion when requested", async () => { + const candidate = temporalCandidate("skills:bounded-ratio", { + slug: "bounded-ratio", + displayName: "Bounded Ratio", }); + candidate.temporalScore.spike = false; + candidate.temporalScore.nearConversion = true; + candidate.temporalScore.recent7Downloads = 800; + candidate.temporalScore.recent7Installs = 96; + candidate.temporalScore.recent30Downloads = 2_400; + candidate.temporalScore.recent30Installs = 288; + candidate.temporalScore.installDownloadRatio7 = 0.12; + candidate.temporalScore.installDownloadRatio30 = 0.12; + candidate.temporalScore.installDownloadExcessZScore7 = 12; + candidate.temporalScore.installDownloadExcessZScore30 = 12; + candidate.temporalScore.reasonCodes = ["temporal_installs_track_downloads"]; + const runMutation = vi.fn(async (_target: unknown, _args: unknown) => ({ + archivedCandidates: 1, + archivedSignals: 1, + changedSignals: 1, + })); const ctx = { + scheduler: { runAfter: vi.fn(async () => null) }, runQuery: vi.fn(async () => ({ - cursor: undefined, - isDone: true, + cursor: "next-page", + isDone: false, scannedSkills: 1, candidates: [candidate], })), - runMutation: vi.fn(), + runMutation, }; await expect( temporalRunHandler(ctx, { - mode: "backfill", + mode: "current", dryRun: true, - candidateLimit: 1, + archiveDryRunSignals: true, + candidateLimit: 2, batchSize: 1, maxPages: 1, todayDay: 100, }), - ).resolves.toEqual({ + ).resolves.toMatchObject({ ok: true, dryRun: true, - mode: "backfill", + mode: "current", scannedSkills: 1, - highTemporalSkills: 0, - flaggedPublishers: 0, + highTemporalSkills: 1, + flaggedPublishers: 1, nominations: 0, - candidates: [], - benchmark: { - sampleSize: 1, - downloads30dAverage: 2_000, - downloads30dMedian: 2_000, - downloads30dP95: 2_000, - downloads30dP99: 2_000, - spikeMultiplier7dP95: 20, - spikeMultiplier7dP99: 20, - }, }); - expect(ctx.runQuery).toHaveBeenCalledTimes(1); - expect(ctx.runMutation).not.toHaveBeenCalled(); + expect(ctx.runMutation).toHaveBeenCalledTimes(1); + expect(ctx.runMutation).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + candidates: [expect.objectContaining({ skillId: "skills:bounded-ratio" })], + }), + ); + expect(ctx.scheduler.runAfter).not.toHaveBeenCalled(); }); it("caps temporal scan candidate limits below Convex array limits", async () => { @@ -7045,7 +8816,7 @@ describe("publisher abuse dry-run persistence", () => { "publisherAbuseScores", expect.objectContaining({ ownerKey: "publisher:publishers:pollyreach", - label: "potential_ban_candidate", + label: "review", zScore: expect.any(Number), temporalHighSkillCount: 2, temporalSpikeSkillCount: 2, @@ -7057,7 +8828,7 @@ describe("publisher abuse dry-run persistence", () => { "publisherAbuseReviewNominations", expect.objectContaining({ ownerKey: "publisher:publishers:pollyreach", - label: "potential_ban_candidate", + label: "review", latestScoreId: "publisherAbuseScores:temporal", }), ); @@ -7065,7 +8836,447 @@ describe("publisher abuse dry-run persistence", () => { ([table]) => table === "publisherAbuseScores", )?.[1] as { zScore: number } | undefined; expect(scoreInsertPayload).toEqual(expect.objectContaining({ zScore: expect.any(Number) })); - expect(scoreInsertPayload?.zScore).toBeGreaterThanOrEqual(2.5); + expect(scoreInsertPayload?.zScore).toBeLessThan(2.5); + }); + + it("archives durable temporal review signals without archiving spike-only evidence", async () => { + const highRatio = temporalCandidate("skills:ratio", { + slug: "ratio", + displayName: "Ratio", + }); + highRatio.temporalScore.spike = false; + highRatio.temporalScore.nearConversion = true; + highRatio.temporalScore.recent7Downloads = 800; + highRatio.temporalScore.recent7Installs = 96; + highRatio.temporalScore.recent30Downloads = 2_400; + highRatio.temporalScore.recent30Installs = 288; + highRatio.temporalScore.installDownloadRatio7 = 0.12; + highRatio.temporalScore.installDownloadRatio30 = 0.12; + highRatio.temporalScore.installDownloadExcessZScore7 = 12; + highRatio.temporalScore.installDownloadExcessZScore30 = 12; + highRatio.temporalScore.reasonCodes = ["temporal_installs_track_downloads"]; + + const sustained = temporalCandidate("skills:sustained", { + slug: "sustained", + displayName: "Sustained", + }); + sustained.temporalScore.spike = false; + sustained.temporalScore.sustained = true; + sustained.temporalScore.recent7Downloads = 1_000; + sustained.temporalScore.recent30Downloads = 5_000; + sustained.temporalScore.reasonCodes = ["temporal_sustained_downloads_flat_installs"]; + + const spikeOnly = temporalCandidate("skills:spike", { + slug: "spike", + displayName: "Spike", + }); + const existingSignal = { + _id: "publisherAbuseSignals:existing-ratio", + signalType: "high_install_download_ratio", + ownerKey: highRatio.ownerKey, + ownerPublisherId: highRatio.ownerPublisherId, + ownerUserId: highRatio.ownerUserId, + handleSnapshot: highRatio.handleSnapshot, + skillId: highRatio.skillId, + skillSlug: highRatio.slug, + skillDisplayName: highRatio.displayName, + latestRunId: "publisherAbuseScoreRuns:old", + firstSeenAt: 10, + lastSeenAt: 20, + seenCount: 5, + recent7Downloads: 700, + recent7Installs: 70, + recent7InstallDownloadRatio: 0.1, + recent30Downloads: 2_100, + recent30Installs: 210, + recent30InstallDownloadRatio: 0.1, + allTimeDownloads: 10_000, + allTimeInstalls: 1_000, + allTimeInstallDownloadRatio: 0.1, + }; + const signalLookups: Array> = []; + const insertedSignals: unknown[] = []; + const insert = vi.fn(async (table: string, value?: unknown) => { + if (table === "publisherAbuseScoreRuns") return "publisherAbuseScoreRuns:temporal"; + if (table === "publisherAbuseSignals") { + insertedSignals.push(value); + return "publisherAbuseSignals:new"; + } + if (table === "publisherAbuseScores") return "publisherAbuseScores:temporal"; + if (table === "publisherAbuseReviewNominations") { + return "publisherAbuseReviewNominations:temporal"; + } + if (table === "publisherAbuseReviewEvents") return "publisherAbuseReviewEvents:temporal"; + throw new Error(`unexpected insert ${table}`); + }); + const patch = vi.fn(async () => null); + const ctx = { + db: { + get: vi.fn(async (id: string) => { + if (id === "publisherAbuseScoreRuns:temporal") { + return { + _id: "publisherAbuseScoreRuns:temporal", + modelVersion: "publisher-abuse-temporal.v1", + modelConfig: TEST_MODEL_CONFIG, + trigger: "cron", + status: "running", + phase: "collecting", + scannedPublishers: 0, + scoredPublishers: 0, + finalizedScores: 0, + nominatedPublishers: 0, + passCount: 0, + reviewCount: 0, + potentialBanCandidateCount: 0, + sumLogPressure: 0, + sumSquaredLogPressure: 0, + }; + } + throw new Error(`unexpected get ${id}`); + }), + insert, + patch, + query: vi.fn((table: string) => { + if (table === "publisherAbuseSignals") { + return { + withIndex: ( + indexName: string, + build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(indexName).toBe("by_skill_signal_type_and_owner_key"); + const constraints: Record = {}; + const q = { + eq(field: string, value: unknown) { + constraints[field] = value; + return q; + }, + }; + build(q); + signalLookups.push(constraints); + return { + first: async () => + constraints.skillId === highRatio.skillId && + constraints.signalType === "high_install_download_ratio" && + constraints.ownerKey === highRatio.ownerKey + ? existingSignal + : null, + }; + }, + }; + } + if (table === "publisherAbuseReviewNominations") { + return { + withIndex: (indexName: string) => { + if (indexName === "by_owner_key_and_model_version") { + return { + first: async () => null, + take: async () => [], + }; + } + if (indexName === "by_status_and_model_version_and_label_and_last_scored_at") { + return { + order: () => ({ + take: async () => [], + }), + }; + } + throw new Error(`unexpected nomination index ${indexName}`); + }, + }; + } + throw new Error(`unexpected query ${table}`); + }), + }, + }; + + await expect( + archiveTemporalPublisherAbuseSignalsPageHandler(ctx, { + runId: "publisherAbuseScoreRuns:temporal", + candidates: [highRatio, sustained, spikeOnly], + now: 1_234, + }), + ).resolves.toEqual({ + archivedCandidates: 3, + archivedSignals: 2, + changedSignals: 2, + }); + + expect(signalLookups).toEqual([ + { + skillId: "skills:ratio", + signalType: "high_install_download_ratio", + ownerKey: highRatio.ownerKey, + }, + { + skillId: "skills:sustained", + signalType: "sustained_downloads_flat_installs", + ownerKey: sustained.ownerKey, + }, + ]); + expect(patch).toHaveBeenCalledWith( + "publisherAbuseSignals:existing-ratio", + expect.objectContaining({ + latestRunId: "publisherAbuseScoreRuns:temporal", + recent7Downloads: 800, + recent7Installs: 96, + recent7InstallDownloadRatio: 0.12, + lastSeenAt: 1_234, + seenCount: 6, + lastChangedAt: 1_234, + needsNotification: true, + }), + ); + expect(insertedSignals).toEqual([ + expect.objectContaining({ + signalType: "sustained_downloads_flat_installs", + skillId: "skills:sustained", + skillSlug: "sustained", + recent30Downloads: 5_000, + firstSeenAt: 1_234, + lastSeenAt: 1_234, + seenCount: 1, + reviewStatus: "open", + lastChangedAt: 1_234, + needsNotification: true, + }), + ]); + }); + + it("keeps active snoozed signals quiet and reopens expired snoozes when archiving", async () => { + const activeSnooze = temporalCandidate("skills:active-snooze", { + slug: "active-snooze", + displayName: "Active Snooze", + }); + activeSnooze.temporalScore.spike = false; + activeSnooze.temporalScore.nearConversion = true; + + const expiredSnooze = temporalCandidate("skills:expired-snooze", { + slug: "expired-snooze", + displayName: "Expired Snooze", + }); + expiredSnooze.temporalScore.spike = false; + expiredSnooze.temporalScore.nearConversion = true; + + const patch = vi.fn(async () => null); + const ctx = { + db: { + insert: vi.fn(async () => { + throw new Error("unexpected insert"); + }), + patch, + query: vi.fn((table: string) => { + if (table !== "publisherAbuseSignals") throw new Error(`unexpected query ${table}`); + return { + withIndex: ( + indexName: string, + build: (q: { eq: (field: string, value: unknown) => unknown }) => unknown, + ) => { + expect(indexName).toBe("by_skill_signal_type_and_owner_key"); + const constraints: Record = {}; + const q = { + eq(field: string, value: unknown) { + constraints[field] = value; + return q; + }, + }; + build(q); + return { + first: async () => ({ + _id: + constraints.skillId === activeSnooze.skillId + ? "publisherAbuseSignals:active-snooze" + : "publisherAbuseSignals:expired-snooze", + signalType: "high_install_download_ratio", + ownerKey: + constraints.skillId === activeSnooze.skillId + ? activeSnooze.ownerKey + : expiredSnooze.ownerKey, + ownerPublisherId: null, + ownerUserId: null, + handleSnapshot: "ratio-owner", + skillId: constraints.skillId, + skillSlug: + constraints.skillId === activeSnooze.skillId + ? activeSnooze.slug + : expiredSnooze.slug, + skillDisplayName: + constraints.skillId === activeSnooze.skillId + ? activeSnooze.displayName + : expiredSnooze.displayName, + firstSeenAt: 10, + lastSeenAt: 20, + seenCount: 2, + recent7Downloads: 10, + recent7Installs: 1, + recent7InstallDownloadRatio: 0.1, + recent30Downloads: 10, + recent30Installs: 1, + recent30InstallDownloadRatio: 0.1, + allTimeDownloads: 100, + allTimeInstalls: 10, + allTimeInstallDownloadRatio: 0.1, + reviewStatus: "snoozed", + snoozedUntil: constraints.skillId === activeSnooze.skillId ? 2_000 : 1_000, + lastChangedAt: 100, + needsNotification: false, + }), + }; + }, + }; + }), + }, + }; + + await expect( + archiveTemporalPublisherAbuseSignalsPageHandler(ctx, { + runId: "publisherAbuseScoreRuns:temporal", + candidates: [activeSnooze, expiredSnooze], + now: 1_234, + }), + ).resolves.toEqual({ + archivedCandidates: 2, + archivedSignals: 2, + changedSignals: 1, + }); + + expect(patch).toHaveBeenCalledWith( + "publisherAbuseSignals:active-snooze", + expect.objectContaining({ + reviewStatus: "snoozed", + snoozedUntil: 2_000, + lastChangedAt: 100, + needsNotification: false, + }), + ); + expect(patch).toHaveBeenCalledWith( + "publisherAbuseSignals:expired-snooze", + expect.objectContaining({ + reviewStatus: "open", + snoozedUntil: undefined, + lastChangedAt: 1_234, + needsNotification: true, + }), + ); + }); + + it("archives temporal review signals in bounded pages and schedules continuation", async () => { + const candidates = [ + temporalCandidate("skills:first", { slug: "first", displayName: "First" }), + temporalCandidate("skills:second", { slug: "second", displayName: "Second" }), + temporalCandidate("skills:third", { slug: "third", displayName: "Third" }), + temporalCandidate("skills:fourth", { slug: "fourth", displayName: "Fourth" }), + temporalCandidate("skills:fifth", { slug: "fifth", displayName: "Fifth" }), + ]; + const runMutation = vi.fn(async (_fn: unknown, args: { candidates: unknown[] }) => ({ + archivedCandidates: args.candidates.length, + archivedSignals: args.candidates.length, + changedSignals: args.candidates.length, + })); + const scheduler = { + runAfter: vi.fn(async () => null), + }; + + await expect( + archiveTemporalPublisherAbuseSignalsHandler( + { runMutation, scheduler }, + { + runId: "publisherAbuseScoreRuns:temporal", + candidates, + now: 1_234, + batchSize: 1, + maxPages: 2, + }, + ), + ).resolves.toEqual({ + ok: true, + pages: 2, + archivedCandidates: 2, + archivedSignals: 2, + changedSignals: 2, + isDone: false, + offset: 2, + }); + + expect(runMutation).toHaveBeenCalledTimes(2); + const mutationCandidateSkillIds = runMutation.mock.calls.map(([, args]) => { + const callArgs = args as { candidates: TemporalSkillCandidate[] }; + return callArgs.candidates[0].skillId; + }); + expect(mutationCandidateSkillIds).toEqual(["skills:first", "skills:second"]); + expect(scheduler.runAfter).toHaveBeenCalledTimes(2); + expect(scheduler.runAfter).toHaveBeenNthCalledWith( + 1, + 60_000, + expect.any(Symbol), + expect.objectContaining({ + runId: "publisherAbuseScoreRuns:temporal", + candidates: [candidates[2], candidates[3]], + now: 1_234, + offset: 0, + batchSize: 1, + maxPages: 2, + }), + ); + expect(scheduler.runAfter).toHaveBeenNthCalledWith( + 2, + 60_000, + expect.any(Symbol), + expect.objectContaining({ + runId: "publisherAbuseScoreRuns:temporal", + candidates: [candidates[4]], + now: 1_234, + offset: 0, + batchSize: 1, + maxPages: 2, + }), + ); + }); + + it("caps temporal signal archive continuation payloads independently", async () => { + const candidates = Array.from({ length: 601 }, (_value, index) => + temporalCandidate(`skills:payload-${index}`, { + slug: `payload-${index}`, + displayName: `Payload ${index}`, + }), + ); + const runMutation = vi.fn(async (_fn: unknown, args: { candidates: unknown[] }) => ({ + archivedCandidates: args.candidates.length, + archivedSignals: args.candidates.length, + changedSignals: args.candidates.length, + })); + const scheduler = { + runAfter: vi.fn(async () => null), + }; + + await expect( + archiveTemporalPublisherAbuseSignalsHandler( + { runMutation, scheduler }, + { + runId: "publisherAbuseScoreRuns:temporal", + candidates, + now: 1_234, + batchSize: 100, + maxPages: 3, + }, + ), + ).resolves.toEqual({ + ok: true, + pages: 3, + archivedCandidates: 300, + archivedSignals: 300, + changedSignals: 300, + isDone: false, + offset: 300, + }); + + expect(runMutation).toHaveBeenCalledTimes(3); + expect(scheduler.runAfter).toHaveBeenCalledTimes(2); + const scheduledCandidateCounts = ( + scheduler.runAfter.mock.calls as unknown as Array< + [number, unknown, { candidates: TemporalSkillCandidate[] }] + > + ).map(([, , scheduledArgs]) => scheduledArgs.candidates.length); + expect(scheduledCandidateCounts).toEqual([250, 51]); }); it("does not clear stale temporal nominations when the current scan is partial", async () => { diff --git a/convex/publisherAbuse.ts b/convex/publisherAbuse.ts index 62ac984995..01106611c2 100644 --- a/convex/publisherAbuse.ts +++ b/convex/publisherAbuse.ts @@ -45,6 +45,8 @@ const MAX_ACTIVE_SKILL_FALLBACK_SCANS_PER_PAGE = 20; const MAX_OWNER_NOMINATION_VERSION_SCAN = 20; const DEFAULT_REVIEW_DASHBOARD_PAGE_SIZE = 25; const MAX_REVIEW_DASHBOARD_PAGE_SIZE = 25; +const DASHBOARD_SIGNAL_COUNT_LIMIT = 25; +const DASHBOARD_SIGNAL_COUNT_SCAN_LIMIT = 100; const MAX_BAN_REASON_LENGTH = 500; const DEFAULT_TEMPORAL_BATCH_SIZE = 50; const MAX_TEMPORAL_BATCH_SIZE = 100; @@ -59,6 +61,16 @@ const MAX_TEMPORAL_DAILY_STAT_READS_PER_PAGE = 8_000; const MAX_TEMPORAL_DRY_RUN_CANDIDATES = 50; const MAX_TEMPORAL_EVIDENCE_SKILLS = 5; const MAX_TEMPORAL_STALE_NOMINATION_CLEARS = 250; +const DEFAULT_TEMPORAL_SIGNAL_ARCHIVE_BATCH_SIZE = 50; +const MAX_TEMPORAL_SIGNAL_ARCHIVE_BATCH_SIZE = 100; +const DEFAULT_TEMPORAL_SIGNAL_ARCHIVE_MAX_PAGES = 5; +const MAX_TEMPORAL_SIGNAL_ARCHIVE_MAX_PAGES = 50; +const MAX_TEMPORAL_SIGNAL_ARCHIVE_CONTINUATION_CANDIDATES = 250; +const DEFAULT_PUBLISHER_ABUSE_SIGNAL_SNOOZE_DAYS = 14; +const MAX_PUBLISHER_ABUSE_SIGNAL_REVIEW_NOTE_LENGTH = 1000; +const PUBLISHER_ABUSE_SIGNAL_NOTIFICATION_BATCH_SIZE = 10; +const PUBLISHER_ABUSE_SIGNAL_NOTIFICATION_MAX_BATCH_SIZE = 25; +const PUBLISHER_ABUSE_SIGNAL_NOTIFICATION_RETRY_MS = 60 * 60 * 1000; const MAX_STAFF_PUBLISHER_MANAGER_EXCLUSION_SCAN = 100; const MAX_STAFF_PUBLISHER_MANAGER_EXCLUSION_READS_PER_PAGE = 2_000; const STAFF_PUBLISHER_MANAGER_ROLES = ["owner", "admin"] as const; @@ -84,6 +96,7 @@ const FAILED_SCORE_RUN_AUTOBAN_SKIP_NOTE = const FAILED_TEMPORAL_RUN_NOMINATION_NOTE = "Publisher abuse temporal score run failed before completion; rerun required."; const PUBLISHER_ABUSE_AUTOBAN_SETTING_KEY = "publisherAbuseAutobanEnabled" as const; +const PUBLISHER_ABUSE_SIGNAL_NOTIFICATION_CLAIM_STALE_MS = 15 * 60 * 1000; const FAILED_TEMPORAL_CLEANUP_LABELS = [ "potential_ban_candidate", "review", @@ -92,6 +105,9 @@ const FAILED_TEMPORAL_CLEANUP_LABELS = [ type TriageStatus = Doc<"publisherAbuseReviewNominations">["status"]; type ScoreRun = Doc<"publisherAbuseScoreRuns">; type ScoreDoc = Doc<"publisherAbuseScores">; +type PublisherAbuseSignalDoc = Doc<"publisherAbuseSignals">; +type PublisherAbuseSignalType = PublisherAbuseSignalDoc["signalType"]; +type PublisherAbuseSignalReviewStatus = NonNullable; type RunPhase = ScoreRun["phase"]; type TemporalAbuseMode = "current" | "backfill"; @@ -101,6 +117,15 @@ const publisherAbuseReviewTabValidator = v.union( v.literal("all_pending"), v.literal("resolved"), ); +const publisherAbuseSignalTypeValidator = v.union( + v.literal("high_install_download_ratio"), + v.literal("sustained_downloads_flat_installs"), +); +const publisherAbuseSignalReviewStatusValidator = v.union( + v.literal("open"), + v.literal("snoozed"), + v.literal("dismissed"), +); type RunState = { runId: Id<"publisherAbuseScoreRuns">; @@ -303,7 +328,10 @@ export const listReviewDashboard = query({ const auth = await requirePublisherAbuseDashboardUser(ctx); if (!auth) return emptyPublisherAbuseReviewDashboard(); - const latestRun = await getLatestPublisherAbuseScoreRun(ctx); + const [latestRun, signalCountSummary] = await Promise.all([ + getLatestPublisherAbuseScoreRun(ctx), + getPublisherAbuseSignalCountSummary(ctx), + ]); return { latestRun: latestRun ? summarizePublisherAbuseRun(latestRun) : null, @@ -311,6 +339,7 @@ export const listReviewDashboard = query({ pendingPotentialBanCandidateItems: [], pendingReviewItems: [], recentResolvedItems: [], + ...signalCountSummary, }; }, }); @@ -369,6 +398,68 @@ export const listReviewItemsPage = query({ }, }); +export const listSignalsPage = query({ + args: { + signalType: v.optional(publisherAbuseSignalTypeValidator), + reviewStatus: v.optional(publisherAbuseSignalReviewStatusValidator), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args) => { + const auth = await requirePublisherAbuseDashboardUser(ctx); + if (!auth) { + return { + page: [], + isDone: true, + continueCursor: "", + }; + } + + const requestedItems = clampInt( + args.paginationOpts.numItems ?? DEFAULT_REVIEW_DASHBOARD_PAGE_SIZE, + 1, + MAX_REVIEW_DASHBOARD_PAGE_SIZE, + ); + const signalType = args.signalType; + const reviewStatus = args.reviewStatus; + if (signalType && reviewStatus) { + throw new Error("Filter by signalType or reviewStatus, not both."); + } + const paginationOpts: PublisherAbuseReviewPaginationOpts = { + cursor: args.paginationOpts.cursor ?? null, + numItems: requestedItems, + }; + const page = + reviewStatus && !signalType + ? await ctx.db + .query("publisherAbuseSignals") + .withIndex("by_review_status_and_last_seen_at", (q) => + q.eq("reviewStatus", reviewStatus), + ) + .order("desc") + .paginate(paginationOpts) + : signalType + ? await ctx.db + .query("publisherAbuseSignals") + .withIndex("by_signal_type_and_last_seen_at", (q) => q.eq("signalType", signalType)) + .order("desc") + .paginate(paginationOpts) + : await ctx.db + .query("publisherAbuseSignals") + .withIndex("by_last_seen_at") + .order("desc") + .paginate(paginationOpts); + + return { + page: await summarizeVisiblePublisherAbuseSignals(ctx, page.page, { + staffManagerExclusionBudget: createStaffPublisherManagerExclusionBudget(), + reviewStatus: reviewStatus ?? "open", + }), + isDone: page.isDone, + continueCursor: page.continueCursor, + }; + }, +}); + export const getReviewNominationDetail = query({ args: { nominationId: v.id("publisherAbuseReviewNominations"), @@ -433,6 +524,8 @@ function emptyPublisherAbuseReviewDashboard() { pendingPotentialBanCandidateItems: [], pendingReviewItems: [], recentResolvedItems: [], + signalCount: 0, + signalCountHasMore: false, }; } @@ -486,6 +579,83 @@ export const setPublisherAbuseAutobanEnabled = mutation({ }, }); +export const snoozePublisherAbuseSignal = mutation({ + args: { + signalId: v.id("publisherAbuseSignals"), + note: v.optional(v.string()), + days: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const { user } = await requireUser(ctx); + assertModerator(user); + const signal = await ctx.db.get(args.signalId); + if (!signal) throw new Error("Publisher abuse signal not found"); + const now = Date.now(); + const days = clampInt(args.days ?? DEFAULT_PUBLISHER_ABUSE_SIGNAL_SNOOZE_DAYS, 1, 90); + await setPublisherAbuseSignalReviewStatusWithActor(ctx, { + signal, + status: "snoozed", + actorUserId: user._id, + note: normalizePublisherAbuseSignalReviewNote(args.note), + snoozedUntil: now + days * 24 * 60 * 60 * 1000, + now, + }); + return { ok: true, status: "snoozed" as const }; + }, +}); + +export const dismissPublisherAbuseSignal = mutation({ + args: { + signalId: v.id("publisherAbuseSignals"), + note: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const { user } = await requireUser(ctx); + assertModerator(user); + const signal = await ctx.db.get(args.signalId); + if (!signal) throw new Error("Publisher abuse signal not found"); + await setPublisherAbuseSignalReviewStatusWithActor(ctx, { + signal, + status: "dismissed", + actorUserId: user._id, + note: normalizePublisherAbuseSignalReviewNote(args.note), + now: Date.now(), + }); + return { ok: true, status: "dismissed" as const }; + }, +}); + +export const reopenPublisherAbuseSignal = mutation({ + args: { + signalId: v.id("publisherAbuseSignals"), + note: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const { user } = await requireUser(ctx); + assertModerator(user); + const signal = await ctx.db.get(args.signalId); + if (!signal) throw new Error("Publisher abuse signal not found"); + if (publisherAbuseSignalReviewStatus(signal) === "open") { + return { ok: true, status: "open" as const, alreadyOpen: true as const }; + } + const now = Date.now(); + await setPublisherAbuseSignalReviewStatusWithActor(ctx, { + signal, + status: "open", + actorUserId: user._id, + note: normalizePublisherAbuseSignalReviewNote(args.note), + notify: true, + now, + }); + await ctx.scheduler.runAfter( + 0, + internal.publisherAbuse.notifyPublisherAbuseSignalChangesInternal, + {}, + ); + return { ok: true, status: "open" as const }; + }, +}); + export const banPublisherAbuseOwner = mutation({ args: { nominationId: v.id("publisherAbuseReviewNominations"), @@ -570,6 +740,59 @@ async function setPublisherAbuseReviewStatusWithActor( }); } +async function setPublisherAbuseSignalReviewStatusWithActor( + ctx: Pick, + args: { + signal: PublisherAbuseSignalDoc; + status: PublisherAbuseSignalReviewStatus; + note: string | undefined; + actorUserId: Id<"users">; + now: number; + snoozedUntil?: number; + notify?: boolean; + }, +) { + const previousStatus = publisherAbuseSignalReviewStatus(args.signal); + await ctx.db.patch(args.signal._id, { + reviewStatus: args.status, + reviewedByUserId: args.actorUserId, + reviewedAt: args.now, + reviewNote: args.note, + snoozedUntil: args.status === "snoozed" ? args.snoozedUntil : undefined, + needsNotification: args.notify === true, + lastChangedAt: args.notify ? args.now : args.signal.lastChangedAt, + notificationClaimedAt: undefined, + lastNotificationError: undefined, + }); + await ctx.db.insert("publisherAbuseSignalReviewEvents", { + signalId: args.signal._id, + ownerKey: args.signal.ownerKey, + actorUserId: args.actorUserId, + eventType: + args.status === "snoozed" + ? "snoozed" + : args.status === "dismissed" + ? "dismissed" + : "reopened", + previousStatus, + nextStatus: args.status, + note: args.note, + snoozedUntil: args.status === "snoozed" ? args.snoozedUntil : undefined, + createdAt: args.now, + }); +} + +function normalizePublisherAbuseSignalReviewNote(note: string | undefined) { + const trimmed = note?.trim(); + if (!trimmed) return undefined; + if (trimmed.length > MAX_PUBLISHER_ABUSE_SIGNAL_REVIEW_NOTE_LENGTH) { + throw new Error( + `Signal review note must be ${MAX_PUBLISHER_ABUSE_SIGNAL_REVIEW_NOTE_LENGTH} characters or fewer.`, + ); + } + return trimmed; +} + async function getPublisherAbuseAutobanEligibility( ctx: Pick, nomination: Doc<"publisherAbuseReviewNominations">, @@ -1229,10 +1452,20 @@ export const persistTemporalPublisherAbuseCandidatesInternal = internalMutation( handler: persistTemporalPublisherAbuseCandidatesInternalHandler, }); +export const archiveTemporalPublisherAbuseSignalsPageInternal = internalMutation({ + args: { + runId: v.optional(v.id("publisherAbuseScoreRuns")), + candidates: v.array(temporalCandidateValidator), + now: v.number(), + }, + handler: archiveTemporalPublisherAbuseSignalsPageInternalHandler, +}); + export const runTemporalPublisherAbuseScanInternal = internalAction({ args: { mode: v.optional(v.union(v.literal("current"), v.literal("backfill"))), dryRun: v.optional(v.boolean()), + archiveDryRunSignals: v.optional(v.boolean()), candidateLimit: v.optional(v.number()), batchSize: v.optional(v.number()), maxPages: v.optional(v.number()), @@ -1244,6 +1477,84 @@ export const runTemporalPublisherAbuseScanInternal = internalAction({ handler: runTemporalPublisherAbuseScanInternalHandler, }); +export const archiveTemporalPublisherAbuseSignalsInternal = internalAction({ + args: { + runId: v.optional(v.id("publisherAbuseScoreRuns")), + candidates: v.array(temporalCandidateValidator), + now: v.number(), + offset: v.optional(v.number()), + batchSize: v.optional(v.number()), + maxPages: v.optional(v.number()), + notifyHermit: v.optional(v.boolean()), + }, + handler: archiveTemporalPublisherAbuseSignalsInternalHandler, +}); + +export const claimPublisherAbuseSignalNotificationsInternal = internalMutation({ + args: { + limit: v.optional(v.number()), + }, + handler: claimPublisherAbuseSignalNotificationsInternalHandler, +}); + +export const markPublisherAbuseSignalNotificationsSucceededInternal = internalMutation({ + args: { + signalIds: v.array(v.id("publisherAbuseSignals")), + claimedAt: v.number(), + now: v.number(), + }, + handler: async (ctx, args) => { + for (const signalId of args.signalIds) { + const signal = await ctx.db.get(signalId); + if ( + !signal || + signal.notificationClaimedAt !== args.claimedAt || + signal.needsNotification !== false + ) { + continue; + } + await ctx.db.patch(signalId, { + needsNotification: false, + notificationClaimedAt: undefined, + lastNotifiedAt: args.now, + lastNotificationError: undefined, + }); + } + }, +}); + +export const markPublisherAbuseSignalNotificationsFailedInternal = internalMutation({ + args: { + signalIds: v.array(v.id("publisherAbuseSignals")), + claimedAt: v.number(), + error: v.string(), + }, + handler: async (ctx, args) => { + for (const signalId of args.signalIds) { + const signal = await ctx.db.get(signalId); + if ( + !signal || + signal.notificationClaimedAt !== args.claimedAt || + signal.needsNotification !== false + ) { + continue; + } + await ctx.db.patch(signalId, { + needsNotification: true, + notificationClaimedAt: undefined, + lastNotificationError: args.error.slice(0, 500), + }); + } + }, +}); + +export const notifyPublisherAbuseSignalChangesInternal = internalAction({ + args: { + limit: v.optional(v.number()), + }, + handler: notifyPublisherAbuseSignalChangesInternalHandler, +}); + export const processPublisherAbuseAutobansInternal = internalAction({ args: { batchSize: v.optional(v.number()), @@ -1934,6 +2245,333 @@ export async function persistTemporalPublisherAbuseCandidatesInternalHandler( return { runId, nominations, flaggedPublishers: sortedAggregates.length }; } +export async function archiveTemporalPublisherAbuseSignalsInternalHandler( + ctx: Pick, + args: { + runId?: Id<"publisherAbuseScoreRuns">; + candidates: TemporalSkillCandidate[]; + now: number; + offset?: number; + batchSize?: number; + maxPages?: number; + notifyHermit?: boolean; + }, +) { + return await archiveTemporalPublisherAbuseSignalPages(ctx, args); +} + +async function archiveTemporalPublisherAbuseSignalPages( + ctx: Pick, + args: { + runId?: Id<"publisherAbuseScoreRuns">; + candidates: TemporalSkillCandidate[]; + now: number; + offset?: number; + batchSize?: number; + maxPages?: number; + notifyHermit?: boolean; + }, +) { + const batchSize = clampInt( + args.batchSize ?? DEFAULT_TEMPORAL_SIGNAL_ARCHIVE_BATCH_SIZE, + 1, + MAX_TEMPORAL_SIGNAL_ARCHIVE_BATCH_SIZE, + ); + const maxPages = clampInt( + args.maxPages ?? DEFAULT_TEMPORAL_SIGNAL_ARCHIVE_MAX_PAGES, + 1, + MAX_TEMPORAL_SIGNAL_ARCHIVE_MAX_PAGES, + ); + let offset = clampInt(args.offset ?? 0, 0, args.candidates.length); + let pages = 0; + let archivedCandidates = 0; + let archivedSignals = 0; + let changedSignals = 0; + + while (pages < maxPages && offset < args.candidates.length) { + const batch = args.candidates.slice(offset, offset + batchSize); + const result: { + archivedCandidates: number; + archivedSignals: number; + changedSignals: number; + } = await ctx.runMutation( + internal.publisherAbuse.archiveTemporalPublisherAbuseSignalsPageInternal, + { + ...(args.runId ? { runId: args.runId } : {}), + candidates: batch, + now: args.now, + }, + ); + pages += 1; + offset += batch.length; + archivedCandidates += result.archivedCandidates; + archivedSignals += result.archivedSignals; + changedSignals += result.changedSignals; + } + + if (offset < args.candidates.length) { + const continuationCandidateLimit = Math.min( + batchSize * maxPages, + MAX_TEMPORAL_SIGNAL_ARCHIVE_CONTINUATION_CANDIDATES, + ); + const continuationCandidates = args.candidates.slice(offset); + const continuationChunks = chunkArray(continuationCandidates, continuationCandidateLimit); + for (const candidates of continuationChunks) { + await ctx.scheduler.runAfter( + ACTION_CONTINUATION_DELAY_MS, + internal.publisherAbuse.archiveTemporalPublisherAbuseSignalsInternal, + { + ...(args.runId ? { runId: args.runId } : {}), + candidates, + now: args.now, + offset: 0, + batchSize, + maxPages, + notifyHermit: args.notifyHermit, + }, + ); + } + } + + if (args.notifyHermit && changedSignals > 0) { + await ctx.scheduler.runAfter( + 0, + internal.publisherAbuse.notifyPublisherAbuseSignalChangesInternal, + {}, + ); + } + + return { + ok: true as const, + pages, + archivedCandidates, + archivedSignals, + changedSignals, + isDone: offset >= args.candidates.length, + offset, + }; +} + +export async function claimPublisherAbuseSignalNotificationsInternalHandler( + ctx: MutationCtx, + args: { limit?: number }, +) { + const limit = clampInt( + args.limit ?? PUBLISHER_ABUSE_SIGNAL_NOTIFICATION_BATCH_SIZE, + 1, + PUBLISHER_ABUSE_SIGNAL_NOTIFICATION_MAX_BATCH_SIZE, + ); + const now = Date.now(); + const hasMoreStaleClaims = await requeueStalePublisherAbuseSignalNotificationClaims(ctx, { + limit, + staleBefore: now - PUBLISHER_ABUSE_SIGNAL_NOTIFICATION_CLAIM_STALE_MS, + }); + const pendingSignals = await ctx.db + .query("publisherAbuseSignals") + .withIndex("by_needs_notification_and_last_changed_at", (q) => q.eq("needsNotification", true)) + .order("desc") + .take(limit + 1); + const signals = pendingSignals.slice(0, limit).filter((signal) => { + return publisherAbuseSignalReviewStatus(signal) === "open"; + }); + for (const signal of pendingSignals.slice(0, limit)) { + if (publisherAbuseSignalReviewStatus(signal) !== "open") { + await ctx.db.patch(signal._id, { + needsNotification: false, + notificationClaimedAt: undefined, + }); + continue; + } + await ctx.db.patch(signal._id, { + needsNotification: false, + notificationClaimedAt: now, + }); + } + return { + signals, + hasMore: pendingSignals.length > limit || hasMoreStaleClaims, + claimedAt: now, + }; +} + +async function requeueStalePublisherAbuseSignalNotificationClaims( + ctx: Pick, + args: { limit: number; staleBefore: number }, +) { + const staleClaims = await ctx.db + .query("publisherAbuseSignals") + .withIndex("by_needs_notification_and_notification_claimed_at", (q) => + q + .eq("needsNotification", false) + .gte("notificationClaimedAt", 1) + .lt("notificationClaimedAt", args.staleBefore), + ) + .take(args.limit + 1); + for (const signal of staleClaims.slice(0, args.limit)) { + await ctx.db.patch(signal._id, { + needsNotification: true, + notificationClaimedAt: undefined, + lastNotificationError: "Retrying after stale Hermit notification claim.", + }); + } + return staleClaims.length > args.limit; +} + +export async function notifyPublisherAbuseSignalChangesInternalHandler( + ctx: Pick, + args: { limit?: number }, +) { + const config = getHermitPublisherAbuseSignalConfig(); + if (!config) { + console.info("[publisher-abuse-signals] Hermit notification skipped: missing config"); + return { ok: false as const, skipped: true as const }; + } + + const claimed: { + signals: PublisherAbuseSignalDoc[]; + hasMore: boolean; + claimedAt: number; + } = await ctx.runMutation( + internal.publisherAbuse.claimPublisherAbuseSignalNotificationsInternal, + { + limit: args.limit, + }, + ); + if (claimed.signals.length === 0) { + if (claimed.hasMore) { + await ctx.scheduler.runAfter( + 0, + internal.publisherAbuse.notifyPublisherAbuseSignalChangesInternal, + { limit: args.limit }, + ); + } + return { ok: true as const, sent: false as const }; + } + + const signalIds = claimed.signals.map((signal) => signal._id); + const payload = buildHermitPublisherAbuseSignalDigest(claimed.signals, claimed.hasMore, config); + try { + const response = await fetch(config.digestUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${config.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Hermit publisher abuse digest failed: ${response.status} ${body}`); + } + const now = Date.now(); + await ctx.runMutation( + internal.publisherAbuse.markPublisherAbuseSignalNotificationsSucceededInternal, + { + signalIds, + claimedAt: claimed.claimedAt, + now, + }, + ); + if (claimed.hasMore) { + await ctx.scheduler.runAfter( + 0, + internal.publisherAbuse.notifyPublisherAbuseSignalChangesInternal, + { limit: args.limit }, + ); + } + return { ok: true as const, sent: true as const, count: signalIds.length }; + } catch (error) { + const message = errorMessageFromUnknown(error); + console.error("[publisher-abuse-signals] Hermit notification failed", { + count: signalIds.length, + message, + }); + await ctx.runMutation( + internal.publisherAbuse.markPublisherAbuseSignalNotificationsFailedInternal, + { + signalIds, + claimedAt: claimed.claimedAt, + error: message, + }, + ); + await ctx.scheduler.runAfter( + PUBLISHER_ABUSE_SIGNAL_NOTIFICATION_RETRY_MS, + internal.publisherAbuse.notifyPublisherAbuseSignalChangesInternal, + { limit: args.limit }, + ); + return { ok: false as const, sent: false as const, error: message }; + } +} + +function getHermitPublisherAbuseSignalConfig() { + const token = + process.env.CLAWHUB_HERMIT_TOKEN?.trim() || process.env.CLAWHUB_BAN_APPEALS_TOKEN?.trim() || ""; + const baseUrl = + process.env.HERMIT_PUBLISHER_ABUSE_BASE_URL?.trim() || + process.env.HERMIT_CONTENT_RIGHTS_BASE_URL?.trim() || + "https://forms.openclaw.ai"; + if (!token || !baseUrl) return null; + const siteUrl = (process.env.SITE_URL?.trim() || "https://clawhub.ai").replace(/\/$/, ""); + return { + token, + siteUrl, + digestUrl: `${baseUrl.replace(/\/$/, "")}/api/clawhub-publisher-abuse/signals/digest`, + }; +} + +function buildHermitPublisherAbuseSignalDigest( + signals: PublisherAbuseSignalDoc[], + hasMore: boolean, + config: { siteUrl: string }, +) { + return { + kind: "publisher_abuse_signals_changed", + changedCount: signals.length, + hasMore, + dashboardUrl: `${config.siteUrl}/management?view=abuse&tab=signals`, + topSignals: signals.map((signal) => ({ + signalId: signal._id, + signalType: signal.signalType, + severity: publisherAbuseSignalSeverity(signal.signalType), + publisher: signal.handleSnapshot, + skillSlug: signal.skillSlug, + skillDisplayName: signal.skillDisplayName, + seenCount: signal.seenCount, + firstSeenAt: signal.firstSeenAt, + lastSeenAt: signal.lastSeenAt, + recent7Downloads: signal.recent7Downloads, + recent7Installs: signal.recent7Installs, + recent7InstallDownloadRatio: signal.recent7InstallDownloadRatio, + recent30Downloads: signal.recent30Downloads, + recent30Installs: signal.recent30Installs, + recent30InstallDownloadRatio: signal.recent30InstallDownloadRatio, + allTimeDownloads: signal.allTimeDownloads, + allTimeInstalls: signal.allTimeInstalls, + allTimeInstallDownloadRatio: signal.allTimeInstallDownloadRatio, + skillUrl: `${config.siteUrl}/${routeSegment(signal.handleSnapshot)}/skills/${routeSegment( + signal.skillSlug, + )}`, + publisherUrl: `${config.siteUrl}/${routeSegment(signal.handleSnapshot)}`, + })), + }; +} + +function publisherAbuseSignalSeverity(signalType: PublisherAbuseSignalType) { + return signalType === "high_install_download_ratio" ? "high" : "review"; +} + +function routeSegment(value: string) { + return encodeURIComponent(value.trim().replace(/^@+/, "")); +} + +function chunkArray(values: T[], chunkSize: number) { + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += chunkSize) { + chunks.push(values.slice(index, index + chunkSize)); + } + return chunks; +} + async function persistTemporalPublisherAbuseAggregate( ctx: Pick, args: { @@ -2006,6 +2644,7 @@ export async function runTemporalPublisherAbuseScanInternalHandler( args: { mode?: TemporalAbuseMode; dryRun?: boolean; + archiveDryRunSignals?: boolean; candidateLimit?: number; batchSize?: number; maxPages?: number; @@ -2027,6 +2666,7 @@ export async function runTemporalPublisherAbuseScanInternalHandler( }> { const mode = args.mode ?? "current"; const dryRun = args.dryRun ?? false; + const archiveDryRunSignals = args.archiveDryRunSignals ?? false; const requestedCandidateLimit = clampInt( args.candidateLimit ?? DEFAULT_TEMPORAL_CANDIDATE_LIMIT, 1, @@ -2089,6 +2729,13 @@ export async function runTemporalPublisherAbuseScanInternalHandler( const flaggedPublishers = aggregateTemporalPublisherCandidates(highTemporalCandidates).length; if (dryRun || !scanComplete || (mode !== "current" && highTemporalCandidates.length === 0)) { + if (dryRun && archiveDryRunSignals && mode === "current" && highTemporalCandidates.length > 0) { + await archiveTemporalPublisherAbuseSignalPages(ctx, { + candidates: highTemporalCandidates, + now: Date.now(), + notifyHermit: (args.trigger ?? "cron") === "cron" && scanComplete, + }); + } return { ok: true, dryRun, @@ -2141,7 +2788,11 @@ async function finishTemporalPublisherAbuseScan( candidate.temporalScore.sustained || candidate.temporalScore.nearConversion, ); - const saved: { nominations: number; flaggedPublishers: number } = await ctx.runMutation( + const saved: { + runId: Id<"publisherAbuseScoreRuns">; + nominations: number; + flaggedPublishers: number; + } = await ctx.runMutation( internal.publisherAbuse.persistTemporalPublisherAbuseCandidatesInternal, { mode: args.mode, @@ -2153,6 +2804,12 @@ async function finishTemporalPublisherAbuseScan( }, ); if (args.mode === "current" && args.scanComplete) { + await archiveTemporalPublisherAbuseSignalPages(ctx, { + runId: saved.runId, + candidates: highTemporalCandidates, + now: Date.now(), + notifyHermit: args.trigger === "cron", + }); await processPublisherAbuseAutobanPages(ctx, {}); } return { @@ -2400,6 +3057,152 @@ function temporalEvidenceFromCandidate(candidate: TemporalSkillCandidate) { }; } +async function archiveTemporalPublisherAbuseSignals( + ctx: Pick, + args: { + runId?: Id<"publisherAbuseScoreRuns">; + candidates: TemporalSkillCandidate[]; + now: number; + }, +) { + let archivedSignals = 0; + let changedSignals = 0; + for (const candidate of args.candidates) { + for (const signalType of signalTypesForTemporalCandidate(candidate)) { + const changed = await upsertPublisherAbuseSignal(ctx, { + runId: args.runId, + candidate, + signalType, + now: args.now, + }); + archivedSignals += 1; + if (changed) changedSignals += 1; + } + } + return { archivedCandidates: args.candidates.length, archivedSignals, changedSignals }; +} + +export async function archiveTemporalPublisherAbuseSignalsPageInternalHandler( + ctx: MutationCtx, + args: { + runId?: Id<"publisherAbuseScoreRuns">; + candidates: TemporalSkillCandidate[]; + now: number; + }, +) { + return await archiveTemporalPublisherAbuseSignals(ctx, args); +} + +function signalTypesForTemporalCandidate( + candidate: TemporalSkillCandidate, +): PublisherAbuseSignalType[] { + const signalTypes: PublisherAbuseSignalType[] = []; + if (candidate.temporalScore.nearConversion) { + signalTypes.push("high_install_download_ratio"); + } + if (candidate.temporalScore.sustained) { + signalTypes.push("sustained_downloads_flat_installs"); + } + return signalTypes; +} + +async function upsertPublisherAbuseSignal( + ctx: Pick, + args: { + runId?: Id<"publisherAbuseScoreRuns">; + candidate: TemporalSkillCandidate; + signalType: PublisherAbuseSignalType; + now: number; + }, +): Promise { + const signal = await ctx.db + .query("publisherAbuseSignals") + .withIndex("by_skill_signal_type_and_owner_key", (q) => + q + .eq("skillId", args.candidate.skillId) + .eq("signalType", args.signalType) + .eq("ownerKey", args.candidate.ownerKey), + ) + .first(); + const snapshot = publisherAbuseSignalSnapshot(args); + + if (signal) { + const previousStatus = publisherAbuseSignalReviewStatus(signal); + const snoozeExpired = + previousStatus === "snoozed" && + typeof signal.snoozedUntil === "number" && + signal.snoozedUntil <= args.now; + const nextStatus = snoozeExpired ? "open" : previousStatus; + const shouldNotify = nextStatus === "open"; + await ctx.db.patch(signal._id, { + ...snapshot, + reviewStatus: nextStatus, + snoozedUntil: nextStatus === "snoozed" ? signal.snoozedUntil : undefined, + lastSeenAt: args.now, + seenCount: signal.seenCount + 1, + lastChangedAt: shouldNotify ? args.now : signal.lastChangedAt, + needsNotification: shouldNotify ? true : (signal.needsNotification ?? false), + notificationClaimedAt: shouldNotify ? undefined : signal.notificationClaimedAt, + lastNotificationError: shouldNotify ? undefined : signal.lastNotificationError, + }); + return shouldNotify; + } + + await ctx.db.insert("publisherAbuseSignals", { + ...snapshot, + firstSeenAt: args.now, + lastSeenAt: args.now, + seenCount: 1, + reviewStatus: "open", + lastChangedAt: args.now, + needsNotification: true, + }); + return true; +} + +function publisherAbuseSignalSnapshot(args: { + runId?: Id<"publisherAbuseScoreRuns">; + candidate: TemporalSkillCandidate; + signalType: PublisherAbuseSignalType; +}) { + const { candidate } = args; + return { + signalType: args.signalType, + ownerKey: candidate.ownerKey, + ownerPublisherId: candidate.ownerPublisherId ?? null, + ownerUserId: candidate.ownerUserId ?? null, + handleSnapshot: candidate.handleSnapshot, + skillId: candidate.skillId, + skillSlug: candidate.slug, + skillDisplayName: candidate.displayName, + ...(args.runId ? { latestRunId: args.runId } : {}), + recent7Downloads: candidate.temporalScore.recent7Downloads, + recent7Installs: candidate.temporalScore.recent7Installs, + recent7InstallDownloadRatio: installDownloadRatio({ + installs: candidate.temporalScore.recent7Installs, + downloads: candidate.temporalScore.recent7Downloads, + }), + recent30Downloads: candidate.temporalScore.recent30Downloads, + recent30Installs: candidate.temporalScore.recent30Installs, + recent30InstallDownloadRatio: installDownloadRatio({ + installs: candidate.temporalScore.recent30Installs, + downloads: candidate.temporalScore.recent30Downloads, + }), + allTimeDownloads: nonNegative(candidate.totalDownloads), + allTimeInstalls: nonNegative(candidate.totalInstalls), + allTimeInstallDownloadRatio: installDownloadRatio({ + installs: candidate.totalInstalls, + downloads: candidate.totalDownloads, + }), + }; +} + +function installDownloadRatio(input: { installs: number; downloads: number }) { + const downloads = nonNegative(input.downloads); + if (downloads <= 0) return 0; + return nonNegative(input.installs) / downloads; +} + function uniqueStrings(values: string[]) { return [...new Set(values)]; } @@ -2985,6 +3788,7 @@ type PublisherAbuseReviewVisibilityOptions = { staffManagerExclusionBudget?: StaffPublisherManagerExclusionBudget; includeInactiveTargets?: boolean; includeScoreDetails?: boolean; + reviewStatus?: PublisherAbuseSignalReviewStatus; }; type PublisherAbuseReviewPaginationOpts = { @@ -3101,6 +3905,70 @@ async function summarizeVisiblePublisherAbuseReviewListNominations( return items; } +async function summarizeVisiblePublisherAbuseSignals( + ctx: QueryCtx, + signals: PublisherAbuseSignalDoc[], + options: PublisherAbuseReviewVisibilityOptions = {}, +) { + const items = []; + for (const signal of signals) { + if (options.reviewStatus && publisherAbuseSignalReviewStatus(signal) !== options.reviewStatus) { + continue; + } + const [publisher, ownerUser] = await Promise.all([ + signal.ownerPublisherId ? ctx.db.get(signal.ownerPublisherId) : null, + signal.ownerUserId ? ctx.db.get(signal.ownerUserId) : null, + ]); + if ( + await isPublisherExcludedFromPublisherAbuse( + ctx, + publisher, + options.staffManagerExclusionBudget, + ) + ) { + continue; + } + items.push({ + signal, + publisher: publisher ? summarizePublisherForAbuseReview(publisher) : null, + ownerUser: ownerUser ? summarizeUserForAbuseReview(ownerUser) : null, + }); + } + return items; +} + +async function getPublisherAbuseSignalCountSummary(ctx: QueryCtx) { + const signals = await ctx.db + .query("publisherAbuseSignals") + .withIndex("by_review_status_and_last_seen_at", (q) => q.eq("reviewStatus", "open")) + .order("desc") + .take(DASHBOARD_SIGNAL_COUNT_SCAN_LIMIT + 1); + const scannedSignals = signals.slice(0, DASHBOARD_SIGNAL_COUNT_SCAN_LIMIT); + const staffManagerExclusionBudget = createStaffPublisherManagerExclusionBudget(); + let visibleCount = 0; + for (const signal of scannedSignals) { + const publisher = signal.ownerPublisherId ? await ctx.db.get(signal.ownerPublisherId) : null; + if (await isPublisherExcludedFromPublisherAbuse(ctx, publisher, staffManagerExclusionBudget)) { + continue; + } + visibleCount += 1; + if (visibleCount > DASHBOARD_SIGNAL_COUNT_LIMIT) break; + } + const signalCountHasMore = + visibleCount > DASHBOARD_SIGNAL_COUNT_LIMIT || + signals.length > DASHBOARD_SIGNAL_COUNT_SCAN_LIMIT; + return { + signalCount: Math.min(visibleCount, DASHBOARD_SIGNAL_COUNT_LIMIT), + signalCountHasMore, + }; +} + +function publisherAbuseSignalReviewStatus( + signal: Pick, +): PublisherAbuseSignalReviewStatus { + return signal.reviewStatus ?? "open"; +} + async function summarizePublisherAbuseReviewNominationListItem( ctx: QueryCtx, nomination: Doc<"publisherAbuseReviewNominations">, diff --git a/convex/publisherAbuseDevSeed.test.ts b/convex/publisherAbuseDevSeed.test.ts index 819bc3bda3..68540a620e 100644 --- a/convex/publisherAbuseDevSeed.test.ts +++ b/convex/publisherAbuseDevSeed.test.ts @@ -19,6 +19,7 @@ const clearSeedHandler = ( scores: number; nominations: number; events: number; + signals: number; users: number; hasMore: boolean; } @@ -199,6 +200,7 @@ describe("publisherAbuseDevSeed.clearSeed", () => { scores: 1, nominations: 1, events: 1, + signals: 0, users: 1, hasMore: false, }); @@ -263,13 +265,24 @@ describe("publisherAbuseDevSeed.seed", () => { (doc) => doc.label === "review" && doc.status === "pending", ); - expect(pendingBan).toHaveLength(16); - expect(pendingReview).toHaveLength(124); + expect(pendingBan).toHaveLength(15); + expect(pendingReview).toHaveLength(125); expect(result.inserted).toBe(nominations.length); // Every ban candidate links a demo user so the inspector ban action is // exercisable; review nominations do not create users. expect(tables.users ?? []).toHaveLength(16); expect(tables.skills?.some((doc) => doc.slug === "demo-temporal-download-burst")).toBe(true); + expect(tables.skills?.some((doc) => doc.slug === "demo-temporal-install-ratio")).toBe(true); + expect(tables.publisherAbuseSignals).toEqual([ + expect.objectContaining({ + signalType: "sustained_downloads_flat_installs", + skillSlug: "demo-temporal-download-burst", + }), + expect.objectContaining({ + signalType: "high_install_download_ratio", + skillSlug: "demo-temporal-install-ratio", + }), + ]); }); it("clears existing demo rows before inserting repeatable seed data", async () => { @@ -300,6 +313,26 @@ describe("publisherAbuseDevSeed.seed", () => { nominationId: "publisherAbuseReviewNominations:old-demo", }, ], + skills: [ + { + _id: "skills:old-temporal", + slug: "demo-temporal-download-burst", + }, + ], + skillDailyStats: [ + { + _id: "skillDailyStats:old-temporal", + skillId: "skills:old-temporal", + day: 19_000, + }, + ], + publisherAbuseSignals: [ + { + _id: "publisherAbuseSignals:old-temporal", + signalType: "sustained_downloads_flat_installs", + skillId: "skills:old-temporal", + }, + ], users: [{ _id: "users:old-demo", handle: "demo-abuse-pub-01" }], }); @@ -317,10 +350,18 @@ describe("publisherAbuseDevSeed.seed", () => { expect(tables.publisherAbuseReviewEvents.map((doc) => doc._id)).not.toContain( "publisherAbuseReviewEvents:old-demo", ); + expect(tables.skills.map((doc) => doc._id)).not.toContain("skills:old-temporal"); + expect(tables.skillDailyStats.map((doc) => doc._id)).not.toContain( + "skillDailyStats:old-temporal", + ); + expect(tables.publisherAbuseSignals.map((doc) => doc._id)).not.toContain( + "publisherAbuseSignals:old-temporal", + ); expect(tables.users.map((doc) => doc._id)).not.toContain("users:old-demo"); expect(tables.users.filter((doc) => doc.handle === "demo-abuse-pub-01")).toHaveLength(1); expect(tables.users).toHaveLength(16); expect(tables.publisherAbuseReviewNominations).toHaveLength(146); + expect(tables.publisherAbuseSignals).toHaveLength(2); }); }); diff --git a/convex/publisherAbuseDevSeed.ts b/convex/publisherAbuseDevSeed.ts index 99bceefa3a..70aa34dc5a 100644 --- a/convex/publisherAbuseDevSeed.ts +++ b/convex/publisherAbuseDevSeed.ts @@ -25,6 +25,7 @@ const DEMO_OWNER_KEY_PREFIX = "user:demo-"; const TEMPORAL_DEMO_HANDLE = `${DEMO_HANDLE_PREFIX}temporal-cohort`; const TEMPORAL_DEMO_OWNER_KEY = `${DEMO_OWNER_KEY_PREFIX}temporal-cohort`; const TEMPORAL_DEMO_SKILL_SLUG = "demo-temporal-download-burst"; +const TEMPORAL_DEMO_RATIO_SKILL_SLUG = "demo-temporal-install-ratio"; const CLEAR_SEED_BATCH_SIZE = 100; type TriageStatus = @@ -52,9 +53,9 @@ type SeedPublisher = { // Prod-scale synthetic distribution so every dashboard tab renders with realistic // volume: 15 potential-ban candidates and 124 review nominations (both pending), -// plus a small resolved/pass set for the Resolved tab. Counts mirror the reported -// production review queue. Rows are deterministic (no randomness) so tests can -// assert the distribution and clearSeed stays reproducible. +// plus one temporal review nomination and a small resolved/pass set. Rows are +// deterministic (no randomness) so tests can assert the distribution and +// clearSeed stays reproducible. const BAN_CANDIDATE_COUNT = 15; const REVIEW_PENDING_COUNT = 124; @@ -240,6 +241,7 @@ type ClearSeedResult = { scores: number; nominations: number; events: number; + signals: number; users: number; hasMore: boolean; }; @@ -451,6 +453,30 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number createdAt: now - DAY_MS, updatedAt: now - HOUR_MS, }); + const ratioSkillId = await ctx.db.insert("skills", { + slug: TEMPORAL_DEMO_RATIO_SKILL_SLUG, + displayName: "Demo Temporal Install Ratio", + summary: "Synthetic fixture: unusually high installs relative to downloads.", + ownerUserId: temporalUserId, + ownerPublisherId: temporalPublisherId, + tags: {}, + badges: {}, + moderationStatus: "active", + statsDownloads: 2_400, + statsStars: 0, + statsInstallsCurrent: 288, + statsInstallsAllTime: 288, + stats: { + downloads: 2_400, + installsCurrent: 288, + installsAllTime: 288, + stars: 0, + versions: 1, + comments: 0, + }, + createdAt: now - DAY_MS, + updatedAt: now - HOUR_MS, + }); for (let offset = 59; offset >= 30; offset -= 1) { await ctx.db.insert("skillDailyStats", { skillId: temporalSkillId, @@ -486,8 +512,8 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number finalizedScores: 1, nominatedPublishers: 1, passCount: 0, - reviewCount: 0, - potentialBanCandidateCount: 1, + reviewCount: 1, + potentialBanCandidateCount: 0, sumLogPressure: 0, sumSquaredLogPressure: 0, meanLogPressure: 0, @@ -501,11 +527,11 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number ownerUserId: temporalUserId, handleSnapshot: TEMPORAL_DEMO_HANDLE, modelVersion: PUBLISHER_TEMPORAL_ABUSE_MODEL_VERSION, - label: "potential_ban_candidate", + label: "review", rank: 1, pressure: 18, logPressure: Math.log10(18), - zScore: 3.13, + zScore: 2.14, publishedSkills: 1, totalInstalls: 0, totalStars: 0, @@ -553,13 +579,63 @@ async function seedTemporalCohortDemoRows(ctx: ClearSeedCtx, args: { now: number handleSnapshot: TEMPORAL_DEMO_HANDLE, latestScoreId: temporalScoreId, modelVersion: PUBLISHER_TEMPORAL_ABUSE_MODEL_VERSION, - label: "potential_ban_candidate", + label: "review", status: "pending", openedAt: temporalCompletedAt, openedByRunId: temporalRunId, lastScoredAt: temporalCompletedAt, updatedAt: temporalCompletedAt, }); + await ctx.db.insert("publisherAbuseSignals", { + signalType: "sustained_downloads_flat_installs", + ownerKey: TEMPORAL_DEMO_OWNER_KEY, + ownerPublisherId: temporalPublisherId, + ownerUserId: temporalUserId, + handleSnapshot: TEMPORAL_DEMO_HANDLE, + skillId: temporalSkillId, + skillSlug: TEMPORAL_DEMO_SKILL_SLUG, + skillDisplayName: "Demo Temporal Download Burst", + latestRunId: temporalRunId, + latestScoreId: temporalScoreId, + firstSeenAt: temporalCompletedAt, + lastSeenAt: temporalCompletedAt, + seenCount: 1, + recent7Downloads: 3_780, + recent7Installs: 0, + recent7InstallDownloadRatio: 0, + recent30Downloads: 16_200, + recent30Installs: 0, + recent30InstallDownloadRatio: 0, + allTimeDownloads: 16_200, + allTimeInstalls: 0, + allTimeInstallDownloadRatio: 0, + reviewStatus: "open", + }); + await ctx.db.insert("publisherAbuseSignals", { + signalType: "high_install_download_ratio", + ownerKey: TEMPORAL_DEMO_OWNER_KEY, + ownerPublisherId: temporalPublisherId, + ownerUserId: temporalUserId, + handleSnapshot: TEMPORAL_DEMO_HANDLE, + skillId: ratioSkillId, + skillSlug: TEMPORAL_DEMO_RATIO_SKILL_SLUG, + skillDisplayName: "Demo Temporal Install Ratio", + latestRunId: temporalRunId, + latestScoreId: temporalScoreId, + firstSeenAt: temporalCompletedAt - 7 * 60_000, + lastSeenAt: temporalCompletedAt, + seenCount: 2, + recent7Downloads: 800, + recent7Installs: 96, + recent7InstallDownloadRatio: 0.12, + recent30Downloads: 2_400, + recent30Installs: 288, + recent30InstallDownloadRatio: 0.12, + allTimeDownloads: 2_400, + allTimeInstalls: 288, + allTimeInstallDownloadRatio: 0.12, + reviewStatus: "open", + }); } async function clearDemoRows(ctx: ClearSeedCtx): Promise { @@ -567,6 +643,7 @@ async function clearDemoRows(ctx: ClearSeedCtx): Promise { let scores = 0; let nominations = 0; let events = 0; + let signals = 0; let users = 0; let hasMore = false; @@ -605,7 +682,7 @@ async function clearDemoRows(ctx: ClearSeedCtx): Promise { } } - await clearTemporalDemoSkillRows(ctx); + signals += await clearTemporalDemoSkillRows(ctx); await clearTemporalDemoPublisherRows(ctx); for (const runId of demoRunIds) { @@ -625,24 +702,52 @@ async function clearDemoRows(ctx: ClearSeedCtx): Promise { } } - return { runs, scores, nominations, events, users, hasMore }; + return { runs, scores, nominations, events, signals, users, hasMore }; } async function clearTemporalDemoSkillRows(ctx: ClearSeedCtx) { - const rows = await ctx.db - .query("skills") - .withIndex("by_slug", (q) => q.eq("slug", TEMPORAL_DEMO_SKILL_SLUG)) - .take(CLEAR_SEED_BATCH_SIZE); - for (const skill of rows) { - const dailyStats = await ctx.db - .query("skillDailyStats") - .withIndex("by_skill_day", (q) => q.eq("skillId", skill._id)) + let signals = 0; + for (const slug of [TEMPORAL_DEMO_SKILL_SLUG, TEMPORAL_DEMO_RATIO_SKILL_SLUG]) { + const rows = await ctx.db + .query("skills") + .withIndex("by_slug", (q) => q.eq("slug", slug)) + .take(CLEAR_SEED_BATCH_SIZE); + for (const skill of rows) { + signals += await clearTemporalDemoSignalsForSkill(ctx, skill._id); + const dailyStats = await ctx.db + .query("skillDailyStats") + .withIndex("by_skill_day", (q) => q.eq("skillId", skill._id)) + .take(CLEAR_SEED_BATCH_SIZE); + for (const stat of dailyStats) { + await ctx.db.delete(stat._id); + } + await ctx.db.delete(skill._id); + } + } + return signals; +} + +async function clearTemporalDemoSignalsForSkill( + ctx: ClearSeedCtx, + skillId: Id<"skills">, +): Promise { + let deleted = 0; + for (const signalType of [ + "high_install_download_ratio", + "sustained_downloads_flat_installs", + ] as const) { + const rows = await ctx.db + .query("publisherAbuseSignals") + .withIndex("by_skill_and_signal_type", (q) => + q.eq("skillId", skillId).eq("signalType", signalType), + ) .take(CLEAR_SEED_BATCH_SIZE); - for (const stat of dailyStats) { - await ctx.db.delete(stat._id); + for (const row of rows) { + await ctx.db.delete(row._id); + deleted += 1; } - await ctx.db.delete(skill._id); } + return deleted; } async function clearTemporalDemoPublisherRows(ctx: ClearSeedCtx) { diff --git a/convex/schema.ts b/convex/schema.ts index b9aa0d61e3..4a7c41ef3b 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -2757,6 +2757,84 @@ const publisherAbuseReviewEvents = defineTable({ .index("by_owner_key_and_created_at", ["ownerKey", "createdAt"]) .index("by_actor_and_created_at", ["actorUserId", "createdAt"]); +const publisherAbuseSignalTypeValidator = v.union( + v.literal("high_install_download_ratio"), + v.literal("sustained_downloads_flat_installs"), +); + +const publisherAbuseSignalReviewStatusValidator = v.union( + v.literal("open"), + v.literal("snoozed"), + v.literal("dismissed"), +); + +const publisherAbuseSignals = defineTable({ + signalType: publisherAbuseSignalTypeValidator, + ownerKey: v.string(), + ownerPublisherId: v.union(v.id("publishers"), v.null()), + ownerUserId: v.union(v.id("users"), v.null()), + handleSnapshot: v.string(), + skillId: v.id("skills"), + skillSlug: v.string(), + skillDisplayName: v.string(), + latestRunId: v.optional(v.id("publisherAbuseScoreRuns")), + latestScoreId: v.optional(v.id("publisherAbuseScores")), + firstSeenAt: v.number(), + lastSeenAt: v.number(), + seenCount: v.number(), + recent7Downloads: v.number(), + recent7Installs: v.number(), + recent7InstallDownloadRatio: v.number(), + recent30Downloads: v.number(), + recent30Installs: v.number(), + recent30InstallDownloadRatio: v.number(), + allTimeDownloads: v.number(), + allTimeInstalls: v.number(), + allTimeInstallDownloadRatio: v.number(), + reviewStatus: publisherAbuseSignalReviewStatusValidator, + snoozedUntil: v.optional(v.number()), + reviewedByUserId: v.optional(v.id("users")), + reviewedAt: v.optional(v.number()), + reviewNote: v.optional(v.string()), + lastChangedAt: v.optional(v.number()), + needsNotification: v.optional(v.boolean()), + notificationClaimedAt: v.optional(v.number()), + lastNotifiedAt: v.optional(v.number()), + lastNotificationError: v.optional(v.string()), +}) + .index("by_last_seen_at", ["lastSeenAt"]) + .index("by_signal_type_and_last_seen_at", ["signalType", "lastSeenAt"]) + .index("by_owner_key_and_last_seen_at", ["ownerKey", "lastSeenAt"]) + .index("by_skill_and_signal_type", ["skillId", "signalType"]) + .index("by_skill_signal_type_and_owner_key", ["skillId", "signalType", "ownerKey"]) + .index("by_review_status_and_last_seen_at", ["reviewStatus", "lastSeenAt"]) + .index("by_needs_notification_and_last_changed_at", ["needsNotification", "lastChangedAt"]) + .index("by_needs_notification_and_notification_claimed_at", [ + "needsNotification", + "notificationClaimedAt", + ]); + +const publisherAbuseSignalReviewEventTypeValidator = v.union( + v.literal("snoozed"), + v.literal("dismissed"), + v.literal("reopened"), +); + +const publisherAbuseSignalReviewEvents = defineTable({ + signalId: v.id("publisherAbuseSignals"), + ownerKey: v.string(), + actorUserId: v.id("users"), + eventType: publisherAbuseSignalReviewEventTypeValidator, + previousStatus: publisherAbuseSignalReviewStatusValidator, + nextStatus: publisherAbuseSignalReviewStatusValidator, + note: v.optional(v.string()), + snoozedUntil: v.optional(v.number()), + createdAt: v.number(), +}) + .index("by_signal_and_created_at", ["signalId", "createdAt"]) + .index("by_owner_key_and_created_at", ["ownerKey", "createdAt"]) + .index("by_actor_and_created_at", ["actorUserId", "createdAt"]); + const vtScanLogs = defineTable({ type: v.union(v.literal("daily_rescan"), v.literal("backfill"), v.literal("pending_poll")), total: v.number(), @@ -3037,6 +3115,8 @@ export default defineSchema({ publisherAbuseScores, publisherAbuseReviewNominations, publisherAbuseReviewEvents, + publisherAbuseSignals, + publisherAbuseSignalReviewEvents, vtScanLogs, apiTokens, cliDeviceCodes, diff --git a/scripts/dev-worktree.test.ts b/scripts/dev-worktree.test.ts index fe917549f7..37e85eafb1 100644 --- a/scripts/dev-worktree.test.ts +++ b/scripts/dev-worktree.test.ts @@ -108,6 +108,7 @@ describe("dev-worktree helpers", () => { "127.0.0.1", "--port", "3999", + "--strictPort", ]); }); diff --git a/scripts/dev-worktree.ts b/scripts/dev-worktree.ts index 1e58ea6804..b6f569fc0c 100644 --- a/scripts/dev-worktree.ts +++ b/scripts/dev-worktree.ts @@ -67,7 +67,7 @@ export function buildForegroundArgs(argv: string[]) { } export function buildViteArgs(port: string) { - return ["--bun", "vite", "dev", "--host", "127.0.0.1", "--port", port]; + return ["--bun", "vite", "dev", "--host", "127.0.0.1", "--port", port, "--strictPort"]; } export function buildDevWorkersArgs(envFile: string) { diff --git a/specs/security-moderation.md b/specs/security-moderation.md index 5cc03a0fdd..fb5eaf726f 100644 --- a/specs/security-moderation.md +++ b/specs/security-moderation.md @@ -44,14 +44,16 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic - Publisher abuse scoring classifies bulk-publishing abuse for staff review and warning-first automatic enforcement. Scheduled pressure scoring runs daily. - Scheduled temporal scoring is a bounded dry-run signal until it has persisted - aggregation/cursor state. The `review` label remains a calibration/manual-review - signal. The `potential_ban_candidate` label is an enforcement signal only for - pressure-score nominations: the first eligible enforcement sweep must warn the - linked non-staff user by email and persist the warning score/run/deadline on the - nomination. A later sweep may automatically ban only after the warning deadline - has passed and a newer pressure score still places the publisher in - `potential_ban_candidate`. A stale warning by itself is not enough to ban. + Plain temporal dry runs are read-only. The scheduled temporal scan explicitly + opts into archived dry-run signal rows for the staff Signals tab until it has + persisted aggregation/cursor state. The `review` label remains a + calibration/manual-review signal. The `potential_ban_candidate` label is an + enforcement signal only for pressure-score nominations: the first eligible + enforcement sweep must warn the linked non-staff user by email and persist the + warning score/run/deadline on the nomination. A later sweep may automatically + ban only after the warning deadline has passed and a newer pressure score + still places the publisher in `potential_ban_candidate`. A stale warning by + itself is not enough to ban. - Publisher abuse scoring must skip staff-linked and official publishers before nominations are created. Publisher abuse autoban must process pending `potential_ban_candidate` pressure nominations without waiting for the score @@ -64,6 +66,16 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic - Publisher abuse automatic bans must still use the account ban flow, including token revocation, owned listing hiding, audit logging, and the normal suspension/appeal email. +- Publisher abuse Signals are manual-review telemetry only. They must not feed + automatic ban pressure. Staff can keep a signal `open`, `snoozed`, or + `dismissed`; there is no separate escalation state. Active snoozed and + dismissed signals stay out of the default Signals queue. A snoozed signal + reopens only when the same signal is seen again after its snooze deadline. +- Hermit owns Discord notification delivery for publisher abuse Signals. + ClawHub queues Hermit digests only for changed open signals: newly archived + signals, repeated open signals with a higher seen count, manual reopens, and + expired snoozes that are seen again. Active snoozed or dismissed signals must + update their metric snapshot without notifying Hermit. - 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. diff --git a/src/routes/-management.test.tsx b/src/routes/-management.test.tsx index 155f4fabea..e1e55ea695 100644 --- a/src/routes/-management.test.tsx +++ b/src/routes/-management.test.tsx @@ -88,6 +88,50 @@ function makePublisherAbuseItem({ }; } +function makePublisherAbuseSignal() { + return { + signal: { + _id: "publisherAbuseSignals:ratio", + signalType: "high_install_download_ratio", + ownerKey: "publisher:publishers:ratio-owner", + ownerPublisherId: "publishers:ratio-owner", + ownerUserId: "users:ratio-owner", + handleSnapshot: "ratio-owner", + skillId: "skills:ratio", + skillSlug: "ratio-skill", + skillDisplayName: "Ratio Skill", + latestRunId: "publisherAbuseScoreRuns:temporal", + firstSeenAt: 1715900000000, + lastSeenAt: 1716000000000, + seenCount: 2, + reviewStatus: "open", + recent7Downloads: 800, + recent7Installs: 96, + recent7InstallDownloadRatio: 0.12, + recent30Downloads: 2_400, + recent30Installs: 288, + recent30InstallDownloadRatio: 0.12, + allTimeDownloads: 10_000, + allTimeInstalls: 1_200, + allTimeInstallDownloadRatio: 0.12, + }, + publisher: { + _id: "publishers:ratio-owner", + handle: "ratio-owner", + displayName: null, + kind: "user", + linkedUserId: "users:ratio-owner", + }, + ownerUser: { + _id: "users:ratio-owner", + handle: "ratio-owner", + name: "Ratio Owner", + displayName: null, + role: "user", + }, + }; +} + function makeManagementUser( id: string, handle: string, @@ -299,6 +343,8 @@ describe("Management", () => { pendingPotentialBanCandidateItems: [potentialBanItem], pendingReviewItems: [reviewItem], recentResolvedItems: [], + signalCount: 1, + signalCountHasMore: false, }; } if (name === "users:list") return { items: [], total: 0 }; @@ -324,6 +370,432 @@ describe("Management", () => { expect(screen.getByText("review-pub")).toBeTruthy(); }); + it("shows archived publisher abuse signals without querying nominations for the signals tab", () => { + const signal = makePublisherAbuseSignal(); + const potentialBanItem = makePublisherAbuseItem(); + const reviewItem = makePublisherAbuseItem({ + handle: "review-pub", + id: "3", + label: "review", + ownerKey: "user:review", + ownerUserId: "users:review", + zScore: 1.8, + }); + const loadMoreSignals = vi.fn(); + 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: { + status: "completed", + scannedPublishers: 2, + scoredPublishers: 2, + potentialBanCandidateCount: 0, + reviewCount: 0, + }, + pendingItems: [], + pendingPotentialBanCandidateItems: [potentialBanItem], + pendingReviewItems: [reviewItem], + recentResolvedItems: [], + signalCount: 1, + signalCountHasMore: false, + }; + } + if (name === "users:list") return { items: [], total: 0 }; + return undefined; + }); + usePaginatedQueryMock.mockImplementation((query, args) => { + const name = getFunctionName(query); + if (name === "publisherAbuse:listSignalsPage") { + return { + results: args === "skip" ? [] : [signal], + status: args === "skip" ? "LoadingFirstPage" : "Exhausted", + loadMore: loadMoreSignals, + }; + } + return { + results: [], + status: args === "skip" ? "LoadingFirstPage" : "Exhausted", + loadMore: vi.fn(), + }; + }); + + render(); + + expect(screen.queryByLabelText("Loading")).toBeNull(); + expect(screen.getByRole("tab", { name: /Signals 1/ })).toBeTruthy(); + + fireEvent.click(screen.getByRole("tab", { name: /Signals/ })); + + expect(navigateMock).toHaveBeenCalledWith({ + to: "/management", + search: { + view: "abuse", + tab: "signals", + skill: undefined, + plugin: undefined, + }, + }); + expect(screen.queryByLabelText("Loading")).toBeNull(); + expect(screen.getByRole("tab", { name: /Potential ban 1/ })).toBeTruthy(); + expect(screen.getByRole("tab", { name: /On the brink 1/ })).toBeTruthy(); + expect(screen.getByRole("tab", { name: /All flagged 2/ })).toBeTruthy(); + expect(screen.getByRole("tab", { name: /Resolved 0/ })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "Signal" })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "Severity" })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "Subject" })).toBeTruthy(); + expect(screen.getByRole("columnheader", { name: "Evidence" })).toBeTruthy(); + expect(screen.queryByRole("columnheader", { name: "Skill" })).toBeNull(); + expect(screen.queryByRole("columnheader", { name: "Publisher" })).toBeNull(); + expect(screen.queryByRole("columnheader", { name: "30d ratio" })).toBeNull(); + expect(screen.queryByRole("columnheader", { name: "Status" })).toBeNull(); + expect(screen.queryByRole("columnheader", { name: "7d ratio" })).toBeNull(); + expect(screen.queryByRole("columnheader", { name: "All-time ratio" })).toBeNull(); + expect(screen.queryByRole("columnheader", { name: "Actions" })).toBeNull(); + expect(screen.getByText("High install/download ratio")).toBeTruthy(); + expect(screen.getAllByText("Open").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("High")).toBeTruthy(); + expect(screen.getByText("Ratio Skill")).toBeTruthy(); + expect(screen.getByText("@ratio-owner / ratio-skill")).toBeTruthy(); + expect(screen.queryByRole("link", { name: "Open skill Ratio Skill" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Open publisher ratio-owner" })).toBeNull(); + expect(screen.getAllByText("12%")).toHaveLength(1); + expect(screen.getByText("Showing 1 signals")).toBeTruthy(); + expect(screen.getByRole("group", { name: "Signal status" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open" }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.queryByRole("button", { name: /^Snooze 14 days$/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Dismiss signal$/ })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Snoozed" })); + expect( + usePaginatedQueryMock.mock.calls.some( + ([query, args]) => + getFunctionName(query) === "publisherAbuse:listSignalsPage" && + JSON.stringify(args) === JSON.stringify({ reviewStatus: "snoozed" }), + ), + ).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Dismissed" })); + expect( + usePaginatedQueryMock.mock.calls.some( + ([query, args]) => + getFunctionName(query) === "publisherAbuse:listSignalsPage" && + JSON.stringify(args) === JSON.stringify({ reviewStatus: "dismissed" }), + ), + ).toBe(true); + fireEvent.click(screen.getByRole("button", { name: "Open details for Ratio Skill" })); + expect(screen.getByRole("heading", { name: "Ratio Skill" })).toBeTruthy(); + const skillLink = screen.getByRole("link", { name: "Open skill Ratio Skill" }); + const publisherLink = screen.getByRole("link", { name: "Open publisher ratio-owner" }); + expect(skillLink.getAttribute("href")).toBe("/ratio-owner/skills/ratio-skill"); + expect(skillLink.getAttribute("target")).toBe("_blank"); + expect(publisherLink.getAttribute("href")).toBe("/ratio-owner"); + expect(publisherLink.getAttribute("target")).toBe("_blank"); + expect(screen.getByText("Install / download evidence")).toBeTruthy(); + expect(screen.getByText("96 installs / 800 downloads")).toBeTruthy(); + expect(screen.getByText("288 installs / 2,400 downloads")).toBeTruthy(); + expect(screen.getByText("1,200 installs / 10,000 downloads")).toBeTruthy(); + expect(screen.getByRole("button", { name: /^Snooze 14 days$/ })).toBeTruthy(); + expect(screen.getByRole("button", { name: /^Dismiss signal$/ })).toBeTruthy(); + expect(screen.queryByRole("columnheader", { name: "Z-score" })).toBeNull(); + expect( + usePaginatedQueryMock.mock.calls.some( + ([query, args]) => + getFunctionName(query) === "publisherAbuse:listReviewItemsPage" && args === "skip", + ), + ).toBe(true); + expect( + usePaginatedQueryMock.mock.calls.some( + ([query, args]) => + getFunctionName(query) === "publisherAbuse:listSignalsPage" && + JSON.stringify(args) === JSON.stringify({ reviewStatus: "open" }), + ), + ).toBe(true); + }); + + it("opens the signals tab from the management search param", () => { + searchState = { view: "abuse", tab: "signals" }; + const signal = makePublisherAbuseSignal(); + 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: [], + pendingReviewItems: [], + recentResolvedItems: [], + signalCount: 1, + signalCountHasMore: false, + }; + } + if (name === "users:list") return { items: [], total: 0 }; + return undefined; + }); + usePaginatedQueryMock.mockImplementation((query, args) => ({ + results: + getFunctionName(query) === "publisherAbuse:listSignalsPage" && args !== "skip" + ? [signal] + : [], + status: args === "skip" ? "LoadingFirstPage" : "Exhausted", + loadMore: vi.fn(), + })); + + render(); + + expect(screen.getByRole("tab", { name: /Signals 1/ }).getAttribute("aria-selected")).toBe( + "true", + ); + expect(screen.getByText("High install/download ratio")).toBeTruthy(); + expect( + usePaginatedQueryMock.mock.calls.some( + ([query, args]) => + getFunctionName(query) === "publisherAbuse:listSignalsPage" && + JSON.stringify(args) === JSON.stringify({ reviewStatus: "open" }), + ), + ).toBe(true); + }); + + it("triages publisher abuse signals from the management signal drawer", async () => { + searchState = { view: "abuse", tab: "signals" }; + const snoozeSignal = vi.fn(async () => ({ ok: true, status: "snoozed" })); + const dismissSignal = vi.fn(async () => ({ ok: true, status: "dismissed" })); + const reopenSignal = vi.fn(async () => ({ ok: true, status: "open" })); + const openSignal = makePublisherAbuseSignal(); + const snoozedSignal = { + ...openSignal, + signal: { + ...openSignal.signal, + reviewStatus: "snoozed", + snoozedUntil: 1717000000000, + }, + }; + let signalResults = [openSignal]; + useMutationMock.mockImplementation((mutation) => { + const name = getFunctionName(mutation); + if (name === "publisherAbuse:snoozePublisherAbuseSignal") return snoozeSignal; + if (name === "publisherAbuse:dismissPublisherAbuseSignal") return dismissSignal; + if (name === "publisherAbuse:reopenPublisherAbuseSignal") return reopenSignal; + return vi.fn(async () => ({ ok: true })); + }); + 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: [], + pendingReviewItems: [], + recentResolvedItems: [], + signalCount: signalResults.length, + signalCountHasMore: false, + }; + } + if (name === "users:list") return { items: [], total: 0 }; + return undefined; + }); + usePaginatedQueryMock.mockImplementation((query, args) => ({ + results: + getFunctionName(query) === "publisherAbuse:listSignalsPage" && args !== "skip" + ? signalResults + : [], + status: args === "skip" ? "LoadingFirstPage" : "Exhausted", + loadMore: vi.fn(), + })); + + const view = render(); + + fireEvent.click(screen.getByRole("button", { name: "Open details for Ratio Skill" })); + fireEvent.click(screen.getByRole("button", { name: /^Snooze 14 days$/ })); + fireEvent.click(screen.getAllByRole("button", { name: /^Snooze 14 days$/ }).at(-1)!); + + await waitFor(() => { + expect(snoozeSignal).toHaveBeenCalledWith({ + signalId: "publisherAbuseSignals:ratio", + note: undefined, + days: 14, + }); + }); + + signalResults = []; + view.rerender(); + await waitFor(() => { + expect(screen.queryByRole("heading", { name: "Ratio Skill" })).toBeNull(); + }); + + signalResults = [openSignal]; + view.rerender(); + fireEvent.click(screen.getByRole("button", { name: "Open details for Ratio Skill" })); + fireEvent.click(screen.getByRole("button", { name: /^Dismiss signal$/ })); + fireEvent.click(screen.getAllByRole("button", { name: /^Dismiss signal$/ }).at(-1)!); + + await waitFor(() => { + expect(dismissSignal).toHaveBeenCalledWith({ + signalId: "publisherAbuseSignals:ratio", + note: undefined, + }); + }); + + signalResults = [snoozedSignal]; + view.rerender(); + fireEvent.click(screen.getByRole("button", { name: /^Reopen signal$/ })); + fireEvent.click(screen.getAllByRole("button", { name: /^Reopen signal$/ }).at(-1)!); + + await waitFor(() => { + expect(reopenSignal).toHaveBeenCalledWith({ + signalId: "publisherAbuseSignals:ratio", + note: undefined, + }); + }); + }); + + it("updates publisher abuse tab badges when live counts decrease", () => { + const firstItem = makePublisherAbuseItem({ id: "1", handle: "first-pub" }); + const secondItem = makePublisherAbuseItem({ id: "2", handle: "second-pub" }); + let potentialBanItems = [firstItem, secondItem]; + 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: { + status: "completed", + scannedPublishers: potentialBanItems.length, + scoredPublishers: potentialBanItems.length, + potentialBanCandidateCount: potentialBanItems.length, + reviewCount: 0, + }, + pendingItems: potentialBanItems, + pendingPotentialBanCandidateItems: potentialBanItems, + pendingReviewItems: [], + recentResolvedItems: [], + signalCount: 0, + signalCountHasMore: false, + }; + } + if (name === "users:list") return { items: [], total: 0 }; + return undefined; + }); + usePaginatedQueryMock.mockImplementation((query, args) => { + if (getFunctionName(query) === "publisherAbuse:listReviewItemsPage" && args !== "skip") { + return { + results: potentialBanItems, + status: "Exhausted", + loadMore: vi.fn(), + }; + } + return { + results: [], + status: args === "skip" ? "LoadingFirstPage" : "Exhausted", + loadMore: vi.fn(), + }; + }); + + const { rerender } = render(); + + expect(screen.getByRole("tab", { name: /Potential ban 2/ })).toBeTruthy(); + expect(screen.getByRole("tab", { name: /All flagged 2/ })).toBeTruthy(); + expect(screen.getByText("Showing 2 of 2 nominations")).toBeTruthy(); + + potentialBanItems = [firstItem]; + rerender(); + + expect(screen.getByRole("tab", { name: /Potential ban 1/ })).toBeTruthy(); + expect(screen.getByRole("tab", { name: /All flagged 1/ })).toBeTruthy(); + expect(screen.getByText("Showing 1 of 1 nominations")).toBeTruthy(); + expect(screen.queryByRole("tab", { name: /Potential ban 2/ })).toBeNull(); + }); + + it("keeps the signals badge on the raw total and loads more signal pages", () => { + const signal = makePublisherAbuseSignal(); + const loadMoreSignals = vi.fn(); + 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: [], + pendingReviewItems: [], + recentResolvedItems: [], + signalCount: 25, + signalCountHasMore: true, + }; + } + if (name === "users:list") return { items: [], total: 0 }; + return undefined; + }); + usePaginatedQueryMock.mockImplementation((query, args) => { + const name = getFunctionName(query); + if (name === "publisherAbuse:listSignalsPage") { + return { + results: args === "skip" ? [] : [signal], + status: args === "skip" ? "LoadingFirstPage" : "CanLoadMore", + loadMore: loadMoreSignals, + }; + } + return { + results: [], + status: args === "skip" ? "LoadingFirstPage" : "Exhausted", + loadMore: vi.fn(), + }; + }); + + render(); + + expect(screen.getByRole("tab", { name: /Signals 25\+/ })).toBeTruthy(); + + fireEvent.click(screen.getByRole("tab", { name: /Signals/ })); + + expect(screen.getByRole("tab", { name: /Signals 25\+/ })).toBeTruthy(); + expect(screen.getByText("Showing 1 of 1+ signals")).toBeTruthy(); + + fireEvent.change(screen.getByPlaceholderText("Search signal, skill, publisher, or user"), { + target: { value: "does-not-match" }, + }); + + expect(screen.getByRole("tab", { name: /Signals 25\+/ })).toBeTruthy(); + expect(screen.getByText("No matching signals")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Load more" })); + expect(loadMoreSignals).toHaveBeenCalledWith(25); + }); + + it("shows table skeleton rows while the active publisher abuse page is loading", () => { + usePaginatedQueryMock.mockImplementation((query, args) => ({ + results: [], + status: + getFunctionName(query) === "publisherAbuse:listReviewItemsPage" && args !== "skip" + ? "LoadingFirstPage" + : "Exhausted", + loadMore: vi.fn(), + })); + + render(); + + expect( + screen.getByRole("status", { name: "Loading publisher abuse nominations" }), + ).toBeTruthy(); + expect(screen.queryByText("Loading publisher abuse nominations…")).toBeNull(); + }); + it("starts a manual publisher abuse scan without exposing force-new", async () => { const startScan = vi.fn(async () => ({ ok: true, diff --git a/src/routes/-management/AbusePage.tsx b/src/routes/-management/AbusePage.tsx index 3d8b780902..8861342acc 100644 --- a/src/routes/-management/AbusePage.tsx +++ b/src/routes/-management/AbusePage.tsx @@ -1,14 +1,18 @@ import { Link } from "@tanstack/react-router"; import { Ban, + Clock3, Copy, ExternalLink, Power, RefreshCcw, + RotateCcw, Search, ShieldCheck, ShieldOff, + XCircle, } from "lucide-react"; +import { useEffect, useState } from "react"; import type { Id } from "../../../convex/_generated/dataModel"; import { Badge, type BadgeProps } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; @@ -21,8 +25,9 @@ import { SheetTitle, } from "../../components/ui/sheet"; import { Textarea } from "../../components/ui/textarea"; -import { buildPublisherProfileHref } from "../../lib/ownerRoute"; +import { buildPublisherProfileHref, buildSkillDetailHref } from "../../lib/ownerRoute"; import { + formatPercent, formatRatio, formatScore, formatShortTimestamp, @@ -31,6 +36,8 @@ import { type PublisherAbuseReviewDetail, type PublisherAbuseReviewItem, type PublisherAbuseReviewScore, + type PublisherAbuseSignalEntry, + type PublisherAbuseSignalStatus, type PublisherAbuseTab, USER_BAN_REASON_MAX_LENGTH, } from "./managementShared"; @@ -47,15 +54,23 @@ export function AbusePage({ search, selectedItem, selectedNominationId, + signalItems, + signalLoadedCount, + signalPageStatus, + signalStatus, tab, onBanOwner, onChangeNotes, onChangeSearch, + onChangeSignalStatus, onChangeTab, onClose, + onDismissSignal, onLoadMore, onRefresh, + onReopenSignal, onSelect, + onSnoozeSignal, onToggleAutoban, }: { admin: boolean; @@ -75,35 +90,64 @@ export function AbusePage({ search: string; selectedItem: PublisherAbuseReviewItem | null; selectedNominationId: Id<"publisherAbuseReviewNominations"> | null; + signalItems: PublisherAbuseSignalEntry[]; + signalLoadedCount: number; + signalPageStatus: string; + signalStatus: PublisherAbuseSignalStatus; tab: PublisherAbuseTab; onBanOwner: (item: PublisherAbuseReviewItem) => void; onChangeNotes: (value: string) => void; onChangeSearch: (value: string) => void; + onChangeSignalStatus: (value: PublisherAbuseSignalStatus) => void; onChangeTab: (value: PublisherAbuseTab) => void; onClose: () => void; + onDismissSignal: (item: PublisherAbuseSignalEntry) => void; onLoadMore: () => void; onRefresh: () => void; + onReopenSignal: (item: PublisherAbuseSignalEntry) => void; onSelect: (value: Id<"publisherAbuseReviewNominations">) => void; + onSnoozeSignal: (item: PublisherAbuseSignalEntry) => void; onToggleAutoban: () => void; }) { + const [selectedSignalItem, setSelectedSignalItem] = useState( + null, + ); + const selectedSignalId = selectedSignalItem?.signal._id ?? null; + useEffect(() => { + if (!selectedSignalId) return; + if (tab !== "signals") { + setSelectedSignalItem(null); + return; + } + const freshSignalItem = signalItems.find((item) => item.signal._id === selectedSignalId); + if (freshSignalItem) { + setSelectedSignalItem(freshSignalItem); + return; + } + if (signalPageStatus !== "LoadingFirstPage") { + setSelectedSignalItem(null); + } + }, [selectedSignalId, signalItems, signalPageStatus, tab]); const latestRun = dashboard?.latestRun ?? null; const selectedScore = selectedItem?.latestScore ?? null; const selectedPublisher = selectedItem?.publisher ?? null; const canBanSelectedUser = canBanPublisherAbuseOwner(selectedItem, currentUserId); const visiblePending = dashboard ? getPublisherAbuseVisiblePendingItems(dashboard) : []; - const potentialBan = - dashboard?.latestRun?.potentialBanCandidateCount ?? - visiblePending.filter((item) => item.nomination.label === "potential_ban_candidate").length; - const review = - dashboard?.latestRun?.reviewCount ?? - visiblePending.filter((item) => item.nomination.label === "review").length; - const totalPending = - dashboard?.latestRun?.potentialBanCandidateCount !== undefined || - dashboard?.latestRun?.reviewCount !== undefined - ? potentialBan + review - : visiblePending.length; + const potentialBan = Math.max( + dashboard?.latestRun?.potentialBanCandidateCount ?? 0, + visiblePending.filter((item) => item.nomination.label === "potential_ban_candidate").length, + ); + const review = Math.max( + dashboard?.latestRun?.reviewCount ?? 0, + visiblePending.filter((item) => item.nomination.label === "review").length, + ); + const totalPending = Math.max(potentialBan + review, visiblePending.length); const resolved = tab === "resolved" ? items.length : (dashboard?.recentResolvedItems.length ?? 0); - const loaded = dashboard !== undefined && pageStatus !== "LoadingFirstPage"; + const dashboardLoaded = dashboard !== undefined; + const nominationPageLoaded = pageStatus !== "LoadingFirstPage"; + const loaded = dashboardLoaded && nominationPageLoaded; + const signalsLoaded = signalPageStatus !== "LoadingFirstPage"; + const signalDashboardCount = dashboard?.signalCount ?? 0; const latestRunTotalForTab = tab === "potential_ban_candidate" ? potentialBan @@ -111,23 +155,54 @@ export function AbusePage({ ? review : tab === "resolved" ? resolved - : totalPending; + : tab === "signals" + ? signalDashboardCount + : totalPending; const totalForTab = loaded ? Math.max(latestRunTotalForTab, items.length) : latestRunTotalForTab; - const potentialBanTabCount = - tab === "potential_ban_candidate" && loaded - ? Math.max(potentialBan, items.length) - : potentialBan; - const reviewTabCount = tab === "review" && loaded ? Math.max(review, items.length) : review; - const allPendingTabCount = - tab === "all_pending" && loaded ? Math.max(totalPending, items.length) : totalPending; + const currentPageTotalForTab = loaded ? totalForTab : 0; + const potentialBanTabCount = Math.max( + potentialBan, + tab === "potential_ban_candidate" ? currentPageTotalForTab : 0, + ); + const reviewTabCount = Math.max(review, tab === "review" ? currentPageTotalForTab : 0); + const allPendingTabCount = Math.max( + totalPending, + tab === "all_pending" ? currentPageTotalForTab : 0, + potentialBanTabCount + reviewTabCount, + ); + const signalTabCount = Math.max( + signalDashboardCount, + tab === "signals" && signalsLoaded ? signalLoadedCount : 0, + ); + const signalTabHasMore = + dashboard?.signalCountHasMore || + signalPageStatus === "CanLoadMore" || + signalPageStatus === "LoadingMore"; + const signalTabCountLabel = + signalTabHasMore && signalTabCount > 0 + ? `${formatWholeNumber(signalTabCount)}+` + : formatWholeNumber(signalTabCount); const canLoadMore = pageStatus === "CanLoadMore"; const loadingMore = pageStatus === "LoadingMore"; + const signalsCanLoadMore = signalPageStatus === "CanLoadMore"; + const signalsLoadingMore = signalPageStatus === "LoadingMore"; const nominationCountLabel = loaded && (canLoadMore || loadingMore) ? `Showing ${formatWholeNumber(items.length)}+ nominations` : loaded ? `Showing ${formatWholeNumber(items.length)} of ${formatWholeNumber(totalForTab)} nominations` : "Loading…"; + const signalCountLabel = + signalsLoaded && (signalsCanLoadMore || signalsLoadingMore) + ? `Showing ${formatWholeNumber(signalItems.length)} of ${formatWholeNumber(signalLoadedCount)}+ signals` + : signalsLoaded + ? signalItems.length === signalLoadedCount + ? `Showing ${formatWholeNumber(signalLoadedCount)} signals` + : `Showing ${formatWholeNumber(signalItems.length)} of ${formatWholeNumber(signalLoadedCount)} signals` + : "Loading…"; + const activeCanLoadMore = tab === "signals" ? signalsCanLoadMore : canLoadMore; + const activeLoadingMore = tab === "signals" ? signalsLoadingMore : loadingMore; + const activeCountLabel = tab === "signals" ? signalCountLabel : nominationCountLabel; const autobanLoaded = autobanSetting !== undefined; const autobanEnabled = autobanSetting?.enabled ?? false; const autobanStatusLabel = autobanLoaded @@ -164,7 +239,7 @@ export function AbusePage({ > {latestRun ? formatPublisherAbuseRunStatus(latestRun.status) - : loaded + : dashboardLoaded ? "No scans yet" : "Loading"} @@ -207,28 +282,40 @@ export function AbusePage({
onChangeTab("potential_ban_candidate")} /> onChangeTab("review")} /> onChangeTab("all_pending")} /> onChangeTab("resolved")} /> + onChangeTab("signals")} + />
@@ -236,108 +323,144 @@ export function AbusePage({ onChangeSearch(event.target.value)} /> -
- - - - - - - - - - - - {!loaded ? ( + {tab === "signals" ? ( +
+ {(["open", "snoozed", "dismissed"] as const).map((status) => ( + + ))} +
+ ) : null} + {tab === "signals" ? ( + 0} + onSelectSignal={setSelectedSignalItem} + /> + ) : ( +
+
LabelHandleZ-scoreReasonsLast scored
+ - + + + + + - ) : items.length === 0 ? ( - - - - ) : ( - items.map((item) => { - const score = item.latestScore; - const selected = item.nomination._id === selectedNominationId; - return ( - onSelect(item.nomination._id)} - > - - - - - - - ); - }) - )} - -
Loading publisher abuse nominations…LabelHandleZ-scoreReasonsLast scored
- Queue clear - No publishers in this view from the latest scoring run. -
- - {formatPublisherAbuseLabel(item.nomination.label)} - - - - - {score ? formatScore(score.zScore) : "—"} - -
- {(score?.reasonCodes ?? []).slice(0, 2).map((reason) => ( - - {formatReasonCode(reason)} - - ))} - {(score?.reasonCodes.length ?? 0) > 2 ? ( - +{(score?.reasonCodes.length ?? 0) - 2} - ) : null} - {!score?.reasonCodes.length ? : null} -
-
- {formatShortTimestamp(item.nomination.lastScoredAt)} -
-
+ + + {!loaded ? ( + + ) : items.length === 0 ? ( + + + Queue clear + No publishers in this view from the latest scoring run. + + + ) : ( + items.map((item) => { + const score = item.latestScore; + const selected = item.nomination._id === selectedNominationId; + return ( + onSelect(item.nomination._id)} + > + + + {formatPublisherAbuseLabel(item.nomination.label)} + + + + + + + {score ? formatScore(score.zScore) : "—"} + + +
+ {(score?.reasonCodes ?? []).slice(0, 2).map((reason) => ( + + {formatReasonCode(reason)} + + ))} + {(score?.reasonCodes.length ?? 0) > 2 ? ( + + +{(score?.reasonCodes.length ?? 0) - 2} + + ) : null} + {!score?.reasonCodes.length ? ( + + ) : null} +
+ + + {formatShortTimestamp(item.nomination.lastScoredAt)} + + + ); + }) + )} + + + + )}
- {nominationCountLabel} - {canLoadMore || loadingMore ? ( + {activeCountLabel} + {activeCanLoadMore || activeLoadingMore ? ( ) : null}
@@ -539,6 +662,24 @@ export function AbusePage({ ) : null} + + { + if (!open) setSelectedSignalItem(null); + }} + > + + {selectedSignalItem ? ( + + ) : null} + + ); } @@ -546,12 +687,16 @@ export function AbusePage({ function PublisherAbuseTabButton({ active, count, + countLabel, label, + loading = false, onClick, }: { active: boolean; count: number | undefined; + countLabel?: string; label: string; + loading?: boolean; onClick: () => void; }) { return ( @@ -563,15 +708,427 @@ function PublisherAbuseTabButton({ onClick={onClick} > {label}{" "} - {count === undefined ? ( + {loading ? ( - ) : ( - {formatWholeNumber(count)} + ) : count === undefined ? null : ( + {countLabel ?? formatWholeNumber(count)} )} ); } +function PublisherAbuseTableSkeletonRows({ + columns, + label, + rows = 5, +}: { + columns: number; + label: string; + rows?: number; +}) { + return ( + <> + {Array.from({ length: rows }, (_row, rowIndex) => ( + + {Array.from({ length: columns }, (_column, columnIndex) => ( + + {rowIndex === 0 && columnIndex === 0 ? ( + + {label} + + ) : null} + + + ))} + + ))} + + ); +} + +function publisherAbuseSkeletonWidth(columnIndex: number, rowIndex: number) { + const widths = [72, 148, 64, 124, 92, 84, 92, 100, 100]; + const base = widths[columnIndex] ?? 96; + return Math.max(48, base - (rowIndex % 3) * 14); +} + +function PublisherAbuseSignalsTable({ + canLoadMore, + items, + loaded, + selectedSignalId, + status, + searchActive, + onSelectSignal, +}: { + canLoadMore: boolean; + items: PublisherAbuseSignalEntry[]; + loaded: boolean; + selectedSignalId: Id<"publisherAbuseSignals"> | null; + status: PublisherAbuseSignalStatus; + searchActive: boolean; + onSelectSignal: (item: PublisherAbuseSignalEntry) => void; +}) { + const emptyState = publisherAbuseSignalEmptyState(searchActive, canLoadMore, status); + return ( +
+ + + + + + + + + + + + {!loaded ? ( + + ) : items.length === 0 ? ( + + + + ) : ( + items.map((item) => { + const selected = item.signal._id === selectedSignalId; + return ( + onSelectSignal(item)} + > + + + + + + + ); + }) + )} + +
SeveritySignalSubjectEvidenceLast seen
+ {emptyState.title} + {emptyState.body} +
+ + {formatPublisherAbuseSignalSeverity(item.signal.signalType)} + + + + {item.signal.snoozedUntil ? ( +
+ until {formatShortTimestamp(item.signal.snoozedUntil)} +
+ ) : null} +
+
+ {item.signal.skillDisplayName} + + @{item.signal.handleSnapshot} / {item.signal.skillSlug} + +
+
{formatShortTimestamp(item.signal.lastSeenAt)}
+
+ ); +} + +function PublisherAbuseSignalInspector({ + item, + onDismissSignal, + onReopenSignal, + onSnoozeSignal, +}: { + item: PublisherAbuseSignalEntry; + onDismissSignal: (item: PublisherAbuseSignalEntry) => void; + onReopenSignal: (item: PublisherAbuseSignalEntry) => void; + onSnoozeSignal: (item: PublisherAbuseSignalEntry) => void; +}) { + const status = signalReviewStatus(item); + const publisherHandle = signalPublisherHandle(item); + return ( + <> + + {item.signal.skillDisplayName} + + Publisher abuse signal evidence, linked skill and publisher, and available review actions. + +
+ + {formatPublisherAbuseSignalStatus(status)} + + + {formatPublisherAbuseSignalSeverity(item.signal.signalType)} + + Seen {formatWholeNumber(item.signal.seenCount)}x +
+ +
+ +
+
+
Signal
+
+
+ {formatPublisherAbuseSignalType(item.signal.signalType)} + {describePublisherAbuseSignalType(item.signal.signalType)} +
+
+
+ +
+
Publisher and skill
+
+ + + + +
+
+ +
+
Install / download evidence
+
+ + + +
+
+ +
+
Review state
+
+ + + + +
+ {item.signal.reviewNote ?

{item.signal.reviewNote}

: null} +
+ +
+
Actions
+
+ {status === "open" ? ( + <> + + + + ) : ( + + )} +
+
+
+ + ); +} + +function PublisherAbuseSignalMeta({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function PublisherAbuseSignalEvidenceMetric({ + downloads, + installs, + label, + ratio, +}: { + downloads: number; + installs: number; + label: string; + ratio: number; +}) { + return ( +
+ {label} + {formatPercent(ratio)} + + {formatWholeNumber(installs)} installs / {formatWholeNumber(downloads)} downloads + +
+ ); +} + +function publisherAbuseSignalEmptyState( + searchActive: boolean, + canLoadMore: boolean, + status: PublisherAbuseSignalStatus, +) { + if (searchActive) { + return { + title: "No matching signals", + body: canLoadMore + ? "Load more to search additional archived rows." + : "No loaded signal matches this search.", + }; + } + if (canLoadMore) { + return { + title: "No visible signals loaded", + body: "Load more to keep scanning archived rows.", + }; + } + return { + title: `No ${formatPublisherAbuseSignalStatus(status).toLowerCase()} signals`, + body: + status === "open" + ? "No actionable publisher abuse signals need review." + : "No durable publisher abuse evidence matches this status.", + }; +} + +function PublisherAbuseSignalRatioCell({ + downloads, + installs, + ratio, +}: { + downloads: number; + installs: number; + ratio: number; +}) { + return ( + + {formatPercent(ratio)} + + {formatWholeNumber(installs)} / {formatWholeNumber(downloads)} + + + ); +} + +function signalPublisherHandle(item: PublisherAbuseSignalEntry) { + return item.publisher?.handle || item.signal.handleSnapshot; +} + +function signalReviewStatus(item: PublisherAbuseSignalEntry): PublisherAbuseSignalStatus { + return item.signal.reviewStatus ?? "open"; +} + +function formatPublisherAbuseSignalStatus(status: PublisherAbuseSignalStatus) { + if (status === "open") return "Open"; + if (status === "snoozed") return "Snoozed"; + return "Dismissed"; +} + function PublisherAbuseIdentity({ label, value }: { label: string; value: string }) { return (
@@ -750,6 +1307,33 @@ export function filterPublisherAbuseItems(items: PublisherAbuseReviewItem[], sea }); } +export function filterPublisherAbuseSignals(items: PublisherAbuseSignalEntry[], search: string) { + const query = search.trim().toLowerCase(); + if (!query) return items; + return items.filter((item) => { + const haystack = [ + item.signal.signalType, + formatPublisherAbuseSignalType(item.signal.signalType), + item.signal.handleSnapshot, + item.signal.ownerKey, + item.signal.ownerPublisherId, + item.signal.ownerUserId, + item.signal.reviewStatus, + item.publisher?.displayName, + item.publisher?.handle, + item.ownerUser?.handle, + item.ownerUser?.name, + item.ownerUser?.displayName, + item.signal.skillSlug, + item.signal.skillDisplayName, + ] + .filter((value) => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase(); + return haystack.includes(query); + }); +} + export function comparePublisherAbuseItems( left: PublisherAbuseReviewItem, right: PublisherAbuseReviewItem, @@ -785,6 +1369,37 @@ function formatPublisherAbuseLabel(label: string) { return label; } +function formatPublisherAbuseSignalType(signalType: string) { + if (signalType === "high_install_download_ratio") return "High install/download ratio"; + if (signalType === "sustained_downloads_flat_installs") { + return "Sustained downloads, flat installs"; + } + return signalType.replaceAll("_", " "); +} + +function describePublisherAbuseSignalType(signalType: string) { + if (signalType === "high_install_download_ratio") { + return "Install counts are unusually high compared with download counts for this skill."; + } + if (signalType === "sustained_downloads_flat_installs") { + return "Downloads stayed high over the review window while installs stayed flat."; + } + return "Archived publisher abuse signal for manual review."; +} + +function formatPublisherAbuseSignalSeverity(signalType: string) { + if (signalType === "high_install_download_ratio") return "High"; + if (signalType === "sustained_downloads_flat_installs") return "Review"; + return "Review"; +} + +function publisherAbuseSignalSeverityVariant( + signalType: string, +): NonNullable { + if (signalType === "high_install_download_ratio") return "warning"; + return "review"; +} + function formatPublisherAbuseStatus(status: string) { if (status === "pending") return "Pending"; if (status === "banned") return "Banned"; diff --git a/src/routes/-management/managementShared.ts b/src/routes/-management/managementShared.ts index 6576cd59ae..65f38c9019 100644 --- a/src/routes/-management/managementShared.ts +++ b/src/routes/-management/managementShared.ts @@ -19,12 +19,21 @@ export type ManagementUserSummary = NonNullable[" export type PublisherAbuseReviewDashboard = FunctionReturnType< typeof api.publisherAbuse.listReviewDashboard >; +export type PublisherAbuseSignalEntry = FunctionReturnType< + typeof api.publisherAbuse.listSignalsPage +>["page"][number]; export type PublisherAbuseReviewDetail = FunctionReturnType< typeof api.publisherAbuse.getReviewNominationDetail >; export type PublisherAbuseReviewItem = NonNullable["item"]; export type PublisherAbuseReviewScore = NonNullable; -export type PublisherAbuseTab = "potential_ban_candidate" | "review" | "all_pending" | "resolved"; +export type PublisherAbuseTab = + | "potential_ban_candidate" + | "review" + | "all_pending" + | "resolved" + | "signals"; +export type PublisherAbuseSignalStatus = "open" | "snoozed" | "dismissed"; export type ManagementView = | "overview" @@ -78,6 +87,15 @@ export function formatRatio(value: number | null | undefined) { }).format(value); } +export function formatPercent(value: number | null | undefined) { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return new Intl.NumberFormat(undefined, { + style: "percent", + maximumFractionDigits: 1, + minimumFractionDigits: 0, + }).format(value); +} + export function formatScore(value: number) { return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2, diff --git a/src/routes/management.tsx b/src/routes/management.tsx index ddaf684dbe..09ae9781e6 100644 --- a/src/routes/management.tsx +++ b/src/routes/management.tsx @@ -33,6 +33,7 @@ import { canBanPublisherAbuseOwner, comparePublisherAbuseItems, filterPublisherAbuseItems, + filterPublisherAbuseSignals, getPublisherAbuseItemsForTab, getPublisherAbuseVisiblePendingItems, } from "./-management/AbusePage"; @@ -47,7 +48,9 @@ import { type ManagementUserListResult, type ManagementView, type PluginByNameResult, + type PublisherAbuseSignalEntry, type PublisherAbuseReviewItem, + type PublisherAbuseSignalStatus, type PublisherAbuseTab, type RecentVersionEntry, type ReportedSkillEntry, @@ -79,6 +82,18 @@ function isManagementView(value: unknown): value is ManagementView { return typeof value === "string" && MANAGEMENT_VIEWS.has(value); } +const PUBLISHER_ABUSE_TABS = new Set([ + "potential_ban_candidate", + "review", + "all_pending", + "resolved", + "signals", +]); + +function isPublisherAbuseTab(value: unknown): value is PublisherAbuseTab { + return typeof value === "string" && PUBLISHER_ABUSE_TABS.has(value); +} + type ManagementConfirmRequest = { title: string; body?: string; @@ -170,6 +185,7 @@ export const Route = createFileRoute("/management")({ skill?: string; plugin?: string; view?: ManagementView; + tab?: PublisherAbuseTab; } = {}; if (typeof search.skill === "string" && search.skill.trim()) { validated.skill = search.skill; @@ -180,6 +196,9 @@ export const Route = createFileRoute("/management")({ if (isManagementView(search.view)) { validated.view = search.view; } + if (isPublisherAbuseTab(search.tab)) { + validated.tab = search.tab; + } return validated; }, component: Management, @@ -241,6 +260,9 @@ export function Management() { const setPublisherAbuseAutobanEnabled = useMutation( api.publisherAbuse.setPublisherAbuseAutobanEnabled, ); + const snoozePublisherAbuseSignal = useMutation(api.publisherAbuse.snoozePublisherAbuseSignal); + const dismissPublisherAbuseSignal = useMutation(api.publisherAbuse.dismissPublisherAbuseSignal); + const reopenPublisherAbuseSignal = useMutation(api.publisherAbuse.reopenPublisherAbuseSignal); const startPublisherAbuseScoreRun = useAction(api.publisherAbuse.startPublisherAbuseScoreRun); const [selectedDuplicate, setSelectedDuplicate] = useState(""); @@ -255,10 +277,13 @@ export function Management() { const [skillSearch, setSkillSearch] = useState(selectedSlug ?? ""); const [skillOverrideNote, setSkillOverrideNote] = useState(""); const [confirmRequest, setConfirmRequest] = useState(null); - const [publisherAbuseTab, setPublisherAbuseTab] = - useState("potential_ban_candidate"); + const [publisherAbuseTab, setPublisherAbuseTab] = useState( + abuseViewActive ? (search.tab ?? "potential_ban_candidate") : "potential_ban_candidate", + ); const [publisherAbuseSearch, setPublisherAbuseSearch] = useState(""); const [publisherAbuseNotes, setPublisherAbuseNotes] = useState(""); + const [publisherAbuseSignalStatus, setPublisherAbuseSignalStatus] = + useState("open"); const [selectedPublisherAbuseNominationId, setSelectedPublisherAbuseNominationId] = useState | null>(null); const { @@ -267,7 +292,20 @@ export function Management() { loadMore: loadMorePublisherAbuseItems, } = usePaginatedQuery( api.publisherAbuse.listReviewItemsPage, - staff && abuseViewActive ? { tab: publisherAbuseTab } : "skip", + staff && abuseViewActive && publisherAbuseTab !== "signals" + ? { tab: publisherAbuseTab } + : "skip", + { initialNumItems: 25 }, + ); + const { + results: publisherAbuseSignalPageResults, + status: publisherAbuseSignalPageStatus, + loadMore: loadMorePublisherAbuseSignals, + } = usePaginatedQuery( + api.publisherAbuse.listSignalsPage, + staff && abuseViewActive && publisherAbuseTab === "signals" + ? { reviewStatus: publisherAbuseSignalStatus } + : "skip", { initialNumItems: 25 }, ); @@ -302,6 +340,8 @@ export function Management() { [publisherAbuseDashboard, publisherAbuseTab], ); const publisherAbusePageItems = (publisherAbusePageResults ?? []) as PublisherAbuseReviewItem[]; + const publisherAbuseSignalItems = (publisherAbuseSignalPageResults ?? + []) as PublisherAbuseSignalEntry[]; const publisherAbuseItemsForTab = publisherAbusePageItems.length > 0 || publisherAbuseDashboardFallbackItems.length === 0 ? publisherAbusePageItems @@ -317,6 +357,10 @@ export function Management() { ) ?? null; const selectedPublisherAbuseItem = selectedPublisherAbuseDetail?.item ?? fallbackSelectedPublisherAbuseItem; + const filteredPublisherAbuseSignals = useMemo( + () => filterPublisherAbuseSignals(publisherAbuseSignalItems, publisherAbuseSearch), + [publisherAbuseSignalItems, publisherAbuseSearch], + ); useEffect(() => { if (!selectedSkillId || !selectedOwnerUserId) return; @@ -336,6 +380,16 @@ export function Management() { setSkillSearch(selectedSlug ?? ""); }, [selectedSlug]); + useEffect(() => { + if (!abuseViewActive) return; + const nextTab = search.tab ?? "potential_ban_candidate"; + setPublisherAbuseTab(nextTab); + if (nextTab === "signals") { + setPublisherAbuseNotes(""); + setSelectedPublisherAbuseNominationId(null); + } + }, [abuseViewActive, search.tab]); + useEffect(() => { const handle = setTimeout(() => setReportSearchDebounced(reportSearch), 250); return () => clearTimeout(handle); @@ -585,6 +639,58 @@ export function Management() { }); }; + const requestSnoozePublisherAbuseSignal = (item: PublisherAbuseSignalEntry) => { + setConfirmRequest({ + title: `Snooze ${item.signal.skillDisplayName}?`, + body: "Hides this signal from the default review queue for 14 days. If it reappears after that, it will reopen and notify Hermit again.", + confirmLabel: "Snooze 14 days", + reason: { + label: "Note (optional)", + placeholder: "Why are you snoozing this signal?", + }, + onConfirm: (note) => { + void snoozePublisherAbuseSignal({ signalId: item.signal._id, note, days: 14 }) + .then(() => toast.success("Signal snoozed.")) + .catch((error) => toast.error(formatMutationError(error))); + }, + }); + }; + + const requestDismissPublisherAbuseSignal = (item: PublisherAbuseSignalEntry) => { + setConfirmRequest({ + title: `Dismiss ${item.signal.skillDisplayName}?`, + body: "Dismissed signals stay archived but are hidden from the default review queue and will not notify Hermit unless reopened.", + confirmLabel: "Dismiss signal", + destructive: true, + reason: { + label: "Note (optional)", + placeholder: "Why are you dismissing this signal?", + }, + onConfirm: (note) => { + void dismissPublisherAbuseSignal({ signalId: item.signal._id, note }) + .then(() => toast.success("Signal dismissed.")) + .catch((error) => toast.error(formatMutationError(error))); + }, + }); + }; + + const requestReopenPublisherAbuseSignal = (item: PublisherAbuseSignalEntry) => { + setConfirmRequest({ + title: `Reopen ${item.signal.skillDisplayName}?`, + body: "Returns this signal to the default review queue and queues a Hermit digest notification.", + confirmLabel: "Reopen signal", + reason: { + label: "Note (optional)", + placeholder: "Why are you reopening this signal?", + }, + onConfirm: (note) => { + void reopenPublisherAbuseSignal({ signalId: item.signal._id, note }) + .then(() => toast.success("Signal reopened.")) + .catch((error) => toast.error(formatMutationError(error))); + }, + }); + }; + const requestTogglePublisherAbuseAutoban = () => { if (!publisherAbuseAutobanSetting) return; const nextEnabled = !publisherAbuseAutobanSetting.enabled; @@ -640,13 +746,40 @@ export function Management() { search={publisherAbuseSearch} selectedItem={selectedPublisherAbuseItem} selectedNominationId={selectedPublisherAbuseNominationId} + signalItems={filteredPublisherAbuseSignals} + signalLoadedCount={publisherAbuseSignalItems.length} + signalPageStatus={publisherAbuseSignalPageStatus} + signalStatus={publisherAbuseSignalStatus} tab={publisherAbuseTab} onBanOwner={banPublisherAbuseOwner} onChangeNotes={setPublisherAbuseNotes} onChangeSearch={setPublisherAbuseSearch} - onChangeTab={setPublisherAbuseTab} + onChangeSignalStatus={setPublisherAbuseSignalStatus} + onChangeTab={(nextTab) => { + setPublisherAbuseTab(nextTab); + if (nextTab === "signals") { + setPublisherAbuseNotes(""); + setSelectedPublisherAbuseNominationId(null); + } + void navigate({ + to: "/management", + search: { + view: "abuse", + tab: nextTab, + skill: undefined, + plugin: undefined, + }, + }); + }} onToggleAutoban={requestTogglePublisherAbuseAutoban} - onLoadMore={() => loadMorePublisherAbuseItems(25)} + onDismissSignal={requestDismissPublisherAbuseSignal} + onLoadMore={() => { + if (publisherAbuseTab === "signals") { + loadMorePublisherAbuseSignals(25); + } else { + loadMorePublisherAbuseItems(25); + } + }} onRefresh={() => { setConfirmRequest({ title: "Run a new abuse scan?", @@ -663,10 +796,12 @@ export function Management() { setPublisherAbuseNotes(""); setSelectedPublisherAbuseNominationId(null); }} + onReopenSignal={requestReopenPublisherAbuseSignal} onSelect={(nominationId) => { setPublisherAbuseNotes(""); setSelectedPublisherAbuseNominationId(nominationId); }} + onSnoozeSignal={requestSnoozePublisherAbuseSignal} /> ) : null} diff --git a/src/styles.css b/src/styles.css index 2fac0b9296..bc35d05383 100644 --- a/src/styles.css +++ b/src/styles.css @@ -14181,6 +14181,34 @@ code { outline: none; } +.pa-signal-status-tabs { + display: inline-flex; + align-self: flex-start; + gap: 2px; + padding: 3px; + border: 1px solid var(--line); + border-radius: var(--r-md); + background: var(--surface); +} + +.pa-signal-status-tabs button { + min-height: 30px; + padding: 0 10px; + border: 0; + border-radius: calc(var(--r-md) - 3px); + background: transparent; + color: var(--ink-soft); + font: inherit; + font-size: var(--fs-sm); + cursor: pointer; +} + +.pa-signal-status-tabs button:hover, +.pa-signal-status-tabs button.active { + background: var(--hover-bg); + color: var(--ink); +} + .pa-table-wrap { min-width: 0; margin: 0 calc(-1 * var(--space-4)); @@ -14225,6 +14253,29 @@ code { background: var(--hover-bg); } +.pa-table tbody tr.pa-skeleton-row { + cursor: default; +} + +.pa-table tbody tr.pa-skeleton-row:hover { + background: transparent; +} + +.pa-table-skeleton-bar { + display: block; + max-width: 100%; + height: 14px; + border-radius: 4px; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--line) 70%, transparent), + color-mix(in srgb, var(--ink-soft) 18%, transparent), + color-mix(in srgb, var(--line) 70%, transparent) + ); + background-size: 220% 100%; + animation: skeleton-shimmer 1.15s ease-in-out infinite; +} + .pa-table tbody tr.is-selected { background: var(--accent-subtle); } @@ -14233,6 +14284,18 @@ code { box-shadow: inset 2px 0 0 var(--accent); } +.pa-signals-table { + min-width: 680px; +} + +.pa-signals-table tbody tr { + cursor: pointer; +} + +.pa-signals-table tbody tr:hover { + background: var(--hover-bg); +} + .pa-num { text-align: right; font-variant-numeric: tabular-nums; @@ -14242,6 +14305,46 @@ code { color: var(--ink-soft); } +.pa-signal-repeat, +.pa-ratio-subtext { + display: block; + margin-top: 3px; + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: var(--fs-xs); + white-space: nowrap; +} + +.pa-signal-name { + font-weight: 600; +} + +.pa-signal-summary { + display: grid; + gap: 3px; +} + +.pa-signal-summary span { + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: var(--fs-xs); +} + +.pa-signal-subject { + display: grid; + gap: 2px; +} + +.pa-signal-subject strong { + font-weight: 600; +} + +.pa-signal-subject span { + color: var(--ink-soft); + font-family: var(--font-mono); + font-size: var(--fs-xs); +} + .pa-handle { display: grid; gap: 2px; @@ -14460,10 +14563,16 @@ code { align-items: baseline; justify-content: space-between; gap: var(--space-2); + min-width: 0; padding: 3px 0; } +.pa-metric > * { + min-width: 0; +} + .pa-metric span { + flex-shrink: 0; color: var(--ink-soft); font-size: var(--fs-sm); } @@ -14472,6 +14581,31 @@ code { font-size: var(--fs-sm); font-weight: 600; font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; + text-align: right; +} + +.pa-metric small { + color: var(--ink-soft); + font-size: var(--fs-xs); + line-height: 1.35; + overflow-wrap: anywhere; +} + +.pa-signal-evidence-grid { + grid-template-columns: 1fr; + gap: 4px; +} + +.pa-signal-evidence-grid .pa-metric { + display: grid; + grid-template-columns: minmax(58px, 0.5fr) minmax(56px, auto) minmax(0, 1fr); + align-items: baseline; + column-gap: var(--space-2); +} + +.pa-signal-evidence-grid .pa-metric strong { + text-align: right; } .pa-section-label { @@ -14655,6 +14789,10 @@ code { grid-template-columns: 1fr; } + .pa-signal-evidence-grid { + grid-template-columns: 1fr; + } + .pa-score > div { border-left: 0; border-top: 1px solid var(--line); diff --git a/vitest.config.ts b/vitest.config.ts index c88b13dbde..b4af37c46a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ "**/coverage/**", "**/convex/_generated/**", "packages/clawhub/**", + "packages/clawhub-admin/test-artifact/**", "e2e/**", "**/*.e2e.test.ts", ],