diff --git a/src/server/__tests__/skill-market.test.ts b/src/server/__tests__/skill-market.test.ts index 2bf857f1..d436e864 100644 --- a/src/server/__tests__/skill-market.test.ts +++ b/src/server/__tests__/skill-market.test.ts @@ -1,4 +1,9 @@ -import { describe, expect, it } from 'bun:test' +import { afterEach, describe, expect, it, mock } from 'bun:test' +import { + handleSkillMarketApi, + resetSkillMarketServiceFactoryForTests, + setSkillMarketServiceFactoryForTests, +} from '../api/skill-market.js' import { normalizeClawHubList, normalizeClawHubScan } from '../services/skillMarket/clawhubAdapter.js' import { analyzeSkillRisk } from '../services/skillMarket/risk.js' import { createSkillMarketService } from '../services/skillMarket/service.js' @@ -738,3 +743,119 @@ describe('skill market risk analysis', () => { expect(risk).toEqual([]) }) }) + +describe('skill market API', () => { + afterEach(() => { + resetSkillMarketServiceFactoryForTests() + }) + + it('rejects unsupported methods', async () => { + const url = new URL('/api/skill-market', 'http://localhost:3456') + const req = new Request(url, { method: 'DELETE' }) + + const res = await handleSkillMarketApi(req, url, ['api', 'skill-market']) + + expect(res.status).toBe(405) + }) + + it('rejects install requests with target paths', async () => { + const url = new URL('/api/skill-market/install', 'http://localhost:3456') + const req = new Request(url, { + method: 'POST', + body: JSON.stringify({ source: 'clawhub', slug: 'skill-vetter', targetPath: '/tmp/escape' }), + }) + + const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install']) + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toMatchObject({ error: 'target_path_not_allowed' }) + }) + + it('returns install skeleton response for safe install requests', async () => { + const url = new URL('/api/skill-market/install', 'http://localhost:3456') + const req = new Request(url, { + method: 'POST', + body: JSON.stringify({ source: 'clawhub', slug: 'skill-vetter' }), + }) + + const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install']) + + expect(res.status).toBe(501) + await expect(res.json()).resolves.toMatchObject({ error: 'install_not_wired' }) + }) + + it('rejects invalid install JSON', async () => { + const url = new URL('/api/skill-market/install', 'http://localhost:3456') + const req = new Request(url, { + method: 'POST', + body: '{not-json', + }) + + const res = await handleSkillMarketApi(req, url, ['api', 'skill-market', 'install']) + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toMatchObject({ error: 'invalid_json' }) + }) + + it('routes skill market requests through the API router without network access', async () => { + mock.module('@whiskeysockets/baileys', () => ({ + DisconnectReason: { loggedOut: 401 }, + fetchLatestBaileysVersion: async () => ({ version: [2, 3000, 0] }), + makeCacheableSignalKeyStore: () => ({}), + makeWASocket: () => ({ ev: { on: () => {} }, ws: { on: () => {} } }), + useMultiFileAuthState: async () => ({ + state: { creds: {}, keys: {} }, + saveCreds: async () => {}, + }), + })) + const { handleApiRequest } = await import('../router.js') + const url = new URL('/api/skill-market?source=unsupported', 'http://localhost:3456') + + const res = await handleApiRequest(new Request(url, { method: 'GET' }), url) + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toMatchObject({ error: 'unsupported_source' }) + }) + + it('lists through the service with validated query params', async () => { + let capturedParams: unknown + let hasInstalledProvider = false + setSkillMarketServiceFactoryForTests((options) => { + hasInstalledProvider = typeof options.installedSkillNames === 'function' + return { + list: async (params) => { + capturedParams = params + return { + items: [], + nextCursor: null, + source: 'skillhub', + sourceStatus: 'ok', + } + }, + listSkills: async (params) => { + capturedParams = params + return { + items: [], + nextCursor: null, + source: 'skillhub', + sourceStatus: 'ok', + } + }, + } + }) + const url = new URL('/api/skill-market?source=skillhub&sort=updated&q=vetter&cursor=abc&limit=12', 'http://localhost:3456') + const req = new Request(url, { method: 'GET' }) + + const res = await handleSkillMarketApi(req, url, ['api', 'skill-market']) + + expect(res.status).toBe(200) + expect(hasInstalledProvider).toBe(true) + expect(capturedParams).toEqual({ + source: 'skillhub', + sort: 'updated', + query: 'vetter', + cursor: 'abc', + limit: 12, + }) + }) +}) diff --git a/src/server/api/skill-market.ts b/src/server/api/skill-market.ts new file mode 100644 index 00000000..dee43e4d --- /dev/null +++ b/src/server/api/skill-market.ts @@ -0,0 +1,127 @@ +import { + createSkillMarketService, + type SkillMarketListParams, + type SkillMarketService, + type SkillMarketServiceOptions, +} from '../services/skillMarket/service.js' +import { collectUserSkillNames } from './skills.js' + +type SkillMarketServiceFactory = (options: SkillMarketServiceOptions) => SkillMarketService + +const SUPPORTED_SOURCES = new Set(['auto', 'clawhub', 'skillhub']) +const SUPPORTED_SORTS = new Set(['downloads', 'installs', 'stars', 'updated', 'trending']) +const MAX_LIMIT = 100 + +let skillMarketServiceFactory: SkillMarketServiceFactory = createSkillMarketService + +export function setSkillMarketServiceFactoryForTests(factory: SkillMarketServiceFactory): void { + skillMarketServiceFactory = factory +} + +export function resetSkillMarketServiceFactoryForTests(): void { + skillMarketServiceFactory = createSkillMarketService +} + +export async function handleSkillMarketApi( + req: Request, + url: URL, + segments: string[], +): Promise { + const action = segments[2] + + if (req.method === 'GET' && action === undefined) { + const params = parseListParams(url) + if (params instanceof Response) { + return params + } + + const service = skillMarketServiceFactory({ + installedSkillNames: collectUserSkillNames, + }) + const result = await service.list(params) + return Response.json(result) + } + + if (action === 'install') { + if (req.method !== 'POST') { + return jsonError('method_not_allowed', 'Method not allowed for skill market install.', 405) + } + return handleInstallSkeleton(req) + } + + if (action !== undefined) { + return jsonError('not_found', `Unknown skill market endpoint: ${action}`, 404) + } + + return jsonError('method_not_allowed', 'Method not allowed for skill market.', 405) +} + +function parseListParams(url: URL): SkillMarketListParams | Response { + const params: SkillMarketListParams = {} + const source = url.searchParams.get('source') + const sort = url.searchParams.get('sort') + const limit = url.searchParams.get('limit') + const query = url.searchParams.get('query') ?? url.searchParams.get('q') + const cursor = url.searchParams.get('cursor') + + if (source !== null) { + if (!SUPPORTED_SOURCES.has(source)) { + return jsonError('unsupported_source', `Unsupported skill market source: ${source}`, 400) + } + params.source = source as SkillMarketListParams['source'] + } + + if (sort !== null) { + if (!SUPPORTED_SORTS.has(sort)) { + return jsonError('unsupported_sort', `Unsupported skill market sort: ${sort}`, 400) + } + params.sort = sort as SkillMarketListParams['sort'] + } + + if (limit !== null) { + const parsedLimit = Number(limit) + if (!Number.isInteger(parsedLimit) || parsedLimit < 1 || parsedLimit > MAX_LIMIT) { + return jsonError('invalid_limit', 'Skill market limit must be an integer from 1 to 100.', 400) + } + params.limit = parsedLimit + } + + if (query !== null) { + params.query = query + } + + if (cursor !== null) { + params.cursor = cursor + } + + return params +} + +async function handleInstallSkeleton(req: Request): Promise { + const body = await parseJsonObject(req) + if (!body) { + return jsonError('invalid_json', 'Request body must be a JSON object.', 400) + } + + if ('targetPath' in body || 'target' in body || 'path' in body) { + return jsonError('target_path_not_allowed', 'Install target is computed by the server.', 400) + } + + return jsonError('install_not_wired', 'Skill market install is not wired yet.', 501) +} + +async function parseJsonObject(req: Request): Promise | null> { + try { + const body = await req.json() + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return null + } + return body as Record + } catch { + return null + } +} + +function jsonError(error: string, message: string, status: number): Response { + return Response.json({ error, message }, { status }) +} diff --git a/src/server/api/skills.ts b/src/server/api/skills.ts index 2e743879..18745db7 100644 --- a/src/server/api/skills.ts +++ b/src/server/api/skills.ts @@ -403,6 +403,11 @@ async function collectAllSkills(cwd?: string): Promise { return skills } +export async function collectUserSkillNames(): Promise> { + const skills = await collectAllSkills(undefined) + return new Set(skills.filter((skill) => skill.source === 'user').map((skill) => skill.name)) +} + export async function listSkillSlashCommands(cwd?: string): Promise { const requestedCwd = cwd || getCwd() const [skills, legacyCommands] = await Promise.all([ diff --git a/src/server/router.ts b/src/server/router.ts index 8936b7e5..9e17e2d4 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -16,6 +16,7 @@ import { handleProvidersApi } from './api/providers.js' import { handleAdaptersApi } from './api/adapters.js' import { handlePluginsApi } from './api/plugins.js' import { handleSkillsApi } from './api/skills.js' +import { handleSkillMarketApi } from './api/skill-market.js' import { handleComputerUseApi } from './api/computer-use.js' import { handleHahaOAuthApi } from './api/haha-oauth.js' import { handleHahaOpenAIOAuthApi } from './api/haha-openai-oauth.js' @@ -90,6 +91,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise