diff --git a/.changeset/moderation-abuse-reporting-501.md b/.changeset/moderation-abuse-reporting-501.md new file mode 100644 index 00000000..6aa462bd --- /dev/null +++ b/.changeset/moderation-abuse-reporting-501.md @@ -0,0 +1,5 @@ +--- +"prompt-hash-stellar": minor +--- + +Add moderation and abuse-reporting flows (#501). Buyers can file signed abuse reports against listings (and reviews/users) through a new `POST /api/moderation/report` endpoint and a Report dialog on the listing page. Moderators get a paginated, filterable report queue (`GET /api/moderation/queue`) and can resolve/dismiss reports and take down or reinstate listings (`POST /api/moderation/actions`). A public `GET /api/moderation/status` endpoint lets the listing page show a takedown notice when a prompt has been removed. All moderator actions require a scoped wallet signature and are written to the existing audit log. diff --git a/api/moderation/actions.test.ts b/api/moderation/actions.test.ts new file mode 100644 index 00000000..6747e9a7 --- /dev/null +++ b/api/moderation/actions.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment node + +import { Buffer } from "buffer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { buildModeratorAuthMessage } from "../../src/lib/auth/challenge"; +import { + addReport, + getReportById, + getPromptModerationState, + getModerationLogs, + setPromptModerationState, +} from "./data"; + +const moderator = Keypair.random(); + +function sign(message: string, kp: Keypair): string { + return Buffer.from(kp.sign(Buffer.from(message, "utf8"))).toString("base64"); +} + +function moderatorProof(kp: Keypair, purpose: string) { + const ts = Date.now(); + return { + moderatorAddress: kp.publicKey(), + moderatorTimestamp: ts, + moderatorSignature: sign(buildModeratorAuthMessage(kp.publicKey(), purpose, ts), kp), + }; +} + +async function invoke(body: Record) { + let statusCode = 0; + let responseData: Record = {}; + const req = { + method: "POST", + headers: {}, + body, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + requestId: "test", + socket: { remoteAddress: "127.0.0.1" }, + }; + const res = { + status(code: number) { statusCode = code; return this; }, + json(d: Record) { responseData = d; return this; }, + setHeader: vi.fn(), + }; + const handler = (await import("./actions")).default; + // @ts-expect-error test handler invocation + await handler(req, res); + return { statusCode, responseData }; +} + +describe("moderation actions endpoint", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.MODERATOR_ADDRESSES = moderator.publicKey(); + }); + + it("requires confirmed: true", async () => { + const proof = moderatorProof(moderator, "moderation-action"); + const { statusCode } = await invoke({ ...proof, confirmed: false, actions: [] }); + expect(statusCode).toBe(400); + }); + + it("rejects mismatched action and target type", async () => { + const proof = moderatorProof(moderator, "moderation-action"); + const { statusCode, responseData } = await invoke({ + ...proof, + confirmed: true, + actions: [{ action: "user_warned", targetId: "x", targetType: "prompt", reason: "bad" }], + }); + expect(statusCode).toBe(207); + expect((responseData as any).errors.length).toBe(1); + }); + + it("resolves a report and logs it", async () => { + const report = addReport({ + reporterAddress: "GREPORTER1", + targetType: "prompt", + targetId: "prompt_xyz", + reason: "spam", + }); + const proof = moderatorProof(moderator, "moderation-action"); + const before = getModerationLogs().length; + const { statusCode, responseData } = await invoke({ + ...proof, + confirmed: true, + actions: [{ action: "report_resolved", targetId: report.id, targetType: "report", reason: "Reviewed" }], + }); + expect(statusCode).toBe(200); + expect((responseData as any).success).toBe(true); + expect(getReportById(report.id)?.status).toBe("resolved"); + expect(getModerationLogs().length).toBe(before + 1); + }); + + it("takes down a prompt and sets its moderation state", async () => { + const proof = moderatorProof(moderator, "moderation-action"); + const { statusCode } = await invoke({ + ...proof, + confirmed: true, + actions: [{ action: "prompt_takedown", targetId: "prompt_td", targetType: "prompt", reason: "Copyright" }], + }); + expect(statusCode).toBe(200); + expect(getPromptModerationState("prompt_td").status).toBe("taken_down"); + }); + + it("reinstates a previously taken-down prompt", async () => { + setPromptModerationState("prompt_rt", "taken_down"); + const proof = moderatorProof(moderator, "moderation-action"); + const { statusCode } = await invoke({ + ...proof, + confirmed: true, + actions: [{ action: "prompt_reinstated", targetId: "prompt_rt", targetType: "prompt", reason: "Appeal granted" }], + }); + expect(statusCode).toBe(200); + expect(getPromptModerationState("prompt_rt").status).toBe("active"); + }); +}); diff --git a/api/moderation/actions.ts b/api/moderation/actions.ts index 9047b9fa..46dadef5 100644 --- a/api/moderation/actions.ts +++ b/api/moderation/actions.ts @@ -1,9 +1,51 @@ import { findReviewById, updateReview } from "../reviews/data"; -import { addModerationLog, verifyModeratorAuth } from "./data"; +import { + addModerationLog, + getReportById, + updateReportStatus, + setPromptModerationState, + verifyModeratorAuth, +} from "./data"; import { withBodySizeLimit } from "../../src/lib/api/bodySizeLimit"; -type Action = "review_removed" | "review_approved" | "user_warned"; -interface BulkAction { action: Action; targetId: string; targetType: "review" | "user"; reason: string; details?: string; } +type Action = + | "review_removed" + | "review_approved" + | "user_warned" + | "report_resolved" + | "report_dismissed" + | "prompt_takedown" + | "prompt_reinstated"; + +type TargetType = "review" | "user" | "report" | "prompt"; + +interface BulkAction { + action: Action; + targetId: string; + targetType: TargetType; + reason: string; + details?: string; +} + +const VALID_ACTIONS: Action[] = [ + "review_removed", + "review_approved", + "user_warned", + "report_resolved", + "report_dismissed", + "prompt_takedown", + "prompt_reinstated", +]; + +const ACTION_TARGET: Record = { + review_removed: "review", + review_approved: "review", + user_warned: "user", + report_resolved: "report", + report_dismissed: "report", + prompt_takedown: "prompt", + prompt_reinstated: "prompt", +}; async function handler(req: any, res: any) { if (req.method !== "POST") return res.status(405).json({ error: "Method not allowed" }); @@ -29,20 +71,65 @@ async function handler(req: any, res: any) { const errors: Array<{ index: number; error: string }> = []; const applied = []; for (const [index, item] of actions.entries()) { - if (!item || !["review_removed", "review_approved", "user_warned"].includes(item.action) || !item.targetId || !item.reason?.trim()) { - errors.push({ index, error: "Invalid action, target, or reason" }); continue; - } - if ((item.action === "user_warned") !== (item.targetType === "user")) { - errors.push({ index, error: "Action does not match target type" }); continue; + if ( + !item || + !VALID_ACTIONS.includes(item.action) || + !item.targetId || + !item.reason?.trim() || + item.targetType !== ACTION_TARGET[item.action] + ) { + errors.push({ index, error: "Invalid action, target, or reason" }); + continue; } + + let ok = true; if (item.targetType === "review") { const review = findReviewById(item.targetId); - if (!review) { errors.push({ index, error: "Review not found" }); continue; } - updateReview(review.promptId, review.id, { - moderation: { status: item.action === "review_removed" ? "removed" : "approved", moderatorAddress: moderatorAddress ?? "", reason: item.reason.trim(), updatedAt: Date.now() }, + if (!review) { + errors.push({ index, error: "Review not found" }); + ok = false; + } else { + updateReview(review.promptId, review.id, { + moderation: { + status: item.action === "review_removed" ? "removed" : "approved", + moderatorAddress: moderatorAddress ?? "", + reason: item.reason.trim(), + updatedAt: Date.now(), + }, + }); + } + } else if (item.targetType === "report") { + const report = getReportById(item.targetId); + if (!report) { + errors.push({ index, error: "Report not found" }); + ok = false; + } else { + const status = item.action === "report_resolved" ? "resolved" : "dismissed"; + updateReportStatus(report.id, status, { + resolvedBy: moderatorAddress ?? "", + resolution: item.reason.trim(), + }); + } + } else if (item.targetType === "prompt") { + const nextStatus = item.action === "prompt_takedown" ? "taken_down" : "active"; + setPromptModerationState(item.targetId, nextStatus, { + reason: item.reason.trim(), + updatedBy: moderatorAddress ?? "", }); } - applied.push(addModerationLog({ ...item, reason: item.reason.trim(), details: item.details?.trim(), moderatorAddress: moderatorAddress ?? "" })); + + if (ok) { + applied.push( + addModerationLog({ + action: item.action, + targetId: item.targetId, + targetType: item.targetType, + reason: item.reason.trim(), + details: item.details?.trim(), + moderatorAddress: moderatorAddress ?? "", + }), + ); + } } return res.status(errors.length ? 207 : 200).json({ success: errors.length === 0, applied, errors }); } diff --git a/api/moderation/data.ts b/api/moderation/data.ts index 1458d69c..f579607b 100644 --- a/api/moderation/data.ts +++ b/api/moderation/data.ts @@ -1,16 +1,195 @@ -import { buildModeratorAuthMessage, verifyChallengeSignature } from "../../src/lib/auth/challenge"; +import { + buildModeratorAuthMessage, + verifyChallengeSignature, +} from "../../src/lib/auth/challenge"; + +export type ModerationAction = + | "review_removed" + | "review_approved" + | "user_warned" + | "report_resolved" + | "report_dismissed" + | "prompt_takedown" + | "prompt_reinstated"; + +export type ModerationTargetType = "review" | "user" | "report" | "prompt"; export interface ModerationLogEntry { id: string; - action: "review_removed" | "review_approved" | "user_warned"; + action: ModerationAction; moderatorAddress: string; targetId: string; - targetType: "review" | "user"; + targetType: ModerationTargetType; reason: string; details?: string; createdAt: number; } +// ── Abuse reports ───────────────────────────────────────────────────────────── + +export type ReportTargetType = "prompt" | "review" | "user"; +export type ReportStatus = "pending" | "under_review" | "resolved" | "dismissed"; +export type ReportReason = + | "copyright" + | "spam" + | "inappropriate" + | "scam" + | "misinformation" + | "other"; + +export interface AbuseReport { + id: string; + reporterAddress: string; + targetType: ReportTargetType; + targetId: string; + reason: ReportReason; + details?: string; + status: ReportStatus; + createdAt: number; + updatedAt: number; + resolvedBy?: string; + resolution?: string; +} + +const reports: AbuseReport[] = []; + +export const REPORT_REASONS: ReportReason[] = [ + "copyright", + "spam", + "inappropriate", + "scam", + "misinformation", + "other", +]; + +function generateId(prefix: string): string { + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +export function addReport( + entry: Omit, +): AbuseReport { + const now = Date.now(); + const stored: AbuseReport = { + ...entry, + id: generateId("rep"), + status: "pending", + createdAt: now, + updatedAt: now, + }; + reports.push(stored); + return stored; +} + +export function getReportById(id: string): AbuseReport | undefined { + return reports.find((report) => report.id === id); +} + +export interface ReportQuery { + status?: ReportStatus; + targetType?: ReportTargetType; + reason?: ReportReason; + reporterAddress?: string; + search?: string; + since?: number; +} + +export function getReports(query: ReportQuery = {}): AbuseReport[] { + let filtered = [...reports]; + if (query.status) filtered = filtered.filter((r) => r.status === query.status); + if (query.targetType) filtered = filtered.filter((r) => r.targetType === query.targetType); + if (query.reason) filtered = filtered.filter((r) => r.reason === query.reason); + if (query.reporterAddress) + filtered = filtered.filter( + (r) => r.reporterAddress.toLowerCase() === query.reporterAddress!.toLowerCase(), + ); + if (query.since) filtered = filtered.filter((r) => r.createdAt >= query.since!); + if (query.search) { + const needle = query.search.toLowerCase(); + filtered = filtered.filter( + (r) => + r.targetId.toLowerCase().includes(needle) || + r.details?.toLowerCase().includes(needle) || + r.reporterAddress.toLowerCase().includes(needle), + ); + } + filtered.sort((a, b) => b.createdAt - a.createdAt); + return filtered; +} + +export function hasOpenReport( + reporterAddress: string, + targetType: ReportTargetType, + targetId: string, +): boolean { + return reports.some( + (r) => + r.reporterAddress.toLowerCase() === reporterAddress.toLowerCase() && + r.targetType === targetType && + r.targetId === targetId && + r.status !== "resolved" && + r.status !== "dismissed", + ); +} + +export function updateReportStatus( + id: string, + status: ReportStatus, + options: { resolvedBy?: string; resolution?: string; now?: number } = {}, +): AbuseReport | undefined { + const report = getReportById(id); + if (!report) return undefined; + report.status = status; + report.updatedAt = options.now ?? Date.now(); + if (options.resolvedBy) report.resolvedBy = options.resolvedBy; + if (options.resolution) report.resolution = options.resolution; + return report; +} + +// ── Prompt takedown state ──────────────────────────────────────────────────── + +export type PromptModerationStatus = "active" | "taken_down"; + +export interface PromptModerationState { + promptId: string; + status: PromptModerationStatus; + reason?: string; + updatedAt: number; + updatedBy?: string; +} + +const promptStates = new Map(); + +export function getPromptModerationState(promptId: string): PromptModerationState { + return ( + promptStates.get(promptId) ?? { + promptId, + status: "active", + updatedAt: 0, + } + ); +} + +export function setPromptModerationState( + promptId: string, + status: PromptModerationStatus, + options: { reason?: string; updatedBy?: string; now?: number } = {}, +): PromptModerationState { + const state: PromptModerationState = { + promptId, + status, + reason: options.reason, + updatedAt: options.now ?? Date.now(), + updatedBy: options.updatedBy, + }; + promptStates.set(promptId, state); + return state; +} + +export function isPromptTakenDown(promptId: string): boolean { + return getPromptModerationState(promptId).status === "taken_down"; +} + const logs: ModerationLogEntry[] = []; export function addModerationLog(entry: Omit): ModerationLogEntry { diff --git a/api/moderation/queue.test.ts b/api/moderation/queue.test.ts new file mode 100644 index 00000000..52aba947 --- /dev/null +++ b/api/moderation/queue.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment node + +import { Buffer } from "buffer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { buildModeratorAuthMessage } from "../../src/lib/auth/challenge"; +import { addReport } from "./data"; + +const moderator = Keypair.random(); + +function sign(message: string, kp: Keypair): string { + return Buffer.from(kp.sign(Buffer.from(message, "utf8"))).toString("base64"); +} + +function buildReq(query: Record, method = "GET") { + return { + method, + headers: {}, + query, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + requestId: "test", + socket: { remoteAddress: "127.0.0.1" }, + }; +} + +async function invokeQueue(query: Record, method = "GET") { + let statusCode = 0; + let responseData: Record = {}; + const req = buildReq(query, method); + const res = { + status(code: number) { + statusCode = code; + return this; + }, + json(data: Record) { + responseData = data; + return this; + }, + setHeader: vi.fn(), + }; + const handler = (await import("./queue")).default; + // @ts-expect-error test handler invocation + await handler(req, res); + return { statusCode, responseData }; +} + +describe("moderation queue endpoint", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.MODERATOR_ADDRESSES = moderator.publicKey(); + // Seed deterministic reports. + addReport({ + reporterAddress: "GREPORTER1", + targetType: "prompt", + targetId: "prompt_abc", + reason: "copyright", + details: "copied content", + }); + addReport({ + reporterAddress: "GREPORTER2", + targetType: "review", + targetId: "review_xyz", + reason: "spam", + }); + }); + + it("rejects requests without a moderator address", async () => { + const { statusCode } = await invokeQueue({}); + expect(statusCode).toBe(401); + }); + + it("rejects requests from a non-moderator", async () => { + const stranger = Keypair.random(); + const ts = Date.now(); + const { statusCode } = await invokeQueue({ + moderatorAddress: stranger.publicKey(), + moderatorTimestamp: String(ts), + moderatorSignature: sign(buildModeratorAuthMessage(stranger.publicKey(), "moderation-queue", ts), stranger), + }); + expect(statusCode).toBe(403); + }); + + it("returns queued reports for an authorized moderator", async () => { + const ts = Date.now(); + const { statusCode, responseData } = await invokeQueue({ + moderatorAddress: moderator.publicKey(), + moderatorTimestamp: String(ts), + moderatorSignature: sign(buildModeratorAuthMessage(moderator.publicKey(), "moderation-queue", ts), moderator), + }); + expect(statusCode).toBe(200); + const entries = (responseData as any).entries as unknown[]; + expect(entries.length).toBeGreaterThanOrEqual(2); + expect((responseData as any).pagination.total).toBeGreaterThanOrEqual(2); + }); + + it("filters by target type", async () => { + const ts = Date.now(); + const { statusCode, responseData } = await invokeQueue({ + moderatorAddress: moderator.publicKey(), + moderatorTimestamp: String(ts), + moderatorSignature: sign(buildModeratorAuthMessage(moderator.publicKey(), "moderation-queue", ts), moderator), + targetType: "review", + }); + expect(statusCode).toBe(200); + const entries = (responseData as any).entries as Array<{ targetType: string }>; + expect(entries.every((e) => e.targetType === "review")).toBe(true); + }); +}); diff --git a/api/moderation/queue.ts b/api/moderation/queue.ts new file mode 100644 index 00000000..3a076758 --- /dev/null +++ b/api/moderation/queue.ts @@ -0,0 +1,100 @@ +import { negotiateVersion } from "../../src/lib/api/versionGuard"; +import { withVersion } from "../../src/lib/api/payloadVersion"; +import { apiError, ErrorCode } from "../../src/lib/api/errorCodes"; +import { + getReports, + verifyModeratorAuth, + REPORT_REASONS, + type AbuseReport, + type ReportReason, + type ReportStatus, + type ReportTargetType, +} from "./data"; + +function isReportStatus(value: unknown): value is ReportStatus { + return value === "pending" || value === "under_review" || value === "resolved" || value === "dismissed"; +} + +function isReportTargetType(value: unknown): value is ReportTargetType { + return value === "prompt" || value === "review" || value === "user"; +} + +function isReportReason(value: unknown): value is ReportReason { + return REPORT_REASONS.includes(value as ReportReason); +} + +export default async function handler(req: any, res: any) { + if (req.method !== "GET") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + const version = negotiateVersion(req, res); + if (!version) return; + + const moderatorAddress = (req.query.moderatorAddress as string) ?? ""; + const moderatorTimestamp = req.query.moderatorTimestamp + ? parseInt(req.query.moderatorTimestamp as string, 10) + : undefined; + const moderatorSignature = (req.query.moderatorSignature as string) ?? undefined; + + if (!moderatorAddress) { + res.status(401).json({ apiVersion: version, error: "Moderator address is required" }); + return; + } + + const auth = verifyModeratorAuth({ + address: moderatorAddress, + timestamp: moderatorTimestamp, + signature: moderatorSignature, + purpose: "moderation-queue", + }); + if (!auth.ok) { + res.status(auth.status).json({ apiVersion: version, error: auth.error }); + return; + } + + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 20)); + + const statusParam = req.query.status as string; + const targetTypeParam = req.query.targetType as string; + const reasonParam = req.query.reason as string; + const search = (req.query.search as string) ?? ""; + const since = req.query.since ? parseInt(req.query.since as string, 10) : 0; + + try { + const filtered = getReports({ + status: statusParam && isReportStatus(statusParam) ? statusParam : undefined, + targetType: targetTypeParam && isReportTargetType(targetTypeParam) ? targetTypeParam : undefined, + reason: reasonParam && isReportReason(reasonParam) ? reasonParam : undefined, + search: search || undefined, + since: since || undefined, + }); + + const total = filtered.length; + const totalPages = Math.ceil(total / limit); + const start = (page - 1) * limit; + const entries: AbuseReport[] = filtered.slice(start, start + limit); + + res.status(200).json( + withVersion( + { + entries, + pagination: { + page, + limit, + total, + totalPages, + hasMore: page < totalPages, + }, + }, + version, + ), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch moderation queue"; + console.error("Moderation queue error:", message); + res.status(500).json(apiError(ErrorCode.TEMPORARY_FAILURE, message, undefined, version)); + } +} diff --git a/api/moderation/report.test.ts b/api/moderation/report.test.ts new file mode 100644 index 00000000..8b0d6d3b --- /dev/null +++ b/api/moderation/report.test.ts @@ -0,0 +1,159 @@ +// @vitest-environment node + +import { Buffer } from "buffer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { + buildReportAuthMessage, +} from "../../src/lib/auth/challenge"; +import { ErrorCode } from "../../src/lib/api/errorCodes"; + +const reporter = Keypair.random(); +const moderator = Keypair.random(); + +function sign(address: string, message: string, kp: Keypair): string { + return Buffer.from(kp.sign(Buffer.from(message, "utf8"))).toString("base64"); +} + +async function invoke(body: Record) { + let statusCode = 0; + let responseData: Record = {}; + const req = { + method: "POST", + headers: {}, + body, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + requestId: "test", + socket: { remoteAddress: "127.0.0.1" }, + }; + const res = { + status(code: number) { + statusCode = code; + return this; + }, + json(data: Record) { + responseData = data; + return this; + }, + setHeader: vi.fn(), + }; + const handler = (await import("./report")).default; + // @ts-expect-error test handler invocation + await handler(req, res); + return { statusCode, responseData }; +} + +describe("abuse report endpoint", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.MODERATOR_ADDRESSES = moderator.publicKey(); + }); + + it("rejects non-POST methods", async () => { + const handler = (await import("./report")).default; + let statusCode = 0; + const req = { method: "GET", headers: {}, body: {}, logger: { error: vi.fn() } }; + const res = { status(c: number) { statusCode = c; return this; }, json: () => this, setHeader: vi.fn() }; + // @ts-expect-error test handler invocation + await handler(req, res); + expect(statusCode).toBe(405); + }); + + it("rejects a report with a missing target", async () => { + const { statusCode, responseData } = await invoke({ reason: "spam" }); + expect(statusCode).toBe(400); + expect((responseData as any).code).toBe(ErrorCode.MISSING_FIELDS); + }); + + it("rejects an invalid reason", async () => { + const { statusCode, responseData } = await invoke({ + targetType: "prompt", + targetId: "42", + reason: "not-a-real-reason", + }); + expect(statusCode).toBe(400); + expect((responseData as any).code).toBe(ErrorCode.INVALID_INPUT); + }); + + it("rejects a report without a valid reporter signature", async () => { + const { statusCode, responseData } = await invoke({ + reporterAddress: reporter.publicKey(), + targetType: "prompt", + targetId: "42", + reason: "spam", + }); + expect(statusCode).toBe(401); + }); + + it("accepts a properly signed report", async () => { + const timestamp = Date.now(); + const signature = sign( + reporter.publicKey(), + buildReportAuthMessage(reporter.publicKey(), "prompt", "42", timestamp), + reporter, + ); + const { statusCode, responseData } = await invoke({ + reporterAddress: reporter.publicKey(), + reporterTimestamp: timestamp, + reporterSignature: signature, + targetType: "prompt", + targetId: "42", + reason: "spam", + details: "This is spammy content", + }); + expect(statusCode).toBe(201); + expect((responseData as any).success).toBe(true); + expect((responseData as any).report.targetId).toBe("42"); + expect((responseData as any).report.status).toBe("pending"); + }); + + it("rejects a report when the signature does not match the address", async () => { + const timestamp = Date.now(); + const signature = sign( + reporter.publicKey(), + buildReportAuthMessage(reporter.publicKey(), "prompt", "42", timestamp), + reporter, + ); + const { statusCode } = await invoke({ + reporterAddress: moderator.publicKey(), // mismatched address + reporterTimestamp: timestamp, + reporterSignature: signature, + targetType: "prompt", + targetId: "42", + reason: "spam", + }); + expect(statusCode).toBe(401); + }); + + it("rejects a duplicate open report from the same reporter", async () => { + const timestamp = Date.now(); + const first = await invoke({ + reporterAddress: reporter.publicKey(), + reporterTimestamp: timestamp, + reporterSignature: sign( + reporter.publicKey(), + buildReportAuthMessage(reporter.publicKey(), "prompt", "77", timestamp), + reporter, + ), + targetType: "prompt", + targetId: "77", + reason: "scam", + }); + expect(first.statusCode).toBe(201); + + const timestamp2 = Date.now(); + const second = await invoke({ + reporterAddress: reporter.publicKey(), + reporterTimestamp: timestamp2, + reporterSignature: sign( + reporter.publicKey(), + buildReportAuthMessage(reporter.publicKey(), "prompt", "77", timestamp2), + reporter, + ), + targetType: "prompt", + targetId: "77", + reason: "scam", + }); + expect(second.statusCode).toBe(409); + }); +}); diff --git a/api/moderation/report.ts b/api/moderation/report.ts new file mode 100644 index 00000000..e5aba5cb --- /dev/null +++ b/api/moderation/report.ts @@ -0,0 +1,133 @@ +import { withBodySizeLimit } from "../../src/lib/api/bodySizeLimit"; +import { negotiateVersion } from "../../src/lib/api/versionGuard"; +import { withVersion } from "../../src/lib/api/payloadVersion"; +import { apiError, ErrorCode } from "../../src/lib/api/errorCodes"; +import { + addReport, + hasOpenReport, + REPORT_REASONS, + type AbuseReport, + type ReportReason, + type ReportTargetType, +} from "./data"; +import { verifyReportAuth } from "../../src/lib/auth/challenge"; + +const MAX_DETAILS_LENGTH = 2000; + +interface ReportSubmission { + reporterAddress?: string; + reporterTimestamp?: number; + reporterSignature?: string; + targetType?: ReportTargetType; + targetId?: string; + reason?: ReportReason; + details?: string; +} + +function isReportTargetType(value: unknown): value is ReportTargetType { + return value === "prompt" || value === "review" || value === "user"; +} + +function isReportReason(value: unknown): value is ReportReason { + return REPORT_REASONS.includes(value as ReportReason); +} + +async function handler(req: any, res: any) { + if (req.method !== "POST") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + const version = negotiateVersion(req, res); + if (!version) return; + + const body = (req.body ?? {}) as ReportSubmission; + const { + reporterAddress, + reporterTimestamp, + reporterSignature, + targetType, + targetId, + reason, + details, + } = body; + + if (!isReportTargetType(targetType) || !targetId || !targetId.trim()) { + res.status(400).json( + apiError(ErrorCode.MISSING_FIELDS, "A valid report target (type and id) is required", undefined, version), + ); + return; + } + + if (!isReportReason(reason)) { + res.status(400).json( + apiError(ErrorCode.INVALID_INPUT, "A valid report reason is required", undefined, version), + ); + return; + } + + const normalizedDetails = details?.trim(); + if (normalizedDetails && normalizedDetails.length > MAX_DETAILS_LENGTH) { + res.status(400).json( + apiError( + ErrorCode.INVALID_INPUT, + `Report details must not exceed ${MAX_DETAILS_LENGTH} characters`, + undefined, + version, + ), + ); + return; + } + + if (!reporterAddress || !reporterAddress.trim()) { + res.status(401).json( + apiError(ErrorCode.MISSING_FIELDS, "A connected wallet is required to file a report", undefined, version), + ); + return; + } + + const auth = verifyReportAuth({ + address: reporterAddress.trim(), + targetType, + targetId: targetId.trim(), + timestamp: reporterTimestamp, + signature: reporterSignature, + }); + if (!auth.ok) { + res.status(auth.status).json({ apiVersion: version, error: auth.error }); + return; + } + + if (hasOpenReport(reporterAddress.trim(), targetType, targetId.trim())) { + res.status(409).json( + withVersion( + { error: "You have already reported this item and it is still being reviewed" }, + version, + ), + ); + return; + } + + try { + const report = addReport({ + reporterAddress: reporterAddress.trim(), + targetType, + targetId: targetId.trim(), + reason, + details: normalizedDetails, + }); + + console.log( + `✓ Abuse report ${report.id} filed by ${reporterAddress.slice(0, 8)}... for ${targetType} ${targetId}`, + ); + + const response: AbuseReport = { ...report }; + res.status(201).json(withVersion({ success: true, report: response }, version)); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to submit report"; + console.error("Report submission error:", message); + res.status(500).json(apiError(ErrorCode.TEMPORARY_FAILURE, message, undefined, version)); + } +} + +export default withBodySizeLimit(handler); diff --git a/api/moderation/status.test.ts b/api/moderation/status.test.ts new file mode 100644 index 00000000..46244617 --- /dev/null +++ b/api/moderation/status.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment node + +import { Buffer } from "buffer"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Keypair } from "@stellar/stellar-sdk"; +import { buildModeratorAuthMessage } from "../../src/lib/auth/challenge"; +import { getPromptModerationState, setPromptModerationState, addReport } from "./data"; + +const moderator = Keypair.random(); + +function sign(message: string, kp: Keypair): string { + return Buffer.from(kp.sign(Buffer.from(message, "utf8"))).toString("base64"); +} + +describe("public prompt status endpoint", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.MODERATOR_ADDRESSES = moderator.publicKey(); + }); + + async function invoke(query: Record) { + let statusCode = 0; + let responseData: Record = {}; + const req = { method: "GET", headers: {}, query, logger: { error: vi.fn() }, requestId: "t" }; + const res = { + status(c: number) { statusCode = c; return this; }, + json(d: Record) { responseData = d; return this; }, + setHeader: vi.fn(), + }; + const handler = (await import("./status")).default; + // @ts-expect-error test handler invocation + await handler(req, res); + return { statusCode, responseData }; + } + + it("requires a promptId", async () => { + const { statusCode } = await invoke({}); + expect(statusCode).toBe(400); + }); + + it("reports active by default", async () => { + const { statusCode, responseData } = await invoke({ promptId: "prompt_active" }); + expect(statusCode).toBe(200); + expect((responseData as any).takenDown).toBe(false); + }); + + it("reflects a takedown set via the data store", async () => { + setPromptModerationState("prompt_down", "taken_down", { reason: "copyright" }); + const state = getPromptModerationState("prompt_down"); + expect(state.status).toBe("taken_down"); + + const { statusCode, responseData } = await invoke({ promptId: "prompt_down" }); + expect(statusCode).toBe(200); + expect((responseData as any).takenDown).toBe(true); + expect((responseData as any).reason).toBe("copyright"); + }); +}); diff --git a/api/moderation/status.ts b/api/moderation/status.ts new file mode 100644 index 00000000..b4f59124 --- /dev/null +++ b/api/moderation/status.ts @@ -0,0 +1,46 @@ +import { negotiateVersion } from "../../src/lib/api/versionGuard"; +import { withVersion } from "../../src/lib/api/payloadVersion"; +import { apiError, ErrorCode } from "../../src/lib/api/errorCodes"; +import { getPromptModerationState } from "./data"; + +/** + * Public, unauthenticated endpoint that lets the frontend know whether a listing + * has been taken down by a moderator. We intentionally only expose the boolean + * state (and the reason category) — never moderator identity or audit detail. + */ +export default async function handler(req: any, res: any) { + if (req.method !== "GET") { + res.status(405).json({ error: "Method not allowed" }); + return; + } + + const version = negotiateVersion(req, res); + if (!version) return; + + const promptId = (req.query.promptId as string) ?? ""; + if (!promptId || !promptId.trim()) { + res.status(400).json( + apiError(ErrorCode.MISSING_FIELDS, "promptId is required", undefined, version), + ); + return; + } + + try { + const state = getPromptModerationState(promptId.trim()); + res.status(200).json( + withVersion( + { + promptId: state.promptId, + takenDown: state.status === "taken_down", + reason: state.status === "taken_down" ? state.reason ?? null : null, + updatedAt: state.updatedAt, + }, + version, + ), + ); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to fetch prompt status"; + console.error("Prompt status error:", message); + res.status(500).json(apiError(ErrorCode.TEMPORARY_FAILURE, message, undefined, version)); + } +} diff --git a/src/components/moderation/AuditLogViewer.tsx b/src/components/moderation/AuditLogViewer.tsx index 821a2a4a..f27a47ee 100644 --- a/src/components/moderation/AuditLogViewer.tsx +++ b/src/components/moderation/AuditLogViewer.tsx @@ -37,9 +37,13 @@ interface AuditLogViewerProps { const ACTION_LABELS: Record = { review_removed: "Review Removed", - prompt_hidden: "Prompt Hidden", - user_warned: "User Warned", review_approved: "Review Approved", + user_warned: "User Warned", + report_resolved: "Report Resolved", + report_dismissed: "Report Dismissed", + prompt_takedown: "Listing Taken Down", + prompt_reinstated: "Listing Reinstated", + prompt_hidden: "Prompt Hidden", prompt_featured: "Prompt Featured", }; diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx new file mode 100644 index 00000000..56dbdff6 --- /dev/null +++ b/src/components/moderation/ModerationQueue.tsx @@ -0,0 +1,425 @@ +import { useState, useEffect, useCallback } from "react"; +import { + Flag, + Search, + Filter, + ChevronLeft, + ChevronRight, + AlertTriangle, + ShieldOff, + ShieldCheck, + CheckCircle2, + XCircle, +} from "lucide-react"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Textarea } from "../ui/textarea"; +import { SkeletonTable } from "../Skeleton"; +import { + fetchModerationQueue, + moderationAction, + REPORT_REASONS, + type AbuseReport, + type ReportPagination, + type ReportReason, + type ReportStatus, + type ReportTargetType, +} from "@/lib/moderation"; +import type { SignMessageFn } from "@/lib/auth/moderatorAuth"; + +const STATUS_LABELS: Record = { + pending: "Pending", + under_review: "Under review", + resolved: "Resolved", + dismissed: "Dismissed", +}; + +const STATUS_COLORS: Record = { + pending: "bg-amber-500/10 text-amber-400 border-amber-500/20", + under_review: "bg-blue-500/10 text-blue-400 border-blue-500/20", + resolved: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", + dismissed: "bg-slate-500/10 text-slate-400 border-slate-500/20", +}; + +const TARGET_COLORS: Record = { + prompt: "bg-blue-500/10 text-blue-400 border-blue-500/20", + review: "bg-amber-500/10 text-amber-400 border-amber-500/20", + user: "bg-purple-500/10 text-purple-400 border-purple-500/20", +}; + +const formatDate = (timestamp: number) => { + try { + return new Date(timestamp).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return "Unknown"; + } +}; + +const formatAddress = (address: string) => { + if (address.length <= 12) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}; + +interface ModerationQueueProps { + moderatorAddress: string; + signMessage?: SignMessageFn; + apiBase?: string; +} + +export const ModerationQueue = ({ + moderatorAddress, + signMessage, + apiBase = "/api/moderation/queue", +}: ModerationQueueProps) => { + const [reports, setReports] = useState([]); + const [pagination, setPagination] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [busyId, setBusyId] = useState(null); + const [page, setPage] = useState(1); + const [filterStatus, setFilterStatus] = useState(""); + const [filterType, setFilterType] = useState(""); + const [filterReason, setFilterReason] = useState(""); + const [search, setSearch] = useState(""); + const [notes, setNotes] = useState>({}); + + const load = useCallback(async () => { + if (!signMessage) { + setError("Wallet does not support message signing — cannot load the queue."); + return; + } + setIsLoading(true); + setError(null); + try { + const { entries, pagination } = await fetchModerationQueue({ + moderatorAddress, + signMessage, + filters: { + status: filterStatus || undefined, + targetType: filterType || undefined, + reason: filterReason || undefined, + search: search || undefined, + page, + }, + apiBase, + }); + setReports(entries); + setPagination(pagination); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load moderation queue"); + setReports([]); + } finally { + setIsLoading(false); + } + }, [moderatorAddress, signMessage, filterStatus, filterType, filterReason, search, page, apiBase]); + + useEffect(() => { + if (moderatorAddress) load(); + }, [load, moderatorAddress]); + + const applyAction = async ( + report: AbuseReport, + action: "report_resolved" | "report_dismissed" | "prompt_takedown" | "prompt_reinstated", + ) => { + if (!signMessage) return; + const reason = (notes[report.id] ?? "").trim() || defaultReason(action, report); + setBusyId(report.id); + setError(null); + try { + if (action === "prompt_takedown" || action === "prompt_reinstated") { + await moderationAction({ + moderatorAddress, + signMessage, + actions: [ + { + action, + targetId: report.targetId, + targetType: "prompt", + reason, + }, + ], + }); + } else { + await moderationAction({ + moderatorAddress, + signMessage, + actions: [ + { + action, + targetId: report.id, + targetType: "report", + reason, + }, + ], + }); + } + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : "Action failed. Please try again."); + } finally { + setBusyId(null); + } + }; + + const handleFilter = () => { + setPage(1); + load(); + }; + + if (!moderatorAddress) { + return ( +
+ +

Connect your wallet to review abuse reports

+
+ ); + } + + return ( +
+
+ +

Abuse Report Queue

+
+ +
+
+ + setSearch(e.target.value)} + placeholder="Search by target, reporter, or details..." + className="pl-10 h-10 rounded-xl border-white/10 bg-white/5 text-sm" + /> +
+ + + + + + + + +
+ + {error && ( +
+ +

{error}

+
+ )} + + {isLoading ? ( + + ) : reports.length === 0 ? ( +
+ +

No reports match the current filters

+
+ ) : ( + <> +
+ {reports.map((report) => { + const open = report.status === "pending" || report.status === "under_review"; + return ( +
+
+
+ + {report.targetType} + + + {STATUS_LABELS[report.status]} + + {formatDate(report.createdAt)} +
+
+ Target: {formatAddress(report.targetId)} +
+
+ +
+
+ Reporter +

{formatAddress(report.reporterAddress)}

+
+
+ Reason +

{report.reason.replace("_", " ")}

+
+
+ + {report.details && ( +

{report.details}

+ )} + +