feat(perf): add gzip response compression middleware

This commit is contained in:
Brad Groux 2026-01-28 12:09:05 -06:00
parent 12c9f4ed65
commit 9d0f5cae47
9 changed files with 397 additions and 4 deletions

View file

@ -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"}

50
pnpm-lock.yaml generated
View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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<string, string> = {};
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();
});
});
});

View file

@ -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 ║
╚═══════════════════════════════════════════════╝

View file

@ -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

View file

@ -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];

View file

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