import { Context } from 'effect'; import { DEFAULT_POLICY } from '@voltro/integration-http'; import { Effect } from 'effect'; import { FetchLike } from '@voltro/integration-http'; import { HttpPolicy } from '@voltro/integration-http'; import { Option } from 'effect'; import { PluginHttpRouteRequest } from '@voltro/protocol'; import { Schema } from 'effect'; import { Subject } from '@voltro/protocol'; import { SubjectService } from '@voltro/protocol'; import { VoltroPlugin } from '@voltro/protocol'; /** Atlassian's fixed 3LO endpoints (identical for every Cloud site). */ export declare const ATLASSIAN_AUTHORIZE_URL = "https://auth.atlassian.com/authorize"; export declare const ATLASSIAN_TOKEN_URL = "https://auth.atlassian.com/oauth/token"; /** The slice of `@voltro/cache`'s CacheStore the plugin uses — kept * structural so the app can pass its own store without a type dep dance. */ export declare interface AtlassianCache { readonly get: (key: string) => Effect.Effect, unknown>; readonly set: (key: string, value: unknown, options?: { readonly ttlMs?: number; }) => Effect.Effect; } /** The cacheable read namespaces — the reference-data GETs the plugin memoises * (board config, project, field list, status list). Issue/board *content* is * never cached (it changes too often to serve stale). */ export declare type AtlassianCacheNamespace = 'boardConfig' | 'project' | 'field' | 'status'; /** * Resolved per-request Atlassian credentials. The app's * `credentialsResolver(subject)` produces these — the plugin never reads * the app's schema. `patApplication` / `patEnvironment` populate the * mandatory `X-PAT-Application` / `X-PAT-Environment` tracking headers. */ export declare interface AtlassianCredentials { /** The JIRA site root (`https://jira.example.com`). */ readonly baseUrl: string; /** * The CONFLUENCE site root — `https://wiki.example.com` on Data Center, or * `https://.atlassian.net/wiki` on Cloud. Distinct from `baseUrl` * 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, so a `ConfluenceService` call built on the Jira root reached * the wrong server in every environment. Absent ⇒ every `ConfluenceService` * method fails with a typed `ConfluenceError` (`code: 'not_configured'`) * rather than a 404 from the wrong host. */ readonly confluenceBaseUrl?: string; readonly token: string; readonly patApplication: string; readonly patEnvironment: string; } export declare type AtlassianError = JiraError | ConfluenceError | AtlassianOAuthError | AtlassianWebhookError; /** * OAuth 2.0 (3LO) failure — a rejected/expired grant, a malformed token * response, or a missing-refresh-token re-consent signal. `transient` is * `true` only for a 5xx / network blip at the token endpoint. The * `client_secret` and tokens are NEVER included in `message`. */ export declare class AtlassianOAuthError extends AtlassianOAuthError_base { } declare const AtlassianOAuthError_base: Schema.TaggedErrorClass; } & { message: typeof Schema.String; transient: typeof Schema.Boolean; status: Schema.optional; }>; export declare const atlassianPlugin: (options: AtlassianPluginOptions) => VoltroPlugin; export declare interface AtlassianPluginOptions { /** Resolve the caller's Atlassian credentials from their Subject. */ readonly credentialsResolver: CredentialsResolver; /** * Resolve the caller's CONFLUENCE credentials separately. On Cloud one * token serves both products and this is not needed; on Data Center a Jira * PAT is not a Confluence PAT, and a service built on the Jira resolver * answered every Confluence call with 401 — four actions unusable in every * environment. Absent → `credentialsResolver` serves Confluence too. */ readonly confluenceCredentialsResolver?: CredentialsResolver; /** Distinguishes multiple instances (e.g. two Atlassian sites). */ readonly name?: string; /** Override the HTTP client (tests / custom transport). Default global fetch. */ readonly fetchImpl?: FetchLike; /** Retry/backoff/timeout overrides (merged over defaults). */ readonly policy?: Partial; /** * Memoise reference-data reads. `true` binds the FRAMEWORK's cache (the * backend `CACHE_BACKEND` selects — memory, or Redis across replicas) with * the default TTLs; `{ ttlMs }` tunes them on that cache; `{ store, ttlMs? }` * memoises into a store of your own. Before this the option needed a * hand-built store object and the docs named no way to obtain one, so the * seven `getFields()` sites of an app each loaded the field catalogue. */ readonly cache?: true | CacheConfig; /** * Enable the avatar proxy. Pick the auth mode explicitly: * * `{ mode: 'service', resolveCredentials: () => creds }` — a public route * backed by ONE service token. * * `{ mode: 'perUser', resolveSubject, resolveCredentials: (subject) => … }` * — every fetch runs with the viewing subject's own PAT; anonymous callers * are refused and there is no service fallback. */ readonly avatar?: AvatarOptions; } /** Inbound-webhook verification failure — a missing/invalid signature or an * unparseable envelope. Reject the request with a 401/400. */ export declare class AtlassianWebhookError extends AtlassianWebhookError_base { } declare const AtlassianWebhookError_base: Schema.TaggedErrorClass; } & { message: typeof Schema.String; /** `'signature'` → 401 (bad/missing signature); `'payload'` → 400 (bad body). */ reason: Schema.Literal<["signature", "payload"]>; }>; export declare interface AuthorizeUrlOptions { /** Scopes to request. `offline_access` is required to receive a refresh * token. Common: `['read:jira-work', 'write:jira-work', 'offline_access']`. */ readonly scopes: ReadonlyArray; /** Opaque anti-CSRF value echoed back on the redirect — the app generates, * stores, and verifies it. */ readonly state: string; /** `prompt=consent` forces the consent screen (needed to (re)issue a refresh * token). Default omitted. */ readonly prompt?: 'consent'; /** `audience`. Default `'api.atlassian.com'` (the Atlassian Cloud gateway). */ readonly audience?: string; } /** Optional byte cache for resolved avatars. The stored value is * credential-free (content-type + base64 bytes), so a hit costs no upstream * call and leaks no token. */ export declare interface AvatarCacheConfig { readonly store: AtlassianCache; /** TTL in ms for a cached avatar. Missing → the store's default. */ readonly ttlMs?: number; } export declare type AvatarConfig = ServiceAvatarConfig | PerUserAvatarConfig; /** Shared by both auth modes. Exported because it is the `extends` base of the * two public config shapes — a user naming either one needs it. */ export declare interface AvatarConfigBase { readonly fetchImpl: FetchLike; /** Mount path. Default `/_voltro/atlassian/avatar`; the trailing path * segment is the `ownerId`. */ readonly pathPrefix?: string; readonly cache?: AvatarCacheConfig; } /** The `avatar` option: {@link AvatarConfig} with `fetchImpl` optional (it * falls back to the plugin's own fetch). */ export declare type AvatarOptions = (Omit & { readonly fetchImpl?: FetchLike; }) | (Omit & { readonly fetchImpl?: FetchLike; }); /** * Build the URL to redirect a user to for consent. Pure string construction — * no IO. The caller stores `state` and verifies it on the callback. */ export declare const buildAuthorizeUrl: (config: OAuthConfig, options: AuthorizeUrlOptions) => string; declare type C = Effect.Effect; export declare interface CacheConfig { /** * The store to memoise into. Omit it and the plugin uses the FRAMEWORK's * cache — the same backend `ctx.cache` uses (`CACHE_BACKEND`: in-process * memory, or Redis shared across replicas), handed over at bind time. Pass * your own for a store the framework does not manage. */ readonly store?: AtlassianCache; /** Per-namespace TTL in ms (e.g. `{ boardConfig: 60_000, project: 30_000 }`). * Missing namespace → no TTL (store default). Only the four reference-data * namespaces are cached — see {@link AtlassianCacheNamespace}. */ readonly ttlMs?: Partial>>; } /** Confluence failure — same shape/semantics as {@link JiraError}. */ export declare class ConfluenceError extends ConfluenceError_base { } declare const ConfluenceError_base: Schema.TaggedErrorClass; } & { message: typeof Schema.String; transient: typeof Schema.Boolean; status: Schema.optional; code: Schema.optional>; }>; export declare class ConfluenceService extends ConfluenceService_base { } declare const ConfluenceService_base: Context.TagClass; /** The Confluence half of {@link jiraServiceFor} — a named instance's tag. */ export declare const confluenceServiceFor: (name: string) => Context.Tag; export declare interface ConfluenceServiceShape { /** GET /rest/api/content/{id}?expand=body.view,version */ readonly getContent: (id: string, options?: { readonly expand?: ReadonlyArray; }) => C; /** GET /rest/api/content/search?cql=&expand=version,space */ readonly searchContent: (cql: string, options?: { readonly limit?: number; readonly expand?: ReadonlyArray; }) => C>; /** GET /rest/api/space/{key} */ readonly getSpace: (spaceKey: string) => C; /** POST /rest/api/content */ readonly createContent: (payload: unknown) => C; /** PUT /rest/api/content/{id} (version-bumped) */ readonly updateContent: (id: string, payload: unknown, options: { readonly version: number; }) => C; /** GET /rest/api/content/{id}/child/attachment */ readonly getAttachments: (contentId: string) => C>; /** POST /rest/api/content — add a comment to a page. `body` is the comment * text (storage-format markup). Returns the created comment content. */ readonly addComment: (pageId: string, body: string) => C; /** GET a PAT-gated binary asset on the Confluence host (an embedded internal * image / attachment download path that the browser can't fetch * unauthenticated). SSRF-guarded to the configured host — pass a same-host * absolute URL or a host-relative path. Returns raw bytes + content-type * (for inlining as a data URL or re-storing via `@voltro/plugin-storage`). */ readonly downloadImage: (url: string) => C<{ readonly bytes: Uint8Array; readonly contentType: string | null; }>; } /** * App-supplied: maps the request's caller to Atlassian credentials. * Fail with the matching typed error (e.g. no PAT on file). * * **It takes a CONTEXT rather than a bare Subject, and the reason is a live * credential leak.** With `subject` as the only input, an app doing per-user * Atlassian auth had nowhere to put the caller's PAT except `subject.metadata` — * so the token travelled 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`, and neither plugin was wrong on its own: this one * REQUIRED the credential to be in the Subject, and the audit plugin serialised * the Subject verbatim. * * `@voltro/plugin-audit` gained a default-on `redactSubject` in the same change, * which stops that particular bleed. This is the other half, and the more * important one: with `store` here the credential never has to enter the Subject * at all — which is the only version that also keeps it out of whatever * persists one NEXT. */ export declare type CredentialsResolver = (ctx: CredentialsResolverContext) => Effect.Effect; /** * What an app-supplied resolver receives. * * `store` is absent when the app bound no data store (tests, a store-less * deployment), so a resolver that needs it has to say what happens then. The * optionality is the point: a runtime `undefined` here would be discovered in * production. */ export declare interface CredentialsResolverContext { readonly subject: Subject; readonly store?: CredentialsStore; } /** * The narrow store surface a resolver needs: one read. * * Deliberately not `DataStore`. This seam exists so an app can LOOK a credential * up; being able to write through it is not part of that, and a narrow type says * so in the signature rather than in a comment. */ export declare interface CredentialsStore { readonly query: (descriptor: unknown) => Promise>>; } export declare const DEFAULT_AVATAR_PREFIX = "/_voltro/atlassian/avatar"; /** Default `X-PAT-Application` when the app doesn't override it. */ export declare const DEFAULT_PAT_APPLICATION = "voltro"; export { DEFAULT_POLICY } /** How close to expiry (ms) a token is considered stale and refreshed early. * Default 60s of headroom so an in-flight call doesn't race the deadline. */ export declare const DEFAULT_REFRESH_SKEW_MS = 60000; /** * Exchange an authorization `code` (from the redirect callback) for a token * set. Fails with {@link AtlassianOAuthError} on a rejected grant / bad code. */ export declare const exchangeCode: (config: OAuthExchangeConfig, code: string) => Effect.Effect; export { FetchLike } /** Jira board data (Greenhopper xboard allData shape — kept loose; the * app maps `raw` in app code). */ export declare interface GreenhopperBoard { readonly issues: ReadonlyArray; readonly columns: ReadonlyArray; readonly swimlanes: ReadonlyArray; readonly raw: unknown; } export { HttpPolicy } declare type J = Effect.Effect; /** * Jira failure. `transient` drives retry — `true` for 408/425/429/5xx + * network blips (worth retrying), `false` for 4xx / config errors. * * Two non-transient credential codes, and the split is the point. Both used to * be `session_expired`, which asserted a cause one of them cannot know: * * - `'unauthorized'` — the UPSTREAM answered 401. It refused the credential * and did not say why: expired, revoked, insufficient scope and MALFORMED * are all this status. Do not conclude "re-auth" from it alone. * - `'credential_unusable'` — the connection VAULT could not produce a * credential (no grant, revoked grant, refresh failed). Here we do know, * because the failure is ours rather than the upstream's, and re-auth is * the right action. * * The rename came from a deployment whose plugin sent ciphertext as a bearer * token — a configuration error that arrived as a 401, was named * `session_expired`, and made their health check delete a perfectly valid * session. A name that asserts a cause gets acted on. */ export declare class JiraError extends JiraError_base { } declare const JiraError_base: Schema.TaggedErrorClass; } & { message: typeof Schema.String; transient: typeof Schema.Boolean; status: Schema.optional; code: Schema.optional>; }>; /** A Jira issue key — `PROJ-123`. Project keys are upper-case letters, digits * and underscores, and start with a letter. */ export declare const JiraIssueKey: Schema.filter; export declare type JiraIssueKey = Schema.Schema.Type; export declare class JiraService extends JiraService_base { } declare const JiraService_base: Context.TagClass; /** * The tag a NAMED plugin instance provides its Jira service under. * * `atlassianPlugin({ name: 'eu' })` and `atlassianPlugin({ name: 'us' })` used * to both provide `JiraService` — one `Context.Tag`, two layers, and the last * one merged silently won. "Run two instances by naming them" was documented * and never worked. A named instance now provides `jiraServiceFor(name)` (and * `confluenceServiceFor(name)`); the unnamed instance keeps the default tags. * A handler reaches a site by its name: `yield* jiraServiceFor('eu')`. */ export declare const jiraServiceFor: (name: string) => Context.Tag; export declare interface JiraServiceShape { /** POST /rest/api/2/search — JQL, batched 100. Returns all matching * issues (follows pagination). */ readonly searchIssues: (jql: string, options?: SearchIssuesOptions) => J>; /** GET /rest/greenhopper/1.0/xboard/work/allData.json?rapidViewId= */ readonly getGreenhopperBoard: (boardId: string | number, options?: { readonly quickFilterId?: string | number; }) => J; /** GET /rest/greenhopper/1.0/rapidviewconfig/editmodel?rapidViewId= */ readonly getBoardConfig: (boardId: string | number) => J; /** GET /rest/agile/1.0/board/{id}/sprint?state=active → null if none * (failures are swallowed to null). */ readonly getActiveSprint: (boardId: string | number) => J; /** GET /rest/agile/1.0/sprint/{id}/issue */ readonly getSprintIssues: (sprintId: string | number) => J>; /** GET /rest/agile/1.0/sprint/{id} */ readonly getSprintInfo: (sprintId: string | number) => J; /** GET /rest/api/2/issue/{key} */ readonly getIssue: (key: string, options?: { readonly expand?: ReadonlyArray; readonly fields?: ReadonlyArray; }) => J; /** GET /rest/api/2/issue/{key}?expand=changelog */ readonly getIssueChangelog: (key: string) => J; /** GET /rest/api/2/issue/{key}/comment */ readonly getComments: (key: string) => J>; /** POST /rest/api/2/issue/{key}/comment — add a comment. `body` is the comment * text (wiki markup on Server/DC). Returns the created comment. */ readonly addComment: (key: string, body: string) => J; /** Issue `issuelinks` resolved to linked issues (read tool for the AI loop). */ readonly getLinkedIssues: (key: string) => J>; /** GET /rest/api/2/user?key= | /rest/api/2/user/search?username= */ readonly getUser: (by: { readonly key?: string; readonly username?: string; }) => J; /** GET /rest/api/2/myself */ readonly getMyself: () => J; /** GET /rest/api/2/issue/{key}/transitions */ readonly getIssueTransitions: (key: string) => J>; /** POST /rest/api/2/issue/{key}/transitions */ readonly transitionIssue: (key: string, transitionId: string, options?: { readonly fields?: Record; }) => J; /** POST /rest/api/2/issue */ readonly createIssue: (payload: unknown) => J; /** PUT /rest/api/2/issue/{key} */ readonly updateIssue: (key: string, payload: unknown) => J; /** GET /rest/api/2/project/{key} */ readonly getProject: (key: string) => J; /** GET /rest/api/2/field */ readonly getFields: () => J>; /** GET /rest/api/2/status */ readonly getStatuses: () => J>; /** GET /rest/api/2/filter/{id} (or list) */ readonly getFilters: () => J>; /** Build the `/secure/useravatar?ownerId=…` URL for a user/owner. */ readonly getAvatarUrl: (ownerId: string) => J; /** GET /rest/agile/1.0/board/{id} — the board's metadata (name, type, location). */ readonly getBoard: (boardId: string | number) => J; /** GET /rest/agile/1.0/board/{id}/quickfilter — the board's configured quick filters (`.values`). */ readonly getBoardQuickFilters: (boardId: string | number) => J>; /** PUT /rest/api/2/issue/{key}/assignee — assign (Jira Server/DC body `{ name }`); pass `null` to unassign. */ readonly assignIssue: (key: string, assignee: string | null) => J; /** DELETE /rest/api/2/issue/{key} — remove an issue (optionally its subtasks). */ readonly deleteIssue: (key: string, options?: { readonly deleteSubtasks?: boolean; }) => J; /** The configured Jira base URL for the caller's tenant — for building issue/board deep-links. No HTTP call. */ readonly getBaseUrl: () => J; /** Resolve a board's SAVED-FILTER JQL: `/board/{id}/configuration` → `filter.id` * → `/filter/{id}.jql`. Returns `null` when the board has no resolvable filter. * Use to scope a query to exactly what the board shows (e.g. sprint reports). */ readonly getBoardFilterJql: (boardId: string | number) => J; } /** * The JQL escaping helpers. Compose the rest as a plain string: * * ```ts * jira.searchIssues(`project = ${jql.str(project)} AND key in ${jql.keyList(keys)} AND ${jql.field('Epic Link')} = ${jql.key(epic)}`) * ``` */ export declare const jql: { readonly str: (value: string) => string; readonly field: (name: string) => string; readonly key: (value: string) => string; readonly keyList: (keys: ReadonlyArray) => string; readonly strList: (values: ReadonlyArray) => string; }; /** OAuth 2.0 client registration + endpoint config. `clientSecret` is a * secret — sourced from config/env, never logged, never bundled. */ export declare interface OAuthConfig { readonly clientId: string; readonly clientSecret: string; /** The exact redirect URI registered on the Atlassian app (must match). */ readonly redirectUri: string; /** Override the token endpoint (tests / Atlassian gov-cloud). Default * {@link ATLASSIAN_TOKEN_URL}. */ readonly tokenUrl?: string; /** Override the authorize endpoint. Default {@link ATLASSIAN_AUTHORIZE_URL}. */ readonly authorizeUrl?: string; } export declare interface OAuthExchangeConfig extends OAuthConfig { readonly fetchImpl: FetchLike; /** Injectable clock for `expiresAt` (tests). Default `Date.now`. */ readonly now?: () => number; } /** A resolved OAuth token set. `expiresAt` is an absolute epoch-ms deadline * (computed from the endpoint's `expires_in`), so a deployment can decide when * to refresh without re-deriving it. */ export declare interface OAuthTokens { readonly accessToken: string; /** Absent when Atlassian didn't return one (e.g. `offline_access` scope not * requested — then the app must re-consent to get a new access token). */ readonly refreshToken?: string; /** Absolute epoch-ms the access token expires at. */ readonly expiresAt: number; /** Granted scopes (space-delimited on the wire; split here). */ readonly scopes: ReadonlyArray; readonly tokenType: string; } /** Authenticated route: every fetch uses the VIEWING subject's own credential. */ export declare interface PerUserAvatarConfig extends AvatarConfigBase { readonly mode: 'perUser'; /** * Resolve the acting subject from the raw request. `PluginHttpRouteRequest` * carries NO subject — the framework does not authenticate plugin HTTP * routes — so the app supplies this, typically by verifying its session * cookie (`readCookie` + `verifySession` from `@voltro/protocol/session`, * which is server-only and must not be imported here: this module is * re-exported from the browser-reachable package root). * * Return `null` for an unauthenticated caller. A returned `anonymous` * subject counts as unauthenticated too. */ readonly resolveSubject: (req: PluginHttpRouteRequest) => Promise | Subject | null; /** Resolve THAT subject's credentials. Throw / reject when the subject has * no PAT on file — the route answers 403, it never falls back. */ readonly resolveCredentials: (subject: Subject) => Promise | AtlassianCredentials; } /** * Swap a refresh token for a fresh token set (rotating refresh — Atlassian * returns a NEW refresh token each time, so the app must persist the returned * one). Fails with {@link AtlassianOAuthError} on an expired / revoked grant. */ export declare const refreshTokens: (config: OAuthExchangeConfig, refreshToken: string) => Effect.Effect; export declare interface SearchIssuesOptions { readonly fields?: ReadonlyArray; readonly maxResults?: number; readonly startAt?: number; } /** Public route: one shared service credential fetches every avatar. */ export declare interface ServiceAvatarConfig extends AvatarConfigBase { readonly mode: 'service'; /** Service-level credentials — NOT a per-user PAT. The route is public; * often a dedicated read-only token. */ readonly resolveCredentials: () => Promise | AtlassianCredentials; } export declare interface SprintRef { readonly id: number; readonly name: string; } /** * Read-refresh-persist in one call: given the currently-stored token set, * return a still-valid one — refreshing (and persisting the rotated set via * `persist`) when it's within `skewMs` of expiry. This is the seam the app * calls before every OAuth-authenticated request; it mirrors the PAT * `credentialsResolver`'s "resolve fresh per request" contract. * * Fails with {@link AtlassianOAuthError} when a refresh is needed but the * stored set has no refresh token (the app must re-consent) or the grant is * rejected. */ export declare const withFreshToken: (config: WithFreshTokenConfig, stored: OAuthTokens, persist: (tokens: OAuthTokens) => Effect.Effect | void) => Effect.Effect; export declare interface WithFreshTokenConfig extends OAuthExchangeConfig { /** Refresh when `expiresAt - now < skewMs`. Default {@link DEFAULT_REFRESH_SKEW_MS}. */ readonly skewMs?: number; } export { }