From cee8cf8b1e351d24940b050874f9bc087e86fc90 Mon Sep 17 00:00:00 2001 From: jango-blockchained Date: Mon, 3 Feb 2025 00:32:14 +0100 Subject: [PATCH] Enhance configuration management with comprehensive environment setup - Added detailed .env.example with extensive configuration options - Created centralized configuration module with type-safe environment variable loading - Implemented configuration validation and default value handling - Added support for different environment modes (development, production, test) - Introduced interfaces for robust type definitions across the project - Implemented middleware for request validation, security, and input sanitization --- .env.example | 52 +++++++++++- src/config/index.ts | 87 +++++++++++++++++++ src/interfaces/index.ts | 181 ++++++++++++++++++++++++++++++++++++++++ src/middleware/index.ts | 139 ++++++++++++++++++++++++++++++ 4 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 src/config/index.ts create mode 100644 src/interfaces/index.ts create mode 100644 src/middleware/index.ts diff --git a/.env.example b/.env.example index 795a2dd..a7efc7a 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,51 @@ -NODE_ENV=development +# Home Assistant Configuration +# The URL of your Home Assistant instance HASS_HOST=http://homeassistant.local:8123 + +# Long-lived access token from Home Assistant +# Generate from Profile -> Long-Lived Access Tokens HASS_TOKEN=your_home_assistant_token -PORT=3000 + +# WebSocket URL for real-time updates HASS_SOCKET_URL=ws://homeassistant.local:8123/api/websocket -LOG_LEVEL=debug -PROCESSOR_TYPE=claude \ No newline at end of file + +# Server Configuration +# Port for the MCP server (default: 3000) +PORT=3000 + +# Environment (development/production/test) +NODE_ENV=development + +# Debug mode (true/false) +DEBUG=false + +# Logging level (debug/info/warn/error) +LOG_LEVEL=info + +# AI Configuration +# Natural Language Processor type (claude/gpt4/custom) +PROCESSOR_TYPE=claude + +# OpenAI API Key (required for GPT-4 analysis) +OPENAI_API_KEY=your_openai_api_key + +# Rate Limiting +# Requests per minute per IP for regular endpoints +RATE_LIMIT_REGULAR=100 + +# Requests per minute per IP for WebSocket connections +RATE_LIMIT_WEBSOCKET=1000 + +# Security +# JWT secret for token generation (change this in production!) +JWT_SECRET=your_jwt_secret_key + +# CORS configuration (comma-separated list of allowed origins) +CORS_ORIGINS=http://localhost:3000,http://localhost:8123 + +# Test Configuration +# Only needed if running tests +TEST_HASS_HOST=http://localhost:8123 +TEST_HASS_TOKEN=test_token +TEST_HASS_SOCKET_URL=ws://localhost:8123/api/websocket +TEST_PORT=3001 \ No newline at end of file diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..10e26bf --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,87 @@ +import { config } from 'dotenv'; +import { resolve } from 'path'; + +// Load environment variables based on NODE_ENV +const envFile = process.env.NODE_ENV === 'production' + ? '.env' + : process.env.NODE_ENV === 'test' + ? '.env.test' + : '.env.development'; + +console.log(`Loading environment from ${envFile}`); +config({ path: resolve(process.cwd(), envFile) }); + +// Home Assistant Configuration +export const HASS_CONFIG = { + HOST: process.env.HASS_HOST || 'http://homeassistant.local:8123', + TOKEN: process.env.HASS_TOKEN, + SOCKET_URL: process.env.HASS_SOCKET_URL || 'ws://homeassistant.local:8123/api/websocket' +}; + +// Server Configuration +export const SERVER_CONFIG = { + PORT: parseInt(process.env.PORT || '3000', 10), + NODE_ENV: process.env.NODE_ENV || 'development', + DEBUG: process.env.DEBUG === 'true', + LOG_LEVEL: process.env.LOG_LEVEL || 'info' +}; + +// AI Configuration +export const AI_CONFIG = { + PROCESSOR_TYPE: process.env.PROCESSOR_TYPE || 'claude', + OPENAI_API_KEY: process.env.OPENAI_API_KEY +}; + +// Rate Limiting Configuration +export const RATE_LIMIT_CONFIG = { + REGULAR: parseInt(process.env.RATE_LIMIT_REGULAR || '100', 10), + WEBSOCKET: parseInt(process.env.RATE_LIMIT_WEBSOCKET || '1000', 10) +}; + +// Security Configuration +export const SECURITY_CONFIG = { + JWT_SECRET: process.env.JWT_SECRET || 'default_secret_key_change_in_production', + CORS_ORIGINS: (process.env.CORS_ORIGINS || 'http://localhost:3000,http://localhost:8123') + .split(',') + .map(origin => origin.trim()) +}; + +// Test Configuration +export const TEST_CONFIG = { + HASS_HOST: process.env.TEST_HASS_HOST || 'http://localhost:8123', + HASS_TOKEN: process.env.TEST_HASS_TOKEN || 'test_token', + HASS_SOCKET_URL: process.env.TEST_HASS_SOCKET_URL || 'ws://localhost:8123/api/websocket', + PORT: parseInt(process.env.TEST_PORT || '3001', 10) +}; + +// Mock Configuration (for testing) +export const MOCK_CONFIG = { + SERVICES: process.env.MOCK_SERVICES === 'true', + RESPONSES_DIR: process.env.MOCK_RESPONSES_DIR || '__tests__/mock-responses' +}; + +// Validate required configuration +function validateConfig() { + const missingVars: string[] = []; + + if (!HASS_CONFIG.TOKEN) missingVars.push('HASS_TOKEN'); + if (!SECURITY_CONFIG.JWT_SECRET) missingVars.push('JWT_SECRET'); + + if (missingVars.length > 0) { + throw new Error(`Missing required environment variables: ${missingVars.join(', ')}`); + } +} + +// Export configuration validation +export const validateConfiguration = validateConfig; + +// Export all configurations as a single object +export const AppConfig = { + HASS: HASS_CONFIG, + SERVER: SERVER_CONFIG, + AI: AI_CONFIG, + RATE_LIMIT: RATE_LIMIT_CONFIG, + SECURITY: SECURITY_CONFIG, + TEST: TEST_CONFIG, + MOCK: MOCK_CONFIG +}; \ No newline at end of file diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts new file mode 100644 index 0000000..99d71f8 --- /dev/null +++ b/src/interfaces/index.ts @@ -0,0 +1,181 @@ +import { z } from 'zod'; + +// Tool interfaces +export interface Tool { + name: string; + description: string; + parameters: z.ZodType; + execute: (params: any) => Promise; +} + +// Command interfaces +export interface CommandParams { + command: string; + entity_id: string; + // Common parameters + state?: string; + // Light parameters + brightness?: number; + color_temp?: number; + rgb_color?: [number, number, number]; + // Cover parameters + position?: number; + tilt_position?: number; + // Climate parameters + temperature?: number; + target_temp_high?: number; + target_temp_low?: number; + hvac_mode?: string; + fan_mode?: string; + humidity?: number; +} + +// Home Assistant interfaces +export interface HassEntity { + entity_id: string; + state: string; + attributes: Record; + last_changed?: string; + last_updated?: string; + context?: { + id: string; + parent_id?: string; + user_id?: string; + }; +} + +export interface HassState { + entity_id: string; + state: string; + attributes: { + friendly_name?: string; + description?: string; + [key: string]: any; + }; +} + +export interface HassAddon { + name: string; + slug: string; + description: string; + version: string; + installed: boolean; + available: boolean; + state: string; +} + +export interface HassAddonResponse { + data: { + addons: HassAddon[]; + }; +} + +export interface HassAddonInfoResponse { + data: { + name: string; + slug: string; + description: string; + version: string; + state: string; + status: string; + options: Record; + [key: string]: any; + }; +} + +// HACS interfaces +export interface HacsRepository { + name: string; + description: string; + category: string; + installed: boolean; + version_installed: string; + available_version: string; + authors: string[]; + domain: string; +} + +export interface HacsResponse { + repositories: HacsRepository[]; +} + +// Automation interfaces +export interface AutomationConfig { + alias: string; + description?: string; + mode?: 'single' | 'parallel' | 'queued' | 'restart'; + trigger: any[]; + condition?: any[]; + action: any[]; +} + +export interface AutomationResponse { + automation_id: string; +} + +// SSE interfaces +export interface SSEHeaders { + onAbort?: () => void; +} + +export interface SSEParams { + token: string; + events?: string[]; + entity_id?: string; + domain?: string; +} + +// History interfaces +export interface HistoryParams { + entity_id: string; + start_time?: string; + end_time?: string; + minimal_response?: boolean; + significant_changes_only?: boolean; +} + +// Scene interfaces +export interface SceneParams { + action: 'list' | 'activate'; + scene_id?: string; +} + +// Notification interfaces +export interface NotifyParams { + message: string; + title?: string; + target?: string; + data?: Record; +} + +// Automation parameter interfaces +export interface AutomationParams { + action: 'list' | 'toggle' | 'trigger'; + automation_id?: string; +} + +export interface AddonParams { + action: 'list' | 'info' | 'install' | 'uninstall' | 'start' | 'stop' | 'restart'; + slug?: string; + version?: string; +} + +export interface PackageParams { + action: 'list' | 'install' | 'uninstall' | 'update'; + category: 'integration' | 'plugin' | 'theme' | 'python_script' | 'appdaemon' | 'netdaemon'; + repository?: string; + version?: string; +} + +export interface AutomationConfigParams { + action: 'create' | 'update' | 'delete' | 'duplicate'; + automation_id?: string; + config?: { + alias: string; + description?: string; + mode?: 'single' | 'parallel' | 'queued' | 'restart'; + trigger: any[]; + condition?: any[]; + action: any[]; + }; +} \ No newline at end of file diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..e1d40b6 --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,139 @@ +import { Request, Response, NextFunction } from 'express'; +import { HASS_CONFIG, RATE_LIMIT_CONFIG } from '../config/index.js'; +import rateLimit from 'express-rate-limit'; + +// Rate limiter middleware +export const rateLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: RATE_LIMIT_CONFIG.REGULAR, + message: { + success: false, + message: 'Too many requests, please try again later.', + reset_time: new Date(Date.now() + 60 * 1000).toISOString() + } +}); + +// WebSocket rate limiter middleware +export const wsRateLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: RATE_LIMIT_CONFIG.WEBSOCKET, + message: { + success: false, + message: 'Too many WebSocket connections, please try again later.', + reset_time: new Date(Date.now() + 60 * 1000).toISOString() + } +}); + +// Security headers middleware +export const securityHeaders = (_req: Request, res: Response, next: NextFunction) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-XSS-Protection', '1; mode=block'); + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + next(); +}; + +// Request validation middleware +export const validateRequest = (req: Request, res: Response, next: NextFunction) => { + // Validate content type for POST/PUT/PATCH requests + if (['POST', 'PUT', 'PATCH'].includes(req.method) && !req.is('application/json')) { + return res.status(415).json({ + success: false, + message: 'Content-Type must be application/json' + }); + } + + // Validate request body size + const contentLength = parseInt(req.headers['content-length'] || '0', 10); + if (contentLength > 1024 * 1024) { // 1MB limit + return res.status(413).json({ + success: false, + message: 'Request body too large' + }); + } + + next(); +}; + +// Input sanitization middleware +export const sanitizeInput = (req: Request, _res: Response, next: NextFunction) => { + if (req.body) { + // Recursively sanitize object + const sanitizeObject = (obj: any): any => { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => sanitizeObject(item)); + } + + const sanitized: any = {}; + for (const [key, value] of Object.entries(obj)) { + // Remove any potentially dangerous characters from keys + const sanitizedKey = key.replace(/[<>]/g, ''); + sanitized[sanitizedKey] = sanitizeObject(value); + } + + return sanitized; + }; + + req.body = sanitizeObject(req.body); + } + + next(); +}; + +// Authentication middleware +export const authenticate = (req: Request, res: Response, next: NextFunction) => { + const token = req.headers.authorization?.replace('Bearer ', ''); + + if (!token || token !== HASS_CONFIG.TOKEN) { + return res.status(401).json({ + success: false, + message: 'Unauthorized - Invalid token' + }); + } + + next(); +}; + +// Error handling middleware +export const errorHandler = (err: Error, _req: Request, res: Response, _next: NextFunction) => { + console.error('Error:', err); + + // Handle specific error types + if (err.name === 'ValidationError') { + return res.status(400).json({ + success: false, + message: 'Validation error', + details: err.message + }); + } + + if (err.name === 'UnauthorizedError') { + return res.status(401).json({ + success: false, + message: 'Unauthorized', + details: err.message + }); + } + + // Default error response + res.status(500).json({ + success: false, + message: 'Internal server error', + details: process.env.NODE_ENV === 'development' ? err.message : undefined + }); +}; + +// Export all middleware +export const middleware = { + rateLimiter, + wsRateLimiter, + securityHeaders, + validateRequest, + sanitizeInput, + authenticate, + errorHandler +}; \ No newline at end of file