fix(skills): repair merge ownership and slug reservations

Fix publisher-owned skill merge authorization, bound historical slug redirects, protect reserved namespaces, and add expiring owner-unpublish slug reservations.

Maintainer follow-up: require current owner-hide provenance before honoring an unpublished slug reservation, clear stale reservation fields on non-owner hide paths, and add regressions for stale moderation-owned reservations.

Verification:
- bunx vitest run convex/skills.rateLimit.test.ts convex/skills.slugAvailability.test.ts convex/skills.undeleteGate.test.ts --reporter verbose
- bun run format:check
- bun run lint
- bunx tsc --noEmit
- bunx tsc -p packages/schema/tsconfig.json --noEmit
- bunx tsc -p packages/clawhub/tsconfig.json --noEmit
- bunx convex codegen
- bun run test
- bun run --cwd packages/clawhub test:src -- src/cli/commands/delete.test.ts --reporter verbose
- git diff --check
- GitHub CI: static, packages, types-build, unit, e2e-http, playwright-smoke, CodeQL, secret scanning all passed on 2d0564d1

Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
Jason
2026-05-09 04:55:32 -04:00
committed by GitHub
parent 5b63d5df60
commit b6875e60f6
21 changed files with 1563 additions and 55 deletions
+1
View File
@@ -6,6 +6,7 @@
### Fixes
- Skills: repair publisher-owned skill merges, bound historical slug redirects, block protected slug namespaces, and expire owner-unpublished slug reservations after 30 days (#2115) (thanks @fuller-stack-dev).
- Skills: allow confirmed owner migration when republishing an existing skill to another publisher, preserving versions, stats, aliases, and audit history (#1998, #2102) (thanks @momothemage).
- Security: block owner delete/undelete paths from overriding moderator or scanner hides, and return explicit 403 authz responses for owner restore denials (#2078) (thanks @momothemage).
+124 -24
View File
@@ -220,6 +220,69 @@ const FEATURED_PLUGIN_SEEDS: SeedPluginSpec[] = [
},
];
const LOCAL_OWNER_PLUGIN_SEEDS: SeedPluginSpec[] = [
{
name: "local-merge-notes-plugin",
displayName: "Local Merge Notes",
summary: "Local owner fixture for validating plugin inventory and skill merge settings.",
version: "0.1.0",
runtimeId: "local.merge.notes",
sourceRepo: "openclaw/local-merge-notes-plugin",
isOfficial: false,
capabilityTags: ["notes", "local-dev", "merge-fixture"],
stats: { downloads: 18, installs: 6, stars: 2, versions: 1 },
readme: "# Local Merge Notes\n\nLocal dev plugin fixture for owner inventory screens.",
},
{
name: "local-merge-browser-plugin",
displayName: "Local Merge Browser",
summary: "Browser automation fixture owned by the local dev account.",
version: "0.1.0",
runtimeId: "local.merge.browser",
sourceRepo: "openclaw/local-merge-browser-plugin",
isOfficial: false,
capabilityTags: ["browser", "automation", "merge-fixture"],
stats: { downloads: 16, installs: 5, stars: 2, versions: 1 },
readme: "# Local Merge Browser\n\nLocal dev browser plugin fixture.",
},
{
name: "local-merge-terminal-plugin",
displayName: "Local Merge Terminal",
summary: "Terminal command fixture owned by the local dev account.",
version: "0.1.0",
runtimeId: "local.merge.terminal",
sourceRepo: "openclaw/local-merge-terminal-plugin",
isOfficial: false,
capabilityTags: ["terminal", "commands", "merge-fixture"],
stats: { downloads: 14, installs: 5, stars: 1, versions: 1 },
readme: "# Local Merge Terminal\n\nLocal dev terminal plugin fixture.",
},
{
name: "local-merge-calendar-plugin",
displayName: "Local Merge Calendar",
summary: "Calendar workflow fixture owned by the local dev account.",
version: "0.1.0",
runtimeId: "local.merge.calendar",
sourceRepo: "openclaw/local-merge-calendar-plugin",
isOfficial: false,
capabilityTags: ["calendar", "scheduling", "merge-fixture"],
stats: { downloads: 12, installs: 4, stars: 1, versions: 1 },
readme: "# Local Merge Calendar\n\nLocal dev calendar plugin fixture.",
},
{
name: "local-merge-git-plugin",
displayName: "Local Merge Git",
summary: "Git workflow fixture owned by the local dev account.",
version: "0.1.0",
runtimeId: "local.merge.git",
sourceRepo: "openclaw/local-merge-git-plugin",
isOfficial: false,
capabilityTags: ["git", "workflow", "merge-fixture"],
stats: { downloads: 10, installs: 3, stars: 1, versions: 1 },
readme: "# Local Merge Git\n\nLocal dev git workflow plugin fixture.",
},
];
type RoleHelpFixtureUser = {
handle: string;
displayName: string;
@@ -506,6 +569,36 @@ hanzi-helper words --char 大 --limit 20
## 学习建议
建议每天学习五个新汉字,结合组词和例句加深记忆。坚持使用听写练习功能可以有效提高汉字识别能力。
`,
},
{
slug: "merge-review-helper",
displayName: "Merge Review Helper",
summary: "Local dev fixture for testing skill merge and redirect flows.",
version: "0.1.0",
metadata: {
openclaw: {
requires: {
config: [".config/clawhub/merge-review.json"],
},
skillKey: "merge-review",
},
},
rawSkillMd: `---
name: merge-review-helper
description: Local dev fixture for testing skill merge and redirect flows.
---
# Merge Review Helper
Use this skill when validating ClawHub skill ownership settings, duplicate cleanup, and merge
redirect behavior.
## Checklist
- Confirm the source skill can select another owned skill as the merge target.
- Confirm the merge creates a slug redirect for the old source slug.
- Confirm hidden source rows disappear from browse and search listings.
`,
},
];
@@ -518,6 +611,34 @@ function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
)}${rawSkillMd.slice(frontmatterEnd)}`;
}
async function seedPluginPackageBatch(
ctx: ActionCtx,
args: SeedActionArgs,
specs: SeedPluginSpec[],
): Promise<SeedMutationResult> {
const storageIds = await Promise.all(
specs.map(async (spec) =>
ctx.storage.store(new Blob([spec.readme], { type: "text/markdown" })),
),
);
return (await ctx.runMutation(internal.devSeed.seedFeaturedPluginPackagesMutation, {
reset: args.reset,
packages: specs.map((spec, index) => ({
name: spec.name,
displayName: spec.displayName,
summary: spec.summary,
version: spec.version,
runtimeId: spec.runtimeId,
sourceRepo: spec.sourceRepo,
isOfficial: spec.isOfficial,
capabilityTags: spec.capabilityTags,
stats: spec.stats,
storageId: storageIds[index],
readmeSize: spec.readme.length,
})),
})) as SeedMutationResult;
}
async function seedNixSkillsHandler(
ctx: ActionCtx,
args: SeedActionArgs,
@@ -573,31 +694,10 @@ async function seedNixSkillsHandler(
);
results.push({ slug: FLAGGED_SKILL_SLUG, ...fixtureResult });
const featuredPluginStorageIds = await Promise.all(
FEATURED_PLUGIN_SEEDS.map(async (spec) =>
ctx.storage.store(new Blob([spec.readme], { type: "text/markdown" })),
),
);
const featuredResult: SeedMutationResult = await ctx.runMutation(
internal.devSeed.seedFeaturedPluginPackagesMutation,
{
reset: args.reset,
packages: FEATURED_PLUGIN_SEEDS.map((spec, index) => ({
name: spec.name,
displayName: spec.displayName,
summary: spec.summary,
version: spec.version,
runtimeId: spec.runtimeId,
sourceRepo: spec.sourceRepo,
isOfficial: spec.isOfficial,
capabilityTags: spec.capabilityTags,
stats: spec.stats,
storageId: featuredPluginStorageIds[index],
readmeSize: spec.readme.length,
})),
},
);
const featuredResult = await seedPluginPackageBatch(ctx, args, FEATURED_PLUGIN_SEEDS);
results.push({ slug: "featured-plugins", ...featuredResult });
const ownerPluginResult = await seedPluginPackageBatch(ctx, args, LOCAL_OWNER_PLUGIN_SEEDS);
results.push({ slug: "local-owner-plugins", ...ownerPluginResult });
return { ok: true, results };
}
+3 -1
View File
@@ -2778,7 +2778,7 @@ describe("httpApiV1 handlers", () => {
} as never);
const runMutation = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("key" in args) return okRate();
return { ok: true };
return args.deleted ? { ok: true, slugReservedUntil: 123 } : { ok: true };
});
const response = await __handlers.skillsDeleteRouterV1Handler(
@@ -2790,6 +2790,7 @@ describe("httpApiV1 handlers", () => {
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true, slugReservedUntil: 123 });
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
@@ -2809,6 +2810,7 @@ describe("httpApiV1 handlers", () => {
}),
);
expect(response2.status).toBe(200);
expect(await response2.json()).toEqual({ ok: true });
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
+4 -4
View File
@@ -1396,13 +1396,13 @@ export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request
const { userId } = await requireApiTokenUser(ctx, request);
const body = await readOptionalJson(request);
const reason = optionalStringField(body, "reason");
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
const result = await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
userId,
slug,
deleted: false,
reason,
});
return json({ ok: true }, 200, rate.headers);
return json(result, 200, rate.headers);
} catch (error) {
return softDeleteErrorToResponse("skill", error, rate.headers);
}
@@ -1459,13 +1459,13 @@ export async function skillsDeleteRouterV1Handler(ctx: ActionCtx, request: Reque
const { userId } = await requireApiTokenUser(ctx, request);
const body = await readOptionalJson(request);
const reason = optionalStringField(body, "reason");
await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
const result = await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, {
userId,
slug,
deleted: true,
reason,
});
return json({ ok: true }, 200, rate.headers);
return json(result, 200, rate.headers);
} catch (error) {
return softDeleteErrorToResponse("skill", error, rate.headers);
}
+13 -1
View File
@@ -86,9 +86,19 @@ describe("assertValidSkillSlug", () => {
expect(() => assertValidSkillSlug("openclaw")).toThrow(/reserved/i);
});
it("allows reserved slugs when allowReserved is set", () => {
it.each(["openclaw-helper", "helper-openclaw", "official-git", "git-official"])(
"rejects protected namespace slug %s",
(slug) => {
expect(() => assertValidSkillSlug(slug)).toThrow(/protected/i);
},
);
it("allows reserved and protected slugs when allowReserved is set", () => {
expect(() => assertValidSkillSlug("admin", { allowReserved: true })).not.toThrow();
expect(assertValidSkillSlug("admin", { allowReserved: true })).toBe("admin");
expect(assertValidSkillSlug("openclaw-helper", { allowReserved: true })).toBe(
"openclaw-helper",
);
});
});
@@ -127,6 +137,8 @@ describe("isReservedSkillSlug", () => {
expect(isReservedSkillSlug("admin")).toBe(true);
expect(isReservedSkillSlug(" ADMIN ")).toBe(true);
expect(isReservedSkillSlug("openclaw")).toBe(true);
expect(isReservedSkillSlug("openclaw-helper")).toBe(true);
expect(isReservedSkillSlug("helper-official")).toBe(true);
});
it("returns false for non-reserved slugs", () => {
+40 -3
View File
@@ -105,9 +105,26 @@ const RESERVED_SKILL_SLUGS: ReadonlySet<string> = new Set([
"false",
]);
// Protected affixes block namespace squatting such as "openclaw-foo",
// "foo-openclaw", "official-foo", or "foo-official". Exact matches are
// already covered by RESERVED_SKILL_SLUGS.
const PROTECTED_SKILL_SLUG_AFFIXES = [
"openclaw",
"clawhub",
"clawd",
"clawdbot",
"onlycrabs",
"soulhub",
"official",
"verified",
"staff",
"admin",
"moderator",
] as const;
interface ValidateSlugOptions {
/**
* Bypass the reserved-word blocklist.
* Bypass the reserved/protected namespace blocklists.
* Intended for admin migrations / internal seeding only.
*/
allowReserved?: boolean;
@@ -118,6 +135,7 @@ export const SKILL_SLUG_CONSTRAINTS = {
maxLength: MAX_SLUG_LENGTH,
pattern: SLUG_PATTERN,
reserved: RESERVED_SKILL_SLUGS,
protectedAffixes: PROTECTED_SKILL_SLUG_AFFIXES,
} as const;
/**
@@ -218,14 +236,33 @@ export function assertValidSkillSlug(
if (!options.allowReserved && RESERVED_SKILL_SLUGS.has(normalized)) {
throw new ConvexError(`"${normalized}" is reserved and cannot be used as a slug.`);
}
if (!options.allowReserved) {
const protectedAffix = getProtectedSkillSlugAffix(normalized);
if (protectedAffix) {
throw new ConvexError(
`"${normalized}" uses the protected "${protectedAffix}" slug namespace. ` +
`Choose a slug that does not start with "${protectedAffix}-" or end with ` +
`"-${protectedAffix}".`,
);
}
}
return normalized;
}
function getProtectedSkillSlugAffix(normalizedSlug: string): string | null {
for (const affix of PROTECTED_SKILL_SLUG_AFFIXES) {
if (normalizedSlug.startsWith(`${affix}-`) || normalizedSlug.endsWith(`-${affix}`)) {
return affix;
}
}
return null;
}
/**
* Convenience predicate: is the slug on the reserved blocklist?
* Convenience predicate: is the slug on the reserved/protected blocklist?
* Exposed so callers (e.g. admin tooling) can pre-check without a throw.
*/
export function isReservedSkillSlug(slug: string | undefined | null): boolean {
const normalized = normalizeSkillSlug(slug);
return RESERVED_SKILL_SLUGS.has(normalized);
return RESERVED_SKILL_SLUGS.has(normalized) || getProtectedSkillSlugAffix(normalized) !== null;
}
+3
View File
@@ -407,6 +407,9 @@ const skills = defineTable({
scanCheckCount: v.optional(v.number()),
hiddenAt: v.optional(v.number()),
hiddenBy: v.optional(v.id("users")),
unpublishedSlugReservedUntil: v.optional(v.number()),
unpublishedSlugReleasedAt: v.optional(v.number()),
unpublishedOriginalSlug: v.optional(v.string()),
reportCount: v.optional(v.number()),
lastReportedAt: v.optional(v.number()),
batch: v.optional(v.string()),
+322 -1
View File
@@ -5,7 +5,7 @@ vi.mock("@convex-dev/auth/server", () => ({
authTables: {},
}));
import { getSkillBySlugInternal } from "./skills";
import { getSkillBySlugInternal, mergeOwnedSkillIntoCanonicalInternal } from "./skills";
type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
@@ -14,12 +14,30 @@ type WrappedHandler<TArgs, TResult = unknown> = {
const getSkillBySlugInternalHandler = (
getSkillBySlugInternal as unknown as WrappedHandler<{ slug: string }>
)._handler;
const mergeOwnedSkillIntoCanonicalInternalHandler = (
mergeOwnedSkillIntoCanonicalInternal as unknown as WrappedHandler<{
actorUserId: string;
sourceSlug: string;
targetSlug: string;
}>
)._handler;
function chainEq(constraints: Record<string, unknown>) {
return {
eq(field: string, value: unknown) {
constraints[field] = value;
return chainEq(constraints);
},
};
}
describe("skills ownership", () => {
it("resolves alias slugs to the live target skill", async () => {
const result = await getSkillBySlugInternalHandler(
{
db: {
normalizeId: vi.fn(() => null),
system: {},
get: vi.fn(async (id: string) => {
if (id === "skills:target") {
return {
@@ -69,4 +87,307 @@ describe("skills ownership", () => {
}),
);
});
it("allows publisher admins to merge publisher-owned skills and preserves alias ownership", async () => {
const patch = vi.fn(async () => {});
const insert = vi.fn(async () => "auditLogs:1");
const skills = [
{
_id: "skills:source",
slug: "merge-source",
displayName: "Merge Source",
ownerUserId: "users:creator",
ownerPublisherId: "publishers:org",
moderationStatus: "hidden",
softDeletedAt: undefined,
statsDownloads: 7,
statsStars: 2,
},
{
_id: "skills:target",
slug: "merge-target",
displayName: "Merge Target",
ownerUserId: "users:creator",
ownerPublisherId: "publishers:org",
latestVersionId: "skillVersions:target",
moderationStatus: "hidden",
softDeletedAt: undefined,
},
];
const aliases = [
{
_id: "skillSlugAliases:old",
slug: "merge-source-old",
skillId: "skills:source",
ownerUserId: "users:creator",
ownerPublisherId: "publishers:org",
},
];
const result = await mergeOwnedSkillIntoCanonicalInternalHandler(
{
db: {
normalizeId: vi.fn(() => null),
system: {},
get: vi.fn(async (id: string) => {
if (id === "users:actor") return { _id: "users:actor", role: "user" };
if (id === "users:creator") {
return {
_id: "users:creator",
publishedSkills: 2,
totalDownloads: 9,
totalStars: 5,
};
}
if (id === "publishers:org") {
return {
_id: "publishers:org",
kind: "org",
handle: "team",
linkedUserId: undefined,
};
}
if (id === "skillVersions:target") return { _id: id, version: "1.2.3" };
return skills.find((skill) => skill._id === id) ?? null;
}),
query: vi.fn((table: string) => {
if (table === "skills") {
return {
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
if (name === "by_slug") {
return {
unique: async () =>
skills.find((skill) => skill.slug === constraints.slug) ?? null,
};
}
if (name === "by_canonical" || name === "by_fork_of") {
return { collect: async () => [] };
}
throw new Error(`unexpected skills index ${name}`);
},
};
}
if (table === "publisherMembers") {
return {
withIndex: (name: string) => {
if (name !== "by_publisher_user") {
throw new Error(`unexpected publisherMembers index ${name}`);
}
return {
unique: async () => ({
_id: "publisherMembers:1",
publisherId: "publishers:org",
userId: "users:actor",
role: "admin",
}),
};
},
};
}
if (table === "skillSlugAliases") {
return {
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
if (name === "by_skill") {
return {
collect: async () =>
aliases.filter((alias) => alias.skillId === constraints.skillId),
};
}
if (name === "by_slug") {
return {
unique: async () =>
aliases.find((alias) => alias.slug === constraints.slug) ?? null,
};
}
if (name === "by_owner_publisher") {
return {
take: async () =>
aliases.filter(
(alias) => alias.ownerPublisherId === constraints.ownerPublisherId,
),
};
}
if (name === "by_owner") {
return {
take: async () =>
aliases.filter((alias) => alias.ownerUserId === constraints.ownerUserId),
};
}
throw new Error(`unexpected skillSlugAliases index ${name}`);
},
};
}
if (table === "skillEmbeddings") {
return {
withIndex: (name: string) => {
if (name !== "by_skill") {
throw new Error(`unexpected skillEmbeddings index ${name}`);
}
return { collect: async () => [] };
},
};
}
throw new Error(`unexpected table ${table}`);
}),
patch,
insert,
},
} as never,
{
actorUserId: "users:actor",
sourceSlug: "merge-source",
targetSlug: "merge-target",
},
);
expect(result).toEqual({
ok: true,
sourceSlug: "merge-source",
targetSlug: "merge-target",
});
expect(patch).toHaveBeenCalledWith(
"skillSlugAliases:old",
expect.objectContaining({
skillId: "skills:target",
ownerUserId: "users:creator",
ownerPublisherId: "publishers:org",
}),
);
expect(insert).toHaveBeenCalledWith(
"skillSlugAliases",
expect.objectContaining({
slug: "merge-source",
skillId: "skills:target",
ownerUserId: "users:creator",
ownerPublisherId: "publishers:org",
}),
);
expect(patch).toHaveBeenCalledWith(
"skills:source",
expect.objectContaining({
canonicalSkillId: "skills:target",
forkOf: expect.objectContaining({
skillId: "skills:target",
kind: "duplicate",
version: "1.2.3",
}),
moderationReason: "owner.merged",
}),
);
expect(patch).toHaveBeenCalledWith(
"users:creator",
expect.objectContaining({
publishedSkills: 1,
totalDownloads: 2,
totalStars: 3,
}),
);
});
it("rejects merges that would reserve too many historical slugs for one skill", async () => {
const patch = vi.fn(async () => {});
const insert = vi.fn(async () => "auditLogs:1");
const skills = [
{
_id: "skills:source",
slug: "merge-source",
displayName: "Merge Source",
ownerUserId: "users:actor",
moderationStatus: "hidden",
softDeletedAt: undefined,
},
{
_id: "skills:target",
slug: "merge-target",
displayName: "Merge Target",
ownerUserId: "users:actor",
moderationStatus: "hidden",
softDeletedAt: undefined,
},
];
const aliases = Array.from({ length: 5 }, (_, index) => ({
_id: `skillSlugAliases:target-${index}`,
slug: `target-old-${index}`,
skillId: "skills:target",
ownerUserId: "users:actor",
ownerPublisherId: undefined,
}));
await expect(
mergeOwnedSkillIntoCanonicalInternalHandler(
{
db: {
normalizeId: vi.fn(() => null),
system: {},
get: vi.fn(async (id: string) => {
if (id === "users:actor") return { _id: "users:actor", role: "user" };
return skills.find((skill) => skill._id === id) ?? null;
}),
query: vi.fn((table: string) => {
if (table === "skills") {
return {
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
if (name === "by_slug") {
return {
unique: async () =>
skills.find((skill) => skill.slug === constraints.slug) ?? null,
};
}
if (name === "by_canonical" || name === "by_fork_of") {
return { collect: async () => [] };
}
throw new Error(`unexpected skills index ${name}`);
},
};
}
if (table === "skillSlugAliases") {
return {
withIndex: (name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build(chainEq(constraints));
if (name === "by_skill") {
return {
collect: async () =>
aliases.filter((alias) => alias.skillId === constraints.skillId),
};
}
if (name === "by_slug") {
return {
unique: async () =>
aliases.find((alias) => alias.slug === constraints.slug) ?? null,
};
}
if (name === "by_owner") {
return {
take: async () =>
aliases.filter((alias) => alias.ownerUserId === constraints.ownerUserId),
};
}
throw new Error(`unexpected skillSlugAliases index ${name}`);
},
};
}
throw new Error(`unexpected table ${table}`);
}),
patch,
insert,
},
} as never,
{
actorUserId: "users:actor",
sourceSlug: "merge-source",
targetSlug: "merge-target",
},
),
).rejects.toThrow(/Too many historical slugs/);
expect(patch).not.toHaveBeenCalled();
expect(insert).not.toHaveBeenCalled();
});
});
+457
View File
@@ -89,6 +89,15 @@ function createPublishArgs(overrides?: Partial<Record<string, unknown>>) {
};
}
function chainEq(constraints: Record<string, unknown>) {
return {
eq(field: string, value: unknown) {
constraints[field] = value;
return chainEq(constraints);
},
};
}
describe("skills anti-spam guards", () => {
it("blocks low-trust users after hourly new-skill cap", async () => {
const now = Date.now();
@@ -369,6 +378,454 @@ describe("skills anti-spam guards", () => {
);
});
it("releases expired owner-unpublished slugs without alias collisions before accepting a new publish", async () => {
const now = Date.now();
const storedSkills = new Map<string, Record<string, unknown>>([
[
"skills:expired",
{
_id: "skills:expired",
slug: "released-demo",
displayName: "Released Demo",
ownerUserId: "users:previous",
softDeletedAt: now - 31 * 24 * 60 * 60 * 1000,
hiddenBy: "users:previous",
unpublishedSlugReservedUntil: now - 1_000,
moderationStatus: "hidden",
tags: {},
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 1,
comments: 0,
},
createdAt: now - 40 * 24 * 60 * 60 * 1000,
updatedAt: now - 31 * 24 * 60 * 60 * 1000,
},
],
]);
const aliasSlugs = new Set(["__unpublished_skills_expired"]);
const patch = vi.fn(
async (
tableOrId: string,
idOrValue: string | Record<string, unknown>,
maybeValue?: Record<string, unknown>,
) => {
const id = typeof idOrValue === "string" ? idOrValue : tableOrId;
const value = typeof idOrValue === "string" ? maybeValue : idOrValue;
if (!value) return;
if (storedSkills.has(id)) {
storedSkills.set(id, { ...storedSkills.get(id), ...value });
}
},
);
const insert = vi.fn(async (table: string, value: Record<string, unknown>) => {
if (table === "skills") {
storedSkills.set("skills:new", { _id: "skills:new", _creationTime: now, ...value });
return "skills:new";
}
if (table === "auditLogs") return "auditLogs:release";
if (table === "skillVersions") return "skillVersions:1";
if (table === "skillEmbeddings") return "skillEmbeddings:1";
if (table === "embeddingSkillMap") return "embeddingSkillMap:1";
if (table === "skillVersionFingerprints") return "skillVersionFingerprints:1";
if (table === "skillSearchDigest") return "skillSearchDigest:1";
throw new Error(`unexpected insert table ${table}`);
});
const db = {
get: vi.fn(async (tableOrId: string, maybeId?: string) => {
const id = maybeId ?? tableOrId;
if (storedSkills.has(id)) return storedSkills.get(id);
if (id === "users:caller") {
return {
_id: "users:caller",
_creationTime: now - 60 * 24 * 60 * 60 * 1000,
createdAt: now - 60 * 24 * 60 * 60 * 1000,
deletedAt: undefined,
deactivatedAt: undefined,
trustedPublisher: true,
role: "user",
handle: "caller",
personalPublisherId: "publishers:caller",
};
}
if (id === "publishers:caller") {
return {
_id: "publishers:caller",
kind: "user",
handle: "caller",
linkedUserId: "users:caller",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
return null;
}),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table);
if (globalStatsQuery) return globalStatsQuery;
const digestQuery = buildDigestQuery(table);
if (digestQuery) return digestQuery;
if (table === "skills") {
return {
withIndex: (name: string, build?: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build?.(chainEq(constraints));
if (name === "by_slug") {
return {
unique: async () =>
Array.from(storedSkills.values()).find(
(skill) => skill.slug === constraints.slug,
) ?? null,
take: async (limit: number) =>
Array.from(storedSkills.values())
.filter((skill) => skill.slug === constraints.slug)
.slice(0, limit),
};
}
if (name === "by_owner") {
return {
order: () => ({
take: async () => [],
}),
};
}
throw new Error(`unexpected skills index ${name}`);
},
};
}
if (table === "reservedSlugs") {
return {
withIndex: (name: string) => {
if (name === "by_slug_active_deletedAt") {
return { order: () => ({ take: async () => [] }) };
}
throw new Error(`unexpected reservedSlugs index ${name}`);
},
};
}
if (table === "skillSlugAliases") {
return {
withIndex: (name: string, build?: (q: ReturnType<typeof chainEq>) => unknown) => {
if (name !== "by_slug") throw new Error(`unexpected skillSlugAliases index ${name}`);
const constraints: Record<string, unknown> = {};
build?.(chainEq(constraints));
const alias = aliasSlugs.has(String(constraints.slug))
? {
_id: "skillSlugAliases:collision",
slug: constraints.slug,
skillId: "skills:collision",
}
: null;
return {
unique: async () => alias,
take: async (limit: number) => (alias && limit > 0 ? [alias] : []),
};
},
};
}
if (table === "skillVersionFingerprints") {
return {
withIndex: (name: string) => {
if (name !== "by_fingerprint") {
throw new Error(`unexpected skillVersionFingerprints index ${name}`);
}
return { take: async () => [] };
},
};
}
if (table === "skillVersions") {
return {
withIndex: (name: string) => {
if (name !== "by_skill_version") {
throw new Error(`unexpected skillVersions index ${name}`);
}
return { unique: async () => null };
},
};
}
if (table === "skillBadges") {
return {
withIndex: (name: string) => {
if (name !== "by_skill") throw new Error(`unexpected skillBadges index ${name}`);
return { take: async () => [] };
},
};
}
if (table === "skillEmbeddings") {
return {
withIndex: (name: string) => {
if (name !== "by_version") {
throw new Error(`unexpected skillEmbeddings index ${name}`);
}
return { unique: async () => null };
},
};
}
throw new Error(`unexpected table ${table}`);
}),
patch,
insert,
normalizeId: vi.fn((tableName: string, id: string) =>
id.startsWith(`${tableName}:`) ? id : null,
),
};
const result = await insertVersionHandler(
{ db, scheduler: { runAfter: vi.fn() } } as never,
createPublishArgs({
userId: "users:caller",
slug: "released-demo",
bypassNewSkillRateLimit: true,
}) as never,
);
expect(result).toEqual({
skillId: "skills:new",
versionId: "skillVersions:1",
embeddingId: "skillEmbeddings:1",
});
expect(patch).toHaveBeenCalledWith(
"skills",
"skills:expired",
expect.objectContaining({
slug: "__unpublished_skills_expired_1",
unpublishedOriginalSlug: "released-demo",
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: expect.any(Number),
}),
);
expect(insert).toHaveBeenCalledWith(
"auditLogs",
expect.objectContaining({
action: "skill.slug.unpublished_release",
actorUserId: "users:caller",
targetId: "skills:expired",
metadata: expect.objectContaining({
from: "released-demo",
to: "__unpublished_skills_expired_1",
previousOwnerUserId: "users:previous",
}),
}),
);
expect(insert).toHaveBeenCalledWith(
"skills",
expect.objectContaining({
slug: "released-demo",
ownerUserId: "users:caller",
}),
);
});
it("does not release a stale owner reservation after moderation owns the current hide", async () => {
const now = Date.now();
const storedSkills = new Map<string, Record<string, unknown>>([
[
"skills:stale",
{
_id: "skills:stale",
slug: "moderated-demo",
displayName: "Moderated Demo",
ownerUserId: "users:previous",
ownerPublisherId: "publishers:previous",
softDeletedAt: now - 31 * 24 * 60 * 60 * 1000,
hiddenBy: undefined,
unpublishedSlugReservedUntil: now - 1_000,
moderationStatus: "hidden",
moderationFlags: ["blocked.malware"],
moderationVerdict: "malicious",
tags: {},
stats: {
downloads: 0,
installsCurrent: 0,
installsAllTime: 0,
stars: 0,
versions: 1,
comments: 0,
},
createdAt: now - 40 * 24 * 60 * 60 * 1000,
updatedAt: now - 31 * 24 * 60 * 60 * 1000,
},
],
]);
const patch = vi.fn(async () => {});
const insert = vi.fn(async () => "unexpected");
const db = {
get: vi.fn(async (tableOrId: string, maybeId?: string) => {
const id = maybeId ?? tableOrId;
if (storedSkills.has(id)) return storedSkills.get(id);
if (id === "users:caller") {
return {
_id: "users:caller",
_creationTime: now - 60 * 24 * 60 * 60 * 1000,
createdAt: now - 60 * 24 * 60 * 60 * 1000,
deletedAt: undefined,
deactivatedAt: undefined,
trustedPublisher: true,
role: "user",
handle: "caller",
personalPublisherId: "publishers:caller",
};
}
if (id === "publishers:caller") {
return {
_id: "publishers:caller",
kind: "user",
handle: "caller",
linkedUserId: "users:caller",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
if (id === "publishers:previous") {
return {
_id: "publishers:previous",
kind: "user",
handle: "previous",
linkedUserId: "users:previous",
deletedAt: undefined,
deactivatedAt: undefined,
};
}
if (id === "users:previous") {
return {
_id: "users:previous",
deletedAt: undefined,
deactivatedAt: undefined,
handle: "previous",
};
}
return null;
}),
query: vi.fn((table: string) => {
const globalStatsQuery = buildGlobalStatsQuery(table);
if (globalStatsQuery) return globalStatsQuery;
const digestQuery = buildDigestQuery(table);
if (digestQuery) return digestQuery;
if (table === "skills") {
return {
withIndex: (name: string, build?: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build?.(chainEq(constraints));
if (name === "by_slug") {
return {
unique: async () =>
Array.from(storedSkills.values()).find(
(skill) => skill.slug === constraints.slug,
) ?? null,
};
}
if (name === "by_owner") {
return {
order: () => ({
take: async () => [],
}),
};
}
throw new Error(`unexpected skills index ${name}`);
},
};
}
if (table === "reservedSlugs") {
return {
withIndex: (name: string) => {
if (name === "by_slug_active_deletedAt") {
return { order: () => ({ take: async () => [] }) };
}
throw new Error(`unexpected reservedSlugs index ${name}`);
},
};
}
if (table === "skillSlugAliases") {
return {
withIndex: (name: string) => {
if (name !== "by_slug") throw new Error(`unexpected skillSlugAliases index ${name}`);
return { unique: async () => null };
},
};
}
if (table === "authAccounts") {
return {
withIndex: (name: string) => {
if (name !== "userIdAndProvider") throw new Error(`unexpected auth index ${name}`);
return { unique: async () => null };
},
};
}
if (table === "publishers") {
return {
withIndex: (name: string, build?: (q: ReturnType<typeof chainEq>) => unknown) => {
const constraints: Record<string, unknown> = {};
build?.(chainEq(constraints));
if (name === "by_handle") return { unique: async () => null };
if (name === "by_linked_user") {
return {
unique: async () =>
constraints.linkedUserId === "users:caller"
? {
_id: "publishers:caller",
kind: "user",
handle: "caller",
linkedUserId: "users:caller",
deletedAt: undefined,
deactivatedAt: undefined,
}
: null,
};
}
throw new Error(`unexpected publishers index ${name}`);
},
};
}
if (table === "publisherMembers") {
return {
withIndex: (name: string) => {
if (name !== "by_publisher_user") {
throw new Error(`unexpected publisherMembers index ${name}`);
}
return {
unique: async () => ({
_id: "publisherMembers:caller",
publisherId: "publishers:caller",
userId: "users:caller",
role: "owner",
}),
};
},
};
}
throw new Error(`unexpected table ${table}`);
}),
patch,
insert,
normalizeId: vi.fn((tableName: string, id: string) =>
id.startsWith(`${tableName}:`) ? id : null,
),
};
await expect(
insertVersionHandler(
{ db, scheduler: { runAfter: vi.fn() } } as never,
createPublishArgs({
userId: "users:caller",
slug: "moderated-demo",
bypassNewSkillRateLimit: true,
}) as never,
),
).rejects.toThrow(/Slug is already taken/);
expect(patch).not.toHaveBeenCalledWith(
"skills",
"skills:stale",
expect.objectContaining({
slug: expect.stringMatching(/^__unpublished_/),
}),
);
expect(insert).not.toHaveBeenCalledWith("skills", expect.anything());
});
it("heals ownership when conflicting owner is deleted but GitHub identity matches", async () => {
let authAccountLookupCount = 0;
const patch = vi.fn(async () => {});
+136
View File
@@ -18,6 +18,8 @@ type SkillDoc = {
slug: string;
ownerUserId: string;
softDeletedAt?: number;
hiddenBy?: string;
unpublishedSlugReservedUntil?: number;
moderationStatus?: "active" | "hidden" | "removed";
moderationFlags?: string[];
};
@@ -173,6 +175,115 @@ describe("skills.checkSlugAvailability", () => {
});
});
it("returns reserved while an owner-unpublished slug reservation is active", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
vi.mocked(getAuthUserId).mockResolvedValue("users:caller" as never);
const result = (await checkSlugAvailabilityHandler(
createCtx({
skill: {
_id: "skills:1",
slug: "unpublished-skill",
ownerUserId: "users:owner",
softDeletedAt: now - 1_000,
hiddenBy: "users:owner",
unpublishedSlugReservedUntil: now + 60_000,
moderationStatus: "hidden",
moderationFlags: undefined,
},
}) as never,
{ slug: "unpublished-skill" } as never,
)) as {
available: boolean;
reason: string;
message: string;
url: string | null;
};
expect(result).toEqual({
available: false,
reason: "reserved",
message:
'Slug "unpublished-skill" is reserved by an unpublished skill until ' +
"2023-11-14T22:14:20.000Z. Publish or restore it before then to keep the slug; " +
"after that another publisher can claim it.",
url: null,
});
});
it("returns available when an owner-unpublished slug reservation has expired", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
vi.mocked(getAuthUserId).mockResolvedValue("users:caller" as never);
const result = (await checkSlugAvailabilityHandler(
createCtx({
skill: {
_id: "skills:1",
slug: "unpublished-skill",
ownerUserId: "users:owner",
softDeletedAt: now - 120_000,
hiddenBy: "users:owner",
unpublishedSlugReservedUntil: now - 60_000,
moderationStatus: "hidden",
moderationFlags: undefined,
},
}) as never,
{ slug: "unpublished-skill" } as never,
)) as {
available: boolean;
reason: string;
message: string | null;
url: string | null;
};
expect(result).toEqual({
available: true,
reason: "available",
message: null,
url: null,
});
});
it("returns taken when a stale owner reservation remains on a moderation hide", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
vi.mocked(getAuthUserId).mockResolvedValue("users:caller" as never);
const result = (await checkSlugAvailabilityHandler(
createCtx({
skill: {
_id: "skills:1",
slug: "moderated-skill",
ownerUserId: "users:owner",
softDeletedAt: now - 120_000,
hiddenBy: undefined,
unpublishedSlugReservedUntil: now - 60_000,
moderationStatus: "hidden",
moderationFlags: ["blocked.malware"],
},
owner: {
_id: "users:owner",
handle: "owner",
},
}) as never,
{ slug: "moderated-skill" } as never,
)) as {
available: boolean;
reason: string;
message: string;
url: string | null;
};
expect(result).toEqual({
available: false,
reason: "taken",
message: "Slug is already taken. Choose a different slug.",
url: null,
});
});
it("returns taken with URL for public collisions", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:caller" as never);
@@ -343,6 +454,31 @@ describe("skills.checkSlugAvailability", () => {
});
});
it("returns reserved for protected namespace slugs", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:caller" as never);
const result = (await checkSlugAvailabilityHandler(
createCtx({
skill: null,
}) as never,
{ slug: "openclaw-helper" } as never,
)) as {
available: boolean;
reason: string;
message: string;
url: string | null;
};
expect(result).toEqual({
available: false,
reason: "reserved",
message:
'"openclaw-helper" uses the protected "openclaw" slug namespace. ' +
'Choose a slug that does not start with "openclaw-" or end with "-openclaw".',
url: null,
});
});
it("returns available when reservation has expired", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
+346 -8
View File
@@ -148,6 +148,10 @@ const RATE_LIMIT_HOUR_MS = 60 * 60 * 1000;
const RATE_LIMIT_DAY_MS = 24 * RATE_LIMIT_HOUR_MS;
const SLUG_RESERVATION_DAYS = 90;
const SLUG_RESERVATION_MS = SLUG_RESERVATION_DAYS * RATE_LIMIT_DAY_MS;
const UNPUBLISHED_SLUG_RESERVATION_DAYS = 30;
const UNPUBLISHED_SLUG_RESERVATION_MS = UNPUBLISHED_SLUG_RESERVATION_DAYS * RATE_LIMIT_DAY_MS;
const MAX_SKILL_SLUG_ALIASES_PER_SKILL = 5;
const MAX_SKILL_SLUG_ALIASES_PER_OWNER = 25;
const LOW_TRUST_ACCOUNT_AGE_MS = 30 * RATE_LIMIT_DAY_MS;
const MAX_MANUAL_OVERRIDE_NOTE_LENGTH = 1200;
const DEFAULT_STAFF_AUDIT_LOG_LIMIT = 10;
@@ -587,6 +591,40 @@ function buildAliasTakenErrorMessage(skill: Doc<"skills">, owner: SkillOwnerRef)
return `${base} Existing skill: ${url}`;
}
function formatUnpublishedSlugReservationMessage(slug: string, expiresAt: number) {
return (
`Slug "${slug}" is reserved by an unpublished skill until ` +
`${new Date(expiresAt).toISOString()}. Publish or restore it before then to keep the slug; ` +
"after that another publisher can claim it."
);
}
function getUnpublishedSlugReservationExpiresAt(
skill: Pick<
Doc<"skills">,
"softDeletedAt" | "hiddenBy" | "ownerUserId" | "unpublishedSlugReservedUntil"
>,
) {
if (!skill.softDeletedAt) return null;
if (skill.hiddenBy !== skill.ownerUserId) return null;
if (typeof skill.unpublishedSlugReservedUntil === "number") {
return skill.unpublishedSlugReservedUntil;
}
return skill.softDeletedAt + UNPUBLISHED_SLUG_RESERVATION_MS;
}
function buildReleasedUnpublishedSkillSlug(skill: Pick<Doc<"skills">, "_id">, attempt = 0) {
const idPart = String(skill._id)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
const suffix = attempt > 0 ? `_${attempt}` : "";
// The double-underscore namespace is intentionally not user-claimable by
// the public slug validator, so released hidden rows cannot squat on public
// slug space after their unpublished reservation expires.
return `__unpublished_${idPart || "skill"}${suffix}`;
}
function normalizeSkillSlugKey(slug: string) {
// Read-path normalization: lowercase + trim only. Intentionally lenient so
// that legacy rows (pre-validator) remain lookup-able. Write paths must
@@ -594,6 +632,17 @@ function normalizeSkillSlugKey(slug: string) {
return normalizeSkillSlug(slug);
}
function slugValidationAvailabilityFailure(error: unknown) {
const message =
error instanceof ConvexError && typeof error.data === "string" ? error.data : "Invalid slug.";
return {
available: false,
reason: /reserved|protected/i.test(message) ? ("reserved" as const) : ("taken" as const),
message,
url: null,
};
}
type SkillOwnerRef =
| {
_id: Id<"users"> | Id<"publishers">;
@@ -628,6 +677,143 @@ async function listSkillSlugAliasesForSkill(
.collect();
}
function sameSkillSlugAliasOwner(
alias: Pick<Doc<"skillSlugAliases">, "ownerUserId" | "ownerPublisherId">,
ownerUserId: Id<"users">,
ownerPublisherId: Id<"publishers"> | undefined,
) {
return (
alias.ownerUserId === ownerUserId &&
(alias.ownerPublisherId ?? null) === (ownerPublisherId ?? null)
);
}
async function countSkillSlugAliasesForOwnerQuota(
ctx: Pick<QueryCtx | MutationCtx, "db">,
ownerUserId: Id<"users">,
ownerPublisherId: Id<"publishers"> | undefined,
) {
if (ownerPublisherId) {
const aliases = await ctx.db
.query("skillSlugAliases")
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
.take(MAX_SKILL_SLUG_ALIASES_PER_OWNER + 1);
return aliases.length;
}
const aliases = await ctx.db
.query("skillSlugAliases")
.withIndex("by_owner", (q) => q.eq("ownerUserId", ownerUserId))
.take(MAX_SKILL_SLUG_ALIASES_PER_OWNER + 1);
return aliases.length;
}
async function assertSkillSlugAliasQuota(
ctx: Pick<QueryCtx | MutationCtx, "db">,
params: {
targetSkillId: Id<"skills">;
ownerUserId: Id<"users">;
ownerPublisherId: Id<"publishers"> | undefined;
currentSkillAliasCount?: number;
addedSkillAliases: number;
removedSkillAliases?: number;
addedOwnerAliases: number;
removedOwnerAliases?: number;
},
) {
const addedSkillAliases = Math.max(0, params.addedSkillAliases);
const removedSkillAliases = Math.max(0, params.removedSkillAliases ?? 0);
const addedOwnerAliases = Math.max(0, params.addedOwnerAliases);
const removedOwnerAliases = Math.max(0, params.removedOwnerAliases ?? 0);
const currentSkillAliasCount =
params.currentSkillAliasCount ??
(await listSkillSlugAliasesForSkill(ctx, params.targetSkillId)).length;
const nextSkillAliasCount =
Math.max(0, currentSkillAliasCount - removedSkillAliases) + addedSkillAliases;
if (nextSkillAliasCount > MAX_SKILL_SLUG_ALIASES_PER_SKILL) {
throw new ConvexError(
"Too many historical slugs are already reserved for this skill. " +
`A skill can keep at most ${MAX_SKILL_SLUG_ALIASES_PER_SKILL} old slug redirects. ` +
"Contact support@openclaw.ai if this is a legitimate migration.",
);
}
if (addedOwnerAliases === 0 && removedOwnerAliases === 0) return;
const currentOwnerAliasCount = await countSkillSlugAliasesForOwnerQuota(
ctx,
params.ownerUserId,
params.ownerPublisherId,
);
const nextOwnerAliasCount =
Math.max(0, currentOwnerAliasCount - removedOwnerAliases) + addedOwnerAliases;
if (nextOwnerAliasCount > MAX_SKILL_SLUG_ALIASES_PER_OWNER) {
throw new ConvexError(
"Too many historical slugs are already reserved by this owner. " +
`An owner can keep at most ${MAX_SKILL_SLUG_ALIASES_PER_OWNER} old slug redirects. ` +
"Contact support@openclaw.ai if this is a legitimate migration.",
);
}
}
async function releaseExpiredUnpublishedSkillSlug(
ctx: MutationCtx,
skill: Doc<"skills">,
now: number,
actorUserId: Id<"users">,
) {
const reservedUntil = getUnpublishedSlugReservationExpiresAt(skill);
if (reservedUntil === null || reservedUntil > now) return false;
let releasedSlug: string | null = null;
for (let attempt = 0; attempt < 5; attempt += 1) {
const candidate = buildReleasedUnpublishedSkillSlug(skill, attempt);
const [conflictingSkills, conflictingAliases] = await Promise.all([
ctx.db
.query("skills")
.withIndex("by_slug", (q) => q.eq("slug", candidate))
.take(1),
ctx.db
.query("skillSlugAliases")
.withIndex("by_slug", (q) => q.eq("slug", candidate))
.take(1),
]);
const conflictingSkill = conflictingSkills.find(
(candidateSkill) => candidateSkill._id !== skill._id,
);
if (!conflictingSkill && conflictingAliases.length === 0) {
releasedSlug = candidate;
break;
}
}
if (!releasedSlug) {
throw new ConvexError("Unable to release expired unpublished slug without a slug collision.");
}
await ctx.db.patch(skill._id, {
slug: releasedSlug,
unpublishedOriginalSlug: skill.unpublishedOriginalSlug ?? skill.slug,
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: now,
updatedAt: now,
});
await ctx.db.insert("auditLogs", {
actorUserId,
action: "skill.slug.unpublished_release",
targetType: "skill",
targetId: skill._id,
metadata: {
from: skill.slug,
to: releasedSlug,
previousOwnerUserId: skill.ownerUserId,
reservedUntil,
},
createdAt: now,
});
return true;
}
async function resolveSkillBySlugOrAlias(
ctx: Pick<QueryCtx | MutationCtx, "db">,
slug: string,
@@ -1774,6 +1960,11 @@ export const checkSlugAvailability = query({
url: null,
};
}
try {
assertValidSkillSlug(slug);
} catch (error) {
return slugValidationAvailabilityFailure(error);
}
return {
available: true,
reason: "available" as const,
@@ -1782,6 +1973,33 @@ export const checkSlugAvailability = query({
};
}
const unpublishedReservationExpiresAt = getUnpublishedSlugReservationExpiresAt(skill);
if (
skill.softDeletedAt &&
unpublishedReservationExpiresAt !== null &&
(!userId || skill.ownerUserId !== userId)
) {
if (unpublishedReservationExpiresAt <= Date.now()) {
try {
assertValidSkillSlug(slug);
} catch (error) {
return slugValidationAvailabilityFailure(error);
}
return {
available: true,
reason: "available" as const,
message: null,
url: null,
};
}
return {
available: false,
reason: "reserved" as const,
message: formatUnpublishedSlugReservationMessage(slug, unpublishedReservationExpiresAt),
url: null,
};
}
if (userId && skill.ownerUserId === userId) {
return {
available: true,
@@ -2751,6 +2969,9 @@ export const report = mutation({
}),
hiddenAt: now,
lastReviewedAt: now,
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
});
}
@@ -3059,6 +3280,9 @@ async function applySkillReportFinalAction(
moderationNotes: trimManualOverrideNote(params.note),
hiddenAt: params.now,
hiddenBy: params.actorUserId,
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
lastReviewedAt: params.now,
updatedAt: params.now,
};
@@ -5351,6 +5575,9 @@ export const escalateSkillByIdInternal = internalMutation({
}),
hiddenAt: moderationStatus === "hidden" ? now : undefined,
hiddenBy: undefined,
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
lastReviewedAt: moderationStatus === "hidden" ? now : undefined,
updatedAt: now,
};
@@ -6018,6 +6245,9 @@ export const approveSkillByHashInternal = internalMutation({
}),
hiddenAt: nextModerationStatus === "hidden" ? now : undefined,
hiddenBy: undefined,
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
lastReviewedAt: nextModerationStatus === "hidden" ? now : undefined,
updatedAt: now,
};
@@ -6136,6 +6366,9 @@ export const escalateByVtInternal = internalMutation({
// "malicious", both of which the undelete gate also enforces.
basePatch.hiddenAt = now;
basePatch.hiddenBy = undefined;
basePatch.unpublishedSlugReservedUntil = undefined;
basePatch.unpublishedSlugReleasedAt = undefined;
basePatch.unpublishedOriginalSlug = undefined;
basePatch.lastReviewedAt = now;
} else if (nextVerdict === "clean" && !alreadyBlocked) {
basePatch.moderationStatus = "active";
@@ -6891,6 +7124,33 @@ async function renameOwnedSkillByActor(
throw new ConvexError(formatReservedSlugCooldownMessage(newSlug, reservation.expiresAt));
}
const aliasesForSkill = await listSkillSlugAliasesForSkill(ctx, skill._id);
const aliasRemovedForNewSlug =
existingAlias && existingAlias.skillId === skill._id ? existingAlias : null;
const previousAlias = await getSkillSlugAliasBySlug(ctx, skill.slug);
const addedSkillAliases = previousAlias?.skillId === skill._id ? 0 : 1;
const removedSkillAliases = aliasRemovedForNewSlug ? 1 : 0;
const addedOwnerAliases = previousAlias
? sameSkillSlugAliasOwner(previousAlias, skill.ownerUserId, skill.ownerPublisherId)
? 0
: 1
: 1;
const removedOwnerAliases =
aliasRemovedForNewSlug &&
sameSkillSlugAliasOwner(aliasRemovedForNewSlug, skill.ownerUserId, skill.ownerPublisherId)
? 1
: 0;
await assertSkillSlugAliasQuota(ctx, {
targetSkillId: skill._id,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId,
currentSkillAliasCount: aliasesForSkill.length,
addedSkillAliases,
removedSkillAliases,
addedOwnerAliases,
removedOwnerAliases,
});
if (existingAlias && existingAlias.skillId === skill._id) {
await ctx.db.delete(existingAlias._id);
}
@@ -6901,18 +7161,19 @@ async function renameOwnedSkillByActor(
});
await releaseActiveReservationsForSlug(ctx, newSlug, now);
const previousAlias = await getSkillSlugAliasBySlug(ctx, skill.slug);
if (previousAlias) {
await ctx.db.patch(previousAlias._id, {
skillId: skill._id,
ownerUserId: actorUserId,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId,
updatedAt: now,
});
} else {
await ctx.db.insert("skillSlugAliases", {
slug: skill.slug,
skillId: skill._id,
ownerUserId: actorUserId,
ownerUserId: skill.ownerUserId,
ownerPublisherId: skill.ownerPublisherId,
createdAt: now,
updatedAt: now,
});
@@ -6966,16 +7227,61 @@ async function mergeOwnedSkillIntoCanonicalByActor(
if (source._id === target._id) {
throw new ConvexError("Source and target must be different skills");
}
if (source.ownerUserId !== actorUserId || target.ownerUserId !== actorUserId) {
throw new ConvexError("Forbidden");
}
await assertCanManageOwnedResource(ctx, {
actor: user,
ownerUserId: source.ownerUserId,
ownerPublisherId: source.ownerPublisherId,
});
await assertCanManageOwnedResource(ctx, {
actor: user,
ownerUserId: target.ownerUserId,
ownerPublisherId: target.ownerPublisherId,
});
const targetLatestVersion = target.latestVersionId
? await ctx.db.get(target.latestVersionId)
: null;
const targetCanonicalSkillId = target.canonicalSkillId ?? target._id;
const targetAliases = await listSkillSlugAliasesForSkill(ctx, target._id);
const targetAliasSlugs = new Set(targetAliases.map((alias) => alias.slug));
const aliases = await listSkillSlugAliasesForSkill(ctx, source._id);
const sourceAlias = await getSkillSlugAliasBySlug(ctx, source.slug);
const addedSkillAliasSlugs = new Set<string>();
const addedOwnerAliasSlugs = new Set<string>();
for (const alias of aliases) {
if (alias.slug === target.slug) continue;
if (!targetAliasSlugs.has(alias.slug)) {
addedSkillAliasSlugs.add(alias.slug);
}
if (!sameSkillSlugAliasOwner(alias, target.ownerUserId, target.ownerPublisherId)) {
addedOwnerAliasSlugs.add(alias.slug);
}
}
if (sourceAlias) {
if (sourceAlias.skillId !== target._id && !targetAliasSlugs.has(source.slug)) {
addedSkillAliasSlugs.add(source.slug);
}
if (!sameSkillSlugAliasOwner(sourceAlias, target.ownerUserId, target.ownerPublisherId)) {
addedOwnerAliasSlugs.add(source.slug);
}
} else {
if (!targetAliasSlugs.has(source.slug)) {
addedSkillAliasSlugs.add(source.slug);
}
addedOwnerAliasSlugs.add(source.slug);
}
await assertSkillSlugAliasQuota(ctx, {
targetSkillId: target._id,
ownerUserId: target.ownerUserId,
ownerPublisherId: target.ownerPublisherId,
currentSkillAliasCount: targetAliases.length,
addedSkillAliases: addedSkillAliasSlugs.size,
addedOwnerAliases: addedOwnerAliasSlugs.size,
});
for (const alias of aliases) {
if (alias.slug === target.slug) {
await ctx.db.delete(alias._id);
@@ -6984,15 +7290,16 @@ async function mergeOwnedSkillIntoCanonicalByActor(
await ctx.db.patch(alias._id, {
skillId: target._id,
ownerUserId: target.ownerUserId,
ownerPublisherId: target.ownerPublisherId,
updatedAt: now,
});
}
const sourceAlias = await getSkillSlugAliasBySlug(ctx, source.slug);
if (sourceAlias) {
await ctx.db.patch(sourceAlias._id, {
skillId: target._id,
ownerUserId: target.ownerUserId,
ownerPublisherId: target.ownerPublisherId,
updatedAt: now,
});
} else {
@@ -7000,6 +7307,7 @@ async function mergeOwnedSkillIntoCanonicalByActor(
slug: source.slug,
skillId: target._id,
ownerUserId: target.ownerUserId,
ownerPublisherId: target.ownerPublisherId,
createdAt: now,
updatedAt: now,
});
@@ -7032,6 +7340,7 @@ async function mergeOwnedSkillIntoCanonicalByActor(
const nextSkill = { ...source, ...patch };
await ctx.db.patch(source._id, patch);
await adjustGlobalPublicCountForSkillChange(ctx, source, nextSkill);
await adjustUserSkillStatsForSkillChange(ctx, source, nextSkill);
await setSkillEmbeddingsSoftDeleted(ctx, source._id, true, now);
await ctx.db.insert("auditLogs", {
@@ -7703,6 +8012,23 @@ export const insertVersion = internalMutation({
.withIndex("by_slug", (q) => q.eq("slug", normalizedSlug))
.unique();
if (skill && skill.softDeletedAt && skill.ownerUserId !== userId) {
const unpublishedReservationExpiresAt = getUnpublishedSlugReservationExpiresAt(skill);
if (unpublishedReservationExpiresAt !== null) {
if (unpublishedReservationExpiresAt > now) {
throw new ConvexError(
formatUnpublishedSlugReservationMessage(
normalizedSlug,
unpublishedReservationExpiresAt,
),
);
}
normalizeSkillSlugForWrite(args.slug);
await releaseExpiredUnpublishedSkillSlug(ctx, skill, now, userId);
skill = null;
}
}
// Only enforce the strict write-path rules when creating a new skill.
// For existing rows, keep the already-persisted (possibly grandfathered)
// slug as-is so legacy publishers are not locked out of version updates.
@@ -8210,6 +8536,9 @@ export const insertVersion = internalMutation({
moderationFlags: nextFlags.length ? nextFlags : undefined,
moderationReason: moderationReason,
}),
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
updatedAt: now,
};
const patch = applySkillManualOverrideToSkillPatch({
@@ -8437,11 +8766,16 @@ export const setSkillSoftDeletedInternal = internalMutation({
const now = Date.now();
const note = args.reason ? trimManualOverrideNote(args.reason) : undefined;
const slugReservedUntil =
args.deleted && isOwner ? now + UNPUBLISHED_SLUG_RESERVATION_MS : undefined;
const patch: Partial<Doc<"skills">> = {
softDeletedAt: args.deleted ? now : undefined,
moderationStatus: args.deleted ? "hidden" : "active",
hiddenAt: args.deleted ? now : undefined,
hiddenBy: args.deleted ? args.userId : undefined,
unpublishedSlugReservedUntil: slugReservedUntil,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
lastReviewedAt: now,
updatedAt: now,
};
@@ -8471,12 +8805,13 @@ export const setSkillSoftDeletedInternal = internalMutation({
slug,
softDeletedAt: args.deleted ? now : null,
actorRole: user.role ?? "user",
...(slugReservedUntil ? { slugReservedUntil } : {}),
...(note ? { reason: note } : {}),
},
createdAt: now,
});
return { ok: true as const };
return slugReservedUntil ? { ok: true as const, slugReservedUntil } : { ok: true as const };
},
});
@@ -8511,6 +8846,9 @@ export const hideSkillForSecurityRedactionInternal = internalMutation({
moderationNotes: note,
hiddenAt: now,
hiddenBy: actor._id,
unpublishedSlugReservedUntil: undefined,
unpublishedSlugReleasedAt: undefined,
unpublishedOriginalSlug: undefined,
lastReviewedAt: now,
updatedAt: now,
};
+45 -4
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@convex-dev/auth/server", () => ({
getAuthUserId: vi.fn(),
@@ -20,6 +20,10 @@ const setSkillSoftDeletedInternalHandler = (
}>
)._handler;
afterEach(() => {
vi.restoreAllMocks();
});
type UserRole = "user" | "moderator" | "admin";
function makeSkill(overrides: Record<string, unknown> = {}) {
@@ -160,6 +164,7 @@ describe("setSkillSoftDeletedInternal B1 undelete gate", () => {
expect.objectContaining({
moderationStatus: "active",
softDeletedAt: undefined,
unpublishedSlugReservedUntil: undefined,
}),
);
expect(insert).toHaveBeenCalledWith(
@@ -324,7 +329,7 @@ describe("setSkillSoftDeletedInternal B1 undelete gate", () => {
slug: "demo",
deleted: true,
}),
).resolves.toEqual({ ok: true });
).resolves.toMatchObject({ ok: true, slugReservedUntil: expect.any(Number) });
expect(patch).toHaveBeenCalledWith(
"skills:1",
@@ -540,6 +545,8 @@ describe("setSkillSoftDeletedInternal B1 undelete gate", () => {
});
it("still allows owner to soft-delete (deleted=true) their own skill regardless of gate", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const skill = makeSkill({
moderationStatus: "active",
softDeletedAt: undefined,
@@ -558,13 +565,47 @@ describe("setSkillSoftDeletedInternal B1 undelete gate", () => {
slug: "demo",
deleted: true,
}),
).resolves.toEqual({ ok: true });
).resolves.toEqual({ ok: true, slugReservedUntil: now + 30 * 24 * 60 * 60 * 1000 });
expect(patch).toHaveBeenCalledWith(
"skills:1",
expect.objectContaining({
moderationStatus: "hidden",
hiddenBy: "users:owner",
unpublishedSlugReservedUntil: now + 30 * 24 * 60 * 60 * 1000,
}),
);
});
it("returns a slug reservation when an elevated owner soft-deletes their own skill", async () => {
const now = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(now);
const skill = makeSkill({
moderationStatus: "active",
softDeletedAt: undefined,
hiddenAt: undefined,
hiddenBy: undefined,
moderationReason: undefined,
});
const { ctx, patch } = makeCtx({
skill,
actor: { _id: "users:owner", role: "admin" },
});
await expect(
setSkillSoftDeletedInternalHandler(ctx, {
userId: "users:owner",
slug: "demo",
deleted: true,
}),
).resolves.toEqual({ ok: true, slugReservedUntil: now + 30 * 24 * 60 * 60 * 1000 });
expect(patch).toHaveBeenCalledWith(
"skills:1",
expect.objectContaining({
moderationStatus: "hidden",
hiddenBy: "users:owner",
unpublishedSlugReservedUntil: now + 30 * 24 * 60 * 60 * 1000,
}),
);
});
@@ -755,7 +796,7 @@ describe("setSkillSoftDeletedInternal B1 undelete gate", () => {
slug: "demo",
deleted: true,
}),
).resolves.toEqual({ ok: true });
).resolves.toMatchObject({ ok: true, slugReservedUntil: expect.any(Number) });
expect(patch).toHaveBeenCalledWith(
"skills:1",
+1
View File
@@ -166,6 +166,7 @@ Stores your API token + cached registry URL.
- Soft-delete a skill (owner, moderator, or admin).
- Calls `DELETE /api/v1/skills/{slug}`.
- Owner-initiated soft deletes reserve the slug for 30 days; the command prints the expiry time.
- `--reason <text>` records a moderation note on the skill and audit log.
- `--note <text>` is an alias for `--reason`.
- `--yes` skips confirmation.
+9
View File
@@ -1175,6 +1175,15 @@ Optional JSON body:
```
When present, `reason` is stored as the skill moderation note and copied into the audit log.
Owner-initiated soft deletes reserve the slug for 30 days, then the slug can be claimed by
another publisher. The delete response includes `slugReservedUntil` when this expiry applies.
Moderator/admin hides and security removals do not expire this way.
Delete response:
```json
{ "ok": true, "slugReservedUntil": 1730000000000 }
```
Status codes:
@@ -43,6 +43,17 @@ describe("delete/undelete", () => {
);
});
it("prints the slug reservation expiry returned by delete", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({
ok: true,
slugReservedUntil: 1_700_086_400_000,
});
await cmdDeleteSkill(makeGlobalOpts(), "demo", { yes: true }, false);
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
"OK. Deleted demo. Slug reserved until 2023-11-15T22:13:20.000Z",
);
});
it("passes a moderation reason on delete", async () => {
httpMocks.apiRequest.mockResolvedValueOnce({ ok: true });
await cmdDeleteSkill(makeGlobalOpts(), "demo", { yes: true, reason: "legal hold" }, false);
+9 -3
View File
@@ -22,7 +22,7 @@ const deleteLabels: SkillActionLabels = {
verb: "Delete",
progress: "Deleting",
past: "Deleted",
promptSuffix: "soft delete, owner/moderator/admin",
promptSuffix: "soft delete; owner slug reservation expires after 30 days",
};
const undeleteLabels: SkillActionLabels = {
@@ -78,8 +78,9 @@ export async function cmdDeleteSkill(
},
ApiV1DeleteResponseSchema,
);
spinner.succeed(`OK. ${labels.past} ${slug}`);
return parseArk(ApiV1DeleteResponseSchema, result, "Delete response");
const parsed = parseArk(ApiV1DeleteResponseSchema, result, "Delete response");
spinner.succeed(`OK. ${labels.past} ${slug}${formatSlugReservation(parsed)}`);
return parsed;
} catch (error) {
spinner.fail(formatError(error));
throw error;
@@ -159,3 +160,8 @@ function formatPrompt(labels: SkillActionLabels, slug: string) {
const suffix = labels.promptSuffix ? ` (${labels.promptSuffix})` : "";
return `${labels.verb} ${slug}?${suffix}`;
}
function formatSlugReservation(result: { slugReservedUntil?: number }) {
if (typeof result.slugReservedUntil !== "number") return "";
return `. Slug reserved until ${new Date(result.slugReservedUntil).toISOString()}`;
}
+2
View File
@@ -109,6 +109,7 @@ export type CliSkillDeleteRequest = (typeof CliSkillDeleteRequestSchema)[inferre
export const ApiCliSkillDeleteResponseSchema = type({
ok: "true",
slugReservedUntil: "number?",
});
export const ApiSkillResolveResponseSchema = type({
@@ -404,6 +405,7 @@ export const ApiV1PublishResponseSchema = type({
export const ApiV1DeleteResponseSchema = type({
ok: "true",
slugReservedUntil: "number?",
});
export const ApiV1RescanResponseSchema = type({
+2
View File
@@ -110,6 +110,7 @@ export type CliSkillDeleteRequest = (typeof CliSkillDeleteRequestSchema)[inferre
export const ApiCliSkillDeleteResponseSchema = type({
ok: "true",
slugReservedUntil: "number?",
});
export const ApiSkillResolveResponseSchema = type({
@@ -413,6 +414,7 @@ export const ApiV1PublishResponseSchema = type({
export const ApiV1DeleteResponseSchema = type({
ok: "true",
slugReservedUntil: "number?",
});
export const ApiV1RescanResponseSchema = type({
+6 -6
View File
@@ -47,19 +47,19 @@ read_when:
- `SKILL.md`
- `notes.md`
- Publish:
- `bun clawhub skill publish . --slug clawhub-manual-<ts> --name "Manual <ts>" --version 1.0.0 --tags latest`
- `bun clawhub skill publish . --slug manual-skill-<ts> --name "Manual <ts>" --version 1.0.0 --tags latest`
- Publish update with empty changelog:
- `bun clawhub skill publish . --slug clawhub-manual-<ts> --name "Manual <ts>" --version 1.0.1 --tags latest`
- `bun clawhub skill publish . --slug manual-skill-<ts> --name "Manual <ts>" --version 1.0.1 --tags latest`
## Delete / undelete (owner/admin)
- `bun clawhub delete clawhub-manual-<ts> --yes`
- `bun clawhub delete manual-skill-<ts> --yes`
- Verify hidden:
- `curl -i "https://clawhub.ai/api/v1/skills/clawhub-manual-<ts>"`
- `curl -i "https://clawhub.ai/api/v1/skills/manual-skill-<ts>"`
- Restore:
- `bun clawhub undelete clawhub-manual-<ts> --yes`
- `bun clawhub undelete manual-skill-<ts> --yes`
- Cleanup:
- `bun clawhub delete clawhub-manual-<ts> --yes`
- `bun clawhub delete manual-skill-<ts> --yes`
## Sync
+5
View File
@@ -236,6 +236,11 @@ Role semantics:
Moderators/admins keep global override powers as they do today.
Skill slug merges are content-management operations. They must authorize through
publisher ownership, not only `ownerUserId`, so org owners/admins can merge two
skills owned by the same manageable publisher. Merge aliases must keep both
`ownerUserId` and `ownerPublisherId` aligned to the live target skill.
## Publishing Flow Changes
### Skills
+24
View File
@@ -102,6 +102,30 @@ Unknown top-level slugs still fall back to skill resolution. Unknown
`@scope/name` owner routes return not found unless a dedicated package route
handles them under `/plugins/...`.
Skill write paths must reject platform and trust-signal namespace squatting.
Exact route/brand/role words are reserved, and slugs that start or end with
protected affixes such as `openclaw-`, `-openclaw`, `official-`, or
`-official` are blocked unless an internal/admin path explicitly bypasses the
reserved list for a controlled migration.
Historical slug redirects are also bounded. Rename and merge may preserve old
slugs as aliases, but a single skill can keep at most five historical slug
redirects and an owner/publisher can keep at most 25. These limits prevent
alias-hoarding while preserving ordinary rename and duplicate-merge redirects.
Owner-initiated unpublishes must not reserve a slug forever. When an owner
soft-deletes a skill, the slug remains reserved for 30 days so they can restore
or republish accidental deletes. After that TTL, availability checks may show
the slug as claimable and the next publish by another owner lazily moves the old
hidden row to an internal `__unpublished_<skill-id>` slug before creating the new
skill. That internal namespace must remain outside the public slug validator and
the release path must collision-check both skill slugs and historical aliases
before patching the hidden row. The audit actor for a lazy release is the caller
who triggered the post-expiry claim; the previous owner is preserved in audit
metadata. The release path may honor a stored reservation timestamp only while
the current hide provenance is still owner-initiated (`hiddenBy === ownerUserId`).
Moderator/security hides are not owner unpublishes and do not expire.
## Adding an official extension
When OpenClaw ships a new extension: