diff --git a/services/cowork-service/src/modules/usage/routes.ts b/services/cowork-service/src/modules/usage/routes.ts new file mode 100644 index 00000000..bc4e8008 --- /dev/null +++ b/services/cowork-service/src/modules/usage/routes.ts @@ -0,0 +1,82 @@ +/** + * Usage/budget proxy routes — forward queries to platform-service /usage endpoints. + * + * Lets the Tauri desktop check AI budget consumption and plan limits + * via cowork-service, which proxies to platform-service. + */ + +import type { FastifyInstance } from 'fastify'; +import { config } from '../../lib/config.js'; +import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getUserId } from '../../lib/request-context.js'; +import { UsageSummaryQuerySchema, CheckLimitsSchema } from './types.js'; + +export async function usageRoutes(app: FastifyInstance) { + const platformUrl = config.PLATFORM_SERVICE_URL; + + // GET /api/usage/summary — proxy to platform-service GET /usage/summary + app.get('/api/usage/summary', async (req, reply) => { + const parsed = UsageSummaryQuerySchema.safeParse(req.query); + if (!parsed.success) { + reply.code(400); + return { error: 'Invalid query', details: parsed.error.issues }; + } + + const userId = getUserId(req); + const { days } = parsed.data; + + try { + const res = await fetch( + `${platformUrl}/usage/summary?userId=${encodeURIComponent(userId)}&days=${days}`, + { + headers: { + 'x-product-id': PRODUCT_ID, + 'x-request-id': req.id, + }, + }, + ); + 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 usage summary to platform-service'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } + }); + + // POST /api/usage/check-limits — proxy to platform-service POST /usage/check-limits + app.post('/api/usage/check-limits', async (req, reply) => { + const parsed = CheckLimitsSchema.safeParse(req.body); + if (!parsed.success) { + reply.code(400); + return { error: 'Invalid body', details: parsed.error.issues }; + } + + const userId = getUserId(req); + const { plan } = parsed.data; + + try { + const res = await fetch(`${platformUrl}/usage/check-limits`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-product-id': PRODUCT_ID, + 'x-request-id': req.id, + }, + body: JSON.stringify({ userId, plan }), + }); + 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 limit check to platform-service'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } + }); +} diff --git a/services/cowork-service/src/modules/usage/types.ts b/services/cowork-service/src/modules/usage/types.ts new file mode 100644 index 00000000..581da3e6 --- /dev/null +++ b/services/cowork-service/src/modules/usage/types.ts @@ -0,0 +1,16 @@ +/** + * Usage/budget query types for cowork-service proxy routes. + */ + +import { z } from 'zod'; + +export const UsageSummaryQuerySchema = z.object({ + days: z.coerce.number().int().min(1).max(365).default(30), +}); + +export const CheckLimitsSchema = z.object({ + plan: z.string().default('free'), +}); + +export type UsageSummaryQuery = z.infer; +export type CheckLimitsInput = z.infer; diff --git a/services/cowork-service/src/server.test.ts b/services/cowork-service/src/server.test.ts index c66b34cc..2e5c9aba 100644 --- a/services/cowork-service/src/server.test.ts +++ b/services/cowork-service/src/server.test.ts @@ -71,6 +71,7 @@ vi.mock('./lib/llm-router.js', () => ({ })); vi.mock('./modules/llm/routes.js', () => ({ llmRoutes: vi.fn() })); vi.mock('./modules/audit/routes.js', () => ({ auditRoutes: vi.fn() })); +vi.mock('./modules/usage/routes.js', () => ({ usageRoutes: vi.fn() })); describe('cowork-service bootstrap', () => { beforeEach(() => { @@ -90,9 +91,9 @@ describe('cowork-service bootstrap', () => { expect(opts.version).toBe('0.1.0'); expect(opts.readiness).toBe(true); - // JWT context + health + task + llm + audit routes = 4 register calls + 1 JWT + // JWT context + health + task + llm + audit + usage routes = 5 register calls + 1 JWT expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); - expect(appMock.register).toHaveBeenCalledTimes(4); + expect(appMock.register).toHaveBeenCalledTimes(5); 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 9dd9d2a3..a1f98527 100644 --- a/services/cowork-service/src/server.ts +++ b/services/cowork-service/src/server.ts @@ -26,6 +26,7 @@ import { getFlushScheduler } from './lib/flush-scheduler.js'; import { initLlmRouter } from './lib/llm-router.js'; import { llmRoutes } from './modules/llm/routes.js'; import { auditRoutes } from './modules/audit/routes.js'; +import { usageRoutes } from './modules/usage/routes.js'; import type { JwtPayload } from './lib/request-context.js'; const jwtSecret = new TextEncoder().encode(config.JWT_SECRET); @@ -56,6 +57,7 @@ await app.register(healthRoutes); await app.register(taskRoutes); await app.register(llmRoutes); await app.register(auditRoutes); +await app.register(usageRoutes); // Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.) app.get('/api/bootstrap', async () => ({