fix(moderation): expose account hold restoration (#3128)

This commit is contained in:
Patrick Erichsen
2026-07-16 18:52:07 -07:00
committed by GitHub
parent ca88ea2270
commit a7cab2a09d
8 changed files with 241 additions and 0 deletions
@@ -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
+55
View File
@@ -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",
+39
View File
@@ -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);
}
+1
View File
@@ -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]
+15
View File
@@ -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,
+7
View File
@@ -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",