Atlassian
JiraService + ConfluenceService over the Atlassian REST / Greenhopper / Agile APIs, with a per-subject PAT resolver, transient retry, SSRF guard, an avatar proxy, and per-tenant caching.
@voltro/plugin-atlassian exposes JiraService + ConfluenceService over the Atlassian REST / Greenhopper / Agile APIs. The app supplies a credentialsResolver({ subject, store }) — the plugin never reads your schema; the caller's credentials are resolved per request from the Subject (populated by AuthMiddleware).
Status: ✓ shipped.
Two auth modes, both first-class (choose per deployment):
- PAT / basic — a Personal Access Token per subject. The default for Jira/Confluence Data Center / Server. Wire it via
credentialsResolver(below). - OAuth 2.0 (3LO) — the authorization-code flow for Atlassian Cloud, where the app acts on behalf of a consenting user. Use the toolkit (OAuth 2.0 (3LO)) to obtain an access token, then feed it into the same
credentialsResolver.
It also supports inbound Jira/Confluence webhooks (signature-verified) and writing comments to issues and pages.
Wiring
// app.config.ts
import { Effect } from 'effect'
import { atlassianPlugin } from '@voltro/plugin-atlassian'
export default {
type: 'api' as const,
name: 'api',
plugins: [
atlassianPlugin({
credentialsResolver: ({ subject, store }) =>
Effect.gen(function* () {
const pat = yield* lookupPatForSubject(store, subject) // your schema
return {
baseUrl: process.env.JIRA_BASE_URL!,
token: pat,
patApplication: 'my-app',
patEnvironment: process.env.PAT_ENVIRONMENT!,
}
}),
// optional: cache, avatar proxy, retry/timeout policy — see below
}),
],
}Never put the credential in
subject.metadata. The resolver used to take a bareSubject, so that was the only place to keep a per-user PAT — and from there it travels with the identity into everything that persists a Subject. A reporter found a working Jira PAT in plaintext in 12 of 23 rows of their_voltro_audit_logexactly that way. Thestorehandle above exists so it never has to enter the Subject;connectionCredentialsis better still, because then you do not hold the token at all.
The resolver returns AtlassianCredentials:
interface AtlassianCredentials {
readonly baseUrl: string // the JIRA site root
readonly confluenceBaseUrl?: string // the CONFLUENCE root — https://wiki.example.com, or https://<site>.atlassian.net/wiki on Cloud
readonly token: string // the caller's Personal Access Token
readonly patApplication: string // → X-PAT-Application header
readonly patEnvironment: string // → X-PAT-Environment header
}confluenceBaseUrl is its own field because the two products never share a REST root — Confluence's /rest/api/content lives under /wiki on Cloud and on a separate host on Data Center. Without it every ConfluenceService call fails with a typed ConfluenceError (code: 'not_configured') rather than reaching the Jira host and answering a 404 from the wrong server.
Every method resolves the caller's PAT by calling credentialsResolver({ subject: yield* SubjectService, store }). The handler context satisfies that requirement, so you never thread credentials by hand. If a subject has no PAT on file, fail the resolver with a JiraError and the call surfaces it as a typed error.
Using it in a handler
import { Effect } from 'effect'
import { JiraService, ConfluenceService } from '@voltro/plugin-atlassian'
export default (input) => Effect.gen(function* () {
const jira = yield* JiraService
const issues = yield* jira.searchIssues(
`project = ${input.project} AND sprint in openSprints()`,
)
const board = yield* jira.getGreenhopperBoard(input.boardId) // { issues, columns, swimlanes, raw }
const sprint = yield* jira.getActiveSprint(input.boardId) // SprintRef | null
return { count: issues.length, sprint }
})JiraService and ConfluenceService are both Effect-returning with typed errors, so a caller can branch on the failure tag:
yield* jira.getIssue('ABC-1').pipe(
Effect.catchTag('JiraError', (err) =>
err.code === 'session_expired'
? Effect.fail(reauthNeeded()) // PAT is stale — re-auth
: Effect.fail(err),
),
)JiraService
| Method | Atlassian endpoint |
|---|---|
searchIssues(jql, options?) |
POST /rest/api/2/search — JQL, batched 100, follows pagination → all matches |
getGreenhopperBoard(boardId, options?) |
GET /rest/greenhopper/1.0/xboard/work/allData.json → { issues, columns, swimlanes, raw } |
getBoardConfig(boardId) |
GET /rest/greenhopper/1.0/rapidviewconfig/editmodel |
getActiveSprint(boardId) |
GET /rest/agile/1.0/board/{id}/sprint?state=active → SprintRef | null |
getSprintIssues(sprintId) |
GET /rest/agile/1.0/sprint/{id}/issue |
getSprintInfo(sprintId) |
GET /rest/agile/1.0/sprint/{id} |
getIssue(key, options?) |
GET /rest/api/2/issue/{key} (expand / fields) |
getIssueChangelog(key) |
GET /rest/api/2/issue/{key}?expand=changelog |
getComments(key) |
GET /rest/api/2/issue/{key}/comment |
addComment(key, body) |
POST /rest/api/2/issue/{key}/comment — add a comment |
getLinkedIssues(key) |
resolves an issue's issuelinks to the linked issues |
getUser({ key?, username? }) |
GET /rest/api/2/user / /rest/api/2/user/search |
getMyself() |
GET /rest/api/2/myself |
getIssueTransitions(key) |
GET /rest/api/2/issue/{key}/transitions |
transitionIssue(key, transitionId, options?) |
POST /rest/api/2/issue/{key}/transitions |
createIssue(payload) |
POST /rest/api/2/issue |
updateIssue(key, payload) |
PUT /rest/api/2/issue/{key} |
getProject(key) |
GET /rest/api/2/project/{key} |
getFields() |
GET /rest/api/2/field |
getStatuses() |
GET /rest/api/2/status |
getFilters() |
GET /rest/api/2/filter |
getAvatarUrl(ownerId) |
builds the /secure/useravatar?ownerId=… URL |
getBoard(boardId) |
GET /rest/agile/1.0/board/{id} |
listBoards(options?) |
GET /rest/agile/1.0/board — every board the caller can see, filtered by projectKeyOrId / name (a contains match) / type, all pages followed → { id, name, type, location? }[], where location names the board's project. The way to find a board you do not have the id of |
getBoardQuickFilters(boardId) |
GET /rest/agile/1.0/board/{id}/quickfilter (→ .values) |
getBoardFilterJql(boardId) |
/board/{id}/configuration → /filter/{id}.jql (board's saved-filter JQL, or null) |
assignIssue(key, assignee) |
PUT /rest/api/2/issue/{key}/assignee ({ name }; null unassigns) |
deleteIssue(key, { deleteSubtasks? }) |
DELETE /rest/api/2/issue/{key} |
getBaseUrl() |
the configured base URL (no HTTP) |
Building JQL safely — jql
searchIssues takes a string, and every value you interpolate into it is one unbalanced quote away from a query that means something else — most such queries get a 400, the rest return someone else's issues, because OR binds like any boolean. jql is the escaping, not a query language: compose the rest as a plain string.
import { JiraService, jql, JiraIssueKey } from '@voltro/plugin-atlassian'
import { Effect, Schema } from 'effect'
export const input = Schema.Struct({ project: Schema.String, keys: Schema.Array(JiraIssueKey), epic: JiraIssueKey })
export default (i: { project: string; keys: ReadonlyArray<string>; epic: string }) => Effect.gen(function* () {
const jira = yield* JiraService
return yield* jira.searchIssues(
`project = ${jql.str(i.project)} AND key in ${jql.keyList(i.keys)} AND ${jql.field('Epic Link')} = ${jql.key(i.epic)}`,
)
})jql.str quotes and escapes a value; jql.field quotes a field name JQL could not otherwise parse (Epic Link, cf[10042]); jql.key / jql.keyList validate issue keys at the call site and throw on a bad one, before anything is sent; jql.strList is the in (…) form for strings. JiraIssueKey is the schema to declare on an input.
ConfluenceService
Same PAT scheme and resilience as Jira. Built on the credentials' confluenceBaseUrl, never on the Jira baseUrl; a credential without one fails every method here with code: 'not_configured'.
| Method | Atlassian endpoint |
|---|---|
getContent(id, options?) |
GET /rest/api/content/{id}?expand=body.view,version |
searchContent(cql, options?) |
GET /rest/api/content/search?cql=…&expand=version,space |
getSpace(spaceKey) |
GET /rest/api/space/{key} |
createContent(payload) |
POST /rest/api/content |
updateContent(id, payload, { version }) |
PUT /rest/api/content/{id} (version-bumped) |
getAttachments(contentId) |
GET /rest/api/content/{id}/child/attachment |
addComment(pageId, body) |
POST /rest/api/content — a type: 'comment' content (storage markup) anchored to the page |
downloadImage(url) |
PAT-gated binary fetch of a same-host embedded image → { bytes, contentType } (SSRF-guarded) |
Resilience
Every call goes through one HTTP choke point that:
- Retries transient failures —
408 / 425 / 429 / 500 / 502 / 503 / 504plus network blips / timeouts — with exponential backoff (default 4 attempts, 500 ms initial, capped at 10 s) — for the idempotent methods only.GET/PUT/DELETEare retried; aPOSTis not, because a proxy that answers 502 after Jira has already committed the issue turns a retry into a duplicate. A transient failure on a POST surfaces withtransient: trueand "not retried: POST is not idempotent" in the message, so the caller can retry on its own terms; a call that carries anIdempotency-Keypassesretry: trueonrequestto opt back in. - Bounds the retry storm per host. Retries against one host are permitted while, over a trailing 10 s window, they stay under 20 % of the requests to it (plus a floor of 10), per process. Every first attempt still goes out — the host's recovery is observed by the traffic itself — but an outage no longer costs
maxAttempts × timeoutMsfor every fiber that hits it. A budget rather than a circuit breaker on purpose: a breaker needs an "open" policy and a probe the plugin cannot choose for an app. - Honours
Retry-Afteron the response over the computed backoff (honourRetryAfter: trueby default). - Times out each request (default 20 s) and treats the timeout as transient.
- SSRF-guards every URL: the resolved host must equal the configured base host, so a crafted ref can't pivot the server-side fetch to an internal address.
- Treats 401 as non-transient — the upstream refused the credential, and the status does not say why (expired, revoked, insufficient scope and malformed all read 401), so re-auth rather than retry. It surfaces as a
JiraError/ConfluenceErrorwithtransient: falseandcode: 'unauthorized'; a credential the connection vault could not produce at all iscode: 'credential_unusable'.
Override the policy per instance:
atlassianPlugin({
credentialsResolver,
policy: {
maxAttempts: 6,
initialDelayMs: 250,
maxDelayMs: 20_000,
timeoutMs: 30_000,
honourRetryAfter: true,
retryMethods: ['GET', 'PUT', 'DELETE'], // the default — POST is never retried unless a call says `retry: true`
retryBudget: { ratio: 0.2, minRetriesPerWindow: 10, windowMs: 10_000 }, // per host; `false` for none
},
})OAuth 2.0 (3LO)
For Atlassian Cloud, where a PAT isn't available and the app acts on behalf of a consenting user, use the OAuth 2.0 three-legged-auth toolkit. It is an alternative to PAT auth, not a replacement — PAT/basic stays fully supported for Data Center / Server. The plugin holds no token storage of its own: the app persists the token set (the same seam PAT credentials use) and the toolkit handles the authorization-code exchange + refresh. The clientSecret and tokens are runtime values sourced from config/env, never logged, never bundled.
import { Effect } from 'effect'
import {
buildAuthorizeUrl,
exchangeCode,
withFreshToken,
type OAuthConfig,
type OAuthTokens,
} from '@voltro/plugin-atlassian'
const oauth: OAuthConfig = {
clientId: process.env.ATLASSIAN_CLIENT_ID!,
clientSecret: process.env.ATLASSIAN_CLIENT_SECRET!, // secret — env/config only
redirectUri: 'https://app.example.com/oauth/atlassian/callback',
}
// 1. Redirect the user to consent. Store `state`; verify it on the callback.
// `offline_access` is required to receive a refresh token.
const authorizeUrl = buildAuthorizeUrl(oauth, {
scopes: ['read:jira-work', 'write:jira-work', 'offline_access'],
state: crypto.randomUUID(),
prompt: 'consent',
})
// 2. On the callback (`?code=…&state=…`), exchange the code + persist the set.
const onCallback = (code: string) => Effect.gen(function* () {
const tokens = yield* exchangeCode({ ...oauth, fetchImpl: globalThis.fetch as never }, code)
yield* persistTokensForSubject(tokens) // your store — { accessToken, refreshToken, expiresAt }
})
// 3. Before an OAuth call, get a still-valid token — refreshes near expiry.
const accessTokenFor = (stored: OAuthTokens) =>
withFreshToken(
{ ...oauth, fetchImpl: globalThis.fetch as never },
stored,
(rotated) => persistTokensForSubject(rotated),
)Then feed the access token into the SAME credentialsResolver — the services send Authorization: Bearer <accessToken>, identical to the PAT path:
atlassianPlugin({
credentialsResolver: (subject) => Effect.gen(function* () {
const stored = yield* loadTokensForSubject(subject) // your store
const fresh = yield* accessTokenFor(stored)
return {
baseUrl: `https://api.atlassian.com/ex/jira/${cloudIdForSubject(subject)}`,
token: fresh.accessToken,
patApplication: 'my-app',
patEnvironment: process.env.PAT_ENVIRONMENT!,
}
}),
})Exchange/refresh failures are a typed AtlassianOAuthError ({ message, transient, status? }) — transient: true only for a 5xx / network blip at the token endpoint; a missing refresh token surfaces as a non-transient re-consent signal. The clientSecret and tokens never appear in message.
Inbound webhooks
Receive Jira/Confluence webhook events over the framework's normal webhook seam. Atlassian signs the raw body with HMAC-SHA-256 and writes X-Hub-Signature: sha256=<hex> (the shared secret is configured on the webhook — env/config only, never logged).
Recommended — the *.webhook.tsx provider path (durable, dashboard-listed, auto-verified + de-duped by the framework). atlassianWebhookProvider() is a drop-in @voltro/plugin-webhooks provider preset:
import { Schema } from 'effect'
import { defineIncomingWebhook } from '@voltro/plugin-webhooks'
import { atlassianWebhookProvider } from '@voltro/plugin-atlassian/webhook'
export default defineIncomingWebhook({
id: 'jira-events',
provider: atlassianWebhookProvider(), // HMAC-SHA-256 signature + idempotency
payload: Schema.Any, // narrow per event in the handler
handler: async (ctx) => {
// Signature already verified by the framework; switch on the event type.
switch ((ctx.body as { webhookEvent?: string }).webhookEvent) {
case 'jira:issue_updated': break
case 'comment_created': break
}
},
})The preset extracts the event type from the body's webhookEvent field and the idempotency key from the X-Atlassian-Webhook-Identifier header (falling back to <event>:<timestamp>).
Raw-route path — if you wire the webhook onto a PluginHttpRoute (or any HTTP surface) yourself, verify + parse with the standalone primitives:
import { verifyWebhookSignature, parseWebhookEnvelope } from '@voltro/plugin-atlassian/webhook'
const handle = (rawBody: Uint8Array, headers: Record<string, string>) => {
verifyWebhookSignature({ rawBody, headers, secret: process.env.ATLASSIAN_WEBHOOK_SECRET! })
const event = parseWebhookEnvelope(rawBody) // { webhookEvent, issue?, comment?, page?, raw }
// dispatch on event.webhookEvent
}Both reject failures with a typed AtlassianWebhookError — reason: 'signature' (missing/invalid signature → map to 401) or reason: 'payload' (bad body → 400). Verification is constant-time.
Jira Data Center
The same preset serves a self-hosted Data Center; three things differ from Cloud, and each is a line in the webhook's admin form rather than in code.
- The URL is the api host plus the mount:
https://api.example.com/webhooks/jira-events(/webhooks/<id>, or thepath:you set ondefineIncomingWebhook). Route it through the same ingress as the rest of the api — a webhook that arrives at a pod's private address bypasses the trusted-proxy and rate-limit layers the api relies on. - The secret. Data Center signs the raw body with HMAC-SHA-256 into
X-Hub-Signature: sha256=<hex>from 8.x on, and only when the webhook has a secret configured (Administration → System → Webhooks → Secret). Set it; the preset verifies againstATLASSIAN_WEBHOOK_SECRET. A Data Center below 8.x sends no signature at all, and the framework does not accept an unverified webhook — there is noverification: 'none'for a provider that can sign. Upgrade the instance, or terminate the webhook at an ingress that authenticates it (mTLS, an allow-listed egress address) and pass the raw body on with the header it adds. - The identifier. Cloud sends
X-Atlassian-Webhook-Identifieron every delivery; Data Center did not always. The preset's idempotency key falls back to<webhookEvent>:<timestamp>from the envelope, so a redelivery of the same event is still de-duplicated, and two events fired in the same millisecond for the samewebhookEventare the one case that cannot be told apart without the header.
Avatar proxy
Jira user avatars require the PAT, so the browser can't fetch them directly. The proxy resolves the avatar server-side and serves the bytes — the artefact the browser gets is credential-free, which is the whole point: the PAT is spent on the fetch and never reaches the client.
The route serves bytes at /_voltro/atlassian/avatar/:ownerId — fetched server-side with binary headers (no Accept, so Jira returns the raw image), SSRF-guarded to the configured host, image/*-only (never proxies an HTML error page), with a 1-year immutable cache (a Jira avatar for an ownerId never changes).
avatar is a discriminated union on a required mode. The two modes are not interchangeable, and a config file must not leave which one is in play to inference:
mode |
Whose credential fetches | Route access | Cache key |
|---|---|---|---|
'service' |
one shared service token | public | the avatar ref alone |
'perUser' |
the viewing subject's own | authenticated only | viewer + avatar ref |
mode: 'service'
One shared credential fetches every avatar and the route is public. Cheap, and every caller sees the same bytes:
atlassianPlugin({
credentialsResolver,
avatar: {
mode: 'service',
resolveCredentials: () => ({
baseUrl: process.env.JIRA_BASE_URL!,
token: process.env.JIRA_SERVICE_PAT!, // dedicated read-only token
patApplication: 'my-app',
patEnvironment: process.env.PAT_ENVIRONMENT!,
}),
// pathPrefix defaults to '/_voltro/atlassian/avatar'
},
})mode: 'perUser'
Every fetch runs with the viewing subject's own credential, resolved per request — for deployments where a service PAT must not exist at all:
atlassianPlugin({
credentialsResolver,
avatar: {
mode: 'perUser',
// The framework does NOT authenticate plugin HTTP routes, so the app
// resolves the acting subject itself — typically from its session cookie.
resolveSubject: (req) => resolveSubjectFromCookie(req.headers['cookie']),
// That subject's own PAT. Throw when they have none — the route answers 403.
resolveCredentials: (subject) => loadPatForSubject(subject),
},
})resolveSubject is app-supplied because PluginHttpRouteRequest carries no subject: a plugin HTTP route is not part of the rpc pipeline, so nothing upstream has authenticated the caller. Verify the session yourself (readCookie + verifySession from @voltro/protocol/session) inside your own server-only module — that subpath must not be imported from a browser-reachable file. Return null for an unauthenticated caller.
Three properties of this mode are load-bearing:
- Anonymous is refused outright.
null, ananonymoussubject, or a subject with noidgets a401with no upstream fetch and no cache read — the identity is settled before anything else happens. - There is no service fallback. A subject with no PAT on file gets a
403. Falling back to a shared credential is precisely what a deployment choosing this mode cannot do. - The cache key is the VIEWER, never the avatar's owner. A per-user entry keys on
type+id+tenantIdof the subject whose credential paid for the fetch, plus the avatar ref. Keying by the owner alone would be a cross-user read: user A's fetch would populate an entry served straight to user B without B's PAT ever being checked against Jira. The cost is a lower hit rate — one entry per (viewer, avatar) pair — and that is the correct trade, because a shared entry is the cross-user read. (servicemode keys by the avatar ref alone, which is correct there: one credential authorised the fetch and every caller of the public route is entitled to exactly those bytes.)
Pass a cache ({ store, ttlMs? }) in either mode; the stored value is content-type + base64 bytes, so a hit costs no upstream call and leaks no token.
Upgrading from a config without mode? voltro update ships a codemod for it — the previous shape was service-only by construction (a zero-argument resolveCredentials, public route), so it inserts mode: 'service' at every atlassianPlugin({ avatar: … }) site. Opting into perUser is a deliberate act afterwards: it needs a resolveSubject only your app can write.
Caching
cache: true memoises read calls per tenant into the framework's cache — the backend CACHE_BACKEND selects, in-process memory or Redis shared across replicas, handed to the plugin at bind time. Keys are namespaced atlassian:<namespace>:<tenant>:<key>, so one org never reads another's cached board:
import { atlassianPlugin } from '@voltro/plugin-atlassian'
// The framework's cache — CACHE_BACKEND picks memory or Redis — with default TTLs:
atlassianPlugin({ credentialsResolver, cache: true })
// …or tune the TTLs on it:
atlassianPlugin({
credentialsResolver,
cache: { ttlMs: { boardConfig: 60_000, project: 30_000 } },
})
// …or memoise into a store of your own:
atlassianPlugin({
credentialsResolver,
cache: { store, ttlMs: { boardConfig: 60_000 } }, // any { get, set } over Effect
})Only reference-data reads are cached — the four namespaces boardConfig (getBoardConfig), project (getProject), field (getFields), status (getStatuses). Issue/board content is never cached (it changes too often to serve stale). A missing namespace TTL falls back to the store default. Caching is best-effort: a cache read/write failure never fails the call — it falls through to the live request, and a read before the framework cache is bound simply misses. With no cache option, every call is a direct pass-through.
Multiple sites
Run two instances against two Atlassian sites by naming them. A named instance provides its services under per-name tags — jiraServiceFor(name) / confluenceServiceFor(name) — and the unnamed instance keeps JiraService / ConfluenceService. (Two instances used to both provide JiraService, and the last one merged silently won.)
Confluence with its own credentials. On Cloud one token serves both products. On Data Center a Jira PAT is not a Confluence PAT, so pass a second resolver — absent, the Jira resolver serves Confluence too:
atlassianPlugin({
credentialsResolver: ({ subject, store }) => jiraCredentialsFor(subject, store),
confluenceCredentialsResolver: ({ subject, store }) => confluenceCredentialsFor(subject, store),
})import { jiraServiceFor } from '@voltro/plugin-atlassian'
// app.config.ts
plugins: [
atlassianPlugin({ name: 'eu', credentialsResolver: euResolver }),
atlassianPlugin({ name: 'us', credentialsResolver: usResolver }),
]
// a handler — each site under its own tag
const eu = yield* jiraServiceFor('eu')
const us = yield* jiraServiceFor('us')Anti-patterns
- Hard-coding a single shared PAT. Resolve per subject so each caller acts as themselves and a stale token surfaces as
unauthorizedfor that user only. - Interpolating a value into JQL by hand. Use
jql.str/jql.keyList— a user-typed string is one quote away from someone else's issues. - Fetching Jira avatars from the browser. They need the PAT; use the avatar proxy and let the browser hit
/_voltro/atlassian/avatar/:ownerId. - Retrying a 401 yourself. It's non-transient by design — re-auth the subject instead.
- Caching writes. Only read calls (board config, project / field / status metadata) belong in the cache; mutations always go live.