mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
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:
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user