From f0cfb4bc9660972adbf629be3df1e12ed29df36f Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Sat, 16 May 2026 10:44:26 -0300 Subject: [PATCH 01/12] fix(api): guard moderated skill files and tags --- CHANGELOG.md | 1 + convex/downloads.test.ts | 61 ++++++++ convex/downloads.ts | 36 +---- convex/httpApiV1.handlers.test.ts | 247 +++++++++++++++++++++++++++++- convex/httpApiV1.shared.test.ts | 21 +++ convex/httpApiV1/packagesV1.ts | 43 ++++-- convex/httpApiV1/shared.ts | 46 +++++- convex/httpApiV1/skillsV1.ts | 22 ++- convex/lib/skillFileAccess.ts | 58 +++++++ convex/skills.deleteTags.test.ts | 109 ++++++++++++- convex/skills.public.test.ts | 24 +++ convex/skills.ts | 42 +++-- convex/versionFileAccess.test.ts | 4 +- specs/security-moderation.md | 8 + 14 files changed, 654 insertions(+), 68 deletions(-) create mode 100644 convex/lib/skillFileAccess.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e3ef4568ed..36640efc6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - API: fix `GET /api/v1/skills` pagination so `cursor` advances to the next page instead of repeating the first page for supported non-trending sorts (#2275) (thanks @vyctorbrzezowski, @enerj). +- API: block public raw skill files when moderation already blocks downloads and reject skill tags that point at another skill's version (thanks @vyctorbrzezowski). ## 0.17.0 - 2026-05-19 diff --git a/convex/downloads.test.ts b/convex/downloads.test.ts index 47499e3b13..d123267bf7 100644 --- a/convex/downloads.test.ts +++ b/convex/downloads.test.ts @@ -91,6 +91,7 @@ describe("downloads helpers", () => { if ("versionId" in args) { return { _id: "skillVersions:1", + skillId: "skills:1", version: "1.0.0", createdAt: 3, files: [{ path: "SKILL.md", storageId: "_storage:1" }], @@ -141,4 +142,64 @@ describe("downloads helpers", () => { hourStart: expect.any(Number), }); }); + + it("does not serve a tag that points at another skill's version", async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + if ("slug" in args) { + return { + skill: { + _id: "skills:1", + ownerUserId: "users:1", + slug: "demo", + tags: { old: "skillVersions:other" }, + latestVersionId: "skillVersions:1", + }, + moderationInfo: null, + }; + } + if (args.versionId === "skillVersions:1") { + return { + _id: "skillVersions:1", + skillId: "skills:1", + version: "1.0.0", + createdAt: 3, + files: [], + softDeletedAt: undefined, + }; + } + if (args.versionId === "skillVersions:other") { + return { + _id: "skillVersions:other", + skillId: "skills:other", + version: "9.9.9", + createdAt: 4, + files: [{ path: "SKILL.md", storageId: "_storage:other" }], + softDeletedAt: undefined, + }; + } + return null; + }); + const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { + if (isRateLimitArgs(args)) return okRate(); + return null; + }); + const storageGet = vi.fn(); + + const response = await downloadZipHandler( + { + runQuery, + runMutation, + scheduler: { runAfter: vi.fn() }, + storage: { get: storageGet }, + } as unknown as ActionCtx, + new Request("https://example.com/api/v1/download?slug=demo&tag=old", { + headers: { "cf-connecting-ip": "1.2.3.4" }, + }), + ); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Version not found"); + expect(storageGet).not.toHaveBeenCalled(); + }); }); diff --git a/convex/downloads.ts b/convex/downloads.ts index 0e54765b0f..14b39b6704 100644 --- a/convex/downloads.ts +++ b/convex/downloads.ts @@ -4,6 +4,7 @@ import { httpAction, internalMutation } from "./functions"; import { getOptionalApiTokenUserId } from "./lib/apiTokenAuth"; import { corsHeaders, mergeHeaders } from "./lib/httpHeaders"; import { applyRateLimit, getClientIp } from "./lib/httpRateLimit"; +import { getPublicSkillFileAccessBlock, isSkillVersionForSkill } from "./lib/skillFileAccess"; import { buildDeterministicZip } from "./lib/skillZip"; import { hashToken } from "./lib/tokens"; import { insertStatEvent } from "./skillStatEvents"; @@ -41,35 +42,10 @@ export async function downloadZipHandler( }); } - // Block downloads based on moderation status. - const mod = skillResult.moderationInfo; - if (mod?.isMalwareBlocked) { - return new Response( - "Blocked: this skill has been flagged as malicious by ClawScan and cannot be downloaded.", - { - status: 403, - headers: mergeHeaders(rate.headers, corsHeaders()), - }, - ); - } - if (mod?.isPendingScan) { - return new Response( - "This skill is pending a ClawScan security review. Please try again in a few minutes.", - { - status: 423, - headers: mergeHeaders(rate.headers, corsHeaders()), - }, - ); - } - if (mod?.isRemoved) { - return new Response("This skill has been removed by a moderator.", { - status: 410, - headers: mergeHeaders(rate.headers, corsHeaders()), - }); - } - if (mod?.isHiddenByMod) { - return new Response("This skill is currently unavailable.", { - status: 403, + const moderationBlock = getPublicSkillFileAccessBlock(skillResult.moderationInfo); + if (moderationBlock) { + return new Response(moderationBlock.message, { + status: moderationBlock.status, headers: mergeHeaders(rate.headers, corsHeaders()), }); } @@ -93,7 +69,7 @@ export async function downloadZipHandler( } } - if (!version) { + if (!version || !isSkillVersionForSkill(version, skill._id)) { return new Response("Version not found", { status: 404, headers: mergeHeaders(rate.headers, corsHeaders()), diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index ff89d39108..41d3d62215 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -720,7 +720,9 @@ describe("httpApiV1 handlers", () => { } // Batch query: versionIds (plural) if ("versionIds" in args) { - return [{ _id: "versions:1", version: "1.0.0", softDeletedAt: undefined }]; + return [ + { _id: "versions:1", skillId: "skills:1", version: "1.0.0", softDeletedAt: undefined }, + ]; } return null; }); @@ -777,9 +779,9 @@ describe("httpApiV1 handlers", () => { expect(ids).toContain("versions:2"); expect(ids).toContain("versions:3"); return [ - { _id: "versions:1", version: "2.0.0", softDeletedAt: undefined }, - { _id: "versions:2", version: "1.0.0", softDeletedAt: undefined }, - { _id: "versions:3", version: "1.0.0", softDeletedAt: undefined }, + { _id: "versions:1", skillId: "skills:1", version: "2.0.0", softDeletedAt: undefined }, + { _id: "versions:2", skillId: "skills:1", version: "1.0.0", softDeletedAt: undefined }, + { _id: "versions:3", skillId: "skills:2", version: "1.0.0", softDeletedAt: undefined }, ]; } return null; @@ -2040,6 +2042,8 @@ describe("httpApiV1 handlers", () => { updatedAt: 2, }, latestVersion: { + _id: "skillVersions:1", + skillId: "skills:1", version: "1.0.0", createdAt: 1, changelog: "c", @@ -2110,6 +2114,8 @@ describe("httpApiV1 handlers", () => { updatedAt: 2, }, latestVersion: { + _id: "skillVersions:1", + skillId: "skills:1", version: "1.0.0", createdAt: 1, changelog: "c", @@ -2161,6 +2167,8 @@ describe("httpApiV1 handlers", () => { updatedAt: 2, }, latestVersion: { + _id: "skillVersions:1", + skillId: "skills:1", version: "1.0.0", createdAt: 1, changelog: "c", @@ -2207,6 +2215,7 @@ describe("httpApiV1 handlers", () => { }, latestVersion: { _id: "skillVersions:2", + skillId: "skills:1", version: "2.0.0", createdAt: 2, changelog: "c", @@ -2264,6 +2273,7 @@ describe("httpApiV1 handlers", () => { }, latestVersion: { _id: "skillVersions:2", + skillId: "skills:1", version: "2.0.0", createdAt: 2, changelog: "c2", @@ -2288,6 +2298,7 @@ describe("httpApiV1 handlers", () => { if ("skillId" in args && "version" in args) { return { _id: "skillVersions:1", + skillId: "skills:1", version: "1.0.0", createdAt: 1, changelog: "c1", @@ -2337,6 +2348,7 @@ describe("httpApiV1 handlers", () => { }, latestVersion: { _id: "skillVersions:2", + skillId: "skills:1", version: "2.0.0", createdAt: 2, changelog: "c2", @@ -2361,6 +2373,7 @@ describe("httpApiV1 handlers", () => { if ("versionId" in args) { return { _id: "skillVersions:1", + skillId: "skills:1", version: "1.0.0", createdAt: 1, changelog: "c1", @@ -2391,8 +2404,51 @@ describe("httpApiV1 handlers", () => { expect(json.moderation.matchesRequestedVersion).toBe(false); }); + it("does not resolve scan tags to another skill's version", async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ("slug" in args) { + return { + skill: { + _id: "skills:1", + slug: "demo", + displayName: "Demo", + summary: "s", + tags: { old: "skillVersions:other" }, + stats: {}, + createdAt: 1, + updatedAt: 2, + }, + latestVersion: null, + owner: null, + moderationInfo: null, + }; + } + if ("versionId" in args) { + return { + _id: "skillVersions:other", + skillId: "skills:other", + version: "9.9.9", + createdAt: 9, + changelog: "other", + files: [], + }; + } + return null; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + + const response = await __handlers.skillsGetRouterV1Handler( + makeCtx({ runQuery, runMutation }), + new Request("https://example.com/api/v1/skills/demo/scan?tag=old"), + ); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Version not found"); + }); + it("returns raw file content", async () => { const internalVersion = { + skillId: "skills:1", version: "1.0.0", createdAt: 1, changelog: "c", @@ -2443,8 +2499,96 @@ describe("httpApiV1 handlers", () => { expect(response.headers.get("X-Content-SHA256")).toBe("abcd"); }); + it("blocks raw file reads for malware-blocked skills", async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ("slug" in args) { + return { + skill: { + _id: "skills:1", + slug: "demo", + displayName: "Demo", + summary: "s", + tags: {}, + stats: {}, + createdAt: 1, + updatedAt: 2, + latestVersionId: "skillVersions:1", + }, + latestVersion: null, + owner: null, + moderationInfo: { + isMalwareBlocked: true, + isPendingScan: false, + isHiddenByMod: false, + isRemoved: false, + }, + }; + } + throw new Error("unexpected version lookup"); + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + const storage = { get: vi.fn() }; + + const response = await __handlers.skillsGetRouterV1Handler( + makeCtx({ runQuery, runMutation, storage }), + new Request("https://example.com/api/v1/skills/demo/file?path=SKILL.md"), + ); + + expect(response.status).toBe(403); + expect(await response.text()).toContain("flagged as malicious"); + expect(storage.get).not.toHaveBeenCalled(); + }); + + it("does not serve raw files from another skill's tagged version", async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ("slug" in args) { + return { + skill: { + _id: "skills:1", + slug: "demo", + displayName: "Demo", + summary: "s", + tags: { old: "skillVersions:other" }, + stats: {}, + createdAt: 1, + updatedAt: 2, + latestVersionId: "skillVersions:1", + }, + latestVersion: null, + owner: null, + moderationInfo: null, + }; + } + if (args.versionId === "skillVersions:1") { + return { _id: "skillVersions:1", skillId: "skills:1", version: "1.0.0", files: [] }; + } + if (args.versionId === "skillVersions:other") { + return { + _id: "skillVersions:other", + skillId: "skills:other", + version: "9.9.9", + files: [{ path: "SKILL.md", size: 5, storageId: "storage:other", sha256: "other" }], + softDeletedAt: undefined, + }; + } + return null; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + const storage = { get: vi.fn() }; + + const response = await __handlers.skillsGetRouterV1Handler( + makeCtx({ runQuery, runMutation, storage }), + new Request("https://example.com/api/v1/skills/demo/file?path=SKILL.md&tag=old"), + ); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Version not found"); + expect(storage.get).not.toHaveBeenCalled(); + }); + it("returns 413 when raw file too large", async () => { const internalVersion = { + skillId: "skills:1", version: "1.0.0", createdAt: 1, changelog: "c", @@ -4660,6 +4804,101 @@ describe("httpApiV1 handlers", () => { expect(storage.get).toHaveBeenCalledWith("storage:skill"); }); + it("packages file blocks skill compatibility files for malware-blocked skills", async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ("name" in args) return null; + if ("slug" in args) { + return { + skill: { + _id: "skills:demo", + slug: "demo", + displayName: "Demo Skill", + summary: "Skill summary", + latestVersionId: "skillVersions:demo-1", + tags: { latest: "skillVersions:demo-1" }, + badges: {}, + createdAt: 1, + updatedAt: 2, + }, + latestVersion: null, + owner: { handle: "steipete" }, + moderationInfo: { + isMalwareBlocked: true, + isPendingScan: false, + isHiddenByMod: false, + isRemoved: false, + }, + }; + } + throw new Error("unexpected version lookup"); + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + const storage = { get: vi.fn() }; + + const response = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery, runMutation, storage }), + new Request("https://example.com/api/v1/packages/demo/file?path=README.md"), + ); + + expect(response.status).toBe(403); + expect(await response.text()).toContain("flagged as malicious"); + expect(storage.get).not.toHaveBeenCalled(); + }); + + it("packages file does not serve skill tags pointing at another skill's version", async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ("name" in args) return null; + if ("slug" in args) { + return { + skill: { + _id: "skills:demo", + slug: "demo", + displayName: "Demo Skill", + summary: "Skill summary", + latestVersionId: "skillVersions:demo-1", + tags: { latest: "skillVersions:demo-1", old: "skillVersions:other" }, + badges: {}, + createdAt: 1, + updatedAt: 2, + }, + latestVersion: null, + owner: { handle: "steipete" }, + moderationInfo: null, + }; + } + if (args.versionId === "skillVersions:other") { + return { + _id: "skillVersions:other", + skillId: "skills:other", + version: "9.9.9", + createdAt: 9, + changelog: "other", + files: [ + { + path: "SKILL.md", + size: 11, + sha256: "abc", + storageId: "storage:other", + contentType: "text/markdown", + }, + ], + }; + } + return null; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); + const storage = { get: vi.fn() }; + + const response = await __handlers.packagesGetRouterV1Handler( + makeCtx({ runQuery, runMutation, storage }), + new Request("https://example.com/api/v1/packages/demo/file?path=README.md&tag=old"), + ); + + expect(response.status).toBe(404); + expect(await response.text()).toBe("Version not found"); + expect(storage.get).not.toHaveBeenCalled(); + }); + it("packages download redirects skills to the skill download endpoint", async () => { const runQuery = vi.fn(async (_query: unknown, args: Record) => { if ("name" in args) return null; diff --git a/convex/httpApiV1.shared.test.ts b/convex/httpApiV1.shared.test.ts index 6d2bf30377..a3fc27f569 100644 --- a/convex/httpApiV1.shared.test.ts +++ b/convex/httpApiV1.shared.test.ts @@ -39,4 +39,25 @@ describe("http API v1 shared helpers", () => { expect(ctx.runQuery).toHaveBeenCalledWith({}, { versionIds: [stableId] }); expect(result).toEqual([{ latest: "2.0.0", stable: "1.5.0" }]); }); + + it("filters resolved skill tags by owning skill", async () => { + const ctx = makeCtx(); + const otherId = "skillVersions:other" as Id<"skillVersions">; + const stableId = "skillVersions:stable" as Id<"skillVersions">; + const skillId = "skills:1" as Id<"skills">; + ctx.runQuery.mockResolvedValueOnce([ + { _id: otherId, skillId: "skills:other", version: "9.9.9" }, + { _id: stableId, skillId, version: "1.5.0" }, + ]); + + const result = await resolveVersionTagsBatch( + ctx, + [{ latest: otherId, stable: stableId }], + {} as never, + [{ _id: otherId, skillId: "skills:other" as Id<"skills">, version: "9.9.9" }], + [skillId], + ); + + expect(result).toEqual([{ stable: "1.5.0" }]); + }); }); diff --git a/convex/httpApiV1/packagesV1.ts b/convex/httpApiV1/packagesV1.ts index a593cf0bd0..c57e5e7522 100644 --- a/convex/httpApiV1/packagesV1.ts +++ b/convex/httpApiV1/packagesV1.ts @@ -47,6 +47,7 @@ import { MAX_CLAWPACK_BYTES, MAX_PUBLISH_FILE_BYTES, } from "../lib/publishLimits"; +import { getPublicSkillFileAccessBlock, isSkillVersionForSkill } from "../lib/skillFileAccess"; import { isMacJunkPath, isTextFile } from "../lib/skills"; import { buildDeterministicPackageZip } from "../lib/skillZip"; import { generateToken, hashToken } from "../lib/tokens"; @@ -1010,10 +1011,11 @@ async function searchPackageCatalog( async function resolveSkillTags( ctx: ActionCtx, + skillId: Id<"skills">, tags: Record>, latestVersion?: SkillVersionLike | null, ): Promise> { - const [resolved] = await resolveTagsBatch(ctx, [tags], [latestVersion]); + const [resolved] = await resolveTagsBatch(ctx, [tags], [latestVersion], [skillId]); return resolved ?? {}; } @@ -2313,6 +2315,12 @@ async function getSkillDetailForRequest(ctx: ActionCtx, slug: string) { skill: SkillPackageDocLike | null; latestVersion: SkillVersionLike | null; owner: { handle?: string; displayName?: string; image?: string } | null; + moderationInfo?: { + isPendingScan?: boolean | null; + isMalwareBlocked?: boolean | null; + isHiddenByMod?: boolean | null; + isRemoved?: boolean | null; + } | null; } | null; } @@ -2326,23 +2334,30 @@ async function getSkillVersionForRequest( const tagParam = url.searchParams.get("tag")?.trim(); if (versionParam) { - return (await runQueryRef(ctx, internalRefs.skills.getVersionBySkillAndVersionInternal, { - skillId: skill._id, - version: versionParam, - })) as SkillVersionLike | null; + const version = (await runQueryRef( + ctx, + internalRefs.skills.getVersionBySkillAndVersionInternal, + { + skillId: skill._id, + version: versionParam, + }, + )) as SkillVersionLike | null; + return isSkillVersionForSkill(version, skill._id) ? version : null; } if (tagParam) { const versionId = skill.tags[tagParam]; if (!versionId) return null; - return (await runQueryRef(ctx, internalRefs.skills.getVersionByIdInternal, { + const version = (await runQueryRef(ctx, internalRefs.skills.getVersionByIdInternal, { versionId, })) as SkillVersionLike | null; + return isSkillVersionForSkill(version, skill._id) ? version : null; } const latestVersionId = skill.latestVersionId ?? skill.tags.latest; if (!latestVersionId) return null; - return (await runQueryRef(ctx, internalRefs.skills.getVersionByIdInternal, { + const version = (await runQueryRef(ctx, internalRefs.skills.getVersionByIdInternal, { versionId: latestVersionId, })) as SkillVersionLike | null; + return isSkillVersionForSkill(version, skill._id) ? version : null; } async function searchPackages( @@ -2680,7 +2695,12 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques skillDetail.skill, skillDetail.latestVersion, skillDetail.owner, - await resolveSkillTags(ctx, skillDetail.skill.tags, skillDetail.latestVersion), + await resolveSkillTags( + ctx, + skillDetail.skill._id, + skillDetail.skill.tags, + skillDetail.latestVersion, + ), ), 200, rate.headers, @@ -2739,7 +2759,7 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques items: Array<{ version: string; createdAt: number; changelog: string }>; nextCursor: string | null; }; - const tags = await resolveSkillTags(ctx, skillDetail.skill.tags); + const tags = await resolveSkillTags(ctx, skillDetail.skill._id, skillDetail.skill.tags); return json( { items: result.items.map((version) => ({ @@ -2838,7 +2858,7 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques }, )) as SkillVersionLike | null; if (!version || version.softDeletedAt) return text("Version not found", 404, rate.headers); - const tags = await resolveSkillTags(ctx, skillDetail.skill.tags); + const tags = await resolveSkillTags(ctx, skillDetail.skill._id, skillDetail.skill.tags); return json( { package: { @@ -2916,6 +2936,9 @@ export async function packagesGetRouterV1Handler(ctx: ActionCtx, request: Reques const path = new URL(request.url).searchParams.get("path")?.trim(); if (!path) return text("Missing path", 400, rate.headers); if (skillDetail?.skill) { + const moderationBlock = getPublicSkillFileAccessBlock(skillDetail.moderationInfo); + if (moderationBlock) + return text(moderationBlock.message, moderationBlock.status, rate.headers); const version = await getSkillVersionForRequest(ctx, skillDetail.skill, request); if (!version || version.softDeletedAt) return text("Version not found", 404, rate.headers); const file = resolveSkillFilePath(version, path); diff --git a/convex/httpApiV1/shared.ts b/convex/httpApiV1/shared.ts index e2280f87ec..c009d25c9c 100644 --- a/convex/httpApiV1/shared.ts +++ b/convex/httpApiV1/shared.ts @@ -161,12 +161,14 @@ export async function resolveTagsBatch( ctx: ActionCtx, tagsList: Array>>, latestVersions?: Array>, + skillIds?: Array | undefined>, ): Promise>> { return resolveVersionTagsBatch( ctx, tagsList, internal.skills.getVersionsByIdsInternal, latestVersions, + skillIds, ); } @@ -175,10 +177,28 @@ type LatestVersionTag = _id: Id; version?: string; softDeletedAt?: unknown; + skillId?: Id<"skills">; + soulId?: Id<"souls">; } | null | undefined; +type TagResourceId = Id<"skills"> | Id<"souls">; + +function versionBelongsToResource( + version: + | { + skillId?: Id<"skills">; + soulId?: Id<"souls">; + } + | null + | undefined, + resourceId: TagResourceId | undefined, +) { + if (!resourceId) return true; + return version?.skillId === resourceId || version?.soulId === resourceId; +} + /** * Batch resolve version tags to version strings. * Collects all version IDs, fetches them in a single query, then maps back. @@ -192,13 +212,20 @@ export async function resolveVersionTagsBatch>>, getVersionsByIdsQuery: unknown, latestVersions?: Array>, + resourceIds?: Array, ): Promise>> { const allVersionIds = new Set>(); const preResolvedTags = tagsList.map((tags, idx) => { const resolved: Record = {}; const latest = latestVersions?.[idx]; + const resourceId = resourceIds?.[idx]; for (const [tag, versionId] of Object.entries(tags)) { - if (latest?._id === versionId && latest.version && !latest.softDeletedAt) { + if ( + latest?._id === versionId && + latest.version && + !latest.softDeletedAt && + versionBelongsToResource(latest, resourceId) + ) { resolved[tag] = latest.version; } else { allVersionIds.add(versionId); @@ -217,19 +244,30 @@ export async function resolveVersionTagsBatch; version: string; softDeletedAt?: unknown; + skillId?: Id<"skills">; + soulId?: Id<"souls">; }> | null) ?? []; - const versionMap = new Map, string>(); + const versionMap = new Map< + Id, + { + version: string; + skillId?: Id<"skills">; + soulId?: Id<"souls">; + } + >(); for (const v of versions) { - if (!v?.softDeletedAt) versionMap.set(v._id, v.version); + if (!v?.softDeletedAt) + versionMap.set(v._id, { version: v.version, skillId: v.skillId, soulId: v.soulId }); } return tagsList.map((tags, idx) => { const resolved = { ...preResolvedTags[idx] }; + const resourceId = resourceIds?.[idx]; for (const [tag, versionId] of Object.entries(tags)) { if (resolved[tag]) continue; const version = versionMap.get(versionId); - if (version) resolved[tag] = version; + if (version && versionBelongsToResource(version, resourceId)) resolved[tag] = version.version; } return resolved; }); diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index c1a7634194..455085a942 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -18,6 +18,7 @@ import type { LlmEvalDimension, LlmRiskSummary, } from "../lib/securityPrompt"; +import { getPublicSkillFileAccessBlock, isSkillVersionForSkill } from "../lib/skillFileAccess"; import { publishVersionForUser } from "../skills"; import { MAX_RAW_FILE_BYTES, @@ -98,6 +99,7 @@ type PublicSkillVersionStaticScan = Pick< type PublicSkillVersionResponse = { _id: Id<"skillVersions">; + skillId?: Id<"skills">; version: string; createdAt?: number; changelog?: string; @@ -570,6 +572,7 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) { ctx, result.items.map((item) => item.skill.tags), result.items.map((item) => item.latestVersion), + result.items.map((item) => item.skill._id), ); const items = result.items.map((item, idx) => ({ @@ -702,7 +705,12 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) return text("Skill not found", 404, rate.headers); } - const [tags] = await resolveTagsBatch(ctx, [result.skill.tags], [result.latestVersion]); + const [tags] = await resolveTagsBatch( + ctx, + [result.skill.tags], + [result.latestVersion], + [result.skill._id], + ); return json( { skill: { @@ -923,7 +931,9 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) } } - if (!version) return text("Version not found", 404, rate.headers); + if (!version || !isSkillVersionForSkill(version, result.skill._id)) { + return text("Version not found", 404, rate.headers); + } if (version.softDeletedAt) return text("Version not available", 410, rate.headers); const security = buildSkillSecuritySnapshot(version); @@ -975,6 +985,10 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) const skillResult = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult; if (!skillResult?.skill) return text("Skill not found", 404, rate.headers); + const moderationBlock = getPublicSkillFileAccessBlock(skillResult.moderationInfo); + if (moderationBlock) { + return text(moderationBlock.message, moderationBlock.status, rate.headers); + } let version: Doc<"skillVersions"> | null = skillResult.skill.latestVersionId ? await ctx.runQuery(internal.skills.getVersionByIdInternal, { @@ -993,7 +1007,9 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) } } - if (!version) return text("Version not found", 404, rate.headers); + if (!version || !isSkillVersionForSkill(version, skillResult.skill._id)) { + return text("Version not found", 404, rate.headers); + } if (version.softDeletedAt) return text("Version not available", 410, rate.headers); const normalized = path.trim(); diff --git a/convex/lib/skillFileAccess.ts b/convex/lib/skillFileAccess.ts new file mode 100644 index 0000000000..59799930e4 --- /dev/null +++ b/convex/lib/skillFileAccess.ts @@ -0,0 +1,58 @@ +import type { Id } from "../_generated/dataModel"; + +type SkillFileModerationInfo = { + isPendingScan?: boolean | null; + isMalwareBlocked?: boolean | null; + isHiddenByMod?: boolean | null; + isRemoved?: boolean | null; +}; + +type SkillFileAccessBlock = { + status: number; + message: string; +}; + +export function getPublicSkillFileAccessBlock( + moderationInfo: SkillFileModerationInfo | null | undefined, +): SkillFileAccessBlock | null { + if (moderationInfo?.isMalwareBlocked) { + return { + status: 403, + message: + "Blocked: this skill has been flagged as malicious by ClawScan and cannot be downloaded.", + }; + } + if (moderationInfo?.isPendingScan) { + return { + status: 423, + message: "This skill is pending a ClawScan security review. Please try again in a few minutes.", + }; + } + if (moderationInfo?.isRemoved) { + return { status: 410, message: "This skill has been removed by a moderator." }; + } + if (moderationInfo?.isHiddenByMod) { + return { status: 403, message: "This skill is currently unavailable." }; + } + return null; +} + +export function isSkillVersionForSkill( + version: { skillId?: Id<"skills"> | string | null } | null | undefined, + skillId: Id<"skills"> | string, +) { + return version?.skillId === skillId; +} + +export function isPublicSkillVersionAvailableForSkill( + version: + | { + skillId?: Id<"skills"> | string | null; + softDeletedAt?: number | null; + } + | null + | undefined, + skillId: Id<"skills"> | string, +) { + return Boolean(version && !version.softDeletedAt && isSkillVersionForSkill(version, skillId)); +} diff --git a/convex/skills.deleteTags.test.ts b/convex/skills.deleteTags.test.ts index dc2b2e0fd5..2e81516e52 100644 --- a/convex/skills.deleteTags.test.ts +++ b/convex/skills.deleteTags.test.ts @@ -6,7 +6,7 @@ vi.mock("@convex-dev/auth/server", () => ({ })); const { getAuthUserId } = await import("@convex-dev/auth/server"); -const { deleteTags, updateSummary } = await import("./skills"); +const { deleteTags, updateSummary, updateTags } = await import("./skills"); type WrappedHandler = { _handler: (ctx: unknown, args: TArgs) => Promise; @@ -24,6 +24,12 @@ const updateSummaryHandler = ( summary: string; }> )._handler; +const updateTagsHandler = ( + updateTags as unknown as WrappedHandler<{ + skillId: string; + tags: Array<{ tag: string; versionId: string }>; + }> +)._handler; function buildGlobalStatsQuery(table: string) { if (table !== "globalStats") return null; @@ -48,6 +54,7 @@ function makeCtx(params: { skill: Record | null; publisher?: Record | null; membership?: Record | null; + versionsById?: Record>; }) { vi.mocked(getAuthUserId).mockResolvedValue(params.user._id as never); const patch = vi.fn(async (_id: string, value: Record) => value); @@ -55,6 +62,7 @@ function makeCtx(params: { get: vi.fn(async (id: string) => { if (id === params.user._id) return params.user; if (params.skill && id === params.skill._id) return params.skill; + if (params.versionsById?.[id]) return params.versionsById[id]; if (params.publisher && id === params.publisher._id) return params.publisher; return null; }), @@ -70,6 +78,13 @@ function makeCtx(params: { }), }; } + if (table === "skillEmbeddings") { + return { + withIndex: () => ({ + collect: async () => [], + }), + }; + } throw new Error(`unexpected table ${table}`); }), insert: vi.fn(), @@ -203,6 +218,98 @@ describe("deleteTags", () => { }); }); +describe("updateTags", () => { + beforeEach(() => { + vi.mocked(getAuthUserId).mockReset(); + }); + + it("updates tags only to versions that belong to the skill", async () => { + const { db, auth, patch } = makeCtx({ + user: ownerUser, + skill: baseSkill, + versionsById: { + "versions:2": { + _id: "versions:2", + skillId: "skills:1", + version: "1.0.0", + createdAt: 10, + changelog: "stable", + changelogSource: "user", + parsed: { clawdis: { os: ["macos"] } }, + capabilityTags: ["posts-externally"], + softDeletedAt: undefined, + }, + }, + }); + + await updateTagsHandler( + { db, auth } as never, + { skillId: "skills:1", tags: [{ tag: "stable", versionId: "versions:2" }] } as never, + ); + + expect(patch).toHaveBeenCalledOnce(); + expect(patch.mock.calls[0][1]).toMatchObject({ + tags: expect.objectContaining({ stable: "versions:2" }), + }); + }); + + it("rejects tag updates to another skill's version", async () => { + const { db, auth, patch } = makeCtx({ + user: ownerUser, + skill: baseSkill, + versionsById: { + "versions:other": { + _id: "versions:other", + skillId: "skills:other", + version: "9.9.9", + createdAt: 10, + changelog: "other", + softDeletedAt: undefined, + }, + }, + }); + + await expect( + updateTagsHandler( + { db, auth } as never, + { + skillId: "skills:1", + tags: [{ tag: "stable", versionId: "versions:other" }], + } as never, + ), + ).rejects.toThrow("Version not found"); + expect(patch).not.toHaveBeenCalled(); + }); + + it("rejects tag updates to soft-deleted versions", async () => { + const { db, auth, patch } = makeCtx({ + user: ownerUser, + skill: baseSkill, + versionsById: { + "versions:deleted": { + _id: "versions:deleted", + skillId: "skills:1", + version: "0.9.0", + createdAt: 9, + changelog: "deleted", + softDeletedAt: 123, + }, + }, + }); + + await expect( + updateTagsHandler( + { db, auth } as never, + { + skillId: "skills:1", + tags: [{ tag: "stable", versionId: "versions:deleted" }], + } as never, + ), + ).rejects.toThrow("Version not found"); + expect(patch).not.toHaveBeenCalled(); + }); +}); + describe("updateSummary", () => { beforeEach(() => { vi.mocked(getAuthUserId).mockReset(); diff --git a/convex/skills.public.test.ts b/convex/skills.public.test.ts index 10b2ed9b50..ba1b13b1c1 100644 --- a/convex/skills.public.test.ts +++ b/convex/skills.public.test.ts @@ -503,6 +503,30 @@ describe("skills.getBySlug", () => { }), ]); }); + + it("does not expose a latest version that belongs to another skill", async () => { + const ctx = makeCtx({ + skill: makeSkill({ latestVersionId: "skillVersions:other" }), + owner: makeOwner("users:1", "demo-owner"), + latestVersion: { + _id: "skillVersions:other", + _creationTime: 2, + skillId: "skills:other", + version: "9.9.9", + fingerprint: "abc", + changelog: "", + changelogSource: "user", + files: [], + createdBy: "users:2", + createdAt: 2, + }, + }); + + const result = await getBySlugHandler(ctx, { slug: "demo" } as never); + + expect(result?.skill).toMatchObject({ latestVersionId: "skillVersions:other" }); + expect(result?.latestVersion).toBeNull(); + }); }); describe("skill artifact moderation", () => { diff --git a/convex/skills.ts b/convex/skills.ts index bd4d36aadd..7810c31dbd 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -99,6 +99,10 @@ import { } from "./lib/reservedSlugs"; import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./lib/searchText"; import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags"; +import { + isPublicSkillVersionAvailableForSkill, + isSkillVersionForSkill, +} from "./lib/skillFileAccess"; import { normalizeSkillIconValue } from "./lib/skillIcon"; import { fetchText, @@ -2083,8 +2087,9 @@ export const getBySlug = query({ : null; const isOwner = Boolean(userId && (userId === skill.ownerUserId || membership)); + const latestVersionDoc = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null; const latestVersion = toPublicSkillVersion( - skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null, + isSkillVersionForSkill(latestVersionDoc, skill._id) ? latestVersionDoc : null, ); const owner = toPublicPublisher(ownerPublisher); if (!owner) return null; @@ -7744,8 +7749,7 @@ async function canReadSkillVersionFiles(ctx: ActionCtx, version: Doc<"skillVersi if (skill.softDeletedAt || version.softDeletedAt) return false; - const isMalwareBlocked = skill.moderationFlags?.includes("blocked.malware") ?? false; - return Boolean(toPublicSkill(skill) || isMalwareBlocked); + return Boolean(toPublicSkill(skill)); } export const getReadme: ReturnType = action({ @@ -7870,6 +7874,18 @@ export const updateTags = mutation({ assertModerator(user); } + const versionsById = new Map, Doc<"skillVersions">>(); + for (const entry of args.tags) { + let version = versionsById.get(entry.versionId) ?? null; + if (!version) { + version = await ctx.db.get(entry.versionId); + if (version) versionsById.set(entry.versionId, version); + } + if (!isPublicSkillVersionAvailableForSkill(version, skill._id)) { + throw new Error("Version not found"); + } + } + const nextTags = { ...skill.tags }; for (const entry of args.tags) { nextTags[entry.tag] = entry.versionId; @@ -7885,17 +7901,15 @@ export const updateTags = mutation({ // Keep latestVersionSummary in sync when the latest tag is repointed if (latestEntry && latestEntry.versionId !== skill.latestVersionId) { - const version = await ctx.db.get(latestEntry.versionId); - if (version) { - patch.latestVersionSummary = { - version: version.version, - createdAt: version.createdAt, - changelog: version.changelog, - changelogSource: version.changelogSource, - clawdis: version.parsed?.clawdis, - }; - patch.capabilityTags = version.capabilityTags; - } + const version = versionsById.get(latestEntry.versionId)!; + patch.latestVersionSummary = { + version: version.version, + createdAt: version.createdAt, + changelog: version.changelog, + changelogSource: version.changelogSource, + clawdis: version.parsed?.clawdis, + }; + patch.capabilityTags = version.capabilityTags; } await ctx.db.patch(skill._id, patch); diff --git a/convex/versionFileAccess.test.ts b/convex/versionFileAccess.test.ts index 7ea813a885..2bb6e3303c 100644 --- a/convex/versionFileAccess.test.ts +++ b/convex/versionFileAccess.test.ts @@ -175,7 +175,7 @@ describe("version file access actions", () => { ).rejects.toThrow("Version not available"); }); - it("keeps malware-blocked skill files readable to public callers", async () => { + it("blocks public reads from malware-blocked skill files", async () => { const ctx = makeActionCtx({ version: makeSkillVersion(), skill: { @@ -193,7 +193,7 @@ describe("version file access actions", () => { versionId: "skillVersions:1", path: "SKILL.md", } as never), - ).resolves.toMatchObject({ path: "SKILL.md", text: "# skill" }); + ).rejects.toThrow("Version not available"); }); it("still allows public access to visible skill files", async () => { diff --git a/specs/security-moderation.md b/specs/security-moderation.md index ce59f661e2..ee5c99bfb0 100644 --- a/specs/security-moderation.md +++ b/specs/security-moderation.md @@ -94,6 +94,14 @@ See also: [acceptable-usage.md](./acceptable-usage.md) for the marketplace polic change, link, or membership events, not routine login refreshes. - Public queries hide non-active moderation statuses; moderators can still access via moderator-only queries and unhide/restore/delete/ban. +- Public skill raw-file, README, package-compat file, and zip download reads must + honor the same malware/pending/hidden/removed download block. Metadata routes + may keep exposing malware-blocked skill summaries for transparency, but they + must not serve the blocked artifact payload to public callers. +- Skill version tags and `latestVersionId` are only valid when the referenced + `skillVersions` row belongs to the same skill and is not soft-deleted. Writers + must reject cross-skill tag targets, and public readers should treat stale + cross-skill pointers as missing versions. - Legacy report rows with `status: "triaged"` are read as `confirmed` for compatibility while new writes store `confirmed`. - Skills directory supports an optional "Hide suspicious" filter to exclude From 324155d408956e4523276701c3ddbc52d9713c71 Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Sat, 16 May 2026 19:22:50 -0300 Subject: [PATCH 02/12] fix(api): guard public list latest version ownership --- convex/functions.ts | 4 +- convex/lib/skillSearchDigest.test.ts | 33 +++++++ convex/lib/skillSearchDigest.ts | 18 ++++ convex/maintenance.ts | 25 +++-- convex/schema.ts | 1 + convex/skills.publicListCursor.test.ts | 125 ++++++++++++++++++++++++- convex/skills.ts | 32 ++++--- 7 files changed, 214 insertions(+), 24 deletions(-) diff --git a/convex/functions.ts b/convex/functions.ts index 0915accabe..d7d3dc19f7 100644 --- a/convex/functions.ts +++ b/convex/functions.ts @@ -24,7 +24,7 @@ import { adjustPublisherStatsForPackageChange, adjustPublisherStatsForSkillChange, } from "./lib/publisherStats"; -import { extractDigestFields, upsertSkillSearchDigest } from "./lib/skillSearchDigest"; +import { extractValidatedDigestFields, upsertSkillSearchDigest } from "./lib/skillSearchDigest"; const triggers = new Triggers(); @@ -207,7 +207,7 @@ async function syncSkillSearchDigestForSkill( skill: Doc<"skills"> | null | undefined, ) { if (!skill) return; - const fields = extractDigestFields(skill); + const fields = await extractValidatedDigestFields(ctx, skill); const owner = await getOwnerPublisher(ctx, { ownerPublisherId: skill.ownerPublisherId, ownerUserId: skill.ownerUserId, diff --git a/convex/lib/skillSearchDigest.test.ts b/convex/lib/skillSearchDigest.test.ts index 8fc28fa623..0680e423e5 100644 --- a/convex/lib/skillSearchDigest.test.ts +++ b/convex/lib/skillSearchDigest.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { digestToHydratableSkill, extractDigestFields, + extractValidatedDigestFields, digestToOwnerInfo, } from "./skillSearchDigest"; @@ -155,6 +156,38 @@ describe("extractDigestFields", () => { }); }); +describe("extractValidatedDigestFields", () => { + it("records latest-version ownership when the version belongs to the skill", async () => { + const digest = await extractValidatedDigestFields( + { + db: { + get: async () => ({ skillId: "skills:abc", softDeletedAt: undefined }), + }, + } as never, + makeSkillDoc() as never, + ); + + expect(digest.latestVersionId).toBe("skillVersions:v1"); + expect(digest.latestVersionSkillId).toBe("skills:abc"); + expect(digest.latestVersionSummary).toMatchObject({ version: "1.0.0" }); + }); + + it("clears stale latest-version metadata when the version belongs to another skill", async () => { + const digest = await extractValidatedDigestFields( + { + db: { + get: async () => ({ skillId: "skills:other", softDeletedAt: undefined }), + }, + } as never, + makeSkillDoc() as never, + ); + + expect(digest.latestVersionId).toBeUndefined(); + expect(digest.latestVersionSkillId).toBeUndefined(); + expect(digest.latestVersionSummary).toBeUndefined(); + }); +}); + describe("digestToOwnerInfo", () => { it("returns owner info when ownerHandle is present", () => { const digest = { diff --git a/convex/lib/skillSearchDigest.ts b/convex/lib/skillSearchDigest.ts index 1ac7f1f17b..c511b49103 100644 --- a/convex/lib/skillSearchDigest.ts +++ b/convex/lib/skillSearchDigest.ts @@ -45,6 +45,7 @@ const SHARED_KEYS = [ /** Fields stored in the skillSearchDigest table. */ export type SkillSearchDigestFields = Pick, (typeof SHARED_KEYS)[number]> & { skillId: Id<"skills">; + latestVersionSkillId?: Id<"skills">; normalizedSlug?: string; normalizedSlugFirstToken?: string; normalizedDisplayName?: string; @@ -68,6 +69,23 @@ export function extractDigestFields(skill: Doc<"skills">): SkillSearchDigestFiel }; } +export async function extractValidatedDigestFields( + ctx: Pick, + skill: Doc<"skills">, +): Promise { + const fields = extractDigestFields(skill); + const version = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null; + if (!version || version.softDeletedAt || version.skillId !== skill._id) { + return { + ...fields, + latestVersionId: undefined, + latestVersionSkillId: undefined, + latestVersionSummary: undefined, + }; + } + return { ...fields, latestVersionSkillId: version.skillId }; +} + export function normalizeSkillSearchText(value: string) { return value.trim().toLowerCase(); } diff --git a/convex/maintenance.ts b/convex/maintenance.ts index 1a64f130dd..7f0f8b3f41 100644 --- a/convex/maintenance.ts +++ b/convex/maintenance.ts @@ -16,7 +16,7 @@ import { import { hashSkillFiles, isTextFile } from "./lib/skills"; import { computeIsSuspicious } from "./lib/skillSafety"; import { - extractDigestFields, + extractValidatedDigestFields, getFirstSearchToken, normalizeSkillSearchText, } from "./lib/skillSearchDigest"; @@ -2066,7 +2066,7 @@ export const backfillSkillSearchDigestInternal = internalMutation({ .withIndex("by_skill", (q) => q.eq("skillId", skill._id)) .unique(); if (!existing) { - await ctx.db.insert("skillSearchDigest", extractDigestFields(skill)); + await ctx.db.insert("skillSearchDigest", await extractValidatedDigestFields(ctx, skill)); inserted++; } } @@ -2262,12 +2262,23 @@ export const backfillDigestVersionSummary = internalMutation({ let patched = 0; for (const digest of page) { - if (digest.latestVersionSummary !== undefined) continue; const skill = await ctx.db.get(digest.skillId); - if (!skill?.latestVersionSummary) continue; - await ctx.db.patch(digest._id, { - latestVersionSummary: skill.latestVersionSummary, - }); + if (!skill) continue; + const fields = await extractValidatedDigestFields(ctx, skill); + const patch = { + latestVersionId: fields.latestVersionId, + latestVersionSkillId: fields.latestVersionSkillId, + latestVersionSummary: fields.latestVersionSummary, + capabilityTags: fields.capabilityTags, + }; + if ( + digest.latestVersionId === patch.latestVersionId && + digest.latestVersionSkillId === patch.latestVersionSkillId && + JSON.stringify(digest.latestVersionSummary) === JSON.stringify(patch.latestVersionSummary) + ) { + continue; + } + await ctx.db.patch(digest._id, patch); patched++; } diff --git a/convex/schema.ts b/convex/schema.ts index 13d1db25ea..a9f873ef67 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -772,6 +772,7 @@ const skillSearchDigest = defineTable({ canonicalSkillId: v.optional(v.id("skills")), forkOf: forkOfValidator, latestVersionId: v.optional(v.id("skillVersions")), + latestVersionSkillId: v.optional(v.id("skills")), latestVersionSummary: v.optional( v.object({ version: v.string(), diff --git a/convex/skills.publicListCursor.test.ts b/convex/skills.publicListCursor.test.ts index 1839c70926..8097a716cd 100644 --- a/convex/skills.publicListCursor.test.ts +++ b/convex/skills.publicListCursor.test.ts @@ -18,7 +18,12 @@ vi.mock("convex-helpers/server/pagination", async () => { }); const pagination = await import("convex-helpers/server/pagination"); -const { listPublicApiPageV1, listPublicPageV4, listRelatedByCategory } = await import("./skills"); +const { + listPublicApiPageV1, + listPublicPageV4, + listPublicTrendingPage, + listRelatedByCategory, +} = await import("./skills"); type WrappedHandler = { _handler: (ctx: unknown, args: TArgs) => Promise; @@ -61,6 +66,57 @@ const listRelatedByCategoryHandler = ( { items: Array<{ skill: { slug: string }; ownerHandle: string | null }> } > )._handler; +const listPublicTrendingPageHandler = ( + listPublicTrendingPage as unknown as WrappedHandler< + { limit?: number; nonSuspiciousOnly?: boolean }, + PublicApiListResult + > +)._handler; + +function makeSearchDigest(overrides: Record = {}) { + return { + _id: "skillSearchDigest:demo", + skillId: "skills:demo", + slug: "demo", + displayName: "Demo", + summary: "Demo skill", + icon: undefined, + ownerUserId: "users:owner", + ownerPublisherId: undefined, + ownerHandle: "owner", + ownerKind: "user", + ownerName: "Owner", + ownerDisplayName: "Owner", + ownerImage: null, + canonicalSkillId: undefined, + forkOf: undefined, + latestVersionId: "skillVersions:1", + latestVersionSkillId: "skills:demo", + latestVersionSummary: { + version: "1.0.0", + createdAt: 9, + changelog: "initial", + changelogSource: "user", + clawdis: undefined, + }, + tags: {}, + capabilityTags: [], + badges: {}, + stats: { downloads: 0, stars: 0, versions: 1, comments: 0 }, + statsDownloads: 0, + statsStars: 0, + statsInstallsCurrent: 0, + statsInstallsAllTime: 0, + softDeletedAt: undefined, + moderationStatus: "active", + moderationFlags: undefined, + moderationReason: undefined, + isSuspicious: false, + createdAt: 1, + updatedAt: 2, + ...overrides, + }; +} function legacyCursor(key: unknown[]): string { return JSON.stringify(key); @@ -285,6 +341,73 @@ describe("public skill list deterministic cursors", () => { expect(result.hasMore).toBe(true); expect(result.nextCursor).toBeTruthy(); }); + + it("drops stale API list latest versions that belong to another skill", async () => { + getPageMock.mockResolvedValueOnce({ + page: [ + makeSearchDigest({ + latestVersionId: "skillVersions:other", + latestVersionSkillId: "skills:other", + latestVersionSummary: { + version: "9.9.9", + createdAt: 9, + changelog: "other", + changelogSource: "user", + clawdis: undefined, + }, + }), + ], + hasMore: false, + indexKeys: [], + }); + + const result = await listPublicApiPageV1Handler({} as never, { numItems: 10 }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ latestVersion: null }); + }); + + it("drops stale trending latest versions that belong to another skill", async () => { + const staleDigest = makeSearchDigest({ + latestVersionId: "skillVersions:other", + latestVersionSkillId: "skills:other", + latestVersionSummary: { + version: "9.9.9", + createdAt: 9, + changelog: "other", + changelogSource: "user", + clawdis: undefined, + }, + }); + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table === "skillLeaderboards") { + return { + withIndex: () => ({ + order: () => ({ + first: async () => ({ items: [{ skillId: "skills:demo" }] }), + }), + }), + }; + } + if (table === "skillSearchDigest") { + return { + withIndex: () => ({ + unique: async () => staleDigest, + }), + }; + } + throw new Error(`unexpected table ${table}`); + }), + }, + }; + + const result = await listPublicTrendingPageHandler(ctx as never, { limit: 10 }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ latestVersion: null }); + }); }); function makeDigest(overrides: Record) { diff --git a/convex/skills.ts b/convex/skills.ts index 7810c31dbd..ef2a55b2bb 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -115,7 +115,7 @@ import { computeIsSuspicious, isSkillReviewFlagged, isSkillSuspicious } from "./ import { digestToHydratableSkill, digestToOwnerInfo, - extractDigestFields, + extractValidatedDigestFields, upsertSkillSearchDigest, } from "./lib/skillSearchDigest"; import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator"; @@ -4198,9 +4198,7 @@ export const listPublicPageV3 = query({ if (!publicSkill) continue; const ownerInfo = digestToOwnerInfo(digest); if (!ownerInfo?.owner) continue; - const latestVersion = digest.latestVersionSummary - ? toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId) - : null; + const latestVersion = toDigestLatestVersionForSkill(digest); items.push({ skill: publicSkill, latestVersion, @@ -4780,9 +4778,7 @@ function buildPublicSkillEntryFromDigest( if (!publicSkill) return null; const ownerInfo = digestToOwnerInfo(digest); if (!ownerInfo?.owner) return null; - const latestVersion = digest.latestVersionSummary - ? toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId) - : null; + const latestVersion = toDigestLatestVersionForSkill(digest); return { skill: publicSkill, latestVersion, @@ -4791,15 +4787,23 @@ function buildPublicSkillEntryFromDigest( }; } +function toDigestLatestVersionForSkill(digest: Doc<"skillSearchDigest">) { + if ( + !digest.latestVersionSummary || + !digest.latestVersionId || + digest.latestVersionSkillId !== digest.skillId + ) { + return null; + } + return toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId); +} + function buildPublicSkillApiListEntryFromDigest(digest: Doc<"skillSearchDigest">) { const publicSkill = toPublicSkill(digestToHydratableSkill(digest)); if (!publicSkill) return null; const ownerInfo = digestToOwnerInfo(digest); if (!ownerInfo?.owner) return null; - const latestVersion = - digest.latestVersionSummary && digest.latestVersionId - ? toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId) - : null; + const latestVersion = toDigestLatestVersionForSkill(digest); return { skill: { @@ -4972,7 +4976,7 @@ function toPublicSkillCatalogItem(digest: Doc<"skillSearchDigest">): PublicSkill ownerHandle: ownerInfo?.ownerHandle ?? null, createdAt: digest.createdAt, updatedAt: digest.updatedAt, - latestVersion: digest.latestVersionSummary?.version ?? null, + latestVersion: toDigestLatestVersionForSkill(digest)?.version ?? null, capabilityTags: digest.capabilityTags ?? [], executesCode: false, verificationTier: null, @@ -8695,7 +8699,7 @@ async function syncSkillSearchDigestForSkillDoc(ctx: MutationCtx, skill: Doc<"sk ownerUserId: skill.ownerUserId, }); await upsertSkillSearchDigest(ctx, { - ...extractDigestFields(skill), + ...(await extractValidatedDigestFields(ctx, skill)), ownerHandle: owner?.handle ?? "", ownerKind: owner?.kind, ownerName: owner?.linkedUserId ? owner.handle : undefined, @@ -9260,7 +9264,7 @@ export const setSkillCapabilityTags = mutation({ ownerUserId: nextSkill.ownerUserId, }); await upsertSkillSearchDigest(ctx, { - ...extractDigestFields(nextSkill), + ...(await extractValidatedDigestFields(ctx, nextSkill)), ownerHandle: owner?.handle ?? "", ownerKind: owner?.kind, ownerName: owner?.linkedUserId ? owner.handle : undefined, From d7cfa11ac9ac313737246ba56eb04044f46d8ccb Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Mon, 18 May 2026 11:41:39 -0300 Subject: [PATCH 03/12] fix(api): guard stale latest version outputs --- convex/lib/skillFileAccess.ts | 3 +- convex/skills.public.test.ts | 118 ++++++++++++++++++++++++++++++++++ convex/skills.ts | 12 ++-- 3 files changed, 126 insertions(+), 7 deletions(-) diff --git a/convex/lib/skillFileAccess.ts b/convex/lib/skillFileAccess.ts index 59799930e4..5f7afd76b5 100644 --- a/convex/lib/skillFileAccess.ts +++ b/convex/lib/skillFileAccess.ts @@ -25,7 +25,8 @@ export function getPublicSkillFileAccessBlock( if (moderationInfo?.isPendingScan) { return { status: 423, - message: "This skill is pending a ClawScan security review. Please try again in a few minutes.", + message: + "This skill is pending a ClawScan security review. Please try again in a few minutes.", }; } if (moderationInfo?.isRemoved) { diff --git a/convex/skills.public.test.ts b/convex/skills.public.test.ts index ba1b13b1c1..d152cb95ed 100644 --- a/convex/skills.public.test.ts +++ b/convex/skills.public.test.ts @@ -17,6 +17,7 @@ const { getSkillBadgeMap } = await import("./lib/badges"); const { getBySlug, listSkillReportsInternal, + resolveVersionByHash, resolveSkillAppealForUserInternal, submitSkillAppealForUserInternal, triageSkillReportForUserInternal, @@ -75,6 +76,19 @@ const getBySlugHandler = ( > )._handler; +const resolveVersionByHashHandler = ( + resolveVersionByHash as unknown as WrappedHandler< + { + slug: string; + hash: string; + }, + { + match: { version: string } | null; + latestVersion: { version: string } | null; + } | null + > +)._handler; + const submitSkillAppealForUserInternalHandler = ( submitSkillAppealForUserInternal as unknown as WrappedHandler< { @@ -215,6 +229,39 @@ function makeSkill(overrides: Record = {}) { }; } +function makeResolveCtx(args: { + skill: Record; + latestVersion?: Record | null; + matchVersion?: Record | null; + fingerprintMatches?: Array>; +}) { + const fingerprintMatches = args.fingerprintMatches ?? [ + { versionId: "skillVersions:match", createdAt: 10 }, + ]; + const query = vi.fn((table: string) => { + if (table === "skills") { + return { withIndex: vi.fn(() => ({ unique: vi.fn().mockResolvedValue(args.skill) })) }; + } + if (table === "skillVersionFingerprints") { + return { withIndex: vi.fn(() => ({ take: vi.fn().mockResolvedValue(fingerprintMatches) })) }; + } + if (table === "skillVersions") { + return { + withIndex: vi.fn(() => ({ + order: vi.fn(() => ({ take: vi.fn().mockResolvedValue([]) })), + })), + }; + } + throw new Error(`Unexpected query table: ${table}`); + }); + const get = vi.fn(async (id: string) => { + if (id === args.skill.latestVersionId) return args.latestVersion ?? null; + if (id === "skillVersions:match") return args.matchVersion ?? null; + return null; + }); + return { db: { query, get } } as never; +} + describe("skills.getBySlug", () => { beforeEach(() => { vi.mocked(getAuthUserId).mockReset(); @@ -527,6 +574,77 @@ describe("skills.getBySlug", () => { expect(result?.skill).toMatchObject({ latestVersionId: "skillVersions:other" }); expect(result?.latestVersion).toBeNull(); }); + + it("does not expose a soft-deleted latest version", async () => { + const ctx = makeCtx({ + skill: makeSkill({ latestVersionId: "skillVersions:deleted" }), + owner: makeOwner("users:1", "demo-owner"), + latestVersion: { + _id: "skillVersions:deleted", + _creationTime: 2, + skillId: "skills:1", + version: "2.0.0", + fingerprint: "abc", + changelog: "", + changelogSource: "user", + files: [], + createdBy: "users:1", + createdAt: 2, + softDeletedAt: 3, + }, + }); + + const result = await getBySlugHandler(ctx, { slug: "demo" } as never); + + expect(result?.latestVersion).toBeNull(); + }); +}); + +describe("skills.resolveVersionByHash", () => { + const hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + it("does not expose a soft-deleted latest version", async () => { + const ctx = makeResolveCtx({ + skill: makeSkill({ latestVersionId: "skillVersions:deleted" }), + latestVersion: { + _id: "skillVersions:deleted", + skillId: "skills:1", + version: "2.0.0", + softDeletedAt: 3, + }, + matchVersion: { + _id: "skillVersions:match", + skillId: "skills:1", + version: "1.0.0", + files: [], + }, + }); + + const result = await resolveVersionByHashHandler(ctx, { slug: "demo", hash }); + + expect(result).toMatchObject({ match: { version: "1.0.0" }, latestVersion: null }); + }); + + it("does not expose a latest version that belongs to another skill", async () => { + const ctx = makeResolveCtx({ + skill: makeSkill({ latestVersionId: "skillVersions:other" }), + latestVersion: { + _id: "skillVersions:other", + skillId: "skills:other", + version: "9.9.9", + }, + matchVersion: { + _id: "skillVersions:match", + skillId: "skills:1", + version: "1.0.0", + files: [], + }, + }); + + const result = await resolveVersionByHashHandler(ctx, { slug: "demo", hash }); + + expect(result).toMatchObject({ match: { version: "1.0.0" }, latestVersion: null }); + }); }); describe("skill artifact moderation", () => { diff --git a/convex/skills.ts b/convex/skills.ts index ef2a55b2bb..6fb88ee927 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -99,10 +99,7 @@ import { } from "./lib/reservedSlugs"; import { matchesAllTokens, matchesExploratoryTokenPrefixes, tokenize } from "./lib/searchText"; import { SKILL_CAPABILITY_TAGS } from "./lib/skillCapabilityTags"; -import { - isPublicSkillVersionAvailableForSkill, - isSkillVersionForSkill, -} from "./lib/skillFileAccess"; +import { isPublicSkillVersionAvailableForSkill } from "./lib/skillFileAccess"; import { normalizeSkillIconValue } from "./lib/skillIcon"; import { fetchText, @@ -2089,7 +2086,7 @@ export const getBySlug = query({ const latestVersionDoc = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null; const latestVersion = toPublicSkillVersion( - isSkillVersionForSkill(latestVersionDoc, skill._id) ? latestVersionDoc : null, + isPublicSkillVersionAvailableForSkill(latestVersionDoc, skill._id) ? latestVersionDoc : null, ); const owner = toPublicPublisher(ownerPublisher); if (!owner) return null; @@ -7812,7 +7809,10 @@ export const resolveVersionByHash = query({ const skill = resolved.skill; if (!skill) return null; - const latestVersion = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null; + const latestVersionDoc = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null; + const latestVersion = isPublicSkillVersionAvailableForSkill(latestVersionDoc, skill._id) + ? latestVersionDoc + : null; const fingerprintMatches = await ctx.db .query("skillVersionFingerprints") From 5033ecad33f77e2e05919eb861e8df4cb22c4356 Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Mon, 18 May 2026 16:56:09 -0300 Subject: [PATCH 04/12] fix(api): keep legacy digest latest versions --- convex/skills.publicListCursor.test.ts | 59 ++++++++++++++++++++++++++ convex/skills.ts | 9 ++-- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/convex/skills.publicListCursor.test.ts b/convex/skills.publicListCursor.test.ts index 8097a716cd..3c2ac0d7a9 100644 --- a/convex/skills.publicListCursor.test.ts +++ b/convex/skills.publicListCursor.test.ts @@ -367,6 +367,27 @@ describe("public skill list deterministic cursors", () => { expect(result.items[0]).toMatchObject({ latestVersion: null }); }); + it("keeps legacy API list latest versions without owner markers until backfill", async () => { + getPageMock.mockResolvedValueOnce({ + page: [ + makeSearchDigest({ + latestVersionSkillId: undefined, + }), + ], + hasMore: false, + indexKeys: [], + }); + + const result = await listPublicApiPageV1Handler({} as never, { numItems: 10 }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + latestVersion: { + version: "1.0.0", + }, + }); + }); + it("drops stale trending latest versions that belong to another skill", async () => { const staleDigest = makeSearchDigest({ latestVersionId: "skillVersions:other", @@ -408,6 +429,44 @@ describe("public skill list deterministic cursors", () => { expect(result.items).toHaveLength(1); expect(result.items[0]).toMatchObject({ latestVersion: null }); }); + + it("keeps legacy trending latest versions without owner markers until backfill", async () => { + const legacyDigest = makeSearchDigest({ + latestVersionSkillId: undefined, + }); + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table === "skillLeaderboards") { + return { + withIndex: () => ({ + order: () => ({ + first: async () => ({ items: [{ skillId: "skills:demo" }] }), + }), + }), + }; + } + if (table === "skillSearchDigest") { + return { + withIndex: () => ({ + unique: async () => legacyDigest, + }), + }; + } + throw new Error(`unexpected table ${table}`); + }), + }, + }; + + const result = await listPublicTrendingPageHandler(ctx as never, { limit: 10 }); + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + latestVersion: { + version: "1.0.0", + }, + }); + }); }); function makeDigest(overrides: Record) { diff --git a/convex/skills.ts b/convex/skills.ts index 6fb88ee927..64adab9a58 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -4785,11 +4785,10 @@ function buildPublicSkillEntryFromDigest( } function toDigestLatestVersionForSkill(digest: Doc<"skillSearchDigest">) { - if ( - !digest.latestVersionSummary || - !digest.latestVersionId || - digest.latestVersionSkillId !== digest.skillId - ) { + if (!digest.latestVersionSummary || !digest.latestVersionId) { + return null; + } + if (digest.latestVersionSkillId !== undefined && digest.latestVersionSkillId !== digest.skillId) { return null; } return toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId); From 3ff605dd3b9cd20d855ca42c7da76083da8e6dfa Mon Sep 17 00:00:00 2001 From: vyctorbrzezowski Date: Mon, 18 May 2026 17:44:12 -0300 Subject: [PATCH 05/12] fix(api): guard public latest-version readers --- convex/skills.publicListCursor.test.ts | 56 ++++++++++ convex/skills.ts | 49 ++++++--- convex/skills.versions.public.test.ts | 144 ++++++++++++++++++++++++- 3 files changed, 233 insertions(+), 16 deletions(-) diff --git a/convex/skills.publicListCursor.test.ts b/convex/skills.publicListCursor.test.ts index 3c2ac0d7a9..a1adfc2597 100644 --- a/convex/skills.publicListCursor.test.ts +++ b/convex/skills.publicListCursor.test.ts @@ -19,6 +19,7 @@ vi.mock("convex-helpers/server/pagination", async () => { const pagination = await import("convex-helpers/server/pagination"); const { + listAuditPage, listPublicApiPageV1, listPublicPageV4, listPublicTrendingPage, @@ -72,6 +73,12 @@ const listPublicTrendingPageHandler = ( PublicApiListResult > )._handler; +const listAuditPageHandler = ( + listAuditPage as unknown as WrappedHandler< + { paginationOpts: { cursor: string | null; numItems: number } }, + PublicListResult + > +)._handler; function makeSearchDigest(overrides: Record = {}) { return { @@ -467,6 +474,55 @@ describe("public skill list deterministic cursors", () => { }, }); }); + + it("drops audit latest versions that resolve to another skill", async () => { + const digest = makeSearchDigest({ + latestVersionId: "skillVersions:other", + latestVersionSkillId: undefined, + }); + const ctx = { + db: { + get: vi.fn(async (id: string) => { + if (id === "skillVersions:other") { + return { + _id: id, + _creationTime: 1, + skillId: "skills:other", + version: "9.9.9", + createdAt: 9, + files: [], + vtAnalysis: { status: "clean" }, + llmAnalysis: { status: "clean" }, + staticScan: { status: "clean", reasonCodes: [], findings: [] }, + softDeletedAt: undefined, + }; + } + return null; + }), + query: vi.fn((table: string) => { + if (table !== "skillSearchDigest") throw new Error(`unexpected table ${table}`); + return { + withIndex: vi.fn(() => ({ + order: vi.fn(() => ({ + paginate: vi.fn().mockResolvedValue({ + page: [digest], + isDone: true, + continueCursor: "", + }), + })), + })), + }; + }), + }, + }; + + const result = await listAuditPageHandler(ctx as never, { + paginationOpts: { cursor: null, numItems: 10 }, + }); + + expect(result.page).toHaveLength(1); + expect(result.page[0]).toMatchObject({ latestVersion: null }); + }); }); function makeDigest(overrides: Record) { diff --git a/convex/skills.ts b/convex/skills.ts index 64adab9a58..6df4ab756a 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -1764,16 +1764,17 @@ async function buildPublicSkillEntries( const summary = skill.latestVersionSummary; const hasSummary = includeVersion && summary; const [latestVersionDoc, ownerInfo] = await Promise.all([ - includeVersion && !hasSummary && skill.latestVersionId - ? ctx.db.get(skill.latestVersionId) + includeVersion && skill.latestVersionId + ? loadPublicLatestVersionForSkill(ctx, skill) : null, getOwnerInfo(skill._id, skill.ownerUserId, skill.ownerPublisherId), ]); const publicSkill = toPublicSkill(skill); if (!publicSkill || !ownerInfo.owner) return null; - const latestVersion = hasSummary - ? toPublicSkillListVersionFromSummary(summary!, skill.latestVersionId) - : toPublicSkillListVersion(latestVersionDoc); + const latestVersion = + hasSummary && latestVersionDoc + ? toPublicSkillListVersionFromSummary(summary!, latestVersionDoc._id) + : toPublicSkillListVersion(latestVersionDoc); return { skill: publicSkill, latestVersion, @@ -1808,6 +1809,15 @@ async function filterSkillsByActiveOwner(ctx: Pick, skills: Doc< return filtered.filter((skill): skill is Doc<"skills"> => skill !== null); } +async function loadPublicLatestVersionForSkill( + ctx: Pick, + skill: Pick, "_id" | "latestVersionId">, +) { + if (!skill.latestVersionId) return null; + const version = await ctx.db.get(skill.latestVersionId); + return isPublicSkillVersionAvailableForSkill(version, skill._id) ? version : null; +} + function toPublicSkillListVersion( version: Doc<"skillVersions"> | null, ): PublicSkillListVersion | null { @@ -3044,12 +3054,13 @@ export const listWithLatest = query({ : withBadges; const limited = ordered.slice(0, limit); const items = await Promise.all( - limited.map(async (skill) => ({ - skill: toPublicSkill(skill), - latestVersion: toPublicSkillVersion( - skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null, - ), - })), + limited.map(async (skill) => { + const latestVersion = await loadPublicLatestVersionForSkill(ctx, skill); + return { + skill: toPublicSkill(skill), + latestVersion: toPublicSkillVersion(latestVersion), + }; + }), ); return items.filter( ( @@ -4725,9 +4736,7 @@ export const listAuditPage = query({ for (const digest of result.page) { const entry = buildPublicSkillEntryFromDigest(digest); if (!entry) continue; - const latestVersion = digest.latestVersionId - ? await ctx.db.get(digest.latestVersionId) - : null; + const latestVersion = await loadPublicLatestVersionForDigest(ctx, digest); page.push({ kind: "skill" as const, skill: entry.skill, @@ -4784,6 +4793,18 @@ function buildPublicSkillEntryFromDigest( }; } +async function loadPublicLatestVersionForDigest( + ctx: Pick, + digest: Pick, "skillId" | "latestVersionId" | "latestVersionSkillId">, +) { + if (!digest.latestVersionId) return null; + if (digest.latestVersionSkillId !== undefined && digest.latestVersionSkillId !== digest.skillId) { + return null; + } + const version = await ctx.db.get(digest.latestVersionId); + return isPublicSkillVersionAvailableForSkill(version, digest.skillId) ? version : null; +} + function toDigestLatestVersionForSkill(digest: Doc<"skillSearchDigest">) { if (!digest.latestVersionSummary || !digest.latestVersionId) { return null; diff --git a/convex/skills.versions.public.test.ts b/convex/skills.versions.public.test.ts index 1e4c528ea1..c1a0d934b5 100644 --- a/convex/skills.versions.public.test.ts +++ b/convex/skills.versions.public.test.ts @@ -14,8 +14,14 @@ vi.mock("./lib/badges", () => ({ const { getAuthUserId } = await import("@convex-dev/auth/server"); const { getSkillBadgeMap, getSkillBadgeMaps } = await import("./lib/badges"); -const { getBySlug, getVersionById, getVersionBySkillAndVersion, listVersions, listWithLatest } = - await import("./skills"); +const { + getBySlug, + getVersionById, + getVersionBySkillAndVersion, + listHighlightedPublic, + listVersions, + listWithLatest, +} = await import("./skills"); type WrappedHandler = { _handler: (ctx: unknown, args: TArgs) => Promise; @@ -52,6 +58,11 @@ const listWithLatestHandler = ( limit?: number; }> )._handler; +const listHighlightedPublicHandler = ( + listHighlightedPublic as unknown as WrappedHandler<{ + limit?: number; + }> +)._handler; function makeVersion() { return { @@ -308,4 +319,133 @@ describe("public skill version queries", () => { expect(result[0]?.latestVersion?.files[0]).not.toHaveProperty("storageId"); expect(result[0]?.latestVersion?.parsed).not.toHaveProperty("frontmatter"); }); + + it("drops cross-skill latestVersion in listWithLatest", async () => { + const version = { ...makeVersion(), _id: "skillVersions:other", skillId: "skills:other" }; + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table !== "skills") throw new Error(`Unexpected table ${table}`); + return { + order: vi.fn(() => ({ + take: vi.fn().mockResolvedValue([ + { + _id: "skills:1", + _creationTime: 1, + slug: "demo", + displayName: "Demo", + summary: "Summary", + ownerUserId: "users:1", + canonicalSkillId: undefined, + forkOf: undefined, + latestVersionId: version._id, + tags: {}, + badges: undefined, + stats: { + downloads: 1, + installsCurrent: 1, + installsAllTime: 1, + stars: 1, + versions: 1, + comments: 0, + }, + createdAt: 1, + updatedAt: 2, + softDeletedAt: undefined, + moderationStatus: "active", + moderationFlags: undefined, + moderationReason: undefined, + }, + ]), + })), + }; + }), + get: vi.fn(async (id: string) => { + if (id === "users:1") return { _id: id }; + if (id === version._id) return version; + return null; + }), + }, + } as never; + + const result = (await listWithLatestHandler(ctx, { limit: 1 } as never)) as Array<{ + latestVersion?: { version: string } | null; + }>; + + expect(result[0]?.latestVersion).toBeNull(); + }); + + it("drops cross-skill latestVersion summaries in highlighted public list", async () => { + const version = { ...makeVersion(), _id: "skillVersions:other", skillId: "skills:other" }; + const skill = { + _id: "skills:1", + _creationTime: 1, + slug: "demo", + displayName: "Demo", + summary: "Summary", + ownerUserId: "users:1", + canonicalSkillId: undefined, + forkOf: undefined, + latestVersionId: version._id, + latestVersionSummary: { + version: "9.9.9", + createdAt: 9, + changelog: "stale", + changelogSource: "user", + clawdis: undefined, + }, + tags: {}, + badges: { highlighted: { byUserId: "users:moderator", at: 3 } }, + stats: { + downloads: 1, + installsCurrent: 1, + installsAllTime: 1, + stars: 1, + versions: 1, + comments: 0, + }, + createdAt: 1, + updatedAt: 2, + softDeletedAt: undefined, + moderationStatus: "active", + moderationFlags: undefined, + moderationReason: undefined, + }; + const ctx = { + db: { + query: vi.fn((table: string) => { + if (table !== "skillBadges") throw new Error(`Unexpected table ${table}`); + return { + withIndex: vi.fn(() => ({ + order: vi.fn(() => ({ + take: vi.fn().mockResolvedValue([{ skillId: skill._id }]), + })), + })), + }; + }), + get: vi.fn(async (id: string) => { + if (id === skill._id) return skill; + if (id === version._id) return version; + if (id === "users:1") { + return { + _id: "users:1", + _creationTime: 1, + handle: "demo", + displayName: "Demo", + image: null, + bio: null, + }; + } + return null; + }), + }, + } as never; + + const result = (await listHighlightedPublicHandler(ctx, { limit: 1 } as never)) as Array<{ + latestVersion?: { version: string } | null; + }>; + + expect(result).toHaveLength(1); + expect(result[0]?.latestVersion).toBeNull(); + }); }); From a037796abcb66a364999c705d2a0efaf9b4ad244 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 13:53:52 -0500 Subject: [PATCH 06/12] fix: avoid ambiguous array allocation in skill export --- convex/httpApiV1/skillsV1.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index 79d66e3b76..e2e463e3a3 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -2463,9 +2463,10 @@ export async function exportSkillsV1Handler(ctx: ActionCtx, request: Request) { : Promise.resolve(null), ); logContext.versionCount = versionDocs.filter(Boolean).length; - const exportableVersions: Array | null> = new Array( - result.page.length, - ).fill(null); + const exportableVersions: Array | null> = Array.from( + { length: result.page.length }, + () => null, + ); type BlobTask = { digestIndex: number; fileIndex: number; storageId: Id<"_storage"> }; const blobTasks: BlobTask[] = []; From afc869a718eefdea730677b5bd015f3b0a087ed8 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 14:13:45 -0500 Subject: [PATCH 07/12] fix: drop legacy markerless digest versions --- convex/skills.publicListCursor.test.ts | 16 ++++------------ convex/skills.ts | 4 +--- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/convex/skills.publicListCursor.test.ts b/convex/skills.publicListCursor.test.ts index a1adfc2597..d518d1d295 100644 --- a/convex/skills.publicListCursor.test.ts +++ b/convex/skills.publicListCursor.test.ts @@ -374,7 +374,7 @@ describe("public skill list deterministic cursors", () => { expect(result.items[0]).toMatchObject({ latestVersion: null }); }); - it("keeps legacy API list latest versions without owner markers until backfill", async () => { + it("drops legacy API list latest versions without owner markers", async () => { getPageMock.mockResolvedValueOnce({ page: [ makeSearchDigest({ @@ -388,11 +388,7 @@ describe("public skill list deterministic cursors", () => { const result = await listPublicApiPageV1Handler({} as never, { numItems: 10 }); expect(result.items).toHaveLength(1); - expect(result.items[0]).toMatchObject({ - latestVersion: { - version: "1.0.0", - }, - }); + expect(result.items[0]).toMatchObject({ latestVersion: null }); }); it("drops stale trending latest versions that belong to another skill", async () => { @@ -437,7 +433,7 @@ describe("public skill list deterministic cursors", () => { expect(result.items[0]).toMatchObject({ latestVersion: null }); }); - it("keeps legacy trending latest versions without owner markers until backfill", async () => { + it("drops legacy trending latest versions without owner markers", async () => { const legacyDigest = makeSearchDigest({ latestVersionSkillId: undefined, }); @@ -468,11 +464,7 @@ describe("public skill list deterministic cursors", () => { const result = await listPublicTrendingPageHandler(ctx as never, { limit: 10 }); expect(result.items).toHaveLength(1); - expect(result.items[0]).toMatchObject({ - latestVersion: { - version: "1.0.0", - }, - }); + expect(result.items[0]).toMatchObject({ latestVersion: null }); }); it("drops audit latest versions that resolve to another skill", async () => { diff --git a/convex/skills.ts b/convex/skills.ts index 8e523949be..1b3b466f11 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -5112,9 +5112,7 @@ function toDigestLatestVersionForSkill(digest: Doc<"skillSearchDigest">) { if (!digest.latestVersionSummary || !digest.latestVersionId) { return null; } - // Legacy digest rows predate latestVersionSkillId. Keep markerless summaries - // until the backfill rewrites them so hot list/catalog paths stay denormalized. - if (digest.latestVersionSkillId !== undefined && digest.latestVersionSkillId !== digest.skillId) { + if (digest.latestVersionSkillId !== digest.skillId) { return null; } return toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId); From 944071574b2628791d850173c562d8d60f09db49 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 14:25:18 -0500 Subject: [PATCH 08/12] fix: verify markerless digest versions --- convex/skills.publicListCursor.test.ts | 44 ++++++++++++-- convex/skills.ts | 79 ++++++++++++++++++-------- 2 files changed, 93 insertions(+), 30 deletions(-) diff --git a/convex/skills.publicListCursor.test.ts b/convex/skills.publicListCursor.test.ts index d518d1d295..275c598dbf 100644 --- a/convex/skills.publicListCursor.test.ts +++ b/convex/skills.publicListCursor.test.ts @@ -374,7 +374,7 @@ describe("public skill list deterministic cursors", () => { expect(result.items[0]).toMatchObject({ latestVersion: null }); }); - it("drops legacy API list latest versions without owner markers", async () => { + it("keeps verified legacy API list latest versions without owner markers", async () => { getPageMock.mockResolvedValueOnce({ page: [ makeSearchDigest({ @@ -385,10 +385,30 @@ describe("public skill list deterministic cursors", () => { indexKeys: [], }); - const result = await listPublicApiPageV1Handler({} as never, { numItems: 10 }); + const result = await listPublicApiPageV1Handler( + { + db: { + get: vi.fn(async (id: string) => + id === "skillVersions:1" + ? { + _id: id, + skillId: "skills:demo", + version: "1.0.0", + softDeletedAt: undefined, + } + : null, + ), + }, + } as never, + { numItems: 10 }, + ); expect(result.items).toHaveLength(1); - expect(result.items[0]).toMatchObject({ latestVersion: null }); + expect(result.items[0]).toMatchObject({ + latestVersion: { + version: "1.0.0", + }, + }); }); it("drops stale trending latest versions that belong to another skill", async () => { @@ -433,12 +453,22 @@ describe("public skill list deterministic cursors", () => { expect(result.items[0]).toMatchObject({ latestVersion: null }); }); - it("drops legacy trending latest versions without owner markers", async () => { + it("keeps verified legacy trending latest versions without owner markers", async () => { const legacyDigest = makeSearchDigest({ latestVersionSkillId: undefined, }); const ctx = { db: { + get: vi.fn(async (id: string) => + id === "skillVersions:1" + ? { + _id: id, + skillId: "skills:demo", + version: "1.0.0", + softDeletedAt: undefined, + } + : null, + ), query: vi.fn((table: string) => { if (table === "skillLeaderboards") { return { @@ -464,7 +494,11 @@ describe("public skill list deterministic cursors", () => { const result = await listPublicTrendingPageHandler(ctx as never, { limit: 10 }); expect(result.items).toHaveLength(1); - expect(result.items[0]).toMatchObject({ latestVersion: null }); + expect(result.items[0]).toMatchObject({ + latestVersion: { + version: "1.0.0", + }, + }); }); it("drops audit latest versions that resolve to another skill", async () => { diff --git a/convex/skills.ts b/convex/skills.ts index 0bdc721062..4859295eb0 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -4509,7 +4509,7 @@ export const listPublicPageV3 = query({ if (!publicSkill) continue; const ownerInfo = digestToOwnerInfo(digest); if (!ownerInfo?.owner) continue; - const latestVersion = toDigestLatestVersionForSkill(digest); + const latestVersion = await resolveDigestLatestVersionForSkill(ctx, digest); items.push({ skill: publicSkill, latestVersion, @@ -4724,9 +4724,11 @@ export const listPublicPageV4 = query({ schema, }); - const items = result.page - .map((digest) => buildPublicSkillEntryFromDigest(digest)) - .filter((item): item is PublicSkillEntry => item !== null); + const items: PublicSkillEntry[] = []; + for (const digest of result.page) { + const item = await buildPublicSkillEntryFromDigest(ctx, digest); + if (item) items.push(item); + } let nextCursor: string | null = null; if (result.hasMore && result.indexKeys.length > 0) { nextCursor = encodeIndexKey(indexName, result.indexKeys[result.indexKeys.length - 1]); @@ -4777,7 +4779,7 @@ export const listPublicPageV4 = query({ excludeCategoryKeywords, }) ) { - const item = buildPublicSkillEntryFromDigest(digest); + const item = await buildPublicSkillEntryFromDigest(ctx, digest); if (item) items.push(item); } if (items.length >= numItems) { @@ -4977,7 +4979,7 @@ export const listRelatedByCategory = query({ if (isSkillSuspicious(hydratable)) continue; if (categorySlug && inferDigestSkillCategorySlug(digest) !== categorySlug) continue; if (!digestMatchesRelatedCategory(digest, keywords)) continue; - const item = buildPublicSkillEntryFromDigest(digest); + const item = await buildPublicSkillEntryFromDigest(ctx, digest); if (!item) continue; items.push(item); if (items.length >= limit) break; @@ -5013,7 +5015,7 @@ export const listPublicTrendingPage = query({ .unique(); if (!digest) continue; if (args.nonSuspiciousOnly && digest.isSuspicious) continue; - const item = buildPublicSkillEntryFromDigest(digest); + const item = await buildPublicSkillEntryFromDigest(ctx, digest); if (!item) continue; items.push(item); if (items.length >= limit) break; @@ -5037,7 +5039,7 @@ export const listAuditPage = query({ const page = []; for (const digest of result.page) { - const entry = buildPublicSkillEntryFromDigest(digest); + const entry = await buildPublicSkillEntryFromDigest(ctx, digest); if (!entry) continue; const latestVersion = await loadPublicLatestVersionForDigest(ctx, digest); page.push({ @@ -5079,15 +5081,16 @@ export const listAuditPage = query({ }, }); -function buildPublicSkillEntryFromDigest( +async function buildPublicSkillEntryFromDigest( + ctx: Pick, digest: Doc<"skillSearchDigest">, -): PublicSkillEntry | null { +): Promise { const hydratable = digestToHydratableSkill(digest); const publicSkill = toPublicSkill(hydratable); if (!publicSkill) return null; const ownerInfo = digestToOwnerInfo(digest); if (!ownerInfo?.owner) return null; - const latestVersion = toDigestLatestVersionForSkill(digest); + const latestVersion = await resolveDigestLatestVersionForSkill(ctx, digest); return { skill: publicSkill, latestVersion, @@ -5118,12 +5121,31 @@ function toDigestLatestVersionForSkill(digest: Doc<"skillSearchDigest">) { return toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId); } -function buildPublicSkillApiListEntryFromDigest(digest: Doc<"skillSearchDigest">) { +async function resolveDigestLatestVersionForSkill( + ctx: Pick, + digest: Doc<"skillSearchDigest">, +) { + if (!digest.latestVersionSummary || !digest.latestVersionId) { + return null; + } + if (digest.latestVersionSkillId === undefined) { + const latestVersion = await loadPublicLatestVersionForDigest(ctx, digest); + return latestVersion + ? toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId) + : null; + } + return toDigestLatestVersionForSkill(digest); +} + +async function buildPublicSkillApiListEntryFromDigest( + ctx: Pick, + digest: Doc<"skillSearchDigest">, +) { const publicSkill = toPublicSkill(digestToHydratableSkill(digest)); if (!publicSkill) return null; const ownerInfo = digestToOwnerInfo(digest); if (!ownerInfo?.owner) return null; - const latestVersion = toDigestLatestVersionForSkill(digest); + const latestVersion = await resolveDigestLatestVersionForSkill(ctx, digest); return { skill: { @@ -5185,9 +5207,11 @@ export const listPublicApiPageV1 = query({ index: indexName, schema, }); - const items = result.page - .map((digest) => buildPublicSkillApiListEntryFromDigest(digest)) - .filter((item): item is NonNullable => item !== null); + const items = []; + for (const digest of result.page) { + const item = await buildPublicSkillApiListEntryFromDigest(ctx, digest); + if (item) items.push(item); + } const nextCursor = result.hasMore && result.indexKeys.length > 0 ? encodeIndexKey(indexName, result.indexKeys[result.indexKeys.length - 1]) @@ -5283,8 +5307,12 @@ function skillCatalogMatchesFilters( return true; } -function toPublicSkillCatalogItem(digest: Doc<"skillSearchDigest">): PublicSkillCatalogItem { +async function toPublicSkillCatalogItem( + ctx: Pick, + digest: Doc<"skillSearchDigest">, +): Promise { const ownerInfo = digestToOwnerInfo(digest); + const latestVersion = await resolveDigestLatestVersionForSkill(ctx, digest); return { name: digest.slug, displayName: digest.displayName, @@ -5296,7 +5324,7 @@ function toPublicSkillCatalogItem(digest: Doc<"skillSearchDigest">): PublicSkill ownerHandle: ownerInfo?.ownerHandle ?? null, createdAt: digest.createdAt, updatedAt: digest.updatedAt, - latestVersion: toDigestLatestVersionForSkill(digest)?.version ?? null, + latestVersion: latestVersion?.version ?? null, capabilityTags: digest.capabilityTags ?? [], executesCode: false, verificationTier: null, @@ -5439,7 +5467,7 @@ export const listPackageCatalogPage = query({ for (let index = offset; index < page.page.length; index += 1) { const digest = page.page[index]; if (!skillCatalogMatchesFilters(digest, args)) continue; - collected.push(toPublicSkillCatalogItem(digest)); + collected.push(await toPublicSkillCatalogItem(ctx, digest)); if (collected.length >= targetCount) { const nextOffset = index + 1; if (nextOffset < page.page.length) { @@ -5507,7 +5535,7 @@ async function searchPackageCatalogImpl(ctx: QueryCtx, args: SkillPackageCatalog seen.add(exactDigest.skillId); matches.push({ ...match, - package: toPublicSkillCatalogItem(exactDigest), + package: await toPublicSkillCatalogItem(ctx, exactDigest), }); } } @@ -5528,7 +5556,7 @@ async function searchPackageCatalogImpl(ctx: QueryCtx, args: SkillPackageCatalog seen.add(digest.skillId); matches.push({ ...match, - package: toPublicSkillCatalogItem(digest), + package: await toPublicSkillCatalogItem(ctx, digest), }); } } @@ -5646,10 +5674,11 @@ async function fetchHighlightedPage( const trimmed = digests.slice(0, opts.numItems); - // Build PublicSkillEntry[] - const items = trimmed - .map((digest) => buildPublicSkillEntryFromDigest(digest)) - .filter((item): item is PublicSkillEntry => item !== null); + const items: PublicSkillEntry[] = []; + for (const digest of trimmed) { + const item = await buildPublicSkillEntryFromDigest(ctx, digest); + if (item) items.push(item); + } // Highlighted skills are few enough to return in one page — no cursor needed return { page: items, hasMore: false, nextCursor: null }; From 2aa807d0a9b2d174e243f481e427bbd3f465f504 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 14:37:02 -0500 Subject: [PATCH 09/12] test: mark package catalog digest versions --- convex/skills.packageCatalog.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/convex/skills.packageCatalog.test.ts b/convex/skills.packageCatalog.test.ts index b85502aa7d..fc9223e117 100644 --- a/convex/skills.packageCatalog.test.ts +++ b/convex/skills.packageCatalog.test.ts @@ -74,6 +74,7 @@ function makeDigest( canonicalSkillId: undefined, forkOf: undefined, latestVersionId: `skillVersions:${slug}-1`, + latestVersionSkillId: `skills:${slug}`, latestVersionSummary: { version: "1.0.0", createdAt: 10, From d3969dc51e2099f7802b3c1db2d281eb81c60b77 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 14:46:21 -0500 Subject: [PATCH 10/12] fix: keep skill list tag resolution on digest path --- convex/httpApiV1.shared.test.ts | 11 ++++++++--- convex/skills.ts | 19 +++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/convex/httpApiV1.shared.test.ts b/convex/httpApiV1.shared.test.ts index 2331c35e14..9cdfeca53a 100644 --- a/convex/httpApiV1.shared.test.ts +++ b/convex/httpApiV1.shared.test.ts @@ -31,10 +31,15 @@ describe("http API v1 shared helpers", () => { it("resolves latest tags without reading version documents", async () => { const ctx = makeCtx(); const versionId = "skillVersions:latest" as Id<"skillVersions">; + const skillId = "skills:demo" as Id<"skills">; - const result = await resolveVersionTagsBatch(ctx, [{ latest: versionId }], {} as never, [ - { _id: versionId, version: "2.0.0" }, - ]); + const result = await resolveVersionTagsBatch( + ctx, + [{ latest: versionId }], + {} as never, + [{ _id: versionId, skillId, version: "2.0.0" }], + [skillId], + ); expect(result).toEqual([{ latest: "2.0.0" }]); expect(ctx.runQuery).not.toHaveBeenCalled(); diff --git a/convex/skills.ts b/convex/skills.ts index 4859295eb0..48eafba065 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -1709,7 +1709,7 @@ async function loadPublicSkillReference(ctx: QueryCtx, skillId: Id<"skills"> | n type PublicSkillListVersion = Pick< Doc<"skillVersions">, - "_id" | "_creationTime" | "version" | "createdAt" | "changelog" | "changelogSource" + "_id" | "_creationTime" | "skillId" | "version" | "createdAt" | "changelog" | "changelogSource" > & { parsed?: PublicSkillVersionParsed; // Mirrors `skillVersions.apiKeyRequired` of the latest version. @@ -1883,7 +1883,7 @@ async function buildPublicSkillEntries( if (!publicSkill || !ownerInfo.owner) return null; const latestVersion = hasSummary && latestVersionDoc - ? toPublicSkillListVersionFromSummary(summary!, latestVersionDoc._id) + ? toPublicSkillListVersionFromSummary(summary!, latestVersionDoc._id, skill._id) : toPublicSkillListVersion(latestVersionDoc); return { skill: publicSkill, @@ -1935,6 +1935,7 @@ function toPublicSkillListVersion( return { _id: version._id, _creationTime: version._creationTime, + skillId: version.skillId, version: version.version, createdAt: version.createdAt, changelog: version.changelog, @@ -2040,10 +2041,12 @@ async function getGeneratedSkillCardPublicFile( function toPublicSkillListVersionFromSummary( summary: NonNullable["latestVersionSummary"]>, latestVersionId: Id<"skillVersions"> | undefined, + skillId: Id<"skills">, ): PublicSkillListVersion | null { if (!latestVersionId) return null; return { _id: latestVersionId, + skillId, // Approximates _creationTime; both are set to `now` in the same transaction _creationTime: summary.createdAt, version: summary.version, @@ -5118,7 +5121,11 @@ function toDigestLatestVersionForSkill(digest: Doc<"skillSearchDigest">) { if (digest.latestVersionSkillId !== digest.skillId) { return null; } - return toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId); + return toPublicSkillListVersionFromSummary( + digest.latestVersionSummary, + digest.latestVersionId, + digest.skillId, + ); } async function resolveDigestLatestVersionForSkill( @@ -5131,7 +5138,11 @@ async function resolveDigestLatestVersionForSkill( if (digest.latestVersionSkillId === undefined) { const latestVersion = await loadPublicLatestVersionForDigest(ctx, digest); return latestVersion - ? toPublicSkillListVersionFromSummary(digest.latestVersionSummary, digest.latestVersionId) + ? toPublicSkillListVersionFromSummary( + digest.latestVersionSummary, + digest.latestVersionId, + digest.skillId, + ) : null; } return toDigestLatestVersionForSkill(digest); From cab60f73d206cd964d468d0959e53880a8bfc6ed Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 15:03:07 -0500 Subject: [PATCH 11/12] test: mark resolved skill versions with owner --- convex/skills.resolve.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/convex/skills.resolve.test.ts b/convex/skills.resolve.test.ts index b463e6ed6b..98f909a041 100644 --- a/convex/skills.resolve.test.ts +++ b/convex/skills.resolve.test.ts @@ -46,10 +46,13 @@ describe("resolveVersionByHash", () => { }; const latestVersion = { _id: "skillVersions:latest", + skillId: skill._id, version: "2.0.0", + softDeletedAt: undefined, }; const matchedVersion = { _id: "skillVersions:1", + skillId: skill._id, version: "1.0.0", softDeletedAt: undefined, }; From 8889ac0af714f6ccf996630de4653ef823aab068 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Wed, 27 May 2026 15:20:03 -0500 Subject: [PATCH 12/12] fix: repair digest capability backfill skip --- convex/maintenance.test.ts | 68 ++++++++++++++++++++++++++++++++++++++ convex/maintenance.ts | 4 ++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/convex/maintenance.test.ts b/convex/maintenance.test.ts index a1d7c47bbe..8ac03f6ffb 100644 --- a/convex/maintenance.test.ts +++ b/convex/maintenance.test.ts @@ -18,6 +18,7 @@ vi.mock("./_generated/api", () => ({ backfillSkillFingerprintsInternal: Symbol("backfillSkillFingerprintsInternal"), applySkillCapabilityTagsInternal: Symbol("applySkillCapabilityTagsInternal"), backfillSkillCapabilityTagsInternal: Symbol("backfillSkillCapabilityTagsInternal"), + backfillDigestVersionSummary: Symbol("backfillDigestVersionSummary"), getEmptySkillCleanupPageInternal: Symbol("getEmptySkillCleanupPageInternal"), applyEmptySkillCleanupInternal: Symbol("applyEmptySkillCleanupInternal"), nominateUserForEmptySkillSpamInternal: Symbol("nominateUserForEmptySkillSpamInternal"), @@ -41,6 +42,7 @@ vi.mock("./lib/skillSummary", () => ({ const { applySkillCapabilityTagsInternal, + backfillDigestVersionSummary, backfillLatestVersionSummaryInternal, backfillSkillFingerprintsInternalHandler, backfillSkillSummariesInternalHandler, @@ -269,6 +271,72 @@ describe("maintenance backfill", () => { expect(runAfter).not.toHaveBeenCalled(); }); + it("backfills digest capability tags even when version summary already matches", async () => { + const digest = { + _id: "skillSearchDigest:1", + skillId: "skills:1", + latestVersionId: "skillVersions:1", + latestVersionSkillId: "skills:1", + latestVersionSummary: { + version: "1.0.0", + createdAt: 123, + changelog: "Same changelog", + changelogSource: "user", + clawdis: undefined, + }, + capabilityTags: ["old"], + }; + const skill = { + _id: "skills:1", + slug: "demo", + displayName: "Demo", + latestVersionId: "skillVersions:1", + latestVersionSummary: digest.latestVersionSummary, + capabilityTags: ["read-files"], + }; + const version = { + _id: "skillVersions:1", + skillId: "skills:1", + softDeletedAt: undefined, + version: "1.0.0", + }; + const paginate = vi.fn().mockResolvedValue({ + page: [digest], + continueCursor: null, + isDone: true, + }); + const patch = vi.fn().mockResolvedValue(undefined); + const ctx = { + db: { + query: vi.fn(() => ({ paginate })), + get: vi.fn(async (id: string) => { + if (id === "skills:1") return skill; + if (id === "skillVersions:1") return version; + return null; + }), + patch, + normalizeId: vi.fn(), + }, + scheduler: { + runAfter: vi.fn(), + }, + } as never; + + const result = await ( + backfillDigestVersionSummary as unknown as { _handler: Function } + )._handler(ctx, { + batchSize: 10, + }); + + expect(result).toEqual({ patched: 1, isDone: true, scanned: 1 }); + expect(patch).toHaveBeenCalledWith("skillSearchDigest:1", { + latestVersionId: "skillVersions:1", + latestVersionSkillId: "skills:1", + latestVersionSummary: digest.latestVersionSummary, + capabilityTags: ["read-files"], + }); + }); + it("backfills denormalized user hover stats from indexed owner pages", async () => { const runQuery = vi .fn() diff --git a/convex/maintenance.ts b/convex/maintenance.ts index fc4cd28c9c..d6f0f99e5c 100644 --- a/convex/maintenance.ts +++ b/convex/maintenance.ts @@ -2298,7 +2298,9 @@ export const backfillDigestVersionSummary = internalMutation({ if ( digest.latestVersionId === patch.latestVersionId && digest.latestVersionSkillId === patch.latestVersionSkillId && - JSON.stringify(digest.latestVersionSummary) === JSON.stringify(patch.latestVersionSummary) + JSON.stringify(digest.latestVersionSummary) === + JSON.stringify(patch.latestVersionSummary) && + JSON.stringify(digest.capabilityTags ?? []) === JSON.stringify(patch.capabilityTags ?? []) ) { continue; }