feat(cowork-service): H.5 audit proxy routes — dual-source audit queries

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
This commit is contained in:
saravanakumardb1 2026-04-02 23:54:42 -07:00
parent ca7c3e571e
commit dc27ee9f21
4 changed files with 96 additions and 2 deletions

View File

@ -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' };
}
});
}

View File

@ -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<typeof AuditQuerySchema>;

View File

@ -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' });
});
});

View File

@ -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 () => ({