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) — 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) =>
Effect.gen(function* () {
const pat = yield* lookupPatForSubject(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
}),
],
}The resolver returns AtlassianCredentials:
interface AtlassianCredentials {
readonly baseUrl: string // the Atlassian site root
readonly token: string // the caller's Personal Access Token
readonly patApplication: string // → X-PAT-Application header
readonly patEnvironment: string // → X-PAT-Environment header
}Every method resolves the caller's PAT by calling credentialsResolver(yield* SubjectService). 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} |
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) |
ConfluenceService
Same PAT scheme and resilience as Jira.
| 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). - 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
session_expired— the stored PAT is stale, so re-auth rather than retry. It surfaces as aJiraError/ConfluenceErrorwithtransient: falseandcode: 'session_expired'.
Override the policy per instance:
atlassianPlugin({
credentialsResolver,
policy: {
maxAttempts: 6,
initialDelayMs: 250,
maxDelayMs: 20_000,
timeoutMs: 30_000,
honourRetryAfter: true,
},
})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.
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
Pass a @voltro/cache store to memoise read calls per tenant. Keys are namespaced atlassian:<namespace>:<tenant>:<key>, so one org never reads another's cached board:
import { atlassianPlugin } from '@voltro/plugin-atlassian'
atlassianPlugin({
credentialsResolver,
cache: {
store, // a @voltro/cache CacheStore
ttlMs: { boardConfig: 60_000, project: 30_000 },
},
})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. With no cache option, every call is a direct pass-through.
Multiple sites
Run two instances against two Atlassian sites by naming them:
plugins: [
atlassianPlugin({ name: 'eu', credentialsResolver: euResolver }),
atlassianPlugin({ name: 'us', credentialsResolver: usResolver }),
]Anti-patterns
- Hard-coding a single shared PAT. Resolve per subject so each caller acts as themselves and a stale token surfaces as
session_expiredfor that user only. - 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.