# 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.



---

<!-- source: en/plugins/atlassian.md -->
## 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)](#oauth-20-3lo)) to obtain an access token, then feed it into the same `credentialsResolver`.

It also supports [inbound Jira/Confluence webhooks](#inbound-webhooks) (signature-verified) and writing comments to issues and pages.

## Wiring

```ts
// 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
> bare `Subject`, 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_log` exactly that way. The `store` handle above exists so it
> never has to enter the Subject; [`connectionCredentials`](#connections) is
> better still, because then you do not hold the token at all.

The resolver returns `AtlassianCredentials`:

```ts
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({ 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

```ts
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:

```ts
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 / 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:

```ts
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.

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```ts
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.
