Compare commits

...
Author SHA1 Message Date
ClawSweeper c862dc9f92 feat(packages): allow owners to delete plugins 2026-05-06 04:39:55 +00:00
5 changed files with 158 additions and 5 deletions
+63 -1
View File
@@ -1158,13 +1158,16 @@ function makeSoftDeletePackageCtx(options?: {
pkg?: Record<string, unknown> | null;
releases?: Array<Record<string, unknown>>;
user?: Record<string, unknown> | null;
publisherMembershipRole?: "owner" | "admin" | "publisher" | null;
}) {
const pkg = options?.pkg ?? makePackageDoc();
const releases = options?.releases ?? [makeReleaseDoc()];
const user = options?.user ?? { _id: "users:owner", role: "user" };
const patch = vi.fn();
const insert = vi.fn();
return {
patch,
insert,
ctx: {
db: {
get: vi.fn(async (id: string) => {
@@ -1186,10 +1189,43 @@ function makeSoftDeletePackageCtx(options?: {
})),
};
}
if (table === "publisherMembers") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(
options?.publisherMembershipRole
? {
_id: "publisherMembers:owner",
publisherId: pkg?.ownerPublisherId,
userId: user?._id,
role: options.publisherMembershipRole,
}
: null,
),
})),
};
}
if (table === "packageSearchDigest") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue({
_id: "packageSearchDigest:demo",
packageId: pkg?._id,
}),
})),
};
}
if (table === "packageCapabilitySearchDigest") {
return {
withIndex: vi.fn(() => ({
collect: vi.fn().mockResolvedValue([]),
})),
};
}
throw new Error(`Unexpected table ${table}`);
}),
patch,
insert: vi.fn(),
insert,
replace: vi.fn(),
delete: vi.fn(),
normalizeId: vi.fn(),
@@ -2154,6 +2190,32 @@ describe("packages public queries", () => {
).rejects.toThrow("Forbidden");
});
it("allows publisher admins to soft-delete packages owned by their publisher", async () => {
const { ctx, patch } = makeSoftDeletePackageCtx({
pkg: makePackageDoc({
ownerUserId: "users:publisher-owner",
ownerPublisherId: "publishers:team",
}),
user: { _id: "users:owner", role: "user" },
publisherMembershipRole: "admin",
});
await expect(
softDeletePackageInternalHandler(ctx, {
userId: "users:owner",
name: "demo-plugin",
}),
).resolves.toMatchObject({ ok: true, alreadyDeleted: false });
expect(patch).toHaveBeenCalledWith(
"packages:demo",
expect.objectContaining({
softDeletedAt: expect.any(Number),
updatedAt: expect.any(Number),
}),
);
});
it("reserves private package placeholders without releases", async () => {
const { ctx, insert } = makeReservePackageNameCtx();
+16 -3
View File
@@ -45,6 +45,7 @@ import {
summarizePackageForSearch,
toConvexSafeJsonValue,
} from "./lib/packageRegistry";
import { extractPackageDigestFields, upsertPackageSearchDigest } from "./lib/packageSearchDigest";
import { isPackageBlockedFromPublic, resolvePackageReleaseScanStatus } from "./lib/packageSecurity";
import { toPublicPublisher } from "./lib/public";
import {
@@ -2106,8 +2107,15 @@ export const softDeletePackageInternal = internalMutation({
const pkg = await getPackageByNormalizedName(ctx, normalizedName);
if (!pkg) throw new Error("Package not found");
if (pkg.ownerUserId !== args.userId) {
assertModerator(user);
if (user.role === "moderator" || user.role === "admin") {
// Staff can moderate packages outside their own publisher memberships.
} else {
await assertCanManageOwnedResource(ctx, {
actor: user,
ownerUserId: pkg.ownerUserId,
ownerPublisherId: pkg.ownerPublisherId,
allowedPublisherRoles: ["admin"],
});
}
if (pkg.softDeletedAt) {
@@ -2131,9 +2139,14 @@ export const softDeletePackageInternal = internalMutation({
releaseCount += 1;
}
await ctx.db.patch(pkg._id, {
const packagePatch = {
softDeletedAt: now,
updatedAt: now,
};
await ctx.db.patch(pkg._id, packagePatch);
await upsertPackageSearchDigest(ctx, {
...extractPackageDigestFields(pkg),
...packagePatch,
});
return {
+11
View File
@@ -23,6 +23,7 @@ import { cmdMergeSkill, cmdRenameSkill } from "./cli/commands/ownership.js";
import {
cmdBackfillPackageArtifacts,
cmdAppealPackage,
cmdDeletePackage,
cmdDownloadPackage,
cmdExplorePackages,
cmdGetPackageTrustedPublisher,
@@ -473,6 +474,16 @@ registerCommand(packageCmd, ["package", "verify"])
});
});
registerCommand(packageCmd, ["package", "delete"])
.description("Soft-delete an owned package/plugin")
.argument("<name>", "Package name")
.option("--yes", "Skip confirmation")
.option("--json", "Output JSON")
.action(async (name, options) => {
const opts = await resolveGlobalOpts();
await cmdDeletePackage(opts, name, options, isInputAllowed());
});
registerCommand(packageCmd, ["package", "moderate"], "moderator")
.description("Set package release moderation state")
.argument("<name>", "Package name")
@@ -29,6 +29,7 @@ vi.mock("../ui.js", () => uiMocks.moduleFactory());
const {
cmdDeletePackageTrustedPublisher,
cmdDeletePackage,
cmdAppealPackage,
cmdDownloadPackage,
cmdExplorePackages,
@@ -518,6 +519,26 @@ describe("package commands", () => {
}
});
it("soft-deletes an owned package through the v1 package API", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true });
await expect(
cmdDeletePackage(makeOpts(), "@team/demo-plugin", { yes: true }, false),
).resolves.toEqual({ ok: true });
expect(authTokenMocks.requireAuthToken).toHaveBeenCalledOnce();
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
expect.objectContaining({
method: "DELETE",
path: "/api/v1/packages/%40team%2Fdemo-plugin",
token: "tkn",
}),
expect.anything(),
);
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith("OK. Deleted @team/demo-plugin");
});
it("sets package release moderation state", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
ok: true,
+47 -1
View File
@@ -31,8 +31,10 @@ import {
ApiV1PackageTrustedPublisherResponseSchema,
ApiV1PackageVersionListResponseSchema,
ApiV1PackageVersionResponseSchema,
ApiV1DeleteResponseSchema,
ApiV1PublishTokenMintResponseSchema,
normalizeOpenClawExternalPluginCompatibility,
parseArk,
type PackageArtifactSummary,
type PackageAppealListStatus,
type PackageAppealStatus,
@@ -54,7 +56,7 @@ import { getOptionalAuthToken, requireAuthToken } from "../authToken.js";
import { getRegistry } from "../registry.js";
import { titleCase } from "../slug.js";
import type { GlobalOpts } from "../types.js";
import { createSpinner, fail, formatError } from "../ui.js";
import { createSpinner, fail, formatError, isInteractive, promptConfirm } from "../ui.js";
import {
fetchGitHubSource,
normalizeGitHubRepo,
@@ -131,6 +133,11 @@ type PackageDownloadOptions = {
json?: boolean;
};
type PackageDeleteOptions = {
yes?: boolean;
json?: boolean;
};
type PackageVerifyOptions = {
packageName?: string;
version?: string;
@@ -1006,6 +1013,45 @@ export async function cmdVerifyPackage(
}
}
export async function cmdDeletePackage(
opts: GlobalOpts,
nameArg: string,
options: PackageDeleteOptions,
inputAllowed: boolean,
) {
const name = nameArg.trim();
if (!name) fail("Package name required");
const allowPrompt = isInteractive() && inputAllowed !== false;
if (!options.yes) {
if (!allowPrompt) fail("Pass --yes (no input)");
const ok = await promptConfirm(`Delete package ${name}? (soft delete, owner/moderator/admin)`);
if (!ok) return undefined;
}
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const spinner = createSpinner(`Deleting ${name}`);
try {
const result = await apiRequest(
registry,
{
method: "DELETE",
path: `${ApiRoutes.packages}/${encodeURIComponent(name)}`,
token,
},
ApiV1DeleteResponseSchema,
);
spinner.succeed(`OK. Deleted ${name}`);
const parsed = parseArk(ApiV1DeleteResponseSchema, result, "Package delete response");
if (options.json) console.log(JSON.stringify(parsed, null, 2));
return parsed;
} catch (error) {
spinner.fail(formatError(error));
throw error;
}
}
export async function cmdModeratePackageRelease(
opts: GlobalOpts,
packageName: string,