All checks were successful
Build & Push Container Image / build (push) Successful in 8s
Participants were tracked per-session, so each new issue started with 0 participants. The first user to join+vote saw 1/1 = all voted, triggering premature reveal. Now members are tracked on the room object and persist across issues. revealIfComplete compares votes against room member count. Also fixes: disconnect handler was dead code (Socket.IO v4 empties socket.rooms before firing disconnect) — replaced with disconnecting. Added manual "Reveal Votes" button and poker:reveal socket handler. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
415 lines
13 KiB
JavaScript
415 lines
13 KiB
JavaScript
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);
|
|
});
|