Merge feat/goo-175-phase3-ws3b-bull-board into master
Some checks failed
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 7s
Deploy / Build API Image (push) Failing after 17s
Deploy / Build Web Image (push) Failing after 7s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m16s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 10s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Failing after 10m47s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Deploy / Rollback Staging (push) Has been skipped
Security Scanning / Trivy Scan — API Image (push) Failing after 40s
Security Scanning / Trivy Scan — Web Image (push) Failing after 38s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 42s
Security Scanning / Trivy Filesystem Scan (push) Failing after 34s
Security Scanning / Security Gate (push) Failing after 3s
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 12s
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled

6 commits covering:
- BullMQ Redis split + Prometheus queue metrics + Bull Board admin UI
  (RFC-004 Phase 3 WS1 / WS3a / WS3b)
- Dual-key JWT verification for WebSocket auth
- Test infrastructure stubs + AVM spec fix (GOO-131)
- Complete MFA grace period feature for required roles + SLO monitoring

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-29 12:19:17 +07:00
36 changed files with 1430 additions and 275 deletions

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describeRedisTopology, getRedisConnection } from '../redis-connection.config';
const KEYS = [
'REDIS_HOST',
'REDIS_PORT',
'REDIS_PASSWORD',
'REDIS_QUEUE_HOST',
'REDIS_QUEUE_PORT',
'REDIS_QUEUE_PASSWORD',
] as const;
describe('redis-connection.config', () => {
let original: Record<string, string | undefined>;
beforeEach(() => {
original = Object.fromEntries(KEYS.map((k) => [k, process.env[k]]));
for (const k of KEYS) delete process.env[k];
});
afterEach(() => {
for (const k of KEYS) {
if (original[k] === undefined) delete process.env[k];
else process.env[k] = original[k];
}
});
it('defaults cache and queue to localhost:6379 when nothing is set', () => {
expect(getRedisConnection('cache')).toEqual({ host: 'localhost', port: 6379, password: undefined });
expect(getRedisConnection('queue')).toEqual({ host: 'localhost', port: 6379, password: undefined });
});
it('queue falls back to cache vars when queue-specific vars are unset', () => {
process.env['REDIS_HOST'] = 'cache.internal';
process.env['REDIS_PORT'] = '6380';
process.env['REDIS_PASSWORD'] = 'pw';
expect(getRedisConnection('queue')).toEqual({ host: 'cache.internal', port: 6380, password: 'pw' });
});
it('queue vars take precedence when set', () => {
process.env['REDIS_HOST'] = 'cache.internal';
process.env['REDIS_QUEUE_HOST'] = 'queue.internal';
process.env['REDIS_QUEUE_PORT'] = '6400';
process.env['REDIS_QUEUE_PASSWORD'] = 'qpw';
expect(getRedisConnection('queue')).toEqual({ host: 'queue.internal', port: 6400, password: 'qpw' });
expect(getRedisConnection('cache').host).toBe('cache.internal');
});
it('describeRedisTopology reports shared=true when cache and queue resolve to the same host/port', () => {
process.env['REDIS_HOST'] = 'one';
process.env['REDIS_PORT'] = '6379';
const t = describeRedisTopology();
expect(t.shared).toBe(true);
expect(t.cache.passwordSet).toBe(false);
});
it('describeRedisTopology reports shared=false when queue is split off', () => {
process.env['REDIS_HOST'] = 'one';
process.env['REDIS_QUEUE_HOST'] = 'two';
process.env['REDIS_PASSWORD'] = 'pw';
const t = describeRedisTopology();
expect(t.shared).toBe(false);
expect(t.queue.host).toBe('two');
expect(t.cache.passwordSet).toBe(true);
});
it('never leaks the password through describeRedisTopology', () => {
process.env['REDIS_PASSWORD'] = 'super-secret';
const t = describeRedisTopology();
expect(JSON.stringify(t)).not.toContain('super-secret');
});
});

View File

@@ -45,6 +45,17 @@ const REQUIRED_WHEN_USED: ReadonlyMap<string, string> = new Map([
* Known placeholder values that must never be used as real secrets.
* Comparison is case-insensitive to catch common variants.
*/
/**
* Previous-version secrets used during key rotation. Validated if set but never
* required. Note: JWT_REFRESH_SECRET_PREVIOUS currently has no runtime consumer
* because refresh tokens are opaque random bytes, not JWTs — the variable is
* accepted here for forward-compatibility should the refresh mechanism change.
*/
const OPTIONAL_PREVIOUS_SECRETS: readonly string[] = [
'JWT_SECRET_PREVIOUS',
'JWT_REFRESH_SECRET_PREVIOUS',
];
const FORBIDDEN_SECRET_VALUES: readonly string[] = [
'change_me',
'changeme',
@@ -127,6 +138,25 @@ export function validateEnv(): void {
);
}
// Validate optional previous secrets if they are set (rotation window).
const prevSecretErrors: string[] = [];
for (const key of OPTIONAL_PREVIOUS_SECRETS) {
const value = process.env[key];
if (value) {
const error = validateJwtSecret(key, value);
if (error) {
prevSecretErrors.push(error);
}
}
}
if (prevSecretErrors.length > 0) {
throw new Error(
`Insecure previous-secret configuration:\n ${prevSecretErrors.join('\n ')}\n` +
'Previous secrets must meet the same strength requirements as primary secrets.',
);
}
if (!isProduction) {
return;
}

View File

@@ -0,0 +1,63 @@
/**
* Redis connection configuration helpers.
*
* RFC-004 Phase 3 — workstream 1: split the Redis connection used by BullMQ
* from the connection used for cache / throttler / websocket adapter.
*
* Contract:
* - Cache / throttler / ws adapter: read REDIS_HOST / REDIS_PORT / REDIS_PASSWORD.
* - Queue (BullMQ): read REDIS_QUEUE_HOST / REDIS_QUEUE_PORT / REDIS_QUEUE_PASSWORD,
* falling back to the cache vars so dev / single-instance deploys keep working
* with a single Redis.
* - In production, split connections are recommended so a hot cache path cannot
* starve queue operations (and vice versa). The two hosts can still point at
* the same server; the split exists so ops can point them elsewhere without a
* code change.
*/
export type RedisConnectionPurpose = 'cache' | 'queue';
export interface RedisConnectionOptions {
host: string;
port: number;
password: string | undefined;
}
function readCacheConnection(): RedisConnectionOptions {
return {
host: process.env['REDIS_HOST'] ?? 'localhost',
port: Number(process.env['REDIS_PORT'] ?? 6379),
password: process.env['REDIS_PASSWORD'] ?? undefined,
};
}
function readQueueConnection(): RedisConnectionOptions {
const cache = readCacheConnection();
return {
host: process.env['REDIS_QUEUE_HOST'] ?? cache.host,
port: Number(process.env['REDIS_QUEUE_PORT'] ?? cache.port),
password: process.env['REDIS_QUEUE_PASSWORD'] ?? cache.password,
};
}
export function getRedisConnection(purpose: RedisConnectionPurpose): RedisConnectionOptions {
return purpose === 'queue' ? readQueueConnection() : readCacheConnection();
}
/**
* Returns a loggable summary of how cache vs queue connections are bound.
* Never includes the password — only host, port, and whether a password is set.
*/
export function describeRedisTopology(): {
cache: { host: string; port: number; passwordSet: boolean };
queue: { host: string; port: number; passwordSet: boolean };
shared: boolean;
} {
const cache = readCacheConnection();
const queue = readQueueConnection();
return {
cache: { host: cache.host, port: cache.port, passwordSet: Boolean(cache.password) },
queue: { host: queue.host, port: queue.port, passwordSet: Boolean(queue.password) },
shared: cache.host === queue.host && cache.port === queue.port,
};
}