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=activeSprintRef | 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 / 504 plus network blips / timeouts — with exponential backoff (default 4 attempts, 500 ms initial, capped at 10 s).
  • Honours Retry-After on the response over the computed backoff (honourRetryAfter: true by 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 a JiraError / ConfluenceError with transient: false and code: '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 AtlassianWebhookErrorreason: '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, an anonymous subject, or a subject with no id gets a 401 with 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 + tenantId of 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. (service mode 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_expired for 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.