TL;DR
TEEMSYNC is a production-ready Atlassian Forge app that keeps issues synchronized between two independent Jira Cloud sites. Since Forge doesn't provide native communication between separate tenants, we built a secure peer-to-peer synchronization system using Forge web triggers, verified bearer tokens, native Jira remote links, and custom loop prevention. Along the way, we solved challenges like cross-site comments, attachment synchronization, Atlassian Document Format (ADF) conversion, inline media migration, and several Forge platform limitations. This article shares the architecture, the engineering trade-offs, and the lessons learned from building a reliable cross-instance synchronization engine on Forge.
Table of contents
- First, the primitive everything rests on: the web trigger
- What TEEMSYNC does
- A tour of the Forge primitives we actually use
- The cross-instance architecture
- Owning less data: from custom tables to native Jira remote links
- Bidirectional sync without infinite loops
- asApp vs asUser: authorization as a design, not an afterthought
- Moving rich content between two tenants
- When the platform fights back
- Shipping it: CI/CD and the monorepo
- What we would tell another team
If you've spent any time on Forge, you've probably absorbed a familiar set of assumptions: functions are short-lived, there are no long-running processes, egress is locked down to an allowlist, and there is no native way for one app installation to communicate with another across tenants.
All of that is true.
TEEMSYNC is a production Forge app we built at ONETEEM that pairs two independent Jira Cloud sites and keeps selected issues synchronized between them, with per-issue cross-site commenting and attachment transfer. It lives entirely within those constraints.
The point of this post is not to sell TEEMSYNC. It is to show with real code and real scars that a genuinely hard distributed system can run on Forge if you understand the platform inside out.
What you'll take away
- How to build an app-to-app channel across two tenants when the platform gives you no native one, using web triggers and a bearer-token handshake.
- Why we deleted our own database tables and let Jira own the cross-issue relationship.
- How to run a bidirectional sync without an infinite echo loop.
- How to move ADF, inline media, and attachments between two tenants that share no media store.
- The production and CI war stories that taught us where the platform fights back.
First, the primitive everything rests on: the web trigger
Before any of the architecture makes sense, it is worth being precise about the one Forge primitive that carries the whole system: the web trigger. Most Forge code is reachable only from inside Atlassian, called by the product, by the UI bridge, or by a scheduled tick. A web trigger is different. It is a Forge function that Forge exposes at a stable, unguessable HTTPS URL, so something outside the app, including another Jira site, can call it directly. You ask the platform for that URL once with webTrigger.getUrl('wt-sync'), and Forge returns a durable endpoint that routes straight into your function.
That property is the entire reason TEEMSYNC is possible. Forge gives you no native way for one installation to message another across tenants, but it does let each installation publish an inbound door that anyone holding the URL can knock on. We turn that door into a private protocol: the caller must present a bearer token we issued, the request body names one of about 18 actions, and the function fans out to the right handler. Nothing about the transport is exotic. It is an HTTPS POST into a function. The work is in what we wrap around it.
Keep that picture in mind for the rest of the post. Every cross-site arrow you see later, the pairing handshake, field updates, comments, attachment relay, is one site's api.fetch hitting the other site's web-trigger URL. TEEMSYNC has exactly two of these doors: wt-pair for the one-time handshake, and wt-sync for everything else.
What TEEMSYNC does
TEEMSYNC links two separate Jira Cloud sites that do not, and should not, share accounts. The canonical case is cross-organisation support: a customer-care team on site A mirrors a ticket into a partner or development team on site B, and from then on the two issues stay in step. Field and description edits propagate, comments flow both directions, and attachments (including images embedded inside the description) are copied across. Each site runs its own installation of the same Forge app, each with its own database. The two installs know each other only through a pair of URLs and a pair of bearer tokens exchanged once during a handshake.
There is a second operating mode. Internal mode does a same-site "duplicate this issue into another project" flow, reusing the same machinery without the cross-site network hop. A single internal_mode flag on the pairing row switches the behaviour, so the create, link, and content-transfer code paths do not fork.
A tour of the Forge primitives we actually use
We keep the surface area deliberately small. Two Forge UI modules are wired in the manifest: a Custom UI issue panel (jira:issueAction -> teemsync-panel), which is the main user surface showing link status, the mirrored remote issue's fields, cross-site comments, and the duplicate/link wizard; and an admin page (jira:adminPage -> teemsync-admin) for pairing and configuration. There is also a per-project field/status/priority mapping configuration experience built as a third React app in the repo. We use Custom UI rather than UI Kit because the panel does non-trivial rendering (sanitised peer HTML, inline media) that we wanted full control over.
Behind the UI sits a single @forge/resolver with roughly 32 actions: it is the only bridge the frontends use. A product trigger, trig-issue-updated, listens on avi:jira:updated:issue. The two web triggers introduced above do the cross-site work: wt-pair handles the handshake and pairing status, and wt-sync multiplexes roughly 18 named actions covering inbound issue create, field and description updates, comments, attachment upload and download, and peer metadata lookups. A scheduled trigger, sched-migrations, runs the SQL migration runner daily. Persistence is Forge SQL (managed MySQL), the only store we use. Live panel updates go over Forge Realtime (a publish/subscribe channel between backend and frontend). Backend gates two features with Forge Feature Flags, and all outbound calls go through Forge external fetch against a tight egress allowlist.
The cross-instance architecture
Here is the headline constraint: Forge offers no native app-to-app messaging across tenants. Two installations of the same app on two different sites cannot address each other through any platform mechanism. As we saw above, the web trigger is the way out: the one primitive reachable from outside. This section is where we actually wire two of them together into a channel.
Each install exposes stable URLs via webTrigger.getUrl('wt-pair') and webTrigger.getUrl('wt-sync'). The peer install calls those URLs with Forge external fetch. That is the whole transport.
Neither side has an account on the other. Trust is symmetric and token-based. The pairing handshake sets that up once.
Ongoing traffic goes through one helper, postToPeer(path, payload), which resolves the stored peer URL, attaches Authorization: Bearer <peer_token>, sends a JSON body, and enforces a 45-second AbortController timeout. Errors are normalised into a PeerError: a 401 becomes PEER_UNAUTHORIZED, a timeout becomes PEER_TIMEOUT, and so on, so callers never have to interpret raw fetch failures.
// src/lib/peer.js
export async function postToPeer(path, payload) {
const { wt_sync_url, peer_token } = await getActivePeer();
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 45_000); // peer must answer in time
try {
const res = await api.fetch(wt_sync_url + path, {
method: 'POST',
headers: { 'content-type': 'application/json',
authorization: `Bearer ${peer_token}` },
body: JSON.stringify(payload),
signal: ctrl.signal,
});
if (res.status === 401) throw new PeerError('PEER_UNAUTHORIZED');
return await res.json();
} catch (e) {
if (e.name === 'AbortError') throw new PeerError('PEER_TIMEOUT');
throw e;
} finally {
clearTimeout(t);
}
}
The security posture is worth spelling out, because a bearer token over HTTP is only as good as its verification. Inbound bearer checks are constant-time: we compare the SHA-256 digest of the provided token against the digest of the expected token with crypto.timingSafeEqual, never a naive === that leaks length and prefix through timing.
// src/lib/auth.js
import { createHash, timingSafeEqual } from 'crypto';
const sha = (s) => createHash('sha256').update(s, 'utf8').digest();
export function bearerMatches(provided, expected) {
if (!provided || !expected) return false;
// digest first so both inputs are fixed-length before the compare
return timingSafeEqual(sha(provided), sha(expected));
}
Two more guards. Peer URLs are validated against an allowlist of .atlassian.net, .atlassian.com, and .atlassian-dev.net hosts and must be HTTPS, both when an admin saves config and when we trust a URL the peer hands back during the handshake. And the manifest's external.fetch.backend allowlist restricts egress to that same Atlassian domain family plus *.amazonaws.com. Even if an attacker seeded a hostile URL, the app would refuse to fetch it.
Owning less data: from custom tables to native Jira remote links
Our first data model was the obvious one. We stored the cross-site link and per-issue messages in our own Forge SQL tables: links, messages, seen_messages, retry_queue, sync_history. It worked, and it was a liability. The relationship between a local issue and its remote twin lived only in our database, invisible to Jira, and it had to be kept consistent by our code alone.
Migration 006 dropped all of them. The local-to-remote relationship now lives as a native Jira remote issue link (/rest/api/3/issue/{id}/remotelink), scoped by application.name = "TEEMSYNC" and application.type = "com.oneteem.teemsync", with the remote key encoded in the link's globalId as teemsync:<localKey>:<remoteKey>. The relationship now lives in Jira itself, survives independently of our app's DB, and shows up in Jira's native "linked" UI for free. The only custom tables that remain are pair_config (the single pairing/token/URL/internal-mode row) and field_maps (per-project sync configuration held as JSON columns).
The lesson we would repeat: if the platform already models the thing you are modelling, let it own the data. We removed a whole class of consistency bugs by deleting code.
Bidirectional sync without infinite loops
Here is the trap in any two-way sync. An inbound write from the peer lands as a local Jira edit. That edit fires avi:jira:updated:issue. Our trigger sees the event and pushes an update back to the peer. The peer applies it, fires its own event, and pushes back to us. Forever.
We break the loop with two layered mechanisms. First, the product trigger is declared with filter: { ignoreSelf: true }, so the app's own API writes do not fire it at all. That handles the common case cleanly. Second, for the writes that still slip through (a Jira automation reacting to our write, for instance), we stamp a Jira issue property marker at teemsync.sync via /rest/api/3/issue/{id}/properties/{key}. After an app-driven write we set { linked: true, at: Date.now() }. The trigger reads that marker and, if the timestamp is within a 30-second echo window, treats the event as a self-inflicted echo and suppresses the outbound notification. A variant marker with at: null records that an issue is linked without arming the echo timer, which we use when linking an existing issue that should not immediately re-sync.
There is no seen_messages dedupe table any more (it went with the dropped tables). The echo story today is exactly three things: ignoreSelf, the issue-property marker, and the time window.
asApp vs asUser: authorization as a design, not an afterthought
Forge lets you call Jira either as the app (api.asApp()) or on behalf of the human who triggered the action (api.asUser()). We treat the choice between them as a design decision made per call site, not a default.
We use asUser wherever the caller's real identity and permissions must apply: permission gating (mypermissions, checking EDIT_ISSUES), same-site field reads scoped to what that user is actually allowed to see, and posting same-site comments so Jira attributes them to the human. We use asApp for everything that must succeed regardless of who clicked: creating the mirror issue, writing remote links, uploading attachments, and cross-project reads.
The interesting case is author attribution across the hop. When the app creates a mirrored or cloned issue, it sets the reporter to the triggering user's accountId, taken only from the server-side authenticated context.accountId in the resolver, never from a client-supplied payload. If we do not have "Modify Reporter" on the target project, we retry the create once with the reporter stripped rather than failing the whole clone.
// src/lib/clone.js
async function createMirror(fields, reporterAccountId) {
const body = { fields: { ...fields, reporter: { id: reporterAccountId } } };
let res = await api.asApp().requestJira(route`/rest/api/3/issue`, {
method: 'POST', body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
});
if (res.status === 400 && await mentionsReporter(res)) {
// no "Modify Reporter" on target: keep the clone, drop attribution
delete body.fields.reporter;
res = await api.asApp().requestJira(route`/rest/api/3/issue`, {
method: 'POST', body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
});
}
return res.json();
}
Cross-site comments cannot be posted as the user, because the user has no account on the peer site. So we post them as the app and preserve attribution by prefixing the human's display name into the body (<Name> wrote via Linking App:).
Moving rich content between two tenants
This is the deepest part of the system, because the two tenants share no media store. A media ID that resolves to an image on site A is meaningless on site B. Copying "the description" is not a string copy, it is a re-homing problem.
ADF walking. First we walk the Atlassian Document Format tree (paragraph, heading, blockquote, codeBlock, listItem, taskItem, mediaSingle, rule, mention, hardBreak) to build readable plain text for summaries and comment bodies. Comment bodies are capped at 5000 characters.
Inline media re-homing. A description can embed images as ADF media nodes pointing at source-local media IDs. To move them we (1) extract every media node ID from the source ADF, (2) re-upload the corresponding attachments to the target issue, locally or by relaying to the peer's wt-sync, (3) resolve the new mediaApiFileId for each uploaded file, with a filename-based fallback that re-lists the target's attachments to map them when the direct lookup is ambiguous, and (4) rebuild a fresh ADF document with the media IDs remapped, dropping any media node whose source has no mapping. The rebuild never mutates the input document; it returns a new one.
The FRGE-635 attachment workaround. Uploading a binary to Jira from Forge hits a platform bug we track as FRGE-635: requestJira does not reliably stream a form-data object as the request body. The multipart gets truncated, or the Content-Type boundary is stripped, and Jira quietly stores a zero-byte attachment. The fix is to stop streaming and fully serialise the form into a single Buffer first, set Content-Length explicitly, and send X-Atlassian-Token: no-check.
// src/lib/attachments.js
async function uploadBuffered(issueId, form) {
// FRGE-635: requestJira won't reliably stream form-data; buffer it fully.
const chunks = [];
for await (const c of form) chunks.push(c);
const body = Buffer.concat(chunks);
const res = await api.asApp().requestJira(
route`/rest/api/3/issue/${issueId}/attachments`, {
method: 'POST',
headers: { ...form.getHeaders(),
'content-length': String(body.length),
'x-atlassian-token': 'no-check' },
body,
});
const [saved] = await res.json();
if (!saved || saved.size === 0) // size 0 means the multipart wasn't parsed
throw new Error('attachment upload produced 0 bytes');
return saved;
}
We assert that Jira's returned attachment size is non-zero, because size 0 is the signature that the multipart was not parsed. Failing loudly there beats discovering missing images days later.
Budgeting. Forge functions are tight on time and body size, so we budget aggressively. Inbound attachment payloads are capped at 750 KB per file (base64 inflation is roughly 33 percent, and web-trigger bodies top out around 1 to 2 MB), up to 20 files per transfer. The create path caps base64 attachment content at about 7 MB, and attachment download at 20 MB (over the limit we return HTTP 413). The attachment-copy loop enforces an 18-second soft budget inside a single invocation and reports skipped items rather than blowing the function timeout. Inline images embedded into rendered HTML are capped at 400 KB each.
Rendering peer content for a reader with no peer account. The person reading the panel usually has no account on the peer Jira, so we render the peer issue as server-produced HTML on the peer side, then rewrite it for a credential-less browser. Images under the cap are inlined as base64 data: URLs. Non-image <img> thumbnails are stripped, because Jira already renders a separate link for them. Attachment <a> links are rewritten with a marker class and a data-attachment-id, so a click is intercepted and the file is fetched through our backend (as the app) and downloaded as a base64 blob. Every other cross-tenant link, dead for this reader, is flattened to plain text. On the frontend the HTML is sanitised with DOMPurify using an explicit tag and attribute allowlist that keeps ADF-derived data-* attributes and forbids scripts, iframes, inline styles, and event handlers.
We also stay JSM-aware throughout. The app detects service-desk projects (projectTypeKey === 'service_desk'), reads request types, and marks cross-synced comments internal via the sd.public.comment property ({ internal: true }) so mirrored comments never leak to the customer portal. The panel badges each comment's visibility, and we resolve JSM portal URLs after create.
When the platform fights back
ITSM-29: a cosmetic signal on a critical path. The issue panel subscribes to a per-issue Forge Realtime channel (sync:${issueId}) via @forge/bridge realtime.subscribeGlobal, and the backend publishes to it with @forge/realtime publishGlobal on link, unlink, and sync events. It is purely a UX nicety. It turned out that Atlassian's realtime backend occasionally returns a non-JSON error body: an envoy proxy "no healthy upstream" message. The @forge/realtime client tried to JSON.parse that and threw a SyntaxError. The throw landed in the middle of the clone path and aborted the attachment-copying step, so the issue was created but its attachments were missing, and the user saw a misleading "Clone failed". They retried, and we got duplicate tickets. Root cause: a cosmetic publish sitting on a write path, and a platform call we had assumed could not throw. The fix was to wrap the publish so it can never throw.
// src/lib/realtime.js
export async function safePublish(channel, payload) {
try {
await publishGlobal(channel, payload);
return { published: true };
} catch (e) {
// ITSM-29: realtime backend sometimes returns non-JSON ("no healthy
// upstream"), so the client throws SyntaxError. Never fail a write for it.
console.warn('realtime publish failed, ignoring', e?.name);
return { published: false };
}
}
The lesson: on Forge, treat platform calls that look infallible as fallible, and never let a cosmetic signal sit on a critical write path.
FRGE-635, again. Covered above. The reason it appears twice in our story is that it cost us a debugging session before we understood that the platform, not our code, was dropping the multipart. Asserting on the returned size turned a silent data-loss bug into an immediate, obvious failure.
The CI trio. Putting Forge in CI produced three distinct flaky-build sources, all real. First, the Forge CLI prompts for analytics consent on first run, and in a non-TTY runner that prompt aborts the whole command with "Prompts can not be meaningfully rendered in non-TTY environments". We export FORGE_DISABLE_ANALYTICS=true so getAnalyticsPreferences() returns false and the prompt path is never entered. Second, forge lint fetches the Jira and JSM API schemas from developer.atlassian.com to validate the manifest, and that endpoint intermittently drops the connection ("Premature close"), failing otherwise-green builds; we retry forge lint up to three times with a backoff. Third, deploys pin the CLI (npx -y @forge/cli@^12) and run --non-interactive --verbose, because an unpinned or interactive CLI is a flaky-build generator.
Shipping it: CI/CD and the monorepo
The build runs on Bitbucket Pipelines with the node:22 image. The build-and-verify step is npm ci, then npm run build:panels (which builds all three UIs through npm workspaces), then a syntax gate (find src -name '*.js' -exec node --check {} +), then forge lint with the retry described above.
Every deploy is a manual trigger, and Bitbucket deployment tiers (test, staging, production) gate the sensitive ones. Each engineer also has an isolated per-developer Forge environment, so nobody steps on a shared dev install.
Two more choices are worth naming. Migrations are code, not a schema dump: @forge/sql's migrationRunner.enqueue(name, sql).run(), one enqueue per idempotent DDL step, run both by the daily sched-migrations trigger and on demand from an admin resolver. On first run a query throws ER_NO_SUCH_TABLE; we catch that specific code and return a needs_init state so the UI can offer an "initialise database" button instead of crashing.
// src/db/migrate.js
export async function runMigrations() {
await migrationRunner
.enqueue('001_pair_config', `CREATE TABLE IF NOT EXISTS pair_config (...)`)
.enqueue('002_field_maps', `CREATE TABLE IF NOT EXISTS field_maps (...)`)
.enqueue('006_drop_link_tables',
`DROP TABLE IF EXISTS links, messages, seen_messages,
retry_queue, sync_history`)
.run();
}
export async function readConfig() {
try { return await queryOne(`SELECT * FROM pair_config LIMIT 1`); }
catch (e) {
if (e.code === 'ER_NO_SUCH_TABLE') return { state: 'needs_init' };
throw e;
}
}
Finally, the frontend. Three Vite plus React 18 plus TypeScript apps under static/ share one @teemsync/shared package, consumed as source through a Vite path alias rather than pre-built. We deliberately do not depend on @atlaskit. We reproduce the ADS look by hand from a tokens.ts module that maps to var(--ds-*) CSS variables with hex fallbacks, plus hand-rolled inline SVG icons and teemsync-* classes. The honest trade-off: smaller bundles and total control, paid for by re-implementing components ourselves. All backend calls go through @forge/bridge invoke against the single resolver, wrapped in a typed envelope ({ success, data, error, code, status }) that throws a typed BridgeError. No requestJira runs in the browser; every Jira REST call is delegated to the backend.
What we would tell another team
If you are starting something ambitious on Forge, a few things we would repeat:
Build your transport out of web triggers early and treat it like a real protocol. The handshake, the bearer verification, the URL allowlist, and the normalised PeerError were the load-bearing parts. Getting them right first made everything above them boring, which is what you want.
Let the platform own data it already models. Deleting links, messages, and the rest in migration 006 removed a whole category of consistency bugs. The native remote link was strictly better than our table.
Assume platform calls can throw, especially the cosmetic ones. ITSM-29 cost us duplicate tickets because a UX signal sat on a write path. Wrap best-effort calls so they cannot take down real work.
Budget for the platform limits up front. The 750 KB, 7 MB, 20 MB, and 18-second numbers are not decorations; they are what keeps a function from timing out mid-transfer. Design the skip-and-report behaviour before you hit the limit in production.
Make first-run and failure states first-class. A caught ER_NO_SUCH_TABLE turning into a needs_init button is a small thing that saved every fresh install from looking broken.
What we would do differently: we would have reached for the native remote link on day one instead of building link tables we later had to migrate away from. If we were starting today, we would model as little of our own state as the platform lets us get away with.
We build this kind of thing at ONETEEM. If you are pushing Forge past where it is comfortable, we are happy to compare notes.
Written by a senior Forge engineer at ONETEEM, who spends most days somewhere between an ADF tree and a Bitbucket pipeline. We build production Atlassian apps on Forge and occasionally write up the parts that fought back.