From d637cbc13d958d90a1ae28495b49295b5403f568 Mon Sep 17 00:00:00 2001 From: Arthurzkv Date: Sun, 15 Feb 2026 14:47:38 -0600 Subject: [PATCH 1/2] feat: expose moderation v2 API and schema contracts --- convex/httpApiV1.handlers.test.ts | 1506 ++++++++++++----------- convex/httpApiV1/skillsV1.ts | 526 ++++---- packages/clawdhub/src/schema/schemas.ts | 382 +++--- packages/schema/dist/schemas.d.ts | 39 + packages/schema/dist/schemas.js | 41 + packages/schema/dist/schemas.js.map | 2 +- packages/schema/src/schemas.ts | 408 +++--- 7 files changed, 1600 insertions(+), 1304 deletions(-) diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index d5e8c8108..b34b1c6e9 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -1,54 +1,54 @@ /* @vitest-environment node */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock('./lib/apiTokenAuth', () => ({ +vi.mock("./lib/apiTokenAuth", () => ({ requireApiTokenUser: vi.fn(), getOptionalApiTokenUserId: vi.fn(), -})) +})); -vi.mock('./skills', () => ({ +vi.mock("./skills", () => ({ publishVersionForUser: vi.fn(), -})) +})); -const { getOptionalApiTokenUserId, requireApiTokenUser } = await import('./lib/apiTokenAuth') -const { publishVersionForUser } = await import('./skills') -const { __handlers } = await import('./httpApiV1') +const { getOptionalApiTokenUserId, requireApiTokenUser } = await import("./lib/apiTokenAuth"); +const { publishVersionForUser } = await import("./skills"); +const { __handlers } = await import("./httpApiV1"); -type ActionCtx = import('./_generated/server').ActionCtx +type ActionCtx = import("./_generated/server").ActionCtx; -type RateLimitArgs = { key: string; limit: number; windowMs: number } +type RateLimitArgs = { key: string; limit: number; windowMs: number }; function isRateLimitArgs(args: unknown): args is RateLimitArgs { - if (!args || typeof args !== 'object') return false - const value = args as Record + if (!args || typeof args !== "object") return false; + const value = args as Record; return ( - typeof value.key === 'string' && - typeof value.limit === 'number' && - typeof value.windowMs === 'number' - ) + typeof value.key === "string" && + typeof value.limit === "number" && + typeof value.windowMs === "number" + ); } function hasSlugArgs(args: unknown): args is { slug: string } { - if (!args || typeof args !== 'object') return false - const value = args as Record - return typeof value.slug === 'string' + if (!args || typeof args !== "object") return false; + const value = args as Record; + return typeof value.slug === "string"; } function makeCtx(partial: Record) { const partialRunQuery = - typeof partial.runQuery === 'function' + typeof partial.runQuery === "function" ? (partial.runQuery as (query: unknown, args: Record) => unknown) - : null + : null; const runQuery = vi.fn(async (query: unknown, args: Record) => { - if (isRateLimitArgs(args)) return okRate() - return partialRunQuery ? await partialRunQuery(query, args) : null - }) + if (isRateLimitArgs(args)) return okRate(); + return partialRunQuery ? await partialRunQuery(query, args) : null; + }); const runMutation = - typeof partial.runMutation === 'function' + typeof partial.runMutation === "function" ? partial.runMutation - : vi.fn().mockResolvedValue(okRate()) + : vi.fn().mockResolvedValue(okRate()); - return { ...partial, runQuery, runMutation } as unknown as ActionCtx + return { ...partial, runQuery, runMutation } as unknown as ActionCtx; } const okRate = () => ({ @@ -56,672 +56,742 @@ const okRate = () => ({ remaining: 10, limit: 100, resetAt: Date.now() + 60_000, -}) +}); const blockedRate = () => ({ allowed: false, remaining: 0, limit: 100, resetAt: Date.now() + 60_000, -}) +}); beforeEach(() => { - vi.mocked(getOptionalApiTokenUserId).mockReset() - vi.mocked(getOptionalApiTokenUserId).mockResolvedValue(null) - vi.mocked(requireApiTokenUser).mockReset() - vi.mocked(publishVersionForUser).mockReset() -}) - -describe('httpApiV1 handlers', () => { - it('search returns empty results for blank query', async () => { - const runAction = vi.fn() - const runMutation = vi.fn().mockResolvedValue(okRate()) + vi.mocked(getOptionalApiTokenUserId).mockReset(); + vi.mocked(getOptionalApiTokenUserId).mockResolvedValue(null); + vi.mocked(requireApiTokenUser).mockReset(); + vi.mocked(publishVersionForUser).mockReset(); +}); + +describe("httpApiV1 handlers", () => { + it("search returns empty results for blank query", async () => { + const runAction = vi.fn(); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.searchSkillsV1Handler( makeCtx({ runAction, runMutation }), - new Request('https://example.com/api/v1/search?q=%20%20'), - ) + new Request("https://example.com/api/v1/search?q=%20%20"), + ); if (response.status !== 200) { - throw new Error(await response.text()) + throw new Error(await response.text()); } - expect(await response.json()).toEqual({ results: [] }) - expect(runAction).not.toHaveBeenCalled() - }) - - it('users/restore forbids non-admin api tokens', async () => { - const runQuery = vi.fn() - const runAction = vi.fn() - const runMutation = vi.fn().mockResolvedValue(okRate()) + expect(await response.json()).toEqual({ results: [] }); + expect(runAction).not.toHaveBeenCalled(); + }); + + it("users/restore forbids non-admin api tokens", async () => { + const runQuery = vi.fn(); + const runAction = vi.fn(); + const runMutation = vi.fn().mockResolvedValue(okRate()); vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:actor', - user: { _id: 'users:actor', role: 'user' }, - } as never) + userId: "users:actor", + user: { _id: "users:actor", role: "user" }, + } as never); const response = await __handlers.usersPostRouterV1Handler( makeCtx({ runQuery, runAction, runMutation }), - new Request('https://example.com/api/v1/users/restore', { - method: 'POST', - body: JSON.stringify({ handle: 'target', slugs: ['a'] }), + new Request("https://example.com/api/v1/users/restore", { + method: "POST", + body: JSON.stringify({ handle: "target", slugs: ["a"] }), }), - ) - expect(response.status).toBe(403) - expect(runQuery).not.toHaveBeenCalled() - expect(runAction).not.toHaveBeenCalled() - }) - - it('users/restore calls restore action for admin', async () => { - const runAction = vi.fn().mockResolvedValue({ ok: true, totalRestored: 1, results: [] }) + ); + expect(response.status).toBe(403); + expect(runQuery).not.toHaveBeenCalled(); + expect(runAction).not.toHaveBeenCalled(); + }); + + it("users/restore calls restore action for admin", async () => { + const runAction = vi.fn().mockResolvedValue({ ok: true, totalRestored: 1, results: [] }); const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { - if (isRateLimitArgs(args)) return okRate() - return { ok: true } - }) + if (isRateLimitArgs(args)) return okRate(); + return { ok: true }; + }); const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('handle' in args) return { _id: 'users:target' } - return null - }) + if ("handle" in args) return { _id: "users:target" }; + return null; + }); vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:admin', - user: { _id: 'users:admin', role: 'admin' }, - } as never) + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); const response = await __handlers.usersPostRouterV1Handler( makeCtx({ runQuery, runAction, runMutation }), - new Request('https://example.com/api/v1/users/restore', { - method: 'POST', + new Request("https://example.com/api/v1/users/restore", { + method: "POST", body: JSON.stringify({ - handle: 'Target', - slugs: ['a', 'b'], + handle: "Target", + slugs: ["a", "b"], forceOverwriteSquatter: true, }), }), - ) - if (response.status !== 200) throw new Error(await response.text()) + ); + if (response.status !== 200) throw new Error(await response.text()); expect(runAction).toHaveBeenCalledWith(expect.anything(), { - actorUserId: 'users:admin', - ownerHandle: 'target', - ownerUserId: 'users:target', - slugs: ['a', 'b'], + actorUserId: "users:admin", + ownerHandle: "target", + ownerUserId: "users:target", + slugs: ["a", "b"], forceOverwriteSquatter: true, - }) - }) + }); + }); - it('users/reclaim forbids non-admin api tokens', async () => { - const runQuery = vi.fn() - const runAction = vi.fn() - const runMutation = vi.fn().mockResolvedValue(okRate()) + it("users/reclaim forbids non-admin api tokens", async () => { + const runQuery = vi.fn(); + const runAction = vi.fn(); + const runMutation = vi.fn().mockResolvedValue(okRate()); vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:actor', - user: { _id: 'users:actor', role: 'user' }, - } as never) + userId: "users:actor", + user: { _id: "users:actor", role: "user" }, + } as never); const response = await __handlers.usersPostRouterV1Handler( makeCtx({ runQuery, runAction, runMutation }), - new Request('https://example.com/api/v1/users/reclaim', { - method: 'POST', - body: JSON.stringify({ handle: 'target', slugs: ['a'] }), + new Request("https://example.com/api/v1/users/reclaim", { + method: "POST", + body: JSON.stringify({ handle: "target", slugs: ["a"] }), }), - ) - expect(response.status).toBe(403) - expect(runQuery).not.toHaveBeenCalled() - }) + ); + expect(response.status).toBe(403); + expect(runQuery).not.toHaveBeenCalled(); + }); - it('users/reclaim calls reclaim mutation for admin', async () => { + it("users/reclaim calls reclaim mutation for admin", async () => { const runMutation = vi.fn(async (_mutation: unknown, args: Record) => { - if (isRateLimitArgs(args)) return okRate() - return { ok: true } - }) + if (isRateLimitArgs(args)) return okRate(); + return { ok: true }; + }); const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('handle' in args) return { _id: 'users:target' } - return null - }) + if ("handle" in args) return { _id: "users:target" }; + return null; + }); vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:admin', - user: { _id: 'users:admin', role: 'admin' }, - } as never) + userId: "users:admin", + user: { _id: "users:admin", role: "admin" }, + } as never); const response = await __handlers.usersPostRouterV1Handler( makeCtx({ runQuery, runAction: vi.fn(), runMutation }), - new Request('https://example.com/api/v1/users/reclaim', { - method: 'POST', - body: JSON.stringify({ handle: 'Target', slugs: [' A ', 'b'], reason: 'r' }), + new Request("https://example.com/api/v1/users/reclaim", { + method: "POST", + body: JSON.stringify({ handle: "Target", slugs: [" A ", "b"], reason: "r" }), }), - ) - if (response.status !== 200) throw new Error(await response.text()) + ); + if (response.status !== 200) throw new Error(await response.text()); - const reclaimCalls = runMutation.mock.calls.filter(([, args]) => hasSlugArgs(args)) - expect(reclaimCalls).toHaveLength(2) + const reclaimCalls = runMutation.mock.calls.filter(([, args]) => hasSlugArgs(args)); + expect(reclaimCalls).toHaveLength(2); expect(reclaimCalls[0]?.[1]).toMatchObject({ - actorUserId: 'users:admin', - slug: 'a', - rightfulOwnerUserId: 'users:target', - reason: 'r', - }) + actorUserId: "users:admin", + slug: "a", + rightfulOwnerUserId: "users:target", + reason: "r", + }); expect(reclaimCalls[1]?.[1]).toMatchObject({ - actorUserId: 'users:admin', - slug: 'b', - rightfulOwnerUserId: 'users:target', - reason: 'r', - }) - }) - - it('search forwards limit and highlightedOnly', async () => { + actorUserId: "users:admin", + slug: "b", + rightfulOwnerUserId: "users:target", + reason: "r", + }); + }); + + it("search forwards limit and highlightedOnly", async () => { const runAction = vi.fn().mockResolvedValue([ { score: 1, - skill: { slug: 'a', displayName: 'A', summary: null, updatedAt: 1 }, - version: { version: '1.0.0' }, + skill: { slug: "a", displayName: "A", summary: null, updatedAt: 1 }, + version: { version: "1.0.0" }, }, - ]) - const runMutation = vi.fn().mockResolvedValue(okRate()) + ]); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.searchSkillsV1Handler( makeCtx({ runAction, runMutation }), - new Request('https://example.com/api/v1/search?q=test&limit=5&highlightedOnly=true'), - ) + new Request("https://example.com/api/v1/search?q=test&limit=5&highlightedOnly=true"), + ); if (response.status !== 200) { - throw new Error(await response.text()) + throw new Error(await response.text()); } expect(runAction).toHaveBeenCalledWith(expect.anything(), { - query: 'test', + query: "test", limit: 5, highlightedOnly: true, - }) - }) + }); + }); - it('search rate limits', async () => { - const runMutation = vi.fn().mockResolvedValue(blockedRate()) + it("search rate limits", async () => { + const runMutation = vi.fn().mockResolvedValue(blockedRate()); const response = await __handlers.searchSkillsV1Handler( makeCtx({ runAction: vi.fn(), runMutation }), - new Request('https://example.com/api/v1/search?q=test'), - ) - expect(response.status).toBe(429) - }) + new Request("https://example.com/api/v1/search?q=test"), + ); + expect(response.status).toBe(429); + }); - it('resolve validates hash', async () => { - const runMutation = vi.fn().mockResolvedValue(okRate()) + it("resolve validates hash", async () => { + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.resolveSkillVersionV1Handler( makeCtx({ runQuery: vi.fn(), runMutation }), - new Request('https://example.com/api/v1/resolve?slug=demo&hash=bad'), - ) - expect(response.status).toBe(400) - }) - - it('resolve returns 404 when missing', async () => { - const runQuery = vi.fn().mockResolvedValue(null) - const runMutation = vi.fn().mockResolvedValue(okRate()) + new Request("https://example.com/api/v1/resolve?slug=demo&hash=bad"), + ); + expect(response.status).toBe(400); + }); + + it("resolve returns 404 when missing", async () => { + const runQuery = vi.fn().mockResolvedValue(null); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.resolveSkillVersionV1Handler( makeCtx({ runQuery, runMutation }), new Request( - 'https://example.com/api/v1/resolve?slug=demo&hash=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + "https://example.com/api/v1/resolve?slug=demo&hash=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ), - ) - expect(response.status).toBe(404) - }) + ); + expect(response.status).toBe(404); + }); - it('resolve returns match and latestVersion', async () => { + it("resolve returns match and latestVersion", async () => { const runQuery = vi.fn().mockResolvedValue({ - match: { version: '1.0.0' }, - latestVersion: { version: '2.0.0' }, - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + match: { version: "1.0.0" }, + latestVersion: { version: "2.0.0" }, + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.resolveSkillVersionV1Handler( makeCtx({ runQuery, runMutation }), new Request( - 'https://example.com/api/v1/resolve?slug=demo&hash=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + "https://example.com/api/v1/resolve?slug=demo&hash=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.match.version).toBe('1.0.0') - }) + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.match.version).toBe("1.0.0"); + }); - it('lists skills with resolved tags using batch query', async () => { + it("lists skills with resolved tags using batch query", async () => { const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('cursor' in args || 'limit' in args) { + if ("cursor" in args || "limit" in args) { return { items: [ { skill: { - _id: 'skills:1', - slug: 'demo', - displayName: 'Demo', - summary: 's', - tags: { latest: 'versions:1' }, + _id: "skills:1", + slug: "demo", + displayName: "Demo", + summary: "s", + tags: { latest: "versions:1" }, stats: { downloads: 0, stars: 0, versions: 1, comments: 0 }, createdAt: 1, updatedAt: 2, }, - latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' }, + latestVersion: { version: "1.0.0", createdAt: 3, changelog: "c" }, }, ], nextCursor: null, - } + }; } // Batch query: versionIds (plural) - if ('versionIds' in args) { - return [{ _id: 'versions:1', version: '1.0.0', softDeletedAt: undefined }] + if ("versionIds" in args) { + return [{ _id: "versions:1", version: "1.0.0", softDeletedAt: undefined }]; } - return null - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + return null; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.listSkillsV1Handler( makeCtx({ runQuery, runMutation }), - new Request('https://example.com/api/v1/skills?limit=1'), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.items[0].tags.latest).toBe('1.0.0') - }) - - it('batches tag resolution across multiple skills into single query', async () => { + new Request("https://example.com/api/v1/skills?limit=1"), + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.items[0].tags.latest).toBe("1.0.0"); + }); + + it("batches tag resolution across multiple skills into single query", async () => { const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('cursor' in args || 'limit' in args) { + if ("cursor" in args || "limit" in args) { return { items: [ { skill: { - _id: 'skills:1', - slug: 'skill-a', - displayName: 'Skill A', - summary: 's', - tags: { latest: 'versions:1', stable: 'versions:2' }, + _id: "skills:1", + slug: "skill-a", + displayName: "Skill A", + summary: "s", + tags: { latest: "versions:1", stable: "versions:2" }, stats: { downloads: 0, stars: 0, versions: 2, comments: 0 }, createdAt: 1, updatedAt: 2, }, - latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'c' }, + latestVersion: { version: "2.0.0", createdAt: 3, changelog: "c" }, }, { skill: { - _id: 'skills:2', - slug: 'skill-b', - displayName: 'Skill B', - summary: 's', - tags: { latest: 'versions:3' }, + _id: "skills:2", + slug: "skill-b", + displayName: "Skill B", + summary: "s", + tags: { latest: "versions:3" }, stats: { downloads: 0, stars: 0, versions: 1, comments: 0 }, createdAt: 1, updatedAt: 2, }, - latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' }, + latestVersion: { version: "1.0.0", createdAt: 3, changelog: "c" }, }, ], nextCursor: null, - } + }; } // Batch query should receive all version IDs from all skills - if ('versionIds' in args) { - const ids = args.versionIds as string[] - expect(ids).toHaveLength(3) - expect(ids).toContain('versions:1') - expect(ids).toContain('versions:2') - expect(ids).toContain('versions:3') + if ("versionIds" in args) { + const ids = args.versionIds as string[]; + expect(ids).toHaveLength(3); + expect(ids).toContain("versions:1"); + 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", version: "2.0.0", softDeletedAt: undefined }, + { _id: "versions:2", version: "1.0.0", softDeletedAt: undefined }, + { _id: "versions:3", version: "1.0.0", softDeletedAt: undefined }, + ]; } - return null - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + return null; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.listSkillsV1Handler( makeCtx({ runQuery, runMutation }), - new Request('https://example.com/api/v1/skills'), - ) - expect(response.status).toBe(200) - const json = await response.json() + new Request("https://example.com/api/v1/skills"), + ); + expect(response.status).toBe(200); + const json = await response.json(); // Verify tags are correctly resolved for each skill - expect(json.items[0].tags.latest).toBe('2.0.0') - expect(json.items[0].tags.stable).toBe('1.0.0') - expect(json.items[1].tags.latest).toBe('1.0.0') + expect(json.items[0].tags.latest).toBe("2.0.0"); + expect(json.items[0].tags.stable).toBe("1.0.0"); + expect(json.items[1].tags.latest).toBe("1.0.0"); // Verify batch query was called exactly once (not per-tag) const batchCalls = runQuery.mock.calls.filter( - ([, args]) => args && 'versionIds' in (args as Record), - ) - expect(batchCalls).toHaveLength(1) - }) + ([, args]) => args && "versionIds" in (args as Record), + ); + expect(batchCalls).toHaveLength(1); + }); - it('lists souls with resolved tags using batch query', async () => { + it("lists souls with resolved tags using batch query", async () => { const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('cursor' in args || 'limit' in args) { + if ("cursor" in args || "limit" in args) { return { items: [ { soul: { - _id: 'souls:1', - slug: 'demo-soul', - displayName: 'Demo Soul', - summary: 's', - tags: { latest: 'soulVersions:1' }, + _id: "souls:1", + slug: "demo-soul", + displayName: "Demo Soul", + summary: "s", + tags: { latest: "soulVersions:1" }, stats: { downloads: 0, stars: 0, versions: 1, comments: 0 }, createdAt: 1, updatedAt: 2, }, - latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' }, + latestVersion: { version: "1.0.0", createdAt: 3, changelog: "c" }, }, ], nextCursor: null, - } + }; } - if ('versionIds' in args) { - return [{ _id: 'soulVersions:1', version: '1.0.0', softDeletedAt: undefined }] + if ("versionIds" in args) { + return [{ _id: "soulVersions:1", version: "1.0.0", softDeletedAt: undefined }]; } - return null - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + return null; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.listSoulsV1Handler( makeCtx({ runQuery, runMutation }), - new Request('https://example.com/api/v1/souls?limit=1'), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.items[0].tags.latest).toBe('1.0.0') - }) - - it('batches tag resolution across multiple souls into single query', async () => { + new Request("https://example.com/api/v1/souls?limit=1"), + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.items[0].tags.latest).toBe("1.0.0"); + }); + + it("batches tag resolution across multiple souls into single query", async () => { const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('cursor' in args || 'limit' in args) { + if ("cursor" in args || "limit" in args) { return { items: [ { soul: { - _id: 'souls:1', - slug: 'soul-a', - displayName: 'Soul A', - summary: 's', - tags: { latest: 'soulVersions:1', stable: 'soulVersions:2' }, + _id: "souls:1", + slug: "soul-a", + displayName: "Soul A", + summary: "s", + tags: { latest: "soulVersions:1", stable: "soulVersions:2" }, stats: { downloads: 0, stars: 0, versions: 2, comments: 0 }, createdAt: 1, updatedAt: 2, }, - latestVersion: { version: '2.0.0', createdAt: 3, changelog: 'c' }, + latestVersion: { version: "2.0.0", createdAt: 3, changelog: "c" }, }, { soul: { - _id: 'souls:2', - slug: 'soul-b', - displayName: 'Soul B', - summary: 's', - tags: { latest: 'soulVersions:3' }, + _id: "souls:2", + slug: "soul-b", + displayName: "Soul B", + summary: "s", + tags: { latest: "soulVersions:3" }, stats: { downloads: 0, stars: 0, versions: 1, comments: 0 }, createdAt: 1, updatedAt: 2, }, - latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' }, + latestVersion: { version: "1.0.0", createdAt: 3, changelog: "c" }, }, ], nextCursor: null, - } + }; } - if ('versionIds' in args) { - const ids = args.versionIds as string[] - expect(ids).toHaveLength(3) - expect(ids).toContain('soulVersions:1') - expect(ids).toContain('soulVersions:2') - expect(ids).toContain('soulVersions:3') + if ("versionIds" in args) { + const ids = args.versionIds as string[]; + expect(ids).toHaveLength(3); + expect(ids).toContain("soulVersions:1"); + expect(ids).toContain("soulVersions:2"); + expect(ids).toContain("soulVersions:3"); return [ - { _id: 'soulVersions:1', version: '2.0.0', softDeletedAt: undefined }, - { _id: 'soulVersions:2', version: '1.0.0', softDeletedAt: undefined }, - { _id: 'soulVersions:3', version: '1.0.0', softDeletedAt: undefined }, - ] + { _id: "soulVersions:1", version: "2.0.0", softDeletedAt: undefined }, + { _id: "soulVersions:2", version: "1.0.0", softDeletedAt: undefined }, + { _id: "soulVersions:3", version: "1.0.0", softDeletedAt: undefined }, + ]; } - return null - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + return null; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.listSoulsV1Handler( makeCtx({ runQuery, runMutation }), - new Request('https://example.com/api/v1/souls'), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.items[0].tags.latest).toBe('2.0.0') - expect(json.items[0].tags.stable).toBe('1.0.0') - expect(json.items[1].tags.latest).toBe('1.0.0') + new Request("https://example.com/api/v1/souls"), + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.items[0].tags.latest).toBe("2.0.0"); + expect(json.items[0].tags.stable).toBe("1.0.0"); + expect(json.items[1].tags.latest).toBe("1.0.0"); const batchCalls = runQuery.mock.calls.filter( - ([, args]) => args && 'versionIds' in (args as Record), - ) - expect(batchCalls).toHaveLength(1) - }) + ([, args]) => args && "versionIds" in (args as Record), + ); + expect(batchCalls).toHaveLength(1); + }); - it('souls get resolves tags using batch query', async () => { + it("souls get resolves tags using batch query", async () => { const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('slug' in args) { + if ("slug" in args) { return { soul: { - _id: 'souls:1', - slug: 'demo-soul', - displayName: 'Demo Soul', - summary: 's', - tags: { latest: 'soulVersions:1' }, + _id: "souls:1", + slug: "demo-soul", + displayName: "Demo Soul", + summary: "s", + tags: { latest: "soulVersions:1" }, stats: { downloads: 0, stars: 0, versions: 1, comments: 0 }, createdAt: 1, updatedAt: 2, }, - latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' }, + latestVersion: { version: "1.0.0", createdAt: 3, changelog: "c" }, owner: null, - } + }; } - if ('versionIds' in args) { - return [{ _id: 'soulVersions:1', version: '1.0.0', softDeletedAt: undefined }] + if ("versionIds" in args) { + return [{ _id: "soulVersions:1", version: "1.0.0", softDeletedAt: undefined }]; } - return null - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + return null; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.soulsGetRouterV1Handler( makeCtx({ runQuery, runMutation }), - new Request('https://example.com/api/v1/souls/demo-soul'), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.soul.tags.latest).toBe('1.0.0') - }) - - it('lists skills supports sort aliases', async () => { + new Request("https://example.com/api/v1/souls/demo-soul"), + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.soul.tags.latest).toBe("1.0.0"); + }); + + it("lists skills supports sort aliases", async () => { const checks: Array<[string, string]> = [ - ['rating', 'stars'], - ['installs', 'installsCurrent'], - ['installs-all-time', 'installsAllTime'], - ['trending', 'trending'], - ] + ["rating", "stars"], + ["installs", "installsCurrent"], + ["installs-all-time", "installsAllTime"], + ["trending", "trending"], + ]; for (const [input, expected] of checks) { const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('sort' in args || 'cursor' in args || 'limit' in args) { - expect(args.sort).toBe(expected) - return { items: [], nextCursor: null } + if ("sort" in args || "cursor" in args || "limit" in args) { + expect(args.sort).toBe(expected); + return { items: [], nextCursor: null }; } - return null - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + return null; + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.listSkillsV1Handler( makeCtx({ runQuery, runMutation }), new Request(`https://example.com/api/v1/skills?sort=${input}`), - ) - expect(response.status).toBe(200) + ); + expect(response.status).toBe(200); } - }) + }); - it('get skill returns 404 when missing', async () => { - const runQuery = vi.fn().mockResolvedValue(null) - const runMutation = vi.fn().mockResolvedValue(okRate()) + it("get skill returns 404 when missing", async () => { + const runQuery = vi.fn().mockResolvedValue(null); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.skillsGetRouterV1Handler( makeCtx({ runQuery, runMutation }), - new Request('https://example.com/api/v1/skills/missing'), - ) - expect(response.status).toBe(404) - }) + new Request("https://example.com/api/v1/skills/missing"), + ); + expect(response.status).toBe(404); + }); - it('get skill returns pending-scan message for owner api token', async () => { - vi.mocked(getOptionalApiTokenUserId).mockResolvedValue('users:1' as never) + it("get skill returns pending-scan message for owner api token", async () => { + vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never); const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('slug' in args) { + if ("slug" in args) { return { - _id: 'skills:1', - slug: 'demo', - ownerUserId: 'users:1', - moderationStatus: 'hidden', - moderationReason: 'pending.scan', - } + _id: "skills:1", + slug: "demo", + ownerUserId: "users:1", + moderationStatus: "hidden", + moderationReason: "pending.scan", + }; } - return null - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + 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'), - ) - expect(response.status).toBe(423) - expect(await response.text()).toContain('security scan is pending') - }) - - it('get skill returns undelete hint for owner soft-deleted skill', async () => { - vi.mocked(getOptionalApiTokenUserId).mockResolvedValue('users:1' as never) + new Request("https://example.com/api/v1/skills/demo"), + ); + expect(response.status).toBe(423); + expect(await response.text()).toContain("security scan is pending"); + }); + + it("get skill returns undelete hint for owner soft-deleted skill", async () => { + vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never); const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('slug' in args) { + if ("slug" in args) { return { - _id: 'skills:1', - slug: 'demo', - ownerUserId: 'users:1', + _id: "skills:1", + slug: "demo", + ownerUserId: "users:1", softDeletedAt: 1, - moderationStatus: 'hidden', - } + moderationStatus: "hidden", + }; } - return null - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + 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'), - ) - expect(response.status).toBe(410) - expect(await response.text()).toContain('clawhub undelete demo') - }) + new Request("https://example.com/api/v1/skills/demo"), + ); + expect(response.status).toBe(410); + expect(await response.text()).toContain("clawhub undelete demo"); + }); - it('get skill returns payload', async () => { + it("get skill returns payload", async () => { const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('slug' in args) { + if ("slug" in args) { return { skill: { - _id: 'skills:1', - slug: 'demo', - displayName: 'Demo', - summary: 's', - tags: { latest: 'versions:1' }, + _id: "skills:1", + slug: "demo", + displayName: "Demo", + summary: "s", + ownerUserId: "users:1", + tags: { latest: "versions:1" }, stats: { downloads: 0, stars: 0, versions: 1, comments: 0 }, createdAt: 1, updatedAt: 2, }, latestVersion: { - version: '1.0.0', + version: "1.0.0", createdAt: 3, - changelog: 'c', + changelog: "c", files: [], }, - owner: { handle: 'p', displayName: 'Peter', image: null }, - } + owner: { handle: "p", displayName: "Peter", image: null }, + moderationInfo: { + isPendingScan: false, + isMalwareBlocked: false, + isSuspicious: true, + isHiddenByMod: false, + isRemoved: false, + verdict: "suspicious", + reasonCodes: ["suspicious.dangerous_exec"], + summary: "Detected: suspicious.dangerous_exec", + engineVersion: "v2.0.0", + updatedAt: 123, + }, + }; } // Batch query for tag resolution - if ('versionIds' in args) { - return [{ _id: 'versions:1', version: '1.0.0', softDeletedAt: undefined }] + if ("versionIds" in args) { + return [{ _id: "versions:1", version: "1.0.0", softDeletedAt: undefined }]; } - return null - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + 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'), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.skill.slug).toBe('demo') - expect(json.latestVersion.version).toBe('1.0.0') - }) - - it('lists versions', async () => { + new Request("https://example.com/api/v1/skills/demo"), + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.skill.slug).toBe("demo"); + expect(json.latestVersion.version).toBe("1.0.0"); + expect(json.moderation.verdict).toBe("suspicious"); + expect(json.moderation.reasonCodes).toEqual(["suspicious.dangerous_exec"]); + }); + + it("get skill moderation returns sanitized evidence for public callers", async () => { + vi.mocked(getOptionalApiTokenUserId).mockResolvedValue(undefined as never); const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('slug' in args) { - return { _id: 'skills:1', slug: 'demo', displayName: 'Demo' } + if ("slug" in args) { + return { + skill: { + _id: "skills:1", + slug: "demo", + displayName: "Demo", + summary: "s", + ownerUserId: "users:1", + tags: { latest: "versions:1" }, + stats: { downloads: 0, stars: 0, versions: 1, comments: 0 }, + moderationEvidence: [ + { + code: "suspicious.dangerous_exec", + severity: "critical", + file: "index.ts", + line: 12, + message: "Shell command execution detected.", + evidence: 'exec("curl ...")', + }, + ], + createdAt: 1, + updatedAt: 2, + }, + latestVersion: null, + owner: { _id: "users:1", handle: "p", displayName: "Peter", image: null }, + moderationInfo: { + isPendingScan: false, + isMalwareBlocked: false, + isSuspicious: true, + isHiddenByMod: false, + isRemoved: false, + verdict: "suspicious", + reasonCodes: ["suspicious.dangerous_exec"], + summary: "Detected: suspicious.dangerous_exec", + engineVersion: "v2.0.0", + updatedAt: 123, + }, + }; } - if ('skillId' in args && 'cursor' in args) { + 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/moderation"), + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.moderation.reasonCodes).toEqual(["suspicious.dangerous_exec"]); + expect(json.moderation.evidence[0].evidence).toBe(""); + }); + + it("lists versions", async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ("slug" in args) { + return { _id: "skills:1", slug: "demo", displayName: "Demo" }; + } + if ("skillId" in args && "cursor" in args) { return { items: [ { - version: '1.0.0', + version: "1.0.0", createdAt: 1, - changelog: 'c', - changelogSource: 'user', + changelog: "c", + changelogSource: "user", files: [], }, ], nextCursor: null, - } + }; } - return null - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + 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/versions?limit=1'), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.items[0].version).toBe('1.0.0') - }) - - it('returns version detail', async () => { + new Request("https://example.com/api/v1/skills/demo/versions?limit=1"), + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.items[0].version).toBe("1.0.0"); + }); + + it("returns version detail", async () => { const runQuery = vi.fn(async (_query: unknown, args: Record) => { - if ('slug' in args) { - return { _id: 'skills:1', slug: 'demo', displayName: 'Demo' } + if ("slug" in args) { + return { _id: "skills:1", slug: "demo", displayName: "Demo" }; } - if ('skillId' in args && 'version' in args) { + if ("skillId" in args && "version" in args) { return { - version: '1.0.0', + version: "1.0.0", createdAt: 1, - changelog: 'c', - changelogSource: 'auto', + changelog: "c", + changelogSource: "auto", files: [ { - path: 'SKILL.md', + path: "SKILL.md", size: 1, - storageId: 'storage:1', - sha256: 'abc', - contentType: 'text/plain', + storageId: "storage:1", + sha256: "abc", + contentType: "text/plain", }, ], - } + }; } - return null - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + 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/versions/1.0.0'), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.version.files[0].path).toBe('SKILL.md') - }) - - it('returns raw file content', async () => { + new Request("https://example.com/api/v1/skills/demo/versions/1.0.0"), + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.version.files[0].path).toBe("SKILL.md"); + }); + + it("returns raw file content", async () => { const version = { - version: '1.0.0', + version: "1.0.0", createdAt: 1, - changelog: 'c', + changelog: "c", files: [ { - path: 'SKILL.md', + path: "SKILL.md", size: 5, - storageId: 'storage:1', - sha256: 'abcd', - contentType: 'text/plain', + storageId: "storage:1", + sha256: "abcd", + contentType: "text/plain", }, ], softDeletedAt: undefined, - } + }; const runQuery = vi.fn().mockResolvedValue({ skill: { - _id: 'skills:1', - slug: 'demo', - displayName: 'Demo', - summary: 's', + _id: "skills:1", + slug: "demo", + displayName: "Demo", + summary: "s", tags: {}, stats: {}, createdAt: 1, @@ -729,42 +799,42 @@ describe('httpApiV1 handlers', () => { }, latestVersion: version, owner: null, - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); const storage = { - get: vi.fn().mockResolvedValue(new Blob(['hello'], { type: 'text/plain' })), - } + get: vi.fn().mockResolvedValue(new Blob(["hello"], { type: "text/plain" })), + }; 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(200) - expect(await response.text()).toBe('hello') - expect(response.headers.get('X-Content-SHA256')).toBe('abcd') - }) - - it('returns 413 when raw file too large', async () => { + new Request("https://example.com/api/v1/skills/demo/file?path=SKILL.md"), + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("hello"); + expect(response.headers.get("X-Content-SHA256")).toBe("abcd"); + }); + + it("returns 413 when raw file too large", async () => { const version = { - version: '1.0.0', + version: "1.0.0", createdAt: 1, - changelog: 'c', + changelog: "c", files: [ { - path: 'SKILL.md', + path: "SKILL.md", size: 210 * 1024, - storageId: 'storage:1', - sha256: 'abcd', - contentType: 'text/plain', + storageId: "storage:1", + sha256: "abcd", + contentType: "text/plain", }, ], softDeletedAt: undefined, - } + }; const runQuery = vi.fn().mockResolvedValue({ skill: { - _id: 'skills:1', - slug: 'demo', - displayName: 'Demo', - summary: 's', + _id: "skills:1", + slug: "demo", + displayName: "Demo", + summary: "s", tags: {}, stats: {}, createdAt: 1, @@ -772,377 +842,377 @@ describe('httpApiV1 handlers', () => { }, latestVersion: version, owner: null, - }) - const runMutation = vi.fn().mockResolvedValue(okRate()) + }); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.skillsGetRouterV1Handler( makeCtx({ runQuery, runMutation, storage: { get: vi.fn() } }), - new Request('https://example.com/api/v1/skills/demo/file?path=SKILL.md'), - ) - expect(response.status).toBe(413) - }) + new Request("https://example.com/api/v1/skills/demo/file?path=SKILL.md"), + ); + expect(response.status).toBe(413); + }); - it('publish json succeeds', async () => { + it("publish json succeeds", async () => { vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ - userId: 'users:1', - user: { handle: 'p' }, - } as never) + userId: "users:1", + user: { handle: "p" }, + } as never); vi.mocked(publishVersionForUser).mockResolvedValueOnce({ - skillId: 's', - versionId: 'v', - embeddingId: 'e', - } as never) - const runMutation = vi.fn().mockResolvedValue(okRate()) + skillId: "s", + versionId: "v", + embeddingId: "e", + } as never); + const runMutation = vi.fn().mockResolvedValue(okRate()); const body = JSON.stringify({ - slug: 'demo', - displayName: 'Demo', - version: '1.0.0', - changelog: 'c', + slug: "demo", + displayName: "Demo", + version: "1.0.0", + changelog: "c", files: [ { - path: 'SKILL.md', + path: "SKILL.md", size: 1, - storageId: 'storage:1', - sha256: 'abc', - contentType: 'text/plain', + storageId: "storage:1", + sha256: "abc", + contentType: "text/plain", }, ], - }) + }); const response = await __handlers.publishSkillV1Handler( makeCtx({ runMutation }), - new Request('https://example.com/api/v1/skills', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer clh_test' }, + new Request("https://example.com/api/v1/skills", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: "Bearer clh_test" }, body, }), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.ok).toBe(true) - expect(publishVersionForUser).toHaveBeenCalled() - }) - - it('publish multipart succeeds', async () => { + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.ok).toBe(true); + expect(publishVersionForUser).toHaveBeenCalled(); + }); + + it("publish multipart succeeds", async () => { vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ - userId: 'users:1', - user: { handle: 'p' }, - } as never) + userId: "users:1", + user: { handle: "p" }, + } as never); vi.mocked(publishVersionForUser).mockResolvedValueOnce({ - skillId: 's', - versionId: 'v', - embeddingId: 'e', - } as never) - const runMutation = vi.fn().mockResolvedValue(okRate()) - const form = new FormData() + skillId: "s", + versionId: "v", + embeddingId: "e", + } as never); + const runMutation = vi.fn().mockResolvedValue(okRate()); + const form = new FormData(); form.set( - 'payload', + "payload", JSON.stringify({ - slug: 'demo', - displayName: 'Demo', - version: '1.0.0', - changelog: '', - tags: ['latest'], + slug: "demo", + displayName: "Demo", + version: "1.0.0", + changelog: "", + tags: ["latest"], }), - ) - form.append('files', new Blob(['hello'], { type: 'text/plain' }), 'SKILL.md') + ); + form.append("files", new Blob(["hello"], { type: "text/plain" }), "SKILL.md"); const response = await __handlers.publishSkillV1Handler( - makeCtx({ runMutation, storage: { store: vi.fn().mockResolvedValue('storage:1') } }), - new Request('https://example.com/api/v1/skills', { - method: 'POST', - headers: { Authorization: 'Bearer clh_test' }, + makeCtx({ runMutation, storage: { store: vi.fn().mockResolvedValue("storage:1") } }), + new Request("https://example.com/api/v1/skills", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, body: form, }), - ) + ); if (response.status !== 200) { - throw new Error(await response.text()) + throw new Error(await response.text()); } - }) + }); - it('publish rejects missing token', async () => { - const runMutation = vi.fn().mockResolvedValue(okRate()) + it("publish rejects missing token", async () => { + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.publishSkillV1Handler( makeCtx({ runMutation }), - new Request('https://example.com/api/v1/skills', { method: 'POST' }), - ) - expect(response.status).toBe(401) - }) + new Request("https://example.com/api/v1/skills", { method: "POST" }), + ); + expect(response.status).toBe(401); + }); - it('whoami returns user payload', async () => { + it("whoami returns user payload", async () => { vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ - userId: 'users:1', - user: { handle: 'p', displayName: 'Peter', image: null }, - } as never) - const runMutation = vi.fn().mockResolvedValue(okRate()) + userId: "users:1", + user: { handle: "p", displayName: "Peter", image: null }, + } as never); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.whoamiV1Handler( makeCtx({ runMutation }), - new Request('https://example.com/api/v1/whoami', { - headers: { Authorization: 'Bearer clh_test' }, + new Request("https://example.com/api/v1/whoami", { + headers: { Authorization: "Bearer clh_test" }, }), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.user.handle).toBe('p') - }) - - it('delete and undelete require auth', async () => { - vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized')) - const runMutation = vi.fn().mockResolvedValue(okRate()) + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.user.handle).toBe("p"); + }); + + it("delete and undelete require auth", async () => { + vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized")); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.skillsDeleteRouterV1Handler( makeCtx({ runMutation }), - new Request('https://example.com/api/v1/skills/demo', { method: 'DELETE' }), - ) - expect(response.status).toBe(401) + new Request("https://example.com/api/v1/skills/demo", { method: "DELETE" }), + ); + expect(response.status).toBe(401); - vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized')) + vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized")); const response2 = await __handlers.skillsPostRouterV1Handler( makeCtx({ runMutation }), - new Request('https://example.com/api/v1/skills/demo/undelete', { method: 'POST' }), - ) - expect(response2.status).toBe(401) - }) + new Request("https://example.com/api/v1/skills/demo/undelete", { method: "POST" }), + ); + expect(response2.status).toBe(401); + }); - it('delete and undelete succeed', async () => { + it("delete and undelete succeed", async () => { vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:1', - user: { handle: 'p' }, - } as never) + userId: "users:1", + user: { handle: "p" }, + } as never); const runMutation = vi.fn(async (_query: unknown, args: Record) => { - if ('key' in args) return okRate() - return { ok: true } - }) + if ("key" in args) return okRate(); + return { ok: true }; + }); const response = await __handlers.skillsDeleteRouterV1Handler( makeCtx({ runMutation }), - new Request('https://example.com/api/v1/skills/demo', { - method: 'DELETE', - headers: { Authorization: 'Bearer clh_test' }, + new Request("https://example.com/api/v1/skills/demo", { + method: "DELETE", + headers: { Authorization: "Bearer clh_test" }, }), - ) - expect(response.status).toBe(200) + ); + expect(response.status).toBe(200); const response2 = await __handlers.skillsPostRouterV1Handler( makeCtx({ runMutation }), - new Request('https://example.com/api/v1/skills/demo/undelete', { - method: 'POST', - headers: { Authorization: 'Bearer clh_test' }, + new Request("https://example.com/api/v1/skills/demo/undelete", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, }), - ) - expect(response2.status).toBe(200) - }) + ); + expect(response2.status).toBe(200); + }); - it('ban user requires auth', async () => { - vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized')) - const runMutation = vi.fn().mockResolvedValue(okRate()) + it("ban user requires auth", async () => { + vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized")); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.usersPostRouterV1Handler( makeCtx({ runMutation }), - new Request('https://example.com/api/v1/users/ban', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ handle: 'demo' }), + new Request("https://example.com/api/v1/users/ban", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ handle: "demo" }), }), - ) - expect(response.status).toBe(401) - }) + ); + expect(response.status).toBe(401); + }); - it('ban user succeeds with handle', async () => { + it("ban user succeeds with handle", async () => { vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:1', - user: { handle: 'p' }, - } as never) - const runQuery = vi.fn().mockResolvedValue({ _id: 'users:2' }) + userId: "users:1", + user: { handle: "p" }, + } as never); + const runQuery = vi.fn().mockResolvedValue({ _id: "users:2" }); const runMutation = vi .fn() .mockResolvedValueOnce(okRate()) - .mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 2 }) + .mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 2 }); const response = await __handlers.usersPostRouterV1Handler( makeCtx({ runQuery, runMutation }), - new Request('https://example.com/api/v1/users/ban', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ handle: 'demo' }), + new Request("https://example.com/api/v1/users/ban", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ handle: "demo" }), }), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.deletedSkills).toBe(2) - }) + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.deletedSkills).toBe(2); + }); - it('ban user forwards reason', async () => { + it("ban user forwards reason", async () => { vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:1', - user: { handle: 'p' }, - } as never) - const runQuery = vi.fn().mockResolvedValue({ _id: 'users:2' }) + userId: "users:1", + user: { handle: "p" }, + } as never); + const runQuery = vi.fn().mockResolvedValue({ _id: "users:2" }); const runMutation = vi .fn() .mockResolvedValueOnce(okRate()) - .mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 }) + .mockResolvedValueOnce({ ok: true, alreadyBanned: false, deletedSkills: 0 }); await __handlers.usersPostRouterV1Handler( makeCtx({ runQuery, runMutation }), - new Request('https://example.com/api/v1/users/ban', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ handle: 'demo', reason: 'malware' }), + new Request("https://example.com/api/v1/users/ban", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ handle: "demo", reason: "malware" }), }), - ) + ); expect(runMutation).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ - actorUserId: 'users:1', - targetUserId: 'users:2', - reason: 'malware', + actorUserId: "users:1", + targetUserId: "users:2", + reason: "malware", }), - ) - }) + ); + }); - it('set role requires auth', async () => { - vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized')) - const runMutation = vi.fn().mockResolvedValue(okRate()) + it("set role requires auth", async () => { + vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized")); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.usersPostRouterV1Handler( makeCtx({ runMutation }), - new Request('https://example.com/api/v1/users/role', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ handle: 'demo', role: 'moderator' }), + new Request("https://example.com/api/v1/users/role", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ handle: "demo", role: "moderator" }), }), - ) - expect(response.status).toBe(401) - }) + ); + expect(response.status).toBe(401); + }); - it('set role succeeds with handle', async () => { + it("set role succeeds with handle", async () => { vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:1', - user: { handle: 'p' }, - } as never) - const runQuery = vi.fn().mockResolvedValue({ _id: 'users:2' }) + userId: "users:1", + user: { handle: "p" }, + } as never); + const runQuery = vi.fn().mockResolvedValue({ _id: "users:2" }); const runMutation = vi .fn() .mockResolvedValueOnce(okRate()) - .mockResolvedValueOnce({ ok: true, role: 'moderator' }) + .mockResolvedValueOnce({ ok: true, role: "moderator" }); const response = await __handlers.usersPostRouterV1Handler( makeCtx({ runQuery, runMutation }), - new Request('https://example.com/api/v1/users/role', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ handle: 'demo', role: 'moderator' }), + new Request("https://example.com/api/v1/users/role", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ handle: "demo", role: "moderator" }), }), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.role).toBe('moderator') - }) - - it('stars require auth', async () => { - vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized')) - const runMutation = vi.fn().mockResolvedValue(okRate()) + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.role).toBe("moderator"); + }); + + it("stars require auth", async () => { + vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error("Unauthorized")); + const runMutation = vi.fn().mockResolvedValue(okRate()); const response = await __handlers.starsPostRouterV1Handler( makeCtx({ runMutation }), - new Request('https://example.com/api/v1/stars/demo', { method: 'POST' }), - ) - expect(response.status).toBe(401) - }) + new Request("https://example.com/api/v1/stars/demo", { method: "POST" }), + ); + expect(response.status).toBe(401); + }); - it('stars add succeeds', async () => { + it("stars add succeeds", async () => { vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:1', - user: { handle: 'p' }, - } as never) - const runQuery = vi.fn().mockResolvedValue({ _id: 'skills:1' }) + userId: "users:1", + user: { handle: "p" }, + } as never); + const runQuery = vi.fn().mockResolvedValue({ _id: "skills:1" }); const runMutation = vi .fn() .mockResolvedValueOnce(okRate()) .mockResolvedValueOnce(okRate()) - .mockResolvedValueOnce({ ok: true, starred: true, alreadyStarred: false }) + .mockResolvedValueOnce({ ok: true, starred: true, alreadyStarred: false }); const response = await __handlers.starsPostRouterV1Handler( makeCtx({ runQuery, runMutation }), - new Request('https://example.com/api/v1/stars/demo', { - method: 'POST', - headers: { Authorization: 'Bearer clh_test' }, + new Request("https://example.com/api/v1/stars/demo", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, }), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.ok).toBe(true) - expect(json.starred).toBe(true) - }) - - it('stars delete succeeds', async () => { + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.ok).toBe(true); + expect(json.starred).toBe(true); + }); + + it("stars delete succeeds", async () => { vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:1', - user: { handle: 'p' }, - } as never) - const runQuery = vi.fn().mockResolvedValue({ _id: 'skills:1' }) + userId: "users:1", + user: { handle: "p" }, + } as never); + const runQuery = vi.fn().mockResolvedValue({ _id: "skills:1" }); const runMutation = vi .fn() .mockResolvedValueOnce(okRate()) .mockResolvedValueOnce(okRate()) - .mockResolvedValueOnce({ ok: true, unstarred: true, alreadyUnstarred: false }) + .mockResolvedValueOnce({ ok: true, unstarred: true, alreadyUnstarred: false }); const response = await __handlers.starsDeleteRouterV1Handler( makeCtx({ runQuery, runMutation }), - new Request('https://example.com/api/v1/stars/demo', { - method: 'DELETE', - headers: { Authorization: 'Bearer clh_test' }, + new Request("https://example.com/api/v1/stars/demo", { + method: "DELETE", + headers: { Authorization: "Bearer clh_test" }, }), - ) - expect(response.status).toBe(200) - const json = await response.json() - expect(json.ok).toBe(true) - expect(json.unstarred).toBe(true) - }) - - it('delete/undelete map forbidden/not-found/unknown to 403/404/500', async () => { + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.ok).toBe(true); + expect(json.unstarred).toBe(true); + }); + + it("delete/undelete map forbidden/not-found/unknown to 403/404/500", async () => { vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:1', - user: { handle: 'p' }, - } as never) + userId: "users:1", + user: { handle: "p" }, + } as never); const runMutationForbidden = vi.fn(async (_query: unknown, args: Record) => { - if ('key' in args) return okRate() - throw new Error('Forbidden') - }) + if ("key" in args) return okRate(); + throw new Error("Forbidden"); + }); const forbidden = await __handlers.skillsDeleteRouterV1Handler( makeCtx({ runMutation: runMutationForbidden }), - new Request('https://example.com/api/v1/skills/demo', { - method: 'DELETE', - headers: { Authorization: 'Bearer clh_test' }, + new Request("https://example.com/api/v1/skills/demo", { + method: "DELETE", + headers: { Authorization: "Bearer clh_test" }, }), - ) - expect(forbidden.status).toBe(403) - expect(await forbidden.text()).toBe('Forbidden') + ); + expect(forbidden.status).toBe(403); + expect(await forbidden.text()).toBe("Forbidden"); vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:1', - user: { handle: 'p' }, - } as never) + userId: "users:1", + user: { handle: "p" }, + } as never); const runMutationNotFound = vi.fn(async (_query: unknown, args: Record) => { - if ('key' in args) return okRate() - throw new Error('Skill not found') - }) + if ("key" in args) return okRate(); + throw new Error("Skill not found"); + }); const notFound = await __handlers.skillsPostRouterV1Handler( makeCtx({ runMutation: runMutationNotFound }), - new Request('https://example.com/api/v1/skills/demo/undelete', { - method: 'POST', - headers: { Authorization: 'Bearer clh_test' }, + new Request("https://example.com/api/v1/skills/demo/undelete", { + method: "POST", + headers: { Authorization: "Bearer clh_test" }, }), - ) - expect(notFound.status).toBe(404) - expect(await notFound.text()).toBe('Skill not found') + ); + expect(notFound.status).toBe(404); + expect(await notFound.text()).toBe("Skill not found"); vi.mocked(requireApiTokenUser).mockResolvedValue({ - userId: 'users:1', - user: { handle: 'p' }, - } as never) + userId: "users:1", + user: { handle: "p" }, + } as never); const runMutationUnknown = vi.fn(async (_query: unknown, args: Record) => { - if ('key' in args) return okRate() - throw new Error('boom') - }) + if ("key" in args) return okRate(); + throw new Error("boom"); + }); const unknown = await __handlers.soulsDeleteRouterV1Handler( makeCtx({ runMutation: runMutationUnknown }), - new Request('https://example.com/api/v1/souls/demo-soul', { - method: 'DELETE', - headers: { Authorization: 'Bearer clh_test' }, + new Request("https://example.com/api/v1/souls/demo-soul", { + method: "DELETE", + headers: { Authorization: "Bearer clh_test" }, }), - ) - expect(unknown.status).toBe(500) - expect(await unknown.text()).toBe('Internal Server Error') - }) -}) + ); + expect(unknown.status).toBe(500); + expect(await unknown.text()).toBe("Internal Server Error"); + }); +}); diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index 358233d61..e2406e8af 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -1,9 +1,9 @@ -import { api, internal } from '../_generated/api' -import type { Doc, Id } from '../_generated/dataModel' -import type { ActionCtx } from '../_generated/server' -import { getOptionalApiTokenUserId, requireApiTokenUser } from '../lib/apiTokenAuth' -import { applyRateLimit, parseBearerToken } from '../lib/httpRateLimit' -import { publishVersionForUser } from '../skills' +import type { Doc, Id } from "../_generated/dataModel"; +import type { ActionCtx } from "../_generated/server"; +import { api, internal } from "../_generated/api"; +import { getOptionalApiTokenUserId, requireApiTokenUser } from "../lib/apiTokenAuth"; +import { applyRateLimit, parseBearerToken } from "../lib/httpRateLimit"; +import { publishVersionForUser } from "../skills"; import { MAX_RAW_FILE_BYTES, getPathSegments, @@ -15,96 +15,102 @@ import { softDeleteErrorToResponse, text, toOptionalNumber, -} from './shared' +} from "./shared"; type SearchSkillEntry = { - score: number + score: number; skill: { - slug?: string - displayName?: string - summary?: string | null - updatedAt?: number - } | null - version: { version?: string; createdAt?: number } | null -} + slug?: string; + displayName?: string; + summary?: string | null; + updatedAt?: number; + } | null; + version: { version?: string; createdAt?: number } | null; +}; type ListSkillsResult = { items: Array<{ skill: { - _id: Id<'skills'> - slug: string - displayName: string - summary?: string - tags: Record> - stats: unknown - createdAt: number - updatedAt: number - latestVersionId?: Id<'skillVersions'> - } - latestVersion: { version: string; createdAt: number; changelog: string } | null - }> - nextCursor: string | null -} - -type SkillFile = Doc<'skillVersions'>['files'][number] + _id: Id<"skills">; + slug: string; + displayName: string; + summary?: string; + tags: Record>; + stats: unknown; + createdAt: number; + updatedAt: number; + latestVersionId?: Id<"skillVersions">; + }; + latestVersion: { version: string; createdAt: number; changelog: string } | null; + }>; + nextCursor: string | null; +}; + +type SkillFile = Doc<"skillVersions">["files"][number]; type GetBySlugResult = { skill: { - _id: Id<'skills'> - slug: string - displayName: string - summary?: string - tags: Record> - stats: unknown - createdAt: number - updatedAt: number - } | null - latestVersion: Doc<'skillVersions'> | null - owner: { _id: Id<'users'>; handle?: string; displayName?: string; image?: string } | null + _id: Id<"skills">; + slug: string; + displayName: string; + summary?: string; + ownerUserId: Id<"users">; + tags: Record>; + stats: unknown; + createdAt: number; + updatedAt: number; + } | null; + latestVersion: Doc<"skillVersions"> | null; + owner: { _id: Id<"users">; handle?: string; displayName?: string; image?: string } | null; moderationInfo?: { - isPendingScan: boolean - isMalwareBlocked: boolean - isSuspicious: boolean - isHiddenByMod: boolean - isRemoved: boolean - reason?: string - } | null -} | null + isPendingScan: boolean; + isMalwareBlocked: boolean; + isSuspicious: boolean; + isHiddenByMod: boolean; + isRemoved: boolean; + verdict?: "clean" | "suspicious" | "malicious"; + reasonCodes?: string[]; + summary?: string; + engineVersion?: string; + updatedAt?: number; + reason?: string; + } | null; +} | null; type ListVersionsResult = { items: Array<{ - version: string - createdAt: number - changelog: string - changelogSource?: 'auto' | 'user' + version: string; + createdAt: number; + changelog: string; + changelogSource?: "auto" | "user"; files: Array<{ - path: string - size: number - storageId: Id<'_storage'> - sha256: string - contentType?: string - }> - softDeletedAt?: number - }> - nextCursor: string | null -} + path: string; + size: number; + storageId: Id<"_storage">; + sha256: string; + contentType?: string; + }>; + softDeletedAt?: number; + }>; + nextCursor: string | null; +}; export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) { - const rate = await applyRateLimit(ctx, request, 'read') - if (!rate.ok) return rate.response + const rate = await applyRateLimit(ctx, request, "read"); + if (!rate.ok) return rate.response; - const url = new URL(request.url) - const query = url.searchParams.get('q')?.trim() ?? '' - const limit = toOptionalNumber(url.searchParams.get('limit')) - const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true' + const url = new URL(request.url); + const query = url.searchParams.get("q")?.trim() ?? ""; + const limit = toOptionalNumber(url.searchParams.get("limit")); + const highlightedOnly = url.searchParams.get("highlightedOnly") === "true"; - if (!query) return json({ results: [] }, 200, rate.headers) + if (!query) return json({ results: [] }, 200, rate.headers); const results = (await ctx.runAction(api.search.searchSkills, { query, limit, highlightedOnly: highlightedOnly || undefined, - })) as SearchSkillEntry[] + })) as SearchSkillEntry[]; return json( { @@ -119,73 +125,77 @@ export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) { }, 200, rate.headers, - ) + ); } export async function resolveSkillVersionV1Handler(ctx: ActionCtx, request: Request) { - const rate = await applyRateLimit(ctx, request, 'read') - if (!rate.ok) return rate.response + const rate = await applyRateLimit(ctx, request, "read"); + if (!rate.ok) return rate.response; - const url = new URL(request.url) - const slug = url.searchParams.get('slug')?.trim().toLowerCase() - const hash = url.searchParams.get('hash')?.trim().toLowerCase() - if (!slug || !hash) return text('Missing slug or hash', 400, rate.headers) - if (!/^[a-f0-9]{64}$/.test(hash)) return text('Invalid hash', 400, rate.headers) + const url = new URL(request.url); + const slug = url.searchParams.get("slug")?.trim().toLowerCase(); + const hash = url.searchParams.get("hash")?.trim().toLowerCase(); + if (!slug || !hash) return text("Missing slug or hash", 400, rate.headers); + if (!/^[a-f0-9]{64}$/.test(hash)) return text("Invalid hash", 400, rate.headers); - const resolved = await ctx.runQuery(api.skills.resolveVersionByHash, { slug, hash }) - if (!resolved) return text('Skill not found', 404, rate.headers) + const resolved = await ctx.runQuery(api.skills.resolveVersionByHash, { slug, hash }); + if (!resolved) return text("Skill not found", 404, rate.headers); - return json({ slug, match: resolved.match, latestVersion: resolved.latestVersion }, 200, rate.headers) + return json( + { slug, match: resolved.match, latestVersion: resolved.latestVersion }, + 200, + rate.headers, + ); } type SkillListSort = - | 'updated' - | 'downloads' - | 'stars' - | 'installsCurrent' - | 'installsAllTime' - | 'trending' + | "updated" + | "downloads" + | "stars" + | "installsCurrent" + | "installsAllTime" + | "trending"; function parseListSort(value: string | null): SkillListSort { - const normalized = value?.trim().toLowerCase() - if (normalized === 'downloads') return 'downloads' - if (normalized === 'stars' || normalized === 'rating') return 'stars' + const normalized = value?.trim().toLowerCase(); + if (normalized === "downloads") return "downloads"; + if (normalized === "stars" || normalized === "rating") return "stars"; if ( - normalized === 'installs' || - normalized === 'install' || - normalized === 'installscurrent' || - normalized === 'installs-current' + normalized === "installs" || + normalized === "install" || + normalized === "installscurrent" || + normalized === "installs-current" ) { - return 'installsCurrent' + return "installsCurrent"; } - if (normalized === 'installsalltime' || normalized === 'installs-all-time') { - return 'installsAllTime' + if (normalized === "installsalltime" || normalized === "installs-all-time") { + return "installsAllTime"; } - if (normalized === 'trending') return 'trending' - return 'updated' + if (normalized === "trending") return "trending"; + return "updated"; } export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) { - const rate = await applyRateLimit(ctx, request, 'read') - if (!rate.ok) return rate.response + const rate = await applyRateLimit(ctx, request, "read"); + if (!rate.ok) return rate.response; - const url = new URL(request.url) - const limit = toOptionalNumber(url.searchParams.get('limit')) - const rawCursor = url.searchParams.get('cursor')?.trim() || undefined - const sort = parseListSort(url.searchParams.get('sort')) - const cursor = sort === 'trending' ? undefined : rawCursor + const url = new URL(request.url); + const limit = toOptionalNumber(url.searchParams.get("limit")); + const rawCursor = url.searchParams.get("cursor")?.trim() || undefined; + const sort = parseListSort(url.searchParams.get("sort")); + const cursor = sort === "trending" ? undefined : rawCursor; const result = (await ctx.runQuery(api.skills.listPublicPage, { limit, cursor, sort, - })) as ListSkillsResult + })) as ListSkillsResult; // Batch resolve all tags in a single query instead of N queries const resolvedTagsList = await resolveTagsBatch( ctx, result.items.map((item) => item.skill.tags), - ) + ); const items = result.items.map((item, idx) => ({ slug: item.skill.slug, @@ -202,9 +212,9 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) { changelog: item.latestVersion.changelog, } : null, - })) + })); - return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers) + return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers); } async function describeOwnerVisibleSkillState( @@ -212,68 +222,71 @@ async function describeOwnerVisibleSkillState( request: Request, slug: string, ): Promise<{ status: number; message: string } | null> { - const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug }) - if (!skill) return null + const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug }); + if (!skill) return null; - const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request) - const isOwner = Boolean(apiTokenUserId && apiTokenUserId === skill.ownerUserId) - if (!isOwner) return null + const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request); + const isOwner = Boolean(apiTokenUserId && apiTokenUserId === skill.ownerUserId); + if (!isOwner) return null; if (skill.softDeletedAt) { return { status: 410, message: `Skill is hidden/deleted. Run "clawhub undelete ${slug}" to restore it.`, - } + }; } - if (skill.moderationStatus === 'hidden') { - if (skill.moderationReason === 'pending.scan' || skill.moderationReason === 'scanner.vt.pending') { + if (skill.moderationStatus === "hidden") { + if ( + skill.moderationReason === "pending.scan" || + skill.moderationReason === "scanner.vt.pending" + ) { return { status: 423, - message: 'Skill is hidden while security scan is pending. Try again in a few minutes.', - } + message: "Skill is hidden while security scan is pending. Try again in a few minutes.", + }; } - if (skill.moderationReason === 'quality.low') { + if (skill.moderationReason === "quality.low") { return { status: 403, message: 'Skill is hidden by quality checks. Update SKILL.md content or run "clawhub undelete " after review.', - } + }; } return { status: 403, message: `Skill is hidden by moderation${ - skill.moderationReason ? ` (${skill.moderationReason})` : '' + skill.moderationReason ? ` (${skill.moderationReason})` : "" }.`, - } + }; } - if (skill.moderationStatus === 'removed') { - return { status: 410, message: 'Skill has been removed by moderation.' } + if (skill.moderationStatus === "removed") { + return { status: 410, message: "Skill has been removed by moderation." }; } - return null + return null; } export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) { - const rate = await applyRateLimit(ctx, request, 'read') - if (!rate.ok) return rate.response + const rate = await applyRateLimit(ctx, request, "read"); + if (!rate.ok) return rate.response; - const segments = getPathSegments(request, '/api/v1/skills/') - if (segments.length === 0) return text('Missing slug', 400, rate.headers) - const slug = segments[0]?.trim().toLowerCase() ?? '' - const second = segments[1] - const third = segments[2] + const segments = getPathSegments(request, "/api/v1/skills/"); + if (segments.length === 0) return text("Missing slug", 400, rate.headers); + const slug = segments[0]?.trim().toLowerCase() ?? ""; + const second = segments[1]; + const third = segments[2]; if (segments.length === 1) { - const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult + const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult; if (!result?.skill) { - const hidden = await describeOwnerVisibleSkillState(ctx, request, slug) - if (hidden) return text(hidden.message, hidden.status, rate.headers) - return text('Skill not found', 404, rate.headers) + const hidden = await describeOwnerVisibleSkillState(ctx, request, slug); + if (hidden) return text(hidden.message, hidden.status, rate.headers); + return text("Skill not found", 404, rate.headers); } - const [tags] = await resolveTagsBatch(ctx, [result.skill.tags]) + const [tags] = await resolveTagsBatch(ctx, [result.skill.tags]); return json( { skill: { @@ -304,26 +317,101 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) ? { isSuspicious: result.moderationInfo.isSuspicious ?? false, isMalwareBlocked: result.moderationInfo.isMalwareBlocked ?? false, + verdict: result.moderationInfo.verdict ?? "clean", + reasonCodes: result.moderationInfo.reasonCodes ?? [], + summary: result.moderationInfo.summary ?? null, + engineVersion: result.moderationInfo.engineVersion ?? null, + updatedAt: result.moderationInfo.updatedAt ?? null, + } + : null, + }, + 200, + rate.headers, + ); + } + + if (second === "moderation" && segments.length === 2) { + const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult; + if (!result?.skill) { + const hidden = await describeOwnerVisibleSkillState(ctx, request, slug); + if (hidden) return text(hidden.message, hidden.status, rate.headers); + return text("Skill not found", 404, rate.headers); + } + + const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request); + const isOwner = Boolean(apiTokenUserId && apiTokenUserId === result.skill.ownerUserId); + let isStaff = false; + if (apiTokenUserId) { + const caller = await ctx.runQuery(internal.users.getByIdInternal, { userId: apiTokenUserId }); + if (caller?.role === "admin" || caller?.role === "moderator") { + isStaff = true; + } + } + + const mod = result.moderationInfo; + const isFlagged = Boolean(mod?.isSuspicious || mod?.isMalwareBlocked); + if (!isOwner && !isStaff && !isFlagged) { + return text("Moderation details unavailable", 404, rate.headers); + } + + const allEvidence = + ( + result.skill as { + moderationEvidence?: Array<{ + code: string; + severity: "info" | "warn" | "critical"; + file: string; + line: number; + message: string; + evidence: string; + }>; + } + ).moderationEvidence ?? []; + const evidence = + isOwner || isStaff + ? allEvidence + : allEvidence.map((entry) => ({ + code: entry.code, + severity: entry.severity, + file: entry.file, + line: entry.line, + message: entry.message, + evidence: "", + })); + + return json( + { + moderation: mod + ? { + isSuspicious: mod.isSuspicious ?? false, + isMalwareBlocked: mod.isMalwareBlocked ?? false, + verdict: mod.verdict ?? "clean", + reasonCodes: mod.reasonCodes ?? [], + summary: mod.summary ?? null, + engineVersion: mod.engineVersion ?? null, + updatedAt: mod.updatedAt ?? null, + evidence, + legacyReason: isOwner || isStaff ? (mod.reason ?? null) : null, } : null, }, 200, rate.headers, - ) + ); } - if (second === 'versions' && segments.length === 2) { - const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug }) - if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers) + if (second === "versions" && segments.length === 2) { + const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug }); + if (!skill || skill.softDeletedAt) return text("Skill not found", 404, rate.headers); - const url = new URL(request.url) - const limit = toOptionalNumber(url.searchParams.get('limit')) - const cursor = url.searchParams.get('cursor')?.trim() || undefined + const url = new URL(request.url); + const limit = toOptionalNumber(url.searchParams.get("limit")); + const cursor = url.searchParams.get("cursor")?.trim() || undefined; const result = (await ctx.runQuery(api.skills.listVersionsPage, { skillId: skill._id, limit, cursor, - })) as ListVersionsResult + })) as ListVersionsResult; const items = result.items .filter((version) => !version.softDeletedAt) @@ -332,21 +420,21 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) createdAt: version.createdAt, changelog: version.changelog, changelogSource: version.changelogSource ?? null, - })) + })); - return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers) + return json({ items, nextCursor: result.nextCursor ?? null }, 200, rate.headers); } - if (second === 'versions' && third && segments.length === 3) { - const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug }) - if (!skill || skill.softDeletedAt) return text('Skill not found', 404, rate.headers) + if (second === "versions" && third && segments.length === 3) { + const skill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug }); + if (!skill || skill.softDeletedAt) return text("Skill not found", 404, rate.headers); const version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, { skillId: skill._id, version: third, - }) - if (!version) return text('Version not found', 404, rate.headers) - if (version.softDeletedAt) return text('Version not available', 410, rate.headers) + }); + if (!version) return text("Version not found", 404, rate.headers); + if (version.softDeletedAt) return text("Version not available", 410, rate.headers); return json( { @@ -366,46 +454,46 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) }, 200, rate.headers, - ) + ); } - if (second === 'file' && segments.length === 2) { - const url = new URL(request.url) - const path = url.searchParams.get('path')?.trim() - if (!path) return text('Missing path', 400, rate.headers) - const versionParam = url.searchParams.get('version')?.trim() - const tagParam = url.searchParams.get('tag')?.trim() + if (second === "file" && segments.length === 2) { + const url = new URL(request.url); + const path = url.searchParams.get("path")?.trim(); + if (!path) return text("Missing path", 400, rate.headers); + const versionParam = url.searchParams.get("version")?.trim(); + const tagParam = url.searchParams.get("tag")?.trim(); - const skillResult = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult - if (!skillResult?.skill) return text('Skill not found', 404, rate.headers) + const skillResult = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult; + if (!skillResult?.skill) return text("Skill not found", 404, rate.headers); - let version = skillResult.latestVersion + let version = skillResult.latestVersion; if (versionParam) { version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, { skillId: skillResult.skill._id, version: versionParam, - }) + }); } else if (tagParam) { - const versionId = skillResult.skill.tags[tagParam] + const versionId = skillResult.skill.tags[tagParam]; if (versionId) { - version = await ctx.runQuery(api.skills.getVersionById, { versionId }) + version = await ctx.runQuery(api.skills.getVersionById, { versionId }); } } - if (!version) return text('Version not found', 404, rate.headers) - if (version.softDeletedAt) return text('Version not available', 410, rate.headers) + if (!version) return text("Version not found", 404, rate.headers); + if (version.softDeletedAt) return text("Version not available", 410, rate.headers); - const normalized = path.trim() - const normalizedLower = normalized.toLowerCase() + const normalized = path.trim(); + const normalizedLower = normalized.toLowerCase(); const file = version.files.find((entry) => entry.path === normalized) ?? - version.files.find((entry) => entry.path.toLowerCase() === normalizedLower) - if (!file) return text('File not found', 404, rate.headers) - if (file.size > MAX_RAW_FILE_BYTES) return text('File exceeds 200KB limit', 413, rate.headers) + version.files.find((entry) => entry.path.toLowerCase() === normalizedLower); + if (!file) return text("File not found", 404, rate.headers); + if (file.size > MAX_RAW_FILE_BYTES) return text("File exceeds 200KB limit", 413, rate.headers); - const blob = await ctx.storage.get(file.storageId) - if (!blob) return text('File missing in storage', 410, rate.headers) - const textContent = await blob.text() + const blob = await ctx.storage.get(file.storageId); + if (!blob) return text("File missing in storage", 410, rate.headers); + const textContent = await blob.text(); return safeTextFileResponse({ textContent, path: file.path, @@ -413,83 +501,83 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) sha256: file.sha256, size: file.size, headers: rate.headers, - }) + }); } - return text('Not found', 404, rate.headers) + return text("Not found", 404, rate.headers); } export async function publishSkillV1Handler(ctx: ActionCtx, request: Request) { - const rate = await applyRateLimit(ctx, request, 'write') - if (!rate.ok) return rate.response + const rate = await applyRateLimit(ctx, request, "write"); + if (!rate.ok) return rate.response; try { - if (!parseBearerToken(request)) return text('Unauthorized', 401, rate.headers) + if (!parseBearerToken(request)) return text("Unauthorized", 401, rate.headers); } catch { - return text('Unauthorized', 401, rate.headers) + return text("Unauthorized", 401, rate.headers); } - const { userId } = await requireApiTokenUser(ctx, request) + const { userId } = await requireApiTokenUser(ctx, request); - const contentType = request.headers.get('content-type') ?? '' + const contentType = request.headers.get("content-type") ?? ""; try { - if (contentType.includes('application/json')) { - const body = await request.json() - const payload = parsePublishBody(body) - const result = await publishVersionForUser(ctx, userId, payload) - return json({ ok: true, ...result }, 200, rate.headers) + if (contentType.includes("application/json")) { + const body = await request.json(); + const payload = parsePublishBody(body); + const result = await publishVersionForUser(ctx, userId, payload); + return json({ ok: true, ...result }, 200, rate.headers); } - if (contentType.includes('multipart/form-data')) { - const payload = await parseMultipartPublish(ctx, request) - const result = await publishVersionForUser(ctx, userId, payload) - return json({ ok: true, ...result }, 200, rate.headers) + if (contentType.includes("multipart/form-data")) { + const payload = await parseMultipartPublish(ctx, request); + const result = await publishVersionForUser(ctx, userId, payload); + return json({ ok: true, ...result }, 200, rate.headers); } } catch (error) { - const message = error instanceof Error ? error.message : 'Publish failed' - return text(message, 400, rate.headers) + const message = error instanceof Error ? error.message : "Publish failed"; + return text(message, 400, rate.headers); } - return text('Unsupported content type', 415, rate.headers) + return text("Unsupported content type", 415, rate.headers); } export async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request) { - const rate = await applyRateLimit(ctx, request, 'write') - if (!rate.ok) return rate.response + const rate = await applyRateLimit(ctx, request, "write"); + if (!rate.ok) return rate.response; - const segments = getPathSegments(request, '/api/v1/skills/') - if (segments.length !== 2 || segments[1] !== 'undelete') { - return text('Not found', 404, rate.headers) + const segments = getPathSegments(request, "/api/v1/skills/"); + if (segments.length !== 2 || segments[1] !== "undelete") { + return text("Not found", 404, rate.headers); } - const slug = segments[0]?.trim().toLowerCase() ?? '' + const slug = segments[0]?.trim().toLowerCase() ?? ""; try { - const { userId } = await requireApiTokenUser(ctx, request) + const { userId } = await requireApiTokenUser(ctx, request); await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, { userId, slug, deleted: false, - }) - return json({ ok: true }, 200, rate.headers) + }); + return json({ ok: true }, 200, rate.headers); } catch (error) { - return softDeleteErrorToResponse('skill', error, rate.headers) + return softDeleteErrorToResponse("skill", error, rate.headers); } } export async function skillsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) { - const rate = await applyRateLimit(ctx, request, 'write') - if (!rate.ok) return rate.response + const rate = await applyRateLimit(ctx, request, "write"); + if (!rate.ok) return rate.response; - const segments = getPathSegments(request, '/api/v1/skills/') - if (segments.length !== 1) return text('Not found', 404, rate.headers) - const slug = segments[0]?.trim().toLowerCase() ?? '' + const segments = getPathSegments(request, "/api/v1/skills/"); + if (segments.length !== 1) return text("Not found", 404, rate.headers); + const slug = segments[0]?.trim().toLowerCase() ?? ""; try { - const { userId } = await requireApiTokenUser(ctx, request) + const { userId } = await requireApiTokenUser(ctx, request); await ctx.runMutation(internal.skills.setSkillSoftDeletedInternal, { userId, slug, deleted: true, - }) - return json({ ok: true }, 200, rate.headers) + }); + return json({ ok: true }, 200, rate.headers); } catch (error) { - return softDeleteErrorToResponse('skill', error, rate.headers) + return softDeleteErrorToResponse("skill", error, rate.headers); } } diff --git a/packages/clawdhub/src/schema/schemas.ts b/packages/clawdhub/src/schema/schemas.ts index 26c046dbe..87ee9a90a 100644 --- a/packages/clawdhub/src/schema/schemas.ts +++ b/packages/clawdhub/src/schema/schemas.ts @@ -1,288 +1,314 @@ -import { type inferred, type } from 'arktype' +import { type inferred, type } from "arktype"; export const GlobalConfigSchema = type({ - registry: 'string', - token: 'string?', -}) -export type GlobalConfig = (typeof GlobalConfigSchema)[inferred] + registry: "string", + token: "string?", +}); +export type GlobalConfig = (typeof GlobalConfigSchema)[inferred]; export const WellKnownConfigSchema = type({ - apiBase: 'string', - authBase: 'string?', - minCliVersion: 'string?', + apiBase: "string", + authBase: "string?", + minCliVersion: "string?", }).or({ - registry: 'string', - authBase: 'string?', - minCliVersion: 'string?', -}) -export type WellKnownConfig = (typeof WellKnownConfigSchema)[inferred] + registry: "string", + authBase: "string?", + minCliVersion: "string?", +}); +export type WellKnownConfig = (typeof WellKnownConfigSchema)[inferred]; export const LockfileSchema = type({ - version: '1', + version: "1", skills: { - '[string]': { - version: 'string|null', - installedAt: 'number', + "[string]": { + version: "string|null", + installedAt: "number", }, }, -}) -export type Lockfile = (typeof LockfileSchema)[inferred] +}); +export type Lockfile = (typeof LockfileSchema)[inferred]; export const ApiCliWhoamiResponseSchema = type({ user: { - handle: 'string|null', + handle: "string|null", }, -}) +}); export const ApiSearchResponseSchema = type({ results: type({ - slug: 'string?', - displayName: 'string?', - version: 'string|null?', - score: 'number', + slug: "string?", + displayName: "string?", + version: "string|null?", + score: "number", }).array(), -}) +}); export const ApiSkillMetaResponseSchema = type({ latestVersion: type({ - version: 'string', + version: "string", }).optional(), - skill: 'unknown|null?', -}) + skill: "unknown|null?", +}); export const ApiCliUploadUrlResponseSchema = type({ - uploadUrl: 'string', -}) + uploadUrl: "string", +}); export const ApiUploadFileResponseSchema = type({ - storageId: 'string', -}) + storageId: "string", +}); export const CliPublishFileSchema = type({ - path: 'string', - size: 'number', - storageId: 'string', - sha256: 'string', - contentType: 'string?', -}) -export type CliPublishFile = (typeof CliPublishFileSchema)[inferred] + path: "string", + size: "number", + storageId: "string", + sha256: "string", + contentType: "string?", +}); +export type CliPublishFile = (typeof CliPublishFileSchema)[inferred]; export const CliPublishRequestSchema = type({ - slug: 'string', - displayName: 'string', - version: 'string', - changelog: 'string', - tags: 'string[]?', + slug: "string", + displayName: "string", + version: "string", + changelog: "string", + tags: "string[]?", forkOf: type({ - slug: 'string', - version: 'string?', + slug: "string", + version: "string?", }).optional(), files: CliPublishFileSchema.array(), -}) -export type CliPublishRequest = (typeof CliPublishRequestSchema)[inferred] +}); +export type CliPublishRequest = (typeof CliPublishRequestSchema)[inferred]; export const ApiCliPublishResponseSchema = type({ - ok: 'true', - skillId: 'string', - versionId: 'string', -}) + ok: "true", + skillId: "string", + versionId: "string", +}); export const CliSkillDeleteRequestSchema = type({ - slug: 'string', -}) -export type CliSkillDeleteRequest = (typeof CliSkillDeleteRequestSchema)[inferred] + slug: "string", +}); +export type CliSkillDeleteRequest = (typeof CliSkillDeleteRequestSchema)[inferred]; export const ApiCliSkillDeleteResponseSchema = type({ - ok: 'true', -}) + ok: "true", +}); export const ApiSkillResolveResponseSchema = type({ - match: type({ version: 'string' }).or('null'), - latestVersion: type({ version: 'string' }).or('null'), -}) + match: type({ version: "string" }).or("null"), + latestVersion: type({ version: "string" }).or("null"), +}); export const CliTelemetrySyncRequestSchema = type({ roots: type({ - rootId: 'string', - label: 'string', + rootId: "string", + label: "string", skills: type({ - slug: 'string', - version: 'string|null?', + slug: "string", + version: "string|null?", }).array(), }).array(), -}) -export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred] +}); +export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred]; export const ApiCliTelemetrySyncResponseSchema = type({ - ok: 'true', -}) + ok: "true", +}); export const ApiV1WhoamiResponseSchema = type({ user: { - handle: 'string|null', - displayName: 'string|null?', - image: 'string|null?', + handle: "string|null", + displayName: "string|null?", + image: "string|null?", }, -}) +}); export const ApiV1UserSearchResponseSchema = type({ items: type({ - userId: 'string', - handle: 'string|null', - displayName: 'string|null?', - name: 'string|null?', + userId: "string", + handle: "string|null", + displayName: "string|null?", + name: "string|null?", role: '"admin"|"moderator"|"user"|null?', }).array(), - total: 'number', -}) + total: "number", +}); export const ApiV1SearchResponseSchema = type({ results: type({ - slug: 'string?', - displayName: 'string?', - summary: 'string|null?', - version: 'string|null?', - score: 'number', - updatedAt: 'number?', + slug: "string?", + displayName: "string?", + summary: "string|null?", + version: "string|null?", + score: "number", + updatedAt: "number?", }).array(), -}) +}); export const ApiV1SkillListResponseSchema = type({ items: type({ - slug: 'string', - displayName: 'string', - summary: 'string|null?', - tags: 'unknown', - stats: 'unknown', - createdAt: 'number', - updatedAt: 'number', + slug: "string", + displayName: "string", + summary: "string|null?", + tags: "unknown", + stats: "unknown", + createdAt: "number", + updatedAt: "number", latestVersion: type({ - version: 'string', - createdAt: 'number', - changelog: 'string', + version: "string", + createdAt: "number", + changelog: "string", }).optional(), }).array(), - nextCursor: 'string|null', -}) + nextCursor: "string|null", +}); export const ApiV1SkillResponseSchema = type({ skill: type({ - slug: 'string', - displayName: 'string', - summary: 'string|null?', - tags: 'unknown', - stats: 'unknown', - createdAt: 'number', - updatedAt: 'number', - }).or('null'), + slug: "string", + displayName: "string", + summary: "string|null?", + tags: "unknown", + stats: "unknown", + createdAt: "number", + updatedAt: "number", + }).or("null"), latestVersion: type({ - version: 'string', - createdAt: 'number', - changelog: 'string', - }).or('null'), + version: "string", + createdAt: "number", + changelog: "string", + }).or("null"), owner: type({ - handle: 'string|null', - displayName: 'string|null?', - image: 'string|null?', - }).or('null'), + handle: "string|null", + displayName: "string|null?", + image: "string|null?", + }).or("null"), moderation: type({ - isSuspicious: 'boolean', - isMalwareBlocked: 'boolean', + isSuspicious: "boolean", + isMalwareBlocked: "boolean", + verdict: '"clean"|"suspicious"|"malicious"?', + reasonCodes: "string[]?", + updatedAt: "number|null?", + engineVersion: "string|null?", + summary: "string|null?", }) - .or('null') + .or("null") .optional(), -}) +}); + +export const ApiV1SkillModerationResponseSchema = type({ + moderation: type({ + isSuspicious: "boolean", + isMalwareBlocked: "boolean", + verdict: '"clean"|"suspicious"|"malicious"', + reasonCodes: "string[]", + updatedAt: "number|null?", + engineVersion: "string|null?", + summary: "string|null?", + legacyReason: "string|null?", + evidence: type({ + code: "string", + severity: '"info"|"warn"|"critical"', + file: "string", + line: "number", + message: "string", + evidence: "string", + }).array(), + }).or("null"), +}); export const ApiV1SkillVersionListResponseSchema = type({ items: type({ - version: 'string', - createdAt: 'number', - changelog: 'string', + version: "string", + createdAt: "number", + changelog: "string", changelogSource: '"auto"|"user"|null?', }).array(), - nextCursor: 'string|null', -}) + nextCursor: "string|null", +}); export const ApiV1SkillVersionResponseSchema = type({ version: type({ - version: 'string', - createdAt: 'number', - changelog: 'string', + version: "string", + createdAt: "number", + changelog: "string", changelogSource: '"auto"|"user"|null?', - files: 'unknown?', - }).or('null'), + files: "unknown?", + }).or("null"), skill: type({ - slug: 'string', - displayName: 'string', - }).or('null'), -}) + slug: "string", + displayName: "string", + }).or("null"), +}); export const ApiV1SkillResolveResponseSchema = type({ - match: type({ version: 'string' }).or('null'), - latestVersion: type({ version: 'string' }).or('null'), -}) + match: type({ version: "string" }).or("null"), + latestVersion: type({ version: "string" }).or("null"), +}); export const ApiV1PublishResponseSchema = type({ - ok: 'true', - skillId: 'string', - versionId: 'string', -}) + ok: "true", + skillId: "string", + versionId: "string", +}); export const ApiV1DeleteResponseSchema = type({ - ok: 'true', -}) + ok: "true", +}); export const ApiV1BanUserResponseSchema = type({ - ok: 'true', - alreadyBanned: 'boolean', - deletedSkills: 'number', -}) + ok: "true", + alreadyBanned: "boolean", + deletedSkills: "number", +}); export const ApiV1SetRoleResponseSchema = type({ - ok: 'true', + ok: "true", role: '"admin"|"moderator"|"user"', -}) +}); export const ApiV1StarResponseSchema = type({ - ok: 'true', - starred: 'boolean', - alreadyStarred: 'boolean', -}) + ok: "true", + starred: "boolean", + alreadyStarred: "boolean", +}); export const ApiV1UnstarResponseSchema = type({ - ok: 'true', - unstarred: 'boolean', - alreadyUnstarred: 'boolean', -}) + ok: "true", + unstarred: "boolean", + alreadyUnstarred: "boolean", +}); export const SkillInstallSpecSchema = type({ - id: 'string?', + id: "string?", kind: '"brew"|"node"|"go"|"uv"', - label: 'string?', - bins: 'string[]?', - formula: 'string?', - tap: 'string?', - package: 'string?', - module: 'string?', -}) -export type SkillInstallSpec = (typeof SkillInstallSpecSchema)[inferred] + label: "string?", + bins: "string[]?", + formula: "string?", + tap: "string?", + package: "string?", + module: "string?", +}); +export type SkillInstallSpec = (typeof SkillInstallSpecSchema)[inferred]; export const ClawdisRequiresSchema = type({ - bins: 'string[]?', - anyBins: 'string[]?', - env: 'string[]?', - config: 'string[]?', -}) -export type ClawdisRequires = (typeof ClawdisRequiresSchema)[inferred] + bins: "string[]?", + anyBins: "string[]?", + env: "string[]?", + config: "string[]?", +}); +export type ClawdisRequires = (typeof ClawdisRequiresSchema)[inferred]; export const ClawdisSkillMetadataSchema = type({ - always: 'boolean?', - skillKey: 'string?', - primaryEnv: 'string?', - emoji: 'string?', - homepage: 'string?', - os: 'string[]?', + always: "boolean?", + skillKey: "string?", + primaryEnv: "string?", + emoji: "string?", + homepage: "string?", + os: "string[]?", requires: ClawdisRequiresSchema.optional(), install: SkillInstallSpecSchema.array().optional(), -}) -export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred] +}); +export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred]; diff --git a/packages/schema/dist/schemas.d.ts b/packages/schema/dist/schemas.d.ts index 77bc5c09b..d4fae54e8 100644 --- a/packages/schema/dist/schemas.d.ts +++ b/packages/schema/dist/schemas.d.ts @@ -135,6 +135,16 @@ export declare const ApiV1WhoamiResponseSchema: import("arktype/internal/variant image?: string | null | undefined; }; }, {}>; +export declare const ApiV1UserSearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + items: { + userId: string; + handle: string | null; + displayName?: string | null | undefined; + name?: string | null | undefined; + role?: "user" | "admin" | "moderator" | null | undefined; + }[]; + total: number; +}, {}>; export declare const ApiV1SearchResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ results: { score: number; @@ -182,6 +192,35 @@ export declare const ApiV1SkillResponseSchema: import("arktype/internal/variants displayName?: string | null | undefined; image?: string | null | undefined; } | null; + moderation?: { + isSuspicious: boolean; + isMalwareBlocked: boolean; + verdict?: "clean" | "suspicious" | "malicious" | undefined; + reasonCodes?: string[] | undefined; + updatedAt?: number | null | undefined; + engineVersion?: string | null | undefined; + summary?: string | null | undefined; + } | null | undefined; +}, {}>; +export declare const ApiV1SkillModerationResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ + moderation: { + isSuspicious: boolean; + isMalwareBlocked: boolean; + verdict: "clean" | "suspicious" | "malicious"; + reasonCodes: string[]; + evidence: { + code: string; + severity: "info" | "warn" | "critical"; + file: string; + line: number; + message: string; + evidence: string; + }[]; + updatedAt?: number | null | undefined; + engineVersion?: string | null | undefined; + summary?: string | null | undefined; + legacyReason?: string | null | undefined; + } | null; }, {}>; export declare const ApiV1SkillVersionListResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{ items: { diff --git a/packages/schema/dist/schemas.js b/packages/schema/dist/schemas.js index 4da64dc27..7eb605d05 100644 --- a/packages/schema/dist/schemas.js +++ b/packages/schema/dist/schemas.js @@ -110,6 +110,16 @@ export const ApiV1WhoamiResponseSchema = type({ image: 'string|null?', }, }); +export const ApiV1UserSearchResponseSchema = type({ + items: type({ + userId: 'string', + handle: 'string|null', + displayName: 'string|null?', + name: 'string|null?', + role: '"admin"|"moderator"|"user"|null?', + }).array(), + total: 'number', +}); export const ApiV1SearchResponseSchema = type({ results: type({ slug: 'string?', @@ -157,6 +167,37 @@ export const ApiV1SkillResponseSchema = type({ displayName: 'string|null?', image: 'string|null?', }).or('null'), + moderation: type({ + isSuspicious: 'boolean', + isMalwareBlocked: 'boolean', + verdict: '"clean"|"suspicious"|"malicious"?', + reasonCodes: 'string[]?', + updatedAt: 'number|null?', + engineVersion: 'string|null?', + summary: 'string|null?', + }) + .or('null') + .optional(), +}); +export const ApiV1SkillModerationResponseSchema = type({ + moderation: type({ + isSuspicious: 'boolean', + isMalwareBlocked: 'boolean', + verdict: '"clean"|"suspicious"|"malicious"', + reasonCodes: 'string[]', + updatedAt: 'number|null?', + engineVersion: 'string|null?', + summary: 'string|null?', + legacyReason: 'string|null?', + evidence: type({ + code: 'string', + severity: '"info"|"warn"|"critical"', + file: 'string', + line: 'number', + message: 'string', + evidence: 'string', + }).array(), + }).or('null'), }); export const ApiV1SkillVersionListResponseSchema = type({ items: type({ diff --git a/packages/schema/dist/schemas.js.map b/packages/schema/dist/schemas.js.map index 694026c5c..ef65d483d 100644 --- a/packages/schema/dist/schemas.js.map +++ b/packages/schema/dist/schemas.js.map @@ -1 +1 @@ -{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"schemas.js","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/schema/src/schemas.ts b/packages/schema/src/schemas.ts index a1e7469a4..4eac028fa 100644 --- a/packages/schema/src/schemas.ts +++ b/packages/schema/src/schemas.ts @@ -1,303 +1,335 @@ -import { type inferred, type } from 'arktype' +import { type inferred, type } from "arktype"; export const GlobalConfigSchema = type({ - registry: 'string', - token: 'string?', -}) -export type GlobalConfig = (typeof GlobalConfigSchema)[inferred] + registry: "string", + token: "string?", +}); +export type GlobalConfig = (typeof GlobalConfigSchema)[inferred]; export const WellKnownConfigSchema = type({ - apiBase: 'string', - authBase: 'string?', - minCliVersion: 'string?', + apiBase: "string", + authBase: "string?", + minCliVersion: "string?", }).or({ - registry: 'string', - authBase: 'string?', - minCliVersion: 'string?', -}) -export type WellKnownConfig = (typeof WellKnownConfigSchema)[inferred] + registry: "string", + authBase: "string?", + minCliVersion: "string?", +}); +export type WellKnownConfig = (typeof WellKnownConfigSchema)[inferred]; export const LockfileSchema = type({ - version: '1', + version: "1", skills: { - '[string]': { - version: 'string|null', - installedAt: 'number', + "[string]": { + version: "string|null", + installedAt: "number", }, }, -}) -export type Lockfile = (typeof LockfileSchema)[inferred] +}); +export type Lockfile = (typeof LockfileSchema)[inferred]; export const ApiCliWhoamiResponseSchema = type({ user: { - handle: 'string|null', + handle: "string|null", }, -}) +}); export const ApiSearchResponseSchema = type({ results: type({ - slug: 'string?', - displayName: 'string?', - version: 'string|null?', - score: 'number', + slug: "string?", + displayName: "string?", + version: "string|null?", + score: "number", }).array(), -}) +}); export const ApiSkillMetaResponseSchema = type({ latestVersion: type({ - version: 'string', + version: "string", }).optional(), - skill: 'unknown|null?', -}) + skill: "unknown|null?", +}); export const ApiCliUploadUrlResponseSchema = type({ - uploadUrl: 'string', -}) + uploadUrl: "string", +}); export const ApiUploadFileResponseSchema = type({ - storageId: 'string', -}) + storageId: "string", +}); export const CliPublishFileSchema = type({ - path: 'string', - size: 'number', - storageId: 'string', - sha256: 'string', - contentType: 'string?', -}) -export type CliPublishFile = (typeof CliPublishFileSchema)[inferred] + path: "string", + size: "number", + storageId: "string", + sha256: "string", + contentType: "string?", +}); +export type CliPublishFile = (typeof CliPublishFileSchema)[inferred]; export const PublishSourceSchema = type({ kind: '"github"', - url: 'string', - repo: 'string', - ref: 'string', - commit: 'string', - path: 'string', - importedAt: 'number', -}) + url: "string", + repo: "string", + ref: "string", + commit: "string", + path: "string", + importedAt: "number", +}); export const CliPublishRequestSchema = type({ - slug: 'string', - displayName: 'string', - version: 'string', - changelog: 'string', - tags: 'string[]?', + slug: "string", + displayName: "string", + version: "string", + changelog: "string", + tags: "string[]?", source: PublishSourceSchema.optional(), forkOf: type({ - slug: 'string', - version: 'string?', + slug: "string", + version: "string?", }).optional(), files: CliPublishFileSchema.array(), -}) -export type CliPublishRequest = (typeof CliPublishRequestSchema)[inferred] +}); +export type CliPublishRequest = (typeof CliPublishRequestSchema)[inferred]; export const ApiCliPublishResponseSchema = type({ - ok: 'true', - skillId: 'string', - versionId: 'string', -}) + ok: "true", + skillId: "string", + versionId: "string", +}); export const CliSkillDeleteRequestSchema = type({ - slug: 'string', -}) -export type CliSkillDeleteRequest = (typeof CliSkillDeleteRequestSchema)[inferred] + slug: "string", +}); +export type CliSkillDeleteRequest = (typeof CliSkillDeleteRequestSchema)[inferred]; export const ApiCliSkillDeleteResponseSchema = type({ - ok: 'true', -}) + ok: "true", +}); export const ApiSkillResolveResponseSchema = type({ - match: type({ version: 'string' }).or('null'), - latestVersion: type({ version: 'string' }).or('null'), -}) + match: type({ version: "string" }).or("null"), + latestVersion: type({ version: "string" }).or("null"), +}); export const CliTelemetrySyncRequestSchema = type({ roots: type({ - rootId: 'string', - label: 'string', + rootId: "string", + label: "string", skills: type({ - slug: 'string', - version: 'string|null?', + slug: "string", + version: "string|null?", }).array(), }).array(), -}) -export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred] +}); +export type CliTelemetrySyncRequest = (typeof CliTelemetrySyncRequestSchema)[inferred]; export const ApiCliTelemetrySyncResponseSchema = type({ - ok: 'true', -}) + ok: "true", +}); export const ApiV1WhoamiResponseSchema = type({ user: { - handle: 'string|null', - displayName: 'string|null?', - image: 'string|null?', + handle: "string|null", + displayName: "string|null?", + image: "string|null?", }, -}) +}); export const ApiV1UserSearchResponseSchema = type({ items: type({ - userId: 'string', - handle: 'string|null', - displayName: 'string|null?', - name: 'string|null?', + userId: "string", + handle: "string|null", + displayName: "string|null?", + name: "string|null?", role: '"admin"|"moderator"|"user"|null?', }).array(), - total: 'number', -}) + total: "number", +}); export const ApiV1SearchResponseSchema = type({ results: type({ - slug: 'string?', - displayName: 'string?', - summary: 'string|null?', - version: 'string|null?', - score: 'number', - updatedAt: 'number?', + slug: "string?", + displayName: "string?", + summary: "string|null?", + version: "string|null?", + score: "number", + updatedAt: "number?", }).array(), -}) +}); export const ApiV1SkillListResponseSchema = type({ items: type({ - slug: 'string', - displayName: 'string', - summary: 'string|null?', - tags: 'unknown', - stats: 'unknown', - createdAt: 'number', - updatedAt: 'number', + slug: "string", + displayName: "string", + summary: "string|null?", + tags: "unknown", + stats: "unknown", + createdAt: "number", + updatedAt: "number", latestVersion: type({ - version: 'string', - createdAt: 'number', - changelog: 'string', + version: "string", + createdAt: "number", + changelog: "string", }).optional(), }).array(), - nextCursor: 'string|null', -}) + nextCursor: "string|null", +}); export const ApiV1SkillResponseSchema = type({ skill: type({ - slug: 'string', - displayName: 'string', - summary: 'string|null?', - tags: 'unknown', - stats: 'unknown', - createdAt: 'number', - updatedAt: 'number', - }).or('null'), + slug: "string", + displayName: "string", + summary: "string|null?", + tags: "unknown", + stats: "unknown", + createdAt: "number", + updatedAt: "number", + }).or("null"), latestVersion: type({ - version: 'string', - createdAt: 'number', - changelog: 'string', - }).or('null'), + version: "string", + createdAt: "number", + changelog: "string", + }).or("null"), owner: type({ - handle: 'string|null', - displayName: 'string|null?', - image: 'string|null?', - }).or('null'), -}) + handle: "string|null", + displayName: "string|null?", + image: "string|null?", + }).or("null"), + moderation: type({ + isSuspicious: "boolean", + isMalwareBlocked: "boolean", + verdict: '"clean"|"suspicious"|"malicious"?', + reasonCodes: "string[]?", + updatedAt: "number|null?", + engineVersion: "string|null?", + summary: "string|null?", + }) + .or("null") + .optional(), +}); + +export const ApiV1SkillModerationResponseSchema = type({ + moderation: type({ + isSuspicious: "boolean", + isMalwareBlocked: "boolean", + verdict: '"clean"|"suspicious"|"malicious"', + reasonCodes: "string[]", + updatedAt: "number|null?", + engineVersion: "string|null?", + summary: "string|null?", + legacyReason: "string|null?", + evidence: type({ + code: "string", + severity: '"info"|"warn"|"critical"', + file: "string", + line: "number", + message: "string", + evidence: "string", + }).array(), + }).or("null"), +}); export const ApiV1SkillVersionListResponseSchema = type({ items: type({ - version: 'string', - createdAt: 'number', - changelog: 'string', + version: "string", + createdAt: "number", + changelog: "string", changelogSource: '"auto"|"user"|null?', }).array(), - nextCursor: 'string|null', -}) + nextCursor: "string|null", +}); export const ApiV1SkillVersionResponseSchema = type({ version: type({ - version: 'string', - createdAt: 'number', - changelog: 'string', + version: "string", + createdAt: "number", + changelog: "string", changelogSource: '"auto"|"user"|null?', - files: 'unknown?', - }).or('null'), + files: "unknown?", + }).or("null"), skill: type({ - slug: 'string', - displayName: 'string', - }).or('null'), -}) + slug: "string", + displayName: "string", + }).or("null"), +}); export const ApiV1SkillResolveResponseSchema = type({ - match: type({ version: 'string' }).or('null'), - latestVersion: type({ version: 'string' }).or('null'), -}) + match: type({ version: "string" }).or("null"), + latestVersion: type({ version: "string" }).or("null"), +}); export const ApiV1PublishResponseSchema = type({ - ok: 'true', - skillId: 'string', - versionId: 'string', -}) + ok: "true", + skillId: "string", + versionId: "string", +}); export const ApiV1DeleteResponseSchema = type({ - ok: 'true', -}) + ok: "true", +}); export const ApiV1SetRoleResponseSchema = type({ - ok: 'true', + ok: "true", role: '"admin"|"moderator"|"user"', -}) +}); export const ApiV1StarResponseSchema = type({ - ok: 'true', - starred: 'boolean', - alreadyStarred: 'boolean', -}) + ok: "true", + starred: "boolean", + alreadyStarred: "boolean", +}); export const ApiV1UnstarResponseSchema = type({ - ok: 'true', - unstarred: 'boolean', - alreadyUnstarred: 'boolean', -}) + ok: "true", + unstarred: "boolean", + alreadyUnstarred: "boolean", +}); export const SkillInstallSpecSchema = type({ - id: 'string?', + id: "string?", kind: '"brew"|"node"|"go"|"uv"', - label: 'string?', - bins: 'string[]?', - formula: 'string?', - tap: 'string?', - package: 'string?', - module: 'string?', -}) -export type SkillInstallSpec = (typeof SkillInstallSpecSchema)[inferred] + label: "string?", + bins: "string[]?", + formula: "string?", + tap: "string?", + package: "string?", + module: "string?", +}); +export type SkillInstallSpec = (typeof SkillInstallSpecSchema)[inferred]; export const NixPluginSpecSchema = type({ - plugin: 'string', - systems: 'string[]?', -}) -export type NixPluginSpec = (typeof NixPluginSpecSchema)[inferred] + plugin: "string", + systems: "string[]?", +}); +export type NixPluginSpec = (typeof NixPluginSpecSchema)[inferred]; export const ClawdbotConfigSpecSchema = type({ - requiredEnv: 'string[]?', - stateDirs: 'string[]?', - example: 'string?', -}) -export type ClawdbotConfigSpec = (typeof ClawdbotConfigSpecSchema)[inferred] + requiredEnv: "string[]?", + stateDirs: "string[]?", + example: "string?", +}); +export type ClawdbotConfigSpec = (typeof ClawdbotConfigSpecSchema)[inferred]; export const ClawdisRequiresSchema = type({ - bins: 'string[]?', - anyBins: 'string[]?', - env: 'string[]?', - config: 'string[]?', -}) -export type ClawdisRequires = (typeof ClawdisRequiresSchema)[inferred] + bins: "string[]?", + anyBins: "string[]?", + env: "string[]?", + config: "string[]?", +}); +export type ClawdisRequires = (typeof ClawdisRequiresSchema)[inferred]; export const ClawdisSkillMetadataSchema = type({ - always: 'boolean?', - skillKey: 'string?', - primaryEnv: 'string?', - emoji: 'string?', - homepage: 'string?', - os: 'string[]?', - cliHelp: 'string?', + always: "boolean?", + skillKey: "string?", + primaryEnv: "string?", + emoji: "string?", + homepage: "string?", + os: "string[]?", + cliHelp: "string?", requires: ClawdisRequiresSchema.optional(), install: SkillInstallSpecSchema.array().optional(), nix: NixPluginSpecSchema.optional(), config: ClawdbotConfigSpecSchema.optional(), -}) -export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred] +}); +export type ClawdisSkillMetadata = (typeof ClawdisSkillMetadataSchema)[inferred]; From 485fae89718bff497598d168755cc8e3991e8e3f Mon Sep 17 00:00:00 2001 From: Arthurzkv Date: Sun, 15 Feb 2026 14:59:45 -0600 Subject: [PATCH 2/2] fix: return moderation JSON for hidden owner views --- convex/httpApiV1.handlers.test.ts | 52 ++++++++++++ convex/httpApiV1/skillsV1.ts | 129 ++++++++++++++++++++++-------- 2 files changed, 148 insertions(+), 33 deletions(-) diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index b34b1c6e9..932a4dd60 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -705,6 +705,58 @@ describe("httpApiV1 handlers", () => { expect(json.moderation.evidence[0].evidence).toBe(""); }); + it("get skill moderation returns full details for hidden skill owner", async () => { + vi.mocked(getOptionalApiTokenUserId).mockResolvedValue("users:1" as never); + let slugCalls = 0; + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ("userId" in args) { + return { _id: "users:1", role: "user" }; + } + if ("slug" in args) { + slugCalls += 1; + if (slugCalls === 1) { + return { skill: null, latestVersion: null, owner: null, moderationInfo: null }; + } + return { + _id: "skills:1", + slug: "demo", + displayName: "Demo", + ownerUserId: "users:1", + moderationStatus: "hidden", + moderationFlags: ["flagged.suspicious"], + moderationVerdict: "suspicious", + moderationReasonCodes: ["suspicious.dangerous_exec"], + moderationSummary: "Detected: suspicious.dangerous_exec", + moderationEngineVersion: "v2.0.0", + moderationEvaluatedAt: 123, + moderationReason: "scanner.vt.suspicious", + moderationEvidence: [ + { + code: "suspicious.dangerous_exec", + severity: "critical", + file: "index.ts", + line: 42, + message: "Shell command execution detected.", + evidence: "execSync(\"curl\")", + }, + ], + updatedAt: 120, + }; + } + 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/moderation"), + ); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.moderation.reasonCodes).toEqual(["suspicious.dangerous_exec"]); + expect(json.moderation.evidence[0].evidence).toContain("execSync"); + expect(json.moderation.legacyReason).toBe("scanner.vt.suspicious"); + }); + it("lists versions", async () => { const runQuery = vi.fn(async (_query: unknown, args: Record) => { if ("slug" in args) { diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index e2406e8af..d175cf18d 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -48,6 +48,27 @@ type ListSkillsResult = { type SkillFile = Doc<"skillVersions">["files"][number]; +type ModerationEvidence = { + code: string; + severity: "info" | "warn" | "critical"; + file: string; + line: number; + message: string; + evidence: string; +}; + +type SkillModerationShape = { + moderationFlags?: string[]; + moderationVerdict?: "clean" | "suspicious" | "malicious"; + moderationReasonCodes?: string[]; + moderationSummary?: string; + moderationEngineVersion?: string; + moderationEvaluatedAt?: number; + moderationReason?: string; + moderationEvidence?: ModerationEvidence[]; + updatedAt?: number; +}; + type GetBySlugResult = { skill: { _id: Id<"skills">; @@ -59,6 +80,8 @@ type GetBySlugResult = { stats: unknown; createdAt: number; updatedAt: number; + moderationReason?: string; + moderationEvidence?: ModerationEvidence[]; } | null; latestVersion: Doc<"skillVersions"> | null; owner: { _id: Id<"users">; handle?: string; displayName?: string; image?: string } | null; @@ -95,6 +118,46 @@ type ListVersionsResult = { nextCursor: string | null; }; +function sanitizeEvidence( + evidence: ModerationEvidence[], + allowSensitiveEvidence: boolean, +): ModerationEvidence[] { + if (allowSensitiveEvidence) return evidence; + return evidence.map((entry) => ({ + code: entry.code, + severity: entry.severity, + file: entry.file, + line: entry.line, + message: entry.message, + evidence: "", + })); +} + +function normalizeModerationFromSkill(skill: SkillModerationShape) { + const flags = Array.isArray(skill.moderationFlags) ? skill.moderationFlags : []; + const verdict = + skill.moderationVerdict ?? + (flags.includes("blocked.malware") + ? "malicious" + : flags.includes("flagged.suspicious") + ? "suspicious" + : "clean"); + const isMalwareBlocked = verdict === "malicious" || flags.includes("blocked.malware"); + const isSuspicious = + !isMalwareBlocked && (verdict === "suspicious" || flags.includes("flagged.suspicious")); + return { + isMalwareBlocked, + isSuspicious, + verdict, + reasonCodes: Array.isArray(skill.moderationReasonCodes) ? skill.moderationReasonCodes : [], + summary: skill.moderationSummary ?? null, + engineVersion: skill.moderationEngineVersion ?? null, + updatedAt: skill.moderationEvaluatedAt ?? skill.updatedAt ?? null, + reason: skill.moderationReason ?? null, + evidence: Array.isArray(skill.moderationEvidence) ? skill.moderationEvidence : [], + }; +} + export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) { const rate = await applyRateLimit(ctx, request, "read"); if (!rate.ok) return rate.response; @@ -331,15 +394,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) } if (second === "moderation" && segments.length === 2) { - const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult; - if (!result?.skill) { - const hidden = await describeOwnerVisibleSkillState(ctx, request, slug); - if (hidden) return text(hidden.message, hidden.status, rate.headers); - return text("Skill not found", 404, rate.headers); - } - const apiTokenUserId = await getOptionalApiTokenUserId(ctx, request); - const isOwner = Boolean(apiTokenUserId && apiTokenUserId === result.skill.ownerUserId); let isStaff = false; if (apiTokenUserId) { const caller = await ctx.runQuery(internal.users.getByIdInternal, { userId: apiTokenUserId }); @@ -348,36 +403,44 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) } } - const mod = result.moderationInfo; + const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult; + if (!result?.skill) { + const hiddenSkill = await ctx.runQuery(internal.skills.getSkillBySlugInternal, { slug }); + const isOwner = Boolean(apiTokenUserId && hiddenSkill && apiTokenUserId === hiddenSkill.ownerUserId); + if (hiddenSkill && (isOwner || isStaff)) { + const mod = normalizeModerationFromSkill(hiddenSkill as SkillModerationShape); + return json( + { + moderation: { + isSuspicious: mod.isSuspicious, + isMalwareBlocked: mod.isMalwareBlocked, + verdict: mod.verdict, + reasonCodes: mod.reasonCodes, + summary: mod.summary, + engineVersion: mod.engineVersion, + updatedAt: mod.updatedAt, + evidence: sanitizeEvidence(mod.evidence, true), + legacyReason: mod.reason, + }, + }, + 200, + rate.headers, + ); + } + + const hidden = await describeOwnerVisibleSkillState(ctx, request, slug); + if (hidden) return text(hidden.message, hidden.status, rate.headers); + return text("Skill not found", 404, rate.headers); + } + + const isOwner = Boolean(apiTokenUserId && apiTokenUserId === result.skill.ownerUserId); + const mod = result.moderationInfo ?? normalizeModerationFromSkill(result.skill as SkillModerationShape); const isFlagged = Boolean(mod?.isSuspicious || mod?.isMalwareBlocked); if (!isOwner && !isStaff && !isFlagged) { return text("Moderation details unavailable", 404, rate.headers); } - const allEvidence = - ( - result.skill as { - moderationEvidence?: Array<{ - code: string; - severity: "info" | "warn" | "critical"; - file: string; - line: number; - message: string; - evidence: string; - }>; - } - ).moderationEvidence ?? []; - const evidence = - isOwner || isStaff - ? allEvidence - : allEvidence.map((entry) => ({ - code: entry.code, - severity: entry.severity, - file: entry.file, - line: entry.line, - message: entry.message, - evidence: "", - })); + const evidence = sanitizeEvidence(result.skill.moderationEvidence ?? [], Boolean(isOwner || isStaff)); return json( {