feat(cowork-service): H.12 marketplace proxy — migrate TO platform marketplace
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)
This commit is contained in:
parent
d838cd658b
commit
bba26f2d5f
258
services/cowork-service/src/modules/marketplace/routes.test.ts
Normal file
258
services/cowork-service/src/modules/marketplace/routes.test.ts
Normal file
@ -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<typeof Fastify>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
296
services/cowork-service/src/modules/marketplace/routes.ts
Normal file
296
services/cowork-service/src/modules/marketplace/routes.ts
Normal file
@ -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<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'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<string, string>,
|
||||
req: { log: { warn: (obj: unknown, msg: string) => void } },
|
||||
reply: { code: (n: number) => void },
|
||||
): Promise<unknown> {
|
||||
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<string, string>,
|
||||
body: unknown,
|
||||
req: { log: { warn: (obj: unknown, msg: string) => void } },
|
||||
reply: { code: (n: number) => void },
|
||||
): Promise<unknown> {
|
||||
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<void> {
|
||||
|
||||
// ── 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<string, string>).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,
|
||||
);
|
||||
});
|
||||
}
|
||||
32
services/cowork-service/src/modules/marketplace/types.ts
Normal file
32
services/cowork-service/src/modules/marketplace/types.ts
Normal file
@ -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),
|
||||
});
|
||||
@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string, unknown>).costUsd as number | undefined;
|
||||
const costUsd = (result as unknown as Record<string, unknown>).costUsd as number | undefined;
|
||||
if (costUsd && params.auth && params.taskId) {
|
||||
bridge.recordSpend(
|
||||
result.model,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user