From b6875e60f64b0a93dffa73dd415c9fd2e79c1488 Mon Sep 17 00:00:00 2001 From: Jason <263060202+fuller-stack-dev@users.noreply.github.com> Date: Sat, 9 May 2026 02:55:32 -0600 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + convex/devSeed.ts | 148 +++++- convex/httpApiV1.handlers.test.ts | 4 +- convex/httpApiV1/skillsV1.ts | 8 +- convex/lib/skillSlugValidator.test.ts | 14 +- convex/lib/skillSlugValidator.ts | 43 +- convex/schema.ts | 3 + convex/skills.ownership.test.ts | 323 ++++++++++++- convex/skills.rateLimit.test.ts | 457 ++++++++++++++++++ convex/skills.slugAvailability.test.ts | 136 ++++++ convex/skills.ts | 354 +++++++++++++- convex/skills.undeleteGate.test.ts | 49 +- docs/cli.md | 1 + docs/http-api.md | 9 + .../clawhub/src/cli/commands/delete.test.ts | 11 + packages/clawhub/src/cli/commands/delete.ts | 12 +- packages/clawhub/src/schema/schemas.ts | 2 + packages/schema/src/schemas.ts | 2 + specs/manual-testing.md | 12 +- specs/orgs.md | 5 + specs/slug-routing.md | 24 + 21 files changed, 1563 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4984e224..533afd96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/convex/devSeed.ts b/convex/devSeed.ts index 1db4497b..a8bc5417 100644 --- a/convex/devSeed.ts +++ b/convex/devSeed.ts @@ -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) { )}${rawSkillMd.slice(frontmatterEnd)}`; } +async function seedPluginPackageBatch( + ctx: ActionCtx, + args: SeedActionArgs, + specs: SeedPluginSpec[], +): Promise { + 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 }; } diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index 592ab7be..327cb69a 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -2778,7 +2778,7 @@ describe("httpApiV1 handlers", () => { } as never); const runMutation = vi.fn(async (_query: unknown, args: Record) => { 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({ diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index 88e52923..573942af 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -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); } diff --git a/convex/lib/skillSlugValidator.test.ts b/convex/lib/skillSlugValidator.test.ts index 72b590c1..66c5b514 100644 --- a/convex/lib/skillSlugValidator.test.ts +++ b/convex/lib/skillSlugValidator.test.ts @@ -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", () => { diff --git a/convex/lib/skillSlugValidator.ts b/convex/lib/skillSlugValidator.ts index a139d798..330f459c 100644 --- a/convex/lib/skillSlugValidator.ts +++ b/convex/lib/skillSlugValidator.ts @@ -105,9 +105,26 @@ const RESERVED_SKILL_SLUGS: ReadonlySet = 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; } diff --git a/convex/schema.ts b/convex/schema.ts index 0735347c..c0b10bd1 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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()), diff --git a/convex/skills.ownership.test.ts b/convex/skills.ownership.test.ts index 115cdb77..1971db76 100644 --- a/convex/skills.ownership.test.ts +++ b/convex/skills.ownership.test.ts @@ -5,7 +5,7 @@ vi.mock("@convex-dev/auth/server", () => ({ authTables: {}, })); -import { getSkillBySlugInternal } from "./skills"; +import { getSkillBySlugInternal, mergeOwnedSkillIntoCanonicalInternal } from "./skills"; type WrappedHandler = { _handler: (ctx: unknown, args: TArgs) => Promise; @@ -14,12 +14,30 @@ type WrappedHandler = { 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) { + 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) => unknown) => { + const constraints: Record = {}; + 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) => unknown) => { + const constraints: Record = {}; + 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) => unknown) => { + const constraints: Record = {}; + 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) => unknown) => { + const constraints: Record = {}; + 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(); + }); }); diff --git a/convex/skills.rateLimit.test.ts b/convex/skills.rateLimit.test.ts index bbaa5678..6268b117 100644 --- a/convex/skills.rateLimit.test.ts +++ b/convex/skills.rateLimit.test.ts @@ -89,6 +89,15 @@ function createPublishArgs(overrides?: Partial>) { }; } +function chainEq(constraints: Record) { + 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>([ + [ + "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, + maybeValue?: Record, + ) => { + 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) => { + 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) => unknown) => { + const constraints: Record = {}; + 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) => unknown) => { + if (name !== "by_slug") throw new Error(`unexpected skillSlugAliases index ${name}`); + const constraints: Record = {}; + 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>([ + [ + "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) => unknown) => { + const constraints: Record = {}; + 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) => unknown) => { + const constraints: Record = {}; + 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 () => {}); diff --git a/convex/skills.slugAvailability.test.ts b/convex/skills.slugAvailability.test.ts index de107fd8..c6faf193 100644 --- a/convex/skills.slugAvailability.test.ts +++ b/convex/skills.slugAvailability.test.ts @@ -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); diff --git a/convex/skills.ts b/convex/skills.ts index 97432b7a..5d14abe5 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -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, "_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, "ownerUserId" | "ownerPublisherId">, + ownerUserId: Id<"users">, + ownerPublisherId: Id<"publishers"> | undefined, +) { + return ( + alias.ownerUserId === ownerUserId && + (alias.ownerPublisherId ?? null) === (ownerPublisherId ?? null) + ); +} + +async function countSkillSlugAliasesForOwnerQuota( + ctx: Pick, + 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, + 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, 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(); + const addedOwnerAliasSlugs = new Set(); + + 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> = { 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, }; diff --git a/convex/skills.undeleteGate.test.ts b/convex/skills.undeleteGate.test.ts index def9c8a4..e1f95e67 100644 --- a/convex/skills.undeleteGate.test.ts +++ b/convex/skills.undeleteGate.test.ts @@ -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 = {}) { @@ -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", diff --git a/docs/cli.md b/docs/cli.md index 2bdab3de..fe5b28d1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 ` records a moderation note on the skill and audit log. - `--note ` is an alias for `--reason`. - `--yes` skips confirmation. diff --git a/docs/http-api.md b/docs/http-api.md index da7bed4a..3ffd9fbe 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -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: diff --git a/packages/clawhub/src/cli/commands/delete.test.ts b/packages/clawhub/src/cli/commands/delete.test.ts index bcc71991..a2623fc2 100644 --- a/packages/clawhub/src/cli/commands/delete.test.ts +++ b/packages/clawhub/src/cli/commands/delete.test.ts @@ -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); diff --git a/packages/clawhub/src/cli/commands/delete.ts b/packages/clawhub/src/cli/commands/delete.ts index 48e9b5bf..a0f6e7fb 100644 --- a/packages/clawhub/src/cli/commands/delete.ts +++ b/packages/clawhub/src/cli/commands/delete.ts @@ -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()}`; +} diff --git a/packages/clawhub/src/schema/schemas.ts b/packages/clawhub/src/schema/schemas.ts index 76e4bc58..56d4b02d 100644 --- a/packages/clawhub/src/schema/schemas.ts +++ b/packages/clawhub/src/schema/schemas.ts @@ -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({ diff --git a/packages/schema/src/schemas.ts b/packages/schema/src/schemas.ts index 355d179e..47601d33 100644 --- a/packages/schema/src/schemas.ts +++ b/packages/schema/src/schemas.ts @@ -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({ diff --git a/specs/manual-testing.md b/specs/manual-testing.md index 065100bb..24d7951e 100644 --- a/specs/manual-testing.md +++ b/specs/manual-testing.md @@ -47,19 +47,19 @@ read_when: - `SKILL.md` - `notes.md` - Publish: - - `bun clawhub skill publish . --slug clawhub-manual- --name "Manual " --version 1.0.0 --tags latest` + - `bun clawhub skill publish . --slug manual-skill- --name "Manual " --version 1.0.0 --tags latest` - Publish update with empty changelog: - - `bun clawhub skill publish . --slug clawhub-manual- --name "Manual " --version 1.0.1 --tags latest` + - `bun clawhub skill publish . --slug manual-skill- --name "Manual " --version 1.0.1 --tags latest` ## Delete / undelete (owner/admin) -- `bun clawhub delete clawhub-manual- --yes` +- `bun clawhub delete manual-skill- --yes` - Verify hidden: -- `curl -i "https://clawhub.ai/api/v1/skills/clawhub-manual-"` +- `curl -i "https://clawhub.ai/api/v1/skills/manual-skill-"` - Restore: - - `bun clawhub undelete clawhub-manual- --yes` + - `bun clawhub undelete manual-skill- --yes` - Cleanup: - - `bun clawhub delete clawhub-manual- --yes` + - `bun clawhub delete manual-skill- --yes` ## Sync diff --git a/specs/orgs.md b/specs/orgs.md index 41047083..f962bf28 100644 --- a/specs/orgs.md +++ b/specs/orgs.md @@ -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 diff --git a/specs/slug-routing.md b/specs/slug-routing.md index 6394b40d..48784cbd 100644 --- a/specs/slug-routing.md +++ b/specs/slug-routing.md @@ -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_` 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: