From cd5483c95bb0643f717c027029a20a673c141288 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 2 Apr 2026 23:58:14 -0700 Subject: [PATCH] feat(cowork-service): H.6 usage/budget proxy routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add usage query routes to cowork-service that proxy to platform-service: - GET /api/usage/summary — user's aggregated usage with days filter - POST /api/usage/check-limits — check if user is within plan limits - modules/usage/types.ts — UsageSummaryQuerySchema, CheckLimitsSchema (Zod) - modules/usage/routes.ts — proxy routes with error handling - server.ts — register usageRoutes (5 route modules total) - server.test.ts — add usage routes mock, update register count to 5 57 tests passing, 9 test files, typecheck clean --- .../src/modules/usage/routes.ts | 82 +++++++++++++++++++ .../cowork-service/src/modules/usage/types.ts | 16 ++++ services/cowork-service/src/server.test.ts | 5 +- services/cowork-service/src/server.ts | 2 + 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 services/cowork-service/src/modules/usage/routes.ts create mode 100644 services/cowork-service/src/modules/usage/types.ts 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 () => ({