Files
inventory/inventory-server/dashboard/acot-server/server.js
T
2026-05-24 09:41:06 -04:00

159 lines
6.0 KiB
JavaScript

// 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;