Compare commits

...
5 changed files with 533 additions and 72 deletions
+7 -3
View File
@@ -71,17 +71,21 @@ export const downloadZip = httpAction(async (ctx, request) => {
}
const skill = skillResult.skill
let version = skillResult.latestVersion
let version = skill.latestVersionId
? await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: skill.latestVersionId,
})
: null
if (versionParam) {
version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
version = await ctx.runQuery(internal.skills.getVersionBySkillAndVersionInternal, {
skillId: skill._id,
version: versionParam,
})
} else if (tagParam) {
const versionId = skill.tags[tagParam]
if (versionId) {
version = await ctx.runQuery(api.skills.getVersionById, { versionId })
version = await ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId })
}
}
+46 -28
View File
@@ -1444,7 +1444,7 @@ describe('httpApiV1 handlers', () => {
})
it('returns raw file content', async () => {
const version = {
const internalVersion = {
version: '1.0.0',
createdAt: 1,
changelog: 'c',
@@ -1459,19 +1459,28 @@ describe('httpApiV1 handlers', () => {
],
softDeletedAt: undefined,
}
const runQuery = vi.fn().mockResolvedValue({
skill: {
_id: 'skills:1',
slug: 'demo',
displayName: 'Demo',
summary: 's',
tags: {},
stats: {},
createdAt: 1,
updatedAt: 2,
},
latestVersion: version,
owner: null,
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('slug' in args) {
return {
skill: {
_id: 'skills:1',
slug: 'demo',
displayName: 'Demo',
summary: 's',
tags: {},
stats: {},
createdAt: 1,
updatedAt: 2,
latestVersionId: 'skillVersions:1',
},
latestVersion: { _id: 'skillVersions:1', version: '1.0.0' },
owner: null,
}
}
if ('versionId' in args) {
return internalVersion
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
const storage = {
@@ -1487,7 +1496,7 @@ describe('httpApiV1 handlers', () => {
})
it('returns 413 when raw file too large', async () => {
const version = {
const internalVersion = {
version: '1.0.0',
createdAt: 1,
changelog: 'c',
@@ -1502,19 +1511,28 @@ describe('httpApiV1 handlers', () => {
],
softDeletedAt: undefined,
}
const runQuery = vi.fn().mockResolvedValue({
skill: {
_id: 'skills:1',
slug: 'demo',
displayName: 'Demo',
summary: 's',
tags: {},
stats: {},
createdAt: 1,
updatedAt: 2,
},
latestVersion: version,
owner: null,
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
if ('slug' in args) {
return {
skill: {
_id: 'skills:1',
slug: 'demo',
displayName: 'Demo',
summary: 's',
tags: {},
stats: {},
createdAt: 1,
updatedAt: 2,
latestVersionId: 'skillVersions:1',
},
latestVersion: { _id: 'skillVersions:1', version: '1.0.0' },
owner: null,
}
}
if ('versionId' in args) {
return internalVersion
}
return null
})
const runMutation = vi.fn().mockResolvedValue(okRate())
const response = await __handlers.skillsGetRouterV1Handler(
+44 -23
View File
@@ -57,7 +57,31 @@ type ListSkillsResult = {
nextCursor: string | null
}
type SkillFile = Doc<'skillVersions'>['files'][number]
type PublicSkillVersionFile = {
path: string
size: number
sha256: string
contentType?: string
}
type PublicSkillVersionParsed = {
license?: 'MIT-0'
clawdis?: { os?: string[]; nix?: { plugin?: boolean; systems?: string[] } }
}
type PublicSkillVersionResponse = {
_id: Id<'skillVersions'>
version: string
createdAt?: number
changelog?: string
changelogSource?: 'auto' | 'user'
files: PublicSkillVersionFile[]
parsed?: PublicSkillVersionParsed
softDeletedAt?: number
sha256hash?: string
vtAnalysis?: Doc<'skillVersions'>['vtAnalysis']
llmAnalysis?: Doc<'skillVersions'>['llmAnalysis']
}
type ModerationEvidence = {
code: string
@@ -90,8 +114,9 @@ type GetBySlugResult = {
stats: unknown
createdAt: number
updatedAt: number
latestVersionId?: Id<'skillVersions'>
} | null
latestVersion: Doc<'skillVersions'> | null
latestVersion: PublicSkillVersionResponse | null
owner: { _id: Id<'users'>; handle?: string; displayName?: string; image?: string } | null
moderationInfo?: {
isPendingScan: boolean
@@ -109,20 +134,7 @@ type GetBySlugResult = {
} | null
type ListVersionsResult = {
items: Array<{
version: string
createdAt: number
changelog: string
changelogSource?: 'auto' | 'user'
files: Array<{
path: string
size: number
storageId: Id<'_storage'>
sha256: string
contentType?: string
}>
softDeletedAt?: number
}>
items: PublicSkillVersionResponse[]
nextCursor: string | null
}
@@ -258,7 +270,12 @@ function hasLlmDimensionWarnings(
})
}
function buildSkillSecuritySnapshot(version: Doc<'skillVersions'>): SkillSecuritySnapshot | null {
function buildSkillSecuritySnapshot(
version: Pick<
PublicSkillVersionResponse,
'sha256hash' | 'vtAnalysis' | 'llmAnalysis'
>,
): SkillSecuritySnapshot | null {
const sha256hash = version.sha256hash ?? null
const vt = version.vtAnalysis
const llm = version.llmAnalysis
@@ -681,10 +698,10 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
const skillResult = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
if (!skillResult?.skill) return text('Skill not found', 404, rate.headers)
const version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
const version = (await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
skillId: skillResult.skill._id,
version: third,
})
})) as PublicSkillVersionResponse | 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)
@@ -698,7 +715,7 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
changelog: version.changelog,
changelogSource: version.changelogSource ?? null,
license: version.parsed?.license ?? null,
files: version.files.map((file: SkillFile) => ({
files: version.files.map((file) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
@@ -792,16 +809,20 @@ export async function skillsGetRouterV1Handler(ctx: ActionCtx, request: Request)
const skillResult = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
if (!skillResult?.skill) return text('Skill not found', 404, rate.headers)
let version = skillResult.latestVersion
let version: Doc<'skillVersions'> | null = skillResult.skill.latestVersionId
? await ctx.runQuery(internal.skills.getVersionByIdInternal, {
versionId: skillResult.skill.latestVersionId,
})
: null
if (versionParam) {
version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
version = await ctx.runQuery(internal.skills.getVersionBySkillAndVersionInternal, {
skillId: skillResult.skill._id,
version: versionParam,
})
} else if (tagParam) {
const versionId = skillResult.skill.tags[tagParam]
if (versionId) {
version = await ctx.runQuery(api.skills.getVersionById, { versionId })
version = await ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId })
}
}
+124 -18
View File
@@ -1030,18 +1030,58 @@ type PublicSkillListVersion = Pick<
| 'changelog'
| 'changelogSource'
> & {
parsed?: {
license?: typeof PLATFORM_SKILL_LICENSE
clawdis?: {
os?: string[]
nix?: {
plugin?: boolean
systems?: string[]
}
parsed?: PublicSkillVersionParsed
}
type PublicSkillVersionParsed = {
license?: typeof PLATFORM_SKILL_LICENSE
clawdis?: {
os?: string[]
nix?: {
plugin?: boolean
systems?: string[]
}
}
}
type PublicSkillVersion = {
_id: Id<'skillVersions'>
_creationTime?: number
skillId?: Id<'skills'>
version: string
fingerprint?: string
changelog?: string
changelogSource?: Doc<'skillVersions'>['changelogSource']
files: Array<{
path: string
size: number
sha256: string
contentType?: string
}>
parsed?: PublicSkillVersionParsed
createdBy?: Id<'users'>
createdAt?: number
softDeletedAt?: number
sha256hash?: string
vtAnalysis?: Doc<'skillVersions'>['vtAnalysis']
llmAnalysis?: Doc<'skillVersions'>['llmAnalysis']
staticScan?: {
status: NonNullable<Doc<'skillVersions'>['staticScan']>['status']
reasonCodes: NonNullable<Doc<'skillVersions'>['staticScan']>['reasonCodes']
findings: Array<{
code: string
severity: 'info' | 'warn' | 'critical'
file: string
line: number
message: string
evidence: string
}>
summary: NonNullable<Doc<'skillVersions'>['staticScan']>['summary']
engineVersion: NonNullable<Doc<'skillVersions'>['staticScan']>['engineVersion']
checkedAt: NonNullable<Doc<'skillVersions'>['staticScan']>['checkedAt']
}
}
type ManagementSkillEntry = {
skill: Doc<'skills'>
latestVersion: Doc<'skillVersions'> | null
@@ -1176,6 +1216,56 @@ function toPublicSkillListVersion(
}
}
function toPublicSkillVersion(
version: Doc<'skillVersions'> | null | undefined,
): PublicSkillVersion | null {
if (!version) return null
return {
_id: version._id,
_creationTime: version._creationTime,
skillId: version.skillId,
version: version.version,
fingerprint: version.fingerprint,
changelog: version.changelog,
changelogSource: version.changelogSource,
files: (version.files ?? []).map((file) => ({
path: file.path,
size: file.size,
sha256: file.sha256,
contentType: file.contentType,
})),
parsed: version.parsed
? {
license: version.parsed.license,
clawdis: version.parsed.clawdis,
}
: undefined,
createdBy: version.createdBy,
createdAt: version.createdAt,
softDeletedAt: version.softDeletedAt,
sha256hash: version.sha256hash,
vtAnalysis: version.vtAnalysis,
llmAnalysis: version.llmAnalysis,
staticScan: version.staticScan
? {
status: version.staticScan.status,
reasonCodes: version.staticScan.reasonCodes,
findings: (version.staticScan.findings ?? []).map((finding) => ({
code: finding.code,
severity: finding.severity,
file: finding.file,
line: finding.line,
message: finding.message,
evidence: '',
})),
summary: version.staticScan.summary,
engineVersion: version.staticScan.engineVersion,
checkedAt: version.staticScan.checkedAt,
}
: undefined,
}
}
function toPublicSkillListVersionFromSummary(
summary: NonNullable<Doc<'skills'>['latestVersionSummary']>,
latestVersionId: Id<'skillVersions'> | undefined,
@@ -1322,9 +1412,9 @@ export const getBySlug = query({
const userId = await getAuthUserId(ctx)
const isOwner = Boolean(userId && userId === skill.ownerUserId)
const latestVersion = skill.latestVersionId
? await ctx.db.get(skill.latestVersionId)
: null
const latestVersion = toPublicSkillVersion(
skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null,
)
const owner = toPublicUser(await ctx.db.get(skill.ownerUserId))
if (!owner) return null
const badges = await getSkillBadgeMap(ctx, skill._id)
@@ -2133,9 +2223,9 @@ export const listWithLatest = query({
const items = await Promise.all(
limited.map(async (skill) => ({
skill: toPublicSkill(skill),
latestVersion: skill.latestVersionId
? await ctx.db.get(skill.latestVersionId)
: null,
latestVersion: toPublicSkillVersion(
skill.latestVersionId ? await ctx.db.get(skill.latestVersionId) : null,
),
})),
)
return items.filter(
@@ -2688,11 +2778,12 @@ export const listVersions = query({
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
handler: async (ctx, args) => {
const limit = args.limit ?? 20
return ctx.db
const versions = await ctx.db
.query('skillVersions')
.withIndex('by_skill', (q) => q.eq('skillId', args.skillId))
.order('desc')
.take(limit)
return versions.map((version) => toPublicSkillVersion(version)!)
},
})
@@ -2709,14 +2800,16 @@ export const listVersionsPage = query({
.withIndex('by_skill', (q) => q.eq('skillId', args.skillId))
.order('desc')
.paginate({ cursor: args.cursor ?? null, numItems: limit })
const items = page.filter((version) => !version.softDeletedAt)
const items = page
.filter((version) => !version.softDeletedAt)
.map((version) => toPublicSkillVersion(version)!)
return { items, nextCursor: isDone ? null : continueCursor }
},
})
export const getVersionById = query({
args: { versionId: v.id('skillVersions') },
handler: async (ctx, args) => ctx.db.get(args.versionId),
handler: async (ctx, args) => toPublicSkillVersion(await ctx.db.get(args.versionId)),
})
export const getVersionsByIdsInternal = internalQuery({
@@ -2734,6 +2827,18 @@ export const getVersionByIdInternal = internalQuery({
handler: async (ctx, args) => ctx.db.get(args.versionId),
})
export const getVersionBySkillAndVersionInternal = internalQuery({
args: { skillId: v.id('skills'), version: v.string() },
handler: async (ctx, args) => {
return ctx.db
.query('skillVersions')
.withIndex('by_skill_version', (q) =>
q.eq('skillId', args.skillId).eq('version', args.version),
)
.unique()
},
})
export const getSkillByIdInternal = internalQuery({
args: { skillId: v.id('skills') },
handler: async (ctx, args) => ctx.db.get(args.skillId),
@@ -4086,12 +4191,13 @@ export const escalateByVtInternal = internalMutation({
export const getVersionBySkillAndVersion = query({
args: { skillId: v.id('skills'), version: v.string() },
handler: async (ctx, args) => {
return ctx.db
const version = await ctx.db
.query('skillVersions')
.withIndex('by_skill_version', (q) =>
q.eq('skillId', args.skillId).eq('version', args.version),
)
.unique()
return toPublicSkillVersion(version)
},
})
+312
View File
@@ -0,0 +1,312 @@
/* @vitest-environment node */
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@convex-dev/auth/server', () => ({
getAuthUserId: vi.fn(),
}))
vi.mock('./lib/badges', () => ({
getSkillBadgeMap: vi.fn(),
getSkillBadgeMaps: vi.fn(),
isSkillHighlighted: vi.fn(),
}))
const { getAuthUserId } = await import('@convex-dev/auth/server')
const { getSkillBadgeMap, getSkillBadgeMaps } = await import('./lib/badges')
const { getBySlug, getVersionById, getVersionBySkillAndVersion, listVersions, listWithLatest } =
await import('./skills')
type WrappedHandler<TArgs, TResult = unknown> = {
_handler: (ctx: unknown, args: TArgs) => Promise<TResult>
}
const getBySlugHandler = (
getBySlug as unknown as WrappedHandler<{
slug: string
}>
)._handler
const getVersionByIdHandler = (
getVersionById as unknown as WrappedHandler<{
versionId: string
}>
)._handler
const getVersionBySkillAndVersionHandler = (
getVersionBySkillAndVersion as unknown as WrappedHandler<{
skillId: string
version: string
}>
)._handler
const listVersionsHandler = (
listVersions as unknown as WrappedHandler<{
skillId: string
limit?: number
}>
)._handler
const listWithLatestHandler = (
listWithLatest as unknown as WrappedHandler<{
limit?: number
}>
)._handler
function makeVersion() {
return {
_id: 'skillVersions:1',
_creationTime: 1,
skillId: 'skills:1',
version: '1.0.0',
fingerprint: 'fp',
changelog: 'Initial release',
changelogSource: 'auto',
files: [
{
path: 'SKILL.md',
size: 10,
storageId: '_storage:1',
sha256: 'abc123',
contentType: 'text/markdown',
},
],
parsed: {
frontmatter: { secret: 'value' },
metadata: { hidden: true },
clawdis: { os: ['macos'] },
moltbot: { prompt: 'hidden' },
license: 'MIT-0',
},
createdBy: 'users:1',
createdAt: 100,
softDeletedAt: undefined,
sha256hash: 'deadbeef',
vtAnalysis: {
status: 'clean',
verdict: 'clean',
analysis: 'safe',
source: 'code_insight',
checkedAt: 1,
},
llmAnalysis: {
status: 'clean',
verdict: 'benign',
confidence: 'high',
summary: 'Looks safe',
dimensions: [],
guidance: 'ok',
findings: 'none',
model: 'gpt',
checkedAt: 1,
},
staticScan: {
status: 'suspicious',
reasonCodes: ['scanner.example'],
findings: [
{
code: 'scanner.example',
severity: 'warn',
file: 'SKILL.md',
line: 1,
message: 'Example finding',
evidence: 'SECRET_SNIPPET',
},
],
summary: 'Something matched',
engineVersion: '1',
checkedAt: 1,
},
}
}
describe('public skill version queries', () => {
beforeEach(() => {
vi.mocked(getAuthUserId).mockReset()
vi.mocked(getSkillBadgeMap).mockReset()
vi.mocked(getSkillBadgeMaps).mockReset()
vi.mocked(getAuthUserId).mockResolvedValue(null as never)
vi.mocked(getSkillBadgeMap).mockResolvedValue({} as never)
vi.mocked(getSkillBadgeMaps).mockResolvedValue(new Map() as never)
})
it('sanitizes latestVersion returned by getBySlug', async () => {
const version = makeVersion()
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table !== 'skills') throw new Error(`Unexpected table ${table}`)
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue({
_id: 'skills:1',
_creationTime: 1,
slug: 'demo',
displayName: 'Demo',
summary: 'Summary',
ownerUserId: 'users:1',
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: version._id,
tags: {},
stats: {
downloads: 1,
installsCurrent: 1,
installsAllTime: 1,
stars: 1,
versions: 1,
comments: 0,
},
createdAt: 1,
updatedAt: 2,
moderationStatus: 'active',
moderationFlags: undefined,
moderationReason: undefined,
softDeletedAt: undefined,
}),
})),
}
}),
get: vi.fn(async (id: string) => {
if (id === version._id) return version
if (id === 'users:1') {
return {
_id: 'users:1',
_creationTime: 1,
handle: 'demo',
name: 'demo',
displayName: 'Demo',
image: null,
bio: null,
}
}
return null
}),
},
} as never
const result = (await getBySlugHandler(ctx, { slug: 'demo' } as never)) as {
latestVersion?: {
files: Array<Record<string, unknown>>
parsed?: Record<string, unknown>
staticScan?: { findings?: Array<{ evidence?: string }> }
} | null
} | null
expect(result?.latestVersion?.files[0]).not.toHaveProperty('storageId')
expect(result?.latestVersion?.parsed).toEqual({
clawdis: { os: ['macos'] },
license: 'MIT-0',
})
expect(result?.latestVersion?.staticScan?.findings?.[0]?.evidence).toBe('')
})
it('sanitizes direct public version queries', async () => {
const version = makeVersion()
const unique = vi.fn().mockResolvedValue(version)
const take = vi.fn().mockResolvedValue([version])
const ctx = {
db: {
get: vi.fn().mockResolvedValue(version),
query: vi.fn((table: string) => {
if (table !== 'skillVersions') throw new Error(`Unexpected table ${table}`)
return {
withIndex: vi.fn(() => ({
unique,
order: vi.fn(() => ({ take })),
})),
}
}),
},
} as never
const byId = (await getVersionByIdHandler(ctx, {
versionId: version._id,
} as never)) as
| {
files: Array<Record<string, unknown>>
parsed?: Record<string, unknown>
staticScan?: { findings?: Array<{ evidence?: string }> }
}
| null
const byVersion = (await getVersionBySkillAndVersionHandler(
ctx,
{ skillId: 'skills:1', version: '1.0.0' } as never,
)) as
| {
files: Array<Record<string, unknown>>
parsed?: Record<string, unknown>
staticScan?: { findings?: Array<{ evidence?: string }> }
}
| null
const list = (await listVersionsHandler(ctx, {
skillId: 'skills:1',
limit: 5,
} as never)) as Array<{
files: Array<Record<string, unknown>>
parsed?: Record<string, unknown>
staticScan?: { findings?: Array<{ evidence?: string }> }
}>
for (const result of [byId, byVersion, list[0]]) {
expect(result?.files[0]).not.toHaveProperty('storageId')
expect(result?.parsed).not.toHaveProperty('frontmatter')
expect(result?.parsed).not.toHaveProperty('metadata')
expect(result?.parsed).not.toHaveProperty('moltbot')
expect(result?.staticScan?.findings?.[0]?.evidence).toBe('')
}
})
it('sanitizes latestVersion in listWithLatest', async () => {
const version = makeVersion()
const ctx = {
db: {
query: vi.fn((table: string) => {
if (table !== 'skills') throw new Error(`Unexpected table ${table}`)
return {
order: vi.fn(() => ({
take: vi.fn().mockResolvedValue([
{
_id: 'skills:1',
_creationTime: 1,
slug: 'demo',
displayName: 'Demo',
summary: 'Summary',
ownerUserId: 'users:1',
canonicalSkillId: undefined,
forkOf: undefined,
latestVersionId: version._id,
tags: {},
badges: undefined,
stats: {
downloads: 1,
installsCurrent: 1,
installsAllTime: 1,
stars: 1,
versions: 1,
comments: 0,
},
createdAt: 1,
updatedAt: 2,
softDeletedAt: undefined,
moderationStatus: 'active',
moderationFlags: undefined,
moderationReason: undefined,
},
]),
})),
}
}),
get: vi.fn().mockResolvedValue(version),
},
} as never
const result = (await listWithLatestHandler(ctx, { limit: 1 } as never)) as Array<{
latestVersion?: {
files: Array<Record<string, unknown>>
parsed?: Record<string, unknown>
} | null
}>
expect(result[0]?.latestVersion?.files[0]).not.toHaveProperty('storageId')
expect(result[0]?.latestVersion?.parsed).not.toHaveProperty('frontmatter')
})
})