Phase 5 + all remaining
This commit is contained in:
@@ -1,103 +1,158 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const morgan = require('morgan');
|
||||
const compression = require('compression');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { closeAllConnections } = require('./db/connection');
|
||||
// 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 = process.env.ACOT_PORT || 3012;
|
||||
const PORT = Number(process.env.ACOT_PORT) || 3012;
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
// 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' });
|
||||
|
||||
// Create a write stream for access logs
|
||||
const accessLogStream = fs.createWriteStream(
|
||||
path.join(logDir, 'access.log'),
|
||||
{ flags: 'a' }
|
||||
);
|
||||
|
||||
// Middleware
|
||||
app.use(requestLog());
|
||||
app.use(compression());
|
||||
app.use(cors());
|
||||
app.use(cors(corsOptions));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Logging middleware
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use(morgan('combined', { stream: accessLogStream }));
|
||||
} else {
|
||||
app.use(morgan('dev'));
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
service: 'acot-server',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime()
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/acot/test', require('./routes/test'));
|
||||
app.use('/api/acot/events', require('./routes/events'));
|
||||
app.use('/api/acot/discounts', require('./routes/discounts'));
|
||||
app.use('/api/acot/employee-metrics', require('./routes/employee-metrics'));
|
||||
app.use('/api/acot/payroll-metrics', require('./routes/payroll-metrics'));
|
||||
app.use('/api/acot/operations-metrics', require('./routes/operations-metrics'));
|
||||
app.use('/api/acot/customers', require('./routes/customers'));
|
||||
// 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);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: process.env.NODE_ENV === 'production'
|
||||
? 'Internal server error'
|
||||
: err.message
|
||||
});
|
||||
});
|
||||
// All remaining /api/acot/* routes require a valid JWT.
|
||||
app.use('/api/acot', authenticate({ pool, secret: process.env.JWT_SECRET }));
|
||||
|
||||
// 404 handler
|
||||
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'
|
||||
});
|
||||
res.status(404).json({ success: false, error: 'Route not found' });
|
||||
});
|
||||
|
||||
// Start server
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`ACOT Server running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV}`);
|
||||
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');
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const gracefulShutdown = async () => {
|
||||
console.log('SIGTERM signal received: closing HTTP server');
|
||||
const gracefulShutdown = async (signal) => {
|
||||
logger.info({ signal }, 'acot-server shutting down');
|
||||
server.close(async () => {
|
||||
console.log('HTTP server closed');
|
||||
|
||||
// Close database connections
|
||||
try {
|
||||
await closeAllConnections();
|
||||
console.log('Database connections closed');
|
||||
} catch (error) {
|
||||
console.error('Error closing database connections:', error);
|
||||
} 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);
|
||||
process.on('SIGINT', gracefulShutdown);
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
module.exports = app;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user