// chat-server — Phase 9 §9.1 of CONSOLIDATION_PLAN.md. // // ESM conversion + in-process authenticate() defense-in-depth. Previously this // service relied on the Caddy `forward_auth` gate alone — `localhost:3014` // was reachable unauthenticated. Now: // 1. Bound to 127.0.0.1 (was 0.0.0.0) so direct-port access is impossible. // 2. authenticate() runs against an in-process `inventory_db` pool before // any route handler sees the request. // // Two pools intentionally: // - `inventoryPool`: used by authenticate() for users/permissions lookups // against the main inventory_db (matches DB_* env vars). // - `pool` (set as global.pool for routes.js): the existing // `rocketchat_converted` pool driven by CHAT_DB_* env vars. routes.js // reads global.pool throughout — no handler-body changes needed. import { config as loadEnv } from 'dotenv'; import express from 'express'; import cors from 'cors'; import morgan from 'morgan'; import pg from 'pg'; import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { authenticate } from '../shared/auth/middleware.js'; import { corsOptions } from '../shared/cors/policy.js'; import { errorHandler } from '../shared/errors/handler.js'; import { requestLog } from '../shared/logging/request-log.js'; import chatRoutes from './routes.js'; const { Pool } = pg; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Env layering matches dashboard-server (Deviation #18): shared .env wins on // collisions for security-critical vars, local .env supplies CHAT_DB_*. const sharedEnvPath = '/var/www/inventory/.env'; const localEnvPath = path.resolve(__dirname, '.env'); if (fs.existsSync(sharedEnvPath)) loadEnv({ path: sharedEnvPath }); if (fs.existsSync(localEnvPath)) loadEnv({ path: localEnvPath }); if (!process.env.JWT_SECRET) { console.error('JWT_SECRET is not set; refusing to start (per Phase 6.4)'); process.exit(1); } const app = express(); const port = Number(process.env.CHAT_PORT) || 3014; console.log('Starting chat server with config:', { host: process.env.CHAT_DB_HOST, user: process.env.CHAT_DB_USER, database: process.env.CHAT_DB_NAME || 'rocketchat_converted', port: process.env.CHAT_DB_PORT, chat_port: port, }); // Rocket.Chat archive pool — routes.js reads it via global.pool. const pool = new Pool({ host: process.env.CHAT_DB_HOST, user: process.env.CHAT_DB_USER, password: process.env.CHAT_DB_PASSWORD, database: process.env.CHAT_DB_NAME || 'rocketchat_converted', port: process.env.CHAT_DB_PORT, }); global.pool = pool; // inventory_db pool — used by authenticate() for user/permission lookups. const inventoryPool = new Pool({ host: process.env.DB_HOST, user: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, port: Number(process.env.DB_PORT) || 5432, ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false, }); app.use(requestLog()); app.use(express.json()); app.use(morgan('combined')); app.use(cors(corsOptions)); // /health stays unauthenticated for out-of-band probes — mounted BEFORE // authenticate() so monitoring tools on the host can poll without a JWT. // Only reachable via localhost:3014 directly (Caddy routes /health to // inventory-server:3010, not here). app.get('/health', (req, res) => res.json({ status: 'healthy' })); // Phase 9 §9.1 — per-server auth re-verification. Every chat route must pass // authenticate() in addition to the Caddy forward_auth gate. app.use(authenticate({ pool: inventoryPool, secret: process.env.JWT_SECRET })); app.get('/test-db', async (req, res, next) => { try { const result = await pool.query('SELECT COUNT(*) as user_count FROM users WHERE active = true'); const messageResult = await pool.query('SELECT COUNT(*) as message_count FROM message'); const roomResult = await pool.query('SELECT COUNT(*) as room_count FROM room'); res.json({ status: 'success', database: 'rocketchat_converted', stats: { active_users: parseInt(result.rows[0].user_count, 10), total_messages: parseInt(messageResult.rows[0].message_count, 10), total_rooms: parseInt(roomResult.rows[0].room_count, 10), }, }); } catch (err) { next(err); } }); app.use('/', chatRoutes); app.use(errorHandler); // Phase 9 §9.1 — bind to 127.0.0.1. Caddy reverse_proxy targets localhost:3014 // already; this closes the gap where unauthenticated direct-port access from // any host on the network was possible. const server = app.listen(port, '127.0.0.1', () => { console.log(`Chat server running on 127.0.0.1:${port}`); }); const shutdown = async (signal) => { console.log(`chat-server shutting down (${signal})`); server.close(); try { await pool.end(); } catch { /* ignore */ } try { await inventoryPool.end(); } catch { /* ignore */ } process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));