@jgardner04/ghost-mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,489 @@
1
+ import {
2
+ ErrorHandler,
3
+ BaseError,
4
+ ValidationError,
5
+ NotFoundError,
6
+ AuthenticationError,
7
+ AuthorizationError,
8
+ RateLimitError
9
+ } from '../errors/index.js';
10
+ import fs from 'fs/promises';
11
+ import path from 'path';
12
+ import crypto from 'crypto';
13
+ import { fileURLToPath } from 'url';
14
+
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+
17
+ /**
18
+ * Error Logger - Logs errors to file and console
19
+ */
20
+ export class ErrorLogger {
21
+ constructor(options = {}) {
22
+ this.logDir = options.logDir || path.join(__dirname, '../../logs');
23
+ this.maxLogSize = options.maxLogSize || 10 * 1024 * 1024; // 10MB
24
+ this.logLevel = options.logLevel || process.env.LOG_LEVEL || 'info';
25
+ this.enableFileLogging = options.enableFileLogging ?? true;
26
+
27
+ // Ensure log directory exists
28
+ if (this.enableFileLogging) {
29
+ this.ensureLogDirectory();
30
+ }
31
+ }
32
+
33
+ async ensureLogDirectory() {
34
+ try {
35
+ await fs.mkdir(this.logDir, { recursive: true });
36
+ } catch (error) {
37
+ console.error('Failed to create log directory:', error);
38
+ this.enableFileLogging = false;
39
+ }
40
+ }
41
+
42
+ getLogFilePath(type = 'error') {
43
+ const date = new Date().toISOString().split('T')[0];
44
+ return path.join(this.logDir, `${type}-${date}.log`);
45
+ }
46
+
47
+ async rotateLogIfNeeded(filePath) {
48
+ try {
49
+ const stats = await fs.stat(filePath);
50
+ if (stats.size > this.maxLogSize) {
51
+ const timestamp = Date.now();
52
+ const rotatedPath = filePath.replace('.log', `-${timestamp}.log`);
53
+ await fs.rename(filePath, rotatedPath);
54
+ }
55
+ } catch (error) {
56
+ // File doesn't exist yet, which is fine
57
+ }
58
+ }
59
+
60
+ formatLogEntry(level, message, meta = {}) {
61
+ return JSON.stringify({
62
+ timestamp: new Date().toISOString(),
63
+ level,
64
+ message,
65
+ ...meta,
66
+ environment: process.env.NODE_ENV || 'development',
67
+ pid: process.pid
68
+ }) + '\n';
69
+ }
70
+
71
+ async writeToFile(type, entry) {
72
+ if (!this.enableFileLogging) return;
73
+
74
+ const filePath = this.getLogFilePath(type);
75
+
76
+ try {
77
+ await this.rotateLogIfNeeded(filePath);
78
+ await fs.appendFile(filePath, entry);
79
+ } catch (error) {
80
+ console.error('Failed to write to log file:', error);
81
+ }
82
+ }
83
+
84
+ async logError(error, context = {}) {
85
+ const isOperational = ErrorHandler.isOperationalError(error);
86
+ const level = isOperational ? 'error' : 'fatal';
87
+
88
+ const logData = {
89
+ name: error.name || 'Error',
90
+ message: error.message,
91
+ code: error.code,
92
+ statusCode: error.statusCode,
93
+ stack: error.stack,
94
+ isOperational,
95
+ ...context
96
+ };
97
+
98
+ // Console logging
99
+ if (level === 'fatal' || this.logLevel === 'debug') {
100
+ console.error(`[${level.toUpperCase()}]`, error.message, logData);
101
+ } else {
102
+ console.error(`[${level.toUpperCase()}]`, error.message);
103
+ }
104
+
105
+ // File logging
106
+ const entry = this.formatLogEntry(level, error.message, logData);
107
+ await this.writeToFile('error', entry);
108
+
109
+ // Also log to general log
110
+ await this.writeToFile('app', entry);
111
+ }
112
+
113
+ async logInfo(message, meta = {}) {
114
+ if (['info', 'debug'].includes(this.logLevel)) {
115
+ console.log(`[INFO] ${message}`);
116
+ const entry = this.formatLogEntry('info', message, meta);
117
+ await this.writeToFile('app', entry);
118
+ }
119
+ }
120
+
121
+ async logWarning(message, meta = {}) {
122
+ if (['warning', 'info', 'debug'].includes(this.logLevel)) {
123
+ console.warn(`[WARNING] ${message}`);
124
+ const entry = this.formatLogEntry('warning', message, meta);
125
+ await this.writeToFile('app', entry);
126
+ }
127
+ }
128
+
129
+ async logDebug(message, meta = {}) {
130
+ if (this.logLevel === 'debug') {
131
+ console.log(`[DEBUG] ${message}`);
132
+ const entry = this.formatLogEntry('debug', message, meta);
133
+ await this.writeToFile('debug', entry);
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Error Metrics Collector
140
+ */
141
+ export class ErrorMetrics {
142
+ constructor() {
143
+ this.metrics = {
144
+ totalErrors: 0,
145
+ errorsByType: {},
146
+ errorsByStatusCode: {},
147
+ errorsByEndpoint: {},
148
+ lastReset: new Date().toISOString()
149
+ };
150
+ }
151
+
152
+ recordError(error, endpoint = null) {
153
+ this.metrics.totalErrors++;
154
+
155
+ // Count by error type
156
+ const errorType = error.constructor.name;
157
+ this.metrics.errorsByType[errorType] = (this.metrics.errorsByType[errorType] || 0) + 1;
158
+
159
+ // Count by status code
160
+ const statusCode = error.statusCode || 500;
161
+ this.metrics.errorsByStatusCode[statusCode] = (this.metrics.errorsByStatusCode[statusCode] || 0) + 1;
162
+
163
+ // Count by endpoint
164
+ if (endpoint) {
165
+ this.metrics.errorsByEndpoint[endpoint] = (this.metrics.errorsByEndpoint[endpoint] || 0) + 1;
166
+ }
167
+ }
168
+
169
+ getMetrics() {
170
+ return {
171
+ ...this.metrics,
172
+ uptime: process.uptime(),
173
+ memoryUsage: process.memoryUsage(),
174
+ timestamp: new Date().toISOString()
175
+ };
176
+ }
177
+
178
+ reset() {
179
+ this.metrics = {
180
+ totalErrors: 0,
181
+ errorsByType: {},
182
+ errorsByStatusCode: {},
183
+ errorsByEndpoint: {},
184
+ lastReset: new Date().toISOString()
185
+ };
186
+ }
187
+ }
188
+
189
+ // Global instances
190
+ const errorLogger = new ErrorLogger();
191
+ const errorMetrics = new ErrorMetrics();
192
+
193
+ /**
194
+ * Express Error Middleware
195
+ */
196
+ export function expressErrorHandler(err, req, res, next) {
197
+ // Log the error
198
+ errorLogger.logError(err, {
199
+ method: req.method,
200
+ url: req.url,
201
+ ip: req.ip,
202
+ userAgent: req.get('user-agent')
203
+ });
204
+
205
+ // Record metrics
206
+ errorMetrics.recordError(err, `${req.method} ${req.path}`);
207
+
208
+ // Format response
209
+ const { statusCode, body } = ErrorHandler.formatHTTPError(err);
210
+
211
+ // Set security headers
212
+ res.set({
213
+ 'X-Content-Type-Options': 'nosniff',
214
+ 'X-Frame-Options': 'DENY',
215
+ 'X-XSS-Protection': '1; mode=block'
216
+ });
217
+
218
+ // Send response
219
+ res.status(statusCode).json(body);
220
+ }
221
+
222
+ /**
223
+ * Async route wrapper to catch errors
224
+ */
225
+ export function asyncHandler(fn) {
226
+ return (req, res, next) => {
227
+ Promise.resolve(fn(req, res, next)).catch(next);
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Request validation middleware
233
+ * @param {Object|Function} schema - Validation schema object or validation function
234
+ */
235
+ export function validateRequest(schema) {
236
+ return (req, res, next) => {
237
+ try {
238
+ // If schema is a function, call it with the request body
239
+ if (typeof schema === 'function') {
240
+ const validationResult = schema(req.body);
241
+ if (validationResult && validationResult.error) {
242
+ throw new ValidationError(validationResult.error);
243
+ }
244
+ }
245
+ // If schema has a validate method (e.g., Joi schema)
246
+ else if (schema && typeof schema.validate === 'function') {
247
+ const { error } = schema.validate(req.body, { abortEarly: false });
248
+ if (error) {
249
+ // Create ValidationError from Joi error details
250
+ const errors = error.details ? error.details.map(detail => detail.message) : [error.message];
251
+ throw new ValidationError('Validation failed', errors);
252
+ }
253
+ }
254
+ // If schema is a simple object with required fields
255
+ else if (schema && typeof schema === 'object') {
256
+ const errors = [];
257
+ for (const [field, rules] of Object.entries(schema)) {
258
+ if (rules.required && !req.body[field]) {
259
+ errors.push(`${field} is required`);
260
+ }
261
+ if (rules.type && req.body[field] && typeof req.body[field] !== rules.type) {
262
+ errors.push(`${field} must be of type ${rules.type}`);
263
+ }
264
+ }
265
+ if (errors.length > 0) {
266
+ throw new ValidationError('Validation failed', errors);
267
+ }
268
+ }
269
+
270
+ next();
271
+ } catch (error) {
272
+ next(error);
273
+ }
274
+ };
275
+ }
276
+
277
+ /**
278
+ * Rate limiting middleware
279
+ */
280
+ export class RateLimiter {
281
+ constructor(options = {}) {
282
+ this.windowMs = options.windowMs || 60000; // 1 minute
283
+ this.maxRequests = options.maxRequests || 100;
284
+ this.requests = new Map();
285
+ }
286
+
287
+ middleware() {
288
+ return (req, res, next) => {
289
+ const key = req.ip;
290
+ const now = Date.now();
291
+
292
+ // Clean old entries
293
+ this.cleanup(now);
294
+
295
+ // Get or create request list for this IP
296
+ if (!this.requests.has(key)) {
297
+ this.requests.set(key, []);
298
+ }
299
+
300
+ const requestTimes = this.requests.get(key);
301
+ requestTimes.push(now);
302
+
303
+ if (requestTimes.length > this.maxRequests) {
304
+ const retryAfter = Math.ceil((this.windowMs - (now - requestTimes[0])) / 1000);
305
+ return next(new RateLimitError(retryAfter));
306
+ }
307
+
308
+ next();
309
+ };
310
+ }
311
+
312
+ cleanup(now) {
313
+ const cutoff = now - this.windowMs;
314
+
315
+ for (const [key, times] of this.requests.entries()) {
316
+ const filtered = times.filter(time => time > cutoff);
317
+
318
+ if (filtered.length === 0) {
319
+ this.requests.delete(key);
320
+ } else {
321
+ this.requests.set(key, filtered);
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * API Key authentication middleware
329
+ */
330
+ export function apiKeyAuth(apiKey) {
331
+ return (req, res, next) => {
332
+ const providedKey = req.headers['x-api-key'] ||
333
+ req.headers['authorization']?.replace('Bearer ', '');
334
+
335
+ if (!providedKey) {
336
+ return next(new AuthenticationError('API key is required'));
337
+ }
338
+
339
+ // Use timing-safe comparison to prevent timing attacks
340
+ const expectedKeyBuffer = Buffer.from(apiKey, 'utf8');
341
+ const providedKeyBuffer = Buffer.from(providedKey, 'utf8');
342
+
343
+ // Ensure buffers are same length to prevent timing attacks
344
+ if (expectedKeyBuffer.length !== providedKeyBuffer.length) {
345
+ return next(new AuthenticationError('Invalid API key'));
346
+ }
347
+
348
+ // Use constant-time comparison
349
+ const isValid = crypto.timingSafeEqual(expectedKeyBuffer, providedKeyBuffer);
350
+
351
+ if (!isValid) {
352
+ return next(new AuthenticationError('Invalid API key'));
353
+ }
354
+
355
+ next();
356
+ };
357
+ }
358
+
359
+ /**
360
+ * CORS middleware for MCP
361
+ */
362
+ export function mcpCors(allowedOrigins = ['*']) {
363
+ return (req, res, next) => {
364
+ const origin = req.headers.origin;
365
+
366
+ if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) {
367
+ res.header('Access-Control-Allow-Origin', origin || '*');
368
+ res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
369
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key');
370
+ res.header('Access-Control-Max-Age', '86400');
371
+ }
372
+
373
+ if (req.method === 'OPTIONS') {
374
+ return res.sendStatus(204);
375
+ }
376
+
377
+ next();
378
+ };
379
+ }
380
+
381
+ /**
382
+ * Health check endpoint
383
+ */
384
+ export function healthCheck(ghostService) {
385
+ return async (req, res) => {
386
+ try {
387
+ const health = await ghostService.checkHealth();
388
+ const metrics = errorMetrics.getMetrics();
389
+
390
+ const status = health.status === 'healthy' ? 200 : 503;
391
+
392
+ res.status(status).json({
393
+ ...health,
394
+ metrics: {
395
+ errors: metrics.totalErrors,
396
+ uptime: metrics.uptime,
397
+ memory: metrics.memoryUsage
398
+ }
399
+ });
400
+ } catch (error) {
401
+ res.status(503).json({
402
+ status: 'unhealthy',
403
+ error: error.message,
404
+ timestamp: new Date().toISOString()
405
+ });
406
+ }
407
+ };
408
+ }
409
+
410
+ /**
411
+ * Graceful shutdown handler
412
+ */
413
+ export class GracefulShutdown {
414
+ constructor() {
415
+ this.isShuttingDown = false;
416
+ this.connections = new Set();
417
+ }
418
+
419
+ trackConnection(connection) {
420
+ this.connections.add(connection);
421
+ connection.on('close', () => this.connections.delete(connection));
422
+ }
423
+
424
+ middleware() {
425
+ return (req, res, next) => {
426
+ if (this.isShuttingDown) {
427
+ res.set('Connection', 'close');
428
+ res.status(503).json({
429
+ error: {
430
+ code: 'SERVER_SHUTTING_DOWN',
431
+ message: 'Server is shutting down'
432
+ }
433
+ });
434
+ return;
435
+ }
436
+
437
+ // Track the connection
438
+ this.trackConnection(req.socket);
439
+ next();
440
+ };
441
+ }
442
+
443
+ async shutdown(server) {
444
+ if (this.isShuttingDown) return;
445
+
446
+ this.isShuttingDown = true;
447
+ console.log('Graceful shutdown initiated...');
448
+
449
+ // Stop accepting new connections
450
+ server.close(() => {
451
+ console.log('Server closed to new connections');
452
+ });
453
+
454
+ // Close existing connections
455
+ for (const connection of this.connections) {
456
+ connection.end();
457
+ }
458
+
459
+ // Force close after timeout
460
+ setTimeout(() => {
461
+ for (const connection of this.connections) {
462
+ connection.destroy();
463
+ }
464
+ }, 10000);
465
+
466
+ // Log final metrics
467
+ await errorLogger.logInfo('Shutdown metrics', errorMetrics.getMetrics());
468
+ }
469
+ }
470
+
471
+ export {
472
+ errorLogger,
473
+ errorMetrics
474
+ };
475
+
476
+ export default {
477
+ expressErrorHandler,
478
+ asyncHandler,
479
+ validateRequest,
480
+ RateLimiter,
481
+ apiKeyAuth,
482
+ mcpCors,
483
+ healthCheck,
484
+ GracefulShutdown,
485
+ ErrorLogger,
486
+ ErrorMetrics,
487
+ errorLogger,
488
+ errorMetrics
489
+ };