diff --git a/convex/httpApi.handlers.test.ts b/convex/httpApi.handlers.test.ts index e114636d27..3a4de02b46 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,51 @@ 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, + }) + }) + + 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('searchSkillsHttp prefers canonical nonSuspiciousOnly over legacy alias', async () => { + const runAction = vi.fn().mockResolvedValue([]) + await __handlers.searchSkillsHandler( + makeCtx({ runAction }), + new Request( + 'https://example.com/api/search?q=test&nonSuspiciousOnly=false&nonSuspicious=1', + ), + ) + expect(runAction).toHaveBeenCalledWith(expect.anything(), { + query: 'test', + limit: undefined, + highlightedOnly: undefined, + nonSuspiciousOnly: undefined, }) }) diff --git a/convex/httpApi.ts b/convex/httpApi.ts index 11b8d0a4f2..cded3a3b71 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, resolveBooleanQueryParam } from './lib/httpUtils' import { publishVersionForUser } from './skills' type SearchSkillEntry = { @@ -44,8 +45,12 @@ 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 = resolveBooleanQueryParam( + url.searchParams.get('nonSuspiciousOnly'), + url.searchParams.get('nonSuspicious'), + ) if (!query) return json({ results: [] }) @@ -53,6 +58,7 @@ async function searchSkillsHandler(ctx: ActionCtx, request: Request) { query, limit, highlightedOnly: highlightedOnly || undefined, + nonSuspiciousOnly: nonSuspiciousOnly || undefined, })) as SearchSkillEntry[] return json({ diff --git a/convex/httpApiV1.handlers.test.ts b/convex/httpApiV1.handlers.test.ts index 37fd87c839..4bd2a2c36e 100644 --- a/convex/httpApiV1.handlers.test.ts +++ b/convex/httpApiV1.handlers.test.ts @@ -225,6 +225,63 @@ 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, + }) + }) + + 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 prefers canonical nonSuspiciousOnly over legacy 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&nonSuspiciousOnly=false&nonSuspicious=1', + ), + ) + if (response.status !== 200) { + throw new Error(await response.text()) + } + expect(runAction).toHaveBeenCalledWith(expect.anything(), { + query: 'test', + limit: undefined, + highlightedOnly: undefined, + nonSuspiciousOnly: undefined, }) }) @@ -554,6 +611,56 @@ 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('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('lists skills prefers canonical nonSuspiciousOnly over legacy alias', async () => { + const runQuery = vi.fn(async (_query: unknown, args: Record) => { + if ('sort' in args || 'cursor' in args || 'limit' in args) { + expect(args.nonSuspiciousOnly).toBeUndefined() + 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=false&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()) @@ -908,6 +1015,396 @@ 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('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) { + 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.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) + }) + + 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('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 200de2c35a..af28081833 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, resolveBooleanQueryParam } from '../lib/httpUtils' import { applyRateLimit, parseBearerToken } from '../lib/httpRateLimit' import { publishVersionForUser } from '../skills' import { @@ -166,6 +167,158 @@ function normalizeModerationFromSkill(skill: SkillModerationShape) { } } +type NormalizedSecurityStatus = 'clean' | 'suspicious' | 'malicious' | 'pending' | 'error' + +type SkillSecuritySnapshot = { + status: NormalizedSecurityStatus + hasWarnings: boolean + checkedAt: number | null + model: string | null + hasScanResult: 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 + } +} + +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, + 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': + case 'completed': + 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 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 + 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 hasScanResult = isDefinitiveSecurityStatus(vtStatus) || isDefinitiveSecurityStatus(llmStatus) + const hasWarnings = + status === 'suspicious' || status === 'malicious' || hasLlmDimensionWarnings(llm?.dimensions) + + 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, + hasScanResult, + 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 +326,11 @@ 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 = resolveBooleanQueryParam( + url.searchParams.get('nonSuspiciousOnly'), + url.searchParams.get('nonSuspicious'), + ) if (!query) return json({ results: [] }, 200, rate.headers) @@ -181,6 +338,7 @@ export async function searchSkillsV1Handler(ctx: ActionCtx, request: Request) { query, limit, highlightedOnly: highlightedOnly || undefined, + nonSuspiciousOnly: nonSuspiciousOnly || undefined, })) as SearchSkillEntry[] return json( @@ -251,11 +409,16 @@ 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 = resolveBooleanQueryParam( + url.searchParams.get('nonSuspiciousOnly'), + 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 +687,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 +704,78 @@ 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) + const moderationMatchesRequestedVersion = Boolean( + result.latestVersion && result.latestVersion._id === version._id, + ) + + 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 + ? { + 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, + isHiddenByMod: result.moderationInfo.isHiddenByMod ?? false, + isRemoved: result.moderationInfo.isRemoved ?? false, + } + : null, + security, }, 200, rate.headers, diff --git a/convex/leaderboards.ts b/convex/leaderboards.ts index 407523bf2f..a11f18b4d0 100644 --- a/convex/leaderboards.ts +++ b/convex/leaderboards.ts @@ -1,13 +1,15 @@ import { v } from 'convex/values' import { internal } from './_generated/api' -import type { Id } from './_generated/dataModel' import { internalAction, internalMutation, internalQuery } from './functions' import { - buildTrendingLeaderboard, - compareTrendingEntries, + buildTrendingEntriesFromDailyRows, + buildTrendingEntryCandidates, getTrendingRange, queryDailyStats, - topN, + takeTopNonSuspiciousTrendingEntries, + takeTopTrendingEntries, + TRENDING_LEADERBOARD_KIND, + TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND, } from './lib/leaderboards' const MAX_TRENDING_LIMIT = 200 @@ -26,9 +28,27 @@ export const getDailyStats = internalQuery({ }, }) +export const filterTopNonSuspiciousTrendingEntries = internalQuery({ + args: { + entries: v.array( + v.object({ + skillId: v.id('skills'), + score: v.number(), + installs: v.number(), + downloads: v.number(), + }), + ), + limit: v.number(), + }, + handler: async (ctx, { entries, limit }) => { + return takeTopNonSuspiciousTrendingEntries(ctx, entries, limit) + }, +}) + /** Writes the pre-computed leaderboard and prunes old entries. */ export const writeTrendingLeaderboard = internalMutation({ args: { + kind: v.string(), items: v.array( v.object({ skillId: v.id('skills'), @@ -40,11 +60,11 @@ export const writeTrendingLeaderboard = internalMutation({ startDay: v.number(), endDay: v.number(), }, - handler: async (ctx, { items, startDay, endDay }) => { + handler: async (ctx, { kind, items, startDay, endDay }) => { const now = Date.now() await ctx.db.insert('skillLeaderboards', { - kind: 'trending', + kind, generatedAt: now, rangeStartDay: startDay, rangeEndDay: endDay, @@ -53,7 +73,7 @@ export const writeTrendingLeaderboard = internalMutation({ const recent = await ctx.db .query('skillLeaderboards') - .withIndex('by_kind', (q) => q.eq('kind', 'trending')) + .withIndex('by_kind', (q) => q.eq('kind', kind)) .order('desc') .take(KEEP_LEADERBOARD_ENTRIES + 5) @@ -70,39 +90,32 @@ export const rebuildTrendingLeaderboardAction = internalAction({ args: { limit: v.optional(v.number()) }, handler: async (ctx, args): Promise<{ ok: true; count: number }> => { const limit = clampInt(args.limit ?? MAX_TRENDING_LIMIT, 1, MAX_TRENDING_LIMIT) - const { startDay, endDay } = getTrendingRange(Date.now()) - + const now = Date.now() + const { startDay, endDay } = getTrendingRange(now) const dayKeys = Array.from({ length: endDay - startDay + 1 }, (_, i) => startDay + i) const perDayRows = await Promise.all( dayKeys.map((day) => ctx.runQuery(internal.leaderboards.getDailyStats, { day })), ) - - const totals = new Map() - for (const rows of perDayRows) { - for (const row of rows) { - const current = totals.get(row.skillId) ?? { installs: 0, downloads: 0 } - current.installs += row.installs - current.downloads += row.downloads - totals.set(row.skillId, current) - } - } - - const entries = Array.from(totals, ([skillId, t]) => ({ - skillId: skillId as Id<'skills'>, - installs: t.installs, - downloads: t.downloads, - score: t.installs, - })) - - const items = topN(entries, limit, compareTrendingEntries).sort((a, b) => - compareTrendingEntries(b, a), + const entries = buildTrendingEntriesFromDailyRows(perDayRows) + const items = takeTopTrendingEntries(entries, limit) + const nonSuspicious = await ctx.runQuery( + internal.leaderboards.filterTopNonSuspiciousTrendingEntries, + { entries, limit }, ) - return await ctx.runMutation(internal.leaderboards.writeTrendingLeaderboard, { + await ctx.runMutation(internal.leaderboards.writeTrendingLeaderboard, { + kind: TRENDING_LEADERBOARD_KIND, items, startDay, endDay, }) + await ctx.runMutation(internal.leaderboards.writeTrendingLeaderboard, { + kind: TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND, + items: nonSuspicious, + startDay, + endDay, + }) + return { ok: true as const, count: items.length } }, }) @@ -115,24 +128,37 @@ export const rebuildTrendingLeaderboardInternal = internalMutation({ handler: async (ctx, args) => { const limit = clampInt(args.limit ?? MAX_TRENDING_LIMIT, 1, MAX_TRENDING_LIMIT) const now = Date.now() - const { startDay, endDay, items } = await buildTrendingLeaderboard(ctx, { limit, now }) + const { startDay, endDay, entries } = await buildTrendingEntryCandidates(ctx, now) + const items = takeTopTrendingEntries(entries, limit) + const nonSuspicious = await takeTopNonSuspiciousTrendingEntries(ctx, entries, limit) await ctx.db.insert('skillLeaderboards', { - kind: 'trending', + kind: TRENDING_LEADERBOARD_KIND, generatedAt: now, rangeStartDay: startDay, rangeEndDay: endDay, items, }) + await ctx.db.insert('skillLeaderboards', { + kind: TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND, + generatedAt: now, + rangeStartDay: startDay, + rangeEndDay: endDay, + items: nonSuspicious, + }) - const recent = await ctx.db - .query('skillLeaderboards') - .withIndex('by_kind', (q) => q.eq('kind', 'trending')) - .order('desc') - .take(KEEP_LEADERBOARD_ENTRIES + 5) - - for (const entry of recent.slice(KEEP_LEADERBOARD_ENTRIES)) { - await ctx.db.delete(entry._id) + for (const kind of [ + TRENDING_LEADERBOARD_KIND, + TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND, + ]) { + const entriesForKind = await ctx.db + .query('skillLeaderboards') + .withIndex('by_kind', (q) => q.eq('kind', kind)) + .order('desc') + .take(KEEP_LEADERBOARD_ENTRIES + 5) + for (const entry of entriesForKind.slice(KEEP_LEADERBOARD_ENTRIES)) { + await ctx.db.delete(entry._id) + } } return { ok: true as const, count: items.length } diff --git a/convex/lib/httpUtils.test.ts b/convex/lib/httpUtils.test.ts new file mode 100644 index 0000000000..a234108191 --- /dev/null +++ b/convex/lib/httpUtils.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { + parseBooleanQueryParam, + parseBooleanQueryParamOptional, + resolveBooleanQueryParam, +} 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) + }) + + it('supports optional parsing for precedence-sensitive callers', () => { + expect(parseBooleanQueryParamOptional(null)).toBeUndefined() + expect(parseBooleanQueryParamOptional('false')).toBe(false) + expect(parseBooleanQueryParamOptional('1')).toBe(true) + }) + + it('prefers the primary param over the legacy alias when both are present', () => { + expect(resolveBooleanQueryParam('false', '1')).toBe(false) + expect(resolveBooleanQueryParam('true', '0')).toBe(true) + expect(resolveBooleanQueryParam(null, '1')).toBe(true) + expect(resolveBooleanQueryParam(null, null)).toBeUndefined() + }) +}) diff --git a/convex/lib/httpUtils.ts b/convex/lib/httpUtils.ts new file mode 100644 index 0000000000..808ff00e26 --- /dev/null +++ b/convex/lib/httpUtils.ts @@ -0,0 +1,17 @@ +export function parseBooleanQueryParam(value: string | null) { + if (!value) return false + const normalized = value.trim().toLowerCase() + return normalized === 'true' || normalized === '1' +} + +export function parseBooleanQueryParamOptional(value: string | null) { + if (value == null) return undefined + return parseBooleanQueryParam(value) +} + +export function resolveBooleanQueryParam( + primaryValue: string | null, + legacyValue: string | null, +) { + return parseBooleanQueryParamOptional(primaryValue) ?? parseBooleanQueryParamOptional(legacyValue) +} diff --git a/convex/lib/leaderboards.test.ts b/convex/lib/leaderboards.test.ts new file mode 100644 index 0000000000..6379cd7246 --- /dev/null +++ b/convex/lib/leaderboards.test.ts @@ -0,0 +1,44 @@ +/* @vitest-environment node */ +import { describe, expect, it, vi } from 'vitest' +import { takeTopNonSuspiciousTrendingEntries, type LeaderboardEntry } from './leaderboards' + +describe('takeTopNonSuspiciousTrendingEntries', () => { + it('keeps scanning past suspicious entries until it finds enough clean skills', async () => { + const entries: LeaderboardEntry[] = [ + { skillId: 'skills:suspicious-1', score: 300, installs: 300, downloads: 10 }, + { skillId: 'skills:suspicious-2', score: 200, installs: 200, downloads: 9 }, + { skillId: 'skills:clean', score: 100, installs: 100, downloads: 8 }, + ] + + const ctx = { + db: { + get: vi.fn(async (id: string) => { + if (id === 'skills:clean') { + return { + _id: id, + softDeletedAt: undefined, + moderationFlags: [], + moderationReason: undefined, + } + } + return { + _id: id, + softDeletedAt: undefined, + moderationFlags: ['flagged.suspicious'], + moderationReason: undefined, + } + }), + }, + } + + const items = await takeTopNonSuspiciousTrendingEntries( + ctx as never, + entries, + 1, + ) + + expect(items).toEqual([ + { skillId: 'skills:clean', score: 100, installs: 100, downloads: 8 }, + ]) + }) +}) diff --git a/convex/lib/leaderboards.ts b/convex/lib/leaderboards.ts index 5b5c8e3bc4..5ae59627a6 100644 --- a/convex/lib/leaderboards.ts +++ b/convex/lib/leaderboards.ts @@ -1,8 +1,11 @@ import type { Id } from '../_generated/dataModel' import type { MutationCtx, QueryCtx } from '../_generated/server' +import { isSkillSuspicious } from './skillSafety' const DAY_MS = 24 * 60 * 60 * 1000 export const TRENDING_DAYS = 7 +export const TRENDING_LEADERBOARD_KIND = 'trending' +export const TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND = 'trending_non_suspicious' export type LeaderboardEntry = { skillId: Id<'skills'> @@ -11,6 +14,12 @@ export type LeaderboardEntry = { downloads: number } +type DailyTrendingRow = { + skillId: Id<'skills'> + installs: number + downloads: number +} + export function toDayKey(timestamp: number) { return Math.floor(timestamp / DAY_MS) } @@ -32,7 +41,34 @@ export async function buildTrendingLeaderboard( ctx: QueryCtx | MutationCtx, params: { limit: number; now?: number }, ) { - const now = params.now ?? Date.now() + const { startDay, endDay, entries } = await buildTrendingEntryCandidates( + ctx, + params.now, + ) + return { + startDay, + endDay, + items: takeTopTrendingEntries(entries, params.limit), + } +} + +export async function buildNonSuspiciousTrendingLeaderboard( + ctx: QueryCtx | MutationCtx, + params: { limit: number; now?: number }, +) { + const { startDay, endDay, entries } = await buildTrendingEntryCandidates( + ctx, + params.now, + ) + const items = await takeTopNonSuspiciousTrendingEntries(ctx, entries, params.limit) + + return { startDay, endDay, items } +} + +export async function buildTrendingEntryCandidates( + ctx: QueryCtx | MutationCtx, + now = Date.now(), +) { const { startDay, endDay } = getTrendingRange(now) // Query one day at a time to stay well under the 32K document limit. @@ -47,6 +83,14 @@ export async function buildTrendingLeaderboard( .collect(), ), ) + const entries = buildTrendingEntriesFromDailyRows(perDayRows) + + return { startDay, endDay, entries } +} + +export function buildTrendingEntriesFromDailyRows( + perDayRows: DailyTrendingRow[][], +) { const totals = new Map, { installs: number; downloads: number }>() for (const rows of perDayRows) { for (const row of rows) { @@ -64,11 +108,35 @@ export async function buildTrendingLeaderboard( score: totalsEntry.installs, })) - const items = topN(entries, params.limit, compareTrendingEntries).sort((a, b) => + entries.sort((a, b) => compareTrendingEntries(b, a)) + + return entries +} + +export function takeTopTrendingEntries( + entries: LeaderboardEntry[], + limit: number, +) { + return topN(entries, limit, compareTrendingEntries).sort((a, b) => compareTrendingEntries(b, a), ) +} - return { startDay, endDay, items } +export async function takeTopNonSuspiciousTrendingEntries( + ctx: QueryCtx | MutationCtx, + entries: LeaderboardEntry[], + limit: number, +) { + const items: LeaderboardEntry[] = [] + + for (const entry of entries) { + const skill = await ctx.db.get(entry.skillId) + if (!skill || skill.softDeletedAt || isSkillSuspicious(skill)) continue + items.push(entry) + if (items.length >= limit) break + } + + return items } export function compareTrendingEntries(a: LeaderboardEntry, b: LeaderboardEntry) { diff --git a/convex/skills.listPublicPage.test.ts b/convex/skills.listPublicPage.test.ts new file mode 100644 index 0000000000..f7d5136515 --- /dev/null +++ b/convex/skills.listPublicPage.test.ts @@ -0,0 +1,295 @@ +/* @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']) + }) + + 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({ + leaderboards: { + trending: [suspicious1._id, suspicious2._id], + trending_non_suspicious: [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({ + 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 makeTrendingCtx({ + leaderboards, + skills, + users, + versions, +}: { + leaderboards: Record + 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: (q: { eq: (field: string, value: string) => unknown }) => unknown) => { + if (index !== 'by_kind') throw new Error(`unexpected index ${index}`) + let requestedKind = 'trending' + builder({ + eq: (field: string, value: string) => { + if (field !== 'kind') throw new Error(`unexpected field ${field}`) + requestedKind = value + return {} + }, + }) + return { + order: vi.fn((dir: string) => { + if (dir !== 'desc') throw new Error(`unexpected order ${dir}`) + return { + take: vi.fn().mockResolvedValue([ + { + kind: requestedKind, + generatedAt: 1, + rangeStartDay: 1, + rangeEndDay: 1, + items: (leaderboards[requestedKind] ?? []).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, + 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 7147388c1e..3eb84bd51e 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -37,7 +37,12 @@ import { isPublicSkillDoc, readGlobalPublicSkillsCount, } from './lib/globalStats' -import { buildTrendingLeaderboard } from './lib/leaderboards' +import { + buildNonSuspiciousTrendingLeaderboard, + buildTrendingLeaderboard, + TRENDING_LEADERBOARD_KIND, + TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND, +} from './lib/leaderboards' import { applyManualOverrideToSkillPatch, isManualOverrideReason, @@ -2252,6 +2257,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,18 +2281,24 @@ 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 } } if (sort === 'trending') { - const entries = await getTrendingEntries(ctx, limit) + const entries = await getTrendingEntries(ctx, limit, { + nonSuspiciousOnly: args.nonSuspiciousOnly, + }) 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 } @@ -2302,7 +2314,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 } }, @@ -2446,12 +2461,19 @@ function sortToIndex( } } -async function getTrendingEntries(ctx: QueryCtx, limit: number) { +async function getTrendingEntries( + ctx: QueryCtx, + limit: number, + args?: { nonSuspiciousOnly?: boolean }, +) { + const kind = args?.nonSuspiciousOnly + ? TRENDING_NON_SUSPICIOUS_LEADERBOARD_KIND + : TRENDING_LEADERBOARD_KIND // Use the pre-computed leaderboard from the hourly cron job. // Avoid Date.now() here to keep the query deterministic and cacheable. const latest = await ctx.db .query('skillLeaderboards') - .withIndex('by_kind', (q) => q.eq('kind', 'trending')) + .withIndex('by_kind', (q) => q.eq('kind', kind)) .order('desc') .take(1) @@ -2460,10 +2482,15 @@ async function getTrendingEntries(ctx: QueryCtx, limit: number) { } // No leaderboard exists yet (cold start) - compute on the fly - const fallback = await buildTrendingLeaderboard(ctx, { - limit, - now: Date.now(), - }) + const fallback = args?.nonSuspiciousOnly + ? await buildNonSuspiciousTrendingLeaderboard(ctx, { + limit, + now: Date.now(), + }) + : await buildTrendingLeaderboard(ctx, { + limit, + now: Date.now(), + }) return fallback.items } diff --git a/docs/api.md b/docs/api.md index a303b0692f..2f80dad349 100644 --- a/docs/api.md +++ b/docs/api.md @@ -59,12 +59,19 @@ Client handling: 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` + - `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=` - `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..f405964944 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -74,6 +74,8 @@ 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 +- `nonSuspicious` (optional): legacy alias for `nonSuspiciousOnly` Response: @@ -90,12 +92,16 @@ 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` 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: @@ -145,6 +151,26 @@ 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. +- `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` Returns raw text content. diff --git a/public/api/v1/openapi.json b/public/api/v1/openapi.json index f7c4ddbdb5..0ca69546c1 100644 --- a/public/api/v1/openapi.json +++ b/public/api/v1/openapi.json @@ -99,7 +99,19 @@ "changelog": { "type": "string" } } }, - "owner": { "$ref": "#/components/schemas/Owner" } + "owner": { "$ref": "#/components/schemas/Owner" }, + "moderation": { + "type": ["object", "null"], + "properties": { + "isSuspicious": { "type": "boolean" }, + "isMalwareBlocked": { "type": "boolean" }, + "verdict": { "type": "string", "enum": ["clean", "suspicious", "malicious"] }, + "reasonCodes": { "type": "array", "items": { "type": "string" } }, + "summary": { "type": ["string", "null"] }, + "engineVersion": { "type": ["string", "null"] }, + "updatedAt": { "type": ["number", "null"] } + } + } } }, "SkillVersion": { @@ -118,6 +130,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"] }, + "hasScanResult": { "type": "boolean" }, + "sha256hash": { "type": ["string", "null"] }, + "virustotalUrl": { "type": ["string", "null"] }, + "scanners": { "type": "object", "additionalProperties": true } + } + }, "SkillVersionResponse": { "type": "object", "properties": { @@ -146,8 +171,51 @@ "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": { + "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" }, + "isHiddenByMod": { "type": "boolean" }, + "isRemoved": { "type": "boolean" } } + }, + "security": { + "anyOf": [{ "$ref": "#/components/schemas/SecuritySnapshot" }, { "type": "null" }] } } }, @@ -192,7 +260,9 @@ "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" } }, + { "name": "nonSuspicious", "in": "query", "required": false, "schema": { "type": "boolean" }, "description": "Legacy alias for nonSuspiciousOnly." } ], "responses": { "200": { @@ -220,7 +290,9 @@ "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" }, "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": { "200": { "description": "Skills", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SkillListResponse" } } } } @@ -301,6 +373,43 @@ } } }, + "/api/v1/skills/{slug}/moderation": { + "get": { + "summary": "Get moderation details", + "parameters": [ + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { + "description": "Moderation details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "moderation": { + "type": ["object", "null"], + "properties": { + "isSuspicious": { "type": "boolean" }, + "isMalwareBlocked": { "type": "boolean" }, + "verdict": { "type": "string", "enum": ["clean", "suspicious", "malicious"] }, + "reasonCodes": { "type": "array", "items": { "type": "string" } }, + "summary": { "type": ["string", "null"] }, + "engineVersion": { "type": ["string", "null"] }, + "updatedAt": { "type": ["number", "null"] }, + "legacyReason": { "type": ["string", "null"] }, + "evidence": { "type": "array", "items": { "type": "object", "additionalProperties": true } } + } + } + } + } + } + } + }, + "404": { "description": "Moderation details unavailable" } + } + } + }, "/api/v1/skills/{slug}/undelete": { "post": { "summary": "Undelete skill", @@ -338,6 +447,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",