// acot-server — Phase 5 of CONSOLIDATION_PLAN.md. // Standalone service on ACOT_PORT (default 3012) exposing /api/acot/* against // the production MySQL `sg` database via an ssh2 tunnel (see db/connection.js). // // Auth model (two flavors, deliberate): // - /api/acot/customers/* : x-acot-api-key shared secret (used by acot-phone-server). // Mounted BEFORE authenticate() so its requirePhoneApiKey // path is the only gate. // - everything else : JWT Bearer via shared/auth/middleware.js authenticate(). // Defense-in-depth on top of Caddy forward_auth. // // Shared infrastructure (Phase 2 + Phase 6): // - shared/auth/middleware.js authenticate() for SPA-served routes // - shared/cors/policy.js explicit allowed-origins list (Phase 6.6) // - shared/logging/request-log.js pino-http, Authorization/Cookie redacted (Phase 6.5/6.9) // - shared/errors/handler.js consistent error envelope, no leak in prod // // Env layering: /var/www/inventory/.env loaded FIRST (JWT_SECRET, DB_* for the // shared PG pool used by authenticate to look up user permissions). Local .env // loaded SECOND for ACOT-specific keys (PROD_DB_*, PROD_SSH_*, ACOT_PHONE_API_KEY). // dotenv defaults to override:false, so the first file wins on collisions. import { config as loadEnv } from 'dotenv'; import express from 'express'; import cors from 'cors'; import compression from 'compression'; import morgan from 'morgan'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import pg from 'pg'; import { authenticate } from '../../shared/auth/middleware.js'; import { corsOptions } from '../../shared/cors/policy.js'; import { errorHandler } from '../../shared/errors/handler.js'; import { logger } from '../../shared/logging/logger.js'; import { requestLog } from '../../shared/logging/request-log.js'; import { closeAllConnections } from './db/connection.js'; import testRouter from './routes/test.js'; import eventsRouter from './routes/events.js'; import discountsRouter from './routes/discounts.js'; import employeeMetricsRouter from './routes/employee-metrics.js'; import payrollMetricsRouter from './routes/payroll-metrics.js'; import operationsMetricsRouter from './routes/operations-metrics.js'; import customersRouter from './routes/customers.js'; const { Pool } = pg; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Layer envs: shared inventory .env first (JWT_SECRET, DB_*) then acot .env. 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 }); // Phase 6.4 — refuse to start without JWT_SECRET. authenticate() would reject // every request anyway; failing fast surfaces the misconfiguration immediately. if (!process.env.JWT_SECRET) { logger.error('JWT_SECRET is not set; refusing to start (per Phase 6.4)'); process.exit(1); } const app = express(); const PORT = Number(process.env.ACOT_PORT) || 3012; // Postgres pool for authenticate() (user/permission lookups against inventory_db). // All MySQL access goes through db/connection.js (separate, ssh-tunneled). const pool = 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, }); // Per-app access log on disk (kept from pre-conversion behavior; pino request-log // is mounted below for structured/redacted server-side logging). const logDir = path.join(__dirname, 'logs/app'); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } const accessLogStream = fs.createWriteStream(path.join(logDir, 'access.log'), { flags: 'a' }); app.use(requestLog()); app.use(compression()); app.use(cors(corsOptions)); app.use(express.json()); app.use(express.urlencoded({ extended: true })); if (process.env.NODE_ENV === 'production') { app.use(morgan('combined', { stream: accessLogStream })); } else { app.use(morgan('dev')); } app.get('/health', (req, res) => { res.json({ status: 'healthy', service: 'acot-server', timestamp: new Date().toISOString(), uptime: process.uptime(), }); }); // Customers route uses x-acot-api-key (shared secret with acot-phone-server), // NOT JWT — mount BEFORE authenticate() so requirePhoneApiKey is the only gate. app.use('/api/acot/customers', customersRouter); // All remaining /api/acot/* routes require a valid JWT. app.use('/api/acot', authenticate({ pool, secret: process.env.JWT_SECRET })); app.use('/api/acot/test', testRouter); app.use('/api/acot/events', eventsRouter); app.use('/api/acot/discounts', discountsRouter); app.use('/api/acot/employee-metrics', employeeMetricsRouter); app.use('/api/acot/payroll-metrics', payrollMetricsRouter); app.use('/api/acot/operations-metrics', operationsMetricsRouter); // 404 for unmatched /api routes (keeps prior behavior). app.use((req, res) => { res.status(404).json({ success: false, error: 'Route not found' }); }); app.use(errorHandler); const server = app.listen(PORT, '0.0.0.0', () => { logger.info({ port: PORT, mode: process.env.NODE_ENV || 'development' }, 'acot-server listening'); }); const gracefulShutdown = async (signal) => { logger.info({ signal }, 'acot-server shutting down'); server.close(async () => { try { await closeAllConnections(); } catch (err) { logger.error({ err: { message: err.message } }, 'error closing MySQL pool'); } try { await pool.end(); } catch { /* ignore */ } process.exit(0); }); }; process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); process.on('SIGINT', () => gracefulShutdown('SIGINT')); process.on('uncaughtException', (err) => { logger.error({ err: { message: err.message, stack: err.stack } }, 'uncaughtException'); process.exit(1); }); process.on('unhandledRejection', (reason) => { logger.error({ reason }, 'unhandledRejection'); }); export default app;