Latchpoint
Security

Verify this yourself

Latchpoint runs entirely inside Atlassian. We operate no servers, hold no credentials to your depot, and store no personal data.

Every claim below can be checked without taking our word for it — two of them from inside your own Jira, before you install anything. That is the point of this page. You are being asked to let an unknown vendor's script run on the server holding your company's entire work product, and the correct default answer to that is no.

What we store

Two items, both in Atlassian's Forge storage, scoped to your installation.

ItemWhat it isPersonal data
Shared secret A random value your Perforce server uses to authenticate to us. You can rotate it at any time from the admin page, with immediate effect. No
Last-sync record When the app last received a changelist, how many were published, the depot label and the last changelist number. No

Changelist information passes through the app on its way to your Jira site. It is held in memory for the length of one request and then discarded. Your Jira site keeps it; we do not. There is no database of your changelists anywhere in our control, because there is no database.

What we cannot do

Most vendors promise not to send your data anywhere. We are not able to.

Forge blocks any outbound network call to a domain that is not declared in the app manifest's egress permissions. Ours declares none — not a restricted list, none at all. The app can reach Atlassian's own APIs and nothing else.

This is enforced by Atlassian's platform, not by our good intentions. It means the app cannot call an analytics service, an error-logging service, a third-party API, or a server of ours — because we have no server for it to call.

Here is the entire permissions block of our manifest, verbatim:

latchpoint-perforce/manifest.yml 2 scopes
permissions: scopes: - write:dev-info:jira - storage:app

What matters most here is what is absent: there is no external: section and no fetch: block. Those are the only ways a Forge app can be granted permission to make an outbound request. Without them, the platform refuses the call.

The permissions Jira will show you

The strongest proof on this page is not on this page. It is in your own install flow: Jira displays the exact scopes an app requests before you approve it. Go and look. If we were lying here, your own Jira would contradict us at install time.

You will see two:

ScopeWhat it permits
write:dev-info:jira Attach changelists to your issues. This is the entire product.
storage:app Store the two items listed above.

There is no read:jira-work, no read:jira-user, and no scope granting access to your issues' content, your projects, your users or your attachments. There is no read scope at all. The app cannot read the issue it is writing to, and it cannot read the development information it has already written.

It previously requested read:dev-info:jira. We checked whether anything in the code used it, found nothing did, removed it and confirmed the app still works. A permission that is merely unused is still a permission you granted.

The code that receives your data

Below is the complete source of the function that receives every changelist, decides what to do with it, and forwards it to Jira. Not an excerpt, and not a description of it — the file itself. A technical reader gets through it in about two minutes and then knows exactly what happens to their data.

The trigger script that runs on your own server is already published on the setup page, because you install it yourself and can read it before you do. This is the same principle applied to our side of the wire.

latchpoint-perforce/src/index.js 314 lines generated from source at build time
import api, { route } from '@forge/api'; import { kvs } from '@forge/kvs'; // Latchpoint for Perforce — Helix Core changelists → Jira development panel. // // The customer's Perforce server runs a change-commit trigger that POSTs each // submitted changelist here. We extract Jira issue keys from the description and // publish the changelist as development information on the matching issues. // // Inverted architecture on purpose: we never hold credentials to their depot. // They authenticate to us with a shared secret issued on the admin page. const ISSUE_KEY = /\b([A-Z][A-Z0-9]+-\d+)\b/g; const SECRET_KEY = 'ingest-secret'; const MAX_CHANGELISTS = 200; // The webtrigger URL is public. It is unguessable and secret-authenticated, but // "unguessable" is not a control - anything that reaches it costs an invocation // before we have decided whether to trust it. These are the limits that apply // before any work happens. const MAX_BODY_BYTES = 1_048_576; // 1 MiB. 200 changelists of real descriptions is ~60 KiB. const MAX_DESCRIPTION = 1000; const MAX_DEPOT_LEN = 100; const MAX_CHANGE_LEN = 40; const RATE_LIMIT_PER_MIN = 60; // backfill sends one request per 10s; a trigger, one per submit // Only these schemes may end up in a link we hand to Jira. A changelist URL is // rendered as a clickable link inside the customer's issue view, so an attacker // who could set it arbitrarily would be phishing from inside Jira's own UI. const SAFE_URL = /^https?:\/\/[^\s<>"']+$/i; /** * Returns the URL only if it is a plain http(s) URL, otherwise null. * Rejects javascript:, data:, vbscript:, protocol-relative and anything with * whitespace or quoting that could break out of an attribute downstream. */ function safeUrl(value) { if (typeof value !== 'string') return null; const v = value.trim(); if (!v || v.length > 2000) return null; if (!SAFE_URL.test(v)) return null; try { const u = new URL(v); if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; return u.toString(); } catch { return null; } } /** Changelist numbers reach Jira inside an entity id. Keep them boring. */ function safeChange(value) { const s = String(value ?? '').trim(); if (!s || s.length > MAX_CHANGE_LEN) return null; return /^[A-Za-z0-9._-]+$/.test(s) ? s : null; } function extractIssueKeys(text) { const found = new Set(); if (text) for (const m of String(text).matchAll(ISSUE_KEY)) found.add(m[1]); return [...found]; } // Constant-time comparison so we don't leak the secret via response timing. function safeEqual(a, b) { if (typeof a !== 'string' || typeof b !== 'string') return false; if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } function headerValue(headers, name) { if (!headers) return undefined; const v = headers[name] ?? headers[name.toLowerCase()]; return Array.isArray(v) ? v[0] : v; } // Jira requires a url on both the repository and every commit. Customers running // Swarm can pass their Swarm base URL so links resolve to the real changelist page; // otherwise we fall back to our docs so the payload stays valid. const FALLBACK_URL = 'https://latchpoint.app/perforce'; function commitUrl(cl, serverUrl, change) { // Every branch is validated. A URL that arrives here becomes a clickable link // in the customer's Jira issue, so an unchecked value would let whoever holds // the secret point their colleagues anywhere. const direct = safeUrl(cl.url); if (direct) return direct; if (serverUrl) { const base = safeUrl(serverUrl); if (base) return `${base.replace(/\/$/, '')}/changes/${encodeURIComponent(change)}`; } return FALLBACK_URL; } // Jira leaves the author column blank in the development dialog when only `name` // is supplied. The devinfo author object accepts name, username, email, url and // avatar, all documented as optional, and Atlassian does not document which of // them the panel actually renders or how it resolves an author to a Jira user. // // So this populates everything we can source for free. `username` costs nothing — // we already hold the Perforce user and were simply not sending it in that field. // `email` is forwarded only when the trigger supplies one; no trigger does yet, // because putting a `p4 user -o` lookup on a customer's server to test a guess is // the wrong order of operations. If the test below shows email is what the panel // needs, that lookup becomes worth adding. // // Why the Author column in Jira's development dialog shows an avatar with no name. // // We tested this properly on 31 Jul 2026 rather than guessing. Two changelists, // payloads identical but for a single field, read back after a full propagation // wait: // name + username -> Author column blank // name + username + email -> Author column shows the matched Jira user // // So Jira resolves a commit author by matching an email address to a Jira account. // There is no other mechanism: `username` has no effect on the panel. // // We could populate it. The trigger would run `p4 user -o` on the customer's server // and send us each developer's email address. We have decided not to, and we are not // going to offer it as an option either. // // Latchpoint does not handle developer email addresses. Not "does not store" — // does not receive. That commitment is worth more to the people who have to approve // this app than a populated column is to the people reading it, and an option to // switch it on would make the commitment conditional, which is the same as not // having one. A blank column is the visible cost of a promise we actually keep. // // Keep in mind when reading anything back from Jira: /rest/devinfo/0.10/bulk returns // 202 Accepted immediately, but the data can take MINUTES to appear. A run in this // project once looked conclusive at six seconds and was simply wrong. 202 is not // proof of storage. function toAuthor(cl) { const user = String(cl.user || 'unknown'); return { name: user, username: user }; } /** ISO-8601 or nothing. A malformed timestamp otherwise reaches Jira verbatim. */ function safeTimestamp(value) { if (typeof value !== 'string' || value.length > 40) return new Date().toISOString(); const t = Date.parse(value); return Number.isNaN(t) ? new Date().toISOString() : new Date(t).toISOString(); } function toCommit(cl, issueKeys, seq, serverUrl, change) { return { id: `cl-${change}`, displayId: `@${change}`, message: String(cl.description || `Changelist ${change}`).slice(0, MAX_DESCRIPTION), author: toAuthor(cl), authorTimestamp: safeTimestamp(cl.time), url: commitUrl(cl, serverUrl, change), // Clamp: a hostile or buggy client should not be able to claim 10^9 files. fileCount: Math.max(0, Math.min(1_000_000, Array.isArray(cl.files) ? cl.files.length : Number(cl.fileCount) || 0)), issueKeys, updateSequenceId: seq }; } /** * Fixed-window rate limit, one counter per minute, kept in app storage. * * WHAT THIS ACTUALLY STOPS, measured against the deployed endpoint rather than * assumed: * * sequential requests - 75 in a row returned 429 from the 52nd onward. A * misconfigured trigger firing in a submit loop, or a * script hammering the URL serially, is caught. * * parallel requests - 70 fired concurrently ALL returned 200. Forge KVS has * no atomic increment, so concurrent handlers all read * the same value before any of them writes. A genuine * flood is NOT caught here. * * So this is a runaway-loop guard, not a flood control, and it should not be * described as one. Protection against an actual flood comes from Atlassian's * platform limits in front of the webtrigger, and from the fact that the URL is * unguessable and every request still has to present the secret. * * Fails OPEN: if storage is unavailable we allow the request rather than drop a * customer's changelist. Losing data is worse than serving one over the limit. */ async function rateLimited() { const bucket = `rl-${Math.floor(Date.now() / 60000)}`; try { const n = (await kvs.get(bucket)) || 0; if (n >= RATE_LIMIT_PER_MIN) return true; await kvs.set(bucket, n + 1); return false; } catch { return false; } } export async function ingest(event) { const seq = Date.now(); // Size check first: refuse before parsing, so a large body never becomes a // large object. JSON.parse on an unbounded string is the cheapest way to hurt // a function that has a memory limit. const raw = event?.body ?? ''; const size = typeof raw === 'string' ? raw.length : 0; if (size > MAX_BODY_BYTES) { return json(413, { error: `Request body too large (max ${MAX_BODY_BYTES} bytes)` }); } // Authenticate BEFORE spending the rate-limit budget. The other order looks // equivalent - both are one storage read - but it is not: it lets anyone who // knows the URL and not the secret exhaust the minute's allowance and have a // customer's real changelist rejected with a 429. Cheapness is not the ordering // rule here; who is allowed to consume the budget is. const expected = await kvs.getSecret(SECRET_KEY); if (!expected) { return json(503, { error: 'Not configured. Open the Latchpoint admin page in Jira to generate a secret.' }); } if (!safeEqual(headerValue(event?.headers, 'x-latchpoint-secret') || '', expected)) { return json(401, { error: 'Invalid or missing X-Latchpoint-Secret' }); } if (await rateLimited()) { return json(429, { error: 'Rate limit exceeded. Slow down and retry; nothing has been lost.' }); } let body; try { body = raw ? JSON.parse(raw) : {}; } catch { return json(400, { error: 'Invalid JSON body' }); } if (!body || typeof body !== 'object' || Array.isArray(body)) { return json(400, { error: 'Body must be a JSON object' }); } const changelists = Array.isArray(body.changelists) ? body.changelists : (body.change ? [body] : []); if (!changelists.length) return json(400, { error: 'No changelists supplied' }); if (changelists.length > MAX_CHANGELISTS) { return json(413, { error: `Too many changelists in one request (max ${MAX_CHANGELISTS})` }); } const depot = String(body.depot || 'perforce').slice(0, MAX_DEPOT_LEN); const serverUrl = safeUrl(body.serverUrl); const commits = []; const skipped = []; const rejected = []; for (const cl of changelists) { if (!cl || typeof cl !== 'object') continue; const change = safeChange(cl.change); if (!change) { rejected.push(String(cl.change ?? '').slice(0, 40)); continue; } const keys = extractIssueKeys(cl.description); if (!keys.length) { skipped.push(change); continue; } commits.push(toCommit(cl, keys, seq, serverUrl, change)); } if (!commits.length) { return json(200, { ok: true, published: 0, skipped, rejected, note: 'No Jira issue keys found in changelist descriptions' }); } const payload = { repositories: [{ id: `p4-${depot}`.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 255), name: depot, description: 'Perforce Helix Core', url: serverUrl || FALLBACK_URL, commits, branches: [], updateSequenceId: seq }], preventTransitions: false, properties: { source: 'latchpoint-perforce' } }; const res = await api.asApp().requestJira(route`/rest/devinfo/0.10/bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const detail = await res.text(); if (res.status !== 202) { console.error('devinfo rejected:', res.status, detail); return json(502, { ok: false, error: 'Jira rejected the update', status: res.status }); } let unknownIssueKeys = []; try { unknownIssueKeys = JSON.parse(detail).unknownIssueKeys || []; } catch { /* non-fatal */ } await kvs.set('last-sync', { at: new Date().toISOString(), published: commits.length, depot, lastChange: commits[commits.length - 1].displayId }); return json(200, { ok: true, published: commits.length, issueKeys: [...new Set(commits.flatMap(c => c.issueKeys))], unknownIssueKeys, skipped, rejected }); } function json(statusCode, obj) { return { statusCode, headers: { 'Content-Type': ['application/json'] }, body: JSON.stringify(obj) }; }

This page is generated directly from that file, so it cannot drift out of date without the build failing. Publishing it costs us nothing worth keeping: our defensibility is distribution, trust and the integration surface, not 314 lines that a competent engineer could rewrite in an afternoon anyway.

Check it yourself

Four verifications, none of which require you to believe anything we have written above.

  1. Read the scopes in your own install dialog. Jira shows them before you approve. Compare against the table above.
  2. Read the trigger script before you run it. It is on the setup page. Modify it if you like — that is expressly permitted, and it will not void your licence or your support.
  3. Watch your own egress. The only outbound host your Perforce server contacts is *.atlassian.app on port 443. No inbound rule, no open port, no VPN. Your firewall logs will confirm it.
  4. Rotate the secret and confirm the old one stops working. Rotation takes effect immediately. Post a changelist with the old secret and you will get a 401.

What we do not claim

A page with no downside on it is marketing. These are the things that cut against us.

We are not eligible for "Runs on Atlassian"

That badge requires an app to have no egress path at all. Ours receives data through a Forge webtrigger, which is the only inbound entry point Forge offers — and because a webtrigger can in principle return data to its caller, its presence makes us ineligible. Some enterprise buyers filter on that badge, and we will lose deals to it. The alternative was an architecture that holds credentials to your depot, which we judged worse.

A Perforce username is personal data

The username and timestamp we forward identify a person and record what they changed and when. That is not incidental to the product — it is the product. We pass this data to your Jira site and do not retain it, but a vendor telling you this integration involves no personal data at all would be either mistaken or not being straight with you.

Uninstalling does not delete our storage instantly

When you uninstall, Atlassian's platform soft deletes the app's stored data and then retains it for a period defined in Atlassian's SOC 2 report. For the first 21 days it is technically possible for a developer to request that a reinstall be relinked to the old data. We will never make that request without your written instruction — but the retention window is Atlassian's, not ours, and we cannot shorten it. We verified this against Atlassian's Data lifecycle for Forge-hosted storage documentation rather than assuming it.

The Author column in your development panel will be blank

Jira resolves a commit author by matching an email address to a Jira account. We tested this directly: with an email supplied the author appears, without one the column shows an empty avatar. There is no other mechanism.

We could populate it. The trigger would read each developer's email address off your Perforce server and send it to us. We have chosen not to, and we do not offer it as an option either — an option would make "we do not receive developer email addresses" a conditional promise, which is the same as not making one. The blank column is the visible price of a commitment we actually keep, and we would rather you saw the price than didn't.

Questions

If your security review needs something this page does not cover, email support@latchpoint.app. A real answer beats a questionnaire, and we would rather write the answer once, here, than send it privately repeatedly.