mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 17:02:11 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c862dc9f92 |
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user