require('dotenv').config() const http = require('http') const app = require('./app') const internalApp = require('./internalApp') const botScore = require('./middleware/botScore') const uoLinkSocket = require('./utils/uoLinkSocket') const uoLinkClient = require('./utils/uoLinkClient') const uoLinkConfig = require('./model/uoLinkConfig/uoLinkConfig.model') const shardBroadcast = require('./utils/shardBroadcast') const announceWorker = require('./utils/announceWorker') const { ensureSchema, close } = require('./utils/db') const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed') const settings = require('./model/settings/settings.model') const revokedSessions = require('./model/revokedSessions/revokedSessions.model') const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model') const createLogger = require('./utils/logger') const { evaluateBotInternalKey } = require('./utils/botInternalKey') const brand = require('./config/brand') const pkg = require('../package.json') const log = createLogger('server') const PORT = Number(process.env.PORT) || 3000 // Separate, UNPUBLISHED listener for server<->bot /internal/* traffic. Kept off // the public PORT so the decrypted-token route can't ride the listener Pangolin // proxies to the world (issue #33). Must match the port in the bot's // SITE_INTERNAL_URL (docker-compose.yml). const INTERNAL_PORT = Number(process.env.INTERNAL_PORT) || 3001 const HOST = '0.0.0.0' // bind all interfaces so Pangolin / the LAN can reach it async function start() { log.info(`starting ${brand.name} server v${pkg.version}`, { node: process.version, env: process.env.NODE_ENV || 'development', logLevel: process.env.LOG_LEVEL || 'info', logFile: createLogger.logFilePath || 'disabled (console only)', db: `${process.env.DB_HOST || '127.0.0.1'}:${process.env.DB_PORT || 3306}/${process.env.DB_NAME || 'runic_gateway'}`, cookieSecure: process.env.COOKIE_SECURE || 'auto', email: 'gmail-oauth2 (configured in admin → settings)', }) // Fail fast if the server<->bot shared secret is weak/placeholder. Fatal in // production (the /internal/bot-config route hands back the decrypted Discord // token and this key is its only guard); a warning otherwise. const keyCheck = evaluateBotInternalKey({ key: process.env.BOT_INTERNAL_KEY, nodeEnv: process.env.NODE_ENV, }) if (keyCheck.fatal) { log.error(keyCheck.message) process.exit(1) } else if (!keyCheck.ok) { log.warn(keyCheck.message) } log.info('ensuring database schema...') await ensureSchema() log.info('seeding defaults...') await seedDefaults() await createInitialAdminFromEnv() // Clear out session-denylist rows whose token has already expired (dead weight). // Best-effort — a prune failure must never block startup. try { const pruned = await revokedSessions.pruneExpired() if (pruned) log.info(`pruned ${pruned} expired revoked-session row(s)`) } catch (err) { log.warn('revoked-session prune failed', { error: err.message }) } // Same treatment for the mobile SSO bridge tables (also pruned opportunistically // on each bridge write). Boot-time sweep catches rows orphaned by a crash. try { const pruned = await mobileAuthBridge.pruneExpired() if (pruned) log.info(`pruned ${pruned} expired mobile-auth-bridge row(s)`) } catch (err) { log.warn('mobile-auth-bridge prune failed', { error: err.message }) } const mode = await settings.get('site_mode') log.info(`site mode: ${String(mode || 'live').toUpperCase()}`) const server = http.createServer(app) server.listen(PORT, HOST, () => { log.info(`listening on http://${HOST}:${PORT} (API at /api/v1, health at /api/health)`) }) // Internal server<->bot API on a separate, unpublished port. NEVER expose this // through Pangolin/the public reverse proxy — it serves the decrypted Discord // bot token to the bot process over the private compose network only (#33). const internalServer = http.createServer(internalApp) internalServer.listen(INTERNAL_PORT, HOST, () => { log.info(`internal API listening on http://${HOST}:${INTERNAL_PORT} (server<->bot only — do NOT proxy)`) }) // Start the uo-link WebSocket ingest client. Self-guards: it only actually // connects when the admin has enabled the integration and saved a token, so // this is a no-op on shards that haven't configured the sidecar. Never let a // sidecar problem block server startup. try { await uoLinkSocket.start() await checkUoLink() } catch (err) { log.warn('uo-link socket failed to start (continuing)', { error: err.message }) } // Start the news-announcement dispatcher: a light in-process poller that pushes // published news posts to the in-game town crier + Discord with independent // retry per leg. No-op until a news post is actually published. announceWorker.start() setupShutdown(server, internalServer) } // Best-effort startup probe of the uo-link sidecar: if the integration is // enabled, log whether it is reachable and warn loudly on a protocol mismatch // (fail-fast visibility rather than silently mis-parsing a newer wire format). async function checkUoLink() { const config = await uoLinkConfig.getSafe() if (!config.enabled) return const health = await uoLinkClient.health() if (!health.ok) { log.warn('uo-link is enabled but the sidecar is unreachable at startup', { baseUrl: config.baseUrl, error: health.error || `status ${health.status}`, }) return } if (health.data && health.data.protocol && health.data.protocol !== config.protocol) { log.error('uo-link PROTOCOL MISMATCH — pinned vs sidecar', { pinned: config.protocol, sidecar: health.data.protocol, }) } else { log.info('uo-link sidecar reachable', { pluginConnected: health.data && health.data.plugin_connected, protocol: health.data && health.data.protocol, }) } } function setupShutdown(server, internalServer) { let closing = false const shutdown = async (signal) => { if (closing) return closing = true log.warn(`${signal} received — shutting down gracefully`) botScore.stopSweeper() // stop the bot-store cleanup interval announceWorker.stop() // stop the news-announcement dispatcher poller uoLinkSocket.stop() // close the uo-link WS ingest client shardBroadcast.closeAll() // end any open shard live-feed SSE streams server.close(() => log.info('http server closed')) if (internalServer) internalServer.close(() => log.info('internal http server closed')) try { await close() log.info('database pool closed') } catch (err) { log.error('error closing database pool', err) } await createLogger.close() // flush the log file process.exit(0) } process.on('SIGINT', () => shutdown('SIGINT')) process.on('SIGTERM', () => shutdown('SIGTERM')) process.on('unhandledRejection', (reason) => log.error('unhandledRejection', { reason: String(reason) })) process.on('uncaughtException', (err) => { log.error('uncaughtException', err) process.exit(1) }) } start().catch((err) => { log.error('failed to start server', err) process.exit(1) })