Initial commit: Pokerface sprint planning poker for Jira
Full-stack app with Express/Socket.io backend, React frontend, NATS JetStream for state, and Atlassian Jira OAuth integration. Includes security hardening: NATS auth support, KV bucket TTL enforcement, CAS retry for race conditions, error message sanitization, and OAuth state stored in NATS KV. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
fdd9ba8d56
36 changed files with 7596 additions and 0 deletions
294
backend/src/index.js
Normal file
294
backend/src/index.js
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
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 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 {
|
||||
canAccessSession,
|
||||
getSessionSnapshot,
|
||||
isSessionParticipant,
|
||||
joinSession,
|
||||
leaveSession,
|
||||
revealIfComplete,
|
||||
saveScopedEstimate,
|
||||
submitVote
|
||||
} from './services/pokerService.js';
|
||||
import { updateIssueEstimate } from './services/jiraService.js';
|
||||
import { getSocketUser } from './middleware/auth.js';
|
||||
import { safeError } from './lib/errors.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 !isProd;
|
||||
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=()');
|
||||
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.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
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, {
|
||||
cors: corsOptions,
|
||||
allowRequest: (req, callback) => {
|
||||
if (isAllowedOrigin(req.headers.origin)) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
callback('Origin not allowed', false);
|
||||
}
|
||||
});
|
||||
|
||||
async function emitSessionState(sessionId) {
|
||||
const snapshot = await getSessionSnapshot(sessionId);
|
||||
if (!snapshot) return;
|
||||
|
||||
io.to(`poker:${sessionId}`).emit('poker:participants', {
|
||||
sessionId,
|
||||
participants: snapshot.participants,
|
||||
votedUserKeys: snapshot.votedUserKeys,
|
||||
voteCount: snapshot.voteCount,
|
||||
participantCount: snapshot.participantCount
|
||||
});
|
||||
|
||||
io.to(`poker:${sessionId}`).emit('poker:vote-update', {
|
||||
sessionId,
|
||||
voteCount: snapshot.voteCount,
|
||||
participantCount: snapshot.participantCount,
|
||||
votedUserKeys: snapshot.votedUserKeys,
|
||||
allVoted: snapshot.voteCount > 0 && snapshot.voteCount === snapshot.participantCount
|
||||
});
|
||||
|
||||
if (snapshot.session.state === 'REVEALED' || snapshot.session.state === 'SAVED') {
|
||||
io.to(`poker:${sessionId}`).emit('poker:revealed', {
|
||||
sessionId,
|
||||
votes: snapshot.votesByUser,
|
||||
average: snapshot.session.averageEstimate,
|
||||
suggestedEstimate: snapshot.session.suggestedEstimate,
|
||||
savedEstimate: snapshot.session.savedEstimate
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
socket.on('poker:join', async ({ sessionId }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
socket.emit('poker:error', { error: 'sessionId is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
|
||||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
socket.join(`poker:${sessionId}`);
|
||||
const snapshot = await joinSession({
|
||||
sessionId,
|
||||
tenantCloudId: socket.user.jiraCloudId,
|
||||
userKey: socket.user.jiraAccountId,
|
||||
userName: socket.user.displayName,
|
||||
avatarUrl: socket.user.avatarUrl || null
|
||||
});
|
||||
if (!snapshot) {
|
||||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
await emitSessionState(sessionId);
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:join failed:', error);
|
||||
socket.emit('poker:error', { error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('poker:vote', async ({ sessionId, vote }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
socket.emit('poker:error', { error: 'sessionId is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
|
||||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
if (!await isSessionParticipant(sessionId, socket.user.jiraAccountId)) {
|
||||
socket.emit('poker:error', { error: 'Join the session 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;
|
||||
}
|
||||
const reveal = await revealIfComplete(sessionId);
|
||||
|
||||
await emitSessionState(sessionId);
|
||||
|
||||
if (reveal?.allVoted) {
|
||||
io.to(`poker:${sessionId}`).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) });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('poker:save', async ({ sessionId, estimate }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
socket.emit('poker:error', { error: 'sessionId is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) {
|
||||
socket.emit('poker:error', { error: 'Session not found.' });
|
||||
return;
|
||||
}
|
||||
if (!await isSessionParticipant(sessionId, socket.user.jiraAccountId)) {
|
||||
socket.emit('poker:error', { error: 'Join the session 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;
|
||||
}
|
||||
|
||||
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(`poker:${sessionId}`).emit('poker:saved', {
|
||||
sessionId,
|
||||
estimate: numericEstimate,
|
||||
issueKey: saved.session.issueKey
|
||||
});
|
||||
|
||||
io.to(`poker:${sessionId}`).emit('poker:ended', { sessionId });
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:save failed:', error);
|
||||
socket.emit('poker:error', { error: safeError(error) });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('poker:leave', async ({ sessionId }) => {
|
||||
try {
|
||||
if (!sessionId) return;
|
||||
if (!await canAccessSession(sessionId, socket.user.jiraCloudId)) return;
|
||||
await leaveSession({
|
||||
sessionId,
|
||||
tenantCloudId: socket.user.jiraCloudId,
|
||||
userKey: socket.user.jiraAccountId
|
||||
});
|
||||
socket.leave(`poker:${sessionId}`);
|
||||
await emitSessionState(sessionId);
|
||||
} catch (error) {
|
||||
console.error('[socket] poker:leave 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue