From 9d0f5cae478fa4640613468016a4b201979f970f Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Wed, 28 Jan 2026 12:09:05 -0600 Subject: [PATCH] feat(perf): add gzip response compression middleware --- .../telemetry/events-2026-01-28.ndjson | 1 + pnpm-lock.yaml | 50 +++ server/.veritas-kanban/activity.json | 22 ++ server/package.json | 2 + server/src/__tests__/jwt-rotation.test.ts | 301 ++++++++++++++++++ server/src/index.ts | 9 + server/src/routes/agent-status.ts | 9 +- .../shared/AgentStatusIndicator.tsx | 5 +- web/src/lib/api.ts | 2 +- 9 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 server/src/__tests__/jwt-rotation.test.ts diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index b5fa10b4..384c90d5 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -1796,3 +1796,4 @@ {"type":"task.created","taskId":"task_20260128_16hw73","project":"project-a","status":"todo","id":"evt_zTX9X7v877UF","timestamp":"2026-01-28T18:08:08.501Z"} {"type":"task.created","taskId":"task_20260128__MeCW1","project":"project-b","status":"todo","id":"evt_r4uHTWxguKEW","timestamp":"2026-01-28T18:08:08.502Z"} {"type":"task.created","taskId":"task_20260128_nmhW6e","status":"todo","id":"evt_rHh1Qe7ijCn0","timestamp":"2026-01-28T18:08:08.517Z"} +{"type":"task.status_changed","taskId":"task_20260128_VxOHXP","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_bVKyHndjSs3o","timestamp":"2026-01-28T18:08:31.860Z"} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7de6bdca..daafecb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: bcrypt: specifier: ^6.0.0 version: 6.0.0 + compression: + specifier: ^1.8.1 + version: 1.8.1 cookie-parser: specifier: ^1.4.7 version: 1.4.7 @@ -149,6 +152,9 @@ importers: '@types/bcrypt': specifier: ^6.0.0 version: 6.0.0 + '@types/compression': + specifier: ^1.8.1 + version: 1.8.1 '@types/cookie-parser': specifier: ^1.4.10 version: 1.4.10(@types/express@5.0.6) @@ -1501,6 +1507,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/compression@1.8.1': + resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -2017,6 +2026,14 @@ packages: resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} engines: {node: '>= 10'} + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -3095,6 +3112,10 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -3153,6 +3174,10 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -5108,6 +5133,11 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/compression@1.8.1': + dependencies: + '@types/express': 5.0.6 + '@types/node': 22.19.7 + '@types/connect@3.4.38': dependencies: '@types/node': 22.19.7 @@ -5745,6 +5775,22 @@ snapshots: normalize-path: 3.0.0 readable-stream: 3.6.2 + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + concat-map@0.0.1: {} concat-stream@2.0.0: @@ -6995,6 +7041,8 @@ snapshots: negotiator@0.6.3: {} + negotiator@0.6.4: {} + negotiator@1.0.0: {} node-addon-api@8.5.0: {} @@ -7049,6 +7097,8 @@ snapshots: dependencies: ee-first: 1.1.1 + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index 4547bd02..3df81e0d 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,4 +1,26 @@ [ + { + "id": "activity_1769623711885_ibjorjxag", + "type": "comment_added", + "taskId": "task_20260128_VxOHXP", + "taskTitle": "DEPLOYMENT: Create production Dockerfile with multi-stage build", + "details": { + "author": "Veritas", + "preview": "Created production Dockerfile with 5-stage multi-s..." + }, + "timestamp": "2026-01-28T18:08:31.885Z" + }, + { + "id": "activity_1769623711861_w4479d2dj", + "type": "status_changed", + "taskId": "task_20260128_VxOHXP", + "taskTitle": "DEPLOYMENT: Create production Dockerfile with multi-stage build", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T18:08:31.861Z" + }, { "id": "activity_1769623679587_4j15ts55x", "type": "status_changed", diff --git a/server/package.json b/server/package.json index 53524df5..85475480 100644 --- a/server/package.json +++ b/server/package.json @@ -18,6 +18,7 @@ "dependencies": { "@veritas-kanban/shared": "workspace:*", "bcrypt": "^6.0.0", + "compression": "^1.8.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.3", @@ -37,6 +38,7 @@ }, "devDependencies": { "@types/bcrypt": "^6.0.0", + "@types/compression": "^1.8.1", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/exceljs": "^1.3.2", diff --git a/server/src/__tests__/jwt-rotation.test.ts b/server/src/__tests__/jwt-rotation.test.ts new file mode 100644 index 00000000..dcd2e90b --- /dev/null +++ b/server/src/__tests__/jwt-rotation.test.ts @@ -0,0 +1,301 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import crypto from 'crypto'; +import jwt from 'jsonwebtoken'; + +// We need to mock the filesystem and env before importing the module +const mockFs: Record = {}; + +vi.mock('fs', () => ({ + default: { + existsSync: (path: string) => path in mockFs, + readFileSync: (path: string) => { + if (path in mockFs) return mockFs[path]; + throw new Error(`ENOENT: ${path}`); + }, + writeFileSync: (path: string, data: string) => { + mockFs[path] = data; + }, + renameSync: (from: string, to: string) => { + mockFs[to] = mockFs[from]; + delete mockFs[from]; + }, + mkdirSync: () => {}, + }, +})); + +// Import after mocks are set up +import { + getSecurityConfig, + getJwtSecret, + getValidJwtSecrets, + rotateJwtSecret, + getJwtRotationStatus, + saveSecurityConfig, + type SecurityConfig, + type JwtSecretEntry, +} from '../config/security.js'; + +describe('JWT Secret Rotation', () => { + const CONFIG_DIR = process.env.VERITAS_DATA_DIR || `${process.cwd()}/.veritas-kanban`; + const CONFIG_PATH = `${CONFIG_DIR}/security.json`; + + beforeEach(() => { + // Clear mock filesystem + for (const key of Object.keys(mockFs)) { + delete mockFs[key]; + } + // Clear env var + delete process.env.VERITAS_JWT_SECRET; + // Reset module cache by clearing the config + // Force cache invalidation by setting a fresh config + }); + + afterEach(() => { + delete process.env.VERITAS_JWT_SECRET; + }); + + function setConfig(config: SecurityConfig) { + mockFs[CONFIG_PATH] = JSON.stringify(config); + // Force cache invalidation + saveSecurityConfig(config); + } + + describe('getJwtSecret', () => { + it('should return env var when set', () => { + process.env.VERITAS_JWT_SECRET = 'env-secret-123'; + expect(getJwtSecret()).toBe('env-secret-123'); + }); + + it('should return latest secret from jwtSecrets array', () => { + setConfig({ + jwtSecrets: [ + { secret: 'old-secret', version: 1, createdAt: '2026-01-01T00:00:00Z', expiresAt: '2026-02-01T00:00:00Z' }, + { secret: 'current-secret', version: 2, createdAt: '2026-01-15T00:00:00Z' }, + ], + jwtSecretVersion: 2, + }); + expect(getJwtSecret()).toBe('current-secret'); + }); + + it('should fall back to legacy jwtSecret field', () => { + setConfig({ jwtSecret: 'legacy-secret' }); + expect(getJwtSecret()).toBe('legacy-secret'); + }); + }); + + describe('getValidJwtSecrets', () => { + it('should return only env var when set', () => { + process.env.VERITAS_JWT_SECRET = 'env-secret'; + expect(getValidJwtSecrets()).toEqual(['env-secret']); + }); + + it('should return current and non-expired secrets', () => { + const futureDate = new Date(Date.now() + 86400000).toISOString(); // +1 day + setConfig({ + jwtSecrets: [ + { secret: 'old-secret', version: 1, createdAt: '2026-01-01T00:00:00Z', expiresAt: futureDate }, + { secret: 'current-secret', version: 2, createdAt: '2026-01-15T00:00:00Z' }, + ], + jwtSecretVersion: 2, + }); + + const secrets = getValidJwtSecrets(); + expect(secrets).toHaveLength(2); + expect(secrets[0]).toBe('current-secret'); // Current first + expect(secrets[1]).toBe('old-secret'); + }); + + it('should exclude expired secrets', () => { + const pastDate = new Date(Date.now() - 86400000).toISOString(); // -1 day + setConfig({ + jwtSecrets: [ + { secret: 'expired-secret', version: 1, createdAt: '2026-01-01T00:00:00Z', expiresAt: pastDate }, + { secret: 'current-secret', version: 2, createdAt: '2026-01-15T00:00:00Z' }, + ], + jwtSecretVersion: 2, + }); + + const secrets = getValidJwtSecrets(); + expect(secrets).toHaveLength(1); + expect(secrets[0]).toBe('current-secret'); + }); + }); + + describe('rotateJwtSecret', () => { + it('should fail when env var is set', () => { + process.env.VERITAS_JWT_SECRET = 'env-secret'; + const result = rotateJwtSecret(); + expect(result.success).toBe(false); + expect(result.message).toContain('VERITAS_JWT_SECRET'); + }); + + it('should migrate legacy jwtSecret to array on first rotation', () => { + setConfig({ + jwtSecret: 'legacy-secret', + authEnabled: true, + setupCompletedAt: '2026-01-01T00:00:00Z', + }); + + const result = rotateJwtSecret(); + expect(result.success).toBe(true); + expect(result.newVersion).toBe(2); // legacy=1, new=2 + + const config = getSecurityConfig(); + expect(config.jwtSecrets).toHaveLength(2); + // Legacy secret should have an expiresAt + const legacyEntry = config.jwtSecrets!.find(s => s.secret === 'legacy-secret'); + expect(legacyEntry).toBeDefined(); + expect(legacyEntry!.expiresAt).toBeDefined(); + // New secret should NOT have expiresAt + const newEntry = config.jwtSecrets!.find(s => s.version === 2); + expect(newEntry).toBeDefined(); + expect(newEntry!.expiresAt).toBeUndefined(); + }); + + it('should rotate existing array', () => { + const secret1 = crypto.randomBytes(32).toString('hex'); + setConfig({ + jwtSecrets: [ + { secret: secret1, version: 1, createdAt: '2026-01-01T00:00:00Z' }, + ], + jwtSecretVersion: 1, + }); + + const result = rotateJwtSecret(); + expect(result.success).toBe(true); + expect(result.newVersion).toBe(2); + + const config = getSecurityConfig(); + expect(config.jwtSecrets).toHaveLength(2); + // Old secret should have expiresAt + const oldEntry = config.jwtSecrets!.find(s => s.version === 1); + expect(oldEntry!.expiresAt).toBeDefined(); + }); + + it('should prune expired secrets during rotation', () => { + const pastDate = new Date(Date.now() - 86400000).toISOString(); + const secret1 = crypto.randomBytes(32).toString('hex'); + const secret2 = crypto.randomBytes(32).toString('hex'); + setConfig({ + jwtSecrets: [ + { secret: secret1, version: 1, createdAt: '2026-01-01T00:00:00Z', expiresAt: pastDate }, + { secret: secret2, version: 2, createdAt: '2026-01-15T00:00:00Z' }, + ], + jwtSecretVersion: 2, + }); + + const result = rotateJwtSecret(); + expect(result.success).toBe(true); + expect(result.prunedCount).toBe(1); + expect(result.newVersion).toBe(3); + + const config = getSecurityConfig(); + // Should have version 2 (with expiresAt) and version 3 (current) + expect(config.jwtSecrets).toHaveLength(2); + expect(config.jwtSecrets!.find(s => s.version === 1)).toBeUndefined(); + }); + + it('should support custom grace period', () => { + const secret1 = crypto.randomBytes(32).toString('hex'); + setConfig({ + jwtSecrets: [ + { secret: secret1, version: 1, createdAt: '2026-01-01T00:00:00Z' }, + ], + jwtSecretVersion: 1, + }); + + // 0ms grace period + const result = rotateJwtSecret(0); + expect(result.success).toBe(true); + + const config = getSecurityConfig(); + const oldEntry = config.jwtSecrets!.find(s => s.version === 1); + expect(oldEntry!.expiresAt).toBeDefined(); + // With 0 grace, the expiresAt should be approximately now + const expiresAt = new Date(oldEntry!.expiresAt!).getTime(); + expect(Math.abs(expiresAt - Date.now())).toBeLessThan(5000); // within 5 seconds + }); + }); + + describe('getJwtRotationStatus', () => { + it('should report env var usage', () => { + process.env.VERITAS_JWT_SECRET = 'env-secret'; + const status = getJwtRotationStatus(); + expect(status.usingEnvVar).toBe(true); + }); + + it('should report secret versions', () => { + const futureDate = new Date(Date.now() + 86400000).toISOString(); + setConfig({ + jwtSecrets: [ + { secret: 'old', version: 1, createdAt: '2026-01-01T00:00:00Z', expiresAt: futureDate }, + { secret: 'current', version: 2, createdAt: '2026-01-15T00:00:00Z' }, + ], + jwtSecretVersion: 2, + }); + + const status = getJwtRotationStatus(); + expect(status.currentVersion).toBe(2); + expect(status.totalSecrets).toBe(2); + expect(status.validSecrets).toBe(2); + expect(status.secrets).toHaveLength(2); + expect(status.secrets[0].isCurrent).toBe(true); + expect(status.secrets[0].version).toBe(2); + }); + }); + + describe('Token verification across rotation', () => { + it('should verify tokens signed with previous secret during grace period', () => { + // Set up initial secret + const initialSecret = crypto.randomBytes(32).toString('hex'); + setConfig({ + jwtSecrets: [ + { secret: initialSecret, version: 1, createdAt: '2026-01-01T00:00:00Z' }, + ], + jwtSecretVersion: 1, + }); + + // Sign a token with the initial secret + const token = jwt.sign({ type: 'session' }, initialSecret, { expiresIn: '24h' }); + + // Rotate the secret + rotateJwtSecret(); + + // The old secret should still be valid (within grace period) + const secrets = getValidJwtSecrets(); + expect(secrets.length).toBeGreaterThanOrEqual(2); + + // Verify the old token works with the fallback secrets + let verified = false; + for (const secret of secrets) { + try { + jwt.verify(token, secret); + verified = true; + break; + } catch { + continue; + } + } + expect(verified).toBe(true); + }); + + it('should sign new tokens with current (latest) secret', () => { + setConfig({ + jwtSecrets: [ + { secret: 'old-secret', version: 1, createdAt: '2026-01-01T00:00:00Z', expiresAt: new Date(Date.now() + 86400000).toISOString() }, + { secret: 'new-secret', version: 2, createdAt: '2026-01-15T00:00:00Z' }, + ], + jwtSecretVersion: 2, + }); + + // getJwtSecret should return the current (v2) secret + const signingSecret = getJwtSecret(); + expect(signingSecret).toBe('new-secret'); + + // Token signed with current secret should verify with it + const token = jwt.sign({ type: 'session' }, signingSecret, { expiresIn: '24h' }); + const decoded = jwt.verify(token, 'new-secret'); + expect(decoded).toBeDefined(); + }); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 8b8a70e4..9af1c7b4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,6 +1,7 @@ import 'dotenv/config'; import express from 'express'; import helmet from 'helmet'; +import compression from 'compression'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import { WebSocketServer, WebSocket } from 'ws'; @@ -99,6 +100,13 @@ app.use( }) ); +// ============================================ +// Performance: Response Compression (gzip/deflate) +// ============================================ +// Compress responses > 1KB at level 6 (good balance of speed vs size). +// Placed after Helmet so security headers are set first. +app.use(compression({ level: 6, threshold: 1024 })); + // ============================================ // Security: CORS Configuration // ============================================ @@ -418,6 +426,7 @@ server.listen(PORT, () => { ║ ${authLine.padEnd(42)}║ ║ ${corsLine.padEnd(42)}║ ║ Helmet: ON (CSP + security headers) ║ +║ Compress: ON (gzip, threshold 1KB) ║ ║ Rate Limit: 100 req/min ║ ║ Body Limit: 1MB ║ ╚═══════════════════════════════════════════════╝ diff --git a/server/src/routes/agent-status.ts b/server/src/routes/agent-status.ts index d432ffd4..fda6f0e4 100644 --- a/server/src/routes/agent-status.ts +++ b/server/src/routes/agent-status.ts @@ -135,8 +135,15 @@ const updateStatusSchema = z.object({ }); // GET /api/agent/status - Get current agent status +// Flatten activeTask for frontend compatibility router.get('/', asyncHandler(async (_req, res) => { - res.json(currentStatus); + const { activeTask, errorMessage, ...rest } = currentStatus; + res.json({ + ...rest, + activeTask: activeTask?.id, + activeTaskTitle: activeTask?.title, + error: errorMessage, + }); })); // POST /api/agent/status - Update agent status diff --git a/web/src/components/shared/AgentStatusIndicator.tsx b/web/src/components/shared/AgentStatusIndicator.tsx index 55996b7e..f591fb71 100644 --- a/web/src/components/shared/AgentStatusIndicator.tsx +++ b/web/src/components/shared/AgentStatusIndicator.tsx @@ -229,8 +229,9 @@ export function AgentStatusIndicator({ className = '' }: AgentStatusIndicatorPro if (error) return 'error'; if (!data) return 'idle'; if (data.status === 'error') return 'error'; - if (data.subAgentCount > 0) return 'subagents'; - return data.status; + if (data.subAgentCount > 0 || data.status === 'sub-agent') return 'subagents'; + if (data.status === 'sub-agent') return 'subagents'; + return data.status as AgentState; }, [data, error]); const config = STATE_CONFIG[state]; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 268b25e5..7b72c11f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -802,7 +802,7 @@ export interface AgentOutput { // Global agent status (not per-task) export interface GlobalAgentStatus { - status: 'idle' | 'working' | 'thinking' | 'error'; + status: 'idle' | 'working' | 'thinking' | 'sub-agent' | 'error'; subAgentCount: number; activeTask?: string; activeTaskTitle?: string;