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
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user