mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
fix(moderation): expose account hold restoration (#3128)
This commit is contained in:
@@ -81,6 +81,7 @@ Pass `--owner <handle>` to `revoke-version` when more than one publisher uses th
|
||||
```text
|
||||
ban <handleOrId>
|
||||
unban <handleOrId>
|
||||
lift-moderation-hold <handleOrId>
|
||||
set-role <handleOrId> <role>
|
||||
reclassify-ban <handleOrId>
|
||||
remediate-autobans
|
||||
@@ -91,6 +92,7 @@ Examples:
|
||||
```sh
|
||||
bun run admin -- users ban <handleOrId> --reason "<reason>" --yes
|
||||
bun run admin -- users unban <handleOrId> --reason "<reason>" --yes
|
||||
bun run admin -- users lift-moderation-hold <handleOrId> --reason "<reason>" --yes
|
||||
bun run admin -- users set-role <handleOrId> <user|moderator|admin> --yes
|
||||
bun run admin -- users reclassify-ban <handleOrId> --reason "<reason>" --apply --yes
|
||||
bun run admin -- users remediate-autobans --apply --reason "<reason>"
|
||||
@@ -214,6 +216,8 @@ only after admin auth succeeds.
|
||||
hides owned skills, soft-deletes comments, and writes audit logs.
|
||||
- `users unban` is admin-only. It clears ban state and restores skills that were
|
||||
hidden by the matching ban flow; revoked API tokens stay revoked.
|
||||
- `users lift-moderation-hold` is admin-only. It clears the account-level
|
||||
moderation hold, restores skills hidden by that hold, and writes an audit log.
|
||||
- `packages transfer` preserves the package row, stats, releases, and history;
|
||||
it changes the owner publisher.
|
||||
- `org delete` soft-deletes an empty org publisher and retains member rows for
|
||||
|
||||
@@ -8433,6 +8433,61 @@ describe("httpApiV1 handlers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lift moderation hold requires admin", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:actor",
|
||||
user: { _id: "users:actor", role: "user" },
|
||||
} as never);
|
||||
const runMutation = vi.fn().mockResolvedValue(okRate());
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/users/lift-moderation-hold", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userId: "users:target", reason: "false positive" }),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("lift moderation hold forwards actor, target, and reason", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:actor",
|
||||
user: { _id: "users:actor", role: "admin" },
|
||||
} as never);
|
||||
const runMutation = vi.fn().mockResolvedValueOnce(okRate()).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
alreadyCleared: false,
|
||||
restoredSkills: 1,
|
||||
scheduledSkills: false,
|
||||
});
|
||||
const response = await __handlers.usersPostRouterV1Handler(
|
||||
makeCtx({ runMutation }),
|
||||
new Request("https://example.com/api/v1/users/lift-moderation-hold", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId: "users:target",
|
||||
reason: "Issue #3008 false positive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
alreadyCleared: false,
|
||||
restoredSkills: 1,
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
actorUserId: "users:actor",
|
||||
targetUserId: "users:target",
|
||||
reason: "Issue #3008 false positive",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reclassify ban requires admin", async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValue({
|
||||
userId: "users:actor",
|
||||
|
||||
@@ -28,6 +28,7 @@ const usersV1InternalRefs = internal as unknown as {
|
||||
users: {
|
||||
getBanAppealContextByGitHubProviderAccountIdInternal: unknown;
|
||||
getByHandleInternal: unknown;
|
||||
liftModerationHoldInternal: unknown;
|
||||
recordStaffEmailAttemptAuditInternal: unknown;
|
||||
recordStaffEmailSentAuditInternal: unknown;
|
||||
reclassifyBanInternal: unknown;
|
||||
@@ -86,6 +87,7 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
if (
|
||||
action !== "ban" &&
|
||||
action !== "unban" &&
|
||||
action !== "lift-moderation-hold" &&
|
||||
action !== "role" &&
|
||||
action !== "reclassify-ban" &&
|
||||
action !== "ban-appeal-unban" &&
|
||||
@@ -121,6 +123,11 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
return handleAdminReclassifyBan(ctx, payload, actorUserId, rate.headers);
|
||||
}
|
||||
|
||||
if (action === "lift-moderation-hold") {
|
||||
const admin = requireAdminOrResponse(actorUser, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
}
|
||||
|
||||
if (action === "reclaim") {
|
||||
const admin = requireAdminOrResponse(actorUser, rate.headers);
|
||||
if (!admin.ok) return admin.response;
|
||||
@@ -248,6 +255,38 @@ export async function usersPostRouterV1Handler(ctx: ActionCtx, request: Request)
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "lift-moderation-hold") {
|
||||
const reason = reasonRaw.length > 0 ? reasonRaw : undefined;
|
||||
if (!reason) {
|
||||
return text("Missing reason", 400, rate.headers);
|
||||
}
|
||||
if (reason.length > 500) {
|
||||
return text("Reason too long (max 500 chars)", 400, rate.headers);
|
||||
}
|
||||
try {
|
||||
const result = await runUsersV1MutationRef<{
|
||||
ok: true;
|
||||
alreadyCleared: boolean;
|
||||
restoredSkills: number;
|
||||
scheduledSkills: boolean;
|
||||
}>(ctx, usersV1InternalRefs.users.liftModerationHoldInternal, {
|
||||
actorUserId,
|
||||
targetUserId,
|
||||
reason,
|
||||
});
|
||||
return json(result, 200, rate.headers);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Moderation hold lift failed";
|
||||
if (message.toLowerCase().includes("forbidden")) {
|
||||
return text("Forbidden", 403, rate.headers);
|
||||
}
|
||||
if (message.toLowerCase().includes("not found")) {
|
||||
return text(message, 404, rate.headers);
|
||||
}
|
||||
return text(message, 400, rate.headers);
|
||||
}
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
return text("Invalid role", 400, rate.headers);
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ User administration:
|
||||
```bash
|
||||
bun run admin -- users ban <handleOrId> [--id] [--fuzzy] [--reason <text>] [--yes]
|
||||
bun run admin -- users unban <handleOrId> [--id] [--fuzzy] [--reason <text>] [--yes]
|
||||
bun run admin -- users lift-moderation-hold <handleOrId> --reason <text> [--id] [--fuzzy] [--yes] [--json]
|
||||
bun run admin -- users set-role <handleOrId> <user|moderator|admin> [--id] [--fuzzy] [--yes]
|
||||
bun run admin -- users reclassify-ban <handleOrId> --reason <text> [--id] [--fuzzy] [--dry-run|--apply] [--yes] [--json]
|
||||
bun run admin -- users recover-publisher <handle> --to <handle> --previous-github-id <id> --next-github-id <id> --reason <text> [--retired-handle <handle>] [--verified] [--apply] [--yes] [--json]
|
||||
|
||||
@@ -30,6 +30,7 @@ import { cmdSendStaffEmail } from "./commands/email.js";
|
||||
import { cmdSetPackageFeatured, cmdSetSkillFeatured } from "./commands/featured.js";
|
||||
import {
|
||||
cmdBanUser,
|
||||
cmdLiftModerationHold,
|
||||
cmdRecoverPersonalPublisher,
|
||||
cmdReclassifyBan,
|
||||
cmdRepairVtPendingSkills,
|
||||
@@ -246,6 +247,20 @@ users
|
||||
await cmdUnbanUser(opts, handleOrId, options, isInputAllowed());
|
||||
});
|
||||
|
||||
users
|
||||
.command("lift-moderation-hold")
|
||||
.description("Lift an account moderation hold and restore eligible skills")
|
||||
.argument("<handleOrId>", "User handle (default) or user id")
|
||||
.option("--id", "Treat argument as user id")
|
||||
.option("--fuzzy", "Resolve handle via fuzzy user search")
|
||||
.requiredOption("--reason <reason>", "Audit reason")
|
||||
.option("--yes", "Skip confirmation")
|
||||
.option("--json", "Output JSON")
|
||||
.action(async (handleOrId, options) => {
|
||||
const opts = await resolveGlobalOpts();
|
||||
await cmdLiftModerationHold(opts, handleOrId, options, isInputAllowed());
|
||||
});
|
||||
|
||||
users
|
||||
.command("set-role")
|
||||
.description("Change a user role")
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock("../../../clawhub/src/cli/ui.js", () => uiMocks.moduleFactory());
|
||||
|
||||
const {
|
||||
cmdBanUser,
|
||||
cmdLiftModerationHold,
|
||||
cmdRecoverPersonalPublisher,
|
||||
cmdReclassifyBan,
|
||||
cmdRepairVtPendingSkills,
|
||||
@@ -939,6 +940,52 @@ describe("cmdUnbanUser", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdLiftModerationHold", () => {
|
||||
it("requires an audit reason", async () => {
|
||||
await expect(
|
||||
cmdLiftModerationHold(makeGlobalOpts(), "demo", { yes: true }, false),
|
||||
).rejects.toThrow(/--reason required/i);
|
||||
});
|
||||
|
||||
it("requires --yes when input is disabled", async () => {
|
||||
await expect(
|
||||
cmdLiftModerationHold(makeGlobalOpts(), "demo", { reason: "false positive" }, false),
|
||||
).rejects.toThrow(/--yes/i);
|
||||
});
|
||||
|
||||
it("posts user id and reason", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
alreadyCleared: false,
|
||||
restoredSkills: 1,
|
||||
scheduledSkills: false,
|
||||
});
|
||||
await cmdLiftModerationHold(
|
||||
makeGlobalOpts(),
|
||||
"users_123",
|
||||
{
|
||||
id: true,
|
||||
reason: "Issue #3008 false positive",
|
||||
yes: true,
|
||||
json: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
path: "/api/v1/users/lift-moderation-hold",
|
||||
body: {
|
||||
userId: "users_123",
|
||||
reason: "Issue #3008 false positive",
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cmdReclassifyBan", () => {
|
||||
it("defaults to dry run and posts handle payload", async () => {
|
||||
httpMocks.apiRequest.mockResolvedValueOnce({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { apiRequest, registryUrl } from "../../../clawhub/src/http.js";
|
||||
import {
|
||||
ApiRoutes,
|
||||
ApiV1BanUserResponseSchema,
|
||||
ApiV1LiftModerationHoldResponseSchema,
|
||||
ApiV1PublisherRecoveryResponseSchema,
|
||||
ApiV1ReclassifyBanResponseSchema,
|
||||
ApiV1SetRoleResponseSchema,
|
||||
@@ -201,6 +202,78 @@ export async function cmdUnbanUser(
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdLiftModerationHold(
|
||||
opts: GlobalOpts,
|
||||
identifierArg: string,
|
||||
options: {
|
||||
yes?: boolean;
|
||||
id?: boolean;
|
||||
fuzzy?: boolean;
|
||||
reason?: string;
|
||||
json?: boolean;
|
||||
},
|
||||
inputAllowed: boolean,
|
||||
) {
|
||||
const raw = identifierArg.trim();
|
||||
if (!raw) fail("Handle or user id required");
|
||||
|
||||
const reason = options.reason?.trim();
|
||||
if (!reason) fail("--reason required");
|
||||
if (reason.length > 500) fail("--reason must be 500 characters or fewer");
|
||||
|
||||
const token = await requireAuthToken();
|
||||
const registry = await getRegistry(opts, { cache: true });
|
||||
const allowPrompt = isInteractive() && inputAllowed !== false;
|
||||
const resolved = await resolveUserIdentifier(
|
||||
registry,
|
||||
token,
|
||||
raw,
|
||||
{ id: options.id, fuzzy: options.fuzzy },
|
||||
allowPrompt,
|
||||
);
|
||||
if (!resolved) return undefined;
|
||||
if (!options.yes) {
|
||||
if (!allowPrompt) fail("Pass --yes (no input)");
|
||||
const ok = await promptConfirm(
|
||||
`Lift the moderation hold for ${resolved.label}? This restores eligible skills.`,
|
||||
);
|
||||
if (!ok) return undefined;
|
||||
}
|
||||
|
||||
const spinner = options.json
|
||||
? null
|
||||
: createCrabLoader(`Lifting moderation hold for ${resolved.label}`);
|
||||
try {
|
||||
const result = await apiRequest(
|
||||
registry,
|
||||
{
|
||||
method: "POST",
|
||||
path: `${ApiRoutes.users}/lift-moderation-hold`,
|
||||
token,
|
||||
body: resolved.userId
|
||||
? { userId: resolved.userId, reason }
|
||||
: { handle: resolved.handle, reason },
|
||||
},
|
||||
ApiV1LiftModerationHoldResponseSchema,
|
||||
);
|
||||
const parsed = parseArk(
|
||||
ApiV1LiftModerationHoldResponseSchema,
|
||||
result,
|
||||
"Lift moderation hold response",
|
||||
);
|
||||
spinner?.succeed(
|
||||
parsed.alreadyCleared
|
||||
? `OK. ${resolved.label} had no moderation hold`
|
||||
: `OK. Lifted moderation hold for ${resolved.label} (${formatRestoredSkills(parsed.restoredSkills)})`,
|
||||
);
|
||||
if (options.json) process.stdout.write(`${JSON.stringify(parsed, null, 2)}\n`);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
spinner?.fail(formatError(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cmdSetRole(
|
||||
opts: GlobalOpts,
|
||||
identifierArg: string,
|
||||
|
||||
@@ -965,6 +965,13 @@ export const ApiV1UnbanUserResponseSchema = type({
|
||||
restoredSkills: "number?",
|
||||
});
|
||||
|
||||
export const ApiV1LiftModerationHoldResponseSchema = type({
|
||||
ok: "true",
|
||||
alreadyCleared: "boolean",
|
||||
restoredSkills: "number",
|
||||
scheduledSkills: "boolean",
|
||||
});
|
||||
|
||||
export const ApiV1ReclassifyBanResponseSchema = type({
|
||||
ok: "true",
|
||||
dryRun: "boolean",
|
||||
|
||||
Reference in New Issue
Block a user