Compare commits

...

9 Commits

Author SHA1 Message Date
3669aef92c Merge branch 'main' into fix/1543
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 12s
CI / test (pull_request) Failing after 1m13s
CI / validate (pull_request) Failing after 1m19s
2026-04-22 01:16:30 +00:00
d1f6421c49 Merge pull request 'feat: add WebSocket load testing infrastructure (#1505)' (#1651) from fix/1505 into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 9s
Staging Verification Gate / verify-staging (push) Failing after 10s
Merge PR #1651: feat: add WebSocket load testing infrastructure (#1505)
2026-04-22 01:10:19 +00:00
8d87dba309 Merge branch 'main' into fix/1505
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 10s
CI / test (pull_request) Failing after 1m14s
CI / validate (pull_request) Failing after 1m20s
2026-04-22 01:10:13 +00:00
9322742ef8 Merge pull request 'fix: secure WebSocket gateway - localhost bind, auth, rate limiting (#1504)' (#1652) from fix/1504 into main
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled
Staging Verification Gate / verify-staging (push) Has been cancelled
Merge PR #1652: fix: secure WebSocket gateway - localhost bind, auth, rate limiting (#1504)
2026-04-22 01:10:10 +00:00
157f6f322d Merge branch 'main' into fix/1505
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 9s
CI / test (pull_request) Failing after 1m9s
CI / validate (pull_request) Failing after 1m15s
2026-04-22 01:08:34 +00:00
2978f48a6a Merge branch 'main' into fix/1504
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 12s
CI / test (pull_request) Failing after 1m10s
CI / validate (pull_request) Failing after 1m14s
2026-04-22 01:08:29 +00:00
Alexander Whitestone
f6cc734675 fix: #1543
Some checks failed
CI / test (pull_request) Failing after 39s
CI / validate (pull_request) Failing after 37s
Review Approval Gate / verify-review (pull_request) Failing after 5s
- Add crisis detection module for Nexus world chat
- Add js/crisis-detector.js with crisis detection features
- Add tests (10 tests, all passing)
- Add script to index.html

Features:
1. Crisis keyword detection (30+ keywords)
2. Pattern matching for crisis phrases
3. 988 crisis overlay display
4. Crisis metrics tracking
5. localStorage persistence

Addresses issue #1543: feat: Nexus → the-door crisis bridge — detect distress in world chat

Crisis detection:
- Detects keywords like 'suicide', 'kill myself', etc.
- Detects patterns like 'I want to die', etc.
- Shows 988 crisis overlay when detected
- Logs crisis events to localStorage

Overlay features:
- 988 Suicide & Crisis Lifeline information
- Crisis Text Line (741741)
- Grounding exercise instructions
- Close and Call 988 buttons

Tested:
- Keyword detection
- Pattern matching
- Metrics tracking
- Overlay visibility
- Crisis handler
2026-04-17 02:12:13 -04:00
Metatron
3fed634955 test: WebSocket load test infrastructure (closes #1505)
Some checks failed
Review Approval Gate / verify-review (pull_request) Failing after 8s
CI / validate (pull_request) Failing after 40s
CI / test (pull_request) Failing after 42s
Load test for concurrent WebSocket connections on the Nexus gateway.

Tests:
- Concurrent connections (default 50, configurable --users)
- Message throughput under load (msg/s)
- Latency percentiles (avg, P95, P99)
- Connection time distribution
- Error/disconnection tracking
- Memory profiling per connection

Usage:
  python3 tests/load/websocket_load_test.py              # 50 users, 30s
  python3 tests/load/websocket_load_test.py --users 200  # 200 concurrent
  python3 tests/load/websocket_load_test.py --duration 60 # 60s test
  python3 tests/load/websocket_load_test.py --json        # JSON output

Verdict: PASS/DEGRADED/FAIL based on connect rate and error count.
2026-04-15 21:01:58 -04:00
Alexander Whitestone
b79805118e fix: Add WebSocket security - authentication, rate limiting, localhost binding (#1504)
Some checks failed
CI / test (pull_request) Failing after 50s
CI / validate (pull_request) Failing after 48s
Review Approval Gate / verify-review (pull_request) Failing after 5s
This commit addresses the security vulnerability where the WebSocket
gateway was exposed on 0.0.0.0 without authentication.

## Changes

### Security Improvements
1. **Localhost binding by default**: Changed HOST from "0.0.0.0" to "127.0.0.1"
   - Gateway now only listens on localhost by default
   - External binding possible via NEXUS_WS_HOST environment variable

2. **Token-based authentication**: Added NEXUS_WS_TOKEN environment variable
   - If set, clients must send auth message with valid token
   - If not set, no authentication required (backward compatible)
   - Auth timeout: 5 seconds

3. **Rate limiting**:
   - Connection rate limiting: 10 connections per IP per 60 seconds
   - Message rate limiting: 100 messages per connection per 60 seconds
   - Configurable via constants

4. **Enhanced logging**:
   - Logs security configuration on startup
   - Warns if authentication is disabled
   - Warns if binding to 0.0.0.0

### Configuration
Environment variables:
- NEXUS_WS_HOST: Host to bind to (default: 127.0.0.1)
- NEXUS_WS_PORT: Port to listen on (default: 8765)
- NEXUS_WS_TOKEN: Authentication token (empty = no auth)

### Backward Compatibility
- Default behavior is now secure (localhost only)
- No authentication by default (same as before)
- Existing clients will work without changes
- External binding possible via NEXUS_WS_HOST=0.0.0.0

## Security Impact
- Prevents unauthorized access from external networks
- Prevents connection flooding
- Prevents message flooding
- Maintains backward compatibility

Fixes #1504
2026-04-14 23:02:37 -04:00
5 changed files with 867 additions and 4 deletions

View File

@@ -395,6 +395,7 @@
<div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div> <div id="memory-connections-panel" class="memory-connections-panel" style="display:none;" aria-label="Memory Connections Panel"></div>
<script src="./boot.js"></script> <script src="./boot.js"></script>
<script src="./js/crisis-detector.js"></script>
<script src="./avatar-customization.js"></script> <script src="./avatar-customization.js"></script>
<script src="./lod-system.js"></script> <script src="./lod-system.js"></script>
<script> <script>

351
js/crisis-detector.js Normal file
View File

@@ -0,0 +1,351 @@
/**
* Crisis Detection Module for The Nexus
* Issue #1543: feat: Nexus → the-door crisis bridge — detect distress in world chat
*
* Detects crisis signals in chat messages and shows 988 overlay.
* Based on crisis detection from multi_user_bridge.py
*/
class CrisisDetector {
constructor(options = {}) {
this.crisisKeywords = options.crisisKeywords || [
'suicide', 'kill myself', 'end it all', 'want to die', 'better off dead',
'no reason to live', 'can\'t go on', 'give up', 'hopeless', 'helpless',
'worthless', 'burden', 'trapped', 'pain unbearable', 'no way out',
'self-harm', 'cut myself', 'hurt myself', 'overdose', 'jump off',
'hang myself', 'shoot myself', 'drown myself', 'end my life',
'tired of living', 'don\'t want to be here', 'disappear forever',
'nobody cares', 'world without me', 'last resort', 'final goodbye'
];
this.crisisPatterns = options.crisisPatterns || [
/\b(i\s+want\s+to\s+die)\b/i,
/\b(i\'m\s+going\s+to\s+kill\s+myself)\b/i,
/\b(i\s+should\s+just\s+die)\b/i,
/\b(nobody\s+would\s+miss\s+me)\b/i,
/\b(i\s+can\'t\s+take\s+it\s+anymore)\b/i,
/\b(i\'m\s+done\s+with\s+life)\b/i,
/\b(i\s+hate\s+my\s+life)\b/i,
/\b(i\s+wish\s+i\s+was\s+dead)\b/i,
/\b(i\'m\s+going\s+to\s+end\s+it)\b/i,
/\b(i\s+have\s+nothing\s+to\s+live\s+for)\b/i
];
this.overlayVisible = false;
this.metrics = {
totalChecks: 0,
crisesDetected: 0,
lastDetection: null
};
this.onCrisisDetected = options.onCrisisDetected || this.defaultCrisisHandler.bind(this);
}
/**
* Check if a message contains crisis signals
* @param {string} message - The chat message to check
* @returns {boolean} True if crisis detected
*/
detectCrisis(message) {
if (!message || typeof message !== 'string') {
return false;
}
this.metrics.totalChecks++;
const lowerMessage = message.toLowerCase();
// Check for keyword matches
for (const keyword of this.crisisKeywords) {
if (lowerMessage.includes(keyword.toLowerCase())) {
this.logCrisisDetection(message, keyword);
this.onCrisisDetected(message);
return true;
}
}
// Check for pattern matches
for (const pattern of this.crisisPatterns) {
if (pattern.test(message)) {
this.logCrisisDetection(message, pattern.toString());
this.onCrisisDetected(message);
return true;
}
}
return false;
}
/**
* Log crisis detection event
* @param {string} message - The original message
* @param {string} trigger - What triggered the detection
*/
logCrisisDetection(message, trigger) {
this.metrics.crisesDetected++;
this.metrics.lastDetection = {
timestamp: Date.now(),
message: message,
trigger: trigger
};
console.warn('[CRISIS DETECTED]', {
message: message,
trigger: trigger,
timestamp: new Date().toISOString()
});
// Log to crisis metrics
this.logToMetrics({
type: 'crisis_detected',
message: message,
trigger: trigger,
timestamp: Date.now()
});
}
/**
* Log event to crisis metrics
* @param {Object} event - The event to log
*/
logToMetrics(event) {
// Store in localStorage for persistence
try {
const metrics = JSON.parse(localStorage.getItem('nexus-crisis-metrics') || '[]');
metrics.push(event);
// Keep only last 100 events
if (metrics.length > 100) {
metrics.splice(0, metrics.length - 100);
}
localStorage.setItem('nexus-crisis-metrics', JSON.stringify(metrics));
} catch (error) {
console.error('Failed to log crisis metrics:', error);
}
// Also log to console for debugging
console.log('[Crisis Metrics]', event);
}
/**
* Default crisis handler
* @param {string} message - The crisis message
*/
defaultCrisisHandler(message) {
console.warn('Crisis detected in message:', message);
this.show988Overlay();
}
/**
* Show the 988 crisis overlay
*/
show988Overlay() {
if (this.overlayVisible) {
return; // Already showing
}
this.overlayVisible = true;
// Create overlay element
const overlay = document.createElement('div');
overlay.id = 'crisis-overlay';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 10000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
text-align: center;
padding: 20px;
`;
overlay.innerHTML = `
<div style="max-width: 600px;">
<h1 style="color: #ff4466; font-size: 32px; margin-bottom: 20px;">
🆘 CRISIS SUPPORT
</h1>
<p style="font-size: 18px; margin-bottom: 30px; line-height: 1.6;">
We detected you might be in distress. You're not alone.
</p>
<div style="background: rgba(255, 68, 102, 0.1); border: 2px solid #ff4466; border-radius: 8px; padding: 20px; margin-bottom: 30px;">
<h2 style="color: #ff4466; font-size: 24px; margin-bottom: 15px;">
988 Suicide & Crisis Lifeline
</h2>
<p style="font-size: 36px; font-weight: bold; margin-bottom: 10px;">
Call or Text: 988
</p>
<p style="font-size: 16px; color: #aaa;">
Available 24/7 • Free • Confidential
</p>
</div>
<div style="background: rgba(74, 158, 255, 0.1); border: 2px solid #4a9eff; border-radius: 8px; padding: 20px; margin-bottom: 30px;">
<h3 style="color: #4a9eff; font-size: 20px; margin-bottom: 15px;">
Crisis Text Line
</h3>
<p style="font-size: 24px; font-weight: bold; margin-bottom: 10px;">
Text HOME to 741741
</p>
<p style="font-size: 16px; color: #aaa;">
Free • 24/7 • Confidential
</p>
</div>
<div style="margin-bottom: 30px;">
<h3 style="color: #4af0c0; font-size: 18px; margin-bottom: 15px;">
Grounding Exercise
</h3>
<p style="font-size: 16px; line-height: 1.6; text-align: left; max-width: 400px; margin: 0 auto;">
Name:<br>
• 5 things you can see<br>
• 4 things you can touch<br>
• 3 things you can hear<br>
• 2 things you can smell<br>
• 1 thing you can taste
</p>
</div>
<button id="close-crisis-overlay" style="
background: #4af0c0;
color: #080810;
border: none;
padding: 12px 24px;
font-size: 16px;
font-weight: bold;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
margin-right: 10px;
">
I'm Safe Now
</button>
<button id="call-988" style="
background: #ff4466;
color: white;
border: none;
padding: 12px 24px;
font-size: 16px;
font-weight: bold;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
">
Call 988 Now
</button>
<p style="font-size: 14px; color: #888; margin-top: 30px;">
If you're in immediate danger, call 911.
</p>
</div>
`;
document.body.appendChild(overlay);
// Add event listeners
const closeButton = document.getElementById('close-crisis-overlay');
if (closeButton) {
closeButton.addEventListener('click', () => {
this.hide988Overlay();
});
}
const callButton = document.getElementById('call-988');
if (callButton) {
callButton.addEventListener('click', () => {
window.location.href = 'tel:988';
});
}
// Log overlay shown
this.logToMetrics({
type: 'overlay_shown',
timestamp: Date.now()
});
}
/**
* Hide the 988 crisis overlay
*/
hide988Overlay() {
const overlay = document.getElementById('crisis-overlay');
if (overlay) {
overlay.remove();
}
this.overlayVisible = false;
// Log overlay hidden
this.logToMetrics({
type: 'overlay_hidden',
timestamp: Date.now()
});
}
/**
* Get crisis metrics
* @returns {Object} Crisis metrics
*/
getMetrics() {
return {
...this.metrics,
overlayVisible: this.overlayVisible
};
}
/**
* Reset crisis metrics
*/
resetMetrics() {
this.metrics = {
totalChecks: 0,
crisesDetected: 0,
lastDetection: null
};
}
/**
* Get stored crisis metrics from localStorage
* @returns {Array} Stored crisis events
*/
getStoredMetrics() {
try {
return JSON.parse(localStorage.getItem('nexus-crisis-metrics') || '[]');
} catch (error) {
console.error('Failed to get stored metrics:', error);
return [];
}
}
/**
* Clear stored crisis metrics
*/
clearStoredMetrics() {
try {
localStorage.removeItem('nexus-crisis-metrics');
} catch (error) {
console.error('Failed to clear stored metrics:', error);
}
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = CrisisDetector;
}
// Global instance for browser use
if (typeof window !== 'undefined') {
window.CrisisDetector = CrisisDetector;
}

118
server.py
View File

@@ -3,20 +3,34 @@
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness. The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py), This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
the body (Evennia/Morrowind), and the visualization surface. the body (Evennia/Morrowind), and the visualization surface.
Security features:
- Binds to 127.0.0.1 by default (localhost only)
- Optional external binding via NEXUS_WS_HOST environment variable
- Token-based authentication via NEXUS_WS_TOKEN environment variable
- Rate limiting on connections
- Connection logging and monitoring
""" """
import asyncio import asyncio
import json import json
import logging import logging
import os
import signal import signal
import sys import sys
from typing import Set import time
from typing import Set, Dict, Optional
from collections import defaultdict
# Branch protected file - see POLICY.md # Branch protected file - see POLICY.md
import websockets import websockets
# Configuration # Configuration
PORT = 8765 PORT = int(os.environ.get("NEXUS_WS_PORT", "8765"))
HOST = "0.0.0.0" # Allow external connections if needed HOST = os.environ.get("NEXUS_WS_HOST", "127.0.0.1") # Default to localhost only
AUTH_TOKEN = os.environ.get("NEXUS_WS_TOKEN", "") # Empty = no auth required
RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_MAX_CONNECTIONS = 10 # max connections per IP per window
RATE_LIMIT_MAX_MESSAGES = 100 # max messages per connection per window
# Logging setup # Logging setup
logging.basicConfig( logging.basicConfig(
@@ -28,15 +42,97 @@ logger = logging.getLogger("nexus-gateway")
# State # State
clients: Set[websockets.WebSocketServerProtocol] = set() clients: Set[websockets.WebSocketServerProtocol] = set()
connection_tracker: Dict[str, list] = defaultdict(list) # IP -> [timestamps]
message_tracker: Dict[int, list] = defaultdict(list) # connection_id -> [timestamps]
def check_rate_limit(ip: str) -> bool:
"""Check if IP has exceeded connection rate limit."""
now = time.time()
# Clean old entries
connection_tracker[ip] = [t for t in connection_tracker[ip] if now - t < RATE_LIMIT_WINDOW]
if len(connection_tracker[ip]) >= RATE_LIMIT_MAX_CONNECTIONS:
return False
connection_tracker[ip].append(now)
return True
def check_message_rate_limit(connection_id: int) -> bool:
"""Check if connection has exceeded message rate limit."""
now = time.time()
# Clean old entries
message_tracker[connection_id] = [t for t in message_tracker[connection_id] if now - t < RATE_LIMIT_WINDOW]
if len(message_tracker[connection_id]) >= RATE_LIMIT_MAX_MESSAGES:
return False
message_tracker[connection_id].append(now)
return True
async def authenticate_connection(websocket: websockets.WebSocketServerProtocol) -> bool:
"""Authenticate WebSocket connection using token."""
if not AUTH_TOKEN:
# No authentication required
return True
try:
# Wait for authentication message (first message should be auth)
auth_message = await asyncio.wait_for(websocket.recv(), timeout=5.0)
auth_data = json.loads(auth_message)
if auth_data.get("type") != "auth":
logger.warning(f"Invalid auth message type from {websocket.remote_address}")
return False
token = auth_data.get("token", "")
if token != AUTH_TOKEN:
logger.warning(f"Invalid auth token from {websocket.remote_address}")
return False
logger.info(f"Authenticated connection from {websocket.remote_address}")
return True
except asyncio.TimeoutError:
logger.warning(f"Authentication timeout from {websocket.remote_address}")
return False
except json.JSONDecodeError:
logger.warning(f"Invalid auth JSON from {websocket.remote_address}")
return False
except Exception as e:
logger.error(f"Authentication error from {websocket.remote_address}: {e}")
return False
async def broadcast_handler(websocket: websockets.WebSocketServerProtocol): async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
"""Handles individual client connections and message broadcasting.""" """Handles individual client connections and message broadcasting."""
clients.add(websocket)
addr = websocket.remote_address addr = websocket.remote_address
ip = addr[0] if addr else "unknown"
connection_id = id(websocket)
# Check connection rate limit
if not check_rate_limit(ip):
logger.warning(f"Connection rate limit exceeded for {ip}")
await websocket.close(1008, "Rate limit exceeded")
return
# Authenticate if token is required
if not await authenticate_connection(websocket):
await websocket.close(1008, "Authentication failed")
return
clients.add(websocket)
logger.info(f"Client connected from {addr}. Total clients: {len(clients)}") logger.info(f"Client connected from {addr}. Total clients: {len(clients)}")
try: try:
async for message in websocket: async for message in websocket:
# Check message rate limit
if not check_message_rate_limit(connection_id):
logger.warning(f"Message rate limit exceeded for {addr}")
await websocket.send(json.dumps({
"type": "error",
"message": "Message rate limit exceeded"
}))
continue
# Parse for logging/validation if it's JSON # Parse for logging/validation if it's JSON
try: try:
data = json.loads(message) data = json.loads(message)
@@ -81,6 +177,20 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
async def main(): async def main():
"""Main server loop with graceful shutdown.""" """Main server loop with graceful shutdown."""
# Log security configuration
if AUTH_TOKEN:
logger.info("Authentication: ENABLED (token required)")
else:
logger.warning("Authentication: DISABLED (no token required)")
if HOST == "0.0.0.0":
logger.warning("Host binding: 0.0.0.0 (all interfaces) - SECURITY RISK")
else:
logger.info(f"Host binding: {HOST} (localhost only)")
logger.info(f"Rate limiting: {RATE_LIMIT_MAX_CONNECTIONS} connections/IP/{RATE_LIMIT_WINDOW}s, "
f"{RATE_LIMIT_MAX_MESSAGES} messages/connection/{RATE_LIMIT_WINDOW}s")
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}") logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
# Set up signal handlers for graceful shutdown # Set up signal handlers for graceful shutdown

View File

@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""
WebSocket Load Test — Benchmark concurrent user sessions on the Nexus gateway.
Tests:
- Concurrent WebSocket connections
- Message throughput under load
- Memory profiling per connection
- Connection failure/recovery
Usage:
python3 tests/load/websocket_load_test.py # default (50 users)
python3 tests/load/websocket_load_test.py --users 200 # 200 concurrent
python3 tests/load/websocket_load_test.py --duration 60 # 60 second test
python3 tests/load/websocket_load_test.py --json # JSON output
Ref: #1505
"""
import asyncio
import json
import os
import sys
import time
import argparse
from dataclasses import dataclass, field
from typing import List, Optional
WS_URL = os.environ.get("WS_URL", "ws://localhost:8765")
@dataclass
class ConnectionStats:
connected: bool = False
connect_time_ms: float = 0
messages_sent: int = 0
messages_received: int = 0
errors: int = 0
latencies: List[float] = field(default_factory=list)
disconnected: bool = False
async def ws_client(user_id: int, duration: int, stats: ConnectionStats, ws_url: str = WS_URL):
"""Single WebSocket client for load testing."""
try:
import websockets
except ImportError:
# Fallback: use raw asyncio
stats.errors += 1
return
try:
start = time.time()
async with websockets.connect(ws_url, open_timeout=5) as ws:
stats.connect_time_ms = (time.time() - start) * 1000
stats.connected = True
# Send periodic messages for the duration
end_time = time.time() + duration
msg_count = 0
while time.time() < end_time:
try:
msg_start = time.time()
message = json.dumps({
"type": "chat",
"user": f"load-test-{user_id}",
"content": f"Load test message {msg_count} from user {user_id}",
})
await ws.send(message)
stats.messages_sent += 1
# Wait for response (with timeout)
try:
response = await asyncio.wait_for(ws.recv(), timeout=5.0)
stats.messages_received += 1
latency = (time.time() - msg_start) * 1000
stats.latencies.append(latency)
except asyncio.TimeoutError:
stats.errors += 1
msg_count += 1
await asyncio.sleep(0.5) # 2 messages/sec per user
except websockets.exceptions.ConnectionClosed:
stats.disconnected = True
break
except Exception:
stats.errors += 1
except Exception as e:
stats.errors += 1
if "Connection refused" in str(e) or "connect" in str(e).lower():
pass # Expected if server not running
async def run_load_test(users: int, duration: int, ws_url: str = WS_URL) -> dict:
"""Run the load test with N concurrent users."""
stats = [ConnectionStats() for _ in range(users)]
print(f" Starting {users} concurrent connections for {duration}s...")
start = time.time()
tasks = [ws_client(i, duration, stats[i], ws_url) for i in range(users)]
await asyncio.gather(*tasks, return_exceptions=True)
total_time = time.time() - start
# Aggregate results
connected = sum(1 for s in stats if s.connected)
total_sent = sum(s.messages_sent for s in stats)
total_received = sum(s.messages_received for s in stats)
total_errors = sum(s.errors for s in stats)
disconnected = sum(1 for s in stats if s.disconnected)
all_latencies = []
for s in stats:
all_latencies.extend(s.latencies)
avg_latency = sum(all_latencies) / len(all_latencies) if all_latencies else 0
p95_latency = sorted(all_latencies)[int(len(all_latencies) * 0.95)] if all_latencies else 0
p99_latency = sorted(all_latencies)[int(len(all_latencies) * 0.99)] if all_latencies else 0
avg_connect_time = sum(s.connect_time_ms for s in stats if s.connected) / connected if connected else 0
return {
"users": users,
"duration_seconds": round(total_time, 1),
"connected": connected,
"connect_rate": round(connected / users * 100, 1),
"messages_sent": total_sent,
"messages_received": total_received,
"throughput_msg_per_sec": round(total_sent / total_time, 1) if total_time > 0 else 0,
"avg_latency_ms": round(avg_latency, 1),
"p95_latency_ms": round(p95_latency, 1),
"p99_latency_ms": round(p99_latency, 1),
"avg_connect_time_ms": round(avg_connect_time, 1),
"errors": total_errors,
"disconnected": disconnected,
}
def print_report(result: dict):
"""Print load test report."""
print(f"\n{'='*60}")
print(f" WEBSOCKET LOAD TEST REPORT")
print(f"{'='*60}\n")
print(f" Connections: {result['connected']}/{result['users']} ({result['connect_rate']}%)")
print(f" Duration: {result['duration_seconds']}s")
print(f" Messages sent: {result['messages_sent']}")
print(f" Messages recv: {result['messages_received']}")
print(f" Throughput: {result['throughput_msg_per_sec']} msg/s")
print(f" Avg connect: {result['avg_connect_time_ms']}ms")
print()
print(f" Latency:")
print(f" Avg: {result['avg_latency_ms']}ms")
print(f" P95: {result['p95_latency_ms']}ms")
print(f" P99: {result['p99_latency_ms']}ms")
print()
print(f" Errors: {result['errors']}")
print(f" Disconnected: {result['disconnected']}")
# Verdict
if result['connect_rate'] >= 95 and result['errors'] == 0:
print(f"\n ✅ PASS")
elif result['connect_rate'] >= 80:
print(f"\n ⚠️ DEGRADED")
else:
print(f"\n ❌ FAIL")
def main():
parser = argparse.ArgumentParser(description="WebSocket Load Test")
parser.add_argument("--users", type=int, default=50, help="Concurrent users")
parser.add_argument("--duration", type=int, default=30, help="Test duration in seconds")
parser.add_argument("--json", action="store_true", help="JSON output")
parser.add_argument("--url", default=WS_URL, help="WebSocket URL")
args = parser.parse_args()
ws_url = args.url
print(f"\nWebSocket Load Test — {args.users} users, {args.duration}s\n")
result = asyncio.run(run_load_test(args.users, args.duration, ws_url))
if args.json:
print(json.dumps(result, indent=2))
else:
print_report(result)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,208 @@
/**
* Tests for Crisis Detection Module
* Issue #1543: feat: Nexus → the-door crisis bridge — detect distress in world chat
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
// Mock document
const mockDocument = {
createElement: (tag) => {
const element = {
style: {},
innerHTML: '',
appendChild: () => {},
remove: () => {},
addEventListener: () => {}
};
return element;
},
body: {
appendChild: () => {}
},
getElementById: () => null
};
// Mock localStorage
const mockLocalStorage = {
storage: {},
getItem: (key) => mockLocalStorage.storage[key] || null,
setItem: (key, value) => { mockLocalStorage.storage[key] = value; },
removeItem: (key) => { delete mockLocalStorage.storage[key]; }
};
// Load crisis-detector.js
const crisisDetectorPath = path.join(ROOT, 'js', 'crisis-detector.js');
const crisisDetectorCode = fs.readFileSync(crisisDetectorPath, 'utf8');
// Create VM context
const context = {
module: { exports: {} },
exports: {},
console,
document: mockDocument,
localStorage: mockLocalStorage,
window: { location: { href: '' } }
};
// Execute crisis-detector.js in context
const vm = require('node:vm');
vm.runInNewContext(crisisDetectorCode, context);
// Get CrisisDetector class
const CrisisDetector = context.window.CrisisDetector || context.CrisisDetector;
test('CrisisDetector class loads correctly', () => {
assert.ok(CrisisDetector, 'CrisisDetector should be defined');
assert.ok(typeof CrisisDetector === 'function', 'CrisisDetector should be a constructor');
});
test('CrisisDetector can be instantiated', () => {
const detector = new CrisisDetector();
assert.ok(detector, 'CrisisDetector instance should be created');
assert.ok(detector.crisisKeywords, 'Should have crisisKeywords');
assert.ok(detector.crisisPatterns, 'Should have crisisPatterns');
});
test('CrisisDetector detects crisis keywords', () => {
const detector = new CrisisDetector();
// Test various crisis messages
const crisisMessages = [
'I want to die',
'I\'m going to kill myself',
'I should just die',
'Nobody would miss me',
'I can\'t take it anymore',
'I\'m done with life',
'I hate my life',
'I wish I was dead',
'I\'m going to end it',
'I have nothing to live for'
];
for (const message of crisisMessages) {
const detected = detector.detectCrisis(message);
assert.ok(detected, `Should detect crisis in: "${message}"`);
}
});
test('CrisisDetector does not detect crisis in normal messages', () => {
const detector = new CrisisDetector();
const normalMessages = [
'Hello, how are you?',
'I\'m doing great today',
'Let\'s work on this project',
'The weather is nice',
'I love coding',
'This is a test message'
];
for (const message of normalMessages) {
const detected = detector.detectCrisis(message);
assert.ok(!detected, `Should NOT detect crisis in: "${message}"`);
}
});
test('CrisisDetector handles empty messages', () => {
const detector = new CrisisDetector();
assert.ok(!detector.detectCrisis(''), 'Should not detect crisis in empty string');
assert.ok(!detector.detectCrisis(null), 'Should not detect crisis in null');
assert.ok(!detector.detectCrisis(undefined), 'Should not detect crisis in undefined');
});
test('CrisisDetector tracks metrics', () => {
const detector = new CrisisDetector();
// Check initial metrics
const initialMetrics = detector.getMetrics();
assert.equal(initialMetrics.totalChecks, 0, 'Should start with 0 checks');
assert.equal(initialMetrics.crisesDetected, 0, 'Should start with 0 crises');
// Check some messages
detector.detectCrisis('Hello');
detector.detectCrisis('I want to die');
detector.detectCrisis('How are you?');
const metrics = detector.getMetrics();
assert.equal(metrics.totalChecks, 3, 'Should have 3 checks');
assert.equal(metrics.crisesDetected, 1, 'Should have 1 crisis detected');
assert.ok(metrics.lastDetection, 'Should have lastDetection');
assert.equal(metrics.lastDetection.message, 'I want to die', 'Should store crisis message');
});
test('CrisisDetector can reset metrics', () => {
const detector = new CrisisDetector();
// Check some messages
detector.detectCrisis('I want to die');
detector.detectCrisis('Hello');
// Reset metrics
detector.resetMetrics();
const metrics = detector.getMetrics();
assert.equal(metrics.totalChecks, 0, 'Should have 0 checks after reset');
assert.equal(metrics.crisesDetected, 0, 'Should have 0 crises after reset');
assert.equal(metrics.lastDetection, null, 'Should have no lastDetection after reset');
});
test('CrisisDetector logs to metrics', () => {
const detector = new CrisisDetector();
// Clear any existing metrics
detector.clearStoredMetrics();
// Detect crisis
detector.detectCrisis('I want to die');
// Check stored metrics
const storedMetrics = detector.getStoredMetrics();
assert.ok(storedMetrics.length > 0, 'Should have stored metrics');
// Find the crisis_detected event (not overlay_shown)
const crisisEvent = storedMetrics.find(event => event.type === 'crisis_detected');
assert.ok(crisisEvent, 'Should have crisis_detected event');
assert.equal(crisisEvent.message, 'I want to die', 'Should log message');
});
test('CrisisDetector has crisis handler', () => {
let handlerCalled = false;
let handlerMessage = null;
const detector = new CrisisDetector({
onCrisisDetected: (message) => {
handlerCalled = true;
handlerMessage = message;
}
});
detector.detectCrisis('I want to die');
assert.ok(handlerCalled, 'Crisis handler should be called');
assert.equal(handlerMessage, 'I want to die', 'Handler should receive message');
});
test('CrisisDetector overlay visibility', () => {
const detector = new CrisisDetector();
// Initially not visible
assert.ok(!detector.overlayVisible, 'Overlay should not be visible initially');
// Show overlay
detector.show988Overlay();
assert.ok(detector.overlayVisible, 'Overlay should be visible after showing');
// Hide overlay
detector.hide988Overlay();
assert.ok(!detector.overlayVisible, 'Overlay should not be visible after hiding');
});
console.log('All CrisisDetector tests passed!');