<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Teamvane</title>
    <description>The latest articles on DEV Community by Teamvane (@teamvane).</description>
    <link>https://dev.to/teamvane</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4237115%2Fb9bddf36-49e0-44e7-8177-b63144097789.png</url>
      <title>DEV Community: Teamvane</title>
      <link>https://dev.to/teamvane</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/teamvane"/>
    <language>en</language>
    <item>
      <title>Building real-time planning poker on Atlassian Forge, without leaking a single vote</title>
      <dc:creator>Teamvane</dc:creator>
      <pubDate>Tue, 10 Nov 2026 12:53:47 +0000</pubDate>
      <link>https://dev.to/teamvane/building-real-time-planning-poker-on-atlassian-forge-without-leaking-a-single-vote-1ii8</link>
      <guid>https://dev.to/teamvane/building-real-time-planning-poker-on-atlassian-forge-without-leaking-a-single-vote-1ii8</guid>
      <description>&lt;p&gt;I'm a software engineer and Scrum Master, and I built &lt;a href="https://marketplace.atlassian.com/apps/2939883696/teamvane-planning-poker-for-jira" rel="noopener noreferrer"&gt;Teamvane Planning Poker for Jira&lt;/a&gt;: 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 &lt;a href="https://developer.atlassian.com/platform/forge/" rel="noopener noreferrer"&gt;Atlassian Forge&lt;/a&gt;, so there's no server of my own anywhere.&lt;/p&gt;

&lt;p&gt;Planning poker has one rule that matters: &lt;strong&gt;nobody sees anyone's card before the reveal&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: hiding votes in the UI
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the rule in this app is: &lt;strong&gt;while a round is open, a card never leaves the server&lt;/strong&gt;. Browsers only learn &lt;em&gt;that&lt;/em&gt; someone voted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;Forge gives you three pieces that are enough for a live multiplayer app:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  1. One key per vote
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Each vote gets its own key instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;sessionKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;issueId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sb"&gt;`pp:s:${issueId}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;voteKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;issueId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sb"&gt;`pp:v:${issueId}:${accountId}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  2. Mask votes on the server
&lt;/h2&gt;

&lt;p&gt;Every response that includes votes goes through one function, so there is exactly one place where the privacy rule lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// While a round is open the server knows every card but must never let one&lt;/span&gt;
&lt;span class="c1"&gt;// leave: clients learn only *that* somebody voted. Doing this here rather than&lt;/span&gt;
&lt;span class="c1"&gt;// in the UI means a crafted invoke() cannot read early votes.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;presentVotes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;votes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revealed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;votes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avatar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;votedAt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="n"&gt;revealed&lt;/span&gt;
      &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avatar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;votedAt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avatar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;votedAt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling the resolver directly doesn't help either: it's the server deciding what to send, not the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Events that leave the card out
&lt;/h2&gt;

&lt;p&gt;When someone votes, everyone else needs to see it happen straight away. The &lt;code&gt;voted&lt;/code&gt; event carries the voter but deliberately not the card:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;writeVote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;issueId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avatar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;votedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Deliberately omits the card.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;broadcastToIssue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;issueId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'voted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;voter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avatar&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reveal is the one moment cards become public, so the &lt;code&gt;revealed&lt;/code&gt; event carries them all at once, and every browser flips the cards together.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Signed, listen-only channels
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Forge Realtime supports &lt;strong&gt;signed tokens with claims&lt;/strong&gt;. 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 &lt;code&gt;subscribe&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;signRealtimeToken&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'@forge/realtime'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expiresAt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;signRealtimeToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'planning-poker-session'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;issueId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;issueId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// claims: this token hears one issue only&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'subscribe'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                  &lt;span class="c1"&gt;// listen-only&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server publishes with its own &lt;code&gt;publish&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;A Forge detail worth knowing: by default a channel is scoped to the &lt;em&gt;module&lt;/em&gt; (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 (&lt;code&gt;contextOverrides: [Jira.Project]&lt;/code&gt;) fixes that, and the token's claims narrow it back down to one issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A failed broadcast never fails the vote
&lt;/h2&gt;

&lt;p&gt;By the time the app publishes an event, the vote is already in storage. So publishing is wrapped to log and carry on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;publishSafely&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`realtime publish failed for "${type}"`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dropped event only means someone's view is stale until their next refresh. Nobody loses their vote.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Personal data that expires
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If your team estimates in Jira, &lt;a href="https://marketplace.atlassian.com/apps/2939883696/teamvane-planning-poker-for-jira" rel="noopener noreferrer"&gt;Teamvane Planning Poker for Jira&lt;/a&gt; is free for teams up to 10 users. Here's the &lt;a href="https://youtu.be/PU2YWiz7tjQ" rel="noopener noreferrer"&gt;60-second demo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I also built &lt;a href="https://marketplace.atlassian.com/apps/2701773542/teamvane-retrospectives-for-jira" rel="noopener noreferrer"&gt;Teamvane Retrospectives for Jira&lt;/a&gt; the same way: anonymous cards whose authors are never stored, hidden votes, and actions that become Jira work items.&lt;/p&gt;

&lt;p&gt;Questions about Forge, Realtime or the Atlassian Marketplace? Ask in the comments.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>javascript</category>
      <category>atlassian</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
