fix(api): guard moderated skill files and tags (#2287)

* fix(api): guard moderated skill files and tags

* fix(api): guard public list latest version ownership

* fix(api): guard stale latest version outputs

* fix(api): keep legacy digest latest versions

* fix(api): guard public latest-version readers

* fix: avoid ambiguous array allocation in skill export

* fix: drop legacy markerless digest versions

* fix: verify markerless digest versions

* test: mark package catalog digest versions

* fix: keep skill list tag resolution on digest path

* test: mark resolved skill versions with owner

* fix: repair digest capability backfill skip

---------

Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
Vyctor H. Brzezowski
2026-05-27 13:34:54 -07:00
committed by GitHub
co-authored by Patrick Erichsen
parent a920323a86
commit 97023d3123
24 changed files with 1719 additions and 143 deletions
+1
View File
@@ -16,6 +16,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).
- Web: stop stale unban restore batches from reactivating skills after the owner is banned again or deactivated (thanks @vyctorbrzezowski).
- Security/API: reject direct skill owner transfers when the skill is hidden, suspicious, or malicious (thanks @vyctorbrzezowski).
- Security/API: revalidate package publish actor, owner, and owner publisher active state in the final release insert (thanks @vyctorbrzezowski).
+61
View File
@@ -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<string, unknown>) => {
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<string, unknown>) => {
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();
});
});
+6 -30
View File
@@ -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()),
+2 -2
View File
@@ -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<DataModel>();
@@ -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,
+474 -5
View File
@@ -410,6 +410,7 @@ describe("httpApiV1 handlers", () => {
return {
page: [
{
skillId: "skills:alice",
slug: "demo",
displayName: "Alice Demo",
latestVersionId: "skillVersions:alice",
@@ -421,6 +422,7 @@ describe("httpApiV1 handlers", () => {
ownerDisplayName: "Alice",
},
{
skillId: "skills:bob",
slug: "demo",
displayName: "Bob Demo",
latestVersionId: "skillVersions:bob",
@@ -438,12 +440,14 @@ describe("httpApiV1 handlers", () => {
}
if (args.versionId === "skillVersions:alice") {
return {
skillId: "skills:alice",
version: "1.0.0",
files: [{ storageId: "storage:alice", path: "SKILL.md" }],
};
}
if (args.versionId === "skillVersions:bob") {
return {
skillId: "skills:bob",
version: "1.0.0",
files: [{ storageId: "storage:bob", path: "SKILL.md" }],
};
@@ -477,6 +481,96 @@ describe("httpApiV1 handlers", () => {
]);
});
it("skills export skips stale latest versions before reading blobs", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:actor",
user: { _id: "users:actor", role: "user" },
} as never);
vi.mocked(getOptionalApiTokenUser).mockResolvedValue({
userId: "users:actor",
user: { _id: "users:actor", role: "user" },
} as never);
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ("startDate" in args) {
return {
page: [
{
skillId: "skills:demo",
slug: "demo",
displayName: "Demo",
latestVersionId: "skillVersions:other",
createdAt: 1,
updatedAt: 2,
stats: {},
ownerUserId: "users:alice",
ownerHandle: "alice",
ownerDisplayName: "Alice",
},
{
skillId: "skills:deleted",
slug: "deleted",
displayName: "Deleted",
latestVersionId: "skillVersions:deleted",
createdAt: 1,
updatedAt: 3,
stats: {},
ownerUserId: "users:bob",
ownerHandle: "bob",
ownerDisplayName: "Bob",
},
],
nextCursor: null,
hasMore: false,
};
}
if (args.versionId === "skillVersions:other") {
return {
skillId: "skills:other",
version: "9.9.9",
files: [{ storageId: "storage:other", path: "SKILL.md" }],
softDeletedAt: undefined,
};
}
if (args.versionId === "skillVersions:deleted") {
return {
skillId: "skills:deleted",
version: "1.0.0",
files: [{ storageId: "storage:deleted", path: "SKILL.md" }],
softDeletedAt: 123,
};
}
return null;
});
const storageGet = vi.fn();
const response = await __handlers.exportSkillsV1Handler(
makeCtx({ runQuery, storage: { get: storageGet } }),
new Request("https://example.com/api/v1/skills/export?startDate=1&endDate=5", {
headers: { authorization: "Bearer user-token" },
}),
);
if (response.status !== 200) throw new Error(await response.text());
expect(response.headers.get("X-Export-Errors")).toBe("2");
expect(response.headers.get("X-Total-Returned")).toBe("0");
expect(storageGet).not.toHaveBeenCalled();
const zipEntries = unzipSync(new Uint8Array(await response.arrayBuffer()));
const errors = JSON.parse(new TextDecoder().decode(zipEntries["_errors.json"]));
expect(errors).toEqual([
{
slug: "demo",
error: "version not found (latestVersionId: skillVersions:other)",
},
{
slug: "deleted",
error: "version not available (latestVersionId: skillVersions:deleted)",
},
]);
expect(Object.keys(zipEntries).some((path) => path.endsWith("/SKILL.md"))).toBe(false);
});
it("skills export logs generation failure context", async () => {
vi.mocked(requireApiTokenUser).mockResolvedValue({
userId: "users:actor",
@@ -493,6 +587,7 @@ describe("httpApiV1 handlers", () => {
return {
page: [
{
skillId: "skills:demo",
slug: "demo",
displayName: "Demo",
latestVersionId: "skillVersions:demo",
@@ -510,6 +605,7 @@ describe("httpApiV1 handlers", () => {
}
if (args.versionId === "skillVersions:demo") {
return {
skillId: "skills:demo",
version: "1.0.0",
files: [
{ storageId: "storage:one", path: "SKILL.md" },
@@ -1061,7 +1157,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;
});
@@ -1118,9 +1216,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;
@@ -2375,6 +2473,8 @@ describe("httpApiV1 handlers", () => {
updatedAt: 2,
},
latestVersion: {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
createdAt: 1,
changelog: "c",
@@ -2445,6 +2545,8 @@ describe("httpApiV1 handlers", () => {
updatedAt: 2,
},
latestVersion: {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
createdAt: 1,
changelog: "c",
@@ -2496,6 +2598,8 @@ describe("httpApiV1 handlers", () => {
updatedAt: 2,
},
latestVersion: {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
createdAt: 1,
changelog: "c",
@@ -2542,6 +2646,7 @@ describe("httpApiV1 handlers", () => {
},
latestVersion: {
_id: "skillVersions:2",
skillId: "skills:1",
version: "2.0.0",
createdAt: 2,
changelog: "c",
@@ -2599,6 +2704,7 @@ describe("httpApiV1 handlers", () => {
},
latestVersion: {
_id: "skillVersions:2",
skillId: "skills:1",
version: "2.0.0",
createdAt: 2,
changelog: "c2",
@@ -2623,6 +2729,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",
@@ -2672,6 +2779,7 @@ describe("httpApiV1 handlers", () => {
},
latestVersion: {
_id: "skillVersions:2",
skillId: "skills:1",
version: "2.0.0",
createdAt: 2,
changelog: "c2",
@@ -2696,6 +2804,7 @@ describe("httpApiV1 handlers", () => {
if ("versionId" in args) {
return {
_id: "skillVersions:1",
skillId: "skills:1",
version: "1.0.0",
createdAt: 1,
changelog: "c1",
@@ -2726,8 +2835,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<string, unknown>) => {
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",
@@ -2778,6 +2930,93 @@ 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<string, unknown>) => {
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<string, unknown>) => {
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 stored Skill Card markdown", async () => {
const internalVersion = {
_id: "skillVersions:1",
@@ -2838,8 +3077,142 @@ describe("httpApiV1 handlers", () => {
expect(response.headers.get("X-Content-SHA256")).toBe("card-sha");
});
it("blocks Skill Card reads for malware-blocked skills", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
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/card"),
);
expect(response.status).toBe(403);
expect(await response.text()).toContain("flagged as malicious");
expect(storage.get).not.toHaveBeenCalled();
});
it("does not serve Skill Cards from another skill's tagged version", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
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:other") {
return {
_id: "skillVersions:other",
skillId: "skills:other",
version: "9.9.9",
files: [
{
path: "skill-card.md",
size: 12,
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/card?tag=old"),
);
expect(response.status).toBe(404);
expect(await response.text()).toBe("Version not found");
expect(storage.get).not.toHaveBeenCalled();
});
it("does not verify another skill's tagged version", async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
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:other") {
return {
_id: "skillVersions:other",
skillId: "skills:other",
version: "9.9.9",
files: [],
softDeletedAt: undefined,
};
}
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/verify?tag=old"),
);
expect(response.status).toBe(404);
expect(await response.text()).toBe("Version not found");
});
it("returns 404 when a Skill Card is missing", async () => {
const internalVersion = {
skillId: "skills:1",
version: "1.0.0",
createdAt: 1,
changelog: "c",
@@ -3854,7 +4227,7 @@ describe("httpApiV1 handlers", () => {
owner: null,
};
}
if ("versionId" in args) return { softDeletedAt: 123, files: [] };
if ("versionId" in args) return { skillId: "skills:1", softDeletedAt: 123, files: [] };
return null;
});
const runMutation = vi.fn().mockResolvedValue(okRate());
@@ -3869,6 +4242,7 @@ describe("httpApiV1 handlers", () => {
it("returns 413 when raw file too large", async () => {
const internalVersion = {
skillId: "skills:1",
version: "1.0.0",
createdAt: 1,
changelog: "c",
@@ -6309,6 +6683,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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
if ("name" in args) return null;
+29 -3
View File
@@ -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();
@@ -56,4 +61,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" }]);
});
});
+33 -10
View File
@@ -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";
@@ -967,10 +968,11 @@ async function searchPackageCatalog(
async function resolveSkillTags(
ctx: ActionCtx,
skillId: Id<"skills">,
tags: Record<string, Id<"skillVersions">>,
latestVersion?: SkillVersionLike | null,
): Promise<Record<string, string>> {
const [resolved] = await resolveTagsBatch(ctx, [tags], [latestVersion]);
const [resolved] = await resolveTagsBatch(ctx, [tags], [latestVersion], [skillId]);
return resolved ?? {};
}
@@ -2295,6 +2297,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;
}
@@ -2308,23 +2316,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(
@@ -2662,7 +2677,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,
@@ -2721,7 +2741,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) => ({
@@ -2820,7 +2840,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: {
@@ -2903,6 +2923,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);
+42 -4
View File
@@ -221,12 +221,14 @@ export async function resolveTagsBatch(
ctx: ActionCtx,
tagsList: Array<Record<string, Id<"skillVersions">>>,
latestVersions?: Array<LatestVersionTag<"skillVersions">>,
skillIds?: Array<Id<"skills"> | undefined>,
): Promise<Array<Record<string, string>>> {
return resolveVersionTagsBatch(
ctx,
tagsList,
internal.skills.getVersionsByIdsInternal,
latestVersions,
skillIds,
);
}
@@ -235,10 +237,28 @@ type LatestVersionTag<TTable extends "skillVersions" | "soulVersions"> =
_id: Id<TTable>;
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.
@@ -252,13 +272,20 @@ export async function resolveVersionTagsBatch<TTable extends "skillVersions" | "
tagsList: Array<Record<string, Id<TTable>>>,
getVersionsByIdsQuery: unknown,
latestVersions?: Array<LatestVersionTag<TTable>>,
resourceIds?: Array<TagResourceId | undefined>,
): Promise<Array<Record<string, string>>> {
const allVersionIds = new Set<Id<TTable>>();
const preResolvedTags = tagsList.map((tags, idx) => {
const resolved: Record<string, string> = {};
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);
@@ -277,19 +304,30 @@ export async function resolveVersionTagsBatch<TTable extends "skillVersions" | "
_id: Id<TTable>;
version: string;
softDeletedAt?: unknown;
skillId?: Id<"skills">;
soulId?: Id<"souls">;
}> | null) ?? [];
const versionMap = new Map<Id<TTable>, string>();
const versionMap = new Map<
Id<TTable>,
{
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;
});
+51 -9
View File
@@ -22,6 +22,7 @@ import type {
LlmRiskSummary,
} from "../lib/securityPrompt";
import { selectGeneratedSkillCardFile, sourceSkillVersionFiles } from "../lib/skillCards";
import { getPublicSkillFileAccessBlock, isSkillVersionForSkill } from "../lib/skillFileAccess";
import {
buildMergedExportZip,
type MergedExportManifestEntry,
@@ -116,6 +117,7 @@ type PublicSkillVersionStaticScan = Pick<
type PublicSkillVersionResponse = {
_id: Id<"skillVersions">;
skillId?: Id<"skills">;
version: string;
createdAt?: number;
changelog?: string;
@@ -1108,6 +1110,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) => ({
@@ -1260,7 +1263,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: {
@@ -1481,7 +1489,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);
@@ -1557,7 +1567,9 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
: null;
}
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 fingerprintEntries = ((await ctx.runQuery(
@@ -1648,6 +1660,10 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
if (hidden) return text(hidden.message, hidden.status, rate.headers);
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, {
@@ -1666,7 +1682,9 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
: null;
}
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 fingerprintEntries = ((await ctx.runQuery(
@@ -1701,6 +1719,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, {
@@ -1719,7 +1741,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();
@@ -2452,6 +2476,7 @@ export async function exportSkillsV1Handler(ctx: ActionCtx, request: Request) {
let result: {
page: Array<{
skillId: Id<"skills">;
slug: string;
displayName: string;
latestVersionId?: Id<"skillVersions">;
@@ -2516,6 +2541,10 @@ export async function exportSkillsV1Handler(ctx: ActionCtx, request: Request) {
: Promise.resolve(null),
);
logContext.versionCount = versionDocs.filter(Boolean).length;
const exportableVersions: Array<Doc<"skillVersions"> | null> = Array.from(
{ length: result.page.length },
() => null,
);
type BlobTask = { digestIndex: number; fileIndex: number; storageId: Id<"_storage"> };
const blobTasks: BlobTask[] = [];
@@ -2523,9 +2552,7 @@ export async function exportSkillsV1Handler(ctx: ActionCtx, request: Request) {
logContext.phase = "plan_blobs";
for (let i = 0; i < result.page.length; i++) {
const digest = result.page[i];
const version = versionDocs[i] as {
files?: Array<{ storageId: Id<"_storage">; path: string }>;
} | null;
const version = versionDocs[i] as Doc<"skillVersions"> | null;
if (!version) {
exportErrors.push({
@@ -2534,6 +2561,20 @@ export async function exportSkillsV1Handler(ctx: ActionCtx, request: Request) {
});
continue;
}
if (!isSkillVersionForSkill(version, digest.skillId)) {
exportErrors.push({
slug: digest.slug,
error: `version not found (latestVersionId: ${digest.latestVersionId})`,
});
continue;
}
if (version.softDeletedAt) {
exportErrors.push({
slug: digest.slug,
error: `version not available (latestVersionId: ${digest.latestVersionId})`,
});
continue;
}
if (!version.files || version.files.length === 0) {
exportErrors.push({
slug: digest.slug,
@@ -2541,6 +2582,7 @@ export async function exportSkillsV1Handler(ctx: ActionCtx, request: Request) {
});
continue;
}
exportableVersions[i] = version;
if (!validateSlug(digest.slug)) {
exportErrors.push({
@@ -2588,7 +2630,7 @@ export async function exportSkillsV1Handler(ctx: ActionCtx, request: Request) {
logContext.phase = "assemble_entries";
for (let i = 0; i < result.page.length; i++) {
const digest = result.page[i];
const version = versionDocs[i] as {
const version = exportableVersions[i] as {
version?: string;
files?: Array<{ storageId: Id<"_storage">; path: string }>;
} | null;
+59
View File
@@ -0,0 +1,59 @@
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));
}
+33
View File
@@ -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 = {
+18
View File
@@ -45,6 +45,7 @@ const SHARED_KEYS = [
/** Fields stored in the skillSearchDigest table. */
export type SkillSearchDigestFields = Pick<Doc<"skills">, (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<MutationCtx, "db">,
skill: Doc<"skills">,
): Promise<SkillSearchDigestFields> {
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();
}
+68
View File
@@ -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()
+20 -7
View File
@@ -17,7 +17,7 @@ import {
import { hashSkillFiles, isTextFile } from "./lib/skills";
import { computeIsSuspicious } from "./lib/skillSafety";
import {
extractDigestFields,
extractValidatedDigestFields,
getFirstSearchToken,
normalizeSkillSearchText,
} from "./lib/skillSearchDigest";
@@ -2090,7 +2090,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++;
}
}
@@ -2286,12 +2286,25 @@ 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) &&
JSON.stringify(digest.capabilityTags ?? []) === JSON.stringify(patch.capabilityTags ?? [])
) {
continue;
}
await ctx.db.patch(digest._id, patch);
patched++;
}
+1
View File
@@ -866,6 +866,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(),
+108 -1
View File
@@ -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<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
@@ -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<string, unknown> | null;
publisher?: Record<string, unknown> | null;
membership?: Record<string, unknown> | null;
versionsById?: Record<string, Record<string, unknown>>;
}) {
vi.mocked(getAuthUserId).mockResolvedValue(params.user._id as never);
const patch = vi.fn(async (_id: string, value: Record<string, unknown>) => 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();
+1
View File
@@ -74,6 +74,7 @@ function makeDigest(
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: `skillVersions:${slug}-1`,
latestVersionSkillId: `skills:${slug}`,
latestVersionSummary: {
version: "1.0.0",
createdAt: 10,
+142
View File
@@ -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<string, unknown> = {}) {
};
}
function makeResolveCtx(args: {
skill: Record<string, unknown>;
latestVersion?: Record<string, unknown> | null;
matchVersion?: Record<string, unknown> | null;
fingerprintMatches?: Array<Record<string, unknown>>;
}) {
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();
@@ -503,6 +550,101 @@ 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();
});
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", () => {
+265 -1
View File
@@ -18,7 +18,13 @@ vi.mock("convex-helpers/server/pagination", async () => {
});
const pagination = await import("convex-helpers/server/pagination");
const { listPublicApiPageV1, listPublicPageV4, listRelatedByCategory } = await import("./skills");
const {
listAuditPage,
listPublicApiPageV1,
listPublicPageV4,
listPublicTrendingPage,
listRelatedByCategory,
} = await import("./skills");
type WrappedHandler<TArgs, TResult> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
@@ -61,6 +67,63 @@ const listRelatedByCategoryHandler = (
{ items: Array<{ skill: { slug: string }; ownerHandle: string | null }> }
>
)._handler;
const listPublicTrendingPageHandler = (
listPublicTrendingPage as unknown as WrappedHandler<
{ limit?: number; nonSuspiciousOnly?: boolean },
PublicApiListResult
>
)._handler;
const listAuditPageHandler = (
listAuditPage as unknown as WrappedHandler<
{ paginationOpts: { cursor: string | null; numItems: number } },
PublicListResult
>
)._handler;
function makeSearchDigest(overrides: Record<string, unknown> = {}) {
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 +348,207 @@ 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("keeps verified legacy API list latest versions without owner markers", async () => {
getPageMock.mockResolvedValueOnce({
page: [
makeSearchDigest({
latestVersionSkillId: undefined,
}),
],
hasMore: false,
indexKeys: [],
});
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: {
version: "1.0.0",
},
});
});
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 });
});
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 {
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",
},
});
});
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<string, unknown>) {
+3
View File
@@ -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,
};
+150 -67
View File
@@ -104,6 +104,7 @@ import {
selectSkillCardFile,
sourceSkillVersionFiles,
} from "./lib/skillCards";
import { isPublicSkillVersionAvailableForSkill } from "./lib/skillFileAccess";
import { normalizeSkillIconValue } from "./lib/skillIcon";
import {
fetchText,
@@ -121,7 +122,7 @@ import {
import {
digestToHydratableSkill,
digestToOwnerInfo,
extractDigestFields,
extractValidatedDigestFields,
upsertSkillSearchDigest,
} from "./lib/skillSearchDigest";
import { assertValidSkillSlug, normalizeSkillSlug } from "./lib/skillSlugValidator";
@@ -1708,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.
@@ -1873,16 +1874,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, skill._id)
: toPublicSkillListVersion(latestVersionDoc);
return {
skill: publicSkill,
latestVersion,
@@ -1917,6 +1919,15 @@ async function filterSkillsByActiveOwner(ctx: Pick<QueryCtx, "db">, skills: Doc<
return filtered.filter((skill): skill is Doc<"skills"> => skill !== null);
}
async function loadPublicLatestVersionForSkill(
ctx: Pick<QueryCtx, "db">,
skill: Pick<Doc<"skills">, "_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 {
@@ -1924,6 +1935,7 @@ function toPublicSkillListVersion(
return {
_id: version._id,
_creationTime: version._creationTime,
skillId: version.skillId,
version: version.version,
createdAt: version.createdAt,
changelog: version.changelog,
@@ -2029,10 +2041,12 @@ async function getGeneratedSkillCardPublicFile(
function toPublicSkillListVersionFromSummary(
summary: NonNullable<Doc<"skills">["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,
@@ -2227,8 +2241,14 @@ export const getBySlug = query({
const isOwner = Boolean(userId && (userId === skill.ownerUserId || membership));
const latestVersionDoc = skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null;
const latestVersion = toPublicSkillVersion(latestVersionDoc);
const generatedSkillCard = await getGeneratedSkillCardPublicFile(ctx, latestVersionDoc);
const publicLatestVersionDoc = isPublicSkillVersionAvailableForSkill(
latestVersionDoc,
skill._id,
)
? latestVersionDoc
: null;
const latestVersion = toPublicSkillVersion(publicLatestVersionDoc);
const generatedSkillCard = await getGeneratedSkillCardPublicFile(ctx, publicLatestVersionDoc);
if (latestVersion) latestVersion.generatedSkillCard = generatedSkillCard;
const owner = toPublicPublisher(ownerPublisher);
if (!owner) return null;
@@ -3340,12 +3360,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(
(
@@ -4491,9 +4512,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 = await resolveDigestLatestVersionForSkill(ctx, digest);
items.push({
skill: publicSkill,
latestVersion,
@@ -4708,9 +4727,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]);
@@ -4761,7 +4782,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) {
@@ -4961,7 +4982,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;
@@ -4997,7 +5018,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;
@@ -5021,11 +5042,9 @@ 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 = digest.latestVersionId
? await ctx.db.get(digest.latestVersionId)
: null;
const latestVersion = await loadPublicLatestVersionForDigest(ctx, digest);
page.push({
kind: "skill" as const,
skill: entry.skill,
@@ -5065,17 +5084,16 @@ export const listAuditPage = query({
},
});
function buildPublicSkillEntryFromDigest(
async function buildPublicSkillEntryFromDigest(
ctx: Pick<QueryCtx, "db">,
digest: Doc<"skillSearchDigest">,
): PublicSkillEntry | null {
): Promise<PublicSkillEntry | null> {
const hydratable = digestToHydratableSkill(digest);
const publicSkill = toPublicSkill(hydratable);
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 = await resolveDigestLatestVersionForSkill(ctx, digest);
return {
skill: publicSkill,
latestVersion,
@@ -5084,15 +5102,61 @@ function buildPublicSkillEntryFromDigest(
};
}
function buildPublicSkillApiListEntryFromDigest(digest: Doc<"skillSearchDigest">) {
async function loadPublicLatestVersionForDigest(
ctx: Pick<QueryCtx, "db">,
digest: Pick<Doc<"skillSearchDigest">, "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;
}
if (digest.latestVersionSkillId !== digest.skillId) {
return null;
}
return toPublicSkillListVersionFromSummary(
digest.latestVersionSummary,
digest.latestVersionId,
digest.skillId,
);
}
async function resolveDigestLatestVersionForSkill(
ctx: Pick<QueryCtx, "db">,
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,
digest.skillId,
)
: null;
}
return toDigestLatestVersionForSkill(digest);
}
async function buildPublicSkillApiListEntryFromDigest(
ctx: Pick<QueryCtx, "db">,
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 = await resolveDigestLatestVersionForSkill(ctx, digest);
return {
skill: {
@@ -5154,9 +5218,11 @@ export const listPublicApiPageV1 = query({
index: indexName,
schema,
});
const items = result.page
.map((digest) => buildPublicSkillApiListEntryFromDigest(digest))
.filter((item): item is NonNullable<typeof item> => 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])
@@ -5252,8 +5318,12 @@ function skillCatalogMatchesFilters(
return true;
}
function toPublicSkillCatalogItem(digest: Doc<"skillSearchDigest">): PublicSkillCatalogItem {
async function toPublicSkillCatalogItem(
ctx: Pick<QueryCtx, "db">,
digest: Doc<"skillSearchDigest">,
): Promise<PublicSkillCatalogItem> {
const ownerInfo = digestToOwnerInfo(digest);
const latestVersion = await resolveDigestLatestVersionForSkill(ctx, digest);
return {
name: digest.slug,
displayName: digest.displayName,
@@ -5265,7 +5335,7 @@ function toPublicSkillCatalogItem(digest: Doc<"skillSearchDigest">): PublicSkill
ownerHandle: ownerInfo?.ownerHandle ?? null,
createdAt: digest.createdAt,
updatedAt: digest.updatedAt,
latestVersion: digest.latestVersionSummary?.version ?? null,
latestVersion: latestVersion?.version ?? null,
capabilityTags: digest.capabilityTags ?? [],
executesCode: false,
verificationTier: null,
@@ -5408,7 +5478,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) {
@@ -5476,7 +5546,7 @@ async function searchPackageCatalogImpl(ctx: QueryCtx, args: SkillPackageCatalog
seen.add(exactDigest.skillId);
matches.push({
...match,
package: toPublicSkillCatalogItem(exactDigest),
package: await toPublicSkillCatalogItem(ctx, exactDigest),
});
}
}
@@ -5497,7 +5567,7 @@ async function searchPackageCatalogImpl(ctx: QueryCtx, args: SkillPackageCatalog
seen.add(digest.skillId);
matches.push({
...match,
package: toPublicSkillCatalogItem(digest),
package: await toPublicSkillCatalogItem(ctx, digest),
});
}
}
@@ -5615,10 +5685,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 };
@@ -8078,8 +8149,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<typeof action> = action({
@@ -8171,7 +8241,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")
@@ -8237,6 +8310,18 @@ export const updateTags = mutation({
assertModerator(user);
}
const versionsById = new Map<Id<"skillVersions">, 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;
@@ -8252,18 +8337,16 @@ 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,
apiKeyRequired: version.apiKeyRequired,
};
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,
apiKeyRequired: version.apiKeyRequired,
};
patch.capabilityTags = version.capabilityTags;
}
await ctx.db.patch(skill._id, patch);
@@ -9040,7 +9123,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,
@@ -9608,7 +9691,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,
+142 -2
View File
@@ -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<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>;
@@ -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();
});
});
+2 -2
View File
@@ -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 () => {
+8
View File
@@ -109,6 +109,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