Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ DATABASE_URL="postgresql://tim:tim@localhost:5432/tim"
# DATABASE_URL="postgresql://tim:tim@db:5432/tim"

# JWT secret for authentication
# ⚠️ MUST be changed in production — server refuses to start with default value
JWT_SECRET="change-me"

# Socket.IO path (leave default)
Expand All @@ -20,15 +21,32 @@ SOCKET_IO_PATH="/socket.io"
# Example: SOCKET_IO_CORS_ORIGIN="https://app.example.com,https://admin.example.com"
SOCKET_IO_CORS_ORIGIN="http://localhost:5173,http://localhost:3000"

# REST API CORS origins (comma-separated).
# Omit or leave empty to allow all origins in development.
# CORS_ORIGIN="https://app.example.com,https://admin.example.com"
CORS_ORIGIN=

# Express port
PORT=4000

# Node environment: development | production | test
NODE_ENV=development

# Optional: log level (info, warn, error)
# Logging level: trace | debug | info | warn | error | fatal
LOG_LEVEL=info

# Rate limiting (requests per 1-minute window per IP)
RATE_LIMIT_GLOBAL=100
RATE_LIMIT_SAP=30
RATE_LIMIT_AUTH=10

# HTTPS redirect — set to "true" behind a TLS-terminating proxy (ALB, nginx, etc.)
DISABLE_HTTPS_REDIRECT=true

# Maximum request body size (Express body-parser limit). Default: 1mb.
# Accepts formats: '100kb', '1mb', '10mb'
REQUEST_BODY_LIMIT=1mb

# Olympus provenance service (optional — leave empty to disable)
# Set to your Olympus instance URL to enable cryptographic provenance
OLYMPUS_URL=
112 changes: 108 additions & 4 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,23 @@
"dependencies": {
"@prisma/client": "^6.19.3",
"bcryptjs": "^3.0.3",
"compression": "^1.8.1",
"cors": "^2.8.6",
"dotenv": "^17.2.3",
"express": "5.0.0",
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"jose": "^5.9.3",
"pino": "^10.3.1",
"pino-http": "^11.0.0",
"pino-pretty": "^13.1.3",
"socket.io": "^4.8.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.8.1",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^22.7.4",
Expand Down
73 changes: 64 additions & 9 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Server } from 'socket.io';
import { createServer } from 'http';
import { jwtVerify } from 'jose';
import path from 'path';
import helmet from 'helmet';
import compression from 'compression';
import workOrderRoutes from './routes/workOrders.js';
import healthRoutes from './routes/health.js';
import queueRoutes from './routes/queue.js';
Expand All @@ -25,6 +27,12 @@ import bomRoutes from './routes/bom.js';
import materialRoutes from './routes/materials.js';
import { globalLimiter, sapLimiter } from './middleware/rateLimiter.js';
import { httpsRedirect } from './middleware/httpsRedirect.js';
import { requestId } from './middleware/requestId.js';
import { requestLogger } from './middleware/requestLogger.js';
import { corsMiddleware } from './middleware/cors.js';
import { AppError } from './errors/AppError.js';
import { registerGracefulShutdown } from './lifecycle/shutdown.js';
import { logger } from './services/logger.js';
import type { Role } from './middleware/auth.js';
import { getJwtSecret, validateJwtSecret } from './config/jwt.js';

Expand Down Expand Up @@ -62,14 +70,38 @@ export const io = new Server(httpServer, {
}
});

// Security middleware
// ─── Enterprise Middleware Stack ─────────────────────────────────────────────

// 1. Request ID — generate/propagate X-Request-ID for every request
app.use(requestId);

// 2. Security headers (X-Content-Type-Options, X-Frame-Options, CSP, etc.)
// CSP is disabled here because the SPA loads dynamic UI5 web components and
// inline styles. In production, enforce CSP at the CDN / reverse-proxy layer.
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false, // Allow cross-origin resources (SAP UI5)
}));

// 3. HTTPS redirect + HSTS (production only)
app.use(httpsRedirect);

// Rate limiting (recommended by CodeQL)
// 4. CORS — allow REST API access from configured origins
app.use(corsMiddleware);

// 5. Response compression (gzip / deflate)
app.use(compression());

// 6. Rate limiting
app.use(globalLimiter);

// Middleware
app.use(express.json());
// 7. Body parsing with size limits to prevent payload abuse
const bodyLimit = process.env.REQUEST_BODY_LIMIT || '1mb';
app.use(express.json({ limit: bodyLimit }));
app.use(express.urlencoded({ extended: true, limit: bodyLimit }));

// 8. Structured request logging (pino-http)
app.use(requestLogger);

// Socket.IO authentication middleware
io.use(async (socket, next) => {
Expand Down Expand Up @@ -130,24 +162,38 @@ if (staticFilesPath) {
const indexPath = path.join(staticFilesPath, 'index.html');
res.sendFile(indexPath, (err) => {
if (err) {
console.error(`Failed to serve index.html: ${err.message}`);
logger.error({ err, path: indexPath }, 'Failed to serve index.html');
res.status(500).send('Application files not found. Please reinstall the application.');
}
});
});

console.log(`📦 Desktop mode: serving frontend from ${staticFilesPath}`);
logger.info({ staticFilesPath }, 'Desktop mode: serving frontend from static path');
}

// ─── Global Error Handler ────────────────────────────────────────────────────
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
if (res.headersSent) {
return next(err);
}

// Structured AppError instances carry their own status/code
if (err instanceof AppError) {
if (err.status >= 500) {
logger.error({ err, requestId: req.id, context: err.context }, err.message);
}
return res.status(err.status).json({
message: err.message,
code: err.code,
...(req.id && { requestId: req.id }),
});
}

const isInvalidJson =
err instanceof SyntaxError &&
'status' in err &&
req.is('application/json');

const status =
isInvalidJson
? 400
Expand All @@ -159,6 +205,7 @@ const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
err.status < 600
? err.status
: 500;

const message =
status >= 500
? 'Internal server error'
Expand All @@ -169,10 +216,15 @@ const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
: 'Unexpected error';

if (status >= 500) {
console.error(err);
logger.error({ err, requestId: req.id }, message);
} else {
logger.warn({ err, requestId: req.id }, message);
}

return res.status(status).json({ message });
return res.status(status).json({
message,
...(req.id && { requestId: req.id }),
});
};

app.use(errorHandler);
Expand Down Expand Up @@ -211,8 +263,11 @@ const PORT = process.env.PORT || 4000;
// during tests causes EADDRINUSE when multiple test files import this module.
if (process.env.NODE_ENV !== 'test') {
httpServer.listen(PORT, () => {
console.log(`🚀 Server running on port ${PORT}`);
logger.info({ port: PORT, nodeEnv: process.env.NODE_ENV || 'development' }, `TiM server running on port ${PORT}`);
});

// Register graceful shutdown handlers (SIGTERM / SIGINT)
registerGracefulShutdown(httpServer, io);
}

export const server = httpServer;
Expand Down
Loading
Loading