From dc27ee9f214b9166eec1880162b3ce99fe67a3ce Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 2 Apr 2026 23:54:42 -0700 Subject: [PATCH] =?UTF-8?q?feat(cowork-service):=20H.5=20audit=20proxy=20r?= =?UTF-8?q?outes=20=E2=80=94=20dual-source=20audit=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add audit query routes to cowork-service that proxy to platform-service: - GET /api/audit — query audit logs with action/category/days/limit/offset filters - GET /api/audit/stats — aggregated audit stats - modules/audit/types.ts — AuditQuerySchema (Zod) - modules/audit/routes.ts — proxy routes with error handling - server.ts — register auditRoutes - server.test.ts — add audit routes mock, update register count to 4 57 tests passing, 9 test files, typecheck clean --- .../src/modules/audit/routes.ts | 74 +++++++++++++++++++ .../cowork-service/src/modules/audit/types.ts | 17 +++++ services/cowork-service/src/server.test.ts | 5 +- services/cowork-service/src/server.ts | 2 + 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 services/cowork-service/src/modules/audit/routes.ts create mode 100644 services/cowork-service/src/modules/audit/types.ts diff --git a/services/cowork-service/src/modules/audit/routes.ts b/services/cowork-service/src/modules/audit/routes.ts new file mode 100644 index 00000000..54991992 --- /dev/null +++ b/services/cowork-service/src/modules/audit/routes.ts @@ -0,0 +1,74 @@ +/** + * Audit proxy routes — forward queries to platform-service GET /audit. + * + * These routes let the Tauri desktop app query centralized audit data + * from platform-service via cowork-service, providing a REST alternative + * to the local Rust IPC path for when the user is online. + */ + +import type { FastifyInstance } from 'fastify'; +import { config } from '../../lib/config.js'; +import { PRODUCT_ID } from '../../lib/product-config.js'; +import { AuditQuerySchema } from './types.js'; + +export async function auditRoutes(app: FastifyInstance) { + const platformUrl = config.PLATFORM_SERVICE_URL; + + // GET /api/audit — proxy to platform-service GET /audit + app.get('/api/audit', async (req, reply) => { + const parsed = AuditQuerySchema.safeParse(req.query); + if (!parsed.success) { + reply.code(400); + return { error: 'Invalid query', details: parsed.error.issues }; + } + + const { action, category, days, limit, offset } = parsed.data; + const params = new URLSearchParams(); + if (action) params.set('action', action); + if (category) params.set('category', category); + params.set('days', String(days)); + params.set('limit', String(limit)); + params.set('offset', String(offset)); + + try { + const res = await fetch(`${platformUrl}/audit?${params}`, { + 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 audit query to platform-service'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } + }); + + // GET /api/audit/stats — proxy to platform-service GET /audit/stats + app.get('/api/audit/stats', async (req, reply) => { + const { days = '30' } = req.query as { days?: string }; + + try { + const res = await fetch(`${platformUrl}/audit/stats?days=${encodeURIComponent(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 audit stats to platform-service'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } + }); +} diff --git a/services/cowork-service/src/modules/audit/types.ts b/services/cowork-service/src/modules/audit/types.ts new file mode 100644 index 00000000..713d79a5 --- /dev/null +++ b/services/cowork-service/src/modules/audit/types.ts @@ -0,0 +1,17 @@ +/** + * Audit query types for cowork-service proxy routes. + * + * These match the platform-service GET /audit query parameters. + */ + +import { z } from 'zod'; + +export const AuditQuerySchema = z.object({ + action: z.string().optional(), + category: z.string().optional(), + days: z.coerce.number().int().min(1).max(365).default(30), + limit: z.coerce.number().int().min(1).max(1000).default(100), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type AuditQueryInput = z.infer; diff --git a/services/cowork-service/src/server.test.ts b/services/cowork-service/src/server.test.ts index 9ffad68b..c66b34cc 100644 --- a/services/cowork-service/src/server.test.ts +++ b/services/cowork-service/src/server.test.ts @@ -70,6 +70,7 @@ vi.mock('./lib/llm-router.js', () => ({ isLlmRouterReady: vi.fn(() => false), })); vi.mock('./modules/llm/routes.js', () => ({ llmRoutes: vi.fn() })); +vi.mock('./modules/audit/routes.js', () => ({ auditRoutes: vi.fn() })); describe('cowork-service bootstrap', () => { beforeEach(() => { @@ -89,9 +90,9 @@ describe('cowork-service bootstrap', () => { expect(opts.version).toBe('0.1.0'); expect(opts.readiness).toBe(true); - // JWT context + health + task + llm routes = 3 register calls + 1 JWT + // JWT context + health + task + llm + audit routes = 4 register calls + 1 JWT expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); - expect(appMock.register).toHaveBeenCalledTimes(3); + expect(appMock.register).toHaveBeenCalledTimes(4); 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 1f64b150..9dd9d2a3 100644 --- a/services/cowork-service/src/server.ts +++ b/services/cowork-service/src/server.ts @@ -25,6 +25,7 @@ import { getIpcBridge } from './lib/ipc-bridge.js'; 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 type { JwtPayload } from './lib/request-context.js'; const jwtSecret = new TextEncoder().encode(config.JWT_SECRET); @@ -54,6 +55,7 @@ await registerOptionalJwtContext(app, { await app.register(healthRoutes); await app.register(taskRoutes); await app.register(llmRoutes); +await app.register(auditRoutes); // Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.) app.get('/api/bootstrap', async () => ({