import path from 'path'; import { fileURLToPath } from 'url'; import { createServer } from 'http'; import express from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import rateLimit from 'express-rate-limit'; import dotenv from 'dotenv'; import { Server } from 'socket.io'; import natsAdapter from '@mickl/socket.io-nats-adapter'; const { createAdapter } = natsAdapter; import { connectNats, getNatsConnection } from './lib/nats.js'; import pokerRoutes from './routes/poker.js'; import jiraRoutes from './routes/jira.js'; import roomRoutes from './routes/rooms.js'; import { getSessionSnapshot, revealIfComplete, forceReveal, saveScopedEstimate, submitVote } from './services/pokerService.js'; import { joinRoom, leaveRoom, getRoomMembers, getRoomMemberCount, isRoomMember } from './services/roomService.js'; import { updateIssueEstimate } from './services/jiraService.js'; import { getSocketUser } from './middleware/auth.js'; import { safeError } from './lib/errors.js'; import { register, websocketConnections, votesTotal, sessionsSavedTotal, httpRequestsTotal, httpRequestDuration, trackUniqueUser, trackUniqueTenant } from './lib/metrics.js'; dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const isProd = process.env.NODE_ENV === 'production'; const app = express(); const httpServer = createServer(app); const port = Number(process.env.PORT || 4010); if (isProd && !process.env.FRONTEND_URL) { throw new Error('FRONTEND_URL must be set in production.'); } const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5174'; const corsOptions = { origin: frontendUrl, credentials: true }; function isAllowedOrigin(origin) { if (!origin) return true; // same-origin requests omit the Origin header return origin === frontendUrl; } app.disable('x-powered-by'); app.use((_req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); res.setHeader('Content-Security-Policy', [ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", `connect-src 'self' wss://${isProd ? new URL(frontendUrl).host : '*'}`, "img-src 'self' https://*.atl-paas.net https://*.atlassian.com https://*.gravatar.com https://*.wp.com data:", "font-src 'self' https://fonts.gstatic.com", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'" ].join('; ')); if (isProd) { res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } next(); }); app.use(cors(corsOptions)); app.use(cookieParser()); app.use(express.json({ limit: '1mb' })); app.use('/api/', rateLimit({ windowMs: 60_000, max: 100, standardHeaders: true, legacyHeaders: false })); // Prometheus metrics endpoint (no auth, before rate limiter) app.get('/metrics', async (_req, res) => { res.set('Content-Type', register.contentType); res.end(await register.metrics()); }); app.get('/api/health', (_req, res) => { res.json({ status: 'ok' }); }); // HTTP metrics middleware app.use((req, res, next) => { if (req.path === '/metrics' || req.path === '/api/health') return next(); const end = httpRequestDuration.startTimer(); res.on('finish', () => { const route = req.route?.path || req.path; httpRequestsTotal.inc({ method: req.method, route, status: res.statusCode }); end({ method: req.method, route }); }); next(); }); app.use('/api/poker', pokerRoutes); app.use('/api/jira', jiraRoutes); app.use('/api/poker/rooms', roomRoutes); if (isProd) { const distPath = path.resolve(__dirname, '../../frontend/dist'); app.use(express.static(distPath)); app.get('*', (req, res) => { if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' }); res.sendFile(path.join(distPath, 'index.html')); }); } const io = new Server(httpServer, { transports: ['websocket'], cors: corsOptions, allowRequest: (req, callback) => { if (isAllowedOrigin(req.headers.origin)) { callback(null, true); return; } callback('Origin not allowed', false); } }); async function emitRoomMembers(roomId, cloudId) { const members = await getRoomMembers(roomId, cloudId); io.to(`room:${roomId}`).emit('room:members', { roomId, members }); } async function emitSessionState(roomId, sessionId, tenantCloudId) { const snapshot = await getSessionSnapshot(sessionId, tenantCloudId); if (!snapshot) return; const memberCount = await getRoomMemberCount(roomId, tenantCloudId); io.to(`room:${roomId}`).emit('poker:vote-update', { sessionId, voteCount: snapshot.voteCount, votedUserKeys: snapshot.votedUserKeys, memberCount }); if (snapshot.session.state === 'REVEALED' || snapshot.session.state === 'SAVED') { io.to(`room:${roomId}`).emit('poker:revealed', { sessionId, votes: snapshot.votesByUser, average: snapshot.session.averageEstimate, suggestedEstimate: snapshot.session.suggestedEstimate, savedEstimate: snapshot.session.savedEstimate }); } } function socketThrottle(socket, limitPerMinute = 60) { const counts = new Map(); return (event, handler) => { socket.on(event, async (...args) => { const now = Date.now(); const entry = counts.get(event) || { count: 0, resetAt: now + 60_000 }; if (now > entry.resetAt) { entry.count = 0; entry.resetAt = now + 60_000; } entry.count++; counts.set(event, entry); if (entry.count > limitPerMinute) { socket.emit('poker:error', { error: 'Too many requests, slow down.' }); return; } await handler(...args); }); }; } io.on('connection', (socket) => { const user = getSocketUser(socket); if (!user || !user.jiraCloudId) { socket.emit('poker:error', { error: 'Not authenticated' }); socket.disconnect(true); return; } socket.user = user; websocketConnections.inc(); trackUniqueUser(user.jiraAccountId); trackUniqueTenant(user.jiraCloudId); // Use 'disconnecting' — socket.rooms is still populated (unlike 'disconnect') socket.on('disconnecting', async () => { websocketConnections.dec(); const roomId = socket.roomId; if (!roomId) return; try { await leaveRoom(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId); await emitRoomMembers(roomId, socket.user.jiraCloudId); } catch { // best-effort cleanup } }); const throttled = socketThrottle(socket); throttled('room:join', async ({ roomId }) => { try { if (!roomId) { socket.emit('poker:error', { error: 'roomId is required.' }); return; } await joinRoom(roomId, socket.user.jiraCloudId, { userKey: socket.user.jiraAccountId, userName: socket.user.displayName, avatarUrl: socket.user.avatarUrl || null }); socket.join(`room:${roomId}`); socket.roomId = roomId; await emitRoomMembers(roomId, socket.user.jiraCloudId); } catch (error) { console.error('[socket] room:join failed:', error); socket.emit('poker:error', { error: safeError(error) }); } }); throttled('room:leave', async ({ roomId }) => { try { if (!roomId) return; await leaveRoom(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId); socket.leave(`room:${roomId}`); socket.roomId = null; await emitRoomMembers(roomId, socket.user.jiraCloudId); } catch (error) { console.error('[socket] room:leave failed:', error); socket.emit('poker:error', { error: safeError(error) }); } }); throttled('poker:vote', async ({ sessionId, vote }) => { try { if (!sessionId) { socket.emit('poker:error', { error: 'sessionId is required.' }); return; } const snapshot = await getSessionSnapshot(sessionId, socket.user.jiraCloudId); if (!snapshot) { socket.emit('poker:error', { error: 'Session not found.' }); return; } const roomId = snapshot.session.roomId; if (!await isRoomMember(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId)) { socket.emit('poker:error', { error: 'Join the room before voting.' }); return; } const voteResult = await submitVote({ sessionId, tenantCloudId: socket.user.jiraCloudId, userKey: socket.user.jiraAccountId, vote }); if (!voteResult) { socket.emit('poker:error', { error: 'Unable to submit vote for this session.' }); return; } votesTotal.inc(); const memberCount = await getRoomMemberCount(roomId, socket.user.jiraCloudId); const reveal = await revealIfComplete(sessionId, socket.user.jiraCloudId, memberCount); await emitSessionState(roomId, sessionId, socket.user.jiraCloudId); if (reveal?.allVoted) { io.to(`room:${roomId}`).emit('poker:revealed', { sessionId, votes: reveal.votesByUser, average: reveal.average, suggestedEstimate: reveal.suggestedEstimate }); } } catch (error) { console.error('[socket] poker:vote failed:', error); socket.emit('poker:error', { error: safeError(error) }); } }); throttled('poker:reveal', async ({ sessionId }) => { try { if (!sessionId) { socket.emit('poker:error', { error: 'sessionId is required.' }); return; } const snapshot = await forceReveal(sessionId, socket.user.jiraCloudId); if (!snapshot) { socket.emit('poker:error', { error: 'Unable to reveal votes.' }); return; } const roomId = snapshot.session.roomId; io.to(`room:${roomId}`).emit('poker:revealed', { sessionId, votes: snapshot.votesByUser, average: snapshot.session.averageEstimate, suggestedEstimate: snapshot.session.suggestedEstimate }); await emitSessionState(roomId, sessionId, socket.user.jiraCloudId); } catch (error) { console.error('[socket] poker:reveal failed:', error); socket.emit('poker:error', { error: safeError(error) }); } }); throttled('poker:save', async ({ sessionId, estimate }) => { try { if (!sessionId) { socket.emit('poker:error', { error: 'sessionId is required.' }); return; } const pre = await getSessionSnapshot(sessionId, socket.user.jiraCloudId); if (!pre) { socket.emit('poker:error', { error: 'Session not found.' }); return; } const roomId = pre.session.roomId; if (!await isRoomMember(roomId, socket.user.jiraCloudId, socket.user.jiraAccountId)) { socket.emit('poker:error', { error: 'Join the room before saving.' }); return; } const numericEstimate = Number(estimate); if (!Number.isFinite(numericEstimate)) { socket.emit('poker:error', { error: 'estimate must be a number.' }); return; } const saved = await saveScopedEstimate({ sessionId, estimate: numericEstimate, tenantCloudId: socket.user.jiraCloudId, userKey: socket.user.jiraAccountId }); if (!saved) { socket.emit('poker:error', { error: 'Unable to save estimate for this session.' }); return; } sessionsSavedTotal.inc(); const issueRef = saved.session.issueId || saved.session.issueKey; try { await updateIssueEstimate(socket.user.jiraAccountId, issueRef, numericEstimate, saved.session.boardId); } catch (_jiraError) { // Jira update is best-effort so poker flow continues even when Jira is unavailable. } io.to(`room:${roomId}`).emit('poker:saved', { sessionId, estimate: numericEstimate, issueKey: saved.session.issueKey }); io.to(`room:${roomId}`).emit('poker:ended', { sessionId }); } catch (error) { console.error('[socket] poker:save failed:', error); socket.emit('poker:error', { error: safeError(error) }); } }); throttled('poker:kick', async ({ roomId, userKey }) => { try { if (!roomId || !userKey) { socket.emit('poker:error', { error: 'roomId and userKey are required.' }); return; } await leaveRoom(roomId, socket.user.jiraCloudId, userKey); await emitRoomMembers(roomId, socket.user.jiraCloudId); } catch (error) { console.error('[socket] poker:kick failed:', error); socket.emit('poker:error', { error: safeError(error) }); } }); }); async function start() { const nc = await connectNats(); io.adapter(createAdapter(nc)); httpServer.listen(port, () => { // eslint-disable-next-line no-console console.log(`Pokerface backend listening on :${port}`); }); } start().catch((error) => { // eslint-disable-next-line no-console console.error('Failed to start server:', error); process.exit(1); });