mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 12:17:50 +00:00
feat(perf): add gzip response compression middleware
This commit is contained in:
parent
12c9f4ed65
commit
9d0f5cae47
9 changed files with 397 additions and 4 deletions
|
|
@ -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
50
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
301
server/src/__tests__/jwt-rotation.test.ts
Normal file
301
server/src/__tests__/jwt-rotation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ║
|
||||
╚═══════════════════════════════════════════════╝
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue