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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/moderation-abuse-reporting-501.md
Original file line number Diff line number Diff line change
@@ -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.
117 changes: 117 additions & 0 deletions api/moderation/actions.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
let statusCode = 0;
let responseData: Record<string, unknown> = {};
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<string, unknown>) { 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");
});
});
111 changes: 99 additions & 12 deletions api/moderation/actions.ts
Original file line number Diff line number Diff line change
@@ -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<Action, TargetType> = {
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" });
Expand All @@ -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 });
}
Expand Down
Loading