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:
Jan Willem Mannaerts 2026-02-26 21:38:37 +01:00
commit fdd9ba8d56
36 changed files with 7596 additions and 0 deletions

12
backend/.env.example Normal file
View file

@ -0,0 +1,12 @@
PORT=4010
FRONTEND_URL=http://localhost:5174
NATS_URL=nats://localhost:4222
# Jira OAuth (Atlassian 3LO)
JIRA_CLIENT_ID=
JIRA_CLIENT_SECRET=
JIRA_OAUTH_REDIRECT_URI=http://localhost:4010/api/jira/oauth/callback
JIRA_SCOPES="offline_access read:jira-work write:jira-work read:me"
JWT_SECRET=change-me-to-a-random-secret
JIRA_STORY_POINTS_FIELD=customfield_10016
JIRA_MOCK_FALLBACK=true

1648
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

24
backend/package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "pokerface-backend",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.js",
"scripts": {
"dev": "nodemon src/index.js",
"start": "node src/index.js"
},
"dependencies": {
"@mickl/socket.io-nats-adapter": "^2.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"jsonwebtoken": "^9.0.3",
"nats": "^2.28.2",
"socket.io": "^4.8.1"
},
"devDependencies": {
"nodemon": "^3.1.7"
}
}

294
backend/src/index.js Normal file
View 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);
});

View file

@ -0,0 +1,5 @@
const isProd = process.env.NODE_ENV === 'production';
export function safeError(error) {
return isProd ? 'Internal server error.' : error.message;
}

78
backend/src/lib/nats.js Normal file
View file

@ -0,0 +1,78 @@
import { connect } from 'nats';
const natsUrl = process.env.NATS_URL || 'nats://localhost:4222';
const BUCKET_TTL_MS = 24 * 60 * 60 * 1000; // 24h in milliseconds
let nc = null;
let js = null;
let kvOAuth = null;
let kvRooms = null;
let kvSprintIndex = null;
let kvActiveRooms = null;
let kvSessions = null;
let kvSessionIndex = null;
let kvOAuthState = null;
export async function connectNats() {
const opts = { servers: natsUrl };
if (process.env.NATS_TOKEN) {
opts.token = process.env.NATS_TOKEN;
} else if (process.env.NATS_USER) {
opts.user = process.env.NATS_USER;
opts.pass = process.env.NATS_PASS || '';
}
nc = await connect(opts);
js = nc.jetstream();
async function getOrCreateKv(name, ttl = BUCKET_TTL_MS) {
const kv = await js.views.kv(name, { ttl });
// Enforce TTL on existing buckets whose config may be stale
const jsm = await nc.jetstreamManager();
const info = await jsm.streams.info(`KV_${name}`);
if (info.config.max_age !== ttl * 1_000_000) { // NATS uses nanoseconds
info.config.max_age = ttl * 1_000_000;
await jsm.streams.update(info.config.name, info.config);
}
return kv;
}
kvOAuth = await getOrCreateKv('oauth');
kvRooms = await getOrCreateKv('rooms');
kvSprintIndex = await getOrCreateKv('rooms-sprint-index');
kvActiveRooms = await getOrCreateKv('active-rooms');
kvSessions = await getOrCreateKv('sessions');
kvSessionIndex = await getOrCreateKv('session-issue-index');
kvOAuthState = await getOrCreateKv('oauth-state', 10 * 60 * 1000); // 10min TTL
// eslint-disable-next-line no-console
console.log('Connected to NATS at', natsUrl);
return nc;
}
export function getNatsConnection() {
return nc;
}
export async function withCasRetry(kv, key, transformFn, maxRetries = 5) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const entry = await kv.get(key);
const current = entry ? entry.json() : null;
const revision = entry ? entry.revision : 0;
const result = await transformFn(current, revision);
if (result === undefined) return current; // no-op
try {
if (revision === 0) {
await kv.create(key, JSON.stringify(result));
} else {
await kv.update(key, JSON.stringify(result), revision);
}
return result;
} catch (err) {
if (attempt === maxRetries) throw err;
// CAS conflict — retry
}
}
}
export { nc, js, kvOAuth, kvRooms, kvSprintIndex, kvActiveRooms, kvSessions, kvSessionIndex, kvOAuthState };

View file

@ -0,0 +1,74 @@
import jwt from 'jsonwebtoken';
const COOKIE_NAME = 'pokerface_session';
const JWT_EXPIRY = '24h';
const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
function getSecret() {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error('Missing JWT_SECRET env var');
return secret;
}
export function createSessionToken(user) {
return jwt.sign(
{
jiraAccountId: user.jiraAccountId,
jiraCloudId: user.jiraCloudId,
displayName: user.displayName,
avatarUrl: user.avatarUrl
},
getSecret(),
{ expiresIn: JWT_EXPIRY }
);
}
export function setSessionCookie(res, token) {
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: SESSION_MAX_AGE_MS
});
}
export function clearSessionCookie(res) {
res.clearCookie(COOKIE_NAME);
}
function verifyToken(token) {
try {
return jwt.verify(token, getSecret());
} catch {
return null;
}
}
export function requireAuth(req, res, next) {
const token = req.cookies?.[COOKIE_NAME];
if (!token) {
return res.status(401).json({ error: 'Not authenticated' });
}
const user = verifyToken(token);
if (!user) {
return res.status(401).json({ error: 'Invalid or expired session' });
}
if (!user.jiraAccountId || !user.jiraCloudId) {
return res.status(401).json({ error: 'Invalid session tenant context' });
}
req.user = user;
next();
}
export function getSocketUser(socket) {
const cookieHeader = socket.handshake.headers.cookie;
if (!cookieHeader) return null;
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`));
if (!match) return null;
return verifyToken(match[1]);
}

175
backend/src/routes/jira.js Normal file
View file

@ -0,0 +1,175 @@
import crypto from 'crypto';
import express from 'express';
import {
buildOAuthUrl,
exchangeCodeForToken,
getProjects,
getBoards,
getBoardSprints,
getSprintIssues,
saveOAuthConnection,
updateIssueEstimate
} from '../services/jiraService.js';
import {
createSessionToken,
setSessionCookie,
clearSessionCookie,
requireAuth
} from '../middleware/auth.js';
import { kvOAuthState } from '../lib/nats.js';
import { safeError } from '../lib/errors.js';
const router = express.Router();
router.get('/oauth/start', async (_req, res) => {
try {
const state = crypto.randomBytes(24).toString('hex');
await kvOAuthState.put(state, 'valid');
const url = buildOAuthUrl(state);
res.json({ url });
} catch (error) {
console.error('[oauth] Start failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/oauth/callback', async (req, res) => {
const { state, code } = req.query;
const isProd = process.env.NODE_ENV === 'production';
const frontendUrl = process.env.FRONTEND_URL || (isProd ? '' : 'http://localhost:5174');
try {
const entry = await kvOAuthState.get(String(state));
if (!state || !entry) {
return res.status(400).send('Invalid OAuth state.');
}
if (!code) {
return res.status(400).send('Missing OAuth code.');
}
await kvOAuthState.delete(String(state));
const tokenPayload = await exchangeCodeForToken(String(code));
const { connection, profile } = await saveOAuthConnection(tokenPayload);
if (!connection.cloudId) {
throw new Error('No Jira cloud site available for this account.');
}
const jwt = createSessionToken({
jiraAccountId: profile.accountId,
jiraCloudId: connection.cloudId,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl
});
setSessionCookie(res, jwt);
res.redirect(`${frontendUrl}?auth=success`);
} catch (error) {
console.error('[oauth] Callback failed:', error.message);
const safeMessage = isProd ? 'Jira authentication failed.' : error.message;
res.redirect(`${frontendUrl}?auth=error&message=${encodeURIComponent(safeMessage)}`);
}
});
router.get('/me', requireAuth, (req, res) => {
res.json({
jiraAccountId: req.user.jiraAccountId,
jiraCloudId: req.user.jiraCloudId,
displayName: req.user.displayName,
avatarUrl: req.user.avatarUrl
});
});
router.post('/logout', (_req, res) => {
clearSessionCookie(res);
res.json({ ok: true });
});
router.get('/projects', requireAuth, async (req, res) => {
try {
const projects = await getProjects(req.user.jiraAccountId);
res.json({ projects });
} catch (error) {
console.error('[jira] Projects failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/boards', requireAuth, async (req, res) => {
try {
const { projectKeyOrId } = req.query;
if (!projectKeyOrId) {
return res.status(400).json({ error: 'projectKeyOrId query parameter is required.' });
}
const boards = await getBoards(req.user.jiraAccountId, projectKeyOrId);
res.json({ boards });
} catch (error) {
console.error('[jira] Boards failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/boards/:boardId/sprints', requireAuth, async (req, res) => {
try {
const sprints = await getBoardSprints(req.user.jiraAccountId, req.params.boardId);
res.json({ sprints });
} catch (error) {
console.error('[jira] Sprints failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/status', requireAuth, async (req, res) => {
try {
res.json({ connected: true, jiraAccountId: req.user.jiraAccountId });
} catch (error) {
console.error('[jira] Status failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/sprints/:sprintId/issues', requireAuth, async (req, res) => {
try {
const { sprintId } = req.params;
const { boardId } = req.query;
const issues = await getSprintIssues(req.user.jiraAccountId, String(sprintId), boardId ? String(boardId) : null);
res.json({ issues, source: 'jira' });
} catch (error) {
if (process.env.JIRA_MOCK_FALLBACK === 'true') {
const sprintId = req.params.sprintId;
const issues = [
{ id: `mock-${sprintId}-1`, key: `MOCK-${sprintId}1`, title: 'Authentication hardening', description: 'Harden authentication flow against common attacks.', estimate: 0, status: 'To Do', assignee: 'Alice' },
{ id: `mock-${sprintId}-2`, key: `MOCK-${sprintId}2`, title: 'Webhook retries', description: 'Add retry logic with exponential backoff for failed webhooks.', estimate: 0, status: 'To Do', assignee: 'Bob' },
{ id: `mock-${sprintId}-3`, key: `MOCK-${sprintId}3`, title: 'Billing summary endpoint', description: 'Create GET /billing/summary endpoint.', estimate: 5, status: 'In Progress', assignee: 'Eve' }
];
return res.json({ issues, source: 'mock' });
}
console.error('[jira] Sprint issues failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.patch('/issues/:issueIdOrKey/estimate', requireAuth, async (req, res) => {
try {
const { issueIdOrKey } = req.params;
const { estimate, boardId } = req.body;
if (estimate === undefined || estimate === null) {
return res.status(400).json({ error: 'estimate is required.' });
}
if (!boardId) {
return res.status(400).json({ error: 'boardId is required.' });
}
await updateIssueEstimate(req.user.jiraAccountId, issueIdOrKey, Number(estimate), String(boardId));
res.json({ ok: true });
} catch (error) {
console.error('[jira] Estimate update failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
export default router;

View file

@ -0,0 +1,65 @@
import express from 'express';
import { requireAuth } from '../middleware/auth.js';
import { canAccessSession, createScopedSession, getSessionSnapshot } from '../services/pokerService.js';
import { getRoomById } from '../services/roomService.js';
import { safeError } from '../lib/errors.js';
const router = express.Router();
router.post('/sessions', requireAuth, async (req, res) => {
try {
const { issueKey, issueId, issueTitle, roomId } = req.body;
if (!req.user.jiraCloudId) {
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
}
if (!issueKey || !issueTitle) {
return res.status(400).json({ error: 'issueKey and issueTitle are required.' });
}
if (!roomId) {
return res.status(400).json({ error: 'roomId is required.' });
}
const room = await getRoomById(String(roomId), req.user.jiraCloudId);
if (!room) {
return res.status(404).json({ error: 'Room not found.' });
}
const snapshot = await createScopedSession({
issueKey,
issueId,
issueTitle,
roomId: String(roomId),
boardId: room.boardId,
tenantCloudId: req.user.jiraCloudId
});
return res.json({ session: snapshot.session });
} catch (error) {
console.error('[poker] Create session failed:', error);
return res.status(500).json({ error: safeError(error) });
}
});
router.get('/sessions/:sessionId', requireAuth, async (req, res) => {
try {
if (!req.user.jiraCloudId) {
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
}
if (!await canAccessSession(req.params.sessionId, req.user.jiraCloudId)) {
return res.status(404).json({ error: 'Session not found.' });
}
const snapshot = await getSessionSnapshot(req.params.sessionId);
if (!snapshot) {
return res.status(404).json({ error: 'Session not found.' });
}
res.json(snapshot);
} catch (error) {
console.error('[poker] Get session failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
export default router;

View file

@ -0,0 +1,98 @@
import express from 'express';
import { requireAuth } from '../middleware/auth.js';
import { createRoom, getActiveRooms, getRoomById, deleteRoom } from '../services/roomService.js';
import { safeError } from '../lib/errors.js';
const router = express.Router();
router.post('/', requireAuth, async (req, res) => {
try {
if (!req.user.jiraCloudId) {
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
}
const { boardId, projectKey, projectName, sprintId, sprintName } = req.body;
if (!boardId || !sprintId || !sprintName) {
return res.status(400).json({ error: 'boardId, sprintId, and sprintName are required.' });
}
const numericBoardId = Number(boardId);
const numericSprintId = Number(sprintId);
if (!Number.isInteger(numericBoardId) || !Number.isInteger(numericSprintId) || numericBoardId <= 0 || numericSprintId <= 0) {
return res.status(400).json({ error: 'boardId and sprintId must be valid integers.' });
}
const room = await createRoom({
cloudId: req.user.jiraCloudId,
boardId: numericBoardId,
projectKey: projectKey || '',
projectName: projectName || '',
sprintId: numericSprintId,
sprintName,
createdByAccountId: req.user.jiraAccountId,
createdByName: req.user.displayName
});
res.json({ room });
} catch (error) {
console.error('[rooms] Create room failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/', requireAuth, async (req, res) => {
try {
if (!req.user.jiraCloudId) {
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
}
const rooms = await getActiveRooms(req.user.jiraCloudId);
res.json({ rooms });
} catch (error) {
console.error('[rooms] Get active rooms failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.get('/:roomId', requireAuth, async (req, res) => {
try {
if (!req.user.jiraCloudId) {
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
}
const room = await getRoomById(req.params.roomId, req.user.jiraCloudId);
if (!room) {
return res.status(404).json({ error: 'Room not found.' });
}
res.json({ room });
} catch (error) {
console.error('[rooms] Get room failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
router.delete('/:roomId', requireAuth, async (req, res) => {
try {
if (!req.user.jiraCloudId) {
return res.status(401).json({ error: 'Missing tenant context. Please sign in again.' });
}
const room = await getRoomById(req.params.roomId, req.user.jiraCloudId);
if (!room) {
return res.status(404).json({ error: 'Room not found.' });
}
if (room.createdByAccountId !== req.user.jiraAccountId) {
return res.status(403).json({ error: 'Only the room creator can delete this room.' });
}
await deleteRoom(req.params.roomId, req.user.jiraCloudId);
res.json({ ok: true });
} catch (error) {
console.error('[rooms] Delete room failed:', error);
res.status(500).json({ error: safeError(error) });
}
});
export default router;

View file

@ -0,0 +1,285 @@
import { kvOAuth } from '../lib/nats.js';
const ATLASSIAN_AUTH_URL = 'https://auth.atlassian.com/authorize';
const ATLASSIAN_TOKEN_URL = 'https://auth.atlassian.com/oauth/token';
const ATLASSIAN_RESOURCES_URL = 'https://api.atlassian.com/oauth/token/accessible-resources';
const ATLASSIAN_ME_URL = 'https://api.atlassian.com/me';
function getRequiredEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`Missing required env var: ${name}`);
return value;
}
function getExpiresAt(expiresInSeconds) {
if (!expiresInSeconds) return null;
return Date.now() + (Number(expiresInSeconds) - 60) * 1000;
}
export function buildOAuthUrl(state) {
const clientId = getRequiredEnv('JIRA_CLIENT_ID');
const redirectUri = getRequiredEnv('JIRA_OAUTH_REDIRECT_URI');
const scope = process.env.JIRA_SCOPES || 'offline_access read:jira-work write:jira-work read:me';
const params = new URLSearchParams({
audience: 'api.atlassian.com',
client_id: clientId,
scope,
redirect_uri: redirectUri,
state,
response_type: 'code',
prompt: 'consent'
});
return `${ATLASSIAN_AUTH_URL}?${params.toString()}`;
}
export async function exchangeCodeForToken(code) {
const body = {
grant_type: 'authorization_code',
client_id: getRequiredEnv('JIRA_CLIENT_ID'),
client_secret: getRequiredEnv('JIRA_CLIENT_SECRET'),
code,
redirect_uri: getRequiredEnv('JIRA_OAUTH_REDIRECT_URI')
};
const response = await fetch(ATLASSIAN_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
}
return response.json();
}
async function getAccessibleResources(accessToken) {
const response = await fetch(ATLASSIAN_RESOURCES_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to load Jira resources: ${response.status} ${errorText}`);
}
return response.json();
}
export async function fetchJiraProfile(accessToken) {
const response = await fetch(ATLASSIAN_ME_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch Jira profile: ${response.status} ${errorText}`);
}
const data = await response.json();
return {
accountId: data.account_id,
displayName: data.name || data.displayName || 'Unknown',
avatarUrl: data.picture || null,
email: data.email || null
};
}
export async function saveOAuthConnection(tokenPayload) {
const profile = await fetchJiraProfile(tokenPayload.access_token);
const resources = await getAccessibleResources(tokenPayload.access_token);
const primary = resources[0] || {};
const connection = {
jiraAccountId: profile.accountId,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
email: profile.email,
cloudId: primary.id || null,
siteUrl: primary.url || null,
accessToken: tokenPayload.access_token,
refreshToken: tokenPayload.refresh_token || null,
scope: tokenPayload.scope || null,
expiresAt: getExpiresAt(tokenPayload.expires_in)
};
await kvOAuth.put(`oauth.${profile.accountId}`, JSON.stringify(connection));
return { connection, profile };
}
async function refreshAccessToken(connection) {
if (!connection.refreshToken) {
throw new Error('Jira refresh token is missing. Reconnect Jira OAuth.');
}
const body = {
grant_type: 'refresh_token',
client_id: getRequiredEnv('JIRA_CLIENT_ID'),
client_secret: getRequiredEnv('JIRA_CLIENT_SECRET'),
refresh_token: connection.refreshToken
};
const response = await fetch(ATLASSIAN_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
}
const refreshed = await response.json();
const updated = {
...connection,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token || connection.refreshToken,
scope: refreshed.scope || connection.scope,
expiresAt: getExpiresAt(refreshed.expires_in)
};
await kvOAuth.put(`oauth.${connection.jiraAccountId}`, JSON.stringify(updated));
return updated;
}
async function getValidConnection(jiraAccountId) {
const entry = await kvOAuth.get(`oauth.${jiraAccountId}`);
if (!entry) throw new Error('Jira is not connected for this account.');
const connection = entry.json();
if (!connection.accessToken || !connection.cloudId) {
throw new Error('Jira is not connected for this account.');
}
if (!connection.expiresAt || connection.expiresAt > Date.now()) {
return connection;
}
return refreshAccessToken(connection);
}
async function jiraFetch(jiraAccountId, path, options = {}) {
const connection = await getValidConnection(jiraAccountId);
const url = `https://api.atlassian.com/ex/jira/${connection.cloudId}${path}`;
const response = await fetch(url, {
...options,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${connection.accessToken}`,
...(options.headers || {})
}
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Jira API failed: ${response.status} ${errorText}`);
}
if (response.status === 204) return null;
return response.json();
}
function normalizeIssue(issue) {
return {
id: issue.id,
key: issue.key,
title: issue.fields?.summary || issue.key,
description: issue.fields?.description || null,
estimate: issue.fields?.[process.env.JIRA_STORY_POINTS_FIELD || 'customfield_10016'] || 0,
status: issue.fields?.status?.name || 'Unknown',
reporter: issue.fields?.reporter?.displayName || null
};
}
export async function getSprintIssues(jiraAccountId, sprintId, boardId) {
let spField = process.env.JIRA_STORY_POINTS_FIELD || 'customfield_10016';
if (boardId) {
try {
const config = await jiraFetch(jiraAccountId, `/rest/agile/1.0/board/${boardId}/configuration`);
const estField = config?.estimation?.field?.fieldId;
if (estField) spField = estField;
} catch (_e) {
// fall back to default
}
}
const response = await jiraFetch(
jiraAccountId,
`/rest/agile/1.0/sprint/${sprintId}/issue?maxResults=200&jql=${encodeURIComponent('resolution = Unresolved AND issuetype not in subtaskIssueTypes()')}&fields=summary,status,reporter,description,${spField}`
);
return (response?.issues || []).map((issue) => ({
id: issue.id,
key: issue.key,
title: issue.fields?.summary || issue.key,
description: issue.fields?.description || null,
estimate: issue.fields?.[spField] || 0,
status: issue.fields?.status?.name || 'Unknown',
reporter: issue.fields?.reporter?.displayName || null
})).filter((issue) => !issue.estimate);
}
export async function updateIssueEstimate(jiraAccountId, issueIdOrKey, estimate, boardId) {
await jiraFetch(jiraAccountId, `/rest/agile/1.0/issue/${issueIdOrKey}/estimation?boardId=${boardId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(estimate) })
});
}
export async function getProjects(jiraAccountId) {
const response = await jiraFetch(
jiraAccountId,
'/rest/api/3/project/search?maxResults=100&orderBy=name'
);
return (response?.values || []).map((project) => ({
id: project.id,
key: project.key,
name: project.name,
avatarUrl: project.avatarUrls?.['24x24'] || null
}));
}
export async function getBoards(jiraAccountId, projectKeyOrId) {
const response = await jiraFetch(
jiraAccountId,
`/rest/agile/1.0/board?projectKeyOrId=${encodeURIComponent(projectKeyOrId)}&maxResults=100`
);
return (response?.values || [])
.filter((board) => board.type === 'scrum' || board.type === 'simple')
.map((board) => ({
id: board.id,
name: board.name,
type: board.type,
projectKey: board.location?.projectKey || null,
projectName: board.location?.projectName || null
}));
}
export async function getBoardSprints(jiraAccountId, boardId) {
const response = await jiraFetch(
jiraAccountId,
`/rest/agile/1.0/board/${boardId}/sprint?state=active,future`
);
return (response?.values || []).map((sprint) => ({
id: sprint.id,
name: sprint.name,
state: sprint.state,
startDate: sprint.startDate || null,
endDate: sprint.endDate || null
}));
}

View file

@ -0,0 +1,250 @@
import crypto from 'crypto';
import { kvSessions, kvSessionIndex, withCasRetry } from '../lib/nats.js';
const FIBONACCI = [0, 0.5, 1, 2, 3, 5, 8, 13, 20, 40, 100];
function parseVote(vote) {
if (vote === null || vote === undefined) return { rawValue: '', numericValue: null };
if (typeof vote === 'number') return { rawValue: String(vote), numericValue: vote };
if (typeof vote === 'string') {
const normalized = vote.trim();
const numeric = Number(normalized);
return {
rawValue: normalized,
numericValue: Number.isFinite(numeric) ? numeric : null
};
}
return { rawValue: String(vote), numericValue: null };
}
function nearestFibonacci(value) {
return FIBONACCI.reduce((best, candidate) =>
Math.abs(candidate - value) < Math.abs(best - value) ? candidate : best
);
}
function serializeSession(session) {
return {
...session,
participants: Object.fromEntries(session.participants),
votes: Object.fromEntries(session.votes)
};
}
function deserializeSession(data) {
if (!data) return null;
data.participants = new Map(Object.entries(data.participants));
data.votes = new Map(Object.entries(data.votes));
return data;
}
async function getSession(sessionId) {
const entry = await kvSessions.get(sessionId);
if (!entry) return null;
return deserializeSession(entry.json());
}
async function putSession(session) {
await kvSessions.put(session.id, JSON.stringify(serializeSession(session)));
}
async function withSessionCas(sessionId, transformFn) {
return withCasRetry(kvSessions, sessionId, (raw) => {
const session = deserializeSession(raw);
if (!session) return undefined;
const result = transformFn(session);
if (result === undefined) return undefined;
return serializeSession(result);
});
}
function getSnapshot(session) {
// Accept both Map-based and plain-object sessions
const participantsMap = session.participants instanceof Map
? session.participants
: new Map(Object.entries(session.participants));
const votesMap = session.votes instanceof Map
? session.votes
: new Map(Object.entries(session.votes));
const participants = [...participantsMap.values()];
const votes = Object.fromEntries(votesMap);
const votedUserKeys = [...votesMap.keys()];
return {
session: {
id: session.id,
issueKey: session.issueKey,
issueId: session.issueId,
issueTitle: session.issueTitle,
boardId: session.boardId,
state: session.state,
averageEstimate: session.averageEstimate,
suggestedEstimate: session.suggestedEstimate,
savedEstimate: session.savedEstimate
},
participants,
votesByUser: votes,
voteCount: votesMap.size,
participantCount: participantsMap.size,
votedUserKeys
};
}
function issueIndexKey(cloudId, issueKey) {
return `${cloudId}.${issueKey}`;
}
export async function createScopedSession({ issueKey, issueId, issueTitle, roomId, boardId, tenantCloudId }) {
// Check for existing active session via issue index
const indexEntry = await kvSessionIndex.get(issueIndexKey(tenantCloudId, issueKey));
if (indexEntry) {
const existingId = indexEntry.string();
const existing = await getSession(existingId);
if (existing && existing.tenantCloudId === tenantCloudId) {
if (existing.state === 'VOTING') {
return getSnapshot(existing);
}
// Clean up stale revealed/saved sessions
await kvSessions.delete(existingId);
await kvSessionIndex.delete(issueIndexKey(tenantCloudId, issueKey));
}
}
const id = crypto.randomUUID();
const session = {
id,
issueKey,
issueId,
issueTitle,
roomId,
boardId,
tenantCloudId,
createdAt: Date.now(),
state: 'VOTING',
participants: new Map(),
votes: new Map(),
averageEstimate: null,
suggestedEstimate: null,
savedEstimate: null
};
await putSession(session);
await kvSessionIndex.put(issueIndexKey(tenantCloudId, issueKey), id);
return getSnapshot(session);
}
export async function getSessionSnapshot(sessionId) {
const session = await getSession(sessionId);
if (!session) return null;
return getSnapshot(session);
}
export async function canAccessSession(sessionId, tenantCloudId) {
const session = await getSession(sessionId);
if (!session) return false;
return session.tenantCloudId === tenantCloudId;
}
export async function isSessionParticipant(sessionId, userKey) {
const session = await getSession(sessionId);
if (!session) return false;
return session.participants.has(userKey);
}
export async function joinSession({ sessionId, tenantCloudId, userKey, userName, avatarUrl }) {
const result = await withSessionCas(sessionId, (session) => {
if (session.tenantCloudId !== tenantCloudId) return undefined;
session.participants.set(userKey, { userKey, userName, avatarUrl });
session.votes.delete(userKey);
return session;
});
if (!result) return null;
return getSnapshot(result);
}
export async function leaveSession({ sessionId, tenantCloudId, userKey }) {
const result = await withSessionCas(sessionId, (session) => {
if (session.tenantCloudId !== tenantCloudId) return undefined;
session.participants.delete(userKey);
session.votes.delete(userKey);
return session;
});
if (!result) return null;
return getSnapshot(result);
}
export async function submitVote({ sessionId, tenantCloudId, userKey, vote }) {
const result = await withSessionCas(sessionId, (session) => {
if (session.state === 'REVEALED' || session.state === 'SAVED') return undefined;
if (session.state !== 'VOTING') return undefined;
if (session.tenantCloudId !== tenantCloudId) return undefined;
if (!session.participants.has(userKey)) return undefined;
const parsed = parseVote(vote);
session.votes.set(userKey, parsed.rawValue);
return session;
});
if (!result) {
// Return current snapshot for REVEALED/SAVED states
const current = await getSession(sessionId);
if (current && (current.state === 'REVEALED' || current.state === 'SAVED')) {
return getSnapshot(current);
}
return null;
}
return getSnapshot(result);
}
export async function revealIfComplete(sessionId) {
const result = await withSessionCas(sessionId, (session) => {
const allVoted = session.participants.size > 0 &&
session.votes.size === session.participants.size;
if (!allVoted) return undefined; // no mutation needed
const numericVotes = [...session.votes.values()]
.map(Number)
.filter(Number.isFinite);
const average = numericVotes.length
? numericVotes.reduce((sum, v) => sum + v, 0) / numericVotes.length
: 0;
const suggestedEstimate = nearestFibonacci(average);
session.state = 'REVEALED';
session.averageEstimate = average;
session.suggestedEstimate = suggestedEstimate;
return session;
});
if (!result) {
// Not all voted — return current snapshot
const current = await getSession(sessionId);
if (!current) return null;
return { ...getSnapshot(current), allVoted: false };
}
const snapshot = getSnapshot(result);
return {
...snapshot,
allVoted: true,
average: snapshot.session.averageEstimate,
suggestedEstimate: snapshot.session.suggestedEstimate
};
}
export async function saveScopedEstimate({ sessionId, estimate, tenantCloudId, userKey }) {
const session = await getSession(sessionId);
if (!session) return null;
if (session.tenantCloudId !== tenantCloudId) return null;
if (!session.participants.has(userKey)) return null;
session.savedEstimate = estimate;
session.state = 'SAVED';
const snapshot = getSnapshot(session);
// Clean up — session is done
await kvSessions.delete(sessionId);
await kvSessionIndex.delete(issueIndexKey(tenantCloudId, session.issueKey));
return snapshot;
}

View file

@ -0,0 +1,124 @@
import crypto from 'crypto';
import { kvRooms, kvSprintIndex, kvActiveRooms, withCasRetry } from '../lib/nats.js';
function assertCloudId(cloudId) {
if (!cloudId) {
throw new Error('Missing Jira tenant (cloudId). Please reconnect Jira.');
}
}
function roomKey(cloudId, id) { return `${cloudId}.${id}`; }
function sprintKey(cloudId, sprintId) { return `${cloudId}.${sprintId}`; }
export async function createRoom(payload) {
const {
cloudId,
boardId,
projectKey,
projectName,
sprintId,
sprintName,
createdByAccountId,
createdByName
} = payload;
assertCloudId(cloudId);
// Return existing room for this sprint if one exists
try {
const existingEntry = await kvSprintIndex.get(sprintKey(cloudId, sprintId));
if (existingEntry) {
const existingId = existingEntry.string();
if (existingId) {
const roomEntry = await kvRooms.get(roomKey(cloudId, existingId));
if (roomEntry) return roomEntry.json();
}
}
} catch {
// Stale index data — continue to create a new room
}
const id = crypto.randomUUID();
const room = {
id,
cloudId,
boardId,
projectKey,
projectName,
sprintId,
sprintName,
createdByAccountId,
createdByName
};
await kvRooms.put(roomKey(cloudId, id), JSON.stringify(room));
await kvSprintIndex.put(sprintKey(cloudId, sprintId), id);
// Update active rooms set with CAS retry
await withCasRetry(kvActiveRooms, cloudId, (activeIds) => {
const ids = activeIds || [];
if (ids.includes(id)) return undefined; // already present, no-op
return [...ids, id];
});
return room;
}
export async function getActiveRooms(cloudId) {
assertCloudId(cloudId);
const activeEntry = await kvActiveRooms.get(cloudId);
if (!activeEntry) return [];
const ids = activeEntry.json();
if (!ids.length) return [];
const staleIds = [];
const parsedRooms = [];
for (const id of ids) {
if (!id) { staleIds.push(id); continue; }
try {
const entry = await kvRooms.get(roomKey(cloudId, id));
if (!entry) {
staleIds.push(id);
continue;
}
parsedRooms.push(entry.json());
} catch {
staleIds.push(id);
}
}
if (staleIds.length) {
await withCasRetry(kvActiveRooms, cloudId, (current) => {
const currentIds = current || [];
return currentIds.filter((id) => !staleIds.includes(id));
});
}
return parsedRooms;
}
export async function getRoomById(roomId, cloudId) {
assertCloudId(cloudId);
const entry = await kvRooms.get(roomKey(cloudId, roomId));
return entry ? entry.json() : null;
}
export async function deleteRoom(roomId, cloudId) {
assertCloudId(cloudId);
const entry = await kvRooms.get(roomKey(cloudId, roomId));
if (!entry) return;
const room = entry.json();
await kvRooms.delete(roomKey(cloudId, roomId));
await kvSprintIndex.delete(sprintKey(cloudId, room.sprintId));
// Remove from active rooms set with CAS retry
await withCasRetry(kvActiveRooms, cloudId, (activeIds) => {
if (!activeIds) return undefined;
const filtered = activeIds.filter((id) => id !== roomId);
if (filtered.length === activeIds.length) return undefined; // not found, no-op
return filtered;
});
}