159 lines
6.0 KiB
JavaScript
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;
|