Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ Optional:

**Project credentials (GitHub tokens, Trello/JIRA/Linear keys, LLM API keys) live in the `project_credentials` table.** The DB is the **sole source of truth** — there is no env var fallback for project-scoped secrets.

## JIRA scoped tokens

JIRA supports classic site tokens **and** Atlassian API tokens with scopes. The optional `authType` field on the JIRA integration config (`'basic' | 'scoped'`, default `'basic'`) is a **non-secret connection setting** (mirrors `baseUrl`, not a credential role) that selects the REST v3 host — **both modes authenticate with HTTP Basic (`email:api_token`)**, so `authType` picks the host, not the auth scheme. Every REST v3 call site routes through the shared resolver `resolveJiraApiBaseUrl(creds)` (`src/jira/api-host.ts`): `basic`/absent keeps the tenant **site URL**; `scoped` routes through the Atlassian **gateway** `https://api.atlassian.com/ex/jira/{cloudId}`, where `cloudId` is resolved from `${baseUrl}/_edge/tenant_info` (always the site URL, never the gateway) and cached per `baseUrl`. The worker carries the mode across process boundaries via `CASCADE_JIRA_AUTH_TYPE`. **Required scopes:** read/write Jira work, plus `manage:jira-webhook` (or granular `write:webhook:jira` + `read:field:jira` + `read:project:jira`) for programmatic `/rest/api/3/webhook` management — a scoped token lacking them gets `401`/`403`, and operators should register the webhook manually. **Known limitation:** ack reactions are unavailable under scoped tokens (`/rest/reactions/1.0/` is not exposed on the gateway), so the reaction degrades to a skipped no-op; `accessible-resources` is intentionally not used for cloudId (it is OAuth 2.0 / 3LO guidance and returns `401` for scoped API tokens).

## Git hooks

Lefthook runs pre-commit (lint, typecheck) and pre-push (unit + integration tests) hooks automatically. Pre-push auto-starts an ephemeral Postgres via `npm run test:db:up` — Docker must be running.
2 changes: 2 additions & 0 deletions docs/architecture/06-integration-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ Each provider declares its credential roles — the mapping from logical role na
- ADF (Atlassian Document Format) ↔ markdown conversion (`src/pm/jira/adf.ts`)
- Status transitions via JIRA transition ID lookup
- Issue key extraction via regex: `[A-Z][A-Z0-9]+-\d+`
- **Auth mode** — the optional `authType` config field (`'basic' | 'scoped'`) is a non-secret connection setting (mirrors `baseUrl`, not a credential role) that selects the REST v3 host. Both modes use HTTP Basic (`email:api_token`); absent ⇒ `'basic'`.
- **Host resolution** — every REST v3 call site routes through the shared `resolveJiraApiBaseUrl(creds)` resolver (`src/jira/api-host.ts`): `basic` keeps the tenant site URL; `scoped` routes through the Atlassian gateway `https://api.atlassian.com/ex/jira/{cloudId}` (cloudId resolved from `/_edge/tenant_info`). Scoped tokens have known limits — see [`08-config-credentials.md`](./08-config-credentials.md#jira-authentication-modes-scoped-tokens).

### Linear (`src/integrations/pm/linear/`, `src/pm/linear/`, `src/linear/`)

Expand Down
25 changes: 23 additions & 2 deletions docs/architecture/08-config-credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,10 @@ await withTrelloCredentials({ apiKey, token }, async () => {
});

// JIRA
await withJiraCredentials({ email, apiToken, baseUrl }, async () => {
// All JIRA API calls use these credentials
await withJiraCredentials({ email, apiToken, baseUrl, authType }, async () => {
// All JIRA API calls use these credentials.
// `authType` ('basic' | 'scoped', optional) selects the REST v3 host
// via the shared resolveJiraApiBaseUrl() resolver — see below.
});

// Linear
Expand All @@ -231,6 +233,25 @@ await withLinearCredentials({ apiKey }, async () => {
});
```

### JIRA authentication modes (scoped tokens)

`src/jira/api-host.ts`, `src/jira/authType.ts`

JIRA supports classic unscoped site tokens **and** Atlassian API tokens with scopes. The mode is selected by the optional `authType` field on the JIRA integration config (`project_integrations.config`) — a non-secret connection setting that mirrors `baseUrl`, **not** a credential role. Values: `'basic'` (or absent) and `'scoped'`. Both modes authenticate with **HTTP Basic** (`email:api_token`); `authType` selects the REST v3 *host*, not the auth scheme.

Every REST v3 call site routes through one shared resolver, `resolveJiraApiBaseUrl(creds)` — the JIRA analogue of the shared auth-header helper:

| `authType` | REST v3 host | Notes |
|---|---|---|
| `basic` / absent | tenant **site URL** (`creds.baseUrl`, e.g. `https://acme.atlassian.net`) | Classic behavior, unchanged. Every pre-existing config maps here. |
| `scoped` | Atlassian **gateway** (`https://api.atlassian.com/ex/jira/{cloudId}`) | `cloudId` is resolved from `${baseUrl}/_edge/tenant_info` (always the site URL, never the gateway) with the same Basic scoped token, cached per `baseUrl`. Direct site REST v3 calls can fail under scoped tokens, so the gateway is the supported path. |

The worker/CLI credential scope carries the mode across process boundaries via the `CASCADE_JIRA_AUTH_TYPE` env var (injected by `secretBuilder.augmentProjectSecrets`); `normalizeJiraAuthType` maps absent/unknown values back to `'basic'` so existing projects keep working. `accessible-resources` is intentionally **not** used to discover `cloudId` — it is OAuth 2.0 / 3LO guidance and returns `401` for scoped API tokens.

**Required scopes.** Read/write Jira work (classic OAuth `read:jira-work` + `write:jira-work`). Programmatic webhook management additionally needs webhook scopes — classic OAuth `manage:jira-webhook`, or granular `read:field:jira` + `read:project:jira` + `write:webhook:jira`. A scoped token without webhook scopes (or a non-app caller) gets `401`/`403` from `/rest/api/3/webhook`; the wizard then surfaces an actionable message pointing at manual webhook registration.

**Known limitation — ack reactions.** The "eyes" acknowledgment reaction uses Jira's internal `/rest/reactions/1.0/` API, which lives only on the tenant site URL and is not confirmed on the scoped gateway. Under `scoped` auth the reaction degrades quietly (one log line, then skip) — it is best-effort and never fails a run. Comments, status transitions, and label writes are unaffected.

## Credential Encryption

`src/db/crypto.ts`
Expand Down
23 changes: 23 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,29 @@ node bin/cascade.js projects integration-set my-project \
--config '{"baseUrl":"https://yourorg.atlassian.net","projectKey":"PROJ","statuses":{"todo":"To Do","inProgress":"In Progress","inReview":"In Review"}}'
```

#### Scoped API tokens (`authType`)

Cascade also supports Atlassian **API tokens with scopes** ("scoped" tokens) alongside classic unscoped site tokens. Opt in with the `authType` field on the integration config:

```bash
node bin/cascade.js projects integration-set my-project \
--category pm --provider jira \
--config '{"baseUrl":"https://yourorg.atlassian.net","projectKey":"PROJ","authType":"scoped","statuses":{"todo":"To Do","inProgress":"In Progress","inReview":"In Review"}}'
```

`authType` is a non-secret connection setting (like `baseUrl`), **not** a credential — the stored `JIRA_EMAIL` / `JIRA_API_TOKEN` are unchanged. Both modes authenticate with **HTTP Basic** (`email:api_token`), so `authType` selects the request *host*, not the auth scheme:

- `authType: "basic"` (or omitted — the historical default) routes REST v3 calls to your tenant **site URL** (`https://yourorg.atlassian.net`). Every existing config keeps working untouched.
- `authType: "scoped"` routes REST v3 calls through the Atlassian **gateway** (`https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3/...`). Cascade resolves `cloudId` automatically from `https://yourorg.atlassian.net/_edge/tenant_info` using the same Basic scoped token and caches it per site URL. Direct site REST v3 calls can fail under a scoped token, so the gateway is the supported path for scoped tokens.

**Required scopes.** Grant the token read/write access to Jira work (classic OAuth `read:jira-work` + `write:jira-work`). Programmatic webhook management additionally needs webhook scopes — classic OAuth `manage:jira-webhook`, or the granular `read:field:jira` + `read:project:jira` + `write:webhook:jira`.

**Known limitations under scoped tokens:**

- **Ack reactions are unavailable.** The "eyes" acknowledgment reaction uses Jira's internal `/rest/reactions/1.0/` API, which is not exposed through the scoped gateway. Under `scoped` auth Cascade logs one line and skips the reaction — it is cosmetic and never fails a run. Comments, status transitions, and label writes are unaffected.
- **Webhook auto-registration may be rejected.** Atlassian can restrict programmatic `/rest/api/3/webhook` registration to app callers and requires webhook scopes, so a scoped token may get `401`/`403`. If it does, register the webhook manually in Jira (**System → WebHooks**) pointing at `https://your-router-host/jira/webhook`.
- **`accessible-resources` is not used** to discover `cloudId` — that endpoint is OAuth 2.0 / 3LO guidance and returns `401` for scoped API tokens, so Cascade uses `/_edge/tenant_info` instead.

### Linear

1. Generate a **Personal API key** from https://linear.app/settings/api
Expand Down
37 changes: 31 additions & 6 deletions src/api/routers/integrationsDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ const jiraCredsInput = z.object({
email: z.string().min(1),
apiToken: z.string().min(1),
baseUrl: z.string().url(),
/**
* Optional JIRA auth mode. Non-secret connection setting mirroring
* `baseUrl` (NOT a credential). Threaded into `withJiraCredentials` so
* wizard-time verification routes through the correct host — the classic
* site URL for `basic`, the Atlassian gateway for `scoped`. Absent ⇒
* downstream treats it as `basic` (the historical default).
*/
authType: z.enum(['basic', 'scoped']).optional(),
});

const linearCredsInput = z.object({
Expand All @@ -63,10 +71,20 @@ async function withTrelloCreds<T>(
async function withJiraCreds<T>(
input: z.infer<typeof jiraCredsInput>,
label: string,
fn: (creds: { email: string; apiToken: string; baseUrl: string }) => Promise<T>,
fn: (creds: {
email: string;
apiToken: string;
baseUrl: string;
authType?: 'basic' | 'scoped';
}) => Promise<T>,
): Promise<T> {
return wrapIntegrationCall(label, () =>
fn({ email: input.email, apiToken: input.apiToken, baseUrl: input.baseUrl }),
fn({
email: input.email,
apiToken: input.apiToken,
baseUrl: input.baseUrl,
authType: input.authType,
}),
);
}

Expand Down Expand Up @@ -205,14 +223,21 @@ export const integrationsDiscoveryRouter = router({
'jira',
'api_token',
);
const baseUrl = (integration.config as Record<string, unknown> | null)?.baseUrl as
| string
| undefined;
const config = integration.config as Record<string, unknown> | null;
const baseUrl = config?.baseUrl as string | undefined;
// Resolve the saved auth mode from config (mirrors `baseUrl` — a
// non-secret connection setting, not a credential). Threading it into
// `withJiraCredentials` makes edit-mode re-verification for scoped
// projects route through the Atlassian gateway instead of the site
// URL. Unknown / absent ⇒ undefined ⇒ downstream treats it as basic.
const rawAuthType = config?.authType;
const authType =
rawAuthType === 'basic' || rawAuthType === 'scoped' ? rawAuthType : undefined;
if (!email || !apiToken || !baseUrl) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'JIRA credentials not configured' });
}
return wrapIntegrationCall('Failed to fetch JIRA project details', () =>
withJiraCredentials({ email, apiToken, baseUrl }, () =>
withJiraCredentials({ email, apiToken, baseUrl, authType }, () =>
Promise.all([
jiraClient.getProjectStatuses(input.projectKey),
jiraClient.getIssueTypesForProject(input.projectKey),
Expand Down
17 changes: 16 additions & 1 deletion src/api/routers/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { logger } from '../../utils/logging.js';
import { adminProcedure, router } from '../trpc.js';
import {
applyOneTimeTokens,
Expand Down Expand Up @@ -67,7 +68,21 @@ async function maybeCreateJiraWebhook(
if (!pctx.jiraEmail || !pctx.jiraApiToken || !pctx.jiraBaseUrl) return {};

const callbackUrl = `${baseUrl}/jira/webhook`;
const existing = await jiraListWebhooks(pctx);
// Best-effort dedup: a scope-restricted token can reject GET /webhook (401/403).
// If the list fails we skip the router-level duplicate check and let
// jiraCreateWebhook run so its actionable scope / manual-registration error
// surfaces instead of a generic "Failed to list JIRA webhooks" failure.
// jiraCreateWebhook performs its own (also best-effort) dedup listing.
let existing: JiraWebhookInfo[] = [];
try {
existing = await jiraListWebhooks(pctx);
} catch (err) {
logger.warn('[JiraWebhook] Could not list existing webhooks for dedup (continuing)', {
projectId: pctx.projectId,
jiraProjectKey: pctx.jiraProjectKey,
error: String(err),
});
}
const duplicate = existing.find(
(w) => w.url === callbackUrl || w.url === `${baseUrl}/webhook/jira`,
);
Expand Down
1 change: 1 addition & 0 deletions src/api/routers/webhooks/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export async function resolveProjectContext(
pmType: project.pm?.type ?? 'trello',
boardId: trelloConfig?.boardId,
jiraBaseUrl: jiraConfig?.baseUrl,
jiraAuthType: jiraConfig?.authType,
jiraProjectKey: jiraConfig?.projectKey,
jiraLabels,
trelloApiKey: creds.TRELLO_API_KEY ?? '',
Expand Down
Loading
Loading