From f242164de64caefa30cdbd749a77b386317300eb Mon Sep 17 00:00:00 2001 From: VAC Date: Thu, 26 Feb 2026 12:16:15 -0500 Subject: [PATCH 1/6] feat(api): add scan verification endpoint and non-suspicious filters --- convex/httpApi.handlers.test.ts | 17 ++ convex/httpApi.ts | 14 +- convex/httpApiV1.handlers.test.ts | 127 ++++++++++++++ convex/httpApiV1/skillsV1.ts | 242 ++++++++++++++++++++++----- convex/skills.listPublicPage.test.ts | 189 +++++++++++++++++++++ convex/skills.ts | 16 +- docs/api.md | 3 + docs/http-api.md | 19 +++ public/api/v1/openapi.json | 69 +++++++- 9 files changed, 649 insertions(+), 47 deletions(-) create mode 100644 convex/skills.listPublicPage.test.ts diff --git a/convex/httpApi.handlers.test.ts b/convex/httpApi.handlers.test.ts index 5f67d847ad..829d12a664 100644 --- a/convex/httpApi.handlers.test.ts +++ b/convex/httpApi.handlers.test.ts @@ -49,6 +49,7 @@ describe('httpApi handlers', () => { query: 'test', limit: 5, highlightedOnly: true, + nonSuspiciousOnly: undefined, }) expect(response.status).toBe(200) const json = await response.json() @@ -65,6 +66,7 @@ describe('httpApi handlers', () => { query: 'test', limit: undefined, highlightedOnly: true, + nonSuspiciousOnly: undefined, }) }) @@ -78,6 +80,21 @@ describe('httpApi handlers', () => { query: 'test', limit: undefined, highlightedOnly: undefined, + nonSuspiciousOnly: undefined, + }) + }) + + it('searchSkillsHttp forwards nonSuspiciousOnly', async () => { + const runAction = vi.fn().mockResolvedValue([]) + await __handlers.searchSkillsHandler( + makeCtx({ runAction }), + new Request('https://example.com/api/search?q=test&nonSuspiciousOnly=1'), + ) + expect(runAction).toHaveBeenCalledWith(expect.anything(), { + query: 'test', + limit: undefined, + highlightedOnly: undefined, + nonSuspiciousOnly: true, }) }) diff --git a/convex/httpApi.ts b/convex/httpApi.ts index f6c91fee85..0e159b7e17 100644 --- a/convex/httpApi.ts +++ b/convex/httpApi.ts @@ -44,8 +44,11 @@ async function searchSkillsHandler(ctx: ActionCtx, request: Request) { const url = new URL(request.url) const query = url.searchParams.get('q')?.trim() ?? '' const limit = toOptionalNumber(url.searchParams.get('limit')) - const approvedOnly = url.searchParams.get('approvedOnly') === 'true' - const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true' || approvedOnly + const approvedOnly = parseBooleanQueryParam(url.searchParams.get('approvedOnly')) + const highlightedOnly = parseBooleanQueryParam(url.searchParams.get('highlightedOnly')) || approvedOnly + const nonSuspiciousOnly = + parseBooleanQueryParam(url.searchParams.get('nonSuspiciousOnly')) || + parseBooleanQueryParam(url.searchParams.get('nonSuspicious')) if (!query) return json({ results: [] }) @@ -53,6 +56,7 @@ async function searchSkillsHandler(ctx: ActionCtx, request: Request) { query, limit, highlightedOnly: highlightedOnly || undefined, + nonSuspiciousOnly: nonSuspiciousOnly || undefined, })) as SearchSkillEntry[] return json({ @@ -274,6 +278,12 @@ function toOptionalNumber(value: string | null) { return Number.isFinite(parsed) ? parsed : undefined } +function parseBooleanQueryParam(value: string | null) { + if (!value) return false + const normalized = value.trim().toLowerCase() + return normalized === 'true' || normalized === '1' +} + function parsePublishBody(body: unknown) { const parsed = parseArk(CliPublishRequestSchema, body, 'Publish payload') if (parsed.files.length === 0) throw new Error('files required') diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index a2b91f85a2..0055eef4b4 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -225,6 +225,25 @@ describe('httpApiV1 handlers', () => { query: 'test', limit: 5, highlightedOnly: true, + nonSuspiciousOnly: undefined, + }) + }) + + it('search forwards nonSuspiciousOnly', async () => { + const runAction = vi.fn().mockResolvedValue([]) + const runMutation = vi.fn().mockResolvedValue(okRate()) + const response = await __handlers.searchSkillsV1Handler( + makeCtx({ runAction, runMutation }), + new Request('https://example.com/api/v1/search?q=test&nonSuspiciousOnly=1'), + ) + if (response.status !== 200) { + throw new Error(await response.text()) + } + expect(runAction).toHaveBeenCalledWith(expect.anything(), { + query: 'test', + limit: undefined, + highlightedOnly: undefined, + nonSuspiciousOnly: true, }) }) @@ -554,6 +573,22 @@ describe('httpApiV1 handlers', () => { } }) + it('lists skills forwards nonSuspiciousOnly', async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ('sort' in args || 'cursor' in args || 'limit' in args) { + expect(args.nonSuspiciousOnly).toBe(true) + return { items: [], nextCursor: null } + } + return null + }) + const runMutation = vi.fn().mockResolvedValue(okRate()) + const response = await __handlers.listSkillsV1Handler( + makeCtx({ runQuery, runMutation }), + new Request('https://example.com/api/v1/skills?nonSuspiciousOnly=true'), + ) + 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()) @@ -908,6 +943,98 @@ describe('httpApiV1 handlers', () => { expect(json.version.files[0].path).toBe('SKILL.md') }) + it('returns version detail security from vt analysis', 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 && 'version' in args) { + return { + version: '1.0.0', + createdAt: 1, + changelog: 'c', + changelogSource: 'auto', + sha256hash: 'a'.repeat(64), + vtAnalysis: { + status: 'suspicious', + source: 'code_insight', + checkedAt: 123, + }, + files: [], + } + } + return null + }) + const runMutation = vi.fn().mockResolvedValue(okRate()) + const response = await __handlers.skillsGetRouterV1Handler( + makeCtx({ runQuery, runMutation }), + new Request('https://example.com/api/v1/skills/demo/versions/1.0.0'), + ) + expect(response.status).toBe(200) + const json = await response.json() + expect(json.version.security.status).toBe('suspicious') + expect(json.version.security.scanners.vt.normalizedStatus).toBe('suspicious') + expect(json.version.security.virustotalUrl).toContain('virustotal.com/gui/file/') + }) + + it('returns scan payload for latest version', async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ('slug' in args) { + return { + skill: { + _id: 'skills:1', + slug: 'demo', + displayName: 'Demo', + summary: 's', + tags: { latest: 'versions:1' }, + stats: {}, + createdAt: 1, + updatedAt: 2, + }, + latestVersion: { + version: '1.0.0', + createdAt: 1, + changelog: 'c', + changelogSource: 'auto', + sha256hash: 'b'.repeat(64), + vtAnalysis: { + status: 'clean', + checkedAt: 111, + }, + llmAnalysis: { + status: 'completed', + verdict: 'suspicious', + confidence: 'high', + summary: 's', + checkedAt: 222, + }, + files: [], + }, + owner: { _id: 'users:1', handle: 'owner', displayName: 'Owner' }, + moderationInfo: { + isPendingScan: false, + isMalwareBlocked: false, + isSuspicious: true, + isHiddenByMod: false, + isRemoved: false, + }, + } + } + return null + }) + const runMutation = vi.fn().mockResolvedValue(okRate()) + const response = await __handlers.skillsGetRouterV1Handler( + makeCtx({ runQuery, runMutation }), + new Request('https://example.com/api/v1/skills/demo/scan'), + ) + expect(response.status).toBe(200) + const json = await response.json() + expect(json.security.status).toBe('suspicious') + expect(json.security.isVerified).toBe(true) + expect(json.security.scanners.llm.verdict).toBe('suspicious') + expect(json.moderation.isSuspicious).toBe(true) + }) + it('returns raw file content', async () => { const version = { version: '1.0.0', diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index 5425356c7b..d6d6499064 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -2,6 +2,7 @@ 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 { parseBooleanQueryParam } from '../lib/httpUtils' import { applyRateLimit, parseBearerToken } from '../lib/httpRateLimit' import { publishVersionForUser } from '../skills' import { @@ -166,6 +167,138 @@ function normalizeModerationFromSkill(skill: SkillModerationShape) { } } +type NormalizedSecurityStatus = 'clean' | 'suspicious' | 'malicious' | 'pending' | 'error' + +type SkillSecuritySnapshot = { + status: NormalizedSecurityStatus + hasWarnings: boolean + checkedAt: number | null + model: string | null + isVerified: boolean + sha256hash: string | null + virustotalUrl: string | null + scanners: { + vt: { + status: string + verdict: string | null + normalizedStatus: NormalizedSecurityStatus + analysis: string | null + source: string | null + checkedAt: number | null + } | null + llm: { + status: string + verdict: string | null + normalizedStatus: NormalizedSecurityStatus + confidence: string | null + summary: string | null + dimensions: NonNullable['llmAnalysis']>['dimensions'] | null + guidance: string | null + findings: string | null + model: string | null + checkedAt: number | null + } | null + } +} + +const SECURITY_STATUS_PRIORITY: Record = { + clean: 0, + error: 1, + pending: 2, + suspicious: 3, + malicious: 4, +} + +function normalizeSecurityStatus(value: string | null | undefined): NormalizedSecurityStatus { + const normalized = value?.trim().toLowerCase() + switch (normalized) { + case 'benign': + case 'clean': + return 'clean' + case 'suspicious': + return 'suspicious' + case 'malicious': + return 'malicious' + case 'error': + case 'failed': + return 'error' + case 'pending': + case 'loading': + case 'not_found': + case 'not-found': + case 'stale': + return 'pending' + default: + return 'pending' + } +} + +function mergeSecurityStatuses(statuses: NormalizedSecurityStatus[]) { + if (statuses.length === 0) return 'pending' satisfies NormalizedSecurityStatus + return statuses.reduce((current, candidate) => + SECURITY_STATUS_PRIORITY[candidate] > SECURITY_STATUS_PRIORITY[current] ? candidate : current, + ) +} + +function buildSkillSecuritySnapshot(version: Doc<'skillVersions'>): SkillSecuritySnapshot | null { + const sha256hash = version.sha256hash ?? null + const vt = version.vtAnalysis + const llm = version.llmAnalysis + + if (!sha256hash && !vt && !llm) return null + + const vtStatus = vt ? normalizeSecurityStatus(vt.verdict ?? vt.status) : null + const llmStatus = llm ? normalizeSecurityStatus(llm.verdict ?? llm.status) : null + + const statuses: NormalizedSecurityStatus[] = [] + if (vtStatus) statuses.push(vtStatus) + if (llmStatus) statuses.push(llmStatus) + if (statuses.length === 0 && sha256hash) statuses.push('pending') + const status = mergeSecurityStatuses(statuses) + const hasWarnings = status === 'suspicious' || status === 'malicious' + + const checkedAtCandidates = [vt?.checkedAt, llm?.checkedAt].filter( + (value): value is number => typeof value === 'number', + ) + const checkedAt = checkedAtCandidates.length > 0 ? Math.max(...checkedAtCandidates) : null + + return { + status, + hasWarnings, + checkedAt, + model: llm?.model ?? null, + isVerified: status === 'clean' || status === 'suspicious' || status === 'malicious', + sha256hash, + virustotalUrl: sha256hash ? `https://www.virustotal.com/gui/file/${sha256hash}` : null, + scanners: { + vt: vt + ? { + status: vt.status, + verdict: vt.verdict ?? null, + normalizedStatus: vtStatus ?? 'pending', + analysis: vt.analysis ?? null, + source: vt.source ?? null, + checkedAt: vt.checkedAt ?? null, + } + : null, + llm: llm + ? { + status: llm.status, + verdict: llm.verdict ?? null, + normalizedStatus: llmStatus ?? 'pending', + confidence: llm.confidence ?? null, + summary: llm.summary ?? null, + dimensions: llm.dimensions ?? null, + guidance: llm.guidance ?? null, + findings: llm.findings ?? null, + model: llm.model ?? null, + checkedAt: llm.checkedAt ?? null, + } + : null, + }, + } +} + export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) { const rate = await applyRateLimit(ctx, request, 'read') if (!rate.ok) return rate.response @@ -173,7 +306,10 @@ export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) { 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 highlightedOnly = parseBooleanQueryParam(url.searchParams.get('highlightedOnly')) + const nonSuspiciousOnly = + parseBooleanQueryParam(url.searchParams.get('nonSuspiciousOnly')) || + parseBooleanQueryParam(url.searchParams.get('nonSuspicious')) if (!query) return json({ results: [] }, 200, rate.headers) @@ -181,6 +317,7 @@ export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) { query, limit, highlightedOnly: highlightedOnly || undefined, + nonSuspiciousOnly: nonSuspiciousOnly || undefined, })) as SearchSkillEntry[] return json( @@ -251,11 +388,15 @@ export async function listSkillsV1Handler(ctx: ActionCtx, request: Request) { const rawCursor = url.searchParams.get('cursor')?.trim() || undefined const sort = parseListSort(url.searchParams.get('sort')) const cursor = sort === 'trending' ? undefined : rawCursor + const nonSuspiciousOnly = + parseBooleanQueryParam(url.searchParams.get('nonSuspiciousOnly')) || + parseBooleanQueryParam(url.searchParams.get('nonSuspicious')) const result = (await ctx.runQuery(api.skills.listPublicPage, { limit, cursor, sort, + nonSuspiciousOnly: nonSuspiciousOnly || undefined, })) as ListSkillsResult // Batch resolve all tags in a single query instead of N queries @@ -524,43 +665,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) }) if (!version) return text('Version not found', 404, rate.headers) if (version.softDeletedAt) return text('Version not available', 410, rate.headers) - - // Map llmAnalysis to security status - let security = undefined - if (version.llmAnalysis) { - const analysis = version.llmAnalysis - let status: 'clean' | 'suspicious' | 'malicious' | 'pending' | 'error' - switch (analysis.verdict) { - case 'benign': - status = 'clean' - break - case 'suspicious': - status = 'suspicious' - break - case 'malicious': - status = 'malicious' - break - default: - status = analysis.status === 'error' ? 'error' : 'pending' - } - - const hasWarnings = - analysis.verdict === 'suspicious' || - analysis.verdict === 'malicious' || - (Array.isArray(analysis.dimensions) && - analysis.dimensions.some((dimension) => { - if (!dimension || typeof dimension !== 'object') return false - const rating = (dimension as { rating?: unknown }).rating - return typeof rating === 'string' && rating !== 'ok' - })) - - security = { - status, - hasWarnings, - checkedAt: analysis.checkedAt ?? null, - model: analysis.model || null, - } - } + const security = buildSkillSecuritySnapshot(version) return json( { @@ -577,8 +682,67 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) sha256: file.sha256, contentType: file.contentType ?? null, })), - security, + security: security ?? undefined, + }, + }, + 200, + rate.headers, + ) + } + + if (second === 'scan' && segments.length === 2) { + const url = new URL(request.url) + const versionParam = url.searchParams.get('version')?.trim() + const tagParam = url.searchParams.get('tag')?.trim() + + 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) + } + + let version = result.latestVersion + if (versionParam) { + version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, { + skillId: result.skill._id, + version: versionParam, + }) + } else if (tagParam) { + const versionId = result.skill.tags[tagParam] + if (versionId) { + version = await ctx.runQuery(api.skills.getVersionById, { versionId }) + } else { + version = null + } + } + + if (!version) return text('Version not found', 404, rate.headers) + if (version.softDeletedAt) return text('Version not available', 410, rate.headers) + + const security = buildSkillSecuritySnapshot(version) + + return json( + { + skill: { + slug: result.skill.slug, + displayName: result.skill.displayName, + }, + version: { + version: version.version, + createdAt: version.createdAt, + changelogSource: version.changelogSource ?? null, }, + moderation: result.moderationInfo + ? { + isPendingScan: result.moderationInfo.isPendingScan ?? false, + isMalwareBlocked: result.moderationInfo.isMalwareBlocked ?? false, + isSuspicious: result.moderationInfo.isSuspicious ?? false, + isHiddenByMod: result.moderationInfo.isHiddenByMod ?? false, + isRemoved: result.moderationInfo.isRemoved ?? false, + } + : null, + security, }, 200, rate.headers, diff --git a/convex/skills.listPublicPage.test.ts b/convex/skills.listPublicPage.test.ts new file mode 100644 index 0000000000..991e59322c --- /dev/null +++ b/convex/skills.listPublicPage.test.ts @@ -0,0 +1,189 @@ +/* @vitest-environment node */ +import { describe, expect, it, vi } from 'vitest' +import { listPublicPage } from './skills' + +type ListArgs = { + cursor?: string + limit?: number + sort?: 'updated' | 'downloads' | 'stars' | 'installsCurrent' | 'installsAllTime' | 'trending' + nonSuspiciousOnly?: boolean +} + +type ListResult = { + items: Array<{ skill: { slug: string } }> + nextCursor: string | null +} + +type WrappedHandler = { + _handler: (ctx: unknown, args: TArgs) => Promise +} + +const listPublicPageHandler = (listPublicPage as unknown as WrappedHandler) + ._handler + +describe('skills.listPublicPage', () => { + it('filters suspicious skills when nonSuspiciousOnly is enabled', async () => { + const clean = makeSkill('skills:clean', 'clean', 'users:1', 'skillVersions:1') + const suspicious = makeSkill( + 'skills:suspicious', + 'suspicious', + 'users:2', + 'skillVersions:2', + ['flagged.suspicious'], + ) + + const paginateMock = vi.fn().mockResolvedValue({ + page: [clean, suspicious], + continueCursor: 'next', + isDone: false, + }) + const ctx = makeCtx({ + by_updated: paginateMock, + users: [makeUser('users:1'), makeUser('users:2')], + versions: [makeVersion('skillVersions:1'), makeVersion('skillVersions:2')], + }) + + const result = await listPublicPageHandler(ctx, { + sort: 'updated', + limit: 10, + nonSuspiciousOnly: true, + }) + + expect(result.items).toHaveLength(1) + expect(result.items[0]?.skill.slug).toBe('clean') + expect(result.nextCursor).toBe('next') + }) + + it('returns suspicious skills when nonSuspiciousOnly is disabled', async () => { + const clean = makeSkill('skills:clean', 'clean', 'users:1', 'skillVersions:1') + const suspicious = makeSkill( + 'skills:suspicious', + 'suspicious', + 'users:2', + 'skillVersions:2', + ['flagged.suspicious'], + ) + + const paginateMock = vi.fn().mockResolvedValue({ + page: [clean, suspicious], + continueCursor: null, + isDone: true, + }) + const ctx = makeCtx({ + by_updated: paginateMock, + users: [makeUser('users:1'), makeUser('users:2')], + versions: [makeVersion('skillVersions:1'), makeVersion('skillVersions:2')], + }) + + const result = await listPublicPageHandler(ctx, { + sort: 'updated', + limit: 10, + nonSuspiciousOnly: false, + }) + + expect(result.items).toHaveLength(2) + expect(result.items.map((entry) => entry.skill.slug)).toEqual(['clean', 'suspicious']) + }) +}) + +function makeCtx({ + by_updated, + users, + versions, +}: { + by_updated: ReturnType + users: Array> + versions: Array> +}) { + const userMap = new Map(users.map((user) => [user._id, user])) + const versionMap = new Map(versions.map((version) => [version._id, version])) + return { + db: { + query: vi.fn((table: string) => { + if (table !== 'skills') throw new Error(`unexpected table ${table}`) + return { + withIndex: vi.fn((index: string, _builder: unknown) => { + if (index !== 'by_updated') throw new Error(`unexpected index ${index}`) + return { + order: vi.fn((dir: string) => { + if (dir !== 'desc') throw new Error(`unexpected order ${dir}`) + return { paginate: by_updated } + }), + } + }), + } + }), + get: vi.fn(async (id: string) => { + if (id.startsWith('users:')) return userMap.get(id) ?? null + if (id.startsWith('skillVersions:')) return versionMap.get(id) ?? null + return null + }), + }, + } +} + +function makeSkill( + id: string, + slug: string, + ownerUserId: string, + latestVersionId: string, + moderationFlags?: string[], +) { + return { + _id: id, + _creationTime: 1, + slug, + displayName: slug, + summary: `${slug} summary`, + ownerUserId, + canonicalSkillId: undefined, + forkOf: undefined, + latestVersionId, + tags: {}, + badges: {}, + statsDownloads: 0, + statsStars: 0, + statsInstallsCurrent: 0, + statsInstallsAllTime: 0, + stats: { + downloads: 0, + stars: 0, + installsCurrent: 0, + installsAllTime: 0, + versions: 1, + comments: 0, + }, + moderationStatus: 'active', + moderationReason: undefined, + moderationFlags, + softDeletedAt: undefined, + createdAt: 1, + updatedAt: 1, + } +} + +function makeUser(id: string) { + return { + _id: id, + _creationTime: 1, + handle: `h-${id}`, + name: 'Owner', + displayName: 'Owner', + image: null, + bio: null, + deletedAt: undefined, + deactivatedAt: undefined, + } +} + +function makeVersion(id: string) { + return { + _id: id, + _creationTime: 1, + version: '1.0.0', + createdAt: 1, + changelog: '', + changelogSource: 'user', + parsed: {}, + } +} diff --git a/convex/skills.ts b/convex/skills.ts index 4817c9fd84..aab3833066 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -2252,6 +2252,7 @@ export const listPublicPage = query({ args: { cursor: v.optional(v.string()), limit: v.optional(v.number()), + nonSuspiciousOnly: v.optional(v.boolean()), sort: v.optional( v.union( v.literal('updated'), @@ -2275,7 +2276,10 @@ export const listPublicPage = query({ .paginate({ cursor: args.cursor ?? null, numItems: limit }) const skills = page.filter((skill) => !skill.softDeletedAt) - const items = await buildPublicSkillEntries(ctx, skills) + const items = await buildPublicSkillEntries( + ctx, + filterPublicSkillPage(skills, { nonSuspiciousOnly: args.nonSuspiciousOnly }), + ) return { items, nextCursor: isDone ? null : continueCursor } } @@ -2291,7 +2295,10 @@ export const listPublicPage = query({ if (skills.length >= limit) break } - const items = await buildPublicSkillEntries(ctx, skills) + const items = await buildPublicSkillEntries( + ctx, + filterPublicSkillPage(skills, { nonSuspiciousOnly: args.nonSuspiciousOnly }), + ) return { items, nextCursor: null } } @@ -2302,7 +2309,10 @@ export const listPublicPage = query({ .order('desc') .paginate({ cursor: args.cursor ?? null, numItems: limit }) - const filtered = page.filter((skill) => !skill.softDeletedAt) + const filtered = filterPublicSkillPage( + page.filter((skill) => !skill.softDeletedAt), + { nonSuspiciousOnly: args.nonSuspiciousOnly }, + ) const items = await buildPublicSkillEntries(ctx, filtered) return { items, nextCursor: isDone ? null : continueCursor } }, diff --git a/docs/api.md b/docs/api.md index a303b0692f..98cc961e71 100644 --- a/docs/api.md +++ b/docs/api.md @@ -59,12 +59,15 @@ Client handling: Public read: - `GET /api/v1/search?q=...` + - Optional filters: `highlightedOnly=true`, `nonSuspiciousOnly=true` - `GET /api/v1/skills?limit=&cursor=&sort=` - `sort`: `updated` (default), `downloads`, `stars` (`rating`), `installsCurrent` (`installs`), `installsAllTime`, `trending` + - Optional filter: `nonSuspiciousOnly=true` - `GET /api/v1/skills/{slug}` - `GET /api/v1/skills/{slug}/moderation` - `GET /api/v1/skills/{slug}/versions?limit=&cursor=` - `GET /api/v1/skills/{slug}/versions/{version}` +- `GET /api/v1/skills/{slug}/scan?version=&tag=` - `GET /api/v1/skills/{slug}/file?path=&version=&tag=` - `GET /api/v1/resolve?slug=&hash=` - `GET /api/v1/download?slug=&version=&tag=` diff --git a/docs/http-api.md b/docs/http-api.md index 60414ca6cd..ed6fd9a94e 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -74,6 +74,7 @@ Query params: - `q` (required): query string - `limit` (optional): integer - `highlightedOnly` (optional): `true` to filter to highlighted skills +- `nonSuspiciousOnly` (optional): `true` to hide suspicious (`flagged.suspicious`) skills Response: @@ -92,6 +93,7 @@ Query params: - `limit` (optional): integer (1–200) - `cursor` (optional): pagination cursor (only for `sort=updated`) - `sort` (optional): `updated` (default), `downloads`, `stars` (alias: `rating`), `installsCurrent` (alias: `installs`), `installsAllTime`, `trending` +- `nonSuspiciousOnly` (optional): `true` to hide suspicious (`flagged.suspicious`) skills Notes: @@ -145,6 +147,23 @@ Query params: Returns version metadata + files list. +- `version.security` includes normalized scan verification status and scanner details + (VirusTotal + LLM), when available. + +### `GET /api/v1/skills/{slug}/scan` + +Returns security scan verification details for a skill version. + +Query params: + +- `version` (optional): specific version string. +- `tag` (optional): resolve a tagged version (for example `latest`). + +Notes: + +- If neither `version` nor `tag` is provided, uses the latest version. +- Includes normalized verification status plus scanner-specific details. + ### `GET /api/v1/skills/{slug}/file` Returns raw text content. diff --git a/public/api/v1/openapi.json b/public/api/v1/openapi.json index f7c4ddbdb5..4832b863d0 100644 --- a/public/api/v1/openapi.json +++ b/public/api/v1/openapi.json @@ -118,6 +118,19 @@ "nextCursor": { "type": ["string", "null"] } } }, + "SecuritySnapshot": { + "type": "object", + "properties": { + "status": { "type": "string", "enum": ["clean", "suspicious", "malicious", "pending", "error"] }, + "hasWarnings": { "type": "boolean" }, + "checkedAt": { "type": ["number", "null"] }, + "model": { "type": ["string", "null"] }, + "isVerified": { "type": "boolean" }, + "sha256hash": { "type": ["string", "null"] }, + "virustotalUrl": { "type": ["string", "null"] }, + "scanners": { "type": "object", "additionalProperties": true } + } + }, "SkillVersionResponse": { "type": "object", "properties": { @@ -146,8 +159,42 @@ "contentType": { "type": ["string", "null"] } } } - } + }, + "security": { "$ref": "#/components/schemas/SecuritySnapshot" } + } + } + } + }, + "SkillScanResponse": { + "type": "object", + "properties": { + "skill": { + "type": "object", + "properties": { + "slug": { "type": "string" }, + "displayName": { "type": "string" } + } + }, + "version": { + "type": "object", + "properties": { + "version": { "type": "string" }, + "createdAt": { "type": "number" }, + "changelogSource": { "type": ["string", "null"], "enum": ["auto", "user", null] } + } + }, + "moderation": { + "type": ["object", "null"], + "properties": { + "isPendingScan": { "type": "boolean" }, + "isMalwareBlocked": { "type": "boolean" }, + "isSuspicious": { "type": "boolean" }, + "isHiddenByMod": { "type": "boolean" }, + "isRemoved": { "type": "boolean" } } + }, + "security": { + "anyOf": [{ "$ref": "#/components/schemas/SecuritySnapshot" }, { "type": "null" }] } } }, @@ -192,7 +239,8 @@ "parameters": [ { "name": "q", "in": "query", "required": true, "schema": { "type": "string" } }, { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } }, - { "name": "highlightedOnly", "in": "query", "required": false, "schema": { "type": "boolean" } } + { "name": "highlightedOnly", "in": "query", "required": false, "schema": { "type": "boolean" } }, + { "name": "nonSuspiciousOnly", "in": "query", "required": false, "schema": { "type": "boolean" } } ], "responses": { "200": { @@ -220,7 +268,8 @@ "summary": "List skills", "parameters": [ { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } }, - { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" } } + { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" } }, + { "name": "nonSuspiciousOnly", "in": "query", "required": false, "schema": { "type": "boolean" } } ], "responses": { "200": { "description": "Skills", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SkillListResponse" } } } } @@ -338,6 +387,20 @@ } } }, + "/api/v1/skills/{slug}/scan": { + "get": { + "summary": "Get security scan details", + "parameters": [ + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "version", "in": "query", "required": false, "schema": { "type": "string" } }, + { "name": "tag", "in": "query", "required": false, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Scan results", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SkillScanResponse" } } } }, + "404": { "description": "Not found" } + } + } + }, "/api/v1/skills/{slug}/file": { "get": { "summary": "Fetch raw file", From 2adc080fd42c16a46ad474e035be617ae194e46f Mon Sep 17 00:00:00 2001 From: VAC Date: Thu, 26 Feb 2026 13:15:31 -0500 Subject: [PATCH 2/6] fix(api): dedupe bool query parsing and backfill trending non-suspicious --- convex/httpApi.ts | 7 +- convex/lib/httpUtils.test.ts | 18 ++++++ convex/lib/httpUtils.ts | 5 ++ convex/skills.listPublicPage.test.ts | 95 ++++++++++++++++++++++++++++ convex/skills.ts | 11 ++-- 5 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 convex/lib/httpUtils.test.ts create mode 100644 convex/lib/httpUtils.ts diff --git a/convex/httpApi.ts b/convex/httpApi.ts index 0e159b7e17..1b9eb501b3 100644 --- a/convex/httpApi.ts +++ b/convex/httpApi.ts @@ -12,6 +12,7 @@ import type { ActionCtx } from './_generated/server' import { httpAction } from './functions' import { requireApiTokenUser } from './lib/apiTokenAuth' import { corsHeaders, mergeHeaders } from './lib/httpHeaders' +import { parseBooleanQueryParam } from './lib/httpUtils' import { publishVersionForUser } from './skills' type SearchSkillEntry = { @@ -278,12 +279,6 @@ function toOptionalNumber(value: string | null) { return Number.isFinite(parsed) ? parsed : undefined } -function parseBooleanQueryParam(value: string | null) { - if (!value) return false - const normalized = value.trim().toLowerCase() - return normalized === 'true' || normalized === '1' -} - function parsePublishBody(body: unknown) { const parsed = parseArk(CliPublishRequestSchema, body, 'Publish payload') if (parsed.files.length === 0) throw new Error('files required') diff --git a/convex/lib/httpUtils.test.ts b/convex/lib/httpUtils.test.ts new file mode 100644 index 0000000000..2b203bd33f --- /dev/null +++ b/convex/lib/httpUtils.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { parseBooleanQueryParam } from './httpUtils' + +describe('parseBooleanQueryParam', () => { + it('returns true for true-like values', () => { + expect(parseBooleanQueryParam('true')).toBe(true) + expect(parseBooleanQueryParam('1')).toBe(true) + expect(parseBooleanQueryParam(' TRUE ')).toBe(true) + }) + + it('returns false for missing and false-like values', () => { + expect(parseBooleanQueryParam(null)).toBe(false) + expect(parseBooleanQueryParam('')).toBe(false) + expect(parseBooleanQueryParam('false')).toBe(false) + expect(parseBooleanQueryParam('0')).toBe(false) + expect(parseBooleanQueryParam('yes')).toBe(false) + }) +}) diff --git a/convex/lib/httpUtils.ts b/convex/lib/httpUtils.ts new file mode 100644 index 0000000000..cff676521c --- /dev/null +++ b/convex/lib/httpUtils.ts @@ -0,0 +1,5 @@ +export function parseBooleanQueryParam(value: string | null) { + if (!value) return false + const normalized = value.trim().toLowerCase() + return normalized === 'true' || normalized === '1' +} diff --git a/convex/skills.listPublicPage.test.ts b/convex/skills.listPublicPage.test.ts index 991e59322c..5ef01ccff2 100644 --- a/convex/skills.listPublicPage.test.ts +++ b/convex/skills.listPublicPage.test.ts @@ -84,6 +84,45 @@ describe('skills.listPublicPage', () => { expect(result.items).toHaveLength(2) expect(result.items.map((entry) => entry.skill.slug)).toEqual(['clean', 'suspicious']) }) + + it('backfills clean trending skills when nonSuspiciousOnly is enabled', async () => { + const suspicious1 = makeSkill( + 'skills:suspicious1', + 'suspicious-1', + 'users:1', + 'skillVersions:1', + ['flagged.suspicious'], + ) + const suspicious2 = makeSkill( + 'skills:suspicious2', + 'suspicious-2', + 'users:2', + 'skillVersions:2', + ['flagged.suspicious'], + ) + const clean = makeSkill('skills:clean', 'clean', 'users:3', 'skillVersions:3') + + const ctx = makeTrendingCtx({ + leaderboardItems: [suspicious1._id, suspicious2._id, clean._id], + skills: [suspicious1, suspicious2, clean], + users: [makeUser('users:1'), makeUser('users:2'), makeUser('users:3')], + versions: [ + makeVersion('skillVersions:1'), + makeVersion('skillVersions:2'), + makeVersion('skillVersions:3'), + ], + }) + + const result = await listPublicPageHandler(ctx, { + sort: 'trending', + limit: 1, + nonSuspiciousOnly: true, + }) + + expect(result.items).toHaveLength(1) + expect(result.items[0]?.skill.slug).toBe('clean') + expect(result.nextCursor).toBeNull() + }) }) function makeCtx({ @@ -122,6 +161,62 @@ function makeCtx({ } } +function makeTrendingCtx({ + leaderboardItems, + skills, + users, + versions, +}: { + leaderboardItems: string[] + skills: Array> + users: Array> + versions: Array> +}) { + const skillMap = new Map(skills.map((skill) => [skill._id, skill])) + const userMap = new Map(users.map((user) => [user._id, user])) + const versionMap = new Map(versions.map((version) => [version._id, version])) + + return { + db: { + query: vi.fn((table: string) => { + if (table !== 'skillLeaderboards') throw new Error(`unexpected table ${table}`) + return { + withIndex: vi.fn((index: string, _builder: unknown) => { + if (index !== 'by_kind') throw new Error(`unexpected index ${index}`) + return { + order: vi.fn((dir: string) => { + if (dir !== 'desc') throw new Error(`unexpected order ${dir}`) + return { + take: vi.fn().mockResolvedValue([ + { + kind: 'trending', + generatedAt: 1, + rangeStartDay: 1, + rangeEndDay: 1, + items: leaderboardItems.map((skillId, idx) => ({ + skillId, + score: 100 - idx, + installs: 10 - idx, + downloads: 20 - idx, + })), + }, + ]), + } + }), + } + }), + } + }), + get: vi.fn(async (id: string) => { + if (id.startsWith('skills:')) return skillMap.get(id) ?? null + if (id.startsWith('users:')) return userMap.get(id) ?? null + if (id.startsWith('skillVersions:')) return versionMap.get(id) ?? null + return null + }), + }, + } +} + function makeSkill( id: string, slug: string, diff --git a/convex/skills.ts b/convex/skills.ts index aab3833066..c4d250d755 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -2285,20 +2285,21 @@ export const listPublicPage = query({ } if (sort === 'trending') { - const entries = await getTrendingEntries(ctx, limit) + const entries = await getTrendingEntries( + ctx, + args.nonSuspiciousOnly ? MAX_PUBLIC_LIST_LIMIT : limit, + ) const skills: Doc<'skills'>[] = [] for (const entry of entries) { const skill = await ctx.db.get(entry.skillId) if (!skill || skill.softDeletedAt) continue + if (args.nonSuspiciousOnly && isSkillSuspicious(skill)) continue skills.push(skill) if (skills.length >= limit) break } - const items = await buildPublicSkillEntries( - ctx, - filterPublicSkillPage(skills, { nonSuspiciousOnly: args.nonSuspiciousOnly }), - ) + const items = await buildPublicSkillEntries(ctx, skills) return { items, nextCursor: null } } From de430faec7e054c40826821ee24a876b6c124f3a Mon Sep 17 00:00:00 2001 From: VAC Date: Thu, 26 Feb 2026 13:30:50 -0500 Subject: [PATCH 3/6] fix(api): preserve llm dimension warnings in security snapshot --- convex/httpApiV1.handlers.test.ts | 41 +++++++++++++++++++++++++++++++ convex/httpApiV1/skillsV1.ts | 14 ++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index 0055eef4b4..b6c381085c 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -977,6 +977,47 @@ describe('httpApiV1 handlers', () => { expect(json.version.security.virustotalUrl).toContain('virustotal.com/gui/file/') }) + it('keeps hasWarnings true when llm dimensions include non-ok ratings', 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 && 'version' in args) { + return { + version: '1.0.0', + createdAt: 1, + changelog: 'c', + changelogSource: 'auto', + sha256hash: 'a'.repeat(64), + llmAnalysis: { + status: 'completed', + verdict: 'benign', + checkedAt: 123, + dimensions: [ + { + name: 'scope_alignment', + rating: 'warn', + rationale: 'broad install footprint', + evidence: '', + }, + ], + }, + files: [], + } + } + return null + }) + const runMutation = vi.fn().mockResolvedValue(okRate()) + const response = await __handlers.skillsGetRouterV1Handler( + makeCtx({ runQuery, runMutation }), + new Request('https://example.com/api/v1/skills/demo/versions/1.0.0'), + ) + expect(response.status).toBe(200) + const json = await response.json() + expect(json.version.security.status).toBe('clean') + expect(json.version.security.hasWarnings).toBe(true) + }) + it('returns scan payload for latest version', 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 d6d6499064..1e96f43c8c 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -240,6 +240,17 @@ function mergeSecurityStatuses(statuses: NormalizedSecurityStatus[]) { ) } +function hasLlmDimensionWarnings( + dimensions: NonNullable['llmAnalysis']>['dimensions'] | undefined, +) { + if (!Array.isArray(dimensions)) return false + return dimensions.some((dimension) => { + if (!dimension || typeof dimension !== 'object') return false + const rating = (dimension as { rating?: unknown }).rating + return typeof rating === 'string' && rating !== 'ok' + }) +} + function buildSkillSecuritySnapshot(version: Doc<'skillVersions'>): SkillSecuritySnapshot | null { const sha256hash = version.sha256hash ?? null const vt = version.vtAnalysis @@ -255,7 +266,8 @@ function buildSkillSecuritySnapshot(version: Doc<'skillVersions'>): SkillSecurit if (llmStatus) statuses.push(llmStatus) if (statuses.length === 0 && sha256hash) statuses.push('pending') const status = mergeSecurityStatuses(statuses) - const hasWarnings = status === 'suspicious' || status === 'malicious' + const hasWarnings = + status === 'suspicious' || status === 'malicious' || hasLlmDimensionWarnings(llm?.dimensions) const checkedAtCandidates = [vt?.checkedAt, llm?.checkedAt].filter( (value): value is number => typeof value === 'number', From 2e7ce157c3c15ca36c0b09ea5ea5e2b8d9e7c393 Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:58:33 -0400 Subject: [PATCH 4/6] fix(api): clarify scan result semantics --- convex/httpApi.handlers.test.ts | 14 +++++ convex/httpApiV1.handlers.test.ts | 87 ++++++++++++++++++++++++++++++- convex/httpApiV1/skillsV1.ts | 5 +- docs/api.md | 2 + docs/http-api.md | 3 ++ public/api/v1/openapi.json | 8 +-- 6 files changed, 113 insertions(+), 6 deletions(-) diff --git a/convex/httpApi.handlers.test.ts b/convex/httpApi.handlers.test.ts index 829d12a664..703d2c78c6 100644 --- a/convex/httpApi.handlers.test.ts +++ b/convex/httpApi.handlers.test.ts @@ -98,6 +98,20 @@ describe('httpApi handlers', () => { }) }) + it('searchSkillsHttp forwards legacy nonSuspicious alias', async () => { + const runAction = vi.fn().mockResolvedValue([]) + await __handlers.searchSkillsHandler( + makeCtx({ runAction }), + new Request('https://example.com/api/search?q=test&nonSuspicious=1'), + ) + expect(runAction).toHaveBeenCalledWith(expect.anything(), { + query: 'test', + limit: undefined, + highlightedOnly: undefined, + nonSuspiciousOnly: true, + }) + }) + it('getSkillHttp validates slug', async () => { const response = await __handlers.getSkillHandler( makeCtx({ runQuery: vi.fn() }), diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index b6c381085c..6a83f6e3d7 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -247,6 +247,24 @@ describe('httpApiV1 handlers', () => { }) }) + it('search forwards legacy nonSuspicious alias', async () => { + const runAction = vi.fn().mockResolvedValue([]) + const runMutation = vi.fn().mockResolvedValue(okRate()) + const response = await __handlers.searchSkillsV1Handler( + makeCtx({ runAction, runMutation }), + new Request('https://example.com/api/v1/search?q=test&nonSuspicious=1'), + ) + if (response.status !== 200) { + throw new Error(await response.text()) + } + expect(runAction).toHaveBeenCalledWith(expect.anything(), { + query: 'test', + limit: undefined, + highlightedOnly: undefined, + nonSuspiciousOnly: true, + }) + }) + it('search rate limits', async () => { const runMutation = vi.fn().mockResolvedValue(blockedRate()) const response = await __handlers.searchSkillsV1Handler( @@ -589,6 +607,22 @@ describe('httpApiV1 handlers', () => { expect(response.status).toBe(200) }) + it('lists skills forwards legacy nonSuspicious alias', async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ('sort' in args || 'cursor' in args || 'limit' in args) { + expect(args.nonSuspiciousOnly).toBe(true) + return { items: [], nextCursor: null } + } + return null + }) + const runMutation = vi.fn().mockResolvedValue(okRate()) + const response = await __handlers.listSkillsV1Handler( + makeCtx({ runQuery, runMutation }), + new Request('https://example.com/api/v1/skills?nonSuspicious=1'), + ) + 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()) @@ -1071,11 +1105,62 @@ describe('httpApiV1 handlers', () => { expect(response.status).toBe(200) const json = await response.json() expect(json.security.status).toBe('suspicious') - expect(json.security.isVerified).toBe(true) + expect(json.security.hasScanResult).toBe(true) expect(json.security.scanners.llm.verdict).toBe('suspicious') expect(json.moderation.isSuspicious).toBe(true) }) + it('treats completed llm analysis without verdict as error', async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ('slug' in args) { + return { + skill: { + _id: 'skills:1', + slug: 'demo', + displayName: 'Demo', + summary: 's', + tags: { latest: 'versions:1' }, + stats: {}, + createdAt: 1, + updatedAt: 2, + }, + latestVersion: { + version: '1.0.0', + createdAt: 1, + changelog: 'c', + changelogSource: 'auto', + sha256hash: 'c'.repeat(64), + llmAnalysis: { + status: 'completed', + summary: 'missing verdict', + checkedAt: 222, + }, + files: [], + }, + owner: { _id: 'users:1', handle: 'owner', displayName: 'Owner' }, + moderationInfo: { + isPendingScan: false, + isMalwareBlocked: false, + isSuspicious: false, + isHiddenByMod: false, + isRemoved: false, + }, + } + } + return null + }) + const runMutation = vi.fn().mockResolvedValue(okRate()) + const response = await __handlers.skillsGetRouterV1Handler( + makeCtx({ runQuery, runMutation }), + new Request('https://example.com/api/v1/skills/demo/scan'), + ) + expect(response.status).toBe(200) + const json = await response.json() + expect(json.security.status).toBe('error') + expect(json.security.hasScanResult).toBe(false) + expect(json.security.scanners.llm.normalizedStatus).toBe('error') + }) + it('returns raw file content', async () => { const version = { version: '1.0.0', diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index 1e96f43c8c..4ec8451b83 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -174,7 +174,7 @@ type SkillSecuritySnapshot = { hasWarnings: boolean checkedAt: number | null model: string | null - isVerified: boolean + hasScanResult: boolean sha256hash: string | null virustotalUrl: string | null scanners: { @@ -221,6 +221,7 @@ function normalizeSecurityStatus(value: string | null | undefined): NormalizedSe return 'malicious' case 'error': case 'failed': + case 'completed': return 'error' case 'pending': case 'loading': @@ -279,7 +280,7 @@ function buildSkillSecuritySnapshot(version: Doc<'skillVersions'>): SkillSecurit hasWarnings, checkedAt, model: llm?.model ?? null, - isVerified: status === 'clean' || status === 'suspicious' || status === 'malicious', + hasScanResult: status === 'clean' || status === 'suspicious' || status === 'malicious', sha256hash, virustotalUrl: sha256hash ? `https://www.virustotal.com/gui/file/${sha256hash}` : null, scanners: { diff --git a/docs/api.md b/docs/api.md index 98cc961e71..76c59c555d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -60,9 +60,11 @@ Public read: - `GET /api/v1/search?q=...` - Optional filters: `highlightedOnly=true`, `nonSuspiciousOnly=true` + - Legacy alias: `nonSuspicious=true` - `GET /api/v1/skills?limit=&cursor=&sort=` - `sort`: `updated` (default), `downloads`, `stars` (`rating`), `installsCurrent` (`installs`), `installsAllTime`, `trending` - Optional filter: `nonSuspiciousOnly=true` + - Legacy alias: `nonSuspicious=true` - `GET /api/v1/skills/{slug}` - `GET /api/v1/skills/{slug}/moderation` - `GET /api/v1/skills/{slug}/versions?limit=&cursor=` diff --git a/docs/http-api.md b/docs/http-api.md index ed6fd9a94e..1ad9f2ba7b 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -75,6 +75,7 @@ Query params: - `limit` (optional): integer - `highlightedOnly` (optional): `true` to filter to highlighted skills - `nonSuspiciousOnly` (optional): `true` to hide suspicious (`flagged.suspicious`) skills +- `nonSuspicious` (optional): legacy alias for `nonSuspiciousOnly` Response: @@ -94,6 +95,7 @@ Query params: - `cursor` (optional): pagination cursor (only for `sort=updated`) - `sort` (optional): `updated` (default), `downloads`, `stars` (alias: `rating`), `installsCurrent` (alias: `installs`), `installsAllTime`, `trending` - `nonSuspiciousOnly` (optional): `true` to hide suspicious (`flagged.suspicious`) skills +- `nonSuspicious` (optional): legacy alias for `nonSuspiciousOnly` Notes: @@ -163,6 +165,7 @@ Notes: - If neither `version` nor `tag` is provided, uses the latest version. - Includes normalized verification status plus scanner-specific details. +- `security.hasScanResult` is `true` only when a scanner produced a definitive verdict (`clean`, `suspicious`, or `malicious`). ### `GET /api/v1/skills/{slug}/file` diff --git a/public/api/v1/openapi.json b/public/api/v1/openapi.json index 4832b863d0..53649ec9bb 100644 --- a/public/api/v1/openapi.json +++ b/public/api/v1/openapi.json @@ -125,7 +125,7 @@ "hasWarnings": { "type": "boolean" }, "checkedAt": { "type": ["number", "null"] }, "model": { "type": ["string", "null"] }, - "isVerified": { "type": "boolean" }, + "hasScanResult": { "type": "boolean" }, "sha256hash": { "type": ["string", "null"] }, "virustotalUrl": { "type": ["string", "null"] }, "scanners": { "type": "object", "additionalProperties": true } @@ -240,7 +240,8 @@ { "name": "q", "in": "query", "required": true, "schema": { "type": "string" } }, { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } }, { "name": "highlightedOnly", "in": "query", "required": false, "schema": { "type": "boolean" } }, - { "name": "nonSuspiciousOnly", "in": "query", "required": false, "schema": { "type": "boolean" } } + { "name": "nonSuspiciousOnly", "in": "query", "required": false, "schema": { "type": "boolean" } }, + { "name": "nonSuspicious", "in": "query", "required": false, "schema": { "type": "boolean" }, "description": "Legacy alias for nonSuspiciousOnly." } ], "responses": { "200": { @@ -269,7 +270,8 @@ "parameters": [ { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } }, { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" } }, - { "name": "nonSuspiciousOnly", "in": "query", "required": false, "schema": { "type": "boolean" } } + { "name": "nonSuspiciousOnly", "in": "query", "required": false, "schema": { "type": "boolean" } }, + { "name": "nonSuspicious", "in": "query", "required": false, "schema": { "type": "boolean" }, "description": "Legacy alias for nonSuspiciousOnly." } ], "responses": { "200": { "description": "Skills", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SkillListResponse" } } } } From 387160d81f98f7f4f2dac88f0f8393e040a4f5af Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:23:50 -0400 Subject: [PATCH 5/6] fix(api): clarify scan version context --- convex/httpApiV1.handlers.test.ts | 206 ++++++++++++++++++++++++++++++ convex/httpApiV1/skillsV1.ts | 20 ++- docs/http-api.md | 2 + public/api/v1/openapi.json | 9 ++ 4 files changed, 236 insertions(+), 1 deletion(-) diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index 6a83f6e3d7..4e52a296c4 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -1107,6 +1107,12 @@ describe('httpApiV1 handlers', () => { expect(json.security.status).toBe('suspicious') expect(json.security.hasScanResult).toBe(true) expect(json.security.scanners.llm.verdict).toBe('suspicious') + expect(json.moderation.scope).toBe('skill') + expect(json.moderation.sourceVersion).toEqual({ + version: '1.0.0', + createdAt: 1, + }) + expect(json.moderation.matchesRequestedVersion).toBe(true) expect(json.moderation.isSuspicious).toBe(true) }) @@ -1161,6 +1167,206 @@ describe('httpApiV1 handlers', () => { expect(json.security.scanners.llm.normalizedStatus).toBe('error') }) + it('keeps hasScanResult true when one scanner returns a definitive verdict', async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ('slug' in args) { + return { + skill: { + _id: 'skills:1', + slug: 'demo', + displayName: 'Demo', + summary: 's', + tags: { latest: 'versions:2' }, + stats: {}, + createdAt: 1, + updatedAt: 2, + }, + latestVersion: { + _id: 'skillVersions:2', + version: '2.0.0', + createdAt: 2, + changelog: 'c', + changelogSource: 'auto', + sha256hash: 'd'.repeat(64), + vtAnalysis: { + status: 'clean', + checkedAt: 111, + }, + llmAnalysis: { + status: 'error', + summary: 'scanner failed', + checkedAt: 222, + }, + files: [], + }, + owner: { _id: 'users:1', handle: 'owner', displayName: 'Owner' }, + moderationInfo: { + isPendingScan: false, + isMalwareBlocked: false, + isSuspicious: false, + isHiddenByMod: false, + isRemoved: false, + }, + } + } + return null + }) + const runMutation = vi.fn().mockResolvedValue(okRate()) + const response = await __handlers.skillsGetRouterV1Handler( + makeCtx({ runQuery, runMutation }), + new Request('https://example.com/api/v1/skills/demo/scan'), + ) + expect(response.status).toBe(200) + const json = await response.json() + expect(json.security.status).toBe('error') + expect(json.security.hasScanResult).toBe(true) + expect(json.security.scanners.vt.normalizedStatus).toBe('clean') + expect(json.security.scanners.llm.normalizedStatus).toBe('error') + }) + + it('marks moderation as a latest-version snapshot when querying a historical version', async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ('slug' in args) { + return { + skill: { + _id: 'skills:1', + slug: 'demo', + displayName: 'Demo', + summary: 's', + tags: { latest: 'skillVersions:2', old: 'skillVersions:1' }, + stats: {}, + createdAt: 1, + updatedAt: 2, + }, + latestVersion: { + _id: 'skillVersions:2', + version: '2.0.0', + createdAt: 2, + changelog: 'c2', + changelogSource: 'auto', + sha256hash: 'e'.repeat(64), + vtAnalysis: { + status: 'clean', + checkedAt: 222, + }, + files: [], + }, + owner: { _id: 'users:1', handle: 'owner', displayName: 'Owner' }, + moderationInfo: { + isPendingScan: false, + isMalwareBlocked: false, + isSuspicious: false, + isHiddenByMod: false, + isRemoved: false, + }, + } + } + if ('skillId' in args && 'version' in args) { + return { + _id: 'skillVersions:1', + version: '1.0.0', + createdAt: 1, + changelog: 'c1', + changelogSource: 'auto', + sha256hash: 'f'.repeat(64), + llmAnalysis: { + status: 'completed', + verdict: 'suspicious', + checkedAt: 123, + }, + files: [], + } + } + return null + }) + const runMutation = vi.fn().mockResolvedValue(okRate()) + const response = await __handlers.skillsGetRouterV1Handler( + makeCtx({ runQuery, runMutation }), + new Request('https://example.com/api/v1/skills/demo/scan?version=1.0.0'), + ) + expect(response.status).toBe(200) + const json = await response.json() + expect(json.version.version).toBe('1.0.0') + expect(json.security.status).toBe('suspicious') + expect(json.moderation.scope).toBe('skill') + expect(json.moderation.sourceVersion).toEqual({ + version: '2.0.0', + createdAt: 2, + }) + expect(json.moderation.matchesRequestedVersion).toBe(false) + expect(json.moderation.isSuspicious).toBe(false) + }) + + it('resolves scan by tag and reports moderation context against latest version', async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ('slug' in args) { + return { + skill: { + _id: 'skills:1', + slug: 'demo', + displayName: 'Demo', + summary: 's', + tags: { latest: 'skillVersions:2', old: 'skillVersions:1' }, + stats: {}, + createdAt: 1, + updatedAt: 2, + }, + latestVersion: { + _id: 'skillVersions:2', + version: '2.0.0', + createdAt: 2, + changelog: 'c2', + changelogSource: 'auto', + sha256hash: '1'.repeat(64), + vtAnalysis: { + status: 'clean', + checkedAt: 222, + }, + files: [], + }, + owner: { _id: 'users:1', handle: 'owner', displayName: 'Owner' }, + moderationInfo: { + isPendingScan: false, + isMalwareBlocked: false, + isSuspicious: false, + isHiddenByMod: false, + isRemoved: false, + }, + } + } + if ('versionId' in args) { + return { + _id: 'skillVersions:1', + version: '1.0.0', + createdAt: 1, + changelog: 'c1', + changelogSource: 'auto', + sha256hash: '2'.repeat(64), + vtAnalysis: { + status: 'malicious', + checkedAt: 123, + }, + files: [], + } + } + return null + }) + const runMutation = vi.fn().mockResolvedValue(okRate()) + const response = await __handlers.skillsGetRouterV1Handler( + makeCtx({ runQuery, runMutation }), + new Request('https://example.com/api/v1/skills/demo/scan?tag=old'), + ) + expect(response.status).toBe(200) + const json = await response.json() + expect(json.version.version).toBe('1.0.0') + expect(json.security.status).toBe('malicious') + expect(json.moderation.sourceVersion).toEqual({ + version: '2.0.0', + createdAt: 2, + }) + expect(json.moderation.matchesRequestedVersion).toBe(false) + }) + it('returns raw file content', async () => { const version = { version: '1.0.0', diff --git a/convex/httpApiV1/skillsV1.ts b/convex/httpApiV1/skillsV1.ts index 4ec8451b83..13961c8910 100644 --- a/convex/httpApiV1/skillsV1.ts +++ b/convex/httpApiV1/skillsV1.ts @@ -201,6 +201,12 @@ type SkillSecuritySnapshot = { } } +function isDefinitiveSecurityStatus( + status: NormalizedSecurityStatus | null | undefined, +): status is 'clean' | 'suspicious' | 'malicious' { + return status === 'clean' || status === 'suspicious' || status === 'malicious' +} + const SECURITY_STATUS_PRIORITY: Record = { clean: 0, error: 1, @@ -267,6 +273,7 @@ function buildSkillSecuritySnapshot(version: Doc<'skillVersions'>): SkillSecurit if (llmStatus) statuses.push(llmStatus) if (statuses.length === 0 && sha256hash) statuses.push('pending') const status = mergeSecurityStatuses(statuses) + const hasScanResult = isDefinitiveSecurityStatus(vtStatus) || isDefinitiveSecurityStatus(llmStatus) const hasWarnings = status === 'suspicious' || status === 'malicious' || hasLlmDimensionWarnings(llm?.dimensions) @@ -280,7 +287,7 @@ function buildSkillSecuritySnapshot(version: Doc<'skillVersions'>): SkillSecurit hasWarnings, checkedAt, model: llm?.model ?? null, - hasScanResult: status === 'clean' || status === 'suspicious' || status === 'malicious', + hasScanResult, sha256hash, virustotalUrl: sha256hash ? `https://www.virustotal.com/gui/file/${sha256hash}` : null, scanners: { @@ -734,6 +741,9 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) if (version.softDeletedAt) return text('Version not available', 410, rate.headers) const security = buildSkillSecuritySnapshot(version) + const moderationMatchesRequestedVersion = Boolean( + result.latestVersion && result.latestVersion._id === version._id, + ) return json( { @@ -748,6 +758,14 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request) }, moderation: result.moderationInfo ? { + scope: 'skill', + sourceVersion: result.latestVersion + ? { + version: result.latestVersion.version, + createdAt: result.latestVersion.createdAt, + } + : null, + matchesRequestedVersion: moderationMatchesRequestedVersion, isPendingScan: result.moderationInfo.isPendingScan ?? false, isMalwareBlocked: result.moderationInfo.isMalwareBlocked ?? false, isSuspicious: result.moderationInfo.isSuspicious ?? false, diff --git a/docs/http-api.md b/docs/http-api.md index 1ad9f2ba7b..021ea69abf 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -166,6 +166,8 @@ Notes: - If neither `version` nor `tag` is provided, uses the latest version. - Includes normalized verification status plus scanner-specific details. - `security.hasScanResult` is `true` only when a scanner produced a definitive verdict (`clean`, `suspicious`, or `malicious`). +- `moderation` is a current skill-level moderation snapshot derived from the latest version. +- When querying a historical version, check `moderation.matchesRequestedVersion` and `moderation.sourceVersion` before treating `moderation` and `security` as the same version context. ### `GET /api/v1/skills/{slug}/file` diff --git a/public/api/v1/openapi.json b/public/api/v1/openapi.json index 53649ec9bb..c62ff4a894 100644 --- a/public/api/v1/openapi.json +++ b/public/api/v1/openapi.json @@ -186,6 +186,15 @@ "moderation": { "type": ["object", "null"], "properties": { + "scope": { "type": "string", "enum": ["skill"] }, + "sourceVersion": { + "type": ["object", "null"], + "properties": { + "version": { "type": "string" }, + "createdAt": { "type": "number" } + } + }, + "matchesRequestedVersion": { "type": "boolean" }, "isPendingScan": { "type": "boolean" }, "isMalwareBlocked": { "type": "boolean" }, "isSuspicious": { "type": "boolean" }, From 4435a778716e0bf8db86337dd5520fef4ba13edd Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Thu, 12 Mar 2026 13:41:46 -0400 Subject: [PATCH 6/6] docs(api): clarify filtered pagination behavior --- docs/api.md | 2 ++ docs/http-api.md | 4 +++- public/api/v1/openapi.json | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/api.md b/docs/api.md index 76c59c555d..2f80dad349 100644 --- a/docs/api.md +++ b/docs/api.md @@ -63,8 +63,10 @@ Public read: - Legacy alias: `nonSuspicious=true` - `GET /api/v1/skills?limit=&cursor=&sort=` - `sort`: `updated` (default), `downloads`, `stars` (`rating`), `installsCurrent` (`installs`), `installsAllTime`, `trending` + - `cursor` applies to non-`trending` sorts - Optional filter: `nonSuspiciousOnly=true` - Legacy alias: `nonSuspicious=true` + - With `nonSuspiciousOnly=true`, cursor-based pages may contain fewer than `limit` items; use `nextCursor` to continue. - `GET /api/v1/skills/{slug}` - `GET /api/v1/skills/{slug}/moderation` - `GET /api/v1/skills/{slug}/versions?limit=&cursor=` diff --git a/docs/http-api.md b/docs/http-api.md index 021ea69abf..f405964944 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -92,7 +92,7 @@ Notes: Query params: - `limit` (optional): integer (1–200) -- `cursor` (optional): pagination cursor (only for `sort=updated`) +- `cursor` (optional): pagination cursor for any non-`trending` sort - `sort` (optional): `updated` (default), `downloads`, `stars` (alias: `rating`), `installsCurrent` (alias: `installs`), `installsAllTime`, `trending` - `nonSuspiciousOnly` (optional): `true` to hide suspicious (`flagged.suspicious`) skills - `nonSuspicious` (optional): legacy alias for `nonSuspiciousOnly` @@ -100,6 +100,8 @@ Query params: Notes: - `trending` ranks by installs in the last 7 days (telemetry-based). +- When `nonSuspiciousOnly=true`, cursor-based sorts may return fewer than `limit` items on a page because suspicious skills are filtered after page retrieval. +- Use `nextCursor` to continue pagination when present. A short page does not by itself mean end-of-results. Response: diff --git a/public/api/v1/openapi.json b/public/api/v1/openapi.json index c62ff4a894..f54c7c4312 100644 --- a/public/api/v1/openapi.json +++ b/public/api/v1/openapi.json @@ -278,8 +278,8 @@ "summary": "List skills", "parameters": [ { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer" } }, - { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" } }, - { "name": "nonSuspiciousOnly", "in": "query", "required": false, "schema": { "type": "boolean" } }, + { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" }, "description": "Pagination cursor for any non-trending sort." }, + { "name": "nonSuspiciousOnly", "in": "query", "required": false, "schema": { "type": "boolean" }, "description": "Hide suspicious skills. For cursor-based sorts, pages may contain fewer than limit items because filtering happens after page retrieval." }, { "name": "nonSuspicious", "in": "query", "required": false, "schema": { "type": "boolean" }, "description": "Legacy alias for nonSuspiciousOnly." } ], "responses": {