diff --git a/Dockerfile.ui b/Dockerfile.ui new file mode 100644 index 0000000..655db67 --- /dev/null +++ b/Dockerfile.ui @@ -0,0 +1,52 @@ +# Build stage +FROM node:18-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY tsconfig.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY src ./src + +# Build TypeScript +RUN npm run build + +# Production stage +FROM node:18-alpine + +WORKDIR /app + +# Install curl for healthcheck +RUN apk add --no-cache curl + +# Copy package files +COPY package*.json ./ + +# Install production dependencies only +RUN npm ci --only=production + +# Copy built application from builder +COPY --from=builder /app/dist ./dist + +# Copy database schema for reference +COPY database ./database + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +USER nodejs + +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8080/ || exit 1 + +CMD ["node", "dist/ui-server.js"] + diff --git a/database/schema.sql b/database/schema.sql index 94cc129..6ed2260 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -239,4 +239,31 @@ CREATE TABLE devils_advocate_logs ( ); CREATE INDEX idx_devils_advocate_logs_request_id ON devils_advocate_logs(request_id); -CREATE INDEX idx_devils_advocate_logs_created_at ON devils_advocate_logs(created_at); \ No newline at end of file +CREATE INDEX idx_devils_advocate_logs_created_at ON devils_advocate_logs(created_at); + +-- ============================================================================ +-- API Keys table (for API authentication) +-- ============================================================================ +-- Enable pgcrypto extension for gen_random_uuid() function +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE api_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(255) NOT NULL, + key_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hash of the API key + active BOOLEAN NOT NULL DEFAULT TRUE, + expires_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP +); + +CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash); +CREATE INDEX idx_api_keys_user_id ON api_keys(user_id); +CREATE INDEX idx_api_keys_active ON api_keys(active); + +-- Seed demo API key for UI (development/testing only) +-- Key: demo-api-key-for-testing-purposes-only-12345678901234567890 +-- Hash: 52346957575b04c715942a324887efde06f034ca62893fa6a76064d7f65f8e43 +INSERT INTO api_keys (user_id, key_hash, active) +VALUES ('demo-user', '52346957575b04c715942a324887efde06f034ca62893fa6a76064d7f65f8e43', true) +ON CONFLICT (key_hash) DO NOTHING; \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 9ac850e..a6036fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -99,6 +99,52 @@ services: retries: 3 start_period: 40s + # AI Council Proxy UI + ui: + build: + context: . + dockerfile: Dockerfile.ui + container_name: council-ui + environment: + NODE_ENV: ${NODE_ENV:-production} + + # Database + DATABASE_HOST: postgres + DATABASE_PORT: 5432 + DATABASE_NAME: ${DATABASE_NAME:-ai_council_proxy} + DATABASE_USER: ${DATABASE_USER:-postgres} + DATABASE_PASSWORD: ${DATABASE_PASSWORD:-postgres} + DATABASE_URL: postgresql://${DATABASE_USER:-postgres}:${DATABASE_PASSWORD:-postgres}@postgres:5432/${DATABASE_NAME:-ai_council_proxy} + + # Redis + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_URL: redis://redis:6379 + + # UI Configuration + UI_PORT: ${UI_PORT:-8080} + # API_BASE_URL defaults to http://localhost:3000 in code (for browser access) + # Override if needed for different deployment scenarios + API_BASE_URL: ${API_BASE_URL} + ports: + - "${UI_PORT:-8080}:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + api: + condition: service_healthy + restart: unless-stopped + networks: + - council-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + volumes: postgres_data: driver: local diff --git a/src/server.ts b/src/server.ts index 7f7d771..b1d70c5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -82,8 +82,12 @@ async function startServer() { console.log(`\nHealth check: http://localhost:${port}/health`); // Handle graceful shutdown - const shutdown = async (signal: string) => { - console.log(`\n${signal} received. Shutting down gracefully...`); + const shutdown = async (signal: string, isError: boolean = false) => { + if (isError) { + console.error(`\n${signal} received. Shutting down due to error...`); + } else { + console.log(`\n${signal} received. Shutting down gracefully...`); + } try { await apiGateway.stop(); console.log('✓ API Gateway stopped'); @@ -92,25 +96,25 @@ async function startServer() { await pool.end(); console.log('✓ PostgreSQL disconnected'); console.log('Shutdown complete'); - process.exit(0); + process.exit(isError ? 1 : 0); } catch (error) { console.error('Error during shutdown:', error); process.exit(1); } }; - process.on('SIGTERM', () => void shutdown('SIGTERM')); - process.on('SIGINT', () => void shutdown('SIGINT')); + process.on('SIGTERM', () => void shutdown('SIGTERM', false)); + process.on('SIGINT', () => void shutdown('SIGINT', false)); // Handle uncaught errors process.on('uncaughtException', (error) => { console.error('Uncaught exception:', error); - void shutdown('UNCAUGHT_EXCEPTION'); + void shutdown('UNCAUGHT_EXCEPTION', true); }); process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled rejection at:', promise, 'reason:', reason); - void shutdown('UNHANDLED_REJECTION'); + void shutdown('UNHANDLED_REJECTION', true); }); } diff --git a/src/ui-server.ts b/src/ui-server.ts new file mode 100644 index 0000000..698ef8a --- /dev/null +++ b/src/ui-server.ts @@ -0,0 +1,108 @@ +/** + * UI Server Entry Point + * Starts the User Interface web server + */ + +import { Pool } from 'pg'; +import { createClient } from 'redis'; +import { UserInterface } from './ui/interface'; +import { ConfigurationManager } from './config/manager'; + +async function startUIServer() { + console.log('Starting AI Council Proxy UI...'); + + // Initialize database connection + const pool = new Pool({ + host: process.env.DATABASE_HOST || 'localhost', + port: parseInt(process.env.DATABASE_PORT || '5432'), + database: process.env.DATABASE_NAME || 'ai_council_proxy', + user: process.env.DATABASE_USER || 'postgres', + password: process.env.DATABASE_PASSWORD || 'postgres' + }); + + console.log('Connecting to PostgreSQL...'); + try { + await pool.query('SELECT 1'); + console.log('✓ PostgreSQL connected'); + } catch (error) { + console.error('Failed to connect to PostgreSQL:', error); + process.exit(1); + } + + // Initialize Redis client + const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; + console.log('Connecting to Redis...'); + const redis = createClient({ url: redisUrl }); + + redis.on('error', (err) => { + console.error('Redis error:', err); + }); + + try { + await redis.connect(); + console.log('✓ Redis connected'); + } catch (error) { + console.error('Failed to connect to Redis:', error); + process.exit(1); + } + + // Initialize ConfigurationManager + const configManager = new ConfigurationManager(pool, redis as any); + + // Get API base URL from environment or default to localhost (for browser access) + // Note: In Docker, this should be set via API_BASE_URL env var to http://localhost:3000 + // so the browser (running on host) can access the API + const apiBaseUrl = process.env.API_BASE_URL || 'http://localhost:3000'; + const uiPort = parseInt(process.env.UI_PORT || '8080'); + + // Start User Interface + const ui = new UserInterface(configManager, apiBaseUrl); + + console.log(`Starting UI server on port ${uiPort}...`); + await ui.start(uiPort); + console.log(`✓ UI server running on http://0.0.0.0:${uiPort}`); + console.log(`✓ API Gateway URL: ${apiBaseUrl}`); + + // Handle graceful shutdown + const shutdown = async (signal: string, isError: boolean = false) => { + if (isError) { + console.error(`\n${signal} received. Shutting down due to error...`); + } else { + console.log(`\n${signal} received. Shutting down gracefully...`); + } + try { + await ui.stop(); + console.log('✓ UI server stopped'); + await redis.disconnect(); + console.log('✓ Redis disconnected'); + await pool.end(); + console.log('✓ PostgreSQL disconnected'); + console.log('Shutdown complete'); + process.exit(isError ? 1 : 0); + } catch (error) { + console.error('Error during shutdown:', error); + process.exit(1); + } + }; + + process.on('SIGTERM', () => void shutdown('SIGTERM', false)); + process.on('SIGINT', () => void shutdown('SIGINT', false)); + + // Handle uncaught errors + process.on('uncaughtException', (error) => { + console.error('Uncaught exception:', error); + void shutdown('UNCAUGHT_EXCEPTION', true); + }); + + process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled rejection at:', promise, 'reason:', reason); + void shutdown('UNHANDLED_REJECTION', true); + }); +} + +// Start the UI server +startUIServer().catch((error) => { + console.error('Failed to start UI server:', error); + process.exit(1); +}); + diff --git a/src/ui/interface.ts b/src/ui/interface.ts index c07bfb6..c67c2d4 100644 --- a/src/ui/interface.ts +++ b/src/ui/interface.ts @@ -448,7 +448,8 @@ export class UserInterface { try { // Get API key from localStorage (in production, use proper auth) - const apiKey = localStorage.getItem('apiKey') || 'demo-api-key'; + // Default key must be at least 32 characters for production mode + const apiKey = localStorage.getItem('apiKey') || 'demo-api-key-for-testing-purposes-only-12345678901234567890'; // Submit request const response = await fetch(\`\${config.apiBaseUrl}/api/v1/requests\`, { @@ -486,7 +487,7 @@ export class UserInterface { // Start streaming response function startStreaming(requestId) { - const apiKey = localStorage.getItem('apiKey') || 'demo-api-key'; + const apiKey = localStorage.getItem('apiKey') || 'demo-api-key-for-testing-purposes-only-12345678901234567890'; eventSource = new EventSource( \`\${config.apiBaseUrl}/api/v1/requests/\${requestId}/stream?apiKey=\${apiKey}\` @@ -524,7 +525,7 @@ export class UserInterface { // Poll for response (fallback) async function pollForResponse(requestId) { - const apiKey = localStorage.getItem('apiKey') || 'demo-api-key'; + const apiKey = localStorage.getItem('apiKey') || 'demo-api-key-for-testing-purposes-only-12345678901234567890'; const maxAttempts = 60; // 60 seconds let attempts = 0;