DEV Community

Teamvane
Teamvane

Posted on

Building real-time planning poker on Atlassian Forge, without leaking a single vote

I'm a software engineer and Scrum Master, and I built Teamvane Planning Poker for Jira: the team estimates on the Jira issue itself, cards stay hidden until the reveal, and the agreed estimate saves to story points in one click. It runs entirely on Atlassian Forge, so there's no server of my own anywhere.

Planning poker has one rule that matters: nobody sees anyone's card before the reveal. If a number leaks early, the team anchors on it and the estimate stops meaning anything. Here's how the app makes sure a vote can't leak, not even to someone opening the browser's developer tools.

The trap: hiding votes in the UI

The easy version of a planning poker app sends every vote to every browser and hides the cards with CSS until someone clicks Reveal. It looks right, and anyone can read the votes in the Network tab.

So the rule in this app is: while a round is open, a card never leaves the server. Browsers only learn that someone voted.

The architecture

Forge gives you three pieces that are enough for a live multiplayer app:

  • Resolvers: backend functions the UI calls with invoke(). Every read and write goes through them.
  • Key-Value Storage (KVS): where sessions and votes live.
  • Forge Realtime: publish/subscribe channels that push events to everyone looking at the same issue.

1. One key per vote

KVS has no conditional writes, so storing all votes inside one session object would be a read-modify-write race. In planning poker everyone votes at the same moment, so that race is the normal case, and votes would silently disappear.

Each vote gets its own key instead:

const sessionKey = (issueId) => `pp:s:${issueId}`;
const voteKey = (issueId, accountId) => `pp:v:${issueId}:${accountId}`;
Enter fullscreen mode Exit fullscreen mode

Two people voting at once write two different keys, so nothing can be lost. Keys use the issue id, not its key, because an issue key changes when the issue moves to another project.

2. Mask votes on the server

Every response that includes votes goes through one function, so there is exactly one place where the privacy rule lives:

// While a round is open the server knows every card but must never let one
// leave: clients learn only *that* somebody voted. Doing this here rather than
// in the UI means a crafted invoke() cannot read early votes.
function presentVotes(votes, revealed) {
  return votes.map(({ accountId, name, avatar, card, votedAt }) =>
    revealed
      ? { accountId, name, avatar, card, votedAt }
      : { accountId, name, avatar, votedAt }
  );
}
Enter fullscreen mode Exit fullscreen mode

Calling the resolver directly doesn't help either: it's the server deciding what to send, not the UI.

3. Events that leave the card out

When someone votes, everyone else needs to see it happen straight away. The voted event carries the voter but deliberately not the card:

await writeVote(issueId, accountId, { card, name, avatar, votedAt: Date.now() });

// Deliberately omits the card.
await broadcastToIssue(issueId, 'voted', {
  round: session.round,
  voter: { accountId, name, avatar },
});
Enter fullscreen mode Exit fullscreen mode

The reveal is the one moment cards become public, so the revealed event carries them all at once, and every browser flips the cards together.

4. Signed, listen-only channels

One more hole: who can listen to the channel? If anyone could subscribe to any issue's events, they could hear reveals for issues they can't open.

Forge Realtime supports signed tokens with claims. The server signs a token for one issue, and only after the user has passed the normal Jira access check for that issue. The token only allows subscribe:

import { signRealtimeToken } from '@forge/realtime';

const { token, expiresAt } = await signRealtimeToken(
  'planning-poker-session',
  { issueId: String(issueId) },  // claims: this token hears one issue only
  ['subscribe']                  // listen-only
);
Enter fullscreen mode Exit fullscreen mode

The server publishes with its own publish token for the same claims. So nobody without access to the issue hears its events, and no browser can fake a vote or a reveal.

A Forge detail worth knowing: by default a channel is scoped to the module (the kind of view) it's used from. The issue panel and the project's refinement page are different modules, so they'd never hear each other. Scoping the channel to the Jira project instead (contextOverrides: [Jira.Project]) fixes that, and the token's claims narrow it back down to one issue.

5. A failed broadcast never fails the vote

By the time the app publishes an event, the vote is already in storage. So publishing is wrapped to log and carry on:

async function publishSafely(channel, type, payload, options) {
  try {
    await publish(channel, { type, ...payload }, options);
  } catch (error) {
    console.error(`realtime publish failed for "${type}"`, error);
  }
}
Enter fullscreen mode Exit fullscreen mode

A dropped event only means someone's view is stale until their next refresh. Nobody loses their vote.

6. Personal data that expires

Votes include names and avatars, which are personal data. Every session and vote is written with a 72-hour TTL, so Forge deletes them on its own, and a daily scheduled trigger reports the stored accounts to Atlassian's personal data API and erases closed ones.

Try it

If your team estimates in Jira, Teamvane Planning Poker for Jira is free for teams up to 10 users. Here's the 60-second demo.

I also built Teamvane Retrospectives for Jira the same way: anonymous cards whose authors are never stored, hidden votes, and actions that become Jira work items.

Questions about Forge, Realtime or the Atlassian Marketplace? Ask in the comments.

Top comments (0)