From bba26f2d5f0c55a1b136f63314dfcb89d97c39ed Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 3 Apr 2026 01:26:30 -0700 Subject: [PATCH] =?UTF-8?q?feat(cowork-service):=20H.12=20marketplace=20pr?= =?UTF-8?q?oxy=20=E2=80=94=20migrate=20TO=20platform=20marketplace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add marketplace module that proxies all requests to platform-service's marketplace module. Replaces the local plugin marketplace (Phase 10) with the shared ByteLyst platform marketplace. Endpoints proxied (18 routes): Public: browse catalog, listing detail, reviews, featured, categories Consumer: install/uninstall, my installs, add review, vote toggle Author: create/update/delete listings, my listings, submit, publish Changes: - modules/marketplace/types.ts: Zod schemas for query validation - modules/marketplace/routes.ts: 18 proxy routes with proxyGet/proxyMutate helpers, productId injection, x-request-id forwarding - modules/marketplace/routes.test.ts: 13 tests (public, consumer, author, error handling, 502 fallback, query validation) - server.ts: Register marketplaceRoutes (8th module), fix costUsd type cast - server.test.ts: Add marketplace mock, update register count 7→8 Test count: 98 (was 85) --- .../src/modules/marketplace/routes.test.ts | 258 +++++++++++++++ .../src/modules/marketplace/routes.ts | 296 ++++++++++++++++++ .../src/modules/marketplace/types.ts | 32 ++ services/cowork-service/src/server.test.ts | 5 +- services/cowork-service/src/server.ts | 4 +- 5 files changed, 592 insertions(+), 3 deletions(-) create mode 100644 services/cowork-service/src/modules/marketplace/routes.test.ts create mode 100644 services/cowork-service/src/modules/marketplace/routes.ts create mode 100644 services/cowork-service/src/modules/marketplace/types.ts diff --git a/services/cowork-service/src/modules/marketplace/routes.test.ts b/services/cowork-service/src/modules/marketplace/routes.test.ts new file mode 100644 index 00000000..ef2609fa --- /dev/null +++ b/services/cowork-service/src/modules/marketplace/routes.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import { marketplaceRoutes } from './routes.js'; + +vi.mock('../../lib/config.js', () => ({ + config: { + PLATFORM_SERVICE_URL: 'http://mock-platform:4003', + }, +})); + +vi.mock('../../lib/product-config.js', () => ({ + PRODUCT_ID: 'clawcowork', +})); + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function buildApp() { + const app = Fastify({ logger: false }); + app.decorateRequest('jwtPayload', null); + app.addHook('onRequest', async (req) => { + (req as any).jwtPayload = { sub: 'user-1', role: 'user' }; + }); + app.register(marketplaceRoutes); + return app; +} + +describe('marketplace proxy routes', () => { + let app: ReturnType; + + beforeEach(() => { + app = buildApp(); + mockFetch.mockReset(); + }); + + afterEach(async () => { + await app.close(); + }); + + // ── Public Routes ── + + it('GET /api/marketplace proxies to platform-service public catalog', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ items: [], total: 0, limit: 20, offset: 0 }), + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/marketplace?category=agent&limit=10', + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.items).toEqual([]); + expect(body.total).toBe(0); + + expect(mockFetch).toHaveBeenCalledOnce(); + const fetchUrl = mockFetch.mock.calls[0][0] as string; + expect(fetchUrl).toContain('/public/marketplace?'); + expect(fetchUrl).toContain('productId=clawcowork'); + expect(fetchUrl).toContain('category=agent'); + expect(fetchUrl).toContain('limit=10'); + }); + + it('GET /api/marketplace/:id proxies listing detail', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ id: 'lst_123', title: 'Test Plugin' }), + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/marketplace/lst_123', + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).id).toBe('lst_123'); + expect(mockFetch.mock.calls[0][0]).toContain('/public/marketplace/lst_123'); + }); + + it('GET /api/marketplace/featured proxies featured listings', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ items: [{ id: 'lst_f1' }] }), + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/marketplace/featured?limit=5', + }); + + expect(res.statusCode).toBe(200); + expect(mockFetch.mock.calls[0][0]).toContain('/public/marketplace/featured'); + }); + + it('GET /api/marketplace/categories proxies categories', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ categories: ['agent', 'skill', 'workflow'] }), + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/marketplace/categories', + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).categories).toEqual(['agent', 'skill', 'workflow']); + }); + + // ── Consumer Routes ── + + it('POST /api/marketplace/:id/install proxies install', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ install: { id: 'inst_1' }, payload: {} }), + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/marketplace/lst_123/install', + }); + + expect(res.statusCode).toBe(200); + expect(mockFetch.mock.calls[0][0]).toContain('/marketplace/listings/lst_123/install'); + expect(mockFetch.mock.calls[0][1].method).toBe('POST'); + }); + + it('DELETE /api/marketplace/:id/install proxies uninstall', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true }), + }); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/marketplace/lst_123/install', + }); + + expect(res.statusCode).toBe(200); + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE'); + }); + + it('GET /api/marketplace/installs proxies my installs with productId', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ items: [], total: 0 }), + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/marketplace/installs', + }); + + expect(res.statusCode).toBe(200); + const fetchUrl = mockFetch.mock.calls[0][0] as string; + expect(fetchUrl).toContain('productId=clawcowork'); + }); + + it('POST /api/marketplace/:id/vote proxies vote toggle', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ voted: true, voteCount: 5 }), + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/marketplace/lst_123/vote', + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).voted).toBe(true); + }); + + // ── Author Routes ── + + it('POST /api/marketplace/listings proxies create listing', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ id: 'lst_new', title: 'New Plugin' }), + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/marketplace/listings', + payload: { title: 'New Plugin', description: 'A test plugin' }, + }); + + expect(res.statusCode).toBe(200); + expect(mockFetch.mock.calls[0][1].method).toBe('POST'); + expect(mockFetch.mock.calls[0][0]).toContain('/marketplace/listings'); + }); + + it('GET /api/marketplace/listings/mine proxies my listings', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ listings: [] }), + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/marketplace/listings/mine', + }); + + expect(res.statusCode).toBe(200); + expect(mockFetch.mock.calls[0][0]).toContain('/marketplace/listings/mine'); + }); + + // ── Error Handling ── + + it('returns 502 when platform-service is unreachable', async () => { + mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); + + const res = await app.inject({ + method: 'GET', + url: '/api/marketplace', + }); + + expect(res.statusCode).toBe(502); + expect(JSON.parse(res.body).error).toBe('Platform-service unavailable'); + }); + + it('forwards platform error status codes', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + text: async () => JSON.stringify({ error: 'Listing not found' }), + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/marketplace/lst_missing/install', + }); + + expect(res.statusCode).toBe(404); + }); + + it('returns 400 for invalid query parameters', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/marketplace?limit=-1', + }); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toBe('Invalid query'); + }); +}); diff --git a/services/cowork-service/src/modules/marketplace/routes.ts b/services/cowork-service/src/modules/marketplace/routes.ts new file mode 100644 index 00000000..c9f3d9e9 --- /dev/null +++ b/services/cowork-service/src/modules/marketplace/routes.ts @@ -0,0 +1,296 @@ +/** + * Marketplace proxy routes for cowork-service. + * + * Forwards marketplace requests to platform-service's marketplace module. + * This replaces the local plugin marketplace (Phase 10) with the shared + * ByteLyst platform marketplace — H.12 in the ecosystem integration roadmap. + * + * Endpoints proxied: + * Public: GET /api/marketplace, GET /api/marketplace/:id, GET /api/marketplace/:id/reviews, + * GET /api/marketplace/featured, GET /api/marketplace/categories + * Consumer: POST /api/marketplace/:id/install, DELETE /api/marketplace/:id/install, + * GET /api/marketplace/installs, POST /api/marketplace/:id/reviews, + * POST /api/marketplace/:id/vote + * Author: POST /api/marketplace/listings, GET /api/marketplace/listings/mine, + * PUT /api/marketplace/listings/:id, DELETE /api/marketplace/listings/:id, + * POST /api/marketplace/listings/:id/submit, POST /api/marketplace/listings/:id/publish + */ + +import type { FastifyInstance } from 'fastify'; +import { config } from '../../lib/config.js'; +import { PRODUCT_ID } from '../../lib/product-config.js'; +import { ListListingsQuerySchema, ListInstallsQuerySchema, ListReviewsQuerySchema } from './types.js'; + +const platformUrl = config.PLATFORM_SERVICE_URL; + +/** Build standard proxy headers for platform-service. */ +function proxyHeaders(req: { id: string; jwtPayload?: { sub: string } }): Record { + const headers: Record = { + 'x-product-id': PRODUCT_ID, + 'x-request-id': req.id, + 'content-type': 'application/json', + }; + if (req.jwtPayload?.sub) { + headers['x-user-id'] = req.jwtPayload.sub; + } + return headers; +} + +/** Forward a GET request to platform-service and return the JSON response. */ +async function proxyGet( + url: string, + headers: Record, + req: { log: { warn: (obj: unknown, msg: string) => void } }, + reply: { code: (n: number) => void }, +): Promise { + try { + const res = await fetch(url, { headers }); + if (!res.ok) { + reply.code(res.status); + return { error: `Platform returned ${res.status}` }; + } + return res.json(); + } catch (err) { + req.log.warn({ err }, 'Failed to proxy marketplace request to platform-service'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } +} + +/** Forward a mutating request (POST/PUT/DELETE) to platform-service. */ +async function proxyMutate( + method: string, + url: string, + headers: Record, + body: unknown, + req: { log: { warn: (obj: unknown, msg: string) => void } }, + reply: { code: (n: number) => void }, +): Promise { + try { + const res = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + reply.code(res.status); + const text = await res.text(); + try { return JSON.parse(text); } catch { return { error: text || `Platform returned ${res.status}` }; } + } + // 204 No Content + if (res.status === 204) return { success: true }; + return res.json(); + } catch (err) { + req.log.warn({ err }, 'Failed to proxy marketplace mutation to platform-service'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } +} + +export async function marketplaceRoutes(app: FastifyInstance): Promise { + + // ── Public Routes ── + + // Browse catalog + app.get('/api/marketplace', async (req, reply) => { + const parsed = ListListingsQuerySchema.safeParse(req.query); + if (!parsed.success) { + reply.code(400); + return { error: 'Invalid query', details: parsed.error.issues }; + } + const params = new URLSearchParams(); + params.set('productId', PRODUCT_ID); + for (const [k, v] of Object.entries(parsed.data)) { + if (v !== undefined) params.set(k, String(v)); + } + return proxyGet(`${platformUrl}/public/marketplace?${params}`, proxyHeaders(req), req, reply); + }); + + // Featured listings + app.get('/api/marketplace/featured', async (req, reply) => { + const limit = (req.query as Record).limit || '10'; + const params = new URLSearchParams({ limit }); + return proxyGet( + `${platformUrl}/public/marketplace/featured?${params}`, + { ...proxyHeaders(req) }, + req, reply, + ); + }); + + // Categories + app.get('/api/marketplace/categories', async (req, reply) => { + return proxyGet( + `${platformUrl}/public/marketplace/categories`, + { ...proxyHeaders(req) }, + req, reply, + ); + }); + + // Listing detail + app.get('/api/marketplace/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + return proxyGet( + `${platformUrl}/public/marketplace/${encodeURIComponent(id)}`, + proxyHeaders(req), + req, reply, + ); + }); + + // Listing reviews + app.get('/api/marketplace/:id/reviews', async (req, reply) => { + const { id } = req.params as { id: string }; + const parsed = ListReviewsQuerySchema.safeParse(req.query); + if (!parsed.success) { + reply.code(400); + return { error: 'Invalid query', details: parsed.error.issues }; + } + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(parsed.data)) { + if (v !== undefined) params.set(k, String(v)); + } + return proxyGet( + `${platformUrl}/public/marketplace/${encodeURIComponent(id)}/reviews?${params}`, + proxyHeaders(req), + req, reply, + ); + }); + + // ── Consumer Routes (Authenticated) ── + + // Install listing + app.post('/api/marketplace/:id/install', async (req, reply) => { + const { id } = req.params as { id: string }; + return proxyMutate( + 'POST', + `${platformUrl}/marketplace/listings/${encodeURIComponent(id)}/install`, + proxyHeaders(req), + null, + req, reply, + ); + }); + + // Uninstall listing + app.delete('/api/marketplace/:id/install', async (req, reply) => { + const { id } = req.params as { id: string }; + return proxyMutate( + 'DELETE', + `${platformUrl}/marketplace/listings/${encodeURIComponent(id)}/install`, + proxyHeaders(req), + null, + req, reply, + ); + }); + + // My installs + app.get('/api/marketplace/installs', async (req, reply) => { + const parsed = ListInstallsQuerySchema.safeParse(req.query); + if (!parsed.success) { + reply.code(400); + return { error: 'Invalid query', details: parsed.error.issues }; + } + const params = new URLSearchParams(); + params.set('productId', PRODUCT_ID); + for (const [k, v] of Object.entries(parsed.data)) { + if (v !== undefined) params.set(k, String(v)); + } + return proxyGet( + `${platformUrl}/marketplace/installs?${params}`, + proxyHeaders(req), + req, reply, + ); + }); + + // Add review + app.post('/api/marketplace/:id/reviews', async (req, reply) => { + const { id } = req.params as { id: string }; + return proxyMutate( + 'POST', + `${platformUrl}/marketplace/listings/${encodeURIComponent(id)}/reviews`, + proxyHeaders(req), + req.body, + req, reply, + ); + }); + + // Vote/upvote toggle + app.post('/api/marketplace/:id/vote', async (req, reply) => { + const { id } = req.params as { id: string }; + return proxyMutate( + 'POST', + `${platformUrl}/marketplace/listings/${encodeURIComponent(id)}/vote`, + proxyHeaders(req), + null, + req, reply, + ); + }); + + // ── Author Routes (Authenticated) ── + + // Create listing + app.post('/api/marketplace/listings', async (req, reply) => { + return proxyMutate( + 'POST', + `${platformUrl}/marketplace/listings`, + proxyHeaders(req), + req.body, + req, reply, + ); + }); + + // My listings + app.get('/api/marketplace/listings/mine', async (req, reply) => { + return proxyGet( + `${platformUrl}/marketplace/listings/mine`, + proxyHeaders(req), + req, reply, + ); + }); + + // Update listing + app.put('/api/marketplace/listings/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + return proxyMutate( + 'PUT', + `${platformUrl}/marketplace/listings/${encodeURIComponent(id)}`, + proxyHeaders(req), + req.body, + req, reply, + ); + }); + + // Delete listing + app.delete('/api/marketplace/listings/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + return proxyMutate( + 'DELETE', + `${platformUrl}/marketplace/listings/${encodeURIComponent(id)}`, + proxyHeaders(req), + null, + req, reply, + ); + }); + + // Submit for certification + app.post('/api/marketplace/listings/:id/submit', async (req, reply) => { + const { id } = req.params as { id: string }; + return proxyMutate( + 'POST', + `${platformUrl}/marketplace/listings/${encodeURIComponent(id)}/submit`, + proxyHeaders(req), + req.body, + req, reply, + ); + }); + + // Publish listing + app.post('/api/marketplace/listings/:id/publish', async (req, reply) => { + const { id } = req.params as { id: string }; + return proxyMutate( + 'POST', + `${platformUrl}/marketplace/listings/${encodeURIComponent(id)}/publish`, + proxyHeaders(req), + null, + req, reply, + ); + }); +} diff --git a/services/cowork-service/src/modules/marketplace/types.ts b/services/cowork-service/src/modules/marketplace/types.ts new file mode 100644 index 00000000..f2d76ce0 --- /dev/null +++ b/services/cowork-service/src/modules/marketplace/types.ts @@ -0,0 +1,32 @@ +/** + * Zod schemas for marketplace proxy endpoints. + * + * These validate query parameters before forwarding to platform-service. + * The actual marketplace business logic lives in platform-service. + */ + +import { z } from 'zod'; + +export const ListListingsQuerySchema = z.object({ + templateType: z.string().optional(), + category: z.string().optional(), + tags: z.string().optional(), + pricingModel: z.string().optional(), + minRating: z.coerce.number().min(0).max(5).optional(), + sortBy: z.enum(['installCount', 'rating', 'newest', 'trending', 'createdAt']).default('createdAt'), + sortOrder: z.enum(['asc', 'desc']).default('desc'), + q: z.string().optional(), + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0), +}); + +export const ListInstallsQuerySchema = z.object({ + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0), +}); + +export const ListReviewsQuerySchema = z.object({ + sortBy: z.enum(['newest', 'rating', 'helpful']).default('newest'), + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0), +}); diff --git a/services/cowork-service/src/server.test.ts b/services/cowork-service/src/server.test.ts index 4c13bd6e..1e93f846 100644 --- a/services/cowork-service/src/server.test.ts +++ b/services/cowork-service/src/server.test.ts @@ -76,6 +76,7 @@ vi.mock('./modules/audit/routes.js', () => ({ auditRoutes: vi.fn() })); vi.mock('./modules/usage/routes.js', () => ({ usageRoutes: vi.fn() })); vi.mock('./modules/notifications/routes.js', () => ({ notificationRoutes: vi.fn() })); vi.mock('./modules/extraction/routes.js', () => ({ extractionRoutes: vi.fn() })); +vi.mock('./modules/marketplace/routes.js', () => ({ marketplaceRoutes: vi.fn() })); describe('cowork-service bootstrap', () => { beforeEach(() => { @@ -95,9 +96,9 @@ describe('cowork-service bootstrap', () => { expect(opts.version).toBe('0.1.0'); expect(opts.readiness).toBe(true); - // health + task + llm + audit + usage + notifications + extraction = 7 register calls + 1 JWT + // health + task + llm + audit + usage + notifications + extraction + marketplace = 8 register calls + 1 JWT expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); - expect(appMock.register).toHaveBeenCalledTimes(7); + expect(appMock.register).toHaveBeenCalledTimes(8); expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4009, host: '0.0.0.0' }); }); }); diff --git a/services/cowork-service/src/server.ts b/services/cowork-service/src/server.ts index fb4af849..b409102a 100644 --- a/services/cowork-service/src/server.ts +++ b/services/cowork-service/src/server.ts @@ -29,6 +29,7 @@ import { auditRoutes } from './modules/audit/routes.js'; import { usageRoutes } from './modules/usage/routes.js'; import { notificationRoutes } from './modules/notifications/routes.js'; import { extractionRoutes } from './modules/extraction/routes.js'; +import { marketplaceRoutes } from './modules/marketplace/routes.js'; import type { JwtPayload } from './lib/request-context.js'; const jwtSecret = new TextEncoder().encode(config.JWT_SECRET); @@ -62,6 +63,7 @@ await app.register(auditRoutes); await app.register(usageRoutes); await app.register(notificationRoutes); await app.register(extractionRoutes); +await app.register(marketplaceRoutes); // Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.) app.get('/api/bootstrap', async () => ({ @@ -114,7 +116,7 @@ bridge.onIncomingRequest(async (method, params) => { }); // Record spend for budget tracking (best-effort) - const costUsd = (result as Record).costUsd as number | undefined; + const costUsd = (result as unknown as Record).costUsd as number | undefined; if (costUsd && params.auth && params.taskId) { bridge.recordSpend( result.model,