feat: add audited admin skill hard delete (#3167)

This commit is contained in:
Patrick Erichsen
2026-07-17 17:33:50 -07:00
committed by GitHub
parent efa3dc7af7
commit 43c079e434
15 changed files with 764 additions and 3 deletions
+9 -1
View File
@@ -55,6 +55,7 @@ skills|skill
`bun run admin -- skills --help` exposes:
```text
hard-delete <skill>
unhide <slug>
revoke-version <slug>
rescan <slug>
@@ -65,6 +66,8 @@ triage-report <report-id>
Examples:
```sh
bun run admin -- skills hard-delete @owner/<slug> --reason "<reason>" # dry-run
bun run admin -- skills hard-delete @owner/<slug> --reason "<reason>" --apply --confirm "<token>" --yes
bun run admin -- skills unhide <slug> --reason "<reason>" --yes
bun run admin -- skills revoke-version <slug> --version <version> --reason "<reason>" --yes
bun run admin -- skills rescan <slug> --reason "<reason>" --yes
@@ -72,7 +75,11 @@ bun run admin -- skills reports --status open
bun run admin -- skills triage-report <report-id> --status confirmed --action hide --note "<note>" --yes
```
Pass `--owner <handle>` to `revoke-version` when more than one publisher uses the same slug.
`hard-delete` requires an owner-qualified ref and defaults to a dry-run. Apply
only with the exact confirmation token returned by that dry-run.
Pass `--owner <handle>` to `revoke-version` when more than one publisher uses
the same slug.
### Users
@@ -193,6 +200,7 @@ only after admin auth succeeds.
## Verification
- For skills, inspect the page/API status after `skills unhide`.
- For `skills hard-delete`, verify owner-scoped page/API reads return not found.
- For users, prefer user search/admin surfaces for target accounts where
available.
- For orgs and packages, use the public publisher/plugin pages and the relevant
+80
View File
@@ -7366,6 +7366,86 @@ describe("httpApiV1 handlers", () => {
expect(await response.text()).toBe(message);
});
it("skill hard-delete dry-runs through the admin-only API", async () => {
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:1";
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:admin",
user: { _id: "users:admin", role: "admin" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
return {
ok: true,
skillId: "skills:1",
slug: "demo",
ownerHandle: "openclaw",
displayName: "Demo",
dryRun: true,
scheduled: false,
confirmationToken: generated_token_reference,
};
});
const response = await __handlers.skillsPostRouterV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/skills/demo/hard-delete", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
body: JSON.stringify({
ownerHandle: "openclaw",
reason: "Owner-requested cleanup",
dryRun: true,
}),
}),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
ok: true,
dryRun: true,
scheduled: false,
confirmationToken: generated_token_reference,
});
expect(runMutation).toHaveBeenCalledWith(
(internal as unknown as { skills: Record<string, unknown> }).skills
.hardDeleteForAdminInternal,
{
actorUserId: "users:admin",
slug: "demo",
ownerHandle: "openclaw",
reason: "Owner-requested cleanup",
dryRun: true,
},
);
});
it("skill hard-delete forbids non-admin API tokens", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:moderator",
user: { _id: "users:moderator", role: "moderator" },
} as never);
const runMutation = vi.fn(async (_mutation: unknown, args: Record<string, unknown>) => {
if (isRateLimitArgs(args)) return okRate();
throw new Error("should not hard-delete");
});
const response = await __handlers.skillsPostRouterV1Handler(
makeCtx({ runMutation }),
new Request("https://example.com/api/v1/skills/demo/hard-delete", {
method: "POST",
headers: { Authorization: "Bearer clh_test" },
body: JSON.stringify({
ownerHandle: "openclaw",
reason: "Owner-requested cleanup",
}),
}),
);
expect(response.status).toBe(403);
await expect(response.text()).resolves.toBe("Admin role required.");
expect(runMutation).toHaveBeenCalledTimes(1);
});
it("skill rescan enqueues owner-authorized ClawScan jobs", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:moderator",
+43
View File
@@ -2,6 +2,7 @@ import {
ApiRoutes,
ApiV1SkillBulkRescanBatchRequestSchema,
ApiV1SkillBulkRescanStatusRequestSchema,
ApiV1SkillHardDeleteRequestSchema,
ApiV1SkillRepairVtPendingRequestSchema,
ApiV1SkillScanBatchRequestSchema,
ApiV1SkillScanBatchStatusRequestSchema,
@@ -353,6 +354,7 @@ const internalRefs = internal as unknown as {
getSkillBySlugInternal: unknown;
getVersionByIdInternal: unknown;
getVersionBySkillAndVersionInternal: unknown;
hardDeleteForAdminInternal: unknown;
reportSkillForUserInternal: unknown;
listSkillReportsInternal: unknown;
triageSkillReportForUserInternal: unknown;
@@ -3043,6 +3045,38 @@ export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request
}
}
if (segments.length === 2 && action === "hard-delete") {
if (!slug) return text("Slug required", 400, rate.headers);
const auth = await requireApiTokenUserOrResponse(ctx, request, rate.headers);
if (!auth.ok) return auth.response;
const admin = requireAdminOrResponse(auth.user, rate.headers);
if (!admin.ok) return admin.response;
try {
const payload = parseArk(
ApiV1SkillHardDeleteRequestSchema,
await request.json(),
"Skill hard-delete payload",
) as {
ownerHandle: string;
reason: string;
dryRun?: boolean;
confirmationToken?: string;
};
const result = await runMutationRef(ctx, internalRefs.skills.hardDeleteForAdminInternal, {
actorUserId: auth.userId,
slug,
ownerHandle: payload.ownerHandle,
reason: payload.reason,
...(payload.dryRun !== undefined ? { dryRun: payload.dryRun } : {}),
...(payload.confirmationToken ? { confirmationToken: payload.confirmationToken } : {}),
});
return json(result, 200, rate.headers);
} catch (error) {
if (error instanceof SyntaxError) return text("Invalid JSON", 400, rate.headers);
return skillHardDeleteErrorToResponse(error, rate.headers);
}
}
if (
segments[0] === "-" &&
segments[1] === "reports" &&
@@ -3286,6 +3320,15 @@ function skillVersionModerationErrorToResponse(error: unknown, headers: HeadersI
return text(message, 400, headers);
}
function skillHardDeleteErrorToResponse(error: unknown, headers: HeadersInit) {
const message = error instanceof Error ? error.message : "Skill hard delete failed";
const lower = message.toLowerCase();
if (lower.includes("unauthorized")) return text(message, 401, headers);
if (lower.includes("forbidden")) return text(message, 403, headers);
if (lower.includes("not found")) return text(message, 404, headers);
return text(message, 400, headers);
}
function skillRescanErrorToResponse(error: unknown, headers: HeadersInit) {
const message = error instanceof Error ? error.message : "Skill rescan failed";
const lower = message.toLowerCase();
+188
View File
@@ -0,0 +1,188 @@
/* @vitest-environment node */
import { describe, expect, it, vi } from "vitest";
import { hardDeleteForAdminInternal } from "./skills";
type HardDeleteForAdminArgs = {
actorUserId: string;
slug: string;
ownerHandle: string;
reason: string;
dryRun?: boolean;
confirmationToken?: string;
};
type HardDeleteForAdminResult = {
ok: true;
skillId: string;
slug: string;
ownerHandle: string;
displayName: string;
dryRun: boolean;
scheduled: boolean;
confirmationToken: string;
};
type WrappedHandler<TArgs, TResult> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
};
const hardDeleteForAdminHandler = (
hardDeleteForAdminInternal as unknown as WrappedHandler<
HardDeleteForAdminArgs,
HardDeleteForAdminResult
>
)._handler;
function makeCtx() {
const skill = {
_id: "skills:demo",
slug: "demo",
displayName: "Demo",
ownerUserId: "users:owner",
ownerPublisherId: "publishers:openclaw",
softDeletedAt: 1_000,
hiddenAt: 1_000,
hiddenBy: "users:moderator",
moderationStatus: "removed",
};
const insert = vi.fn();
const scheduler = { runAfter: vi.fn() };
const query = vi.fn((table: string) => {
if (table === "publishers") {
return {
withIndex: () => ({
unique: async () => ({
_id: "publishers:openclaw",
kind: "user",
handle: "openclaw",
linkedUserId: "users:owner",
}),
}),
};
}
if (table === "skills") {
return {
withIndex: () => ({
unique: async () => skill,
}),
};
}
if (table === "skillVersions") {
return {
withIndex: () => ({
take: async () => [],
}),
};
}
throw new Error(`Unexpected table ${table}`);
});
const ctx = {
db: {
get: vi.fn(async (id: string) =>
id === "users:admin"
? {
_id: "users:admin",
role: "admin",
deletedAt: undefined,
deactivatedAt: undefined,
}
: null,
),
insert,
patch: vi.fn(),
replace: vi.fn(),
delete: vi.fn(),
query,
normalizeId: vi.fn(),
},
scheduler,
} as never;
return { ctx, insert, scheduler };
}
const baseArgs = {
actorUserId: "users:admin",
slug: "demo",
ownerHandle: "openclaw",
reason: "Owner-requested cleanup",
};
describe("hardDeleteForAdminInternal", () => {
it("returns an exact confirmation token without mutating during dry-run", async () => {
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:demo";
const { ctx, insert, scheduler } = makeCtx();
const result = await hardDeleteForAdminHandler(ctx, baseArgs);
expect(result).toEqual({
ok: true,
skillId: "skills:demo",
slug: "demo",
ownerHandle: "openclaw",
displayName: "Demo",
dryRun: true,
scheduled: false,
confirmationToken: generated_token_reference,
});
expect(insert).not.toHaveBeenCalled();
expect(scheduler.runAfter).not.toHaveBeenCalled();
});
it("rejects apply without the exact dry-run confirmation token", async () => {
const { ctx, insert, scheduler } = makeCtx();
await expect(
hardDeleteForAdminHandler(ctx, {
...baseArgs,
dryRun: false,
confirmationToken: "wrong",
}),
).rejects.toThrow('Confirmation token must be "hard-delete-skill:@openclaw/demo:skills:demo"');
expect(insert).not.toHaveBeenCalled();
expect(scheduler.runAfter).not.toHaveBeenCalled();
});
it("audits the reason and schedules the existing batched cleanup", async () => {
const { ctx, insert, scheduler } = makeCtx();
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:demo";
const result = await hardDeleteForAdminHandler(ctx, {
...baseArgs,
dryRun: false,
confirmationToken: generated_token_reference,
});
expect(result).toMatchObject({
dryRun: false,
scheduled: true,
confirmationToken: generated_token_reference,
});
expect(insert).toHaveBeenCalledWith(
"auditLogs",
expect.objectContaining({
actorUserId: "users:admin",
action: "skill.hard_delete.requested",
targetType: "skill",
targetId: "skills:demo",
metadata: {
slug: "demo",
ownerHandle: "openclaw",
reason: "Owner-requested cleanup",
source: "clawhub-admin",
},
}),
);
expect(scheduler.runAfter).toHaveBeenCalledWith(
0,
expect.anything(),
expect.objectContaining({
skillId: "skills:demo",
actorUserId: "users:admin",
phase: "fingerprints",
source: "admin",
reason: "Owner-requested cleanup",
}),
);
});
});
+86 -1
View File
@@ -1615,6 +1615,7 @@ type HardDeleteSource = "admin" | "account.delete" | "publisher.delete";
type HardDeleteScope = {
source?: HardDeleteSource;
ownerPublisherId?: Id<"publishers">;
reason?: string;
};
const hardDeleteSourceValidator = v.optional(
@@ -1639,6 +1640,7 @@ async function scheduleHardDelete(
phase,
source: scope.source,
ownerPublisherId: scope.ownerPublisherId,
reason: scope.reason,
});
}
@@ -1916,7 +1918,11 @@ async function hardDeleteSkillStep(
action: "skill.hard_delete",
targetType: "skill",
targetId: skill._id,
metadata: { slug: skill.slug },
metadata: {
slug: skill.slug,
source: scope.source ?? "admin",
...(scope.reason ? { reason: scope.reason } : {}),
},
createdAt: now,
});
return;
@@ -11925,6 +11931,83 @@ export const hardDelete = mutation({
},
});
export const hardDeleteForAdminInternal = internalMutation({
args: {
actorUserId: v.id("users"),
slug: v.string(),
ownerHandle: v.string(),
reason: v.string(),
dryRun: v.optional(v.boolean()),
confirmationToken: v.optional(v.string()),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) throw new ConvexError("Unauthorized");
assertAdmin(actor);
const slug = normalizeSkillSlugKey(args.slug);
const ownerHandle = normalizePublisherHandle(args.ownerHandle);
const reason = args.reason.trim();
if (!slug) throw new ConvexError("Slug required");
if (!ownerHandle) throw new ConvexError("Owner handle required");
if (!reason) throw new ConvexError("Reason is required");
if (reason.length > 500) throw new ConvexError("Reason too long (max 500 chars)");
const resolved = await resolveSkillBySlugOrAliasForOwner(ctx, slug, ownerHandle, {
includeSoftDeleted: true,
});
const skill = resolved.skill;
if (!skill) throw new ConvexError("Skill not found");
const generated_token_reference = `hard-delete-skill:@${ownerHandle}/${skill.slug}:${skill._id}`;
const baseResult = {
ok: true as const,
skillId: skill._id,
slug: skill.slug,
ownerHandle,
displayName: skill.displayName,
confirmationToken: generated_token_reference,
};
const dryRun = args.dryRun !== false;
if (dryRun) {
return {
...baseResult,
dryRun: true,
scheduled: false,
};
}
if (args.confirmationToken !== generated_token_reference) {
throw new ConvexError(`Confirmation token must be "${generated_token_reference}"`);
}
const now = Date.now();
await ctx.db.insert("auditLogs", {
actorUserId: args.actorUserId,
action: "skill.hard_delete.requested",
targetType: "skill",
targetId: skill._id,
metadata: {
slug: skill.slug,
ownerHandle,
reason,
source: "clawhub-admin",
},
createdAt: now,
});
await hardDeleteSkillStep(ctx, skill, args.actorUserId, "versions", {
source: "admin",
reason,
});
return {
...baseResult,
dryRun: false,
scheduled: true,
};
},
});
export const hardDeleteInternal = internalMutation({
args: {
skillId: v.id("skills"),
@@ -11932,6 +12015,7 @@ export const hardDeleteInternal = internalMutation({
phase: v.optional(v.string()),
source: hardDeleteSourceValidator,
ownerPublisherId: v.optional(v.id("publishers")),
reason: v.optional(v.string()),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId);
@@ -11965,6 +12049,7 @@ export const hardDeleteInternal = internalMutation({
await hardDeleteSkillStep(ctx, skill, args.actorUserId, phase, {
source,
ownerPublisherId: args.ownerPublisherId,
reason: args.reason,
});
},
});
+1
View File
@@ -116,6 +116,7 @@ Package moderation and operations:
bun run admin -- skills reports [--status open|confirmed|dismissed|all]
bun run admin -- skills feature <slug|@owner/slug> [--json]
bun run admin -- skills unfeature <slug|@owner/slug> [--json]
bun run admin -- skills hard-delete @owner/slug --reason <text> [--apply --confirm <token> --yes] [--json]
bun run admin -- skills rescan <slug> [--version <version>] [--yes] [--json]
bun run admin -- skills unhide <slug> --reason <text> [--yes]
bun run admin -- skills triage-report <report-id> --status open|confirmed|dismissed [--note <text>] [--action none|hide] [--yes]
+15
View File
@@ -69,6 +69,7 @@ import {
cmdSetPromotionStatus,
cmdUpdatePromotion,
} from "./commands/promotions.js";
import { cmdHardDeleteSkill } from "./commands/skills.js";
const program = new Command()
.name("clawhub-admin")
@@ -793,6 +794,20 @@ function registerPluginOperations(command: Command) {
function registerSkillModerationCommands(command: Command) {
registerFeaturedCommands(command, "skill");
command
.command("hard-delete")
.description("Permanently delete one owner-qualified skill and all related history")
.argument("<skill>", "Owner-qualified skill ref: @owner/slug")
.requiredOption("--reason <reason>", "Audit reason")
.option("--apply", "Schedule deletion; defaults to dry-run")
.option("--confirm <token>", "Confirmation token returned by the dry-run")
.option("--yes", "Skip confirmation for --apply")
.option("--json", "Output JSON")
.action(async (skill, options) => {
const opts = await resolveGlobalOpts();
await cmdHardDeleteSkill(opts, skill, options, isInputAllowed());
});
command
.command("revoke-version")
.description("Permanently remove one skill version from public access")
@@ -0,0 +1,131 @@
/* @vitest-environment node */
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createAuthTokenModuleMocks,
createHttpModuleMocks,
createRegistryModuleMocks,
createUiModuleMocks,
makeGlobalOpts,
} from "../../../clawhub/test/cliCommandTestKit.js";
const authTokenMocks = createAuthTokenModuleMocks();
const registryMocks = createRegistryModuleMocks();
const httpMocks = createHttpModuleMocks();
const uiMocks = createUiModuleMocks();
vi.mock("../../../clawhub/src/cli/authToken.js", () => authTokenMocks.moduleFactory());
vi.mock("../../../clawhub/src/cli/registry.js", () => registryMocks.moduleFactory());
vi.mock("../../../clawhub/src/http.js", () => httpMocks.moduleFactory());
vi.mock("../../../clawhub/src/cli/ui.js", () => uiMocks.moduleFactory());
const { cmdHardDeleteSkill } = await import("./skills");
afterEach(() => {
vi.clearAllMocks();
});
describe("cmdHardDeleteSkill", () => {
it("dry-runs an owner-qualified skill by default", async () => {
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:demo";
httpMocks.apiRequest.mockResolvedValueOnce({
ok: true,
skillId: "skills:demo",
slug: "demo",
ownerHandle: "openclaw",
displayName: "Demo",
dryRun: true,
scheduled: false,
confirmationToken: generated_token_reference,
});
const result = await cmdHardDeleteSkill(
makeGlobalOpts(),
"@OpenClaw/Demo",
{ reason: "Owner-requested cleanup", json: true },
false,
);
expect(result).toMatchObject({
dryRun: true,
scheduled: false,
ownerHandle: "openclaw",
slug: "demo",
});
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
expect.objectContaining({
method: "POST",
path: "/api/v1/skills/demo/hard-delete",
token: "tkn",
body: {
ownerHandle: "openclaw",
reason: "Owner-requested cleanup",
dryRun: true,
},
}),
expect.anything(),
);
});
it("requires an owner-qualified skill ref", async () => {
await expect(
cmdHardDeleteSkill(makeGlobalOpts(), "demo", { reason: "Cleanup" }, false),
).rejects.toThrow(/owner-qualified/i);
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
});
it("requires a confirmation token when applying", async () => {
await expect(
cmdHardDeleteSkill(
makeGlobalOpts(),
"@openclaw/demo",
{ reason: "Cleanup", apply: true, yes: true },
false,
),
).rejects.toThrow(/--confirm required/i);
expect(httpMocks.apiRequest).not.toHaveBeenCalled();
});
it("applies with the dry-run confirmation token and disables retries", async () => {
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:demo";
httpMocks.apiRequest.mockResolvedValueOnce({
ok: true,
skillId: "skills:demo",
slug: "demo",
ownerHandle: "openclaw",
displayName: "Demo",
dryRun: false,
scheduled: true,
confirmationToken: generated_token_reference,
});
await cmdHardDeleteSkill(
makeGlobalOpts(),
"@openclaw/demo",
{
reason: "Owner-requested cleanup",
apply: true,
confirm: generated_token_reference,
yes: true,
},
false,
);
expect(httpMocks.apiRequest).toHaveBeenCalledWith(
"https://clawhub.ai",
expect.objectContaining({
method: "POST",
path: "/api/v1/skills/demo/hard-delete",
retryCount: 0,
body: {
ownerHandle: "openclaw",
reason: "Owner-requested cleanup",
dryRun: false,
confirmationToken: generated_token_reference,
},
}),
expect.anything(),
);
});
});
@@ -0,0 +1,114 @@
import { requireAuthToken } from "../../../clawhub/src/cli/authToken.js";
import { getRegistry } from "../../../clawhub/src/cli/registry.js";
import type { GlobalOpts } from "../../../clawhub/src/cli/types.js";
import {
createCrabLoader,
fail,
formatError,
isInteractive,
promptConfirm,
} from "../../../clawhub/src/cli/ui.js";
import { apiRequest } from "../../../clawhub/src/http.js";
import {
ApiRoutes,
ApiV1SkillHardDeleteResponseSchema,
} from "../../../clawhub/src/schema/index.js";
type SkillHardDeleteOptions = {
reason?: string;
apply?: boolean;
confirm?: string;
json?: boolean;
yes?: boolean;
};
type OwnerQualifiedSkillRef = {
ownerHandle: string;
slug: string;
};
export async function cmdHardDeleteSkill(
opts: GlobalOpts,
skillRef: string,
options: SkillHardDeleteOptions,
inputAllowed: boolean,
) {
const ref = parseOwnerQualifiedSkillRef(skillRef);
const label = `@${ref.ownerHandle}/${ref.slug}`;
const reason = options.reason?.trim();
if (!reason) fail("--reason required");
const dryRun = options.apply !== true;
const confirmationToken = options.confirm?.trim();
if (!dryRun && !confirmationToken) fail("--confirm required when using --apply");
if (!dryRun && !options.yes) {
const allowPrompt = isInteractive() && inputAllowed !== false;
if (!allowPrompt) fail("Pass --yes (no input)");
const confirmed = await promptConfirm(
`Permanently hard-delete ${label} and all related history? (cannot be undone)`,
);
if (!confirmed) return undefined;
}
const token = await requireAuthToken();
const registry = await getRegistry(opts, { cache: true });
const spinner = options.json
? null
: createCrabLoader(`${dryRun ? "Planning hard delete for" : "Hard-deleting"} ${label}`);
try {
const result = await apiRequest(
registry,
{
method: "POST",
path: `${ApiRoutes.skills}/${encodeURIComponent(ref.slug)}/hard-delete`,
token,
...(dryRun ? {} : { retryCount: 0 }),
body: {
ownerHandle: ref.ownerHandle,
reason,
dryRun,
...(confirmationToken ? { confirmationToken } : {}),
},
},
ApiV1SkillHardDeleteResponseSchema,
);
spinner?.succeed(
result.scheduled
? `Scheduled hard delete for @${result.ownerHandle}/${result.slug}`
: `Dry run OK for @${result.ownerHandle}/${result.slug}: pass --apply --confirm ${result.confirmationToken}`,
);
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
}
return result;
} catch (error) {
spinner?.fail(formatError(error));
throw error;
}
}
function parseOwnerQualifiedSkillRef(value: string): OwnerQualifiedSkillRef {
const ref = value.trim();
const slashIndex = ref.indexOf("/");
if (slashIndex < 0 || ref.indexOf("/", slashIndex + 1) >= 0) {
fail("Use an owner-qualified skill ref: @owner/slug");
}
const ownerHandle = ref.slice(0, slashIndex).trim().replace(/^@+/, "").toLowerCase();
const slug = ref
.slice(slashIndex + 1)
.trim()
.toLowerCase();
if (
!ownerHandle ||
!slug ||
ownerHandle.includes("\\") ||
ownerHandle.includes("..") ||
slug.includes("\\") ||
slug.includes("..")
) {
fail(`Invalid skill ref: ${value}`);
}
return { ownerHandle, slug };
}
+20
View File
@@ -647,6 +647,26 @@ export const ApiV1SkillRescanResponseSchema = type({
});
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
export const ApiV1SkillHardDeleteRequestSchema = type({
ownerHandle: "string",
reason: "string",
dryRun: "boolean?",
confirmationToken: "string?",
});
export type ApiV1SkillHardDeleteRequest = (typeof ApiV1SkillHardDeleteRequestSchema)[inferred];
export const ApiV1SkillHardDeleteResponseSchema = type({
ok: "true",
skillId: "string",
slug: "string",
ownerHandle: "string",
displayName: "string",
dryRun: "boolean",
scheduled: "boolean",
confirmationToken: "string",
});
export type ApiV1SkillHardDeleteResponse = (typeof ApiV1SkillHardDeleteResponseSchema)[inferred];
export const ApiV1SkillScanStatusSchema = type('"queued"|"running"|"succeeded"|"failed"');
export type ApiV1SkillScanStatus = (typeof ApiV1SkillScanStatusSchema)[inferred];
+18
View File
@@ -502,6 +502,24 @@ export declare const ApiV1SkillRescanResponseSchema: import("arktype/internal/va
alreadyQueued: boolean;
}, {}>;
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
export declare const ApiV1SkillHardDeleteRequestSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ownerHandle: string;
reason: string;
dryRun?: boolean | undefined;
confirmationToken?: string | undefined;
}, {}>;
export type ApiV1SkillHardDeleteRequest = (typeof ApiV1SkillHardDeleteRequestSchema)[inferred];
export declare const ApiV1SkillHardDeleteResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
ok: true;
skillId: string;
slug: string;
ownerHandle: string;
displayName: string;
dryRun: boolean;
scheduled: boolean;
confirmationToken: string;
}, {}>;
export type ApiV1SkillHardDeleteResponse = (typeof ApiV1SkillHardDeleteResponseSchema)[inferred];
export declare const ApiV1SkillScanStatusSchema: import("arktype/internal/variants/string.ts").StringType<"failed" | "queued" | "running" | "succeeded", {}>;
export type ApiV1SkillScanStatus = (typeof ApiV1SkillScanStatusSchema)[inferred];
export declare const ApiV1SkillScanSourceSchema: import("arktype/internal/variants/object.ts").ObjectType<{
+16
View File
@@ -467,6 +467,22 @@ export const ApiV1SkillRescanResponseSchema = type({
scheduled: "boolean",
alreadyQueued: "boolean",
});
export const ApiV1SkillHardDeleteRequestSchema = type({
ownerHandle: "string",
reason: "string",
dryRun: "boolean?",
confirmationToken: "string?",
});
export const ApiV1SkillHardDeleteResponseSchema = type({
ok: "true",
skillId: "string",
slug: "string",
ownerHandle: "string",
displayName: "string",
dryRun: "boolean",
scheduled: "boolean",
confirmationToken: "string",
});
export const ApiV1SkillScanStatusSchema = type('"queued"|"running"|"succeeded"|"failed"');
export const ApiV1SkillScanSourceSchema = type({
kind: '"upload"',
File diff suppressed because one or more lines are too long
+22
View File
@@ -11,6 +11,7 @@ import {
} from "./packages";
import {
ApiSearchResponseSchema,
ApiV1SkillHardDeleteResponseSchema,
ApiV1SkillInstallResolveResponseSchema,
ApiV1SkillRescanResponseSchema,
ApiV1SearchResponseSchema,
@@ -23,6 +24,27 @@ import {
} from "./schemas";
describe("clawhub-schema", () => {
it("parses skill hard-delete responses", () => {
const generated_token_reference = "hard-delete-skill:@openclaw/demo:skills:demo";
const response = parseArk(
ApiV1SkillHardDeleteResponseSchema,
{
ok: true,
skillId: "skills:demo",
slug: "demo",
ownerHandle: "openclaw",
displayName: "Demo",
dryRun: true,
scheduled: false,
confirmationToken: generated_token_reference,
},
"Skill hard-delete response",
);
expect(response.ownerHandle).toBe("openclaw");
expect(response.scheduled).toBe(false);
});
it("parses lockfile records", () => {
const lock = parseArk(
LockfileSchema,
+20
View File
@@ -547,6 +547,26 @@ export const ApiV1SkillRescanResponseSchema = type({
});
export type ApiV1SkillRescanResponse = (typeof ApiV1SkillRescanResponseSchema)[inferred];
export const ApiV1SkillHardDeleteRequestSchema = type({
ownerHandle: "string",
reason: "string",
dryRun: "boolean?",
confirmationToken: "string?",
});
export type ApiV1SkillHardDeleteRequest = (typeof ApiV1SkillHardDeleteRequestSchema)[inferred];
export const ApiV1SkillHardDeleteResponseSchema = type({
ok: "true",
skillId: "string",
slug: "string",
ownerHandle: "string",
displayName: "string",
dryRun: "boolean",
scheduled: "boolean",
confirmationToken: "string",
});
export type ApiV1SkillHardDeleteResponse = (typeof ApiV1SkillHardDeleteResponseSchema)[inferred];
export const ApiV1SkillScanStatusSchema = type('"queued"|"running"|"succeeded"|"failed"');
export type ApiV1SkillScanStatus = (typeof ApiV1SkillScanStatusSchema)[inferred];