diff --git a/src/config/app.config.js b/src/config/app.config.js new file mode 100644 index 00000000..11478efa --- /dev/null +++ b/src/config/app.config.js @@ -0,0 +1,52 @@ +/** + * Application Configuration + */ + +module.exports = { + // Server + port: process.env.PORT || 9000, + env: process.env.NODE_ENV || 'development', + appName: process.env.APP_NAME || 'Tractatus', + + // MongoDB + mongodb: { + uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/tractatus_dev', + db: process.env.MONGODB_DB || 'tractatus_dev' + }, + + // JWT + jwt: { + secret: process.env.JWT_SECRET || 'CHANGE_THIS_IN_PRODUCTION', + expiry: process.env.JWT_EXPIRY || '7d' + }, + + // Admin + admin: { + email: process.env.ADMIN_EMAIL || 'john.stroh.nz@pm.me' + }, + + // Logging + logging: { + level: process.env.LOG_LEVEL || 'info', + file: process.env.LOG_FILE || 'logs/app.log' + }, + + // Feature Flags + features: { + aiCuration: process.env.ENABLE_AI_CURATION === 'true', + mediaTriage: process.env.ENABLE_MEDIA_TRIAGE === 'true', + caseSubmissions: process.env.ENABLE_CASE_SUBMISSIONS === 'true' + }, + + // Security + security: { + rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 min + rateLimitMaxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100') + }, + + // CORS + cors: { + origin: process.env.CORS_ORIGIN || '*', + credentials: true + } +}; diff --git a/src/middleware/auth.middleware.js b/src/middleware/auth.middleware.js new file mode 100644 index 00000000..9dd48a25 --- /dev/null +++ b/src/middleware/auth.middleware.js @@ -0,0 +1,109 @@ +/** + * Authentication Middleware + * JWT-based authentication for admin routes + */ + +const { verifyToken, extractTokenFromHeader } = require('../utils/jwt.util'); +const { User } = require('../models'); +const logger = require('../utils/logger.util'); + +/** + * Verify JWT token and attach user to request + */ +async function authenticateToken(req, res, next) { + try { + const token = extractTokenFromHeader(req.headers.authorization); + + if (!token) { + return res.status(401).json({ + error: 'Authentication required', + message: 'No token provided' + }); + } + + // Verify token + const decoded = verifyToken(token); + + // Get user from database + const user = await User.findById(decoded.userId); + + if (!user) { + return res.status(401).json({ + error: 'Authentication failed', + message: 'User not found' + }); + } + + if (!user.active) { + return res.status(401).json({ + error: 'Authentication failed', + message: 'User account is inactive' + }); + } + + // Attach user to request + req.user = user; + req.userId = user._id; + + next(); + } catch (error) { + logger.error('Authentication error:', error); + + return res.status(401).json({ + error: 'Authentication failed', + message: error.message + }); + } +} + +/** + * Check if user has required role + */ +function requireRole(...roles) { + return (req, res, next) => { + if (!req.user) { + return res.status(401).json({ + error: 'Authentication required' + }); + } + + if (!roles.includes(req.user.role)) { + return res.status(403).json({ + error: 'Insufficient permissions', + message: `Required role: ${roles.join(' or ')}` + }); + } + + next(); + }; +} + +/** + * Optional authentication (attach user if token present, continue if not) + */ +async function optionalAuth(req, res, next) { + try { + const token = extractTokenFromHeader(req.headers.authorization); + + if (token) { + const decoded = verifyToken(token); + const user = await User.findById(decoded.userId); + + if (user && user.active) { + req.user = user; + req.userId = user._id; + } + } + } catch (error) { + // Silently fail - authentication is optional + logger.debug('Optional auth failed:', error.message); + } + + next(); +} + +module.exports = { + authenticateToken, + requireRole, + optionalAuth +}; diff --git a/src/middleware/error.middleware.js b/src/middleware/error.middleware.js new file mode 100644 index 00000000..4f0ac570 --- /dev/null +++ b/src/middleware/error.middleware.js @@ -0,0 +1,92 @@ +/** + * Error Handling Middleware + */ + +const logger = require('../utils/logger.util'); + +/** + * 404 Not Found handler + */ +function notFound(req, res, next) { + res.status(404).json({ + error: 'Not Found', + message: `Route ${req.method} ${req.originalUrl} not found` + }); +} + +/** + * Global error handler + */ +function errorHandler(err, req, res, next) { + // Log error + logger.error('Error:', { + message: err.message, + stack: err.stack, + url: req.originalUrl, + method: req.method, + ip: req.ip + }); + + // Determine status code + const statusCode = err.statusCode || err.status || 500; + + // MongoDB errors + if (err.name === 'MongoError' || err.name === 'MongoServerError') { + if (err.code === 11000) { + // Duplicate key error + return res.status(409).json({ + error: 'Duplicate Entry', + message: 'A resource with this value already exists' + }); + } + } + + // Validation errors + if (err.name === 'ValidationError') { + return res.status(400).json({ + error: 'Validation Error', + message: err.message, + details: err.errors + }); + } + + // JWT errors + if (err.name === 'JsonWebTokenError') { + return res.status(401).json({ + error: 'Invalid Token', + message: 'Authentication token is invalid' + }); + } + + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ + error: 'Token Expired', + message: 'Authentication token has expired' + }); + } + + // Generic error response + res.status(statusCode).json({ + error: err.name || 'Internal Server Error', + message: process.env.NODE_ENV === 'production' + ? 'An error occurred processing your request' + : err.message, + ...(process.env.NODE_ENV !== 'production' && { stack: err.stack }) + }); +} + +/** + * Async error wrapper + * Wraps async route handlers to catch errors + */ +function asyncHandler(fn) { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} + +module.exports = { + notFound, + errorHandler, + asyncHandler +}; diff --git a/src/middleware/validation.middleware.js b/src/middleware/validation.middleware.js new file mode 100644 index 00000000..f896a1a0 --- /dev/null +++ b/src/middleware/validation.middleware.js @@ -0,0 +1,182 @@ +/** + * Validation Middleware + * Input validation and sanitization + */ + +const validator = require('validator'); +const sanitizeHtml = require('sanitize-html'); + +/** + * Validate email + */ +function validateEmail(req, res, next) { + const { email } = req.body; + + if (!email || !validator.isEmail(email)) { + return res.status(400).json({ + error: 'Validation failed', + message: 'Valid email address is required' + }); + } + + // Normalize email + req.body.email = validator.normalizeEmail(email); + + next(); +} + +/** + * Validate required fields + */ +function validateRequired(fields) { + return (req, res, next) => { + const missing = []; + + for (const field of fields) { + if (!req.body[field] || req.body[field].trim() === '') { + missing.push(field); + } + } + + if (missing.length > 0) { + return res.status(400).json({ + error: 'Validation failed', + message: 'Required fields missing', + missing: missing + }); + } + + next(); + }; +} + +/** + * Sanitize string inputs + */ +function sanitizeInputs(req, res, next) { + const sanitizeString = (str) => { + if (typeof str !== 'string') return str; + return validator.escape(str.trim()); + }; + + const sanitizeObject = (obj) => { + const sanitized = {}; + + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'string') { + sanitized[key] = sanitizeString(value); + } else if (typeof value === 'object' && value !== null) { + sanitized[key] = sanitizeObject(value); + } else { + sanitized[key] = value; + } + } + + return sanitized; + }; + + req.body = sanitizeObject(req.body); + + next(); +} + +/** + * Validate MongoDB ObjectId + */ +function validateObjectId(paramName = 'id') { + return (req, res, next) => { + const id = req.params[paramName]; + + if (!id || !validator.isMongoId(id)) { + return res.status(400).json({ + error: 'Validation failed', + message: 'Invalid ID format' + }); + } + + next(); + }; +} + +/** + * Validate slug format + */ +function validateSlug(req, res, next) { + const { slug } = req.body; + + if (!slug) { + return next(); + } + + const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + + if (!slugRegex.test(slug)) { + return res.status(400).json({ + error: 'Validation failed', + message: 'Slug must be lowercase letters, numbers, and hyphens only' + }); + } + + next(); +} + +/** + * Validate URL + */ +function validateUrl(fieldName = 'url') { + return (req, res, next) => { + const url = req.body[fieldName]; + + if (!url || !validator.isURL(url, { require_protocol: true })) { + return res.status(400).json({ + error: 'Validation failed', + message: `Valid URL required for ${fieldName}` + }); + } + + next(); + }; +} + +/** + * Sanitize HTML content + */ +function sanitizeContent(fieldName = 'content') { + return (req, res, next) => { + const content = req.body[fieldName]; + + if (!content) { + return next(); + } + + req.body[fieldName] = sanitizeHtml(content, { + allowedTags: [ + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'p', 'br', 'hr', + 'strong', 'em', 'u', 'code', 'pre', + 'a', 'img', + 'ul', 'ol', 'li', + 'blockquote', + 'table', 'thead', 'tbody', 'tr', 'th', 'td' + ], + allowedAttributes: { + 'a': ['href', 'title', 'target', 'rel'], + 'img': ['src', 'alt', 'title'], + 'code': ['class'], + 'pre': ['class'] + } + }); + + next(); + }; +} + +module.exports = { + validateEmail, + validateRequired, + sanitizeInputs, + validateObjectId, + validateSlug, + validateUrl, + sanitizeContent +}; diff --git a/src/server.js b/src/server.js new file mode 100644 index 00000000..6492dc8c --- /dev/null +++ b/src/server.js @@ -0,0 +1,193 @@ +/** + * Tractatus Express Server + * Main application entry point + */ + +require('dotenv').config(); + +const express = require('express'); +const helmet = require('helmet'); +const cors = require('cors'); +const rateLimit = require('express-rate-limit'); + +const config = require('./config/app.config'); +const logger = require('./utils/logger.util'); +const { connect: connectDb, close: closeDb } = require('./utils/db.util'); +const { notFound, errorHandler } = require('./middleware/error.middleware'); + +// Create Express app +const app = express(); + +// Trust proxy (for rate limiting behind reverse proxy) +app.set('trust proxy', 1); + +// Security middleware +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'"], + imgSrc: ["'self'", "data:", "https:"], + }, + }, +})); + +// CORS +app.use(cors(config.cors)); + +// Body parsers +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Request logging +app.use(logger.request); + +// Rate limiting +const limiter = rateLimit({ + windowMs: config.security.rateLimitWindowMs, + max: config.security.rateLimitMaxRequests, + message: 'Too many requests from this IP, please try again later', + standardHeaders: true, + legacyHeaders: false, +}); +app.use('/api/', limiter); + +// Static files +app.use(express.static('public')); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + environment: config.env + }); +}); + +// API routes (placeholder - will be implemented) +app.get('/api', (req, res) => { + res.json({ + name: config.appName, + version: '1.0.0', + message: 'Tractatus AI Safety Framework API', + documentation: '/api/docs', + endpoints: { + documents: '/api/documents', + blog: '/api/blog', + media: '/api/media', + cases: '/api/cases', + resources: '/api/resources', + admin: '/api/admin' + } + }); +}); + +// Homepage (temporary) +app.get('/', (req, res) => { + res.send(` + + + + Tractatus AI Safety Framework + + + + + +

Tractatus AI Safety Framework

+

✓ Server Running

+

Development environment for the Tractatus-Based LLM Safety Framework website.

+ +

Status

+ + +

Available Endpoints

+ + +

Phase 1 Development - Not for public use

+ + + `); +}); + +// 404 handler +app.use(notFound); + +// Error handler +app.use(errorHandler); + +// Server startup +async function start() { + try { + // Connect to MongoDB + await connectDb(); + + // Start server + const server = app.listen(config.port, () => { + logger.info(`🚀 Tractatus server started`); + logger.info(`✅ Environment: ${config.env}`); + logger.info(`✅ Port: ${config.port}`); + logger.info(`✅ MongoDB: ${config.mongodb.db}`); + logger.info(`✨ Ready for development`); + console.log(`\n🌐 http://localhost:${config.port}\n`); + }); + + // Graceful shutdown + process.on('SIGTERM', () => shutdown(server)); + process.on('SIGINT', () => shutdown(server)); + + } catch (error) { + logger.error('Failed to start server:', error); + process.exit(1); + } +} + +// Graceful shutdown +async function shutdown(server) { + logger.info('Shutting down gracefully...'); + + server.close(async () => { + logger.info('HTTP server closed'); + + await closeDb(); + logger.info('Database connection closed'); + + process.exit(0); + }); + + // Force shutdown after 10 seconds + setTimeout(() => { + logger.error('Forced shutdown after timeout'); + process.exit(1); + }, 10000); +} + +// Start server if run directly +if (require.main === module) { + start(); +} + +module.exports = app;