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:
parent
ca7c3e571e
commit
dc27ee9f21
74
services/cowork-service/src/modules/audit/routes.ts
Normal file
74
services/cowork-service/src/modules/audit/routes.ts
Normal 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' };
|
||||
}
|
||||
});
|
||||
}
|
||||
17
services/cowork-service/src/modules/audit/types.ts
Normal file
17
services/cowork-service/src/modules/audit/types.ts
Normal 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>;
|
||||
@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 () => ({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user