diff --git a/web/server/server.js b/web/server/server.js index b6973db..f4e0db5 100644 --- a/web/server/server.js +++ b/web/server/server.js @@ -1,85 +1,48 @@ -import express from 'express'; import { WebSocketServer } from 'ws'; -import { createServer } from 'http'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; import { loadFullState, saveFullState, recordItemHistory, saveItems, saveFurnaces, saveAlerts, saveState, loadState, getHistory, getHistorySummary, closeDb, flushPendingSave, } from './db.js'; +const require = createRequire(import.meta.url); +const { + createPlatformServer, + setupGracefulShutdown, + createRateLimiter, + createProxyEndpoint, +} = require('@cc-platform/server'); + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const app = express(); -const PORT = process.env.PORT || 3001; -const HOST = process.env.HOST || '0.0.0.0'; - -// CORS intentionally omitted — nginx reverse-proxy makes all requests same-origin. -// If you need direct server access during dev, add: app.use(require('cors')()) -app.disable('x-powered-by'); -app.use(express.json({ limit: '5mb' })); - -// ========== API Key Authentication ========== - -const API_KEY = process.env.API_KEY || ''; - -// Validate bearer token from Authorization header or ?key= query param -function extractApiKey(req) { - const auth = req.headers.authorization || ''; - if (auth.startsWith('Bearer ')) return auth.slice(7); - return req.query.key || ''; -} - -// Middleware: require API key on mutating endpoints -function requireAuth(req, res, next) { - if (!API_KEY) return next(); // Auth disabled when no key configured - const token = extractApiKey(req); - if (token === API_KEY) return next(); - return res.status(401).json({ error: 'Unauthorized — invalid or missing API key' }); -} - -// Apply auth to all POST/PUT/DELETE routes -app.use((req, res, next) => { - if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') { - return next(); // Read-only endpoints stay open - } - return requireAuth(req, res, next); +// ========== Platform Server Setup ========== +// Provides Express app, HTTP server, auth middleware, and health endpoint. +// CORS intentionally disabled — nginx reverse-proxy makes all requests same-origin. +const { app, server, auth, start, port } = createPlatformServer({ + serviceName: 'inventory-manager', + cors: false, + rateLimit: false, // custom rate limiting below (excludes bridge endpoints) + healthExtras: () => ({ + lastUpdate, + bridgeConnected: bridgeClients.size > 0, + webClients: webClients.size, + }), }); -// ========== Rate Limiting (in-memory, no external dependencies) ========== +const { requireAuth } = auth; -function createRateLimiter(windowMs, max) { - const hits = new Map(); - setInterval(() => { - const cutoff = Date.now() - windowMs; - for (const [key, entry] of hits) { - if (entry.start < cutoff) hits.delete(key); - } - }, windowMs); - return (req, res, next) => { - const key = req.ip || req.socket.remoteAddress || 'unknown'; - const now = Date.now(); - const entry = hits.get(key); - if (!entry || now - entry.start > windowMs) { - hits.set(key, { start: now, count: 1 }); - return next(); - } - entry.count++; - if (entry.count > max) { - res.set('Retry-After', String(Math.ceil((entry.start + windowMs - now) / 1000))); - return res.status(429).json({ error: 'Too many requests — try again later' }); - } - return next(); - }; -} +// API key reference needed for WebSocket upgrade authentication +const API_KEY = process.env.API_KEY || ''; -// 30 mutating requests per minute per IP (excludes bridge state updates) -const commandLimiter = createRateLimiter(60_000, 30); +// Custom rate limiting: 30 mutating requests/min per IP, excluding bridge endpoints +const commandLimiter = createRateLimiter({ windowMs: 60000, maxRequests: 30 }); app.use((req, res, next) => { - if (req.method !== 'POST' || req.path.startsWith('/api/bridge/')) return next(); + if (req.path.startsWith('/api/bridge/')) return next(); return commandLimiter(req, res, next); }); @@ -171,9 +134,6 @@ function pushCommandToBridge(command) { } } -// ========== HTTP Server ========== -const server = createServer(app); - // ========== Texture Proxy / Cache ========== const TEXTURE_CACHE_DIR = process.env.TEXTURE_CACHE_DIR || path.join('/data', 'texture-cache'); const NEGATIVE_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days for 404s @@ -524,16 +484,7 @@ app.delete('/api/texture-cache/negative', (req, res) => { res.json({ cleared }); }); -// Health check -app.get('/api/health', (req, res) => { - res.json({ - status: 'ok', - lastUpdate, - uptime: process.uptime(), - bridgeConnected: bridgeClients.size > 0, - webClients: webClients.size, - }); -}); +// Health endpoint provided by platform (createPlatformServer) with custom extras // Get current inventory state app.get('/api/inventory', (req, res) => { @@ -1383,53 +1334,24 @@ app.get('/api/integration/low-stock', (req, res) => { }); // Proxy to turtle server for combined dashboard info -app.get('/api/integration/turtle-status', async (req, res) => { - if (!TURTLE_SERVER_URL) { - return res.json({ configured: false, message: 'TURTLE_SERVER_URL not configured' }); - } - try { - const resp = await fetch(`${TURTLE_SERVER_URL}/api/turtles`); - const data = await resp.json(); - res.json({ configured: true, ...data }); - } catch (err) { - res.status(502).json({ configured: true, error: `Cannot reach turtle server: ${err.message}` }); - } -}); +createProxyEndpoint(app, '/api/integration/turtle-status', 'TURTLE_SERVER_URL', '/api/turtles'); // ========== Start Server ========== -server.listen(PORT, HOST, () => { - console.log(`✅ Inventory Manager Web Server ready on ${HOST}:${PORT}`); - console.log(`\nBridge HTTP endpoint: http://localhost:${PORT}/api/bridge/state`); - console.log(`Bridge WebSocket: ws://localhost:${PORT}/ws/bridge`); - console.log(`Web client WebSocket: ws://localhost:${PORT}/ws`); +setupGracefulShutdown({ + serviceName: 'inventory-manager', + cleanup: [ + () => wss.close(), + () => server.close(), + () => { closeDb(); console.log('💾 Database closed'); }, + ], +}); + +start(() => { + console.log(`\nBridge HTTP endpoint: http://localhost:${port}/api/bridge/state`); + console.log(`Bridge WebSocket: ws://localhost:${port}/ws/bridge`); + console.log(`Web client WebSocket: ws://localhost:${port}/ws`); if (TURTLE_SERVER_URL) { console.log(`🐢 Turtle server integration: ${TURTLE_SERVER_URL}`); } }); - -// Graceful shutdown -function shutdown() { - console.log('\n🛑 Shutting down server...'); - try { - wss.close(); - server.close(); - closeDb(); - console.log('💾 Database closed'); - } catch (err) { - console.error('❌ Error during shutdown:', err.message); - } - process.exit(0); -} - -process.on('SIGINT', shutdown); -process.on('SIGTERM', shutdown); - -// Catch unhandled errors to prevent silent crashes -process.on('unhandledRejection', (reason) => { - console.error('❌ Unhandled rejection:', reason); -}); -process.on('uncaughtException', (err) => { - console.error('❌ Uncaught exception:', err); - shutdown(); -});