feat: add Express server foundation with middleware
Configuration: - app.config.js: Centralized configuration (ports, MongoDB, JWT, features) - Feature flags for AI curation, media triage, case submissions Middleware: - auth.middleware.js: JWT authentication, role-based access control - validation.middleware.js: Input validation, sanitization, ObjectId checks - error.middleware.js: Global error handling, async wrapper, 404 handler Express Server (src/server.js): - Security: Helmet, CORS, rate limiting - Request logging with Winston - Health check endpoint - MongoDB connection with graceful shutdown - Static file serving - Temporary homepage showing development status Features: - Production-ready error handling - MongoDB duplicate key detection - JWT token validation - XSS protection via sanitization - Rate limiting (100 req / 15min per IP) - Graceful shutdown (SIGTERM/SIGINT) Status: Server foundation complete, ready for API routes Port: 9000 Database: tractatus_dev (MongoDB 27017)
This commit is contained in:
parent
78ab5754f2
commit
6285adc572
5 changed files with 628 additions and 0 deletions
52
src/config/app.config.js
Normal file
52
src/config/app.config.js
Normal file
|
|
@ -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
|
||||
}
|
||||
};
|
||||
109
src/middleware/auth.middleware.js
Normal file
109
src/middleware/auth.middleware.js
Normal file
|
|
@ -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
|
||||
};
|
||||
92
src/middleware/error.middleware.js
Normal file
92
src/middleware/error.middleware.js
Normal file
|
|
@ -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
|
||||
};
|
||||
182
src/middleware/validation.middleware.js
Normal file
182
src/middleware/validation.middleware.js
Normal file
|
|
@ -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
|
||||
};
|
||||
193
src/server.js
Normal file
193
src/server.js
Normal file
|
|
@ -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(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tractatus AI Safety Framework</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { color: #2563eb; }
|
||||
.status { color: #059669; font-weight: bold; }
|
||||
code { background: #f3f4f6; padding: 2px 6px; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Tractatus AI Safety Framework</h1>
|
||||
<p class="status">✓ Server Running</p>
|
||||
<p>Development environment for the Tractatus-Based LLM Safety Framework website.</p>
|
||||
|
||||
<h2>Status</h2>
|
||||
<ul>
|
||||
<li>✓ MongoDB connected (port 27017)</li>
|
||||
<li>✓ Express server running (port ${config.port})</li>
|
||||
<li>✓ Database initialized (10 collections)</li>
|
||||
<li>✓ Core models implemented</li>
|
||||
<li>⏳ API routes (in development)</li>
|
||||
<li>⏳ Frontend (pending)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Available Endpoints</h2>
|
||||
<ul>
|
||||
<li><code>GET /health</code> - Health check</li>
|
||||
<li><code>GET /api</code> - API information</li>
|
||||
</ul>
|
||||
|
||||
<p><em>Phase 1 Development - Not for public use</em></p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
// 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;
|
||||
Loading…
Add table
Reference in a new issue