Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions convex/httpApi.handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -65,6 +66,7 @@ describe('httpApi handlers', () => {
query: 'test',
limit: undefined,
highlightedOnly: true,
nonSuspiciousOnly: undefined,
})
})

Expand All @@ -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,
})
})

Expand Down
9 changes: 7 additions & 2 deletions convex/httpApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { ActionCtx } from './_generated/server'
import { httpAction } from './_generated/server'
import { requireApiTokenUser } from './lib/apiTokenAuth'
import { corsHeaders, mergeHeaders } from './lib/httpHeaders'
import { parseBooleanQueryParam } from './lib/httpUtils'
import { publishVersionForUser } from './skills'

type SearchSkillEntry = {
Expand Down Expand Up @@ -44,15 +45,19 @@ 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: [] })

const results = (await ctx.runAction(api.search.searchSkills, {
query,
limit,
highlightedOnly: highlightedOnly || undefined,
nonSuspiciousOnly: nonSuspiciousOnly || undefined,
})) as SearchSkillEntry[]

return json({
Expand Down
168 changes: 168 additions & 0 deletions convex/httpApiV1.handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
})

Expand Down Expand Up @@ -554,6 +573,22 @@ describe('httpApiV1 handlers', () => {
}
})

it('lists skills forwards nonSuspiciousOnly', async () => {
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
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())
Expand Down Expand Up @@ -715,6 +750,139 @@ 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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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',
Expand Down
Loading