Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions Dockerfile.ui
Original file line number Diff line number Diff line change
@@ -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"]

29 changes: 28 additions & 1 deletion database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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(),
Comment thread
alias8818 marked this conversation as resolved.
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;
46 changes: 46 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Comment thread
cursor[bot] marked this conversation as resolved.

# 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"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Port configuration mismatch in UI service

The UI_PORT environment variable is passed to the container, allowing the UI server to listen on a configurable port, but the Docker port mapping always forwards to container port 8080. If UI_PORT is set to a different value (e.g., 9000), the container listens on 9000 while Docker forwards to 8080, causing connection failures. The health check also hardcodes port 8080, which would fail if UI_PORT differs. The container should always listen on a fixed port internally.

Additional Locations (1)

Fix in Cursor Fix in Web

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
Expand Down
18 changes: 11 additions & 7 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);
});
}

Expand Down
108 changes: 108 additions & 0 deletions src/ui-server.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
Comment thread
alias8818 marked this conversation as resolved.

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);
});

7 changes: 4 additions & 3 deletions src/ui/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
cursor[bot] marked this conversation as resolved.

// Submit request
const response = await fetch(\`\${config.apiBaseUrl}/api/v1/requests\`, {
Expand Down Expand Up @@ -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';
Comment thread
alias8818 marked this conversation as resolved.

eventSource = new EventSource(
\`\${config.apiBaseUrl}/api/v1/requests/\${requestId}/stream?apiKey=\${apiKey}\`
Expand Down Expand Up @@ -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;

Expand Down